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:
2026-08-11 17:38:38 +02:00
co-authored by Claude Opus 5
parent 65828b526c
commit 6c8ead8e15
22 changed files with 4644 additions and 15 deletions
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useState } from 'react'
import { HardDrive, Images, KeyRound, LogOut, PieChart, UserCog } from 'lucide-react'
import { run, useStore } from './store'
import { Anmelden } from './pages/Anmelden'
import { Galerie } from './pages/Galerie'
import { Speicher } from './pages/Speicher'
import { Tokens } from './pages/Tokens'
import { Konto } from './pages/Konto'
import { Meldungen } from './components/Meldungen'
// 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: '/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
// ganze Dashboard neu — bei 60 Kacheln mit Bildern ist das spürbar.
const user = useStore((s) => s.user)
const checking = useStore((s) => s.checking)
const checkSession = useStore((s) => s.checkSession)
const logout = useStore((s) => s.logout)
const [pfad, gehZu] = usePfad()
useEffect(() => {
void checkSession()
}, [checkSession])
// Solange nicht feststeht, ob eine Sitzung besteht, wird NICHTS gezeigt.
// Sonst blitzt bei jedem Neuladen kurz das Anmeldeformular auf, obwohl man
// angemeldet ist — und das sieht aus wie ein Fehler.
if (checking) {
return (
<div className="flex min-h-full items-center justify-center">
<HardDrive className="animate-pulse text-schrift-leise" />
<Meldungen />
</div>
)
}
if (!user) {
return (
<>
<Anmelden />
<Meldungen />
</>
)
}
const aktiv = seiten.find((s) => s.pfad === pfad) ?? seiten[0]
const Inhalt = aktiv.Inhalt
return (
<div className="flex min-h-full flex-col">
<header className="sticky top-0 z-40 border-b border-rand bg-flaeche/80 backdrop-blur">
<div className="mx-auto flex max-w-7xl flex-wrap items-center gap-x-6 gap-y-2 px-4 py-2.5">
<a
href="/"
onClick={(e) => {
e.preventDefault()
gehZu('/')
}}
className="flex items-center gap-2 font-semibold"
>
<HardDrive size={18} className="text-akzent" />
d4rk_media
</a>
<nav className="flex flex-1 gap-1">
{seiten.map((s) => {
const an = s.pfad === aktiv.pfad
return (
<a
key={s.pfad}
href={s.pfad}
onClick={(e) => {
e.preventDefault()
gehZu(s.pfad)
}}
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm
transition-colors ${
an
? 'bg-flaeche-hoch text-schrift'
: 'text-schrift-leise hover:text-schrift'
}`}
>
<s.kind size={15} />
{s.name}
</a>
)
})}
</nav>
<button
onClick={() => void run(logout, 'Abgemeldet.')}
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm
text-schrift-leise hover:text-schrift"
>
<LogOut size={15} />
Abmelden
</button>
</div>
</header>
<main className="mx-auto w-full max-w-7xl flex-1 px-4 py-5">
<Inhalt />
</main>
<Meldungen />
</div>
)
}