mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(web): Installation and Settings are real routes; every page owns a URL
/<pid>/install and /<pid>/settings join /insights and /history as view routes — deep links, reload, and back/forward work; the sidebar menu navigates instead of toggling panel state. Rule recorded in CLAUDE.md: new surfaces are view routes, never URL-less panels (org/hub admin panels are the legacy exceptions). 46/46 e2e incl. deep-link spec. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbiaaVM2ACxeRi8ySG9ybc
This commit is contained in:
co-authored by
Claude Fable 5
parent
e894f4ced9
commit
bacb528de6
@@ -164,21 +164,26 @@ test("insights scopes to the selected folder via the ⋯ menu", async ({ page })
|
||||
await expect(page.locator(".insights .dl-sub")).toContainText("notes and everything in it");
|
||||
});
|
||||
|
||||
test("project menu: Dashboard, Installation, Settings", async ({ page }) => {
|
||||
test("project menu pages each own a URL: Dashboard, Installation, Settings", async ({ page }) => {
|
||||
await login(page);
|
||||
await wikiId(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.click("#nav-dashboard");
|
||||
await page.waitForURL(`/${pid}/insights`);
|
||||
await expect(page.locator(".insights .in-title")).toContainText("Knowledge insights");
|
||||
await expect(page.locator("#nav-dashboard")).toHaveClass(/active/);
|
||||
await page.click("#nav-install");
|
||||
await page.waitForURL(`/${pid}/install`);
|
||||
await expect(page.locator("#crumb")).toHaveText("Installation");
|
||||
await expect(page.locator("#nav-install")).toHaveClass(/active/);
|
||||
await page.click("#nav-settings");
|
||||
await page.waitForURL(`/${pid}/settings`);
|
||||
await expect(page.locator("#crumb")).toHaveText("Project settings");
|
||||
await expect(page.locator(".project-settings h2")).toHaveText("wiki");
|
||||
// Regression: from a panel, Dashboard must work even when the URL is
|
||||
// already /insights (same-path navigation can't rely on route change).
|
||||
await page.click("#nav-dashboard");
|
||||
await expect(page.locator(".insights .in-title")).toContainText("Knowledge insights");
|
||||
await page.waitForURL(`/${pid}/insights`);
|
||||
await expect(page.locator("#nav-dashboard")).toHaveClass(/active/);
|
||||
// Deep link + reload land on the page, like any URL.
|
||||
await page.goto(`/${pid}/settings`);
|
||||
await expect(page.locator(".project-settings h2")).toHaveText("wiki");
|
||||
await expect(page.locator("#nav-settings")).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
@@ -23,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" } | { kind: "install" }>(null);
|
||||
const [panel, setPanel] = useState<null | { kind: "hub" } | { kind: "org"; orgId: string }>(null);
|
||||
useEffect(() => setPanel(null), [pathname]);
|
||||
|
||||
const joinToken = useMemo(() => {
|
||||
@@ -141,11 +141,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
const activePanel =
|
||||
panel?.kind === "hub"
|
||||
? { crumb: "Signup & access", body: <HubSettings /> }
|
||||
: panel?.kind === "project"
|
||||
? { crumb: "Project settings", body: <ProjectSettings project={current} org={org} /> }
|
||||
: panel?.kind === "install"
|
||||
? { crumb: "Installation", body: <div className="onboard"><ConnectGuide project={current} /></div> }
|
||||
: panelOrg
|
||||
: panelOrg
|
||||
? {
|
||||
crumb: panelOrg.name,
|
||||
body: (
|
||||
@@ -159,6 +155,20 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
}
|
||||
: null;
|
||||
|
||||
const routePage =
|
||||
route.view === "settings"
|
||||
? { crumb: "Project settings", body: <ProjectSettings project={current} org={org} /> }
|
||||
: route.view === "install"
|
||||
? {
|
||||
crumb: "Installation",
|
||||
body: (
|
||||
<div className="onboard">
|
||||
<ConnectGuide project={current} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
: null;
|
||||
|
||||
// Landing ("/") and unknown project ids both resolve to a real project
|
||||
// URL; replace so back/forward never bounces through the redirect.
|
||||
if (route.project !== current.id) {
|
||||
@@ -182,28 +192,30 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
projects={projects}
|
||||
currentId={current.id}
|
||||
menu={{
|
||||
active:
|
||||
panel?.kind === "project"
|
||||
? "settings"
|
||||
: panel?.kind === "install"
|
||||
active: panel
|
||||
? null
|
||||
: route.view === "insights"
|
||||
? "dashboard"
|
||||
: route.view === "install"
|
||||
? "install"
|
||||
: !panel && route.view === "insights"
|
||||
? "dashboard"
|
||||
: route.view === "settings"
|
||||
? "settings"
|
||||
: null,
|
||||
// Each page is a URL; explicitly close overlay panels because
|
||||
// same-path navigation doesn't change pathname.
|
||||
onDashboard: () => {
|
||||
// Explicitly close any panel: navigating to the SAME url
|
||||
// (already on /insights) doesn't change pathname, so the
|
||||
// route-change effect can't do it.
|
||||
setPanel(null);
|
||||
navigate(urlForView("insights", current.id));
|
||||
closeSidebarOnMobile();
|
||||
},
|
||||
onInstall: () => {
|
||||
setPanel({ kind: "install" });
|
||||
setPanel(null);
|
||||
navigate(urlForView("install", current.id));
|
||||
closeSidebarOnMobile();
|
||||
},
|
||||
onSettings: () => {
|
||||
setPanel({ kind: "project" });
|
||||
setPanel(null);
|
||||
navigate(urlForView("settings", current.id));
|
||||
closeSidebarOnMobile();
|
||||
},
|
||||
}}
|
||||
@@ -211,7 +223,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
),
|
||||
orgBar: accountBar,
|
||||
}}
|
||||
panel={activePanel}
|
||||
panel={activePanel || routePage}
|
||||
onClosePanel={() => setPanel(null)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -16,16 +16,21 @@ export function decodePath(p: string): string {
|
||||
|
||||
// Special views are RESTful routes under the project — the first segment
|
||||
// after the project id is reserved when it names a view:
|
||||
// /<project-id>/insights the Insights dashboard
|
||||
// /<project-id>/insights[/<path>] the Insights dashboard (optionally scoped)
|
||||
// /<project-id>/history[/<path>] change feed (project / subtree / file)
|
||||
// (Root-level files literally named "insights" or "history" lose the URL
|
||||
// shortcut and remain reachable through the tree.)
|
||||
export const VIEW_ROUTES = new Set(["insights", "history"]);
|
||||
// /<project-id>/install connect-a-device guide
|
||||
// /<project-id>/settings project settings
|
||||
// Rule: every page gets its own URL path (see CLAUDE.md) — new surfaces are
|
||||
// view routes here, not ephemeral panel state. (Root-level files literally
|
||||
// named like a view lose the URL shortcut and remain reachable via the tree.)
|
||||
export const VIEW_ROUTES = new Set(["insights", "history", "install", "settings"]);
|
||||
|
||||
export type ViewName = "insights" | "history" | "install" | "settings";
|
||||
|
||||
export interface Route {
|
||||
project?: string;
|
||||
path: string;
|
||||
view?: "insights" | "history";
|
||||
view?: ViewName;
|
||||
viewTarget?: string;
|
||||
}
|
||||
|
||||
@@ -38,7 +43,7 @@ export function parseRoute(pathname: string, mode: "volume" | "hub"): Route {
|
||||
const seg = r.path.indexOf("/");
|
||||
const head = seg === -1 ? r.path : r.path.slice(0, seg);
|
||||
if (VIEW_ROUTES.has(head)) {
|
||||
r.view = head as "insights" | "history";
|
||||
r.view = head as ViewName;
|
||||
r.viewTarget = seg === -1 ? "" : r.path.slice(seg + 1).replace(/\/+$/, "");
|
||||
r.path = "";
|
||||
}
|
||||
@@ -53,11 +58,7 @@ export function urlForPath(path: string, projectId?: string): string {
|
||||
}
|
||||
|
||||
// The URL for a special view of a project.
|
||||
export function urlForView(
|
||||
view: "insights" | "history",
|
||||
projectId?: string,
|
||||
target?: string,
|
||||
): string {
|
||||
export function urlForView(view: ViewName, projectId?: string, target?: string): string {
|
||||
let s = (projectId ? "/" + projectId : "") + "/" + view;
|
||||
if (target) s += "/" + encodePath(target.replace(/\/+$/, ""));
|
||||
return s;
|
||||
|
||||
+6
-6
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
||||
<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'>🐻</text></svg>">
|
||||
<script type="module" crossorigin src="/assets/index-DDdhKsd5.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-B9q7i7UX.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C5jjBsTc.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user