commit a39780b3fb50e31908318bf8a7f52c1e3f4c895f Author: Daniel Date: Thu Sep 24 11:46:54 2026 +0200 Initial diff --git a/README.md b/README.md new file mode 100644 index 0000000..e5ff759 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Lichttisch + +Fotos aus deiner Nextcloud ansehen, ohne sie herunterzuladen. Bilder werden nur +zur Anzeige übertragen und landen nicht auf deiner Festplatte (Vorschaubilder +liegen ausschließlich im Arbeitsspeicher). + +## Starten + + chmod +x Lichttisch-1.0.0-x86_64.AppImage + ./Lichttisch-1.0.0-x86_64.AppImage + +Falls ein Fehler zu FUSE erscheint (z. B. auf Ubuntu 22.04+): + + sudo apt install libfuse2 # bzw. libfuse2t64 auf Ubuntu 24.04 + +## Anmelden + +Adresse der Nextcloud eingeben und „Im Browser anmelden“ wählen. Die App erhält +dann ein eigenes App-Passwort, das du in der Nextcloud unter +Einstellungen › Sicherheit jederzeit widerrufen kannst. +Alternativ geht die Anmeldung mit Benutzername und App-Passwort. + +Mit „Angemeldet bleiben“ wird das App-Passwort über den Schlüsselbund des +Systems (GNOME Keyring / KWallet) verschlüsselt in +~/.config/Lichttisch/config.json gespeichert. + +## Ansichten + +- Alle Fotos: alle Bilder und Videos als Zeitleiste nach Monaten (wie die Fotos-App) +- Ordner: durch deine Ordner blättern +- Alben: Alben aus der Nextcloud-Fotos-App (inklusive geteilter Alben) + +## Tastatur + +Im Betrachter: ← / → blättern, Esc schließen, F Vollbild, O Originaldatei +laden, Doppelklick Vollbild. In der Übersicht: Rücktaste eine Ebene hoch, +F5 neu laden. + +## Selbst bauen + + npm install + npm start # zum Ausprobieren + npm run dist # erzeugt dist/Lichttisch-1.0.0-x86_64.AppImage diff --git a/build/icon.png b/build/icon.png new file mode 100644 index 0000000..f5c3588 Binary files /dev/null and b/build/icon.png differ diff --git a/main.js b/main.js new file mode 100644 index 0000000..2ec81e1 --- /dev/null +++ b/main.js @@ -0,0 +1,443 @@ +// Lichttisch – Nextcloud-Fotobetrachter +// Hauptprozess: spricht per WebDAV/OCS mit der Nextcloud und reicht Bilder +// über das eigene Protokoll nc:// an die Oberfläche durch. Nichts wird auf +// die Festplatte geschrieben außer der Anmeldung (falls gewünscht). + +const { app, BrowserWindow, ipcMain, protocol, shell, safeStorage, Menu } = require('electron'); +const path = require('path'); +const fs = require('fs'); +const { XMLParser } = require('fast-xml-parser'); + +const UA = 'Lichttisch (Linux Fotobetrachter)'; +const PAGE_SIZE = 200; + +protocol.registerSchemesAsPrivileged([ + { scheme: 'nc', privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true } }, +]); + +let win = null; +let conn = null; // { server, loginName, userId, displayName, auth } +let loginFlowAbort = null; + +// ---------------------------------------------------------------- Konfiguration + +const configFile = () => path.join(app.getPath('userData'), 'config.json'); + +function loadConfig() { + try { return JSON.parse(fs.readFileSync(configFile(), 'utf8')); } catch { return {}; } +} + +function saveConfig(cfg) { + fs.mkdirSync(path.dirname(configFile()), { recursive: true }); + fs.writeFileSync(configFile(), JSON.stringify(cfg, null, 2), { mode: 0o600 }); +} + +function encryptSecret(s) { + if (safeStorage.isEncryptionAvailable()) { + return { enc: true, v: safeStorage.encryptString(s).toString('base64') }; + } + return { enc: false, v: Buffer.from(s, 'utf8').toString('base64') }; +} + +function decryptSecret(o) { + if (!o) return null; + try { + const buf = Buffer.from(o.v, 'base64'); + return o.enc ? safeStorage.decryptString(buf) : buf.toString('utf8'); + } catch { return null; } +} + +// ---------------------------------------------------------------- Hilfsfunktionen + +function normalizeServer(s) { + s = String(s || '').trim(); + if (!s) throw new Error('Bitte die Adresse deiner Nextcloud eingeben.'); + if (!/^https?:\/\//i.test(s)) s = 'https://' + s; + s = s.replace(/\/(index\.php|remote\.php|apps|login)(\/.*)?$/i, ''); + return s.replace(/\/+$/, ''); +} + +const origin = (server) => new URL(server).origin; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function encodePath(p) { + return p.split('/').map((seg) => encodeURIComponent(seg)).join('/'); +} + +function xmlEscape(s) { + return String(s).replace(/[<>&'"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[c])); +} + +async function ncFetch(url, opts = {}) { + if (!conn) throw new Error('Nicht angemeldet.'); + const headers = { Authorization: conn.auth, 'User-Agent': UA, 'OCS-APIRequest': 'true', ...(opts.headers || {}) }; + let res; + try { + res = await fetch(url, { ...opts, headers }); + } catch (e) { + throw new Error('Server nicht erreichbar: ' + (e.cause?.code || e.message)); + } + if (res.status === 401) throw new Error('Die Anmeldung wurde abgelehnt. Bitte neu anmelden.'); + return res; +} + +// ---------------------------------------------------------------- WebDAV-Antworten lesen + +const parser = new XMLParser({ + removeNSPrefix: true, + ignoreAttributes: true, + parseTagValue: false, + isArray: (name) => name === 'response' || name === 'propstat', +}); + +function parseMultistatus(xml) { + const doc = parser.parse(xml); + const responses = doc?.multistatus?.response || []; + return responses.map((r) => { + const props = {}; + for (const ps of r.propstat || []) { + if (/\s200\s/.test(String(ps.status) + ' ')) Object.assign(props, ps.prop || {}); + } + return { href: String(r.href), props }; + }); +} + +function kindOf(mime, isDir) { + if (isDir) return 'dir'; + if (mime.startsWith('image/')) return 'image'; + if (mime.startsWith('video/')) return 'video'; + return 'other'; +} + +function toItem({ href, props }) { + const isDir = typeof props.resourcetype === 'object' && props.resourcetype !== null && 'collection' in props.resourcetype; + const mime = String(props.getcontenttype || ''); + const decoded = decodeURIComponent(href); + const name = String(props.displayname || '') || decoded.replace(/\/+$/, '').split('/').pop(); + const mtime = props.getlastmodified ? Date.parse(props.getlastmodified) : 0; + const takenRaw = Number(props['metadata-photos-original_date_time'] || 0); + return { + href, + name, + isDir, + mime, + kind: kindOf(mime, isDir), + size: Number(props.getcontentlength || 0), + mtime, + taken: takenRaw > 0 ? takenRaw * 1000 : 0, + fileId: props.fileid ? String(props.fileid) : null, + hasPreview: String(props['has-preview']) === 'true', + }; +} + +const FILE_PROPS = ` + + `; + +const NS = 'xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns"'; + +async function propfind(url, propsXml, depth = '1') { + const body = `${propsXml}`; + const res = await ncFetch(url, { method: 'PROPFIND', headers: { Depth: depth, 'Content-Type': 'text/xml; charset=utf-8' }, body }); + if (res.status === 404) { const e = new Error('Nicht gefunden.'); e.code = 404; throw e; } + if (res.status !== 207) throw new Error(`Unerwartete Antwort vom Server (${res.status}).`); + return parseMultistatus(await res.text()); +} + +// ---------------------------------------------------------------- Anmeldung + +async function finishLogin(server, loginName, password, remember) { + server = normalizeServer(server); + const auth = 'Basic ' + Buffer.from(`${loginName}:${password}`, 'utf8').toString('base64'); + let res; + try { + res = await fetch(server + '/ocs/v2.php/cloud/user?format=json', { + headers: { Authorization: auth, 'OCS-APIRequest': 'true', Accept: 'application/json', 'User-Agent': UA }, + }); + } catch (e) { + throw new Error(`Keine Verbindung zu ${server}. Stimmt die Adresse? (${e.cause?.code || e.message})`); + } + if (res.status === 401) throw new Error('Benutzername oder Passwort stimmt nicht. Bei aktivierter Zwei-Faktor-Anmeldung brauchst du ein App-Passwort.'); + if (!res.ok) throw new Error(`Der Server antwortet mit Status ${res.status}. Ist das wirklich eine Nextcloud-Adresse?`); + let data; + try { data = (await res.json()).ocs.data; } catch { throw new Error('Unter dieser Adresse läuft offenbar keine Nextcloud.'); } + + conn = { + server, + loginName, + userId: data.id, + displayName: data['display-name'] || data.displayname || data.id, + auth, + }; + thumbCache.clear(); + + const cfg = loadConfig(); + cfg.server = server; + cfg.loginName = loginName; + if (remember) cfg.secret = encryptSecret(password); + else delete cfg.secret; + saveConfig(cfg); + + return publicConn(); +} + +function publicConn() { + return conn && { server: conn.server, userId: conn.userId, displayName: conn.displayName }; +} + +async function loginFlow(serverInput, remember) { + const server = normalizeServer(serverInput); + let res; + try { + res = await fetch(server + '/index.php/login/v2', { method: 'POST', headers: { 'User-Agent': UA } }); + } catch (e) { + throw new Error(`Keine Verbindung zu ${server}. Stimmt die Adresse? (${e.cause?.code || e.message})`); + } + if (!res.ok) throw new Error(`Die Browser-Anmeldung wird von diesem Server nicht angeboten (Status ${res.status}).`); + const flow = await res.json(); + await shell.openExternal(flow.login); + + const ctrl = { aborted: false }; + loginFlowAbort = ctrl; + const deadline = Date.now() + 20 * 60 * 1000; + while (Date.now() < deadline) { + await sleep(2000); + if (ctrl.aborted) throw new Error('Anmeldung abgebrochen.'); + let poll; + try { + poll = await fetch(flow.poll.endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': UA }, + body: 'token=' + encodeURIComponent(flow.poll.token), + }); + } catch { continue; } + if (poll.status === 200) { + const d = await poll.json(); + loginFlowAbort = null; + return finishLogin(d.server || server, d.loginName, d.appPassword, remember); + } + } + throw new Error('Die Anmeldung im Browser hat zu lange gedauert. Bitte noch einmal versuchen.'); +} + +// ---------------------------------------------------------------- Datenquellen + +async function listFolder(relPath) { + const base = `${conn.server}/remote.php/dav/files/${encodeURIComponent(conn.userId)}`; + const rel = ('/' + (relPath || '').replace(/^\/+|\/+$/g, '')).replace(/\/$/, '') + '/'; + const entries = await propfind(base + encodePath(rel), FILE_PROPS, '1'); + const marker = `/remote.php/dav/files/${conn.userId}`; + const items = entries.slice(1).map(toItem).map((it) => { + if (it.isDir) { + const dec = decodeURIComponent(it.href); + const i = dec.indexOf(marker); + it.path = i >= 0 ? dec.slice(i + marker.length).replace(/\/+$/, '') : rel + it.name; + } + return it; + }); + return items; +} + +async function searchMedia(offset) { + const body = ` + + + ${FILE_PROPS} + /files/${xmlEscape(conn.userId)}infinity + + + image/% + video/% + + + + ${PAGE_SIZE}${Number(offset) || 0} + +`; + const res = await ncFetch(`${conn.server}/remote.php/dav/`, { + method: 'SEARCH', + 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}).`); + const items = parseMultistatus(await res.text()).map(toItem).filter((i) => !i.isDir); + return { items, done: items.length < PAGE_SIZE }; +} + +const ALBUM_PROPS = ``; + +async function listAlbums() { + const u = encodeURIComponent(conn.userId); + const out = []; + let available = false; + for (const [kind, shared] of [['albums', false], ['sharedalbums', true]]) { + try { + const entries = await propfind(`${conn.server}/remote.php/dav/photos/${u}/${kind}/`, ALBUM_PROPS, '1'); + available = true; + for (const e of entries.slice(1)) { + const name = decodeURIComponent(e.href).replace(/\/+$/, '').split('/').pop(); + let range = null; + try { range = e.props.dateRange ? JSON.parse(e.props.dateRange) : null; } catch {} + out.push({ + href: e.href, + name: shared ? name.replace(/\s*\([^)]*\)$/, '') : name, + shared, + count: Number(e.props.nbItems || 0), + cover: e.props['last-photo'] && e.props['last-photo'] !== '-1' ? String(e.props['last-photo']) : null, + location: e.props.location ? String(e.props.location) : '', + start: range?.start ? range.start * 1000 : 0, + end: range?.end ? range.end * 1000 : 0, + }); + } + } catch (e) { + if (e.code !== 404) throw e; + } + } + return { available, albums: out }; +} + +async function listAlbum(href) { + if (!String(href).startsWith('/')) throw new Error('Ungültiges Album.'); + const entries = await propfind(origin(conn.server) + href, FILE_PROPS, '1'); + return entries.slice(1).map(toItem).filter((i) => !i.isDir); +} + +// ---------------------------------------------------------------- Bild-Proxy nc:// + +// Vorschaubilder landen nur im Arbeitsspeicher (max. ca. 150 MB). +const thumbCache = new Map(); +let thumbBytes = 0; +const THUMB_LIMIT = 150 * 1024 * 1024; + +function cachePut(key, entry) { + if (thumbCache.has(key)) return; + thumbCache.set(key, entry); + thumbBytes += entry.buf.byteLength; + while (thumbBytes > THUMB_LIMIT && thumbCache.size) { + const [k, v] = thumbCache.entries().next().value; + thumbCache.delete(k); + thumbBytes -= v.buf.byteLength; + } +} + +async function handleNc(request) { + if (!conn) return new Response('Nicht angemeldet', { status: 401 }); + const u = new URL(request.url); + try { + if (u.hostname === 'preview') { + const id = u.searchParams.get('id'); + const s = Math.min(4096, Math.max(32, Number(u.searchParams.get('s')) || 256)); + const key = `${id}:${s}`; + const hit = thumbCache.get(key); + if (hit) { + thumbCache.delete(key); thumbCache.set(key, hit); // LRU auffrischen + return new Response(hit.buf, { headers: { 'Content-Type': hit.type } }); + } + const res = await ncFetch(`${conn.server}/index.php/core/preview?fileId=${encodeURIComponent(id)}&x=${s}&y=${s}&a=1&forceIcon=0`); + if (!res.ok) return new Response('', { status: res.status }); + const buf = Buffer.from(await res.arrayBuffer()); + const type = res.headers.get('content-type') || 'image/jpeg'; + if (s <= 1024) cachePut(key, { buf, type }); + return new Response(buf, { headers: { 'Content-Type': type } }); + } + + if (u.hostname === 'file') { + const href = u.searchParams.get('h') || ''; + if (!href.startsWith('/')) return new Response('', { status: 400 }); + const headers = {}; + const range = request.headers.get('range'); + if (range) headers.Range = range; + const res = await ncFetch(origin(conn.server) + href, { headers }); + const out = new Headers(); + for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges', 'last-modified']) { + const v = res.headers.get(h); + if (v) out.set(h, v); + } + return new Response(res.body, { status: res.status, headers: out }); + } + } catch (e) { + return new Response(String(e.message), { status: 502 }); + } + return new Response('', { status: 404 }); +} + +// ---------------------------------------------------------------- IPC + +function handle(channel, fn) { + ipcMain.handle(channel, async (_e, ...args) => { + try { return { ok: true, data: await fn(...args) }; } + catch (e) { return { ok: false, error: e.message || String(e) }; } + }); +} + +handle('config', () => { + const c = loadConfig(); + return { server: c.server || '', loginName: c.loginName || '', hasSecret: !!c.secret }; +}); + +handle('restore', async () => { + const c = loadConfig(); + const pw = decryptSecret(c.secret); + if (!c.server || !c.loginName || !pw) return null; + return finishLogin(c.server, c.loginName, pw, true); +}); + +handle('login-password', (server, user, pass, remember) => finishLogin(server, user, pass, remember)); +handle('login-browser', (server, remember) => loginFlow(server, remember)); +handle('login-cancel', () => { if (loginFlowAbort) loginFlowAbort.aborted = true; return true; }); + +handle('logout', () => { + conn = null; + thumbCache.clear(); thumbBytes = 0; + const c = loadConfig(); + delete c.secret; + saveConfig(c); + return true; +}); + +handle('folder', (p) => listFolder(p)); +handle('timeline', (offset) => searchMedia(offset)); +handle('albums', () => listAlbums()); +handle('album', (href) => listAlbum(href)); + +handle('open-in-nextcloud', (fileId) => { + if (!conn || !/^\d+$/.test(String(fileId))) return false; + shell.openExternal(`${conn.server}/index.php/f/${fileId}`); + return true; +}); + +handle('toggle-fullscreen', () => { + if (win) win.setFullScreen(!win.isFullScreen()); + return win?.isFullScreen(); +}); + +// ---------------------------------------------------------------- Fenster + +function createWindow() { + win = new BrowserWindow({ + width: 1280, + height: 820, + minWidth: 640, + minHeight: 420, + backgroundColor: '#1f2022', + title: 'Lichttisch', + autoHideMenuBar: true, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); + win.webContents.on('will-navigate', (e) => e.preventDefault()); + win.loadFile(path.join(__dirname, 'renderer', 'index.html')); +} + +app.whenReady().then(() => { + Menu.setApplicationMenu(null); + protocol.handle('nc', handleNc); + createWindow(); +}); + +app.on('window-all-closed', () => app.quit()); diff --git a/package.json b/package.json new file mode 100644 index 0000000..9a911ce --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "name": "lichttisch", + "productName": "Lichttisch", + "version": "1.0.0", + "description": "Fotos aus der Nextcloud per WebDAV ansehen, ohne sie herunterzuladen", + "main": "main.js", + "author": { + "name": "Lichttisch", + "email": "lichttisch@example.invalid" + }, + "license": "MIT", + "scripts": { + "start": "electron .", + "dist": "electron-builder --linux AppImage" + }, + "dependencies": { + "fast-xml-parser": "^5.2.0" + }, + "devDependencies": { + "electron": "^44.0.0", + "electron-builder": "^26.0.0" + }, + "build": { + "appId": "de.lichttisch.viewer", + "productName": "Lichttisch", + "files": [ + "main.js", + "preload.js", + "renderer/**/*", + "package.json" + ], + "linux": { + "target": [ + "AppImage" + ], + "category": "Graphics", + "icon": "build/icon.png", + "synopsis": "Nextcloud-Fotobetrachter", + "artifactName": "Lichttisch-${version}-${arch}.AppImage", + "syncDesktopName": true + } + }, + "desktopName": "lichttisch.desktop" +} \ No newline at end of file diff --git a/preload.js b/preload.js new file mode 100644 index 0000000..ca126a4 --- /dev/null +++ b/preload.js @@ -0,0 +1,22 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +const call = async (channel, ...args) => { + const r = await ipcRenderer.invoke(channel, ...args); + if (!r.ok) throw new Error(r.error); + return r.data; +}; + +contextBridge.exposeInMainWorld('nc', { + config: () => call('config'), + restore: () => call('restore'), + loginPassword: (server, user, pass, remember) => call('login-password', server, user, pass, remember), + loginBrowser: (server, remember) => call('login-browser', server, remember), + loginCancel: () => call('login-cancel'), + logout: () => call('logout'), + folder: (path) => call('folder', path), + timeline: (offset) => call('timeline', offset), + albums: () => call('albums'), + album: (href) => call('album', href), + openInNextcloud: (fileId) => call('open-in-nextcloud', fileId), + toggleFullscreen: () => call('toggle-fullscreen'), +}); diff --git a/renderer/app.js b/renderer/app.js new file mode 100644 index 0000000..e99f44b --- /dev/null +++ b/renderer/app.js @@ -0,0 +1,612 @@ +'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(); +})(); diff --git a/renderer/index.html b/renderer/index.html new file mode 100644 index 0000000..33a5681 --- /dev/null +++ b/renderer/index.html @@ -0,0 +1,123 @@ + + + + + + Lichttisch + + + + + + + + + + + + + + + + + + + diff --git a/renderer/style.css b/renderer/style.css new file mode 100644 index 0000000..17ac849 --- /dev/null +++ b/renderer/style.css @@ -0,0 +1,350 @@ +/* Lichttisch + Fotos stehen im Mittelpunkt: neutrales Mittelgrau als Unterlage (wie ein + Kontaktbogen auf dem Leuchtpult), die Bedienung tritt zurück. Einziger + Farbakzent ist das Bernstein der Dunkelkammerlampe. */ + +:root { + --chrome: #242527; /* Seitenleiste, Werkzeugleiste */ + --table: #38393c; /* Unterlage hinter den Fotos */ + --tile: #2e2f32; /* Platzhalter, solange ein Bild lädt */ + --line: #47484c; + --text: #ecebe7; + --muted: #a3a29d; + --safelight: #eea33a; + --safelight-ink: #2a1b05; + --danger: #f08a7c; + + --thumb: 180px; + --gap: 3px; + + font-family: "Cantarell", "Noto Sans", "Ubuntu", "DejaVu Sans", system-ui, sans-serif; + font-size: 15px; + line-height: 1.45; + color: var(--text); + background: var(--chrome); + color-scheme: dark; +} + +* { box-sizing: border-box; } +html, body { height: 100%; margin: 0; } +body { overflow: hidden; -webkit-font-smoothing: antialiased; } +[hidden] { display: none !important; } + +button, input, select { font: inherit; color: inherit; } + +button { + background: transparent; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.45em 0.9em; + cursor: pointer; +} +button:hover { background: rgba(255, 255, 255, 0.06); } +button.primary { + background: var(--safelight); + color: var(--safelight-ink); + border-color: var(--safelight); + font-weight: 600; +} +button.primary:hover { filter: brightness(1.07); } +button.quiet { border-color: transparent; color: var(--muted); padding-left: 0; padding-right: 0; } +button.quiet:hover { background: none; color: var(--text); } +button:disabled { opacity: 0.5; cursor: default; } + +:focus-visible { outline: 2px solid var(--safelight); outline-offset: 2px; } + +input[type=text], input[type=url], input[type=password], select { + background: #1b1c1e; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.55em 0.7em; +} +select { padding: 0.35em 0.5em; } + +/* ------------------------------------------------------------ Anmeldung */ + +.login, .connecting { + height: 100%; + display: grid; + place-items: center; + padding: 2rem; + overflow: auto; +} +.connecting p { color: var(--muted); } + +.login-panel { width: min(26rem, 100%); display: grid; gap: 1.1rem; } + +.wordmark { + margin: 0; + font-size: 2.6rem; + font-weight: 300; + letter-spacing: -0.01em; + line-height: 1.1; +} +.lede { margin: -0.4rem 0 0.6rem; color: var(--muted); } + +.field { display: grid; gap: 0.35rem; } +.field span { font-size: 0.87rem; color: var(--muted); } + +.hint { margin: 0.4rem 0 0; font-size: 0.87rem; color: var(--muted); } + +#browser-login button { width: 100%; padding: 0.7em; } + +.waiting { display: flex; align-items: center; gap: 0.8rem; } +.waiting p { margin: 0; flex: 1; } +.pulse { + width: 10px; height: 10px; border-radius: 50%; + background: var(--safelight); + animation: pulse 1.6s ease-in-out infinite; +} +@keyframes pulse { 50% { opacity: 0.25; } } + +details { border-top: 1px solid var(--line); padding-top: 0.9rem; } +details[open] { display: grid; gap: 0.9rem; } +summary { cursor: pointer; color: var(--muted); } +summary:hover { color: var(--text); } +details button { justify-self: start; } + +.check { display: flex; gap: 0.5rem; align-items: center; color: var(--muted); font-size: 0.93rem; } +.check input { accent-color: var(--safelight); } + +.error { margin: 0; color: var(--danger); } + +/* ------------------------------------------------------------ Grundlayout */ + +.app { height: 100%; display: grid; grid-template-columns: 13rem 1fr; } + +.sidebar { + display: flex; + flex-direction: column; + gap: 1.6rem; + padding: 1.1rem 0.8rem 1rem; + border-right: 1px solid #1a1b1d; + min-width: 0; +} +.brand { font-size: 1.25rem; font-weight: 300; padding: 0 0.5rem; } + +.sources { display: grid; gap: 2px; } +.sources button { + text-align: left; + border: 0; + border-radius: 6px; + padding: 0.5em 0.6em; + color: var(--muted); +} +.sources button[aria-current="true"] { + color: var(--text); + background: rgba(255, 255, 255, 0.07); + box-shadow: inset 3px 0 0 var(--safelight); +} + +.account { margin-top: auto; padding: 0 0.5rem; display: grid; gap: 0.1rem; font-size: 0.87rem; } +.acc-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.acc-server { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.account button { justify-self: start; margin-top: 0.4rem; } + +.main { display: grid; grid-template-rows: auto 1fr; min-width: 0; min-height: 0; } + +.toolbar { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.7rem 1.1rem; + border-bottom: 1px solid #1a1b1d; + min-width: 0; +} +.crumbs { flex: 1; display: flex; align-items: center; flex-wrap: wrap; gap: 0.15rem; min-width: 0; font-size: 1.05rem; } +.crumbs button { border: 0; padding: 0.2em 0.4em; color: var(--muted); } +.crumbs button:last-child { color: var(--text); cursor: default; } +.crumbs button:last-child:hover { background: none; } +.crumbs .sep { color: var(--line); } + +.tools { display: flex; align-items: center; gap: 1rem; } +.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; } +.zoom-dot.small { width: 8px; height: 8px; } +.zoom-dot.big { width: 13px; height: 13px; } + +.content { + overflow-y: auto; + background: var(--table); + padding: 1.1rem 1.1rem 3rem; + outline: none; +} + +/* ------------------------------------------------------------ Kontaktbogen */ + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--thumb), 1fr)); + gap: var(--gap); +} + +.tile { + position: relative; + aspect-ratio: 1; + padding: 0; + border: 0; + border-radius: 2px; + overflow: hidden; + background: var(--tile); + content-visibility: auto; + contain-intrinsic-size: var(--thumb) var(--thumb); +} +.tile:hover { background: var(--tile); } +.tile img { + width: 100%; height: 100%; + object-fit: cover; + display: block; + opacity: 0; + transition: opacity 0.2s ease; +} +.tile img.loaded { opacity: 1; } +.tile:hover img { filter: brightness(1.08); } +.tile:focus-visible { outline-offset: -2px; } + +.tile .badge { + position: absolute; left: 6px; bottom: 6px; + width: 22px; height: 22px; border-radius: 50%; + background: rgba(0, 0, 0, 0.55); + display: grid; place-items: center; + font-size: 10px; +} +.tile.broken::after { + content: attr(data-name); + position: absolute; inset: 0; + display: grid; place-items: center; + padding: 0.6rem; + font-size: 0.8rem; color: var(--muted); + word-break: break-all; text-align: center; +} + +/* Ordner und Alben tragen ihren Namen unter dem Bild */ +.tile.folder, .tile.album { + aspect-ratio: auto; + background: none; + text-align: left; + display: grid; + gap: 0.35rem; + content-visibility: visible; +} +.tile.folder:hover, .tile.album:hover { background: none; } +.cover { + aspect-ratio: 1; + border-radius: 2px; + background: var(--tile); + overflow: hidden; + display: grid; place-items: center; +} +.cover img { width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity 0.2s ease; } +.cover img.loaded { opacity: 1; } +.folder-shape { + width: 42%; aspect-ratio: 5 / 4; + border: 2px solid var(--muted); + border-radius: 3px 6px 4px 4px; + position: relative; + opacity: 0.7; +} +.folder-shape::before { + content: ""; position: absolute; top: -8px; left: -2px; + width: 40%; height: 8px; + border: 2px solid var(--muted); border-bottom: 0; + border-radius: 3px 3px 0 0; +} +.tile:hover .folder-shape { opacity: 1; } +.label { font-size: 0.9rem; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 2px; } +.sublabel { font-size: 0.8rem; color: var(--muted); margin-top: -0.3rem; padding: 0 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.folders { margin-bottom: 1.6rem; row-gap: 0.9rem; } + +/* Zeitleiste: Monate als ruhige, große Überschriften */ +.month { margin: 0 0 2.2rem; } +.month h2 { + margin: 0 0 0.7rem; + font-size: 1.75rem; + font-weight: 300; + letter-spacing: -0.01em; + display: flex; + align-items: baseline; + gap: 0.7rem; +} +.month h2 small { font-size: 0.85rem; color: var(--muted); letter-spacing: 0; } + +.notice { + max-width: 34rem; + margin: 4rem auto; + text-align: center; + color: var(--muted); + display: grid; + gap: 1rem; + justify-items: center; +} +.notice strong { color: var(--text); font-weight: 600; font-size: 1.05rem; } +.notice.error strong { color: var(--danger); } + +.sentinel { height: 1px; } +.loading-more { text-align: center; color: var(--muted); padding: 1.5rem; } + +/* ------------------------------------------------------------ Betrachter */ + +.viewer { + position: fixed; inset: 0; + background: #000; + z-index: 10; +} +.v-top { + display: flex; align-items: center; gap: 1rem; + padding: 0.6rem 1rem 1.4rem; + background: linear-gradient(rgba(0,0,0,0.85), rgba(0,0,0,0)); + position: absolute; top: 0; left: 0; right: 0; z-index: 2; + transition: opacity 0.25s ease; +} +.v-title { flex: 1; min-width: 0; display: flex; gap: 0.9rem; align-items: baseline; } +#v-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.v-meta { color: var(--muted); font-size: 0.87rem; white-space: nowrap; display: flex; gap: 1.1rem; } +.v-actions { display: flex; gap: 0.4rem; } +.v-actions button { border-color: #3a3a3a; font-size: 0.9rem; } +#v-original.active { border-color: var(--safelight); color: var(--safelight); } + +.v-stage { position: absolute; inset: 0; overflow: hidden; } +.v-stage img, .v-stage video { + position: absolute; inset: 0; + width: 100%; height: 100%; + object-fit: contain; + display: block; +} +.v-stage img.lowres { filter: blur(6px); } +.v-msg { position: absolute; bottom: 1.5rem; left: 0; right: 0; text-align: center; color: var(--muted); margin: 0; } + +.v-nav { + position: fixed; top: 50%; transform: translateY(-50%); + width: 3.2rem; height: 5rem; + border: 0; border-radius: 6px; + font-size: 2.4rem; line-height: 1; + color: rgba(255,255,255,0.75); + background: rgba(0,0,0,0.25); + z-index: 3; + transition: opacity 0.25s ease; +} +.v-nav:hover { background: rgba(0,0,0,0.5); color: #fff; } +.v-nav.prev { left: 0.8rem; } +.v-nav.next { right: 0.8rem; } +.v-nav:disabled { opacity: 0 !important; pointer-events: none; } + +.viewer.idle .v-top, .viewer.idle .v-nav { opacity: 0; } +.viewer.idle { cursor: none; } + +@media (max-width: 760px) { + .app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; } + .sidebar { flex-direction: row; align-items: center; padding: 0.6rem; gap: 1rem; border-right: 0; border-bottom: 1px solid #1a1b1d; } + .sources { display: flex; } + .account { margin: 0 0 0 auto; } + .acc-server, .acc-name { display: none; } + .brand { display: none; } + .zoom { display: none; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { transition: none !important; animation: none !important; } +}