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 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 18:49:02 +02:00
co-authored by Claude Opus 5
parent dffec7714c
commit 1c5bf20eb0
6 changed files with 468 additions and 9 deletions
+24
View File
@@ -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')
+4
View File
@@ -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<string, string>) =>
post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { discord }),
stand: () => get<Stand>('/api/dash/stand'),
stats: () => get<Stats>('/api/dash/stats'),
thumbsNachziehen: () => post<ThumbLauf>('/api/dash/maintenance/thumbs'),
+101
View File
@@ -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<Stand | null>(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 }
}
+206
View File
@@ -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 (
<div
className="fixed inset-0 z-50 flex flex-col bg-black/85 backdrop-blur-sm"
onClick={onSchliessen}
>
{/* Kopfzeile */}
<header
className="flex shrink-0 items-center gap-3 border-b border-white/10 px-4 py-2.5"
onClick={(e) => e.stopPropagation()}
>
<div className="min-w-0 flex-1">
<p className="truncate font-mono text-sm text-schrift" title={item.path}>
{item.path}
</p>
<p className="text-xs text-schrift-leise">
{bytes(item.size)} · {item.mime} · {zeit(item.updated_at)}
</p>
</div>
<button
onClick={async () => {
if (!(await kopieren(item.url, 'url')))
note('fehler', 'Die Zwischenablage ist gesperrt.')
}}
className="flex items-center gap-1.5 rounded border border-white/15 px-2.5 py-1.5
text-xs text-schrift-leise hover:bg-white/10 hover:text-schrift"
>
{kopiert === 'url' ? (
<>
<Check size={13} className="text-gut" /> kopiert
</>
) : (
<>
<Copy size={13} /> Adresse
</>
)}
</button>
<a
href={item.url}
target="_blank"
rel="noreferrer"
className="rounded border border-white/15 px-2.5 py-1.5 text-schrift-leise
hover:bg-white/10 hover:text-schrift"
aria-label="In neuem Tab öffnen"
>
<ExternalLink size={13} />
</a>
<button
onClick={onLoeschen}
className="rounded border border-white/15 px-2.5 py-1.5 text-schrift-leise
hover:bg-schlecht/20 hover:text-schlecht"
aria-label="Löschen"
>
<Trash2 size={13} />
</button>
<button
onClick={onSchliessen}
className="rounded border border-white/15 px-2.5 py-1.5 text-schrift-leise
hover:bg-white/10 hover:text-schrift"
aria-label="Schließen (Esc)"
>
<X size={14} />
</button>
</header>
{/* Der Inhalt */}
<div className="relative flex min-h-0 flex-1 items-center justify-center p-4">
{hatVorher && (
<Pfeil links onClick={onVorher} />
)}
{hatNachher && <Pfeil onClick={onNachher} />}
<div
className="flex max-h-full max-w-full items-center justify-center"
onClick={(e) => e.stopPropagation()}
>
{art === 'image' ? (
<img
src={item.url}
alt={item.path}
className="max-h-[calc(100vh-8rem)] max-w-full object-contain"
/>
) : art === 'video' ? (
<video
src={item.url}
controls
autoPlay
className="max-h-[calc(100vh-8rem)] max-w-full"
/>
) : art === 'audio' ? (
<div className="w-full max-w-lg rounded-lg border border-white/15 bg-flaeche p-6">
<audio src={item.url} controls className="w-full" />
</div>
) : item.mime === 'application/pdf' ? (
<iframe
src={item.url}
title={item.path}
className="h-[calc(100vh-8rem)] w-full max-w-4xl rounded border border-white/15 bg-white"
/>
) : (
// Der ehrliche Fall: wir können es nicht zeigen, und
// sagen das, statt ein leeres Feld hinzustellen.
<div className="flex flex-col items-center gap-3 rounded-lg border border-white/15 bg-flaeche px-10 py-8 text-center">
<FileQuestion size={32} className="text-schrift-leise" />
<p className="text-sm text-schrift-leise">
Diese Art lässt sich hier nicht anzeigen
<br />
<code className="font-mono text-xs">{item.mime}</code>
</p>
<a
href={item.url}
download
className="flex items-center gap-2 rounded-md border border-rand px-3 py-1.5
text-sm text-schrift hover:bg-flaeche-hoch"
>
<Download size={15} />
Herunterladen
</a>
</div>
)}
</div>
</div>
</div>
)
}
function Pfeil({ links, onClick }: { links?: boolean; onClick: () => void }) {
return (
<button
onClick={(e) => {
e.stopPropagation()
onClick()
}}
className={`absolute top-1/2 z-10 -translate-y-1/2 rounded-full border border-white/15
bg-black/50 p-2 text-schrift-leise hover:bg-black/80 hover:text-schrift
${links ? 'left-4' : 'right-4'}`}
aria-label={links ? 'Vorheriges' : 'Nächstes'}
>
{links ? <ChevronLeft size={20} /> : <ChevronRight size={20} />}
</button>
)
}
+95 -7
View File
@@ -8,6 +8,7 @@ import {
FileQuestion,
ImageOff,
Loader2,
RefreshCw,
Search,
Trash2,
X,
@@ -18,6 +19,8 @@ import { bytes, istBild, zeit } from '../format'
import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
import { Ordner } from '../components/Ordner'
import { Hochladen } from '../components/Hochladen'
import { Ansehen } from '../components/Ansehen'
import { useAuffrischen } from '../auffrischen'
const PRO_SEITE = 60
@@ -32,6 +35,9 @@ export function Galerie() {
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
const [loeschen, setLoeschen] = useState<MediaItem[] | null>(null)
const [loescht, setLoescht] = useState(false)
// Welche Datei gerade gross zu sehen ist -- als ID und nicht als Objekt:
// nach einem Neuladen der Liste zeigt sie sonst auf einen alten Datensatz.
const [ansehen, setAnsehen] = useState<number | null>(null)
const { kopiert, kopieren } = useKopieren()
// Die Suche wartet einen Moment ab. Ohne das schickt jeder Tastendruck
@@ -86,6 +92,18 @@ export function Galerie() {
void ordnerLaden()
}, [ordnerLaden])
const allesLaden = useCallback(() => {
void laden()
void ordnerLaden()
}, [laden, ordnerLaden])
// Blockiert, solange eine Auswahl steht oder ein Bild offen ist: dann gibt
// es nur den Hinweis und keinen Neuaufbau unter den Haenden.
const frisch = useAuffrischen({
laden: allesLaden,
blockiert: gewaehlt.size > 0 || ansehen !== null || loeschen !== null,
})
const items = daten?.items ?? []
const gesamt = daten?.total ?? 0
const seiten = Math.max(1, Math.ceil(gesamt / PRO_SEITE))
@@ -137,6 +155,7 @@ export function Galerie() {
setGewaehlt(new Set())
void laden()
void ordnerLaden()
void frisch.merken()
}
}
@@ -195,6 +214,32 @@ export function Galerie() {
)}
</span>
{frisch.wartet && (
<Knopf
art="haupt"
onClick={frisch.jetztLaden}
kind={
<>
<RefreshCw size={14} />
Neues da laden
</>
}
/>
)}
<label
className="flex cursor-pointer items-center gap-1.5 text-xs text-schrift-leise"
title="Alle fünf Sekunden nachsehen, ob sich etwas geändert hat"
>
<input
type="checkbox"
checked={frisch.an}
onChange={(e) => frisch.setAn(e.target.checked)}
className="accent-akzent"
/>
automatisch
</label>
{items.length > 0 && (
<Knopf
onClick={alleUmschalten}
@@ -221,6 +266,7 @@ export function Galerie() {
fertig={() => {
void laden()
void ordnerLaden()
void frisch.merken()
}}
/>
@@ -246,11 +292,15 @@ export function Galerie() {
className={`group relative overflow-hidden rounded-lg border bg-flaeche
transition-colors ${an ? 'border-akzent' : 'border-rand'}`}
>
{/* Klick auf die Kachel ÖFFNET, Klick auf das
Kästchen WÄHLT AUS. Vorher tat der Klick
beides nicht gleichzeitig — er wählte nur
aus, und zum Ansehen musste man einen
neuen Tab aufmachen. */}
<button
onClick={() => umschalten(item.id)}
onClick={() => setAnsehen(item.id)}
className="block w-full"
aria-pressed={an}
aria-label={`${item.path} auswählen`}
aria-label={`${item.path} ansehen`}
>
<div className="flex aspect-square items-center justify-center bg-grund">
{istBild(item.mime) ? (
@@ -282,11 +332,25 @@ export function Galerie() {
</div>
</button>
{an && (
<span className="pointer-events-none absolute top-2 left-2 rounded bg-akzent p-1 text-grund">
{/* Immer sichtbar, wenn gewählt; sonst erst
beim Darüberfahren. Ein Kästchen, das man
erst suchen muss, ist keines — eines, das
bei 60 Kacheln immer leuchtet, ist Lärm. */}
<button
onClick={(e) => {
e.stopPropagation()
umschalten(item.id)
}}
aria-pressed={an}
aria-label={`${item.path} auswählen`}
className={`absolute top-2 left-2 rounded border p-1 transition-opacity ${
an
? 'border-akzent bg-akzent text-grund opacity-100'
: 'border-rand bg-grund/80 text-schrift-leise opacity-0 group-hover:opacity-100 hover:text-schrift focus-visible:opacity-100'
}`}
>
<Check size={12} />
</span>
)}
</button>
<figcaption className="border-t border-rand p-2">
<p
@@ -365,6 +429,30 @@ export function Galerie() {
</div>
{(() => {
if (ansehen === null) return null
const i = items.findIndex((x) => x.id === ansehen)
// Die Datei kann zwischenzeitlich verschwunden sein -- etwa
// weil daneben geloescht wurde. Dann schliesst sich der
// Betrachter, statt auf ein Loch zu zeigen.
if (i === -1) return null
const item = items[i]!
return (
<Ansehen
item={item}
hatVorher={i > 0}
hatNachher={i < items.length - 1}
onVorher={() => setAnsehen(items[i - 1]!.id)}
onNachher={() => setAnsehen(items[i + 1]!.id)}
onSchliessen={() => setAnsehen(null)}
onLoeschen={() => {
setAnsehen(null)
setLoeschen([item])
}}
/>
)
})()}
<Nachfrage
offen={loeschen !== null}
titel={loeschen?.length === 1 ? 'Datei löschen?' : `${loeschen?.length} Dateien löschen?`}
+37 -1
View File
@@ -16,6 +16,7 @@ import { api, type EventKind, type EventsSeite } from '../api'
import { useStore } from '../store'
import { bytes, zeit } from '../format'
import { Karte, Knopf, Leer } from '../components/Bausteine'
import { useAuffrischen } from '../auffrischen'
const SEITENGROESSEN = [25, 50, 100, 200] as const
@@ -61,6 +62,12 @@ export function Verlauf() {
void laden()
}, [laden])
// Im Verlauf ist das Mitlesen der eigentliche Zweck: waehrend ein
// Serienlauf laeuft, sieht man hier zu. Blockiert wird nur, wenn man
// geblaettert hat -- eine Seite drei, die unter den Haenden zu Seite eins
// wird, ist aergerlich.
const frisch = useAuffrischen({ laden: () => void laden(), blockiert: seite > 0 })
const items = daten?.items ?? []
const seiten = Math.max(1, Math.ceil((daten?.total ?? 0) / proSeite))
@@ -96,7 +103,36 @@ export function Verlauf() {
/>
))}
<span className="ml-auto text-sm text-schrift-leise">
{frisch.wartet && (
<Knopf
art="haupt"
className="ml-auto"
onClick={frisch.jetztLaden}
kind={
<>
<RefreshCw size={14} />
Neues da laden
</>
}
/>
)}
<label
className={`flex cursor-pointer items-center gap-1.5 text-xs text-schrift-leise ${
frisch.wartet ? '' : 'ml-auto'
}`}
title="Alle fünf Sekunden nachsehen, ob etwas dazugekommen ist"
>
<input
type="checkbox"
checked={frisch.an}
onChange={(e) => frisch.setAn(e.target.checked)}
className="accent-akzent"
/>
automatisch
</label>
<span className="text-sm text-schrift-leise">
{laedt ? (
<Loader2 size={15} className="animate-spin" />
) : (