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
+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; }