Squash merge feature/library-reorganization
This commit is contained in:
218
frontend/src/pages/GamePage.tsx
Normal file
218
frontend/src/pages/GamePage.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user