Help registrants add events to their calendars and keep waitlisted places distinct from confirmed attendance.
671 lines
22 KiB
TypeScript
671 lines
22 KiB
TypeScript
/* ============================================================================
|
||
EVENTS CALENDAR - /{locale}/events-calendar
|
||
----------------------------------------------------------------------------
|
||
Draft section 4, laid out to the 2026 redesign draft ("Events-Calendar"):
|
||
|
||
filter row All · Upcoming · Ongoing · Completed as pills, a region
|
||
select beside them, and a List / Month view switch
|
||
list view one wide row per event: a date chip, the title, where and
|
||
when, the registration state, and the call to action
|
||
month view a seven-column grid with the events of one month in it
|
||
|
||
Every control is a real URL (?status=®ion=&view=&month=&page=), so a
|
||
filtered calendar is shareable, survives the back button and is reachable by
|
||
a crawler that only follows links. There is no client-side filtering to fall
|
||
out of step with the server, and the page works with scripting off.
|
||
|
||
Upcoming / Ongoing / Completed are DERIVED from the dates rather than stored
|
||
(see ./_events-opportunities/events.ts), so nothing goes stale the morning
|
||
after an event.
|
||
|
||
NOT ONE DATE ON THIS PAGE IS WRITTEN HERE. The draft's five sample events -
|
||
the Tripoli SUQ dialogue on 4 March, the Benghazi network meeting, "March
|
||
2026" - were mockup content. The grid and the list are built from Strapi
|
||
entries; with an empty CMS the localised empty state renders instead, and if
|
||
Strapi cannot be reached at all the page still renders, with a notice,
|
||
rather than a 500.
|
||
|
||
WHAT THE DRAFT SHIPS THAT THIS PAGE DOES NOT
|
||
* the "You're registered." confirmation panel - registration happens on
|
||
the event's own page, which already renders that approved confirmation;
|
||
putting it on the listing would mean faking a submission;
|
||
* "Add to calendar · iCal / Google" on the month header - the .ics route
|
||
is per event (events-calendar.$slug.ics.ts) and there is no whole-
|
||
calendar feed to offer, so the action lives on each event instead;
|
||
* the event-type kicker ("SUQ Dialogue", "Training"): `event.eventType`
|
||
exists in Strapi but is not carried on the card view model, which lives
|
||
in a module this change does not own.
|
||
========================================================================= */
|
||
|
||
import { Link, useSearchParams } from "react-router";
|
||
|
||
import { ArrowIcon, Button } from "~/components/ui/Button";
|
||
import { CardLink, CardShell } from "~/components/ui/Card";
|
||
import {
|
||
intlLocale,
|
||
localizedPath,
|
||
sectionPath,
|
||
toIsoDate,
|
||
translate,
|
||
useTranslation,
|
||
type Locale,
|
||
} from "~/i18n";
|
||
import { buildBreadcrumbJsonLd, buildEventJsonLd, buildMeta } from "~/lib/seo";
|
||
|
||
import type { Route } from "./+types/events-calendar";
|
||
import {
|
||
collectRegions,
|
||
eventActionKey,
|
||
eventRegistrationLabel,
|
||
eventTimeStatusKey,
|
||
EVENT_STATUS_FILTERS,
|
||
EVENT_TIME_ZONE,
|
||
filterEvents,
|
||
formatEventWhen,
|
||
sortEvents,
|
||
type EventCardData,
|
||
} from "./_events-opportunities/events";
|
||
import { fetchEvents } from "./_events-opportunities/events.server";
|
||
import { getRouteCopy } from "./_events-opportunities/copy";
|
||
import { readRootSeo } from "./_events-opportunities/root-seo";
|
||
import { Notice } from "./_events-opportunities/ui";
|
||
import {
|
||
buildCalendarMonth,
|
||
calendarDayIn,
|
||
monthKeyOf,
|
||
parseMonthKey,
|
||
weekdayLabel,
|
||
weekStartFor,
|
||
type CalendarDay,
|
||
type CalendarMonth,
|
||
} from "./_shared/calendar";
|
||
import {
|
||
buildPagination,
|
||
PAGE_SIZE,
|
||
parseEnum,
|
||
parsePage,
|
||
requireLocale,
|
||
withParam,
|
||
} from "./_shared/listing";
|
||
import {
|
||
EmptyState,
|
||
FilterPills,
|
||
ListingHero,
|
||
MetaLine,
|
||
PageShell,
|
||
Pagination,
|
||
PillSelect,
|
||
ResultSummary,
|
||
} from "./_shared/listing-ui";
|
||
import { getListingChrome } from "./_shared/page-copy";
|
||
|
||
import "./_page-kit/motion.css";
|
||
|
||
/** Longest region value we will echo back into a filter. */
|
||
const MAX_REGION_LENGTH = 80;
|
||
|
||
/** The two ways the calendar can be read. */
|
||
const VIEWS = ["list", "month"] as const;
|
||
type CalendarView = (typeof VIEWS)[number];
|
||
|
||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||
const locale = requireLocale(params.lang);
|
||
const url = new URL(request.url);
|
||
const now = new Date();
|
||
|
||
const status = parseEnum(
|
||
url.searchParams,
|
||
"status",
|
||
EVENT_STATUS_FILTERS,
|
||
"all",
|
||
);
|
||
const view = parseEnum(url.searchParams, "view", VIEWS, "list");
|
||
|
||
const { events, available } = await fetchEvents(locale, now);
|
||
|
||
/* Region options come from the events themselves, never from a hard-coded
|
||
list: an empty CMS offers no regions, and a region only appears once
|
||
something is actually happening in it. A requested region that no event
|
||
carries is dropped rather than rendering an empty page. */
|
||
const regions = collectRegions(events, locale);
|
||
const requestedRegion = url.searchParams
|
||
.get("region")
|
||
?.trim()
|
||
.slice(0, MAX_REGION_LENGTH);
|
||
const region =
|
||
requestedRegion && regions.includes(requestedRegion) ? requestedRegion : null;
|
||
|
||
const matching = sortEvents(filterEvents(events, { status, region }));
|
||
const pagination = buildPagination(
|
||
parsePage(url.searchParams),
|
||
PAGE_SIZE,
|
||
matching.length,
|
||
);
|
||
const start = (pagination.page - 1) * PAGE_SIZE;
|
||
|
||
/* Which month the grid opens on: the one the reader asked for, else the
|
||
month of the next event in the filtered set, else the current month.
|
||
Derived, never a literal - the draft's "March 2026" was sample content. */
|
||
const requestedMonth = parseMonthKey(url.searchParams.get("month"));
|
||
const firstDay = matching[0]
|
||
? calendarDayIn(matching[0].start, EVENT_TIME_ZONE)
|
||
: undefined;
|
||
const today = calendarDayIn(now, EVENT_TIME_ZONE);
|
||
const month =
|
||
requestedMonth ??
|
||
(firstDay ? monthKeyOf(firstDay) : monthKeyOf(today ?? "1970-01-01"));
|
||
|
||
const calendar = buildCalendarMonth({
|
||
month,
|
||
entries: matching.map((event) => ({
|
||
id: event.slug,
|
||
title: event.title,
|
||
href: `${sectionPath("events", locale)}/${encodeURIComponent(event.slug)}`,
|
||
start: event.start,
|
||
...(event.end ? { end: event.end } : {}),
|
||
allDay: event.allDay,
|
||
})),
|
||
timeZone: EVENT_TIME_ZONE,
|
||
weekStartsOn: weekStartFor(locale),
|
||
now,
|
||
});
|
||
|
||
return {
|
||
locale,
|
||
events: matching.slice(start, start + PAGE_SIZE),
|
||
pagination,
|
||
status,
|
||
region,
|
||
regions,
|
||
view,
|
||
calendar,
|
||
available,
|
||
/** Distinguishes "the CMS is empty" from "these filters match nothing". */
|
||
hasEvents: events.length > 0,
|
||
};
|
||
}
|
||
|
||
export function meta({ location, matches, loaderData }: Route.MetaArgs) {
|
||
const root = readRootSeo(matches);
|
||
const copy = getRouteCopy(root.locale);
|
||
const search = new URLSearchParams(location.search);
|
||
const eventJsonLd = (loaderData?.events ?? []).map((event) =>
|
||
buildEventJsonLd({
|
||
siteUrl: root.siteUrl,
|
||
locale: root.locale,
|
||
name: event.title,
|
||
pathname: `${sectionPath("events", root.locale)}/${encodeURIComponent(event.slug)}`,
|
||
startDate: toIsoDate(event.start) ?? event.start,
|
||
...(event.end ? { endDate: toIsoDate(event.end) ?? event.end } : {}),
|
||
...(event.summary ? { description: event.summary } : {}),
|
||
...(event.image ? { image: event.image.src } : {}),
|
||
...(event.place ? { locationName: event.place } : {}),
|
||
...(event.region ? { addressLocality: event.region } : {}),
|
||
isOnline: event.format === "online" || event.format === "hybrid",
|
||
}),
|
||
);
|
||
|
||
return buildMeta({
|
||
locale: root.locale,
|
||
pathname: root.pathname,
|
||
siteUrl: root.siteUrl,
|
||
title: translate(root.locale, "nav.sections.events"),
|
||
description: copy.events.metaDescription,
|
||
// A filtered, paginated or re-viewed calendar is the same content in
|
||
// another order.
|
||
noIndex:
|
||
search.has("status") ||
|
||
search.has("region") ||
|
||
search.has("page") ||
|
||
search.has("view") ||
|
||
search.has("month"),
|
||
jsonLd: [
|
||
buildBreadcrumbJsonLd(root.siteUrl, [
|
||
{
|
||
name: translate(root.locale, "nav.sections.home"),
|
||
pathname: localizedPath("/", root.locale),
|
||
},
|
||
{
|
||
name: translate(root.locale, "nav.sections.events"),
|
||
pathname: sectionPath("events", root.locale),
|
||
},
|
||
]),
|
||
...eventJsonLd,
|
||
],
|
||
});
|
||
}
|
||
|
||
export default function EventsCalendar({ loaderData }: Route.ComponentProps) {
|
||
const {
|
||
events,
|
||
pagination,
|
||
status,
|
||
region,
|
||
regions,
|
||
view,
|
||
calendar,
|
||
available,
|
||
hasEvents,
|
||
} = loaderData;
|
||
const { locale, t } = useTranslation();
|
||
const copy = getRouteCopy(locale);
|
||
const chrome = getListingChrome(locale).events;
|
||
const [searchParams] = useSearchParams();
|
||
|
||
const pathname = sectionPath("events", locale);
|
||
const href = (key: string, value: string | undefined) =>
|
||
`${pathname}${withParam(searchParams, key, value)}`;
|
||
|
||
const statusPills = EVENT_STATUS_FILTERS.map((value) => ({
|
||
id: value,
|
||
label: value === "all" ? t("filters.all") : t(eventTimeStatusKey(value)),
|
||
href: href("status", value === "all" ? undefined : value),
|
||
isCurrent: status === value,
|
||
}));
|
||
|
||
/* Paging and the month on show are properties of one view, so switching
|
||
view keeps the filters and starts the new view at its own beginning. */
|
||
const viewParams = new URLSearchParams();
|
||
if (status !== "all") viewParams.set("status", status);
|
||
if (region) viewParams.set("region", region);
|
||
|
||
const viewPills = VIEWS.map((value) => ({
|
||
id: value,
|
||
label: value === "list" ? chrome.viewList : chrome.viewMonth,
|
||
href: `${pathname}${withParam(
|
||
viewParams,
|
||
"view",
|
||
value === "list" ? undefined : value,
|
||
)}`,
|
||
isCurrent: view === value,
|
||
}));
|
||
|
||
/* What the region form has to carry across a GET submit - the query string
|
||
is replaced wholesale, so anything not in the form is lost. */
|
||
const keep: (readonly [string, string])[] = [
|
||
...(status === "all" ? [] : [["status", status] as const]),
|
||
...(view === "list" ? [] : [["view", view] as const]),
|
||
];
|
||
|
||
const isFiltered = status !== "all" || region !== null;
|
||
|
||
return (
|
||
<>
|
||
<ListingHero
|
||
eyebrow={chrome.eyebrow}
|
||
heading={copy.events.heading}
|
||
intro={copy.events.intro}
|
||
ambient={[{ top: -250, from: "end", offset: "4%", width: 720, height: 560, durationSeconds: 20 }]}
|
||
/>
|
||
|
||
<PageShell>
|
||
{available ? null : (
|
||
<Notice tone="info" role="status">
|
||
{copy.events.unavailable}
|
||
</Notice>
|
||
)}
|
||
|
||
<div className="flex flex-col gap-lg">
|
||
<div className="flex flex-wrap items-center justify-between gap-lg border-b border-border-subtle pb-lg">
|
||
<FilterPills
|
||
label={copy.events.statusFilterLabel}
|
||
pills={statusPills}
|
||
>
|
||
{regions.length > 0 ? (
|
||
<PillSelect
|
||
name="region"
|
||
label={t("filters.region")}
|
||
value={region ?? ""}
|
||
options={regions.map((value) => ({ value, label: value }))}
|
||
allLabel={t("filters.all")}
|
||
applyLabel={t("filters.apply")}
|
||
action={pathname}
|
||
keep={keep}
|
||
/>
|
||
) : null}
|
||
</FilterPills>
|
||
|
||
{/* The draft draws the List / Month switch as a pair of square
|
||
controls with no caption, beside the rounded filter pills. */}
|
||
<FilterPills
|
||
label={chrome.viewLabel}
|
||
pills={viewPills}
|
||
shape="control"
|
||
hideLabel
|
||
/>
|
||
</div>
|
||
|
||
{view === "list" ? <ResultSummary pagination={pagination} t={t} /> : null}
|
||
</div>
|
||
|
||
{view === "month" ? (
|
||
<MonthView
|
||
calendar={calendar}
|
||
locale={locale}
|
||
emptyMessage={chrome.monthEmpty}
|
||
label={chrome.calendarLabel}
|
||
previousLabel={chrome.previousMonth}
|
||
nextLabel={chrome.nextMonth}
|
||
hrefFor={(month) => href("month", month)}
|
||
/>
|
||
) : events.length > 0 ? (
|
||
<>
|
||
{/* The list is the page's content, so it gets a heading in the
|
||
outline even though the hero already names the page. */}
|
||
<h2 className="sr-only">{copy.events.listLabel}</h2>
|
||
<ul
|
||
aria-label={copy.events.listLabel}
|
||
className="flex flex-col gap-md"
|
||
>
|
||
{events.map((event) => (
|
||
<EventRow
|
||
key={event.slug}
|
||
event={event}
|
||
href={`${pathname}/${encodeURIComponent(event.slug)}`}
|
||
/>
|
||
))}
|
||
</ul>
|
||
|
||
<Pagination
|
||
model={pagination}
|
||
pathname={pathname}
|
||
params={searchParams}
|
||
t={t}
|
||
/>
|
||
</>
|
||
) : hasEvents && isFiltered ? (
|
||
<EmptyState
|
||
title={t("filters.noResults")}
|
||
body={t("filters.noResultsHint")}
|
||
>
|
||
<Button as={Link} to={pathname} variant="secondary" size="sm">
|
||
{t("filters.clear")}
|
||
</Button>
|
||
</EmptyState>
|
||
) : (
|
||
<EmptyState title={t("events.noEvents")} body={copy.events.emptyHint} />
|
||
)}
|
||
</PageShell>
|
||
</>
|
||
);
|
||
}
|
||
|
||
/* ---------------------------------------------------------------------------
|
||
ONE EVENT, AS THE DRAFT DRAWS IT
|
||
------------------------------------------------------------------------ */
|
||
|
||
/** "10:00–14:00" in Libya time, or nothing for an all-day event. */
|
||
function formatClock(event: EventCardData, locale: Locale): string | undefined {
|
||
if (event.allDay) return undefined;
|
||
|
||
const options: Intl.DateTimeFormatOptions = {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
timeZone: EVENT_TIME_ZONE,
|
||
};
|
||
const formatter = new Intl.DateTimeFormat(intlLocale(locale), options);
|
||
|
||
const start = new Date(event.start);
|
||
if (Number.isNaN(start.getTime())) return undefined;
|
||
|
||
const end = event.end ? new Date(event.end) : null;
|
||
if (end && !Number.isNaN(end.getTime()) && end.getTime() !== start.getTime()) {
|
||
return formatter.formatRange(start, end);
|
||
}
|
||
return formatter.format(start);
|
||
}
|
||
|
||
/** The date block on the inline-start edge: month over the day number. */
|
||
function DateChip({ event, locale }: { event: EventCardData; locale: Locale }) {
|
||
const timeZone = event.allDay ? "UTC" : EVENT_TIME_ZONE;
|
||
const date = new Date(event.start);
|
||
if (Number.isNaN(date.getTime())) return null;
|
||
|
||
const part = (options: Intl.DateTimeFormatOptions) =>
|
||
new Intl.DateTimeFormat(intlLocale(locale), { ...options, timeZone }).format(
|
||
date,
|
||
);
|
||
|
||
return (
|
||
/* The draft draws a 76x82 block holding the month over the day and
|
||
nothing else - the full date, year included, is on the line under the
|
||
title, so repeating it here only crowds the block. */
|
||
<div className="flex h-[5.125rem] w-[4.75rem] shrink-0 flex-col items-center justify-center gap-2xs rounded-card border border-border-subtle bg-surface-sunken">
|
||
<span className="label-md uppercase text-text-accent">
|
||
{part({ month: "short" })}
|
||
</span>
|
||
<span className="heading-h2 text-text-primary">
|
||
{part({ day: "numeric" })}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EventRow({ event, href }: { event: EventCardData; href: string }) {
|
||
const { locale, t } = useTranslation();
|
||
const copy = getRouteCopy(locale);
|
||
|
||
const isPast = event.timeStatus === "completed";
|
||
const isLive = !isPast && event.registrationStatus === "open";
|
||
|
||
const formatLabel =
|
||
event.format === "online"
|
||
? t("events.online")
|
||
: event.format === "hybrid"
|
||
? copy.events.formatHybrid
|
||
: event.format === "in-person"
|
||
? t("events.inPerson")
|
||
: undefined;
|
||
|
||
const clock = formatClock(event, locale);
|
||
|
||
/* The draft's kicker above the title. Empty for an event the CMS left
|
||
unclassified ("other"), in which case only the time status shows. */
|
||
const typeLabel = event.eventType
|
||
? copy.events.eventTypes[event.eventType]
|
||
: undefined;
|
||
|
||
return (
|
||
<CardShell
|
||
as="li"
|
||
linked
|
||
className="sh-reveal sh-reveal--in flex-col gap-lg p-lg md:flex-row md:items-center"
|
||
>
|
||
<div className="flex min-w-0 flex-1 items-center gap-lg">
|
||
<DateChip event={event} locale={locale} />
|
||
|
||
<div className="flex min-w-0 flex-col gap-xs">
|
||
<p className="label-md uppercase text-text-tertiary">
|
||
{typeLabel ? (
|
||
<>
|
||
<span className="text-text-accent">{typeLabel}</span>
|
||
<span aria-hidden="true"> · </span>
|
||
</>
|
||
) : null}
|
||
{t(eventTimeStatusKey(event.timeStatus))}
|
||
</p>
|
||
|
||
<h3 className="sh-card-title body-lg-bold text-text-primary">
|
||
<CardLink href={href} className="sh-card-link">
|
||
{event.title}
|
||
</CardLink>
|
||
</h3>
|
||
|
||
<p className="body-sm-bold text-text-secondary">
|
||
<time dateTime={toIsoDate(event.start)}>
|
||
{formatEventWhen(event, locale)}
|
||
</time>
|
||
</p>
|
||
|
||
<MetaLine
|
||
items={[formatLabel, event.place, event.region, clock].filter(
|
||
(item): item is string => Boolean(item),
|
||
)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-md md:justify-end">
|
||
{/* A finished event's registration state is history, not news. */}
|
||
{isPast ? null : (
|
||
<span className="inline-flex items-center gap-xs rounded-pill border border-border-default px-md py-2xs label-md uppercase text-text-primary">
|
||
{isLive ? (
|
||
<span
|
||
aria-hidden="true"
|
||
className="sh-pulse size-[7px] shrink-0 rounded-pill bg-[var(--surface-accent)]"
|
||
/>
|
||
) : null}
|
||
{eventRegistrationLabel(event.registrationStatus, locale)}
|
||
</span>
|
||
)}
|
||
|
||
{/* The draft ends every row with the filled accent action - Register,
|
||
Apply, Join waitlist, Read recap - not a text hint. `sh-card-above`
|
||
lifts it over the stretched title link so it stays its own target,
|
||
and the label comes from the event's own registration state. */}
|
||
<Button
|
||
as={Link}
|
||
to={href}
|
||
prefetch="intent"
|
||
variant="primary"
|
||
size="md"
|
||
iconEnd={<ArrowIcon />}
|
||
className="sh-card-above shrink-0"
|
||
>
|
||
{t(eventActionKey(event))}
|
||
</Button>
|
||
</div>
|
||
</CardShell>
|
||
);
|
||
}
|
||
|
||
/* ---------------------------------------------------------------------------
|
||
THE MONTH GRID
|
||
------------------------------------------------------------------------ */
|
||
|
||
function MonthView({
|
||
calendar,
|
||
locale,
|
||
label,
|
||
emptyMessage,
|
||
previousLabel,
|
||
nextLabel,
|
||
hrefFor,
|
||
}: {
|
||
calendar: CalendarMonth;
|
||
locale: Locale;
|
||
label: string;
|
||
emptyMessage: string;
|
||
previousLabel: string;
|
||
nextLabel: string;
|
||
hrefFor: (month: string) => string;
|
||
}) {
|
||
const monthName = new Intl.DateTimeFormat(intlLocale(locale), {
|
||
month: "long",
|
||
year: "numeric",
|
||
timeZone: "UTC",
|
||
}).format(new Date(`${calendar.firstDay}T00:00:00Z`));
|
||
|
||
return (
|
||
<section aria-label={label} className="flex flex-col gap-lg">
|
||
<div className="flex flex-wrap items-center justify-between gap-md">
|
||
<h2 className="heading-h3 text-text-primary">{monthName}</h2>
|
||
|
||
<div className="flex items-center gap-xs">
|
||
<Button
|
||
as={Link}
|
||
to={hrefFor(calendar.previous)}
|
||
prefetch="intent"
|
||
variant="ghost"
|
||
size="sm"
|
||
iconStart={<ArrowIcon direction="backward" />}
|
||
>
|
||
{previousLabel}
|
||
</Button>
|
||
<Button
|
||
as={Link}
|
||
to={hrefFor(calendar.next)}
|
||
prefetch="intent"
|
||
variant="ghost"
|
||
size="sm"
|
||
iconEnd={<ArrowIcon />}
|
||
>
|
||
{nextLabel}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* The hairlines are the grid's own gap showing through, which is how the
|
||
draft draws it - one border round the whole block, one pixel between
|
||
the cells. */}
|
||
<div className="grid grid-cols-7 gap-px overflow-hidden rounded-card border border-border-subtle bg-[var(--border-subtle)]">
|
||
{calendar.weekdays.map((weekday) => (
|
||
<div
|
||
key={weekday}
|
||
className="bg-surface-raised p-sm text-center label-md uppercase text-text-tertiary"
|
||
>
|
||
<span aria-hidden="true">
|
||
{weekdayLabel(weekday, intlLocale(locale), "narrow")}
|
||
</span>
|
||
<span className="sr-only">
|
||
{weekdayLabel(weekday, intlLocale(locale), "long")}
|
||
</span>
|
||
</div>
|
||
))}
|
||
|
||
{calendar.days.map((day) => (
|
||
<MonthCell key={day.key} day={day} locale={locale} />
|
||
))}
|
||
</div>
|
||
|
||
{calendar.entryCount === 0 ? (
|
||
<p className="body-md text-text-secondary" role="status">
|
||
{emptyMessage}
|
||
</p>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function MonthCell({ day, locale }: { day: CalendarDay; locale: Locale }) {
|
||
const dayNumber = new Intl.NumberFormat(intlLocale(locale), {
|
||
useGrouping: false,
|
||
}).format(day.dayNumber);
|
||
|
||
return (
|
||
<div
|
||
className={[
|
||
"sh-lift flex min-h-[6rem] flex-col gap-2xs p-xs",
|
||
day.inMonth ? "bg-[var(--surface-page)]" : "bg-surface-sunken",
|
||
"hover:bg-surface-muted",
|
||
].join(" ")}
|
||
>
|
||
<p
|
||
className={[
|
||
"label-md",
|
||
day.isToday ? "text-text-accent" : "text-text-tertiary",
|
||
day.inMonth ? "" : "opacity-60",
|
||
].join(" ")}
|
||
>
|
||
<time dateTime={day.key}>{dayNumber}</time>
|
||
</p>
|
||
|
||
{day.entries.map((entry) => (
|
||
<Link
|
||
key={`${day.key}-${entry.id}`}
|
||
to={entry.href}
|
||
prefetch="intent"
|
||
className={[
|
||
"rounded-chip border-s-2 border-s-[var(--border-accent)]",
|
||
"bg-surface-accent-subtle px-2xs py-2xs body-sm-bold text-text-primary",
|
||
"transition-colors duration-[var(--motion-duration-fast)]",
|
||
"ease-[var(--motion-ease-standard)] hover:text-text-accent",
|
||
].join(" ")}
|
||
>
|
||
{entry.title}
|
||
</Link>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|