This commit is contained in:
2026-09-24 11:47:48 +02:00
parent a39780b3fb
commit 60975c88ab
7 changed files with 145 additions and 48 deletions
+19 -3
View File
@@ -6,8 +6,8 @@ liegen ausschließlich im Arbeitsspeicher).
## Starten ## Starten
chmod +x Lichttisch-1.0.0-x86_64.AppImage chmod +x Lichttisch-1.2.0-x86_64.AppImage
./Lichttisch-1.0.0-x86_64.AppImage ./Lichttisch-1.2.0-x86_64.AppImage
Falls ein Fehler zu FUSE erscheint (z. B. auf Ubuntu 22.04+): 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 - Ordner: durch deine Ordner blättern
- Alben: Alben aus der Nextcloud-Fotos-App (inklusive geteilter Alben) - 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 ## Tastatur
Im Betrachter: ← / → blättern, Esc schließen, F Vollbild, O Originaldatei Im Betrachter: ← / → blättern, Esc schließen, F Vollbild, O Originaldatei
@@ -40,4 +56,4 @@ F5 neu laden.
npm install npm install
npm start # zum Ausprobieren 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
+11 -4
View File
@@ -238,7 +238,10 @@ async function listFolder(relPath) {
return items; 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"?> const body = `<?xml version="1.0" encoding="UTF-8"?>
<d:searchrequest ${NS} xmlns:ns="https://github.com/icewind1991/SearchDAV/ns"> <d:searchrequest ${NS} xmlns:ns="https://github.com/icewind1991/SearchDAV/ns">
<d:basicsearch> <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:like><d:prop><d:getcontenttype/></d:prop><d:literal>video/%</d:literal></d:like>
</d:or> </d:or>
</d:where> </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:limit><d:nresults>${PAGE_SIZE}</d:nresults><ns:firstresult>${Number(offset) || 0}</ns:firstresult></d:limit>
</d:basicsearch> </d:basicsearch>
</d:searchrequest>`; </d:searchrequest>`;
@@ -259,7 +262,11 @@ async function searchMedia(offset) {
headers: { 'Content-Type': 'text/xml; charset=utf-8' }, headers: { 'Content-Type': 'text/xml; charset=utf-8' },
body, 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); const items = parseMultistatus(await res.text()).map(toItem).filter((i) => !i.isDir);
return { items, done: items.length < PAGE_SIZE }; return { items, done: items.length < PAGE_SIZE };
} }
@@ -396,7 +403,7 @@ handle('logout', () => {
}); });
handle('folder', (p) => listFolder(p)); handle('folder', (p) => listFolder(p));
handle('timeline', (offset) => searchMedia(offset)); handle('timeline', (offset, order) => searchMedia(offset, order));
handle('albums', () => listAlbums()); handle('albums', () => listAlbums());
handle('album', (href) => listAlbum(href)); handle('album', (href) => listAlbum(href));
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "lichttisch", "name": "lichttisch",
"productName": "Lichttisch", "productName": "Lichttisch",
"version": "1.0.0", "version": "1.2.0",
"description": "Fotos aus der Nextcloud per WebDAV ansehen, ohne sie herunterzuladen", "description": "Fotos aus der Nextcloud per WebDAV ansehen, ohne sie herunterzuladen",
"main": "main.js", "main": "main.js",
"author": { "author": {
+1 -1
View File
@@ -14,7 +14,7 @@ contextBridge.exposeInMainWorld('nc', {
loginCancel: () => call('login-cancel'), loginCancel: () => call('login-cancel'),
logout: () => call('logout'), logout: () => call('logout'),
folder: (path) => call('folder', path), folder: (path) => call('folder', path),
timeline: (offset) => call('timeline', offset), timeline: (offset, order) => call('timeline', offset, order),
albums: () => call('albums'), albums: () => call('albums'),
album: (href) => call('album', href), album: (href) => call('album', href),
openInNextcloud: (fileId) => call('open-in-nextcloud', fileId), openInNextcloud: (fileId) => call('open-in-nextcloud', fileId),
+95 -34
View File
@@ -47,11 +47,13 @@ function thumbUrl(item) {
const state = { const state = {
source: store.get('source', 'timeline'), source: store.get('source', 'timeline'),
path: store.get('path', ''), 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), zoom: store.get('zoom', 180),
album: null, // { href, name } wenn ein Album offen ist album: null, // { href, name } wenn ein Album offen ist
media: [], // aktuell sichtbare Bilder/Videos (für den Betrachter) 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, loadToken: 0,
}; };
@@ -193,6 +195,7 @@ async function load() {
state.media = []; state.media = [];
renderCrumbs(); renderCrumbs();
$('sort').hidden = !(state.source === 'folder' || (state.source === 'albums' && state.album)); $('sort').hidden = !(state.source === 'folder' || (state.source === 'albums' && state.album));
$('tl-order').hidden = state.source !== 'timeline';
try { try {
if (state.source === 'folder') { if (state.source === 'folder') {
@@ -210,7 +213,7 @@ async function load() {
renderAlbums(res); renderAlbums(res);
} }
} else { } else {
startTimeline(token); startTimeline(token, state.timelineOrder);
} }
} catch (e) { } catch (e) {
if (token !== state.loadToken) return; 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) { function sortItems(items) {
const s = state.sort; const s = state.sort;
const by = (i) => i.taken || i.mtime; const by = photoDate;
const arr = [...items]; const arr = [...items];
if (s === 'name') arr.sort((a, b) => collator.compare(a.name, b.name)); 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 === 'new') arr.sort((a, b) => by(b) - by(a));
if (s === 'old') arr.sort((a, b) => by(a) - by(b)); 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; return arr;
} }
@@ -269,11 +281,16 @@ function folderTile(item) {
return b; 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) { function renderMediaList(items, emptyText) {
const media = sortItems(items.filter((i) => i.kind === 'image' || i.kind === 'video')); const media = sortItems(visibleMedia(items));
state.media = media; state.media = media;
const content = $('content'); 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'); const grid = el('div', 'grid');
media.forEach((m, i) => grid.append(mediaTile(m, i))); media.forEach((m, i) => grid.append(mediaTile(m, i)));
content.replaceChildren(grid); content.replaceChildren(grid);
@@ -281,7 +298,7 @@ function renderMediaList(items, emptyText) {
function renderFolder(items) { function renderFolder(items) {
const dirs = items.filter((i) => i.isDir).sort((a, b) => collator.compare(a.name, b.name)); 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; state.media = media;
const content = $('content'); const content = $('content');
content.replaceChildren(); content.replaceChildren();
@@ -297,7 +314,7 @@ function renderFolder(items) {
content.append(g); content.append(g);
} }
if (!dirs.length && !media.length) { 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; let timelineObserver = null;
function startTimeline(token) { function startTimeline(token, order = 'taken', fellBack = false) {
state.timeline = { offset: 0, done: false, loading: false, lastMonth: null, grid: null, token }; $('tl-order').value = order;
state.timeline = { offset: 0, done: false, loading: false, token, order, months: new Map(), skipped: 0 };
state.media = [];
const content = $('content'); const content = $('content');
content.replaceChildren(); 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 sentinel = el('div', 'sentinel');
const more = el('div', 'loading-more', 'Lade Fotos …'); const more = el('div', 'loading-more', 'Lade Fotos …');
content.append(sentinel, more); content.append(sentinel, more);
@@ -355,6 +378,27 @@ function startTimeline(token) {
loadTimelinePage(); 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() { async function loadTimelinePage() {
const t = state.timeline; const t = state.timeline;
if (t.loading || t.done || t.token !== state.loadToken) return; if (t.loading || t.done || t.token !== state.loadToken) return;
@@ -363,33 +407,36 @@ async function loadTimelinePage() {
const sentinel = content.querySelector('.sentinel'); const sentinel = content.querySelector('.sentinel');
const more = content.querySelector('.loading-more'); const more = content.querySelector('.loading-more');
try { 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; if (t.token !== state.loadToken) return;
t.offset += items.length;
t.done = done; // Server kann nicht nach Aufnahmedatum sortieren, oder kein einziges Foto
for (const item of items) { // hat eines: auf Änderungsdatum umschalten
const d = new Date(item.mtime); const noMetadata = t.order === 'taken' && t.offset === 0 && res.items.length && !res.items.some((i) => i.taken);
const key = `${d.getFullYear()}-${d.getMonth()}`; if (res.unsupported || noMetadata) {
if (key !== t.lastMonth) { t.loading = false;
t.lastMonth = key; startTimeline(t.token, 'mtime', true);
const sec = el('section', 'month'); return;
const h = el('h2', null, monthFmt.format(d)); }
t.countEl = el('small');
t.count = 0; t.offset += res.items.length;
h.append(t.countEl); t.done = res.done;
t.grid = el('div', 'grid'); for (const item of res.items) {
sec.append(h, t.grid); if (isHidden(item)) { t.skipped++; continue; }
content.insertBefore(sec, sentinel); const m = monthSection(t, t.order === 'taken' ? photoDate(item) : item.mtime, sentinel);
}
const index = state.media.length; const index = state.media.length;
state.media.push(item); state.media.push(item);
t.grid.append(mediaTile(item, index)); m.grid.append(mediaTile(item, index));
t.count++; m.count++;
t.countEl.textContent = `${t.count} ${t.count === 1 ? 'Foto' : 'Fotos'}`; m.countEl.textContent = `${m.count} ${m.count === 1 ? 'Foto' : 'Fotos'}`;
} }
if (done) { if (t.done) {
more.remove(); 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) { } catch (e) {
if (t.token !== state.loadToken) return; if (t.token !== state.loadToken) return;
@@ -406,6 +453,13 @@ async function loadTimelinePage() {
// ------------------------------------------------------------------ Werkzeugleiste // ------------------------------------------------------------------ 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').value = state.sort;
$('sort').addEventListener('change', () => { $('sort').addEventListener('change', () => {
state.sort = $('sort').value; state.sort = $('sort').value;
@@ -413,6 +467,13 @@ $('sort').addEventListener('change', () => {
load(); load();
}); });
$('hide-dng').checked = state.hideDng;
$('hide-dng').addEventListener('change', () => {
state.hideDng = $('hide-dng').checked;
store.set('hideDng', state.hideDng);
load();
});
function applyZoom() { function applyZoom() {
document.documentElement.style.setProperty('--thumb', `${state.zoom}px`); document.documentElement.style.setProperty('--thumb', `${state.zoom}px`);
} }
@@ -474,9 +535,9 @@ function showCurrent() {
msg.hidden = true; msg.hidden = true;
$('v-name').textContent = item.name; $('v-name').textContent = item.name;
const when = item.taken || item.mtime;
const meta = [`${viewer.index + 1} von ${state.media.length}`]; 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)); if (item.size) meta.push(fmtSize(item.size));
$('v-meta').replaceChildren(...meta.map((m) => el('span', null, m))); $('v-meta').replaceChildren(...meta.map((m) => el('span', null, m)));
$('v-prev').disabled = viewer.index === 0; $('v-prev').disabled = viewer.index === 0;
+14 -4
View File
@@ -79,11 +79,21 @@
<header class="toolbar"> <header class="toolbar">
<nav id="crumbs" class="crumbs" aria-label="Pfad"></nav> <nav id="crumbs" class="crumbs" aria-label="Pfad"></nav>
<div class="tools"> <div class="tools">
<select id="sort" title="Sortierung"> <select id="tl-order" title="Wonach die Zeitleiste sortiert wird">
<option value="name">Nach Name</option> <option value="taken">Nach Aufnahmedatum</option>
<option value="new">Neueste zuerst</option> <option value="mtime">Nach Änderungsdatum</option>
<option value="old">Älteste zuerst</option>
</select> </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"> <label class="zoom" title="Vorschaugröße">
<span class="zoom-dot small" aria-hidden="true"></span> <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"> <input id="zoom" type="range" min="110" max="360" step="10" value="180" aria-label="Vorschaugröße">
+4 -1
View File
@@ -159,7 +159,10 @@ details button { justify-self: start; }
.crumbs button:last-child:hover { background: none; } .crumbs button:last-child:hover { background: none; }
.crumbs .sep { color: var(--line); } .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 { display: flex; align-items: center; gap: 0.5rem; }
.zoom input { width: 7rem; accent-color: var(--safelight); } .zoom input { width: 7rem; accent-color: var(--safelight); }
.zoom-dot { display: block; border: 1.5px solid var(--muted); border-radius: 2px; } .zoom-dot { display: block; border: 1.5px solid var(--muted); border-radius: 2px; }