Files
walkup/frontend/src/pages/GamePage.tsx
2026-04-22 06:46:23 -05:00

219 lines
9.1 KiB
TypeScript

import { useEffect, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { api } from "../api/client";
import { useWalkupContext } from "../hooks/useWalkupContext";
import { loadPreparedGame, savePreparedGame } from "../lib/offlinePrep";
import { queryClient } from "../lib/queryClient";
import { formatGameDate, formatGameTitle, formatMemberName, formatTeamLabel } from "../lib/teamsnapHelpers";
export function GamePage() {
const walkup = useWalkupContext();
const [searchParams, setSearchParams] = useSearchParams();
const [selectedGameId, setSelectedGameId] = useState(searchParams.get("gameId") ?? "");
const [clipId, setClipId] = useState<number>(0);
const [slot, setSlot] = useState<number>(1);
const [offlineMessage, setOfflineMessage] = useState<string | null>(null);
const teamId = walkup.selectedTeamId;
const playerId = walkup.currentPlayerId;
useEffect(() => {
const requestedGameId = searchParams.get("gameId");
if (requestedGameId) {
setSelectedGameId(requestedGameId);
return;
}
if (!selectedGameId && walkup.nextGame) {
setSelectedGameId(String(walkup.nextGame.id));
}
}, [searchParams, selectedGameId, walkup.nextGame]);
const clipsQuery = useQuery({
queryKey: ["clips", teamId, playerId],
queryFn: () => api.listClips(teamId, playerId),
enabled: Boolean(teamId && playerId),
});
const assignmentsQuery = useQuery({
queryKey: ["assignments", selectedGameId, playerId],
queryFn: () => api.listAssignments(selectedGameId, playerId),
enabled: Boolean(selectedGameId && playerId),
});
const prepQuery = useQuery({
queryKey: ["prep", selectedGameId],
queryFn: () => api.prepareGame(selectedGameId),
enabled: Boolean(selectedGameId),
});
const saveMutation = useMutation({
mutationFn: () =>
api.createAssignment(selectedGameId, {
external_team_id: teamId,
external_player_id: playerId,
clip_id: clipId,
batting_slot: slot,
status: "ready",
}),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["assignments", selectedGameId, playerId] }),
queryClient.invalidateQueries({ queryKey: ["prep", selectedGameId] }),
]);
},
});
function selectGame(gameId: string) {
setSelectedGameId(gameId);
setSearchParams({ gameId });
setOfflineMessage(null);
}
function cachePreparedGame() {
if (!prepQuery.data) {
setOfflineMessage("Prepare the game first so there is something to cache locally.");
return;
}
savePreparedGame(selectedGameId, prepQuery.data);
setOfflineMessage(`Cached ${prepQuery.data.assignments.length} assignments for offline operator use.`);
}
const selectedGame = walkup.games.find((game) => String(game.id) === selectedGameId) ?? null;
const cachedPrep = selectedGameId ? loadPreparedGame(selectedGameId) : null;
if (!walkup.isTeamSnap) {
return (
<section className="container-fluid py-4">
<div className="card shadow-sm">
<div className="card-body">Reconnect with TeamSnap to attach clips to games.</div>
</div>
</section>
);
}
if (!teamId || !playerId) {
return (
<section className="container-fluid py-4">
<div className="card shadow-sm">
<div className="card-body">
No player record was found on the selected team, so game-specific clip selection is unavailable.
</div>
</div>
</section>
);
}
return (
<section className="container-fluid py-4 d-grid gap-4">
<div className="card bg-dark text-white border-0 shadow-sm">
<div className="card-body p-4 p-lg-5">
<p className="text-uppercase small text-info-emphasis mb-2">Game clips</p>
<h1 className="h2">{selectedGame ? formatGameTitle(selectedGame) : "Select a game"}</h1>
<p className="mb-0 text-white-50">
{formatMemberName(walkup.currentPlayer)} can attach clips from song files in their own library to any game on{" "}
{formatTeamLabel(walkup.selectedTeam)}.
</p>
</div>
</div>
<div className="row g-4">
<div className="col-12 col-xl-6">
<div className="card shadow-sm h-100">
<div className="card-body d-grid gap-3">
<div className="d-grid gap-2">
<label className="form-label d-grid gap-2">
<span>Selected game</span>
<select className="form-select" value={selectedGameId} onChange={(event) => selectGame(event.target.value)}>
<option value="">Select a game</option>
{walkup.games.map((game) => (
<option key={String(game.id)} value={String(game.id)}>
{formatGameTitle(game)}
</option>
))}
</select>
</label>
<div className="text-body-secondary">
{selectedGame ? formatGameDate(selectedGame) : "Choose a game to attach clips."}
</div>
{walkup.nextGame ? <div className="text-body-secondary">Next game: {formatGameTitle(walkup.nextGame)}</div> : null}
</div>
</div>
</div>
</div>
<div className="col-12 col-xl-6">
<div className="card shadow-sm h-100">
<div className="card-body d-grid gap-3">
<h2 className="h4 mb-0">Attach a clip</h2>
{selectedGame ? (
<>
<div className="text-body-secondary">{formatGameDate(selectedGame)}</div>
<label className="form-label d-grid gap-2">
<span>Clip</span>
<select className="form-select" value={clipId} onChange={(event) => setClipId(Number(event.target.value))}>
<option value={0}>Select clip</option>
{clipsQuery.data?.map((clip) => (
<option key={clip.id} value={clip.id}>
{clip.label} from song {clip.asset_title}
</option>
))}
</select>
</label>
<label className="form-label d-grid gap-2">
<span>Suggested batting slot</span>
<input className="form-control" type="number" value={slot} onChange={(event) => setSlot(Number(event.target.value))} />
</label>
<button type="button" className="btn btn-primary" disabled={!clipId} onClick={() => void saveMutation.mutateAsync()}>
{saveMutation.isPending ? "Saving..." : "Attach clip to this game"}
</button>
{saveMutation.error instanceof Error ? <div className="text-body-secondary">{saveMutation.error.message}</div> : null}
</>
) : (
<div className="text-body-secondary">Pick a game to attach clips.</div>
)}
</div>
</div>
</div>
</div>
<div className="row g-4">
<div className="col-12 col-xl-6">
<div className="card shadow-sm h-100">
<div className="card-body d-grid gap-3">
<h2 className="h4 mb-0">Your selected clips</h2>
<div className="list-group">
{assignmentsQuery.data?.map((assignment) => (
<div className="list-group-item d-flex justify-content-between align-items-center" key={assignment.id}>
<div>
<strong>{assignment.clip_label}</strong>
<div className="text-body-secondary">
{assignment.asset_title} {assignment.batting_slot ? `| slot ${assignment.batting_slot}` : ""}
</div>
</div>
<span className="badge rounded-pill text-bg-warning">{assignment.status}</span>
</div>
))}
{!assignmentsQuery.isLoading && !assignmentsQuery.data?.length ? (
<div className="text-body-secondary">No clips attached to this game yet.</div>
) : null}
</div>
</div>
</div>
</div>
<div className="col-12 col-xl-6">
<div className="card shadow-sm h-100">
<div className="card-body d-grid gap-3">
<h2 className="h4 mb-0">Prepared payload</h2>
<div className="d-grid gap-2">
<div className="text-body-secondary">Prepared at: {prepQuery.data?.prepared_at ?? "Not prepared yet"}</div>
<div className="text-body-secondary">Assignments in package: {prepQuery.data?.assignments.length ?? 0}</div>
<div className="text-body-secondary">Cached locally: {cachedPrep ? `${cachedPrep.assignments.length} assignments` : "No"}</div>
</div>
<button type="button" className="btn btn-outline-secondary" onClick={cachePreparedGame} disabled={!selectedGameId}>
Cache on this device
</button>
{offlineMessage ? <div className="text-body-secondary">{offlineMessage}</div> : null}
</div>
</div>
</div>
</div>
</section>
);
}