Squash merge feature/library-reorganization
This commit is contained in:
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Walkup</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
6359
frontend/package-lock.json
generated
Normal file
6359
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/package.json
Normal file
28
frontend/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "walkup-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"teamsnap.js": "github:anthonyscorrea/teamsnap-javascript-sdk#add-eventLineup-eventLineupEntry",
|
||||
"wavesurfer.js": "^7.12.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-pwa": "^0.20.5"
|
||||
}
|
||||
}
|
||||
7
frontend/public/icon.svg
Normal file
7
frontend/public/icon.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<rect width="256" height="256" rx="48" fill="#132238"/>
|
||||
<circle cx="128" cy="128" r="78" fill="#f4ede2"/>
|
||||
<path d="M93 166V92h18l28 33 28-33h18v74h-20v-40l-26 30-26-30v40H93z" fill="#d94f04"/>
|
||||
<path d="M53 198c18-23 45-35 75-35s57 12 75 35" fill="none" stroke="#d94f04" stroke-width="12" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 392 B |
301
frontend/src/App.tsx
Normal file
301
frontend/src/App.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import { Component, useEffect, useState, type ErrorInfo, type ReactElement, type ReactNode } from "react";
|
||||
import { NavLink, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
|
||||
import { WalkupProvider, useWalkupContext } from "./hooks/useWalkupContext";
|
||||
import { useSession } from "./hooks/useSession";
|
||||
import { DashboardPage } from "./pages/DashboardPage";
|
||||
import { GamePage } from "./pages/GamePage";
|
||||
import { LibraryPage } from "./pages/LibraryPage";
|
||||
import { OperatorPage } from "./pages/OperatorPage";
|
||||
import { ProfilePage } from "./pages/ProfilePage";
|
||||
import { AdminPage } from "./pages/AdminPage";
|
||||
import { SignInPage } from "./pages/SignInPage";
|
||||
import { formatTeamLabel } from "./lib/teamsnapHelpers";
|
||||
|
||||
function getRouteDestinationLabel(pathname: string) {
|
||||
switch (pathname) {
|
||||
case "/":
|
||||
return "your dashboard";
|
||||
case "/library":
|
||||
return "walkup clips";
|
||||
case "/games":
|
||||
return "game clips";
|
||||
case "/operator":
|
||||
return "the operator console";
|
||||
default:
|
||||
return "this page";
|
||||
}
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children }: { children: ReactElement }) {
|
||||
const location = useLocation();
|
||||
const { data, isLoading } = useSession();
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container-fluid py-4">
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-body">Loading session...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!data?.authenticated) {
|
||||
return <Navigate to="/signin" replace state={{ from: location }} />;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
function HomeRoute() {
|
||||
const walkup = useWalkupContext();
|
||||
|
||||
if (walkup.sessionQuery.isLoading) {
|
||||
return (
|
||||
<div className="container-fluid py-4">
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-body">Loading session...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!walkup.sessionQuery.data?.authenticated) {
|
||||
return <SignInPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage />
|
||||
);
|
||||
}
|
||||
|
||||
function SignInRoute() {
|
||||
const walkup = useWalkupContext();
|
||||
|
||||
if (walkup.sessionQuery.isLoading) {
|
||||
return (
|
||||
<div className="container-fluid py-4">
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-body">Loading session...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (walkup.sessionQuery.data?.authenticated) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <SignInPage />;
|
||||
}
|
||||
|
||||
function TeamSelectionRoute({ children }: { children: ReactElement }) {
|
||||
return children;
|
||||
}
|
||||
|
||||
function TeamSelectionModal() {
|
||||
const location = useLocation();
|
||||
const walkup = useWalkupContext();
|
||||
|
||||
if (!walkup.isTeamSnap || !walkup.teamsQuery.isFetched || walkup.hasSelectedTeam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="position-fixed top-0 start-0 w-100 h-100 bg-dark bg-opacity-75 d-flex align-items-center justify-content-center p-3"
|
||||
role="presentation"
|
||||
>
|
||||
<section
|
||||
className="card shadow-lg border-0 w-100"
|
||||
style={{ maxWidth: "920px", maxHeight: "88vh" }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="team-selection-title"
|
||||
>
|
||||
<div className="card-body d-grid gap-4 overflow-auto p-4 p-lg-5">
|
||||
<div className="d-grid gap-2">
|
||||
<p className="text-uppercase small text-secondary-emphasis mb-0">Step 2 of 2</p>
|
||||
<h2 id="team-selection-title" className="h3 mb-0">
|
||||
Pick the team you want to use.
|
||||
</h2>
|
||||
<p className="text-body-secondary mb-0">
|
||||
You are signed in with TeamSnap. Choose a team to continue to {getRouteDestinationLabel(location.pathname)}.
|
||||
</p>
|
||||
</div>
|
||||
<div className="row g-4">
|
||||
<div className="col-12 col-lg-8">
|
||||
<div className="card shadow-sm h-100">
|
||||
<div className="card-body d-grid gap-3">
|
||||
<h3 className="h5 mb-0">Available teams</h3>
|
||||
{walkup.teamsQuery.isLoading ? (
|
||||
<div className="text-body-secondary">Loading teams...</div>
|
||||
) : (
|
||||
<div className="list-group">
|
||||
{walkup.teamsQuery.data?.map((team) => {
|
||||
const teamId = String(team.id);
|
||||
const selected = teamId === walkup.selectedTeamId;
|
||||
return (
|
||||
<button
|
||||
key={teamId}
|
||||
type="button"
|
||||
className={`list-group-item list-group-item-action d-flex justify-content-between align-items-center text-start${
|
||||
selected ? " active" : ""
|
||||
}`}
|
||||
onClick={() => walkup.selectTeam(teamId)}
|
||||
>
|
||||
<div>
|
||||
<strong>{formatTeamLabel(team)}</strong>
|
||||
<div className={selected ? "text-white-50" : "text-body-secondary"}>Tap to continue</div>
|
||||
</div>
|
||||
<span className={`badge rounded-pill ${selected ? "text-bg-light" : "text-bg-secondary"}`}>
|
||||
{selected ? "Selected" : "Choose"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!walkup.teamsQuery.data?.length ? (
|
||||
<div className="text-body-secondary">No teams were returned for this account.</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 col-lg-4">
|
||||
<div className="card bg-body-tertiary border-0 h-100">
|
||||
<div className="card-body d-grid gap-3">
|
||||
<h3 className="h5 mb-0">What happens next</h3>
|
||||
<div className="d-grid gap-2 text-body-secondary">
|
||||
<div>1. Sign in with TeamSnap.</div>
|
||||
<div>2. Choose the team you want to manage.</div>
|
||||
<div>3. Continue into the dashboard, walkup clips, or game tools.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
class AppErrorBoundary extends Component<{ children: ReactNode }, { errorMessage: string | null }> {
|
||||
state = { errorMessage: null };
|
||||
|
||||
static getDerivedStateFromError(error: unknown) {
|
||||
return {
|
||||
errorMessage: error instanceof Error ? error.message : "Unexpected render error",
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown, errorInfo: ErrorInfo) {
|
||||
console.error("Walkup render error", error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.errorMessage) {
|
||||
return (
|
||||
<div className="container-fluid py-4">
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-body d-grid gap-2">
|
||||
<h2 className="h4 mb-0">App Error</h2>
|
||||
<div className="text-body-secondary">{this.state.errorMessage}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function ShellLayout() {
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const walkup = useWalkupContext();
|
||||
const location = useLocation();
|
||||
const showNavbar = walkup.sessionQuery.data?.authenticated === true;
|
||||
const showTeamSelectionModal = walkup.isTeamSnap && walkup.teamsQuery.isFetched && !walkup.hasSelectedTeam;
|
||||
const shellClassName = showNavbar ? "shell is-authenticated" : "shell is-authless";
|
||||
|
||||
useEffect(() => {
|
||||
setNavOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showTeamSelectionModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [showTeamSelectionModal]);
|
||||
|
||||
return (
|
||||
<div className={shellClassName}>
|
||||
{showNavbar ? (
|
||||
<header className="navbar navbar-expand-lg navbar-dark bg-dark shadow-sm sticky-top px-3 py-2">
|
||||
<div className="container-fluid gap-3 align-items-center">
|
||||
<div className="navbar-brand d-grid gap-0">
|
||||
<span className="text-uppercase small text-info-emphasis">Baseball audio ops</span>
|
||||
<span className="fw-semibold fs-4 lh-1 text-white">Walkup</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="navbar-toggler"
|
||||
aria-expanded={navOpen}
|
||||
aria-controls="primary-nav"
|
||||
aria-label={navOpen ? "Close menu" : "Open menu"}
|
||||
onClick={() => setNavOpen((value) => !value)}
|
||||
>
|
||||
<span className="navbar-toggler-icon" aria-hidden="true" />
|
||||
</button>
|
||||
<nav id="primary-nav" className={`navbar-collapse collapse${navOpen ? " show" : ""}`}>
|
||||
<div className="navbar-nav ms-auto gap-2">
|
||||
<NavLink to="/" className={({ isActive }) => `nav-link${isActive ? " active" : ""}`}>
|
||||
Home
|
||||
</NavLink>
|
||||
<NavLink to="/library" className={({ isActive }) => `nav-link${isActive ? " active" : ""}`}>
|
||||
Walkup Clips
|
||||
</NavLink>
|
||||
<NavLink to="/games" className={({ isActive }) => `nav-link${isActive ? " active" : ""}`}>
|
||||
Games
|
||||
</NavLink>
|
||||
<NavLink to="/operator" className={({ isActive }) => `nav-link${isActive ? " active" : ""}`}>
|
||||
Operator
|
||||
</NavLink>
|
||||
<NavLink to="/profile" className={({ isActive }) => `nav-link${isActive ? " active" : ""}`}>
|
||||
Profile
|
||||
</NavLink>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
) : null}
|
||||
<main className="container-fluid py-4">
|
||||
<Routes>
|
||||
<Route path="/signin" element={<SignInRoute />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/profile" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
||||
<Route path="/" element={<HomeRoute />} />
|
||||
<Route path="/library" element={<ProtectedRoute><TeamSelectionRoute><LibraryPage /></TeamSelectionRoute></ProtectedRoute>} />
|
||||
<Route path="/games" element={<ProtectedRoute><TeamSelectionRoute><GamePage /></TeamSelectionRoute></ProtectedRoute>} />
|
||||
<Route path="/operator" element={<ProtectedRoute><TeamSelectionRoute><OperatorPage /></TeamSelectionRoute></ProtectedRoute>} />
|
||||
</Routes>
|
||||
</main>
|
||||
{showTeamSelectionModal ? <TeamSelectionModal /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AppErrorBoundary>
|
||||
<WalkupProvider>
|
||||
<ShellLayout />
|
||||
</WalkupProvider>
|
||||
</AppErrorBoundary>
|
||||
);
|
||||
}
|
||||
185
frontend/src/api/client.ts
Normal file
185
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import type {
|
||||
AudioAsset,
|
||||
AudioAssetImportCreate,
|
||||
AudioAssetUpdate,
|
||||
AudioClip,
|
||||
AudioClipUpdate,
|
||||
GameAssignment,
|
||||
GamePrepResponse,
|
||||
PlaybackSession,
|
||||
SessionResponse,
|
||||
TeamSnapTokenResponse,
|
||||
} from "./types";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||
|
||||
type UploadAssetPayload = {
|
||||
teamId: string;
|
||||
playerId: string;
|
||||
title: string;
|
||||
file: File;
|
||||
onProcessingStart?: () => void;
|
||||
onUploadProgress?: (percent: number) => void;
|
||||
};
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
credentials: "include",
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(detail || `Request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getSession: () => request<SessionResponse>("/auth/session"),
|
||||
startTeamSnap: (returnTo: string) =>
|
||||
request<{ authorize_url: string; state: string }>(`/auth/teamsnap/start?return_to=${encodeURIComponent(returnTo)}`),
|
||||
getTeamSnapToken: () => request<TeamSnapTokenResponse>("/auth/teamsnap/token", { method: "POST" }),
|
||||
adminLogin: (payload: { username: string; password: string }) =>
|
||||
request<SessionResponse>("/auth/admin/login", { method: "POST", body: JSON.stringify(payload) }),
|
||||
logout: () => request<{ ok: boolean }>("/auth/logout", { method: "POST" }),
|
||||
updateWalkupSessionSelection: (payload: { external_team_id: string; external_player_id: string }) =>
|
||||
request<SessionResponse>("/auth/session/walkup", { method: "POST", body: JSON.stringify(payload) }),
|
||||
listAssets: (teamId: string, playerId?: string) =>
|
||||
request<AudioAsset[]>(
|
||||
`/media/assets?external_team_id=${encodeURIComponent(teamId)}${
|
||||
playerId ? `&owner_external_player_id=${encodeURIComponent(playerId)}` : ""
|
||||
}`,
|
||||
),
|
||||
updateAsset: (assetId: number, payload: AudioAssetUpdate, ownerExternalPlayerId?: string) =>
|
||||
request<AudioAsset>(
|
||||
`/media/assets/${assetId}${ownerExternalPlayerId ? `?owner_external_player_id=${encodeURIComponent(ownerExternalPlayerId)}` : ""}`,
|
||||
{ method: "PATCH", body: JSON.stringify(payload) },
|
||||
),
|
||||
uploadAsset: async (payload: UploadAssetPayload) => {
|
||||
const formData = new FormData();
|
||||
formData.set("external_team_id", payload.teamId);
|
||||
formData.set("owner_external_player_id", payload.playerId);
|
||||
formData.set("title", payload.title);
|
||||
formData.set("file", payload.file);
|
||||
|
||||
return new Promise<AudioAsset>((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open("POST", `${API_BASE}/media/uploads`);
|
||||
request.withCredentials = true;
|
||||
|
||||
request.upload.addEventListener("progress", (event) => {
|
||||
if (!event.lengthComputable || event.total <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
payload.onUploadProgress?.(Math.min(100, Math.round((event.loaded / event.total) * 100)));
|
||||
});
|
||||
|
||||
request.upload.addEventListener("load", () => {
|
||||
payload.onUploadProgress?.(100);
|
||||
payload.onProcessingStart?.();
|
||||
});
|
||||
|
||||
request.addEventListener("load", () => {
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
reject(new Error(request.responseText || `Request failed: ${request.status}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(JSON.parse(request.responseText) as AudioAsset);
|
||||
});
|
||||
|
||||
request.addEventListener("error", () => reject(new Error("Upload failed. Check the connection and try again.")));
|
||||
request.addEventListener("abort", () => reject(new Error("Upload was cancelled.")));
|
||||
request.send(formData);
|
||||
});
|
||||
},
|
||||
importAssetFromUrl: (payload: AudioAssetImportCreate) =>
|
||||
request<AudioAsset>("/media/imports", { method: "POST", body: JSON.stringify(payload) }),
|
||||
deleteAsset: async (assetId: number, ownerExternalPlayerId?: string) => {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/media/assets/${assetId}${ownerExternalPlayerId ? `?owner_external_player_id=${encodeURIComponent(ownerExternalPlayerId)}` : ""}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
},
|
||||
updateClip: (clipId: number, payload: AudioClipUpdate, ownerExternalPlayerId?: string) =>
|
||||
request<AudioClip>(
|
||||
`/media/clips/${clipId}${ownerExternalPlayerId ? `?owner_external_player_id=${encodeURIComponent(ownerExternalPlayerId)}` : ""}`,
|
||||
{ method: "PATCH", body: JSON.stringify(payload) },
|
||||
),
|
||||
deleteClip: async (clipId: number, ownerExternalPlayerId?: string) => {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/media/clips/${clipId}${ownerExternalPlayerId ? `?owner_external_player_id=${encodeURIComponent(ownerExternalPlayerId)}` : ""}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
},
|
||||
listClips: (teamId: string, playerId?: string) =>
|
||||
request<AudioClip[]>(
|
||||
`/media/clips?external_team_id=${encodeURIComponent(teamId)}${
|
||||
playerId ? `&owner_external_player_id=${encodeURIComponent(playerId)}` : ""
|
||||
}`,
|
||||
),
|
||||
createClip: (payload: {
|
||||
asset_id: number;
|
||||
external_team_id: string;
|
||||
owner_external_player_id: string;
|
||||
label: string;
|
||||
start_ms: number;
|
||||
end_ms: number;
|
||||
}) =>
|
||||
request<AudioClip>("/media/clips", { method: "POST", body: JSON.stringify(payload) }),
|
||||
listAssignments: (gameId: string, playerId?: string) =>
|
||||
request<GameAssignment[]>(
|
||||
`/games/${encodeURIComponent(gameId)}/assignments${
|
||||
playerId ? `?external_player_id=${encodeURIComponent(playerId)}` : ""
|
||||
}`,
|
||||
),
|
||||
createAssignment: (
|
||||
gameId: string,
|
||||
payload: {
|
||||
external_team_id: string;
|
||||
external_player_id: string;
|
||||
clip_id: number;
|
||||
batting_slot?: number | null;
|
||||
status: string;
|
||||
},
|
||||
) =>
|
||||
request<GameAssignment>(`/games/${encodeURIComponent(gameId)}/assignments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
prepareGame: (gameId: string) => request<GamePrepResponse>(`/games/${encodeURIComponent(gameId)}/prep`),
|
||||
createPlaybackSession: (gameId: string, teamId: string) =>
|
||||
request<PlaybackSession>(`/games/${encodeURIComponent(gameId)}/operator/session`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ external_team_id: teamId }),
|
||||
}),
|
||||
triggerPlaybackAssignment: (gameId: string, playbackSessionId: number, assignmentId: number) =>
|
||||
request<PlaybackSession>(`/games/${encodeURIComponent(gameId)}/operator/session/${playbackSessionId}/trigger`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ assignment_id: assignmentId, state: "playing" }),
|
||||
}),
|
||||
triggerPlaybackClip: (gameId: string, playbackSessionId: number, clipId: number, playerId: string) =>
|
||||
request<PlaybackSession>(`/games/${encodeURIComponent(gameId)}/operator/session/${playbackSessionId}/trigger`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ clip_id: clipId, external_player_id: playerId, state: "playing" }),
|
||||
}),
|
||||
};
|
||||
168
frontend/src/api/types.ts
Normal file
168
frontend/src/api/types.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
export interface SessionResponse {
|
||||
authenticated: boolean;
|
||||
provider?: string | null;
|
||||
is_admin: boolean;
|
||||
external_user_id?: string | null;
|
||||
external_team_id?: string | null;
|
||||
external_player_id?: string | null;
|
||||
token_expires_at?: string | null;
|
||||
}
|
||||
|
||||
export interface TeamSnapTokenResponse {
|
||||
access_token: string;
|
||||
expires_at?: string | null;
|
||||
api_root: string;
|
||||
auth_url: string;
|
||||
}
|
||||
|
||||
export interface AudioAsset {
|
||||
id: number;
|
||||
external_team_id: string;
|
||||
owner_external_player_id: string;
|
||||
title: string;
|
||||
original_filename: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AudioAssetUpdate {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface AudioAssetImportCreate {
|
||||
external_team_id: string;
|
||||
owner_external_player_id: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface AudioClip {
|
||||
id: number;
|
||||
asset_id: number;
|
||||
external_team_id: string;
|
||||
owner_external_player_id: string;
|
||||
asset_title: string;
|
||||
label: string;
|
||||
start_ms: number;
|
||||
end_ms: number;
|
||||
normalization_status: string;
|
||||
normalized_url?: string | null;
|
||||
waveform_duration_ms?: number | null;
|
||||
waveform_peaks?: number[] | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AudioClipUpdate {
|
||||
start_ms: number;
|
||||
end_ms: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface GameAssignment {
|
||||
id: number;
|
||||
external_team_id: string;
|
||||
external_game_id: string;
|
||||
external_player_id: string;
|
||||
clip_id: number;
|
||||
clip_label: string;
|
||||
asset_title: string;
|
||||
start_ms: number;
|
||||
end_ms: number;
|
||||
batting_slot?: number | null;
|
||||
status: string;
|
||||
normalized_url?: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface GamePrepResponse {
|
||||
external_game_id: string;
|
||||
external_team_id: string;
|
||||
prepared_at: string;
|
||||
assignments: GameAssignment[];
|
||||
}
|
||||
|
||||
export interface PlaybackSession {
|
||||
id: number;
|
||||
external_team_id: string;
|
||||
external_game_id: string;
|
||||
current_assignment_id?: number | null;
|
||||
state: string;
|
||||
last_triggered_at?: string | null;
|
||||
}
|
||||
|
||||
export interface TeamSnapTeam {
|
||||
id: number | string;
|
||||
name?: string;
|
||||
seasonName?: string;
|
||||
isRetired?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TeamSnapUser {
|
||||
id: number | string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface TeamSnapMember {
|
||||
id: number | string;
|
||||
teamId?: number | string;
|
||||
userId?: number | string;
|
||||
isNonPlayer?: boolean;
|
||||
number?: number | string;
|
||||
jerseyNumber?: number | string;
|
||||
jersey_number?: number | string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface TeamSnapAvailability {
|
||||
id: number | string;
|
||||
teamId?: number | string;
|
||||
eventId?: number | string;
|
||||
memberId?: number | string;
|
||||
statusCode?: number | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TeamSnapEvent {
|
||||
id: number | string;
|
||||
teamId?: number | string;
|
||||
name?: string;
|
||||
isGame?: boolean;
|
||||
opponentName?: string;
|
||||
locationName?: string;
|
||||
startDate?: string | Date;
|
||||
}
|
||||
|
||||
export interface TeamSnapEventLineup {
|
||||
id: number | string;
|
||||
eventId?: number | string;
|
||||
isPublished?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TeamSnapEventLineupEntry {
|
||||
id: number | string;
|
||||
eventLineupId?: number | string;
|
||||
memberId?: number | string;
|
||||
label?: string;
|
||||
sequence?: number | string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TeamSnapAssignment {
|
||||
id: number | string;
|
||||
teamId?: number | string;
|
||||
eventId?: number | string;
|
||||
memberId?: number | string;
|
||||
description?: string;
|
||||
order?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
11
frontend/src/hooks/useSession.ts
Normal file
11
frontend/src/hooks/useSession.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "../api/client";
|
||||
|
||||
export function useSession() {
|
||||
return useQuery({
|
||||
queryKey: ["session"],
|
||||
queryFn: api.getSession,
|
||||
});
|
||||
}
|
||||
|
||||
135
frontend/src/hooks/useWalkupContext.tsx
Normal file
135
frontend/src/hooks/useWalkupContext.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type { TeamSnapEvent, TeamSnapMember } from "../api/types";
|
||||
import { queryClient } from "../lib/queryClient";
|
||||
import { findCurrentPlayer, findNextGame, sortGames } from "../lib/teamsnapHelpers";
|
||||
import { teamsnapClient } from "../lib/teamsnap";
|
||||
import { useSession } from "./useSession";
|
||||
|
||||
const TEAM_STORAGE_KEY = "walkup.selectedTeamId";
|
||||
|
||||
function readStoredTeamId(): string {
|
||||
if (typeof window === "undefined") {
|
||||
return "";
|
||||
}
|
||||
return window.localStorage.getItem(TEAM_STORAGE_KEY) ?? "";
|
||||
}
|
||||
|
||||
type WalkupContextValue = ReturnType<typeof useBuildWalkupContext>;
|
||||
|
||||
const WalkupContext = createContext<WalkupContextValue | null>(null);
|
||||
|
||||
function useBuildWalkupContext() {
|
||||
const sessionQuery = useSession();
|
||||
const isTeamSnap = sessionQuery.data?.authenticated === true && sessionQuery.data?.provider === "teamsnap";
|
||||
const [selectedTeamId, setSelectedTeamId] = useState(readStoredTeamId);
|
||||
const teamsQuery = useQuery({
|
||||
queryKey: ["teamsnap", "teams"],
|
||||
queryFn: () => teamsnapClient.loadTeams(),
|
||||
enabled: isTeamSnap,
|
||||
});
|
||||
|
||||
const teams = teamsQuery.data ?? [];
|
||||
const selectedTeam = teams.find((team) => String(team.id) === selectedTeamId) ?? null;
|
||||
const resolvedTeamId = selectedTeam ? String(selectedTeam.id) : "";
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTeamId && !selectedTeam && teams.length) {
|
||||
setSelectedTeamId("");
|
||||
window.localStorage.removeItem(TEAM_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedTeamId) {
|
||||
window.localStorage.removeItem(TEAM_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(TEAM_STORAGE_KEY, selectedTeamId);
|
||||
}, [resolvedTeamId, selectedTeam, selectedTeamId, teams.length]);
|
||||
|
||||
const membersQuery = useQuery({
|
||||
queryKey: ["teamsnap", "members", resolvedTeamId],
|
||||
queryFn: () => teamsnapClient.loadMembers(resolvedTeamId),
|
||||
enabled: isTeamSnap && Boolean(resolvedTeamId),
|
||||
});
|
||||
const eventsQuery = useQuery({
|
||||
queryKey: ["teamsnap", "events", resolvedTeamId],
|
||||
queryFn: () => teamsnapClient.loadEvents(resolvedTeamId),
|
||||
enabled: isTeamSnap && Boolean(resolvedTeamId),
|
||||
});
|
||||
|
||||
const members: TeamSnapMember[] = membersQuery.data ?? [];
|
||||
const games: TeamSnapEvent[] = sortGames(eventsQuery.data ?? []);
|
||||
const currentPlayer = findCurrentPlayer(sessionQuery.data?.external_user_id, members);
|
||||
const nextGame = findNextGame(games);
|
||||
const currentPlayerId =
|
||||
sessionQuery.data?.external_team_id === selectedTeamId && sessionQuery.data?.external_player_id
|
||||
? String(sessionQuery.data.external_player_id)
|
||||
: currentPlayer
|
||||
? String(currentPlayer.id)
|
||||
: "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTeamSnap || !resolvedTeamId || !currentPlayer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedPlayerId = String(currentPlayer.id);
|
||||
if (
|
||||
sessionQuery.data?.external_team_id === selectedTeamId &&
|
||||
sessionQuery.data?.external_player_id === selectedPlayerId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
void api
|
||||
.updateWalkupSessionSelection({
|
||||
external_team_id: resolvedTeamId,
|
||||
external_player_id: selectedPlayerId,
|
||||
})
|
||||
.then(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["session"] });
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the UI working even if the session cache update fails.
|
||||
});
|
||||
}, [currentPlayer, isTeamSnap, resolvedTeamId, selectedTeamId, sessionQuery.data?.external_player_id, sessionQuery.data?.external_team_id]);
|
||||
|
||||
function selectTeam(teamId: string) {
|
||||
setSelectedTeamId(teamId);
|
||||
}
|
||||
|
||||
return {
|
||||
isTeamSnap,
|
||||
sessionQuery,
|
||||
teamsQuery,
|
||||
selectedTeam,
|
||||
selectedTeamId,
|
||||
hasSelectedTeam: Boolean(resolvedTeamId),
|
||||
selectTeam,
|
||||
membersQuery,
|
||||
members,
|
||||
currentPlayer,
|
||||
currentPlayerId,
|
||||
eventsQuery,
|
||||
games,
|
||||
nextGame,
|
||||
};
|
||||
}
|
||||
|
||||
export function WalkupProvider({ children }: { children: ReactNode }) {
|
||||
const value = useBuildWalkupContext();
|
||||
const memoizedValue = useMemo(() => value, [value]);
|
||||
return <WalkupContext.Provider value={memoizedValue}>{children}</WalkupContext.Provider>;
|
||||
}
|
||||
|
||||
export function useWalkupContext() {
|
||||
const value = useContext(WalkupContext);
|
||||
if (!value) {
|
||||
throw new Error("useWalkupContext must be used within WalkupProvider");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
26
frontend/src/lib/media.ts
Normal file
26
frontend/src/lib/media.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
function formatSecondsValue(milliseconds: number): string {
|
||||
return (milliseconds / 1000).toFixed(1);
|
||||
}
|
||||
|
||||
export function formatClipRange(startMs: number, endMs: number): string {
|
||||
return `${formatSecondsValue(startMs)}s to ${formatSecondsValue(endMs)}s`;
|
||||
}
|
||||
|
||||
export function formatPlaybackPosition(milliseconds: number): string {
|
||||
const roundedSeconds = Math.round(Math.max(0, milliseconds) / 100) / 10;
|
||||
const wholeSeconds = Math.floor(roundedSeconds);
|
||||
const tenths = Math.round((roundedSeconds - wholeSeconds) * 10);
|
||||
const hours = Math.floor(wholeSeconds / 3600);
|
||||
const minutes = Math.floor((wholeSeconds % 3600) / 60);
|
||||
const seconds = wholeSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${tenths}`;
|
||||
}
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${minutes}:${String(seconds).padStart(2, "0")}.${tenths}`;
|
||||
}
|
||||
|
||||
return `${seconds}.${tenths}s`;
|
||||
}
|
||||
19
frontend/src/lib/offlinePrep.ts
Normal file
19
frontend/src/lib/offlinePrep.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { GamePrepResponse } from "../api/types";
|
||||
|
||||
const KEY_PREFIX = "walkup-prep:";
|
||||
|
||||
export function savePreparedGame(gameId: string, payload: GamePrepResponse): void {
|
||||
localStorage.setItem(`${KEY_PREFIX}${gameId}`, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export function loadPreparedGame(gameId: string): GamePrepResponse | null {
|
||||
const raw = localStorage.getItem(`${KEY_PREFIX}${gameId}`);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as GamePrepResponse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
4
frontend/src/lib/queryClient.ts
Normal file
4
frontend/src/lib/queryClient.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient();
|
||||
|
||||
186
frontend/src/lib/teamsnap.ts
Normal file
186
frontend/src/lib/teamsnap.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import teamsnapScriptUrl from "teamsnap.js/lib/teamsnap.js?url";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type {
|
||||
TeamSnapAssignment,
|
||||
TeamSnapAvailability,
|
||||
TeamSnapEvent,
|
||||
TeamSnapEventLineup,
|
||||
TeamSnapEventLineupEntry,
|
||||
TeamSnapMember,
|
||||
TeamSnapTeam,
|
||||
TeamSnapUser,
|
||||
} from "../api/types";
|
||||
|
||||
type TeamSnapSdk = {
|
||||
auth?: (token: string) => Promise<void> | void;
|
||||
enablePersistence?: () => void;
|
||||
loadCollections?: () => Promise<void>;
|
||||
loadMe?: () => Promise<TeamSnapUser>;
|
||||
loadTeams?: (...args: unknown[]) => Promise<TeamSnapTeam[]>;
|
||||
loadMembers?: (params: unknown) => Promise<TeamSnapMember[]>;
|
||||
loadEvents?: (params: unknown) => Promise<TeamSnapEvent[]>;
|
||||
loadEventLineups?: (params: unknown) => Promise<TeamSnapEventLineup[]>;
|
||||
loadAvailabilities?: (params: unknown) => Promise<TeamSnapAvailability[]>;
|
||||
loadAssignments?: (params: unknown) => Promise<TeamSnapAssignment[]>;
|
||||
bulkLoad?: (teamId: string | number, typesOrParams?: unknown) => Promise<TeamSnapBulkItem[]>;
|
||||
createEventLineup?: (data?: Record<string, unknown>) => unknown;
|
||||
saveEventLineup?: (eventLineup: unknown) => Promise<unknown> | void;
|
||||
deleteEventLineup?: (eventLineup: unknown) => Promise<unknown> | void;
|
||||
createEventLineupEntry?: (data?: Record<string, unknown>) => unknown;
|
||||
saveEventLineupEntry?: (eventLineupEntry: unknown) => Promise<unknown> | void;
|
||||
deleteEventLineupEntry?: (eventLineupEntry: unknown) => Promise<unknown> | void;
|
||||
memberName?: (member: TeamSnapMember, reverse?: boolean, forSort?: boolean) => string;
|
||||
collections?: {
|
||||
};
|
||||
};
|
||||
|
||||
type TeamSnapBulkItem = {
|
||||
type?: string;
|
||||
id?: number | string;
|
||||
eventId?: number | string;
|
||||
eventLineupId?: number | string;
|
||||
memberId?: number | string;
|
||||
sequence?: number | string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
let sdkPromise: Promise<TeamSnapSdk> | null = null;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
teamsnap?: TeamSnapSdk;
|
||||
}
|
||||
}
|
||||
|
||||
function loadScript(src: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const existing = document.querySelector<HTMLScriptElement>(`script[data-sdk="teamsnap"][src="${src}"]`);
|
||||
if (existing) {
|
||||
if (window.teamsnap) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
existing.addEventListener("load", () => resolve(), { once: true });
|
||||
existing.addEventListener("error", () => reject(new Error("Failed to load TeamSnap SDK")), { once: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.dataset.sdk = "teamsnap";
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error("Failed to load TeamSnap SDK"));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
async function getSdk(): Promise<TeamSnapSdk> {
|
||||
if (!sdkPromise) {
|
||||
sdkPromise = loadScript(teamsnapScriptUrl).then(() => {
|
||||
if (!window.teamsnap) {
|
||||
throw new Error("TeamSnap SDK did not initialize");
|
||||
}
|
||||
return window.teamsnap;
|
||||
});
|
||||
}
|
||||
return sdkPromise;
|
||||
}
|
||||
|
||||
async function ensureAuthorized(): Promise<TeamSnapSdk> {
|
||||
const sdk = await getSdk();
|
||||
const token = await api.getTeamSnapToken();
|
||||
(sdk as TeamSnapSdk & { apiUrl?: string; authUrl?: string }).apiUrl = token.api_root;
|
||||
(sdk as TeamSnapSdk & { apiUrl?: string; authUrl?: string }).authUrl = token.auth_url;
|
||||
if (sdk.auth) {
|
||||
await sdk.auth(token.access_token);
|
||||
}
|
||||
if (sdk.loadCollections) {
|
||||
await sdk.loadCollections();
|
||||
}
|
||||
if (sdk.enablePersistence) {
|
||||
sdk.enablePersistence();
|
||||
}
|
||||
return sdk;
|
||||
}
|
||||
|
||||
export const teamsnapClient = {
|
||||
async loadMe(): Promise<TeamSnapUser | null> {
|
||||
const sdk = await ensureAuthorized();
|
||||
if (sdk.loadMe) {
|
||||
return sdk.loadMe();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
async loadTeams(): Promise<TeamSnapTeam[]> {
|
||||
const sdk = await ensureAuthorized();
|
||||
if (sdk.loadTeams) {
|
||||
const teams = await sdk.loadTeams();
|
||||
return teams.filter((team) => team.isRetired !== true);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
async loadMembers(teamId: string): Promise<TeamSnapMember[]> {
|
||||
const sdk = await ensureAuthorized();
|
||||
if (sdk.loadMembers) {
|
||||
return sdk.loadMembers({ teamId });
|
||||
}
|
||||
return [];
|
||||
},
|
||||
async loadEvents(teamId: string): Promise<TeamSnapEvent[]> {
|
||||
const sdk = await ensureAuthorized();
|
||||
if (sdk.loadEvents) {
|
||||
return sdk.loadEvents({ teamId });
|
||||
}
|
||||
return [];
|
||||
},
|
||||
async loadAvailabilities(teamId: string, eventId?: string): Promise<TeamSnapAvailability[]> {
|
||||
const sdk = await ensureAuthorized();
|
||||
if (sdk.loadAvailabilities) {
|
||||
return sdk.loadAvailabilities(eventId ? { teamId, eventId } : { teamId });
|
||||
}
|
||||
return [];
|
||||
},
|
||||
async loadAssignments(teamId: string, eventId?: string): Promise<TeamSnapAssignment[]> {
|
||||
const sdk = await ensureAuthorized();
|
||||
if (sdk.loadAssignments) {
|
||||
return sdk.loadAssignments(eventId ? { teamId, eventId } : { teamId });
|
||||
}
|
||||
return [];
|
||||
},
|
||||
async loadEventLineupData(teamId: string, eventId: string): Promise<{
|
||||
eventLineup: TeamSnapEventLineup | null;
|
||||
entries: TeamSnapEventLineupEntry[];
|
||||
}> {
|
||||
const sdk = await ensureAuthorized();
|
||||
if (!sdk.loadEventLineups) {
|
||||
return { eventLineup: null, entries: [] };
|
||||
}
|
||||
|
||||
const eventLineups = await sdk.loadEventLineups(eventId);
|
||||
const eventLineup = eventLineups.length ? eventLineups[eventLineups.length - 1] : null;
|
||||
|
||||
const eventLineupWithLinks = eventLineup as TeamSnapEventLineup & {
|
||||
loadItems?: (linkName: string) => Promise<TeamSnapEventLineupEntry[]>;
|
||||
} | null;
|
||||
if (!eventLineupWithLinks?.loadItems) {
|
||||
return { eventLineup, entries: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const rawEntries = await eventLineupWithLinks.loadItems("eventLineupEntries");
|
||||
const entries = rawEntries
|
||||
.filter((item): item is TeamSnapEventLineupEntry => item.type === "eventLineupEntry")
|
||||
.sort((left, right) => {
|
||||
const leftSequence = Number(left.sequence ?? Number.MAX_SAFE_INTEGER);
|
||||
const rightSequence = Number(right.sequence ?? Number.MAX_SAFE_INTEGER);
|
||||
return leftSequence - rightSequence;
|
||||
});
|
||||
|
||||
return { eventLineup, entries };
|
||||
} catch {
|
||||
return { eventLineup, entries: [] };
|
||||
}
|
||||
},
|
||||
};
|
||||
334
frontend/src/lib/teamsnapHelpers.ts
Normal file
334
frontend/src/lib/teamsnapHelpers.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import type {
|
||||
TeamSnapAssignment,
|
||||
TeamSnapAvailability,
|
||||
TeamSnapEvent,
|
||||
TeamSnapEventLineupEntry,
|
||||
TeamSnapMember,
|
||||
TeamSnapTeam,
|
||||
} from "../api/types";
|
||||
|
||||
function asDisplayText(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function toDate(value: Date | string | undefined | null): Date | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
export function formatMemberName(member: TeamSnapMember | null | undefined): string {
|
||||
if (!member) {
|
||||
return "Unknown player";
|
||||
}
|
||||
const sdkName = typeof window !== "undefined" ? window.teamsnap?.memberName?.(member) : "";
|
||||
if (typeof sdkName === "string" && sdkName.trim()) {
|
||||
return sdkName.trim();
|
||||
}
|
||||
|
||||
const name =
|
||||
asDisplayText(member.name) ||
|
||||
asDisplayText(member.fullName) ||
|
||||
asDisplayText(member.displayName) ||
|
||||
[asDisplayText(member.firstName), asDisplayText(member.lastName)].filter(Boolean).join(" ").trim();
|
||||
return name || `Player ${member.id}`;
|
||||
}
|
||||
|
||||
export function formatMemberJerseyNumber(member: TeamSnapMember | null | undefined): string {
|
||||
if (!member) {
|
||||
return "";
|
||||
}
|
||||
const value =
|
||||
member.number ??
|
||||
member.jerseyNumber ??
|
||||
member.jersey_number;
|
||||
const text = asDisplayText(value);
|
||||
return text ? `#${text}` : "";
|
||||
}
|
||||
|
||||
export function formatTeamLabel(team: TeamSnapTeam | null | undefined): string {
|
||||
if (!team) {
|
||||
return "No team selected";
|
||||
}
|
||||
const teamName = asDisplayText(team.name) || `Team ${team.id}`;
|
||||
const seasonName = asDisplayText(team.seasonName);
|
||||
return seasonName ? `${teamName} (${seasonName})` : teamName;
|
||||
}
|
||||
|
||||
export function findCurrentPlayer(externalUserId: string | number | null | undefined, members: TeamSnapMember[]): TeamSnapMember | null {
|
||||
if (externalUserId == null || externalUserId === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const meId = String(externalUserId);
|
||||
|
||||
return (
|
||||
members.find((member) => member.userId != null && String(member.userId) === meId) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function formatGameTitle(game: TeamSnapEvent): string {
|
||||
const name = asDisplayText(game.name);
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
const opponentName = asDisplayText(game.opponentName);
|
||||
if (opponentName) {
|
||||
return `vs ${opponentName}`;
|
||||
}
|
||||
return `Game ${game.id}`;
|
||||
}
|
||||
|
||||
export function formatGameDate(game: TeamSnapEvent): string {
|
||||
const date = toDate(game.startDate);
|
||||
if (!date) {
|
||||
return "Date TBD";
|
||||
}
|
||||
return date.toLocaleString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function sortGames(events: TeamSnapEvent[]): TeamSnapEvent[] {
|
||||
return [...events]
|
||||
.filter((event) => event.isGame)
|
||||
.sort((left, right) => {
|
||||
const leftTime = toDate(left.startDate)?.getTime() ?? Number.MAX_SAFE_INTEGER;
|
||||
const rightTime = toDate(right.startDate)?.getTime() ?? Number.MAX_SAFE_INTEGER;
|
||||
return leftTime - rightTime;
|
||||
});
|
||||
}
|
||||
|
||||
export function findNextGame(games: TeamSnapEvent[]): TeamSnapEvent | null {
|
||||
const now = Date.now();
|
||||
return games.find((game) => {
|
||||
const start = toDate(game.startDate);
|
||||
return start ? start.getTime() >= now : false;
|
||||
}) ?? games[0] ?? null;
|
||||
}
|
||||
|
||||
function toId(value: number | string | undefined | null): string {
|
||||
return value == null ? "" : String(value);
|
||||
}
|
||||
|
||||
function toSequence(value: number | string | null | undefined): number {
|
||||
if (value == null || value === "") {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
function getMemberLookupName(member: TeamSnapMember): string {
|
||||
return [asDisplayText(member.firstName), asDisplayText(member.lastName)].filter(Boolean).join(" ").trim();
|
||||
}
|
||||
|
||||
function getLineupEntryMemberName(entry: TeamSnapEventLineupEntry): string {
|
||||
return asDisplayText(entry.memberName);
|
||||
}
|
||||
|
||||
function getMemberLastName(member: TeamSnapMember): string {
|
||||
return asDisplayText(member.lastName);
|
||||
}
|
||||
|
||||
function matchesLineupEntry(member: TeamSnapMember, entry: TeamSnapEventLineupEntry): boolean {
|
||||
const memberId = toId(member.id);
|
||||
if (memberId && toId(entry.memberId) === memberId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const memberName = getMemberLookupName(member);
|
||||
const entryMemberName = getLineupEntryMemberName(entry);
|
||||
return memberName !== "" && entryMemberName !== "" && memberName === entryMemberName;
|
||||
}
|
||||
|
||||
export function findLineupEntryForMember(
|
||||
member: TeamSnapMember,
|
||||
lineupEntries: TeamSnapEventLineupEntry[],
|
||||
): TeamSnapEventLineupEntry | null {
|
||||
const matchingEntries = lineupEntries
|
||||
.filter((entry) => matchesLineupEntry(member, entry))
|
||||
.sort((left, right) => {
|
||||
const leftSequence = toSequence(left.sequence);
|
||||
const rightSequence = toSequence(right.sequence);
|
||||
if (leftSequence !== rightSequence) {
|
||||
return leftSequence - rightSequence;
|
||||
}
|
||||
return toId(left.id).localeCompare(toId(right.id));
|
||||
});
|
||||
|
||||
return matchingEntries[0] ?? null;
|
||||
}
|
||||
|
||||
export function orderMembersByAssignments(
|
||||
members: TeamSnapMember[],
|
||||
assignments: TeamSnapAssignment[],
|
||||
): TeamSnapMember[] {
|
||||
if (!assignments.length) {
|
||||
return members;
|
||||
}
|
||||
|
||||
const byId = new Map(members.map((member) => [toId(member.id), member] as const));
|
||||
const ordered: TeamSnapMember[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const assignment of assignments) {
|
||||
const memberId = toId(assignment.memberId);
|
||||
if (!memberId || seen.has(memberId)) {
|
||||
continue;
|
||||
}
|
||||
const member = byId.get(memberId);
|
||||
if (!member) {
|
||||
continue;
|
||||
}
|
||||
ordered.push(member);
|
||||
seen.add(memberId);
|
||||
}
|
||||
|
||||
for (const member of members) {
|
||||
const memberId = toId(member.id);
|
||||
if (seen.has(memberId)) {
|
||||
continue;
|
||||
}
|
||||
ordered.push(member);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export function isPlayerMember(member: TeamSnapMember): boolean {
|
||||
return member.isNonPlayer !== true;
|
||||
}
|
||||
|
||||
export function getAvailabilityRank(statusCode: number | null | undefined): number {
|
||||
if (statusCode === 1) {
|
||||
return 0;
|
||||
}
|
||||
if (statusCode === 2) {
|
||||
return 1;
|
||||
}
|
||||
if (statusCode == null) {
|
||||
return 2;
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
|
||||
export function getAvailabilityLabel(statusCode: number | null | undefined): string {
|
||||
if (statusCode === 1) {
|
||||
return "Yes";
|
||||
}
|
||||
if (statusCode === 2) {
|
||||
return "Maybe";
|
||||
}
|
||||
if (statusCode === 0) {
|
||||
return "No";
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
export function orderMembersByLineupAndRsvps(
|
||||
members: TeamSnapMember[],
|
||||
lineupEntries: TeamSnapEventLineupEntry[],
|
||||
availabilities: TeamSnapAvailability[],
|
||||
): TeamSnapMember[] {
|
||||
if (!members.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ordered: TeamSnapMember[] = [];
|
||||
const seen = new Set<string>();
|
||||
const availabilityByMemberId = new Map<string, TeamSnapAvailability>();
|
||||
|
||||
for (const availability of availabilities) {
|
||||
const memberId = toId(availability.memberId);
|
||||
if (memberId) {
|
||||
availabilityByMemberId.set(memberId, availability);
|
||||
}
|
||||
}
|
||||
|
||||
const lineupMembers: TeamSnapMember[] = [];
|
||||
const lineupMembersSeen = new Set<string>();
|
||||
|
||||
for (const entry of lineupEntries.slice().sort((left, right) => {
|
||||
const leftSequence = toSequence(left.sequence);
|
||||
const rightSequence = toSequence(right.sequence);
|
||||
if (leftSequence !== rightSequence) {
|
||||
return leftSequence - rightSequence;
|
||||
}
|
||||
return toId(left.id).localeCompare(toId(right.id));
|
||||
})) {
|
||||
const member = members.find((candidate) => matchesLineupEntry(candidate, entry));
|
||||
if (!member) {
|
||||
continue;
|
||||
}
|
||||
const memberId = toId(member.id);
|
||||
if (lineupMembersSeen.has(memberId)) {
|
||||
continue;
|
||||
}
|
||||
lineupMembers.push(member);
|
||||
lineupMembersSeen.add(memberId);
|
||||
}
|
||||
|
||||
for (const member of lineupMembers) {
|
||||
const memberId = toId(member.id);
|
||||
if (seen.has(memberId)) {
|
||||
continue;
|
||||
}
|
||||
ordered.push(member);
|
||||
seen.add(memberId);
|
||||
}
|
||||
|
||||
const rankedMembers = members
|
||||
.filter((member) => isPlayerMember(member))
|
||||
.filter((member) => !seen.has(toId(member.id)))
|
||||
.map((member, index) => {
|
||||
const memberId = toId(member.id);
|
||||
const availability = availabilityByMemberId.get(memberId);
|
||||
return {
|
||||
member,
|
||||
rank: getAvailabilityRank(availability?.statusCode as number | null | undefined),
|
||||
index,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (left.rank !== right.rank) {
|
||||
return left.rank - right.rank;
|
||||
}
|
||||
const leftLastName = getMemberLastName(left.member).toLowerCase();
|
||||
const rightLastName = getMemberLastName(right.member).toLowerCase();
|
||||
if (leftLastName !== rightLastName) {
|
||||
return leftLastName.localeCompare(rightLastName);
|
||||
}
|
||||
const leftFirstName = asDisplayText(left.member.firstName).toLowerCase();
|
||||
const rightFirstName = asDisplayText(right.member.firstName).toLowerCase();
|
||||
if (leftFirstName !== rightFirstName) {
|
||||
return leftFirstName.localeCompare(rightFirstName);
|
||||
}
|
||||
return left.index - right.index;
|
||||
})
|
||||
;
|
||||
|
||||
for (const entry of rankedMembers) {
|
||||
ordered.push(entry.member);
|
||||
seen.add(toId(entry.member.id));
|
||||
}
|
||||
|
||||
for (const member of members) {
|
||||
const memberId = toId(member.id);
|
||||
if (seen.has(memberId)) {
|
||||
continue;
|
||||
}
|
||||
ordered.push(member);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
19
frontend/src/main.tsx
Normal file
19
frontend/src/main.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
import App from "./App";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
import "bootstrap/dist/css/bootstrap.css";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
68
frontend/src/pages/AdminPage.tsx
Normal file
68
frontend/src/pages/AdminPage.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import { queryClient } from "../lib/queryClient";
|
||||
|
||||
export function AdminPage() {
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState("admin");
|
||||
const [password, setPassword] = useState("admin");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleAdminLogin(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
await api.adminLogin({ username, password });
|
||||
await queryClient.invalidateQueries({ queryKey: ["session"] });
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unable to sign in");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="container-fluid min-vh-100 d-flex align-items-center justify-content-center py-4">
|
||||
<div className="row w-100 justify-content-center g-4">
|
||||
<div className="col-12 col-md-8 col-lg-5 col-xl-4">
|
||||
<div className="card shadow-sm border-0">
|
||||
<div className="card-body p-4 p-lg-5 d-grid gap-4">
|
||||
<div className="d-grid gap-2">
|
||||
<p className="text-uppercase small text-primary-emphasis mb-0">Support</p>
|
||||
<h1 className="h2 mb-0">Admin sign-in</h1>
|
||||
<p className="text-body-secondary mb-0">Use local credentials for bootstrap and maintenance.</p>
|
||||
</div>
|
||||
<form className="d-grid gap-3" onSubmit={handleAdminLogin}>
|
||||
<label className="form-label d-grid gap-2">
|
||||
<span>Username</span>
|
||||
<input className="form-control" value={username} onChange={(event) => setUsername(event.target.value)} />
|
||||
</label>
|
||||
<label className="form-label d-grid gap-2">
|
||||
<span>Password</span>
|
||||
<input
|
||||
className="form-control"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary btn-lg">
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
<div className="small text-body-secondary">TeamSnap sign-in remains the normal entry point.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="col-12 col-md-8 col-lg-5 col-xl-4">
|
||||
<div className="alert alert-danger mb-0" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
96
frontend/src/pages/DashboardPage.tsx
Normal file
96
frontend/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useWalkupContext } from "../hooks/useWalkupContext";
|
||||
import { formatGameDate, formatGameTitle, formatMemberName } from "../lib/teamsnapHelpers";
|
||||
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const walkup = useWalkupContext();
|
||||
|
||||
if (!walkup.isTeamSnap) {
|
||||
return (
|
||||
<section className="container-fluid py-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">Player flow</p>
|
||||
<h1 className="h2">Sign in with TeamSnap to resolve your player and team context.</h1>
|
||||
<p className="mb-0 text-white-50">The player dashboard depends on your TeamSnap user, roster membership, and upcoming games.</p>
|
||||
</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">Player dashboard</p>
|
||||
<h1 className="h2">{walkup.nextGame ? formatGameTitle(walkup.nextGame) : "No upcoming game found yet."}</h1>
|
||||
<p className="mb-0 text-white-50">
|
||||
{walkup.currentPlayer
|
||||
? `${formatMemberName(walkup.currentPlayer)} is ready for the selected team.`
|
||||
: "Your TeamSnap user is connected, but no matching player record was found on the selected team."}
|
||||
</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">
|
||||
<h2 className="h4 mb-0">Next game</h2>
|
||||
{walkup.nextGame ? (
|
||||
<>
|
||||
<strong className="fs-5">{formatGameTitle(walkup.nextGame)}</strong>
|
||||
<div className="text-body-secondary">{formatGameDate(walkup.nextGame)}</div>
|
||||
{walkup.nextGame.locationName ? <div className="text-body-secondary">{walkup.nextGame.locationName}</div> : null}
|
||||
<div className="d-flex flex-wrap gap-2">
|
||||
<button type="button" className="btn btn-primary" onClick={() => navigate("/library")}>
|
||||
Add walkup clip
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={() => navigate(`/games?gameId=${encodeURIComponent(String(walkup.nextGame?.id ?? ""))}`)}
|
||||
>
|
||||
Attach clip to game
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-body-secondary">No upcoming games were returned for this team.</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">Other games</h2>
|
||||
<div className="list-group">
|
||||
{walkup.eventsQuery.isLoading ? <div className="text-body-secondary">Loading games...</div> : null}
|
||||
{walkup.games.slice(0, 8).map((game) => (
|
||||
<button
|
||||
key={String(game.id)}
|
||||
type="button"
|
||||
className="list-group-item list-group-item-action d-flex justify-content-between align-items-center text-start"
|
||||
onClick={() => navigate(`/games?gameId=${encodeURIComponent(String(game.id))}`)}
|
||||
>
|
||||
<div>
|
||||
<strong>{formatGameTitle(game)}</strong>
|
||||
<div className="text-body-secondary">{formatGameDate(game)}</div>
|
||||
</div>
|
||||
<span className="badge rounded-pill text-bg-warning">{String(game.id) === String(walkup.nextGame?.id) ? "Next" : "Browse"}</span>
|
||||
</button>
|
||||
))}
|
||||
{!walkup.eventsQuery.isLoading && !walkup.games.length ? (
|
||||
<div className="text-body-secondary">No games were returned for the selected team.</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
1462
frontend/src/pages/LibraryPage.tsx
Normal file
1462
frontend/src/pages/LibraryPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
673
frontend/src/pages/OperatorPage.tsx
Normal file
673
frontend/src/pages/OperatorPage.tsx
Normal file
@@ -0,0 +1,673 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type { AudioClip, GameAssignment } from "../api/types";
|
||||
import { useWalkupContext } from "../hooks/useWalkupContext";
|
||||
import { loadPreparedGame } from "../lib/offlinePrep";
|
||||
import { teamsnapClient } from "../lib/teamsnap";
|
||||
import {
|
||||
formatGameDate,
|
||||
formatGameTitle,
|
||||
formatMemberName,
|
||||
formatMemberJerseyNumber,
|
||||
formatTeamLabel,
|
||||
findLineupEntryForMember,
|
||||
isPlayerMember,
|
||||
orderMembersByLineupAndRsvps,
|
||||
} from "../lib/teamsnapHelpers";
|
||||
|
||||
function clipKey(kind: "assignment" | "library", id: number | string): string {
|
||||
return `${kind}:${id}`;
|
||||
}
|
||||
|
||||
type NowPlaying = {
|
||||
key: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
const DEFAULT_FADE_OUT_MS = 1000;
|
||||
|
||||
function getAvailabilityDotClass(statusCode: number | null | undefined): string {
|
||||
if (statusCode === 1) {
|
||||
return "is-yes";
|
||||
}
|
||||
if (statusCode === 0) {
|
||||
return "is-no";
|
||||
}
|
||||
if (statusCode === 2) {
|
||||
return "is-maybe";
|
||||
}
|
||||
return "is-blank";
|
||||
}
|
||||
|
||||
function getAvailabilityDotLabel(statusCode: number | null | undefined): string {
|
||||
if (statusCode === 1) {
|
||||
return "Availability yes";
|
||||
}
|
||||
if (statusCode === 0) {
|
||||
return "Availability no";
|
||||
}
|
||||
if (statusCode === 2) {
|
||||
return "Availability maybe";
|
||||
}
|
||||
return "Availability unset";
|
||||
}
|
||||
|
||||
export function OperatorPage() {
|
||||
const walkup = useWalkupContext();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [selectedGameId, setSelectedGameId] = useState(searchParams.get("gameId") ?? "");
|
||||
const [selectedPlayerId, setSelectedPlayerId] = useState("");
|
||||
const [expandedPlayerId, setExpandedPlayerId] = useState("");
|
||||
const [playerFilter, setPlayerFilter] = useState<"players" | "nonPlayers" | "all">("players");
|
||||
const [playbackSessionId, setPlaybackSessionId] = useState<number | null>(null);
|
||||
const [playingClipKey, setPlayingClipKey] = useState<string | null>(null);
|
||||
const [nowPlaying, setNowPlaying] = useState<NowPlaying | null>(null);
|
||||
const [isPlaybackPlaying, setIsPlaybackPlaying] = useState(false);
|
||||
const selectedPlayerWasManualRef = useRef(false);
|
||||
const hasInitializedExpandedPlayerRef = useRef(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const mediaSourceRef = useRef<MediaElementAudioSourceNode | null>(null);
|
||||
const gainNodeRef = useRef<GainNode | null>(null);
|
||||
const playbackRangeRef = useRef<{ startSeconds: number; endSeconds: number } | null>(null);
|
||||
const fadeOutTimerRef = useRef<number | null>(null);
|
||||
const teamId = walkup.selectedTeamId;
|
||||
|
||||
useEffect(() => {
|
||||
const requestedGameId = searchParams.get("gameId");
|
||||
if (requestedGameId) {
|
||||
setSelectedGameId(requestedGameId);
|
||||
return;
|
||||
}
|
||||
if (!selectedGameId && walkup.nextGame) {
|
||||
setSelectedGameId(String(walkup.nextGame.id));
|
||||
}
|
||||
}, [searchParams, selectedGameId, walkup.nextGame]);
|
||||
|
||||
useEffect(() => {
|
||||
stopPlayback();
|
||||
setPlaybackSessionId(null);
|
||||
setExpandedPlayerId("");
|
||||
hasInitializedExpandedPlayerRef.current = false;
|
||||
}, [selectedGameId]);
|
||||
|
||||
useEffect(() => {
|
||||
stopPlayback();
|
||||
}, [selectedPlayerId]);
|
||||
|
||||
const assignmentsQuery = useQuery({
|
||||
queryKey: ["assignments", selectedGameId],
|
||||
queryFn: () => api.listAssignments(selectedGameId),
|
||||
enabled: Boolean(selectedGameId),
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const preparedGame = selectedGameId ? loadPreparedGame(selectedGameId) : null;
|
||||
const assignmentList = assignmentsQuery.data ?? preparedGame?.assignments ?? [];
|
||||
|
||||
const eventLineupQuery = useQuery({
|
||||
queryKey: ["teamsnap", "eventLineup", teamId, selectedGameId],
|
||||
queryFn: () => teamsnapClient.loadEventLineupData(teamId, selectedGameId),
|
||||
enabled: Boolean(teamId && selectedGameId),
|
||||
});
|
||||
|
||||
const availabilityQuery = useQuery({
|
||||
queryKey: ["teamsnap", "availabilities", teamId, selectedGameId],
|
||||
queryFn: () => teamsnapClient.loadAvailabilities(teamId, selectedGameId),
|
||||
enabled: Boolean(teamId && selectedGameId),
|
||||
});
|
||||
|
||||
const orderedMembers = useMemo(
|
||||
() =>
|
||||
orderMembersByLineupAndRsvps(
|
||||
walkup.members,
|
||||
eventLineupQuery.data?.entries ?? [],
|
||||
availabilityQuery.data ?? [],
|
||||
),
|
||||
[availabilityQuery.data, eventLineupQuery.data?.entries, walkup.members],
|
||||
);
|
||||
|
||||
const visibleMembers =
|
||||
playerFilter === "all"
|
||||
? orderedMembers
|
||||
: orderedMembers.filter((member) => (playerFilter === "players" ? isPlayerMember(member) : !isPlayerMember(member)));
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedGameId || !visibleMembers.length) {
|
||||
return;
|
||||
}
|
||||
if (!selectedPlayerId || !selectedPlayerWasManualRef.current) {
|
||||
setSelectedPlayerId(String(visibleMembers[0].id));
|
||||
}
|
||||
}, [selectedGameId, selectedPlayerId, visibleMembers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visibleMembers.length) {
|
||||
setExpandedPlayerId("");
|
||||
hasInitializedExpandedPlayerRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const expandedStillVisible = visibleMembers.some((member) => String(member.id) === expandedPlayerId);
|
||||
if (!hasInitializedExpandedPlayerRef.current) {
|
||||
setExpandedPlayerId(String(visibleMembers[0].id));
|
||||
hasInitializedExpandedPlayerRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (expandedPlayerId && !expandedStillVisible) {
|
||||
setExpandedPlayerId(String(visibleMembers[0].id));
|
||||
}
|
||||
}, [expandedPlayerId, visibleMembers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPlayerId) {
|
||||
return;
|
||||
}
|
||||
const selectedIsVisible = visibleMembers.some((member) => String(member.id) === selectedPlayerId);
|
||||
if (!selectedIsVisible) {
|
||||
selectedPlayerWasManualRef.current = false;
|
||||
setSelectedPlayerId(visibleMembers[0] ? String(visibleMembers[0].id) : "");
|
||||
}
|
||||
}, [selectedPlayerId, visibleMembers]);
|
||||
|
||||
const selectedPlayer =
|
||||
walkup.members.find((member) => String(member.id) === selectedPlayerId) ??
|
||||
(selectedPlayerId ? { id: selectedPlayerId } : null);
|
||||
const selectedPlayerJersey = selectedPlayer ? formatMemberJerseyNumber(selectedPlayer) : "";
|
||||
|
||||
const selectedAssignments = useMemo(
|
||||
() => assignmentList.filter((assignment) => assignment.external_player_id === selectedPlayerId),
|
||||
[assignmentList, selectedPlayerId],
|
||||
);
|
||||
|
||||
const createSession = useMutation({
|
||||
mutationFn: () => api.createPlaybackSession(selectedGameId, teamId),
|
||||
onSuccess: (session) => setPlaybackSessionId(session.id),
|
||||
});
|
||||
|
||||
const triggerAssignmentMutation = useMutation({
|
||||
mutationFn: (assignmentId: number) => {
|
||||
if (!playbackSessionId) {
|
||||
throw new Error("Start an operator session first");
|
||||
}
|
||||
return api.triggerPlaybackAssignment(selectedGameId, playbackSessionId, assignmentId);
|
||||
},
|
||||
});
|
||||
|
||||
const triggerClipMutation = useMutation({
|
||||
mutationFn: (clip: AudioClip) => {
|
||||
if (!playbackSessionId) {
|
||||
throw new Error("Start an operator session first");
|
||||
}
|
||||
return api.triggerPlaybackClip(selectedGameId, playbackSessionId, clip.id, selectedPlayerId);
|
||||
},
|
||||
});
|
||||
|
||||
const selectedGame = walkup.games.find((game) => String(game.id) === selectedGameId) ?? null;
|
||||
|
||||
function selectGame(gameId: string) {
|
||||
selectedPlayerWasManualRef.current = false;
|
||||
setSelectedGameId(gameId);
|
||||
setSelectedPlayerId("");
|
||||
setExpandedPlayerId("");
|
||||
setPlayingClipKey(null);
|
||||
setNowPlaying(null);
|
||||
setSearchParams({ gameId });
|
||||
}
|
||||
|
||||
function getAudio() {
|
||||
const audio = audioRef.current ?? new Audio();
|
||||
if (!audioRef.current) {
|
||||
audio.onplay = () => {
|
||||
setIsPlaybackPlaying(true);
|
||||
};
|
||||
audio.onpause = () => {
|
||||
setIsPlaybackPlaying(false);
|
||||
};
|
||||
audio.onended = () => {
|
||||
stopPlayback();
|
||||
};
|
||||
audio.ontimeupdate = () => {
|
||||
const range = playbackRangeRef.current;
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
if (audio.currentTime >= range.endSeconds) {
|
||||
stopPlayback();
|
||||
}
|
||||
};
|
||||
const AudioContextCtor = window.AudioContext ?? (window as Window & typeof globalThis & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (AudioContextCtor && !audioContextRef.current) {
|
||||
const context = new AudioContextCtor();
|
||||
const source = context.createMediaElementSource(audio);
|
||||
const gain = context.createGain();
|
||||
gain.gain.value = 1;
|
||||
source.connect(gain);
|
||||
gain.connect(context.destination);
|
||||
audioContextRef.current = context;
|
||||
mediaSourceRef.current = source;
|
||||
gainNodeRef.current = gain;
|
||||
}
|
||||
}
|
||||
audioRef.current = audio;
|
||||
return audio;
|
||||
}
|
||||
|
||||
function setPlaybackGain(value: number) {
|
||||
const gainNode = gainNodeRef.current;
|
||||
if (gainNode) {
|
||||
gainNode.gain.cancelScheduledValues(gainNode.context.currentTime);
|
||||
gainNode.gain.setValueAtTime(value, gainNode.context.currentTime);
|
||||
}
|
||||
}
|
||||
|
||||
function clearFadeOutTimer() {
|
||||
if (fadeOutTimerRef.current !== null) {
|
||||
window.clearTimeout(fadeOutTimerRef.current);
|
||||
fadeOutTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function stopPlayback(resetGain = true) {
|
||||
clearFadeOutTimer();
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
}
|
||||
if (resetGain) {
|
||||
setPlaybackGain(1);
|
||||
}
|
||||
playbackRangeRef.current = null;
|
||||
setPlayingClipKey(null);
|
||||
setNowPlaying(null);
|
||||
setIsPlaybackPlaying(false);
|
||||
}
|
||||
|
||||
function fadeOutPlayback(durationMs = DEFAULT_FADE_OUT_MS) {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || audio.paused) {
|
||||
stopPlayback();
|
||||
return;
|
||||
}
|
||||
|
||||
clearFadeOutTimer();
|
||||
const gainNode = gainNodeRef.current;
|
||||
if (!gainNode) {
|
||||
stopPlayback();
|
||||
return;
|
||||
}
|
||||
|
||||
const safeDuration = Math.max(1, durationMs);
|
||||
const now = gainNode.context.currentTime;
|
||||
const currentGain = gainNode.gain.value;
|
||||
|
||||
gainNode.gain.cancelScheduledValues(now);
|
||||
gainNode.gain.setValueAtTime(currentGain, now);
|
||||
gainNode.gain.linearRampToValueAtTime(0, now + safeDuration / 1000);
|
||||
|
||||
fadeOutTimerRef.current = window.setTimeout(() => {
|
||||
stopPlayback(false);
|
||||
}, safeDuration);
|
||||
}
|
||||
|
||||
async function playAudio(
|
||||
url: string | null | undefined,
|
||||
key: string,
|
||||
playingItem: NowPlaying,
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
onPlay?: () => Promise<unknown>,
|
||||
) {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const audio = getAudio();
|
||||
if (playingClipKey === key && !audio.paused) {
|
||||
stopPlayback();
|
||||
return;
|
||||
}
|
||||
|
||||
setPlayingClipKey(key);
|
||||
setNowPlaying(playingItem);
|
||||
setIsPlaybackPlaying(false);
|
||||
audio.pause();
|
||||
setPlaybackGain(1);
|
||||
if (audioContextRef.current?.state === "suspended") {
|
||||
await audioContextRef.current.resume();
|
||||
}
|
||||
const startSeconds = startMs / 1000;
|
||||
const endSeconds = endMs / 1000;
|
||||
playbackRangeRef.current = { startSeconds, endSeconds };
|
||||
const metadataReady = new Promise<void>((resolve) => {
|
||||
audio.onloadedmetadata = () => {
|
||||
if (playbackRangeRef.current?.endSeconds === endSeconds) {
|
||||
audio.currentTime = startSeconds;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
audio.src = `${import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000"}${url}`;
|
||||
await metadataReady;
|
||||
try {
|
||||
await audio.play();
|
||||
if (onPlay) {
|
||||
await onPlay();
|
||||
}
|
||||
} catch (error) {
|
||||
if (audio.paused) {
|
||||
setPlayingClipKey(null);
|
||||
setNowPlaying(null);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function playAssignment(assignment: GameAssignment) {
|
||||
await playAudio(
|
||||
assignment.normalized_url,
|
||||
clipKey("assignment", assignment.id),
|
||||
{
|
||||
key: clipKey("assignment", assignment.id),
|
||||
title: assignment.clip_label,
|
||||
subtitle: formatMemberName(selectedPlayer),
|
||||
},
|
||||
assignment.start_ms,
|
||||
assignment.end_ms,
|
||||
() => triggerAssignmentMutation.mutateAsync(assignment.id),
|
||||
);
|
||||
}
|
||||
|
||||
async function playClip(clip: AudioClip) {
|
||||
await playAudio(
|
||||
clip.normalized_url,
|
||||
clipKey("library", clip.id),
|
||||
{
|
||||
key: clipKey("library", clip.id),
|
||||
title: clip.label,
|
||||
subtitle: formatMemberName(selectedPlayer),
|
||||
},
|
||||
clip.start_ms,
|
||||
clip.end_ms,
|
||||
() => triggerClipMutation.mutateAsync(clip),
|
||||
);
|
||||
}
|
||||
|
||||
if (!walkup.isTeamSnap) {
|
||||
return (
|
||||
<section className="page-grid">
|
||||
<div className="panel">Reconnect with TeamSnap to run operator mode.</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-grid operator-page">
|
||||
{isPlaybackPlaying && nowPlaying ? (
|
||||
<div className="operator-toolbar">
|
||||
<div className="operator-toolbar-copy">
|
||||
<span className="operator-toolbar-label">Now playing</span>
|
||||
<strong>{nowPlaying.title}</strong>
|
||||
<span className="muted">{nowPlaying.subtitle}</span>
|
||||
</div>
|
||||
<div className="operator-toolbar-actions">
|
||||
<button type="button" className="btn btn-outline-secondary btn-sm" onClick={() => stopPlayback()}>
|
||||
Stop
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline-secondary btn-sm" onClick={() => fadeOutPlayback()}>
|
||||
Fade out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="hero">
|
||||
<p className="eyebrow">Operator mode</p>
|
||||
<h1>{selectedGame ? formatGameTitle(selectedGame) : "Select a game to operate"}</h1>
|
||||
<p>
|
||||
Any player can operate. The player list now follows the event lineup first, then RSVP order, and each expanded
|
||||
row shows the current game clips before the player's library clips.
|
||||
</p>
|
||||
</div>
|
||||
<div className="panel-grid">
|
||||
<div className="panel">
|
||||
<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 operate."}
|
||||
</div>
|
||||
{walkup.nextGame ? <div className="text-body-secondary">Next game: {formatGameTitle(walkup.nextGame)}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel stack">
|
||||
<div className="row">
|
||||
<h2 style={{ margin: 0 }}>Players</h2>
|
||||
<label className="field" style={{ marginLeft: "auto", minWidth: 180 }}>
|
||||
Filter
|
||||
<select className="form-select" value={playerFilter} onChange={(event) => setPlayerFilter(event.target.value as typeof playerFilter)}>
|
||||
<option value="players">Players</option>
|
||||
<option value="nonPlayers">Non-players</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="list-group operator-player-list">
|
||||
{visibleMembers.map((member) => {
|
||||
const memberId = String(member.id);
|
||||
const jerseyNumber = formatMemberJerseyNumber(member);
|
||||
const lineupEntry = findLineupEntryForMember(member, eventLineupQuery.data?.entries ?? []);
|
||||
const availability = (availabilityQuery.data ?? []).find(
|
||||
(entry) => String(entry.memberId) === memberId,
|
||||
) ?? null;
|
||||
const assignmentCount = assignmentList.filter((assignment) => assignment.external_player_id === memberId).length;
|
||||
const isExpanded = memberId === expandedPlayerId;
|
||||
const expansionId = `player-clips-${memberId}`;
|
||||
const availabilityStatusCode = availability?.statusCode ?? null;
|
||||
const playerMeta = [
|
||||
assignmentCount ? `${assignmentCount} game clip${assignmentCount === 1 ? "" : "s"}` : null,
|
||||
lineupEntry?.label ?? null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className={`operator-player-card${isExpanded ? " is-selected" : ""}`} key={memberId}>
|
||||
<button
|
||||
type="button"
|
||||
className={`list-group-item list-group-item-action d-flex justify-content-between align-items-center text-start${isExpanded ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
selectedPlayerWasManualRef.current = true;
|
||||
setSelectedPlayerId(memberId);
|
||||
setExpandedPlayerId((current) => (current === memberId ? "" : memberId));
|
||||
}}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={expansionId}
|
||||
id={`player-${memberId}-toggle`}
|
||||
>
|
||||
<div className="operator-player-summary">
|
||||
<div className="operator-player-heading">
|
||||
<strong>
|
||||
<span
|
||||
className={`operator-availability-dot ${getAvailabilityDotClass(availabilityStatusCode)}`}
|
||||
aria-label={getAvailabilityDotLabel(availabilityStatusCode)}
|
||||
title={getAvailabilityDotLabel(availabilityStatusCode)}
|
||||
/>
|
||||
{formatMemberName(member)}
|
||||
{jerseyNumber ? ` ${jerseyNumber}` : ""}
|
||||
</strong>
|
||||
{lineupEntry ? <span className="pill">Lineup {lineupEntry.sequence ?? "?"}</span> : null}
|
||||
</div>
|
||||
<div className="muted">{playerMeta.join(" • ")}</div>
|
||||
</div>
|
||||
<span className="operator-player-chevron" aria-hidden="true">
|
||||
{isExpanded ? "−" : "›"}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded ? (
|
||||
<div
|
||||
className="operator-expansion"
|
||||
id={expansionId}
|
||||
role="region"
|
||||
aria-labelledby={`player-${memberId}-toggle`}
|
||||
>
|
||||
<div className="operator-section">
|
||||
<div className="operator-section-title">
|
||||
<h3 style={{ margin: 0 }}>Game clips</h3>
|
||||
<span className="muted">Attached to this game</span>
|
||||
</div>
|
||||
<div className="operator-clip-list">
|
||||
{selectedAssignments.length ? (
|
||||
selectedAssignments.map((assignment) => {
|
||||
const key = clipKey("assignment", assignment.id);
|
||||
const isPlaying = playingClipKey === key;
|
||||
return (
|
||||
<div className="operator-clip-row" key={assignment.id}>
|
||||
<div className="operator-clip-copy">
|
||||
<strong>{assignment.clip_label}</strong>
|
||||
<div className="muted">
|
||||
{assignment.asset_title} {assignment.batting_slot ? `| slot ${assignment.batting_slot}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${isPlaying ? "btn-primary" : "btn-outline-secondary"}`}
|
||||
onClick={() => void playAssignment(assignment)}
|
||||
aria-pressed={isPlaying}
|
||||
>
|
||||
<span className={`operator-clip-button-indicator${isPlaying ? " is-playing" : ""}`} />
|
||||
{isPlaying ? "Stop" : "Play"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="muted">No clips attached to this game for this player yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="operator-section">
|
||||
<div className="operator-section-title">
|
||||
<h3 style={{ margin: 0 }}>Clip library</h3>
|
||||
<span className="muted">Available clips for this player</span>
|
||||
</div>
|
||||
<div className="operator-clip-list">
|
||||
<LibraryClips
|
||||
teamId={teamId}
|
||||
playerId={selectedPlayerId}
|
||||
playingClipKey={playingClipKey}
|
||||
onPlayClip={playClip}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="operator-section">
|
||||
<h3 style={{ margin: 0 }}>Debug</h3>
|
||||
<details className="operator-debug-details">
|
||||
<summary>Show raw lineup data</summary>
|
||||
<pre className="operator-debug">
|
||||
{JSON.stringify(
|
||||
{
|
||||
selectedPlayerId,
|
||||
selectedPlayerName: formatMemberName(member),
|
||||
rawEventLineup: eventLineupQuery.data?.eventLineup ?? null,
|
||||
rawEventLineupEntries: eventLineupQuery.data?.entries ?? [],
|
||||
matchedLineupEntry: lineupEntry,
|
||||
matchedLineupEntryCount: (eventLineupQuery.data?.entries ?? []).filter(
|
||||
(entry) => String(entry.memberId) === memberId,
|
||||
).length,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!visibleMembers.length ? <div className="muted">No members match this filter.</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel stack">
|
||||
<h2>Session</h2>
|
||||
<button type="button" className="btn btn-primary" disabled={!selectedGameId || !teamId} onClick={() => void createSession.mutateAsync()}>
|
||||
{createSession.isPending ? "Starting..." : playbackSessionId ? "Session ready" : "Start operator session"}
|
||||
</button>
|
||||
<div className="panel-note">Team: {formatTeamLabel(walkup.selectedTeam)}</div>
|
||||
<div className="panel-note">Game: {selectedGame ? formatGameDate(selectedGame) : "Select a game"}</div>
|
||||
<div className="panel-note">
|
||||
Player:{" "}
|
||||
{selectedPlayer ? `${formatMemberName(selectedPlayer)}${selectedPlayerJersey ? ` ${selectedPlayerJersey}` : ""}` : "Select a player"}
|
||||
</div>
|
||||
{triggerAssignmentMutation.error instanceof Error ? <div className="muted">{triggerAssignmentMutation.error.message}</div> : null}
|
||||
{triggerClipMutation.error instanceof Error ? <div className="muted">{triggerClipMutation.error.message}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryClips({
|
||||
teamId,
|
||||
playerId,
|
||||
playingClipKey,
|
||||
onPlayClip,
|
||||
}: {
|
||||
teamId: string;
|
||||
playerId: string;
|
||||
playingClipKey: string | null;
|
||||
onPlayClip: (clip: AudioClip) => Promise<void>;
|
||||
}) {
|
||||
const fallbackClipsQuery = useQuery({
|
||||
queryKey: ["clips", teamId, playerId],
|
||||
queryFn: () => api.listClips(teamId, playerId),
|
||||
enabled: Boolean(teamId && playerId),
|
||||
});
|
||||
|
||||
if (fallbackClipsQuery.isLoading) {
|
||||
return <div className="muted">Loading library clips...</div>;
|
||||
}
|
||||
|
||||
if (!fallbackClipsQuery.data?.length) {
|
||||
return <div className="muted">No library clips available for this player.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{fallbackClipsQuery.data.map((clip) => {
|
||||
const key = clipKey("library", clip.id);
|
||||
const isPlaying = playingClipKey === key;
|
||||
return (
|
||||
<div className="operator-clip-row" key={clip.id}>
|
||||
<div className="operator-clip-copy">
|
||||
<strong>{clip.label}</strong>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${isPlaying ? "btn-primary" : "btn-outline-secondary"}`}
|
||||
onClick={() => void onPlayClip(clip)}
|
||||
aria-pressed={isPlaying}
|
||||
>
|
||||
<span className={`operator-clip-button-indicator${isPlaying ? " is-playing" : ""}`} />
|
||||
{isPlaying ? "Stop" : "Play"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
98
frontend/src/pages/ProfilePage.tsx
Normal file
98
frontend/src/pages/ProfilePage.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import { useWalkupContext } from "../hooks/useWalkupContext";
|
||||
import { formatMemberName, formatTeamLabel } from "../lib/teamsnapHelpers";
|
||||
import { queryClient } from "../lib/queryClient";
|
||||
|
||||
export function ProfilePage() {
|
||||
const navigate = useNavigate();
|
||||
const walkup = useWalkupContext();
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: api.logout,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["session"] });
|
||||
await queryClient.removeQueries({ queryKey: ["teamsnap"] });
|
||||
navigate("/signin");
|
||||
},
|
||||
});
|
||||
|
||||
async function reconnect() {
|
||||
const auth = await api.startTeamSnap(window.location.pathname);
|
||||
window.location.href = auth.authorize_url;
|
||||
}
|
||||
|
||||
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">Profile</p>
|
||||
<h1 className="h2 mb-3">{walkup.hasSelectedTeam ? formatTeamLabel(walkup.selectedTeam) : "Choose your team"}</h1>
|
||||
<p className="mb-0 text-white-50">
|
||||
Session details and the selected team live here. The team choice is stored on this device and reused on the
|
||||
next visit.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row g-4">
|
||||
<div className="col-12 col-lg-6">
|
||||
<div className="card shadow-sm h-100">
|
||||
<div className="card-body d-grid gap-3">
|
||||
<h2 className="h4 mb-0">Session</h2>
|
||||
<div className="text-body-secondary">Provider: {walkup.sessionQuery.data?.provider ?? "none"}</div>
|
||||
<div className="text-body-secondary">Authenticated: {walkup.sessionQuery.data?.authenticated ? "yes" : "no"}</div>
|
||||
<div className="text-body-secondary">
|
||||
Player: {walkup.currentPlayer ? formatMemberName(walkup.currentPlayer) : "No matching player on the selected team"}
|
||||
</div>
|
||||
<div className="d-flex flex-wrap gap-2">
|
||||
{walkup.isTeamSnap ? (
|
||||
<button type="button" className="btn btn-outline-secondary" onClick={() => void reconnect()}>
|
||||
Reconnect TeamSnap
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="btn btn-outline-secondary" onClick={() => void logoutMutation.mutateAsync()}>
|
||||
{logoutMutation.isPending ? "Signing out..." : "Sign out"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 col-lg-6">
|
||||
<div className="card shadow-sm h-100">
|
||||
<div className="card-body d-grid gap-3">
|
||||
<h2 className="h4 mb-0">Selected team</h2>
|
||||
{walkup.isTeamSnap ? (
|
||||
<>
|
||||
<label className="form-label d-grid gap-2">
|
||||
<span>Team and season</span>
|
||||
<select
|
||||
className="form-select"
|
||||
value={walkup.selectedTeamId}
|
||||
onChange={(event) => walkup.selectTeam(event.target.value)}
|
||||
disabled={walkup.teamsQuery.isLoading}
|
||||
>
|
||||
<option value="">Select a team</option>
|
||||
{walkup.teamsQuery.data?.map((team) => (
|
||||
<option key={String(team.id)} value={String(team.id)}>
|
||||
{formatTeamLabel(team)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="text-body-secondary">
|
||||
{walkup.hasSelectedTeam
|
||||
? `Current selection: ${formatTeamLabel(walkup.selectedTeam)}`
|
||||
: "Pick a team to continue into the app."}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-body-secondary">Team selection is available for TeamSnap-backed sessions.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
59
frontend/src/pages/SignInPage.tsx
Normal file
59
frontend/src/pages/SignInPage.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { api } from "../api/client";
|
||||
|
||||
export function SignInPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleTeamSnapStart() {
|
||||
try {
|
||||
const routeState = location.state as { from?: { pathname?: string; search?: string } } | null;
|
||||
const from = routeState?.from;
|
||||
const returnTo =
|
||||
from?.pathname && from.pathname !== "/signin"
|
||||
? `${from.pathname}${from.search ?? ""}`
|
||||
: "/";
|
||||
const data = await api.startTeamSnap(returnTo);
|
||||
window.location.href = data.authorize_url;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unable to start TeamSnap sign-in");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="container-fluid min-vh-100 d-flex align-items-center justify-content-center py-4">
|
||||
<div className="row w-100 justify-content-center g-4">
|
||||
<div className="col-12 col-md-8 col-lg-5 col-xl-4">
|
||||
<div className="card shadow-sm border-0">
|
||||
<div className="card-body p-4 p-lg-5 d-grid gap-4">
|
||||
<div className="d-grid gap-2">
|
||||
<p className="text-uppercase small text-primary-emphasis mb-0">Walkup</p>
|
||||
<h1 className="h2 mb-0">Sign in</h1>
|
||||
<p className="text-body-secondary mb-0">Use TeamSnap to continue into your team dashboard.</p>
|
||||
</div>
|
||||
<div className="d-grid gap-3">
|
||||
<p className="text-body-secondary mb-0">
|
||||
After sign-in, you will choose your team and land in the app with your session ready.
|
||||
</p>
|
||||
<button type="button" className="btn btn-primary btn-lg w-100" onClick={handleTeamSnapStart}>
|
||||
Sign in with TeamSnap
|
||||
</button>
|
||||
</div>
|
||||
<div className="small text-body-secondary">TeamSnap sign-in is the primary access path.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="col-12 col-md-8 col-lg-5 col-xl-4">
|
||||
<div className="alert alert-danger mb-0" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
965
frontend/src/styles.css
Normal file
965
frontend/src/styles.css
Normal file
@@ -0,0 +1,965 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f7f3eb;
|
||||
--ink: #132238;
|
||||
--muted: #5f6670;
|
||||
--accent: #d94f04;
|
||||
--accent-soft: #ffd19b;
|
||||
--line: rgba(19, 34, 56, 0.1);
|
||||
--panel: rgba(255, 255, 255, 0.9);
|
||||
--panel-border: rgba(19, 34, 56, 0.12);
|
||||
font-family: var(--bs-body-font-family);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(217, 79, 4, 0.18), transparent 24%),
|
||||
linear-gradient(135deg, #faf6ef 0%, #f2e3cd 100%);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.operator-page {
|
||||
padding-bottom: 112px;
|
||||
}
|
||||
|
||||
.page-grid {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 1.75rem;
|
||||
border-radius: 1.25rem;
|
||||
background: linear-gradient(135deg, rgba(19, 34, 56, 0.96), rgba(217, 79, 4, 0.95));
|
||||
color: white;
|
||||
box-shadow: 0 20px 40px rgba(19, 34, 56, 0.14);
|
||||
}
|
||||
|
||||
.hero p {
|
||||
max-width: 50rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: clamp(2rem, 4vw, 3.5rem);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.panel-grid {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.clip-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.clip-list-add-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 999px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.clip-list-add-button svg {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.walkup-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: rgba(11, 18, 28, 0.76);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 3000;
|
||||
}
|
||||
|
||||
.walkup-modal {
|
||||
max-width: 1080px;
|
||||
max-height: 92vh;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
z-index: 3001;
|
||||
}
|
||||
|
||||
.walkup-modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem 1.25rem 0;
|
||||
}
|
||||
|
||||
.walkup-modal-body {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.walkup-stepper {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.walkup-step {
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(19, 34, 56, 0.08);
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.walkup-step.is-active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.walkup-step.is-complete {
|
||||
background: rgba(47, 158, 68, 0.14);
|
||||
color: #25643b;
|
||||
}
|
||||
|
||||
.walkup-modal-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.source-progress-panel {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid rgba(19, 34, 56, 0.12);
|
||||
border-radius: 0.85rem;
|
||||
background: rgba(19, 34, 56, 0.04);
|
||||
}
|
||||
|
||||
.source-progress-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.source-progress-bar {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 0.65rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(19, 34, 56, 0.12);
|
||||
}
|
||||
|
||||
.source-progress-bar-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, var(--accent), #f5a13b);
|
||||
transition: width 160ms ease;
|
||||
}
|
||||
|
||||
.source-progress-bar.is-indeterminate .source-progress-bar-fill {
|
||||
width: 42% !important;
|
||||
animation: source-progress-slide 1.05s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes source-progress-slide {
|
||||
0% {
|
||||
transform: translateX(-115%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(250%);
|
||||
}
|
||||
}
|
||||
|
||||
.walkup-panel-actions {
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 1rem;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 18px 32px rgba(19, 34, 56, 0.08);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.panel h2,
|
||||
.panel h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.clip-summary {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 0.85rem 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.clip-summary:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.clip-summary-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.clip-summary-title-row {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: block;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.clip-summary-title-row strong {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.icon-button-circle {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
min-width: 2rem;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-button-bare {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
line-height: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-button-menu {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
min-width: 1.75rem;
|
||||
border-radius: 999px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.icon-button-menu svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: block;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.icon-button svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: -0.125em;
|
||||
fill: currentColor;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
line-height: 0;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.icon-button:hover:not(:disabled) {
|
||||
background: rgba(19, 34, 56, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-button:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.clip-summary-menu-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.clip-summary-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.45rem);
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
min-width: 12rem;
|
||||
padding: 0.45rem;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 0.85rem;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: 0 16px 28px rgba(19, 34, 56, 0.14);
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.clip-summary-menu-item {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
padding: 0.45rem 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
color: var(--ink);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.clip-summary-menu-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
color: var(--muted);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.clip-summary-menu-icon svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: -0.125em;
|
||||
fill: currentColor;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.clip-summary-menu-label {
|
||||
padding: 0.45rem 0.5rem;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 0.88rem;
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
min-width: 2.1rem;
|
||||
padding-inline: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: rgba(244, 237, 226, 0.8);
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--ink);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.panel-note {
|
||||
padding: 0.75rem 0.85rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(19, 34, 56, 0.06);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.asset-card {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.asset-card-header {
|
||||
display: flex;
|
||||
gap: 0.9rem;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.asset-card-copy {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
.asset-card-copy strong,
|
||||
.asset-card-filename {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.asset-card-actions {
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.asset-card-meta {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.asset-card-footer {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.asset-card .field input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.clip-editor {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.9rem;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.clip-editor-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.clip-editor-range {
|
||||
font-size: 0.95rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.clip-waveform-shell {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 0.9rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.95rem;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.clip-waveform-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.clip-waveform-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.clip-waveform-meta .btn-group {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.clip-wavesurfer {
|
||||
overflow: hidden;
|
||||
min-height: 7rem;
|
||||
border-radius: 0.9rem;
|
||||
border: 1px solid rgba(19, 34, 56, 0.12);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(19, 34, 56, 0.05), rgba(19, 34, 56, 0.02)),
|
||||
rgba(19, 34, 56, 0.03);
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.clip-wavesurfer ::part(region) {
|
||||
border-inline: 2px solid rgba(217, 79, 4, 0.78);
|
||||
border-radius: 0.65rem;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.clip-wavesurfer ::part(region-handle-left),
|
||||
.clip-wavesurfer ::part(region-handle-right) {
|
||||
width: 1.5rem;
|
||||
height: 1.8rem !important;
|
||||
top: 50% !important;
|
||||
bottom: auto !important;
|
||||
border-radius: 0.3rem;
|
||||
background:
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-grip-vertical' viewBox='0 0 16 16'%3E%3Cpath d='M7 2a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0M7 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0M7 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0'/%3E%3C/svg%3E"),
|
||||
linear-gradient(90deg, rgba(19, 34, 56, 0.94), rgba(19, 34, 56, 0.94)),
|
||||
rgba(19, 34, 56, 0.94);
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 0.82rem 0.82rem, 2px 100%, auto;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.92), 0 0.25rem 0.65rem rgba(19, 34, 56, 0.16);
|
||||
cursor: ew-resize;
|
||||
transform: translateY(-50%);
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.clip-wavesurfer ::part(region-handle-right) {
|
||||
background:
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-grip-vertical' viewBox='0 0 16 16'%3E%3Cpath d='M7 2a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0M7 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0M7 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0'/%3E%3C/svg%3E"),
|
||||
linear-gradient(90deg, rgba(217, 79, 4, 0.96), rgba(217, 79, 4, 0.96)),
|
||||
rgba(217, 79, 4, 0.96);
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 0.82rem 0.82rem, 2px 100%, auto;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.clip-wavesurfer ::part(region-handle-left),
|
||||
.clip-wavesurfer ::part(region-handle-right) {
|
||||
width: 1.8rem;
|
||||
height: 2.2rem !important;
|
||||
background-size: 0.92rem 0.92rem, 2px 100%, auto;
|
||||
}
|
||||
}
|
||||
|
||||
.clip-zoom-scrubber {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.clip-zoom-scrubber-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.clip-zoom-scrubber input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.clip-waveform-controls {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.clip-waveform-control {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.clip-waveform-control-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.clip-waveform-nudges {
|
||||
display: inline-flex;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.clip-waveform-preview-action {
|
||||
display: flex;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.clip-waveform-preview-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.clip-waveform-preview-button svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex: 0 0 auto;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.clip-editor-fields {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.clip-editor-shortcuts {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.clip-editor-shortcut {
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
.clip-editor-actions {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.action-card.active,
|
||||
.action-card.is-selected {
|
||||
border-color: rgba(217, 79, 4, 0.6);
|
||||
box-shadow: 0 0 0 2px rgba(217, 79, 4, 0.12);
|
||||
}
|
||||
|
||||
.operator-toolbar {
|
||||
position: fixed;
|
||||
right: 1.75rem;
|
||||
bottom: 1.15rem;
|
||||
left: 1.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid rgba(19, 34, 56, 0.16);
|
||||
border-radius: 0.8rem;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: 0 18px 40px rgba(19, 34, 56, 0.18);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.operator-toolbar-copy {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.operator-toolbar-label {
|
||||
color: rgba(19, 34, 56, 0.58);
|
||||
font-size: 0.74rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.operator-toolbar-copy strong,
|
||||
.operator-toolbar-copy .muted {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.operator-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.operator-player-list {
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 0.8rem;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.operator-player-card {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.operator-player-card:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.operator-player-card.is-selected {
|
||||
background: rgba(217, 79, 4, 0.03);
|
||||
}
|
||||
|
||||
.operator-player-summary {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.operator-player-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.operator-player-heading strong {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.operator-availability-dot {
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
background: #9aa0a6;
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.operator-availability-dot.is-yes {
|
||||
background: #2f9e44;
|
||||
}
|
||||
|
||||
.operator-availability-dot.is-no {
|
||||
background: #e03131;
|
||||
}
|
||||
|
||||
.operator-availability-dot.is-maybe {
|
||||
background: #1c7ed6;
|
||||
}
|
||||
|
||||
.operator-availability-dot.is-blank {
|
||||
background: #adb5bd;
|
||||
}
|
||||
|
||||
.operator-player-chevron {
|
||||
flex: 0 0 auto;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(19, 34, 56, 0.08);
|
||||
color: var(--ink);
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
background-color 0.18s ease,
|
||||
color 0.18s ease;
|
||||
}
|
||||
|
||||
.list-group-item.active .operator-player-chevron {
|
||||
transform: rotate(90deg);
|
||||
background: rgba(217, 79, 4, 0.18);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.operator-expansion {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.operator-section {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
padding: 1rem 1rem 0;
|
||||
}
|
||||
|
||||
.operator-section:last-child {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.operator-section-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.operator-clip-list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.operator-clip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.operator-clip-copy {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.operator-clip-button-indicator {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(19, 34, 56, 0.32);
|
||||
}
|
||||
|
||||
.operator-clip-button-indicator.is-playing {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.operator-debug {
|
||||
margin: 0;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(19, 34, 56, 0.06);
|
||||
color: var(--ink);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.operator-debug-details {
|
||||
padding: 0.15rem 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.operator-debug-details > summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.operator-debug-details > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.operator-debug-details[open] > summary {
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.operator-toolbar {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
}
|
||||
}
|
||||
1
frontend/src/types/teamsnap-js.d.ts
vendored
Normal file
1
frontend/src/types/teamsnap-js.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "teamsnap.js";
|
||||
2
frontend/src/vite-env.d.ts
vendored
Normal file
2
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
22
frontend/tsconfig.json
Normal file
22
frontend/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
42
frontend/vite.config.ts
Normal file
42
frontend/vite.config.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, ".", "");
|
||||
const appHost = env.APP_HOST || "kif.local.ascorrea.com";
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
includeAssets: ["icon.svg"],
|
||||
manifest: {
|
||||
name: "Walkup",
|
||||
short_name: "Walkup",
|
||||
description: "Collaborative baseball walk-up songs.",
|
||||
theme_color: "#132238",
|
||||
background_color: "#f4ede2",
|
||||
display: "standalone",
|
||||
start_url: "/",
|
||||
icons: [
|
||||
{
|
||||
src: "/icon.svg",
|
||||
sizes: "any",
|
||||
type: "image/svg+xml",
|
||||
purpose: "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
allowedHosts: [appHost],
|
||||
},
|
||||
preview: {
|
||||
allowedHosts: [appHost],
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user