Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions frontend/web/app/api/players/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}
136 changes: 127 additions & 9 deletions frontend/web/app/players/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand All @@ -154,6 +157,76 @@ export default function PlayersPage() {
const [yearFilter, setYearFilter] = useState<string>('all')
const [sort, setSort] = useState<SortKey>('name')

// Roster management (add / edit / delete) dialog state.
const [formOpen, setFormOpen] = useState(false)
const [formMode, setFormMode] = useState<'add' | 'edit'>('add')
const [editingPlayer, setEditingPlayer] = useState<RosterPlayer | null>(null)
const [deleteOpen, setDeleteOpen] = useState(false)
const [deletingPlayer, setDeletingPlayer] = useState<RosterPlayer | null>(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],
Expand Down Expand Up @@ -208,6 +281,15 @@ export default function PlayersPage() {
Recordings
</Link>
</Button>
<Button variant="primary" size="sm" onClick={openAdd}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<line x1="19" y1="8" x2="19" y2="14" />
<line x1="22" y1="11" x2="16" y2="11" />
</svg>
Add player
</Button>
<Button variant="ink" size="sm" asChild>
<Link href="/upload">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round">
Expand Down Expand Up @@ -294,14 +376,42 @@ export default function PlayersPage() {
{loading ? (
<SkeletonGrid />
) : filtered.length === 0 ? (
<EmptyState hasPlayers={players.length > 0} hasFilter={yearFilter !== 'all' || query.trim() !== ''} />
<EmptyState
hasPlayers={players.length > 0}
hasFilter={yearFilter !== 'all' || query.trim() !== ''}
onAdd={openAdd}
/>
) : (
<div className="mb-9 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((c) => (
<PlayerCard key={c.id} player={c} />
<div key={c.id} className="relative">
<PlayerCard player={c} />
<div className="absolute right-5 top-5 z-10">
<PlayerActionsMenu
playerName={c.name}
onEdit={() => openEdit(c)}
onDelete={() => openDelete(c)}
/>
</div>
</div>
))}
</div>
)}

<PlayerFormDialog
mode={formMode}
open={formOpen}
onOpenChange={setFormOpen}
initial={formMode === 'edit' ? editingPlayer : null}
onSaved={(p) => handleSaved(p, formMode)}
/>
<DeletePlayerDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
player={deletingPlayer}
recordingCount={deletingCount}
onDeleted={handleDeleted}
/>
</div>
)
}
Expand Down Expand Up @@ -333,7 +443,15 @@ function SkeletonGrid() {
)
}

function EmptyState({ hasPlayers, hasFilter }: { hasPlayers: boolean; hasFilter: boolean }) {
function EmptyState({
hasPlayers,
hasFilter,
onAdd,
}: {
hasPlayers: boolean
hasFilter: boolean
onAdd: () => void
}) {
if (hasPlayers && hasFilter) {
return (
<div className="cc-card mb-9 flex flex-col items-center gap-3 px-6 py-16 text-center">
Expand All @@ -349,11 +467,11 @@ function EmptyState({ hasPlayers, hasFilter }: { hasPlayers: boolean; hasFilter:
Add players, then assign recordings to them.
</p>
<div className="mt-2 flex flex-wrap justify-center gap-2.5">
<Button variant="ink" size="sm" asChild>
<Link href="/upload">Upload a recording</Link>
<Button variant="primary" size="sm" onClick={onAdd}>
Add player
</Button>
<Button variant="ghost" size="sm" asChild>
<Link href="/recordings">Browse recordings</Link>
<Link href="/upload">Upload a recording</Link>
</Button>
</div>
</div>
Expand Down
Loading
Loading