diff --git a/client/portal/src/pages/admin/_shared/AppSidebar.tsx b/client/portal/src/pages/admin/_shared/AppSidebar.tsx index 15834b0c..f1d57621 100644 --- a/client/portal/src/pages/admin/_shared/AppSidebar.tsx +++ b/client/portal/src/pages/admin/_shared/AppSidebar.tsx @@ -11,6 +11,7 @@ import { ScanLine, Settings, Star, + Trophy, UserCheck, Users, } from "lucide-react"; @@ -66,6 +67,11 @@ const eventNav = [ url: "/admin/faq", icon: MessageSquare, }, + { + name: "Tracks", + url: "/admin/tracks", + icon: Trophy, + }, ]; const superAdminNav = [ diff --git a/client/portal/src/pages/admin/index.ts b/client/portal/src/pages/admin/index.ts index 06c8cf92..e23c41a3 100644 --- a/client/portal/src/pages/admin/index.ts +++ b/client/portal/src/pages/admin/index.ts @@ -4,3 +4,4 @@ export { default as ReviewsPage } from "./reviews/ReviewsPage"; export { default as ScansPage } from "./scans/ScansPage"; export { default as SchedulePage } from "./schedule/SchedulePage"; export { default as SponsorsPage } from "./sponsors/SponsorsPage"; +export { default as TracksPage } from "./tracks/TracksPage"; diff --git a/client/portal/src/pages/admin/tracks/TracksPage.tsx b/client/portal/src/pages/admin/tracks/TracksPage.tsx new file mode 100644 index 00000000..b805f40c --- /dev/null +++ b/client/portal/src/pages/admin/tracks/TracksPage.tsx @@ -0,0 +1,58 @@ +import { useEffect } from "react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +import { TracksTable } from "./components/TracksTable"; +import { useTracksStore } from "./store"; + +export default function TracksPage() { + const { + tracks, + canEdit, + loading, + saving, + fetch: loadTracks, + createTrack, + updateTrack, + deleteTrack, + uploadLogo, + } = useTracksStore(); + + useEffect(() => { + const controller = new AbortController(); + loadTracks(controller.signal); + return () => controller.abort(); + }, [loadTracks]); + + if (loading && tracks.length === 0) { + return ( +
+ + + + + + {[...Array(3)].map((_, i) => ( + + ))} + + +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/client/portal/src/pages/admin/tracks/api.ts b/client/portal/src/pages/admin/tracks/api.ts new file mode 100644 index 00000000..083b0e78 --- /dev/null +++ b/client/portal/src/pages/admin/tracks/api.ts @@ -0,0 +1,61 @@ +import { + deleteRequest, + getRequest, + postRequest, + putRequest, +} from "@/shared/lib/api"; +import type { ApiResponse } from "@/types"; + +import type { Track, TrackListResponse, TrackPayload } from "./types"; + +export async function fetchTracks( + signal?: AbortSignal, +): Promise> { + return getRequest("/admin/tracks", "tracks", signal); +} + +export async function fetchTrackEditPermission( + signal?: AbortSignal, +): Promise> { + return getRequest<{ enabled: boolean }>( + "/admin/tracks/edit-permission", + "track edit permission", + signal, + ); +} + +export async function createTrack( + payload: TrackPayload, + signal?: AbortSignal, +): Promise> { + return postRequest("/admin/tracks", payload, "track", signal); +} + +export async function updateTrack( + id: string, + payload: TrackPayload, + signal?: AbortSignal, +): Promise> { + return putRequest(`/admin/tracks/${id}`, payload, "track", signal); +} + +export async function deleteTrack( + id: string, + signal?: AbortSignal, +): Promise> { + return deleteRequest(`/admin/tracks/${id}`, "track", signal); +} + +export async function uploadTrackLogo( + trackId: string, + logoData: string, + contentType: string, + signal?: AbortSignal, +): Promise> { + return putRequest( + `/admin/tracks/${trackId}/logo`, + { logo_data: logoData, content_type: contentType }, + "track logo", + signal, + ); +} diff --git a/client/portal/src/pages/admin/tracks/components/TrackFormDialog.tsx b/client/portal/src/pages/admin/tracks/components/TrackFormDialog.tsx new file mode 100644 index 00000000..f3c935cb --- /dev/null +++ b/client/portal/src/pages/admin/tracks/components/TrackFormDialog.tsx @@ -0,0 +1,312 @@ +import { ImagePlus, Plus, X } from "lucide-react"; +import { useRef, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; + +import { ALLOWED_LOGO_TYPES, MAX_LOGO_BYTES } from "../constants"; +import type { Track, TrackPayload, TrackPrize } from "../types"; + +interface TrackFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + track: Track | null; + saving: boolean; + onSubmit: (payload: TrackPayload, logoFile?: File) => void; +} + +function logoPreviewFor(track: Track | null): string { + return track?.logo_data + ? `data:${track.logo_content_type};base64,${track.logo_data}` + : ""; +} + +function TrackForm({ + track, + saving, + onSubmit, + onCancel, +}: { + track: Track | null; + saving: boolean; + onSubmit: (payload: TrackPayload, logoFile?: File) => void; + onCancel: () => void; +}) { + const [title, setTitle] = useState(track?.title ?? ""); + const [sponsorName, setSponsorName] = useState(track?.sponsor_name ?? ""); + const [description, setDescription] = useState(track?.description ?? ""); + const [prizes, setPrizes] = useState( + track?.prizes?.length ? track.prizes : [{ place: "1st", prize: "" }], + ); + const [displayOrder, setDisplayOrder] = useState(track?.display_order ?? 0); + const [logoFile, setLogoFile] = useState(null); + const [logoPreview, setLogoPreview] = useState(logoPreviewFor(track)); + const logoInputRef = useRef(null); + + const handleLogoChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (!ALLOWED_LOGO_TYPES.includes(file.type)) { + toast.error("Unsupported file type. Use PNG, JPEG, WebP, or GIF."); + return; + } + + if (file.size > MAX_LOGO_BYTES) { + toast.error("File too large. Maximum size is 750KB."); + return; + } + + setLogoFile(file); + const reader = new FileReader(); + reader.onload = () => setLogoPreview(reader.result as string); + reader.readAsDataURL(file); + }; + + const clearLogo = () => { + setLogoFile(null); + setLogoPreview(logoPreviewFor(track)); + if (logoInputRef.current) logoInputRef.current.value = ""; + }; + + const updatePrize = (index: number, patch: Partial) => { + setPrizes((current) => + current.map((prize, i) => (i === index ? { ...prize, ...patch } : prize)), + ); + }; + + const addPrize = () => { + setPrizes((current) => [...current, { place: "", prize: "" }]); + }; + + const removePrize = (index: number) => { + setPrizes((current) => current.filter((_, i) => i !== index)); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!title.trim()) return; + + // Blank rows are the natural state of a freshly added prize, so drop them + // rather than failing validation on the server. + const filledPrizes = prizes + .map((p) => ({ place: p.place.trim(), prize: p.prize.trim() })) + .filter((p) => p.place !== "" || p.prize !== ""); + + if (filledPrizes.some((p) => p.place === "" || p.prize === "")) { + toast.error("Every prize needs both a place and a prize."); + return; + } + + onSubmit( + { + title: title.trim(), + sponsor_name: sponsorName.trim(), + description: description.trim(), + prizes: filledPrizes, + display_order: displayOrder, + }, + logoFile ?? undefined, + ); + }; + + return ( +
+
+ +
+ {logoPreview ? ( + Logo preview + ) : ( +
+ +
+ )} +
+ + + {logoFile && ( + + )} +
+
+

+ PNG, JPEG, WebP, or GIF (max 750KB) +

+
+ +
+ + setTitle(e.target.value)} + placeholder="Best Financial Hack" + required + /> +
+ +
+ + setSponsorName(e.target.value)} + placeholder="Capital One" + /> +
+ +
+ +