From 77735660c9dac0035a3a3a03fbb4acaa2e359b27 Mon Sep 17 00:00:00 2001 From: Snow Lee Date: Sun, 19 Jul 2026 09:25:22 -0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20nav=20redesign=20=E2=80=94=20lucid?= =?UTF-8?q?e=20icons;=20project=20settings=20in=20header,=20workspace=20ge?= =?UTF-8?q?ar=20+=20sign-out=20in=20org=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design feedback applied: - icons are lucide-react (lucide.dev) behind the existing API; the inline SVG sprite is gone - org bar (sidebar footer): the Manage text button is a gear icon opening the org admin panel; sign-out moved here, after the gear - header: the org-manage (people) button is removed; the header gear now opens a new per-PROJECT settings panel (identity facts + connect guide); sign-out stays in the header only when no project/org bar is present (volume mode, loading, empty state) Playwright suite updated and green (44/44); static/ rebuilt. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VbiaaVM2ACxeRi8ySG9ybc --- CLAUDE.md | 2 +- README.md | 5 +- internal/webapp/frontend/e2e/admin.spec.ts | 10 +- internal/webapp/frontend/e2e/hub.spec.ts | 4 +- internal/webapp/frontend/index.html | 30 ---- internal/webapp/frontend/package-lock.json | 10 ++ internal/webapp/frontend/package.json | 1 + internal/webapp/frontend/src/apps/HubApp.tsx | 21 ++- .../webapp/frontend/src/components/OrgBar.tsx | 68 +++++--- .../src/components/ProjectSettings.tsx | 32 ++++ .../webapp/frontend/src/components/shell.tsx | 84 +++++++-- internal/webapp/frontend/src/style.css | 9 +- internal/webapp/manual_serve_test.go | 163 ++++++++++++++++++ .../webapp/static/assets/index-CGPKgglj.js | 11 ++ .../webapp/static/assets/index-DbihATmy.css | 1 + .../webapp/static/assets/index-Dz7xLxXQ.js | 11 -- .../webapp/static/assets/index-h2c92P2n.css | 1 - internal/webapp/static/index.html | 34 +--- 18 files changed, 358 insertions(+), 139 deletions(-) create mode 100644 internal/webapp/frontend/src/components/ProjectSettings.tsx create mode 100644 internal/webapp/manual_serve_test.go create mode 100644 internal/webapp/static/assets/index-CGPKgglj.js create mode 100644 internal/webapp/static/assets/index-DbihATmy.css delete mode 100644 internal/webapp/static/assets/index-Dz7xLxXQ.js delete mode 100644 internal/webapp/static/assets/index-h2c92P2n.css diff --git a/CLAUDE.md b/CLAUDE.md index 5899449..9ae5695 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ Package roles (`internal/`): - **`syncer`** — the heart: `Session.Cycle()` runs one pass: scan → commit local ops → pull peer journals → preserve conflict copies → materialize merged state → push blobs + own journal. Read the package doc comment in `syncer.go` first. `ignore.go` holds the path filter (`.bdriveignore` rules + the `.bdrive` include list), applied symmetrically in scan and materialize; a newly filtered path is dropped from the cache *without* a delete op so opting out locally never deletes remotely. - **`daemon`** — per-mount background loop (detached process, `daemon.pid`/`daemon.log` in the mount's volume dir). Scans every `--scan-interval` (3s), talks to the remote every `--remote-interval` (10s) or immediately after local edits. Re-reads `.bdrive/config.json` each tick; if it vanishes (folder moved/renamed/deleted) the daemon **exits cleanly without propagating deletes** — the next bdrive command at the new location resumes it (self-heal on next touch). - **`config`** — global state under `$BDRIVE_HOME` (default `~/.bdrive`): device identity (`device.json`), settings (`settings.json`: default server + device token + signed-in account), and the mount registry (`mounts.json`, keyed by **stable mount id**, holding only each mount's last-known path). The per-folder `.bdrive/` directory (`project.go`) holds `config.json` with the mount id + volume/remote/include; **nothing is keyed by the folder path**, so renames/moves are free — `ResolveMount` self-heals the registry path, and the volume store lives at `~/.bdrive/volumes//`. `.bdrive/` is never synced and holds no credentials. -- **`webapp`** — the `bdrive web` server, in two modes. Single-volume: `Source` is a `DirSource` (plain folder from disk) or `RemoteSource` (folds journals into a file tree with per-file provenance). Hub: `Root` + `Projects` host many projects on one storage root, each under `//` via `remote.Prefixed`; `ProjectDB` (`projects.go`) is a file-backed registry (JSON, loaded at open, rewritten atomically per change) with create-or-join-by-name semantics, name-scoped per organization. Orgs (`orgs.go`, file-backed `orgs.json`) wall projects by membership (email → owner|member): every per-project route — viewer APIs, uploads, history, shares management, the `/store/*` sync proxy — 403s for non-members, `/api/projects` lists only your orgs' projects, owners mint expiring multi-use invite links (`/join/`), and a pre-org hub migrates all projects into a "default" org (all existing accounts join, oldest owns) at startup. `QuotaProvider` (`quota.go`) is the plan-enforcement seam mirroring `AuthProvider` — CheckWrite/RecordUsage on every write path, CheckSeat on invite redemption; OSS ships only `UnlimitedQuota`, managed deployments swap the provider. Renders markdown (goldmark + Obsidian `[[wikilinks]]`). With `--upload` it accepts writes: browser uploads (`upload.go` — direct-to-storage via presigned URLs when the backend implements `remote.PutSigner`, relayed otherwise; ops journaled under the server's own device) and the per-project `/api/p//store/*` proxy (`store.go`) that whole devices sync through — the `https://` remote backend (`remote/http.go`) is its client; journals are never presigned, only immutable blobs. Frontend is a React + TypeScript app (`webapp/frontend/`, Vite; runtime deps only react, react-dom, @tanstack/react-query) whose **built output is committed** at `webapp/static/` — the `go:embed static` target — so plain `go build` needs no Node; after any `frontend/src` change run `npm run build` there and commit the new `static/` (`frontend/check-dist.sh` verifies freshness; e2e suite: `npm run e2e` — Playwright against the seeded harness in `e2e_serve_test.go`, port 8993). It learns everything from `/api/config` (+ `/api/projects` in hub mode) and never sees storage info or credentials. It uses native History-API path routing (`//` in hub mode, `/` in volume mode, `/join/` for invites — no `#`, slashes stay literal) implemented by the in-repo synchronous router `frontend/src/nav.ts` + `router.ts` (deliberately NOT a router library: react-router v7's startTransition navigation left stale views on screen); `Server.frontend` serves `index.html` as the SPA fallback for any non-asset, non-API/auth/share route so deep links and refreshes resolve (hashed `assets/*` are cached immutable, everything else no-cache), and all client API/asset URLs are root-absolute so a deep path doesn't break relative resolution. Rendered markdown is transformed as a string before mounting and link clicks are delegated on the container — never patch the `dangerouslySetInnerHTML` subtree after commit (React re-applies the markup on unrelated updates and discards DOM patches). **Read heat** (`reads.go`): a `ReadLedger` (hub-only, nil = off, config `reads` block) aggregates read telemetry into daily per-actor buckets, debounced to 10-minute visits, folded into all-time rows past `retention_days` — viewer file/render/download = human (recorded via the project id the `proj()` resolver stashes in the request context), `/s/*` hits = share, device-reported reads (`POST /api/p//reads`) = agent; `/store/*` replication and history `/blob` views are NEVER reads. `GET /api/p//heat?prefix=&days=` returns counts/distinct-readers/last-read only — actor identities (the email/device/token in the buckets) must never appear in an API response. Recording and flushing degrade silently (log once); telemetry must never fail a request or a sync cycle. The frontend shows heat dots on folder listings for members and an admin/org-owner Insights quadrant (reads × staleness). **Hub metadata persistence** (accounts, projects, orgs+invites, shares, devices, read buckets — never blobs or journals) sits behind a pluggable `MetaStore` of typed repos (`db.go`): the service structs (`BuiltinAuth`, `OrgDB`, `ProjectDB`, `ShareDB`, `DeviceRegistry`, `ReadLedger`) keep their in-memory maps + logic and persist each change as one record through a repo (the `ReadRepo` alone is batch-oriented — one flush, one write). Two backends — `db_file.go` (the historical JSON files, still the zero-dep default, reached via the `Open*(path)` constructors) and `db_sql.go` (one `database/sql` impl over pure-Go drivers: `modernc.org/sqlite` locally, `jackc/pgx` for Postgres/Supabase, portable schema + idempotent migrations + transactional multi-row writes). `web.go`'s `database` config (`{driver:file|sqlite|postgres, dsn}`) selects it; file is default and untouched. `db_conformance_test.go` runs the same service ops against every backend. +- **`webapp`** — the `bdrive web` server, in two modes. Single-volume: `Source` is a `DirSource` (plain folder from disk) or `RemoteSource` (folds journals into a file tree with per-file provenance). Hub: `Root` + `Projects` host many projects on one storage root, each under `//` via `remote.Prefixed`; `ProjectDB` (`projects.go`) is a file-backed registry (JSON, loaded at open, rewritten atomically per change) with create-or-join-by-name semantics, name-scoped per organization. Orgs (`orgs.go`, file-backed `orgs.json`) wall projects by membership (email → owner|member): every per-project route — viewer APIs, uploads, history, shares management, the `/store/*` sync proxy — 403s for non-members, `/api/projects` lists only your orgs' projects, owners mint expiring multi-use invite links (`/join/`), and a pre-org hub migrates all projects into a "default" org (all existing accounts join, oldest owns) at startup. `QuotaProvider` (`quota.go`) is the plan-enforcement seam mirroring `AuthProvider` — CheckWrite/RecordUsage on every write path, CheckSeat on invite redemption; OSS ships only `UnlimitedQuota`, managed deployments swap the provider. Renders markdown (goldmark + Obsidian `[[wikilinks]]`). With `--upload` it accepts writes: browser uploads (`upload.go` — direct-to-storage via presigned URLs when the backend implements `remote.PutSigner`, relayed otherwise; ops journaled under the server's own device) and the per-project `/api/p//store/*` proxy (`store.go`) that whole devices sync through — the `https://` remote backend (`remote/http.go`) is its client; journals are never presigned, only immutable blobs. Frontend is a React + TypeScript app (`webapp/frontend/`, Vite; runtime deps only react, react-dom, @tanstack/react-query, lucide-react) whose **built output is committed** at `webapp/static/` — the `go:embed static` target — so plain `go build` needs no Node; after any `frontend/src` change run `npm run build` there and commit the new `static/` (`frontend/check-dist.sh` verifies freshness; e2e suite: `npm run e2e` — Playwright against the seeded harness in `e2e_serve_test.go`, port 8993). It learns everything from `/api/config` (+ `/api/projects` in hub mode) and never sees storage info or credentials. It uses native History-API path routing (`//` in hub mode, `/` in volume mode, `/join/` for invites — no `#`, slashes stay literal) implemented by the in-repo synchronous router `frontend/src/nav.ts` + `router.ts` (deliberately NOT a router library: react-router v7's startTransition navigation left stale views on screen); `Server.frontend` serves `index.html` as the SPA fallback for any non-asset, non-API/auth/share route so deep links and refreshes resolve (hashed `assets/*` are cached immutable, everything else no-cache), and all client API/asset URLs are root-absolute so a deep path doesn't break relative resolution. Rendered markdown is transformed as a string before mounting and link clicks are delegated on the container — never patch the `dangerouslySetInnerHTML` subtree after commit (React re-applies the markup on unrelated updates and discards DOM patches). **Read heat** (`reads.go`): a `ReadLedger` (hub-only, nil = off, config `reads` block) aggregates read telemetry into daily per-actor buckets, debounced to 10-minute visits, folded into all-time rows past `retention_days` — viewer file/render/download = human (recorded via the project id the `proj()` resolver stashes in the request context), `/s/*` hits = share, device-reported reads (`POST /api/p//reads`) = agent; `/store/*` replication and history `/blob` views are NEVER reads. `GET /api/p//heat?prefix=&days=` returns counts/distinct-readers/last-read only — actor identities (the email/device/token in the buckets) must never appear in an API response. Recording and flushing degrade silently (log once); telemetry must never fail a request or a sync cycle. The frontend shows heat dots on folder listings for members and an admin/org-owner Insights quadrant (reads × staleness). **Hub metadata persistence** (accounts, projects, orgs+invites, shares, devices, read buckets — never blobs or journals) sits behind a pluggable `MetaStore` of typed repos (`db.go`): the service structs (`BuiltinAuth`, `OrgDB`, `ProjectDB`, `ShareDB`, `DeviceRegistry`, `ReadLedger`) keep their in-memory maps + logic and persist each change as one record through a repo (the `ReadRepo` alone is batch-oriented — one flush, one write). Two backends — `db_file.go` (the historical JSON files, still the zero-dep default, reached via the `Open*(path)` constructors) and `db_sql.go` (one `database/sql` impl over pure-Go drivers: `modernc.org/sqlite` locally, `jackc/pgx` for Postgres/Supabase, portable schema + idempotent migrations + transactional multi-row writes). `web.go`'s `database` config (`{driver:file|sqlite|postgres, dsn}`) selects it; file is default and untouched. `db_conformance_test.go` runs the same service ops against every backend. `cmd/bdrive/` is a thin cobra CLI over these packages (`login`, `logout`, `init`, `stop`, `sync`, `status`, `log`, `url`, `web`, `whoami`, `daemon`, `version` — `mnt`/`umnt`/`remote` are gone; `init` is the front door and `stop` pauses). `bdrive login` signs the device in (bare form uses the remembered server or `config.DefaultServer` = beardrive.ai; loopback-callback browser flow in `login.go`, `--device` for headless) and stores server+token+account in `settings.json`; `bdrive logout` clears the saved token+account (keeps the remembered server unless `--forget`). Switching hubs is `bdrive login ` then re-`init` — `init` is the only thing that writes a folder's remote (always a hub, `server + "/p/" + id`); there is no client command to point a folder at a raw bucket. `bdrive init` is interactive on a TTY (survey menus: create-new vs connect-existing with a project list; whole-folder vs `--shared `, which becomes the include list) with full flag bypass (`--name/--project/--shared/--yes`) and never prompts without a TTY; it runs the login flow first when there is no session, writes `.bdrive/config.json`, seeds `.bdriveignore`, and starts sync via `startSync`; re-running it resumes — including after a folder move. `bdrive web -c config.json` configures the server from a file, explicit flags winning. diff --git a/README.md b/README.md index 1aa02b5..9b6e36c 100644 --- a/README.md +++ b/README.md @@ -494,8 +494,9 @@ conflicts. Set `BDRIVE_HOME` to relocate all beardrive state (used heavily in te ### Web frontend The hub's web UI is a React + TypeScript app in `internal/webapp/frontend` -(Vite; runtime dependencies are just react, react-dom, and -@tanstack/react-query — routing is a small in-repo history router). Its +(Vite; runtime dependencies are just react, react-dom, +@tanstack/react-query, and lucide-react for icons — routing is a small +in-repo history router). Its **built output is committed** at `internal/webapp/static`, the `go:embed` target, so building or `go install`-ing the binary never needs Node. diff --git a/internal/webapp/frontend/e2e/admin.spec.ts b/internal/webapp/frontend/e2e/admin.spec.ts index f6a5c76..1a8648f 100644 --- a/internal/webapp/frontend/e2e/admin.spec.ts +++ b/internal/webapp/frontend/e2e/admin.spec.ts @@ -8,7 +8,7 @@ import { login, wikiId, ADMIN, MEMBER } from "./helpers"; test("org admin: members with roles, self marked, rename round-trip", async ({ page }) => { await login(page); - await page.click("#invite-btn"); // owner's Manage button + await page.click("#org-settings-btn"); // owner's Manage button 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)"); @@ -27,7 +27,7 @@ test("org admin: members with roles, self marked, rename round-trip", async ({ p test("org admin: member role change round-trip", async ({ page }) => { await login(page); - await page.click("#invite-btn"); + await page.click("#org-settings-btn"); const sel = page.locator(".admin-item", { hasText: MEMBER }).locator("select"); await sel.selectOption("owner"); await expect(page.locator("#toast")).toContainText("Role updated"); @@ -38,7 +38,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("#invite-btn"); + await page.click("#org-settings-btn"); 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 +54,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("#invite-btn"); + await page.click("#org-settings-btn"); const row = page.locator(".admin-item", { hasText: "index.md" }); await expect(row).toBeVisible(); await expect(row.locator(".ai-tag")).toContainText("wiki"); @@ -68,7 +68,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("#invite-btn"); + await page.click("#org-settings-btn"); const row = page.locator(".admin-item", { hasText: "doomed" }); await row.locator(".ai-btn", { hasText: "Rename" }).click(); await page.fill(".modal-input", "doomed-2"); diff --git a/internal/webapp/frontend/e2e/hub.spec.ts b/internal/webapp/frontend/e2e/hub.spec.ts index 0c350f0..724cbe5 100644 --- a/internal/webapp/frontend/e2e/hub.spec.ts +++ b/internal/webapp/frontend/e2e/hub.spec.ts @@ -33,7 +33,7 @@ test("admin sees admin bar and org Manage; member does not", async ({ page, brow 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("#invite-btn")).toBeVisible(); + await expect(page.locator("#org-settings-btn")).toBeVisible(); await expect(page.locator("#signout")).toBeVisible(); const ctx = await browser.newContext(); @@ -41,7 +41,7 @@ test("admin sees admin bar and org Manage; member does not", async ({ page, brow await login(p2, MEMBER); await expect(p2.locator("#orgbar #org-name")).toHaveText("default"); await expect(p2.locator("#adminbar")).toHaveCount(0); - await expect(p2.locator("#invite-btn")).toHaveCount(0); + await expect(p2.locator("#org-settings-btn")).toHaveCount(0); await ctx.close(); }); diff --git a/internal/webapp/frontend/index.html b/internal/webapp/frontend/index.html index 50e2df7..eab9b66 100644 --- a/internal/webapp/frontend/index.html +++ b/internal/webapp/frontend/index.html @@ -7,36 +7,6 @@ -
diff --git a/internal/webapp/frontend/package-lock.json b/internal/webapp/frontend/package-lock.json index ca61e98..ead7df4 100644 --- a/internal/webapp/frontend/package-lock.json +++ b/internal/webapp/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@tanstack/react-query": "^5.90.0", + "lucide-react": "^1.25.0", "react": "^19.2.0", "react-dom": "^19.2.0" }, @@ -1532,6 +1533,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz", + "integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/internal/webapp/frontend/package.json b/internal/webapp/frontend/package.json index 19b3c55..3cf5804 100644 --- a/internal/webapp/frontend/package.json +++ b/internal/webapp/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@tanstack/react-query": "^5.90.0", + "lucide-react": "^1.25.0", "react": "^19.2.0", "react-dom": "^19.2.0" }, diff --git a/internal/webapp/frontend/src/apps/HubApp.tsx b/internal/webapp/frontend/src/apps/HubApp.tsx index c97e724..aa2dd90 100644 --- a/internal/webapp/frontend/src/apps/HubApp.tsx +++ b/internal/webapp/frontend/src/apps/HubApp.tsx @@ -9,6 +9,7 @@ import { OrgAdmin } from "../components/OrgAdmin"; import { HubSettings } from "../components/HubSettings"; import { ProjectNav } from "../components/ProjectNav"; import { OrgBar } from "../components/OrgBar"; +import { ProjectSettings } from "../components/ProjectSettings"; import { EmptyState } from "../components/EmptyState"; import { toast } from "../toast"; import Browser from "./Browser"; @@ -21,7 +22,7 @@ export default function HubApp({ config }: { config: ServerConfig }) { const [joinedOrgId, setJoinedOrgId] = useState(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); + const [panel, setPanel] = useState(null); useEffect(() => setPanel(null), [pathname]); const joinToken = useMemo(() => { @@ -67,11 +68,6 @@ export default function HubApp({ config }: { config: ServerConfig }) { const brand = config.brand || config.volume || "BearDrive"; const org = (current && orgs?.find((o) => o.id === current.org)) || null; - const ownedOrg = orgs?.find((o) => o.role === "owner") || null; - // The top-of-sidebar gear is the always-visible admin entry point: any - // account that owns an org (or is a hub admin) gets it, whatever project - // is open. The panels it opens arrive in Phase 4. - const gearTarget = org && org.role === "owner" ? org : ownedOrg; // Insights (embedded on the project home and behind the ⋯ menu) is for // hub admins and owners of the project's org. const canInsights = isAdmin || (org ? org.role === "owner" : false); @@ -80,7 +76,7 @@ export default function HubApp({ config }: { config: ServerConfig }) { navigate("/" + current.id) : undefined} - showSignout={config.auth.enabled} + showSignout={config.auth.enabled && !current} admin={ isAdmin ? { @@ -92,11 +88,11 @@ export default function HubApp({ config }: { config: ServerConfig }) { } : undefined } - gear={ - gearTarget + projectSettings={ + current ? { onClick: () => { - setPanel({ kind: "org", orgId: gearTarget.id }); + setPanel({ kind: "project" }); closeSidebarOnMobile(); }, } @@ -146,7 +142,9 @@ export default function HubApp({ config }: { config: ServerConfig }) { const activePanel = panel?.kind === "hub" ? { crumb: "Signup & access", body: } - : panelOrg + : panel?.kind === "project" + ? { crumb: "Project settings", body: } + : panelOrg ? { crumb: panelOrg.name, body: ( @@ -182,6 +180,7 @@ export default function HubApp({ config }: { config: ServerConfig }) { orgBar: ( { setPanel({ kind: "org", orgId: o.id }); closeSidebarOnMobile(); diff --git a/internal/webapp/frontend/src/components/OrgBar.tsx b/internal/webapp/frontend/src/components/OrgBar.tsx index 91bdfda..cc33dc1 100644 --- a/internal/webapp/frontend/src/components/OrgBar.tsx +++ b/internal/webapp/frontend/src/components/OrgBar.tsx @@ -1,32 +1,54 @@ import type { Org } from "../api/types"; +import { Icon } from "./shell"; -// The sidebar footer names the project's org; clicking it opens the org -// admin panel, and owners get a Manage button that does the same. The -// panel itself arrives with the admin surfaces (Phase 4). -export function OrgBar({ org, onManage }: { org: Org | null; onManage: (org: Org) => void }) { - if (!org) return null; +// 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 (
- onManage(org)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onManage(org); - } - }} - > - {org.name} - - {org.role === "owner" && ( - )} + {showSignout && ( + + + + )}
); } diff --git a/internal/webapp/frontend/src/components/ProjectSettings.tsx b/internal/webapp/frontend/src/components/ProjectSettings.tsx new file mode 100644 index 0000000..6e4d47a --- /dev/null +++ b/internal/webapp/frontend/src/components/ProjectSettings.tsx @@ -0,0 +1,32 @@ +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. +export function ProjectSettings({ project, org }: { project: Project; org: Org | null }) { + return ( +
+

{project.name}

+
+
Project id
+
+ {project.id} +
+ {org && ( + <> +
Workspace
+
{org.name}
+ + )} + {project.created && ( + <> +
Created
+
{new Date(project.created).toLocaleDateString()}
+ + )} +
+

Connect a device

+ +
+ ); +} diff --git a/internal/webapp/frontend/src/components/shell.tsx b/internal/webapp/frontend/src/components/shell.tsx index 5b268e4..118ac21 100644 --- a/internal/webapp/frontend/src/components/shell.tsx +++ b/internal/webapp/frontend/src/components/shell.tsx @@ -1,4 +1,32 @@ import type { ReactNode } from "react"; +import { + Check, + ChevronDown, + ChevronRight, + Clock, + Copy, + Download, + Ellipsis, + FileText, + Folder, + Globe, + History, + Link, + Lock, + LogOut, + Menu, + Plus, + Search, + Settings, + Share2, + Shield, + Trash2, + TriangleAlert, + Upload, + Users, + X, + type LucideIcon, +} from "lucide-react"; // The app's fixed layout: off-canvas sidebar (mobile: body.sb-open toggles // it), topbar, and the content pane. Ids and classes match the classic app @@ -11,12 +39,40 @@ export function closeSidebarOnMobile() { document.body.classList.remove("sb-open"); } +// Icons are lucide (lucide.dev) components behind the historical sprite +// names, so call sites keep the tiny `` API and style.css's +// `.ico` sizing/stroke rules apply unchanged. +const ICONS: Record = { + alert: TriangleAlert, + check: Check, + chev: ChevronRight, + chevd: ChevronDown, + clock: Clock, + copy: Copy, + doc: FileText, + dots: Ellipsis, + download: Download, + folder: Folder, + gear: Settings, + globe: Globe, + hist: History, + link: Link, + lock: Lock, + menu: Menu, + plus: Plus, + power: LogOut, + search: Search, + share: Share2, + shield: Shield, + trash: Trash2, + upload: Upload, + users: Users, + x: X, +}; + export function Icon({ name }: { name: string }) { - return ( - - ); + const C = ICONS[name]; + return C ?