Fix backend imports and clip pinning flow

This commit is contained in:
Codex
2026-04-22 07:48:12 -05:00
parent 45c2b46304
commit ec73156966
12 changed files with 736 additions and 156 deletions

View File

@@ -146,12 +146,27 @@ export const api = {
end_ms: number;
}) =>
request<AudioClip>("/media/clips", { method: "POST", body: JSON.stringify(payload) }),
reorderClips: async (payload: { external_team_id: string; owner_external_player_id: string; clip_ids: number[] }) => {
const response = await fetch(`${API_BASE}/media/clips/reorder`, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(await response.text());
}
},
listAssignments: (gameId: string, playerId?: string) =>
request<GameAssignment[]>(
`/games/${encodeURIComponent(gameId)}/assignments${
playerId ? `?external_player_id=${encodeURIComponent(playerId)}` : ""
}`,
),
listPins: (playerId: string) =>
request<GameAssignment[]>(`/games/pins?external_player_id=${encodeURIComponent(playerId)}`),
createAssignment: (
gameId: string,
payload: {
@@ -166,6 +181,20 @@ export const api = {
method: "POST",
body: JSON.stringify(payload),
}),
deleteAssignment: (gameId: string, assignmentId: number, playerId?: string) =>
fetch(
`${API_BASE}/games/${encodeURIComponent(gameId)}/assignments/${assignmentId}${
playerId ? `?external_player_id=${encodeURIComponent(playerId)}` : ""
}`,
{
method: "DELETE",
credentials: "include",
},
).then(async (response) => {
if (!response.ok) {
throw new Error(await response.text());
}
}),
prepareGame: (gameId: string) => request<GamePrepResponse>(`/games/${encodeURIComponent(gameId)}/prep`),
createPlaybackSession: (gameId: string, teamId: string) =>
request<PlaybackSession>(`/games/${encodeURIComponent(gameId)}/operator/session`, {

View File

@@ -46,6 +46,7 @@ export interface AudioClip {
label: string;
start_ms: number;
end_ms: number;
sort_order: number;
normalization_status: string;
normalized_url?: string | null;
waveform_duration_ms?: number | null;
@@ -57,6 +58,7 @@ export interface AudioClipUpdate {
start_ms: number;
end_ms: number;
label?: string;
sort_order?: number | null;
}
export interface GameAssignment {

View File

@@ -62,6 +62,16 @@ export function GamePage() {
},
});
const unpinMutation = useMutation({
mutationFn: (assignmentId: number) => api.deleteAssignment(selectedGameId, assignmentId, playerId),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["assignments", selectedGameId, playerId] }),
queryClient.invalidateQueries({ queryKey: ["prep", selectedGameId] }),
]);
},
});
function selectGame(gameId: string) {
setSelectedGameId(gameId);
setSearchParams({ gameId });
@@ -74,7 +84,7 @@ export function GamePage() {
return;
}
savePreparedGame(selectedGameId, prepQuery.data);
setOfflineMessage(`Cached ${prepQuery.data.assignments.length} assignments for offline operator use.`);
setOfflineMessage(`Cached ${prepQuery.data.assignments.length} pinned clips for offline operator use.`);
}
const selectedGame = walkup.games.find((game) => String(game.id) === selectedGameId) ?? null;
@@ -84,7 +94,7 @@ export function GamePage() {
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 className="card-body">Reconnect with TeamSnap to pin clips to games.</div>
</div>
</section>
);
@@ -106,10 +116,10 @@ export function GamePage() {
<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>
<p className="text-uppercase small text-info-emphasis mb-2">Pinned 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{" "}
{formatMemberName(walkup.currentPlayer)} can pin clips from song files in their own library to any game on{" "}
{formatTeamLabel(walkup.selectedTeam)}.
</p>
</div>
@@ -131,7 +141,7 @@ export function GamePage() {
</select>
</label>
<div className="text-body-secondary">
{selectedGame ? formatGameDate(selectedGame) : "Choose a game to attach clips."}
{selectedGame ? formatGameDate(selectedGame) : "Choose a game to pin clips."}
</div>
{walkup.nextGame ? <div className="text-body-secondary">Next game: {formatGameTitle(walkup.nextGame)}</div> : null}
</div>
@@ -141,7 +151,7 @@ export function GamePage() {
<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>
<h2 className="h4 mb-0">Pin a clip</h2>
{selectedGame ? (
<>
<div className="text-body-secondary">{formatGameDate(selectedGame)}</div>
@@ -161,12 +171,12 @@ export function GamePage() {
<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"}
{saveMutation.isPending ? "Saving..." : "Pin 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 className="text-body-secondary">Pick a game to pin clips.</div>
)}
</div>
</div>
@@ -176,21 +186,31 @@ export function GamePage() {
<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>
<h2 className="h4 mb-0">Pinned 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>
<div className="list-group-item d-flex justify-content-between align-items-center gap-3" key={assignment.id}>
<div className="d-grid gap-1">
<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>
<button
type="button"
className="btn btn-sm icon-button icon-button-circle btn-outline-secondary"
onClick={() => void unpinMutation.mutateAsync(assignment.id)}
aria-label="Unpin clip"
title="Unpin clip"
>
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M9.828.722a1 1 0 0 1 1.415 0l3.535 3.535a1 1 0 0 1 0 1.415l-1.06 1.06a1 1 0 0 1-1.414 0l-2.03-2.03-1.75 1.75a1 1 0 0 1-.22.17l-1.74.97-.97 1.74a1 1 0 0 1-.17.22l-3.03 3.03.71.71 3.03-3.03a1 1 0 0 1 .22-.17l1.74-.97 1.75 1.75a1 1 0 0 1 .17.22l.97 1.74 3.03-3.03 1.06 1.06a1 1 0 0 1 0 1.415l-1.06 1.06a1 1 0 0 1-1.415 0l-3.535-3.535a1 1 0 0 1 0-1.415l1.75-1.75-2.03-2.03a1 1 0 0 1 0-1.415z" />
</svg>
</button>
</div>
))}
{!assignmentsQuery.isLoading && !assignmentsQuery.data?.length ? (
<div className="text-body-secondary">No clips attached to this game yet.</div>
<div className="text-body-secondary">No clips pinned to this game yet.</div>
) : null}
</div>
</div>
@@ -202,8 +222,8 @@ export function GamePage() {
<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 className="text-body-secondary">Pinned clips in package: {prepQuery.data?.assignments.length ?? 0}</div>
<div className="text-body-secondary">Cached locally: {cachedPrep ? `${cachedPrep.assignments.length} pinned clips` : "No"}</div>
</div>
<button type="button" className="btn btn-outline-secondary" onClick={cachePreparedGame} disabled={!selectedGameId}>
Cache on this device

View File

@@ -1,14 +1,14 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import WaveSurfer from "wavesurfer.js";
import RegionsPlugin, { type Region } from "wavesurfer.js/plugins/regions";
import { api } from "../api/client";
import type { AudioAsset, AudioClip } from "../api/types";
import type { AudioAsset, AudioClip, TeamSnapEvent } from "../api/types";
import { useWalkupContext } from "../hooks/useWalkupContext";
import { queryClient } from "../lib/queryClient";
import { formatClipRange, formatPlaybackPosition } from "../lib/media";
import { formatMemberName } from "../lib/teamsnapHelpers";
import { formatGameTitle, formatMemberName } from "../lib/teamsnapHelpers";
const MEDIA_ACCEPT =
".mp3,.m4a,.aac,.wav,.ogg,.oga,.flac,.mp4,.m4v,.mov,audio/*,video/*,application/octet-stream";
@@ -25,7 +25,16 @@ type WalkupClipModalState =
| { mode: "create" }
| { mode: "edit"; clip: AudioClip };
type BootstrapIconName = "play" | "stop" | "three-dots-vertical" | "pencil-square" | "plus-lg" | "x-lg";
type BootstrapIconName =
| "play"
| "stop"
| "three-dots-vertical"
| "pencil-square"
| "plus-lg"
| "x-lg"
| "chevron-up"
| "chevron-down"
| "pin-fill";
type TrimFocusEdge = "start" | "end";
type SourceCreationProgress = {
label: string;
@@ -55,6 +64,24 @@ export function LibraryPage() {
queryFn: () => api.listClips(teamId, playerId),
enabled: Boolean(teamId && playerId),
});
const pinsQuery = useQuery({
queryKey: ["pins", teamId, playerId],
queryFn: () => api.listPins(playerId),
enabled: Boolean(playerId),
});
const orderedClips = useMemo(
() =>
[...(clipsQuery.data ?? [])].sort((left, right) => {
if (left.sort_order !== right.sort_order) {
return left.sort_order - right.sort_order;
}
return right.created_at.localeCompare(left.created_at);
}),
[clipsQuery.data],
);
const pinnedAssignmentsByClipAndGame = useMemo(() => {
return new Map((pinsQuery.data ?? []).map((assignment) => [`${assignment.external_game_id}:${assignment.clip_id}`, assignment]));
}, [pinsQuery.data]);
useEffect(() => {
return () => {
@@ -70,6 +97,45 @@ export function LibraryPage() {
},
});
const reorderClipsMutation = useMutation({
mutationFn: (clipIds: number[]) =>
api.reorderClips({
external_team_id: teamId,
owner_external_player_id: playerId,
clip_ids: clipIds,
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["clips", teamId, playerId] });
},
});
const togglePinMutation = useMutation({
mutationFn: async ({ clipId, gameId }: { clipId: number; gameId: string }) => {
const assignment = (pinsQuery.data ?? []).find(
(entry) => entry.clip_id === clipId && entry.external_game_id === gameId,
);
if (assignment) {
await api.deleteAssignment(gameId, assignment.id, playerId);
return;
}
await api.createAssignment(gameId, {
external_team_id: teamId,
external_player_id: playerId,
clip_id: clipId,
batting_slot: null,
status: "ready",
});
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["pins", teamId, playerId] }),
queryClient.invalidateQueries({ queryKey: ["assignments"] }),
queryClient.invalidateQueries({ queryKey: ["prep"] }),
]);
},
});
function getAudio() {
const audio = audioRef.current ?? new Audio();
if (!audioRef.current) {
@@ -210,7 +276,7 @@ export function LibraryPage() {
</button>
</div>
<div className="stack">
{clipsQuery.data?.map((clip) => (
{orderedClips.map((clip, index) => (
<WalkupClipCard
key={clip.id}
clip={clip}
@@ -218,9 +284,34 @@ export function LibraryPage() {
onPreview={() => void playPreview(clip)}
onEdit={() => openEditWalkupClip(clip)}
onStopPreview={stopPreview}
onMoveUp={() => {
const nextOrder = [...orderedClips];
if (index <= 0) {
return;
}
const [movedClip] = nextOrder.splice(index, 1);
nextOrder.splice(index - 1, 0, movedClip);
void reorderClipsMutation.mutateAsync(nextOrder.map((item) => item.id));
}}
onMoveDown={() => {
const nextOrder = [...orderedClips];
if (index >= nextOrder.length - 1) {
return;
}
const [movedClip] = nextOrder.splice(index, 1);
nextOrder.splice(index + 1, 0, movedClip);
void reorderClipsMutation.mutateAsync(nextOrder.map((item) => item.id));
}}
canMoveUp={index > 0}
canMoveDown={index < orderedClips.length - 1}
games={walkup.games}
pinnedAssignmentsByClipAndGame={pinnedAssignmentsByClipAndGame}
onTogglePin={(gameId) => {
void togglePinMutation.mutateAsync({ clipId: clip.id, gameId });
}}
/>
))}
{!clipsQuery.isLoading && !clipsQuery.data?.length ? (
{!clipsQuery.isLoading && !orderedClips.length ? (
<div className="muted">No walkup clips created yet. Open the modal to make the first one.</div>
) : null}
{deleteClipMutation.error instanceof Error ? <div className="muted">{deleteClipMutation.error.message}</div> : null}
@@ -300,6 +391,30 @@ function BootstrapIcon({ name }: { name: BootstrapIconName }) {
);
}
if (name === "chevron-up") {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M7.646 5.854a.5.5 0 0 1 .708 0l4.5 4.5a.5.5 0 0 1-.708.708L8 7.207 3.854 11.062a.5.5 0 1 1-.708-.708z" />
</svg>
);
}
if (name === "chevron-down") {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M1.646 5.854a.5.5 0 0 1 .708 0L8 11.5l5.646-5.646a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708" />
</svg>
);
}
if (name === "pin-fill") {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M4.146 1.5a.5.5 0 0 1 .708 0l3.75 3.75 1.646-1.646a1 1 0 0 1 1.414 0l.732.732a1 1 0 0 1 0 1.414L10.75 7.896l3.75 3.75-1.5 1.5-3.75-3.75-2.354 2.354a1 1 0 0 1-1.414 0l-.732-.732a1 1 0 0 1 0-1.414L6.354 7.25l-3.75-3.75a.5.5 0 0 1 0-.708z" />
</svg>
);
}
if (name === "x-lg") {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
@@ -854,14 +969,54 @@ function WalkupClipCard({
onPreview,
onEdit,
onStopPreview,
onMoveUp,
onMoveDown,
canMoveUp,
canMoveDown,
games,
pinnedAssignmentsByClipAndGame,
onTogglePin,
}: {
clip: AudioClip;
isPreviewing: boolean;
onPreview: () => void;
onEdit: () => void;
onStopPreview: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
canMoveUp: boolean;
canMoveDown: boolean;
games: TeamSnapEvent[];
pinnedAssignmentsByClipAndGame: Map<string, { id: number }>;
onTogglePin: (gameId: string) => void;
}) {
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!menuOpen) {
return;
}
function handlePointerDown(event: PointerEvent) {
if (!menuRef.current?.contains(event.target as Node)) {
setMenuOpen(false);
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
setMenuOpen(false);
}
}
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [menuOpen]);
return (
<div className="clip-summary">
@@ -879,7 +1034,29 @@ function WalkupClipCard({
<div className="clip-summary-title-row">
<strong>{clip.label}</strong>
</div>
<div className="clip-summary-menu-wrap">
<div className="clip-summary-order-controls">
<button
type="button"
className="btn btn-sm icon-button icon-button-circle btn-outline-secondary"
onClick={onMoveUp}
disabled={!canMoveUp}
aria-label="Move clip up"
title="Move clip up"
>
<BootstrapIcon name="chevron-up" />
</button>
<button
type="button"
className="btn btn-sm icon-button icon-button-circle btn-outline-secondary"
onClick={onMoveDown}
disabled={!canMoveDown}
aria-label="Move clip down"
title="Move clip down"
>
<BootstrapIcon name="chevron-down" />
</button>
</div>
<div className="clip-summary-menu-wrap" ref={menuRef}>
<button
type="button"
className="icon-button-bare icon-button-menu"
@@ -899,6 +1076,29 @@ function WalkupClipCard({
</span>
<span>Edit clip</span>
</button>
<div className="clip-summary-menu-label">Pin to game</div>
{games.map((game) => {
const gameId = String(game.id);
const isPinned = pinnedAssignmentsByClipAndGame.has(`${gameId}:${clip.id}`);
return (
<button
type="button"
key={gameId}
className={`clip-summary-menu-item${isPinned ? " is-active" : ""}`}
role="menuitemcheckbox"
aria-checked={isPinned}
onClick={() => {
onTogglePin(gameId);
setMenuOpen(false);
}}
>
<span className="clip-summary-menu-icon">
<BootstrapIcon name="pin-fill" />
</span>
<span>{isPinned ? "Pinned: " : "Pin: "}{formatGameTitle(game)}</span>
</button>
);
})}
<div className="clip-summary-menu-label">Source: {clip.asset_title}</div>
</div>
) : null}

View File

@@ -63,11 +63,13 @@ export function OperatorPage() {
const [selectedPlayerId, setSelectedPlayerId] = useState("");
const [expandedPlayerId, setExpandedPlayerId] = useState("");
const [playerFilter, setPlayerFilter] = useState<"players" | "nonPlayers" | "all">("players");
const [playerFilterMenuOpen, setPlayerFilterMenuOpen] = useState(false);
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 playerFilterMenuRef = useRef<HTMLDivElement | null>(null);
const hasInitializedExpandedPlayerRef = useRef(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
@@ -99,6 +101,31 @@ export function OperatorPage() {
stopPlayback();
}, [selectedPlayerId]);
useEffect(() => {
if (!playerFilterMenuOpen) {
return;
}
function handlePointerDown(event: PointerEvent) {
if (!playerFilterMenuRef.current?.contains(event.target as Node)) {
setPlayerFilterMenuOpen(false);
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
setPlayerFilterMenuOpen(false);
}
}
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [playerFilterMenuOpen]);
const assignmentsQuery = useQuery({
queryKey: ["assignments", selectedGameId],
queryFn: () => api.listAssignments(selectedGameId),
@@ -180,25 +207,20 @@ export function OperatorPage() {
(selectedPlayerId ? { id: selectedPlayerId } : null);
const selectedPlayerJersey = selectedPlayer ? formatMemberJerseyNumber(selectedPlayer) : "";
const selectedAssignments = useMemo(
const selectedPinnedAssignments = useMemo(
() => assignmentList.filter((assignment) => assignment.external_player_id === selectedPlayerId),
[assignmentList, selectedPlayerId],
);
const selectedPinnedAssignmentByClipId = useMemo(
() => new Map(selectedPinnedAssignments.map((assignment) => [String(assignment.clip_id), assignment])),
[selectedPinnedAssignments],
);
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 a gameday session first");
}
return api.triggerPlaybackAssignment(selectedGameId, playbackSessionId, assignmentId);
},
});
const triggerClipMutation = useMutation({
mutationFn: (clip: AudioClip) => {
if (!playbackSessionId) {
@@ -369,21 +391,6 @@ export function OperatorPage() {
}
}
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,
@@ -431,7 +438,7 @@ export function OperatorPage() {
<h1>{selectedGame ? formatGameTitle(selectedGame) : "Select a game for gameday"}</h1>
<p>
Any player can run gameday. The player list now follows the event lineup first, then RSVP order, and each expanded
row shows the current game clips before the player&apos;s library clips.
row keeps pinned clips at the top of the player&apos;s library.
</p>
</div>
<div className="panel-grid">
@@ -455,16 +462,63 @@ export function OperatorPage() {
</div>
</div>
<div className="panel stack">
<div className="row">
<div className="operator-panel-header">
<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 className="operator-filter-menu-wrap" ref={playerFilterMenuRef}>
<button
type="button"
className="btn btn-outline-secondary btn-sm d-inline-flex align-items-center justify-content-center"
onClick={() => setPlayerFilterMenuOpen((current) => !current)}
aria-label="Filter players"
aria-haspopup="menu"
aria-expanded={playerFilterMenuOpen}
title="Filter players"
>
<svg className="icon-button-menu-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M1.5 2.75A.75.75 0 0 1 2.25 2h11.5a.75.75 0 0 1 .56 1.25L10 7.74V12a.75.75 0 0 1-1.14.64l-2-1.2A.75.75 0 0 1 6.5 10.8V7.74L1.69 3.25a.75.75 0 0 1-.19-.5" />
</svg>
</button>
{playerFilterMenuOpen ? (
<div className="operator-filter-menu" role="menu" aria-label="Player filter options">
<button
type="button"
className={`operator-filter-menu-item${playerFilter === "players" ? " is-active" : ""}`}
role="menuitemradio"
aria-checked={playerFilter === "players"}
onClick={() => {
setPlayerFilter("players");
setPlayerFilterMenuOpen(false);
}}
>
Players
</button>
<button
type="button"
className={`operator-filter-menu-item${playerFilter === "nonPlayers" ? " is-active" : ""}`}
role="menuitemradio"
aria-checked={playerFilter === "nonPlayers"}
onClick={() => {
setPlayerFilter("nonPlayers");
setPlayerFilterMenuOpen(false);
}}
>
Non-players
</button>
<button
type="button"
className={`operator-filter-menu-item${playerFilter === "all" ? " is-active" : ""}`}
role="menuitemradio"
aria-checked={playerFilter === "all"}
onClick={() => {
setPlayerFilter("all");
setPlayerFilterMenuOpen(false);
}}
>
All
</button>
</div>
) : null}
</div>
</div>
<div className="list-group operator-player-list">
{visibleMembers.map((member) => {
@@ -474,12 +528,12 @@ export function OperatorPage() {
const availability = (availabilityQuery.data ?? []).find(
(entry) => String(entry.memberId) === memberId,
) ?? null;
const assignmentCount = assignmentList.filter((assignment) => assignment.external_player_id === memberId).length;
const pinCount = 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,
pinCount ? `${pinCount} pinned clip${pinCount === 1 ? "" : "s"}` : null,
lineupEntry?.label ?? null,
].filter(Boolean);
@@ -487,7 +541,9 @@ export function OperatorPage() {
<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" : ""}`}
className={`operator-player-toggle 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);
@@ -512,9 +568,6 @@ export function OperatorPage() {
</div>
<div className="muted">{playerMeta.join(" • ")}</div>
</div>
<span className="operator-player-chevron" aria-hidden="true">
{isExpanded ? "" : ""}
</span>
</button>
{isExpanded ? (
<div
@@ -523,45 +576,10 @@ export function OperatorPage() {
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>
<span className="muted">Pinned clips stay at the top</span>
</div>
<div className="operator-clip-list">
<LibraryClips
@@ -569,6 +587,7 @@ export function OperatorPage() {
playerId={selectedPlayerId}
playingClipKey={playingClipKey}
onPlayClip={playClip}
pinnedAssignmentsByClipId={selectedPinnedAssignmentByClipId}
/>
</div>
</div>
@@ -613,7 +632,6 @@ export function OperatorPage() {
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>
@@ -626,11 +644,13 @@ function LibraryClips({
playerId,
playingClipKey,
onPlayClip,
pinnedAssignmentsByClipId,
}: {
teamId: string;
playerId: string;
playingClipKey: string | null;
onPlayClip: (clip: AudioClip) => Promise<void>;
pinnedAssignmentsByClipId: Map<string, GameAssignment>;
}) {
const fallbackClipsQuery = useQuery({
queryKey: ["clips", teamId, playerId],
@@ -646,19 +666,36 @@ function LibraryClips({
return <div className="muted">No library clips available for this player.</div>;
}
const clips = [...fallbackClipsQuery.data].sort((a, b) => {
const aPinned = pinnedAssignmentsByClipId.has(String(a.id));
const bPinned = pinnedAssignmentsByClipId.has(String(b.id));
if (aPinned !== bPinned) {
return aPinned ? -1 : 1;
}
if (a.sort_order !== b.sort_order) {
return a.sort_order - b.sort_order;
}
return a.label.localeCompare(b.label);
});
return (
<>
{fallbackClipsQuery.data.map((clip) => {
{clips.map((clip) => {
const key = clipKey("library", clip.id);
const isPlaying = playingClipKey === key;
const isPinned = pinnedAssignmentsByClipId.has(String(clip.id));
return (
<div className="operator-clip-row" key={clip.id}>
<div className="operator-clip-copy">
<strong>{clip.label}</strong>
<strong className="operator-clip-title">
{clip.label}
{isPinned ? <span className="pill">Pinned</span> : null}
</strong>
{isPinned ? <div className="muted">Pinned to this game</div> : null}
</div>
<button
type="button"
className={`btn btn-sm ${isPlaying ? "btn-primary" : "btn-outline-secondary"}`}
className={`operator-clip-play-button btn btn-sm ${isPlaying ? "btn-primary" : "btn-outline-secondary"}`}
onClick={() => void onPlayClip(clip)}
aria-pressed={isPlaying}
>

View File

@@ -289,6 +289,20 @@ select {
overflow-wrap: anywhere;
}
.clip-summary-order-controls {
display: inline-flex;
align-items: center;
gap: 0.35rem;
flex: 0 0 auto;
}
.operator-clip-title {
display: inline-flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.icon-button-circle {
width: 2rem;
height: 2rem;
@@ -324,6 +338,71 @@ select {
fill: currentColor;
}
.operator-panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.operator-filter-menu-wrap {
position: relative;
display: flex;
align-items: center;
flex: 0 0 auto;
}
.icon-button-menu-icon {
width: 1rem;
height: 1rem;
display: block;
fill: currentColor;
}
.operator-filter-menu {
position: absolute;
top: calc(100% + 0.4rem);
right: 0;
z-index: 10;
min-width: 8.5rem;
padding: 0.35rem;
border: 1px solid var(--panel-border);
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 16px 28px rgba(19, 34, 56, 0.14);
display: grid;
gap: 0.2rem;
}
.operator-filter-menu-item {
width: 100%;
border: 0;
background: transparent;
text-align: left;
padding: 0.4rem 0.55rem;
border-radius: 0.45rem;
color: var(--ink);
font-size: 0.92rem;
}
.operator-filter-menu-item.is-active {
background: rgba(217, 79, 4, 0.1);
color: var(--accent);
font-weight: 600;
}
@media (hover: hover) and (pointer: fine) {
.operator-filter-menu-item:hover {
background: rgba(19, 34, 56, 0.06);
}
}
.operator-filter-menu-item:focus-visible,
.operator-filter-button:focus-visible {
outline: 2px solid rgba(217, 79, 4, 0.45);
outline-offset: 2px;
}
.icon-button svg {
width: 1em;
height: 1em;
@@ -802,6 +881,7 @@ select {
.operator-player-summary {
display: grid;
gap: 0.3rem;
flex: 1 1 auto;
text-align: left;
}
@@ -843,27 +923,35 @@ select {
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;
.operator-player-toggle {
--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23132238' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23d94f04' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
--bs-accordion-btn-icon-width: 1.25rem;
--bs-accordion-btn-icon-transform: rotate(-180deg);
--bs-accordion-btn-icon-transition: transform 0.2s ease-in-out;
}
.list-group-item.active .operator-player-chevron {
transform: rotate(90deg);
background: rgba(217, 79, 4, 0.18);
color: var(--accent);
.operator-player-toggle::after {
flex-shrink: 0;
width: var(--bs-accordion-btn-icon-width);
height: var(--bs-accordion-btn-icon-width);
margin-left: auto;
content: "";
background-image: var(--bs-accordion-btn-icon);
background-repeat: no-repeat;
background-size: var(--bs-accordion-btn-icon-width);
transition: var(--bs-accordion-btn-icon-transition);
}
.operator-player-toggle.active::after {
background-image: var(--bs-accordion-btn-active-icon);
transform: var(--bs-accordion-btn-icon-transform);
}
@media (prefers-reduced-motion: reduce) {
.operator-player-toggle::after {
transition: none;
}
}
.operator-expansion {
@@ -900,7 +988,7 @@ select {
.operator-clip-row {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
gap: 0.75rem;
padding: 0.75rem 0.85rem;
border: 1px solid var(--line);
@@ -911,6 +999,12 @@ select {
.operator-clip-copy {
display: grid;
gap: 0.25rem;
flex: 1 1 auto;
min-width: 0;
}
.operator-clip-play-button {
margin-left: auto;
}
.operator-clip-button-indicator {