Files
Aether/frontend/src/pages/CalendarPage.tsx
T
marcandClaude Opus 5 5d90c0a2d2 feat(calendar): Kalender an Nutzer und Gruppen freigeben
Nicht als eigene Tabelle, wie im Plan vorgesehen: acl_entries beantwortet
fuer Ordner und Dateien laengst dieselbe Frage -- wer darf was, direkt oder
ueber eine Gruppe. Ein zweites Rechtesystem daneben waere eine zweite
Antwort auf dieselbe Frage, mit eigener Gruppenaufloesung, eigenem
Widerruf, eigenem Pruefpfad; die Luecke dazwischen faellt erst auf, wenn
jemand sieht, was er nicht sehen soll. Migration 25 haengt deshalb nur eine
dritte Ressourcenspalte an.

Der Gewinn: Freigeben an eine **Gruppe** kostete keine Zeile extra.

Drei Entscheidungen, die nicht offensichtlich waren:

* Ein fremder Kalender steht immer unter seiner UUID, nie unter dem
  Pfadsegment des Eigentuemers -- das hat der sich ausgesucht, und zwei
  Leute duerfen denselben Namen waehlen. Sonst laegen im Kalenderbestand
  des Empfaengers zwei Kalender auf derselben Adresse.
* Alle Wege laufen durch RequireCalendar. CalDAV und Weboberflaeche sind
  zwei getrennte Zugaenge zu denselben Daten; ein Recht, das nur einer von
  beiden prueft, ist kein Recht. Im CalDAV-Teil genuegte, resolveCalendar
  umzustellen -- dort kommt jede Operation vorbei.
* Wer ein Recht geliehen hat, darf es nicht weiterverleihen: Umbenennen,
  Loeschen und Freigeben bleiben auch mit Schreibrecht beim Eigentuemer.

Oberflaeche: fremde Kalender tragen Eigentuemer und "(nur lesen)", das
Menue zeigt nur, was auch geht, und ein Termin laesst sich gar nicht erst
in einen Kalender ziehen, den der Server danach ablehnen wuerde.

scripts/kalender-teilen-selbsttest.py, 41 Pruefungen, jede ueber beide
Zugaenge. Er beginnt damit, dass der Empfaenger vorher nichts sieht --
ohne diesen Anfang beweist der Rest nichts.

Dabei hatte der Test selbst eine Falle: Mit festem Testdatum lief die
Terminliste aus ihrem Standardzeitraum, und die Pruefung "beim Eigentuemer
ist nichts angekommen" bestand **leer**.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:46:18 +02:00

