feat(ui): Platzhalter beim Laden und ordentliche Leerzustände
Beim Laden stand ein Kreisel in der Mitte. Der sagt „warte", aber nicht, worauf – und wenn die Daten kommen, klappt das Layout um. Jetzt steht dort ein Platzhalter in der Form des Kommenden: Kacheln in der Kachelansicht, Zeilen in der Liste. Die Maße sind aus FileViews.tsx abgeschrieben, nicht geschätzt; weichen sie ab, springt es beim Wechsel wieder und der Zweck wäre dahin. Leerzustände sagen jetzt, warum nichts da ist, und bieten den Knopf an, der an dieser Stelle weiterhilft. Der leere Papierkorb bekommt bewusst keinen: Er ist ein guter Zustand, kein Mangel, und ein Knopf, der nur da ist damit einer da ist, führt nirgendwohin. Beides respektiert prefers-reduced-motion. Nebenbei gefunden: `@keyframes spin` lag als Inline-<style> in **sechs** Komponenten, und zwei Nutzer (AuditTab, SearchBox) hatten gar keine eigene Kopie. Dass es lief, war Zufall – @keyframes gilt fürs ganze Dokument, es genügte also, dass irgendeine der sechs gerade im Bild war. Kaputt wäre es an dem Tag gewesen, an dem jemand die letzte Kopie entfernt. Steht jetzt einmal in index.css. Nicht angefasst: der Sprachmix. CalendarPage ist durchgehend deutsch, die übrige Oberfläche englisch, betroffen sind sechs Dateien. Eine halbe Übersetzung wäre schlimmer als keine – das gehört in einem Zug gemacht, samt der Entscheidung, welche Sprache die Oberfläche spricht. In TESTING.md notiert. Geprüft: tsc sauber, 41 Vitest-Tests, alle 234 Prüfungen der sieben Selbsttest-Suiten grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
94a803bf7a
commit
1d10c6bd0e
13 files changed
+281
-57
No files matched your search
@@ -0,0 +1,63 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Was dasteht, wenn nichts dasteht.
|
||||
*
|
||||
* Bisher war das je nach Seite ein grauer Halbsatz („Trash is empty") oder gar
|
||||
* nichts. Beides lässt offen, ob die Liste leer ist oder das Laden
|
||||
* fehlgeschlagen – und beides verschenkt die Stelle, an der jemand am
|
||||
* ehesten Hilfe gebrauchen kann: Er steht vor etwas Leerem und weiß nicht,
|
||||
* wie es sich füllt.
|
||||
*
|
||||
* Deshalb drei Teile, und der dritte ist der wichtige:
|
||||
*
|
||||
* 1. ein Zeichen, damit die Fläche nicht kaputt aussieht
|
||||
* 2. ein Satz, der sagt, **warum** hier nichts ist
|
||||
* 3. der Knopf, der genau hier weiterhilft – wo es einen gibt
|
||||
*
|
||||
* Wo es keinen sinnvollen Knopf gibt (ein leerer Papierkorb ist ein guter
|
||||
* Zustand, kein Mangel), bleibt es bei zweien. Ein Knopf, der nur da ist,
|
||||
* damit einer da ist, führt nirgendwohin.
|
||||
*/
|
||||
export default function EmptyState({ icon, title, hint, action }: {
|
||||
/** Ein Emoji oder ein SVG. Gedämpft – es soll die Fläche tragen, nicht sie beherrschen. */
|
||||
icon: ReactNode
|
||||
title: string
|
||||
hint?: ReactNode
|
||||
action?: { label: string; onClick: () => void }
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
justifyContent: 'center', textAlign: 'center',
|
||||
padding: '72px 24px', gap: 10,
|
||||
}}>
|
||||
<div style={{ fontSize: 48, opacity: 0.28, lineHeight: 1 }}>{icon}</div>
|
||||
|
||||
<p style={{ margin: 0, fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
{title}
|
||||
</p>
|
||||
|
||||
{hint && (
|
||||
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)', maxWidth: 380 }}>
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{action && (
|
||||
<button
|
||||
onClick={action.onClick}
|
||||
style={{
|
||||
marginTop: 6,
|
||||
padding: '8px 16px', borderRadius: 8,
|
||||
border: 'none', cursor: 'pointer',
|
||||
background: 'var(--color-primary)', color: 'var(--color-primary-fg)',
|
||||
fontSize: 13, fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Platzhalter, solange geladen wird.
|
||||
*
|
||||
* Vorher stand dort ein Kreisel in der Mitte. Der sagt „warte", aber er sagt
|
||||
* nicht, **worauf** – und wenn die Daten da sind, klappt das Layout um: Der
|
||||
* Kreisel verschwindet, Kacheln erscheinen, die Seite springt. Ein Platzhalter
|
||||
* in der Form des Kommenden tut beides besser. Er zeigt, was gleich da ist,
|
||||
* und er hält den Platz, sodass nichts springt.
|
||||
*
|
||||
* **Die Maße sind abgeschrieben, nicht geschätzt** – aus FileViews.tsx: Kachel
|
||||
* mit 12er Radius und 100 Pixel Deckfläche, Zeile mit 8/12er Innenabstand und
|
||||
* 8er Radius. Weichen sie ab, springt es beim Wechsel wieder, und der ganze
|
||||
* Zweck wäre dahin.
|
||||
*/
|
||||
|
||||
/** Ein graues Feld, das leicht pulsiert. */
|
||||
function Feld({ w, h, r = 6, mb }: { w: number | string; h: number; r?: number; mb?: number }) {
|
||||
return (
|
||||
<div
|
||||
className="aether-skeleton"
|
||||
style={{ width: w, height: h, borderRadius: r, marginBottom: mb }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele Platzhalter.
|
||||
*
|
||||
* Genug, um die sichtbare Fläche zu füllen, und nicht mehr: Ein Schirm voll
|
||||
* grauer Kästen, der sich noch scrollen lässt, verspricht mehr Inhalt als da
|
||||
* ist – und enttäuscht zweimal, wenn der Ordner am Ende drei Dateien hat.
|
||||
*/
|
||||
const kacheln = 8
|
||||
const zeilen = 7
|
||||
|
||||
export function SkeletonGrid() {
|
||||
return (
|
||||
<div className="aether-grid" aria-hidden="true">
|
||||
{Array.from({ length: kacheln }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={{ height: 100 }}>
|
||||
<Feld w="100%" h={100} r={0} />
|
||||
</div>
|
||||
<div style={{ padding: '8px 10px 10px' }}>
|
||||
{/* Unterschiedlich lang, sonst sieht es aus wie ein Formular und
|
||||
nicht wie eine Liste von Namen. */}
|
||||
<Feld w={`${55 + ((i * 17) % 35)}%`} h={9} mb={6} />
|
||||
<Feld w="38%" h={8} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonRows() {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }} aria-hidden="true">
|
||||
{Array.from({ length: zeilen }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '8px 12px', borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Feld w={28} h={28} r={6} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Feld w={`${35 + ((i * 23) % 40)}%`} h={10} />
|
||||
</div>
|
||||
<Feld w={64} h={8} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -134,7 +134,6 @@ export default function VersionsDialog({ file, onClose, onRestored }: Props) {
|
||||
{loading && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 32 }}>
|
||||
<div style={{ width: 20, height: 20, border: '2px solid var(--color-primary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />
|
||||
<style>{`@keyframes spin{to{transform:rotate(360deg)}}`}</style>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in new issue
Block a user