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

+74
View File
@@ -884,4 +884,78 @@ export const adminApi = {
request<AppSettings>('PATCH', '/admin/settings', data),
}
// ── Calendar ─────────────────────────────────────────────────────────────
// Termine selbst laufen für CalDAV-Clients (DAVx5 & Co.) über /caldav/ mit
// App-Passwörtern – ein eigenes, Basic-Auth-geschütztes Protokoll. Diese
// Web-Oberfläche spricht stattdessen /api/v1/calendars mit der normalen
// Session, wie jeder andere Bereich.
export interface CalendarItem {
id: string
owner_id: string
name: string
color: string
description: string
supports_todo: boolean
timezone: string
ctag: number
created_at: string
}
export interface CalendarEvent {
uid: string
summary: string
description: string
location: string
/** ISO 8601, immer UTC ("...Z") – der Server rechnet nicht um, das
* übernimmt der Browser beim Anzeigen (new Date(...) kennt die lokale
* Zeitzone bereits). */
start: string
end?: string
all_day: boolean
/** Über einen echten CalDAV-Client mit Wiederholung angelegt – hier nur
* sichtbar, nicht bearbeitbar (siehe events_api.go, UpdateEvent). */
recurring: boolean
etag: string
}
export interface EventInput {
summary: string
description?: string
location?: string
start: string
end?: string
all_day: boolean
}
export const calendarApi = {
list: () => request<CalendarItem[]>('GET', '/calendars'),
create: (name: string, color: string, description = '') =>
request<CalendarItem>('POST', '/calendars', { name, color, description }),
update: (id: string, patch: Partial<Pick<CalendarItem, 'name' | 'color' | 'description' | 'timezone'>>) =>
request<CalendarItem>('PATCH', `/calendars/${id}`, patch),
remove: (id: string) => request<void>('DELETE', `/calendars/${id}`),
exportUrl: (id: string) => `/api/v1/calendars/${id}/export`,
import: async (id: string, file: File): Promise<{ imported: number }> => {
const headers: Record<string, string> = { 'Content-Type': 'text/calendar' }
if (csrfToken) headers['X-CSRF-Token'] = csrfToken
const res = await fetch(`/api/v1/calendars/${id}/import`, {
method: 'POST', headers, credentials: 'include', body: file,
})
const json = await res.json().catch(() => ({ error: res.statusText }))
if (!res.ok) throw new APIError(res.status, json.error ?? 'Import failed')
return json.data
},
listEvents: (id: string) => request<CalendarEvent[]>('GET', `/calendars/${id}/events`),
createEvent: (id: string, input: EventInput) =>
request<CalendarEvent>('POST', `/calendars/${id}/events`, input),
updateEvent: (id: string, uid: string, input: EventInput) =>
request<CalendarEvent>('PATCH', `/calendars/${id}/events/${encodeURIComponent(uid)}`, input),
deleteEvent: (id: string, uid: string) =>
request<void>('DELETE', `/calendars/${id}/events/${encodeURIComponent(uid)}`),
}
export { APIError }
+2
View File
@@ -12,6 +12,7 @@ import SharedWithMePage from '../pages/SharedWithMePage'
import AdminPage from '../pages/AdminPage'
import ProfilePage from '../pages/ProfilePage'
import TagsPage from '../pages/TagsPage'
import CalendarPage from '../pages/CalendarPage'
import { UserMenuProvider } from './UserMenu'
export type { View }
@@ -112,6 +113,7 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) {
reloadSignal={trashBump}
/>
)}
{view === 'calendar' && <CalendarPage />}
{view === 'trash' && <TrashPage onUserChanged={onUserChanged} />}
{view === 'incoming' && <SharedWithMePage onOpenFolder={navigateToFolder} />}
{view === 'shared' && <SharedPage />}
+10
View File
@@ -36,6 +36,16 @@ const nav: { id: View; label: string; icon: React.ReactNode }[] = [
</svg>
),
},
{
id: 'calendar',
label: '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" />
<path d="M3 10h18M8 3v4M16 3v4" />
</svg>
),
},
{
id: 'trash',
label: 'Trash',
@@ -0,0 +1,78 @@
import { useRef, useState, type FormEvent } from 'react'
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
import type { CalendarItem } from '../../api/client'
const palette = ['#4285f4', '#ea4335', '#fbbc04', '#34a853', '#a142f4', '#ff6d00', '#00acc1', '#607d8b']
interface Props {
calendar?: CalendarItem
onSave: (name: string, color: string) => void
onCancel: () => void
}
export default function CalendarDialog({ calendar, onSave, onCancel }: Props) {
const overlay = useOverlayDismiss(onCancel)
const inputRef = useRef<HTMLInputElement>(null)
const [name, setName] = useState(calendar?.name ?? '')
const [color, setColor] = useState(calendar?.color ?? palette[0])
function handleSubmit(e: FormEvent) {
e.preventDefault()
if (!name.trim()) return
onSave(name.trim(), color)
}
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: 340, maxWidth: '100%',
}}>
<h2 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>
{calendar ? 'Kalender bearbeiten' : 'Neuer Kalender'}
</h2>
<label style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4, display: 'block' }}>Name</label>
<input
ref={inputRef} type="text" value={name} onChange={e => setName(e.target.value)}
autoFocus
style={{
width: '100%', boxSizing: 'border-box', marginBottom: 16,
background: 'var(--color-surface-2)', border: '1px solid var(--color-border)',
borderRadius: 8, padding: '8px 10px', fontSize: 13.5, color: 'var(--color-text)',
}}
/>
<label style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 8, display: 'block' }}>Farbe</label>
<div style={{ display: 'flex', gap: 8, marginBottom: 20, flexWrap: 'wrap' }}>
{palette.map(c => (
<button
key={c} type="button" onClick={() => setColor(c)}
aria-label={c}
style={{
width: 28, height: 28, borderRadius: '50%', background: c, cursor: 'pointer',
border: color === c ? '2px solid var(--color-text)' : '2px solid transparent',
boxShadow: color === c ? '0 0 0 2px var(--color-surface)' : 'none',
}}
/>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', 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>
<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>
</form>
</div>
)
}
@@ -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>
)
}
+2 -2
View File
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { FolderItem } from '../api/client'
export type View = 'files' | 'trash' | 'incoming' | 'shared' | 'admin' | 'profile' | 'tags' | 'from'
export type View = 'files' | 'calendar' | 'trash' | 'incoming' | 'shared' | 'admin' | 'profile' | 'tags' | 'from'
const views: View[] = ['files', 'trash', 'incoming', 'shared', 'admin', 'profile', 'tags', 'from']
const views: View[] = ['files', 'calendar', 'trash', 'incoming', 'shared', 'admin', 'profile', 'tags', 'from']
export interface NavState {
view: View
+550
View File
@@ -0,0 +1,550 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
calendarApi, APIError,
type CalendarItem, type CalendarEvent, type EventInput,
} from '../api/client'
import PageHeader from '../components/PageHeader'
import ConfirmDialog from '../components/ConfirmDialog'
import EventDialog from '../components/calendar/EventDialog'
import CalendarDialog from '../components/calendar/CalendarDialog'
import { TrashIcon, RenameIcon, DownloadIcon } from '../components/icons'
type ViewMode = 'month' | 'week' | 'list'
const weekdayLabels = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const monthLabels = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
]
function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
}
function addDays(d: Date, n: number): Date {
const r = new Date(d)
r.setDate(r.getDate() + n)
return r
}
/** Montag der Woche, in der `d` liegt. */
function startOfWeek(d: Date): Date {
const r = new Date(d.getFullYear(), d.getMonth(), d.getDate())
const dow = (r.getDay() + 6) % 7 // 0 = Montag
return addDays(r, -dow)
}
function startOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), 1)
}
interface EventWithCalendar extends CalendarEvent {
calendarId: string
color: string
}
/** Ein flacher Zeiger von jedem Tag auf seine Termine – Grundlage für Monats-,
* Wochen- und Listenansicht gleichermaßen. Mehrtägige Termine erscheinen nur
* am Starttag; eine echte Spanne über mehrere Zellen ist für eine erste
* Fassung bewusst zurückgestellt. */
function groupByDay(events: EventWithCalendar[]): Map<string, EventWithCalendar[]> {
const map = new Map<string, EventWithCalendar[]>()
for (const ev of events) {
const d = new Date(ev.start)
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(ev)
}
for (const list of map.values()) list.sort((a, b) => a.start.localeCompare(b.start))
return map
}
function dayKey(d: Date): string {
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
}
export default function CalendarPage() {
const [calendars, setCalendars] = useState<CalendarItem[]>([])
const [visible, setVisible] = useState<Set<string>>(new Set())
const [eventsByCal, setEventsByCal] = useState<Record<string, CalendarEvent[]>>({})
const [viewMode, setViewMode] = useState<ViewMode>('month')
const [cursor, setCursor] = useState(new Date())
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [eventDialog, setEventDialog] = useState<{ calId: string; event?: CalendarEvent; date: Date } | null>(null)
const [calDialog, setCalDialog] = useState<{ calendar?: CalendarItem } | null>(null)
const [deleteCalendar, setDeleteCalendar] = useState<CalendarItem | null>(null)
const [deleteEvent, setDeleteEvent] = useState<{ calId: string; uid: string } | null>(null)
const importInputRef = useRef<HTMLInputElement>(null)
const importTargetRef = useRef<string | null>(null)
const loadCalendars = useCallback(async () => {
const list = await calendarApi.list()
setCalendars(list)
setVisible(prev => {
// Neue Kalender starten sichtbar; einmal ausgeblendete bleiben es beim Neuladen.
const next = new Set(prev)
for (const c of list) if (!prev.has(c.id) && prev.size === 0) next.add(c.id)
if (prev.size === 0) for (const c of list) next.add(c.id)
return next
})
return list
}, [])
const loadEvents = useCallback(async (list: CalendarItem[]) => {
const pairs = await Promise.all(list.map(async c => {
try {
return [c.id, await calendarApi.listEvents(c.id)] as const
} catch {
return [c.id, []] as const
}
}))
setEventsByCal(Object.fromEntries(pairs))
}, [])
const reloadAll = useCallback(async () => {
setError(null)
try {
const list = await loadCalendars()
await loadEvents(list)
} catch (e) {
setError(e instanceof APIError ? e.message : 'Laden fehlgeschlagen')
} finally {
setLoading(false)
}
}, [loadCalendars, loadEvents])
useEffect(() => { reloadAll() }, [reloadAll])
const allEvents: EventWithCalendar[] = useMemo(() => {
const out: EventWithCalendar[] = []
for (const cal of calendars) {
if (!visible.has(cal.id)) continue
for (const ev of eventsByCal[cal.id] ?? []) {
out.push({ ...ev, calendarId: cal.id, color: cal.color })
}
}
return out
}, [calendars, visible, eventsByCal])
const byDay = useMemo(() => groupByDay(allEvents), [allEvents])
function toggleVisible(id: string) {
setVisible(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
const defaultCalendarId = calendars[0]?.id
function openNewEvent(date: Date) {
if (!defaultCalendarId) return
setEventDialog({ calId: defaultCalendarId, date })
}
function openEditEvent(ev: EventWithCalendar) {
setEventDialog({ calId: ev.calendarId, event: ev, date: new Date(ev.start) })
}
async function saveEvent(input: EventInput) {
if (!eventDialog) return
try {
if (eventDialog.event) {
await calendarApi.updateEvent(eventDialog.calId, eventDialog.event.uid, input)
} else {
await calendarApi.createEvent(eventDialog.calId, input)
}
setEventDialog(null)
await loadEvents(calendars)
} catch (e) {
setError(e instanceof APIError ? e.message : 'Speichern fehlgeschlagen')
}
}
async function confirmDeleteEvent() {
if (!deleteEvent) return
try {
await calendarApi.deleteEvent(deleteEvent.calId, deleteEvent.uid)
setDeleteEvent(null)
setEventDialog(null)
await loadEvents(calendars)
} catch (e) {
setError(e instanceof APIError ? e.message : 'Löschen fehlgeschlagen')
}
}
async function saveCalendar(name: string, color: string) {
try {
if (calDialog?.calendar) {
await calendarApi.update(calDialog.calendar.id, { name, color })
} else {
await calendarApi.create(name, color)
}
setCalDialog(null)
await reloadAll()
} catch (e) {
setError(e instanceof APIError ? e.message : 'Speichern fehlgeschlagen')
}
}
async function confirmDeleteCalendar() {
if (!deleteCalendar) return
try {
await calendarApi.remove(deleteCalendar.id)
setDeleteCalendar(null)
await reloadAll()
} catch (e) {
setError(e instanceof APIError ? e.message : 'Löschen fehlgeschlagen')
}
}
function triggerImport(calId: string) {
importTargetRef.current = calId
importInputRef.current?.click()
}
async function handleImportFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
const calId = importTargetRef.current
e.target.value = ''
if (!file || !calId) return
try {
await calendarApi.import(calId, file)
await loadEvents(calendars)
} catch (err) {
setError(err instanceof APIError ? err.message : 'Import fehlgeschlagen')
}
}
function stepPeriod(dir: 1 | -1) {
setCursor(prev => {
if (viewMode === 'month') return new Date(prev.getFullYear(), prev.getMonth() + dir, 1)
if (viewMode === 'week') return addDays(prev, dir * 7)
return addDays(prev, dir * 30)
})
}
const periodLabel = viewMode === 'month'
? `${monthLabels[cursor.getMonth()]} ${cursor.getFullYear()}`
: viewMode === 'week'
? (() => {
const s = startOfWeek(cursor)
const e = addDays(s, 6)
return `${s.getDate()}.${s.getMonth() + 1}. – ${e.getDate()}.${e.getMonth() + 1}.${e.getFullYear()}`
})()
: 'Anstehend'
if (loading) {
return <div style={{ padding: 24, color: 'var(--color-text-muted)' }}>Lädt…</div>
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', minWidth: 0 }}>
<PageHeader title="Kalender">
<div style={{ display: 'flex', gap: 6 }}>
{(['month', 'week', 'list'] as ViewMode[]).map(m => (
<button key={m} onClick={() => setViewMode(m)} style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12.5, cursor: 'pointer',
border: '1px solid var(--color-border)',
background: viewMode === m ? 'var(--color-primary)' : 'transparent',
color: viewMode === m ? '#fff' : 'var(--color-text-muted)',
}}>
{m === 'month' ? 'Monat' : m === 'week' ? 'Woche' : 'Liste'}
</button>
))}
</div>
{viewMode !== 'list' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button onClick={() => stepPeriod(-1)} style={navBtnStyle}>‹</button>
<span style={{ fontSize: 13, color: 'var(--color-text)', minWidth: 130, textAlign: 'center' }}>{periodLabel}</span>
<button onClick={() => stepPeriod(1)} style={navBtnStyle}>›</button>
<button onClick={() => setCursor(new Date())} style={{ ...navBtnStyle, width: 'auto', padding: '0 10px', fontSize: 12 }}>Heute</button>
</div>
)}
<button onClick={() => openNewEvent(new Date())} disabled={!defaultCalendarId} style={{
padding: '7px 14px', borderRadius: 8, border: 'none',
background: 'var(--color-primary)', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
opacity: defaultCalendarId ? 1 : 0.5,
}}>
+ Termin
</button>
</PageHeader>
{error && (
<div style={{ padding: '8px 16px', background: 'var(--color-danger)', color: '#fff', fontSize: 13 }}>
{error}
</div>
)}
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<aside style={{ width: 220, flexShrink: 0, borderRight: '1px solid var(--color-border)', padding: 14, overflowY: 'auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase' }}>Kalender</span>
<button onClick={() => setCalDialog({})} title="Neuer Kalender" style={{
border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-primary)', fontSize: 16, lineHeight: 1,
}}>+</button>
</div>
{calendars.map(cal => (
<div key={cal.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0', fontSize: 13, color: 'var(--color-text)' }}>
<input type="checkbox" checked={visible.has(cal.id)} onChange={() => toggleVisible(cal.id)} />
<span style={{ width: 10, height: 10, borderRadius: '50%', background: cal.color, flexShrink: 0 }} />
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{cal.name}</span>
<CalMenu
onRename={() => setCalDialog({ calendar: cal })}
onExport={() => window.open(calendarApi.exportUrl(cal.id), '_blank')}
onImport={() => triggerImport(cal.id)}
onDelete={() => setDeleteCalendar(cal)}
/>
</div>
))}
{calendars.length === 0 && (
<p style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>Noch kein Kalender – leg einen an.</p>
)}
</aside>
<div style={{ flex: 1, minWidth: 0, overflow: 'auto' }}>
{viewMode === 'month' && (
<MonthGrid cursor={cursor} byDay={byDay} onDayClick={openNewEvent} onEventClick={openEditEvent} />
)}
{viewMode === 'week' && (
<WeekColumns cursor={cursor} byDay={byDay} onDayClick={openNewEvent} onEventClick={openEditEvent} />
)}
{viewMode === 'list' && (
<ListAgenda events={allEvents} onEventClick={openEditEvent} />
)}
</div>
</div>
<input ref={importInputRef} type="file" accept=".ics,text/calendar" style={{ display: 'none' }} onChange={handleImportFile} />
{eventDialog && (
<EventDialog
event={eventDialog.event}
defaultDate={eventDialog.date}
onSave={saveEvent}
onDelete={eventDialog.event ? () => setDeleteEvent({ calId: eventDialog.calId, uid: eventDialog.event!.uid }) : undefined}
onCancel={() => setEventDialog(null)}
/>
)}
{calDialog && (
<CalendarDialog calendar={calDialog.calendar} onSave={saveCalendar} onCancel={() => setCalDialog(null)} />
)}
{deleteCalendar && (
<ConfirmDialog
title="Kalender löschen"
message={`"${deleteCalendar.name}" mit allen Terminen darin unwiderruflich löschen?`}
confirmLabel="Löschen" danger
onConfirm={confirmDeleteCalendar}
onCancel={() => setDeleteCalendar(null)}
/>
)}
{deleteEvent && (
<ConfirmDialog
title="Termin löschen"
message="Diesen Termin unwiderruflich löschen?"
confirmLabel="Löschen" danger
onConfirm={confirmDeleteEvent}
onCancel={() => setDeleteEvent(null)}
/>
)}
</div>
)
}
const navBtnStyle: React.CSSProperties = {
width: 26, height: 26, borderRadius: 6, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text)', cursor: 'pointer', fontSize: 14,
}
function CalMenu({ onRename, onExport, onImport, onDelete }: {
onRename: () => void; onExport: () => void; onImport: () => void; onDelete: () => void
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
function onDoc(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [])
return (
<div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
<button onClick={() => setOpen(o => !o)} style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', padding: 2 }}>⋮</button>
{open && (
<div style={{
position: 'absolute', right: 0, top: 22, zIndex: 30, minWidth: 150,
background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.2)', overflow: 'hidden',
}}>
<MenuRow icon={<RenameIcon />} label="Bearbeiten" onClick={() => { setOpen(false); onRename() }} />
<MenuRow icon={<DownloadIcon />} label="Exportieren (.ics)" onClick={() => { setOpen(false); onExport() }} />
<MenuRow label="Importieren (.ics)" onClick={() => { setOpen(false); onImport() }} />
<MenuRow icon={<TrashIcon />} label="Löschen" danger onClick={() => { setOpen(false); onDelete() }} />
</div>
)}
</div>
)
}
function MenuRow({ icon, label, onClick, danger }: { icon?: React.ReactNode; label: string; onClick: () => void; danger?: boolean }) {
return (
<button onClick={onClick} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 12px',
border: 'none', background: 'none', cursor: 'pointer', fontSize: 12.5, textAlign: 'left',
color: danger ? 'var(--color-danger)' : 'var(--color-text)',
}}>
{icon}{label}
</button>
)
}
function EventChip({ ev, onClick }: { ev: EventWithCalendar; onClick: () => void }) {
return (
<div onClick={onClick} title={ev.summary} style={{
display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, padding: '1px 4px', borderRadius: 4,
background: ev.color + '22', color: ev.color, cursor: 'pointer', marginBottom: 2,
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
}}>
{!ev.all_day && <span style={{ opacity: 0.8 }}>{new Date(ev.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{ev.summary}</span>
</div>
)
}
function MonthGrid({ cursor, byDay, onDayClick, onEventClick }: {
cursor: Date
byDay: Map<string, EventWithCalendar[]>
onDayClick: (d: Date) => void
onEventClick: (ev: EventWithCalendar) => void
}) {
const first = startOfMonth(cursor)
const gridStart = startOfWeek(first)
const today = new Date()
const weeks: Date[][] = []
let d = gridStart
for (let w = 0; w < 6; w++) {
const row: Date[] = []
for (let i = 0; i < 7; i++) { row.push(d); d = addDays(d, 1) }
weeks.push(row)
if (d > addDays(startOfMonth(new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1)), 0) && w >= 3) break
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', borderBottom: '1px solid var(--color-border)' }}>
{weekdayLabels.map(l => (
<div key={l} style={{ padding: '6px 8px', fontSize: 11, color: 'var(--color-text-muted)', fontWeight: 600 }}>{l}</div>
))}
</div>
<div style={{ display: 'grid', gridTemplateRows: `repeat(${weeks.length}, 1fr)`, flex: 1 }}>
{weeks.map((row, i) => (
<div key={i} style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', borderBottom: '1px solid var(--color-border)' }}>
{row.map(day => {
const inMonth = day.getMonth() === cursor.getMonth()
const events = byDay.get(dayKey(day)) ?? []
return (
<div key={day.toISOString()} onClick={() => onDayClick(day)} style={{
borderRight: '1px solid var(--color-border)', padding: 4, minHeight: 90, cursor: 'pointer',
opacity: inMonth ? 1 : 0.4, display: 'flex', flexDirection: 'column', overflow: 'hidden',
}}>
<span style={{
fontSize: 11.5, color: isSameDay(day, today) ? '#fff' : 'var(--color-text-muted)',
background: isSameDay(day, today) ? 'var(--color-primary)' : 'transparent',
borderRadius: 10, padding: '1px 6px', alignSelf: 'flex-start', marginBottom: 3,
}}>
{day.getDate()}
</span>
{events.slice(0, 4).map(ev => (
<EventChip key={ev.uid} ev={ev} onClick={() => onEventClick(ev)} />
))}
{events.length > 4 && (
<span style={{ fontSize: 10.5, color: 'var(--color-text-muted)' }}>+{events.length - 4} mehr</span>
)}
</div>
)
})}
</div>
))}
</div>
</div>
)
}
function WeekColumns({ cursor, byDay, onDayClick, onEventClick }: {
cursor: Date
byDay: Map<string, EventWithCalendar[]>
onDayClick: (d: Date) => void
onEventClick: (ev: EventWithCalendar) => void
}) {
const start = startOfWeek(cursor)
const days = Array.from({ length: 7 }, (_, i) => addDays(start, i))
const today = new Date()
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', height: '100%' }}>
{days.map(day => {
const events = byDay.get(dayKey(day)) ?? []
return (
<div key={day.toISOString()} onClick={() => onDayClick(day)} style={{
borderRight: '1px solid var(--color-border)', padding: 8, cursor: 'pointer', minHeight: '100%',
}}>
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{weekdayLabels[(day.getDay() + 6) % 7]}</div>
<span style={{
fontSize: 14, fontWeight: 600,
color: isSameDay(day, today) ? '#fff' : 'var(--color-text)',
background: isSameDay(day, today) ? 'var(--color-primary)' : 'transparent',
borderRadius: 12, padding: '1px 8px',
}}>
{day.getDate()}
</span>
</div>
{events.map(ev => <EventChip key={ev.uid} ev={ev} onClick={() => onEventClick(ev)} />)}
</div>
)
})}
</div>
)
}
function ListAgenda({ events, onEventClick }: { events: EventWithCalendar[]; onEventClick: (ev: EventWithCalendar) => void }) {
const sorted = useMemo(() => [...events].sort((a, b) => a.start.localeCompare(b.start)), [events])
const groups = useMemo(() => {
const map = new Map<string, EventWithCalendar[]>()
for (const ev of sorted) {
const d = new Date(ev.start)
const key = dayKey(d)
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(ev)
}
return [...map.entries()]
}, [sorted])
if (groups.length === 0) {
return <p style={{ padding: 24, color: 'var(--color-text-muted)', fontSize: 13 }}>Keine Termine.</p>
}
return (
<div style={{ padding: '12px 20px' }}>
{groups.map(([key, evs]) => {
const d = new Date(evs[0].start)
return (
<div key={key} style={{ marginBottom: 18 }}>
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 6 }}>
{weekdayLabels[(d.getDay() + 6) % 7]}, {d.getDate()}. {monthLabels[d.getMonth()]} {d.getFullYear()}
</div>
{evs.map(ev => (
<div key={ev.uid} onClick={() => onEventClick(ev)} style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '7px 10px', borderRadius: 8,
cursor: 'pointer', border: '1px solid var(--color-border)', marginBottom: 6,
}}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: ev.color, flexShrink: 0 }} />
<span style={{ fontSize: 12.5, color: 'var(--color-text-muted)', width: 50, flexShrink: 0 }}>
{ev.all_day ? 'Ganztägig' : new Date(ev.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
<span style={{ fontSize: 13.5, color: 'var(--color-text)' }}>{ev.summary}</span>
</div>
))}
</div>
)
})}
</div>
)
}