Refine gameday bootstrap layout

This commit is contained in:
Codex
2026-04-22 17:08:02 -05:00
parent 3bf3950899
commit 72360cc4dd
11 changed files with 58 additions and 467 deletions

View File

@@ -10,11 +10,11 @@
## Initial Deliverables
- Thin TeamSnap auth/session backend.
- Media upload and clip registration flow.
- Game assignment and gameday session APIs.
- Game assignment and gameday APIs.
- Installable React PWA shell with offline-ready game prep scaffolding.
- Docker-based local development stack.
## Known Constraints
- TeamSnap entities should not be durably mirrored on the backend.
- Gameday lineup changes are local-session state in v1.
- Gameday lineup changes are local state in v1.
- Browser clip editing is first-class; backend finalizes playback assets.

View File

@@ -54,7 +54,7 @@ Walkup is a collaborative baseball walk-up song app built as a React PWA with a
- TeamSnap OAuth start/callback/refresh
- Session cookie management
- Media upload and normalized clip registration
- Game assignments and gameday session APIs
- Game assignments and gameday APIs
## Frontend Responsibilities
- TeamSnap SDK bootstrap with server-issued access tokens

View File

@@ -81,20 +81,3 @@ class GameAssignment(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
clip: Mapped[AudioClip] = relationship()
class PlaybackSession(Base):
__tablename__ = "playback_sessions"
id: Mapped[int] = mapped_column(primary_key=True)
external_team_id: Mapped[str] = mapped_column(String(128), index=True)
external_game_id: Mapped[str] = mapped_column(String(128), index=True)
gameday_session_id: Mapped[int | None] = mapped_column(ForeignKey("user_sessions.id"))
current_assignment_id: Mapped[int | None] = mapped_column(ForeignKey("game_assignments.id"))
state: Mapped[str] = mapped_column(String(32), default="idle")
last_triggered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
gameday_session: Mapped[UserSession | None] = relationship()
current_assignment: Mapped[GameAssignment | None] = relationship()

View File

@@ -3,19 +3,16 @@ from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, update
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..auth import require_session
from ..database import get_db
from ..models import AudioClip, GameAssignment, PlaybackSession, UserSession
from ..models import AudioClip, GameAssignment, UserSession
from ..schemas import (
GameAssignmentCreate,
GameAssignmentResponse,
GamePrepResponse,
PlaybackAction,
PlaybackSessionCreate,
PlaybackSessionResponse,
)
router = APIRouter(prefix="/games", tags=["games"])
@@ -133,7 +130,6 @@ def delete_assignment(
if external_player_id is not None and assignment.external_player_id != external_player_id:
raise HTTPException(status_code=403, detail="Pin does not belong to that player")
db.execute(update(PlaybackSession).where(PlaybackSession.current_assignment_id == assignment.id).values(current_assignment_id=None))
db.delete(assignment)
db.commit()
@@ -157,61 +153,3 @@ def prepare_game(
prepared_at=datetime.now(timezone.utc),
assignments=[assignment_to_response(assignment) for assignment in assignments],
)
@router.post("/{external_game_id}/gameday/session", response_model=PlaybackSessionResponse)
def create_gameday_session(
external_game_id: str,
payload: PlaybackSessionCreate,
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> PlaybackSessionResponse:
playback = PlaybackSession(
external_team_id=payload.external_team_id,
external_game_id=external_game_id,
gameday_session_id=session.id,
state="idle",
)
db.add(playback)
db.commit()
db.refresh(playback)
return PlaybackSessionResponse.model_validate(playback, from_attributes=True)
@router.post("/{external_game_id}/gameday/session/{playback_session_id}/trigger", response_model=PlaybackSessionResponse)
def trigger_gameday(
external_game_id: str,
playback_session_id: int,
payload: PlaybackAction,
_: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> PlaybackSessionResponse:
playback = db.get(PlaybackSession, playback_session_id)
if playback is None or playback.external_game_id != external_game_id:
raise HTTPException(status_code=404, detail="Playback session not found")
if payload.assignment_id is None and payload.clip_id is None:
raise HTTPException(status_code=422, detail="Provide a pin or clip to trigger")
if payload.assignment_id is not None:
assignment = db.get(GameAssignment, payload.assignment_id)
if assignment is None or assignment.external_game_id != external_game_id:
raise HTTPException(status_code=404, detail="Pin not found")
if assignment.clip.hidden:
raise HTTPException(status_code=404, detail="Pin not found")
playback.current_assignment_id = assignment.id
else:
clip = db.get(AudioClip, payload.clip_id)
if clip is None or clip.asset.external_team_id != playback.external_team_id:
raise HTTPException(status_code=404, detail="Clip not found")
if clip.hidden:
raise HTTPException(status_code=404, detail="Clip not found")
if payload.external_player_id and clip.asset.owner_external_player_id != payload.external_player_id:
raise HTTPException(status_code=403, detail="Clip does not belong to that player")
playback.current_assignment_id = None
playback.state = payload.state
playback.last_triggered_at = datetime.now(timezone.utc)
db.commit()
db.refresh(playback)
return PlaybackSessionResponse.model_validate(playback, from_attributes=True)

View File

@@ -6,12 +6,12 @@ from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy import delete, func, select, update
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from ..auth import require_session
from ..database import get_db
from ..models import AudioAsset, AudioClip, GameAssignment, PlaybackSession, UserSession
from ..models import AudioAsset, AudioClip, GameAssignment, UserSession
from ..schemas import (
AudioAssetImportCreate,
AudioAssetResponse,
@@ -239,14 +239,7 @@ def delete_asset(
clips = db.scalars(select(AudioClip).where(AudioClip.asset_id == asset.id)).all()
clip_ids = [clip.id for clip in clips]
if clip_ids:
assignment_ids = db.scalars(select(GameAssignment.id).where(GameAssignment.clip_id.in_(clip_ids))).all()
db.execute(delete(GameAssignment).where(GameAssignment.clip_id.in_(clip_ids)))
if assignment_ids:
db.execute(
update(PlaybackSession)
.where(PlaybackSession.current_assignment_id.in_(assignment_ids))
.values(current_assignment_id=None)
)
for clip in clips:
if clip.normalized_path:
storage.delete_relative_path(clip.normalized_path)
@@ -361,14 +354,7 @@ def delete_clip(
if not can_manage_asset(session, clip.asset, owner_external_player_id):
raise HTTPException(status_code=403, detail="You can only delete clips from your own uploads")
assignment_ids = db.scalars(select(GameAssignment.id).where(GameAssignment.clip_id == clip.id)).all()
db.execute(delete(GameAssignment).where(GameAssignment.clip_id == clip.id))
if assignment_ids:
db.execute(
update(PlaybackSession)
.where(PlaybackSession.current_assignment_id.in_(assignment_ids))
.values(current_assignment_id=None)
)
if clip.normalized_path:
storage.delete_relative_path(clip.normalized_path)
db.delete(clip)

View File

@@ -126,21 +126,7 @@ class AudioClipReorder(BaseModel):
clip_ids: list[int] = Field(min_length=1)
class PlaybackSessionCreate(BaseModel):
external_team_id: str
class PlaybackAction(BaseModel):
assignment_id: int | None = None
clip_id: int | None = None
external_player_id: str | None = None
state: str = "playing"
class PlaybackSessionResponse(BaseModel):
id: int
external_team_id: str
external_game_id: str
current_assignment_id: int | None
state: str
last_triggered_at: datetime | None

View File

@@ -7,7 +7,7 @@ Walkup is a baseball walk-up song app with a React PWA frontend and a FastAPI ba
- The frontend runs as a browser app and PWA.
- The backend owns authentication, persisted app data, and media processing.
- TeamSnap is the source of truth for teams, members, events, lineups, and availability.
- The backend stores only app-owned data plus TeamSnap external IDs and tokens needed for the session flow.
- The backend stores only app-owned data plus TeamSnap external IDs and tokens needed for the auth flow.
## Frontend

View File

@@ -6,7 +6,6 @@ import type {
AudioClipUpdate,
GameAssignment,
GamePrepResponse,
PlaybackSession,
SessionResponse,
TeamSnapTokenResponse,
} from "./types";
@@ -196,19 +195,4 @@ export const api = {
}
}),
prepareGame: (gameId: string) => request<GamePrepResponse>(`/games/${encodeURIComponent(gameId)}/prep`),
createGamedaySession: (gameId: string, teamId: string) =>
request<PlaybackSession>(`/games/${encodeURIComponent(gameId)}/gameday/session`, {
method: "POST",
body: JSON.stringify({ external_team_id: teamId }),
}),
triggerGamedayAssignment: (gameId: string, playbackSessionId: number, assignmentId: number) =>
request<PlaybackSession>(`/games/${encodeURIComponent(gameId)}/gameday/session/${playbackSessionId}/trigger`, {
method: "POST",
body: JSON.stringify({ assignment_id: assignmentId, state: "playing" }),
}),
triggerGamedayClip: (gameId: string, playbackSessionId: number, clipId: number, playerId: string) =>
request<PlaybackSession>(`/games/${encodeURIComponent(gameId)}/gameday/session/${playbackSessionId}/trigger`, {
method: "POST",
body: JSON.stringify({ clip_id: clipId, external_player_id: playerId, state: "playing" }),
}),
};

View File

@@ -86,15 +86,6 @@ export interface GamePrepResponse {
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;

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { api } from "../api/client";
@@ -12,7 +12,6 @@ import {
formatGameTitle,
formatMemberName,
formatMemberJerseyNumber,
formatTeamLabel,
findLineupEntryForMember,
isPlayerMember,
orderMembersByLineupAndRsvps,
@@ -30,17 +29,17 @@ type NowPlaying = {
const DEFAULT_FADE_OUT_MS = 1000;
function getAvailabilityDotClass(statusCode: number | null | undefined): string {
function getAvailabilityIconClass(statusCode: number | null | undefined): string {
if (statusCode === 1) {
return "is-yes";
return "bi-check-circle-fill text-success";
}
if (statusCode === 0) {
return "is-no";
return "bi-x-circle-fill text-danger";
}
if (statusCode === 2) {
return "is-maybe";
return "bi-question-circle-fill text-primary";
}
return "is-blank";
return "bi-circle-fill text-secondary";
}
function getAvailabilityDotLabel(statusCode: number | null | undefined): string {
@@ -64,7 +63,6 @@ export function GamedayPage() {
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);
@@ -92,7 +90,6 @@ export function GamedayPage() {
useEffect(() => {
stopPlayback();
setPlaybackSessionId(null);
setExpandedPlayerId("");
hasInitializedExpandedPlayerRef.current = false;
}, [selectedGameId]);
@@ -205,7 +202,6 @@ export function GamedayPage() {
const selectedPlayer =
walkup.members.find((member) => String(member.id) === selectedPlayerId) ??
(selectedPlayerId ? { id: selectedPlayerId } : null);
const selectedPlayerJersey = selectedPlayer ? formatMemberJerseyNumber(selectedPlayer) : "";
const selectedPinnedAssignments = useMemo(
() => assignmentList.filter((assignment) => assignment.external_player_id === selectedPlayerId),
@@ -216,20 +212,6 @@ export function GamedayPage() {
[selectedPinnedAssignments],
);
const createSession = useMutation({
mutationFn: () => api.createGamedaySession(selectedGameId, teamId),
onSuccess: (session) => setPlaybackSessionId(session.id),
});
const triggerClipMutation = useMutation({
mutationFn: (clip: AudioClip) => {
if (!playbackSessionId) {
throw new Error("Start a gameday session first");
}
return api.triggerGamedayClip(selectedGameId, playbackSessionId, clip.id, selectedPlayerId);
},
});
const selectedGame = walkup.games.find((game) => String(game.id) === selectedGameId) ?? null;
function selectGame(gameId: string) {
@@ -402,7 +384,6 @@ export function GamedayPage() {
},
clip.start_ms,
clip.end_ms,
() => triggerClipMutation.mutateAsync(clip),
);
}
@@ -462,9 +443,9 @@ export function GamedayPage() {
</div>
</div>
<div className="panel stack">
<div className="gameday-panel-header">
<h2 style={{ margin: 0 }}>Players</h2>
<div className="gameday-filter-menu-wrap" ref={playerFilterMenuRef}>
<div className="d-flex align-items-center justify-content-between gap-3 flex-wrap">
<h2 className="mb-0">Players</h2>
<div className="position-relative" ref={playerFilterMenuRef}>
<button
type="button"
className="btn btn-outline-secondary btn-sm d-inline-flex align-items-center justify-content-center"
@@ -474,15 +455,18 @@ export function GamedayPage() {
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>
<i className="bi bi-funnel" aria-hidden="true" />
</button>
{playerFilterMenuOpen ? (
<div className="gameday-filter-menu" role="menu" aria-label="Player filter options">
<div
className="dropdown-menu show dropdown-menu-end p-1 shadow-sm"
role="menu"
aria-label="Player filter options"
style={{ minWidth: "12rem" }}
>
<button
type="button"
className={`gameday-filter-menu-item${playerFilter === "players" ? " is-active" : ""}`}
className={`dropdown-item rounded-2${playerFilter === "players" ? " active" : ""}`}
role="menuitemradio"
aria-checked={playerFilter === "players"}
onClick={() => {
@@ -494,7 +478,7 @@ export function GamedayPage() {
</button>
<button
type="button"
className={`gameday-filter-menu-item${playerFilter === "nonPlayers" ? " is-active" : ""}`}
className={`dropdown-item rounded-2${playerFilter === "nonPlayers" ? " active" : ""}`}
role="menuitemradio"
aria-checked={playerFilter === "nonPlayers"}
onClick={() => {
@@ -506,7 +490,7 @@ export function GamedayPage() {
</button>
<button
type="button"
className={`gameday-filter-menu-item${playerFilter === "all" ? " is-active" : ""}`}
className={`dropdown-item rounded-2${playerFilter === "all" ? " active" : ""}`}
role="menuitemradio"
aria-checked={playerFilter === "all"}
onClick={() => {
@@ -520,7 +504,7 @@ export function GamedayPage() {
) : null}
</div>
</div>
<div className="list-group gameday-player-list">
<div className="list-group list-group-flush rounded-3 overflow-hidden border">
{visibleMembers.map((member) => {
const memberId = String(member.id);
const jerseyNumber = formatMemberJerseyNumber(member);
@@ -538,11 +522,11 @@ export function GamedayPage() {
].filter(Boolean);
return (
<div className={`gameday-player-card${isExpanded ? " is-selected" : ""}`} key={memberId}>
<div className={`list-group-item p-0${isExpanded ? " bg-body-tertiary" : ""}`} key={memberId}>
<button
type="button"
className={`gameday-player-toggle list-group-item list-group-item-action d-flex justify-content-between align-items-center text-start${
isExpanded ? " active" : ""
className={`btn w-100 d-flex align-items-center gap-3 text-start border-0 rounded-0 px-3 py-3${
isExpanded ? " btn-outline-primary" : " btn-light"
}`}
onClick={() => {
selectedPlayerWasManualRef.current = true;
@@ -553,31 +537,36 @@ export function GamedayPage() {
aria-controls={expansionId}
id={`player-${memberId}-toggle`}
>
<div className="gameday-player-summary">
<div className="gameday-player-heading">
<strong>
<span
className={`gameday-availability-dot ${getAvailabilityDotClass(availabilityStatusCode)}`}
aria-label={getAvailabilityDotLabel(availabilityStatusCode)}
title={getAvailabilityDotLabel(availabilityStatusCode)}
/>
<div className="flex-grow-1 d-grid gap-1 text-start min-w-0">
<div className="d-flex align-items-center gap-2 flex-wrap">
<i
className={`bi ${getAvailabilityIconClass(availabilityStatusCode)} flex-shrink-0`}
aria-label={getAvailabilityDotLabel(availabilityStatusCode)}
title={getAvailabilityDotLabel(availabilityStatusCode)}
/>
<strong className="d-inline-flex align-items-center gap-2 text-truncate">
{formatMemberName(member)}
{jerseyNumber ? ` ${jerseyNumber}` : ""}
</strong>
{lineupEntry ? <span className="pill">Lineup {lineupEntry.sequence ?? "?"}</span> : null}
{lineupEntry ? <span className="badge rounded-pill text-bg-secondary">Lineup {lineupEntry.sequence ?? "?"}</span> : null}
</div>
<div className="muted">{playerMeta.join(" • ")}</div>
<div className="text-body-secondary small text-truncate">{playerMeta.join(" • ")}</div>
</div>
<i
className="bi bi-chevron-down ms-auto fs-5 lh-1"
aria-hidden="true"
style={{ transform: isExpanded ? "rotate(180deg)" : "none", transition: "transform 0.18s ease" }}
/>
</button>
{isExpanded ? (
<div
className="gameday-expansion"
className="border-top bg-white"
id={expansionId}
role="region"
aria-labelledby={`player-${memberId}-toggle`}
>
<div className="gameday-section">
<div className="gameday-clip-list">
<div className="p-3 pb-0">
<div className="list-group list-group-flush">
<LibraryClips
teamId={teamId}
playerId={selectedPlayerId}
@@ -587,10 +576,10 @@ export function GamedayPage() {
/>
</div>
</div>
<div className="gameday-section">
<details className="gameday-debug-details">
<div className="p-3 pt-2">
<details className="text-body-secondary">
<summary>Debug: Show raw lineup data</summary>
<pre className="gameday-debug">
<pre className="mt-2 mb-0 rounded-3 bg-body-tertiary p-3 small text-body-secondary overflow-auto">
{JSON.stringify(
{
selectedPlayerId,
@@ -616,19 +605,6 @@ export function GamedayPage() {
{!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 gameday 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>
{triggerClipMutation.error instanceof Error ? <div className="muted">{triggerClipMutation.error.message}</div> : null}
</div>
</div>
</section>
);
@@ -680,21 +656,20 @@ function LibraryClips({
const isPlaying = playingClipKey === key;
const isPinned = pinnedAssignmentsByClipId.has(String(clip.id));
return (
<div className="gameday-clip-row" key={clip.id}>
<div className="gameday-clip-copy">
<strong className="gameday-clip-title">
<div className="list-group-item list-group-item-action d-flex align-items-center gap-3" key={clip.id}>
<div className="flex-grow-1 d-grid gap-1 min-w-0">
<strong className="text-truncate">
{clip.label}
{isPinned ? <span className="pill">Pinned</span> : null}
{isPinned ? <span className="ms-2 badge rounded-pill text-bg-success">Pinned</span> : null}
</strong>
{isPinned ? <div className="muted">Pinned to this game</div> : null}
{isPinned ? <div className="text-body-secondary small">Pinned to this game</div> : null}
</div>
<button
type="button"
className={`gameday-clip-play-button btn btn-sm ${isPlaying ? "btn-primary" : "btn-outline-secondary"}`}
className={`btn btn-sm ${isPlaying ? "btn-primary" : "btn-outline-secondary"}`}
onClick={() => void onPlayClip(clip)}
aria-pressed={isPlaying}
>
<span className={`gameday-clip-button-indicator${isPlaying ? " is-playing" : ""}`} />
{isPlaying ? "Stop" : "Play"}
</button>
</div>

View File

@@ -355,71 +355,6 @@ select {
font-size: 1rem;
}
.gameday-panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.gameday-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;
}
.gameday-filter-menu {
position: absolute;
top: calc(100% + 0.4rem);
right: 0;
z-index: 11000;
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;
}
.gameday-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;
}
.gameday-filter-menu-item.is-active {
background: rgba(217, 79, 4, 0.1);
color: var(--accent);
font-weight: 600;
}
@media (hover: hover) and (pointer: fine) {
.gameday-filter-menu-item:hover {
background: rgba(19, 34, 56, 0.06);
}
}
.gameday-filter-menu-item:focus-visible,
.gameday-filter-button:focus-visible {
outline: 2px solid rgba(217, 79, 4, 0.45);
outline-offset: 2px;
}
.icon-button .bootstrap-icon {
font-size: 1em;
vertical-align: -0.125em;
@@ -940,173 +875,6 @@ select {
flex: 0 0 auto;
}
.gameday-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);
}
.gameday-player-card {
display: grid;
gap: 0;
padding: 0;
border: 0;
border-top: 1px solid var(--line);
background: transparent;
}
.gameday-player-card:first-child {
border-top: 0;
}
.gameday-player-card.is-selected {
background: rgba(217, 79, 4, 0.03);
}
.gameday-player-summary {
display: grid;
gap: 0.3rem;
flex: 1 1 auto;
text-align: left;
}
.gameday-player-heading {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.gameday-player-heading strong {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.gameday-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);
}
.gameday-availability-dot.is-yes {
background: #2f9e44;
}
.gameday-availability-dot.is-no {
background: #e03131;
}
.gameday-availability-dot.is-maybe {
background: #1c7ed6;
}
.gameday-availability-dot.is-blank {
background: #adb5bd;
}
.gameday-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;
}
.gameday-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);
}
.gameday-player-toggle.active::after {
background-image: var(--bs-accordion-btn-active-icon);
transform: var(--bs-accordion-btn-icon-transform);
}
@media (prefers-reduced-motion: reduce) {
.gameday-player-toggle::after {
transition: none;
}
}
.gameday-expansion {
display: grid;
gap: 1rem;
padding: 0;
border-top: 1px solid var(--line);
background: rgba(255, 255, 255, 0.94);
}
.gameday-section {
display: grid;
gap: 0.65rem;
padding: 1rem 1rem 0;
}
.gameday-section:last-child {
padding-bottom: 1rem;
}
.gameday-section-title {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.75rem;
flex-wrap: wrap;
}
.gameday-clip-list {
display: grid;
gap: 0.5rem;
}
.gameday-clip-row {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.75rem;
padding: 0.75rem 0.85rem;
border: 1px solid var(--line);
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.75);
}
.gameday-clip-copy {
display: grid;
gap: 0.25rem;
flex: 1 1 auto;
min-width: 0;
}
.gameday-clip-play-button {
margin-left: auto;
}
.gameday-clip-button-indicator {
width: 0.55rem;
height: 0.55rem;
border-radius: 999px;
background: rgba(19, 34, 56, 0.32);
}
.gameday-clip-button-indicator.is-playing {
background: var(--accent);
}
.gameday-debug {
margin: 0;
padding: 0.75rem 0.85rem;
@@ -1120,26 +888,6 @@ select {
overflow-x: auto;
}
.gameday-debug-details {
padding: 0.15rem 0 0;
color: var(--muted);
}
.gameday-debug-details > summary {
cursor: pointer;
list-style: none;
color: var(--muted);
font-size: 0.88rem;
}
.gameday-debug-details > summary::-webkit-details-marker {
display: none;
}
.gameday-debug-details[open] > summary {
margin-bottom: 0.65rem;
}
@media (max-width: 900px) {
.gameday-toolbar {
left: 1rem;