diff --git a/README.md b/README.md
index e5ff759..9f67569 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/main.js b/main.js
index 2ec81e1..1df3209 100644
--- a/main.js
+++ b/main.js
@@ -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' ? '' : '';
const body = `
@@ -250,7 +253,7 @@ async function searchMedia(offset) {
video/%
-
+ ${orderProp}
${PAGE_SIZE}${Number(offset) || 0}
`;
@@ -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));
diff --git a/package.json b/package.json
index 9a911ce..66ed844 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/preload.js b/preload.js
index ca126a4..767a125 100644
--- a/preload.js
+++ b/preload.js
@@ -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),
diff --git a/renderer/app.js b/renderer/app.js
index e99f44b..f0750b8 100644
--- a/renderer/app.js
+++ b/renderer/app.js
@@ -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;
diff --git a/renderer/index.html b/renderer/index.html
index 33a5681..e90babc 100644
--- a/renderer/index.html
+++ b/renderer/index.html
@@ -79,11 +79,21 @@