Files
headquarter/apps/web/src/components/app-shell.tsx
T
miguel 6f35eb77ae feat: redesign authenticated UI with home overview and settings hub
- Add HomePage with open sessions grid, projects overview, and session composer
- Add SettingsPage with tabs for General, SSH Keys, Tool Types, Tool Configs
- Update navigation to Home, Projects, Settings
- Redirect legacy routes (/sessions, /ssh-keys, /tool-types, /tool-configs)
- Apply Inter font and warm editorial styling
- Update tests for new dashboard and projects pages

Quality gates: typecheck pass, lint pass, 15/15 tests pass

Refs: openspec/changes/ui-redesign-home-settings
2026-05-22 20:38:05 +02:00

122 lines
3.7 KiB
TypeScript

import { useCallback, useEffect } from "react";
import { Link, NavLink, Outlet } from "react-router-dom";
import { getUserSessions } from "../api/sessions";
import type { Session } from "../api/sessions";
import { useTheme } from "../hooks/use-theme";
import { useAuth } from "../state/auth";
import { useSessions } from "../state/sessions";
import { Icon } from "./icon";
import type { IconName } from "../utils/icons";
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
{ to: "/", label: "Home", icon: "dashboard" },
{ to: "/projects", label: "Projects", icon: "projects" },
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
{ to: "/settings", label: "Settings", icon: "settings" }
];
const SessionItem = ({ session }: { session: Session }) => {
const isRunning = session.status === "running";
return (
<a
href={session.url ?? `/projects/${session.project_id}`}
target={session.url ? "_blank" : undefined}
rel={session.url ? "noopener noreferrer" : undefined}
className="nav-item session-item"
title={`${session.display_name} (${session.status})`}
>
<span className={`session-status ${isRunning ? "running" : ""}`} />
<Icon name={session.tool_icon as IconName} size="sm" />
<span className="session-name">{session.display_name}</span>
</a>
);
};
export const AppShell = () => {
useTheme();
const { user, logout } = useAuth();
const { sessions, setAllSessions } = useSessions();
const loadSessions = useCallback(async () => {
try {
const data = await getUserSessions();
setAllSessions(data);
} catch {
// Silently fail - sessions are optional
}
}, [setAllSessions]);
useEffect(() => {
void loadSessions();
// Poll every 10 seconds
const interval = setInterval(() => {
void loadSessions();
}, 10000);
return () => clearInterval(interval);
}, [loadSessions]);
return (
<div className="shell">
<header className="shell-header">
<Link className="brand" to="/">
Headquarter
</Link>
<div className="header-actions">
<Link className="user-chip" to="/profile">
{user?.name ?? "User"}
</Link>
<button
className="ghost-button"
onClick={() => {
void logout();
}}
type="button"
>
<Icon name="logout" size="sm" />
Logout
</button>
</div>
</header>
<div className="shell-body">
<aside className="shell-nav" aria-label="Primary navigation">
{NAV_ITEMS.map((item) => {
const isHome = item.to === "/";
const activeCount = sessions.filter((s) => s.status === "running").length;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
end={item.to === "/"}
>
<Icon name={item.icon} size="sm" />
{item.label}
{isHome && activeCount > 0 && (
<span className="nav-badge">{activeCount}</span>
)}
</NavLink>
);
})}
{sessions.length > 0 && (
<>
<div className="nav-divider" />
<div className="nav-section-title">Live sessions</div>
{sessions.map((session) => (
<SessionItem key={session.id} session={session} />
))}
</>
)}
</aside>
<main className="shell-content">
<Outlet />
</main>
</div>
</div>
);
};