From 76dd802d7f5b05534bca6d022331474b3f7cac2d Mon Sep 17 00:00:00 2001 From: No767 <73260931+No767@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:27:15 -0700 Subject: [PATCH 1/2] finally done --- src/components/app/attendance-dialog.tsx | 191 +++++++++++++ src/components/app/dashboard-events.tsx | 105 +++++++ src/index.css | 1 + src/routeTree.gen.ts | 21 ++ src/routes/dashboard/events.tsx | 265 +----------------- src/routes/dashboard/index.tsx | 79 +++++- src/routes/dashboard/manage/events.tsx | 153 +--------- src/routes/event/$eventId.tsx | 341 +++++++++++++++++++++++ src/routes/events.tsx | 19 +- src/routes/index.tsx | 201 ++++++++----- 10 files changed, 873 insertions(+), 503 deletions(-) create mode 100644 src/components/app/attendance-dialog.tsx create mode 100644 src/routes/event/$eventId.tsx diff --git a/src/components/app/attendance-dialog.tsx b/src/components/app/attendance-dialog.tsx new file mode 100644 index 00000000..b19a26ee --- /dev/null +++ b/src/components/app/attendance-dialog.tsx @@ -0,0 +1,191 @@ +import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import axios from "axios"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; + +import { CheckInPanel } from "@/components/app/check-in-panel"; +import { RosterRow } from "@/components/app/dashboard-events"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + type AttendanceMember, + type FullEvent, + type KanaePage, + determineCheckIn, + fmtClock, +} from "@/lib/dashboard-events"; + +/// Types & interfaces + +interface AttendanceDialogProps { + event: FullEvent; + now: Date; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +/// Constants + +const API_BASE_URL = + (import.meta.env.VITE_API_URL as string | undefined) ?? "http://localhost:8000"; +const ROSTER_STAT_CLASS = + "flex-1 rounded-xl border border-border bg-card px-3.5 py-3 shadow-[0px_2px_5px_rgba(112,144,176,0.12)] dark:shadow-[0px_2px_5px_rgba(0,0,0,0.3)]"; + +/// Tanstack Query options + +const eventAttendanceCodeQueryOptions = (eventId: string) => + queryOptions({ + queryKey: ["events", eventId, "attendance-code"], + queryFn: async () => { + const { data } = await axios.get<{ code: string }>( + `${API_BASE_URL}/events/${eventId}/attendance-code`, + ); + return data; + }, + }); + +const eventAttendanceQueryOptions = (eventId: string) => + queryOptions({ + queryKey: ["events", eventId, "attendance"], + queryFn: async () => { + const { data } = await axios.get>( + `${API_BASE_URL}/events/${eventId}/attendance`, + { params: { page: 1, size: 100 } }, + ); + return data; + }, + }); + +/// AttendanceDialog + +export function AttendanceDialog({ + event, + now, + open, + onOpenChange, +}: Readonly) { + const queryClient = useQueryClient(); + + const [rosterTab, setRosterTab] = useState<"qr" | "roster">("qr"); + const [copied, setCopied] = useState(false); + + const codeQuery = useQuery({ + ...eventAttendanceCodeQueryOptions(event.id), + enabled: rosterTab === "qr", + }); + const rosterQuery = useQuery({ + ...eventAttendanceQueryOptions(event.id), + enabled: rosterTab === "roster", + }); + + const { mutate: undoCheckin, isPending: isUndoing } = useMutation({ + mutationFn: async (memberId: string) => { + await axios.delete(`${API_BASE_URL}/events/${event.id}/attendance/${memberId}`); + }, + onError: () => toast.error("Couldn't undo the check-in. Please try again."), + onSuccess: () => toast.success("Check-in undone."), + onSettled: () => + queryClient.invalidateQueries({ queryKey: eventAttendanceQueryOptions(event.id).queryKey }), + }); + + const code = (codeQuery.data?.code ?? "").slice(0, 8); + const copyCode = useCallback(() => { + navigator.clipboard + .writeText(code) + .then(() => { + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 1800); + }) + .catch(() => { + toast.error("Couldn't copy the code."); + }); + }, [code]); + + const rosterMembers = rosterQuery.data?.data ?? []; + const plannedCount = rosterMembers.filter((member) => member.planned).length; + const attendedCount = rosterMembers.filter((member) => member.attended).length; + const checkIn = determineCheckIn(event, now); + + return ( + + + + Attendance + {event.name} + + + + + + Check-in QR + + + Roster · {String(rosterMembers.length)} + + + + + + + + +
+
+
+ {String(plannedCount)} +
+
+ Planned (RSVP'd) +
+
+
+
+ {String(attendedCount)} +
+
Attended
+
+
+
+ {rosterQuery.isPending && ( +

+ Loading roster… +

+ )} + {!rosterQuery.isPending && rosterMembers.length === 0 && ( +

+ No attendees yet. +

+ )} + {rosterMembers.map((member) => ( + + ))} +
+

+ Undo clears a member's attended flag but keeps their RSVP — for correcting a mistaken + check-in. +

+
+
+
+
+ ); +} diff --git a/src/components/app/dashboard-events.tsx b/src/components/app/dashboard-events.tsx index 679b6acb..d7f638e1 100644 --- a/src/components/app/dashboard-events.tsx +++ b/src/components/app/dashboard-events.tsx @@ -7,11 +7,15 @@ import { LayoutGrid, List, MapPin, + MinusCircle, + MoreHorizontal, QrCode, + RotateCcw, ScanLine, Search, Ticket, User, + UserPlus, X, } from "lucide-react"; import { @@ -32,6 +36,12 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Select, @@ -43,6 +53,7 @@ import { import { Separator } from "@/components/ui/separator"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { + type AttendanceMember, type DashboardEvent, type EventType, type EventView, @@ -60,6 +71,8 @@ import { cn } from "@/lib/utils"; /// Types & interfaces +type RosterStatus = "checked_in" | "expected" | "no_show" | "walk_in"; + export interface EventCallbacks { onOpen?: (event: DashboardEvent) => void; onRsvp?: (event: DashboardEvent, planned: boolean) => void; @@ -100,6 +113,29 @@ const LONG_DATE_OPTIONS: Intl.DateTimeFormatOptions = { day: "numeric", }; +const ROSTER_STATUS = { + checked_in: { + label: "Checked in", + icon: Check, + className: "bg-[#15a66e]/15 text-[#15a66e] dark:text-[#3fd68c]", + }, + walk_in: { + label: "Walk-in", + icon: UserPlus, + className: "bg-[#f7b731]/18 text-[#a06d00] dark:text-[#ffd56b]", + }, + expected: { + label: "Expected", + icon: Clock, + className: "bg-brand-sky/15 text-brand-sky-text", + }, + no_show: { + label: "No-show", + icon: MinusCircle, + className: "bg-muted text-muted-foreground", + }, +} satisfies Record; + /// Helpers function eventStateMeta(event: DashboardEvent, now: Date): StateMeta { @@ -108,6 +144,11 @@ function eventStateMeta(event: DashboardEvent, now: Date): StateMeta { return event.planned ? STATE_GOING : STATE_OPEN; } +function getRosterStatus(member: AttendanceMember): RosterStatus { + if (member.attended) return member.planned ? "checked_in" : "walk_in"; + return member.planned ? "expected" : "no_show"; +} + /// EventTypeChip interface EventTypeChipProps { @@ -859,3 +900,67 @@ export function EventDetailDialog({ ); } + +/// RosterRow + +export function RosterRow({ + disabled, + member, + onUndo, +}: Readonly<{ + disabled: boolean; + member: AttendanceMember; + onUndo: (memberId: string) => void; +}>) { + const handleUndo = useCallback(() => { + onUndo(member.id); + }, [member.id, onUndo]); + + const status = ROSTER_STATUS[getRosterStatus(member)]; + const initials = + member.name + .match(/\S+/g) + ?.slice(0, 2) + .map((part) => part[0]) + .join("") + .toUpperCase() ?? "··"; + + const StatusIcon = status.icon; + + return ( +
+
+ {initials} +
+
+
{member.name}
+
+ + + {status.label} + + {member.attended && ( + + } + title="Member actions" + aria-label="Member actions" + > + + + + + + Undo check-in + + + + )} +
+ ); +} diff --git a/src/index.css b/src/index.css index df69957e..29e53ab2 100644 --- a/src/index.css +++ b/src/index.css @@ -187,6 +187,7 @@ } html { @apply scroll-smooth font-sans; + scrollbar-gutter: stable; } } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 31294db7..3fd7038c 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -23,6 +23,7 @@ import { Route as DashboardIndexRouteImport } from './routes/dashboard/index' import { Route as DashboardEventsRouteImport } from './routes/dashboard/events' import { Route as DashboardProjectsRouteImport } from './routes/dashboard/projects' import { Route as DashboardSettingsRouteImport } from './routes/dashboard/settings' +import { Route as EventEventIdRouteImport } from './routes/event/$eventId' import { Route as ProjectProjectIdRouteImport } from './routes/project/$projectId' import { Route as DashboardAdminMembersRouteImport } from './routes/dashboard/admin/members' import { Route as DashboardAdminOverviewRouteImport } from './routes/dashboard/admin/overview' @@ -102,6 +103,11 @@ const DashboardSettingsRoute = DashboardSettingsRouteImport.update({ path: '/settings', getParentRoute: () => DashboardRouteRoute, } as any) +const EventEventIdRoute = EventEventIdRouteImport.update({ + id: '/event/$eventId', + path: '/event/$eventId', + getParentRoute: () => rootRouteImport, +} as any) const ProjectProjectIdRoute = ProjectProjectIdRouteImport.update({ id: '/project/$projectId', path: '/project/$projectId', @@ -158,6 +164,7 @@ export interface FileRoutesByFullPath { '/dashboard/events': typeof DashboardEventsRoute '/dashboard/projects': typeof DashboardProjectsRoute '/dashboard/settings': typeof DashboardSettingsRoute + '/event/$eventId': typeof EventEventIdRoute '/project/$projectId': typeof ProjectProjectIdRoute '/dashboard/': typeof DashboardIndexRoute '/dashboard/admin/members': typeof DashboardAdminMembersRoute @@ -181,6 +188,7 @@ export interface FileRoutesByTo { '/dashboard/events': typeof DashboardEventsRoute '/dashboard/projects': typeof DashboardProjectsRoute '/dashboard/settings': typeof DashboardSettingsRoute + '/event/$eventId': typeof EventEventIdRoute '/project/$projectId': typeof ProjectProjectIdRoute '/dashboard': typeof DashboardIndexRoute '/dashboard/admin/members': typeof DashboardAdminMembersRoute @@ -206,6 +214,7 @@ export interface FileRoutesById { '/dashboard/events': typeof DashboardEventsRoute '/dashboard/projects': typeof DashboardProjectsRoute '/dashboard/settings': typeof DashboardSettingsRoute + '/event/$eventId': typeof EventEventIdRoute '/project/$projectId': typeof ProjectProjectIdRoute '/dashboard/': typeof DashboardIndexRoute '/dashboard/admin/members': typeof DashboardAdminMembersRoute @@ -232,6 +241,7 @@ export interface FileRouteTypes { | '/dashboard/events' | '/dashboard/projects' | '/dashboard/settings' + | '/event/$eventId' | '/project/$projectId' | '/dashboard/' | '/dashboard/admin/members' @@ -255,6 +265,7 @@ export interface FileRouteTypes { | '/dashboard/events' | '/dashboard/projects' | '/dashboard/settings' + | '/event/$eventId' | '/project/$projectId' | '/dashboard' | '/dashboard/admin/members' @@ -279,6 +290,7 @@ export interface FileRouteTypes { | '/dashboard/events' | '/dashboard/projects' | '/dashboard/settings' + | '/event/$eventId' | '/project/$projectId' | '/dashboard/' | '/dashboard/admin/members' @@ -301,6 +313,7 @@ export interface RootRouteChildren { RecoveryRoute: typeof RecoveryRoute RegisterRoute: typeof RegisterRoute SigsRoute: typeof SigsRoute + EventEventIdRoute: typeof EventEventIdRoute ProjectProjectIdRoute: typeof ProjectProjectIdRoute } @@ -404,6 +417,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardSettingsRouteImport parentRoute: typeof DashboardRouteRoute } + '/event/$eventId': { + id: '/event/$eventId' + path: '/event/$eventId' + fullPath: '/event/$eventId' + preLoaderRoute: typeof EventEventIdRouteImport + parentRoute: typeof rootRouteImport + } '/project/$projectId': { id: '/project/$projectId' path: '/project/$projectId' @@ -507,6 +527,7 @@ const rootRouteChildren: RootRouteChildren = { RecoveryRoute: RecoveryRoute, RegisterRoute: RegisterRoute, SigsRoute: SigsRoute, + EventEventIdRoute: EventEventIdRoute, ProjectProjectIdRoute: ProjectProjectIdRoute, } export const routeTree = rootRouteImport diff --git a/src/routes/dashboard/events.tsx b/src/routes/dashboard/events.tsx index ac5b3c61..39de4d41 100644 --- a/src/routes/dashboard/events.tsx +++ b/src/routes/dashboard/events.tsx @@ -9,43 +9,17 @@ import type { CalendarType } from "@schedule-x/calendar"; import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import axios from "axios"; -import { - type LucideIcon, - Check, - Clock, - MapPin, - MinusCircle, - MoreHorizontal, - RotateCcw, - ScanLine, - UserPlus, -} from "lucide-react"; +import { Clock, MapPin, ScanLine } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; +import { AttendanceDialog } from "@/components/app/attendance-dialog"; import { CheckInDialog } from "@/components/app/check-in-dialog"; -import { CheckInPanel } from "@/components/app/check-in-panel"; import { EventDetailDialog, EventResults, EventToolbar } from "@/components/app/dashboard-events"; import { ManageEventsCalendar } from "@/components/app/events-calendar"; import { Button } from "@/components/ui/button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { - type AttendanceMember, type DashboardEvent, - type KanaePage, type EventType, type EventView, type FullEvent, @@ -71,10 +45,6 @@ export const Route = createFileRoute("/dashboard/events")({ }, }); -/// Types and Interfaces - -type RosterStatus = "checked_in" | "expected" | "no_show" | "walk_in"; - // Constants const EVENT_CALENDARS: Record = Object.fromEntries( @@ -99,29 +69,6 @@ const EVENT_CALENDARS: Record = Object.fromEntries( }), ); -const ROSTER_STATUS = { - checked_in: { - label: "Checked in", - icon: Check, - className: "bg-[#15a66e]/15 text-[#15a66e] dark:text-[#3fd68c]", - }, - walk_in: { - label: "Walk-in", - icon: UserPlus, - className: "bg-[#f7b731]/18 text-[#a06d00] dark:text-[#ffd56b]", - }, - expected: { - label: "Expected", - icon: Clock, - className: "bg-brand-sky/15 text-brand-sky-text", - }, - no_show: { - label: "No-show", - icon: MinusCircle, - className: "bg-muted text-muted-foreground", - }, -} satisfies Record; - const API_BASE_URL = (import.meta.env.VITE_API_URL as string | undefined) ?? "http://localhost:8000"; const EVENTS_VIEWS: EventView[] = ["calendar", "grid", "list"]; @@ -139,98 +86,8 @@ const memberQueryOptions = (memberId: string) => }, }); -const eventAttendanceCodeQueryOptions = (eventId: string) => - queryOptions({ - queryKey: ["events", eventId, "attendance-code"], - queryFn: async () => { - const { data } = await axios.get<{ code: string }>( - `${API_BASE_URL}/events/${eventId}/attendance-code`, - ); - return data; - }, - }); - -const eventAttendanceQueryOptions = (eventId: string) => - queryOptions({ - queryKey: ["events", eventId, "attendance"], - queryFn: async () => { - const { data } = await axios.get>( - `${API_BASE_URL}/events/${eventId}/attendance`, - { params: { page: 1, size: 100 } }, - ); - return data; - }, - }); - /// Route components -function getRosterStatus(member: AttendanceMember): RosterStatus { - if (member.attended) return member.planned ? "checked_in" : "walk_in"; - return member.planned ? "expected" : "no_show"; -} - -export function RosterRow({ - disabled, - member, - onUndo, -}: Readonly<{ - disabled: boolean; - member: AttendanceMember; - onUndo: (memberId: string) => void; -}>) { - const handleUndo = useCallback(() => { - onUndo(member.id); - }, [member.id, onUndo]); - - const status = ROSTER_STATUS[getRosterStatus(member)]; - const initials = - member.name - .match(/\S+/g) - ?.slice(0, 2) - .map((part) => part[0]) - .join("") - .toUpperCase() ?? "··"; - - const StatusIcon = status.icon; - - return ( -
-
- {initials} -
-
-
{member.name}
-
- - - {status.label} - - {member.attended && ( - - } - title="Member actions" - aria-label="Member actions" - > - - - - - - Undo check-in - - - - )} -
- ); -} - function DashboardEvents() { const queryClient = useQueryClient(); @@ -241,8 +98,6 @@ function DashboardEvents() { const [detail, setDetail] = useState(); const [checkin, setCheckin] = useState(); const [roster, setRoster] = useState(); - const [rosterTab, setRosterTab] = useState<"qr" | "roster">("qr"); - const [copied, setCopied] = useState(false); const { data: events } = useQuery(eventsListQueryOptions); const { data: me } = useQuery(meQueryOptions); @@ -252,14 +107,6 @@ function DashboardEvents() { ...memberQueryOptions(detail?.creator_id ?? ""), enabled: !!detail?.creator_id, }); - const codeQuery = useQuery({ - ...eventAttendanceCodeQueryOptions(roster?.id ?? ""), - enabled: !!roster && rosterTab === "qr", - }); - const rosterQuery = useQuery({ - ...eventAttendanceQueryOptions(roster?.id ?? ""), - enabled: !!roster && rosterTab === "roster", - }); const { mutate: joinMutate } = useMutation({ mutationFn: async (eventId: string) => { @@ -271,17 +118,6 @@ function DashboardEvents() { }, onError: () => toast.error("Couldn't RSVP. Please try again."), }); - const { mutate: removeMutate, isPending: isRemoving } = useMutation({ - mutationFn: async (memberId: string) => { - await axios.delete(`${API_BASE_URL}/events/${roster?.id ?? ""}/attendance/${memberId}`); - }, - onSuccess: () => { - toast.success("Check-in undone."); - return queryClient.invalidateQueries({ - queryKey: eventAttendanceQueryOptions(roster?.id ?? "").queryKey, - }); - }, - }); const handleRsvp = useCallback( (event: DashboardEvent, planned: boolean) => { @@ -307,8 +143,6 @@ function DashboardEvents() { }, []); const openRosterFromDetail = useCallback((event: DashboardEvent) => { setDetail(undefined); - setRosterTab("qr"); - setCopied(false); setRoster(event); }, []); const closeDetail = useCallback((open: boolean) => { @@ -321,21 +155,6 @@ function DashboardEvents() { if (!open) setRoster(undefined); }, []); - const code = (codeQuery.data?.code ?? "").slice(0, 8); - const copyCode = useCallback(() => { - navigator.clipboard - .writeText(code) - .then(() => { - setCopied(true); - setTimeout(() => { - setCopied(false); - }, 1800); - }) - .catch(() => { - toast.error("Couldn't copy the code."); - }); - }, [code]); - const dashboardEvents = useMemo( () => (events ?? []).map((event) => @@ -382,11 +201,6 @@ function DashboardEvents() { !!me && (me.roles.includes("admin") || me.roles.includes("leads") || detail.creator_id === me.id); - const rosterMembers = rosterQuery.data?.data ?? []; - const plannedCount = rosterMembers.filter((member) => member.planned).length; - const attendedCount = rosterMembers.filter((member) => member.attended).length; - const checkIn = roster ? determineCheckIn(roster, now) : "ended"; - return (
{nextCheckIn && ( @@ -508,80 +322,7 @@ function DashboardEvents() { /> )} - {roster && ( - - - - Attendance - {roster.name} - - - - - - Check-in QR - - - Roster · {String(rosterMembers.length)} - - - - - - - - -
-
-
- {String(plannedCount)} -
-
- Planned (RSVP'd) -
-
-
-
- {String(attendedCount)} -
-
Attended
-
-
-
- {rosterQuery.isPending && ( -

- Loading roster… -

- )} - {!rosterQuery.isPending && rosterMembers.length === 0 && ( -

- No attendees yet. -

- )} - {rosterMembers.map((member) => ( - - ))} -
-

- Undo clears a member's attended flag but keeps their RSVP — for correcting a - mistaken check-in. -

-
-
-
-
- )} + {roster && }
); } diff --git a/src/routes/dashboard/index.tsx b/src/routes/dashboard/index.tsx index 8888b435..26370d49 100644 --- a/src/routes/dashboard/index.tsx +++ b/src/routes/dashboard/index.tsx @@ -1,7 +1,7 @@ import { type ClientMember, ROLE_META, ROLES_BY_RANK } from "./route"; import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { createFileRoute, Link } from "@tanstack/react-router"; import axios from "axios"; import { ArrowRight, @@ -17,8 +17,9 @@ import { import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; +import { AttendanceDialog } from "@/components/app/attendance-dialog"; import { CheckInDialog } from "@/components/app/check-in-dialog"; -import { EventCard, StatTile } from "@/components/app/dashboard-events"; +import { EventCard, EventDetailDialog, StatTile } from "@/components/app/dashboard-events"; import { Button } from "@/components/ui/button"; import { Carousel, @@ -118,6 +119,17 @@ export const meQueryOptions = queryOptions({ export const plannedEventsQueryOptions = memberEventsQueryOptions("planned"); export const attendedEventsQueryOptions = memberEventsQueryOptions("attended"); +const memberQueryOptions = (memberId: string) => + queryOptions({ + queryKey: ["members", memberId], + queryFn: async () => { + const { data } = await axios.get<{ id: string; name: string }>( + `${API_BASE_URL}/members/${memberId}`, + ); + return data; + }, + }); + /// Helper functions function heroEyebrowText(live: boolean, planned: boolean) { @@ -129,16 +141,21 @@ function heroEyebrowText(live: boolean, planned: boolean) { /// Route function DashboardHome() { - const navigate = useNavigate(); const queryClient = useQueryClient(); + const [now] = useState(() => new Date()); + const [detail, setDetail] = useState(); + const [checkinEvent, setCheckinEvent] = useState(); + const [roster, setRoster] = useState(); + const { data: events, isPending } = useQuery(eventsListQueryOptions); const { data: me } = useQuery(meQueryOptions); const { data: plannedEvents } = useQuery(plannedEventsQueryOptions); const { data: attendedEvents } = useQuery(attendedEventsQueryOptions); - - const [now] = useState(() => new Date()); - const [checkinEvent, setCheckinEvent] = useState(); + const creatorQuery = useQuery({ + ...memberQueryOptions(detail?.creator_id ?? ""), + enabled: !!detail?.creator_id, + }); const { mutate: joinMutate } = useMutation({ mutationFn: async (eventId: string) => { @@ -164,17 +181,29 @@ function DashboardHome() { }, [joinMutate, queryClient], ); - const goToEvents = useCallback(() => { - navigate({ to: "/dashboard/events" }).catch(() => {}); - }, [navigate]); - const closeCheckin = useCallback((open: boolean) => { - if (!open) setCheckinEvent(undefined); - }, []); const invalidateAttended = useCallback( () => queryClient.invalidateQueries({ queryKey: attendedEventsQueryOptions.queryKey }), [queryClient], ); + const openCheckinFromDetail = useCallback((event: DashboardEvent) => { + setDetail(undefined); + setCheckinEvent(event); + }, []); + const openRosterFromDetail = useCallback((event: DashboardEvent) => { + setDetail(undefined); + setRoster(event); + }, []); + const closeDetail = useCallback((open: boolean) => { + if (!open) setDetail(undefined); + }, []); + const closeCheckin = useCallback((open: boolean) => { + if (!open) setCheckinEvent(undefined); + }, []); + const closeRoster = useCallback((open: boolean) => { + if (!open) setRoster(undefined); + }, []); + const { hero, heroDate, heroLive, heroEyebrow, myUpcoming, myRsvpsSub, rail, attendedCount } = useMemo(() => { // Apparently this events ?? [] results in not a function? @@ -219,6 +248,12 @@ function DashboardHome() { const role = ROLES_BY_RANK.find((item) => me?.roles.includes(item)); + const organizerName = detail?.creator_id ? creatorQuery.data?.name : undefined; + const canManageDetail = + !!detail && + !!me && + (me.roles.includes("admin") || me.roles.includes("leads") || detail.creator_id === me.id); + return isPending ? (
@@ -372,7 +407,7 @@ function DashboardHome() { @@ -429,7 +464,7 @@ function DashboardHome() { @@ -454,6 +489,20 @@ function DashboardHome() {
+ {detail && ( + + )} + {checkinEvent && ( )} + + {roster && } ); } diff --git a/src/routes/dashboard/manage/events.tsx b/src/routes/dashboard/manage/events.tsx index b0493e73..6356c8f1 100644 --- a/src/routes/dashboard/manage/events.tsx +++ b/src/routes/dashboard/manage/events.tsx @@ -34,7 +34,7 @@ import { toast } from "sonner"; import { Temporal } from "temporal-polyfill"; import { z } from "zod"; -import { CheckInPanel } from "@/components/app/check-in-panel"; +import { AttendanceDialog } from "@/components/app/attendance-dialog"; import { EmptyState } from "@/components/app/dashboard-events"; import { DataPagination } from "@/components/app/data-pagination"; import { ThumbnailDropzone } from "@/components/app/thumbnail-dropzone"; @@ -67,7 +67,6 @@ import { SelectValue, } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { type EventType, @@ -77,12 +76,10 @@ import { EVENT_TYPE_CLASSES, EVENT_TYPE_META, EVENT_TYPES, - determineCheckIn, fmtClock, fmtDay, } from "@/lib/dashboard-events"; import { cn } from "@/lib/utils"; -import { RosterRow } from "@/routes/dashboard/events"; import { meQueryOptions } from "@/routes/dashboard/index"; export const Route = createFileRoute("/dashboard/manage/events")({ @@ -102,7 +99,6 @@ export const Route = createFileRoute("/dashboard/manage/events")({ type ThumbnailUpload = { url: string } | { hash: string; url: string }; type RowHandler = (event: MouseEvent) => void; -type RosterTab = "qr" | "roster"; interface AttendanceSummary { planned: number; @@ -135,11 +131,6 @@ interface SaveVars { removeThumbnail?: boolean; } -interface UndoVars { - eventId: string; - memberId: string; -} - declare module "@tanstack/react-table" { interface TableMeta { events?: ManageEventsMeta; @@ -310,8 +301,6 @@ const TAG_PILL_CLASS = const TIME_INPUT_CLASS = "border-border bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"; const FIELD_ERROR_CLASS = "text-[12px] font-semibold text-[#e13737] dark:text-[#ff6b6b]"; -const ROSTER_STAT_CLASS = - "flex-1 rounded-xl border border-border bg-card px-3.5 py-3 shadow-[0px_2px_5px_rgba(112,144,176,0.12)] dark:shadow-[0px_2px_5px_rgba(0,0,0,0.3)]"; const FACT_TILE_CLASS = "rounded-xl border border-border bg-muted/60 px-3.5 py-3"; const FACT_LABEL_CLASS = "mb-1 text-[10.5px] font-bold tracking-[0.06em] text-muted-foreground uppercase"; @@ -378,17 +367,6 @@ const eventAttendanceQueryOptions = (eventId: string) => }, }); -const eventAttendanceCodeQueryOptions = (eventId: string) => - queryOptions({ - queryKey: ["events", eventId, "attendance-code"], - queryFn: async () => { - const { data } = await axios.get<{ code: string }>( - `${API_BASE_URL}/events/${eventId}/attendance-code`, - ); - return data; - }, - }); - /// Helper functions const summaryCache = new WeakMap(); @@ -439,22 +417,11 @@ function ManageEventsPage() { const [detailId, setDetailId] = useState(); const [confirmId, setConfirmId] = useState(); const [rosterId, setRosterId] = useState(); - const [rosterTab, setRosterTab] = useState("qr"); - const [copied, setCopied] = useState(false); const { data: events, isPending } = useQuery(manageEventsQueryOptions); const { data: me } = useQuery(meQueryOptions); const meId = me?.id; - const codeQuery = useQuery({ - ...eventAttendanceCodeQueryOptions(rosterId ?? ""), - enabled: !!rosterId && rosterTab === "qr", - }); - const rosterQuery = useQuery({ - ...eventAttendanceQueryOptions(rosterId ?? ""), - enabled: !!rosterId && rosterTab === "roster", - }); - const invalidateManage = useCallback( () => queryClient.invalidateQueries({ queryKey: MANAGE_EVENTS_KEY }), [queryClient], @@ -518,16 +485,6 @@ function ManageEventsPage() { onSettled: invalidateManage, }); - const { mutate: undoCheckin, isPending: isUndoing } = useMutation({ - mutationFn: async ({ eventId, memberId }: UndoVars) => { - await axios.delete(`${API_BASE_URL}/events/${eventId}/attendance/${memberId}`); - }, - onError: () => toast.error("Couldn't undo the check-in. Please try again."), - onSuccess: () => toast.success("Check-in undone."), - onSettled: (_data, _error, { eventId }) => - queryClient.invalidateQueries({ queryKey: eventAttendanceQueryOptions(eventId).queryKey }), - }); - const form = useForm({ defaultValues: BLANK_EVENT_FORM, validators: { onChange: eventFormSchema }, @@ -650,22 +607,11 @@ function ManageEventsPage() { /// Roster actions const openRoster = useCallback((event: MouseEvent) => { - setRosterTab("qr"); - setCopied(false); setRosterId(event.currentTarget.dataset.id); }, []); const closeRoster = useCallback((open: boolean) => { if (!open) setRosterId(undefined); }, []); - const changeRosterTab = useCallback((value: string) => { - setRosterTab(value as RosterTab); - }, []); - const handleRosterUndo = useCallback( - (memberId: string) => { - if (rosterId) undoCheckin({ eventId: rosterId, memberId }); - }, - [rosterId, undoCheckin], - ); /// Search actions @@ -723,21 +669,6 @@ function ManageEventsPage() { [form], ); - const code = (codeQuery.data?.code ?? "").slice(0, 8); - const copyCode = useCallback(() => { - navigator.clipboard - .writeText(code) - .then(() => { - setCopied(true); - setTimeout(() => { - setCopied(false); - }, 1800); - }) - .catch(() => { - toast.error("Couldn't copy the code."); - }); - }, [code]); - const manageable = useMemo(() => { const hasRole = me?.roles.some((role) => EVENT_MANAGE_ROLES.has(role)) ?? false; return (events ?? EMPTY_EVENTS).filter((event) => hasRole || event.creator_id === me?.id); @@ -791,15 +722,10 @@ function ManageEventsPage() { [now, meId, attendance, openDetail, openRoster, openEditor, askDelete], ); - const rosterMembers = rosterQuery.data?.data ?? []; - const { planned: plannedCount, attended: attendedCount } = summarizeAttendance(rosterMembers); - const rosterEvent = (events ?? EMPTY_EVENTS).find((event) => event.id === rosterId); const confirmName = (events ?? EMPTY_EVENTS).find((event) => event.id === confirmId)?.name; const detailEvent = (events ?? EMPTY_EVENTS).find((event) => event.id === detailId); - const windowState = rosterEvent ? determineCheckIn(rosterEvent, now) : "ended"; - return (
@@ -1185,80 +1111,9 @@ function ManageEventsPage() { )} - - {rosterEvent && ( - - - Attendance - {rosterEvent.name} - - - - - - Check-in QR - - - Roster · {rosterMembers.length} - - - - - - - - -
-
-
- {plannedCount} -
-
- Planned (RSVP'd) -
-
-
-
- {attendedCount} -
-
Attended
-
-
-
- {rosterQuery.isPending && ( -

- Loading roster… -

- )} - {!rosterQuery.isPending && rosterMembers.length === 0 && ( -

- No attendees yet. -

- )} - {rosterMembers.map((member) => ( - - ))} -
-

- Undo clears a member's attended flag but keeps their RSVP — for correcting a - mistaken check-in. -

-
-
-
- )} -
+ {rosterEvent && ( + + )}
); } diff --git a/src/routes/event/$eventId.tsx b/src/routes/event/$eventId.tsx new file mode 100644 index 00000000..a3f0eae5 --- /dev/null +++ b/src/routes/event/$eventId.tsx @@ -0,0 +1,341 @@ +import { queryOptions, skipToken, useMutation, useQuery } from "@tanstack/react-query"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import axios from "axios"; +import { ChevronLeft, Clock, MapPin, Ticket } from "lucide-react"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; + +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + type EventType, + type FullEvent, + EVENT_TYPE_CLASSES, + EVENT_TYPE_META, + fmtClock, + fmtDay, + isPastEvent, + monthDay, +} from "@/lib/dashboard-events"; +import { cn } from "@/lib/utils"; + +export const Route = createFileRoute("/event/$eventId")({ + component: Event, + loader: ({ context: { queryClient }, params: { eventId } }) => + queryClient.prefetchQuery(eventDetailQueryOptions(eventId)), +}); + +/// Types and Interfaces + +interface EventOrganizer { + id: string; + name: string; +} + +/// Constants with data + +const EVENT_TYPE_ACCENTS: Record = { + general: "[--type-color:#2e7d9a] dark:[--type-color:#5b9bd5]", + sig_swe: "[--type-color:#3da9fc] dark:[--type-color:#6cbcff]", + sig_ai: "[--type-color:#00c9a7] dark:[--type-color:#2fdcbb]", + sig_cyber: "[--type-color:#ff6b6b] dark:[--type-color:#ff9a9a]", + sig_data: "[--type-color:#f7b731] dark:[--type-color:#ffd56b]", + sig_arch: "[--type-color:#fc5c7d] dark:[--type-color:#ff8fa6]", + sig_graph: "[--type-color:#a55eea] dark:[--type-color:#c79bf2]", + social: "[--type-color:#fd9644] dark:[--type-color:#ffb37a]", + misc: "[--type-color:#93a3b6] dark:[--type-color:#b4c2d2]", +}; + +const JOIN_ERRORS: Record = { + 401: "Sign in to join this event.", + 403: "This event has already ended.", + 404: "This event no longer exists.", + 409: "You've already joined this event.", +}; + +const EVENT_STATUSES = { + past: { label: "Past", className: "border-border bg-muted text-muted-foreground" }, + live: { label: "Happening now", className: "border-sig-ai/30 bg-sig-ai/15 text-sig-ai" }, + upcoming: { label: "Upcoming", className: "border-sig-swe/30 bg-sig-swe/15 text-sig-swe" }, +}; + +/// Module-scoped constants + +const API_BASE_URL = + (import.meta.env.VITE_API_URL as string | undefined) ?? "http://localhost:8000"; + +const LONG_DATE_OPTIONS: Intl.DateTimeFormatOptions = { + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", +}; + +const PAGE_CLASSES = "mx-auto max-w-225 px-4 md:px-12"; +const BADGE_CLASSES = cn( + "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.75", + "text-[11px] font-bold tracking-wide uppercase", +); +const DETAIL_PANEL_CLASSES = cn( + "rounded-[20px] border border-border bg-card p-5", + "shadow-[0px_4px_14px_rgba(112,144,176,0.14)] dark:shadow-[0px_4px_14px_rgba(0,0,0,0.4)]", +); +const DETAIL_LABEL_CLASSES = + "text-[11px] font-semibold tracking-[0.06em] text-muted-foreground uppercase"; +const DETAIL_VALUE_CLASSES = "mt-1 flex items-start gap-1.75 text-sm font-bold text-foreground"; +const SECTION_LABEL_CLASSES = + "mb-2.5 text-[11px] font-bold tracking-[0.08em] text-muted-foreground uppercase"; + +/// Tanstack Query options + +const eventDetailQueryOptions = (eventId: string) => + queryOptions({ + queryKey: ["events", eventId, "detail"], + queryFn: async () => { + const { data } = await axios.get(`${API_BASE_URL}/events/${eventId}`); + return data; + }, + }); + +const eventOrganizerQueryOptions = (memberId: string | null | undefined) => + queryOptions({ + queryKey: ["members", memberId], + queryFn: memberId + ? async () => { + const { data } = await axios.get(`${API_BASE_URL}/members/${memberId}`); + return data; + } + : skipToken, + staleTime: 300_000, + }); + +/// Helper functions + +function eventStatus(event: FullEvent, now: Date) { + if (isPastEvent(event, now)) return EVENT_STATUSES.past; + if (Date.parse(event.start_at) <= now.getTime()) return EVENT_STATUSES.live; + return EVENT_STATUSES.upcoming; +} + +/// Route Component + +function Event() { + const { eventId } = Route.useParams(); + + const { data: event, isPending, isError } = useQuery(eventDetailQueryOptions(eventId)); + const { data: organizer } = useQuery(eventOrganizerQueryOptions(event?.creator_id)); + + const [now] = useState(() => new Date()); + + const { mutate: joinEvent, isPending: isJoining } = useMutation({ + mutationFn: async () => { + const { data } = await axios.post<{ message: string }>( + `${API_BASE_URL}/events/${eventId}/join`, + ); + return data; + }, + onSuccess: () => { + toast.success("You're on the list! We'll see you there."); + }, + onError: (error) => { + const status = axios.isAxiosError(error) ? error.response?.status : undefined; + toast.error(JOIN_ERRORS[status ?? 0] ?? "Could not join this event. Please try again."); + }, + }); + + const handleJoin = useCallback(() => { + joinEvent(); + }, [joinEvent]); + + const organizerName = organizer?.name ?? (event?.creator_id ? "…" : "Unknown organizer"); + const status = event ? eventStatus(event, now) : undefined; + const startDate = event ? monthDay(event.start_at, event.timezone) : undefined; + + return ( +
+
+ + + Back to Events + +
+ + {isPending && ( +
+
+
+ + +
+ +
+ + + +
+ + + +
+
+ )} + + {isError && ( +
+ Event not found. +
+ )} + + {event && status && ( +
+
+
+ + {EVENT_TYPE_META[event.type].label} + + + +
+ {status !== EVENT_STATUSES.past && ( + + )} +
+ + {event.thumbnail ? ( + + ) : undefined} + +

+ {event.name} +

+ + {event.tags && event.tags.length > 0 ? ( +
    + {event.tags.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ ) : undefined} + +
+ +
+
Details
+
+
+
+
+ {startDate?.mon} +
+
+ {startDate?.day} +
+
+
+
When
+
+ {fmtDay(event.start_at, event.timezone, LONG_DATE_OPTIONS)} +
+
+ + {fmtClock(event.start_at, event.timezone)} -{" "} + {fmtClock(event.end_at, event.timezone)} +
+
+
+ +
+
+
Where
+
+ + {event.location} +
+
+
+
Hosted by
+
+ {organizer ? ( + + ) : undefined} + {organizerName} +
+
+
+
+
+ +
+
Description
+

+ {event.description} +

+
+ +
+ All times shown in {event.timezone} +
+
+ )} +
+ ); +} diff --git a/src/routes/events.tsx b/src/routes/events.tsx index 7ef89439..bed46c68 100644 --- a/src/routes/events.tsx +++ b/src/routes/events.tsx @@ -1,7 +1,7 @@ import { Popover as PopoverPrimitive } from "@base-ui/react/popover"; import type { CalendarType } from "@schedule-x/calendar"; import { queryOptions, useMutation, useQuery } from "@tanstack/react-query"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, Link } from "@tanstack/react-router"; import axios from "axios"; import { MapPin } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; @@ -276,6 +276,8 @@ function Events() { [selectedEvent], ); + const eventLinkParams = useMemo(() => ({ eventId: selectedEvent?.id ?? "" }), [selectedEvent]); + const anchor = useMemo( () => (selected ? { getBoundingClientRect: () => selected.rect } : undefined), [selected], @@ -385,8 +387,19 @@ function Events() { {selectedEventType.label} -

- {selectedEvent.name} + {/* The title carries the way into the full page, so the footer keeps a + single action instead of a second control competing with the CTA */} +

+ + {selectedEvent.name} +

diff --git a/src/routes/index.tsx b/src/routes/index.tsx index f30386e5..6a3810fe 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -2,10 +2,9 @@ import { SiDiscord, SiGithub, SiInstagram, type IconType } from "@icons-pack/rea import { queryOptions, useQuery } from "@tanstack/react-query"; import { createFileRoute, Link } from "@tanstack/react-router"; import axios from "axios"; -import { ChevronDown } from "lucide-react"; +import { ChevronDown, Clock, MapPin } from "lucide-react"; import { useMemo, type CSSProperties } from "react"; -import aboutUsImg from "@/assets/images/about-us.png"; import beginningsLogo from "@/assets/images/beginnings-logo.png"; import sigAiLogo from "@/assets/logos/sigs/ai.svg"; import sigArchLogo from "@/assets/logos/sigs/arch.svg"; @@ -20,6 +19,14 @@ import { CarouselNext, CarouselPrevious, } from "@/components/ui/carousel"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + type EventThumbnailData, + EVENT_TYPE_CLASSES, + EVENT_TYPE_META, + fmtClock, + monthDay, +} from "@/lib/dashboard-events"; import { cn } from "@/lib/utils"; export const Route = createFileRoute("/")({ @@ -50,6 +57,7 @@ interface ApiEvent { location: string; type: EventType; timezone: string; + thumbnail?: EventThumbnailData | null; creator_id: string; } @@ -69,12 +77,9 @@ interface SocialLink { const API_BASE_URL = (import.meta.env.VITE_API_URL as string | undefined) ?? "http://localhost:8000"; -// These are too expensive to compute, so we do it here -const SHORT_MONTH_FMT = new Intl.DateTimeFormat("en-US", { month: "short" }); -const TIME_RANGE_FMT = new Intl.DateTimeFormat("en-US", { - hour: "numeric", - minute: "2-digit", -}); +const CAROUSEL_NAV_CLASS = "static size-9 translate-0"; + +const PAGE_LOADED_AT = new Date().toISOString(); // One transform class shared by every tile. Per-tile angle is supplied via the // --tile-angle CSS variable (set by `angleClass` below), and CSS cos()/sin() compute @@ -167,10 +172,12 @@ const eventsKeys = { }; const eventsQueryOptions = queryOptions({ - queryKey: eventsKeys.list({}), + queryKey: eventsKeys.list({ after: PAGE_LOADED_AT }), queryFn: async () => { - const { data } = await axios.get(`${API_BASE_URL}/events`); - return data; + const { data } = await axios.get(`${API_BASE_URL}/events`, { + params: { after: PAGE_LOADED_AT }, + }); + return data.data.toReversed(); }, // Events don't change minute-to-minute; treat the cache as fresh for a minute so // navigating back/forward doesn't refetch immediately. @@ -185,8 +192,7 @@ const eventsQueryOptions = queryOptions({ }); function Index() { - const { data: eventsPage, isLoading: loading } = useQuery(eventsQueryOptions); - const events: ApiEvent[] = eventsPage?.data ?? []; + const { data: events = [], isLoading: loading } = useQuery(eventsQueryOptions); const carouselOptions = useMemo(() => ({ align: "start" as const }), []); @@ -365,36 +371,48 @@ function Index() {
-
-
-

Events

-

- ACM @ UCM hosts 50+ events for our diverse community of students. -

+ +
+
+

Events

+

+ ACM @ UCM hosts 50+ events for our diverse community of students. +

+
+
+ + See All Events → + + {events.length > 1 && ( + <> + + + + )} +
- - See All Events → - -
- {loading && Array.from({ length: 4 }, (_, i) => ( -
-
+
+
-
-
-
-
+ + + + +
+ + +
@@ -407,60 +425,93 @@ function Index() { )} {events.map((event) => { - const start = new Date(event.start_at); + const { mon, day } = monthDay(event.start_at, event.timezone); return ( -
-
- -
- - {SHORT_MONTH_FMT.format(start).toUpperCase()} - - - {start.getDate()} - +
+
+ {event.thumbnail ? ( + + ) : ( +
+ + {EVENT_TYPE_META[event.type].label} + +
+ )} +
+
+ {mon} +
+
+ {day} +
-
-
- {TIME_RANGE_FMT.formatRange(start, new Date(event.end_at))} · @{" "} - {event.location} +
+
+ + {EVENT_TYPE_META[event.type].label} + +

+ {event.name} +

-

- {event.name} -

-

+

{event.description}

- +
+ + + {fmtClock(event.start_at, event.timezone)} -{" "} + {fmtClock(event.end_at, event.timezone)} + + + + {event.location} + +
); })} - -
From 2cd1aa16545027b95ba215ce27d0793d175fb41d Mon Sep 17 00:00:00 2001 From: No767 <73260931+No767@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:35:40 -0700 Subject: [PATCH 2/2] no more comments --- src/routes/events.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/routes/events.tsx b/src/routes/events.tsx index bed46c68..9391e389 100644 --- a/src/routes/events.tsx +++ b/src/routes/events.tsx @@ -387,8 +387,6 @@ function Events() { {selectedEventType.label} - {/* The title carries the way into the full page, so the footer keeps a - single action instead of a second control competing with the CTA */}