Merge branch 'feature/home-page-cleanup' into dev
This commit is contained in:
5
PLAN.md
5
PLAN.md
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user