feat(web): frontend foundation — Tailwind terminal theme, Query, Router, Zustand + live booth screen

Add tailwindcss (Bloomberg-terminal theme in index.css), @tanstack/react-query +
react-router, zustand, and Radix primitives. Router with role-guarded routes;
QueryClient wrapping the existing apiFetch; a small Zustand live store fed by a
/api/ws client that invalidates Query caches. Booth screen: live occupancy gauge
+ streaming entry/exit/payment feed. Vite proxies the WS upgrade.

Note: BoothScreen references the pay/exit modal + active-sessions panel added in
following commits; final HEAD builds.
This commit is contained in:
2026-06-18 11:00:42 +02:00
parent c2f06a5d2a
commit 49df2015c8
13 changed files with 1870 additions and 49 deletions
+22 -39
View File
@@ -1,15 +1,15 @@
import { useEffect, useState } from "react";
import { fetchMe, logout, type SessionUser } from "./api.js";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router";
import { fetchMe, type SessionUser } from "./api.js";
import { Login } from "./Login.js";
import { PermitManager } from "./PermitManager.js";
import { SetupWizard } from "./SetupWizard.js";
import { ShiftControl } from "./ShiftControl.js";
import { SiteSettings } from "./SiteSettings.js";
import { TariffComposer } from "./TariffComposer.js";
import { queryClient } from "./lib/query.js";
import { router } from "./router.js";
// Operator UI shell. Plain React (no admin framework) — the operator UI is
// simple enough that a framework's abstractions cost more than they save.
// Auth is cookie-based; the SPA bootstraps the session from /api/auth/me.
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
// off to TanStack Router inside the QueryClient provider. The router renders the
// terminal chrome + screens; auth gating stays here (Login until signed in), and
// the signed-in user flows into the router context for role-based route guards.
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
export function App() {
@@ -22,37 +22,20 @@ export function App() {
.finally(() => setLoading(false));
}, []);
if (loading) return <p style={{ fontFamily: "system-ui", padding: "2rem" }}>Loading…</p>;
if (!user) return <Login onLoggedIn={setUser} />;
if (loading) {
return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>;
}
if (!user) {
return (
<QueryClientProvider client={queryClient}>
<Login onLoggedIn={setUser} />
</QueryClientProvider>
);
}
return (
<main style={{ fontFamily: "system-ui", padding: "2rem", maxWidth: 720 }}>
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
<h1 style={{ margin: 0 }}>Parking System</h1>
<span style={{ color: "#555" }}>
{user.username} ({user.role}){" "}
<button
type="button"
onClick={async () => {
await logout();
setUser(null);
}}
>
Log out
</button>
</span>
</header>
<SiteSettings canEdit={user.role === "admin"} />
{user.role !== "readonly" && <ShiftControl />}
{user.role === "admin" ? (
<>
<SetupWizard />
<TariffComposer />
<PermitManager />
</>
) : (
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
)}
</main>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} context={{ user, setUser }} />
</QueryClientProvider>
);
}