Split the web UI into a page per section

The dashboard was a single route that swapped section components via a
`section` state field. Convert it to the App Router's multi-page model so
each left-nav selection is its own route (/, /repositories, /agents,
/schedules, /approvals, /servers, /activity, /shared, /login), making the
pages modular and independently updatable.

- Move the token gate + store provider + sidebar into a persistent frame
  (AppFrame + Shell) rendered by the root layout, so auth, the polling
  loop, and shared state survive client-side navigation.
- Sidebar items are now <Link> routes; the active item and the store's
  polled section are derived from the URL (lib/nav).
- Each section gets an app/<section>/page.tsx; Runs stays at root and keeps
  its full-height split layout, the rest render in the shared scroll frame.
- Emit per-route index.html (trailingSlash) so the FastAPI StaticFiles
  mount serves clean slash-terminated URLs with no SPA rewrite.
- Regenerate the bundled static export.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsAeGVadULRzPhV2PRDttM
This commit is contained in:
Claude
2026-07-22 19:34:33 +00:00
parent c600ad481e
commit 5d8c62450c
58 changed files with 410 additions and 174 deletions
+13
View File
@@ -0,0 +1,13 @@
/* Activity page. The shell (sidebar, banners, store) comes from the root layout; this
* route contributes only its section, in the shared scroll frame. */
"use client";
import { ActivitySection } from "@/components/sections/ActivitySection";
export default function ActivityPage() {
return (
<div className="main-scroll">
<ActivitySection />
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
/* Agents page. The shell (sidebar, banners, store) comes from the root layout; this
* route contributes only its section, in the shared scroll frame. */
"use client";
import { AgentsSection } from "@/components/sections/AgentsSection";
export default function AgentsPage() {
return (
<div className="main-scroll">
<AgentsSection />
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
/* Approvals page. The shell (sidebar, banners, store) comes from the root layout; this
* route contributes only its section, in the shared scroll frame. */
"use client";
import { ApprovalsSection } from "@/components/sections/ApprovalsSection";
export default function ApprovalsPage() {
return (
<div className="main-scroll">
<ApprovalsSection />
</div>
);
}
+6 -1
View File
@@ -1,15 +1,20 @@
import type { Metadata } from "next";
import "./globals.css";
import { AppFrame } from "@/components/AppFrame";
export const metadata: Metadata = {
title: "Handler · Claude Activity",
description: "Monitor and manage Claude Code agents across projects.",
};
/* The frame (token gate, store/polling, sidebar) wraps every route and persists across
* navigation; each page under app/ supplies only its own section content. */
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
<body>
<AppFrame>{children}</AppFrame>
</body>
</html>
);
}
+13
View File
@@ -0,0 +1,13 @@
/* Claude Login page. The shell (sidebar, banners, store) comes from the root layout; this
* route contributes only its section, in the shared scroll frame. */
"use client";
import { LoginSection } from "@/components/sections/LoginSection";
export default function LoginPage() {
return (
<div className="main-scroll">
<LoginSection />
</div>
);
}
+6 -45
View File
@@ -1,49 +1,10 @@
/* Root page: token gate → dashboard. Client-only; the exported HTML is a shell and every
* byte of data is fetched by the browser from the authed API after the token is supplied. */
/* Runs — the inbox and the app's landing page (root route). Rendered inside the shared
* shell from the root layout; unlike the scrollable sections it owns its own split layout,
* so it renders directly into main with no outer scroll wrapper. */
"use client";
import { useCallback, useEffect, useState } from "react";
import { DashboardProvider } from "@/components/store";
import { Dashboard } from "@/components/Dashboard";
import { TokenGate } from "@/components/TokenGate";
import { RunsSection } from "@/components/sections/RunsSection";
const TOKEN_KEY = "handler_token";
export default function Home() {
const [token, setToken] = useState<string | null>(null);
const [error, setError] = useState("");
// Read the stored token after mount (localStorage is client-only).
useEffect(() => {
const stored = window.localStorage.getItem(TOKEN_KEY);
if (stored) setToken(stored);
}, []);
const saveToken = useCallback((t: string) => {
window.localStorage.setItem(TOKEN_KEY, t);
setError("");
setToken(t);
}, []);
const signOut = useCallback(() => {
window.localStorage.removeItem(TOKEN_KEY);
setToken(null);
}, []);
// A 401 from any call clears the token and re-prompts with an error.
const onUnauthorized = useCallback(() => {
window.localStorage.removeItem(TOKEN_KEY);
setToken(null);
setError("Invalid token — please try again.");
}, []);
if (!token) {
return <TokenGate error={error} onSubmit={saveToken} />;
}
return (
<DashboardProvider token={token} onUnauthorized={onUnauthorized}>
<Dashboard onSignOut={signOut} />
</DashboardProvider>
);
export default function RunsPage() {
return <RunsSection />;
}
+13
View File
@@ -0,0 +1,13 @@
/* Repositories page. The shell (sidebar, banners, store) comes from the root layout;
* this route contributes only its section, in the shared scroll frame. */
"use client";
import { RepositoriesSection } from "@/components/sections/RepositoriesSection";
export default function RepositoriesPage() {
return (
<div className="main-scroll">
<RepositoriesSection />
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
/* Schedules page. The shell (sidebar, banners, store) comes from the root layout; this
* route contributes only its section, in the shared scroll frame. */
"use client";
import { SchedulesSection } from "@/components/sections/SchedulesSection";
export default function SchedulesPage() {
return (
<div className="main-scroll">
<SchedulesSection />
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
/* Git Servers page. The shell (sidebar, banners, store) comes from the root layout; this
* route contributes only its section, in the shared scroll frame. */
"use client";
import { GitServersSection } from "@/components/sections/GitServersSection";
export default function GitServersPage() {
return (
<div className="main-scroll">
<GitServersSection />
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
/* Shared page. The shell (sidebar, banners, store) comes from the root layout; this
* route contributes only its section, in the shared scroll frame. */
"use client";
import { SharedSection } from "@/components/sections/SharedSection";
export default function SharedPage() {
return (
<div className="main-scroll">
<SharedSection />
</div>
);
}