302 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|