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
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
+11 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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),
+95 -34
View File
@@ -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
View File
@@ -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
View File
@@ -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; }