Merge branch 'feature/home-page-cleanup' into dev

This commit is contained in:
Codex
2026-04-23 16:12:39 -05:00
7 changed files with 153 additions and 126 deletions

View File

@@ -14,6 +14,11 @@
- Installable React PWA shell with offline-ready game prep scaffolding. - Installable React PWA shell with offline-ready game prep scaffolding.
- Docker-based local development stack. - Docker-based local development stack.
## Completed UI Cleanup
- Home page now acts as a lightweight landing page with direct links to Library and Gameday.
- Removed the old game-list-heavy dashboard content that was not useful as a landing surface.
- Game titles in the UI now include a day parenthetical such as `(sun 5/3)` wherever the shared formatter is used.
## Storage Status ## Storage Status
- Backend media persists in the `backend-media` named Docker volume. - Backend media persists in the `backend-media` named Docker volume.

View File

@@ -15,6 +15,7 @@ Walkup is a baseball walk-up song app with a React PWA frontend and a FastAPI ba
- The app uses React Router for navigation and TanStack Query for server state. - The app uses React Router for navigation and TanStack Query for server state.
- TeamSnap data is loaded through the official JavaScript SDK from the browser after the backend provides an access token. - TeamSnap data is loaded through the official JavaScript SDK from the browser after the backend provides an access token.
- The UI includes player, gameday, and library views for clip management and gameday playback. - The UI includes player, gameday, and library views for clip management and gameday playback.
- The home page is a lightweight landing page that orients users and links to the Library and Gameday views.
- The app is shipped as a PWA with install and offline-prep behavior. - The app is shipped as a PWA with install and offline-prep behavior.
## Backend ## Backend

View File

