feat(calendar): Oberfläche auf Englisch, Standardkalender heißt „Personal"

Die Kalenderseite war durchgehend deutsch, die übrige Oberfläche englisch –
80 Stellen in sechs Dateien. Kommentare bleiben deutsch; das Ersetzen hat
Kommentarzeilen übersprungen.

Shell, TagChip und UserMenu standen nur wegen ihrer Kommentare auf der
Verdachtsliste, deren Oberfläche war längst englisch.

Der automatisch angelegte Kalender hieß „Persönlich" und heißt jetzt
„Personal". Migration 000027 zieht die bestehenden nach, bewusst eng gefasst:
nur Zeilen mit Name *und* Farbe des Automaten. Wer sich selbst einen Kalender
„Persönlich" angelegt hat, behält seinen Namen – das ist seine Beschriftung,
nicht unsere. Nachgeprüft: Die API meldet danach 'Personal', nicht nur
„Migration gelaufen".

Dazu zwei Punkte aus Marcs Rückmeldung:

fix(ui): Der Leerzustand der Freigabe-Links stand auf anderer Höhe als die
übrigen – die Seite hatte ich beim Umbau übersehen. Nutzt jetzt EmptyState
wie alle anderen.

fix(ui): Beim Durchklicken von Ordner zu Ordner blitzte für einen Moment ein
Feld grauer Kästen auf. Sie sahen aus wie Dateien, die gleich wieder
verschwinden – „Geister". Der Platzhalter kommt jetzt erst nach 220 ms; ist
die Liste vorher da, wird er nie sichtbar, und 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.

Nebenbei: Ein fehlgeschlagenes Laden eines Ordners ist nicht mehr stumm.
Vorher sah es aus wie ein Ordner, der zufällig denselben Inhalt hat wie der
vorige.

Geprüft: go build/vet sauber, 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 18:38:30 +02:00
1 parent 1d10c6bd0e
commit 0591a40d12
14 files changed
+179 -101

No files matched your search

