feat: das Dashboard -- und die eine Regel steht im Code, nicht im Vorsatz
Schritt 4. React 18, TypeScript, Vite 5, Tailwind 4 (CSS-first), Lucide, Zustand; kein Konsta. Quelle in server/ui, Build nach server/web (gitignored), im Abbild von einer eigenen Docker-Stufe gebaut. JEDER KNOPF GIBT RUECKMELDUNG -- und zwar erzwungen, nicht vorgenommen: - api.ts haelt den EINZIGEN fetch und GENAU EINE Fehlerklasse. Der Fivemanage-Fehler war, zwei zu haben: geworfen wurde `new Error`, geprueft auf `instanceof ApiError`, und damit verschwand jeder Fehlschlag lautlos. - store.ts haelt run(): Aktion rein, Erfolgstext rein, und es meldet bei Erfolg diesen und bei Fehlschlag den Text DES DIENSTES. Wer darueber geht, kann keinen stillen Knopf bauen. - Jeder wartende Knopf ist gesperrt und zeigt einen Kreisel. Der zweite Klick eine Sekunde spaeter war der Weg zu neunzehn gleichnamigen Organisationen. - Fehlermeldungen bleiben stehen, bis jemand sie wegklickt. Ein Fehler, der von allein verschwindet, ist einer, den niemand gelesen hat. @vitejs/plugin-react ist auf ^4 festgenagelt: die 6 verlangt vite ^8. In den peerDependencies nachgesehen -- Vite 5.4.21 liegt in der Schnittmenge von plugin-react@4 und @tailwindcss/vite@4. Am laufenden Container nachgemessen: / kommt als text/html, assets/*.js als text/javascript, *.css als text/css (die MIME-Falle aus Schritt 1 traegt), und /tokens, /speicher, /konto liefern dieselbe index.html -- die SPA-Rueckfall- route tut, wofuer sie gebaut wurde. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FileQuestion,
|
||||
ImageOff,
|
||||
Loader2,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { api, type MediaItem, type MediaPage } from '../api'
|
||||
import { run, useStore } from '../store'
|
||||
import { bytes, istBild, zeit } from '../format'
|
||||
import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
|
||||
|
||||
const PRO_SEITE = 60
|
||||
|
||||
export function Galerie() {
|
||||
const note = useStore((s) => s.note)
|
||||
const [suche, setSuche] = useState('')
|
||||
const [seite, setSeite] = useState(0)
|
||||
const [daten, setDaten] = useState<MediaPage | null>(null)
|
||||
const [laedt, setLaedt] = useState(true)
|
||||
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
|
||||
const [loeschen, setLoeschen] = useState<MediaItem[] | null>(null)
|
||||
const [loescht, setLoescht] = useState(false)
|
||||
const { kopiert, kopieren } = useKopieren()
|
||||
|
||||
// Die Suche wartet einen Moment ab. Ohne das schickt jeder Tastendruck
|
||||
// eine Anfrage, und bei "adder" sind das fünf, von denen vier veraltet
|
||||
// ankommen können.
|
||||
const [sucheAktiv, setSucheAktiv] = useState('')
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setSucheAktiv(suche)
|
||||
setSeite(0)
|
||||
}, 250)
|
||||
return () => clearTimeout(t)
|
||||
}, [suche])
|
||||
|
||||
// Welche Anfrage die neueste ist. Eine ältere, die später ankommt, darf
|
||||
// die neuere nicht überschreiben — sonst steht nach schnellem Tippen das
|
||||
// Ergebnis von vorletzter Eingabe auf dem Schirm.
|
||||
const lauf = useRef(0)
|
||||
|
||||
const laden = useCallback(async () => {
|
||||
const meine = ++lauf.current
|
||||
setLaedt(true)
|
||||
try {
|
||||
const seiteDaten = await api.media(sucheAktiv, PRO_SEITE, seite * PRO_SEITE)
|
||||
if (meine !== lauf.current) return
|
||||
setDaten(seiteDaten)
|
||||
} catch (err) {
|
||||
if (meine !== lauf.current) return
|
||||
note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
if (meine === lauf.current) setLaedt(false)
|
||||
}
|
||||
}, [sucheAktiv, seite, note])
|
||||
|
||||
useEffect(() => {
|
||||
void laden()
|
||||
}, [laden])
|
||||
|
||||
const items = daten?.items ?? []
|
||||
const gesamt = daten?.total ?? 0
|
||||
const seiten = Math.max(1, Math.ceil(gesamt / PRO_SEITE))
|
||||
|
||||
function umschalten(id: number) {
|
||||
setGewaehlt((alt) => {
|
||||
const neu = new Set(alt)
|
||||
if (neu.has(id)) neu.delete(id)
|
||||
else neu.add(id)
|
||||
return neu
|
||||
})
|
||||
}
|
||||
|
||||
const alleGewaehlt = items.length > 0 && items.every((i) => gewaehlt.has(i.id))
|
||||
|
||||
function alleUmschalten() {
|
||||
setGewaehlt((alt) => {
|
||||
const neu = new Set(alt)
|
||||
if (alleGewaehlt) items.forEach((i) => neu.delete(i.id))
|
||||
else items.forEach((i) => neu.add(i.id))
|
||||
return neu
|
||||
})
|
||||
}
|
||||
|
||||
async function loeschenAusfuehren() {
|
||||
if (!loeschen) return
|
||||
setLoescht(true)
|
||||
|
||||
const ergebnis =
|
||||
loeschen.length === 1
|
||||
? await run(
|
||||
() => api.deleteMedia(loeschen[0]!.id),
|
||||
(r) => `${r.deleted} gelöscht.`,
|
||||
)
|
||||
: await run(
|
||||
() => api.deleteMediaBulk(loeschen.map((i) => i.id)),
|
||||
(r) =>
|
||||
r.count === loeschen.length
|
||||
? `${r.count} Dateien gelöscht.`
|
||||
: // Der Dienst überspringt, was er nicht findet.
|
||||
// Diese Lücke zu verschweigen wäre genau die
|
||||
// Sorte Rückmeldung, die hier nicht vorkommt.
|
||||
`${r.count} von ${loeschen.length} gelöscht — der Rest war schon weg.`,
|
||||
)
|
||||
|
||||
setLoescht(false)
|
||||
setLoeschen(null)
|
||||
if (ergebnis) {
|
||||
setGewaehlt(new Set())
|
||||
void laden()
|
||||
}
|
||||
}
|
||||
|
||||
async function adresseKopieren(item: MediaItem) {
|
||||
const ok = await kopieren(item.url, String(item.id))
|
||||
if (!ok) note('fehler', 'Die Zwischenablage ist gesperrt — nur über https erreichbar.')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Kopfzeile: Suche, Auswahl, Löschen */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative min-w-56 flex-1">
|
||||
<Search
|
||||
size={15}
|
||||
className="absolute top-1/2 left-3 -translate-y-1/2 text-schrift-leise"
|
||||
/>
|
||||
<input
|
||||
value={suche}
|
||||
onChange={(e) => setSuche(e.target.value)}
|
||||
placeholder="Pfad durchsuchen, z. B. vehicles/"
|
||||
className="w-full rounded-md border border-rand bg-flaeche py-1.5 pr-8 pl-9
|
||||
text-sm placeholder:text-schrift-leise/60 focus:border-akzent
|
||||
focus:outline-none"
|
||||
/>
|
||||
{suche && (
|
||||
<button
|
||||
onClick={() => setSuche('')}
|
||||
className="absolute top-1/2 right-2 -translate-y-1/2 text-schrift-leise hover:text-schrift"
|
||||
aria-label="Suche leeren"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-schrift-leise">
|
||||
{laedt ? (
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{gesamt} {gesamt === 1 ? 'Datei' : 'Dateien'}
|
||||
{sucheAktiv && ' gefunden'}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{items.length > 0 && (
|
||||
<Knopf
|
||||
onClick={alleUmschalten}
|
||||
kind={alleGewaehlt ? 'Auswahl aufheben' : 'Seite wählen'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{gewaehlt.size > 0 && (
|
||||
<Knopf
|
||||
art="gefahr"
|
||||
onClick={() => setLoeschen(items.filter((i) => gewaehlt.has(i.id)))}
|
||||
kind={
|
||||
<>
|
||||
<Trash2 size={15} />
|
||||
{gewaehlt.size} löschen
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Das Raster */}
|
||||
{items.length === 0 && !laedt ? (
|
||||
<Leer
|
||||
kind={<ImageOff size={28} className="text-schrift-leise/50" />}
|
||||
text={
|
||||
sucheAktiv
|
||||
? `Nichts gefunden für "${sucheAktiv}".`
|
||||
: 'Noch keine Datei hochgeladen.'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3">
|
||||
{items.map((item) => {
|
||||
const an = gewaehlt.has(item.id)
|
||||
return (
|
||||
<figure
|
||||
key={item.id}
|
||||
className={`group relative overflow-hidden rounded-lg border bg-flaeche
|
||||
transition-colors ${an ? 'border-akzent' : 'border-rand'}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => umschalten(item.id)}
|
||||
className="block w-full"
|
||||
aria-pressed={an}
|
||||
aria-label={`${item.path} auswählen`}
|
||||
>
|
||||
<div className="flex aspect-square items-center justify-center bg-grund">
|
||||
{istBild(item.mime) ? (
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.path}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<FileQuestion
|
||||
size={28}
|
||||
className="text-schrift-leise/50"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{an && (
|
||||
<span className="pointer-events-none absolute top-2 left-2 rounded bg-akzent p-1 text-grund">
|
||||
<Check size={12} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<figcaption className="border-t border-rand p-2">
|
||||
<p
|
||||
className="truncate font-mono text-xs text-schrift"
|
||||
title={item.path}
|
||||
>
|
||||
{item.path}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] text-schrift-leise">
|
||||
{bytes(item.size)} · {zeit(item.updated_at)}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex gap-1">
|
||||
<button
|
||||
onClick={() => void adresseKopieren(item)}
|
||||
className="flex flex-1 items-center justify-center gap-1 rounded border
|
||||
border-rand py-1 text-[11px] text-schrift-leise
|
||||
hover:bg-flaeche-hoch hover:text-schrift"
|
||||
>
|
||||
{kopiert === String(item.id) ? (
|
||||
<>
|
||||
<Check size={11} className="text-gut" />
|
||||
kopiert
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={11} />
|
||||
Adresse
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded border border-rand px-2 py-1 text-schrift-leise
|
||||
hover:bg-flaeche-hoch hover:text-schrift"
|
||||
aria-label="In neuem Tab öffnen"
|
||||
>
|
||||
<ExternalLink size={11} />
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setLoeschen([item])}
|
||||
className="rounded border border-rand px-2 py-1 text-schrift-leise
|
||||
hover:bg-schlecht/15 hover:text-schlecht"
|
||||
aria-label={`${item.path} löschen`}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Blättern */}
|
||||
{seiten > 1 && (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Knopf
|
||||
disabled={seite === 0}
|
||||
onClick={() => setSeite((s) => s - 1)}
|
||||
kind={<ChevronLeft size={15} />}
|
||||
/>
|
||||
<span className="text-sm text-schrift-leise">
|
||||
Seite {seite + 1} von {seiten}
|
||||
</span>
|
||||
<Knopf
|
||||
disabled={seite + 1 >= seiten}
|
||||
onClick={() => setSeite((s) => s + 1)}
|
||||
kind={<ChevronRight size={15} />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Nachfrage
|
||||
offen={loeschen !== null}
|
||||
titel={loeschen?.length === 1 ? 'Datei löschen?' : `${loeschen?.length} Dateien löschen?`}
|
||||
bestaetigung="Endgültig löschen"
|
||||
wartet={loescht}
|
||||
onNein={() => setLoeschen(null)}
|
||||
onJa={() => void loeschenAusfuehren()}
|
||||
text={
|
||||
<>
|
||||
<p className="mb-2">
|
||||
Die Datei wird von der Platte entfernt. Es gibt keine Sicherung, aus
|
||||
der sie zurückkommt.
|
||||
</p>
|
||||
<ul className="max-h-40 overflow-auto rounded border border-rand bg-grund p-2 font-mono text-xs">
|
||||
{loeschen?.slice(0, 12).map((i) => <li key={i.id}>{i.path}</li>)}
|
||||
{loeschen && loeschen.length > 12 && (
|
||||
<li className="text-schrift-leise">
|
||||
… und {loeschen.length - 12} weitere
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user