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

+27 -9
View File
@@ -1564,17 +1564,35 @@ gewesen, an dem jemand die letzte Kopie entfernt. Steht jetzt einmal in
- [ ] Kreisel dreht sich noch: Suchfeld tippen, Admin → Audit laden, - [ ] Kreisel dreht sich noch: Suchfeld tippen, Admin → Audit laden,
Versionen-Dialog öffnen 🔎 Versionen-Dialog öffnen 🔎
### Offen: der Sprachmix ### Der Sprachmix ist weg (18.09.2026)
`CalendarPage.tsx` ist durchgehend deutsch („Kalender", „Lädt…", „Keine Die Kalenderseite war durchgehend deutsch, die übrige Oberfläche englisch.
Termine"), die übrige Oberfläche englisch. Betroffen sind sechs Dateien: Jetzt spricht alles Englisch – 80 Stellen in sechs Dateien. Shell, TagChip und
CalendarPage, Shell, calendar/controls, calendar/EventDialog, TagChip, UserMenu standen nur wegen ihrer **Kommentare** auf der Verdachtsliste; deren
UserMenu. Oberfläche war längst englisch.
Bewusst **nicht** nebenbei erledigt: Eine halbe Übersetzung ist schlimmer als Kommentare bleiben deutsch. Das Ersetzen hat Kommentarzeilen deshalb
gar keine, weil dann eine Seite in sich gemischt ist. Das gehört in einem Zug übersprungen.
gemacht – samt der Entscheidung, welche Sprache die Oberfläche eigentlich
spricht. - [ ] Monatsansicht: Wochentage Mon–Sun, Monatsnamen englisch 🔎
- [ ] Umschalter Month / Week / List, Knopf „Today"
- [ ] Termin anlegen: Title, Calendar, All day, Start/End, Repeat, Color,
Location, Description
- [ ] Wiederholung: Does not repeat / Daily / Weekly / Monthly / Yearly,
Ends: Never / After / On a date
- [ ] Serientermin löschen: „Only this event" / „Entire series" / „Cancel"
- [ ] Kalendermenü: Edit / Share… / Export (.ics) / Import (.ics) / Delete
- [ ] Leerer Zeitraum → „No events"
### Der Standardkalender heißt „Personal"
Automatisch angelegt wurde er als „Persönlich". Migration 000027 zieht die
bestehenden nach – **bewusst eng gefasst**: nur Zeilen mit Name *und* Farbe des
Automaten (`#4285f4`). Wer sich selbst einen Kalender „Persönlich" angelegt
hat, behält seinen Namen; das ist seine Beschriftung, nicht unsere.
- [x] Nach der Migration meldet die API `'Personal'` – geprüft, nicht nur
„Migration gelaufen".
--- ---
@@ -0,0 +1,5 @@
-- Zurück auf den deutschen Namen, nach derselben engen Regel.
UPDATE cal_calendars
SET name = 'Persönlich'
WHERE name = 'Personal'
AND color = '#4285f4';
@@ -0,0 +1,11 @@
-- Der automatisch angelegte Kalender hieß "Persönlich", die übrige Oberfläche
-- spricht Englisch. Seit dem 18.09.2026 heißt er "Personal"; diese Migration
-- zieht die bereits angelegten nach.
--
-- **Bewusst eng gefasst:** nur Zeilen, die Name *und* Farbe des Automaten
-- tragen. Wer sich selbst einen Kalender "Persönlich" angelegt hat, behält
-- seinen Namen – das ist seine Beschriftung, nicht unsere.
UPDATE cal_calendars
SET name = 'Personal'
WHERE name = 'Persönlich'
AND color = '#4285f4';
+2 -2
View File
@@ -126,7 +126,7 @@ func isUniqueViolation(err error) bool {
} }
// EnsureDefaultCalendar liefert den ersten Kalender des Nutzers, legt bei // EnsureDefaultCalendar liefert den ersten Kalender des Nutzers, legt bei
// Bedarf "Persönlich" an. So findet ein frisch eingerichteter Client sofort // Bedarf "Personal" an. So findet ein frisch eingerichteter Client sofort
// ein Ziel vor, statt erst ein MKCALENDAR schicken zu müssen – nicht jeder // ein Ziel vor, statt erst ein MKCALENDAR schicken zu müssen – nicht jeder
// CalDAV-Client kann das. // CalDAV-Client kann das.
func (s *Store) EnsureDefaultCalendar(ctx context.Context, ownerID uuid.UUID) (*Calendar, error) { func (s *Store) EnsureDefaultCalendar(ctx context.Context, ownerID uuid.UUID) (*Calendar, error) {
@@ -137,7 +137,7 @@ func (s *Store) EnsureDefaultCalendar(ctx context.Context, ownerID uuid.UUID) (*
if len(cals) > 0 { if len(cals) > 0 {
return &cals[0], nil return &cals[0], nil
} }
return s.CreateCalendar(ctx, ownerID, "Persönlich", "#4285f4", "") return s.CreateCalendar(ctx, ownerID, "Personal", "#4285f4", "")
} }
func (s *Store) CreateCalendar(ctx context.Context, ownerID uuid.UUID, name, color, description string) (*Calendar, error) { func (s *Store) CreateCalendar(ctx context.Context, ownerID uuid.UUID, name, color, description string) (*Calendar, error) {
@@ -0,0 +1,5 @@
-- Zurück auf den deutschen Namen, nach derselben engen Regel.
UPDATE cal_calendars
SET name = 'Persönlich'
WHERE name = 'Personal'
AND color = '#4285f4';
@@ -0,0 +1,11 @@
-- Der automatisch angelegte Kalender hieß "Persönlich", die übrige Oberfläche
-- spricht Englisch. Seit dem 18.09.2026 heißt er "Personal"; diese Migration
-- zieht die bereits angelegten nach.
--
-- **Bewusst eng gefasst:** nur Zeilen, die Name *und* Farbe des Automaten
-- tragen. Wer sich selbst einen Kalender "Persönlich" angelegt hat, behält
-- seinen Namen – das ist seine Beschriftung, nicht unsere.
UPDATE cal_calendars
SET name = 'Personal'
WHERE name = 'Persönlich'
AND color = '#4285f4';
@@ -34,7 +34,7 @@ export default function CalendarDialog({ calendar, onSave, onCancel }: Props) {
borderRadius: 14, padding: 24, width: 340, maxWidth: '100%', borderRadius: 14, padding: 24, width: 340, maxWidth: '100%',
}}> }}>
<h2 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}> <h2 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>
{calendar ? 'Kalender bearbeiten' : 'Neuer Kalender'} {calendar ? 'Edit calendar' : 'New calendar'}
</h2> </h2>
<label style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4, display: 'block' }}>Name</label> <label style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4, display: 'block' }}>Name</label>
@@ -48,7 +48,7 @@ export default function CalendarDialog({ calendar, onSave, onCancel }: Props) {
}} }}
/> />
<label style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 8, display: 'block' }}>Farbe</label> <label style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 8, display: 'block' }}>Color</label>
<div style={{ marginBottom: 20 }}> <div style={{ marginBottom: 20 }}>
<ColorSwatches value={color} onChange={setColor} /> <ColorSwatches value={color} onChange={setColor} />
</div> </div>
@@ -2,10 +2,10 @@ import { useEffect } from 'react'
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss' import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
import type { CalendarEvent } from '../../api/client' import type { CalendarEvent } from '../../api/client'
const weekdayLong = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'] const weekdayLong = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const monthLabels = [ const monthLabels = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'January', 'February', 'March', 'April', 'May', 'June',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', 'July', 'August', 'September', 'October', 'November', 'December',
] ]
export interface DayEvent extends CalendarEvent { export interface DayEvent extends CalendarEvent {
@@ -42,7 +42,7 @@ export default function DayDialog({ day, events, onPick, onCreate, onClose }: {
}, [onClose]) }, [onClose])
const timeLabel = (ev: DayEvent) => { const timeLabel = (ev: DayEvent) => {
if (ev.all_day) return 'ganztägig' if (ev.all_day) return 'all day'
const from = ev.startsAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) const from = ev.startsAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
if (!ev.endsAt) return from if (!ev.endsAt) return from
const to = ev.endsAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) const to = ev.endsAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
@@ -86,7 +86,7 @@ export default function DayDialog({ day, events, onPick, onCreate, onClose }: {
<span style={{ width: 4, alignSelf: 'stretch', borderRadius: 2, background: ev.color, flexShrink: 0 }} /> <span style={{ width: 4, alignSelf: 'stretch', borderRadius: 2, background: ev.color, flexShrink: 0 }} />
<span style={{ flex: 1, minWidth: 0 }}> <span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: 'block', fontSize: 13.5, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <span style={{ display: 'block', fontSize: 13.5, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ev.summary || '(ohne Titel)'} {ev.summary || '(untitled)'}
{ev.recurring && <span style={{ marginLeft: 6, fontSize: 11, color: 'var(--color-text-muted)' }}>↻</span>} {ev.recurring && <span style={{ marginLeft: 6, fontSize: 11, color: 'var(--color-text-muted)' }}>↻</span>}
</span> </span>
<span style={{ display: 'block', fontSize: 11.5, color: 'var(--color-text-muted)' }}> <span style={{ display: 'block', fontSize: 11.5, color: 'var(--color-text-muted)' }}>
@@ -42,17 +42,17 @@ export default function DeleteEventDialog({ summary, recurring, onSingle, onSeri
borderRadius: 14, padding: 24, width: 380, maxWidth: '100%', borderRadius: 14, padding: 24, width: 380, maxWidth: '100%',
}}> }}>
<h2 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}> <h2 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>
{recurring ? 'Serientermin löschen' : 'Termin löschen'} {recurring ? 'Delete recurring event' : 'Delete event'}
</h2> </h2>
<p style={{ margin: '0 0 20px', fontSize: 13.5, color: 'var(--color-text-muted)', lineHeight: 1.5 }}> <p style={{ margin: '0 0 20px', fontSize: 13.5, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
{recurring {recurring
? <>„{summary || 'Ohne Titel'}" gehört zu einer Reihe. Was soll gelöscht werden?</> ? <>„{summary || 'Untitled'}" gehört zu einer Reihe. Was soll gelöscht werden?</>
: <>„{summary || 'Ohne Titel'}" unwiderruflich löschen?</>} : <>„{summary || 'Untitled'}" unwiderruflich löschen?</>}
</p> </p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<button onClick={onSingle} style={danger}> <button onClick={onSingle} style={danger}>
{recurring ? 'Nur diesen Termin' : 'Löschen'} {recurring ? 'Only this event' : 'Delete'}
</button> </button>
{recurring && ( {recurring && (
<button onClick={onSeries} style={danger}> <button onClick={onSeries} style={danger}>
@@ -23,18 +23,18 @@ type FreqChoice = 'NONE' | Recurrence['freq']
type EndChoice = 'NEVER' | 'COUNT' | 'UNTIL' type EndChoice = 'NEVER' | 'COUNT' | 'UNTIL'
const freqLabels: Record<FreqChoice, string> = { const freqLabels: Record<FreqChoice, string> = {
NONE: 'Einmalig', NONE: 'Does not repeat',
DAILY: 'Täglich', DAILY: 'Daily',
WEEKLY: 'Wöchentlich', WEEKLY: 'Weekly',
MONTHLY: 'Monatlich', MONTHLY: 'Monthly',
YEARLY: 'Jährlich', YEARLY: 'Yearly',
} }
const weekdayShort = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] const weekdayShort = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const weekdayLong = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'] const weekdayLong = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
const monthNames = [ const monthNames = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'January', 'February', 'March', 'April', 'May', 'June',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', 'July', 'August', 'September', 'October', 'November', 'December',
] ]
/** „Am 4." bzw. „Am 1. Freitag" – beide aus dem Startdatum abgeleitet, wie /** „Am 4." bzw. „Am 1. Freitag" – beide aus dem Startdatum abgeleitet, wie
@@ -185,7 +185,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
borderRadius: 14, padding: 24, width: 440, maxWidth: '100%', maxHeight: '90vh', overflowY: 'auto', borderRadius: 14, padding: 24, width: 440, maxWidth: '100%', maxHeight: '90vh', overflowY: 'auto',
}}> }}>
<h2 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}> <h2 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>
{event ? (isSeries ? 'Terminreihe bearbeiten' : 'Termin bearbeiten') : 'Neuer Termin'} {event ? (isSeries ? 'Edit series' : 'Edit event') : 'New event'}
</h2> </h2>
{readOnly && ( {readOnly && (
@@ -197,20 +197,20 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
)} )}
{isSeries && !readOnly && ( {isSeries && !readOnly && (
<div style={noteStyle}> <div style={noteStyle}>
Terminreihe: Änderungen gelten für <strong>alle</strong> Termine der Terminreihe: Änderungen gelten für <strong>every</strong> Termine der
Reihe. Angezeigt wird deshalb ihr Beginn, nicht der angeklickte Tag. Reihe. Angezeigt wird deshalb ihr Beginn, nicht der angeklickte Tag.
</div> </div>
)} )}
<div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<label style={labelStyle}>Titel</label> <label style={labelStyle}>Title</label>
<input ref={titleRef} type="text" value={summary} disabled={readOnly} <input ref={titleRef} type="text" value={summary} disabled={readOnly}
onChange={e => setSummary(e.target.value)} style={inputStyle} required /> onChange={e => setSummary(e.target.value)} style={inputStyle} required />
</div> </div>
{calendars.length > 1 && ( {calendars.length > 1 && (
<div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<label style={labelStyle}>Kalender</label> <label style={labelStyle}>Calendar</label>
{/* Auch beim Bearbeiten wählbar: Der Termin wechselt dann beim {/* Auch beim Bearbeiten wählbar: Der Termin wechselt dann beim
Speichern die Sammlung, behält aber seine Kennung – für Speichern die Sammlung, behält aber seine Kennung – für
CalDAV-Clients derselbe Termin an neuer Adresse, nicht ein CalDAV-Clients derselbe Termin an neuer Adresse, nicht ein
@@ -219,7 +219,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
{!!event && calId !== calendarId && ( {!!event && calId !== calendarId && (
<p style={{ margin: '6px 2px 0', fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.4 }}> <p style={{ margin: '6px 2px 0', fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
Wird beim Speichern nach „{calendars.find(c => c.id === calId)?.name}" verschoben Wird beim Speichern nach „{calendars.find(c => c.id === calId)?.name}" verschoben
{isSeries ? ' – mit der ganzen Reihe.' : '.'} {isSeries ? ' – together with the whole series.' : '.'}
</p> </p>
)} )}
</div> </div>
@@ -237,7 +237,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
</div> </div>
{!allDay && ( {!allDay && (
<div style={{ width: 116 }}> <div style={{ width: 116 }}>
<label style={labelStyle}>Uhrzeit</label> <label style={labelStyle}>Time</label>
<input type="time" value={startTime} disabled={readOnly} onChange={e => setStartTime(e.target.value)} style={inputStyle} /> <input type="time" value={startTime} disabled={readOnly} onChange={e => setStartTime(e.target.value)} style={inputStyle} />
</div> </div>
)} )}
@@ -245,13 +245,13 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}> <div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<label style={labelStyle}>Ende</label> <label style={labelStyle}>End</label>
<input type="date" value={endDate} min={startDate} disabled={readOnly} <input type="date" value={endDate} min={startDate} disabled={readOnly}
onChange={e => setEndDate(e.target.value)} style={inputStyle} /> onChange={e => setEndDate(e.target.value)} style={inputStyle} />
</div> </div>
{!allDay && ( {!allDay && (
<div style={{ width: 116 }}> <div style={{ width: 116 }}>
<label style={labelStyle}>Uhrzeit</label> <label style={labelStyle}>Time</label>
<input type="time" value={endTime} disabled={readOnly} onChange={e => setEndTime(e.target.value)} style={inputStyle} /> <input type="time" value={endTime} disabled={readOnly} onChange={e => setEndTime(e.target.value)} style={inputStyle} />
</div> </div>
)} )}
@@ -259,7 +259,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
{/* ── Wiederholung ───────────────────────────────────────────── */} {/* ── Wiederholung ───────────────────────────────────────────── */}
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 14 }}>
<label style={labelStyle}>Wiederholung</label> <label style={labelStyle}>Repeat</label>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<select value={freq} disabled={readOnly} <select value={freq} disabled={readOnly}
onChange={e => setFreq(e.target.value as FreqChoice)} onChange={e => setFreq(e.target.value as FreqChoice)}
@@ -270,7 +270,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
</select> </select>
{freq !== 'NONE' && ( {freq !== 'NONE' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<span style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>alle</span> <span style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>every</span>
<input type="number" min={1} max={99} value={interval} disabled={readOnly} <input type="number" min={1} max={99} value={interval} disabled={readOnly}
onChange={e => setInterval(e.target.value)} onChange={e => setInterval(e.target.value)}
style={{ ...inputStyle, width: 60 }} /> style={{ ...inputStyle, width: 60 }} />
@@ -283,7 +283,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
eine Reihe, die ihren eigenen ersten Termin auslässt, ist keine. */} eine Reihe, die ihren eigenen ersten Termin auslässt, ist keine. */}
{freq === 'WEEKLY' && ( {freq === 'WEEKLY' && (
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 14 }}>
<label style={labelStyle}>An diesen Tagen</label> <label style={labelStyle}>On these days</label>
<div style={{ display: 'flex', gap: 5 }}> <div style={{ display: 'flex', gap: 5 }}>
{weekdayCodes.map((code, i) => { {weekdayCodes.map((code, i) => {
const on = activeDays.includes(code) const on = activeDays.includes(code)
@@ -314,7 +314,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
{/* Monatlich/jährlich: am festen Tag oder am n-ten Wochentag. */} {/* Monatlich/jährlich: am festen Tag oder am n-ten Wochentag. */}
{(freq === 'MONTHLY' || freq === 'YEARLY') && ( {(freq === 'MONTHLY' || freq === 'YEARLY') && (
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 14 }}>
<label style={labelStyle}>Wiederholt sich</label> <label style={labelStyle}>Repeats</label>
<select value={monthMode} disabled={readOnly} <select value={monthMode} disabled={readOnly}
onChange={e => setMonthMode(e.target.value as 'DAY' | 'WEEKDAY')} onChange={e => setMonthMode(e.target.value as 'DAY' | 'WEEKDAY')}
style={inputStyle}> style={inputStyle}>
@@ -326,20 +326,20 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
{freq !== 'NONE' && ( {freq !== 'NONE' && (
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 14 }}>
<label style={labelStyle}>Endet</label> <label style={labelStyle}>Ends</label>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<select value={endChoice} disabled={readOnly} <select value={endChoice} disabled={readOnly}
onChange={e => setEndChoice(e.target.value as EndChoice)} onChange={e => setEndChoice(e.target.value as EndChoice)}
style={{ ...inputStyle, flex: 1 }}> style={{ ...inputStyle, flex: 1 }}>
<option value="NEVER">Nie</option> <option value="NEVER">Never</option>
<option value="COUNT">Nach Anzahl</option> <option value="COUNT">After</option>
<option value="UNTIL">An einem Datum</option> <option value="UNTIL">On a date</option>
</select> </select>
{endChoice === 'COUNT' && ( {endChoice === 'COUNT' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<input type="number" min={1} max={999} value={count} disabled={readOnly} <input type="number" min={1} max={999} value={count} disabled={readOnly}
onChange={e => setCount(e.target.value)} style={{ ...inputStyle, width: 70 }} /> onChange={e => setCount(e.target.value)} style={{ ...inputStyle, width: 70 }} />
<span style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>Mal</span> <span style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>times</span>
</div> </div>
)} )}
{endChoice === 'UNTIL' && ( {endChoice === 'UNTIL' && (
@@ -352,17 +352,17 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
{/* ── Farbe ──────────────────────────────────────────────────── */} {/* ── Farbe ──────────────────────────────────────────────────── */}
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 14 }}>
<label style={labelStyle}>Farbe</label> <label style={labelStyle}>Color</label>
<ColorSwatches value={color} onChange={setColor} inherit={calColor} disabled={readOnly} /> <ColorSwatches value={color} onChange={setColor} inherit={calColor} disabled={readOnly} />
</div> </div>
<div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<label style={labelStyle}>Ort</label> <label style={labelStyle}>Location</label>
<input type="text" value={location} disabled={readOnly} onChange={e => setLocation(e.target.value)} style={inputStyle} /> <input type="text" value={location} disabled={readOnly} onChange={e => setLocation(e.target.value)} style={inputStyle} />
</div> </div>
<div style={{ marginBottom: 20 }}> <div style={{ marginBottom: 20 }}>
<label style={labelStyle}>Beschreibung</label> <label style={labelStyle}>Description</label>
<textarea value={description} disabled={readOnly} onChange={e => setDescription(e.target.value)} <textarea value={description} disabled={readOnly} onChange={e => setDescription(e.target.value)}
rows={3} style={{ ...inputStyle, resize: 'vertical' }} /> rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
</div> </div>
@@ -384,7 +384,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)', padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer', background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
}}> }}>
{readOnly ? 'Schließen' : 'Abbrechen'} {readOnly ? 'Close' : 'Cancel'}
</button> </button>
{!readOnly && ( {!readOnly && (
<button type="submit" style={{ <button type="submit" style={{
@@ -85,7 +85,7 @@ export function ColorSwatches({ value, onChange, inherit, disabled }: {
return ( return (
<div style={{ display: 'flex', gap: 9, flexWrap: 'wrap', alignItems: 'center' }}> <div style={{ display: 'flex', gap: 9, flexWrap: 'wrap', alignItems: 'center' }}>
{inherit !== undefined && dot(inherit, value === '', () => onChange(''), 'inherit', 'Farbe des Kalenders')} {inherit !== undefined && dot(inherit, value === '', () => onChange(''), 'inherit', 'Calendar color')}
{inherit !== undefined && ( {inherit !== undefined && (
<span style={{ width: 1, height: 20, background: 'var(--color-border)', margin: '0 2px' }} /> <span style={{ width: 1, height: 20, background: 'var(--color-border)', margin: '0 2px' }} />
)} )}
@@ -151,7 +151,7 @@ export function CalendarSelect({ calendars, value, onChange, disabled }: {
aria-haspopup="listbox" aria-expanded={open} style={field}> aria-haspopup="listbox" aria-expanded={open} style={field}>
{current && dot(current.color, true)} {current && dot(current.color, true)}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{current?.name ?? 'Kalender wählen'} {current?.name ?? 'Choose calendar'}
</span> </span>
<span style={{ color: 'var(--color-text-muted)', fontSize: 10, flexShrink: 0 }}>▼</span> <span style={{ color: 'var(--color-text-muted)', fontSize: 10, flexShrink: 0 }}>▼</span>
</button> </button>
+28 -31
View File
@@ -23,10 +23,10 @@ import EmptyState from '../components/EmptyState'
type ViewMode = 'month' | 'week' | 'list' 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 = [ const monthLabels = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'January', 'February', 'March', 'April', 'May', 'June',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', 'July', 'August', 'September', 'October', 'November', 'December',
] ]
/** Höhe einer Stunde im Wochenraster. */ /** Höhe einer Stunde im Wochenraster. */
@@ -157,7 +157,7 @@ export default function CalendarPage() {
// eine Ansicht sollte an einer unerwarteten Antwort nicht zerbrechen. // eine Ansicht sollte an einer unerwarteten Antwort nicht zerbrechen.
setCalendars(await calendarApi.list() ?? []) setCalendars(await calendarApi.list() ?? [])
} catch (e) { } catch (e) {
setError(e instanceof APIError ? e.message : 'Laden fehlgeschlagen') setError(e instanceof APIError ? e.message : 'Could not load the calendar')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -233,7 +233,7 @@ export default function CalendarPage() {
setEventDialog(null) setEventDialog(null)
await refreshEvents() await refreshEvents()
} catch (e) { } 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() await refreshEvents()
} catch (e) { } 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) setEventDialog(null)
await refreshEvents() await refreshEvents()
} catch (e) { } 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) setCalDialog(null)
await loadCalendars() await loadCalendars()
} catch (e) { } 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) setDeleteCalendar(null)
await loadCalendars() await loadCalendars()
} catch (e) { } 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 calendarApi.import(calId, file)
await refreshEvents() await refreshEvents()
} catch (err) { } 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) const e = addDays(s, 6)
return `${s.getDate()}.${s.getMonth() + 1}. – ${e.getDate()}.${e.getMonth() + 1}.${e.getFullYear()}` return `${s.getDate()}.${s.getMonth() + 1}. – ${e.getDate()}.${e.getMonth() + 1}.${e.getFullYear()}`
})() })()
: 'Alle Termine' : 'All events'
if (loading) { 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 className="aether-content"><SkeletonRows /></div>
} }
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', minWidth: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100%', minWidth: 0 }}>
<PageHeader <PageHeader
title="Kalender" title="Calendar"
icon={ icon={
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <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" /> <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)', color: viewMode === m ? '#fff' : 'var(--color-text-muted)',
transition: 'background 0.15s, color 0.15s', transition: 'background 0.15s, color 0.15s',
}}> }}>
{m === 'month' ? 'Monat' : m === 'week' ? 'Woche' : 'Liste'} {m === 'month' ? 'Month' : m === 'week' ? 'Week' : 'List'}
</button> </button>
))} ))}
</div> </div>
{viewMode !== 'list' && ( {viewMode !== 'list' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <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> <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={() => 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> </div>
)} )}
@@ -414,8 +411,8 @@ export default function CalendarPage() {
padding: 14, overflowY: 'auto', padding: 14, overflowY: 'auto',
}}> }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}> <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> <span style={{ fontSize: 11, fontWeight: 700, color: 'var(--color-text-muted)', letterSpacing: 0.5 }}>CALENDARS</span>
<button onClick={() => setCalDialog({})} title="Neuer Kalender" style={{ <button onClick={() => setCalDialog({})} title="New calendar" style={{
border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-primary)', border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-primary)',
fontSize: 18, lineHeight: 1, padding: '0 2px', fontSize: 18, lineHeight: 1, padding: '0 2px',
}}>+</button> }}>+</button>
@@ -449,7 +446,7 @@ export default function CalendarPage() {
<button <button
onClick={() => toggleVisible(cal.id)} onClick={() => toggleVisible(cal.id)}
role="switch" aria-checked={on} role="switch" aria-checked={on}
title={on ? 'Ausblenden' : 'Einblenden'} title={on ? 'Hide' : 'Show'}
style={{ style={{
width: 15, height: 15, borderRadius: '50%', flexShrink: 0, padding: 0, width: 15, height: 15, borderRadius: '50%', flexShrink: 0, padding: 0,
border: `2px solid ${cal.color}`, border: `2px solid ${cal.color}`,
@@ -466,7 +463,7 @@ export default function CalendarPage() {
nebeneinander wären sonst nicht zu unterscheiden. */} nebeneinander wären sonst nicht zu unterscheiden. */}
{cal.shared && cal.owner_name && ( {cal.shared && cal.owner_name && (
<span style={{ color: 'var(--color-text-muted)', fontSize: 11.5 }}> <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>
)} )}
</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)', border: `1px solid ${drag.ev.color}`, boxShadow: '0 6px 18px rgba(0,0,0,0.35)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', 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> </div>
)} )}
@@ -586,7 +583,7 @@ export default function CalendarPage() {
{deleteCalendar && ( {deleteCalendar && (
<ConfirmDialog <ConfirmDialog
title="Kalender löschen" title="Delete calendar"
message={`"${deleteCalendar.name}" mit allen Terminen darin unwiderruflich löschen?`} message={`"${deleteCalendar.name}" mit allen Terminen darin unwiderruflich löschen?`}
confirmLabel="Löschen" danger confirmLabel="Löschen" danger
onConfirm={confirmDeleteCalendar} onConfirm={confirmDeleteCalendar}
@@ -707,7 +704,7 @@ function EventChip({ ev, onClick, onDown }: {
}} }}
> >
{!ev.all_day && <span style={{ opacity: 0.75 }}>{timeLabel(ev.startsAt)}</span>} {!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>} {ev.recurring && <span style={{ opacity: 0.7, flexShrink: 0 }}>↻</span>}
</div> </div>
) )
@@ -894,7 +891,7 @@ function WeekGrid({ cursor, byDay, onSlotClick, onOpenDay, onEventClick, onEvent
{/* Ganztägige Termine – eigene Zeile über dem Stundenraster. */} {/* 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={{ 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 => ( {days.map(day => (
<div key={+day} className="aether-cal-cell" onClick={() => onOpenDay(day)} <div key={+day} className="aether-cal-cell" onClick={() => onOpenDay(day)}
style={{ borderLeft: '1px solid var(--color-border)', padding: 2, cursor: 'pointer', minWidth: 0 }}> 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' }}> <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> </div>
{height > 28 && ( {height > 28 && (
<div style={{ fontSize: 9.5, color: 'var(--color-text-muted)' }}>{timeLabel(ev.startsAt)}</div> <div style={{ fontSize: 9.5, color: 'var(--color-text-muted)' }}>{timeLabel(ev.startsAt)}</div>
@@ -999,8 +996,8 @@ function ListAgenda({ events, onEventClick, onEventDown }: {
return ( return (
<EmptyState <EmptyState
icon="📅" icon="📅"
title="Keine Termine" title="No events"
hint="In diesem Zeitraum steht nichts an." 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)', 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]}, {day.getDate()}. {monthLabels[day.getMonth()]} {day.getFullYear()}
{isSameDay(day, today) && ' · heute'} {isSameDay(day, today) && ' · today'}
</div> </div>
{evs.map(ev => ( {evs.map(ev => (
<div key={eventKey(ev)} className="aether-cal-cell" <div key={eventKey(ev)} className="aether-cal-cell"
@@ -1035,7 +1032,7 @@ function ListAgenda({ events, onEventClick, onEventDown }: {
{ev.all_day ? 'ganztg.' : timeLabel(ev.startsAt)} {ev.all_day ? 'ganztg.' : timeLabel(ev.startsAt)}
</span> </span>
<span style={{ fontSize: 13.5, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <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> </span>
{ev.location && ( {ev.location && (
<span style={{ fontSize: 12, color: 'var(--color-text-muted)', marginLeft: 'auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <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 [files, setFiles] = useState<FileItem[]>([])
const [folders, setFolders] = useState<FolderItem[]>([]) const [folders, setFolders] = useState<FolderItem[]>([])
const [loading, setLoading] = useState(true) 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 [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null)
const [dialog, setDialog] = useState<Dialog | null>(null) const [dialog, setDialog] = useState<Dialog | null>(null)
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null) const [dragOverFolder, setDragOverFolder] = useState<string | null>(null)
@@ -158,13 +174,30 @@ export default function FileBrowser({
setFolders(data.folders ?? []) setFolders(data.folders ?? [])
setFileTags(data.tags ?? {}) setFileTags(data.tags ?? {})
setFileCorrespondents(data.correspondents ?? {}) setFileCorrespondents(data.correspondents ?? {})
} catch { schonGeladen.current = true
/* die Liste bleibt stehen – beim nächsten Wechsel wird es neu versucht */ } 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 { } finally {
setLoading(false) setLoading(false)
} }
}, [currentFolderId]) }, [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. */ /** Nur die Absender-Zuordnung nachziehen, ohne die Liste flackern zu lassen. */
const reloadCorrespondents = useCallback(async () => { const reloadCorrespondents = useCallback(async () => {
try { try {
@@ -599,7 +632,7 @@ export default function FileBrowser({
// ── Anzeige ──────────────────────────────────────────────────────────────── // ── 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. */ /** Was Kachel- und Listenansicht gleichermaßen brauchen. */
const viewProps = { const viewProps = {
@@ -662,9 +695,9 @@ export default function FileBrowser({
<div className="aether-content" style={{ flex: 1, overflowY: 'auto' }}> <div className="aether-content" style={{ flex: 1, overflowY: 'auto' }}>
{/* Platzhalter in der Form der gewählten Ansicht – sonst springt das {/* Platzhalter in der Form der gewählten Ansicht – sonst springt das
Layout in dem Moment, in dem die Daten ankommen. */} Layout in dem Moment, in dem die Daten ankommen. */}
{loading && (viewMode === 'grid' ? <SkeletonGrid /> : <SkeletonRows />)} {zeigeSkelett && (viewMode === 'grid' ? <SkeletonGrid /> : <SkeletonRows />)}
{!loading && isEmpty && ( {!zeigeSkelett && isEmpty && (
<EmptyState <EmptyState
icon="☁️" icon="☁️"
title={currentFolderId ? 'This folder is empty' : 'Nothing here yet'} 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} /> viewMode === 'grid' ? <GridView {...viewProps} /> : <ListView {...viewProps} />
)} )}
</div> </div>
+8 -10
View File
@@ -2,6 +2,8 @@ import { useState, useEffect } from 'react'
import { sharesApi, type ShareLink } from '../api/client' import { sharesApi, type ShareLink } from '../api/client'
import ConfirmDialog from '../components/ConfirmDialog' import ConfirmDialog from '../components/ConfirmDialog'
import PageHeader from '../components/PageHeader' import PageHeader from '../components/PageHeader'
import { SkeletonRows } from '../components/Skeleton'
import EmptyState from '../components/EmptyState'
function shareURL(token: string): string { function shareURL(token: string): string {
return `${window.location.origin}/s/${token}` return `${window.location.origin}/s/${token}`
@@ -57,18 +59,14 @@ export default function SharedPage() {
/> />
<div className="aether-content" style={{ flex: 1, overflowY: 'auto' }}> <div className="aether-content" style={{ flex: 1, overflowY: 'auto' }}>
{loading && ( {loading && <SkeletonRows />}
<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 && links.length === 0 && ( {!loading && links.length === 0 && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: 100, gap: 12 }}> <EmptyState
<div style={{ fontSize: 56, opacity: 0.25 }}>🔗</div> icon="🔗"
<p style={{ color: 'var(--color-text-muted)', fontSize: 14, margin: 0 }}>No shared links yet</p> title="No shared links yet"
<p style={{ color: 'var(--color-text-muted)', fontSize: 12, margin: 0 }}>Select a file and click "Share" to create a link</p> hint={<>Right-click a file and choose <strong>Share link…</strong> to create one.</>}
</div> />
)} )}
{!loading && links.length > 0 && ( {!loading && links.length > 0 && (