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:
marcandClaude Opus 5 committed 2026-09-18 16:33:25 +02:00
1 parent 94a803bf7a
commit 1d10c6bd0e
13 files changed
+281 -57

No files matched your search

+53
View File
@@ -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-`<style>` in sechs Komponenten – und zwei
Nutzer (`AuditTab`, `SearchBox`) hatten **gar keine** eigene Kopie. Dass es
trotzdem 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`.
- [ ] Kreisel dreht sich noch: Suchfeld tippen, Admin → Audit laden,
Versionen-Dialog öffnen 🔎
### Offen: der Sprachmix
`CalendarPage.tsx` ist durchgehend deutsch („Kalender", „Lädt…", „Keine
Termine"), die übrige Oberfläche englisch. Betroffen sind sechs Dateien:
CalendarPage, Shell, calendar/controls, calendar/EventDialog, TagChip,
UserMenu.
Bewusst **nicht** nebenbei erledigt: Eine halbe Übersetzung ist schlimmer als
gar keine, weil dann eine Seite in sich gemischt ist. Das gehört in einem Zug
gemacht – samt der Entscheidung, welche Sprache die Oberfläche eigentlich
spricht.
---
## Absender / Korrespondent (23.08.2026)
Das zweite Ordnungsmerkmal neben den Etiketten. Unterschied: genau **einer**
-1
View File
@@ -53,7 +53,6 @@ function AuthedApp() {
borderRadius: '50%',
animation: 'spin 0.7s linear infinite',
}} />
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
)
}
+63
View File
@@ -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>
)
}
+84
View File
@@ -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>
)}
+32
View File
@@ -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 `<style>`-Block mitten in
Komponenten – und zwei Stellen (AuditTab, SearchBox) hatten gar keinen,
die liefen nur, solange eine der anderen gerade im Bild war. @keyframes
gilt für das ganze Dokument, deshalb fiel das nie auf; kaputt wäre es erst
an dem Tag gewesen, an dem jemand die letzte Kopie entfernt. */
@keyframes spin {
to { transform: rotate(360deg); }
}
+13 -2
View File
@@ -18,6 +18,8 @@ import {
import { useEventDrag } from '../components/calendar/useEventDrag'
import { TrashIcon, RenameIcon, DownloadIcon, PeopleIcon } from '../components/icons'
import ShareWithDialog from '../components/ShareWithDialog'
import { SkeletonRows } from '../components/Skeleton'
import EmptyState from '../components/EmptyState'
type ViewMode = 'month' | 'week' | 'list'
@@ -339,7 +341,10 @@ export default function CalendarPage() {
: 'Alle Termine'
if (loading) {
return <div style={{ padding: 24, color: 'var(--color-text-muted)' }}>Lädt…</div>
// Deutsch, wie der Rest dieser Seite. Sie fällt damit aus der sonst
// englischen Oberfläche heraus – das ist bekannt und gehört in einem
// Zug erledigt, nicht halb.
return <div className="aether-content"><SkeletonRows /></div>
}
return (
@@ -991,7 +996,13 @@ function ListAgenda({ events, onEventClick, onEventDown }: {
}, [events])
if (groups.length === 0) {
return <p style={{ padding: 24, color: 'var(--color-text-muted)', fontSize: 13 }}>Keine Termine.</p>
return (
<EmptyState
icon="📅"
title="Keine Termine"
hint="In diesem Zeitraum steht nichts an."
/>
)
}
const today = new Date()
+11 -18
View File
@@ -12,6 +12,8 @@ import ContextMenu, { type MenuItem } from '../components/ContextMenu'
import UploadQueue from '../components/UploadQueue'
import OfficeEditor from '../components/OfficeEditor'
import { toast, fehlertext } from '../components/Toasts'
import { SkeletonGrid, SkeletonRows } from '../components/Skeleton'
import EmptyState from '../components/EmptyState'
import FileToolbar, { type Sort, type ViewMode } from '../components/files/FileToolbar'
import FileDialogs, { type Dialog } from '../components/files/FileDialogs'
import { fileMenuItems, folderMenuItems } from '../components/files/menus'
@@ -658,25 +660,17 @@ export default function FileBrowser({
</PageHeader>
<div className="aether-content" style={{ flex: 1, overflowY: 'auto' }}>
{loading && (
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 80 }}>
<div style={{
width: 24, height: 24,
border: '2px solid var(--color-primary)',
borderTopColor: 'transparent',
borderRadius: '50%',
animation: 'spin 0.7s linear infinite',
}} />
</div>
)}
{/* Platzhalter in der Form der gewählten Ansicht – sonst springt das
Layout in dem Moment, in dem die Daten ankommen. */}
{loading && (viewMode === 'grid' ? <SkeletonGrid /> : <SkeletonRows />)}
{!loading && isEmpty && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: 100, gap: 12 }}>
<div style={{ fontSize: 56, opacity: 0.25 }}>☁️</div>
<p style={{ color: 'var(--color-text-muted)', fontSize: 14, margin: 0 }}>
Drag files here or click <strong>Upload</strong>
</p>
</div>
<EmptyState
icon="☁️"
title={currentFolderId ? 'This folder is empty' : 'Nothing here yet'}
hint={<>Drag files anywhere on this page, or use <strong>Upload</strong>.</>}
action={{ label: 'Upload files', onClick: () => fileInputRef.current?.click() }}
/>
)}
{!loading && !isEmpty && (
@@ -750,7 +744,6 @@ export default function FileBrowser({
}}
/>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
)
}
-1
View File
@@ -100,7 +100,6 @@ export default function SharePage({ token }: Props) {
) : !info ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
<div style={{ width: 24, height: 24, 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>
) : (
<>
-1
View File
@@ -60,7 +60,6 @@ export default function SharedPage() {
{loading && (
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 80 }}>
<div style={{ width: 24, height: 24, 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>
)}
+8 -10
View File
@@ -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) {
<div className="aether-content" style={{ flex: 1, overflowY: 'auto' }}>
{loading ? (
<p style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>Loading…</p>
<SkeletonRows />
) : items.length === 0 ? (
<div style={{
padding: 40, textAlign: 'center', borderRadius: 12,
border: '1px dashed var(--color-border)',
}}>
<p style={{ margin: 0, fontSize: 14, color: 'var(--color-text)' }}>Nothing shared with you yet</p>
<p style={{ margin: '6px 0 0', fontSize: 12, color: 'var(--color-text-muted)' }}>
When someone shares a file or folder, it shows up here.
</p>
</div>
<EmptyState
icon="🤝"
title="Nothing shared with you yet"
hint="When someone shares a file or folder with you, it shows up here."
/>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxWidth: 760 }}>
{items.map(item => (
+7 -13
View File
@@ -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
</PageHeader>
<div className="aether-content" style={{ flex: 1, overflowY: 'auto' }}>
{loading && (
<p style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>Loading…</p>
)}
{loading && <SkeletonRows />}
{!loading && !id && (
<Empty text={isFrom
@@ -208,14 +208,8 @@ function CorrespondentGlyph() {
)
}
function Empty({ text }: { text: string }) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '60%', color: 'var(--color-text-muted)', fontSize: 13,
textAlign: 'center', padding: 20,
}}>
{text}
</div>
)
/** 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 <EmptyState icon={icon} title={text} />
}
+10 -10
View File
@@ -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) {
</PageHeader>
<div className="aether-content" style={{ flex: 1, overflowY: 'auto' }}>
{loading && (
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 80 }}>
<div style={{ width: 24, height: 24, 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>
)}
{loading && <SkeletonRows />}
{/* Kein Knopf: Ein leerer Papierkorb ist ein guter Zustand, kein
Mangel, den man beheben müsste. */}
{!loading && items.length === 0 && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: 100, gap: 12 }}>
<div style={{ fontSize: 56, opacity: 0.25 }}>🗑</div>
<p style={{ color: 'var(--color-text-muted)', fontSize: 14, margin: 0 }}>Trash is empty</p>
</div>
<EmptyState
icon="🗑"
title="Trash is empty"
hint="Deleted files land here first and can be restored from it."
/>
)}
{!loading && items.length > 0 && (