diff --git a/package.json b/package.json index b108b85a..9579e4e2 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,10 @@ "@base-ui/react": "^1.5.0", "@fontsource-variable/raleway": "^5.2.8", "@icons-pack/react-simple-icons": "^13.13.0", + "@schedule-x/calendar": "^4.6.0", + "@schedule-x/react": "^4.1.0", + "@schedule-x/scroll-controller": "^4.6.0", + "@schedule-x/theme-shadcn": "^4.6.0", "@tanstack/react-query": "^5.100.14", "@tanstack/react-router": "^1.170.8", "axios": "^1.16.1", @@ -23,7 +27,8 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "sonner": "^2.0.7", - "tailwind-merge": "^3.6.0" + "tailwind-merge": "^3.6.0", + "temporal-polyfill": "^0.3.2" }, "devDependencies": { "@rolldown/plugin-babel": "^0.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebd6295d..19cf7c88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,18 @@ importers: '@icons-pack/react-simple-icons': specifier: ^13.13.0 version: 13.13.0(react@19.2.6) + '@schedule-x/calendar': + specifier: ^4.6.0 + version: 4.6.0(@preact/signals@2.9.1(preact@10.29.2))(preact@10.29.2)(temporal-polyfill@0.3.2) + '@schedule-x/react': + specifier: ^4.1.0 + version: 4.1.0(@schedule-x/calendar@4.6.0(@preact/signals@2.9.1(preact@10.29.2))(preact@10.29.2)(temporal-polyfill@0.3.2))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@schedule-x/scroll-controller': + specifier: ^4.6.0 + version: 4.6.0(@preact/signals@2.9.1(preact@10.29.2)) + '@schedule-x/theme-shadcn': + specifier: ^4.6.0 + version: 4.6.0 '@tanstack/react-query': specifier: ^5.100.14 version: 5.100.14(react@19.2.6) @@ -53,6 +65,9 @@ importers: tailwind-merge: specifier: ^3.6.0 version: 3.6.0 + temporal-polyfill: + specifier: ^0.3.2 + version: 0.3.2 devDependencies: '@rolldown/plugin-babel': specifier: ^0.2.3 @@ -1106,8 +1121,8 @@ packages: peerDependencies: preact: '>= 10.25.0 || >=11.0.0-0' - '@react-grab/cli@0.1.42': - resolution: {integrity: sha512-yfK2tmRxCbigM5NQ3IW/1abTOUxhAkmpLopWLtdRGPfUw5liVyKjy5yb7VJZa9nj/v9RDl3+VfqgRmFASYJCdg==} + '@react-grab/cli@0.1.43': + resolution: {integrity: sha512-S+UBB0Mwu1g0Cjhff5YzsA33YFYu91DmazPL9uVvF1wDjsO2Lu9hKN/OzBCHYjWlxdBT7TLy2A6pNTS3Sx8o9w==} hasBin: true '@rolldown/binding-android-arm64@1.0.2': @@ -1234,6 +1249,28 @@ packages: rollup: optional: true + '@schedule-x/calendar@4.6.0': + resolution: {integrity: sha512-8Rf5bUf0DVphJB2BaudtG4IHkw21wgMJiiuk5o+7SIOq6YH4hkVJo+Kk12pWSfp2KTZ9mnnUuLV+szBVhiEPlg==} + peerDependencies: + '@preact/signals': ^2.0.2 + preact: ^10.19.2 + temporal-polyfill: 0.3.0 + + '@schedule-x/react@4.1.0': + resolution: {integrity: sha512-l2hn1kdTModk4Lbt+nEr/VAcuNMA+w+hAAtgz8VLOj5UeieM7PWoK+cDapzPHCjUKisCSPd8QZfAX966Ma82kQ==} + peerDependencies: + '@schedule-x/calendar': ^3.1.0 || ^4.0.0 + react: ^16.7.0 || ^17 || ^18 || ^19 + react-dom: ^16.7.0 || ^17 || ^18 || ^19 + + '@schedule-x/scroll-controller@4.6.0': + resolution: {integrity: sha512-bJsNrwdX3KD6/vsovZgCNeGBboa++RgXg6ZJTKEAFOcNb/SeMYf4EhX66+pEhCe0JPfvzjoWEBpSj0fSCcoUcg==} + peerDependencies: + '@preact/signals': ^2.0.2 + + '@schedule-x/theme-shadcn@4.6.0': + resolution: {integrity: sha512-MwEBne7fPeN88Xa1mXxPQFkVIqs7Xi0zWMIHyppCgrPncfhDt1Gid4I7GqjsB3SnOyHWLbb5sL5Ab2ZaQesgtQ==} + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -2921,8 +2958,8 @@ packages: peerDependencies: react: ^19.2.6 - react-grab@0.1.42: - resolution: {integrity: sha512-U4a0UXnuB0oJm/1c2aSjFCuifVlkWB4lStrMEh1V4iWsvhZln69Sf4ISH5pJah3N27zjiCc+CGcla9wZp9Hc6A==} + react-grab@0.1.43: + resolution: {integrity: sha512-3wplt0CobZE5k1Vf3PBqBm3vlNQnVWEpSGBMHRnnFcj/khYYwGXSMSTedW1s8MI5FqkdhU/CFoJ7hQqGwzqFIA==} hasBin: true peerDependencies: react: '>=17.0.0' @@ -3164,6 +3201,12 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + temporal-polyfill@0.3.2: + resolution: {integrity: sha512-TzHthD/heRK947GNiSu3Y5gSPpeUDH34+LESnfsq8bqpFhsB79HFBX8+Z834IVX68P3EUyRPZK5bL/1fh437Eg==} + + temporal-spec@0.3.1: + resolution: {integrity: sha512-B4TUhezh9knfSIMwt7RVggApDRJZo73uZdj8AacL2mZ8RP5KtLianh2MXxL06GN9ESYiIsiuoLQhgVfwe55Yhw==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -4249,7 +4292,7 @@ snapshots: '@preact/signals-core': 1.14.2 preact: 10.29.2 - '@react-grab/cli@0.1.42': + '@react-grab/cli@0.1.43': dependencies: agent-install: 0.0.5 commander: 14.0.3 @@ -4328,6 +4371,24 @@ snapshots: estree-walker: 2.0.2 picomatch: 4.0.4 + '@schedule-x/calendar@4.6.0(@preact/signals@2.9.1(preact@10.29.2))(preact@10.29.2)(temporal-polyfill@0.3.2)': + dependencies: + '@preact/signals': 2.9.1(preact@10.29.2) + preact: 10.29.2 + temporal-polyfill: 0.3.2 + + '@schedule-x/react@4.1.0(@schedule-x/calendar@4.6.0(@preact/signals@2.9.1(preact@10.29.2))(preact@10.29.2)(temporal-polyfill@0.3.2))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@schedule-x/calendar': 4.6.0(@preact/signals@2.9.1(preact@10.29.2))(preact@10.29.2)(temporal-polyfill@0.3.2) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@schedule-x/scroll-controller@4.6.0(@preact/signals@2.9.1(preact@10.29.2))': + dependencies: + '@preact/signals': 2.9.1(preact@10.29.2) + + '@schedule-x/theme-shadcn@4.6.0': {} + '@sec-ant/readable-stream@0.4.1': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -6099,9 +6160,9 @@ snapshots: react: 19.2.6 scheduler: 0.27.0 - react-grab@0.1.42(react@19.2.6): + react-grab@0.1.43(react@19.2.6): dependencies: - '@react-grab/cli': 0.1.42 + '@react-grab/cli': 0.1.43 bippy: 0.5.41(react@19.2.6) optionalDependencies: react: 19.2.6 @@ -6120,7 +6181,7 @@ snapshots: react: 19.2.6 react-doctor: 0.2.14(eslint@10.4.1(jiti@2.7.0))(oxlint-tsgolint@0.23.0) react-dom: 19.2.6(react@19.2.6) - react-grab: 0.1.42(react@19.2.6) + react-grab: 0.1.43(react@19.2.6) optionalDependencies: unplugin: 3.0.0 transitivePeerDependencies: @@ -6396,6 +6457,12 @@ snapshots: tapable@2.3.3: {} + temporal-polyfill@0.3.2: + dependencies: + temporal-spec: 0.3.1 + + temporal-spec@0.3.1: {} + tiny-invariant@1.3.3: {} tinyexec@1.2.3: {} diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 00000000..62f0a568 --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,88 @@ +import * as React from "react" +import { Popover as PopoverPrimitive } from "@base-ui/react/popover" + +import { cn } from "@/lib/utils" + +function Popover({ ...props }: PopoverPrimitive.Root.Props) { + return +} + +function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) { + return +} + +function PopoverContent({ + className, + align = "center", + alignOffset = 0, + side = "bottom", + sideOffset = 4, + ...props +}: PopoverPrimitive.Popup.Props & + Pick< + PopoverPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + + + ) +} + +function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) { + return ( + + ) +} + +function PopoverDescription({ + className, + ...props +}: PopoverPrimitive.Description.Props) { + return ( + + ) +} + +export { + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} diff --git a/src/index.css b/src/index.css index 4d635c5c..816fd434 100644 --- a/src/index.css +++ b/src/index.css @@ -108,6 +108,8 @@ --brand-text-hero: #cde8f0; --brand-navbar: #0d3b4a; --brand-footer: rgba(8, 71, 80, 0.95); + + scrollbar-color: var(--muted-foreground) transparent; } /* ---break--- @@ -185,6 +187,22 @@ } } +/* Weird CSS stuff/fixes we have to modify thanks to Schedule-X */ +.sx__time-grid-event-inner .sx__time-grid-event-time, +.sx__time-grid-event-inner .sx__time-grid-event-location { + display: inline; +} +.sx__time-grid-event-inner .sx__time-grid-event-time .sx__event-icon, +.sx__time-grid-event-inner .sx__time-grid-event-location .sx__event-icon { + display: none; +} +.sx__time-grid-event-inner .sx__time-grid-event-location::before { + content: ", "; +} +.sx__date-picker-popup.sx__date-picker-popup { + overflow: auto; +} + /* Hero scroll cue: pulses opacity and offsets vertically (CSS animation, not motion-react, to keep the work on the GPU/compositor instead of the JS main thread). */ @keyframes scroll-cue { diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 4ec2cea2..ba88adae 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SigsRouteImport } from './routes/sigs' import { Route as ProjectsRouteImport } from './routes/projects' +import { Route as EventsRouteImport } from './routes/events' import { Route as AboutRouteImport } from './routes/about' import { Route as IndexRouteImport } from './routes/index' import { Route as ProjectProjectIdRouteImport } from './routes/project/$projectId' @@ -25,6 +26,11 @@ const ProjectsRoute = ProjectsRouteImport.update({ path: '/projects', getParentRoute: () => rootRouteImport, } as any) +const EventsRoute = EventsRouteImport.update({ + id: '/events', + path: '/events', + getParentRoute: () => rootRouteImport, +} as any) const AboutRoute = AboutRouteImport.update({ id: '/about', path: '/about', @@ -44,6 +50,7 @@ const ProjectProjectIdRoute = ProjectProjectIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/about': typeof AboutRoute + '/events': typeof EventsRoute '/projects': typeof ProjectsRoute '/sigs': typeof SigsRoute '/project/$projectId': typeof ProjectProjectIdRoute @@ -51,6 +58,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/about': typeof AboutRoute + '/events': typeof EventsRoute '/projects': typeof ProjectsRoute '/sigs': typeof SigsRoute '/project/$projectId': typeof ProjectProjectIdRoute @@ -59,19 +67,27 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/about': typeof AboutRoute + '/events': typeof EventsRoute '/projects': typeof ProjectsRoute '/sigs': typeof SigsRoute '/project/$projectId': typeof ProjectProjectIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/about' | '/projects' | '/sigs' | '/project/$projectId' + fullPaths: + | '/' + | '/about' + | '/events' + | '/projects' + | '/sigs' + | '/project/$projectId' fileRoutesByTo: FileRoutesByTo - to: '/' | '/about' | '/projects' | '/sigs' | '/project/$projectId' + to: '/' | '/about' | '/events' | '/projects' | '/sigs' | '/project/$projectId' id: | '__root__' | '/' | '/about' + | '/events' | '/projects' | '/sigs' | '/project/$projectId' @@ -80,6 +96,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute AboutRoute: typeof AboutRoute + EventsRoute: typeof EventsRoute ProjectsRoute: typeof ProjectsRoute SigsRoute: typeof SigsRoute ProjectProjectIdRoute: typeof ProjectProjectIdRoute @@ -101,6 +118,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectsRouteImport parentRoute: typeof rootRouteImport } + '/events': { + id: '/events' + path: '/events' + fullPath: '/events' + preLoaderRoute: typeof EventsRouteImport + parentRoute: typeof rootRouteImport + } '/about': { id: '/about' path: '/about' @@ -128,6 +152,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AboutRoute: AboutRoute, + EventsRoute: EventsRoute, ProjectsRoute: ProjectsRoute, SigsRoute: SigsRoute, ProjectProjectIdRoute: ProjectProjectIdRoute, diff --git a/src/routes/events.tsx b/src/routes/events.tsx new file mode 100644 index 00000000..c69e8ade --- /dev/null +++ b/src/routes/events.tsx @@ -0,0 +1,449 @@ +import "temporal-polyfill/global"; +import "@schedule-x/theme-shadcn/dist/index.css"; + +import { Popover as PopoverPrimitive } from "@base-ui/react/popover"; +import { + createViewDay, + createViewMonthAgenda, + createViewMonthGrid, + createViewWeek, + type CalendarEvent, + type CalendarType, +} from "@schedule-x/calendar"; +import { ScheduleXCalendar, useCalendarApp } from "@schedule-x/react"; +import { createScrollControllerPlugin } from "@schedule-x/scroll-controller"; +import { queryOptions, useMutation, useQuery } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import axios from "axios"; +import { MapPin } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import { toast } from "sonner"; + +import aboutUsThumb from "@/assets/images/about-us.png"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverTrigger } from "@/components/ui/popover"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useTheme } from "@/components/ui/theme-provider"; +import { cn } from "@/lib/utils"; + +export const Route = createFileRoute("/events")({ + component: Events, + loader: ({ context: { queryClient } }) => queryClient.prefetchQuery(eventsQueryOptions), +}); + +/// Types and Interfaces + +type ResolvedTheme = "dark" | "light"; + +type EventType = + | "general" + | "sig_ai" + | "sig_swe" + | "sig_cyber" + | "sig_data" + | "sig_arch" + | "sig_graph" + | "social" + | "misc"; + +interface ApiEvent { + id: string; + name: string; + description: string; + start_at: string; + end_at: string; + location: string; + type: EventType; + timezone: string; + creator_id: string; +} + +interface EventsPage { + data: ApiEvent[]; + total: number; +} + +interface SelectedEvent { + event: ApiEvent; + rect: DOMRect; +} + +interface EventsCalendarProps { + theme: ResolvedTheme; + events: ApiEvent[]; + onSelect: (selection: SelectedEvent) => void; +} + +/// Module-scoped constants + +const EVENT_TYPES = [ + { key: "general", label: "General", color: "#084778", darkColor: "#5b9bd5" }, + { key: "sig_swe", label: "SWE", color: "#3da9fc", darkColor: "#6cbcff" }, + { key: "sig_ai", label: "AI", color: "#00c9a7", darkColor: "#2fdcbb" }, + { key: "sig_cyber", label: "Cyber", color: "#ff6b6b", darkColor: "#ff9a9a" }, + { key: "sig_data", label: "Data", color: "#f7b731", darkColor: "#ffd56b" }, + { key: "sig_graph", label: "Graphics", color: "#a55eea", darkColor: "#c79bf2" }, + { key: "sig_arch", label: "Architecture", color: "#fc5c7d", darkColor: "#ff8fa6" }, + { key: "social", label: "Social", color: "#00e1bf", darkColor: "#3df0d6" }, + { key: "misc", label: "Misc", color: "#93a3b6", darkColor: "#b4c2d2" }, +] as const satisfies readonly { + readonly key: EventType; + readonly label: string; + readonly color: string; + readonly darkColor: string; +}[]; + +const EVENT_TYPE_BADGE_CLASSES: Record = { + general: "border-[#338acf]/30 bg-[#338acf]/15 text-[#338acf]", + sig_swe: "border-[#4cb0fc]/30 bg-[#4cb0fc]/15 text-[#4cb0fc]", + sig_ai: "border-[#00c9a7]/30 bg-[#00c9a7]/15 text-[#00c9a7]", + sig_cyber: "border-[#ff6b6b]/30 bg-[#ff6b6b]/15 text-[#ff6b6b]", + sig_data: "border-[#f7b731]/30 bg-[#f7b731]/15 text-[#f7b731]", + sig_graph: "border-[#a55eea]/30 bg-[#a55eea]/15 text-[#a55eea]", + sig_arch: "border-[#fc5c7d]/30 bg-[#fc5c7d]/15 text-[#fc5c7d]", + social: "border-[#00e1bf]/30 bg-[#00e1bf]/15 text-[#00e1bf]", + misc: "border-[#93a3b6]/30 bg-[#93a3b6]/15 text-[#93a3b6]", +}; + +const EVENT_CALENDARS: Record = Object.fromEntries( + EVENT_TYPES.map((meta) => [ + meta.key, + { + colorName: meta.key, + lightColors: { main: meta.color, container: `${meta.color}40`, onContainer: meta.color }, + darkColors: { + main: meta.darkColor, + container: `${meta.darkColor}40`, + onContainer: meta.darkColor, + }, + }, + ]), +); + +const API_BASE_URL = + (import.meta.env.VITE_API_URL as string | undefined) || "http://localhost:8000"; + +const EVENTS_PAGE_SIZE = 50; +const PACIFIC_TZ = "America/Los_Angeles"; + +const WEEKDAY_FMT = new Intl.DateTimeFormat("en-US", { + weekday: "short", + month: "short", + day: "numeric", + timeZone: PACIFIC_TZ, +}); +const TIME_FMT = new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", + timeZone: PACIFIC_TZ, +}); + +const CALENDAR_SECTION_CLASSES = cn( + "mx-auto flex w-full max-w-[1700px] flex-col", + "h-[calc(100svh-4rem)] px-4 py-4 md:h-[calc(100svh-5.125rem)] md:px-8 md:py-6", +); +const CALENDAR_SHELL_CLASSES = cn( + "min-h-0 w-full flex-1 rounded-3xl border border-border bg-card", + "shadow-[0px_16px_40px_rgba(112,144,176,0.2)]", + "[&_.sx-react-calendar-wrapper]:size-full", +); + +/// Tanstack Query keys + +const eventsKeys = { + all: ["events"] as const, + lists: () => [...eventsKeys.all, "list"] as const, + list: (params: Record) => [...eventsKeys.lists(), params] as const, +}; + +const eventsQueryOptions = queryOptions({ + queryKey: eventsKeys.list({}), + queryFn: async () => { + // Until we have date-range paginations on Kanae, we will literally fetch everything + const first = await axios.get(`${API_BASE_URL}/events`, { + params: { page: 1, size: EVENTS_PAGE_SIZE }, + }); + const { total } = first.data; + + const remaining = total - first.data.data.length; + const leftover = remaining % EVENTS_PAGE_SIZE; + const remainingPages = (remaining - leftover) / EVENTS_PAGE_SIZE + (leftover > 0 ? 1 : 0); + + const rest = await Promise.all( + Array.from({ length: remainingPages }, (_, index) => + axios.get(`${API_BASE_URL}/events`, { + params: { page: index + 2, size: EVENTS_PAGE_SIZE }, + }), + ), + ); + + return { + data: [...first.data.data, ...rest.flatMap((response) => response.data.data)], + total, + }; + }, + staleTime: 60_000, + + // Remove these when Kanae is live + retry: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, +}); + +/// Events Calendar component + +// This exists because `useCalendarApp` only builds the calendar once and doesn't re-render theme changes +// See: https://github.com/schedule-x/react/blob/main/src/use-calendar-app.tsx +function EventsCalendar({ theme, events, onSelect }: Readonly) { + const [initialScroll] = useState(() => `${String(new Date().getHours()).padStart(2, "0")}:00`); + const scrollController = useMemo( + () => createScrollControllerPlugin({ initialScroll }), + [initialScroll], + ); + + const sortedEvents = useMemo(() => new Map(events.map((event) => [event.id, event])), [events]); + + const calendarEvents = useMemo( + () => + events.map((event) => ({ + id: event.id, + title: event.name, + description: event.description, + location: event.location, + calendarId: event.type, + start: Temporal.Instant.from(event.start_at).toZonedDateTimeISO(event.timezone), + end: Temporal.Instant.from(event.end_at).toZonedDateTimeISO(event.timezone), + })), + [events], + ); + + const calendar = useCalendarApp({ + views: [createViewWeek(), createViewMonthGrid(), createViewDay(), createViewMonthAgenda()], + defaultView: "week", + weekOptions: { gridHeight: 1200 }, + dayBoundaries: { start: "08:00", end: "24:00" }, + events: calendarEvents, + calendars: EVENT_CALENDARS, + theme: "shadcn", + isDark: theme === "dark", + timezone: PACIFIC_TZ, + selectedDate: Temporal.Now.plainDateISO(PACIFIC_TZ), + plugins: [scrollController], + callbacks: { + onEventClick: (calendarEvent, clickEvent) => { + const found = sortedEvents.get(String(calendarEvent.id)); + const target = clickEvent.currentTarget; + if (found && target instanceof Element) { + onSelect({ event: found, rect: target.getBoundingClientRect() }); + } + }, + }, + }); + + calendar?.setTheme(theme); + + return ( +
+ +
+ ); +} + +/// Route Component + +function Events() { + const { data: eventsPage, isError } = useQuery(eventsQueryOptions); + const events = useMemo(() => eventsPage?.data ?? [], [eventsPage]); + + const { mutate: joinEvent, isPending: isJoining } = useMutation({ + mutationFn: (eventId: string) => { + // Until Kanae is up, this goes nowhere for now + return Promise.resolve(eventId); + }, + onSuccess: () => { + toast.success("You're on the list! We'll see you there."); + setSelected(undefined); + }, + }); + + const [selected, setSelected] = useState(); + const selectedEvent = selected?.event; + + // Not my favorite way to do this, + // but a system theme could result in the calendar could result in it being set to light theme + const { theme } = useTheme(); + const [prefersDark] = useState( + () => globalThis.matchMedia("(prefers-color-scheme: dark)").matches, + ); + const systemTheme: ResolvedTheme = prefersDark ? "dark" : "light"; + const resolvedTheme: ResolvedTheme = theme === "system" ? systemTheme : theme; + + const handleJoinEvent = useCallback(() => { + if (selectedEvent) { + joinEvent(selectedEvent.id); + } + }, [selectedEvent, joinEvent]); + + const handlePopoverOpenChange = useCallback((open: boolean) => { + if (!open) { + setSelected(undefined); + } + }, []); + + const selectedEventType = useMemo( + () => EVENT_TYPES.find((meta) => meta.key === selectedEvent?.type) ?? EVENT_TYPES[0], + [selectedEvent], + ); + + const anchor = useMemo( + () => (selected ? { getBoundingClientRect: () => selected.rect } : undefined), + [selected], + ); + + return ( +
+
+
+
+
+

