Files
walkup/frontend/src/App.tsx
2026-04-22 06:46:23 -05:00

302 lines
11 KiB
TypeScript

import { Component, useEffect, useState, type ErrorInfo, type ReactElement, type ReactNode } from "react";
import { NavLink, Navigate, Route, Routes, useLocation } from "react-router-dom";
import { WalkupProvider, useWalkupContext } from "./hooks/useWalkupContext";
import { useSession } from "./hooks/useSession";
import { DashboardPage } from "./pages/DashboardPage";
import { GamePage } from "./pages/GamePage";
import { LibraryPage } from "./pages/LibraryPage";
import { OperatorPage } from "./pages/OperatorPage";
import { ProfilePage } from "./pages/ProfilePage";
import { AdminPage } from "./pages/AdminPage";
import { SignInPage } from "./pages/SignInPage";
import { formatTeamLabel } from "./lib/teamsnapHelpers";
function getRouteDestinationLabel(pathname: string) {
switch (pathname) {
case "/":
return "your dashboard";
case "/library":
return "walkup clips";
case "/games":
return "game clips";
case "/operator":
return "the operator console";
default:
return "this page";
}
}
function ProtectedRoute({ children }: { children: ReactElement }) {
const location = useLocation();
const { data, isLoading } = useSession();
if (isLoading) {
return (
<div className="container-fluid py-4">
<div className="card shadow-sm">
<div className="card-body">Loading session...</div>
</div>
</div>
);
}
if (!data?.authenticated) {
return <Navigate to="/signin" replace state={{ from: location }} />;
}
return children;
}
function HomeRoute() {
const walkup = useWalkupContext();
if (walkup.sessionQuery.isLoading) {
return (
<div className="container-fluid py-4">
<div className="card shadow-sm">
<div className="card-body">Loading session...</div>
</div>
</div>
);
}
if (!walkup.sessionQuery.data?.authenticated) {
return <SignInPage />;
}
return (
<DashboardPage />
);
}
function SignInRoute() {
const walkup = useWalkupContext();
if (walkup.sessionQuery.isLoading) {
return (
<div className="container-fluid py-4">
<div className="card shadow-sm">
<div className="card-body">Loading session...</div>
</div>
</div>
);
}
if (walkup.sessionQuery.data?.authenticated) {
return <Navigate to="/" replace />;
}
return <SignInPage />;
}
function TeamSelectionRoute({ children }: { children: ReactElement }) {
return children;
}
function TeamSelectionModal() {
const location = useLocation();
const walkup = useWalkupContext();
if (!walkup.isTeamSnap || !walkup.teamsQuery.isFetched || walkup.hasSelectedTeam) {
return null;
}
return (
<div
className="position-fixed top-0 start-0 w-100 h-100 bg-dark bg-opacity-75 d-flex align-items-center justify-content-center p-3"
role="presentation"
>
<section
className="card shadow-lg border-0 w-100"
style={{ maxWidth: "920px", maxHeight: "88vh" }}
role="dialog"
aria-modal="true"
aria-labelledby="team-selection-title"
>
<div className="card-body d-grid gap-4 overflow-auto p-4 p-lg-5">
<div className="d-grid gap-2">
<p className="text-uppercase small text-secondary-emphasis mb-0">Step 2 of 2</p>
<h2 id="team-selection-title" className="h3 mb-0">
Pick the team you want to use.
</h2>
<p className="text-body-secondary mb-0">
You are signed in with TeamSnap. Choose a team to continue to {getRouteDestinationLabel(location.pathname)}.
</p>
</div>
<div className="row g-4">
<div className="col-12 col-lg-8">
<div className="card shadow-sm h-100">
<div className="card-body d-grid gap-3">
<h3 className="h5 mb-0">Available teams</h3>
{walkup.teamsQuery.isLoading ? (
<div className="text-body-secondary">Loading teams...</div>
) : (
<div className="list-group">
{walkup.teamsQuery.data?.map((team) => {
const teamId = String(team.id);
const selected = teamId === walkup.selectedTeamId;
return (
<button
key={teamId}
type="button"
className={`list-group-item list-group-item-action d-flex justify-content-between align-items-center text-start${
selected ? " active" : ""
}`}
onClick={() => walkup.selectTeam(teamId)}
>
<div>
<strong>{formatTeamLabel(team)}</strong>
<div className={selected ? "text-white-50" : "text-body-secondary"}>Tap to continue</div>
</div>
<span className={`badge rounded-pill ${selected ? "text-bg-light" : "text-bg-secondary"}`}>
{selected ? "Selected" : "Choose"}
</span>
</button>
);
})}
{!walkup.teamsQuery.data?.length ? (
<div className="text-body-secondary">No teams were returned for this account.</div>
) : null}
</div>
)}
</div>
</div>
</div>
<div className="col-12 col-lg-4">
<div className="card bg-body-tertiary border-0 h-100">
<div className="card-body d-grid gap-3">
<h3 className="h5 mb-0">What happens next</h3>
<div className="d-grid gap-2 text-body-secondary">
<div>1. Sign in with TeamSnap.</div>
<div>2. Choose the team you want to manage.</div>
<div>3. Continue into the dashboard, walkup clips, or game tools.</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
);
}
class AppErrorBoundary extends Component<{ children: ReactNode }, { errorMessage: string | null }> {
state = { errorMessage: null };
static getDerivedStateFromError(error: unknown) {
return {
errorMessage: error instanceof Error ? error.message : "Unexpected render error",
};
}
componentDidCatch(error: unknown, errorInfo: ErrorInfo) {
console.error("Walkup render error", error, errorInfo);
}
render() {
if (this.state.errorMessage) {
return (
<div className="container-fluid py-4">
<div className="card shadow-sm">
<div className="card-body d-grid gap-2">
<h2 className="h4 mb-0">App Error</h2>
<div className="text-body-secondary">{this.state.errorMessage}</div>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
function ShellLayout() {
const [navOpen, setNavOpen] = useState(false);
const walkup = useWalkupContext();
const location = useLocation();
const showNavbar = walkup.sessionQuery.data?.authenticated === true;
const showTeamSelectionModal = walkup.isTeamSnap && walkup.teamsQuery.isFetched && !walkup.hasSelectedTeam;
const shellClassName = showNavbar ? "shell is-authenticated" : "shell is-authless";
useEffect(() => {
setNavOpen(false);
}, [location.pathname]);
useEffect(() => {
if (!showTeamSelectionModal) {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, [showTeamSelectionModal]);
return (
<div className={shellClassName}>
{showNavbar ? (
<header className="navbar navbar-expand-lg navbar-dark bg-dark shadow-sm sticky-top px-3 py-2">
<div className="container-fluid gap-3 align-items-center">
<div className="navbar-brand d-grid gap-0">
<span className="text-uppercase small text-info-emphasis">Baseball audio ops</span>
<span className="fw-semibold fs-4 lh-1 text-white">Walkup</span>
</div>
<button
type="button"
className="navbar-toggler"
aria-expanded={navOpen}
aria-controls="primary-nav"
aria-label={navOpen ? "Close menu" : "Open menu"}
onClick={() => setNavOpen((value) => !value)}
>
<span className="navbar-toggler-icon" aria-hidden="true" />
</button>
<nav id="primary-nav" className={`navbar-collapse collapse${navOpen ? " show" : ""}`}>
<div className="navbar-nav ms-auto gap-2">
<NavLink to="/" className={({ isActive }) => `nav-link${isActive ? " active" : ""}`}>
Home
</NavLink>
<NavLink to="/library" className={({ isActive }) => `nav-link${isActive ? " active" : ""}`}>
Walkup Clips
</NavLink>
<NavLink to="/games" className={({ isActive }) => `nav-link${isActive ? " active" : ""}`}>
Games
</NavLink>
<NavLink to="/operator" className={({ isActive }) => `nav-link${isActive ? " active" : ""}`}>
Operator
</NavLink>
<NavLink to="/profile" className={({ isActive }) => `nav-link${isActive ? " active" : ""}`}>
Profile
</NavLink>
</div>
</nav>
</div>
</header>
) : null}
<main className="container-fluid py-4">
<Routes>
<Route path="/signin" element={<SignInRoute />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/profile" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
<Route path="/" element={<HomeRoute />} />
<Route path="/library" element={<ProtectedRoute><TeamSelectionRoute><LibraryPage /></TeamSelectionRoute></ProtectedRoute>} />
<Route path="/games" element={<ProtectedRoute><TeamSelectionRoute><GamePage /></TeamSelectionRoute></ProtectedRoute>} />
<Route path="/operator" element={<ProtectedRoute><TeamSelectionRoute><OperatorPage /></TeamSelectionRoute></ProtectedRoute>} />
</Routes>
</main>
{showTeamSelectionModal ? <TeamSelectionModal /> : null}
</div>
);
}
export default function App() {
return (
<AppErrorBoundary>
<WalkupProvider>
<ShellLayout />
</WalkupProvider>
</AppErrorBoundary>
);
}