From 1c5bf20eb0e39b0b126dd1eb3bf53d3c275e178e Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Tue, 11 Aug 2026 18:49:02 +0200 Subject: [PATCH] feat: Medien im Fenster ansehen, und eine Auffrischung, die nichts wegzieht ANSEHEN OHNE NEUEN TAB. Klick auf eine Kachel oeffnet den Betrachter: Bilder, Videos, Ton und PDF direkt, alles andere mit ehrlichem Hinweis und Herunterladen-Knopf -- ein Betrachter, der bei einer .zip ein schwarzes Feld zeigt, sieht aus wie ein kaputter Betrachter. Pfeiltasten blaettern, Escape schliesst; Adresse kopieren, neuer Tab und Loeschen sitzen in der Kopfzeile. Gezeigt wird IMMER die Vollfassung, nie die Vorschau: 320 Pixel reichen fuer eine Kachel, aber nicht dafuer, ein Bild zu beurteilen -- und genau dazu klickt man darauf. Damit musste die Auswahl umziehen: der Klick auf die Kachel oeffnet jetzt, das Kaestchen oben links waehlt aus. Es erscheint beim Darueberfahren und bleibt sichtbar, solange etwas gewaehlt ist. AUTOMATISCH AUFFRISCHEN, aber nicht stumpf. Ein billiger Fuehler (/api/dash/stand: drei Zahlen, GEMESSEN 6 ms je Abfrage ueber TLS und Proxy) wird alle fuenf Sekunden gefragt; erst wenn er sich aendert, wird richtig nachgeladen. Ein Tab im Hintergrund fragt gar nicht. Die dritte Zahl ist kein Zierrat: nach einem LOESCHEN gehen Anzahl und neuester Zeitstempel auf die alten Werte zurueck -- nur die Ereignis-ID zaehlt weiter. Ohne sie bliebe eine geloeschte Datei in der Galerie stehen. Nachgemessen. UND ES ZIEHT NICHTS WEG: wer etwas ausgewaehlt hat, ein Bild offen hat oder geblaettert ist, bekommt keinen Neuaufbau unter den Haenden, sondern einen Knopf "Neues da -- laden". Eine Liste, die sich beim Klicken selbst neu sortiert, ist der Grund, warum Leute Auto-Refresh abschalten. Der Schalter merkt sich seinen Zustand. Kein SSE, und das mit Absicht: dafuer braeuchte NPM zusaetzlich `proxy_buffering off` -- ein weiterer Handgriff im Proxy, der vergessen werden kann und dann zu einer Verbindung fuehrt, die scheinbar haengt. Co-Authored-By: Claude Opus 5 --- server/src/routes/dash.ts | 24 ++++ server/ui/src/api.ts | 4 + server/ui/src/auffrischen.ts | 101 +++++++++++++ server/ui/src/components/Ansehen.tsx | 206 +++++++++++++++++++++++++++ server/ui/src/pages/Galerie.tsx | 104 ++++++++++++-- server/ui/src/pages/Verlauf.tsx | 38 ++++- 6 files changed, 468 insertions(+), 9 deletions(-) create mode 100644 server/ui/src/auffrischen.ts create mode 100644 server/ui/src/components/Ansehen.tsx diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index 29638e4..a40077a 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -669,6 +669,30 @@ dashRoutes.post('/settings', async (c) => { }) }) +/** Der Fuehler fuer die automatische Auffrischung. + * + * Drei Zahlen, drei Indexzugriffe -- billig genug, um ihn alle paar Sekunden + * zu fragen. Die Oberflaeche laedt erst dann RICHTIG nach, wenn sich hier + * etwas geaendert hat. + * + * Warum nicht Server-Sent Events: die waeren schoener, brauchen hinter NPM + * aber zusaetzlich `proxy_buffering off` -- also einen weiteren Handgriff im + * Proxy, der vergessen werden kann und dann zu einer Verbindung fuehrt, die + * scheinbar haengt. Ein Zaehler, den man abfragt, hat diese Fussangel nicht. + * + * `neuest` ist noetig NEBEN `medien`: wird eine Datei ersetzt, bleibt die + * Anzahl gleich und nur der Zeitstempel wandert. Ohne ihn saehe man das neue + * Bild nicht. */ +dashRoutes.get('/stand', (c) => { + const m = db + .prepare('SELECT COUNT(*) AS n, COALESCE(MAX(updated_at), 0) AS neuest FROM media') + .get() as { n: number; neuest: number } + const e = db.prepare('SELECT COALESCE(MAX(id), 0) AS letzte FROM events').get() as { + letzte: number + } + return c.json({ medien: m.n, neuest: m.neuest, ereignis: e.letzte }) +}) + dashRoutes.get('/stats', (c) => { const media = db .prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes FROM media') diff --git a/server/ui/src/api.ts b/server/ui/src/api.ts index 66569c2..32490e4 100644 --- a/server/ui/src/api.ts +++ b/server/ui/src/api.ts @@ -147,6 +147,9 @@ export type Folders = { wurzel: { files: number; bytes: number } } +/** Der billige Fuehler fuer die automatische Auffrischung. */ +export type Stand = { medien: number; neuest: number; ereignis: number } + export type Einstellungen = { discord: { clientId: string @@ -249,6 +252,7 @@ export const api = { einstellungenSpeichern: (discord: Record) => post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { discord }), + stand: () => get('/api/dash/stand'), stats: () => get('/api/dash/stats'), thumbsNachziehen: () => post('/api/dash/maintenance/thumbs'), diff --git a/server/ui/src/auffrischen.ts b/server/ui/src/auffrischen.ts new file mode 100644 index 0000000..db04189 --- /dev/null +++ b/server/ui/src/auffrischen.ts @@ -0,0 +1,101 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { api, type Stand } from './api' + +// Automatisch auffrischen — aber nicht, indem man alle fünf Sekunden alles neu +// lädt. +// +// Gefragt wird ein billiger Fühler (/api/dash/stand: drei Zahlen). Erst wenn +// der sich ändert, gibt es überhaupt etwas zu tun. Das hält den Verkehr klein +// genug, dass ein offener Tab neben einem Serienlauf niemanden stört. +// +// UND ES ZIEHT NICHTS WEG. Wer gerade etwas ausgewählt hat oder ein Bild offen +// hat, bekommt keinen Neuaufbau unter den Händen, sondern einen Hinweis mit +// Knopf. Eine Liste, die sich beim Klicken selbst neu sortiert, ist der Grund, +// warum Leute Auto-Refresh abschalten. + +const SPEICHER_SCHLUESSEL = 'd4rk_media.auffrischen' +const TAKT_MS = 5000 + +const gleich = (a: Stand | null, b: Stand | null) => + a !== null && + b !== null && + a.medien === b.medien && + a.neuest === b.neuest && + a.ereignis === b.ereignis + +export function useAuffrischen({ + laden, + blockiert, +}: { + /** Was zu tun ist, wenn sich etwas geändert hat. */ + laden: () => void + /** Solange true, wird nicht von selbst nachgeladen — es gibt nur den + * Hinweis. Etwa: eine Auswahl steht, oder ein Bild ist offen. */ + blockiert: boolean +}) { + const [an, setAn] = useState(() => localStorage.getItem(SPEICHER_SCHLUESSEL) !== 'aus') + const [wartet, setWartet] = useState(false) + const bekannt = useRef(null) + const blockiertRef = useRef(blockiert) + blockiertRef.current = blockiert + + useEffect(() => { + localStorage.setItem(SPEICHER_SCHLUESSEL, an ? 'an' : 'aus') + }, [an]) + + /** Nach eigenem Zutun (Löschen, Hochladen) gleichzuziehen — sonst meldet + * der Fühler die eigene Änderung als „neu". */ + const merken = useCallback(async () => { + try { + bekannt.current = await api.stand() + setWartet(false) + } catch { + /* beim nächsten Takt wieder */ + } + }, []) + + const jetztLaden = useCallback(() => { + setWartet(false) + laden() + void merken() + }, [laden, merken]) + + useEffect(() => { + if (!an) return + let lebt = true + + const takt = async () => { + // Ein Tab im Hintergrund braucht nichts zu wissen. Das spart bei + // einem Bildschirm voller offener Tabs mehr, als es kostet. + if (document.visibilityState === 'hidden') return + try { + const stand = await api.stand() + if (!lebt) return + if (bekannt.current === null) { + bekannt.current = stand + return + } + if (gleich(bekannt.current, stand)) return + + if (blockiertRef.current) { + setWartet(true) + } else { + bekannt.current = stand + laden() + } + } catch { + // Ein Aussetzer im Netz ist kein Grund für eine Fehlermeldung + // alle fünf Sekunden. Beim nächsten Takt noch einmal. + } + } + + void takt() + const uhr = setInterval(takt, TAKT_MS) + return () => { + lebt = false + clearInterval(uhr) + } + }, [an, laden]) + + return { an, setAn, wartet, jetztLaden, merken } +} diff --git a/server/ui/src/components/Ansehen.tsx b/server/ui/src/components/Ansehen.tsx new file mode 100644 index 0000000..be90584 --- /dev/null +++ b/server/ui/src/components/Ansehen.tsx @@ -0,0 +1,206 @@ +import { useEffect } from 'react' +import { + Check, + ChevronLeft, + ChevronRight, + Copy, + Download, + ExternalLink, + FileQuestion, + Trash2, + X, +} from 'lucide-react' +import type { MediaItem } from '../api' +import { useStore } from '../store' +import { bytes, zeit } from '../format' +import { useKopieren } from './Bausteine' + +/** Eine Datei ansehen, ohne die Seite zu verlassen. + * + * Bilder, Videos, Ton und PDF werden HIER gezeigt; alles andere bekommt einen + * ehrlichen Hinweis samt Herunterladen-Knopf. Ein Betrachter, der bei einer + * .zip ein leeres schwarzes Feld zeigt, sieht aus wie ein kaputter + * Betrachter. + * + * Gezeigt wird IMMER die Vollfassung und nie die Vorschau: 320 Pixel breit + * reichen für eine Kachel, aber nicht dafür, ein Bild zu beurteilen — und + * genau dazu klickt man darauf. */ +export function Ansehen({ + item, + hatVorher, + hatNachher, + onVorher, + onNachher, + onSchliessen, + onLoeschen, +}: { + item: MediaItem + hatVorher: boolean + hatNachher: boolean + onVorher: () => void + onNachher: () => void + onSchliessen: () => void + onLoeschen: () => void +}) { + const note = useStore((s) => s.note) + const { kopiert, kopieren } = useKopieren() + + // Tastatur: Escape zu, Pfeile weiter. Wer 900 Bilder durchsieht, will + // nicht 900 Mal zielen. + useEffect(() => { + const taste = (e: KeyboardEvent) => { + if (e.key === 'Escape') onSchliessen() + else if (e.key === 'ArrowLeft' && hatVorher) onVorher() + else if (e.key === 'ArrowRight' && hatNachher) onNachher() + } + window.addEventListener('keydown', taste) + return () => window.removeEventListener('keydown', taste) + }, [hatVorher, hatNachher, onVorher, onNachher, onSchliessen]) + + const art = item.mime.split('/')[0] + + return ( +
+ {/* Kopfzeile */} +
e.stopPropagation()} + > +
+

+ {item.path} +

+

+ {bytes(item.size)} · {item.mime} · {zeit(item.updated_at)} +

+
+ + + + + + + + + + +
+ + {/* Der Inhalt */} +
+ {hatVorher && ( + + )} + {hatNachher && } + +
e.stopPropagation()} + > + {art === 'image' ? ( + {item.path} + ) : art === 'video' ? ( +