Files
d4rk_media/server/ui/src/pages/Start.tsx
T
D4rkst3randClaude Opus 5 67d2f4ec93 feat: verschieben, Ordner, Adressvorlagen, API-Seite mit Export
VERSCHIEBEN. Dateien in einen anderen Ordner legen -- mit einer Warnung, die
OBEN steht und nicht im Kleingedruckten: der Pfad IST die oeffentliche Adresse,
nach dem Verschieben fuehrt die alte URL ins Leere. Das ist kein Fehler,
sondern der Preis vorhersagbarer Adressen; man muss es nur vorher wissen.

Der Umzug ist ein `rename` und kein Kopieren-dann-Loeschen: auf derselben
Platte ist das unteilbar. Die Vorschau zieht mit, sonst zeigte die Galerie am
neuen Ort nichts und am alten das Bild einer Datei, die dort nicht mehr liegt.
Eine belegte Zieladresse wird NICHT still ueberschrieben -- da verschwaende
sonst eine fremde Datei, und der Verlauf zeigte nur einen Umzug.

Der Verlauf kennt dafuer eine neue Spalte `von` und eine neue Art `move`; ohne
sie stuende dort nur das Ziel und die Frage "wo war die vorher" waere nicht
mehr zu beantworten.

ORDNER anlegen und entfernen. Leere Ordner gibt es sonst nicht -- sie sind der
Teil eines Pfades vor dem letzten Schraegstrich, mehr nicht. Jetzt sind sie ein
echtes Verzeichnis, und /folders liest Tabelle und Platte zusammen. Ein Ordner
MIT Inhalt wird nicht mitgeloescht: das waere ein Knopf, der hunderte Dateien
wegraeumt, ohne sie zu zeigen.

ADRESSVORLAGEN. Der Bauplan einer oeffentlichen Adresse, benannt und
gespeichert (vehicles/{model}.webp). Sie werden auf der Startseite und der
API-Seite mit ausgefuellter Adresse gezeigt. Ohne Platzhalter oder mit ".." im
Muster wird abgelehnt -- beides nachgemessen.

API-SEITE. Anleitung fuer die eigenen Skripte, und der Punkt daran: die
Beispiele stehen MIT DER ECHTEN ADRESSE drin statt mit example.com. Wer
abschreibt, schreibt keinen Tippfehler ab. Dazu Export in drei Formen -- JSON,
CSV (Semikolon und CRLF, sonst steht in Excel alles in einer Spalte) und eine
fertige Lua-Tabelle Name -> Adresse, die man in eine Resource legt.

DREI FEHLER BEIM MESSEN GEFUNDEN:
- rm mit recursive:false wirft bei einem Verzeichnis EISDIR und sagt damit
  etwas ganz anderes, als der Fall ist. rmdir scheitert von selbst, wenn der
  Ordner nicht leer ist -- genau das gewuenschte Verhalten.
- In der Lua-Ausgabe war "\'" gemeint und "\'" geschrieben: in JavaScript ist
  das schlicht ein Apostroph, der Backslash faellt weg. Ein Modellname mit
  Apostroph haette die erzeugte Datei zerlegt.
- Der Export mit Ordnerfilter antwortete mit einem NACKTEN 500 (kaputtes
  ESCAPE in der SQL-Zeile). Ausgerechnet das.

Deshalb hat der Dienst jetzt eine AUFFANGLINIE: app.onError gibt einen Text
statt "Internal Server Error". Wir haben das bisher an den bekannten Stellen
einzeln abgefangen -- und prompt hat ein Tippfehler die Luecke gefunden. Eine
Stelle schliesst sie fuer alles, woran heute niemand denkt.

Dazu: die Verlaufstabelle hat feste Spaltenbreiten (colgroup) und ueberall
dieselben Abstaende -- vorher hatten Kopf und Koerper verschiedene, und der
Pfad wurde abgeschnitten, obwohl daneben Platz frei war. Und die Anmeldeseite
hat zwei weiche Lichter statt einer leeren Flaeche.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:01:01 +02:00

