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>
1041 lines
44 KiB
TypeScript
1041 lines
44 KiB
TypeScript
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>
|
||
)
|
||
}
|