diff --git a/.oxlintrc.json b/.oxlintrc.json index 86928e4b..145fa63a 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -22,7 +22,8 @@ "src/components/ui", "!src/components/ui/theme-provider.tsx", "!src/components/ui/carousel.tsx", - "!src/components/ui/data-table.tsx" + "!src/components/ui/data-table.tsx", + "!src/components/ui/date-picker.tsx" ], "overrides": [ { diff --git a/eslint.config.js b/eslint.config.js index a1577662..12f2bb23 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,6 +19,7 @@ export default defineConfig([ "!src/components/ui/theme-provider.tsx", "!src/components/ui/carousel.tsx", "!src/components/ui/data-table.tsx", + "!src/components/ui/date-picker.tsx" ]), { diff --git a/package.json b/package.json index 52b89b74..5ac0d651 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "lucide-react": "^1.23.0", "next-themes": "^0.4.6", "react": "^19.2.7", + "react-day-picker": "^10.0.1", "react-dom": "^19.2.7", "react-dropzone": "^15.0.0", "sonner": "^2.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f8e3a19..7744869d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@base-ui/react': specifier: ^1.6.0 - version: 1.6.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@dnd-kit/dom': specifier: ^0.5.0 version: 0.5.0 @@ -77,6 +77,9 @@ importers: react: specifier: ^19.2.7 version: 19.2.7 + react-day-picker: + specifier: ^10.0.1 + version: 10.0.1(@types/react@19.2.17)(react@19.2.7) react-dom: specifier: ^19.2.7 version: 19.2.7(react@19.2.7) @@ -366,6 +369,9 @@ packages: '@types/react': optional: true + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + '@dnd-kit/abstract@0.5.0': resolution: {integrity: sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA==} @@ -2083,6 +2089,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + debounce-fn@4.0.0: resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} engines: {node: '>=10'} @@ -2139,8 +2148,8 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - deslop-js@0.7.6: - resolution: {integrity: sha512-3RwCevYF5Nux7rdixzyauk4EANTJmkkJbIqkAwVmzkR3HJ4HnMQxvuLNgRX33j5gF7ftcdv7Iqc2JdJCn8Abvw==} + deslop-js@0.7.7: + resolution: {integrity: sha512-mUr2B3FZM+vJZK4w6ZRwuHm6cXVWM5kS4xGRjRLC0yyen/zD+/ZI7QtH/UqUN0LaEIQZL2+P8PMcsDOIB5xgPQ==} detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} @@ -3065,8 +3074,8 @@ packages: vite-plus: optional: true - oxlint-plugin-react-doctor@0.7.6: - resolution: {integrity: sha512-oE01pROKCbxZ7mfHWXEZ27lw12ZWbWUe1p6OtgtRBtHWyNCqXi5MGYVsG7u3zlqG0i6k1UEYVvHrQvle2fJ1HQ==} + oxlint-plugin-react-doctor@0.7.7: + resolution: {integrity: sha512-OMl/8k3rcxTSyMJWUL/pdq/ZZKFtv1F4kyM+AWYsj/YYaL/nftL/idrJOxyd2CuFa5Nrbz2DlK3QFRej1uwQ/Q==} engines: {node: ^20.19.0 || >=22.13.0} oxlint-tsgolint@0.24.0: @@ -3246,8 +3255,18 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} - react-doctor@0.7.6: - resolution: {integrity: sha512-dVLFNw4/0EhAoLORK+t0LAkn7DHXQ1vxMPzNyJlmfpk4X8YIAfN6wJn3keCJzRwEsuJDsNjXZjlXn+yR5nwRlA==} + react-day-picker@10.0.1: + resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8.0' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + react-doctor@0.7.7: + resolution: {integrity: sha512-kHlzPDZNRKlQbXsXgnFh1I4mL0ybt0xJca3/uRMZafaXR4AG5zf7iFMqtdRUiFWHeefE4vGf4YQmkYkXzE1rlg==} engines: {node: ^20.19.0 || >=22.13.0} hasBin: true @@ -4047,7 +4066,7 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@base-ui/react@1.6.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@base-ui/react@1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@babel/runtime': 7.29.7 '@base-ui/utils': 0.3.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -4057,7 +4076,9 @@ snapshots: react-dom: 19.2.7(react@19.2.7) use-sync-external-store: 1.6.0(react@19.2.7) optionalDependencies: + '@date-fns/tz': 1.5.0 '@types/react': 19.2.17 + date-fns: 4.4.0 '@base-ui/utils@0.3.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: @@ -4070,6 +4091,8 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@date-fns/tz@1.5.0': {} + '@dnd-kit/abstract@0.5.0': dependencies: '@dnd-kit/geometry': 0.5.0 @@ -5530,6 +5553,8 @@ snapshots: csstype@3.2.3: {} + date-fns@4.4.0: {} + debounce-fn@4.0.0: dependencies: mimic-fn: 3.1.0 @@ -5563,7 +5588,7 @@ snapshots: depd@2.0.0: {} - deslop-js@0.7.6: + deslop-js@0.7.7: dependencies: '@oxc-project/types': 0.138.0 fast-glob: 3.3.3 @@ -6544,7 +6569,7 @@ snapshots: '@oxfmt/binding-win32-ia32-msvc': 0.58.0 '@oxfmt/binding-win32-x64-msvc': 0.58.0 - oxlint-plugin-react-doctor@0.7.6: + oxlint-plugin-react-doctor@0.7.7: dependencies: '@typescript-eslint/types': 8.63.0 eslint-scope: 9.1.2 @@ -6726,19 +6751,27 @@ snapshots: iconv-lite: 0.7.3 unpipe: 1.0.0 - react-doctor@0.7.6(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(eslint@10.7.0(jiti@2.7.0))(oxlint-tsgolint@0.24.0): + react-day-picker@10.0.1(@types/react@19.2.17)(react@19.2.7): + dependencies: + '@date-fns/tz': 1.5.0 + date-fns: 4.4.0 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + react-doctor@0.7.7(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(eslint@10.7.0(jiti@2.7.0))(oxlint-tsgolint@0.24.0): dependencies: '@babel/code-frame': 7.29.7 '@sentry/node': 10.65.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)) agent-install: 0.0.5 conf: 15.1.0 confbox: 0.2.4 - deslop-js: 0.7.6 + deslop-js: 0.7.7 eslint-plugin-react-hooks: 7.1.1(eslint@10.7.0(jiti@2.7.0)) jiti: 2.7.0 magicast: 0.5.3 oxlint: 1.66.0(oxlint-tsgolint@0.24.0) - oxlint-plugin-react-doctor: 0.7.6 + oxlint-plugin-react-doctor: 0.7.7 prompts: 2.4.2 typescript: 5.9.3 vscode-languageserver: 9.0.1 @@ -6785,7 +6818,7 @@ snapshots: preact: 10.29.7 prompts: 2.4.2 react: 19.2.7 - react-doctor: 0.7.6(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(eslint@10.7.0(jiti@2.7.0))(oxlint-tsgolint@0.24.0) + react-doctor: 0.7.7(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(eslint@10.7.0(jiti@2.7.0))(oxlint-tsgolint@0.24.0) react-dom: 19.2.7(react@19.2.7) react-grab: 0.1.48(react@19.2.7) optionalDependencies: diff --git a/src/components/app/check-in-panel.tsx b/src/components/app/check-in-panel.tsx new file mode 100644 index 00000000..3eddc938 --- /dev/null +++ b/src/components/app/check-in-panel.tsx @@ -0,0 +1,78 @@ +import { Check, Copy, Info, Lock, LockOpen, QrCode } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { type CheckInState } from "@/lib/dashboard-events"; +import { cn } from "@/lib/utils"; + +interface CheckInPanelProps { + code: string; + copied: boolean; + closesAt: string; + state: CheckInState; + onCopy: () => void; +} + +const CHECK_IN_WINDOW_LABEL: Record = { + ended: "Check-in closed", + open: "Check-in open", + too_early: "Opens 1 hour before start", +}; + +export function CheckInPanel({ + code, + copied, + closesAt, + state, + onCopy, +}: Readonly) { + const WindowIcon = state === "open" ? LockOpen : Lock; + + return ( + <> + + + {CHECK_IN_WINDOW_LABEL[state]} + + +
+ + + QR preview unavailable + +
+ +
+
+ {code ? `${code.slice(0, 4)} · ${code.slice(4)}` : "—— · ——"} +
+
+ Attendees submit these 8 characters +
+
+ +
+ +

+ Project this code at the door. The window opens 1 hour before start and closes at{" "} + {closesAt}. +

+
+ + ); +} diff --git a/src/components/app/data-pagination.tsx b/src/components/app/data-pagination.tsx new file mode 100644 index 00000000..be21c195 --- /dev/null +++ b/src/components/app/data-pagination.tsx @@ -0,0 +1,124 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { type MouseEvent, useCallback } from "react"; + +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, +} from "@/components/ui/pagination"; + +interface DataPaginationProps { + page: number; + pageCount: number; + pageSize: number; + itemCount: number; + total: number; + onPageChange: (page: number) => void; + onPrefetchPage?: (page: number) => void; +} + +const PAGE_WINDOW = 3; + +export function DataPagination({ + page, + pageCount, + pageSize, + itemCount, + total, + onPageChange, + onPrefetchPage, +}: Readonly) { + const handleClick = useCallback( + (event: MouseEvent) => { + event.preventDefault(); + const next = Number(event.currentTarget.dataset.page); + if (next) onPageChange(next); + }, + [onPageChange], + ); + const handlePrefetch = useCallback( + (event: MouseEvent) => { + const next = Number(event.currentTarget.dataset.page); + if (onPrefetchPage && next >= 1 && next <= pageCount) onPrefetchPage(next); + }, + [onPrefetchPage, pageCount], + ); + + const rangeStart = (page - 1) * pageSize + 1; + const rangeEnd = rangeStart + itemCount - 1; + + const windowStart = Math.max(1, Math.min(page - 1, pageCount - PAGE_WINDOW + 1)); + const windowEnd = Math.min(windowStart + PAGE_WINDOW - 1, pageCount); + const pageNumbers = Array.from( + { length: windowEnd - windowStart + 1 }, + (_, index) => windowStart + index, + ); + const showLeadingEllipsis = windowStart > 1; + const showTrailingEllipsis = windowEnd < pageCount; + + return ( +
+
+ Showing {rangeStart}-{rangeEnd} of {total} +
+ {pageCount > 1 && ( + + + + + + + + + {showLeadingEllipsis && ( + + + + )} + {pageNumbers.map((number) => ( + + + {number} + + + ))} + {showTrailingEllipsis && ( + + + + )} + + + + + + + + )} +
+ ); +} diff --git a/src/components/app/thumbnail-dropzone.tsx b/src/components/app/thumbnail-dropzone.tsx new file mode 100644 index 00000000..9c010b36 --- /dev/null +++ b/src/components/app/thumbnail-dropzone.tsx @@ -0,0 +1,67 @@ +import { Trash2, Upload } from "lucide-react"; +import { type DropzoneState } from "react-dropzone"; + +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; + +interface ThumbnailDropzoneProps { + label: string; + value: { url: string } | null | undefined; + onRemove: () => void; + getRootProps: DropzoneState["getRootProps"]; + getInputProps: DropzoneState["getInputProps"]; + isDragActive: boolean; +} + +const SECTION_LABEL_CLASS = + "mb-1.5 text-[11px] font-bold tracking-[0.06em] text-muted-foreground uppercase"; + +export function ThumbnailDropzone({ + label, + value, + onRemove, + getRootProps, + getInputProps, + isDragActive, +}: Readonly) { + return ( +
+ + {value ? ( +
+ + +
+ ) : ( +
+ + + + {isDragActive ? "Drop to upload" : "Upload a thumbnail"} + + + Drag & drop or click to browse · ≤ 32 MB + +
+ )} +
+ ); +} diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 00000000..e22652d4 --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -0,0 +1,219 @@ +import * as React from "react" +import { + DayPicker, + getDefaultClassNames, + type DayButton, + type Locale, +} from "react-day-picker" + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" +import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + locale, + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + locale={locale} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString(locale?.code, { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-4 md:flex-row", + defaultClassNames.months + ), + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), + nav: cn( + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_next + ), + month_caption: cn( + "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)", + defaultClassNames.month_caption + ), + dropdowns: cn( + "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "relative rounded-(--cell-radius)", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "absolute inset-0 bg-popover opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "font-medium select-none", + captionLayout === "label" + ? "text-sm" + : "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground", + defaultClassNames.caption_label + ), + month_grid: cn("w-full border-collapse", defaultClassNames.month_grid), + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none", + defaultClassNames.weekday + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn( + "w-(--cell-size) select-none", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-[0.8rem] text-muted-foreground select-none", + defaultClassNames.week_number + ), + day: cn( + "group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)", + props.showWeekNumber + ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)" + : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)", + defaultClassNames.day + ), + range_start: cn( + "relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn( + "relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted", + defaultClassNames.range_end + ), + today: cn( + "rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: ({ ...props }) => ( + + ), + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + locale, + ...props +}: React.ComponentProps & { locale?: Partial }) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + -
- -

