282 lines
8.3 KiB
TypeScript
282 lines
8.3 KiB
TypeScript
"use client";
|
|
|
|
import { useQueryState, parseAsStringLiteral, parseAsString } from "nuqs";
|
|
import { EventItem } from "./EventItem";
|
|
import { EventFilter, EventFilterExplained } from "./EventFilter";
|
|
import {
|
|
EventFragment,
|
|
EventCategory,
|
|
SingularEvent,
|
|
getSingularEvents,
|
|
organizeEventsInCalendar,
|
|
EventOrganizer,
|
|
} from "@/lib/event";
|
|
import { isTodayOrFuture } from "@/lib/date";
|
|
import styles from "./eventContainer.module.scss";
|
|
import { formatDate, formatYearMonth } from "@/lib/date";
|
|
import {
|
|
startOfWeek,
|
|
endOfWeek,
|
|
differenceInCalendarDays,
|
|
parse,
|
|
} from "date-fns";
|
|
import { unique } from "@/lib/common";
|
|
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: EventFragment[];
|
|
eventCategories: EventCategory[];
|
|
eventOrganizers: EventOrganizer[];
|
|
venues: VenueFragment[];
|
|
}) => {
|
|
const [mode, setMode] = useQueryState(
|
|
"mode",
|
|
parseAsStringLiteral(["list", "calendar"]).withDefault("list")
|
|
);
|
|
const [category, setCategory] = useQueryState("category", parseAsString);
|
|
const [organizer, setOrganizer] = useQueryState("organizer", parseAsString);
|
|
const [venue, setVenue] = useQueryState("venue", parseAsString);
|
|
|
|
const resetFilters = () => {
|
|
setCategory(null);
|
|
setOrganizer(null);
|
|
setVenue(null);
|
|
};
|
|
|
|
const onResetFilters = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
resetFilters();
|
|
};
|
|
|
|
/* Allow filtering on all categories that are configured to be shown */
|
|
const filterableCategories = eventCategories.filter((x) => x.showInFilters);
|
|
|
|
/*
|
|
Allow filtering on all organizers that have upcoming events
|
|
Filtering on an organizer with no upcoming events will work, but be hidden from dropdown
|
|
*/
|
|
const uniqueOrganizers: string[] = unique(
|
|
events
|
|
.map((x) => x.organizers)
|
|
.flat()
|
|
.filter((x) => x.__typename === "EventOrganizer")
|
|
.map((x) => x.slug)
|
|
.filter((x) => typeof x === "string")
|
|
);
|
|
const filterableOrganizers = uniqueOrganizers
|
|
.map((slug) => eventOrganizers.find((haystack) => haystack.slug === slug))
|
|
.filter((x) => x !== undefined) as EventOrganizer[];
|
|
|
|
/*
|
|
Allow filtering on all venues that have upcoming events
|
|
Filtering on a venue with no upcoming events will work,
|
|
and in that case it's included in the dropdown
|
|
*/
|
|
const venueSlugsWithUpcomingEvents = events
|
|
.map((x) => x.occurrences)
|
|
.flat()
|
|
.filter((x) => x.venue?.__typename === "VenuePage")
|
|
.map((x) => x.venue?.slug)
|
|
.filter((x) => typeof x === "string");
|
|
const filterableVenues = venues
|
|
.map((x) =>
|
|
venues.find(
|
|
(haystack) => haystack.slug === x.slug || haystack.slug === venue
|
|
)
|
|
)
|
|
.filter((x) => x !== undefined) as VenueFragment[];
|
|
|
|
const filteredEvents = events
|
|
.filter(
|
|
(x) =>
|
|
!organizer ||
|
|
x.organizers.map((organizer) => organizer.slug).includes(organizer)
|
|
)
|
|
.filter(
|
|
(x) =>
|
|
!category ||
|
|
x.categories
|
|
.map((eventCategory) => eventCategory.slug)
|
|
.includes(category)
|
|
)
|
|
.filter(
|
|
(x) =>
|
|
!venue ||
|
|
x.occurrences
|
|
.map((occurrence) => occurrence.venue?.slug)
|
|
.filter((x) => typeof x === "string")
|
|
.includes(venue)
|
|
);
|
|
|
|
const [showFilter, setShowFilter] = useState(false);
|
|
function toggleFilter() {
|
|
setShowFilter(!showFilter);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.events}>
|
|
<div className={styles.eventWrapper}>
|
|
<div className={styles.displayOptions}>
|
|
<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>
|
|
{(category || 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>
|
|
<EventFilter
|
|
eventCategories={filterableCategories}
|
|
setCategory={setCategory}
|
|
activeCategory={category}
|
|
eventOrganizers={filterableOrganizers}
|
|
setOrganizer={setOrganizer}
|
|
activeOrganizer={organizer}
|
|
venues={filterableVenues}
|
|
setVenue={setVenue}
|
|
activeVenue={venue}
|
|
isVisible={showFilter}
|
|
/>
|
|
<EventFilterExplained
|
|
eventCategories={filterableCategories}
|
|
activeCategory={category}
|
|
eventOrganizers={filterableOrganizers}
|
|
activeOrganizer={organizer}
|
|
venues={filterableVenues}
|
|
activeVenue={venue}
|
|
/>
|
|
{mode === "list" && <EventList events={filteredEvents} />}
|
|
{mode === "calendar" && <EventCalendar events={filteredEvents} />}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const EventList = ({ events }: { events: EventFragment[] }) => {
|
|
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[];
|
|
}) => (
|
|
<div
|
|
className={`${styles.calendarDay} ${events.length === 0 && styles.empty}`}
|
|
>
|
|
<h3>{formatDate(parse(day, "yyyy-MM-dd", new Date()), "eeee dd.MM.")}</h3>
|
|
<ul>
|
|
{events.map((event) => (
|
|
<EventItem key={event.id} event={event} mode="calendar" size="small" />
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
|
|
const CalendarWeek = ({ days }: { days: Record<string, SingularEvent[]> }) => {
|
|
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>
|
|
);
|
|
};
|
|
|
|
const EventCalendar = ({ events }: { events: EventFragment[] }) => {
|
|
const futureSingularEvents = getSingularEvents(events).filter(
|
|
(x) => x.occurrence?.start && isTodayOrFuture(x.occurrence.start)
|
|
);
|
|
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) => (
|
|
<div
|
|
key={yearMonth}
|
|
className={styles.calendarYearMonth}
|
|
data-collapsed={!visibleYearMonths.includes(yearMonth)}
|
|
>
|
|
<h2 onClick={() => toggleYearMonth(yearMonth)}>
|
|
{formatYearMonth(yearMonth)}
|
|
</h2>
|
|
{Object.keys(eventsByDate[yearMonth]).map((week) => (
|
|
<CalendarWeek key={week} days={eventsByDate[yearMonth][week]} />
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
};
|