+ Events and Activities +

+

+ Keep up to date with our events! +

+
+
+ +
+ {eventsPage ? ( + + ) : undefined} + + {!eventsPage && isError ? ( +
+ Failed to load events +
+ ) : undefined} + + {!eventsPage && !isError ? ( + + ) : undefined} +
+ + + + + + + {selectedEvent ? ( + <> + + +
+ + {selectedEventType.label} + + +

+ {selectedEvent.name} +

+ +
+ + {WEEKDAY_FMT.format(new Date(selectedEvent.start_at))} + + + {TIME_FMT.format(new Date(selectedEvent.start_at))} -{" "} + {TIME_FMT.format(new Date(selectedEvent.end_at))} + + + + {selectedEvent.location} + +
+ +

+ {selectedEvent.description} +

+ + +
+ + ) : undefined} +
+
+
+
+ +
+
+
+
+ Never miss an event +
+
+ Subscribe to our newsletter for weekly updates +
+
+ +
+
+
+ ); +} diff --git a/vite.config.ts b/vite.config.ts index 10821127..7e51f7db 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -29,13 +29,19 @@ export default defineConfig({ output: { codeSplitting: { groups: [ - { name: "vendor-react", test: /[\\/]react(-dom)?[\\/]/ }, - { name: "vendor-base-ui", test: /[\\/]@base-ui[\\/]/ }, + // Schedule-X's renderer uses @preact/signals, so we send it into its own vendor chunk + { name: "vendor-preact", test: /[\\/](preact|@preact)[\\/]/ }, + { name: "vendor-react", test: /[\\/]node_modules[\\/]react(-dom)?[\\/]/ }, + { name: "vendor-base-ui", test: /[\\/](@base-ui|@floating-ui)[\\/]/ }, { name: "vendor-tanstack", test: /[\\/]@tanstack[\\/]/ }, { name: "vendor-icons", test: /[\\/](@icons-pack[\\/]react-simple-icons|lucide-react)[\\/]/, }, + { + name: "vendor-schedule-x", + test: /[\\/](@schedule-x|temporal-polyfill)[\\/]/, + }, { name: "vendor-misc", test: /node_modules[\\/]/ }, ], },