303 lines
13 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react'
import {
Check,
Copy,
Folder,
FolderInput,
HardDrive,
Images,
KeyRound,
Loader2,
RefreshCw,
ShieldCheck,
Trash2,
TrendingUp,
Upload,
} from 'lucide-react'
import { api, vorschauUrl, type EventKind, type Uebersicht } from '../api'
import { useStore } from '../store'
import { bytes, zeit } from '../format'
import { Karte, Leer, useKopieren } from '../components/Bausteine'
import { Avatar } from '../components/Avatar'
import { useAuffrischen } from '../auffrischen'
import { gehZu } from '../router'
const arten: Record<EventKind, { text: string; farbe: string; kind: typeof Upload }> = {
upload: { text: 'hochgeladen', farbe: 'text-gut', kind: Upload },
replace: { text: 'ersetzt', farbe: 'text-warn', kind: RefreshCw },
delete: { text: 'gelöscht', farbe: 'text-schlecht', kind: Trash2 },
move: { text: 'verschoben', farbe: 'text-akzent', kind: FolderInput },
}
/** Die Startseite: was ist da, was ist zuletzt passiert, wohin muss ich.
*
* Bisher landete man in der Galerie — mit 228 Kacheln und ohne Antwort auf
* die erste Frage, die man beim Öffnen hat: läuft alles? Diese Seite
* beantwortet sie in einem Blick und verlinkt den Rest. */
export function Start() {
const note = useStore((s) => s.note)
const user = useStore((s) => s.user)
const [daten, setDaten] = useState<Uebersicht | null>(null)
const [laedt, setLaedt] = useState(true)
const { kopiert, kopieren } = useKopieren()
const laden = useCallback(async () => {
try {
setDaten(await api.uebersicht())
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
} finally {
setLaedt(false)
}
}, [note])
useEffect(() => {
void laden()
}, [laden])
// Hier zieht nichts weg: die Seite hat keine Auswahl und kein offenes
// Fenster, sie darf sich jederzeit erneuern.
useAuffrischen({ laden: () => void laden(), blockiert: false })
if (laedt) {
return (
<div className="flex justify-center py-16">
<Loader2 className="animate-spin text-schrift-leise" />
</div>
)
}
if (!daten) return <Leer text="Die Übersicht ließ sich nicht laden." />
// Die eingetragenen Vorlagen — und solange keine da ist, die eine, die
// dieses Projekt ohnehin benutzt. Eine leere Karte waere hier nur ein
// Loch.
const vorlagen =
daten.vorlagen.length > 0
? daten.vorlagen
: [{ name: 'Fahrzeuge', muster: 'vehicles/{model}.webp' }]
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<Avatar name={user?.username ?? '?'} url={user?.avatar} groesse={38} />
<div>
<h1 className="text-lg font-semibold">
Hallo {user?.discordName ?? user?.username ?? ''}
</h1>
<p className="text-xs text-schrift-leise">
{daten.files} Dateien · {bytes(daten.bytes)} belegt
</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Kachel
kind={<Images size={16} />}
was="Dateien"
wert={String(daten.files)}
onClick={() => gehZu('/galerie')}
/>
<Kachel kind={<HardDrive size={16} />} was="Belegt" wert={bytes(daten.bytes)} />
<Kachel
kind={<TrendingUp size={16} />}
was="Neu heute"
wert={String(daten.heuteHoch)}
unten={`${daten.wocheHoch} in sieben Tagen`}
onClick={() => gehZu('/verlauf')}
/>
<Kachel
kind={<KeyRound size={16} />}
was="Token"
wert={String(daten.tokens.length)}
onClick={() => gehZu('/tokens')}
/>
</div>
{/* Die Vorlage, die im Handy steht — die haeufigste Frage ueberhaupt */}
<Karte
titel={vorlagen.length === 1 ? 'Adressvorlage' : 'Adressvorlagen'}
rechts={
<button
onClick={() => gehZu('/einstellungen')}
className="text-xs text-schrift-leise hover:text-schrift"
>
verwalten
</button>
}
>
<div className="flex flex-col gap-2">
{vorlagen.map((v, i) => {
const voll = `${daten.publicUrl}${daten.filePrefix}/${v.muster}`
return (
<div key={i} className="flex items-center gap-2">
<span className="w-28 shrink-0 truncate text-xs text-schrift-leise">
{v.name}
</span>
<code className="min-w-0 flex-1 overflow-x-auto rounded border border-rand bg-grund px-3 py-1.5 font-mono text-xs">
{voll}
</code>
<button
onClick={async () => {
if (!(await kopieren(voll, 'v' + i)))
note('fehler', 'Die Zwischenablage ist gesperrt.')
}}
className="shrink-0 rounded border border-rand px-2.5 py-1.5 text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
aria-label={`${v.name} kopieren`}
>
{kopiert === 'v' + i ? (
<Check size={14} className="text-gut" />
) : (
<Copy size={14} />
)}
</button>
</div>
)
})}
</div>
</Karte>
<div className="grid gap-4 lg:grid-cols-2">
<Karte titel="Zuletzt hochgeladen">
{daten.bilder.length === 0 ? (
<Leer text="Noch nichts da." />
) : (
<div className="grid grid-cols-4 gap-2 sm:grid-cols-6">
{daten.bilder.map((b) => (
<button
key={b.id}
onClick={() => gehZu('/galerie')}
title={b.path}
className="aspect-square overflow-hidden rounded border border-rand bg-grund"
>
<img
src={vorschauUrl(b.path)}
onError={(e) => {
const i = e.currentTarget
if (i.dataset.gefallen) return
i.dataset.gefallen = 'ja'
i.src = b.url
}}
alt={b.path}
loading="lazy"
className="h-full w-full object-contain"
/>
</button>
))}
</div>
)}
</Karte>
<Karte titel="Zuletzt passiert">
{daten.letzte.length === 0 ? (
<Leer text="Noch nichts passiert." />
) : (
<ul className="flex flex-col gap-1.5 text-sm">
{daten.letzte.map((e) => {
const a = arten[e.kind]
const Kind = a.kind
return (
<li key={e.id} className="flex items-center gap-2">
<Kind size={13} className={`shrink-0 ${a.farbe}`} />
<span
className="min-w-0 flex-1 truncate font-mono text-xs"
title={e.path}
>
{e.path}
</span>
<span className="shrink-0 text-[11px] text-schrift-leise">
{zeit(e.at)}
</span>
</li>
)
})}
</ul>
)}
</Karte>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Karte titel="Größte Ordner" rechts={<Folder size={14} className="text-schrift-leise" />}>
{daten.ordner.length === 0 ? (
<Leer text="Noch nichts abgelegt." />
) : (
<ul className="flex flex-col gap-1.5 text-sm">
{daten.ordner.map((o) => (
<li key={o.folder} className="flex items-center gap-2">
<span className="flex-1 truncate font-mono text-xs">
{o.folder}
</span>
<span className="text-xs text-schrift-leise">
{o.files} Dateien
</span>
<span className="w-16 text-right text-xs">
{bytes(o.bytes)}
</span>
</li>
))}
</ul>
)}
</Karte>
<Karte titel="Zugänge">
<ul className="flex flex-col gap-1.5 text-sm">
{daten.tokens.map((t) => (
<li key={t.id} className="flex items-center gap-2">
<KeyRound size={13} className="shrink-0 text-schrift-leise" />
<span className="flex-1 truncate">{t.name}</span>
<span className="text-xs text-schrift-leise">
{t.last_used_at ? `zuletzt ${zeit(t.last_used_at)}` : 'nie benutzt'}
</span>
</li>
))}
<li className="mt-1 flex items-center gap-2 border-t border-rand pt-2 text-xs">
<ShieldCheck
size={13}
className={daten.discord ? 'text-gut' : 'text-schrift-leise'}
/>
Discord-Anmeldung {daten.discord ? 'eingerichtet' : 'nicht eingerichtet'}
</li>
</ul>
</Karte>
</div>
</div>
)
}
function Kachel({
kind,
was,
wert,
unten,
onClick,
}: {
kind: React.ReactNode
was: string
wert: string
unten?: string
onClick?: () => void
}) {
const inhalt = (
<>
<div className="flex items-center gap-2 text-xs text-schrift-leise">
{kind}
{was}
</div>
<p className="mt-1 text-2xl font-semibold">{wert}</p>
{unten && <p className="text-[11px] text-schrift-leise">{unten}</p>}
</>
)
// Nur was wirklich irgendwohin fuehrt, wird ein Knopf. Eine Kachel, die
// aussieht wie ein Knopf und nichts tut, ist der Anfang des Misstrauens.
return onClick ? (
<button
onClick={onClick}
className="rounded-lg border border-rand bg-flaeche p-4 text-left transition-colors hover:border-akzent"
>
{inhalt}
</button>
) : (
<div className="rounded-lg border border-rand bg-flaeche p-4">{inhalt}</div>
)
}