diff --git a/TESTING.md b/TESTING.md index dc24df1..2cf6bac 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1525,6 +1525,59 @@ bleibt `node` (schnell); wer ein DOM braucht, schreibt --- +## Platzhalter und Leerzustände (18.09.2026) + +Beim Laden stand ein Kreisel in der Mitte, danach klappte das Layout um. Jetzt +steht dort ein Platzhalter **in der Form des Kommenden** – Kacheln in der +Kachelansicht, Zeilen in der Liste –, und nichts springt mehr, wenn die Daten +ankommen. Die Maße sind aus `FileViews.tsx` abgeschrieben, nicht geschätzt; +weichen sie ab, springt es wieder. + +- [ ] Ordner öffnen → Platzhalter in der Form der gewählten Ansicht 🔎 +- [ ] Beim Umschalten zwischen Kachel und Liste wechselt auch der Platzhalter +- [ ] Datenankunft ohne Sprung: Die Kacheln stehen dort, wo die Platzhalter + standen +- [ ] Papierkorb, „Für mich freigegeben", Etiketten, Kalender: ebenfalls + Platzhalter statt „Loading…" +- [ ] Betriebssystem auf „Bewegung reduzieren" → kein Pulsieren, nur gedämpfte + Flächen + +Leerzustände sagen jetzt, **warum** nichts da ist, und bieten den Knopf an, +der an dieser Stelle weiterhilft: + +- [ ] Leerer Ordner → „This folder is empty" mit Knopf „Upload files" +- [ ] Wurzel ohne Inhalt → „Nothing here yet" +- [ ] Leerer Papierkorb → **kein** Knopf (ein leerer Papierkorb ist ein guter + Zustand, kein Mangel) +- [ ] Nichts freigegeben → „Nothing shared with you yet" +- [ ] Etikett ohne Dateien → Hinweis auf „Organise…" + +### Der Kreisel stand sechsmal im Quelltext + +`@keyframes spin` lag als Inline-` ) } diff --git a/frontend/src/components/EmptyState.tsx b/frontend/src/components/EmptyState.tsx new file mode 100644 index 0000000..03587e8 --- /dev/null +++ b/frontend/src/components/EmptyState.tsx @@ -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 ( +
+
{icon}
+ +

+ {title} +

