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' ? ( +