703 lines
24 KiB
TypeScript
703 lines
24 KiB
TypeScript
"use client";
|
|
|
|
import FullCalendar from "@fullcalendar/react";
|
|
import dayGridPlugin from "@fullcalendar/daygrid";
|
|
import interactionPlugin from "@fullcalendar/interaction";
|
|
import timeGridPlugin from "@fullcalendar/timegrid";
|
|
import listPlugin from "@fullcalendar/list";
|
|
import type { DateSelectArg, EventClickArg, EventDropArg } from "@fullcalendar/core";
|
|
import type { EventResizeDoneArg } from "@fullcalendar/interaction";
|
|
import { CalendarPlus, Check, Eye, EyeOff, Plus, Trash2, X } from "lucide-react";
|
|
import { useMemo, useState, useEffect, useTransition } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { ShareButton } from "@/components/share-button";
|
|
import type { CalView } from "@/modules/_core/themes";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import type { CalendarDto, CalendarEventDto } from "../server/queries";
|
|
import {
|
|
createCalendar,
|
|
createEvent,
|
|
deleteCalendar,
|
|
deleteEvent,
|
|
renameCalendar,
|
|
setCalendarColor,
|
|
setCalendarVisibility,
|
|
updateEvent,
|
|
} from "../server/actions";
|
|
|
|
type EventDraft = {
|
|
id?: string;
|
|
calendarId: string;
|
|
title: string;
|
|
startAt: string;
|
|
endAt: string;
|
|
allDay: boolean;
|
|
location: string;
|
|
notes: string;
|
|
remindMinutesBefore: number | null;
|
|
};
|
|
|
|
type MobileView = "day" | "week" | "agenda";
|
|
|
|
const DEFAULT_COLOR = "#B85C3C";
|
|
|
|
const VIEW_MAP: Record<CalView, string> = {
|
|
month: "dayGridMonth",
|
|
week: "timeGridWeek",
|
|
day: "timeGridDay",
|
|
};
|
|
|
|
const MOBILE_VIEW_MAP: Record<MobileView, string> = {
|
|
day: "timeGridDay",
|
|
week: "timeGridWeek",
|
|
agenda: "listWeek",
|
|
};
|
|
|
|
function withAlpha(hex: string, alpha: number): string {
|
|
if (!hex.startsWith("#")) return hex;
|
|
let h = hex.slice(1);
|
|
if (h.length === 3)
|
|
h = h
|
|
.split("")
|
|
.map((c) => c + c)
|
|
.join("");
|
|
if (h.length !== 6) return hex;
|
|
const a = Math.round(Math.min(1, Math.max(0, alpha)) * 255)
|
|
.toString(16)
|
|
.padStart(2, "0");
|
|
return `#${h}${a}`;
|
|
}
|
|
|
|
export function CalendarShell({
|
|
calendars,
|
|
events,
|
|
defaultView = "month",
|
|
}: {
|
|
calendars: CalendarDto[];
|
|
events: CalendarEventDto[];
|
|
defaultView?: CalView;
|
|
}) {
|
|
const [calendarRows, setCalendarRows] = useState(calendars);
|
|
const [eventRows, setEventRows] = useState(events);
|
|
const [visibleIds, setVisibleIds] = useState(() => new Set(calendars.map((c) => c.id)));
|
|
const [selectedEvent, setSelectedEvent] = useState<EventDraft | null>(null);
|
|
const [calendarName, setCalendarName] = useState("");
|
|
const [calendarColor, setCalendarColorValue] = useState(DEFAULT_COLOR);
|
|
const [calendarVisibility, setCalendarVisibilityValue] = useState<"private" | "household">(
|
|
"household",
|
|
);
|
|
const [lastCalendarId, setLastCalendarId] = useState(calendars[0]?.id ?? "");
|
|
const [isPending, startTransition] = useTransition();
|
|
const [isMobile, setIsMobile] = useState(false);
|
|
const [showCalendarsPanel, setShowCalendarsPanel] = useState(false);
|
|
const [mobileView, setMobileView] = useState<MobileView>("day");
|
|
|
|
useEffect(() => {
|
|
const mq = window.matchMedia("(max-width: 759px)");
|
|
const apply = () => setIsMobile(mq.matches);
|
|
apply();
|
|
mq.addEventListener("change", apply);
|
|
return () => mq.removeEventListener("change", apply);
|
|
}, []);
|
|
|
|
const defaultCalendarId = calendarRows.some((calendar) => calendar.id === lastCalendarId)
|
|
? lastCalendarId
|
|
: (calendarRows[0]?.id ?? "");
|
|
const visibleEvents = useMemo(
|
|
() => eventRows.filter((event) => visibleIds.has(event.calendarId)),
|
|
[eventRows, visibleIds],
|
|
);
|
|
|
|
function toggleCalendar(id: string) {
|
|
setVisibleIds((current) => {
|
|
const next = new Set(current);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function openNewEvent(startAt?: Date, endAt?: Date, allDay = false) {
|
|
if (!defaultCalendarId) return;
|
|
const start = startAt ?? new Date();
|
|
const end = endAt ?? new Date(start.getTime() + 60 * 60 * 1000);
|
|
setSelectedEvent({
|
|
calendarId: defaultCalendarId,
|
|
title: "",
|
|
startAt: toInputDateTime(start),
|
|
endAt: toInputDateTime(end),
|
|
allDay,
|
|
location: "",
|
|
notes: "",
|
|
remindMinutesBefore: 30,
|
|
});
|
|
}
|
|
|
|
function openExistingEvent({ event }: EventClickArg) {
|
|
const row = eventRows.find((item) => item.id === event.id);
|
|
if (!row) return;
|
|
setSelectedEvent({
|
|
id: row.id,
|
|
calendarId: row.calendarId,
|
|
title: row.title,
|
|
startAt: toInputDateTime(new Date(row.startAt)),
|
|
endAt: toInputDateTime(new Date(row.endAt)),
|
|
allDay: row.allDay,
|
|
location: row.location ?? "",
|
|
notes: row.notes ?? "",
|
|
remindMinutesBefore: null,
|
|
});
|
|
}
|
|
|
|
function handleDateSelect(selection: DateSelectArg) {
|
|
openNewEvent(selection.start, selection.end, selection.allDay);
|
|
}
|
|
|
|
function saveSelectedEvent() {
|
|
if (!selectedEvent) return;
|
|
const eventId = selectedEvent.id;
|
|
const payload = {
|
|
calendarId: selectedEvent.calendarId,
|
|
title: selectedEvent.title,
|
|
startAt: new Date(selectedEvent.startAt),
|
|
endAt: new Date(selectedEvent.endAt),
|
|
allDay: selectedEvent.allDay,
|
|
location: selectedEvent.location || null,
|
|
notes: selectedEvent.notes || null,
|
|
};
|
|
|
|
startTransition(async () => {
|
|
if (eventId) {
|
|
await updateEvent({ id: eventId, ...payload });
|
|
setLastCalendarId(payload.calendarId);
|
|
setEventRows((current) =>
|
|
current.map((event) =>
|
|
event.id === eventId
|
|
? {
|
|
...event,
|
|
calendarId: payload.calendarId,
|
|
title: payload.title,
|
|
allDay: payload.allDay,
|
|
location: payload.location,
|
|
notes: payload.notes,
|
|
startAt: payload.startAt.toISOString(),
|
|
endAt: payload.endAt.toISOString(),
|
|
}
|
|
: event,
|
|
),
|
|
);
|
|
} else {
|
|
const created = await createEvent({
|
|
...payload,
|
|
remindMinutesBefore: selectedEvent.remindMinutesBefore,
|
|
});
|
|
setLastCalendarId(payload.calendarId);
|
|
setEventRows((current) => [...current, created]);
|
|
}
|
|
setSelectedEvent(null);
|
|
});
|
|
}
|
|
|
|
function removeSelectedEvent() {
|
|
if (!selectedEvent?.id) return;
|
|
const id = selectedEvent.id;
|
|
startTransition(async () => {
|
|
await deleteEvent({ id });
|
|
setEventRows((current) => current.filter((event) => event.id !== id));
|
|
setSelectedEvent(null);
|
|
});
|
|
}
|
|
|
|
function moveEvent(change: EventDropArg | EventResizeDoneArg) {
|
|
const start = change.event.start;
|
|
const end = change.event.end ?? start;
|
|
if (!start || !end) return;
|
|
const id = change.event.id;
|
|
setEventRows((current) =>
|
|
current.map((event) =>
|
|
event.id === id
|
|
? {
|
|
...event,
|
|
startAt: start.toISOString(),
|
|
endAt: end.toISOString(),
|
|
allDay: change.event.allDay,
|
|
}
|
|
: event,
|
|
),
|
|
);
|
|
startTransition(async () => {
|
|
await updateEvent({
|
|
id,
|
|
startAt: start,
|
|
endAt: end,
|
|
allDay: change.event.allDay,
|
|
});
|
|
});
|
|
}
|
|
|
|
function addCalendar() {
|
|
startTransition(async () => {
|
|
const created = await createCalendar({
|
|
name: calendarName,
|
|
color: calendarColor,
|
|
visibility: calendarVisibility,
|
|
});
|
|
const calendar = {
|
|
id: created.id,
|
|
name: created.name,
|
|
color: created.color,
|
|
visibility: created.visibility as "private" | "household",
|
|
ownerId: created.ownerId,
|
|
};
|
|
setCalendarRows((current) => [...current, calendar]);
|
|
setVisibleIds((current) => new Set([...current, calendar.id]));
|
|
setLastCalendarId(calendar.id);
|
|
setCalendarName("");
|
|
setCalendarColorValue(DEFAULT_COLOR);
|
|
setCalendarVisibilityValue("household");
|
|
});
|
|
}
|
|
|
|
function updateCalendar(calendar: CalendarDto, values: Partial<CalendarDto>) {
|
|
const next = { ...calendar, ...values };
|
|
setCalendarRows((current) => current.map((item) => (item.id === calendar.id ? next : item)));
|
|
startTransition(async () => {
|
|
if (values.name !== undefined) {
|
|
await renameCalendar({ id: calendar.id, name: values.name });
|
|
}
|
|
if (values.color !== undefined) {
|
|
await setCalendarColor({ id: calendar.id, color: values.color });
|
|
}
|
|
if (values.visibility !== undefined) {
|
|
await setCalendarVisibility({ id: calendar.id, visibility: values.visibility });
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateCalendarName(calendar: CalendarDto, name: string) {
|
|
setCalendarRows((current) =>
|
|
current.map((item) => (item.id === calendar.id ? { ...item, name } : item)),
|
|
);
|
|
}
|
|
|
|
function commitCalendarName(calendar: CalendarDto) {
|
|
if (!calendar.name.trim()) return;
|
|
startTransition(async () => {
|
|
await renameCalendar({ id: calendar.id, name: calendar.name });
|
|
});
|
|
}
|
|
|
|
function removeCalendar(calendarId: string) {
|
|
startTransition(async () => {
|
|
await deleteCalendar({ id: calendarId });
|
|
setCalendarRows((current) => current.filter((calendar) => calendar.id !== calendarId));
|
|
setEventRows((current) => current.filter((event) => event.calendarId !== calendarId));
|
|
setVisibleIds((current) => {
|
|
const next = new Set(current);
|
|
next.delete(calendarId);
|
|
return next;
|
|
});
|
|
});
|
|
}
|
|
|
|
// Shared calendar list + new-calendar form rendered in both the desktop aside
|
|
// and the mobile bottom-sheet panel.
|
|
const calendarsPanelContent = (
|
|
<>
|
|
<div className="space-y-2">
|
|
{calendarRows.map((calendar) => (
|
|
<div key={calendar.id} className="rounded-lg border bg-background p-2">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
className="grid size-7 place-items-center rounded-md border"
|
|
onClick={() => toggleCalendar(calendar.id)}
|
|
aria-label={`${visibleIds.has(calendar.id) ? "Hide" : "Show"} ${calendar.name}`}
|
|
>
|
|
{visibleIds.has(calendar.id) ? <Eye /> : <EyeOff />}
|
|
</button>
|
|
<span
|
|
className="size-3 rounded-full"
|
|
style={{ backgroundColor: calendar.color ?? DEFAULT_COLOR }}
|
|
/>
|
|
<Input
|
|
aria-label={`${calendar.name} name`}
|
|
value={calendar.name}
|
|
onChange={(event) => updateCalendarName(calendar, event.target.value)}
|
|
onBlur={() => commitCalendarName(calendar)}
|
|
/>
|
|
</div>
|
|
<div className="mt-2 grid grid-cols-[1fr_1fr_auto] gap-2">
|
|
<Input
|
|
aria-label={`${calendar.name} color`}
|
|
type="color"
|
|
value={calendar.color ?? DEFAULT_COLOR}
|
|
onChange={(event) => updateCalendar(calendar, { color: event.target.value })}
|
|
/>
|
|
<Select
|
|
value={calendar.visibility}
|
|
onValueChange={(value) =>
|
|
updateCalendar(calendar, { visibility: value as "private" | "household" })
|
|
}
|
|
>
|
|
<SelectTrigger aria-label={`${calendar.name} visibility`}>
|
|
<SelectValue>
|
|
{calendar.visibility === "household" ? "Household" : "Private"}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="household">Household</SelectItem>
|
|
<SelectItem value="private">Private</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={() => removeCalendar(calendar.id)}
|
|
aria-label={`Delete ${calendar.name}`}
|
|
>
|
|
<Trash2 />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-4 rounded-lg border bg-background p-3">
|
|
<div className="mb-3 flex items-center gap-2 text-sm font-medium">
|
|
<CalendarPlus className="size-4" />
|
|
New calendar
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="calendar-name">Name</Label>
|
|
<Input
|
|
id="calendar-name"
|
|
value={calendarName}
|
|
onChange={(event) => setCalendarName(event.target.value)}
|
|
/>
|
|
<div className="grid grid-cols-[1fr_1fr] gap-2">
|
|
<Input
|
|
aria-label="Calendar color"
|
|
type="color"
|
|
value={calendarColor}
|
|
onChange={(event) => setCalendarColorValue(event.target.value)}
|
|
/>
|
|
<Select
|
|
value={calendarVisibility}
|
|
onValueChange={(value) =>
|
|
setCalendarVisibilityValue(value as "private" | "household")
|
|
}
|
|
>
|
|
<SelectTrigger aria-label="Calendar visibility">
|
|
<SelectValue>
|
|
{calendarVisibility === "household" ? "Household" : "Private"}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="household">Household</SelectItem>
|
|
<SelectItem value="private">Private</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<Button className="w-full" onClick={addCalendar} disabled={!calendarName}>
|
|
<Check />
|
|
Create calendar
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
const fcEvents = visibleEvents.map((event) => {
|
|
const color =
|
|
calendarRows.find((calendar) => calendar.id === event.calendarId)?.color ?? DEFAULT_COLOR;
|
|
return {
|
|
id: event.id,
|
|
title: event.title,
|
|
start: event.startAt,
|
|
end: event.endAt,
|
|
allDay: event.allDay,
|
|
backgroundColor: withAlpha(color, 0.14),
|
|
borderColor: color,
|
|
textColor: "var(--ink-2)",
|
|
extendedProps: { calendarId: event.calendarId, color },
|
|
};
|
|
});
|
|
|
|
// Shared event form content — rendered inside both the desktop centered modal
|
|
// and the mobile bottom-sheet.
|
|
const eventFormContent = selectedEvent && (
|
|
<div className="grid gap-3">
|
|
<Label htmlFor="event-title">Title</Label>
|
|
<Input
|
|
id="event-title"
|
|
value={selectedEvent.title}
|
|
onChange={(event) => setSelectedEvent({ ...selectedEvent, title: event.target.value })}
|
|
/>
|
|
<Label htmlFor="event-calendar">Calendar</Label>
|
|
<Select
|
|
value={selectedEvent.calendarId}
|
|
onValueChange={(value) => setSelectedEvent({ ...selectedEvent, calendarId: value ?? "" })}
|
|
>
|
|
<SelectTrigger id="event-calendar">
|
|
<SelectValue>
|
|
{calendarRows.find((c) => c.id === selectedEvent.calendarId)?.name ??
|
|
"Select a calendar"}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{calendarRows.map((calendar) => (
|
|
<SelectItem key={calendar.id} value={calendar.id}>
|
|
{calendar.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="event-start">Start</Label>
|
|
<Input
|
|
id="event-start"
|
|
type="datetime-local"
|
|
value={selectedEvent.startAt}
|
|
onChange={(event) =>
|
|
setSelectedEvent({ ...selectedEvent, startAt: event.target.value })
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="event-end">End</Label>
|
|
<Input
|
|
id="event-end"
|
|
type="datetime-local"
|
|
value={selectedEvent.endAt}
|
|
onChange={(event) => setSelectedEvent({ ...selectedEvent, endAt: event.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Label htmlFor="event-location">Location</Label>
|
|
<Input
|
|
id="event-location"
|
|
value={selectedEvent.location}
|
|
onChange={(event) => setSelectedEvent({ ...selectedEvent, location: event.target.value })}
|
|
/>
|
|
<Label htmlFor="event-notes">Notes</Label>
|
|
<textarea
|
|
id="event-notes"
|
|
className="min-h-20 rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
value={selectedEvent.notes}
|
|
onChange={(event) => setSelectedEvent({ ...selectedEvent, notes: event.target.value })}
|
|
/>
|
|
{!selectedEvent.id && (
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
className="size-4 cursor-pointer"
|
|
checked={selectedEvent.remindMinutesBefore !== null}
|
|
onChange={(e) =>
|
|
setSelectedEvent({
|
|
...selectedEvent,
|
|
remindMinutesBefore: e.target.checked ? 30 : null,
|
|
})
|
|
}
|
|
/>
|
|
Remind me 30 min before
|
|
</label>
|
|
)}
|
|
<div className="flex items-center justify-between gap-2 pt-2">
|
|
<div className="flex gap-2">
|
|
{selectedEvent.id && (
|
|
<Button variant="destructive" onClick={removeSelectedEvent}>
|
|
Delete event
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{selectedEvent.id && (
|
|
<ShareButton entityType="calendar.event" entityId={selectedEvent.id} />
|
|
)}
|
|
<Button onClick={saveSelectedEvent} disabled={!selectedEvent.title || isPending}>
|
|
Save event
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className="grid grid-cols-1 lg:grid-cols-[280px_1fr]"
|
|
style={{ minHeight: "calc(100dvh - var(--cal-topbar-h) - var(--cal-nav-h))" }}
|
|
>
|
|
{/* Desktop aside — hidden on mobile */}
|
|
<aside
|
|
className={`${isMobile ? "hidden" : ""} border-b bg-sidebar p-4 lg:border-r lg:border-b-0`}
|
|
>
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h1 className="text-xl font-semibold">Calendar</h1>
|
|
<Button size="icon-sm" variant="outline" onClick={() => openNewEvent()}>
|
|
<Plus />
|
|
<span className="sr-only">New event</span>
|
|
</Button>
|
|
</div>
|
|
{calendarsPanelContent}
|
|
</aside>
|
|
|
|
{/* Calendar section */}
|
|
<section className="fc-skin flex min-w-0 flex-col">
|
|
{/* Mobile toolbar: view switcher + calendars button + new event */}
|
|
{isMobile && (
|
|
<div
|
|
className="flex items-center gap-2 border-b px-3 py-2"
|
|
style={{ borderColor: "var(--hair)" }}
|
|
>
|
|
<div className="seg flex-1">
|
|
{(["day", "week", "agenda"] as MobileView[]).map((v) => (
|
|
<button
|
|
key={v}
|
|
type="button"
|
|
aria-selected={mobileView === v}
|
|
onClick={() => setMobileView(v)}
|
|
>
|
|
{v.charAt(0).toUpperCase() + v.slice(1)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost btn-sm"
|
|
onClick={() => setShowCalendarsPanel(true)}
|
|
>
|
|
<Eye size={14} />
|
|
Cals
|
|
</button>
|
|
<button type="button" className="btn btn-primary btn-sm" onClick={() => openNewEvent()}>
|
|
<Plus size={14} />
|
|
New
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1 p-4">
|
|
<FullCalendar
|
|
key={isMobile ? `mobile-${mobileView}` : `desktop-${defaultView}`}
|
|
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin]}
|
|
initialView={isMobile ? MOBILE_VIEW_MAP[mobileView] : VIEW_MAP[defaultView]}
|
|
headerToolbar={
|
|
isMobile
|
|
? { left: "prev,next", center: "title", right: "today" }
|
|
: {
|
|
left: "prev,next today",
|
|
center: "title",
|
|
right: "dayGridMonth,timeGridWeek,timeGridDay",
|
|
}
|
|
}
|
|
buttonText={{ today: "Today", month: "Month", week: "Week", day: "Day", list: "List" }}
|
|
dayHeaderFormat={{ weekday: "short" }}
|
|
selectable={!isMobile}
|
|
editable={!isMobile}
|
|
eventResizableFromStart={!isMobile}
|
|
select={handleDateSelect}
|
|
eventClick={openExistingEvent}
|
|
eventDrop={moveEvent}
|
|
eventResize={moveEvent}
|
|
events={fcEvents}
|
|
eventClassNames={(arg) => {
|
|
const id = String(arg.event.extendedProps["calendarId"] ?? "");
|
|
return id ? [`fc-cal-${id.slice(0, 8)}`] : [];
|
|
}}
|
|
height="auto"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Mobile: calendars bottom sheet */}
|
|
{isMobile && showCalendarsPanel && (
|
|
<div
|
|
className="fixed inset-0 z-[45] bg-black/40"
|
|
onClick={() => setShowCalendarsPanel(false)}
|
|
>
|
|
<div
|
|
className="absolute inset-x-0 overflow-auto rounded-t-[var(--r-xl)]"
|
|
style={{
|
|
bottom: "var(--cal-nav-h)",
|
|
maxHeight: "72dvh",
|
|
background: "var(--card)",
|
|
paddingBottom: "env(safe-area-inset-bottom)",
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div
|
|
className="flex items-center justify-between border-b p-4"
|
|
style={{ borderColor: "var(--hair)" }}
|
|
>
|
|
<h3 className="serif text-[16px] font-medium">Calendars</h3>
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost btn-icon"
|
|
aria-label="Close calendars panel"
|
|
onClick={() => setShowCalendarsPanel(false)}
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
<div className="p-4">{calendarsPanelContent}</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Event form — centered modal on desktop, bottom sheet on mobile */}
|
|
{selectedEvent && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex bg-black/20"
|
|
style={
|
|
isMobile
|
|
? { alignItems: "flex-end" }
|
|
: { alignItems: "center", justifyContent: "center" }
|
|
}
|
|
onClick={(e) => {
|
|
if (e.target === e.currentTarget) setSelectedEvent(null);
|
|
}}
|
|
>
|
|
<div
|
|
className="overflow-auto bg-popover text-popover-foreground shadow-lg"
|
|
style={{
|
|
borderRadius: isMobile ? "var(--r-xl) var(--r-xl) 0 0" : "var(--r-lg)",
|
|
width: isMobile ? "100%" : undefined,
|
|
maxWidth: isMobile ? undefined : "512px",
|
|
maxHeight: "90dvh",
|
|
padding: "1rem",
|
|
paddingBottom: isMobile ? "calc(1rem + env(safe-area-inset-bottom))" : "1rem",
|
|
}}
|
|
>
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold">
|
|
{selectedEvent.id ? "Edit event" : "New event"}
|
|
</h2>
|
|
<Button size="icon-sm" variant="ghost" onClick={() => setSelectedEvent(null)}>
|
|
<X size={16} />
|
|
<span className="sr-only">Close</span>
|
|
</Button>
|
|
</div>
|
|
{eventFormContent}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function toInputDateTime(date: Date) {
|
|
const offset = date.getTimezoneOffset();
|
|
const local = new Date(date.getTime() - offset * 60 * 1000);
|
|
return local.toISOString().slice(0, 16);
|
|
}
|