From 40a0b19aa4e6376a8da866f2349622b27a0d07aa Mon Sep 17 00:00:00 2001 From: Snow Lee Date: Sun, 19 Jul 2026 09:42:30 -0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20PropelAuth-style=20sidebar=20layou?= =?UTF-8?q?t=20=E2=80=94=20brand=20header,=20project=20dropdown,=20account?= =?UTF-8?q?=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - header is the brand only (BearDrive), linking home - Projects section is a dropdown (native select, styled) with the project mark, plus the project-settings gear beside it and + to create - sidebar footer is the account row (avatar, name, email) opening a popover: Organization → Settings, Hub → Signup & access (admins), Account → Log out (danger red) - volume mode unchanged (sign-out stays in its header) - Playwright suite reworked for the new layout — 44/44 green; static rebuilt Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VbiaaVM2ACxeRi8ySG9ybc --- internal/webapp/frontend/e2e/admin.spec.ts | 32 ++++-- internal/webapp/frontend/e2e/hub.spec.ts | 31 +++--- internal/webapp/frontend/src/apps/HubApp.tsx | 48 ++++----- .../frontend/src/components/AccountBar.tsx | 102 ++++++++++++++++++ .../webapp/frontend/src/components/OrgBar.tsx | 54 ---------- .../frontend/src/components/ProjectNav.tsx | 77 ++++++++----- .../webapp/frontend/src/components/shell.tsx | 31 +----- internal/webapp/frontend/src/style.css | 78 ++++++++++---- .../webapp/static/assets/index-BWf7Qnlg.css | 1 + .../webapp/static/assets/index-DbihATmy.css | 1 - .../{index-CGPKgglj.js => index-lt5n9mM7.js} | 16 +-- internal/webapp/static/index.html | 4 +- 12 files changed, 281 insertions(+), 194 deletions(-) create mode 100644 internal/webapp/frontend/src/components/AccountBar.tsx delete mode 100644 internal/webapp/frontend/src/components/OrgBar.tsx create mode 100644 internal/webapp/static/assets/index-BWf7Qnlg.css delete mode 100644 internal/webapp/static/assets/index-DbihATmy.css rename internal/webapp/static/assets/{index-CGPKgglj.js => index-lt5n9mM7.js} (62%) diff --git a/internal/webapp/frontend/e2e/admin.spec.ts b/internal/webapp/frontend/e2e/admin.spec.ts index 1a8648f..8bd557e 100644 --- a/internal/webapp/frontend/e2e/admin.spec.ts +++ b/internal/webapp/frontend/e2e/admin.spec.ts @@ -6,9 +6,15 @@ import { login, wikiId, ADMIN, MEMBER } from "./helpers"; // navigation closes them. Mutating specs revert their changes: the suite // shares one hub per run. +// The org panel opens from the account menu (sidebar footer). +async function openOrgSettings(page: import("@playwright/test").Page) { + await page.click("#account-btn"); + await page.click("#menu-org-settings"); +} + test("org admin: members with roles, self marked, rename round-trip", async ({ page }) => { await login(page); - await page.click("#org-settings-btn"); // owner's Manage button + await openOrgSettings(page); await expect(page.locator("#org-title")).toHaveText("default"); await expect(page.locator("#crumb")).toHaveText("default"); await expect(page.locator(".admin-item", { hasText: ADMIN })).toContainText("(you)"); @@ -19,15 +25,19 @@ test("org admin: members with roles, self marked, rename round-trip", async ({ p await page.fill("#org-rename", "renamed-org"); await page.click("#org-rename-btn"); await expect(page.locator("#toast")).toContainText("Renamed"); - await expect(page.locator("#orgbar #org-name")).toHaveText("renamed-org"); + await page.click("#account-btn"); + await expect(page.locator("#menu-org-settings")).toContainText("renamed-org"); + await page.keyboard.press("Escape"); await page.fill("#org-rename", "default"); await page.click("#org-rename-btn"); - await expect(page.locator("#orgbar #org-name")).toHaveText("default"); + await page.click("#account-btn"); + await expect(page.locator("#menu-org-settings")).toContainText("default"); + await page.keyboard.press("Escape"); }); test("org admin: member role change round-trip", async ({ page }) => { await login(page); - await page.click("#org-settings-btn"); + await openOrgSettings(page); const sel = page.locator(".admin-item", { hasText: MEMBER }).locator("select"); await sel.selectOption("owner"); await expect(page.locator("#toast")).toContainText("Role updated"); @@ -38,7 +48,7 @@ test("org admin: member role change round-trip", async ({ page }) => { test("org admin: invite create shows in list, revoke removes it", async ({ page }) => { await login(page); - await page.click("#org-settings-btn"); + await openOrgSettings(page); await page.click(".admin-h .pbtn"); // New invite await expect(page.locator("#toast")).toContainText("Invite"); const row = page.locator(".admin-item", { hasText: "/join/" }).first(); @@ -54,7 +64,7 @@ test("org admin: public share audit lists and revokes", async ({ page }) => { await login(page); const pid = await wikiId(page); await page.request.post(`/api/p/${pid}/shares`, { data: { path: "index.md" } }); - await page.click("#org-settings-btn"); + await openOrgSettings(page); const row = page.locator(".admin-item", { hasText: "index.md" }); await expect(row).toBeVisible(); await expect(row.locator(".ai-tag")).toContainText("wiki"); @@ -68,7 +78,7 @@ test("org admin: project rename and delete", async ({ page }) => { await login(page); await page.request.post("/api/projects", { data: { name: "doomed" } }); await page.reload(); // pick up the new project - await page.click("#org-settings-btn"); + await openOrgSettings(page); const row = page.locator(".admin-item", { hasText: "doomed" }); await row.locator(".ai-btn", { hasText: "Rename" }).click(); await page.fill(".modal-input", "doomed-2"); @@ -85,7 +95,7 @@ test("org admin: project rename and delete", async ({ page }) => { test("member sees the org panel read-only", async ({ page }) => { await login(page, MEMBER); - await page.click("#orgbar #org-name"); + await openOrgSettings(page); await expect(page.locator("#org-title")).toContainText("member"); await expect(page.locator("#org-rename")).toHaveCount(0); await expect(page.locator(".admin-item select")).toHaveCount(0); @@ -94,7 +104,8 @@ test("member sees the org panel read-only", async ({ page }) => { test("hub settings: policy view, save round-trip, pending queue empty", async ({ page }) => { await login(page); - await page.click("#adminbar"); + await page.click("#account-btn"); + await page.click("#menu-hub-admin"); await expect(page.locator("#crumb")).toHaveText("Signup & access"); await expect(page.locator(".admin h1")).toHaveText("Signup & access"); // Server has no SMTP: verification toggle disabled @@ -115,7 +126,8 @@ test("hub settings: policy view, save round-trip, pending queue empty", async ({ test("navigating away closes an open admin panel", async ({ page }) => { await login(page); - await page.click("#adminbar"); + await page.click("#account-btn"); + await page.click("#menu-hub-admin"); await expect(page.locator(".admin h1")).toBeVisible(); await page.click('#tree .row[data-path="index.md"]'); await expect(page.locator("#content h1")).toHaveText("Wiki"); diff --git a/internal/webapp/frontend/e2e/hub.spec.ts b/internal/webapp/frontend/e2e/hub.spec.ts index 724cbe5..9fec06b 100644 --- a/internal/webapp/frontend/e2e/hub.spec.ts +++ b/internal/webapp/frontend/e2e/hub.spec.ts @@ -9,16 +9,16 @@ test("landing selects the first project and rewrites the URL", async ({ page }) await login(page); const pid = await wikiId(page); await page.waitForURL("/" + pid); - await expect(page.locator("#vault-name")).toHaveText("wiki"); + await expect(page.locator("#project-select option:checked")).toHaveText("wiki"); await expect(page).toHaveTitle("wiki — BearDrive"); - await expect(page.locator("#projects .row.active .label")).toHaveText("wiki"); + await expect(page.locator("#vault-name")).toHaveText("BearDrive"); }); test("deep link to a project resolves after reload", async ({ page }) => { await login(page); const pid = await wikiId(page); await page.goto("/" + pid); - await expect(page.locator("#vault-name")).toHaveText("wiki"); + await expect(page.locator("#project-select option:checked")).toHaveText("wiki"); await expect(page).toHaveURL("/" + pid); }); @@ -26,22 +26,23 @@ test("unknown project id falls back to a real project", async ({ page }) => { await login(page); await page.goto("/p-00000000"); await page.waitForURL(/\/p-[0-9a-f]{8}$/); - await expect(page.locator("#vault-name")).not.toHaveText("…"); + await expect(page.locator("#project-select option:checked")).toHaveText(/.+/); }); -test("admin sees admin bar and org Manage; member does not", async ({ page, browser }) => { +test("account menu: admin gets hub admin entry; member does not", async ({ page, browser }) => { await login(page); // admin, owner of "default" - await expect(page.locator("#adminbar")).toBeVisible(); - await expect(page.locator("#orgbar #org-name")).toHaveText("default"); - await expect(page.locator("#org-settings-btn")).toBeVisible(); + await page.click("#account-btn"); + await expect(page.locator("#menu-org-settings")).toContainText("default"); + await expect(page.locator("#menu-hub-admin")).toBeVisible(); await expect(page.locator("#signout")).toBeVisible(); + await page.keyboard.press("Escape"); const ctx = await browser.newContext(); const p2 = await ctx.newPage(); await login(p2, MEMBER); - await expect(p2.locator("#orgbar #org-name")).toHaveText("default"); - await expect(p2.locator("#adminbar")).toHaveCount(0); - await expect(p2.locator("#org-settings-btn")).toHaveCount(0); + await p2.click("#account-btn"); + await expect(p2.locator("#menu-org-settings")).toContainText("default"); + await expect(p2.locator("#menu-hub-admin")).toHaveCount(0); await ctx.close(); }); @@ -77,8 +78,8 @@ test("no-org account gets the onboarding empty state and can create a project", await page.fill("#ob-name", "solo-notes"); await page.click("#ob-create"); await page.waitForURL(/\/p-[0-9a-f]{8}$/); - await expect(page.locator("#vault-name")).toHaveText("solo-notes"); - await expect(page.locator("#orgbar")).toBeVisible(); // fresh org, owner + await expect(page.locator("#project-select option:checked")).toHaveText("solo-notes"); + await expect(page.locator("#accountbar")).toBeVisible(); // fresh org, owner }); test("new project via the sidebar + modal", async ({ page }) => { @@ -87,7 +88,7 @@ test("new project via the sidebar + modal", async ({ page }) => { await page.fill(".modal-input", "scratch"); await page.click(".modal .pbtn"); await page.waitForURL(/\/p-[0-9a-f]{8}$/); - await expect(page.locator("#vault-name")).toHaveText("scratch"); - await expect(page.locator("#projects .row .label")).toContainText(["scratch", "wiki"]); + await expect(page.locator("#project-select option:checked")).toHaveText("scratch"); + await expect(page.locator("#project-select option")).toContainText(["scratch", "wiki"]); await expect(page.locator("#toast")).toContainText("Created"); }); diff --git a/internal/webapp/frontend/src/apps/HubApp.tsx b/internal/webapp/frontend/src/apps/HubApp.tsx index aa2dd90..276dc42 100644 --- a/internal/webapp/frontend/src/apps/HubApp.tsx +++ b/internal/webapp/frontend/src/apps/HubApp.tsx @@ -8,7 +8,7 @@ import { AppShell, Topbar, VaultHeader, closeSidebarOnMobile } from "../componen import { OrgAdmin } from "../components/OrgAdmin"; import { HubSettings } from "../components/HubSettings"; import { ProjectNav } from "../components/ProjectNav"; -import { OrgBar } from "../components/OrgBar"; +import { AccountBar } from "../components/AccountBar"; import { ProjectSettings } from "../components/ProjectSettings"; import { EmptyState } from "../components/EmptyState"; import { toast } from "../toast"; @@ -72,11 +72,14 @@ export default function HubApp({ config }: { config: ServerConfig }) { // hub admins and owners of the project's org. const canInsights = isAdmin || (org ? org.role === "owner" : false); - const vault = ( - navigate("/" + current.id) : undefined} - showSignout={config.auth.enabled && !current} + // Top of the sidebar is the brand; project and account actions live in + // their own sections below (PropelAuth-style layout). + const vault = navigate("/")} />; + + const accountBar = config.me ? ( + { - setPanel({ kind: "project" }); - closeSidebarOnMobile(); - }, - } - : undefined - } + onOrgSettings={(o) => { + setPanel({ kind: "org", orgId: o.id }); + closeSidebarOnMobile(); + }} /> - ); + ) : undefined; if (!projects || !orgs) { return ( @@ -114,6 +111,7 @@ export default function HubApp({ config }: { config: ServerConfig }) { } + orgBar={accountBar} topbar={} contentClass="view" > @@ -176,17 +174,17 @@ export default function HubApp({ config }: { config: ServerConfig }) { canInsights={canInsights} sidebar={{ vault, - projectsNav: , - orgBar: ( - { - setPanel({ kind: "org", orgId: o.id }); + projectsNav: ( + { + setPanel({ kind: "project" }); closeSidebarOnMobile(); }} /> ), + orgBar: accountBar, }} panel={activePanel} /> @@ -217,7 +215,7 @@ function JoinInvite({ token, onDone }: { token: string; onDone: (orgId: string | // eslint-disable-next-line react-hooks/exhaustive-deps }, [token]); return ( - } topbar={}> + } topbar={}>
Joining…
); diff --git a/internal/webapp/frontend/src/components/AccountBar.tsx b/internal/webapp/frontend/src/components/AccountBar.tsx new file mode 100644 index 0000000..e08deed --- /dev/null +++ b/internal/webapp/frontend/src/components/AccountBar.tsx @@ -0,0 +1,102 @@ +import { useEffect, useRef, useState } from "react"; +import type { Org } from "../api/types"; +import { Icon } from "./shell"; +import { projColor } from "./ProjectNav"; + +// The sidebar footer is the account row: avatar, name, email. Clicking it +// opens a popover with the workspace (org) and account actions — settings, +// hub administration for admins, and sign-out. +export function AccountBar({ + me, + org, + admin, + onOrgSettings, +}: { + me: { email: string; name: string }; + org: Org | null; + admin?: { pending: number; onClick: () => void }; // hub admins only + onOrgSettings: (org: Org) => void; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + const display = me.name || me.email; + return ( +
+ {open && ( + + )} + +
+ ); +} diff --git a/internal/webapp/frontend/src/components/OrgBar.tsx b/internal/webapp/frontend/src/components/OrgBar.tsx deleted file mode 100644 index cc33dc1..0000000 --- a/internal/webapp/frontend/src/components/OrgBar.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type { Org } from "../api/types"; -import { Icon } from "./shell"; - -// The sidebar footer is the workspace (org) row: the org name, a gear that -// opens the org admin panel (owners), and sign-out. Project-scoped actions -// live in the header; workspace-scoped ones end here. -export function OrgBar({ - org, - onManage, - showSignout, -}: { - org: Org | null; - onManage: (org: Org) => void; - showSignout?: boolean; -}) { - if (!org && !showSignout) return null; - return ( -
- {org && ( - onManage(org)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onManage(org); - } - }} - > - {org.name} - - )} - {org && org.role === "owner" && ( - - )} - {showSignout && ( - - - - )} -
- ); -} diff --git a/internal/webapp/frontend/src/components/ProjectNav.tsx b/internal/webapp/frontend/src/components/ProjectNav.tsx index fdd44c5..27b9556 100644 --- a/internal/webapp/frontend/src/components/ProjectNav.tsx +++ b/internal/webapp/frontend/src/components/ProjectNav.tsx @@ -1,4 +1,5 @@ import { navigate } from "../nav"; +import { Icon } from "./shell"; import { postJSON } from "../api/http"; import type { Project, ProjectCreated } from "../api/types"; import { modalPrompt } from "../modal"; @@ -15,7 +16,15 @@ export function projColor(s: string): string { return PROJ_COLORS[h % PROJ_COLORS.length]; } -export function ProjectNav({ projects, currentId }: { projects: Project[]; currentId?: string }) { +export function ProjectNav({ + projects, + currentId, + onOpenSettings, +}: { + projects: Project[]; + currentId?: string; + onOpenSettings?: () => void; +}) { const refresh = useHubRefresh(); const create = async () => { @@ -39,33 +48,47 @@ export function ProjectNav({ projects, currentId }: { projects: Project[]; curre + -