Initial
This commit is contained in:
@@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -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 = `
|
||||||
|
<d:getlastmodified/><d:getcontenttype/><d:resourcetype/><d:getcontentlength/><d:displayname/>
|
||||||
|
<oc:fileid/><nc:has-preview/><nc:metadata-photos-original_date_time/>`;
|
||||||
|
|
||||||
|
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 = `<?xml version="1.0" encoding="UTF-8"?><d:propfind ${NS}><d:prop>${propsXml}</d:prop></d:propfind>`;
|
||||||
|
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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<d:searchrequest ${NS} xmlns:ns="https://github.com/icewind1991/SearchDAV/ns">
|
||||||
|
<d:basicsearch>
|
||||||
|
<d:select><d:prop>${FILE_PROPS}</d:prop></d:select>
|
||||||
|
<d:from><d:scope><d:href>/files/${xmlEscape(conn.userId)}</d:href><d:depth>infinity</d:depth></d:scope></d:from>
|
||||||
|
<d:where>
|
||||||
|
<d:or>
|
||||||
|
<d:like><d:prop><d:getcontenttype/></d:prop><d:literal>image/%</d:literal></d:like>
|
||||||
|
<d:like><d:prop><d:getcontenttype/></d:prop><d:literal>video/%</d:literal></d:like>
|
||||||
|
</d:or>
|
||||||
|
</d:where>
|
||||||
|
<d:orderby><d:order><d:prop><d:getlastmodified/></d:prop><d:descending/></d:order></d:orderby>
|
||||||
|
<d:limit><d:nresults>${PAGE_SIZE}</d:nresults><ns:firstresult>${Number(offset) || 0}</ns:firstresult></d:limit>
|
||||||
|
</d:basicsearch>
|
||||||
|
</d:searchrequest>`;
|
||||||
|
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 = `<nc:last-photo/><nc:nbItems/><nc:location/><nc:dateRange/><d:displayname/><d:resourcetype/>`;
|
||||||
|
|
||||||
|
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());
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
+22
@@ -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'),
|
||||||
|
});
|
||||||
+612
@@ -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();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self'; img-src 'self' nc: data:; media-src nc:; style-src 'self'; script-src 'self'; connect-src 'none'">
|
||||||
|
<title>Lichttisch</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Anmeldung -->
|
||||||
|
<section id="login" class="login" hidden>
|
||||||
|
<div class="login-panel">
|
||||||
|
<h1 class="wordmark">Lichttisch</h1>
|
||||||
|
<p class="lede">Deine Fotos aus der Nextcloud ansehen, ohne sie herunterzuladen.</p>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Adresse deiner Nextcloud</span>
|
||||||
|
<input id="server" type="url" placeholder="cloud.example.de" autocomplete="url" spellcheck="false">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="browser-login">
|
||||||
|
<button id="btn-browser" class="primary">Im Browser anmelden</button>
|
||||||
|
<p class="hint">Es öffnet sich deine Nextcloud im Browser. Nach der Bestätigung dort bist du hier automatisch angemeldet.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="waiting" class="waiting" hidden>
|
||||||
|
<span class="pulse" aria-hidden="true"></span>
|
||||||
|
<p>Bestätige die Anmeldung im Browserfenster.</p>
|
||||||
|
<button id="btn-cancel" class="quiet">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details id="manual">
|
||||||
|
<summary>Mit Benutzername und App-Passwort anmelden</summary>
|
||||||
|
<label class="field">
|
||||||
|
<span>Benutzername</span>
|
||||||
|
<input id="user" type="text" autocomplete="username" spellcheck="false">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>App-Passwort</span>
|
||||||
|
<input id="pass" type="password" autocomplete="current-password">
|
||||||
|
</label>
|
||||||
|
<p class="hint">Ein App-Passwort erstellst du in der Nextcloud unter Persönliche Einstellungen › Sicherheit.</p>
|
||||||
|
<button id="btn-password">Anmelden</button>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<label class="check">
|
||||||
|
<input id="remember" type="checkbox" checked>
|
||||||
|
<span>Angemeldet bleiben</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p id="login-error" class="error" role="alert" hidden></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Startbildschirm beim automatischen Verbinden -->
|
||||||
|
<section id="connecting" class="connecting" hidden>
|
||||||
|
<p>Verbinde mit deiner Nextcloud …</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Hauptansicht -->
|
||||||
|
<div id="app" class="app" hidden>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">Lichttisch</div>
|
||||||
|
<nav class="sources" aria-label="Ansicht">
|
||||||
|
<button data-source="timeline">Alle Fotos</button>
|
||||||
|
<button data-source="folder">Ordner</button>
|
||||||
|
<button data-source="albums">Alben</button>
|
||||||
|
</nav>
|
||||||
|
<div class="account">
|
||||||
|
<div id="acc-name" class="acc-name"></div>
|
||||||
|
<div id="acc-server" class="acc-server"></div>
|
||||||
|
<button id="btn-logout" class="quiet">Abmelden</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<header class="toolbar">
|
||||||
|
<nav id="crumbs" class="crumbs" aria-label="Pfad"></nav>
|
||||||
|
<div class="tools">
|
||||||
|
<select id="sort" title="Sortierung">
|
||||||
|
<option value="name">Nach Name</option>
|
||||||
|
<option value="new">Neueste zuerst</option>
|
||||||
|
<option value="old">Älteste zuerst</option>
|
||||||
|
</select>
|
||||||
|
<label class="zoom" title="Vorschaugröße">
|
||||||
|
<span class="zoom-dot small" aria-hidden="true"></span>
|
||||||
|
<input id="zoom" type="range" min="110" max="360" step="10" value="180" aria-label="Vorschaugröße">
|
||||||
|
<span class="zoom-dot big" aria-hidden="true"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div id="content" class="content" tabindex="-1"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bildbetrachter -->
|
||||||
|
<div id="viewer" class="viewer" hidden role="dialog" aria-label="Bildansicht">
|
||||||
|
<div class="v-top">
|
||||||
|
<div class="v-title">
|
||||||
|
<span id="v-name"></span>
|
||||||
|
<span id="v-meta" class="v-meta"></span>
|
||||||
|
</div>
|
||||||
|
<div class="v-actions">
|
||||||
|
<button id="v-original" title="Originaldatei laden (O)">Original</button>
|
||||||
|
<button id="v-open" title="In der Nextcloud öffnen">In Nextcloud</button>
|
||||||
|
<button id="v-full" title="Vollbild (F)">Vollbild</button>
|
||||||
|
<button id="v-close" title="Schließen (Esc)" aria-label="Schließen">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="v-stage">
|
||||||
|
<img id="v-img" alt="">
|
||||||
|
<video id="v-video" controls hidden></video>
|
||||||
|
<p id="v-msg" class="v-msg" hidden></p>
|
||||||
|
</div>
|
||||||
|
<button id="v-prev" class="v-nav prev" aria-label="Vorheriges Bild">‹</button>
|
||||||
|
<button id="v-next" class="v-nav next" aria-label="Nächstes Bild">›</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user