1041 lines
44 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 {
useCallback, useEffect, useMemo, useRef, useState,
type CSSProperties, type MouseEvent, type PointerEvent as ReactPointerEvent,
} from 'react'
import {
calendarApi, APIError, canEditCalendar,
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 DayDialog, { type DayEvent } from '../components/calendar/DayDialog'
import DeleteEventDialog from '../components/calendar/DeleteEventDialog'
import {
addDays, dayKey, eventDay, isSameDay, spannedDays, startOfDay, startOfMonth, startOfWeek,
} from '../components/calendar/dates'
import { useEventDrag } from '../components/calendar/useEventDrag'
import { TrashIcon, RenameIcon, DownloadIcon, PeopleIcon } from '../components/icons'
import ShareWithDialog from '../components/ShareWithDialog'
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',
]
/** Höhe einer Stunde im Wochenraster. */
const HOUR_H = 44
/** Breite der Stundenspalte links im Wochenraster. */
const GUTTER = 52
type EventWithCalendar = DayEvent
/** Aufnehmen zum Ziehen – siehe components/calendar/useEventDrag.ts. */
type EventDown = (ev: EventWithCalendar, e: ReactPointerEvent) => void
function decorate(ev: CalendarEvent, cal: CalendarItem): EventWithCalendar {
return {
...ev,
calendarId: cal.id,
calendarName: cal.name,
// Eigene Terminfarbe schlägt die Kalenderfarbe – aber nur, wenn eine
// gesetzt ist. Sonst erbt der Termin sie weiterhin.
color: ev.color || cal.color,
startsAt: eventDay(ev.start, ev.all_day),
endsAt: ev.end ? eventDay(ev.end, ev.all_day) : null,
}
}
/** Eindeutig auch für aufgeklappte Wiederholungen: die teilen sich die UID
* und unterscheiden sich nur im Anfangszeitpunkt. */
function eventKey(ev: EventWithCalendar): string {
return `${ev.calendarId}|${ev.uid}|${ev.start}`
}
/** Termine je Tag. Ein mehrtägiger Termin steht in **jeder** Zelle, die er
* belegt – vorher nur am Starttag, was ihn ab dem zweiten Tag verschwinden
* ließ. */
function groupByDay(events: EventWithCalendar[]): Map<string, EventWithCalendar[]> {
const map = new Map<string, EventWithCalendar[]>()
for (const ev of events) {
for (const day of spannedDays(ev.startsAt, ev.endsAt, ev.all_day)) {
const key = dayKey(day)
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(ev)
}
}
for (const list of map.values()) {
list.sort((a, b) => {
// Ganztägige zuerst, danach nach Uhrzeit – so wie jeder Kalender es zeigt.
if (a.all_day !== b.all_day) return a.all_day ? -1 : 1
return +a.startsAt - +b.startsAt
})
}
return map
}
/** Vorschlag für einen neuen Termin: heute die nächste volle Stunde, an
* anderen Tagen 9 Uhr. Mitternacht als Vorgabe trifft fast nie zu. */
function suggestStart(day: Date): Date {
const now = new Date()
if (isSameDay(day, now)) {
const next = new Date(now)
next.setMinutes(0, 0, 0)
next.setHours(next.getHours() + 1)
if (isSameDay(next, day)) return next
}
const d = startOfDay(day)
d.setHours(9)
return d
}
export default function CalendarPage() {
const [calendars, setCalendars] = useState<CalendarItem[]>([])
/** Ausgeblendete Kalender. Bewusst andersherum als „sichtbare": So ist ein
* **neu angelegter** Kalender automatisch sichtbar. */
const [hidden, setHidden] = 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 [showList, setShowList] = useState(() =>
typeof window === 'undefined' ? true : window.matchMedia('(min-width: 900px)').matches)
const [dayDialog, setDayDialog] = useState<Date | null>(null)
const [eventDialog, setEventDialog] = useState<{ calId: string; event?: CalendarEvent; start: Date } | null>(null)
const [calDialog, setCalDialog] = useState<{ calendar?: CalendarItem } | null>(null)
const [deleteCalendar, setDeleteCalendar] = useState<CalendarItem | null>(null)
/** Kalender, dessen Freigaben gerade verwaltet werden. */
const [shareCalendar, setShareCalendar] = useState<CalendarItem | null>(null)
const [deleteEvent, setDeleteEvent] = useState<
{ calId: string; uid: string; summary: string; recurring: boolean; occurrence: string } | null>(null)
const importInputRef = useRef<HTMLInputElement>(null)
const importTargetRef = useRef<string | null>(null)
/**
* Sichtbares Zeitfenster. Der Server klappt Wiederholungen **darin** auf –
* eine wöchentliche Reihe ohne Ende hat kein natürliches Ende, also muss
* die Ansicht sagen, welchen Ausschnitt sie braucht.
*/
const range = useMemo(() => {
if (viewMode === 'month') {
const s = startOfWeek(startOfMonth(cursor))
return { from: addDays(s, -1), to: addDays(s, 43) }
}
if (viewMode === 'week') {
const s = startOfWeek(cursor)
return { from: addDays(s, -1), to: addDays(s, 8) }
}
const today = startOfDay(new Date())
return { from: addDays(today, -31), to: addDays(today, 365) }
}, [viewMode, cursor.getFullYear(), cursor.getMonth(), viewMode === 'week' ? +startOfWeek(cursor) : 0])
const loadEvents = useCallback(async (list: CalendarItem[], from: Date, to: Date) => {
const pairs = await Promise.all(list.map(async c => {
try {
return [c.id, await calendarApi.listEvents(c.id, from, to)] as const
} catch {
return [c.id, [] as CalendarEvent[]] as const
}
}))
setEventsByCal(Object.fromEntries(pairs))
}, [])
const loadCalendars = useCallback(async () => {
setError(null)
try {
// ?? []: Eine Liste, die als null ankommt, legte die ganze Seite lahm
// (das ist einmal passiert). Der Server schickt das nicht mehr – aber
// eine Ansicht sollte an einer unerwarteten Antwort nicht zerbrechen.
setCalendars(await calendarApi.list() ?? [])
} catch (e) {
setError(e instanceof APIError ? e.message : 'Laden fehlgeschlagen')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadCalendars() }, [loadCalendars])
// Termine hängen an Kalenderliste **und** Zeitfenster: Beim Blättern in
// einen anderen Monat müssen die Wiederholungen dort neu aufgeklappt werden.
useEffect(() => {
if (calendars.length === 0) { setEventsByCal({}); return }
loadEvents(calendars, range.from, range.to)
}, [calendars, range.from, range.to, loadEvents])
const refreshEvents = useCallback(
() => loadEvents(calendars, range.from, range.to),
[calendars, range.from, range.to, loadEvents])
const allEvents = useMemo(() => {
const out: EventWithCalendar[] = []
for (const cal of calendars) {
if (hidden.has(cal.id)) continue
for (const ev of eventsByCal[cal.id] ?? []) out.push(decorate(ev, cal))
}
return out
}, [calendars, hidden, eventsByCal])
const byDay = useMemo(() => groupByDay(allEvents), [allEvents])
/**
* Kalender, in die dieses Konto schreiben darf.
*
* Die Kalenderwahl im Termin-Dialog zeigt nur diese. Einen nur lesbaren
* Kalender dort anzubieten hieße, jemanden ein Formular ausfüllen zu lassen,
* das der Server danach ablehnt.
*/
const writableCalendars = useMemo(() => calendars.filter(canEditCalendar), [calendars])
function toggleVisible(id: string) {
setHidden(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(start: Date) {
if (!defaultCalendarId) return
setEventDialog({ calId: defaultCalendarId, start })
}
function openEditEvent(ev: EventWithCalendar) {
setEventDialog({ calId: ev.calendarId, event: ev, start: ev.startsAt })
}
async function saveEvent(input: EventInput, calId: string) {
if (!eventDialog) return
try {
if (eventDialog.event) {
// Erst die Änderungen, dann der Umzug. Andersherum stünde der Termin
// nach einem Fehler im neuen Kalender, aber unverändert – die
// verwirrendere Hälfte. So bleibt er im Zweifel liegen, wo er war,
// und die Eingaben sind trotzdem gesichert.
await calendarApi.updateEvent(eventDialog.calId, eventDialog.event.uid, input)
if (calId !== eventDialog.calId) {
await calendarApi.moveEvent(eventDialog.calId, eventDialog.event.uid, calId)
}
} else {
await calendarApi.createEvent(calId, input)
}
setEventDialog(null)
await refreshEvents()
} catch (e) {
setError(e instanceof APIError ? e.message : 'Speichern fehlgeschlagen')
}
}
/** Termin auf einen Kalender in der Seitenleiste gezogen. */
async function moveEventTo(ev: EventWithCalendar, targetId: string) {
try {
await calendarApi.moveEvent(ev.calendarId, ev.uid, targetId)
// Ein Kalender, in den man gerade etwas hineingezogen hat, soll das
// auch zeigen – sonst verschwindet der Termin scheinbar spurlos.
setHidden(prev => {
if (!prev.has(targetId)) return prev
const next = new Set(prev)
next.delete(targetId)
return next
})
await refreshEvents()
} catch (e) {
setError(e instanceof APIError ? e.message : 'Verschieben fehlgeschlagen')
}
}
const { drag, onPointerDown, dragged } = useEventDrag(moveEventTo)
/** Klick auf einen Termin – aber nicht der, der nur einem Ziehen hinterherkommt. */
function clickEvent(ev: EventWithCalendar) {
if (dragged()) return
openEditEvent(ev)
}
/** `scope: 'single'` streicht nur diesen einen Termin aus der Reihe
* (EXDATE), `'series'` löscht sie ganz. Bei Einzelterminen sind beide
* Wege gleichbedeutend. */
async function confirmDeleteEvent(scope: 'single' | 'series') {
if (!deleteEvent) return
try {
await calendarApi.deleteEvent(
deleteEvent.calId, deleteEvent.uid,
scope === 'single' && deleteEvent.recurring ? deleteEvent.occurrence : undefined)
setDeleteEvent(null)
setEventDialog(null)
await refreshEvents()
} 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 loadCalendars()
} 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 loadCalendars()
} 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 refreshEvents()
} 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()}`
})()
: 'Alle Termine'
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"
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>
}
>
<button onClick={() => setShowList(v => !v)} title="Kalenderliste" style={{
...navBtnStyle, width: 30,
color: showList ? 'var(--color-primary)' : 'var(--color-text-muted)',
}}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" />
<circle cx="3.5" cy="6" r="1.2" fill="currentColor" /><circle cx="3.5" cy="12" r="1.2" fill="currentColor" /><circle cx="3.5" cy="18" r="1.2" fill="currentColor" />
</svg>
</button>
<div style={{ display: 'flex', gap: 4 }}>
{(['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)',
transition: 'background 0.15s, color 0.15s',
}}>
{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} aria-label="zurück">‹</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>
</div>
)}
<button onClick={() => openNewEvent(suggestStart(new Date()))} disabled={!defaultCalendarId} style={{
padding: '7px 14px', borderRadius: 8, border: 'none',
background: 'var(--color-primary)', color: '#fff', fontSize: 13, fontWeight: 600,
cursor: defaultCalendarId ? 'pointer' : 'not-allowed', opacity: defaultCalendarId ? 1 : 0.5,
}}>
+ Termin
</button>
</PageHeader>
{error && (
<div style={{ padding: '8px 16px', background: 'var(--color-danger)', color: '#fff', fontSize: 13, display: 'flex', justifyContent: 'space-between' }}>
<span>{error}</span>
<button onClick={() => setError(null)} style={{ border: 'none', background: 'none', color: '#fff', cursor: 'pointer' }}>✕</button>
</div>
)}
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
{showList && (
<aside style={{
width: 210, 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: 11, fontWeight: 700, color: 'var(--color-text-muted)', letterSpacing: 0.5 }}>KALENDER</span>
<button onClick={() => setCalDialog({})} title="Neuer Kalender" style={{
border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-primary)',
fontSize: 18, lineHeight: 1, padding: '0 2px',
}}>+</button>
</div>
{drag && (
<p style={{ fontSize: 11.5, color: 'var(--color-text-muted)', margin: '0 0 8px', lineHeight: 1.4 }}>
Auf einen Kalender ziehen zum Verschieben
</p>
)}
{calendars.map(cal => {
const on = !hidden.has(cal.id)
// Ablegefläche fürs Ziehen. Der eigene Kalender des gezogenen
// Termins ist keins – dorthin gibt es nichts zu verschieben.
// Ablegefläche nur, wo auch geschrieben werden darf. Ein Ziel
// anzubieten, das der Server danach ablehnt, ist schlechter als
// gar keines: Man hat den Termin schon losgelassen.
const isTarget = !!drag && drag.ev.calendarId !== cal.id && canEditCalendar(cal)
const isOver = isTarget && drag!.overId === cal.id
return (
<div key={cal.id} className="aether-cal-row"
data-cal-drop={isTarget ? cal.id : undefined}
style={{
display: 'flex', alignItems: 'center', gap: 9, padding: '6px 4px',
fontSize: 13, color: 'var(--color-text)', borderRadius: 6,
outline: isOver ? `2px solid ${cal.color}` : isTarget ? '1px dashed var(--color-border)' : 'none',
background: isOver ? cal.color + '22' : undefined,
}}>
{/* Der Farbpunkt **ist** der Schalter: gefüllt = wird
angezeigt, leerer Ring = ausgeblendet. Ein Kästchen
daneben sagte dasselbe zweimal. */}
<button
onClick={() => toggleVisible(cal.id)}
role="switch" aria-checked={on}
title={on ? 'Ausblenden' : 'Einblenden'}
style={{
width: 15, height: 15, borderRadius: '50%', flexShrink: 0, padding: 0,
border: `2px solid ${cal.color}`,
background: on ? cal.color : 'transparent',
cursor: 'pointer', transition: 'background 0.12s',
}}
/>
<span onClick={() => toggleVisible(cal.id)} style={{
flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer',
opacity: on ? 1 : 0.45,
}}>
{cal.name}
{/* Fremde Kalender tragen ihren Absender. Zwei „Privat"
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)' : ''}
</span>
)}
</span>
<CalMenu
own={!cal.shared}
canWrite={canEditCalendar(cal)}
onRename={() => setCalDialog({ calendar: cal })}
onShare={() => setShareCalendar(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)', lineHeight: 1.5 }}>
Noch kein Kalender – über <strong>+</strong> einen anlegen.
</p>
)}
</aside>
)}
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', display: 'flex' }}>
{viewMode === 'month' && (
<MonthGrid cursor={cursor} byDay={byDay} onOpenDay={setDayDialog}
onEventClick={clickEvent} onEventDown={onPointerDown} />
)}
{viewMode === 'week' && (
<WeekGrid cursor={cursor} byDay={byDay} onSlotClick={openNewEvent} onOpenDay={setDayDialog}
onEventClick={clickEvent} onEventDown={onPointerDown} />
)}
{viewMode === 'list' && (
<ListAgenda events={allEvents} onEventClick={clickEvent} onEventDown={onPointerDown} />
)}
</div>
</div>
{/* Ist die Kalenderliste zugeklappt – am Handy der Regelfall –, fehlte
sonst jedes Ziel, und die Geste liefe ins Leere. Die Leiste taucht
deshalb nur während des Ziehens auf, unten in Daumennähe. */}
{drag && !showList && (
<div style={{
position: 'fixed', left: 0, right: 0, bottom: 0, zIndex: 205,
display: 'flex', gap: 8, padding: 12, overflowX: 'auto',
background: 'var(--color-surface)', borderTop: '1px solid var(--color-border)',
boxShadow: '0 -4px 16px rgba(0,0,0,0.3)',
}}>
{calendars.filter(c => c.id !== drag.ev.calendarId && canEditCalendar(c)).map(cal => (
<div key={cal.id} data-cal-drop={cal.id} style={{
display: 'flex', alignItems: 'center', gap: 7, padding: '10px 14px',
borderRadius: 10, fontSize: 13, whiteSpace: 'nowrap', flexShrink: 0,
border: `2px solid ${drag.overId === cal.id ? cal.color : 'var(--color-border)'}`,
background: drag.overId === cal.id ? cal.color + '22' : 'transparent',
color: 'var(--color-text)',
}}>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: cal.color }} />
{cal.name}
</div>
))}
</div>
)}
{/* Der Termin am Zeiger. `pointerEvents: none`, sonst läge er selbst
unter dem Finger und verdeckte das Ziel darunter. */}
{drag && (
<div style={{
position: 'fixed', left: drag.x + 12, top: drag.y - 14, zIndex: 300,
pointerEvents: 'none', maxWidth: 220, padding: '5px 9px', borderRadius: 6,
fontSize: 12, background: 'var(--color-surface)', color: drag.ev.color,
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)' : ''}
</div>
)}
<input ref={importInputRef} type="file" accept=".ics,text/calendar" style={{ display: 'none' }} onChange={handleImportFile} />
{dayDialog && (
<DayDialog
day={dayDialog}
events={byDay.get(dayKey(dayDialog)) ?? []}
onPick={ev => { setDayDialog(null); openEditEvent(ev) }}
onCreate={() => { const d = dayDialog; setDayDialog(null); openNewEvent(suggestStart(d)) }}
onClose={() => setDayDialog(null)}
/>
)}
{eventDialog && (
<EventDialog
event={eventDialog.event}
defaultStart={eventDialog.start}
calendars={writableCalendars}
calendarId={eventDialog.calId}
onSave={saveEvent}
onDelete={eventDialog.event ? () => setDeleteEvent({
calId: eventDialog.calId,
uid: eventDialog.event!.uid,
summary: eventDialog.event!.summary,
recurring: eventDialog.event!.recurring,
// Der angeklickte Einzeltermin – nur er wird gestrichen, wenn
// man sich für „nur diesen" entscheidet.
occurrence: eventDialog.event!.start,
}) : undefined}
onCancel={() => setEventDialog(null)}
/>
)}
{calDialog && (
<CalendarDialog calendar={calDialog.calendar} onSave={saveCalendar} onCancel={() => setCalDialog(null)} />
)}
{shareCalendar && (
<ShareWithDialog
resource={{ type: 'calendar', id: shareCalendar.id, name: shareCalendar.name }}
onClose={() => setShareCalendar(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 && (
<DeleteEventDialog
summary={deleteEvent.summary}
recurring={deleteEvent.recurring}
onSingle={() => confirmDeleteEvent('single')}
onSeries={() => confirmDeleteEvent('series')}
onCancel={() => setDeleteEvent(null)}
/>
)}
<style>{`
.aether-cal-row:hover { background: var(--color-surface-2); }
.aether-cal-cell { transition: background 0.12s; }
.aether-cal-cell:hover { background: var(--color-surface-2); }
.aether-cal-chip { transition: filter 0.12s; }
.aether-cal-chip:hover { filter: brightness(1.12); }
`}</style>
</div>
)
}
const navBtnStyle: CSSProperties = {
width: 26, height: 26, borderRadius: 6, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text)', cursor: 'pointer', fontSize: 14,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}
/**
* Das Menü eines Kalenders.
*
* Bei einem **fremden** Kalender bleiben nur die Punkte stehen, die auch
* gehen: ansehen und ausgeben. Bearbeiten, Löschen, Freigeben gehören dem
* Eigentümer – ein Menüpunkt, der immer scheitert, ist schlimmer als keiner.
* Der Server lehnt sie ohnehin ab; hier geht es darum, gar nicht erst
* hinzuführen.
*/
function CalMenu({ onRename, onExport, onImport, onDelete, onShare, own, canWrite }: {
onRename: () => void; onExport: () => void; onImport: () => void
onDelete: () => void; onShare: () => void
own: boolean; canWrite: boolean
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function onDoc(e: globalThis.MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', onDoc)
window.addEventListener('keydown', onKey)
return () => { document.removeEventListener('mousedown', onDoc); window.removeEventListener('keydown', onKey) }
}, [open])
return (
<div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
<button onClick={() => setOpen(o => !o)} aria-label="Kalendermenü" style={{
border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', padding: '0 2px',
}}>⋮</button>
{open && (
<div style={{
position: 'absolute', right: 0, top: 22, zIndex: 30, minWidth: 165,
background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: 8,
boxShadow: '0 4px 14px rgba(0,0,0,0.25)', overflow: 'hidden',
}}>
{own && <MenuRow icon={<RenameIcon />} label="Bearbeiten" onClick={() => { setOpen(false); onRename() }} />}
{own && <MenuRow icon={<PeopleIcon />} label="Freigeben…" onClick={() => { setOpen(false); onShare() }} />}
<MenuRow icon={<DownloadIcon />} label="Exportieren (.ics)" onClick={() => { setOpen(false); onExport() }} />
{canWrite && <MenuRow label="Importieren (.ics)" onClick={() => { setOpen(false); onImport() }} />}
{own && <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} className="aether-cal-row" 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 ?? <span style={{ width: 15 }} />}{label}
</button>
)
}
function timeLabel(d: Date): string {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
/** Ein Termin im Monatsraster bzw. in der ganztägigen Zeile der Woche.
* `stopPropagation` ist hier das Entscheidende: Ohne das reichte der Klick
* an die Tageszelle darunter durch und öffnete den Tag statt den Termin. */
function EventChip({ ev, onClick, onDown }: {
ev: EventWithCalendar; onClick: () => void; onDown: EventDown
}) {
return (
<div
className="aether-cal-chip"
onClick={(e: MouseEvent) => { e.stopPropagation(); onClick() }}
onPointerDown={e => { e.stopPropagation(); onDown(ev, e) }}
title={ev.summary}
style={{
// Ohne das scrollt die Ansicht mit, sobald der Finger auf einem
// Termin liegt – und das lange Drücken käme nie zustande.
touchAction: 'none', userSelect: 'none',
display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, padding: '1px 5px', borderRadius: 4,
background: ev.color + '26', color: ev.color, cursor: 'pointer', marginBottom: 2,
borderLeft: `2px solid ${ev.color}`,
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', flexShrink: 0,
}}
>
{!ev.all_day && <span style={{ opacity: 0.75 }}>{timeLabel(ev.startsAt)}</span>}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{ev.summary || '(ohne Titel)'}</span>
{ev.recurring && <span style={{ opacity: 0.7, flexShrink: 0 }}>↻</span>}
</div>
)
}
function MonthGrid({ cursor, byDay, onOpenDay, onEventClick, onEventDown }: {
cursor: Date
byDay: Map<string, EventWithCalendar[]>
onOpenDay: (day: Date) => void
onEventClick: (ev: EventWithCalendar) => void
onEventDown: EventDown
}) {
const gridStart = startOfWeek(startOfMonth(cursor))
const today = new Date()
// So viele Wochen, wie der Monat wirklich berührt – nicht immer sechs.
const nextMonth = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1)
const weekCount = Math.ceil((+nextMonth - +gridStart) / (7 * 86400000))
const weeks = Array.from({ length: weekCount }, (_, w) =>
Array.from({ length: 7 }, (_, i) => addDays(gridStart, w * 7 + i)))
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', borderBottom: '1px solid var(--color-border)', flexShrink: 0 }}>
{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}, minmax(0, 1fr))`, flex: 1, overflow: 'auto' }}>
{weeks.map((row, i) => (
<div key={i} style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', borderBottom: '1px solid var(--color-border)', minHeight: 96 }}>
{row.map(day => {
const inMonth = day.getMonth() === cursor.getMonth()
const events = byDay.get(dayKey(day)) ?? []
const isToday = isSameDay(day, today)
return (
<div
key={+day}
className="aether-cal-cell"
onClick={() => onOpenDay(day)}
style={{
borderRight: '1px solid var(--color-border)', padding: 4, cursor: 'pointer',
background: inMonth ? 'transparent' : 'var(--color-surface-2)',
opacity: inMonth ? 1 : 0.55,
display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: 0,
}}
>
<span style={{
fontSize: 11.5, fontWeight: isToday ? 700 : 400,
color: isToday ? '#fff' : 'var(--color-text-muted)',
background: isToday ? 'var(--color-primary)' : 'transparent',
borderRadius: 10, padding: '1px 6px', alignSelf: 'flex-start', marginBottom: 3, flexShrink: 0,
}}>
{day.getDate()}
</span>
<div style={{ overflow: 'hidden', minWidth: 0 }}>
{events.slice(0, 3).map(ev => (
<EventChip key={eventKey(ev)} ev={ev} onClick={() => onEventClick(ev)} onDown={onEventDown} />
))}
{events.length > 3 && (
<span style={{ fontSize: 10.5, color: 'var(--color-text-muted)', paddingLeft: 4 }}>
+{events.length - 3} weitere
</span>
)}
</div>
</div>
)
})}
</div>
))}
</div>
</div>
)
}
interface Slot {
ev: EventWithCalendar
top: number
height: number
col: number
cols: number
}
/** Überlappende Termine nebeneinander statt übereinander. Ohne das liegt der
* zweite Termin einer Stunde unsichtbar unter dem ersten. */
function layoutDay(events: EventWithCalendar[], day: Date): Slot[] {
const dayStart = +startOfDay(day)
const items = events
.filter(ev => !ev.all_day)
.map(ev => {
const from = Math.max(0, (+ev.startsAt - dayStart) / 60000)
const rawTo = ev.endsAt ? (+ev.endsAt - dayStart) / 60000 : from + 60
const to = Math.min(24 * 60, Math.max(from + 20, rawTo)) // Mindesthöhe, sonst unklickbar
return { ev, from, to, col: 0, cols: 1 }
})
.sort((a, b) => a.from - b.from || a.to - b.to)
let cluster: typeof items = []
let clusterEnd = -1
const colEnds: number[] = []
const flush = () => {
const cols = Math.max(...cluster.map(s => s.col)) + 1
cluster.forEach(s => { s.cols = cols })
cluster = []
colEnds.length = 0
}
for (const s of items) {
if (cluster.length && s.from >= clusterEnd) flush()
let c = colEnds.findIndex(e => e <= s.from)
if (c === -1) { c = colEnds.length; colEnds.push(s.to) } else { colEnds[c] = s.to }
s.col = c
cluster.push(s)
clusterEnd = Math.max(clusterEnd, s.to)
}
if (cluster.length) flush()
return items.map(s => ({
ev: s.ev,
top: (s.from / 60) * HOUR_H,
height: ((s.to - s.from) / 60) * HOUR_H,
col: s.col,
cols: s.cols,
}))
}
function WeekGrid({ cursor, byDay, onSlotClick, onOpenDay, onEventClick, onEventDown }: {
cursor: Date
byDay: Map<string, EventWithCalendar[]>
onSlotClick: (start: Date) => void
onOpenDay: (day: Date) => void
onEventClick: (ev: EventWithCalendar) => void
onEventDown: EventDown
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const start = startOfWeek(cursor)
const days = useMemo(() => Array.from({ length: 7 }, (_, i) => addDays(start, i)), [+start])
const today = new Date()
const hours = Array.from({ length: 24 }, (_, h) => h)
// Beim Öffnen auf den Morgen springen – 0 Uhr oben zu zeigen verschenkt
// den halben Bildschirm an eine Zeit, in der nichts stattfindet.
useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = 7 * HOUR_H
}, [])
const cols = `${GUTTER}px repeat(7, minmax(0, 1fr))`
const nowTop = (today.getHours() * 60 + today.getMinutes()) / 60 * HOUR_H
/** Klick ins Raster: die angeklickte Stunde, nicht Mitternacht. */
function slotFromClick(e: MouseEvent<HTMLDivElement>, day: Date) {
const rect = e.currentTarget.getBoundingClientRect()
const minutes = ((e.clientY - rect.top) / HOUR_H) * 60
const d = startOfDay(day)
// Auf halbe Stunden runden – minutengenau anzuklicken ist Glückssache.
d.setMinutes(Math.max(0, Math.min(23 * 60 + 30, Math.round(minutes / 30) * 30)))
return d
}
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
{/* Kopfzeile – der Tag darin öffnet den Tag, wie im Monatsraster. */}
<div style={{ display: 'grid', gridTemplateColumns: cols, borderBottom: '1px solid var(--color-border)', flexShrink: 0 }}>
<div />
{days.map(day => {
const isToday = isSameDay(day, today)
return (
<button key={+day} onClick={() => onOpenDay(day)} className="aether-cal-cell" style={{
padding: '6px 4px', textAlign: 'center', borderLeft: '1px solid var(--color-border)',
background: 'transparent', border: 'none', borderLeftWidth: 1, borderLeftStyle: 'solid',
borderLeftColor: 'var(--color-border)', cursor: 'pointer',
}}>
<div style={{ fontSize: 10.5, color: 'var(--color-text-muted)' }}>{weekdayLabels[(day.getDay() + 6) % 7]}</div>
<span style={{
fontSize: 14, fontWeight: isToday ? 700 : 500,
color: isToday ? '#fff' : 'var(--color-text)',
background: isToday ? 'var(--color-primary)' : 'transparent',
borderRadius: 12, padding: '1px 7px', display: 'inline-block',
}}>
{day.getDate()}
</span>
</button>
)
})}
</div>
{/* 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>
{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 }}>
{(byDay.get(dayKey(day)) ?? []).filter(ev => ev.all_day).map(ev => (
<EventChip key={eventKey(ev)} ev={ev} onClick={() => onEventClick(ev)} onDown={onEventDown} />
))}
</div>
))}
</div>
{/* Stundenraster */}
<div ref={scrollRef} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: cols, height: 24 * HOUR_H, position: 'relative' }}>
<div style={{ position: 'relative' }}>
{hours.map(h => (
<div key={h} style={{
position: 'absolute', top: h * HOUR_H - 6, right: 6,
fontSize: 10, color: 'var(--color-text-muted)',
}}>
{h > 0 ? `${String(h).padStart(2, '0')}:00` : ''}
</div>
))}
</div>
{days.map(day => {
const slots = layoutDay(byDay.get(dayKey(day)) ?? [], day)
const isToday = isSameDay(day, today)
return (
<div
key={+day}
className="aether-cal-cell"
onClick={e => onSlotClick(slotFromClick(e, day))}
style={{ position: 'relative', borderLeft: '1px solid var(--color-border)', cursor: 'pointer', minWidth: 0 }}
>
{hours.map(h => (
<div key={h} style={{
position: 'absolute', top: h * HOUR_H, left: 0, right: 0, height: HOUR_H,
borderTop: h === 0 ? 'none' : '1px solid var(--color-border)',
opacity: 0.6, pointerEvents: 'none',
}} />
))}
{isToday && (
<div style={{
position: 'absolute', top: nowTop, left: 0, right: 0, height: 2,
background: 'var(--color-danger)', zIndex: 3, pointerEvents: 'none',
}} />
)}
{slots.map(({ ev, top, height, col, cols: n }) => (
<div
key={eventKey(ev)}
className="aether-cal-chip"
onClick={e => { e.stopPropagation(); onEventClick(ev) }}
onPointerDown={e => { e.stopPropagation(); onEventDown(ev, e) }}
title={`${ev.summary} – ${timeLabel(ev.startsAt)}`}
style={{
position: 'absolute', top, height, touchAction: 'none', userSelect: 'none',
left: `calc(${(col / n) * 100}% + 2px)`,
width: `calc(${100 / n}% - 4px)`,
background: ev.color + '2e', borderLeft: `3px solid ${ev.color}`,
borderRadius: 4, padding: '2px 4px', overflow: 'hidden', cursor: 'pointer', zIndex: 2,
}}
>
<div style={{ fontSize: 10.5, color: ev.color, fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{ev.summary || '(ohne Titel)'}{ev.recurring ? ' ↻' : ''}
</div>
{height > 28 && (
<div style={{ fontSize: 9.5, color: 'var(--color-text-muted)' }}>{timeLabel(ev.startsAt)}</div>
)}
</div>
))}
</div>
)
})}
</div>
</div>
</div>
)
}
function ListAgenda({ events, onEventClick, onEventDown }: {
events: EventWithCalendar[]
onEventClick: (ev: EventWithCalendar) => void
onEventDown: EventDown
}) {
const groups = useMemo(() => {
const map = new Map<string, { day: Date; evs: EventWithCalendar[] }>()
for (const ev of events) {
for (const day of spannedDays(ev.startsAt, ev.endsAt, ev.all_day)) {
const key = dayKey(day)
if (!map.has(key)) map.set(key, { day, evs: [] })
map.get(key)!.evs.push(ev)
}
}
return [...map.values()]
.sort((a, b) => +a.day - +b.day)
.map(g => ({ ...g, evs: g.evs.sort((a, b) => (a.all_day === b.all_day ? +a.startsAt - +b.startsAt : a.all_day ? -1 : 1)) }))
}, [events])
if (groups.length === 0) {
return <p style={{ padding: 24, color: 'var(--color-text-muted)', fontSize: 13 }}>Keine Termine.</p>
}
const today = new Date()
return (
<div style={{ padding: '12px 20px', overflowY: 'auto', flex: 1 }}>
{groups.map(({ day, evs }) => (
<div key={dayKey(day)} style={{ marginBottom: 18 }}>
<div style={{
fontSize: 12.5, fontWeight: 600, marginBottom: 6,
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'}
</div>
{evs.map(ev => (
<div key={eventKey(ev)} className="aether-cal-cell"
onClick={() => onEventClick(ev)}
onPointerDown={e => onEventDown(ev, e)}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8,
cursor: 'pointer', border: '1px solid var(--color-border)', marginBottom: 6,
// Hier bewusst **kein** touchAction: 'none' wie bei den
// Terminen im Raster: Die Zeilen füllen die ganze Breite, und
// eine Liste, die sich nicht mehr scrollen lässt, wäre ein
// schlechter Tausch für eine Zieh-Geste. Mit der Maus ziehen
// geht trotzdem.
}}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: ev.color, flexShrink: 0 }} />
<span style={{ fontSize: 12.5, color: 'var(--color-text-muted)', width: 62, flexShrink: 0 }}>
{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 ? ' ↻' : ''}
</span>
{ev.location && (
<span style={{ fontSize: 12, color: 'var(--color-text-muted)', marginLeft: 'auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ev.location}
</span>
)}
</div>
))}
</div>
))}
</div>
)
}