311 lines
9.1 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
};
|