fix(ui): Platzhalter blitzt auch beim Tab-Wechsel nicht mehr auf

Die Frist von 220 ms galt nur beim Wechsel *innerhalb* der Dateiansicht. Beim
Wechsel zwischen den Menüpunkten entsteht die Seite jedes Mal neu – damit war
jeder Tab-Wechsel ein „erstes Laden", und genau dort war die Regel „beim ersten
Mal sofort zeigen" falsch herum. Die Frist gilt jetzt immer; 220 ms Leere ist
das ruhigere Bild, man kommt ohnehin von einer anderen Seite.

Als gemeinsamer Haken `useSkeleton`, statt die Logik in fünf Seiten zu
wiederholen.

Dabei wäre beinahe ein schlimmerer Geist entstanden: Im Wartefenster hat kurz
der **Leerzustand** aufgeblitzt – „Nothing shared with you yet", eine
Zehntelsekunde bevor die Liste kam. Der Platzhalter sieht nur nach Daten aus,
ein Satz behauptet etwas. Das Wartefenster bleibt jetzt überall leer.

Dazu drei Reste der Kalender-Übersetzung, die mein Suchmuster übersehen hatte,
weil sie in Template-Strings standen: der Löschen-Dialog des Kalenders und der
Wochentag-Hinweis im Terminfenster. Und das Datum in der Listenansicht stand
noch im deutschen Format („18. September 2026") in einer englischen
Oberfläche – jetzt „September 18, 2026".

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 20:15:45 +02:00
1 parent 0591a40d12
commit 4208676aa9
8 files changed
+58 -45

No files matched your search

+30
View File
@@ -1,3 +1,5 @@
import { useEffect, useState } from 'react'
/**
* Platzhalter, solange geladen wird.
*
@@ -13,6 +15,34 @@
* Zweck wäre dahin.
*/
/**
* Zeigen wir den Platzhalter **wirklich** – oder ist die Liste schneller da?
*
* Ohne Frist blitzt bei jedem Ordnerwechsel und bei jedem Tab-Wechsel kurz ein
* Feld grauer Kästen auf. Sie sehen aus wie Dateien, die sofort wieder
* verschwinden; Marc nannte sie „Geister", und das trifft es: Der Platzhalter
* soll Warten überbrücken, nicht Warten erfinden.
*
* **Die Frist gilt auch beim allerersten Laden.** Das war zuerst anders – ich
* hatte gedacht, beim ersten Mal gebe es ja nichts, was stehenbleiben könnte,
* also könne er sofort kommen. Beim Wechsel zwischen den Menüpunkten entsteht
* die Seite aber jedes Mal neu, und damit war *jeder* Tab-Wechsel ein „erstes
* Laden". Genau dort blitzte es dann. 220 ms Leere ist das ruhigere Bild: Man
* kommt ohnehin von einer anderen Seite, der Wechsel liest sich als Wechsel.
*
* 220 ms ist die Grenze, ab der ein Wechsel als „hakt" empfunden wird –
* darunter merkt niemand, dass überhaupt geladen wurde.
*/
export function useSkeleton(loading: boolean, frist = 220): boolean {
const [zeigen, setZeigen] = useState(false)
useEffect(() => {
if (!loading) { setZeigen(false); return }
const t = setTimeout(() => setZeigen(true), frist)
return () => clearTimeout(t)
}, [loading, frist])
return zeigen
}
/** Ein graues Feld, das leicht pulsiert. */
function Feld({ w, h, r = 6, mb }: { w: number | string; h: number; r?: number; mb?: number }) {
return (
@@ -292,7 +292,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
<button
key={code} type="button" disabled={readOnly || fixed}
onClick={() => toggleDay(code)}
title={fixed ? `${weekdayLong[i]} – Tag des Starts` : weekdayLong[i]}
title={fixed ? `${weekdayLong[i]} – the day the event starts` : weekdayLong[i]}
style={{
flex: 1, padding: '6px 0', borderRadius: 7, fontSize: 12,
border: '1px solid var(--color-border)',
+5 -4
View File
@@ -18,7 +18,7 @@ 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 { SkeletonRows, useSkeleton } from '../components/Skeleton'
import EmptyState from '../components/EmptyState'
type ViewMode = 'month' | 'week' | 'list'
@@ -104,6 +104,7 @@ export default function CalendarPage() {
const [viewMode, setViewMode] = useState<ViewMode>('month')
const [cursor, setCursor] = useState(new Date())
const [loading, setLoading] = useState(true)
const zeigeSkelett = useSkeleton(loading)
const [error, setError] = useState<string | null>(null)
const [showList, setShowList] = useState(() =>
typeof window === 'undefined' ? true : window.matchMedia('(min-width: 900px)').matches)
@@ -341,7 +342,7 @@ export default function CalendarPage() {
: 'All events'
if (loading) {
return <div className="aether-content"><SkeletonRows /></div>
return <div className="aether-content">{zeigeSkelett && <SkeletonRows />}</div>
}
return (
@@ -584,7 +585,7 @@ export default function CalendarPage() {
{deleteCalendar && (
<ConfirmDialog
title="Delete calendar"
message={`"${deleteCalendar.name}" mit allen Terminen darin unwiderruflich löschen?`}
message={`Permanently delete "${deleteCalendar.name}" and every event in it?`}
confirmLabel="Löschen" danger
onConfirm={confirmDeleteCalendar}
onCancel={() => setDeleteCalendar(null)}
@@ -1011,7 +1012,7 @@ function ListAgenda({ events, onEventClick, onEventDown }: {
fontSize: 12.5, fontWeight: 600, marginBottom: 6,
color: isSameDay(day, today) ? 'var(--color-primary)' : 'var(--color-text-muted)',
}}>
{weekdayLabels[(day.getDay() + 6) % 7]}, {day.getDate()}. {monthLabels[day.getMonth()]} {day.getFullYear()}
{weekdayLabels[(day.getDay() + 6) % 7]}, {monthLabels[day.getMonth()]} {day.getDate()}, {day.getFullYear()}
{isSameDay(day, today) && ' · today'}
</div>
{evs.map(ev => (
+4 -32
View File
@@ -12,7 +12,7 @@ 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 { SkeletonGrid, SkeletonRows, useSkeleton } 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'
@@ -95,22 +95,6 @@ export default function FileBrowser({
const [files, setFiles] = useState<FileItem[]>([])
const [folders, setFolders] = useState<FolderItem[]>([])
const [loading, setLoading] = useState(true)
/**
* Zeigen wir den Platzhalter **wirklich**?
*
* Beim Durchklicken von Ordner zu Ordner ist eine Liste in ein paar
* Millisekunden da. Blitzt in dieser Zeit ein Feld grauer Kästen auf,
* entsteht der Eindruck, es seien Dateien – die dann verschwinden. Marc
* nannte sie „Geister", und das trifft es: Der Platzhalter soll Warten
* überbrücken, nicht Warten erfinden.
*
* Also erst nach einer kurzen Frist. Ist die Liste vorher da, wird er nie
* sichtbar; solange bleibt die vorherige stehen (`load` löscht sie nicht).
* Nur beim allerersten Laden kommt er sofort – da gibt es nichts, was
* stehenbleiben könnte, und eine leere Fläche wäre das schlechtere Bild.
*/
const [zeigeSkelett, setZeigeSkelett] = useState(true)
const schonGeladen = useRef(false)
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null)
const [dialog, setDialog] = useState<Dialog | null>(null)
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null)
@@ -166,6 +150,8 @@ export default function FileBrowser({
// ── Laden ──────────────────────────────────────────────────────────────────
const zeigeSkelett = useSkeleton(loading)
const load = useCallback(async () => {
setLoading(true)
try {
@@ -174,7 +160,6 @@ export default function FileBrowser({
setFolders(data.folders ?? [])
setFileTags(data.tags ?? {})
setFileCorrespondents(data.correspondents ?? {})
schonGeladen.current = true
} catch (err) {
// Die Liste bleibt stehen – aber stumm bleibt es nicht mehr: Sonst
// sieht ein fehlgeschlagener Wechsel aus wie ein Ordner, der zufällig
@@ -185,19 +170,6 @@ export default function FileBrowser({
}
}, [currentFolderId])
/**
* Die Frist. 220 ms sind die Grenze, ab der ein Wechsel als „hakt" empfunden
* wird – darunter merkt niemand, dass überhaupt geladen wurde.
*/
const skelettFrist = 220
useEffect(() => {
if (!loading) { setZeigeSkelett(false); return }
if (!schonGeladen.current) { setZeigeSkelett(true); return }
const t = setTimeout(() => setZeigeSkelett(true), skelettFrist)
return () => clearTimeout(t)
}, [loading])
/** Nur die Absender-Zuordnung nachziehen, ohne die Liste flackern zu lassen. */
const reloadCorrespondents = useCallback(async () => {
try {
@@ -697,7 +669,7 @@ export default function FileBrowser({
Layout in dem Moment, in dem die Daten ankommen. */}
{zeigeSkelett && (viewMode === 'grid' ? <SkeletonGrid /> : <SkeletonRows />)}
{!zeigeSkelett && isEmpty && (
{!loading && isEmpty && (
<EmptyState
icon="☁️"
title={currentFolderId ? 'This folder is empty' : 'Nothing here yet'}
+3 -2
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
import { sharesApi, type ShareLink } from '../api/client'
import ConfirmDialog from '../components/ConfirmDialog'
import PageHeader from '../components/PageHeader'
import { SkeletonRows } from '../components/Skeleton'
import { SkeletonRows, useSkeleton } from '../components/Skeleton'
import EmptyState from '../components/EmptyState'
function shareURL(token: string): string {
@@ -22,6 +22,7 @@ export default function SharedPage() {
const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState<string | null>(null)
const [confirmDelete, setConfirmDelete] = useState<ShareLink | null>(null)
const zeigeSkelett = useSkeleton(loading)
useEffect(() => {
sharesApi.list()
@@ -59,7 +60,7 @@ export default function SharedPage() {
/>
<div className="aether-content" style={{ flex: 1, overflowY: 'auto' }}>
{loading && <SkeletonRows />}
{zeigeSkelett && <SkeletonRows />}
{!loading && links.length === 0 && (
<EmptyState
+9 -2
View File
@@ -3,7 +3,7 @@ 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 { SkeletonRows, useSkeleton } from '../components/Skeleton'
import EmptyState from '../components/EmptyState'
interface Props {
@@ -17,6 +17,7 @@ interface Props {
export default function SharedWithMePage({ onOpenFolder }: Props) {
const [items, setItems] = useState<SharedResource[]>([])
const [loading, setLoading] = useState(true)
const zeigeSkelett = useSkeleton(loading)
useEffect(() => {
aclApi.sharedWithMe()
@@ -41,8 +42,14 @@ export default function SharedWithMePage({ onOpenFolder }: Props) {
<div className="aether-content" style={{ flex: 1, overflowY: 'auto' }}>
{loading ? (
{zeigeSkelett ? (
<SkeletonRows />
) : loading ? (
// Das Wartefenster bleibt leer. Ohne diesen Zweig blitzte hier der
// Leerzustand auf – „nichts freigegeben", eine Zehntelsekunde bevor
// die Liste kommt. Das ist die unangenehmere Sorte Geist: Der
// Platzhalter sieht nur nach Daten aus, dieser Satz behauptet etwas.
null
) : items.length === 0 ? (
<EmptyState
icon="🤝"
+3 -2
View File
@@ -3,7 +3,7 @@ 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 { SkeletonRows, useSkeleton } from '../components/Skeleton'
import EmptyState from '../components/EmptyState'
import { formatBytes } from '../format'
@@ -36,6 +36,7 @@ export default function TagsPage({ tagId, correspondentId, onOpenFolder }: Props
const [files, setFiles] = useState<TaggedFile[]>([])
const [loading, setLoading] = useState(true)
const [missing, setMissing] = useState(false)
const zeigeSkelett = useSkeleton(loading)
/** Was gerade gefiltert wird – Etikett oder Absender. */
const subject = correspondentId ? correspondent : tag
@@ -112,7 +113,7 @@ export default function TagsPage({ tagId, correspondentId, onOpenFolder }: Props
</PageHeader>
<div className="aether-content" style={{ flex: 1, overflowY: 'auto' }}>
{loading && <SkeletonRows />}
{zeigeSkelett && <SkeletonRows />}
{!loading && !id && (
<Empty text={isFrom
+3 -2
View File
@@ -3,7 +3,7 @@ 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 { SkeletonRows, useSkeleton } from '../components/Skeleton'
import EmptyState from '../components/EmptyState'
interface Props {
@@ -17,6 +17,7 @@ export default function TrashPage({ onUserChanged }: Props) {
const [confirmDelete, setConfirmDelete] = useState<TrashItem | null>(null)
const [confirmEmpty, setConfirmEmpty] = useState(false)
const [emptying, setEmptying] = useState(false)
const zeigeSkelett = useSkeleton(loading)
useEffect(() => {
trashApi.list()
@@ -76,7 +77,7 @@ export default function TrashPage({ onUserChanged }: Props) {
</PageHeader>
<div className="aether-content" style={{ flex: 1, overflowY: 'auto' }}>
{loading && <SkeletonRows />}
{zeigeSkelett && <SkeletonRows />}
{/* Kein Knopf: Ein leerer Papierkorb ist ein guter Zustand, kein
Mangel, den man beheben müsste. */}