RegistryUI recipes
Calendar view
A month and week calendar of events with a dialog to add one.
It loads its data on the server through the block's client; the calendar block must be installed (npx better-supabase add calendar). Its page on bettersupabase.com is /recipes/calendar-view.
Web, with shadcn components:
npx shadcn add badge button card empty skeletonexport async function CalendarView({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const session = await getSession();
if (!can(session, "calendar.read")) {
forbidden();
}
const [t, format, query] = await Promise.all([
getExtracted("calendar"),
getFormatter(),
searchParams,
connection(),
]);
const canWrite = can(session, "calendar.write");
const canManage = can(session, "calendar.manage");
const first = firstOfMonth(query["month"], "UTC");
const weeks = weeksOf(first);
const from = weeks[0]?.[0] ?? first;
const until = (weeks.at(-1)?.[6] ?? first).add({ days: 1 });
const month = await settle(() =>
loadCalendarMonth(from.toString(), until.toString()),
);
const monthKey = (date: Temporal.PlainDate) =>
date.toPlainYearMonth().toString();
const label = (date: Temporal.PlainDate, options: DateTimeFormatOptions) =>
format.dateTime(new Date(`${date.toString()}T12:00:00Z`), {
...options,
timeZone: "UTC",
});
const byDay = new Map<string, MonthEvent[]>();
if (month.kind === "ok") {
for (const event of month.data.events) {
byDay.set(event.day, [...(byDay.get(event.day) ?? []), event]);
}
}
const today =
month.kind === "ok"
? Temporal.Now.plainDateISO(month.data.tz).toString()
: null;
const calendars = month.kind === "ok" ? month.data.calendars : [];
return (
<Card data-recipe="calendar-view">
<CardHeader>
<CardTitle>
{label(first, { month: "long", year: "numeric" })}
</CardTitle>
<CardDescription>
{month.kind === "ok" && calendars.length > 0
? t("{count, plural, one {# event} other {# events}} in view", {
count: month.data.events.length,
})
: t("Events of your organization by day.")}
</CardDescription>
<CardAction className="flex items-center gap-2">
<Link
href={`/calendar?month=${monthKey(first.subtract({ months: 1 }))}`}
aria-label={t("Previous month")}
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
>
<ChevronLeftIcon />
</Link>
<Link
href="/calendar"
className={buttonVariants({ variant: "outline", size: "sm" })}
>
{t("Today")}
</Link>
<Link
href={`/calendar?month=${monthKey(first.add({ months: 1 }))}`}
aria-label={t("Next month")}
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
>
<ChevronRightIcon />
</Link>
{calendars.length > 0 ? <WorkingHoursDialog /> : null}
{canWrite && calendars.length > 0 ? (
<CreateEventDialog
calendars={calendars}
defaultDate={today ?? first.toString()}
/>
) : null}
</CardAction>
</CardHeader>
<CardContent>
{month.kind === "error" ? (
<CalendarLoadError title={t("The calendar didn't load.")} />
) : month.kind === "no-organization" || calendars.length === 0 ? (
<Empty className="border">
<EmptyHeader>
<EmptyMedia variant="icon">
<CalendarIcon />
</EmptyMedia>
<EmptyTitle>{t("No calendar yet")}</EmptyTitle>
<EmptyDescription>
{month.kind === "no-organization"
? t("Pick an organization to see its calendar.")
: t("Create the team calendar, then add events to it.")}
</EmptyDescription>
</EmptyHeader>
{month.kind === "ok" && canManage ? (
<EmptyContent>
<CreateCalendarButton />
</EmptyContent>
) : null}
</Empty>
) : (
<div className="overflow-x-auto">
<div className="min-w-[42rem] overflow-hidden rounded-lg border">
<div
aria-hidden="true"
className="grid grid-cols-7 border-b bg-muted/50"
>
{weeks[0]?.map((day) => (
<span
key={day.toString()}
className="px-2 py-1.5 text-xs font-medium text-muted-foreground"
>
{label(day, { weekday: "short" })}
</span>
))}
</div>
<ol
aria-label={label(first, { month: "long", year: "numeric" })}
className="grid grid-cols-7"
>
{weeks.flat().map((day) => {
const key = day.toString();
const events = byDay.get(key) ?? [];
const outside = day.month !== first.month;
return (
<li
key={key}
className={cn(
"flex min-h-24 flex-col gap-1 border-e border-b p-1.5 [&:nth-child(7n)]:border-e-0",
outside && "bg-muted/30",
)}
>
<span className="sr-only">
{label(day, { dateStyle: "full" })}
</span>
<span
aria-hidden="true"
className={cn(
"text-xs",
outside ? "text-muted-foreground" : "font-medium",
)}
>
{key === today ? (
<Badge variant="default">{day.day}</Badge>
) : (
day.day
)}
</span>
{events.slice(0, SHOWN_PER_DAY).map((event) => (
<span
key={event.key}
title={event.title}
className="truncate rounded-sm bg-primary/10 px-1.5 py-0.5 text-xs text-foreground"
>
{event.time ? (
<span className="text-muted-foreground tabular-nums">
{event.time}{" "}
</span>
) : null}
{event.title}
</span>
))}
{events.length > SHOWN_PER_DAY ? (
<span className="text-xs text-muted-foreground">
{t("{count} more", {
count: String(events.length - SHOWN_PER_DAY),
})}
</span>
) : null}
</li>
);
})}
</ol>
</div>
</div>
)}
</CardContent>
</Card>
);
}Last updated on