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:
2026-08-11 19:27:09 +02:00
co-authored by Claude Opus 5
parent 84cb3ee200
commit 74cf56ce81
6 changed files with 516 additions and 34 deletions
+16 -28
View File
@@ -1,47 +1,35 @@
import { useEffect, useState } from 'react'
import { HardDrive, History, Images, KeyRound, LogOut, PieChart, UserCog } from 'lucide-react'
import { useEffect } from 'react'
import {
Gauge,
HardDrive,
History,
Images,
KeyRound,
LogOut,
PieChart,
UserCog,
} from 'lucide-react'
import { run, useStore } from './store'
import { Anmelden } from './pages/Anmelden'
import { Start } from './pages/Start'
import { Galerie } from './pages/Galerie'
import { Speicher } from './pages/Speicher'
import { Tokens } from './pages/Tokens'
import { Konto } from './pages/Konto'
import { Verlauf } from './pages/Verlauf'
import { Meldungen } from './components/Meldungen'
import { gehZu, usePfad } from './router'
import { Avatar } from './components/Avatar'
// Ein winziger Router über die History-API statt einer Bibliothek.
//
// Echte Pfade und keine Reiter im Zustand, weil der Dienst dafür schon gebaut
// ist: index.ts liefert auf alles Unbekannte die index.html aus, damit ein
// Neuladen auf /tokens im Router landet und nicht im 404.
const seiten = [
{ pfad: '/', name: 'Galerie', kind: Images, Inhalt: Galerie },
{ pfad: '/', name: 'Start', kind: Gauge, Inhalt: Start },
{ pfad: '/galerie', name: 'Galerie', kind: Images, Inhalt: Galerie },
{ pfad: '/verlauf', name: 'Verlauf', kind: History, Inhalt: Verlauf },
{ pfad: '/speicher', name: 'Speicher', kind: PieChart, Inhalt: Speicher },
{ pfad: '/tokens', name: 'Token', kind: KeyRound, Inhalt: Tokens },
{ pfad: '/konto', name: 'Konto', kind: UserCog, Inhalt: Konto },
] as const
function usePfad(): [string, (p: string) => void] {
const [pfad, setPfad] = useState(window.location.pathname)
useEffect(() => {
const zurueck = () => setPfad(window.location.pathname)
window.addEventListener('popstate', zurueck)
return () => window.removeEventListener('popstate', zurueck)
}, [])
return [
pfad,
(p: string) => {
window.history.pushState(null, '', p)
setPfad(p)
},
]
}
export function App() {
// Einzeln ausgewählt und nicht als ganzer Laden: `useStore()` ohne
// Auswahlfunktion abonniert ALLES, und dann zeichnet jede Meldung das
@@ -50,7 +38,7 @@ export function App() {
const checking = useStore((s) => s.checking)
const checkSession = useStore((s) => s.checkSession)
const logout = useStore((s) => s.logout)
const [pfad, gehZu] = usePfad()
const pfad = usePfad()
useEffect(() => {
void checkSession()
+15
View File
@@ -159,6 +159,20 @@ export type Folders = {
/** Der billige Fuehler fuer die automatische Auffrischung. */
export type Stand = { medien: number; neuest: number; ereignis: number }
export type Uebersicht = {
files: number
bytes: number
heuteHoch: number
wocheHoch: number
publicUrl: string
filePrefix: string
letzte: MediaEvent[]
bilder: MediaItem[]
ordner: { folder: string; files: number; bytes: number }[]
tokens: { id: number; name: string; prefix: string; can_delete: number; last_used_at: number | null }[]
discord: boolean
}
export type Sortierung = 'geaendert' | 'angelegt' | 'pfad' | 'groesse'
export type Richtung = 'auf' | 'ab'
@@ -290,6 +304,7 @@ export const api = {
einstellungenSpeichern: (discord: Record<string, string>) =>
post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { discord }),
uebersicht: () => get<Uebersicht>('/api/dash/uebersicht'),
stand: () => get<Stand>('/api/dash/stand'),
stats: () => get<Stats>('/api/dash/stats'),
thumbsNachziehen: () => post<ThumbLauf>('/api/dash/maintenance/thumbs'),
+274
View File
@@ -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>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useState } from 'react'
// Ein winziger Router über die History-API statt einer Bibliothek.
//
// Echte Pfade und keine Reiter im Zustand, weil der Dienst dafür schon gebaut
// ist: index.ts liefert auf alles Unbekannte die index.html aus, damit ein
// Neuladen auf /tokens im Router landet und nicht im 404.
//
// `gehZu` steht hier als MODULFUNKTION und nicht als Eigenschaft der Seiten.
// Der Unterschied ist mehr als Geschmack: sobald eine einzige Seite eine
// Navigationsfunktion durchgereicht bekäme, hätten alle Seiten verschiedene
// Signaturen — und die gemeinsame Liste in App.tsx ginge nicht mehr. Genau
// daran ist der erste Versuch gescheitert.
/** Woanders hin, ohne die Seite neu zu laden. */
export function gehZu(pfad: string) {
if (window.location.pathname === pfad) return
window.history.pushState(null, '', pfad)
// pushState löst KEIN popstate aus — das tut nur der Zurück-Knopf. Ohne
// dieses Ereignis bliebe die Ansicht stehen, obwohl die Adresse wechselt.
window.dispatchEvent(new PopStateEvent('popstate'))
}
export function usePfad(): string {
const [pfad, setPfad] = useState(window.location.pathname)
useEffect(() => {
const zurueck = () => setPfad(window.location.pathname)
window.addEventListener('popstate', zurueck)
return () => window.removeEventListener('popstate', zurueck)
}, [])
return pfad
}