feat(calendar): Phase 3 – Weboberfläche mit Monats-, Wochen- und Listenansicht

Neue Seite CalendarPage (Menüpunkt 'Calendar' in der Seitenleiste,
zwischen Home und Papierkorb) mit Kalenderliste zum Anlegen/Umbenennen/
Löschen/Ein- und Ausblenden, Export/Import als .ics über die
Phase-2-API, und Termine anlegen/ändern/löschen über einen neuen
JSON-Endpunkt.

Backend: neuer events_api.go-Handler unter /api/v1/calendars/{id}/events
(Session-Auth) neben dem bestehenden CalDAV-Protokoll – ein Termin, den
die Web-Oberfläche anlegt, ist ein ganz normales CalDAV-Objekt und
synchronisiert genauso zu DAVx5 wie umgekehrt. Ein per CalDAV mit RRULE
angelegter, wiederkehrender Termin zeigt sich hier nur (recurring:true),
lässt sich aber bewusst nicht über dieses vereinfachte Formular
bearbeiten (409) – das würde die Wiederholungsregel eher beschädigen
als korrekt anpassen.

Gegen den laufenden Container getestet: Kalender/Termin anlegen, per
Web-API ändern und löschen, ein per CalDAV mit RRULE angelegter
Wiederkehrer taucht als recurring:true auf und blockt PATCH mit 409.
TypeScript-Build (tsc + vite build) sauber.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Sonnet 5 committed 2026-09-03 23:14:28 +02:00
1 parent 4d635fff20
commit 27a026421f
10 files changed
+1163 -6

No files matched your search

@@ -0,0 +1,162 @@
import { useEffect, useRef, useState, type FormEvent } from 'react'
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
import type { CalendarEvent, EventInput } from '../../api/client'
interface Props {
/** Fehlt sie, entsteht ein neuer Termin. */
event?: CalendarEvent
/** Vorbelegter Starttag – aus dem angeklickten Tag der Monats-/Wochenansicht. */
defaultDate: Date
onSave: (input: EventInput) => void
onDelete?: () => void
onCancel: () => void
}
function toDateInput(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function toTimeInput(d: Date): string {
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
export default function EventDialog({ event, defaultDate, onSave, onDelete, onCancel }: Props) {
const overlay = useOverlayDismiss(onCancel)
const titleRef = useRef<HTMLInputElement>(null)
const start = event ? new Date(event.start) : defaultDate
const end = event?.end ? new Date(event.end) : new Date(start.getTime() + 60 * 60 * 1000)
const [summary, setSummary] = useState(event?.summary ?? '')
const [description, setDescription] = useState(event?.description ?? '')
const [location, setLocation] = useState(event?.location ?? '')
const [allDay, setAllDay] = useState(event?.all_day ?? false)
const [startDate, setStartDate] = useState(toDateInput(start))
const [startTime, setStartTime] = useState(toTimeInput(start))
const [endDate, setEndDate] = useState(toDateInput(end))
const [endTime, setEndTime] = useState(toTimeInput(end))
useEffect(() => { titleRef.current?.focus() }, [])
const readOnly = !!event?.recurring
function handleSubmit(e: FormEvent) {
e.preventDefault()
if (readOnly || !summary.trim()) return
const s = allDay ? new Date(`${startDate}T00:00:00`) : new Date(`${startDate}T${startTime}:00`)
const en = allDay ? new Date(`${endDate}T00:00:00`) : new Date(`${endDate}T${endTime}:00`)
onSave({
summary: summary.trim(),
description: description.trim() || undefined,
location: location.trim() || undefined,
all_day: allDay,
start: s.toISOString(),
end: en.toISOString(),
})
}
const inputStyle: React.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)',
}
const labelStyle: React.CSSProperties = {
fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4, display: 'block',
}
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: 420, maxWidth: '100%', maxHeight: '90vh', overflowY: 'auto',
}}>
<h2 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>
{event ? 'Termin bearbeiten' : 'Neuer Termin'}
</h2>
{readOnly && (
<div style={{
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,
}}>
Wiederholender Termin – nur mit einem CalDAV-Client (z. B. DAVx5) bearbeitbar, nicht hier.
</div>
)}
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>Titel</label>
<input ref={titleRef} type="text" value={summary} disabled={readOnly}
onChange={e => setSummary(e.target.value)} style={inputStyle} required />
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, fontSize: 13, color: 'var(--color-text)' }}>
<input type="checkbox" checked={allDay} disabled={readOnly} onChange={e => setAllDay(e.target.checked)} />
Ganztägig
</label>
<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 => setStartDate(e.target.value)} style={inputStyle} />
</div>
{!allDay && (
<div style={{ width: 110 }}>
<label style={labelStyle}>&nbsp;</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: 12 }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Ende</label>
<input type="date" value={endDate} disabled={readOnly} onChange={e => setEndDate(e.target.value)} style={inputStyle} />
</div>
{!allDay && (
<div style={{ width: 110 }}>
<label style={labelStyle}>&nbsp;</label>
<input type="time" value={endTime} disabled={readOnly} onChange={e => setEndTime(e.target.value)} style={inputStyle} />
</div>
)}
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>Ort</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>
<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',
}}>
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',
}}>
Abbrechen
</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>
)
}