+ + {hint && ( +

+ {hint} +

+ )} + + {action && ( + + )} +
+ ) +} diff --git a/frontend/src/components/Skeleton.tsx b/frontend/src/components/Skeleton.tsx new file mode 100644 index 0000000..78baa5f --- /dev/null +++ b/frontend/src/components/Skeleton.tsx @@ -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 ( +
+ ) +} + +/** + * 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 ( + + ) +} + +export function SkeletonRows() { + return ( + + ) +} diff --git a/frontend/src/components/VersionsDialog.tsx b/frontend/src/components/VersionsDialog.tsx index 6c2d78d..c1f2f1e 100644 --- a/frontend/src/components/VersionsDialog.tsx +++ b/frontend/src/components/VersionsDialog.tsx @@ -134,7 +134,6 @@ export default function VersionsDialog({ file, onClose, onRestored }: Props) { {loading && (
-
)} diff --git a/frontend/src/index.css b/frontend/src/index.css index 1671b72..0370dea 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -155,3 +155,35 @@ body { @media (prefers-reduced-motion: reduce) { .aether-toast { animation: none; } } + +/* ── Platzhalter beim Laden ────────────────────────────────────────────────── + Ein ruhiges Pulsieren, kein Glanzstreifen: Der wandernde Reflex, den man + oft sieht, zieht den Blick genau dorthin, wo noch nichts steht. Hier soll + die Fläche zeigen, dass gleich etwas kommt – nicht um Aufmerksamkeit + bitten. + + 1,4 s ist bewusst langsam. Schneller wirkt es hektisch, und der Platzhalter + steht ja meist nur den Bruchteil einer Sekunde. */ +@keyframes aether-puls { + 0%, 100% { opacity: 0.55; } + 50% { opacity: 0.9; } +} + +.aether-skeleton { + background: var(--color-surface-2); + animation: aether-puls 1.4s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .aether-skeleton { animation: none; opacity: 0.6; } +} + +/* ── Kreisel ───────────────────────────────────────────────────────────────── + Stand bis zum 18.09.2026 **sechsmal** als `
) } diff --git a/frontend/src/pages/SharePage.tsx b/frontend/src/pages/SharePage.tsx index 5a66ec1..7dacbfc 100644 --- a/frontend/src/pages/SharePage.tsx +++ b/frontend/src/pages/SharePage.tsx @@ -100,7 +100,6 @@ export default function SharePage({ token }: Props) { ) : !info ? (
-
) : ( <> diff --git a/frontend/src/pages/SharedPage.tsx b/frontend/src/pages/SharedPage.tsx index 6c614a6..d22ac5f 100644 --- a/frontend/src/pages/SharedPage.tsx +++ b/frontend/src/pages/SharedPage.tsx @@ -60,7 +60,6 @@ export default function SharedPage() { {loading && (
-
)} diff --git a/frontend/src/pages/SharedWithMePage.tsx b/frontend/src/pages/SharedWithMePage.tsx index 9a50e24..a96da90 100644 --- a/frontend/src/pages/SharedWithMePage.tsx +++ b/frontend/src/pages/SharedWithMePage.tsx @@ -3,6 +3,8 @@ import { aclApi, filesApi, type SharedResource } from '../api/client' import { formatBytes } from '../format' import PageHeader from '../components/PageHeader' import { FolderGlyph } from '../components/icons' +import { SkeletonRows } from '../components/Skeleton' +import EmptyState from '../components/EmptyState' interface Props { /** Öffnet einen freigegebenen Ordner in der Dateiansicht. */ @@ -40,17 +42,13 @@ export default function SharedWithMePage({ onOpenFolder }: Props) {
{loading ? ( -

Loading…

+ ) : items.length === 0 ? ( -
-

Nothing shared with you yet

-

- When someone shares a file or folder, it shows up here. -

-
+ ) : (
{items.map(item => ( diff --git a/frontend/src/pages/TagsPage.tsx b/frontend/src/pages/TagsPage.tsx index 0ee7aa8..0085a0d 100644 --- a/frontend/src/pages/TagsPage.tsx +++ b/frontend/src/pages/TagsPage.tsx @@ -3,6 +3,8 @@ import { tagsApi, correspondentsApi, filesApi, downloadZip, type Correspondent, import PageHeader from '../components/PageHeader' import { FileTypeIcon } from '../components/icons' import TagChip, { tagColor } from '../components/TagChip' +import { SkeletonRows } from '../components/Skeleton' +import EmptyState from '../components/EmptyState' import { formatBytes } from '../format' /** @@ -110,9 +112,7 @@ export default function TagsPage({ tagId, correspondentId, onOpenFolder }: Props
- {loading && ( -

Loading…

- )} + {loading && } {!loading && !id && ( - {text} -
- ) +/** Die drei leeren Fälle dieser Seite sehen gleich aus; nur der Satz wechselt. + * Das Zeichen richtet sich danach, worauf man gerade schaut. */ +function Empty({ text, icon = '🏷' }: { text: string; icon?: string }) { + return } diff --git a/frontend/src/pages/TrashPage.tsx b/frontend/src/pages/TrashPage.tsx index eb044bd..b3d4d9b 100644 --- a/frontend/src/pages/TrashPage.tsx +++ b/frontend/src/pages/TrashPage.tsx @@ -3,6 +3,8 @@ import { trashApi, filesApi, type TrashItem } from '../api/client' import { formatBytes } from '../format' import PageHeader from '../components/PageHeader' import ConfirmDialog from '../components/ConfirmDialog' +import { SkeletonRows } from '../components/Skeleton' +import EmptyState from '../components/EmptyState' interface Props { /** Wiederherstellen verändert den belegten Speicher – Balken nachziehen. */ @@ -74,18 +76,16 @@ export default function TrashPage({ onUserChanged }: Props) {
- {loading && ( -
-
- -
- )} + {loading && } + {/* Kein Knopf: Ein leerer Papierkorb ist ein guter Zustand, kein + Mangel, den man beheben müsste. */} {!loading && items.length === 0 && ( -
-
🗑
-

Trash is empty

-
+ )} {!loading && items.length > 0 && (