"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) => (
))}
>
);
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 && (
);
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);
}