fix(calendar): Termine anklickbar, Ganztags-Datum korrekt, Wochenraster mit Stunden
Drei gemeldete Fehler, alle nachgestellt: 1. Ein vorhandener Termin ließ sich nicht öffnen. Der Klick auf den Termin reichte an die Tageszelle darunter durch, die sofort 'Neuer Termin' öffnete und den Bearbeiten-Dialog überschrieb. Fehlendes stopPropagation. 2. Klick auf den 3. legte den Termin am 2. an. Ein ganztägiger Termin ist ein *Datum*, kein Zeitpunkt - die Oberfläche rechnete ihn über new Date(...).toISOString() trotzdem in UTC um, was östlich von UTC den Vortag ergibt. Nachgestellt und bestätigt: DTSTART landete als 20260902 statt 20260903. Ganztägige Termine laufen jetzt über dates.ts (allDayISO/eventDay), damit Dialog und Raster dieselbe Rechnung benutzen. Nebenbefund mitgefixt: DTEND war gleich DTSTART, was RFC 5545 verbietet (DTEND ist exklusiv) - ein eintägiger Termin am 3. schreibt jetzt DTEND 20260904, nach außen bleibt die Schnittstelle beim letzten belegten Tag. 3. 'Fühlt sich starr an': Wochenansicht ist jetzt ein echtes Stundenraster (Klick trifft die Stunde statt Mitternacht, ganztägige in eigener Zeile, überlappende Termine nebeneinander, Jetzt-Linie), mehrtägige Termine stehen in jeder Zelle statt nur am Starttag, neue Termine schlagen die nächste volle Stunde bzw. 9 Uhr vor, Kalenderauswahl im Termindialog, Escape schließt, Hover-Rückmeldung, Kalenderliste auf schmalen Schirmen einklappbar. Außerdem: ein neu angelegter Kalender blieb unsichtbar, weil er in keiner Sichtbar-Liste stand - die Liste merkt sich jetzt die ausgeblendeten statt der sichtbaren. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ba33706d2e
commit
105ce8de4a
5 files changed
+702
-240
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState, type FormEvent } from 'react'
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
||||
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
|
||||
import type { CalendarItem } from '../../api/client'
|
||||
|
||||
@@ -16,6 +16,12 @@ export default function CalendarDialog({ calendar, onSave, onCancel }: Props) {
|
||||
const [name, setName] = useState(calendar?.name ?? '')
|
||||
const [color, setColor] = useState(calendar?.color ?? palette[0])
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onCancel() }
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onCancel])
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) return
|
||||
|
||||
@@ -1,70 +1,105 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
||||
import { useEffect, useRef, useState, type CSSProperties, type FormEvent } from 'react'
|
||||
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
|
||||
import type { CalendarEvent, EventInput } from '../../api/client'
|
||||
import type { CalendarEvent, CalendarItem, EventInput } from '../../api/client'
|
||||
import { addDays, allDayISO, eventDay, timedISO, toDateInput, toTimeInput } from './dates'
|
||||
|
||||
interface Props {
|
||||
/** Fehlt sie, entsteht ein neuer Termin. */
|
||||
/** Fehlt er, entsteht ein neuer Termin. */
|
||||
event?: CalendarEvent
|
||||
/** Vorbelegter Starttag – aus dem angeklickten Tag der Monats-/Wochenansicht. */
|
||||
defaultDate: Date
|
||||
onSave: (input: EventInput) => void
|
||||
/** Vorbelegter Start – aus dem angeklickten Tag bzw. der Stunde. */
|
||||
defaultStart: Date
|
||||
calendars: CalendarItem[]
|
||||
calendarId: string
|
||||
onSave: (input: EventInput, calendarId: string) => void
|
||||
onDelete?: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
function toDateInput(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
function toTimeInput(d: Date): string {
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export default function EventDialog({ event, defaultDate, onSave, onDelete, onCancel }: Props) {
|
||||
export default function EventDialog({ event, defaultStart, calendars, calendarId, onSave, onDelete, onCancel }: Props) {
|
||||
const overlay = useOverlayDismiss(onCancel)
|
||||
const titleRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const start = event ? new Date(event.start) : defaultDate
|
||||
const end = event?.end ? new Date(event.end) : new Date(start.getTime() + 60 * 60 * 1000)
|
||||
const allDayInit = event?.all_day ?? false
|
||||
const start = event ? eventDay(event.start, allDayInit) : defaultStart
|
||||
const startTimed = event && !allDayInit ? new Date(event.start) : defaultStart
|
||||
// Bei ganztägigen Terminen liefert die Schnittstelle den letzten *belegten*
|
||||
// Tag – genau das, was im Formular stehen soll (siehe events_api.go).
|
||||
const end = event?.end
|
||||
? eventDay(event.end, allDayInit)
|
||||
: new Date(startTimed.getTime() + 60 * 60 * 1000)
|
||||
|
||||
const [calId, setCalId] = useState(calendarId)
|
||||
const [summary, setSummary] = useState(event?.summary ?? '')
|
||||
const [description, setDescription] = useState(event?.description ?? '')
|
||||
const [location, setLocation] = useState(event?.location ?? '')
|
||||
const [allDay, setAllDay] = useState(event?.all_day ?? false)
|
||||
const [allDay, setAllDay] = useState(allDayInit)
|
||||
const [startDate, setStartDate] = useState(toDateInput(start))
|
||||
const [startTime, setStartTime] = useState(toTimeInput(start))
|
||||
const [startTime, setStartTime] = useState(toTimeInput(startTimed))
|
||||
const [endDate, setEndDate] = useState(toDateInput(end))
|
||||
const [endTime, setEndTime] = useState(toTimeInput(end))
|
||||
|
||||
useEffect(() => { titleRef.current?.focus() }, [])
|
||||
|
||||
// Escape schließt – erwartet man bei jedem Dialog, und ohne fühlt sich die
|
||||
// Oberfläche zäh an.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onCancel() }
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onCancel])
|
||||
|
||||
const readOnly = !!event?.recurring
|
||||
|
||||
/** Ende darf nicht vor dem Start liegen – statt einer Fehlermeldung zieht
|
||||
* das Ende einfach mit, so wie man es von anderen Kalendern kennt. */
|
||||
function changeStartDate(v: string) {
|
||||
const shift = (+new Date(v) - +new Date(startDate)) / 86400000
|
||||
setStartDate(v)
|
||||
if (Number.isFinite(shift) && shift !== 0) {
|
||||
setEndDate(toDateInput(addDays(new Date(`${endDate}T12:00:00`), shift)))
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (readOnly || !summary.trim()) return
|
||||
const s = allDay ? new Date(`${startDate}T00:00:00`) : new Date(`${startDate}T${startTime}:00`)
|
||||
const en = allDay ? new Date(`${endDate}T00:00:00`) : new Date(`${endDate}T${endTime}:00`)
|
||||
|
||||
let startISO: string, endISO: string
|
||||
if (allDay) {
|
||||
startISO = allDayISO(startDate)
|
||||
endISO = allDayISO(endDate < startDate ? startDate : endDate)
|
||||
} else {
|
||||
startISO = timedISO(startDate, startTime)
|
||||
endISO = timedISO(endDate, endTime)
|
||||
if (endISO <= startISO) {
|
||||
endISO = new Date(+new Date(startISO) + 60 * 60 * 1000).toISOString()
|
||||
}
|
||||
}
|
||||
onSave({
|
||||
summary: summary.trim(),
|
||||
description: description.trim() || undefined,
|
||||
location: location.trim() || undefined,
|
||||
all_day: allDay,
|
||||
start: s.toISOString(),
|
||||
end: en.toISOString(),
|
||||
})
|
||||
start: startISO,
|
||||
end: endISO,
|
||||
}, calId)
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
background: 'var(--color-surface-2)', border: '1px solid var(--color-border)',
|
||||
borderRadius: 8, padding: '8px 10px', fontSize: 13.5, color: 'var(--color-text)',
|
||||
outline: 'none',
|
||||
}
|
||||
const labelStyle: React.CSSProperties = {
|
||||
const labelStyle: CSSProperties = {
|
||||
fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4, display: 'block',
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.6)', padding: 16 }} {...overlay}>
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 200, display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.6)', padding: 16,
|
||||
}} {...overlay}>
|
||||
<form onSubmit={handleSubmit} style={{
|
||||
background: 'var(--color-surface)', border: '1px solid var(--color-border)',
|
||||
borderRadius: 14, padding: 24, width: 420, maxWidth: '100%', maxHeight: '90vh', overflowY: 'auto',
|
||||
@@ -88,7 +123,19 @@ export default function EventDialog({ event, defaultDate, onSave, onDelete, onCa
|
||||
onChange={e => setSummary(e.target.value)} style={inputStyle} required />
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, fontSize: 13, color: 'var(--color-text)' }}>
|
||||
{calendars.length > 1 && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>Kalender</label>
|
||||
{/* Beim Bearbeiten fest: Ein Umhängen wäre Löschen und Neuanlegen
|
||||
unter neuer Kennung – das gehört nicht versteckt in ein
|
||||
Auswahlfeld. */}
|
||||
<select value={calId} disabled={!!event} onChange={e => setCalId(e.target.value)} style={inputStyle}>
|
||||
{calendars.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, fontSize: 13, color: 'var(--color-text)', cursor: readOnly ? 'default' : 'pointer' }}>
|
||||
<input type="checkbox" checked={allDay} disabled={readOnly} onChange={e => setAllDay(e.target.checked)} />
|
||||
Ganztägig
|
||||
</label>
|
||||
@@ -96,11 +143,12 @@ export default function EventDialog({ event, defaultDate, onSave, onDelete, onCa
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Start</label>
|
||||
<input type="date" value={startDate} disabled={readOnly} onChange={e => setStartDate(e.target.value)} style={inputStyle} />
|
||||
<input type="date" value={startDate} disabled={readOnly}
|
||||
onChange={e => changeStartDate(e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
{!allDay && (
|
||||
<div style={{ width: 110 }}>
|
||||
<label style={labelStyle}> </label>
|
||||
<div style={{ width: 116 }}>
|
||||
<label style={labelStyle}>Uhrzeit</label>
|
||||
<input type="time" value={startTime} disabled={readOnly} onChange={e => setStartTime(e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
@@ -109,11 +157,12 @@ export default function EventDialog({ event, defaultDate, onSave, onDelete, onCa
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Ende</label>
|
||||
<input type="date" value={endDate} disabled={readOnly} onChange={e => setEndDate(e.target.value)} style={inputStyle} />
|
||||
<input type="date" value={endDate} min={startDate} disabled={readOnly}
|
||||
onChange={e => setEndDate(e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
{!allDay && (
|
||||
<div style={{ width: 110 }}>
|
||||
<label style={labelStyle}> </label>
|
||||
<div style={{ width: 116 }}>
|
||||
<label style={labelStyle}>Uhrzeit</label>
|
||||
<input type="time" value={endTime} disabled={readOnly} onChange={e => setEndTime(e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
@@ -144,7 +193,7 @@ export default function EventDialog({ event, defaultDate, onSave, onDelete, onCa
|
||||
padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)',
|
||||
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
|
||||
}}>
|
||||
Abbrechen
|
||||
{readOnly ? 'Schließen' : 'Abbrechen'}
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button type="submit" style={{
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Datumsrechnung für den Kalender – eine Stelle für alle Ansichten und den
|
||||
* Termindialog.
|
||||
*
|
||||
* Der Grund, warum das ein eigenes Modul ist: Ein ganztägiger Termin hat
|
||||
* **kein** Zeitzonen-Bezugssystem. Er ist ein Datum, kein Zeitpunkt. Rechnet
|
||||
* man ihn wie einen Zeitpunkt (`new Date('2026-09-03T00:00:00').toISOString()`),
|
||||
* kommt in einer Zeitzone östlich von UTC der Vortag heraus – aus dem 3.
|
||||
* wurde der 2. Genau das ist passiert, und zwar getrennt in Dialog und
|
||||
* Raster. Deshalb kommen beide jetzt hier vorbei.
|
||||
*/
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
/** Ortszeit → "YYYY-MM-DD" für <input type="date">. */
|
||||
export function toDateInput(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
|
||||
}
|
||||
|
||||
/** Ortszeit → "HH:MM" für <input type="time">. */
|
||||
export function toTimeInput(d: Date): string {
|
||||
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
|
||||
}
|
||||
|
||||
export function addDays(d: Date, n: number): Date {
|
||||
const r = new Date(d)
|
||||
r.setDate(r.getDate() + n)
|
||||
return r
|
||||
}
|
||||
|
||||
/** Mitternacht Ortszeit desselben Tages. */
|
||||
export function startOfDay(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||
}
|
||||
|
||||
export function isSameDay(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
||||
}
|
||||
|
||||
/** Montag der Woche, in der `d` liegt. */
|
||||
export function startOfWeek(d: Date): Date {
|
||||
const r = startOfDay(d)
|
||||
return addDays(r, -((r.getDay() + 6) % 7))
|
||||
}
|
||||
|
||||
export function startOfMonth(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1)
|
||||
}
|
||||
|
||||
export function dayKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Tag, an dem ein Termin im Raster steht.
|
||||
*
|
||||
* Ganztägig: Der Server liefert Mitternacht **UTC** als reine Datumsangabe.
|
||||
* Die Y/M/D werden deshalb aus den UTC-Feldern gelesen und als *örtlicher*
|
||||
* Tag neu aufgebaut. Über `new Date(iso)` zu gehen wäre falsch: westlich von
|
||||
* UTC landete man auf dem Vortag, östlich zur falschen Stunde.
|
||||
*
|
||||
* Mit Uhrzeit: ein echter Zeitpunkt, den der Browser in Ortszeit umrechnet –
|
||||
* hier ist `new Date(iso)` genau richtig.
|
||||
*/
|
||||
export function eventDay(iso: string, allDay: boolean): Date {
|
||||
const d = new Date(iso)
|
||||
if (!allDay) return d
|
||||
return new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())
|
||||
}
|
||||
|
||||
/** "2026-09-03" → Mitternacht UTC, wie die Schnittstelle es für ganztägige
|
||||
* Termine erwartet (erster bzw. letzter belegter Tag). */
|
||||
export function allDayISO(dateInput: string): string {
|
||||
const [y, m, d] = dateInput.split('-').map(Number)
|
||||
return new Date(Date.UTC(y, m - 1, d)).toISOString()
|
||||
}
|
||||
|
||||
/** "2026-09-03" + "09:30" (Ortszeit) → Zeitpunkt in ISO/UTC. */
|
||||
export function timedISO(dateInput: string, timeInput: string): string {
|
||||
const [y, m, d] = dateInput.split('-').map(Number)
|
||||
const [hh, mm] = timeInput.split(':').map(Number)
|
||||
return new Date(y, m - 1, d, hh, mm, 0, 0).toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Tage, die ein Termin belegt – Grundlage dafür, dass ein mehrtägiger
|
||||
* Termin in jeder Zelle auftaucht statt nur am Starttag.
|
||||
*/
|
||||
export function spannedDays(start: Date, end: Date | null, allDay: boolean): Date[] {
|
||||
const first = startOfDay(start)
|
||||
if (!end) return [first]
|
||||
let last = startOfDay(end)
|
||||
// Ein Termin, der um Mitternacht endet, belegt den Folgetag nicht mehr.
|
||||
if (!allDay && end.getHours() === 0 && end.getMinutes() === 0 && +last > +first) {
|
||||
last = addDays(last, -1)
|
||||
}
|
||||
if (+last < +first) last = first
|
||||
const out: Date[] = []
|
||||
// Obergrenze gegen kaputte Daten: ein Termin über Jahre hinweg würde sonst
|
||||
// das Raster fluten.
|
||||
for (let d = first, i = 0; +d <= +last && i < 400; d = addDays(d, 1), i++) out.push(d)
|
||||
return out
|
||||
}
|
||||
+454
-193
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent } from 'react'
|
||||
import {
|
||||
calendarApi, APIError,
|
||||
type CalendarItem, type CalendarEvent, type EventInput,
|
||||
@@ -7,6 +7,9 @@ import PageHeader from '../components/PageHeader'
|
||||
import ConfirmDialog from '../components/ConfirmDialog'
|
||||
import EventDialog from '../components/calendar/EventDialog'
|
||||
import CalendarDialog from '../components/calendar/CalendarDialog'
|
||||
import {
|
||||
addDays, dayKey, eventDay, isSameDay, spannedDays, startOfDay, startOfMonth, startOfWeek,
|
||||
} from '../components/calendar/dates'
|
||||
import { TrashIcon, RenameIcon, DownloadIcon } from '../components/icons'
|
||||
|
||||
type ViewMode = 'month' | 'week' | 'list'
|
||||
@@ -17,58 +20,81 @@ const monthLabels = [
|
||||
'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)
|
||||
}
|
||||
/** Höhe einer Stunde im Wochenraster. */
|
||||
const HOUR_H = 44
|
||||
/** Breite der Stundenspalte links im Wochenraster. */
|
||||
const GUTTER = 52
|
||||
|
||||
interface EventWithCalendar extends CalendarEvent {
|
||||
calendarId: string
|
||||
color: string
|
||||
/** Anfang als örtlicher Zeitpunkt – ganztägige Termine als Tagesbeginn. */
|
||||
startsAt: Date
|
||||
endsAt: Date | null
|
||||
}
|
||||
|
||||
/** 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 decorate(ev: CalendarEvent, cal: CalendarItem): EventWithCalendar {
|
||||
return {
|
||||
...ev,
|
||||
calendarId: cal.id,
|
||||
color: cal.color,
|
||||
startsAt: eventDay(ev.start, ev.all_day),
|
||||
endsAt: ev.end ? eventDay(ev.end, ev.all_day) : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
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 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
|
||||
})
|
||||
}
|
||||
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()}`
|
||||
|
||||
/** Vorschlag für einen neuen Termin: heute die nächste volle Stunde, an
|
||||
* anderen Tagen 9 Uhr. Mitternacht als Vorgabe (vorher) 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[]>([])
|
||||
const [visible, setVisible] = useState<Set<string>>(new Set())
|
||||
/** Ausgeblendete Kalender. Bewusst andersherum als „sichtbare": So ist ein
|
||||
* **neu angelegter** Kalender automatisch sichtbar. Vorher landete er in
|
||||
* keiner Sichtbar-Liste und blieb nach dem Anlegen unsichtbar. */
|
||||
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 [eventDialog, setEventDialog] = useState<{ calId: string; event?: CalendarEvent; date: 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)
|
||||
const [deleteEvent, setDeleteEvent] = useState<{ calId: string; uid: string } | null>(null)
|
||||
@@ -76,25 +102,12 @@ export default function CalendarPage() {
|
||||
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
|
||||
return [c.id, [] as CalendarEvent[]] as const
|
||||
}
|
||||
}))
|
||||
setEventsByCal(Object.fromEntries(pairs))
|
||||
@@ -103,32 +116,31 @@ export default function CalendarPage() {
|
||||
const reloadAll = useCallback(async () => {
|
||||
setError(null)
|
||||
try {
|
||||
const list = await loadCalendars()
|
||||
const list = await calendarApi.list()
|
||||
setCalendars(list)
|
||||
await loadEvents(list)
|
||||
} catch (e) {
|
||||
setError(e instanceof APIError ? e.message : 'Laden fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [loadCalendars, loadEvents])
|
||||
}, [loadEvents])
|
||||
|
||||
useEffect(() => { reloadAll() }, [reloadAll])
|
||||
|
||||
const allEvents: EventWithCalendar[] = useMemo(() => {
|
||||
const allEvents = 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 })
|
||||
}
|
||||
if (hidden.has(cal.id)) continue
|
||||
for (const ev of eventsByCal[cal.id] ?? []) out.push(decorate(ev, cal))
|
||||
}
|
||||
return out
|
||||
}, [calendars, visible, eventsByCal])
|
||||
}, [calendars, hidden, eventsByCal])
|
||||
|
||||
const byDay = useMemo(() => groupByDay(allEvents), [allEvents])
|
||||
|
||||
function toggleVisible(id: string) {
|
||||
setVisible(prev => {
|
||||
setHidden(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
@@ -137,21 +149,25 @@ export default function CalendarPage() {
|
||||
|
||||
const defaultCalendarId = calendars[0]?.id
|
||||
|
||||
function openNewEvent(date: Date) {
|
||||
if (!defaultCalendarId) return
|
||||
setEventDialog({ calId: defaultCalendarId, date })
|
||||
}
|
||||
const openNewEvent = useCallback((start: Date) => {
|
||||
setCalendars(cals => {
|
||||
const first = cals[0]
|
||||
if (first) setEventDialog({ calId: first.id, start })
|
||||
return cals
|
||||
})
|
||||
}, [])
|
||||
|
||||
function openEditEvent(ev: EventWithCalendar) {
|
||||
setEventDialog({ calId: ev.calendarId, event: ev, date: new Date(ev.start) })
|
||||
setEventDialog({ calId: ev.calendarId, event: ev, start: ev.startsAt })
|
||||
}
|
||||
|
||||
async function saveEvent(input: EventInput) {
|
||||
async function saveEvent(input: EventInput, calId: string) {
|
||||
if (!eventDialog) return
|
||||
try {
|
||||
if (eventDialog.event) {
|
||||
await calendarApi.updateEvent(eventDialog.calId, eventDialog.event.uid, input)
|
||||
} else {
|
||||
await calendarApi.createEvent(eventDialog.calId, input)
|
||||
await calendarApi.createEvent(calId, input)
|
||||
}
|
||||
setEventDialog(null)
|
||||
await loadEvents(calendars)
|
||||
@@ -222,6 +238,11 @@ export default function CalendarPage() {
|
||||
})
|
||||
}
|
||||
|
||||
function jumpToDay(day: Date) {
|
||||
setCursor(day)
|
||||
setViewMode('week')
|
||||
}
|
||||
|
||||
const periodLabel = viewMode === 'month'
|
||||
? `${monthLabels[cursor.getMonth()]} ${cursor.getFullYear()}`
|
||||
: viewMode === 'week'
|
||||
@@ -230,7 +251,7 @@ export default function CalendarPage() {
|
||||
const e = addDays(s, 6)
|
||||
return `${s.getDate()}.${s.getMonth() + 1}. – ${e.getDate()}.${e.getMonth() + 1}.${e.getFullYear()}`
|
||||
})()
|
||||
: 'Anstehend'
|
||||
: 'Alle Termine'
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ padding: 24, color: 'var(--color-text-muted)' }}>Lädt…</div>
|
||||
@@ -247,73 +268,103 @@ export default function CalendarPage() {
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<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" style={{ display: 'block', margin: '0 auto' }}>
|
||||
<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}>‹</button>
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text)', minWidth: 130, textAlign: 'center' }}>{periodLabel}</span>
|
||||
<button onClick={() => stepPeriod(1)} style={navBtnStyle}>›</button>
|
||||
<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(new Date())} disabled={!defaultCalendarId} style={{
|
||||
|
||||
<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: 'pointer',
|
||||
opacity: defaultCalendarId ? 1 : 0.5,
|
||||
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 }}>
|
||||
{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 }}>
|
||||
<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)}
|
||||
/>
|
||||
{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>
|
||||
))}
|
||||
{calendars.length === 0 && (
|
||||
<p style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>Noch kein Kalender – leg einen an.</p>
|
||||
)}
|
||||
</aside>
|
||||
{calendars.map(cal => (
|
||||
<div key={cal.id} className="aether-cal-row" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 4px',
|
||||
fontSize: 13, color: 'var(--color-text)', borderRadius: 6,
|
||||
}}>
|
||||
<input type="checkbox" checked={!hidden.has(cal.id)} onChange={() => toggleVisible(cal.id)}
|
||||
style={{ accentColor: cal.color, cursor: 'pointer' }} />
|
||||
<span onClick={() => toggleVisible(cal.id)} style={{
|
||||
flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer',
|
||||
opacity: hidden.has(cal.id) ? 0.45 : 1,
|
||||
}}>
|
||||
{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)', lineHeight: 1.5 }}>
|
||||
Noch kein Kalender – über <strong>+</strong> einen anlegen.
|
||||
</p>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: 'auto' }}>
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', display: 'flex' }}>
|
||||
{viewMode === 'month' && (
|
||||
<MonthGrid cursor={cursor} byDay={byDay} onDayClick={openNewEvent} onEventClick={openEditEvent} />
|
||||
<MonthGrid cursor={cursor} byDay={byDay} onDayClick={openNewEvent} onEventClick={openEditEvent} onMore={jumpToDay} />
|
||||
)}
|
||||
{viewMode === 'week' && (
|
||||
<WeekColumns cursor={cursor} byDay={byDay} onDayClick={openNewEvent} onEventClick={openEditEvent} />
|
||||
<WeekGrid cursor={cursor} byDay={byDay} onSlotClick={openNewEvent} onEventClick={openEditEvent} />
|
||||
)}
|
||||
{viewMode === 'list' && (
|
||||
<ListAgenda events={allEvents} onEventClick={openEditEvent} />
|
||||
@@ -326,7 +377,9 @@ export default function CalendarPage() {
|
||||
{eventDialog && (
|
||||
<EventDialog
|
||||
event={eventDialog.event}
|
||||
defaultDate={eventDialog.date}
|
||||
defaultStart={eventDialog.start}
|
||||
calendars={calendars}
|
||||
calendarId={eventDialog.calId}
|
||||
onSave={saveEvent}
|
||||
onDelete={eventDialog.event ? () => setDeleteEvent({ calId: eventDialog.calId, uid: eventDialog.event!.uid }) : undefined}
|
||||
onCancel={() => setEventDialog(null)}
|
||||
@@ -353,13 +406,22 @@ export default function CalendarPage() {
|
||||
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: React.CSSProperties = {
|
||||
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,
|
||||
}
|
||||
|
||||
function CalMenu({ onRename, onExport, onImport, onDelete }: {
|
||||
@@ -368,20 +430,25 @@ function CalMenu({ onRename, onExport, onImport, onDelete }: {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
function onDoc(e: MouseEvent) {
|
||||
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)
|
||||
return () => document.removeEventListener('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)} style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', padding: 2 }}>⋮</button>
|
||||
<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: 150,
|
||||
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 12px rgba(0,0,0,0.2)', overflow: 'hidden',
|
||||
boxShadow: '0 4px 14px rgba(0,0,0,0.25)', overflow: 'hidden',
|
||||
}}>
|
||||
<MenuRow icon={<RenameIcon />} label="Bearbeiten" onClick={() => { setOpen(false); onRename() }} />
|
||||
<MenuRow icon={<DownloadIcon />} label="Exportieren (.ics)" onClick={() => { setOpen(false); onExport() }} />
|
||||
@@ -392,80 +459,111 @@ function CalMenu({ onRename, onExport, onImport, onDelete }: {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuRow({ icon, label, onClick, danger }: { icon?: React.ReactNode; label: string; onClick: () => void; danger?: boolean }) {
|
||||
return (
|
||||
<button onClick={onClick} style={{
|
||||
<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}{label}
|
||||
{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, die sofort den Dialog „Neuer Termin"
|
||||
* öffnete – ein vorhandener Termin ließ sich dadurch gar nicht öffnen. */
|
||||
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
|
||||
className="aether-cal-chip"
|
||||
onClick={(e: MouseEvent) => { e.stopPropagation(); onClick() }}
|
||||
title={ev.summary}
|
||||
style={{
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MonthGrid({ cursor, byDay, onDayClick, onEventClick }: {
|
||||
function MonthGrid({ cursor, byDay, onDayClick, onEventClick, onMore }: {
|
||||
cursor: Date
|
||||
byDay: Map<string, EventWithCalendar[]>
|
||||
onDayClick: (d: Date) => void
|
||||
onDayClick: (start: Date) => void
|
||||
onEventClick: (ev: EventWithCalendar) => void
|
||||
onMore: (day: Date) => void
|
||||
}) {
|
||||
const first = startOfMonth(cursor)
|
||||
const gridStart = startOfWeek(first)
|
||||
const gridStart = startOfWeek(startOfMonth(cursor))
|
||||
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
|
||||
}
|
||||
// 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', height: '100%' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', borderBottom: '1px solid var(--color-border)' }}>
|
||||
<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}, 1fr)`, flex: 1 }}>
|
||||
<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)' }}>
|
||||
<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.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',
|
||||
}}>
|
||||
<div
|
||||
key={+day}
|
||||
className="aether-cal-cell"
|
||||
onClick={() => onDayClick(suggestStart(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, 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,
|
||||
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>
|
||||
{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 style={{ overflow: 'hidden', minWidth: 0 }}>
|
||||
{events.slice(0, 3).map(ev => (
|
||||
<EventChip key={ev.calendarId + ev.uid} ev={ev} onClick={() => onEventClick(ev)} />
|
||||
))}
|
||||
{events.length > 3 && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onMore(day) }}
|
||||
style={{
|
||||
border: 'none', background: 'none', padding: '0 4px', cursor: 'pointer',
|
||||
fontSize: 10.5, color: 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
+{events.length - 3} weitere
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -476,83 +574,246 @@ function MonthGrid({ cursor, byDay, onDayClick, onEventClick }: {
|
||||
)
|
||||
}
|
||||
|
||||
function WeekColumns({ cursor, byDay, onDayClick, onEventClick }: {
|
||||
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, onEventClick }: {
|
||||
cursor: Date
|
||||
byDay: Map<string, EventWithCalendar[]>
|
||||
onDayClick: (d: Date) => void
|
||||
onSlotClick: (start: Date) => void
|
||||
onEventClick: (ev: EventWithCalendar) => void
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const start = startOfWeek(cursor)
|
||||
const days = Array.from({ length: 7 }, (_, i) => addDays(start, i))
|
||||
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 = ((now: Date) => (now.getHours() * 60 + now.getMinutes()) / 60 * HOUR_H)(today)
|
||||
|
||||
/** 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: '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>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
|
||||
{/* Kopfzeile */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: cols, borderBottom: '1px solid var(--color-border)', flexShrink: 0 }}>
|
||||
<div />
|
||||
{days.map(day => {
|
||||
const isToday = isSameDay(day, today)
|
||||
return (
|
||||
<div key={+day} style={{ padding: '6px 4px', textAlign: 'center', borderLeft: '1px solid var(--color-border)' }}>
|
||||
<div style={{ fontSize: 10.5, 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',
|
||||
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>
|
||||
</div>
|
||||
{events.map(ev => <EventChip key={ev.uid} ev={ev} onClick={() => onEventClick(ev)} />)}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Ganztägige Termine – eigene Zeile über dem Stundenraster, so wie sie
|
||||
auch gemeint sind: ohne Uhrzeit. */}
|
||||
<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={() => onSlotClick(suggestStart(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={ev.calendarId + ev.uid} ev={ev} onClick={() => onEventClick(ev)} />
|
||||
))}
|
||||
</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={ev.calendarId + ev.uid}
|
||||
className="aether-cal-chip"
|
||||
onClick={e => { e.stopPropagation(); onEventClick(ev) }}
|
||||
title={`${ev.summary} – ${timeLabel(ev.startsAt)}`}
|
||||
style={{
|
||||
position: 'absolute', top, height,
|
||||
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)'}
|
||||
</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 }: { 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)
|
||||
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.entries()]
|
||||
}, [sorted])
|
||||
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' }}>
|
||||
{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 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={ev.calendarId + ev.uid} className="aether-cal-cell" onClick={() => onEventClick(ev)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 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: 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)'}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user