Files
Lichttisch/renderer/app.js
T

674 lines
22 KiB
JavaScript
Raw Permalink Normal View History

2026-09-24 11:46:54 +02:00
'use strict';
const $ = (id) => document.getElementById(id);
const el = (tag, cls, text) => {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
};
const store = {
get: (k, d) => { try { const v = localStorage.getItem(k); return v == null ? d : JSON.parse(v); } catch { return d; } },
set: (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} },
};
const monthFmt = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' });
const dateFmt = new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' });
const shortDate = new Intl.DateTimeFormat('de-DE', { month: 'short', year: 'numeric' });
const collator = new Intl.Collator('de', { numeric: true, sensitivity: 'base' });
function fmtSize(b) {
if (!b) return '';
const u = ['B', 'KB', 'MB', 'GB'];
let i = 0;
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
return `${b.toFixed(i > 1 ? 1 : 0).replace('.', ',')} ${u[i]}`;
}
// ------------------------------------------------------------------ Bild-URLs
const fileUrl = (item) => `nc://file/?h=${encodeURIComponent(item.href)}`;
const previewUrl = (fileId, s) => `nc://preview/?id=${encodeURIComponent(fileId)}&s=${s}`;
function thumbSize() {
const px = state.zoom * (window.devicePixelRatio || 1);
return px <= 256 ? 256 : px <= 512 ? 512 : 768;
}
function thumbUrl(item) {
if (item.fileId && (item.hasPreview || item.kind === 'video')) return previewUrl(item.fileId, thumbSize());
if (item.kind === 'image') return fileUrl(item);
return null;
}
// ------------------------------------------------------------------ Zustand
const state = {
source: store.get('source', 'timeline'),
path: store.get('path', ''),
2026-09-24 11:47:48 +02:00
sort: store.get('sort', 'new'),
timelineOrder: store.get('timelineOrder', 'taken'),
hideDng: store.get('hideDng', true),
2026-09-24 11:46:54 +02:00
zoom: store.get('zoom', 180),
album: null, // { href, name } wenn ein Album offen ist
media: [], // aktuell sichtbare Bilder/Videos (für den Betrachter)
2026-09-24 11:47:48 +02:00
timeline: { offset: 0, done: false, loading: false },
2026-09-24 11:46:54 +02:00
loadToken: 0,
};
// ------------------------------------------------------------------ Anmeldung
function showScreen(name) {
for (const id of ['login', 'connecting', 'app']) $(id).hidden = id !== name;
}
function loginError(msg) {
$('login-error').textContent = msg || '';
$('login-error').hidden = !msg;
}
function setWaiting(on) {
$('waiting').hidden = !on;
$('browser-login').hidden = on;
$('manual').hidden = on;
$('server').disabled = on;
}
async function afterLogin(info) {
$('acc-name').textContent = info.displayName;
$('acc-server').textContent = info.server.replace(/^https?:\/\//, '');
showScreen('app');
selectSource(state.source, true);
}
$('btn-browser').addEventListener('click', async () => {
loginError();
setWaiting(true);
try {
const info = await window.nc.loginBrowser($('server').value, $('remember').checked);
await afterLogin(info);
} catch (e) {
loginError(e.message);
} finally {
setWaiting(false);
}
});
$('btn-cancel').addEventListener('click', () => window.nc.loginCancel());
async function passwordLogin() {
loginError();
const btn = $('btn-password');
btn.disabled = true;
try {
const info = await window.nc.loginPassword($('server').value, $('user').value.trim(), $('pass').value, $('remember').checked);
$('pass').value = '';
await afterLogin(info);
} catch (e) {
loginError(e.message);
} finally {
btn.disabled = false;
}
}
$('btn-password').addEventListener('click', passwordLogin);
$('pass').addEventListener('keydown', (e) => { if (e.key === 'Enter') passwordLogin(); });
$('server').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('btn-browser').click(); });
$('btn-logout').addEventListener('click', async () => {
await window.nc.logout();
$('content').replaceChildren();
showScreen('login');
});
// ------------------------------------------------------------------ Navigation
document.querySelectorAll('.sources button').forEach((b) =>
b.addEventListener('click', () => selectSource(b.dataset.source)));
function selectSource(source, initial = false) {
state.source = source;
store.set('source', source);
if (!initial) state.album = null;
document.querySelectorAll('.sources button').forEach((b) =>
b.setAttribute('aria-current', String(b.dataset.source === source)));
load();
}
function renderCrumbs() {
const c = $('crumbs');
c.replaceChildren();
const add = (label, fn) => {
if (c.childElementCount) c.append(el('span', 'sep', '›'));
const b = el('button', null, label);
if (fn) b.addEventListener('click', fn);
c.append(b);
};
if (state.source === 'timeline') add('Alle Fotos');
if (state.source === 'albums') {
add('Alben', state.album ? () => { state.album = null; selectSource('albums'); } : null);
if (state.album) add(state.album.name);
}
if (state.source === 'folder') {
add('Dateien', () => openFolder(''));
const parts = state.path.split('/').filter(Boolean);
parts.forEach((p, i) => add(p, () => openFolder(parts.slice(0, i + 1).join('/'))));
}
}
function openFolder(path) {
state.path = path.replace(/^\/+|\/+$/g, '');
store.set('path', state.path);
load();
}
function goUp() {
if (state.source === 'folder' && state.path) openFolder(state.path.split('/').slice(0, -1).join('/'));
else if (state.source === 'albums' && state.album) { state.album = null; selectSource('albums'); }
}
// ------------------------------------------------------------------ Laden
function notice(title, text, action) {
const n = el('div', 'notice');
n.append(el('strong', null, title));
if (text) n.append(el('span', null, text));
if (action) {
const b = el('button', null, action.label);
b.addEventListener('click', action.fn);
n.append(b);
}
return n;
}
function errorNotice(e) {
const n = notice('Das hat nicht geklappt', e.message, { label: 'Erneut versuchen', fn: load });
n.classList.add('error');
return n;
}
async function load() {
const token = ++state.loadToken;
const content = $('content');
content.replaceChildren(el('div', 'loading-more', 'Lade …'));
content.scrollTop = 0;
state.media = [];
renderCrumbs();
$('sort').hidden = !(state.source === 'folder' || (state.source === 'albums' && state.album));
2026-09-24 11:47:48 +02:00
$('tl-order').hidden = state.source !== 'timeline';
2026-09-24 11:46:54 +02:00
try {
if (state.source === 'folder') {
const items = await window.nc.folder(state.path);
if (token !== state.loadToken) return;
renderFolder(items);
} else if (state.source === 'albums') {
if (state.album) {
const items = await window.nc.album(state.album.href);
if (token !== state.loadToken) return;
renderMediaList(items, 'Dieses Album ist leer.');
} else {
const res = await window.nc.albums();
if (token !== state.loadToken) return;
renderAlbums(res);
}
} else {
2026-09-24 11:47:48 +02:00
startTimeline(token, state.timelineOrder);
2026-09-24 11:46:54 +02:00
}
} catch (e) {
if (token !== state.loadToken) return;
// Gespeicherter Ordner existiert nicht mehr: zurück zur Wurzel
if (state.source === 'folder' && state.path && /Nicht gefunden/.test(e.message)) return openFolder('');
content.replaceChildren(errorNotice(e));
}
}
2026-09-24 11:47:48 +02:00
// 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));
2026-09-24 11:46:54 +02:00
function sortItems(items) {
const s = state.sort;
2026-09-24 11:47:48 +02:00
const by = photoDate;
2026-09-24 11:46:54 +02:00
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));
2026-09-24 11:47:48 +02:00
if (s === 'mnew') arr.sort((a, b) => b.mtime - a.mtime);
if (s === 'mold') arr.sort((a, b) => a.mtime - b.mtime);
2026-09-24 11:46:54 +02:00
return arr;
}
// ------------------------------------------------------------------ Kacheln
function attachImg(img, src, holder) {
img.alt = '';
img.loading = 'lazy';
img.decoding = 'async';
img.draggable = false;
img.addEventListener('load', () => img.classList.add('loaded'));
img.addEventListener('error', () => { holder.classList.add('broken'); img.remove(); });
img.src = src;
}
function mediaTile(item, index) {
const b = el('button', 'tile');
b.dataset.name = item.name;
b.title = item.name;
const src = thumbUrl(item);
if (src) {
const img = el('img');
attachImg(img, src, b);
b.append(img);
} else {
b.classList.add('broken');
}
if (item.kind === 'video') b.append(el('span', 'badge', '▶'));
b.addEventListener('click', () => openViewer(index));
return b;
}
function folderTile(item) {
const b = el('button', 'tile folder');
const cover = el('div', 'cover');
cover.append(el('div', 'folder-shape'));
b.append(cover, el('div', 'label', item.name));
b.title = item.name;
b.addEventListener('click', () => openFolder(item.path));
return b;
}
2026-09-24 11:47:48 +02:00
function hiddenNote(items) {
const n = items.filter(isHidden).length;
return n ? `${n} DNG-${n === 1 ? 'Datei ist' : 'Dateien sind'} ausgeblendet.` : null;
}
2026-09-24 11:46:54 +02:00
function renderMediaList(items, emptyText) {
2026-09-24 11:47:48 +02:00
const media = sortItems(visibleMedia(items));
2026-09-24 11:46:54 +02:00
state.media = media;
const content = $('content');
2026-09-24 11:47:48 +02:00
if (!media.length) { content.replaceChildren(notice(emptyText, hiddenNote(items))); return; }
2026-09-24 11:46:54 +02:00
const grid = el('div', 'grid');
media.forEach((m, i) => grid.append(mediaTile(m, i)));
content.replaceChildren(grid);
}
function renderFolder(items) {
const dirs = items.filter((i) => i.isDir).sort((a, b) => collator.compare(a.name, b.name));
2026-09-24 11:47:48 +02:00
const media = sortItems(visibleMedia(items));
2026-09-24 11:46:54 +02:00
state.media = media;
const content = $('content');
content.replaceChildren();
if (dirs.length) {
const g = el('div', 'grid folders');
dirs.forEach((d) => g.append(folderTile(d)));
content.append(g);
}
if (media.length) {
const g = el('div', 'grid');
media.forEach((m, i) => g.append(mediaTile(m, i)));
content.append(g);
}
if (!dirs.length && !media.length) {
2026-09-24 11:47:48 +02:00
content.append(notice('Hier liegen keine Fotos.', hiddenNote(items) || (state.path ? 'Über den Pfad oben kommst du zurück.' : null)));
2026-09-24 11:46:54 +02:00
}
}
function renderAlbums({ available, albums }) {
const content = $('content');
if (!available) {
content.replaceChildren(notice('Alben sind nicht verfügbar.', 'Dafür muss auf deiner Nextcloud die App „Fotos“ installiert sein. Die Ansichten „Alle Fotos“ und „Ordner“ funktionieren trotzdem.'));
return;
}
if (!albums.length) {
content.replaceChildren(notice('Du hast noch keine Alben.', 'Alben legst du in der Fotos-App deiner Nextcloud an.'));
return;
}
const g = el('div', 'grid folders');
albums
.sort((a, b) => (b.end || 0) - (a.end || 0) || collator.compare(a.name, b.name))
.forEach((a) => {
const b = el('button', 'tile album');
const cover = el('div', 'cover');
if (a.cover) {
const img = el('img');
attachImg(img, previewUrl(a.cover, thumbSize()), cover);
cover.append(img);
} else {
cover.append(el('div', 'folder-shape'));
}
const bits = [`${a.count} ${a.count === 1 ? 'Foto' : 'Fotos'}`];
if (a.start) bits.push(shortDate.format(a.start));
if (a.shared) bits.push('geteilt');
b.append(cover, el('div', 'label', a.name), el('div', 'sublabel', bits.join(', ')));
b.title = a.name;
b.addEventListener('click', () => { state.album = { href: a.href, name: a.name }; load(); });
g.append(b);
});
content.replaceChildren(g);
}
// ------------------------------------------------------------------ Zeitleiste
let timelineObserver = null;
2026-09-24 11:47:48 +02:00
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 = [];
2026-09-24 11:46:54 +02:00
const content = $('content');
content.replaceChildren();
2026-09-24 11:47:48 +02:00
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.'));
}
2026-09-24 11:46:54 +02:00
const sentinel = el('div', 'sentinel');
const more = el('div', 'loading-more', 'Lade Fotos …');
content.append(sentinel, more);
if (timelineObserver) timelineObserver.disconnect();
timelineObserver = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) loadTimelinePage();
}, { root: content, rootMargin: '1200px' });
timelineObserver.observe(sentinel);
loadTimelinePage();
}
2026-09-24 11:47:48 +02:00
// 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;
}
2026-09-24 11:46:54 +02:00
async function loadTimelinePage() {
const t = state.timeline;
if (t.loading || t.done || t.token !== state.loadToken) return;
t.loading = true;
const content = $('content');
const sentinel = content.querySelector('.sentinel');
const more = content.querySelector('.loading-more');
try {
2026-09-24 11:47:48 +02:00
const res = await window.nc.timeline(t.offset, t.order);
2026-09-24 11:46:54 +02:00
if (t.token !== state.loadToken) return;
2026-09-24 11:47:48 +02:00
// 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);
2026-09-24 11:46:54 +02:00
const index = state.media.length;
state.media.push(item);
2026-09-24 11:47:48 +02:00
m.grid.append(mediaTile(item, index));
m.count++;
m.countEl.textContent = `${m.count} ${m.count === 1 ? 'Foto' : 'Fotos'}`;
2026-09-24 11:46:54 +02:00
}
2026-09-24 11:47:48 +02:00
if (t.done) {
2026-09-24 11:46:54 +02:00
more.remove();
2026-09-24 11:47:48 +02:00
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.'));
}
2026-09-24 11:46:54 +02:00
}
} catch (e) {
if (t.token !== state.loadToken) return;
t.done = true;
more.replaceWith(errorNotice(e));
} finally {
t.loading = false;
}
// Falls der Bildschirm noch nicht gefüllt ist, direkt nachladen
if (!t.done && sentinel.isConnected && sentinel.getBoundingClientRect().top < content.clientHeight + 1200) {
loadTimelinePage();
}
}
// ------------------------------------------------------------------ Werkzeugleiste
2026-09-24 11:47:48 +02:00
$('tl-order').value = state.timelineOrder;
$('tl-order').addEventListener('change', () => {
state.timelineOrder = $('tl-order').value;
store.set('timelineOrder', state.timelineOrder);
load();
});
2026-09-24 11:46:54 +02:00
$('sort').value = state.sort;
$('sort').addEventListener('change', () => {
state.sort = $('sort').value;
store.set('sort', state.sort);
load();
});
2026-09-24 11:47:48 +02:00
$('hide-dng').checked = state.hideDng;
$('hide-dng').addEventListener('change', () => {
state.hideDng = $('hide-dng').checked;
store.set('hideDng', state.hideDng);
load();
});
2026-09-24 11:46:54 +02:00
function applyZoom() {
document.documentElement.style.setProperty('--thumb', `${state.zoom}px`);
}
$('zoom').value = state.zoom;
applyZoom();
$('zoom').addEventListener('input', () => {
state.zoom = Number($('zoom').value);
store.set('zoom', state.zoom);
applyZoom();
});
// ------------------------------------------------------------------ Betrachter
const viewer = {
index: 0,
original: false,
token: 0,
idleTimer: null,
};
function openViewer(index) {
viewer.index = index;
viewer.original = false;
$('viewer').hidden = false;
showCurrent();
wakeViewer();
}
function closeViewer() {
$('viewer').hidden = true;
const v = $('v-video');
v.pause();
v.removeAttribute('src');
v.load();
$('v-img').removeAttribute('src');
// Fokus zurück auf die zuletzt gezeigte Kachel
const tiles = $('content').querySelectorAll('.grid:not(.folders) .tile');
tiles[viewer.index]?.focus({ preventScroll: false });
}
function bigSize() {
const s = Math.max(window.innerWidth, window.innerHeight) * (window.devicePixelRatio || 1);
return s <= 1600 ? 1600 : s <= 2560 ? 2560 : 4096;
}
function preload(item) {
if (!item || item.kind !== 'image' || !item.fileId || !item.hasPreview) return;
const i = new Image();
i.src = previewUrl(item.fileId, bigSize());
}
function showCurrent() {
const item = state.media[viewer.index];
if (!item) return closeViewer();
const token = ++viewer.token;
const img = $('v-img');
const video = $('v-video');
const msg = $('v-msg');
msg.hidden = true;
$('v-name').textContent = item.name;
const meta = [`${viewer.index + 1} von ${state.media.length}`];
2026-09-24 11:47:48 +02:00
if (item.taken) meta.push(`Aufgenommen ${dateFmt.format(item.taken)}`);
if (item.mtime) meta.push(`Geändert ${dateFmt.format(item.mtime)}`);
2026-09-24 11:46:54 +02:00
if (item.size) meta.push(fmtSize(item.size));
$('v-meta').replaceChildren(...meta.map((m) => el('span', null, m)));
$('v-prev').disabled = viewer.index === 0;
$('v-next').disabled = viewer.index >= state.media.length - 1;
$('v-open').hidden = !item.fileId;
$('v-original').hidden = item.kind !== 'image';
$('v-original').classList.toggle('active', viewer.original);
if (item.kind === 'video') {
img.hidden = true;
video.hidden = false;
video.src = fileUrl(item);
video.play().catch(() => {});
return;
}
video.pause();
video.hidden = true;
video.removeAttribute('src');
img.hidden = false;
const canPreview = item.fileId && item.hasPreview;
const target = viewer.original || !canPreview ? fileUrl(item) : previewUrl(item.fileId, bigSize());
// Zuerst das (meist schon geladene) Vorschaubild zeigen, dann scharf nachladen
if (canPreview) {
img.src = previewUrl(item.fileId, thumbSize());
img.classList.add('lowres');
} else {
img.removeAttribute('src');
}
if (viewer.original) { msg.textContent = 'Original wird geladen …'; msg.hidden = false; }
const full = new Image();
full.onload = () => {
if (token !== viewer.token) return;
img.src = target;
img.classList.remove('lowres');
msg.hidden = true;
};
full.onerror = () => {
if (token !== viewer.token) return;
img.classList.remove('lowres');
msg.textContent = viewer.original
? 'Dieses Dateiformat kann nur als Vorschau angezeigt werden.'
: 'Das Bild konnte nicht geladen werden.';
msg.hidden = false;
};
full.src = target;
preload(state.media[viewer.index + 1]);
preload(state.media[viewer.index - 1]);
// In der Zeitleiste rechtzeitig weitere Fotos holen
if (state.source === 'timeline' && viewer.index > state.media.length - 10) loadTimelinePage();
}
function step(d) {
const n = viewer.index + d;
if (n < 0 || n >= state.media.length) return;
viewer.index = n;
viewer.original = false;
showCurrent();
}
function wakeViewer() {
const v = $('viewer');
v.classList.remove('idle');
clearTimeout(viewer.idleTimer);
viewer.idleTimer = setTimeout(() => v.classList.add('idle'), 2500);
}
$('v-prev').addEventListener('click', () => step(-1));
$('v-next').addEventListener('click', () => step(1));
$('v-close').addEventListener('click', closeViewer);
$('v-full').addEventListener('click', () => window.nc.toggleFullscreen());
$('v-original').addEventListener('click', () => { viewer.original = !viewer.original; showCurrent(); });
$('v-open').addEventListener('click', () => {
const item = state.media[viewer.index];
if (item?.fileId) window.nc.openInNextcloud(item.fileId);
});
$('viewer').addEventListener('mousemove', wakeViewer);
$('viewer').querySelector('.v-stage').addEventListener('dblclick', () => window.nc.toggleFullscreen());
$('viewer').addEventListener('wheel', (e) => {
if (Math.abs(e.deltaY) < 30) return;
step(e.deltaY > 0 ? 1 : -1);
}, { passive: true });
document.addEventListener('keydown', (e) => {
if (!$('viewer').hidden) {
const k = e.key;
if (k === 'ArrowRight' || k === ' ' || k === 'PageDown') { e.preventDefault(); step(1); }
else if (k === 'ArrowLeft' || k === 'PageUp') { e.preventDefault(); step(-1); }
else if (k === 'Home') { viewer.index = 0; showCurrent(); }
else if (k === 'End') { viewer.index = state.media.length - 1; showCurrent(); }
else if (k === 'Escape') closeViewer();
else if (k === 'f' || k === 'F11') { e.preventDefault(); window.nc.toggleFullscreen(); }
else if (k === 'o') $('v-original').click();
wakeViewer();
return;
}
if ($('app').hidden) return;
const typing = /INPUT|SELECT|TEXTAREA/.test(document.activeElement?.tagName);
if (e.key === 'Backspace' && !typing) { e.preventDefault(); goUp(); }
if (e.key === 'F5') { e.preventDefault(); load(); }
});
document.addEventListener('mouseup', (e) => {
if (e.button === 3) { // Maus-Zurück-Taste
if (!$('viewer').hidden) closeViewer(); else goUp();
}
});
// ------------------------------------------------------------------ Start
(async function start() {
const cfg = await window.nc.config();
$('server').value = cfg.server;
$('user').value = cfg.loginName;
if (cfg.hasSecret) {
showScreen('connecting');
try {
const info = await window.nc.restore();
if (info) return afterLogin(info);
} catch (e) {
showScreen('login');
loginError(e.message);
return;
}
}
showScreen('login');
$('server').focus();
})();