Files
neuf-www/web/src/components/events/EventContainer.tsx
T
2026-10-13 23:59:14 +02:00

311 lines
9.1 KiB
TypeScript

"use client";
import { useEffect } from "react";
import {
useQueryState,
parseAsStringLiteral,
parseAsString,
parseAsArrayOf,
} from "nuqs";
import { EventItem } from "./EventItem";
import { EventFilter, EventFilterExplained } from "./EventFilter";
import {
EventOverviewItemFragment,
EventCategory,
SingularEvent,
getSingularEvents,
organizeEventsInCalendar,
EventOrganizer,
} from "@/lib/event";
import {
filterEvents,
getFilterableCategories,
getFilterableOrganizers,
getFilterableVenues,
} from "@/lib/eventFilter";
import { isTodayOrFuture } from "@/lib/date";
import { useToday } from "@/components/general/Today";
import styles from "./eventContainer.module.scss";
import { formatDate, formatYearMonth } from "@/lib/date";
import {
startOfWeek,
endOfWeek,
differenceInCalendarDays,
parse,
} from "date-fns";
import { Icon } from "../general/Icon";
import { useState } from "react";
import { VenueFragment } from "@/gql/graphql";
/*
TODO: canonical / alternate URLs https://github.com/47ng/nuqs?tab=readme-ov-file#seo
*/
export const EventContainer = ({
events,
eventCategories,
eventOrganizers,
venues,
}: {
events: EventOverviewItemFragment[];
eventCategories: EventCategory[];
eventOrganizers: EventOrganizer[];
venues: VenueFragment[];
}) => {
const [mode, setMode] = useQueryState(
"mode",
parseAsStringLiteral(["list", "calendar"]).withDefault("list"),
);
const [categories, setCategories] = useQueryState(
"category",
parseAsArrayOf(parseAsString, ","),
);
const [organizer, setOrganizer] = useQueryState("organizer", parseAsString);
const [venue, setVenue] = useQueryState("venue", parseAsString);
const resetFilters = () => {
setCategories(null);
setOrganizer(null);
setVenue(null);
};
const onResetFilters = (e: React.MouseEvent<HTMLButtonElement>) => {
resetFilters();
};
const filterableCategories = getFilterableCategories(eventCategories);
const filterableOrganizers = getFilterableOrganizers(
events,
eventOrganizers,
organizer,
);
const filterableVenues = getFilterableVenues(events, venues, venue);
/* Reset organizer if slug is invalid */
useEffect(() => {
if (!eventOrganizers.find((haystack) => haystack.slug === organizer)) {
setOrganizer(null);
}
}, [eventOrganizers, organizer]);
/* Reset venue if slug is invalid */
useEffect(() => {
if (!venues.find((haystack) => haystack.slug === venue)) {
setVenue(null);
}
}, [venues, venue]);
const filteredEvents = filterEvents(events, { categories, organizer, venue });
const [showFilter, setShowFilter] = useState(false);
function toggleFilter() {
setShowFilter(!showFilter);
}
return (
<div className={styles.events}>
<div className={styles.eventWrapper}>
<div className={styles.displayOptions}>
<div className={styles.displayButtons}>
<button
onClick={() => setMode(null)}
className="button toggler hasIcon"
data-active={mode === "list"}
>
<span>Vis liste</span>
<Icon type="list" />
</button>
<button
onClick={() => setMode("calendar")}
className="button toggler hasIcon"
data-active={mode === "calendar"}
>
<span>Vis kalender</span>
<Icon type="calendar" />
</button>
</div>
<div className={styles.filterButtons}>
{(categories || organizer || venue) && (
<button onClick={onResetFilters} className="button tertiary">
<span>Vis alle</span>
<Icon type="noFilter" />
</button>
)}
<button onClick={toggleFilter} className="button tertiary">
<span>Filter</span>
<Icon type="filter" />
</button>
</div>
</div>
<EventFilter
eventCategories={filterableCategories}
setCategories={setCategories}
activeCategories={categories}
eventOrganizers={filterableOrganizers}
setOrganizer={setOrganizer}
activeOrganizer={organizer}
venues={filterableVenues}
setVenue={setVenue}
activeVenue={venue}
isVisible={showFilter}
/>
<EventFilterExplained
eventCategories={filterableCategories}
activeCategories={categories}
eventOrganizers={filterableOrganizers}
activeOrganizer={organizer}
venues={filterableVenues}
activeVenue={venue}
/>
{mode === "list" && <EventList events={filteredEvents} />}
{mode === "calendar" && <EventCalendar events={filteredEvents} />}
</div>
</div>
);
};
const EventList = ({ events }: { events: EventOverviewItemFragment[] }) => {
if (events.length === 0) {
return <div className={styles.noEvents}>Ingen kommende arrangementer.</div>;
}
return (
<ul className={styles.eventList}>
{events.map((event) => (
<EventItem key={event.id} event={event} mode="list" />
))}
</ul>
);
};
const EmptyCalendarCell = () => (
<div className={`${styles.calendarDay} ${styles.empty}`} />
);
const CalendarDay = ({
day,
events,
}: {
day: string;
events: SingularEvent<EventOverviewItemFragment>[];
}) => (
<div
className={`${styles.calendarDay} ${events.length === 0 && styles.empty}`}
>
<h3>{formatDate(parse(day, "yyyy-MM-dd", new Date(0)), "eeee dd.MM.")}</h3>
<ul>
{events.map((event) => (
<EventItem
key={`${day}--${event.id}--${event.occurrence.id}`}
event={event}
mode="calendar"
size="small"
/>
))}
</ul>
</div>
);
const CalendarWeek = ({
days,
}: {
days: Record<string, SingularEvent<EventOverviewItemFragment>[]>;
}) => {
const daysInWeek = Object.keys(days);
const firstDay = daysInWeek[0];
const lastDay = daysInWeek[daysInWeek.length - 1];
const weekStart = startOfWeek(firstDay, { weekStartsOn: 1 });
const weekEnd = endOfWeek(lastDay, { weekStartsOn: 1 });
const emptyCellsBefore = differenceInCalendarDays(firstDay, weekStart);
const emptyCellsAfter = differenceInCalendarDays(weekEnd, lastDay);
return (
<div className={styles.calendarWeek}>
{[...Array(emptyCellsBefore)].map((_, idx) => (
<EmptyCalendarCell key={`before-${idx}`} />
))}
{Object.keys(days).map((day) => (
<CalendarDay key={day} day={day} events={days[day]} />
))}
{[...Array(emptyCellsAfter)].map((_, idx) => (
<EmptyCalendarCell key={`after-${idx}`} />
))}
</div>
);
};
function srOnlyYear(yearMonthString: string, today: Date) {
// formatYearMonth only shows the year for non-current years; always expose it to screen readers
const yearMonth = new Date(yearMonthString);
const isCurrentYear = yearMonth.getFullYear() === today.getFullYear();
if (!isCurrentYear) {
return null;
}
return <span className="sr-only"> {yearMonth.getFullYear()}</span>;
}
const EventCalendar = ({ events }: { events: EventOverviewItemFragment[] }) => {
const today = useToday();
const futureSingularEvents = getSingularEvents(events).filter(
(x) => x.occurrence?.start && isTodayOrFuture(x.occurrence.start, today),
);
const eventsByDate = organizeEventsInCalendar(futureSingularEvents);
const yearMonths = Object.keys(eventsByDate);
const [visibleYearMonths, setVisibleYearMonths] = useState(
yearMonths.slice(0, 2),
);
const toggleYearMonth = (yearMonth: string) => {
if (visibleYearMonths.includes(yearMonth)) {
setVisibleYearMonths(visibleYearMonths.filter((x) => x != yearMonth));
} else {
setVisibleYearMonths([...visibleYearMonths, yearMonth]);
}
};
return (
<>
<div className={styles.eventCalendar}>
{Object.keys(eventsByDate).map((yearMonth) => {
const eventCount = Object.values(eventsByDate[yearMonth]).reduce(
(yearMonthSum, week) =>
yearMonthSum +
Object.values(week).reduce(
(weekSum, day) => weekSum + day.length,
0,
),
0,
);
return (
<div
key={yearMonth}
className={styles.calendarYearMonth}
data-collapsed={!visibleYearMonths.includes(yearMonth)}
>
<h2 onClick={() => toggleYearMonth(yearMonth)}>
{formatYearMonth(yearMonth, today)}
{srOnlyYear(yearMonth, today)}{" "}
<span className={styles.eventCounter}>({eventCount})</span>
</h2>
{eventCount === 0 && (
<div className={styles.calendarWeekEmpty}>
Ingen arrangementer denne måneden.
</div>
)}
{eventCount !== 0 &&
Object.keys(eventsByDate[yearMonth]).map((week) => (
<CalendarWeek
key={week}
days={eventsByDate[yearMonth][week]}
/>
))}
</div>
);
})}
</div>
</>
);
};