1.2
This commit is contained in:
@@ -6,8 +6,8 @@ liegen ausschließlich im Arbeitsspeicher).
|
||||
|
||||
## Starten
|
||||
|
||||
chmod +x Lichttisch-1.0.0-x86_64.AppImage
|
||||
./Lichttisch-1.0.0-x86_64.AppImage
|
||||
chmod +x Lichttisch-1.2.0-x86_64.AppImage
|
||||
./Lichttisch-1.2.0-x86_64.AppImage
|
||||
|
||||
Falls ein Fehler zu FUSE erscheint (z. B. auf Ubuntu 22.04+):
|
||||
|
||||
@@ -30,6 +30,22 @@ Systems (GNOME Keyring / KWallet) verschlüsselt in
|
||||
- Ordner: durch deine Ordner blättern
|
||||
- Alben: Alben aus der Nextcloud-Fotos-App (inklusive geteilter Alben)
|
||||
|
||||
## Sortierung und Filter
|
||||
|
||||
Oben in der Werkzeugleiste wählst du, wonach sortiert wird:
|
||||
|
||||
- Aufnahmedatum aus den Bild-Metadaten (EXIF), verfügbar ab Nextcloud 28.
|
||||
Bei älteren Servern schaltet die Zeitleiste automatisch aufs Änderungsdatum
|
||||
um und zeigt einen Hinweis.
|
||||
- Änderungsdatum der Datei, so wie die Nextcloud es als „Geändert“ anzeigt.
|
||||
Bei Uploads über den Browser ist das der Zeitpunkt des Hochladens.
|
||||
|
||||
In Ordnern und Alben gibt es beides jeweils aufsteigend und absteigend sowie
|
||||
die Sortierung nach Name.
|
||||
|
||||
„DNG ausblenden“ in der Werkzeugleiste versteckt Rohdaten im DNG-Format in
|
||||
allen Ansichten. Die Einstellung bleibt gespeichert.
|
||||
|
||||
## Tastatur
|
||||
|
||||
Im Betrachter: ← / → blättern, Esc schließen, F Vollbild, O Originaldatei
|
||||
@@ -40,4 +56,4 @@ F5 neu laden.
|
||||
|
||||
npm install
|
||||
npm start # zum Ausprobieren
|
||||
npm run dist # erzeugt dist/Lichttisch-1.0.0-x86_64.AppImage
|
||||
npm run dist # erzeugt dist/Lichttisch-1.2.0-x86_64.AppImage
|
||||
|
||||
@@ -238,7 +238,10 @@ async function listFolder(relPath) {
|
||||
return items;
|
||||
}
|
||||
|
||||
async function searchMedia(offset) {
|
||||
// order: 'taken' = Aufnahmedatum aus den Metadaten (Nextcloud 28+),
|
||||
// 'mtime' = Änderungs-/Upload-Datum der Datei
|
||||
async function searchMedia(offset, order = 'taken') {
|
||||
const orderProp = order === 'taken' ? '<nc:metadata-photos-original_date_time/>' : '<d:getlastmodified/>';
|
||||
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:searchrequest ${NS} xmlns:ns="https://github.com/icewind1991/SearchDAV/ns">
|
||||
<d:basicsearch>
|
||||
@@ -250,7 +253,7 @@ async function searchMedia(offset) {
|
||||
<d:like><d:prop><d:getcontenttype/></d:prop><d:literal>video/%</d:literal></d:like>
|
||||
</d:or>
|
||||
</d:where>
|
||||
<d:orderby><d:order><d:prop><d:getlastmodified/></d:prop><d:descending/></d:order></d:orderby>
|
||||
<d:orderby><d:order><d:prop>${orderProp}</d:prop><d:descending/></d:order></d:orderby>
|
||||
<d:limit><d:nresults>${PAGE_SIZE}</d:nresults><ns:firstresult>${Number(offset) || 0}</ns:firstresult></d:limit>
|
||||
</d:basicsearch>
|
||||
</d:searchrequest>`;
|
||||
@@ -259,7 +262,11 @@ async function searchMedia(offset) {
|
||||
headers: { 'Content-Type': 'text/xml; charset=utf-8' },
|
||||
body,
|
||||
});
|
||||
if (res.status !== 207) throw new Error(`Die Suche nach Fotos ist fehlgeschlagen (Status ${res.status}).`);
|
||||
if (res.status !== 207) {
|
||||
// Ältere Server kennen das Aufnahmedatum nicht und lehnen die Sortierung ab
|
||||
if (order === 'taken') return { unsupported: true, items: [], done: false };
|
||||
throw new Error(`Die Suche nach Fotos ist fehlgeschlagen (Status ${res.status}).`);
|
||||
}
|
||||
const items = parseMultistatus(await res.text()).map(toItem).filter((i) => !i.isDir);
|
||||
return { items, done: items.length < PAGE_SIZE };
|
||||
}
|
||||
@@ -396,7 +403,7 @@ handle('logout', () => {
|
||||
});
|
||||
|
||||
handle('folder', (p) => listFolder(p));
|
||||
handle('timeline', (offset) => searchMedia(offset));
|
||||
handle('timeline', (offset, order) => searchMedia(offset, order));
|
||||
handle('albums', () => listAlbums());
|
||||
handle('album', (href) => listAlbum(href));
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "lichttisch",
|
||||
"productName": "Lichttisch",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"description": "Fotos aus der Nextcloud per WebDAV ansehen, ohne sie herunterzuladen",
|
||||
"main": "main.js",
|
||||
"author": {
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ contextBridge.exposeInMainWorld('nc', {
|
||||
loginCancel: () => call('login-cancel'),
|
||||
logout: () => call('logout'),
|
||||
folder: (path) => call('folder', path),
|
||||
timeline: (offset) => call('timeline', offset),
|
||||
timeline: (offset, order) => call('timeline', offset, order),
|
||||
albums: () => call('albums'),
|
||||
album: (href) => call('album', href),
|
||||
openInNextcloud: (fileId) => call('open-in-nextcloud', fileId),
|
||||
|
||||
+94
-33
@@ -47,11 +47,13 @@ function thumbUrl(item) {
|
||||
const state = {
|
||||
source: store.get('source', 'timeline'),
|
||||
path: store.get('path', ''),
|
||||
sort: store.get('sort', 'name'),
|
||||
sort: store.get('sort', 'new'),
|
||||
timelineOrder: store.get('timelineOrder', 'taken'),
|
||||
hideDng: store.get('hideDng', true),
|
||||
zoom: store.get('zoom', 180),
|
||||
album: null, // { href, name } wenn ein Album offen ist
|
||||
media: [], // aktuell sichtbare Bilder/Videos (für den Betrachter)
|
||||
timeline: { offset: 0, done: false, loading: false, lastMonth: null, grid: null },
|
||||
timeline: { offset: 0, done: false, loading: false },
|
||||
loadToken: 0,
|
||||
};
|
||||
|
||||
@@ -193,6 +195,7 @@ async function load() {
|
||||
state.media = [];
|
||||
renderCrumbs();
|
||||
$('sort').hidden = !(state.source === 'folder' || (state.source === 'albums' && state.album));
|
||||
$('tl-order').hidden = state.source !== 'timeline';
|
||||
|
||||
try {
|
||||
if (state.source === 'folder') {
|
||||
@@ -210,7 +213,7 @@ async function load() {
|
||||
renderAlbums(res);
|
||||
}
|
||||
} else {
|
||||
startTimeline(token);
|
||||
startTimeline(token, state.timelineOrder);
|
||||
}
|
||||
} catch (e) {
|
||||
if (token !== state.loadToken) return;
|
||||
@@ -220,13 +223,22 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
// DNG-Dateien (Rohdaten) optional ausblenden
|
||||
function isDng(item) {
|
||||
return /\.dng$/i.test(item.name) || /dng/i.test(item.mime);
|
||||
}
|
||||
const isHidden = (item) => state.hideDng && isDng(item);
|
||||
const visibleMedia = (items) => items.filter((i) => (i.kind === 'image' || i.kind === 'video') && !isHidden(i));
|
||||
|
||||
function sortItems(items) {
|
||||
const s = state.sort;
|
||||
const by = (i) => i.taken || i.mtime;
|
||||
const by = photoDate;
|
||||
const arr = [...items];
|
||||
if (s === 'name') arr.sort((a, b) => collator.compare(a.name, b.name));
|
||||
if (s === 'new') arr.sort((a, b) => by(b) - by(a));
|
||||
if (s === 'old') arr.sort((a, b) => by(a) - by(b));
|
||||
if (s === 'mnew') arr.sort((a, b) => b.mtime - a.mtime);
|
||||
if (s === 'mold') arr.sort((a, b) => a.mtime - b.mtime);
|
||||
return arr;
|
||||
}
|
||||
|
||||
@@ -269,11 +281,16 @@ function folderTile(item) {
|
||||
return b;
|
||||
}
|
||||
|
||||
function hiddenNote(items) {
|
||||
const n = items.filter(isHidden).length;
|
||||
return n ? `${n} DNG-${n === 1 ? 'Datei ist' : 'Dateien sind'} ausgeblendet.` : null;
|
||||
}
|
||||
|
||||
function renderMediaList(items, emptyText) {
|
||||
const media = sortItems(items.filter((i) => i.kind === 'image' || i.kind === 'video'));
|
||||
const media = sortItems(visibleMedia(items));
|
||||
state.media = media;
|
||||
const content = $('content');
|
||||
if (!media.length) { content.replaceChildren(notice(emptyText)); return; }
|
||||
if (!media.length) { content.replaceChildren(notice(emptyText, hiddenNote(items))); return; }
|
||||
const grid = el('div', 'grid');
|
||||
media.forEach((m, i) => grid.append(mediaTile(m, i)));
|
||||
content.replaceChildren(grid);
|
||||
@@ -281,7 +298,7 @@ function renderMediaList(items, emptyText) {
|
||||
|
||||
function renderFolder(items) {
|
||||
const dirs = items.filter((i) => i.isDir).sort((a, b) => collator.compare(a.name, b.name));
|
||||
const media = sortItems(items.filter((i) => i.kind === 'image' || i.kind === 'video'));
|
||||
const media = sortItems(visibleMedia(items));
|
||||
state.media = media;
|
||||
const content = $('content');
|
||||
content.replaceChildren();
|
||||
@@ -297,7 +314,7 @@ function renderFolder(items) {
|
||||
content.append(g);
|
||||
}
|
||||
if (!dirs.length && !media.length) {
|
||||
content.append(notice('Hier liegen keine Fotos.', state.path ? 'Über den Pfad oben kommst du zurück.' : null));
|
||||
content.append(notice('Hier liegen keine Fotos.', hiddenNote(items) || (state.path ? 'Über den Pfad oben kommst du zurück.' : null)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,10 +356,16 @@ function renderAlbums({ available, albums }) {
|
||||
|
||||
let timelineObserver = null;
|
||||
|
||||
function startTimeline(token) {
|
||||
state.timeline = { offset: 0, done: false, loading: false, lastMonth: null, grid: null, token };
|
||||
function startTimeline(token, order = 'taken', fellBack = false) {
|
||||
$('tl-order').value = order;
|
||||
state.timeline = { offset: 0, done: false, loading: false, token, order, months: new Map(), skipped: 0 };
|
||||
state.media = [];
|
||||
const content = $('content');
|
||||
content.replaceChildren();
|
||||
if (fellBack) {
|
||||
content.append(el('p', 'hint-line',
|
||||
'Dein Server liefert keine Aufnahmedaten, darum ist nach dem Änderungsdatum der Dateien sortiert. Aufnahmedaten gibt es ab Nextcloud 28.'));
|
||||
}
|
||||
const sentinel = el('div', 'sentinel');
|
||||
const more = el('div', 'loading-more', 'Lade Fotos …');
|
||||
content.append(sentinel, more);
|
||||
@@ -355,6 +378,27 @@ function startTimeline(token) {
|
||||
loadTimelinePage();
|
||||
}
|
||||
|
||||
// Datum, nach dem ein Foto einsortiert wird: Aufnahmedatum, sonst Dateidatum
|
||||
const photoDate = (item) => item.taken || item.mtime;
|
||||
|
||||
function monthSection(t, date, sentinel) {
|
||||
const d = new Date(date);
|
||||
const key = `${d.getFullYear()}-${d.getMonth()}`;
|
||||
let m = t.months.get(key);
|
||||
if (!m) {
|
||||
const sec = el('section', 'month');
|
||||
const h = el('h2', null, monthFmt.format(d));
|
||||
const countEl = el('small');
|
||||
h.append(countEl);
|
||||
const grid = el('div', 'grid');
|
||||
sec.append(h, grid);
|
||||
sentinel.before(sec);
|
||||
m = { grid, countEl, count: 0 };
|
||||
t.months.set(key, m);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
async function loadTimelinePage() {
|
||||
const t = state.timeline;
|
||||
if (t.loading || t.done || t.token !== state.loadToken) return;
|
||||
@@ -363,33 +407,36 @@ async function loadTimelinePage() {
|
||||
const sentinel = content.querySelector('.sentinel');
|
||||
const more = content.querySelector('.loading-more');
|
||||
try {
|
||||
const { items, done } = await window.nc.timeline(t.offset);
|
||||
const res = await window.nc.timeline(t.offset, t.order);
|
||||
if (t.token !== state.loadToken) return;
|
||||
t.offset += items.length;
|
||||
t.done = done;
|
||||
for (const item of items) {
|
||||
const d = new Date(item.mtime);
|
||||
const key = `${d.getFullYear()}-${d.getMonth()}`;
|
||||
if (key !== t.lastMonth) {
|
||||
t.lastMonth = key;
|
||||
const sec = el('section', 'month');
|
||||
const h = el('h2', null, monthFmt.format(d));
|
||||
t.countEl = el('small');
|
||||
t.count = 0;
|
||||
h.append(t.countEl);
|
||||
t.grid = el('div', 'grid');
|
||||
sec.append(h, t.grid);
|
||||
content.insertBefore(sec, sentinel);
|
||||
|
||||
// Server kann nicht nach Aufnahmedatum sortieren, oder kein einziges Foto
|
||||
// hat eines: auf Änderungsdatum umschalten
|
||||
const noMetadata = t.order === 'taken' && t.offset === 0 && res.items.length && !res.items.some((i) => i.taken);
|
||||
if (res.unsupported || noMetadata) {
|
||||
t.loading = false;
|
||||
startTimeline(t.token, 'mtime', true);
|
||||
return;
|
||||
}
|
||||
|
||||
t.offset += res.items.length;
|
||||
t.done = res.done;
|
||||
for (const item of res.items) {
|
||||
if (isHidden(item)) { t.skipped++; continue; }
|
||||
const m = monthSection(t, t.order === 'taken' ? photoDate(item) : item.mtime, sentinel);
|
||||
const index = state.media.length;
|
||||
state.media.push(item);
|
||||
t.grid.append(mediaTile(item, index));
|
||||
t.count++;
|
||||
t.countEl.textContent = `${t.count} ${t.count === 1 ? 'Foto' : 'Fotos'}`;
|
||||
m.grid.append(mediaTile(item, index));
|
||||
m.count++;
|
||||
m.countEl.textContent = `${m.count} ${m.count === 1 ? 'Foto' : 'Fotos'}`;
|
||||
}
|
||||
if (done) {
|
||||
if (t.done) {
|
||||
more.remove();
|
||||
if (!state.media.length) content.replaceChildren(notice('Keine Fotos gefunden.', 'In deiner Nextcloud liegen noch keine Bilder oder Videos.'));
|
||||
if (!state.media.length) {
|
||||
content.replaceChildren(t.skipped
|
||||
? notice('Keine Fotos sichtbar.', 'Alle gefundenen Dateien sind DNG-Dateien. Schalte „DNG ausblenden“ oben aus, um sie zu sehen.')
|
||||
: notice('Keine Fotos gefunden.', 'In deiner Nextcloud liegen noch keine Bilder oder Videos.'));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (t.token !== state.loadToken) return;
|
||||
@@ -406,6 +453,13 @@ async function loadTimelinePage() {
|
||||
|
||||
// ------------------------------------------------------------------ Werkzeugleiste
|
||||
|
||||
$('tl-order').value = state.timelineOrder;
|
||||
$('tl-order').addEventListener('change', () => {
|
||||
state.timelineOrder = $('tl-order').value;
|
||||
store.set('timelineOrder', state.timelineOrder);
|
||||
load();
|
||||
});
|
||||
|
||||
$('sort').value = state.sort;
|
||||
$('sort').addEventListener('change', () => {
|
||||
state.sort = $('sort').value;
|
||||
@@ -413,6 +467,13 @@ $('sort').addEventListener('change', () => {
|
||||
load();
|
||||
});
|
||||
|
||||
$('hide-dng').checked = state.hideDng;
|
||||
$('hide-dng').addEventListener('change', () => {
|
||||
state.hideDng = $('hide-dng').checked;
|
||||
store.set('hideDng', state.hideDng);
|
||||
load();
|
||||
});
|
||||
|
||||
function applyZoom() {
|
||||
document.documentElement.style.setProperty('--thumb', `${state.zoom}px`);
|
||||
}
|
||||
@@ -474,9 +535,9 @@ function showCurrent() {
|
||||
msg.hidden = true;
|
||||
|
||||
$('v-name').textContent = item.name;
|
||||
const when = item.taken || item.mtime;
|
||||
const meta = [`${viewer.index + 1} von ${state.media.length}`];
|
||||
if (when) meta.push(dateFmt.format(when));
|
||||
if (item.taken) meta.push(`Aufgenommen ${dateFmt.format(item.taken)}`);
|
||||
if (item.mtime) meta.push(`Geändert ${dateFmt.format(item.mtime)}`);
|
||||
if (item.size) meta.push(fmtSize(item.size));
|
||||
$('v-meta').replaceChildren(...meta.map((m) => el('span', null, m)));
|
||||
$('v-prev').disabled = viewer.index === 0;
|
||||
|
||||
+14
-4
@@ -79,11 +79,21 @@
|
||||
<header class="toolbar">
|
||||
<nav id="crumbs" class="crumbs" aria-label="Pfad"></nav>
|
||||
<div class="tools">
|
||||
<select id="sort" title="Sortierung">
|
||||
<option value="name">Nach Name</option>
|
||||
<option value="new">Neueste zuerst</option>
|
||||
<option value="old">Älteste zuerst</option>
|
||||
<select id="tl-order" title="Wonach die Zeitleiste sortiert wird">
|
||||
<option value="taken">Nach Aufnahmedatum</option>
|
||||
<option value="mtime">Nach Änderungsdatum</option>
|
||||
</select>
|
||||
<select id="sort" title="Sortierung">
|
||||
<option value="new">Aufnahmedatum, neueste zuerst</option>
|
||||
<option value="old">Aufnahmedatum, älteste zuerst</option>
|
||||
<option value="mnew">Änderungsdatum, neueste zuerst</option>
|
||||
<option value="mold">Änderungsdatum, älteste zuerst</option>
|
||||
<option value="name">Nach Name</option>
|
||||
</select>
|
||||
<label class="check tool-check" title="Rohdaten im DNG-Format nicht anzeigen">
|
||||
<input id="hide-dng" type="checkbox">
|
||||
<span>DNG ausblenden</span>
|
||||
</label>
|
||||
<label class="zoom" title="Vorschaugröße">
|
||||
<span class="zoom-dot small" aria-hidden="true"></span>
|
||||
<input id="zoom" type="range" min="110" max="360" step="10" value="180" aria-label="Vorschaugröße">
|
||||
|
||||
+4
-1
@@ -159,7 +159,10 @@ details button { justify-self: start; }
|
||||
.crumbs button:last-child:hover { background: none; }
|
||||
.crumbs .sep { color: var(--line); }
|
||||
|
||||
.tools { display: flex; align-items: center; gap: 1rem; }
|
||||
.tools { display: flex; align-items: center; gap: 1.2rem; }
|
||||
.tool-check { font-size: 0.9rem; white-space: nowrap; cursor: pointer; }
|
||||
.tool-check:hover { color: var(--text); }
|
||||
.hint-line { margin: 0 0 1.4rem; color: var(--muted); font-size: 0.9rem; max-width: 60ch; }
|
||||
.zoom { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.zoom input { width: 7rem; accent-color: var(--safelight); }
|
||||
.zoom-dot { display: block; border: 1.5px solid var(--muted); border-radius: 2px; }
|
||||
|
||||
Reference in New Issue
Block a user