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
+10 -1
View File
@@ -12,13 +12,22 @@
},
"dependencies": {
"@parking/shared": "workspace:*",
"@radix-ui/react-dialog": "^1.1.17",
"@radix-ui/react-dropdown-menu": "^2.1.18",
"@radix-ui/react-tabs": "^1.1.15",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.170.16",
"react": "19.2.7",
"react-dom": "19.2.7"
"react-dom": "19.2.7",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.1",
"@tanstack/react-router-devtools": "^1.167.0",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2",
"tailwindcss": "^4.3.1",
"typescript": "6.0.3",
"vite": "8.0.16"
}
+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>
);
}
+181
View File
@@ -0,0 +1,181 @@
import { useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
import { qk } from "./lib/query.js";
import { useLiveStore } from "./lib/live-store.js";
import { Panel } from "./ui/Panel.js";
import { StatusDot } from "./ui/StatusDot.js";
import { BoothPayModal } from "./BoothPayModal.js";
import { ActiveSessions } from "./ActiveSessions.js";
// The live operator booth view — the real-time heart of the console. Occupancy
// gauge + a streaming entry/exit/payment ticker. Query owns the initial load and
// the authoritative numbers; the WS-fed live store overlays real-time updates so
// the screen reacts the instant a car enters or exits. Dense, dark, glanceable.
/** Per-event-type display: label + accent colour for the ticker. */
const EVENT_STYLE: Record<string, { label: string; color: string }> = {
vehicle_entry: { label: "ENTRY", color: "text-term-green" },
vehicle_exit: { label: "EXIT", color: "text-term-red" },
payment: { label: "PAY", color: "text-term-cyan" },
void: { label: "VOID", color: "text-term-amber" },
barrier_open_command: { label: "OPEN→", color: "text-term-muted" },
barrier_open_observed: { label: "OPEN✓", color: "text-term-muted" },
shift_open: { label: "SHIFT+", color: "text-term-amber" },
shift_z_report: { label: "SHIFT Z", color: "text-term-amber" },
anomaly: { label: "ANOMALY", color: "text-term-red" },
};
function hhmmss(iso: string): string {
// Local time-of-day, terminal style. Defensive against a bad timestamp.
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "--:--:--" : d.toTimeString().slice(0, 8);
}
function OccupancyGauge({ occ }: { occ: Occupancy }) {
const pct = occ.capacity ? Math.min(100, Math.round((occ.count / occ.capacity) * 100)) : null;
const barColor = occ.full ? "bg-term-red" : pct != null && pct >= 85 ? "bg-term-amber" : "bg-term-green";
return (
<div className="flex flex-col gap-3">
<div className="flex items-end gap-4">
<div className="text-6xl font-bold leading-none tabular-nums text-term-text">{occ.count}</div>
<div className="pb-1 text-term-muted">
<div className="text-[11px] uppercase tracking-wider">inside</div>
<div className="text-sm tabular-nums">
{occ.capacity == null ? "uncapped" : `of ${occ.capacity}`}
</div>
</div>
<div className="ml-auto text-right">
<div className="text-[11px] uppercase tracking-wider text-term-muted">free</div>
<div className={`text-3xl font-bold tabular-nums ${occ.full ? "text-term-red" : "text-term-green"}`}>
{occ.free == null ? "∞" : occ.free}
</div>
</div>
</div>
{pct != null && (
<div className="h-2 w-full overflow-hidden rounded-term bg-term-panel-2">
<div className={`h-full ${barColor} transition-[width] duration-300`} style={{ width: `${pct}%` }} />
</div>
)}
{occ.full && (
<div className="rounded-term border border-term-red px-2 py-1 text-center text-[11px] font-bold uppercase tracking-widest text-term-red">
● lot full
</div>
)}
</div>
);
}
function EventRow({ e }: { e: LedgerEvent }) {
const style = EVENT_STYLE[e.type] ?? { label: e.type.toUpperCase(), color: "text-term-text" };
return (
<div className="flex items-center gap-3 border-b border-term-border/50 py-1 text-[12px] tabular-nums">
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
<span className={`w-20 shrink-0 font-semibold ${style.color}`}>{style.label}</span>
<span className="truncate text-term-text">{e.identity ?? "—"}</span>
<span className="ml-auto text-term-muted">#{e.index}</span>
</div>
);
}
/** Ticket entry: an HID barcode scanner types the id and presses Enter; a manual
* operator types it. Either way, submit opens the pay/exit modal for that id. The
* input auto-focuses and re-focuses after a scan so the scanner always lands here. */
function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
const [value, setValue] = useState("");
const ref = useRef<HTMLInputElement>(null);
return (
<form
className="flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
const id = value.trim();
if (id) {
onSubmit(id);
setValue("");
ref.current?.focus();
}
}}
>
<input
ref={ref}
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Scan or type ticket number…"
inputMode="numeric"
className="flex-1 rounded-term border border-term-border bg-term-bg px-3 py-2 text-lg tabular-nums text-term-text placeholder:text-term-muted focus:border-term-amber"
/>
<button
type="submit"
className="rounded-term border border-term-amber bg-term-amber/10 px-4 py-2 text-[12px] font-semibold uppercase tracking-wider text-term-amber"
>
Open
</button>
</form>
);
}
export function BoothScreen() {
// Initial load via Query (also the fallback if the WS is briefly down).
const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy });
const eventsQuery = useQuery({ queryKey: qk.events, queryFn: () => fetchEvents(100) });
// The ticket currently open in the pay/exit modal (null = no modal).
const [activeTicket, setActiveTicket] = useState<string | null>(null);
// Live overlays from the WS store.
const liveOcc = useLiveStore((s) => s.occupancy);
const liveFeed = useLiveStore((s) => s.feed);
// Prefer the live-pushed occupancy; fall back to the query.
const occ = liveOcc ?? occQuery.data ?? null;
// Merge: live events first (newest), then the queried history, de-duped by id.
const seen = new Set(liveFeed.map((e) => e.id));
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
const events = [...liveFeed, ...history].slice(0, 200);
return (
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
{/* Ticket input spans both columns at the top — the operator's primary action. */}
<div className="lg:col-span-2">
<Panel title="Process ticket">
<TicketInput onSubmit={setActiveTicket} />
</Panel>
</div>
{/* Left column: occupancy gauge above the active-sessions list. */}
<div className="flex min-h-0 flex-col gap-3">
<Panel title="Occupancy" right={<StatusDot />}>
{occ ? (
<OccupancyGauge occ={occ} />
) : (
<div className="text-term-muted">{occQuery.isError ? "occupancy unavailable" : "loading…"}</div>
)}
</Panel>
<div className="min-h-0 flex-1">
<ActiveSessions onPick={setActiveTicket} />
</div>
</div>
<Panel
title="Live feed"
right={<span className="text-[10px] uppercase tracking-wider text-term-muted">{events.length} events</span>}
className="min-h-0"
>
<div className="h-full overflow-y-auto pr-1">
{events.length === 0 ? (
<div className="text-term-muted">
{eventsQuery.isLoading ? "loading…" : "no events yet — entries and exits will stream here."}
</div>
) : (
events.map((e) => <EventRow key={e.id} e={e} />)
)}
</div>
</Panel>
{activeTicket && <BoothPayModal identity={activeTicket} onClose={() => setActiveTicket(null)} />}
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
@import "tailwindcss";
/* Bloomberg-terminal aesthetic: dense, dark, monospace, keyboard-first.
Tailwind v4 — design tokens live here in @theme (no tailwind.config.js).
The booth runs on a fixed appliance display; we optimise for a dark room,
glanceable status colour, and high information density over whitespace. */
@theme {
/* Surfaces — near-black, layered greys for panels/borders. */
--color-term-bg: #0a0e12;
--color-term-panel: #11161c;
--color-term-panel-2: #161d25;
--color-term-border: #232c37;
--color-term-muted: #6b7785;
--color-term-text: #c9d3de;
/* Status accents — the terminal's signal colours. */
--color-term-amber: #f5a623; /* primary accent / headings / focus */
--color-term-green: #2ecc71; /* entry / ok / free */
--color-term-red: #ff4d4f; /* exit / fault / full */
--color-term-cyan: #38bdf8; /* payment / info */
/* Monospace stack — IBM Plex Mono / JetBrains first, system mono fallback. */
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SFMono-Regular",
"Menlo", "Consolas", monospace;
/* Tight radius — terminals are square. */
--radius-term: 2px;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background: var(--color-term-bg);
color: var(--color-term-text);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.4;
-webkit-font-smoothing: antialiased;
/* Crisp text and no rubber-banding on the fixed appliance display. */
overscroll-behavior: none;
}
/* Terminal scrollbars — thin, dark, unobtrusive. */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-term-border) transparent;
}
/* A visible keyboard-focus ring in the amber accent (keyboard-first UI). */
:focus-visible {
outline: 1px solid var(--color-term-amber);
outline-offset: 1px;
}
+41
View File
@@ -0,0 +1,41 @@
import { create } from "zustand";
import type { LedgerEvent, Occupancy } from "../api.js";
// CLIENT state for the live booth feed — deliberately small. Server data (the
// authoritative event list, occupancy totals) is owned by TanStack Query; this
// store holds only what Query shouldn't: the WS connection status, the latest
// pushed occupancy snapshot, and a rolling in-memory tail of recent events for the
// live ticker. Anything durable is re-fetched via Query. See lib/query.ts.
/** Connection state of the booth WebSocket, for a status indicator in the UI. */
export type WsStatus = "connecting" | "open" | "closed";
/** Cap the in-memory live feed so a long-running booth session can't grow it
* unbounded — the full history is always available via the /api/events query. */
const MAX_FEED = 200;
interface LiveState {
status: WsStatus;
/** Most recent occupancy pushed by the server (rides on every ledger event). */
occupancy: Occupancy | null;
/** Newest-first tail of recently pushed ledger events (for the live ticker). */
feed: LedgerEvent[];
setStatus: (s: WsStatus) => void;
setOccupancy: (o: Occupancy) => void;
pushEvent: (e: LedgerEvent) => void;
reset: () => void;
}
export const useLiveStore = create<LiveState>((set) => ({
status: "connecting",
occupancy: null,
feed: [],
setStatus: (status) => set({ status }),
setOccupancy: (occupancy) => set({ occupancy }),
pushEvent: (e) =>
set((s) => ({
// Newest first; de-dupe by id (a reconnect can replay) and cap the length.
feed: s.feed.some((x) => x.id === e.id) ? s.feed : [e, ...s.feed].slice(0, MAX_FEED),
})),
reset: () => set({ status: "connecting", occupancy: null, feed: [] }),
}));
+28
View File
@@ -0,0 +1,28 @@
import { QueryClient } from "@tanstack/react-query";
// Single QueryClient for the app. TanStack Query owns SERVER state (fetch, cache,
// refetch, loading/error) — wrapping the existing thin api.ts fetchers. Client/UI
// state (live feed, WS status) lives in Zustand, not here. The WS layer invalidates
// these caches on live events so Query stays the source of truth for server data.
//
// Defaults tuned for a single-appliance booth: no window-focus refetch (it's a
// kiosk, not a tab someone switches to), and a short staleTime since the WS is the
// real freshness mechanism — queries are the fallback/initial load.
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: 5_000,
retry: 1,
},
},
});
/** Stable query keys — referenced by both the screens and the WS invalidator. */
export const qk = {
me: ["me"] as const,
occupancy: ["occupancy"] as const,
events: ["events"] as const,
activeSessions: ["active-sessions"] as const,
siteConfig: ["site-config"] as const,
} as const;
+96
View File
@@ -0,0 +1,96 @@
import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { LedgerEvent, Occupancy } from "../api.js";
import { qk } from "./query.js";
import { useLiveStore } from "./live-store.js";
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
// (a) live-store updates for the ticker/occupancy and (b) Query cache invalidations
// so TanStack Query remains the source of truth for durable server data. The browser
// attaches the auth cookie automatically; the backend gates by cookie + Origin
// (see routes/ws.ts). Auto-reconnects with capped backoff so a booth left running
// recovers from a server restart without a manual refresh.
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
type WsMessage =
| { kind: "hello"; occupancy: Occupancy }
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
| { kind: "printer-status"; event: unknown };
/** Build the ws:// or wss:// URL for the same origin the SPA is served from. */
function wsUrl(): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}/api/ws`;
}
export function useLiveFeed(): void {
const qc = useQueryClient();
const { setStatus, setOccupancy, pushEvent } = useLiveStore();
// Hold the socket + reconnect timer across renders; guard against StrictMode
// double-invoke and unmount.
const sockRef = useRef<WebSocket | null>(null);
const retryRef = useRef(0);
const closedRef = useRef(false);
useEffect(() => {
closedRef.current = false;
const connect = () => {
if (closedRef.current) return;
setStatus(retryRef.current === 0 ? "connecting" : "connecting");
const sock = new WebSocket(wsUrl());
sockRef.current = sock;
sock.onopen = () => {
retryRef.current = 0;
setStatus("open");
};
sock.onmessage = (ev) => {
let msg: WsMessage;
try {
msg = JSON.parse(ev.data as string) as WsMessage;
} catch {
return; // ignore malformed frames
}
if (msg.kind === "hello") {
setOccupancy(msg.occupancy);
} else if (msg.kind === "ledger") {
setOccupancy(msg.occupancy);
pushEvent(msg.event);
// Keep Query authoritative: the durable event list, occupancy totals,
// and active-sessions list refetch on the next read instead of trusting
// the pushed copy alone.
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.occupancy });
void qc.invalidateQueries({ queryKey: qk.activeSessions });
} else if (msg.kind === "printer-status") {
void qc.invalidateQueries({ queryKey: ["printers"] });
}
};
const scheduleReconnect = () => {
if (closedRef.current) return;
setStatus("closed");
// Capped exponential backoff: 0.5s, 1s, 2s, … up to 10s.
const delay = Math.min(500 * 2 ** retryRef.current, 10_000);
retryRef.current += 1;
window.setTimeout(connect, delay);
};
sock.onclose = scheduleReconnect;
// onerror fires before onclose; let onclose own the reconnect to avoid double.
sock.onerror = () => sock.close();
};
connect();
return () => {
closedRef.current = true;
sockRef.current?.close();
sockRef.current = null;
};
// qc / store setters are stable; run once on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
+1
View File
@@ -1,5 +1,6 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import { App } from "./App.js";
const rootEl = document.getElementById("root");
+161
View File
@@ -0,0 +1,161 @@
import {
createRootRouteWithContext,
createRoute,
createRouter,
Link,
Outlet,
redirect,
} from "@tanstack/react-router";
import type { SessionUser } from "./api.js";
import { logout } from "./api.js";
import { queryClient } from "./lib/query.js";
import { useLiveFeed } from "./lib/use-live-feed.js";
import { StatusDot } from "./ui/StatusDot.js";
import { BoothScreen } from "./BoothScreen.js";
import { SetupWizard } from "./SetupWizard.js";
import { TariffComposer } from "./TariffComposer.js";
import { PermitManager } from "./PermitManager.js";
import { ShiftControl } from "./ShiftControl.js";
import { SiteSettings } from "./SiteSettings.js";
// Code-based TanStack Router (no file-based codegen — the app is small enough that
// an explicit tree is clearer). The router context carries the signed-in user and
// a setter so route guards can redirect by role. The root renders the terminal
// chrome (nav + user + live status) and opens the booth WebSocket once, app-wide.
export interface RouterContext {
user: SessionUser | null;
setUser: (u: SessionUser | null) => void;
}
const rootRoute = createRootRouteWithContext<RouterContext>()({
component: RootLayout,
});
function NavLink({ to, label }: { to: string; label: string }) {
return (
<Link
to={to}
className="px-2 py-1 text-[11px] uppercase tracking-wider text-term-muted rounded-term hover:text-term-text [&.active]:text-term-amber [&.active]:bg-term-panel-2"
>
{label}
</Link>
);
}
function RootLayout() {
const { user, setUser } = rootRoute.useRouteContext();
// One app-wide WebSocket for the live feed (booth + any live widget).
useLiveFeed();
const isAdmin = user?.role === "admin";
return (
<div className="flex h-screen flex-col bg-term-bg text-term-text">
<header className="flex items-center gap-4 border-b border-term-border bg-term-panel px-4 py-2">
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
<nav className="flex items-center gap-1">
<NavLink to="/booth" label="Booth" />
<NavLink to="/shift" label="Shift" />
{isAdmin && <NavLink to="/setup" label="Setup" />}
{isAdmin && <NavLink to="/tariff" label="Tariff" />}
{isAdmin && <NavLink to="/permits" label="Permits" />}
{isAdmin && <NavLink to="/site" label="Site" />}
</nav>
<div className="ml-auto flex items-center gap-3">
<StatusDot />
<span className="text-[11px] text-term-muted">
{user?.username} · {user?.role}
</span>
<button
type="button"
className="rounded-term border border-term-border px-2 py-0.5 text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text"
onClick={async () => {
await logout();
setUser(null);
}}
>
Log out
</button>
</div>
</header>
<main className="min-h-0 flex-1 overflow-auto p-3">
<Outlet />
</main>
</div>
);
}
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
beforeLoad: () => {
throw redirect({ to: "/booth" });
},
});
const boothRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/booth",
component: BoothScreen,
});
const shiftRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/shift",
component: function ShiftRoute() {
const { user } = rootRoute.useRouteContext();
return <ShiftControl isAdmin={user?.role === "admin"} />;
},
});
/** Guard: admin-only routes redirect non-admins back to the booth. */
function adminOnly(ctx: RouterContext) {
if (ctx.user?.role !== "admin") throw redirect({ to: "/booth" });
}
const setupRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/setup",
beforeLoad: ({ context }) => adminOnly(context),
component: () => <SetupWizard />,
});
const tariffRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/tariff",
beforeLoad: ({ context }) => adminOnly(context),
component: () => <TariffComposer />,
});
const permitsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/permits",
beforeLoad: ({ context }) => adminOnly(context),
component: () => <PermitManager />,
});
const siteRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/site",
beforeLoad: ({ context }) => adminOnly(context),
component: () => <SiteSettings canEdit={true} />,
});
const routeTree = rootRoute.addChildren([
indexRoute,
boothRoute,
shiftRoute,
setupRoute,
tariffRoute,
permitsRoute,
siteRoute,
]);
export const router = createRouter({
routeTree,
context: { user: null, setUser: () => {} },
defaultPreload: "intent",
});
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
+33
View File
@@ -0,0 +1,33 @@
import type { ReactNode } from "react";
// Terminal panel: a bordered, titled box — the basic building block of the dense
// booth layout. Title bar in amber, square corners, subtle layered surfaces.
export function Panel({
title,
right,
children,
className = "",
}: {
title?: string;
/** Optional right-aligned content in the title bar (e.g. a status dot). */
right?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<section
className={`flex flex-col border border-term-border bg-term-panel rounded-term overflow-hidden ${className}`}
>
{title && (
<header className="flex items-center justify-between px-3 py-1.5 bg-term-panel-2 border-b border-term-border">
<h2 className="m-0 text-[11px] font-semibold uppercase tracking-wider text-term-amber">
{title}
</h2>
{right}
</header>
)}
<div className="flex-1 min-h-0 p-3">{children}</div>
</section>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { useLiveStore, type WsStatus } from "../lib/live-store.js";
// Small live-connection indicator for the booth chrome: a coloured dot + label
// reflecting the WebSocket status. Green = live, amber = connecting, red = down.
const COLOR: Record<WsStatus, string> = {
open: "bg-term-green",
connecting: "bg-term-amber",
closed: "bg-term-red",
};
const LABEL: Record<WsStatus, string> = {
open: "LIVE",
connecting: "CONNECTING",
closed: "OFFLINE",
};
export function StatusDot() {
const status = useLiveStore((s) => s.status);
return (
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-term-muted">
<span
className={`inline-block h-2 w-2 rounded-full ${COLOR[status]} ${status === "open" ? "" : "animate-pulse"}`}
/>
{LABEL[status]}
</span>
);
}
+9 -2
View File
@@ -1,18 +1,25 @@
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
// Operator SPA. Built by Vite and served by Fastify in production
// (see wiki/entities/react-vite-spa.md). The dev proxy points the API at the
// local Fastify server.
export default defineConfig({
plugins: [react()],
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
// Use 127.0.0.1 (not "localhost") so the proxy never tries IPv6 ::1
// first and stall — the backend binds IPv4. Avoids slow/hung requests,
// notably under WSL2 mirrored networking.
"/api": "http://127.0.0.1:3000",
"/api": {
target: "http://127.0.0.1:3000",
// The live booth feed (/api/ws) is a WebSocket — without `ws: true` the
// proxy would not forward the upgrade. The backend's Origin allowlist must
// include the dev origin (http://localhost:5173) via WS_ALLOWED_ORIGINS.
ws: true,
},
"/health": "http://127.0.0.1:3000",
},
},
+1203 -7
View File
File diff suppressed because it is too large Load Diff