- Project this code at the door. The window opens 1 hour before start and closes - at {fmtClock(roster.end_at, roster.timezone)}. -

-
+ diff --git a/src/routes/dashboard/index.tsx b/src/routes/dashboard/index.tsx index 3700ff42..2dfce91b 100644 --- a/src/routes/dashboard/index.tsx +++ b/src/routes/dashboard/index.tsx @@ -103,14 +103,14 @@ export const eventsListQueryOptions = queryOptions({ params: { page, size: EVENTS_PAGE_SIZE }, }); const first = await fetchPage(1); - const events = [...first.data.data]; + const events = [...(first.data.data ?? [])]; const pending = Array.from( { length: Math.ceil(first.data.total / EVENTS_PAGE_SIZE) - 1 }, (_, index) => fetchPage(index + 2), ); for (const request of pending) { const page = await request; - events.push(...page.data.data); + events.push(...(page.data.data ?? [])); } return events; }, diff --git a/src/routes/dashboard/manage/events.tsx b/src/routes/dashboard/manage/events.tsx new file mode 100644 index 00000000..11c9117c --- /dev/null +++ b/src/routes/dashboard/manage/events.tsx @@ -0,0 +1,1258 @@ +import "temporal-polyfill/full/global"; + +import { useForm } from "@tanstack/react-form"; +import { + queryOptions, + useMutation, + useQueries, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { type ColumnDef, type RowData, type TableMeta } from "@tanstack/react-table"; +import axios from "axios"; +import { blake3 } from "hash-wasm"; +import { + Calendar, + CalendarPlus, + Check, + Clock, + Lock, + MapPin, + MoreHorizontal, + Pencil, + QrCode, + Search, + Trash2, +} from "lucide-react"; +import { type ChangeEvent, type MouseEvent, useCallback, useMemo, useRef, useState } from "react"; +import { useDropzone } from "react-dropzone"; +import { toast } from "sonner"; +import { z } from "zod"; + +import { CheckInPanel } from "@/components/app/check-in-panel"; +import { EmptyState } from "@/components/app/dashboard-events"; +import { DataPagination } from "@/components/app/data-pagination"; +import { ThumbnailDropzone } from "@/components/app/thumbnail-dropzone"; +import { Button } from "@/components/ui/button"; +import { DataTable } from "@/components/ui/data-table"; +import { DatePicker } from "@/components/ui/date-picker"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Progress } from "@/components/ui/progress"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + 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, + type FullEvent, + type AttendanceMember, + type KanaePage, + 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")({ + component: ManageEventsPage, + staticData: { + area: "Manage", + title: "Manage Events", + sub: "Create, edit, and track attendance for chapter events", + }, + loader: async ({ context: { queryClient } }) => { + await queryClient.prefetchQuery(manageEventsQueryOptions); + await queryClient.prefetchQuery(meQueryOptions); + }, +}); + +/// Types and Interfaces + +type RowHandler = (event: MouseEvent) => void; +type RosterTab = "qr" | "roster"; + +interface AttendanceSummary { + planned: number; + attended: number; +} + +interface ManageEventsMeta { + now: Date; + meId?: string; + attendance: ReadonlyMap; + onDetail: RowHandler; + onAttendance: RowHandler; + onEdit: RowHandler; + onAskDelete: RowHandler; +} + +interface Editor { + creating: boolean; + id: string; + timezone: string; + type: EventType; + creatorId?: string | null; + hadThumbnail: boolean; +} + +interface SaveVars { + creating: boolean; + event: FullEvent; + thumbnailFile?: File; + removeThumbnail?: boolean; +} + +interface UndoVars { + eventId: string; + memberId: string; +} + +declare module "@tanstack/react-table" { + interface TableMeta { + events?: ManageEventsMeta; + } +} + +/// Constants - stable/empty data + +type EventFormValues = z.infer; +const BLANK_EVENT_FORM: EventFormValues = { + name: "", + type: "general", + location: "", + date: new Date(), + start: "17:00", + end: "18:30", + description: "", + tags: [], + thumbnail: undefined, +}; + +const EMPTY_EVENTS: FullEvent[] = []; +const EMPTY_ATTENDANCE_SUMMARY: AttendanceSummary = { planned: 0, attended: 0 }; + +/// Constants — Table Columns + +const ACTIONS_TRIGGER = +
{location}
+
+ + ); + }, + }, + { + id: "type", + header: "Type", + meta: { className: "hidden @md:table-cell" }, + cell: ({ row }) => ( + + + {EVENT_TYPE_META[row.original.type].label} + + ), + }, + { + id: "when", + header: "Date & time", + cell: ({ row }) => { + const { end_at, start_at, timezone } = row.original; + return ( +
+
+ {fmtDay(start_at, timezone)} +
+
+ {fmtClock(start_at, timezone)} – {fmtClock(end_at, timezone)} +
+
+ ); + }, + }, + { + id: "attendance", + header: "Attendance", + meta: { className: "hidden @lg:table-cell" }, + cell: ({ row, table }) => { + const { planned, attended } = + table.options.meta?.events?.attendance.get(row.original.id) ?? EMPTY_ATTENDANCE_SUMMARY; + const fillMax = Math.max(attended, planned) || 1; + return ( +
+ + {attended} checked + in + + +
+ ); + }, + }, + { + id: "actions", + header: () => Actions, + meta: { className: "text-right" }, + cell: ({ row, table }) => { + const meta = table.options.meta?.events; + return ( +
+ + + + + + + + Manage attendance + + + + Edit event + + + + + Delete + + + +
+ ); + }, + }, +]; + +/// Constants — regular + +const API_BASE_URL = + (import.meta.env.VITE_API_URL as string | undefined) ?? "http://localhost:8000"; +const THUMBNAIL_ACCEPT = { "image/*": [] }; +const THUMBNAIL_MAX_BYTES = 32 * 1024 * 1024; +const DEFAULT_TZ = "America/Los_Angeles"; +const EVENTS_PAGE_SIZE = 100; +const TABLE_PAGE_SIZE = 10; +const MANAGE_EVENTS_KEY = ["events", "manage"] as const; +const EVENT_MANAGE_ROLES = new Set(["admin", "leads", "manager"]); +const CARD_CLASS = + "rounded-[18px] border border-border bg-card shadow-[0px_4px_14px_rgba(112,144,176,0.14)] dark:shadow-[0px_4px_14px_rgba(0,0,0,0.4)]"; +const TEAL_BUTTON_CLASS = "bg-brand-teal font-bold text-primary hover:bg-brand-teal/85"; +const SECTION_LABEL_CLASS = + "mb-1.5 text-[11px] font-bold tracking-[0.06em] text-muted-foreground uppercase"; +const TAG_PILL_CLASS = + "inline-flex items-center rounded-full border border-border bg-muted px-2 py-0.5 text-[11px] font-semibold text-brand-text-sub"; +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"; +const FACT_VALUE_CLASS = "text-[13.5px] font-bold text-foreground"; + +const SHARED_QUERY_OPTIONS = { + staleTime: 60_000, + retry: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, +} as const; + +/// Zod schema + +const eventFormSchema = z + .object({ + name: z + .string() + .trim() + .min(1, "An event name is required.") + .max(120, "Keep the name under 120 characters."), + type: z.enum(EVENT_TYPES as [EventType, ...EventType[]]), + location: z + .string() + .trim() + .min(1, "A location is required.") + .max(160, "That location is too long."), + date: z.date({ message: "Pick a date." }), + start: z.iso.time({ precision: -1, error: "Set a start time." }), + end: z.iso.time({ precision: -1, error: "Set an end time." }), + description: z.string().max(2000, "That description is a little too long."), + tags: z.array(z.string()), + thumbnail: z + .object({ hash: z.string(), url: z.string(), file: z.instanceof(File).optional() }) + .nullish(), + }) + .refine((value) => value.end > value.start, { + message: "End time must be after the start time.", + path: ["end"], + }); + +/// Tanstack Query options + +const manageEventsQueryOptions = queryOptions({ + queryKey: MANAGE_EVENTS_KEY, + queryFn: async () => { + const first = await axios.get>(`${API_BASE_URL}/events`, { + params: { page: 1, size: EVENTS_PAGE_SIZE }, + }); + const events = [...(first.data.data ?? [])]; + const pageCount = Math.ceil(first.data.total / EVENTS_PAGE_SIZE); + for (let page = 2; page <= pageCount; page++) { + const next = await axios.get>(`${API_BASE_URL}/events`, { + params: { page, size: EVENTS_PAGE_SIZE }, + }); + events.push(...(next.data.data ?? [])); + } + return events; + }, + ...SHARED_QUERY_OPTIONS, +}); + +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; + }, + ...SHARED_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; + }, + ...SHARED_QUERY_OPTIONS, + }); + +/// Helper functions + +const summaryCache = new WeakMap(); +function summarizeAttendance(members?: readonly AttendanceMember[]): AttendanceSummary { + if (!members || members.length === 0) return EMPTY_ATTENDANCE_SUMMARY; + const cached = summaryCache.get(members); + if (cached) return cached; + let planned = 0; + let attended = 0; + for (const member of members) { + if (member.planned) planned += 1; + if (member.attended) attended += 1; + } + const summary: AttendanceSummary = { planned, attended }; + summaryCache.set(members, summary); + return summary; +} + +function toInstant(date: Date, time: string, timeZone: string): string { + return Temporal.PlainDate.from({ + year: date.getFullYear(), + month: date.getMonth() + 1, + day: date.getDate(), + }) + .toPlainDateTime(Temporal.PlainTime.from(time)) + .toZonedDateTime(timeZone) + .toInstant() + .toString(); +} + +const renderEventTypeLabel = (value: EventType) => EVENT_TYPE_META[value].label; +const renderTypeFilterLabel = (value: "all" | EventType) => + value === "all" ? "All types" : EVENT_TYPE_META[value].label; + +/// Route + +function ManageEventsPage() { + const queryClient = useQueryClient(); + + const topRef = useRef(null); + + const [now] = useState(() => new Date()); + const [search, setSearch] = useState(""); + const [typeFilter, setTypeFilter] = useState<"all" | EventType>("all"); + const [page, setPage] = useState(1); + const [editor, setEditor] = useState(); + const [tagText, setTagText] = useState(""); + 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], + ); + const { mutate: saveEvent } = useMutation({ + mutationFn: async ({ creating, event, thumbnailFile, removeThumbnail }: SaveVars) => { + const body = { + name: event.name, + description: event.description, + location: event.location, + start_at: event.start_at, + end_at: event.end_at, + timezone: event.timezone, + }; + await (creating + ? axios.post(`${API_BASE_URL}/events/create`, { + ...body, + id: event.id, + type: event.type, + }) + : axios.put(`${API_BASE_URL}/events/${event.id}`, body)); + + const eventId = event.id; + if (thumbnailFile) { + const bytes = new Uint8Array(await thumbnailFile.arrayBuffer()); + await axios.post(`${API_BASE_URL}/events/${eventId}/thumbnail`, { + hash: await blake3(bytes), + content_type: thumbnailFile.type, + }); + } else if (removeThumbnail) { + await axios.delete(`${API_BASE_URL}/events/${eventId}/thumbnail`); + } + + const tags = event.tags ?? []; + if (tags.length > 0) await axios.put(`${API_BASE_URL}/events/${eventId}/tags`, { tags }); + else if (!creating) await axios.delete(`${API_BASE_URL}/events/${eventId}/tags`); + }, + onError: () => toast.error("Couldn't save the event. Please try again."), + onSuccess: (_data, { creating }) => + toast.success(creating ? "Event published." : "Event saved."), + onSettled: invalidateManage, + }); + + const { mutate: deleteEvent } = useMutation({ + mutationFn: async (eventId: string) => { + await axios.delete(`${API_BASE_URL}/events/${eventId}`); + }, + onError: () => toast.error("Couldn't delete the event. Please try again."), + onSuccess: () => toast.success("Event deleted."), + 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 }, + onSubmit: ({ value }) => { + if (!editor) return; + const timezone = editor.timezone; + const thumb = value.thumbnail; + saveEvent({ + creating: editor.creating, + event: { + id: editor.id, + name: value.name.trim(), + description: value.description, + location: value.location.trim(), + start_at: toInstant(value.date, value.start, timezone), + end_at: toInstant(value.date, value.end, timezone), + type: editor.creating ? value.type : editor.type, + timezone, + tags: value.tags, + creator_id: editor.creating ? meId : editor.creatorId, + thumbnail: thumb ? { hash: thumb.hash, url: thumb.url } : undefined, + }, + thumbnailFile: thumb?.file, + removeThumbnail: !editor.creating && !thumb && editor.hadThumbnail, + }); + closeEditor(); + }, + }); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + accept: THUMBNAIL_ACCEPT, + maxSize: THUMBNAIL_MAX_BYTES, + multiple: false, + onDrop: (accepted, rejections) => { + if (accepted.length > 0) + form.setFieldValue("thumbnail", { + hash: "local", + url: URL.createObjectURL(accepted[0]), + file: accepted[0], + }); + else if (rejections.length > 0) toast.error("That file must be an image under 32 MB."); + }, + }); + + /// Toolbar + dialog open/close + + const closeEditor = useCallback(() => { + setEditor(undefined); + }, []); + const removeThumb = useCallback(() => { + form.setFieldValue("thumbnail", undefined); + }, [form]); + + const openCreate = useCallback(() => { + form.reset(BLANK_EVENT_FORM); + setTagText(""); + setEditor({ + creating: true, + id: crypto.randomUUID(), + timezone: DEFAULT_TZ, + type: "general", + creatorId: meId, + hadThumbnail: false, + }); + }, [form, meId]); + + /// Row actions + + const openEditor = useCallback( + (event: MouseEvent) => { + const target = (events ?? EMPTY_EVENTS).find( + (item) => item.id === event.currentTarget.dataset.id, + ); + if (!target) return; + const zoned = Temporal.Instant.from(target.start_at).toZonedDateTimeISO(target.timezone); + const end = Temporal.Instant.from(target.end_at).toZonedDateTimeISO(target.timezone); + form.reset( + { + name: target.name, + type: target.type, + location: target.location, + date: new Date(zoned.year, zoned.month - 1, zoned.day), + start: zoned.toPlainTime().toString({ smallestUnit: "minute" }), + end: end.toPlainTime().toString({ smallestUnit: "minute" }), + description: target.description, + tags: target.tags ?? [], + thumbnail: target.thumbnail ?? undefined, + }, + { keepDefaultValues: true }, + ); + setTagText((target.tags ?? []).join(", ")); + setEditor({ + creating: false, + id: target.id, + timezone: target.timezone, + type: target.type, + creatorId: target.creator_id, + hadThumbnail: !!target.thumbnail, + }); + }, + [events, form], + ); + const openDetail = useCallback((event: MouseEvent) => { + setDetailId(event.currentTarget.dataset.id); + }, []); + const closeDetail = useCallback(() => { + setDetailId(undefined); + }, []); + const askDelete = useCallback((event: MouseEvent) => { + setConfirmId(event.currentTarget.dataset.id); + }, []); + const closeConfirm = useCallback(() => { + setConfirmId(undefined); + }, []); + const confirmDelete = useCallback(() => { + if (confirmId) deleteEvent(confirmId); + setConfirmId(undefined); + }, [confirmId, deleteEvent]); + + /// 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 + + const handleSearch = useCallback((event: ChangeEvent) => { + setSearch(event.target.value); + setPage(1); + }, []); + const handleTypeFilter = useCallback((value: "all" | EventType | null) => { + if (value) { + setTypeFilter(value); + setPage(1); + } + }, []); + const goToPage = useCallback((next: number) => { + setPage(next); + topRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, []); + + /// Form field handlers + + const handleSubmit = useCallback(() => { + void form.handleSubmit(); + }, [form]); + const handleText = useCallback( + (event: ChangeEvent) => { + const { name, value } = event.target; + form.setFieldValue(name as "description" | "end" | "location" | "name" | "start", value); + }, + [form], + ); + const handleTypeChange = useCallback( + (value: EventType | null) => { + if (value) form.setFieldValue("type", value); + }, + [form], + ); + const handleDateChange = useCallback( + (date: Date | undefined) => { + if (date) form.setFieldValue("date", date); + }, + [form], + ); + const handleTagsChange = useCallback( + (event: ChangeEvent) => { + const { value } = event.target; + setTagText(value); + form.setFieldValue( + "tags", + value + .split(",") + .map((part) => part.trim()) + .filter(Boolean), + ); + }, + [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); + }, [events, me]); + + const rows = useMemo(() => { + const needle = search.trim().toLowerCase(); + return manageable + .filter((event) => typeFilter === "all" || event.type === typeFilter) + .filter( + (event) => + !needle || + `${event.name} ${event.location} ${(event.tags ?? []).join(" ")}` + .toLowerCase() + .includes(needle), + ) + .toSorted((a, b) => b.start_at.localeCompare(a.start_at)); + }, [manageable, typeFilter, search]); + + const total = rows.length; + const pageCount = Math.max(1, Math.ceil(total / TABLE_PAGE_SIZE)); + const pageRows = useMemo( + () => rows.slice((page - 1) * TABLE_PAGE_SIZE, page * TABLE_PAGE_SIZE), + [rows, page], + ); + + // There is no good place to put this except here + const attendance = useQueries({ + queries: pageRows.map((event) => eventAttendanceQueryOptions(event.id)), + combine: (results) => { + const map = new Map(); + pageRows.forEach((event, index) => { + map.set(event.id, summarizeAttendance(results[index]?.data?.data)); + }); + return map; + }, + }); + + const tableMeta = useMemo>( + () => ({ + events: { + now, + meId, + attendance, + onDetail: openDetail, + onAttendance: openRoster, + onEdit: openEditor, + onAskDelete: askDelete, + }, + }), + [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 ( +
+
+
+
+ {manageable.length} event{manageable.length === 1 ? "" : "s"} you can manage +
+
+
+ + +
+ + +
+
+
+ + {isPending && ( +
+ {Array.from({ length: 5 }, (_, index) => ( + + ))} +
+ )} + {!isPending && rows.length === 0 && ( +
+ +
+ )} + {!isPending && rows.length > 0 && ( +
+
+ +
+
+ )} + + {!isPending && total > 0 && ( + + )} + + + {editor && ( + + + + {(state) => ( + + {editor.creating ? "Create event" : state.values.name || "Untitled event"} + + )} + + + +
+ + {(field) => ( + + )} + + + + {(field) => ( +
+ + +
+ )} +
+ + + {(field) => ( +
+ +