"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 dynamic from "next/dynamic"; import { useMemo, useState, useEffect, useTransition } from "react"; import { richTextToPlainText } from "@/components/rich-text"; 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 { ReminderPicker } from "@/components/reminder-picker"; import type { CalView } from "@/modules/_core/themes"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import type { CalendarDto, CalendarEventDto } from "../server/queries"; import { getEventReminderOffsets } from "../server/queries"; import { createCalendar, createEvent, deleteCalendar, deleteEvent, renameCalendar, setCalendarColor, setCalendarVisibility, updateEvent, } from "../server/actions"; const RichTextEditor = dynamic( () => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor), { ssr: false, loading: () => (
Loading editor…
), }, ); type EventDraft = { id?: string; calendarId: string; title: string; startAt: string; endAt: string; allDay: boolean; location: string; notes: string; reminderOffsets: number[]; }; type MobileView = "day" | "week" | "agenda"; const DEFAULT_COLOR = "#B85C3C"; const VISIBILITY_ITEMS = [ { label: "Household", value: "household" }, { label: "Private", value: "private" }, ]; const VIEW_MAP: Record = { month: "dayGridMonth", week: "timeGridWeek", day: "timeGridDay", }; const MOBILE_VIEW_MAP: Record = { 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", defaultReminderOffsets = [30], }: { calendars: CalendarDto[]; events: CalendarEventDto[]; defaultView?: CalView; defaultReminderOffsets?: number[]; }) { 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(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("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], ); const calendarSelectItems = useMemo( () => calendarRows.map((calendar) => ({ label: calendar.name, value: calendar.id })), [calendarRows], ); 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: "", reminderOffsets: [...defaultReminderOffsets], }); } function openExistingEvent({ event }: EventClickArg) { const row = eventRows.find((item) => item.id === event.id); if (!row) return; const eventId = row.id; 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 ?? "", reminderOffsets: [], }); void getEventReminderOffsets(eventId).then((offsets) => { setSelectedEvent((current) => current?.id === eventId ? { ...current, reminderOffsets: offsets } : current, ); }); } 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: richTextToPlainText(selectedEvent.notes) ? selectedEvent.notes : null, }; startTransition(async () => { if (eventId) { await updateEvent({ id: eventId, ...payload, reminderOffsets: selectedEvent.reminderOffsets, }); 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, reminderOffsets: selectedEvent.reminderOffsets, }); 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) { 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 = ( <>
{calendarRows.map((calendar) => (
updateCalendarName(calendar, event.target.value)} onBlur={() => commitCalendarName(calendar)} />
updateCalendar(calendar, { color: event.target.value })} />
))}
New calendar
setCalendarName(event.target.value)} />
setCalendarColorValue(event.target.value)} />
); 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 && (
setSelectedEvent({ ...selectedEvent, title: event.target.value })} />
setSelectedEvent({ ...selectedEvent, startAt: event.target.value }) } />
setSelectedEvent({ ...selectedEvent, endAt: event.target.value })} />
setSelectedEvent({ ...selectedEvent, location: event.target.value })} />
setSelectedEvent({ ...selectedEvent, notes })} disabled={isPending} placeholder="Add details…" />
setSelectedEvent({ ...selectedEvent, reminderOffsets })} disabled={isPending} />
{selectedEvent.id && ( )}
{selectedEvent.id && ( )}
); return (
{/* Desktop aside — hidden on mobile */} {/* Calendar section */}
{/* Mobile toolbar: view switcher + calendars button + new event */} {isMobile && (
{(["day", "week", "agenda"] as MobileView[]).map((v) => ( ))}
)}
{ const id = String(arg.event.extendedProps["calendarId"] ?? ""); return id ? [`fc-cal-${id.slice(0, 8)}`] : []; }} height="auto" />
{/* Mobile: calendars bottom sheet */} {isMobile && showCalendarsPanel && (
setShowCalendarsPanel(false)} >
e.stopPropagation()} >

Calendars

{calendarsPanelContent}
)} {/* Event form — centered modal on desktop, bottom sheet on mobile */} {selectedEvent && (
{ if (e.target === e.currentTarget) setSelectedEvent(null); }} >

{selectedEvent.id ? "Edit event" : "New event"}

{eventFormContent}
)}
); } function toInputDateTime(date: Date) { const offset = date.getTimezoneOffset(); const local = new Date(date.getTime() - offset * 60 * 1000); return local.toISOString().slice(0, 16); }