From 90531113c5dbcc0502bd0260c4287a10f2b02178 Mon Sep 17 00:00:00 2001 From: No767 <73260931+No767@users.noreply.github.com> Date: Sun, 19 Jul 2026 04:23:59 -0700 Subject: [PATCH 1/4] Members and tags pages are *mostly* done --- src/routeTree.gen.ts | 42 ++ src/routes/dashboard/admin/members.tsx | 626 ++++++++++++++++++++++ src/routes/dashboard/admin/tags.tsx | 685 +++++++++++++++++++++++++ src/routes/dashboard/route.tsx | 35 +- 4 files changed, 1387 insertions(+), 1 deletion(-) create mode 100644 src/routes/dashboard/admin/members.tsx create mode 100644 src/routes/dashboard/admin/tags.tsx diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 4950ec3f..f4c809e1 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -25,6 +25,8 @@ import { Route as DashboardEventsRouteImport } from './routes/dashboard/events' import { Route as DashboardManageProjectsRouteImport } from './routes/dashboard/manage/projects' import { Route as DashboardManageEventsRouteImport } from './routes/dashboard/manage/events' import { Route as DashboardEventsPastRouteImport } from './routes/dashboard/events_.past' +import { Route as DashboardAdminTagsRouteImport } from './routes/dashboard/admin/tags' +import { Route as DashboardAdminMembersRouteImport } from './routes/dashboard/admin/members' import { Route as DashboardManageProjectsProjectIdGalleryRouteImport } from './routes/dashboard/manage/projects_.$projectId.gallery' const SigsRoute = SigsRouteImport.update({ @@ -107,6 +109,16 @@ const DashboardEventsPastRoute = DashboardEventsPastRouteImport.update({ path: '/events/past', getParentRoute: () => DashboardRouteRoute, } as any) +const DashboardAdminTagsRoute = DashboardAdminTagsRouteImport.update({ + id: '/admin/tags', + path: '/admin/tags', + getParentRoute: () => DashboardRouteRoute, +} as any) +const DashboardAdminMembersRoute = DashboardAdminMembersRouteImport.update({ + id: '/admin/members', + path: '/admin/members', + getParentRoute: () => DashboardRouteRoute, +} as any) const DashboardManageProjectsProjectIdGalleryRoute = DashboardManageProjectsProjectIdGalleryRouteImport.update({ id: '/manage/projects_/$projectId/gallery', @@ -128,6 +140,8 @@ export interface FileRoutesByFullPath { '/dashboard/projects': typeof DashboardProjectsRoute '/project/$projectId': typeof ProjectProjectIdRoute '/dashboard/': typeof DashboardIndexRoute + '/dashboard/admin/members': typeof DashboardAdminMembersRoute + '/dashboard/admin/tags': typeof DashboardAdminTagsRoute '/dashboard/events/past': typeof DashboardEventsPastRoute '/dashboard/manage/events': typeof DashboardManageEventsRoute '/dashboard/manage/projects': typeof DashboardManageProjectsRoute @@ -146,6 +160,8 @@ export interface FileRoutesByTo { '/dashboard/projects': typeof DashboardProjectsRoute '/project/$projectId': typeof ProjectProjectIdRoute '/dashboard': typeof DashboardIndexRoute + '/dashboard/admin/members': typeof DashboardAdminMembersRoute + '/dashboard/admin/tags': typeof DashboardAdminTagsRoute '/dashboard/events/past': typeof DashboardEventsPastRoute '/dashboard/manage/events': typeof DashboardManageEventsRoute '/dashboard/manage/projects': typeof DashboardManageProjectsRoute @@ -166,6 +182,8 @@ export interface FileRoutesById { '/dashboard/projects': typeof DashboardProjectsRoute '/project/$projectId': typeof ProjectProjectIdRoute '/dashboard/': typeof DashboardIndexRoute + '/dashboard/admin/members': typeof DashboardAdminMembersRoute + '/dashboard/admin/tags': typeof DashboardAdminTagsRoute '/dashboard/events_/past': typeof DashboardEventsPastRoute '/dashboard/manage/events': typeof DashboardManageEventsRoute '/dashboard/manage/projects': typeof DashboardManageProjectsRoute @@ -187,6 +205,8 @@ export interface FileRouteTypes { | '/dashboard/projects' | '/project/$projectId' | '/dashboard/' + | '/dashboard/admin/members' + | '/dashboard/admin/tags' | '/dashboard/events/past' | '/dashboard/manage/events' | '/dashboard/manage/projects' @@ -205,6 +225,8 @@ export interface FileRouteTypes { | '/dashboard/projects' | '/project/$projectId' | '/dashboard' + | '/dashboard/admin/members' + | '/dashboard/admin/tags' | '/dashboard/events/past' | '/dashboard/manage/events' | '/dashboard/manage/projects' @@ -224,6 +246,8 @@ export interface FileRouteTypes { | '/dashboard/projects' | '/project/$projectId' | '/dashboard/' + | '/dashboard/admin/members' + | '/dashboard/admin/tags' | '/dashboard/events_/past' | '/dashboard/manage/events' | '/dashboard/manage/projects' @@ -357,6 +381,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardEventsPastRouteImport parentRoute: typeof DashboardRouteRoute } + '/dashboard/admin/tags': { + id: '/dashboard/admin/tags' + path: '/admin/tags' + fullPath: '/dashboard/admin/tags' + preLoaderRoute: typeof DashboardAdminTagsRouteImport + parentRoute: typeof DashboardRouteRoute + } + '/dashboard/admin/members': { + id: '/dashboard/admin/members' + path: '/admin/members' + fullPath: '/dashboard/admin/members' + preLoaderRoute: typeof DashboardAdminMembersRouteImport + parentRoute: typeof DashboardRouteRoute + } '/dashboard/manage/projects_/$projectId/gallery': { id: '/dashboard/manage/projects_/$projectId/gallery' path: '/manage/projects/$projectId/gallery' @@ -371,6 +409,8 @@ interface DashboardRouteRouteChildren { DashboardEventsRoute: typeof DashboardEventsRoute DashboardProjectsRoute: typeof DashboardProjectsRoute DashboardIndexRoute: typeof DashboardIndexRoute + DashboardAdminMembersRoute: typeof DashboardAdminMembersRoute + DashboardAdminTagsRoute: typeof DashboardAdminTagsRoute DashboardEventsPastRoute: typeof DashboardEventsPastRoute DashboardManageEventsRoute: typeof DashboardManageEventsRoute DashboardManageProjectsRoute: typeof DashboardManageProjectsRoute @@ -381,6 +421,8 @@ const DashboardRouteRouteChildren: DashboardRouteRouteChildren = { DashboardEventsRoute: DashboardEventsRoute, DashboardProjectsRoute: DashboardProjectsRoute, DashboardIndexRoute: DashboardIndexRoute, + DashboardAdminMembersRoute: DashboardAdminMembersRoute, + DashboardAdminTagsRoute: DashboardAdminTagsRoute, DashboardEventsPastRoute: DashboardEventsPastRoute, DashboardManageEventsRoute: DashboardManageEventsRoute, DashboardManageProjectsRoute: DashboardManageProjectsRoute, diff --git a/src/routes/dashboard/admin/members.tsx b/src/routes/dashboard/admin/members.tsx new file mode 100644 index 00000000..96ed8f98 --- /dev/null +++ b/src/routes/dashboard/admin/members.tsx @@ -0,0 +1,626 @@ +import { queryOptions, useMutation, 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 { + type LucideIcon, + AlertTriangle, + Check, + Gauge, + MoreHorizontal, + Search, + Shield, + Star, + Trash2, +} from "lucide-react"; +import { type ChangeEvent, type MouseEvent, useCallback, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; + +import { EmptyState } from "@/components/app/dashboard-events"; +import { DataPagination } from "@/components/app/data-pagination"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { DataTable } from "@/components/ui/data-table"; +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; +import { type Role, meQueryOptions } from "@/routes/dashboard/route"; + +export const Route = createFileRoute("/dashboard/admin/members")({ + component: MembersPage, + staticData: { + area: "Admin", + title: "Members & Roles", + sub: "Grant global roles and manage the chapter directory", + }, + loader: async ({ context: { queryClient } }) => + await queryClient.ensureQueryData(membersQueryOptions), +}); + +/// Types and Interfaces + +type RowHandler = (event: MouseEvent) => void; +type RoleFilter = "admin" | "all" | "leads" | "manager" | "none"; + +interface SimpleMember { + id: string; + name: string; + display_name?: string | null; + email: string; + created_at: string; +} + +interface AdminMember { + id: string; + name: string; + display_name?: string | null; + email: string; + created_at: string; + roles: Role[]; +} + +interface MemberRoles { + roles: Role[]; +} + +interface MembersMeta { + meId?: string; + onManage: RowHandler; + onAskDelete: RowHandler; +} + +interface RoleModifyVars { + memberId: string; + role: Role; + action: "grant" | "revoke"; +} + +declare module "@tanstack/react-table" { + interface TableMeta { + members?: MembersMeta; + } +} + +/// Constants - metadata and presentation + +const ASSIGNABLE_ROLES: Role[] = ["admin", "manager", "leads"]; +const ROLE_META: Record = { + root: { + label: "Root", + desc: "Unseeded by design", + icon: Shield, + badge: "text-[#e13737] bg-[#e13737]/12 dark:text-[#ff6b6b] dark:bg-[#ff6b6b]/16", + }, + admin: { + label: "Admin", + desc: "Full chapter administration", + icon: Shield, + badge: "text-[#084778] bg-[#084778]/12 dark:text-[#7fc4ff] dark:bg-[#7fc4ff]/16", + }, + manager: { + label: "Manager", + desc: "Create & manage projects", + icon: Gauge, + badge: "text-[#3da9fc] bg-[#3da9fc]/14", + }, + leads: { + label: "Leads", + desc: "Create & run events", + icon: Star, + badge: "text-[#078c79] bg-[#078c79]/14 dark:text-[#2fead0] dark:bg-[#2fead0]/16", + }, +}; + +const AVATAR_TINTS = [ + "border-brand-sky/30 bg-brand-sky/15 text-brand-sky-text", + "border-[#00c9a7]/30 bg-[#00c9a7]/15 text-[#00715e] dark:text-[#2fdcbb]", + "border-[#a55eea]/30 bg-[#a55eea]/15 text-[#7844aa] dark:text-[#c79bf2]", + "border-[#f7b731]/30 bg-[#f7b731]/15 text-[#84621a] dark:text-[#ffd56b]", + "border-[#fc5c7d]/30 bg-[#fc5c7d]/15 text-[#a13b50] dark:text-[#ff8fa6]", +]; + +const FILTER_OPTIONS: { label: string; value: RoleFilter }[] = [ + { value: "all", label: "All members" }, + { value: "admin", label: "Admins" }, + { value: "manager", label: "Managers" }, + { value: "leads", label: "Leads" }, + { value: "none", label: "No role" }, +]; + +/// Constants — stable/empty data + +const EMPTY_MEMBERS: AdminMember[] = []; +const EMPTY_ROLES: Role[] = []; + +/// Constants — Table Columns + +const ACTIONS_TRIGGER = + + ); + })} + + + )} + + + + {deletingMember && ( + + + Delete member? + {deletingName} + +
+ +

+ This revokes all authenticated sessions and purges all data associated with the + member. CANNOT BE UNDONE! +

+
+ + + + +
+ )} +
+ + ); +} diff --git a/src/routes/dashboard/admin/tags.tsx b/src/routes/dashboard/admin/tags.tsx new file mode 100644 index 00000000..9f7b237a --- /dev/null +++ b/src/routes/dashboard/admin/tags.tsx @@ -0,0 +1,685 @@ +import { useForm } from "@tanstack/react-form"; +import { queryOptions, useMutation, 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 { + AlertTriangle, + Check, + MoreHorizontal, + Pencil, + Plus, + Search, + Tag as TagIcon, + Trash2, + Upload, +} from "lucide-react"; +import { type ChangeEvent, type MouseEvent, useCallback, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { z } from "zod"; + +import { EmptyState } from "@/components/app/dashboard-events"; +import { DataPagination } from "@/components/app/data-pagination"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { DataTable } from "@/components/ui/data-table"; +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 { Skeleton } from "@/components/ui/skeleton"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; + +export const Route = createFileRoute("/dashboard/admin/tags")({ + component: TagsPage, + staticData: { + area: "Admin", + title: "Tags", + sub: "The shared taxonomy referenced by events and projects", + }, + loader: async ({ context: { queryClient } }) => + await queryClient.ensureQueryData(tagsQueryOptions), +}); + +/// Types and Interfaces + +type RowHandler = (event: MouseEvent) => void; + +interface Tag { + id: number; + title: string; + description: string; +} + +interface FullTags extends Tag { + in_use: boolean; +} + +interface AttachedTagEntry { + id: string; + name: string; + type: "Project" | "Event"; +} + +interface AttachedTagResponse { + detail: string; + entries: AttachedTagEntry[]; +} + +interface Conflict { + title: string; + entries: AttachedTagEntry[]; +} + +interface TagsMeta { + onEdit: RowHandler; + onDelete: RowHandler; +} + +interface TagVars { + title: string; + description: string; +} + +interface EditVars extends TagVars { + id: number; +} + +declare module "@tanstack/react-table" { + interface TableMeta { + tags?: TagsMeta; + } +} + +/// Constants — stable/empty data + +type TagFormValues = z.infer; +const BLANK_TAG_FORM: TagFormValues = { title: "", description: "" }; + +const EMPTY_TAGS: FullTags[] = []; + +/// Constants — Table Columns + +const ACTIONS_TRIGGER = + + + + + {isPending && ( +
+ {Array.from({ length: 6 }, (_, index) => ( + + ))} +
+ )} + {!isPending && total === 0 && ( +
+ +
+ )} + {!isPending && total > 0 && ( +
+
+ +
+
+ )} + + {!isPending && total > 0 && ( + + )} + + + + + + {editingTag ? "Edit tag" : "Create tag"} + + +
+ + {(field) => ( +
+ + + {field.state.meta.errors[0] && ( + {field.state.meta.errors[0].message} + )} +
+ )} +
+ + {(field) => ( +
+ +