From b83996bf9fcef3a7cb92fa4f40b740a9deed09bf Mon Sep 17 00:00:00 2001 From: Anik Majumdar Date: Wed, 30 Sep 2026 19:00:36 -0700 Subject: [PATCH 1/4] feat(api): add DELETE /api/players/[id] with cascading recording cleanup --- frontend/web/app/api/players/[id]/route.ts | 110 +++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/frontend/web/app/api/players/[id]/route.ts b/frontend/web/app/api/players/[id]/route.ts index 5c90a053..07d35660 100644 --- a/frontend/web/app/api/players/[id]/route.ts +++ b/frontend/web/app/api/players/[id]/route.ts @@ -189,3 +189,113 @@ export async function PATCH( return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } + +export async function DELETE( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const user = await getAuthenticatedUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Cascading delete destroys recordings + their storage objects; cap per-user. + const rl = await checkRateLimit({ + userId: user.id, + ip: clientIp(req), + bucket: 'players-delete', + limit: 10, + windowSec: 3600, + }); + if (!rl.ok) return rateLimitResponse(rl.retryAfterSec); + + const { id } = await params; + + // Ownership check: only the row's owner can delete. Template rows (user_id null) + // are not deletable. 404 (not 403) for non-owned rows to avoid leaking + // existence of other users' players. + const { data: ownerRow, error: ownerErr } = await fetchPlayerForOwnershipCheck(id); + if (ownerErr || !ownerRow) { + return NextResponse.json({ error: 'Player not found' }, { status: 404 }); + } + if (ownerRow.user_id !== user.id) { + return NextResponse.json({ error: 'Player not found' }, { status: 404 }); + } + + // Cascade: the caller chose to delete this player's recordings too. The + // matches.player_id FK has no ON DELETE rule, so the matches rows must go + // before the player row or the final delete fails with a FK violation. + // Fetch storage paths first so we can clean up the buckets afterward. + const { data: matches, error: matchErr } = await supabaseAdmin + .from('matches') + .select( + 'id, input_path, results_path, bounce_heatmap_path, player_heatmap_path, player_shot_map_path', + ) + .eq('player_id', id) + .eq('user_id', user.id); + + if (matchErr) { + console.error('Player delete: match lookup error', matchErr); + return NextResponse.json({ error: 'Failed to delete player' }, { status: 500 }); + } + + if (matches && matches.length > 0) { + const { error: matchDeleteErr } = await supabaseAdmin + .from('matches') + .delete() + .eq('player_id', id) + .eq('user_id', user.id); + + if (matchDeleteErr) { + console.error('Player delete: match delete error', matchDeleteErr); + return NextResponse.json({ error: 'Failed to delete recordings' }, { status: 500 }); + } + + // Rows are gone — clean up storage. Non-blocking: log but don't fail the + // request, since the DB (source of truth) is already consistent. + const rawPaths = matches + .map((m) => m.input_path) + .filter(Boolean) as string[]; + if (rawPaths.length > 0) { + const { error: rawError } = await supabaseAdmin.storage + .from('raw-videos') + .remove(rawPaths); + if (rawError) console.error('Storage cleanup error (raw-videos):', rawError); + } + + const resultsPaths = matches + .flatMap((m) => [ + m.results_path, + m.bounce_heatmap_path, + m.player_heatmap_path, + m.player_shot_map_path, + ]) + .filter(Boolean) as string[]; + if (resultsPaths.length > 0) { + const { error: resultsError } = await supabaseAdmin.storage + .from('results') + .remove(resultsPaths); + if (resultsError) console.error('Storage cleanup error (results):', resultsError); + } + } + + // Re-assert ownership in the delete itself (defense in depth against a race + // between the ownership fetch above and this write). + const { error: delErr } = await supabaseAdmin + .from('players') + .delete() + .eq('id', id) + .eq('user_id', user.id); + + if (delErr) { + console.error('Player delete error', delErr); + return NextResponse.json({ error: 'Failed to delete player' }, { status: 500 }); + } + + return new NextResponse(null, { status: 204 }); + } catch (e) { + console.error(e); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} From 55bb21bac2bc48db34f7c05388a8419e3c41a042 Mon Sep 17 00:00:00 2001 From: Anik Majumdar Date: Wed, 30 Sep 2026 19:00:46 -0700 Subject: [PATCH 2/4] feat(players): let coaches add, edit, and delete players from the roster --- frontend/web/app/players/page.tsx | 136 +++++++++- .../components/players/DeletePlayerDialog.tsx | 117 +++++++++ .../components/players/PlayerActionsMenu.tsx | 60 +++++ .../components/players/PlayerFormDialog.tsx | 232 ++++++++++++++++++ frontend/web/components/ui/dialog.tsx | 113 +++++++++ 5 files changed, 649 insertions(+), 9 deletions(-) create mode 100644 frontend/web/components/players/DeletePlayerDialog.tsx create mode 100644 frontend/web/components/players/PlayerActionsMenu.tsx create mode 100644 frontend/web/components/players/PlayerFormDialog.tsx create mode 100644 frontend/web/components/ui/dialog.tsx diff --git a/frontend/web/app/players/page.tsx b/frontend/web/app/players/page.tsx index ec947c00..18354d93 100644 --- a/frontend/web/app/players/page.tsx +++ b/frontend/web/app/players/page.tsx @@ -4,10 +4,13 @@ import { useMemo, useState } from 'react' import Link from 'next/link' import { PlayerCard, type PlayerCardData } from '@/components/players/PlayerCard' +import { PlayerActionsMenu } from '@/components/players/PlayerActionsMenu' +import { PlayerFormDialog, type RosterPlayer } from '@/components/players/PlayerFormDialog' +import { DeletePlayerDialog } from '@/components/players/DeletePlayerDialog' import { Eyebrow } from '@/components/ui/eyebrow' import { Display, Num } from '@/components/ui/display' import { Button } from '@/components/ui/button' -import { usePlayersData, useRecordingsData } from '@/lib/hooks/useApiData' +import { usePlayersData, useRecordingsData, type PlayerRow } from '@/lib/hooks/useApiData' interface ApiPlayer { id: string @@ -133,8 +136,8 @@ function buildCards( export default function PlayersPage() { // SWR-cached roster + recordings — shared with the dashboard and detail pages, // so navigating between them serves cached data instantly. - const { data: pData, error: pErr, isLoading: pLoading } = usePlayersData() - const { data: rData, error: rErr, isLoading: rLoading } = useRecordingsData() + const { data: pData, error: pErr, isLoading: pLoading, mutate: mutatePlayers } = usePlayersData() + const { data: rData, error: rErr, isLoading: rLoading, mutate: mutateRecordings } = useRecordingsData() const players = useMemo( () => (pData?.players ?? []) as unknown as ApiPlayer[], [pData], @@ -154,6 +157,76 @@ export default function PlayersPage() { const [yearFilter, setYearFilter] = useState('all') const [sort, setSort] = useState('name') + // Roster management (add / edit / delete) dialog state. + const [formOpen, setFormOpen] = useState(false) + const [formMode, setFormMode] = useState<'add' | 'edit'>('add') + const [editingPlayer, setEditingPlayer] = useState(null) + const [deleteOpen, setDeleteOpen] = useState(false) + const [deletingPlayer, setDeletingPlayer] = useState(null) + const [deletingCount, setDeletingCount] = useState(0) + + const openAdd = () => { + setEditingPlayer(null) + setFormMode('add') + setFormOpen(true) + } + const openEdit = (c: PlayerCardData) => { + setEditingPlayer({ + id: c.id, + name: c.name, + year: c.year, + position: c.position, + photo_url: c.photo_url, + }) + setFormMode('edit') + setFormOpen(true) + } + const openDelete = (c: PlayerCardData) => { + setDeletingPlayer({ + id: c.id, + name: c.name, + year: c.year, + position: c.position, + photo_url: c.photo_url, + }) + setDeletingCount(c.matchCount) + setDeleteOpen(true) + } + + // Optimistically patch the SWR roster cache so the grid updates instantly. + const handleSaved = (player: RosterPlayer, mode: 'add' | 'edit') => { + const row: PlayerRow = { + id: player.id, + name: player.name, + year: player.year, + position: player.position, + photo_url: player.photo_url, + handedness: player.handedness ?? null, + created_at: player.created_at ?? new Date().toISOString(), + } + mutatePlayers( + (curr) => { + const list = curr?.players ?? [] + if (mode === 'add') { + if (list.some((p) => p.id === row.id)) return curr + return { players: [...list, row] } + } + return { players: list.map((p) => (p.id === row.id ? { ...p, ...row } : p)) } + }, + { revalidate: false }, + ) + } + + const handleDeleted = (playerId: string) => { + mutatePlayers( + (curr) => (curr ? { players: curr.players.filter((p) => p.id !== playerId) } : curr), + { revalidate: false }, + ) + // The player's recordings were deleted server-side; refresh that cache so + // per-player stats and the recordings list reflect the removal. + mutateRecordings() + } + const cards = useMemo( () => buildCards(players, recordings), [players, recordings], @@ -208,6 +281,15 @@ export default function PlayersPage() { Recordings + diff --git a/frontend/web/components/players/DeletePlayerDialog.tsx b/frontend/web/components/players/DeletePlayerDialog.tsx new file mode 100644 index 00000000..5cad4d3f --- /dev/null +++ b/frontend/web/components/players/DeletePlayerDialog.tsx @@ -0,0 +1,117 @@ +'use client' + +import { useState } from 'react' +import { toast } from 'sonner' + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import type { RosterPlayer } from '@/components/players/PlayerFormDialog' + +type Props = { + open: boolean + onOpenChange: (open: boolean) => void + player: RosterPlayer | null + /** Recordings currently assigned to this player — deleted alongside them. */ + recordingCount: number + /** Called after a successful delete with the removed player's id. */ + onDeleted: (playerId: string) => void +} + +export function DeletePlayerDialog({ + open, + onOpenChange, + player, + recordingCount, + onDeleted, +}: Props) { + const [deleting, setDeleting] = useState(false) + const [error, setError] = useState(null) + + async function handleDelete() { + if (!player) return + setDeleting(true) + setError(null) + try { + const res = await fetch(`/api/players/${player.id}`, { method: 'DELETE' }) + if (!res.ok && res.status !== 204) { + const body = await res.json().catch(() => ({})) + const message = typeof body?.error === 'string' ? body.error.trim() : '' + throw new Error( + message && message !== 'Internal server error' + ? message + : "Couldn't delete the player. Try again.", + ) + } + onDeleted(player.id) + toast.success(`${player.name} removed from your roster.`) + onOpenChange(false) + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong.') + } finally { + setDeleting(false) + } + } + + return ( + + + + Delete {player?.name ?? 'player'}? + + This permanently removes the player + {recordingCount > 0 ? ( + <> + {' '} + and their{' '} + + {recordingCount} recording{recordingCount === 1 ? '' : 's'} + + , including the processed videos and heatmaps. + + ) : ( + '.' + )}{' '} + This can’t be undone. + + + + {error && ( +
+ {error} +
+ )} + + + + + +
+
+ ) +} diff --git a/frontend/web/components/players/PlayerActionsMenu.tsx b/frontend/web/components/players/PlayerActionsMenu.tsx new file mode 100644 index 00000000..898c7767 --- /dev/null +++ b/frontend/web/components/players/PlayerActionsMenu.tsx @@ -0,0 +1,60 @@ +'use client' + +import { MoreVertical, Pencil, Trash2 } from 'lucide-react' + +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from '@/components/ui/dropdown-menu' + +type Props = { + playerName: string + onEdit: () => void + onDelete: () => void +} + +export function PlayerActionsMenu({ playerName, onEdit, onDelete }: Props) { + return ( + + ; stop the click/navigation from bubbling. + onClick={(e) => { + e.preventDefault() + e.stopPropagation() + }} + className="flex size-8 items-center justify-center rounded-full border border-line bg-paper/80 text-ink-mute backdrop-blur-sm transition-colors hover:border-ink hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-court/30" + > + + + e.stopPropagation()} + > + { + e.preventDefault() + onEdit() + }} + > + + Edit + + { + e.preventDefault() + onDelete() + }} + > + + Delete + + + + ) +} diff --git a/frontend/web/components/players/PlayerFormDialog.tsx b/frontend/web/components/players/PlayerFormDialog.tsx new file mode 100644 index 00000000..7f90c957 --- /dev/null +++ b/frontend/web/components/players/PlayerFormDialog.tsx @@ -0,0 +1,232 @@ +'use client' + +import { useEffect, useState } from 'react' +import { toast } from 'sonner' + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' + +export interface RosterPlayer { + id: string + name: string + year: string | null + position: string | null + photo_url: string | null + handedness?: string | null + created_at?: string +} + +const YEAR_OPTIONS = [ + { value: '', label: 'No year' }, + { value: 'Fr', label: 'Freshman' }, + { value: 'So', label: 'Sophomore' }, + { value: 'Jr', label: 'Junior' }, + { value: 'Sr', label: 'Senior' }, + { value: 'Gr', label: 'Graduate' }, +] as const + +type Props = { + mode: 'add' | 'edit' + open: boolean + onOpenChange: (open: boolean) => void + /** Existing player when mode === 'edit'. */ + initial?: RosterPlayer | null + /** Called with the created/updated player after a successful save. */ + onSaved: (player: RosterPlayer) => void +} + +export function PlayerFormDialog({ mode, open, onOpenChange, initial, onSaved }: Props) { + const [name, setName] = useState('') + const [year, setYear] = useState('') + const [position, setPosition] = useState('') + const [photoUrl, setPhotoUrl] = useState('') + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + // Reset the form whenever the dialog opens so stale values from a previous + // edit don't leak into a new add (and vice versa). + useEffect(() => { + if (!open) return + setName(initial?.name ?? '') + setYear(initial?.year ?? '') + setPosition(initial?.position ?? '') + setPhotoUrl(initial?.photo_url ?? '') + setError(null) + }, [open, initial]) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + const trimmedName = name.trim() + if (!trimmedName) { + setError('Name is required.') + return + } + if (trimmedName.length > 100) { + setError('Name is too long (max 100 characters).') + return + } + const trimmedPhoto = photoUrl.trim() + if (trimmedPhoto && !/^https:\/\//i.test(trimmedPhoto)) { + setError('Photo URL must start with https://') + return + } + + const payload = { + name: trimmedName, + year: year || null, + position: position.trim() || null, + photo_url: trimmedPhoto || null, + } + + setSaving(true) + setError(null) + try { + const url = mode === 'add' ? '/api/players' : `/api/players/${initial!.id}` + const method = mode === 'add' ? 'POST' : 'PATCH' + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + const message = typeof body?.error === 'string' ? body.error.trim() : '' + throw new Error( + message && message !== 'Internal server error' + ? message + : "Couldn't save the player. Try again.", + ) + } + + if (mode === 'add') { + const body = await res.json().catch(() => ({})) + const created = (body?.player ?? null) as RosterPlayer | null + onSaved( + created ?? { + id: crypto.randomUUID(), + ...payload, + }, + ) + toast.success(`${trimmedName} added to your roster.`) + } else { + // PATCH returns only the changed fields; merge onto the known player. + onSaved({ ...(initial as RosterPlayer), ...payload }) + toast.success('Player updated.') + } + onOpenChange(false) + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong.') + } finally { + setSaving(false) + } + } + + return ( + + + + {mode === 'add' ? 'Add player' : 'Edit player'} + + {mode === 'add' + ? 'Add a player to your roster. You can assign recordings to them later.' + : 'Update this player’s details.'} + + + +
+
+ + setName(e.target.value)} + placeholder="e.g. Sophie Luescher" + maxLength={100} + autoFocus + required + /> +
+ +
+
+ + +
+ +
+ + setPosition(e.target.value)} + placeholder="Optional" + maxLength={50} + /> +
+
+ +
+ + setPhotoUrl(e.target.value)} + placeholder="https://… (optional)" + /> +
+ + {error && ( +
+ {error} +
+ )} + + + + + +
+
+
+ ) +} diff --git a/frontend/web/components/ui/dialog.tsx b/frontend/web/components/ui/dialog.tsx new file mode 100644 index 00000000..c1eb2a45 --- /dev/null +++ b/frontend/web/components/ui/dialog.tsx @@ -0,0 +1,113 @@ +'use client' + +import * as React from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { X } from 'lucide-react' + +import { cn } from '@/lib/utils' + +const Dialog = DialogPrimitive.Root +const DialogTrigger = DialogPrimitive.Trigger +const DialogPortal = DialogPrimitive.Portal +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +function DialogHeader({ className, ...props }: React.HTMLAttributes) { + return
+} + +function DialogFooter({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ) +} + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogTrigger, + DialogPortal, + DialogClose, + DialogOverlay, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} From 58c972876a52d0d3cb53142dd3ce5e2b74f07d38 Mon Sep 17 00:00:00 2001 From: Anik Majumdar Date: Wed, 30 Sep 2026 19:17:40 -0700 Subject: [PATCH 3/4] fix(deps): bump next to 16.3.8 to patch critical next/og RCE (GHSA-vcvr-r3jv-pc5j) --- frontend/web/package-lock.json | 93 +++++++++++++++++++--------------- frontend/web/package.json | 2 +- 2 files changed, 53 insertions(+), 42 deletions(-) diff --git a/frontend/web/package-lock.json b/frontend/web/package-lock.json index edb741ed..e617c754 100644 --- a/frontend/web/package-lock.json +++ b/frontend/web/package-lock.json @@ -49,7 +49,7 @@ "embla-carousel-react": "8.6.0", "input-otp": "1.4.2", "lucide-react": "^0.454.0", - "next": "16.3.4", + "next": "16.3.8", "next-themes": "^0.4.6", "react": "19.2.6", "react-chartjs-2": "^5.2.0", @@ -715,15 +715,15 @@ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==" }, "node_modules/@next/env": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz", - "integrity": "sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz", + "integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.4.tgz", - "integrity": "sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz", + "integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==", "cpu": [ "arm64" ], @@ -737,9 +737,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.4.tgz", - "integrity": "sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz", + "integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==", "cpu": [ "x64" ], @@ -753,12 +753,15 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.4.tgz", - "integrity": "sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz", + "integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -769,12 +772,15 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.4.tgz", - "integrity": "sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz", + "integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -785,12 +791,15 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.4.tgz", - "integrity": "sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz", + "integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -801,12 +810,15 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.4.tgz", - "integrity": "sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz", + "integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -817,9 +829,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.4.tgz", - "integrity": "sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz", + "integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==", "cpu": [ "arm64" ], @@ -833,9 +845,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.4.tgz", - "integrity": "sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz", + "integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==", "cpu": [ "x64" ], @@ -3688,7 +3700,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4768,12 +4779,12 @@ } }, "node_modules/next": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.4.tgz", - "integrity": "sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz", + "integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==", "license": "MIT", "dependencies": { - "@next/env": "16.3.4", + "@next/env": "16.3.8", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -4787,14 +4798,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.4", - "@next/swc-darwin-x64": "16.3.4", - "@next/swc-linux-arm64-gnu": "16.3.4", - "@next/swc-linux-arm64-musl": "16.3.4", - "@next/swc-linux-x64-gnu": "16.3.4", - "@next/swc-linux-x64-musl": "16.3.4", - "@next/swc-win32-arm64-msvc": "16.3.4", - "@next/swc-win32-x64-msvc": "16.3.4", + "@next/swc-darwin-arm64": "16.3.8", + "@next/swc-darwin-x64": "16.3.8", + "@next/swc-linux-arm64-gnu": "16.3.8", + "@next/swc-linux-arm64-musl": "16.3.8", + "@next/swc-linux-x64-gnu": "16.3.8", + "@next/swc-linux-x64-musl": "16.3.8", + "@next/swc-win32-arm64-msvc": "16.3.8", + "@next/swc-win32-x64-msvc": "16.3.8", "sharp": "^0.35.4" }, "peerDependencies": { diff --git a/frontend/web/package.json b/frontend/web/package.json index 97221935..3f62bb59 100644 --- a/frontend/web/package.json +++ b/frontend/web/package.json @@ -50,7 +50,7 @@ "embla-carousel-react": "8.6.0", "input-otp": "1.4.2", "lucide-react": "^0.454.0", - "next": "16.3.4", + "next": "16.3.8", "next-themes": "^0.4.6", "react": "19.2.6", "react-chartjs-2": "^5.2.0", From 7afa2c2750f1e20873b4713a399823414ef22481 Mon Sep 17 00:00:00 2001 From: Anik Majumdar Date: Wed, 30 Sep 2026 19:34:47 -0700 Subject: [PATCH 4/4] refactor(players): simplify delete response check and drop dead UUID fallback --- frontend/web/components/players/DeletePlayerDialog.tsx | 2 +- frontend/web/components/players/PlayerFormDialog.tsx | 9 ++------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/frontend/web/components/players/DeletePlayerDialog.tsx b/frontend/web/components/players/DeletePlayerDialog.tsx index 5cad4d3f..73eee99e 100644 --- a/frontend/web/components/players/DeletePlayerDialog.tsx +++ b/frontend/web/components/players/DeletePlayerDialog.tsx @@ -40,7 +40,7 @@ export function DeletePlayerDialog({ setError(null) try { const res = await fetch(`/api/players/${player.id}`, { method: 'DELETE' }) - if (!res.ok && res.status !== 204) { + if (!res.ok) { const body = await res.json().catch(() => ({})) const message = typeof body?.error === 'string' ? body.error.trim() : '' throw new Error( diff --git a/frontend/web/components/players/PlayerFormDialog.tsx b/frontend/web/components/players/PlayerFormDialog.tsx index 7f90c957..6065a840 100644 --- a/frontend/web/components/players/PlayerFormDialog.tsx +++ b/frontend/web/components/players/PlayerFormDialog.tsx @@ -109,13 +109,8 @@ export function PlayerFormDialog({ mode, open, onOpenChange, initial, onSaved }: if (mode === 'add') { const body = await res.json().catch(() => ({})) - const created = (body?.player ?? null) as RosterPlayer | null - onSaved( - created ?? { - id: crypto.randomUUID(), - ...payload, - }, - ) + const created = body?.player as RosterPlayer | undefined + if (created) onSaved(created) toast.success(`${trimmedName} added to your roster.`) } else { // PATCH returns only the changed fields; merge onto the known player.