From 1d10c6bd0e09586a6aff84e8c00b87891cdca788 Mon Sep 17 00:00:00 2001 From: marc Date: Fri, 18 Sep 2026 16:33:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Platzhalter=20beim=20Laden=20und=20?= =?UTF-8?q?ordentliche=20Leerzust=C3=A4nde?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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- ) } 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 && (