+28 -31
View File
@@ -23,10 +23,10 @@ import EmptyState from '../components/EmptyState'
type ViewMode = 'month' | 'week' | 'list'
const weekdayLabels = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const weekdayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const monthLabels = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
]
/** Höhe einer Stunde im Wochenraster. */
@@ -157,7 +157,7 @@ export default function CalendarPage() {
// eine Ansicht sollte an einer unerwarteten Antwort nicht zerbrechen.
setCalendars(await calendarApi.list() ?? [])
} catch (e) {
setError(e instanceof APIError ? e.message : 'Laden fehlgeschlagen')
setError(e instanceof APIError ? e.message : 'Could not load the calendar')
} finally {
setLoading(false)
}
@@ -233,7 +233,7 @@ export default function CalendarPage() {
setEventDialog(null)
await refreshEvents()
} catch (e) {
setError(e instanceof APIError ? e.message : 'Speichern fehlgeschlagen')
setError(e instanceof APIError ? e.message : 'Could not save')
}
}
@@ -251,7 +251,7 @@ export default function CalendarPage() {
})
await refreshEvents()
} catch (e) {
setError(e instanceof APIError ? e.message : 'Verschieben fehlgeschlagen')
setError(e instanceof APIError ? e.message : 'Could not move the event')
}
}
@@ -276,7 +276,7 @@ export default function CalendarPage() {
setEventDialog(null)
await refreshEvents()
} catch (e) {
setError(e instanceof APIError ? e.message : 'Löschen fehlgeschlagen')
setError(e instanceof APIError ? e.message : 'Could not delete')
}
}
@@ -290,7 +290,7 @@ export default function CalendarPage() {
setCalDialog(null)
await loadCalendars()
} catch (e) {
setError(e instanceof APIError ? e.message : 'Speichern fehlgeschlagen')
setError(e instanceof APIError ? e.message : 'Could not save')
}
}
@@ -301,7 +301,7 @@ export default function CalendarPage() {
setDeleteCalendar(null)
await loadCalendars()
} catch (e) {
setError(e instanceof APIError ? e.message : 'Löschen fehlgeschlagen')
setError(e instanceof APIError ? e.message : 'Could not delete')
}
}
@@ -318,7 +318,7 @@ export default function CalendarPage() {
await calendarApi.import(calId, file)
await refreshEvents()
} catch (err) {
setError(err instanceof APIError ? err.message : 'Import fehlgeschlagen')
setError(err instanceof APIError ? err.message : 'Import failed')
}
}
@@ -338,19 +338,16 @@ export default function CalendarPage() {
const e = addDays(s, 6)
return `${s.getDate()}.${s.getMonth() + 1}. – ${e.getDate()}.${e.getMonth() + 1}.${e.getFullYear()}`
})()
: 'Alle Termine'
: 'All events'
if (loading) {
// 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 (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', minWidth: 0 }}>
<PageHeader
title="Kalender"
title="Calendar"
icon={
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="5" width="18" height="16" rx="2" />
@@ -377,17 +374,17 @@ export default function CalendarPage() {
color: viewMode === m ? '#fff' : 'var(--color-text-muted)',
transition: 'background 0.15s, color 0.15s',
}}>
{m === 'month' ? 'Monat' : m === 'week' ? 'Woche' : 'Liste'}
{m === 'month' ? 'Month' : m === 'week' ? 'Week' : 'List'}
</button>
))}
</div>
{viewMode !== 'list' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button onClick={() => stepPeriod(-1)} style={navBtnStyle} aria-label="zurück">‹</button>
<button onClick={() => stepPeriod(-1)} style={navBtnStyle} aria-label="back">‹</button>
<span style={{ fontSize: 13, color: 'var(--color-text)', minWidth: 140, textAlign: 'center' }}>{periodLabel}</span>
<button onClick={() => stepPeriod(1)} style={navBtnStyle} aria-label="weiter">›</button>
<button onClick={() => setCursor(new Date())} style={{ ...navBtnStyle, width: 'auto', padding: '0 10px', fontSize: 12 }}>Heute</button>
<button onClick={() => setCursor(new Date())} style={{ ...navBtnStyle, width: 'auto', padding: '0 10px', fontSize: 12 }}>Today</button>
</div>
)}
@@ -414,8 +411,8 @@ export default function CalendarPage() {
padding: 14, overflowY: 'auto',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--color-text-muted)', letterSpacing: 0.5 }}>KALENDER</span>
<button onClick={() => setCalDialog({})} title="Neuer Kalender" style={{
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--color-text-muted)', letterSpacing: 0.5 }}>CALENDARS</span>
<button onClick={() => setCalDialog({})} title="New calendar" style={{
border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-primary)',
fontSize: 18, lineHeight: 1, padding: '0 2px',
}}>+</button>
@@ -449,7 +446,7 @@ export default function CalendarPage() {
<button
onClick={() => toggleVisible(cal.id)}
role="switch" aria-checked={on}
title={on ? 'Ausblenden' : 'Einblenden'}
title={on ? 'Hide' : 'Show'}
style={{
width: 15, height: 15, borderRadius: '50%', flexShrink: 0, padding: 0,
border: `2px solid ${cal.color}`,
@@ -466,7 +463,7 @@ export default function CalendarPage() {
nebeneinander wären sonst nicht zu unterscheiden. */}
{cal.shared && cal.owner_name && (
<span style={{ color: 'var(--color-text-muted)', fontSize: 11.5 }}>
{' · '}{cal.owner_name}{cal.permission === 'read' ? ' (nur lesen)' : ''}
{' · '}{cal.owner_name}{cal.permission === 'read' ? ' (read-only)' : ''}
</span>
)}
</span>
@@ -540,7 +537,7 @@ export default function CalendarPage() {
border: `1px solid ${drag.ev.color}`, boxShadow: '0 6px 18px rgba(0,0,0,0.35)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{drag.ev.summary || '(ohne Titel)'}{drag.ev.recurring ? ' ↻ (ganze Reihe)' : ''}
{drag.ev.summary || '(untitled)'}{drag.ev.recurring ? ' ↻ (entire series)' : ''}
</div>
)}
@@ -586,7 +583,7 @@ export default function CalendarPage() {
{deleteCalendar && (
<ConfirmDialog
title="Kalender löschen"
title="Delete calendar"
message={`"${deleteCalendar.name}" mit allen Terminen darin unwiderruflich löschen?`}
confirmLabel="Löschen" danger
onConfirm={confirmDeleteCalendar}
@@ -707,7 +704,7 @@ function EventChip({ ev, onClick, onDown }: {
}}
>
{!ev.all_day && <span style={{ opacity: 0.75 }}>{timeLabel(ev.startsAt)}</span>}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{ev.summary || '(ohne Titel)'}</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{ev.summary || '(untitled)'}</span>
{ev.recurring && <span style={{ opacity: 0.7, flexShrink: 0 }}>↻</span>}
</div>
)
@@ -894,7 +891,7 @@ function WeekGrid({ cursor, byDay, onSlotClick, onOpenDay, onEventClick, onEvent
{/* Ganztägige Termine – eigene Zeile über dem Stundenraster. */}
<div style={{ display: 'grid', gridTemplateColumns: cols, borderBottom: '1px solid var(--color-border)', flexShrink: 0, minHeight: 26 }}>
<div style={{ fontSize: 9.5, color: 'var(--color-text-muted)', padding: '4px 6px', textAlign: 'right' }}>ganztg.</div>
<div style={{ fontSize: 9.5, color: 'var(--color-text-muted)', padding: '4px 6px', textAlign: 'right' }}>all-day</div>
{days.map(day => (
<div key={+day} className="aether-cal-cell" onClick={() => onOpenDay(day)}
style={{ borderLeft: '1px solid var(--color-border)', padding: 2, cursor: 'pointer', minWidth: 0 }}>
@@ -960,7 +957,7 @@ function WeekGrid({ cursor, byDay, onSlotClick, onOpenDay, onEventClick, onEvent
}}
>
<div style={{ fontSize: 10.5, color: ev.color, fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{ev.summary || '(ohne Titel)'}{ev.recurring ? ' ↻' : ''}
{ev.summary || '(untitled)'}{ev.recurring ? ' ↻' : ''}
</div>
{height > 28 && (
<div style={{ fontSize: 9.5, color: 'var(--color-text-muted)' }}>{timeLabel(ev.startsAt)}</div>
@@ -999,8 +996,8 @@ function ListAgenda({ events, onEventClick, onEventDown }: {
return (
<EmptyState
icon="📅"
title="Keine Termine"
hint="In diesem Zeitraum steht nichts an."
title="No events"
hint="Nothing scheduled in this period."
/>
)
}
@@ -1015,7 +1012,7 @@ function ListAgenda({ events, onEventClick, onEventDown }: {
color: isSameDay(day, today) ? 'var(--color-primary)' : 'var(--color-text-muted)',
}}>
{weekdayLabels[(day.getDay() + 6) % 7]}, {day.getDate()}. {monthLabels[day.getMonth()]} {day.getFullYear()}
{isSameDay(day, today) && ' · heute'}
{isSameDay(day, today) && ' · today'}
</div>
{evs.map(ev => (
<div key={eventKey(ev)} className="aether-cal-cell"
@@ -1035,7 +1032,7 @@ function ListAgenda({ events, onEventClick, onEventDown }: {
{ev.all_day ? 'ganztg.' : timeLabel(ev.startsAt)}
</span>
<span style={{ fontSize: 13.5, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ev.summary || '(ohne Titel)'}{ev.recurring ? ' ↻' : ''}
{ev.summary || '(untitled)'}{ev.recurring ? ' ↻' : ''}
</span>
{ev.location && (
<span style={{ fontSize: 12, color: 'var(--color-text-muted)', marginLeft: 'auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
+39 -6
View File
@@ -95,6 +95,22 @@ 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)
@@ -158,13 +174,30 @@ export default function FileBrowser({
setFolders(data.folders ?? [])
setFileTags(data.tags ?? {})
setFileCorrespondents(data.correspondents ?? {})
} catch {
/* die Liste bleibt stehen – beim nächsten Wechsel wird es neu versucht */
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
// denselben Inhalt hat wie der vorige.
toast.error(fehlertext(err, 'Could not load this folder'))
} finally {
setLoading(false)
}
}, [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 {
@@ -599,7 +632,7 @@ export default function FileBrowser({
// ── Anzeige ────────────────────────────────────────────────────────────────
const isEmpty = !loading && files.length === 0 && folders.length === 0
const isEmpty = files.length === 0 && folders.length === 0
/** Was Kachel- und Listenansicht gleichermaßen brauchen. */
const viewProps = {
@@ -662,9 +695,9 @@ export default function FileBrowser({
<div className="aether-content" style={{ flex: 1, overflowY: 'auto' }}>
{/* 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 />)}
{zeigeSkelett && (viewMode === 'grid' ? <SkeletonGrid /> : <SkeletonRows />)}
{!loading && isEmpty && (
{!zeigeSkelett && isEmpty && (
<EmptyState
icon="☁️"
title={currentFolderId ? 'This folder is empty' : 'Nothing here yet'}
@@ -673,7 +706,7 @@ export default function FileBrowser({
/>
)}
{!loading && !isEmpty && (
{!zeigeSkelett && !isEmpty && (
viewMode === 'grid' ? <GridView {...viewProps} /> : <ListView {...viewProps} />
)}
</div>
+8 -10
View File
@@ -2,6 +2,8 @@ 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 EmptyState from '../components/EmptyState'
function shareURL(token: string): string {
return `${window.location.origin}/s/${token}`
@@ -57,18 +59,14 @@ export default function SharedPage() {
/>
<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>
)}
{loading && <SkeletonRows />}
{!loading && links.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 }}>No shared links yet</p>
<p style={{ color: 'var(--color-text-muted)', fontSize: 12, margin: 0 }}>Select a file and click "Share" to create a link</p>
</div>
<EmptyState
icon="🔗"
title="No shared links yet"
hint={<>Right-click a file and choose <strong>Share link…</strong> to create one.</>}
/>
)}
{!loading && links.length > 0 && (