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:
1 parent
0591a40d12
commit
4208676aa9
8 files changed
+58
-45
No files matched your search
@@ -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 => (
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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. */}
|
||||
|
||||
Reference in new issue
Block a user