FILTER. Eine Leiste ueber der Galerie: Alles, Bilder, Video, Ton, Dokumente, Anderes -- mit der Anzahl AM KNOPF. Ein Knopf "Video", hinter dem nichts liegt, kostet sonst einen Klick, um das herauszufinden; leere Arten sind deshalb sichtbar, aber gesperrt. Die Uebersetzung Art -> SQL steht an EINER Stelle (ARTEN), weil zwei Stellen zwangslaeufig auseinanderlaufen: die Liste zeigte dann etwas anderes, als der Zaehler daneben behauptet. "Anderes" ist bewusst die VERNEINUNG der uebrigen und keine eigene Liste -- sonst faellt ein neuer Typ durch alle Filter und ist nirgends zu finden. Nachgemessen: die Summe der fuenf Arten ergibt die Gesamtzahl. /media/ids nimmt denselben Filter. Ohne das waehlt "alle waehlen" mehr aus, als zu sehen ist -- und geloescht wuerde Unsichtbares. DER FUND BEIM MESSEN: clip.mp4, ton.mp3, handbuch.pdf und notiz.txt landeten ALLE unter "Anderes". Ein Formularteil ohne erkannte Art traegt "application/octet-stream", und mein Code hat das der Endungstabelle vorgezogen. Was der Absender ueber die Art sagt, gilt weiterhin -- aber nicht sein Achselzucken. Danach: mp4 -> video/mp4, mp3 -> audio/mpeg, pdf und txt -> Dokumente, und .zip bleibt richtigerweise "Anderes". POLITUR. Ein ruhiger Verlauf im Hintergrund statt einer flachen Flaeche (ein einfarbiges Dunkel sieht auf einem grossen Monitor aus wie ein Ladefehler), Bildlaufleisten im Ton der Oberflaeche, ein Balken unter der aktiven Seite, Karten mit einem Hauch Tiefe, ein spuerbarer Druckpunkt an den Knoepfen, und Kacheln, die sich beim Darueberfahren leicht heben. Der Seitenwechsel blendet auf -- und wer prefers-reduced-motion gesetzt hat, bekommt nichts davon. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
141 lines
5.6 KiB
TypeScript
141 lines
5.6 KiB
TypeScript
import { useEffect } from 'react'
|
|
import {
|
|
Gauge,
|
|
HardDrive,
|
|
History,
|
|
Images,
|
|
KeyRound,
|
|
LogOut,
|
|
PieChart,
|
|
Settings,
|
|
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 { Einstellungen } from './pages/Einstellungen'
|
|
import { Verlauf } from './pages/Verlauf'
|
|
import { Meldungen } from './components/Meldungen'
|
|
import { gehZu, usePfad } from './router'
|
|
import { Avatar } from './components/Avatar'
|
|
|
|
const seiten = [
|
|
{ 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 },
|
|
{ pfad: '/einstellungen', name: 'Einstellungen', kind: Settings, Inhalt: Einstellungen },
|
|
] as const
|
|
|
|
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 = 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={`relative flex items-center gap-1.5 rounded-md px-2.5 py-1.5
|
|
text-sm transition-colors ${
|
|
an
|
|
? 'bg-flaeche-hoch text-schrift after:absolute after:inset-x-2.5 after:-bottom-[9px] after:h-0.5 after:rounded-full after:bg-akzent'
|
|
: 'text-schrift-leise hover:bg-flaeche-hoch/40 hover:text-schrift'
|
|
}`}
|
|
>
|
|
<s.kind size={15} />
|
|
{s.name}
|
|
</a>
|
|
)
|
|
})}
|
|
</nav>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Avatar name={user.username} url={user.avatar} groesse={26} />
|
|
<span className="hidden text-sm text-schrift-leise sm:inline">
|
|
{user.username}
|
|
</span>
|
|
<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"
|
|
aria-label="Abmelden"
|
|
>
|
|
<LogOut size={15} />
|
|
<span className="hidden md:inline">Abmelden</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main key={aktiv.pfad} className="auftauchen mx-auto w-full max-w-7xl flex-1 px-4 py-5">
|
|
<Inhalt />
|
|
</main>
|
|
|
|
<Meldungen />
|
|
</div>
|
|
)
|
|
}
|