feat(calendar): Phase 3 – Weboberfläche mit Monats-, Wochen- und Listenansicht
Neue Seite CalendarPage (Menüpunkt 'Calendar' in der Seitenleiste,
zwischen Home und Papierkorb) mit Kalenderliste zum Anlegen/Umbenennen/
Löschen/Ein- und Ausblenden, Export/Import als .ics über die
Phase-2-API, und Termine anlegen/ändern/löschen über einen neuen
JSON-Endpunkt.
Backend: neuer events_api.go-Handler unter /api/v1/calendars/{id}/events
(Session-Auth) neben dem bestehenden CalDAV-Protokoll – ein Termin, den
die Web-Oberfläche anlegt, ist ein ganz normales CalDAV-Objekt und
synchronisiert genauso zu DAVx5 wie umgekehrt. Ein per CalDAV mit RRULE
angelegter, wiederkehrender Termin zeigt sich hier nur (recurring:true),
lässt sich aber bewusst nicht über dieses vereinfachte Formular
bearbeiten (409) – das würde die Wiederholungsregel eher beschädigen
als korrekt anpassen.
Gegen den laufenden Container getestet: Kalender/Termin anlegen, per
Web-API ändern und löschen, ein per CalDAV mit RRULE angelegter
Wiederkehrer taucht als recurring:true auf und blockt PATCH mit 409.
TypeScript-Build (tsc + vite build) sauber.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4d635fff20
commit
27a026421f
10 files changed
+1163
-6
No files matched your search
@@ -0,0 +1,550 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
calendarApi, APIError,
|
||||
type CalendarItem, type CalendarEvent, type EventInput,
|
||||
} from '../api/client'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import ConfirmDialog from '../components/ConfirmDialog'
|
||||
import EventDialog from '../components/calendar/EventDialog'
|
||||
import CalendarDialog from '../components/calendar/CalendarDialog'
|
||||
import { TrashIcon, RenameIcon, DownloadIcon } from '../components/icons'
|
||||
|
||||
type ViewMode = 'month' | 'week' | 'list'
|
||||
|
||||
const weekdayLabels = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
const monthLabels = [
|
||||
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||
]
|
||||
|
||||
function isSameDay(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
||||
}
|
||||
function addDays(d: Date, n: number): Date {
|
||||
const r = new Date(d)
|
||||
r.setDate(r.getDate() + n)
|
||||
return r
|
||||
}
|
||||
/** Montag der Woche, in der `d` liegt. */
|
||||
function startOfWeek(d: Date): Date {
|
||||
const r = new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||
const dow = (r.getDay() + 6) % 7 // 0 = Montag
|
||||
return addDays(r, -dow)
|
||||
}
|
||||
function startOfMonth(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1)
|
||||
}
|
||||
|
||||
interface EventWithCalendar extends CalendarEvent {
|
||||
calendarId: string
|
||||
color: string
|
||||
}
|
||||
|
||||
/** Ein flacher Zeiger von jedem Tag auf seine Termine – Grundlage für Monats-,
|
||||
* Wochen- und Listenansicht gleichermaßen. Mehrtägige Termine erscheinen nur
|
||||
* am Starttag; eine echte Spanne über mehrere Zellen ist für eine erste
|
||||
* Fassung bewusst zurückgestellt. */
|
||||
function groupByDay(events: EventWithCalendar[]): Map<string, EventWithCalendar[]> {
|
||||
const map = new Map<string, EventWithCalendar[]>()
|
||||
for (const ev of events) {
|
||||
const d = new Date(ev.start)
|
||||
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key)!.push(ev)
|
||||
}
|
||||
for (const list of map.values()) list.sort((a, b) => a.start.localeCompare(b.start))
|
||||
return map
|
||||
}
|
||||
function dayKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
|
||||
}
|
||||
|
||||
export default function CalendarPage() {
|
||||
const [calendars, setCalendars] = useState<CalendarItem[]>([])
|
||||
const [visible, setVisible] = useState<Set<string>>(new Set())
|
||||
const [eventsByCal, setEventsByCal] = useState<Record<string, CalendarEvent[]>>({})
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('month')
|
||||
const [cursor, setCursor] = useState(new Date())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [eventDialog, setEventDialog] = useState<{ calId: string; event?: CalendarEvent; date: Date } | null>(null)
|
||||
const [calDialog, setCalDialog] = useState<{ calendar?: CalendarItem } | null>(null)
|
||||
const [deleteCalendar, setDeleteCalendar] = useState<CalendarItem | null>(null)
|
||||
const [deleteEvent, setDeleteEvent] = useState<{ calId: string; uid: string } | null>(null)
|
||||
|
||||
const importInputRef = useRef<HTMLInputElement>(null)
|
||||
const importTargetRef = useRef<string | null>(null)
|
||||
|
||||
const loadCalendars = useCallback(async () => {
|
||||
const list = await calendarApi.list()
|
||||
setCalendars(list)
|
||||
setVisible(prev => {
|
||||
// Neue Kalender starten sichtbar; einmal ausgeblendete bleiben es beim Neuladen.
|
||||
const next = new Set(prev)
|
||||
for (const c of list) if (!prev.has(c.id) && prev.size === 0) next.add(c.id)
|
||||
if (prev.size === 0) for (const c of list) next.add(c.id)
|
||||
return next
|
||||
})
|
||||
return list
|
||||
}, [])
|
||||
|
||||
const loadEvents = useCallback(async (list: CalendarItem[]) => {
|
||||
const pairs = await Promise.all(list.map(async c => {
|
||||
try {
|
||||
return [c.id, await calendarApi.listEvents(c.id)] as const
|
||||
} catch {
|
||||
return [c.id, []] as const
|
||||
}
|
||||
}))
|
||||
setEventsByCal(Object.fromEntries(pairs))
|
||||
}, [])
|
||||
|
||||
const reloadAll = useCallback(async () => {
|
||||
setError(null)
|
||||
try {
|
||||
const list = await loadCalendars()
|
||||
await loadEvents(list)
|
||||
} catch (e) {
|
||||
setError(e instanceof APIError ? e.message : 'Laden fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [loadCalendars, loadEvents])
|
||||
|
||||
useEffect(() => { reloadAll() }, [reloadAll])
|
||||
|
||||
const allEvents: EventWithCalendar[] = useMemo(() => {
|
||||
const out: EventWithCalendar[] = []
|
||||
for (const cal of calendars) {
|
||||
if (!visible.has(cal.id)) continue
|
||||
for (const ev of eventsByCal[cal.id] ?? []) {
|
||||
out.push({ ...ev, calendarId: cal.id, color: cal.color })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}, [calendars, visible, eventsByCal])
|
||||
|
||||
const byDay = useMemo(() => groupByDay(allEvents), [allEvents])
|
||||
|
||||
function toggleVisible(id: string) {
|
||||
setVisible(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const defaultCalendarId = calendars[0]?.id
|
||||
|
||||
function openNewEvent(date: Date) {
|
||||
if (!defaultCalendarId) return
|
||||
setEventDialog({ calId: defaultCalendarId, date })
|
||||
}
|
||||
function openEditEvent(ev: EventWithCalendar) {
|
||||
setEventDialog({ calId: ev.calendarId, event: ev, date: new Date(ev.start) })
|
||||
}
|
||||
|
||||
async function saveEvent(input: EventInput) {
|
||||
if (!eventDialog) return
|
||||
try {
|
||||
if (eventDialog.event) {
|
||||
await calendarApi.updateEvent(eventDialog.calId, eventDialog.event.uid, input)
|
||||
} else {
|
||||
await calendarApi.createEvent(eventDialog.calId, input)
|
||||
}
|
||||
setEventDialog(null)
|
||||
await loadEvents(calendars)
|
||||
} catch (e) {
|
||||
setError(e instanceof APIError ? e.message : 'Speichern fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteEvent() {
|
||||
if (!deleteEvent) return
|
||||
try {
|
||||
await calendarApi.deleteEvent(deleteEvent.calId, deleteEvent.uid)
|
||||
setDeleteEvent(null)
|
||||
setEventDialog(null)
|
||||
await loadEvents(calendars)
|
||||
} catch (e) {
|
||||
setError(e instanceof APIError ? e.message : 'Löschen fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCalendar(name: string, color: string) {
|
||||
try {
|
||||
if (calDialog?.calendar) {
|
||||
await calendarApi.update(calDialog.calendar.id, { name, color })
|
||||
} else {
|
||||
await calendarApi.create(name, color)
|
||||
}
|
||||
setCalDialog(null)
|
||||
await reloadAll()
|
||||
} catch (e) {
|
||||
setError(e instanceof APIError ? e.message : 'Speichern fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteCalendar() {
|
||||
if (!deleteCalendar) return
|
||||
try {
|
||||
await calendarApi.remove(deleteCalendar.id)
|
||||
setDeleteCalendar(null)
|
||||
await reloadAll()
|
||||
} catch (e) {
|
||||
setError(e instanceof APIError ? e.message : 'Löschen fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
function triggerImport(calId: string) {
|
||||
importTargetRef.current = calId
|
||||
importInputRef.current?.click()
|
||||
}
|
||||
async function handleImportFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
const calId = importTargetRef.current
|
||||
e.target.value = ''
|
||||
if (!file || !calId) return
|
||||
try {
|
||||
await calendarApi.import(calId, file)
|
||||
await loadEvents(calendars)
|
||||
} catch (err) {
|
||||
setError(err instanceof APIError ? err.message : 'Import fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
function stepPeriod(dir: 1 | -1) {
|
||||
setCursor(prev => {
|
||||
if (viewMode === 'month') return new Date(prev.getFullYear(), prev.getMonth() + dir, 1)
|
||||
if (viewMode === 'week') return addDays(prev, dir * 7)
|
||||
return addDays(prev, dir * 30)
|
||||
})
|
||||
}
|
||||
|
||||
const periodLabel = viewMode === 'month'
|
||||
? `${monthLabels[cursor.getMonth()]} ${cursor.getFullYear()}`
|
||||
: viewMode === 'week'
|
||||
? (() => {
|
||||
const s = startOfWeek(cursor)
|
||||
const e = addDays(s, 6)
|
||||
return `${s.getDate()}.${s.getMonth() + 1}. – ${e.getDate()}.${e.getMonth() + 1}.${e.getFullYear()}`
|
||||
})()
|
||||
: 'Anstehend'
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ padding: 24, color: 'var(--color-text-muted)' }}>Lädt…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', minWidth: 0 }}>
|
||||
<PageHeader title="Kalender">
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{(['month', 'week', 'list'] as ViewMode[]).map(m => (
|
||||
<button key={m} onClick={() => setViewMode(m)} style={{
|
||||
padding: '6px 12px', borderRadius: 8, fontSize: 12.5, cursor: 'pointer',
|
||||
border: '1px solid var(--color-border)',
|
||||
background: viewMode === m ? 'var(--color-primary)' : 'transparent',
|
||||
color: viewMode === m ? '#fff' : 'var(--color-text-muted)',
|
||||
}}>
|
||||
{m === 'month' ? 'Monat' : m === 'week' ? 'Woche' : 'Liste'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{viewMode !== 'list' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<button onClick={() => stepPeriod(-1)} style={navBtnStyle}>‹</button>
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text)', minWidth: 130, textAlign: 'center' }}>{periodLabel}</span>
|
||||
<button onClick={() => stepPeriod(1)} style={navBtnStyle}>›</button>
|
||||
<button onClick={() => setCursor(new Date())} style={{ ...navBtnStyle, width: 'auto', padding: '0 10px', fontSize: 12 }}>Heute</button>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => openNewEvent(new Date())} disabled={!defaultCalendarId} style={{
|
||||
padding: '7px 14px', borderRadius: 8, border: 'none',
|
||||
background: 'var(--color-primary)', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||
opacity: defaultCalendarId ? 1 : 0.5,
|
||||
}}>
|
||||
+ Termin
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{error && (
|
||||
<div style={{ padding: '8px 16px', background: 'var(--color-danger)', color: '#fff', fontSize: 13 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
||||
<aside style={{ width: 220, flexShrink: 0, borderRight: '1px solid var(--color-border)', padding: 14, overflowY: 'auto' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase' }}>Kalender</span>
|
||||
<button onClick={() => setCalDialog({})} title="Neuer Kalender" style={{
|
||||
border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-primary)', fontSize: 16, lineHeight: 1,
|
||||
}}>+</button>
|
||||
</div>
|
||||
{calendars.map(cal => (
|
||||
<div key={cal.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0', fontSize: 13, color: 'var(--color-text)' }}>
|
||||
<input type="checkbox" checked={visible.has(cal.id)} onChange={() => toggleVisible(cal.id)} />
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: cal.color, flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{cal.name}</span>
|
||||
<CalMenu
|
||||
onRename={() => setCalDialog({ calendar: cal })}
|
||||
onExport={() => window.open(calendarApi.exportUrl(cal.id), '_blank')}
|
||||
onImport={() => triggerImport(cal.id)}
|
||||
onDelete={() => setDeleteCalendar(cal)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{calendars.length === 0 && (
|
||||
<p style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>Noch kein Kalender – leg einen an.</p>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: 'auto' }}>
|
||||
{viewMode === 'month' && (
|
||||
<MonthGrid cursor={cursor} byDay={byDay} onDayClick={openNewEvent} onEventClick={openEditEvent} />
|
||||
)}
|
||||
{viewMode === 'week' && (
|
||||
<WeekColumns cursor={cursor} byDay={byDay} onDayClick={openNewEvent} onEventClick={openEditEvent} />
|
||||
)}
|
||||
{viewMode === 'list' && (
|
||||
<ListAgenda events={allEvents} onEventClick={openEditEvent} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input ref={importInputRef} type="file" accept=".ics,text/calendar" style={{ display: 'none' }} onChange={handleImportFile} />
|
||||
|
||||
{eventDialog && (
|
||||
<EventDialog
|
||||
event={eventDialog.event}
|
||||
defaultDate={eventDialog.date}
|
||||
onSave={saveEvent}
|
||||
onDelete={eventDialog.event ? () => setDeleteEvent({ calId: eventDialog.calId, uid: eventDialog.event!.uid }) : undefined}
|
||||
onCancel={() => setEventDialog(null)}
|
||||
/>
|
||||
)}
|
||||
{calDialog && (
|
||||
<CalendarDialog calendar={calDialog.calendar} onSave={saveCalendar} onCancel={() => setCalDialog(null)} />
|
||||
)}
|
||||
{deleteCalendar && (
|
||||
<ConfirmDialog
|
||||
title="Kalender löschen"
|
||||
message={`"${deleteCalendar.name}" mit allen Terminen darin unwiderruflich löschen?`}
|
||||
confirmLabel="Löschen" danger
|
||||
onConfirm={confirmDeleteCalendar}
|
||||
onCancel={() => setDeleteCalendar(null)}
|
||||
/>
|
||||
)}
|
||||
{deleteEvent && (
|
||||
<ConfirmDialog
|
||||
title="Termin löschen"
|
||||
message="Diesen Termin unwiderruflich löschen?"
|
||||
confirmLabel="Löschen" danger
|
||||
onConfirm={confirmDeleteEvent}
|
||||
onCancel={() => setDeleteEvent(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const navBtnStyle: React.CSSProperties = {
|
||||
width: 26, height: 26, borderRadius: 6, border: '1px solid var(--color-border)',
|
||||
background: 'transparent', color: 'var(--color-text)', cursor: 'pointer', fontSize: 14,
|
||||
}
|
||||
|
||||
function CalMenu({ onRename, onExport, onImport, onDelete }: {
|
||||
onRename: () => void; onExport: () => void; onImport: () => void; onDelete: () => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
function onDoc(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDoc)
|
||||
return () => document.removeEventListener('mousedown', onDoc)
|
||||
}, [])
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button onClick={() => setOpen(o => !o)} style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', padding: 2 }}>⋮</button>
|
||||
{open && (
|
||||
<div style={{
|
||||
position: 'absolute', right: 0, top: 22, zIndex: 30, minWidth: 150,
|
||||
background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.2)', overflow: 'hidden',
|
||||
}}>
|
||||
<MenuRow icon={<RenameIcon />} label="Bearbeiten" onClick={() => { setOpen(false); onRename() }} />
|
||||
<MenuRow icon={<DownloadIcon />} label="Exportieren (.ics)" onClick={() => { setOpen(false); onExport() }} />
|
||||
<MenuRow label="Importieren (.ics)" onClick={() => { setOpen(false); onImport() }} />
|
||||
<MenuRow icon={<TrashIcon />} label="Löschen" danger onClick={() => { setOpen(false); onDelete() }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function MenuRow({ icon, label, onClick, danger }: { icon?: React.ReactNode; label: string; onClick: () => void; danger?: boolean }) {
|
||||
return (
|
||||
<button onClick={onClick} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 12px',
|
||||
border: 'none', background: 'none', cursor: 'pointer', fontSize: 12.5, textAlign: 'left',
|
||||
color: danger ? 'var(--color-danger)' : 'var(--color-text)',
|
||||
}}>
|
||||
{icon}{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function EventChip({ ev, onClick }: { ev: EventWithCalendar; onClick: () => void }) {
|
||||
return (
|
||||
<div onClick={onClick} title={ev.summary} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, padding: '1px 4px', borderRadius: 4,
|
||||
background: ev.color + '22', color: ev.color, cursor: 'pointer', marginBottom: 2,
|
||||
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{!ev.all_day && <span style={{ opacity: 0.8 }}>{new Date(ev.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>}
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{ev.summary}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MonthGrid({ cursor, byDay, onDayClick, onEventClick }: {
|
||||
cursor: Date
|
||||
byDay: Map<string, EventWithCalendar[]>
|
||||
onDayClick: (d: Date) => void
|
||||
onEventClick: (ev: EventWithCalendar) => void
|
||||
}) {
|
||||
const first = startOfMonth(cursor)
|
||||
const gridStart = startOfWeek(first)
|
||||
const today = new Date()
|
||||
const weeks: Date[][] = []
|
||||
let d = gridStart
|
||||
for (let w = 0; w < 6; w++) {
|
||||
const row: Date[] = []
|
||||
for (let i = 0; i < 7; i++) { row.push(d); d = addDays(d, 1) }
|
||||
weeks.push(row)
|
||||
if (d > addDays(startOfMonth(new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1)), 0) && w >= 3) break
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', borderBottom: '1px solid var(--color-border)' }}>
|
||||
{weekdayLabels.map(l => (
|
||||
<div key={l} style={{ padding: '6px 8px', fontSize: 11, color: 'var(--color-text-muted)', fontWeight: 600 }}>{l}</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateRows: `repeat(${weeks.length}, 1fr)`, flex: 1 }}>
|
||||
{weeks.map((row, i) => (
|
||||
<div key={i} style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', borderBottom: '1px solid var(--color-border)' }}>
|
||||
{row.map(day => {
|
||||
const inMonth = day.getMonth() === cursor.getMonth()
|
||||
const events = byDay.get(dayKey(day)) ?? []
|
||||
return (
|
||||
<div key={day.toISOString()} onClick={() => onDayClick(day)} style={{
|
||||
borderRight: '1px solid var(--color-border)', padding: 4, minHeight: 90, cursor: 'pointer',
|
||||
opacity: inMonth ? 1 : 0.4, display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: 11.5, color: isSameDay(day, today) ? '#fff' : 'var(--color-text-muted)',
|
||||
background: isSameDay(day, today) ? 'var(--color-primary)' : 'transparent',
|
||||
borderRadius: 10, padding: '1px 6px', alignSelf: 'flex-start', marginBottom: 3,
|
||||
}}>
|
||||
{day.getDate()}
|
||||
</span>
|
||||
{events.slice(0, 4).map(ev => (
|
||||
<EventChip key={ev.uid} ev={ev} onClick={() => onEventClick(ev)} />
|
||||
))}
|
||||
{events.length > 4 && (
|
||||
<span style={{ fontSize: 10.5, color: 'var(--color-text-muted)' }}>+{events.length - 4} mehr</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WeekColumns({ cursor, byDay, onDayClick, onEventClick }: {
|
||||
cursor: Date
|
||||
byDay: Map<string, EventWithCalendar[]>
|
||||
onDayClick: (d: Date) => void
|
||||
onEventClick: (ev: EventWithCalendar) => void
|
||||
}) {
|
||||
const start = startOfWeek(cursor)
|
||||
const days = Array.from({ length: 7 }, (_, i) => addDays(start, i))
|
||||
const today = new Date()
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', height: '100%' }}>
|
||||
{days.map(day => {
|
||||
const events = byDay.get(dayKey(day)) ?? []
|
||||
return (
|
||||
<div key={day.toISOString()} onClick={() => onDayClick(day)} style={{
|
||||
borderRight: '1px solid var(--color-border)', padding: 8, cursor: 'pointer', minHeight: '100%',
|
||||
}}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{weekdayLabels[(day.getDay() + 6) % 7]}</div>
|
||||
<span style={{
|
||||
fontSize: 14, fontWeight: 600,
|
||||
color: isSameDay(day, today) ? '#fff' : 'var(--color-text)',
|
||||
background: isSameDay(day, today) ? 'var(--color-primary)' : 'transparent',
|
||||
borderRadius: 12, padding: '1px 8px',
|
||||
}}>
|
||||
{day.getDate()}
|
||||
</span>
|
||||
</div>
|
||||
{events.map(ev => <EventChip key={ev.uid} ev={ev} onClick={() => onEventClick(ev)} />)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ListAgenda({ events, onEventClick }: { events: EventWithCalendar[]; onEventClick: (ev: EventWithCalendar) => void }) {
|
||||
const sorted = useMemo(() => [...events].sort((a, b) => a.start.localeCompare(b.start)), [events])
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, EventWithCalendar[]>()
|
||||
for (const ev of sorted) {
|
||||
const d = new Date(ev.start)
|
||||
const key = dayKey(d)
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key)!.push(ev)
|
||||
}
|
||||
return [...map.entries()]
|
||||
}, [sorted])
|
||||
|
||||
if (groups.length === 0) {
|
||||
return <p style={{ padding: 24, color: 'var(--color-text-muted)', fontSize: 13 }}>Keine Termine.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px 20px' }}>
|
||||
{groups.map(([key, evs]) => {
|
||||
const d = new Date(evs[0].start)
|
||||
return (
|
||||
<div key={key} style={{ marginBottom: 18 }}>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
||||
{weekdayLabels[(d.getDay() + 6) % 7]}, {d.getDate()}. {monthLabels[d.getMonth()]} {d.getFullYear()}
|
||||
</div>
|
||||
{evs.map(ev => (
|
||||
<div key={ev.uid} onClick={() => onEventClick(ev)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '7px 10px', borderRadius: 8,
|
||||
cursor: 'pointer', border: '1px solid var(--color-border)', marginBottom: 6,
|
||||
}}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: ev.color, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 12.5, color: 'var(--color-text-muted)', width: 50, flexShrink: 0 }}>
|
||||
{ev.all_day ? 'Ganztägig' : new Date(ev.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
<span style={{ fontSize: 13.5, color: 'var(--color-text)' }}>{ev.summary}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user