@@ -75,14 +75,15 @@ export function findCurrentPlayer(externalUserId: string | number | null | undef
export function formatGameTitle(game: TeamSnapEvent): string { export function formatGameTitle(game: TeamSnapEvent): string {
const name = asDisplayText(game.name); const name = asDisplayText(game.name);
const dayLabel = formatGameDayLabel(game);
if (name) { if (name) {
return name; return `${name}${dayLabel}`;
} }
const opponentName = asDisplayText(game.opponentName); const opponentName = asDisplayText(game.opponentName);
if (opponentName) { if (opponentName) {
return `vs ${opponentName}`; return `vs ${opponentName}${dayLabel}`;
} }
return `Game ${game.id}`; return `Game ${game.id}${dayLabel}`;
} }
export function formatGameDate(game: TeamSnapEvent): string { export function formatGameDate(game: TeamSnapEvent): string {
@@ -98,6 +99,20 @@ export function formatGameDate(game: TeamSnapEvent): string {
}); });
} }
function formatGameDayLabel(game: TeamSnapEvent): string {
const date = toDate(game.startDate);
if (!date) {
return "";
}
const weekday = date
.toLocaleDateString("en-US", { weekday: "short" })
.replace(/\./g, "")
.replace(/^./, (character) => character.toUpperCase());
const monthDay = date.toLocaleDateString("en-US", { month: "numeric", day: "numeric" });
return ` (${weekday} ${monthDay})`;
}
export function sortGames(events: TeamSnapEvent[]): TeamSnapEvent[] { export function sortGames(events: TeamSnapEvent[]): TeamSnapEvent[] {
return [...events] return [...events]
.filter((event) => event.isGame) .filter((event) => event.isGame)

View File

@@ -1,20 +1,45 @@
import { useNavigate } from "react-router-dom"; import { Link } from "react-router-dom";
import { useWalkupContext } from "../hooks/useWalkupContext"; import { useWalkupContext } from "../hooks/useWalkupContext";
import { formatGameDate, formatGameTitle, formatMemberName } from "../lib/teamsnapHelpers";
export function DashboardPage() { export function DashboardPage() {
const navigate = useNavigate();
const walkup = useWalkupContext(); const walkup = useWalkupContext();
if (!walkup.isTeamSnap) { if (!walkup.isTeamSnap) {
return ( return (
<section className="container-fluid py-4"> <section className="container-fluid py-4 d-grid gap-4">
<div className="card bg-dark text-white border-0 shadow-sm"> <div className="row g-4">
<div className="card-body p-4 p-lg-5"> <div className="col-12 col-lg-6">
<p className="text-uppercase small text-info-emphasis mb-2">Player flow</p> <div className="card shadow-sm h-100">
<h1 className="h2">Sign in with TeamSnap to resolve your player and team context.</h1> <div className="card-body d-grid gap-3">
<p className="mb-0 text-white-50">The player dashboard depends on your TeamSnap user, roster membership, and upcoming games.</p> <p className="text-uppercase small text-body-secondary mb-0">Library</p>
<h2 className="h4 mb-0">Manage walkup clips</h2>
<p className="text-body-secondary mb-0">
Upload audio, trim clips, reorder them, and pin them to players before game day.
</p>
<div>
<Link to="/library" className="btn btn-primary">
Open Library
</Link>
</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">
<p className="text-uppercase small text-body-secondary mb-0">Gameday</p>
<h2 className="h4 mb-0">Run the game-day view</h2>
<p className="text-body-secondary mb-0">
Review lineups, check availability, and play the right walkup clips during the game.
</p>
<div>
<Link to="/gameday" className="btn btn-primary">
Open Gameday
</Link>
</div>
</div>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -23,61 +48,36 @@ export function DashboardPage() {
return ( return (
<section className="container-fluid py-4 d-grid gap-4"> <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="row g-4">
<div className="col-12 col-xl-6"> <div className="col-12 col-lg-6">
<div className="card shadow-sm h-100"> <div className="card shadow-sm h-100">
<div className="card-body d-grid gap-3"> <div className="card-body d-grid gap-3">
<h2 className="h4 mb-0">Next game</h2> <p className="text-uppercase small text-body-secondary mb-0">Library</p>
{walkup.nextGame ? ( <h2 className="h4 mb-0">Manage walkup clips</h2>
<> <p className="text-body-secondary mb-0">
<strong className="fs-5">{formatGameTitle(walkup.nextGame)}</strong> Upload audio, trim clips, reorder them, and pin them to players before game day.
<div className="text-body-secondary">{formatGameDate(walkup.nextGame)}</div> </p>
{walkup.nextGame.locationName ? <div className="text-body-secondary">{walkup.nextGame.locationName}</div> : null} <div>
<div className="d-flex flex-wrap gap-2"> <Link to="/library" className="btn btn-primary">
<button type="button" className="btn btn-primary" onClick={() => navigate("/library")}> Open Library
Add walkup clip </Link>
</button>
</div> </div>
</>
) : (
<div className="text-body-secondary">No upcoming games were returned for this team.</div>
)}
</div> </div>
</div> </div>
</div> </div>
<div className="col-12 col-xl-6"> <div className="col-12 col-lg-6">
<div className="card shadow-sm h-100"> <div className="card shadow-sm h-100">
<div className="card-body d-grid gap-3"> <div className="card-body d-grid gap-3">
<h2 className="h4 mb-0">Upcoming games</h2> <p className="text-uppercase small text-body-secondary mb-0">Gameday</p>
<div className="list-group"> <h2 className="h4 mb-0">Run the game-day view</h2>
{walkup.eventsQuery.isLoading ? <div className="text-body-secondary">Loading games...</div> : null} <p className="text-body-secondary mb-0">
{walkup.games.slice(0, 8).map((game) => ( Review lineups, check availability, and play the right walkup clips during the game.
<div </p>
key={String(game.id)} <div>
className="list-group-item d-flex justify-content-between align-items-center text-start" <Link to="/gameday" className="btn btn-primary">
> Open Gameday
<div> </Link>
<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>
</div> </div>
))}
{!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>
</div> </div>

View File

@@ -15,6 +15,8 @@ import { formatGameTitle, formatMemberName } from "../lib/teamsnapHelpers";
const MEDIA_ACCEPT = const MEDIA_ACCEPT =
".mp3,.m4a,.aac,.wav,.ogg,.oga,.flac,.mp4,.m4v,.mov,audio/*,video/*,application/octet-stream"; ".mp3,.m4a,.aac,.wav,.ogg,.oga,.flac,.mp4,.m4v,.mov,audio/*,video/*,application/octet-stream";
const DEFAULT_CLIP_LENGTH_MS = 30_000; const DEFAULT_CLIP_LENGTH_MS = 30_000;
const END_SHORTCUT_LENGTH_MS = 90_000;
const SAVE_FADE_OUT_MS = 1000;
const TRIM_NUDGE_MS = 100; const TRIM_NUDGE_MS = 100;
const TRIM_STEP_MS = 100; const TRIM_STEP_MS = 100;
const TRIM_ZOOM_WINDOW_MS = 3_000; const TRIM_ZOOM_WINDOW_MS = 3_000;
@@ -63,6 +65,7 @@ export function LibraryPage() {
const { const {
activeKey: previewKey, activeKey: previewKey,
currentTimeMs: previewTimeMs, currentTimeMs: previewTimeMs,
fadeOutClip,
playClip: playClipPreview, playClip: playClipPreview,
stopClip: stopPreview, stopClip: stopPreview,
} = useClipPlayback(); } = useClipPlayback();
@@ -314,6 +317,7 @@ export function LibraryPage() {
teamId={teamId} teamId={teamId}
playerId={playerId} playerId={playerId}
previewTimeMs={previewTimeMs} previewTimeMs={previewTimeMs}
fadeOutPreview={fadeOutClip}
playPreview={playPreview} playPreview={playPreview}
onClose={closeCreateWalkupClip} onClose={closeCreateWalkupClip}
stopPreview={stopPreview} stopPreview={stopPreview}
@@ -373,6 +377,7 @@ function WalkupClipModal({
teamId, teamId,
playerId, playerId,
previewTimeMs, previewTimeMs,
fadeOutPreview,
playPreview, playPreview,
onClose, onClose,
stopPreview, stopPreview,
@@ -384,6 +389,7 @@ function WalkupClipModal({
teamId: string; teamId: string;
playerId: string; playerId: string;
previewTimeMs: number | null; previewTimeMs: number | null;
fadeOutPreview: (durationMs?: number) => void;
playPreview: (clip: AudioClip, startMs?: number, endMs?: number) => Promise<void>; playPreview: (clip: AudioClip, startMs?: number, endMs?: number) => Promise<void>;
onClose: () => void; onClose: () => void;
stopPreview: () => void; stopPreview: () => void;
@@ -593,23 +599,19 @@ function WalkupClipModal({
</button> </button>
</div> </div>
<div className="walkup-modal-body"> <div className="walkup-modal-body">
<div className="walkup-stepper"> <nav aria-label="Walkup clip steps">
<div className={`walkup-step${step === "source" ? " is-active" : " is-complete"}`}>1. Source</div> <ol className="breadcrumb walkup-step-breadcrumb mb-0">
<div className={`walkup-step${step === "editor" ? " is-active" : ""}`}>2. Trim and metadata</div> <li className={`breadcrumb-item${step === "source" ? " active" : ""}`} aria-current={step === "source" ? "page" : undefined}>
</div> Source
</li>
<li className={`breadcrumb-item${step === "editor" ? " active" : ""}`} aria-current={step === "editor" ? "page" : undefined}>
Trim and metadata
</li>
</ol>
</nav>
{step === "source" ? ( {step === "source" ? (
<form className="stack" onSubmit={handleSourceSubmit} aria-busy={createSourceMutation.isPending}> <form className="stack" onSubmit={handleSourceSubmit} aria-busy={createSourceMutation.isPending}>
<label className="field">
Walkup clip name
<input
value={draftLabel}
onChange={(event) => setDraftLabel(event.target.value)}
placeholder="Optional clip name"
autoComplete="off"
disabled={createSourceMutation.isPending}
/>
</label>
<ul className="nav nav-tabs" role="tablist" aria-label="Walkup clip source"> <ul className="nav nav-tabs" role="tablist" aria-label="Walkup clip source">
<li className="nav-item" role="presentation"> <li className="nav-item" role="presentation">
<button <button
@@ -622,7 +624,7 @@ function WalkupClipModal({
disabled={createSourceMutation.isPending} disabled={createSourceMutation.isPending}
onClick={() => setSourceMode("upload")} onClick={() => setSourceMode("upload")}
> >
Upload file File
</button> </button>
</li> </li>
<li className="nav-item" role="presentation"> <li className="nav-item" role="presentation">
@@ -636,7 +638,7 @@ function WalkupClipModal({
disabled={createSourceMutation.isPending} disabled={createSourceMutation.isPending}
onClick={() => setSourceMode("url")} onClick={() => setSourceMode("url")}
> >
Import URL URL
</button> </button>
</li> </li>
<li className="nav-item" role="presentation"> <li className="nav-item" role="presentation">
@@ -650,7 +652,7 @@ function WalkupClipModal({
disabled={createSourceMutation.isPending} disabled={createSourceMutation.isPending}
onClick={() => setSourceMode("existing")} onClick={() => setSourceMode("existing")}
> >
Existing media Existing
</button> </button>
</li> </li>
</ul> </ul>
@@ -662,6 +664,7 @@ function WalkupClipModal({
className={`tab-pane fade${sourceMode === "upload" ? " show active" : ""}`} className={`tab-pane fade${sourceMode === "upload" ? " show active" : ""}`}
> >
<div className="stack"> <div className="stack">
<div className="muted">Upload a local audio file to create a new walkup clip.</div>
<label className="field"> <label className="field">
Media title Media title
<input <input
@@ -691,6 +694,7 @@ function WalkupClipModal({
className={`tab-pane fade${sourceMode === "url" ? " show active" : ""}`} className={`tab-pane fade${sourceMode === "url" ? " show active" : ""}`}
> >
<div className="stack"> <div className="stack">
<div className="muted">Paste a link, such as a YouTube URL, and we will download the audio for clip creation.</div>
<label className="field"> <label className="field">
Media title Media title
<input <input
@@ -722,6 +726,7 @@ function WalkupClipModal({
className={`tab-pane fade${sourceMode === "existing" ? " show active" : ""}`} className={`tab-pane fade${sourceMode === "existing" ? " show active" : ""}`}
> >
<div className="stack"> <div className="stack">
<div className="muted">Pick an existing media file to turn into a walkup clip.</div>
<label className="field"> <label className="field">
Existing media file Existing media file
<select <select
@@ -774,11 +779,17 @@ function WalkupClipModal({
previewTimeMs={previewTimeMs} previewTimeMs={previewTimeMs}
playerId={playerId} playerId={playerId}
onSaveComplete={async () => { onSaveComplete={async () => {
if (previewTimeMs !== null) {
fadeOutPreview(SAVE_FADE_OUT_MS);
await new Promise<void>((resolve) => {
window.setTimeout(resolve, SAVE_FADE_OUT_MS);
});
}
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: ["assets", teamId, playerId] }), queryClient.invalidateQueries({ queryKey: ["assets", teamId, playerId] }),
queryClient.invalidateQueries({ queryKey: clipsQueryPrefix(teamId, playerId) }), queryClient.invalidateQueries({ queryKey: clipsQueryPrefix(teamId, playerId) }),
]); ]);
handleClose(); onClose();
}} }}
saveButtonLabel={isCreateMode ? "Save walk up clip" : "Save changes"} saveButtonLabel={isCreateMode ? "Save walk up clip" : "Save changes"}
introText={ introText={
@@ -980,10 +991,10 @@ function WalkupClipCard({
titleExtras={isHidden ? <span className="pill">Hidden</span> : null} titleExtras={isHidden ? <span className="pill">Hidden</span> : null}
actions={ actions={
<> <>
<div className="clip-summary-order-controls"> <div className="btn-group btn-group-sm clip-summary-order-controls" role="group" aria-label="Clip order controls">
<button <button
type="button" type="button"
className="btn btn-sm icon-button icon-button-circle btn-outline-secondary" className="btn btn-outline-secondary"
onClick={onMoveUp} onClick={onMoveUp}
disabled={!canMoveUp} disabled={!canMoveUp}
aria-label="Move clip up" aria-label="Move clip up"
@@ -993,7 +1004,7 @@ function WalkupClipCard({
</button> </button>
<button <button
type="button" type="button"
className="btn btn-sm icon-button icon-button-circle btn-outline-secondary" className="btn btn-outline-secondary"
onClick={onMoveDown} onClick={onMoveDown}
disabled={!canMoveDown} disabled={!canMoveDown}
aria-label="Move clip down" aria-label="Move clip down"
@@ -1274,10 +1285,6 @@ function WalkupClipEditorPanel({
await handleSave(); await handleSave();
} }
function useThirtySecondLength() {
setEndMs(startMs + DEFAULT_CLIP_LENGTH_MS);
}
function handleStartChange(nextStart: number) { function handleStartChange(nextStart: number) {
setStartMs(nextStart); setStartMs(nextStart);
if (nextStart >= endMs) { if (nextStart >= endMs) {
@@ -1554,10 +1561,14 @@ function ClipTrimScrubber({
onStartChange(nextStartMs); onStartChange(nextStartMs);
} }
function nudgeEnd(deltaMs: number) { function setEndToDefaultLength() {
const nextEndMs = Math.max(safeStartMs + 1, safeEndMs + deltaMs); onEndChange(Math.min(Math.max(1, durationMs), safeStartMs + END_SHORTCUT_LENGTH_MS));
setFocusEdge("end");
}
function setEndToSourceEnd() {
onEndChange(Math.max(1, durationMs));
setFocusEdge("end"); setFocusEdge("end");
onEndChange(nextEndMs);
} }
return ( return (
@@ -1608,12 +1619,12 @@ function ClipTrimScrubber({
<div className="clip-waveform-control-header"> <div className="clip-waveform-control-header">
<span>End</span> <span>End</span>
<span className="muted">{formatPlaybackPosition(safeEndMs)}</span> <span className="muted">{formatPlaybackPosition(safeEndMs)}</span>
<div className="clip-waveform-nudges"> <div className="btn-group btn-group-sm" role="group" aria-label="End clip shortcuts">
<button type="button" className="btn btn-outline-secondary btn-sm" onClick={() => nudgeEnd(-TRIM_NUDGE_MS)}> <button type="button" className="btn btn-outline-secondary" onClick={setEndToDefaultLength}>
-{TRIM_NUDGE_MS}ms 1:30
</button> </button>
<button type="button" className="btn btn-outline-secondary btn-sm" onClick={() => nudgeEnd(TRIM_NUDGE_MS)}> <button type="button" className="btn btn-outline-secondary" onClick={setEndToSourceEnd}>
+{TRIM_NUDGE_MS}ms EOF
</button> </button>
</div> </div>
</div> </div>

View File

@@ -25,16 +25,6 @@ export function ProfilePage() {
return ( return (
<section className="container-fluid py-4 d-grid gap-4"> <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="row g-4">
<div className="col-12 col-lg-6"> <div className="col-12 col-lg-6">
<div className="card shadow-sm h-100"> <div className="card shadow-sm h-100">

View File

@@ -165,37 +165,49 @@ select {
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
padding: 1.25rem 1.25rem 0; padding: 1rem 1rem 0;
}
.walkup-modal-header h2 {
color: var(--ink);
font-weight: 700;
letter-spacing: -0.03em;
}
.walkup-modal-header .eyebrow {
color: var(--accent);
opacity: 1;
} }
.walkup-modal-body { .walkup-modal-body {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
padding: 1.25rem; padding: 1rem;
} }
.walkup-stepper { .walkup-step-breadcrumb {
display: flex; --bs-breadcrumb-divider: "";
gap: 0.75rem; align-items: center;
flex-wrap: wrap;
}
.walkup-step {
padding: 0.4rem 0.7rem;
border-radius: 999px;
background: rgba(19, 34, 56, 0.08);
color: var(--muted); color: var(--muted);
font-size: 0.85rem; margin-bottom: 0;
} }
.walkup-step.is-active { .walkup-step-breadcrumb .breadcrumb-item {
background: var(--accent-soft); display: inline-flex;
color: var(--ink); align-items: center;
gap: 0.35rem;
color: var(--muted);
opacity: 0.82;
} }
.walkup-step.is-complete { .walkup-step-breadcrumb .breadcrumb-item + .breadcrumb-item::before {
background: rgba(47, 158, 68, 0.14); color: var(--muted);
color: #25643b; }
.walkup-step-breadcrumb .breadcrumb-item.active {
color: var(--accent);
font-weight: 600;
opacity: 1;
} }
.walkup-modal-actions { .walkup-modal-actions {
@@ -375,13 +387,6 @@ select {
font-size: 0.95rem; font-size: 0.95rem;
} }
.clip-summary-order-controls {
display: inline-flex;
align-items: center;
gap: 0.35rem;
flex: 0 0 auto;
}
.gameday-clip-title { .gameday-clip-title {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;