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:
1 parent
1d10c6bd0e
commit
0591a40d12
14 files changed
+179
-101
No files matched your search
+27
-9
@@ -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,
|
||||
Versionen-Dialog öffnen 🔎
|
||||
|
||||
### Offen: der Sprachmix
|
||||
### Der Sprachmix ist weg (18.09.2026)
|
||||
|
||||
`CalendarPage.tsx` ist durchgehend deutsch („Kalender", „Lädt…", „Keine
|
||||
Termine"), die übrige Oberfläche englisch. Betroffen sind sechs Dateien:
|
||||
CalendarPage, Shell, calendar/controls, calendar/EventDialog, TagChip,
|
||||
UserMenu.
|
||||
Die Kalenderseite war durchgehend deutsch, die übrige Oberfläche englisch.
|
||||
Jetzt spricht alles Englisch – 80 Stellen in sechs Dateien. Shell, TagChip und
|
||||
UserMenu standen nur wegen ihrer **Kommentare** auf der Verdachtsliste; deren
|
||||
Oberfläche war längst englisch.
|
||||
|
||||
Bewusst **nicht** nebenbei erledigt: Eine halbe Übersetzung ist schlimmer als
|
||||
gar keine, weil dann eine Seite in sich gemischt ist. Das gehört in einem Zug
|
||||
gemacht – samt der Entscheidung, welche Sprache die Oberfläche eigentlich
|
||||
spricht.
|
||||
Kommentare bleiben deutsch. Das Ersetzen hat Kommentarzeilen deshalb
|
||||
übersprungen.
|
||||
|
||||
- [ ] 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';
|
||||
@@ -126,7 +126,7 @@ func isUniqueViolation(err error) bool {
|
||||
}
|
||||
|
||||
// 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
|
||||
// CalDAV-Client kann das.
|
||||
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 {
|
||||
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) {
|
||||
|
||||
@@ -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%',
|
||||
}}>
|
||||
<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>
|
||||
|
||||
<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 }}>
|
||||
<ColorSwatches value={color} onChange={setColor} />
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,10 @@ import { useEffect } from 'react'
|
||||
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
|
||||
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 = [
|
||||
'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',
|
||||
]
|
||||
|
||||
export interface DayEvent extends CalendarEvent {
|
||||
@@ -42,7 +42,7 @@ export default function DayDialog({ day, events, onPick, onCreate, onClose }: {
|
||||
}, [onClose])
|
||||
|
||||
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' })
|
||||
if (!ev.endsAt) return from
|
||||
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={{ flex: 1, minWidth: 0 }}>
|
||||
<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>}
|
||||
</span>
|
||||
<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%',
|
||||
}}>
|
||||
<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>
|
||||
<p style={{ margin: '0 0 20px', fontSize: 13.5, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||
{recurring
|
||||
? <>„{summary || 'Ohne Titel'}" gehört zu einer Reihe. Was soll gelöscht werden?</>
|
||||
: <>„{summary || 'Ohne Titel'}" unwiderruflich löschen?</>}
|
||||
? <>„{summary || 'Untitled'}" gehört zu einer Reihe. Was soll gelöscht werden?</>
|
||||
: <>„{summary || 'Untitled'}" unwiderruflich löschen?</>}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button onClick={onSingle} style={danger}>
|
||||
{recurring ? 'Nur diesen Termin' : 'Löschen'}
|
||||
{recurring ? 'Only this event' : 'Delete'}
|
||||
</button>
|
||||
{recurring && (
|
||||
<button onClick={onSeries} style={danger}>
|
||||
|
||||
@@ -23,18 +23,18 @@ type FreqChoice = 'NONE' | Recurrence['freq']
|
||||
type EndChoice = 'NEVER' | 'COUNT' | 'UNTIL'
|
||||
|
||||
const freqLabels: Record<FreqChoice, string> = {
|
||||
NONE: 'Einmalig',
|
||||
DAILY: 'Täglich',
|
||||
WEEKLY: 'Wöchentlich',
|
||||
MONTHLY: 'Monatlich',
|
||||
YEARLY: 'Jährlich',
|
||||
NONE: 'Does not repeat',
|
||||
DAILY: 'Daily',
|
||||
WEEKLY: 'Weekly',
|
||||
MONTHLY: 'Monthly',
|
||||
YEARLY: 'Yearly',
|
||||
}
|
||||
|
||||
const weekdayShort = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
const weekdayLong = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']
|
||||
const weekdayShort = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
const weekdayLong = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
||||
const monthNames = [
|
||||
'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',
|
||||
]
|
||||
|
||||
/** „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',
|
||||
}}>
|
||||
<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>
|
||||
|
||||
{readOnly && (
|
||||
@@ -197,20 +197,20 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
)}
|
||||
{isSeries && !readOnly && (
|
||||
<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.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>Titel</label>
|
||||
<label style={labelStyle}>Title</label>
|
||||
<input ref={titleRef} type="text" value={summary} disabled={readOnly}
|
||||
onChange={e => setSummary(e.target.value)} style={inputStyle} required />
|
||||
</div>
|
||||
|
||||
{calendars.length > 1 && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>Kalender</label>
|
||||
<label style={labelStyle}>Calendar</label>
|
||||
{/* Auch beim Bearbeiten wählbar: Der Termin wechselt dann beim
|
||||
Speichern die Sammlung, behält aber seine Kennung – für
|
||||
CalDAV-Clients derselbe Termin an neuer Adresse, nicht ein
|
||||
@@ -219,7 +219,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
{!!event && calId !== calendarId && (
|
||||
<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
|
||||
{isSeries ? ' – mit der ganzen Reihe.' : '.'}
|
||||
{isSeries ? ' – together with the whole series.' : '.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -237,7 +237,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
</div>
|
||||
{!allDay && (
|
||||
<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} />
|
||||
</div>
|
||||
)}
|
||||
@@ -245,13 +245,13 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Ende</label>
|
||||
<label style={labelStyle}>End</label>
|
||||
<input type="date" value={endDate} min={startDate} disabled={readOnly}
|
||||
onChange={e => setEndDate(e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
{!allDay && (
|
||||
<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} />
|
||||
</div>
|
||||
)}
|
||||
@@ -259,7 +259,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
|
||||
{/* ── Wiederholung ───────────────────────────────────────────── */}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={labelStyle}>Wiederholung</label>
|
||||
<label style={labelStyle}>Repeat</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<select value={freq} disabled={readOnly}
|
||||
onChange={e => setFreq(e.target.value as FreqChoice)}
|
||||
@@ -270,7 +270,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
</select>
|
||||
{freq !== 'NONE' && (
|
||||
<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}
|
||||
onChange={e => setInterval(e.target.value)}
|
||||
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. */}
|
||||
{freq === 'WEEKLY' && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={labelStyle}>An diesen Tagen</label>
|
||||
<label style={labelStyle}>On these days</label>
|
||||
<div style={{ display: 'flex', gap: 5 }}>
|
||||
{weekdayCodes.map((code, i) => {
|
||||
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. */}
|
||||
{(freq === 'MONTHLY' || freq === 'YEARLY') && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={labelStyle}>Wiederholt sich</label>
|
||||
<label style={labelStyle}>Repeats</label>
|
||||
<select value={monthMode} disabled={readOnly}
|
||||
onChange={e => setMonthMode(e.target.value as 'DAY' | 'WEEKDAY')}
|
||||
style={inputStyle}>
|
||||
@@ -326,20 +326,20 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
|
||||
{freq !== 'NONE' && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={labelStyle}>Endet</label>
|
||||
<label style={labelStyle}>Ends</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<select value={endChoice} disabled={readOnly}
|
||||
onChange={e => setEndChoice(e.target.value as EndChoice)}
|
||||
style={{ ...inputStyle, flex: 1 }}>
|
||||
<option value="NEVER">Nie</option>
|
||||
<option value="COUNT">Nach Anzahl</option>
|
||||
<option value="UNTIL">An einem Datum</option>
|
||||
<option value="NEVER">Never</option>
|
||||
<option value="COUNT">After</option>
|
||||
<option value="UNTIL">On a date</option>
|
||||
</select>
|
||||
{endChoice === 'COUNT' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||
<input type="number" min={1} max={999} value={count} disabled={readOnly}
|
||||
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>
|
||||
)}
|
||||
{endChoice === 'UNTIL' && (
|
||||
@@ -352,17 +352,17 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
|
||||
{/* ── Farbe ──────────────────────────────────────────────────── */}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={labelStyle}>Farbe</label>
|
||||
<label style={labelStyle}>Color</label>
|
||||
<ColorSwatches value={color} onChange={setColor} inherit={calColor} disabled={readOnly} />
|
||||
</div>
|
||||
|
||||
<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} />
|
||||
</div>
|
||||
|
||||
<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)}
|
||||
rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
|
||||
</div>
|
||||
@@ -384,7 +384,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)',
|
||||
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
|
||||
}}>
|
||||
{readOnly ? 'Schließen' : 'Abbrechen'}
|
||||
{readOnly ? 'Close' : 'Cancel'}
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button type="submit" style={{
|
||||
|
||||
@@ -85,7 +85,7 @@ export function ColorSwatches({ value, onChange, inherit, disabled }: {
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<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}>
|
||||
{current && dot(current.color, true)}
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{current?.name ?? 'Kalender wählen'}
|
||||
{current?.name ?? 'Choose calendar'}
|
||||
</span>
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: 10, flexShrink: 0 }}>▼</span>
|
||||
</button>
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in new issue
Block a user