feat(web): project menu in the sidebar — Dashboard, Installation, Settings

Under the project dropdown: Dashboard (project insights), Installation
(connect guide, moved out of Settings), Settings (project facts). Active
states follow the open view/panel; the inline gear next to the dropdown is
gone. 46/46 e2e.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbiaaVM2ACxeRi8ySG9ybc
This commit is contained in:
Snow Lee
2026-07-19 09:58:48 -07:00
co-authored by Claude Fable 5
parent 3698d70f68
commit 7a41a9e5b6
11 changed files with 106 additions and 38 deletions
+14
View File
@@ -163,3 +163,17 @@ test("insights scopes to the selected folder via the ⋯ menu", async ({ page })
// Scope note in the subtitle is the stable assertion.
await expect(page.locator(".insights .dl-sub")).toContainText("notes and everything in it");
});
test("project menu: Dashboard, Installation, Settings", async ({ page }) => {
await login(page);
await wikiId(page);
await page.click("#nav-dashboard");
await expect(page.locator(".insights .in-title")).toContainText("Knowledge insights");
await expect(page.locator("#nav-dashboard")).toHaveClass(/active/);
await page.click("#nav-install");
await expect(page.locator("#crumb")).toHaveText("Installation");
await expect(page.locator("#nav-install")).toHaveClass(/active/);
await page.click("#nav-settings");
await expect(page.locator("#crumb")).toHaveText("Project settings");
await expect(page.locator(".project-settings h2")).toHaveText("wiki");
});
+27 -6
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { postJSON } from "../api/http";
import type { InviteAccepted, Project, ProjectCreated, ServerConfig } from "../api/types";
import { useOrgs, usePending, useProjects, useHubRefresh } from "../hooks/useHub";
import { parseRoute } from "../router";
import { parseRoute, urlForView } from "../router";
import { navigate, Redirect, useLocationPath } from "../nav";
import { AppShell, Topbar, VaultHeader, closeSidebarOnMobile } from "../components/shell";
import { OrgAdmin } from "../components/OrgAdmin";
@@ -10,6 +10,7 @@ import { HubSettings } from "../components/HubSettings";
import { ProjectNav } from "../components/ProjectNav";
import { AccountBar } from "../components/AccountBar";
import { ProjectSettings } from "../components/ProjectSettings";
import { ConnectGuide } from "../components/ConnectGuide";
import { EmptyState } from "../components/EmptyState";
import { toast } from "../toast";
import Browser from "./Browser";
@@ -22,7 +23,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
const [joinedOrgId, setJoinedOrgId] = useState<string | null>(null);
// Admin panels replace the content pane without touching the URL (they
// were never routes in the classic app); any navigation closes them.
const [panel, setPanel] = useState<null | { kind: "hub" } | { kind: "org"; orgId: string } | { kind: "project" }>(null);
const [panel, setPanel] = useState<null | { kind: "hub" } | { kind: "org"; orgId: string } | { kind: "project" } | { kind: "install" }>(null);
useEffect(() => setPanel(null), [pathname]);
const joinToken = useMemo(() => {
@@ -142,7 +143,9 @@ export default function HubApp({ config }: { config: ServerConfig }) {
? { crumb: "Signup & access", body: <HubSettings /> }
: panel?.kind === "project"
? { crumb: "Project settings", body: <ProjectSettings project={current} org={org} /> }
: panelOrg
: panel?.kind === "install"
? { crumb: "Installation", body: <div className="onboard"><ConnectGuide project={current} /></div> }
: panelOrg
? {
crumb: panelOrg.name,
body: (
@@ -178,9 +181,27 @@ export default function HubApp({ config }: { config: ServerConfig }) {
<ProjectNav
projects={projects}
currentId={current.id}
onOpenSettings={() => {
setPanel({ kind: "project" });
closeSidebarOnMobile();
menu={{
active:
panel?.kind === "project"
? "settings"
: panel?.kind === "install"
? "install"
: !panel && route.view === "insights"
? "dashboard"
: null,
onDashboard: () => {
navigate(urlForView("insights", current.id));
closeSidebarOnMobile();
},
onInstall: () => {
setPanel({ kind: "install" });
closeSidebarOnMobile();
},
onSettings: () => {
setPanel({ kind: "project" });
closeSidebarOnMobile();
},
}}
/>
),
@@ -16,14 +16,21 @@ export function projColor(s: string): string {
return PROJ_COLORS[h % PROJ_COLORS.length];
}
export interface ProjectMenu {
active: "dashboard" | "install" | "settings" | null;
onDashboard: () => void;
onInstall: () => void;
onSettings: () => void;
}
export function ProjectNav({
projects,
currentId,
onOpenSettings,
menu,
}: {
projects: Project[];
currentId?: string;
onOpenSettings?: () => void;
menu?: ProjectMenu;
}) {
const refresh = useHubRefresh();
@@ -77,18 +84,37 @@ export function ProjectNav({
</select>
<Icon name="chevd" />
</span>
{onOpenSettings && (
<button
id="project-settings-btn"
className="icon-btn2"
title="Project settings"
aria-label="Project settings"
onClick={onOpenSettings}
>
<Icon name="gear" />
</button>
)}
</div>
{menu && (
<ul className="nav-menu" aria-label="Project pages">
{(
[
["dashboard", "Dashboard", "dashboard", menu.onDashboard],
["install", "Installation", "terminal", menu.onInstall],
["settings", "Settings", "gear", menu.onSettings],
] as const
).map(([key, label, icon, onClick]) => (
<li key={key}>
<div
id={"nav-" + key}
className={"row" + (menu.active === key ? " active" : "")}
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
>
<Icon name={icon} />
<span className="label">{label}</span>
</div>
</li>
))}
</ul>
)}
</nav>
);
}
@@ -1,8 +1,8 @@
import type { Org, Project } from "../api/types";
import { ConnectGuide } from "./ConnectGuide";
// Settings for the open project (the header gear). Today: identity facts
// plus the connect guide; per-project knobs land here as they grow.
// Settings for the open project (sidebar menu). Today: identity facts;
// per-project knobs land here as they grow. Install/connect lives on the
// Installation page.
export function ProjectSettings({ project, org }: { project: Project; org: Org | null }) {
return (
<div className="project-settings">
@@ -25,8 +25,6 @@ export function ProjectSettings({ project, org }: { project: Project; org: Org |
</>
)}
</dl>
<h3>Connect a device</h3>
<ConnectGuide project={project} />
</div>
);
}
@@ -9,6 +9,7 @@ import {
Ellipsis,
FileText,
Folder,
LayoutDashboard,
Globe,
History,
Link,
@@ -20,6 +21,7 @@ import {
Settings,
Share2,
Shield,
SquareTerminal,
Trash2,
TriangleAlert,
Upload,
@@ -53,6 +55,7 @@ const ICONS: Record<string, LucideIcon> = {
dots: Ellipsis,
download: Download,
folder: Folder,
dashboard: LayoutDashboard,
gear: Settings,
globe: Globe,
hist: History,
@@ -64,6 +67,7 @@ const ICONS: Record<string, LucideIcon> = {
search: Search,
share: Share2,
shield: Shield,
terminal: SquareTerminal,
trash: Trash2,
upload: Upload,
users: Users,
+5
View File
@@ -136,6 +136,11 @@ button, input, a.btn { font-family: inherit; }
#tree li.collapsed > .row .chev { transform: rotate(-90deg); }
/* org bar */
/* ---- project menu ---- */
.nav-menu { list-style: none; margin: 6px 0 0; padding: 0; }
.nav-menu .row .ico { width: 15px; height: 15px; flex: none; color: var(--text-ghost); }
.nav-menu .row.active .ico { color: var(--accent-bright); }
/* ---- project switcher ---- */
.proj-row { display: flex; align-items: center; gap: 4px; padding: 0 10px 4px 12px; }
.proj-select-wrap { position: relative; flex: 1; min-width: 0; display: flex; align-items: center; }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>&#128059;</text></svg>">
<script type="module" crossorigin src="/assets/index-BcyLXD3p.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BCO_xByM.css">
<script type="module" crossorigin src="/assets/index-BZW-lolE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C5jjBsTc.css">
</head>
<body>
<div id="root"></div>