Squash merge feature/library-reorganization
This commit is contained in:
301
frontend/src/App.tsx
Normal file
301
frontend/src/App.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user