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
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
Reference in new issue
Block a user