'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(); })();