Files
Aether/frontend/src/components/calendar/EventDialog.tsx
T
marcandClaude Opus 5 0591a40d12 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>
2026-09-18 18:38:30 +02:00

403 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState, type CSSProperties, type FormEvent } from 'react'
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
import type { CalendarEvent, CalendarItem, EventInput, Recurrence, WeekdayCode } from '../../api/client'
import {
addDays, allDayISO, dateFromInput, eventDay, nthOfMonth, timedISO,
toDateInput, toTimeInput, weekdayCodeOf, weekdayCodes,
} from './dates'
import { CalendarSelect, ColorSwatches, Toggle } from './controls'
interface Props {
/** Fehlt er, entsteht ein neuer Termin. */
event?: CalendarEvent
/** Vorbelegter Start – aus dem angeklickten Tag bzw. der Stunde. */
defaultStart: Date
calendars: CalendarItem[]
calendarId: string
onSave: (input: EventInput, calendarId: string) => void
onDelete?: () => void
onCancel: () => void
}
type FreqChoice = 'NONE' | Recurrence['freq']
type EndChoice = 'NEVER' | 'COUNT' | 'UNTIL'
const freqLabels: Record<FreqChoice, string> = {
NONE: 'Does not repeat',
DAILY: 'Daily',
WEEKLY: 'Weekly',
MONTHLY: 'Monthly',
YEARLY: 'Yearly',
}
const weekdayShort = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const weekdayLong = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
]
/** „Am 4." bzw. „Am 1. Freitag" – beide aus dem Startdatum abgeleitet, wie
* man es aus anderen Kalendern kennt. Bei jährlich kommt der Monat dazu. */
function monthlyModeLabels(start: Date, yearly: boolean): { day: string; weekday: string } {
const n = nthOfMonth(start)
const nth = n === -1 ? 'letzten' : `${n}.`
const wd = weekdayLong[(start.getDay() + 6) % 7]
const month = monthNames[start.getMonth()]
return yearly
? { day: `Am ${start.getDate()}. ${month}`, weekday: `Am ${nth} ${wd} im ${month}` }
: { day: `Am ${start.getDate()}.`, weekday: `Am ${nth} ${wd}` }
}
export default function EventDialog({ event, defaultStart, calendars, calendarId, onSave, onDelete, onCancel }: Props) {
const overlay = useOverlayDismiss(onCancel)
const titleRef = useRef<HTMLInputElement>(null)
const allDayInit = event?.all_day ?? false
// Bei einer Reihe bearbeitet das Formular die **Reihe**, nicht den
// angeklickten Einzeltermin – deshalb series_start, wo vorhanden.
const baseStartISO = event?.series_start ?? event?.start
const baseEndISO = event?.series_end ?? event?.end
const start = baseStartISO ? eventDay(baseStartISO, allDayInit) : defaultStart
const startTimed = baseStartISO && !allDayInit ? new Date(baseStartISO) : defaultStart
const end = baseEndISO
? eventDay(baseEndISO, allDayInit)
: new Date(startTimed.getTime() + 60 * 60 * 1000)
const [calId, setCalId] = useState(calendarId)
const [summary, setSummary] = useState(event?.summary ?? '')
const [description, setDescription] = useState(event?.description ?? '')
const [location, setLocation] = useState(event?.location ?? '')
const [color, setColor] = useState(event?.color ?? '')
const [allDay, setAllDay] = useState(allDayInit)
const [startDate, setStartDate] = useState(toDateInput(start))
const [startTime, setStartTime] = useState(toTimeInput(startTimed))
const [endDate, setEndDate] = useState(toDateInput(end))
const [endTime, setEndTime] = useState(toTimeInput(end))
const rec = event?.recurrence
const [freq, setFreq] = useState<FreqChoice>(rec?.freq ?? 'NONE')
const [interval, setInterval] = useState(String(rec?.interval ?? 1))
const [endChoice, setEndChoice] = useState<EndChoice>(
rec?.count ? 'COUNT' : rec?.until ? 'UNTIL' : 'NEVER')
const [count, setCount] = useState(String(rec?.count ?? 10))
const [until, setUntil] = useState(
rec?.until ? toDateInput(eventDay(rec.until, true)) : toDateInput(addDays(start, 90)))
const [byday, setByday] = useState<WeekdayCode[]>(rec?.byday ?? [])
const [monthMode, setMonthMode] = useState<'DAY' | 'WEEKDAY'>(rec?.mode === 'WEEKDAY' ? 'WEEKDAY' : 'DAY')
// Bezugstag für alles, was sich aus dem Start ableitet („am 1. Freitag").
// Mittags, damit ein Sommerzeitsprung das Datum nicht kippt.
const startDay = dateFromInput(startDate)
const startWeekday = weekdayCodeOf(startDay)
// Der Wochentag des Starts ist immer dabei und lässt sich nicht abwählen –
// sonst zeigte die Reihe nicht mehr auf ihren eigenen ersten Termin.
const activeDays: WeekdayCode[] = Array.from(new Set([...byday, startWeekday]))
const modeLabels = monthlyModeLabels(startDay, freq === 'YEARLY')
useEffect(() => { titleRef.current?.focus() }, [])
useEffect(() => {
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onCancel() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onCancel])
// Nur Regeln, die dieses Formular vollständig abbildet, sind bearbeitbar.
// Alles andere ("jeden zweiten Dienstag im Monat") bliebe beim Speichern
// auf der Strecke – der Server lehnt es deshalb auch ab.
const readOnly = !!event?.recurring && !event?.recurrence
const isSeries = !!event?.recurring
function changeStartDate(v: string) {
const shift = (+new Date(v) - +new Date(startDate)) / 86400000
setStartDate(v)
if (Number.isFinite(shift) && shift !== 0) {
setEndDate(toDateInput(addDays(new Date(`${endDate}T12:00:00`), shift)))
}
}
function buildRecurrence(): Recurrence | null {
if (freq === 'NONE') return null
const r: Recurrence = { freq, interval: Math.max(1, parseInt(interval, 10) || 1) }
if (endChoice === 'COUNT') r.count = Math.max(1, parseInt(count, 10) || 1)
if (endChoice === 'UNTIL') r.until = allDayISO(until)
if (freq === 'WEEKLY') r.byday = activeDays
if (freq === 'MONTHLY' || freq === 'YEARLY') r.mode = monthMode
return r
}
function toggleDay(code: WeekdayCode) {
if (code === startWeekday) return
setByday(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code])
}
function handleSubmit(e: FormEvent) {
e.preventDefault()
if (readOnly || !summary.trim()) return
let startISO: string, endISO: string
if (allDay) {
startISO = allDayISO(startDate)
endISO = allDayISO(endDate < startDate ? startDate : endDate)
} else {
startISO = timedISO(startDate, startTime)
endISO = timedISO(endDate, endTime)
if (endISO <= startISO) {
endISO = new Date(+new Date(startISO) + 60 * 60 * 1000).toISOString()
}
}
onSave({
summary: summary.trim(),
description: description.trim() || undefined,
location: location.trim() || undefined,
all_day: allDay,
color: color || undefined,
recurrence: buildRecurrence(),
start: startISO,
end: endISO,
}, calId)
}
const inputStyle: CSSProperties = {
width: '100%', boxSizing: 'border-box',
background: 'var(--color-surface-2)', border: '1px solid var(--color-border)',
borderRadius: 8, padding: '8px 10px', fontSize: 13.5, color: 'var(--color-text)',
outline: 'none',
}
const labelStyle: CSSProperties = {
fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4, display: 'block',
}
const noteStyle: CSSProperties = {
background: 'var(--color-surface-2)', border: '1px solid var(--color-border)',
borderRadius: 8, padding: '8px 10px', fontSize: 12.5,
color: 'var(--color-text-muted)', marginBottom: 14, lineHeight: 1.45,
}
const calColor = calendars.find(c => c.id === calId)?.color ?? '#4285f4'
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 200, display: 'flex',
alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.6)', padding: 16,
}} {...overlay}>
<form onSubmit={handleSubmit} style={{
background: 'var(--color-surface)', border: '1px solid var(--color-border)',
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 ? 'Edit series' : 'Edit event') : 'New event'}
</h2>
{readOnly && (
<div style={noteStyle}>
Diese Wiederholungsregel kann dieses Formular nicht vollständig
abbilden – zum Ändern einen CalDAV-Client verwenden (z. B. DAVx5).
Der Termin bleibt unverändert erhalten.
</div>
)}
{isSeries && !readOnly && (
<div style={noteStyle}>
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}>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}>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
neuer. */}
<CalendarSelect calendars={calendars} value={calId} onChange={setCalId} disabled={readOnly} />
{!!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 ? ' – together with the whole series.' : '.'}
</p>
)}
</div>
)}
<div style={{ marginBottom: 14 }}>
<Toggle checked={allDay} onChange={setAllDay} disabled={readOnly} label="Ganztägig" />
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Start</label>
<input type="date" value={startDate} disabled={readOnly}
onChange={e => changeStartDate(e.target.value)} style={inputStyle} />
</div>
{!allDay && (
<div style={{ width: 116 }}>
<label style={labelStyle}>Time</label>
<input type="time" value={startTime} disabled={readOnly} onChange={e => setStartTime(e.target.value)} style={inputStyle} />
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
<div style={{ flex: 1 }}>
<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}>Time</label>
<input type="time" value={endTime} disabled={readOnly} onChange={e => setEndTime(e.target.value)} style={inputStyle} />
</div>
)}
</div>
{/* ── Wiederholung ───────────────────────────────────────────── */}
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>Repeat</label>
<div style={{ display: 'flex', gap: 8 }}>
<select value={freq} disabled={readOnly}
onChange={e => setFreq(e.target.value as FreqChoice)}
style={{ ...inputStyle, flex: 1 }}>
{(Object.keys(freqLabels) as FreqChoice[]).map(f => (
<option key={f} value={f}>{freqLabels[f]}</option>
))}
</select>
{freq !== 'NONE' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<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 }} />
</div>
)}
</div>
</div>
{/* Wöchentlich: an welchen Tagen. Der Starttag ist fest an –
eine Reihe, die ihren eigenen ersten Termin auslässt, ist keine. */}
{freq === 'WEEKLY' && (
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>On these days</label>
<div style={{ display: 'flex', gap: 5 }}>
{weekdayCodes.map((code, i) => {
const on = activeDays.includes(code)
const fixed = code === startWeekday
return (
<button
key={code} type="button" disabled={readOnly || fixed}
onClick={() => toggleDay(code)}
title={fixed ? `${weekdayLong[i]} – Tag des Starts` : weekdayLong[i]}
style={{
flex: 1, padding: '6px 0', borderRadius: 7, fontSize: 12,
border: '1px solid var(--color-border)',
background: on ? 'var(--color-primary)' : 'transparent',
color: on ? '#fff' : 'var(--color-text-muted)',
cursor: readOnly || fixed ? 'default' : 'pointer',
opacity: fixed && !readOnly ? 0.85 : 1,
transition: 'background 0.12s, color 0.12s',
}}
>
{weekdayShort[i]}
</button>
)
})}
</div>
</div>
)}
{/* Monatlich/jährlich: am festen Tag oder am n-ten Wochentag. */}
{(freq === 'MONTHLY' || freq === 'YEARLY') && (
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>Repeats</label>
<select value={monthMode} disabled={readOnly}
onChange={e => setMonthMode(e.target.value as 'DAY' | 'WEEKDAY')}
style={inputStyle}>
<option value="DAY">{modeLabels.day}</option>
<option value="WEEKDAY">{modeLabels.weekday}</option>
</select>
</div>
)}
{freq !== 'NONE' && (
<div style={{ marginBottom: 14 }}>
<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">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)' }}>times</span>
</div>
)}
{endChoice === 'UNTIL' && (
<input type="date" value={until} min={startDate} disabled={readOnly}
onChange={e => setUntil(e.target.value)} style={{ ...inputStyle, width: 160 }} />
)}
</div>
</div>
)}
{/* ── Farbe ──────────────────────────────────────────────────── */}
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>Color</label>
<ColorSwatches value={color} onChange={setColor} inherit={calColor} disabled={readOnly} />
</div>
<div style={{ marginBottom: 12 }}>
<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}>Description</label>
<textarea value={description} disabled={readOnly} onChange={e => setDescription(e.target.value)}
rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
{onDelete ? (
<button type="button" onClick={onDelete} style={{
padding: '8px 14px', borderRadius: 8, border: 'none',
background: 'var(--color-danger)', color: '#fff', fontSize: 13, cursor: 'pointer',
}}>
{/* Nicht „Reihe löschen": Ob die ganze Reihe oder nur dieser
eine Termin verschwindet, wird erst im nächsten Schritt
gefragt – hier vorzugreifen wäre eine falsche Ansage. */}
Löschen
</button>
) : <span />}
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" onClick={onCancel} style={{
padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
}}>
{readOnly ? 'Close' : 'Cancel'}
</button>
{!readOnly && (
<button type="submit" style={{
padding: '8px 16px', borderRadius: 8, border: 'none',
background: 'var(--color-primary)', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
}}>
Speichern
</button>
)}
</div>
</div>
</form>
</div>
)
}