feat: eine Startseite, und die Sicherung geht in die Nextcloud
STARTSEITE. Wer das Dashboard oeffnet, landete bisher in der Galerie -- mit 228 Kacheln und ohne Antwort auf die erste Frage, die man beim Oeffnen hat: laeuft alles? Jetzt zuerst eine Uebersicht: Zahlen, was heute dazukam, ein Bilderstreifen der letzten Uploads, die letzten acht Schritte aus dem Verlauf, die groessten Ordner, die Zugaenge -- und die ADRESSVORLAGE zum Kopieren, weil das die haeufigste Frage ueberhaupt ist. Die Galerie zieht auf /galerie um. Alles in EINER Antwort (/api/dash/uebersicht, gemessen 14 ms). Vier Einzelabfragen waeren vier Runden ueber den Proxy und koennten auseinander- laufen: Dateizahl aus der einen und letzte Ereignisse aus der naechsten Antwort sind zwei verschiedene Zeitpunkte. Der Router zog dabei nach src/router.ts um. Der Grund ist mehr als Geschmack: die Startseite braucht eine Navigationsfunktion, die anderen Seiten nicht -- und sobald EINE Seite eine Eigenschaft mehr hat, passt die gemeinsame Liste in App.tsx nicht mehr. Genau daran ist der erste Versuch gescheitert (TypeScript hat es gefangen). Als Modulfunktion hat wieder jede Seite dieselbe Signatur. SICHERUNG IN DIE NEXTCLOUD. Bisher lagen die Archive auf derselben Platte wie die Daten -- gegen einen Plattenausfall half das gar nicht. Jetzt gehen sie per WebDAV hinauf, mit curl statt rclone: Nextcloud spricht WebDAV, curl spricht WebDAV, und curl ist ohnehin da. Auch dort wird NACHGEZAEHLT: die Kopie wird zurueckgeholt und die Pruefsumme verglichen. Ein PUT, der 201 sagt, hat nichts bewiesen -- abgeschnittene Uebertragungen sehen genauso aus. Und ausgeduennt wird ebenfalls, sonst waechst die Nextcloud still voll. Die Zugangsdaten liegen NEBEN den Sicherungen (C:\backup\d4rk_media\ nextcloud.txt), nicht im Repo: sie gehoeren zur Maschine, nicht zum Code. Fehlt die Datei, sagt das Skript das einmal und sichert lokal weiter -- eine Sicherung, die wegen eines fehlenden Passworts GAR NICHT stattfindet, waere der schlechtere Tausch. Dabei einmal reingefallen: die Pruefung brach mit "sh: set: Illegal option -" ab, nachdem ein Werkzeug die Datei mit CRLF gespeichert hatte -- die Shell im Container liest dann "set -e\r". Sieht aus wie ein Tippfehler und ist keiner. Das Skript entfernt die Wagenruecklaeufe jetzt selbst. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Folder,
|
||||
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 },
|
||||
}
|
||||
|
||||
/** 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." />
|
||||
|
||||
const vorlage = `${daten.publicUrl}${daten.filePrefix}/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="Adressvorlage">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 overflow-x-auto rounded border border-rand bg-grund px-3 py-1.5 font-mono text-xs">
|
||||
{vorlage}
|
||||
</code>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!(await kopieren(vorlage, 'v')))
|
||||
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="Vorlage kopieren"
|
||||
>
|
||||
{kopiert === 'v' ? (
|
||||
<Check size={14} className="text-gut" />
|
||||
) : (
|
||||
<Copy size={14} />
|
||||
)}
|
||||
</button>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user