Die Frist von 220 ms galt nur beim Wechsel *innerhalb* der Dateiansicht. Beim Wechsel zwischen den Menüpunkten entsteht die Seite jedes Mal neu – damit war jeder Tab-Wechsel ein „erstes Laden", und genau dort war die Regel „beim ersten Mal sofort zeigen" falsch herum. Die Frist gilt jetzt immer; 220 ms Leere ist das ruhigere Bild, man kommt ohnehin von einer anderen Seite. Als gemeinsamer Haken `useSkeleton`, statt die Logik in fünf Seiten zu wiederholen. Dabei wäre beinahe ein schlimmerer Geist entstanden: Im Wartefenster hat kurz der **Leerzustand** aufgeblitzt – „Nothing shared with you yet", eine Zehntelsekunde bevor die Liste kam. Der Platzhalter sieht nur nach Daten aus, ein Satz behauptet etwas. Das Wartefenster bleibt jetzt überall leer. Dazu drei Reste der Kalender-Übersetzung, die mein Suchmuster übersehen hatte, weil sie in Template-Strings standen: der Löschen-Dialog des Kalenders und der Wochentag-Hinweis im Terminfenster. Und das Datum in der Listenansicht stand noch im deutschen Format („18. September 2026") in einer englischen Oberfläche – jetzt „September 18, 2026". Geprüft: tsc sauber, 41 Vitest-Tests, alle 234 Prüfungen der sieben Selbsttest-Suiten grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1050 lines
45 KiB
TypeScript
1050 lines
45 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'
|
||
import { SkeletonRows, useSkeleton } from '../components/Skeleton'
|
||
import EmptyState from '../components/EmptyState'
|
||
|
||
type ViewMode = 'month' | 'week' | 'list'
|
||
|
||
const weekdayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||
const monthLabels = [
|
||
'January', 'February', 'March', 'April', 'May', 'June',
|
||
'July', 'August', 'September', 'October', 'November', 'December',
|
||
]
|
||
|
||
/** 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 zeigeSkelett = useSkeleton(loading)
|
||
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 : 'Could not load the calendar')
|
||
} 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 : 'Could not save')
|
||
}
|
||
}
|
||
|
||
/** 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 : 'Could not move the event')
|
||
}
|
||
}
|
||
|
||
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 : 'Could not delete')
|
||
}
|
||
}
|
||
|
||
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 : 'Could not save')
|
||
}
|
||
}
|
||
|
||
async function confirmDeleteCalendar() {
|
||
if (!deleteCalendar) return
|
||
try {
|
||
await calendarApi.remove(deleteCalendar.id)
|
||
setDeleteCalendar(null)
|
||
await loadCalendars()
|
||
} catch (e) {
|
||
setError(e instanceof APIError ? e.message : 'Could not delete')
|
||
}
|
||
}
|
||
|
||
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 failed')
|
||
}
|
||
}
|
||
|
||
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()}`
|
||
})()
|
||
: 'All events'
|
||
|
||
if (loading) {
|
||
return <div className="aether-content">{zeigeSkelett && <SkeletonRows />}</div>
|
||
}
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', minWidth: 0 }}>
|
||
<PageHeader
|
||
title="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>
|
||
}
|
||
>
|
||
<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' ? 'Month' : m === 'week' ? 'Week' : 'List'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{viewMode !== 'list' && (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<button onClick={() => stepPeriod(-1)} style={navBtnStyle} aria-label="back">‹</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 }}>Today</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 }}>CALENDARS</span>
|
||
<button onClick={() => setCalDialog({})} title="New calendar" 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 ? 'Hide' : 'Show'}
|
||
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' ? ' (read-only)' : ''}
|
||
</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 || '(untitled)'}{drag.ev.recurring ? ' ↻ (entire series)' : ''}
|
||
</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="Delete calendar"
|
||
message={`Permanently delete "${deleteCalendar.name}" and every event in it?`}
|
||
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 || '(untitled)'}</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' }}>all-day</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 || '(untitled)'}{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 (
|
||
<EmptyState
|
||
icon="📅"
|
||
title="No events"
|
||
hint="Nothing scheduled in this period."
|
||
/>
|
||
)
|
||
}
|
||
|
||
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]}, {monthLabels[day.getMonth()]} {day.getDate()}, {day.getFullYear()}
|
||
{isSameDay(day, today) && ' · today'}
|
||
</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 || '(untitled)'}{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>
|
||
)
|
||
}
|