Files
Lichttisch/renderer/app.js
T
2026-09-24 11:46:54 +02:00

613 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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', ''),
sort: store.get('sort', 'name'),
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 },
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));
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 {
startTimeline(token);
}
} 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));
}
}
function sortItems(items) {
const s = state.sort;
const by = (i) => i.taken || i.mtime;
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));
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;
}
function renderMediaList(items, emptyText) {
const media = sortItems(items.filter((i) => i.kind === 'image' || i.kind === 'video'));
state.media = media;
const content = $('content');
if (!media.length) { content.replaceChildren(notice(emptyText)); return; }
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));
const media = sortItems(items.filter((i) => i.kind === 'image' || i.kind === 'video'));
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) {
content.append(notice('Hier liegen keine Fotos.', state.path ? 'Über den Pfad oben kommst du zurück.' : null));
}
}
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;
function startTimeline(token) {
state.timeline = { offset: 0, done: false, loading: false, lastMonth: null, grid: null, token };
const content = $('content');
content.replaceChildren();
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();
}
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 {
const { items, done } = await window.nc.timeline(t.offset);
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);
}
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'}`;
}
if (done) {
more.remove();
if (!state.media.length) content.replaceChildren(notice('Keine Fotos gefunden.', 'In deiner Nextcloud liegen noch keine Bilder oder Videos.'));
}
} 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
$('sort').value = state.sort;
$('sort').addEventListener('change', () => {
state.sort = $('sort').value;
store.set('sort', state.sort);
load();
});
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 when = item.taken || item.mtime;
const meta = [`${viewer.index + 1} von ${state.media.length}`];
if (when) meta.push(dateFmt.format(when));
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();
})();