Merge pull request #34 from runbear-io/feat/nav-lucide-redesign

feat(web): nav redesign — lucide icons, project settings in header, workspace actions in org bar
This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-19 11:52:12 -07:00
committed by GitHub
31 changed files with 1052 additions and 474 deletions
+111
View File
@@ -0,0 +1,111 @@
---
name: beardrive-cto
description: CTO-level engineering reviewer for BearDrive — audits architecture, reusability, and scalability across the Go backend (sync engine, hub, storage) and the React/TS frontend. Reads the real code, checks changes against the repo's invariants and seams, and returns prioritized findings with concrete refactor plans, effort estimates, and per-category scores. Use before merging large features, when planning refactors, or for a periodic architecture health check — not for style nits or one-line bug hunts.
tools: Bash, Read, Write, Glob, Grep
model: opus
---
You are the CTO doing an engineering review of BearDrive (repo root:
/Users/snow/workspace/runbear/sfs). You think in systems: boundaries,
seams, failure modes, and what this code will look like with 100× the
tenants, files, and contributors. You are pragmatic — this is a small
team shipping fast — so every recommendation is weighed against its cost
and sequenced. You never hand-wave: every finding names files and lines,
every proposal has a first commit.
## Ground rules of this codebase (violations are findings)
Read `CLAUDE.md` first — it is the constitution. In particular:
- **Sync invariants**: each device writes only its own journal; blobs
push before journals; scan before pull; deterministic `Replay`;
materialize never clobbers dirty files; atomic state writes; cycles
under the flock; degrade-to-offline, never fail a cycle.
- **Seams are sacred**: `AuthProvider`, `QuotaProvider`, `MetaStore`,
`remote.Backend` are the extension points a closed managed deployment
builds on. Logic creeping to the wrong side of a seam (provider
specifics in OSS, hub logic in providers) is an architecture bug.
- **One binary, no Node at build**: frontend output is committed at
`internal/webapp/static` (go:embed). Runtime frontend deps are
deliberately minimal (react, react-dom, @tanstack/react-query,
lucide-react).
- **Every user-facing page owns a URL** (`VIEW_ROUTES` in
`frontend/src/router.ts`); no new URL-less panel state.
- **Clients are storage-blind**; credentials never reach the frontend or
the CLI.
If `cloud/` exists in the checkout it is the private managed layer
(separate repo). Review it only when the task says so; otherwise treat
its existence as context for seam decisions.
## What to examine
**Backend (Go, `internal/`, `cmd/bdrive`)**
- Package boundaries and dependency direction: does `journal` stay pure,
does `syncer` remain the only orchestrator, do `webapp` services keep
their in-memory-map + repo persistence discipline?
- Scalability ceilings, named concretely: in-memory maps that grow with
users/orgs/files, whole-file JSON rewrites, O(n) journal replays,
`List`-the-world storage walks, per-request allocations on hot paths
(`/store/*`, heat recording), the hub's single-writer journal identity
(max-instances=1), polling intervals vs. tenant count.
- Concurrency: lock scope and ordering, what the flock actually protects,
races between daemon and CLI, context propagation and timeouts on
remote calls.
- Error posture: is the "degrade, log once, retry next cycle" rule
applied consistently, or do some paths fail loud/silent inconsistently?
- API surface: handler-to-service layering in `webapp`, route/permission
duplication, whether new endpoints reuse `proj()`-style resolvers or
reinvent them.
- Test architecture: does new sync behavior come with multi-device
`syncer_test.go` coverage? Do webapp features land in the e2e harness?
Is `db_conformance_test.go` still exercising every backend?
**Frontend (`internal/webapp/frontend/src`)**
- Component structure and reuse: shared primitives vs. copy-paste
(buttons, menus, tooltips, panels); props drilling vs. the small
in-repo emitter patterns (`nav.ts`, `search.ts`) — used consistently?
- State: react-query cache keys and invalidation discipline, polling
cost, derived-state recomputation on large trees (thousands of files),
memoization where it matters and not where it doesn't.
- Routing: everything through `router.ts`/`nav.ts`, no drift back toward
panel state; deep-link + reload behavior for new surfaces.
- Bundle and rendering: dependency creep, list virtualization needs,
`dangerouslySetInnerHTML` handling rules (transform-before-mount only).
- The Playwright suite: does it cover the surfaces that matter, is it
one-hub-shared-state aware, are selectors resilient?
## How to work
1. Map the change or area under review (`git log`/`git diff` for a
branch review; `Glob`/`Grep`/`Read` sweeps for a health check). Read
the actual code — never review from file names.
2. Check it against the ground rules above, then against general
architecture judgment (coupling, cohesion, single-responsibility,
YAGNI vs. known roadmap: multi-tenant cloud, GCS/Postgres at scale).
3. Where you suspect a scalability ceiling, estimate it with numbers
(e.g. "orgs.json rewrites whole-file per membership change: at 10k
orgs × 20 members that is ~X MB per write, Y writes/s ceiling").
4. Verify claims empirically where cheap: `go build ./...`,
`go vet ./...`, targeted `go test`, `npm run build`, grep for the
pattern you assert is duplicated. Do not run destructive commands,
long benchmarks, or anything that mutates repos or running servers.
## Report format
1. **Verdict** — one paragraph: overall architecture health and the one
thing to fix first.
2. **Findings** — ordered by severity (`blocker` / `high` / `medium` /
`low`), each with: claim, evidence (`file:line`), blast radius (what
breaks or ossifies if ignored), and a concrete fix with a first step
and effort (S/M/L).
3. **Reuse map** — duplication worth consolidating and, equally,
consolidations NOT worth doing yet (say why).
4. **Scale outlook** — the three nearest ceilings with rough numbers and
the cheapest raise for each.
5. **Scores (010)** — architecture & boundaries, reusability, backend
scalability, frontend scalability, test architecture — each with a
one-line justification.
Be direct. A finding that survives your own steelman of the current
design is worth reporting; anything else, cut.
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -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.
+22 -10
View File
@@ -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("#invite-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("#invite-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("#invite-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("#invite-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("#invite-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");
+22 -9
View File
@@ -19,7 +19,9 @@ test("markdown file: rendered content, crumb, meta, download + share buttons", a
await expect(page.locator("#content h1")).toHaveText("Wiki");
await expect(page.locator("#crumb")).toContainText("index.md");
await expect(page.locator("#meta")).toContainText("alice@x.io");
await expect(page.locator("#download")).toBeVisible();
// Download lives in the ⋯ menu now; the hidden anchor powers it.
await expect(page.locator("#download")).toHaveCount(1);
await expect(page.locator("#more-btn")).toBeVisible();
await expect(page.locator("#share-btn")).toBeVisible();
});
@@ -86,6 +88,14 @@ test("back/forward walks file → folder → file", async ({ page }) => {
await expect(page.locator(".dl-title")).toContainText("notes");
});
test("header search button opens the palette", async ({ page }) => {
await login(page);
await wikiId(page);
await page.click("#search-btn");
await expect(page.locator("#palette")).toBeVisible();
await page.keyboard.press("Escape");
});
test("palette (⌘K) fuzzy-jumps to a file", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
@@ -113,17 +123,20 @@ test("share mints a public link that serves the file, revoke kills it", async ({
expect(gone.status()).toBe(404);
});
test("upload into the selected folder, then the file opens", async ({ page }) => {
test("no browser upload: content arrives via sync; the tree picks it up", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/notes`);
await page.locator("#upload-btn").waitFor();
await page.setInputFiles('input[type="file"]', {
name: "dropped.md",
mimeType: "text/markdown",
buffer: Buffer.from("# Dropped\n\nUploaded through the browser.\n"),
});
await page.waitForURL(`/${pid}/notes/dropped.md`);
// The upload affordance is gone everywhere — content enters via local sync.
await expect(page.locator("#upload-btn")).toHaveCount(0);
await expect(page.locator('input[type="file"]')).toHaveCount(0);
// A file lands through the device/store path (simulated via the API)…
await page.request.put(
`/api/p/${pid}/upload/content?path=${encodeURIComponent("notes/dropped.md")}`,
{ data: "# Dropped\n\nArrived through sync.\n" },
);
// …and the polling tree shows it; opening renders it.
await page.goto(`/${pid}/notes/dropped.md`);
await expect(page.locator("#content h1")).toHaveText("Dropped");
await expect(page.locator('#tree .row[data-path="notes/dropped.md"]')).toBeVisible();
});
+39
View File
@@ -151,3 +151,42 @@ test("folder listing's Full history goes to the subtree feed", async ({ page })
const paths = await page.$$eval(".history .hpath", (els) => els.map((e) => e.textContent));
for (const p of paths) expect(p).toContain("notes/");
});
test("insights scopes to the selected folder via the ⋯ menu", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/notes`);
await page.click("#more-btn");
await page.click("#more-menu .more-item:has-text('Insights')");
await page.waitForURL(`/${pid}/insights/notes`);
await expect(page.locator(".in-title .in-scope")).toContainText("notes");
// 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 pages each own a URL: Dashboard, Installation, Settings", async ({ page }) => {
await login(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-history");
await page.waitForURL(`/${pid}/history`);
await expect(page.locator("#nav-history")).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");
await page.click("#nav-dashboard");
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/);
});
+16 -15
View File
@@ -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("#invite-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("#invite-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");
});
@@ -0,0 +1,35 @@
import { test, expect } from "@playwright/test";
import { login, wikiId } from "./helpers";
test("insights via ⋯ scopes to the open file", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/notes/readme.md`);
await page.click("#more-btn");
await page.click("#more-menu .more-item:has-text('Insights')");
await expect(page).toHaveURL(`/${pid}/insights/notes/readme.md`);
await expect(page.locator(".in-title .in-scope")).toContainText("notes/readme.md");
// The subject stays selected in the tree; Dashboard does NOT light up.
await expect(page.locator('#tree .row[data-path="notes/readme.md"]')).toHaveClass(/active/);
await expect(page.locator("#nav-dashboard")).not.toHaveClass(/active/);
});
test("insights via ⋯ scopes to the selected folder", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/notes`);
await page.click("#more-btn");
await page.click("#more-menu .more-item:has-text('Insights')");
await expect(page).toHaveURL(`/${pid}/insights/notes`);
await expect(page.locator(".in-title .in-scope")).toContainText("notes");
await expect(page.locator('#tree .row[data-path="notes"]')).toHaveClass(/active/);
await expect(page.locator("#nav-dashboard")).not.toHaveClass(/active/);
});
test("root Dashboard still lights the menu, not the tree", async ({ page }) => {
await login(page);
await wikiId(page);
await page.click("#nav-dashboard");
await expect(page.locator("#nav-dashboard")).toHaveClass(/active/);
await expect(page.locator("#tree .row.active")).toHaveCount(0);
});
-30
View File
@@ -7,36 +7,6 @@
<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>">
</head>
<body>
<svg width="0" height="0" class="sprite" aria-hidden="true" focusable="false">
<symbol id="i-chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></symbol>
<symbol id="i-chevd" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
<symbol id="i-folder" viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></symbol>
<symbol id="i-doc" viewBox="0 0 24 24"><path d="M14 3v5h5"/><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.2-3.2"/></symbol>
<symbol id="i-share" viewBox="0 0 24 24"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 10.5 6.8-4M8.6 13.5l6.8 4"/></symbol>
<symbol id="i-hist" viewBox="0 0 24 24"><path d="M3 3v6h6"/><path d="M3.5 9a9 9 0 1 0 2-4"/><path d="M12 8v4l3 2"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-gear" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 13a7.6 7.6 0 0 0 0-2l1.7-1.3-1.7-3-2 .8a7.6 7.6 0 0 0-1.7-1l-.3-2.1H10l-.3 2.1a7.6 7.6 0 0 0-1.7 1l-2-.8-1.7 3L6 11a7.6 7.6 0 0 0 0 2l-1.7 1.3 1.7 3 2-.8a7.6 7.6 0 0 0 1.7 1l.3 2.1h3.4l.3-2.1a7.6 7.6 0 0 0 1.7-1l2 .8 1.7-3z"/></symbol>
<symbol id="i-users" viewBox="0 0 24 24"><path d="M16 20v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="3.2"/><path d="M22 20v-2a4 4 0 0 0-3-3.8"/><path d="M16 3.5a4 4 0 0 1 0 7"/></symbol>
<symbol id="i-link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M12 3 5 6v5c0 4.5 3 7.5 7 9 4-1.5 7-4.5 7-9V6z"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="m5 13 4 4L19 7"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m2 0v12a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V7"/></symbol>
<symbol id="i-lock" viewBox="0 0 24 24"><rect x="4.5" y="10" width="15" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 8v4l3 2"/></symbol>
<symbol id="i-globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"/></symbol>
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h8"/></symbol>
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 9v4M12 17h.01"/><path d="M10.3 4 3 17a2 2 0 0 0 1.7 3h14.6a2 2 0 0 0 1.7-3L13.7 4a2 2 0 0 0-3.4 0z"/></symbol>
<symbol id="i-power" viewBox="0 0 24 24"><path d="M12 4v8M7.5 7a7 7 0 1 0 9 0"/></symbol>
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 4v11m0 0 4-4m-4 4-4-4M5 19h14"/></symbol>
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 20V9m0 0 4 4m-4-4-4 4M5 5h14"/></symbol>
<symbol id="i-dot" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.5" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-edit" viewBox="0 0 24 24"><path d="M4 20l1.2-4.2L16.6 4.4a2 2 0 0 1 2.9 2.9L8.2 18.8z"/></symbol>
<symbol id="i-enter" viewBox="0 0 24 24"><path d="M9 10 4 15l5 5"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></symbol>
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
<symbol id="i-dots" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none"/></symbol>
</svg>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
+10
View File
@@ -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",
+1
View File
@@ -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"
},
+30 -62
View File
@@ -10,9 +10,9 @@ import type { Project, ServerConfig } from "../api/types";
import { useHeat, useTree } from "../hooks/useBrowse";
import { urlForPath, urlForView, type Route } from "../router";
import { currentNavType, navigate, useLocationPath } from "../nav";
import { uploadFile } from "../upload";
import { copyText } from "../util";
import { toast } from "../toast";
import { onSearchRequest } from "../search";
import { AppShell, Icon, Topbar, closeSidebarOnMobile } from "../components/shell";
import { FileTree, ancestorsOf } from "../components/FileTree";
import { Breadcrumbs } from "../components/Breadcrumbs";
@@ -41,6 +41,7 @@ export default function Browser(props: {
// touching the URL — matching the classic app, where they were never
// routes. Any navigation closes them (the caller owns that state).
panel?: { crumb: string; body: ReactNode } | null;
onClosePanel?: () => void; // panels are not routes: same-path navigation needs an explicit close
}) {
const { config, apiBase, route, hub, project } = props;
const routeKey = useLocationPath(); // scroll memo key, one slot per URL
@@ -58,6 +59,9 @@ export default function Browser(props: {
}, [insightsOpen, apiBase, qc]);
const path = route.path;
// On scoped view routes (/insights/<p>, /history/<p>) the subject of the
// page is the target — the tree highlights it, not a menu item.
const treePath = path || (route.view === "insights" || route.view === "history" ? route.viewTarget || "" : "");
const isDir = !!path && dirIndex.has(path);
// A file only counts as one when the tree actually contains it — a
// missing path gets the not-found view, not a broken file view.
@@ -77,18 +81,19 @@ export default function Browser(props: {
if (rootDirs.length === 1) setExpanded((s) => new Set(s).add(rootDirs[0].path));
}, [tree]);
useEffect(() => {
// Opening any path (tree click, palette, wikilink, deep link) unfolds
// the way to it; a selected folder itself opens too.
if (!path || !loaded) return;
// Opening any path (tree click, palette, wikilink, deep link — or a
// scoped insights/history view of it) unfolds the way to it; a selected
// folder itself opens too.
if (!treePath || !loaded) return;
setExpanded((s) => {
const next = new Set(s);
for (const a of ancestorsOf(path)) next.add(a);
if (dirIndex.has(path)) next.add(path);
for (const a of ancestorsOf(treePath)) next.add(a);
if (dirIndex.has(treePath)) next.add(treePath);
return next;
});
const row = document.querySelector(`#tree .row[data-path="${CSS.escape(path)}"]`);
const row = document.querySelector(`#tree .row[data-path="${CSS.escape(treePath)}"]`);
if (row) row.scrollIntoView({ block: "nearest" });
}, [path, loaded, dirIndex]);
}, [treePath, loaded, dirIndex]);
const onToggle = useCallback((p: string) => {
setExpanded((s) => {
const next = new Set(s);
@@ -138,17 +143,17 @@ export default function Browser(props: {
/* ---- topbar state + actions ---- */
const [meta, setMeta] = useState("");
const [uploadStatus, setUploadStatus] = useState("");
const [share, setShare] = useState<{ url: string; copied: boolean } | null>(null);
const [moreOpen, setMoreOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const uploadInput = useRef<HTMLInputElement>(null);
useEffect(() => onSearchRequest(() => setPaletteOpen(true)), []);
const downloadRef = useRef<HTMLAnchorElement>(null);
const panel = props.panel ?? null;
const canShare = !panel && hub && !!project && isFile;
const canHistory = !panel && hub && !!project;
const canUpload = !!config.upload?.enabled && (!hub || !!project);
// Browser upload is deliberately absent (for now): content enters through
// local sync only; the web app is a read/share/history surface.
const canDownload = !panel && isFile;
const canMore = !panel && (isFile || (hub && !!project && isDir));
const downloadURL = apiBase + "download?path=" + encodeURIComponent(path);
@@ -175,32 +180,6 @@ export default function Browser(props: {
openHistory(isDir ? path + "/" : path);
}, [path, isDir, openHistory]);
const uploadNow = useCallback(() => uploadInput.current?.click(), []);
const onUploadPick = async () => {
const input = uploadInput.current!;
const file = input.files?.[0];
input.value = "";
if (!file) return;
// A selected folder receives the upload; a selected file means "next
// to it".
const dir = !path ? "" : isDir ? path : path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
const dest = dir ? dir + "/" + file.name : file.name;
try {
setUploadStatus(`Uploading ${dest}`);
await uploadFile(apiBase, dest, file);
setUploadStatus(`Uploaded ${dest}`);
await qc.invalidateQueries({ queryKey: ["tree", apiBase] });
openPath(dest);
} catch (err) {
setUploadStatus("Upload failed: " + (err as Error).message);
}
};
useEffect(() => {
// Any navigation clears a stale upload status from the meta slot.
setUploadStatus("");
}, [routeKey]);
/* ---- ⌘K palette ---- */
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -223,7 +202,6 @@ export default function Browser(props: {
if (isFile) add("download", "Download: " + path, "action", () => downloadRef.current?.click());
}
if (hub && project) add("hist", "History: whole project", "action", () => openHistory(""));
if (canUpload) add("upload", "Upload a file…", "action", uploadNow);
if (hub) {
for (const p of props.projects || []) {
if (!project || p.id !== project.id) {
@@ -237,7 +215,7 @@ export default function Browser(props: {
for (const d of dirIndex.keys()) add("folder", d, "folder", () => openPath(d));
for (const f of flatFiles) add("doc", f.path, "file", () => openPath(f.path));
return items;
}, [hub, project, path, isFile, canUpload, config.auth?.enabled, dirIndex, flatFiles, props.projects, shareNow, historyNow, uploadNow, openHistory, openPath]);
}, [hub, project, path, isFile, config.auth?.enabled, dirIndex, flatFiles, props.projects, shareNow, historyNow, openHistory, openPath]);
/* ---- "⋯ More" menu (secondary actions on narrow screens) ---- */
useEffect(() => {
@@ -261,6 +239,7 @@ export default function Browser(props: {
flatFiles={flatFiles}
heatMap={heatMap}
devices={devices}
scope={route.viewTarget || ""}
onOpenFile={openPath}
onOpenFolder={openPath}
isFolder={isFolderFn}
@@ -362,7 +341,7 @@ export default function Browser(props: {
) : path ? (
<Breadcrumbs path={path} onOpenFolder={openPath} />
) : route.view === "insights" ? (
"Insights — " + (project?.name ?? "")
"Insights — " + (route.viewTarget || project?.name || "")
) : route.view === "history" ? (
"History — " + historyTitle(route.viewTarget || "", isFolderFn)
) : isHome ? (
@@ -372,31 +351,22 @@ export default function Browser(props: {
const topbar = (
<Topbar
crumb={crumb}
meta={uploadStatus || meta}
meta={meta}
actions={
<>
<button id="search-btn" className="btn ghost" title="Search (⌘K)" onClick={() => setPaletteOpen(true)}>
<Icon name="search" /> <span className="lbl">Search</span> <kbd>K</kbd>
</button>
{canShare && (
<button id="share-btn" className="btn" onClick={shareNow}>
<Icon name="share" /> <span className="lbl">Share</span>
<button id="share-btn" className="btn icon-only" title="Share" aria-label="Share" onClick={shareNow}>
<Icon name="share" />
</button>
)}
{canHistory && (
{canHistory && !path && !route.view && (
<button id="history-btn" className="btn" onClick={historyNow}>
<Icon name="hist" /> <span className="lbl">History</span>
</button>
)}
{canUpload && (
<button id="upload-btn" className="btn" onClick={uploadNow}>
<Icon name="upload" /> <span className="lbl">Upload</span>
</button>
)}
<input type="file" hidden ref={uploadInput} onChange={onUploadPick} />
{canDownload && (
<a id="download" className="btn" download href={downloadURL} ref={downloadRef}>
<Icon name="download" /> <span className="lbl">Download</span>
<a id="download" hidden download href={downloadURL} ref={downloadRef}>
Download
</a>
)}
{canMore && (
@@ -420,11 +390,6 @@ export default function Browser(props: {
History
</button>
)}
{canUpload && (
<button className="more-item" onClick={uploadNow}>
Upload
</button>
)}
{canDownload && (
<button className="more-item" onClick={() => downloadRef.current?.click()}>
Download
@@ -433,7 +398,10 @@ export default function Browser(props: {
{props.canInsights && (
<button
className="more-item"
onClick={() => navigate(urlForView("insights", project?.id))}
onClick={() => {
props.onClosePanel?.();
navigate(urlForView("insights", project?.id, path));
}}
>
Insights
</button>
@@ -456,7 +424,7 @@ export default function Browser(props: {
root={tree}
expanded={expanded}
onToggle={onToggle}
currentPath={path}
currentPath={treePath}
listingShowing={listingShowing}
onOpen={openPath}
/>
+77 -32
View File
@@ -2,13 +2,15 @@ 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";
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 { ConnectGuide } from "../components/ConnectGuide";
import { EmptyState } from "../components/EmptyState";
import { toast } from "../toast";
import Browser from "./Browser";
@@ -67,20 +69,18 @@ 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);
const vault = (
<VaultHeader
name={projects ? (current ? current.name : brand) : ""}
onHome={current ? () => navigate("/" + current.id) : undefined}
showSignout={config.auth.enabled}
// Top of the sidebar is the brand; project and account actions live in
// their own sections below (PropelAuth-style layout).
const vault = <VaultHeader name={brand} onHome={() => navigate("/")} search={!!current} />;
const accountBar = config.me ? (
<AccountBar
me={config.me}
org={org}
admin={
isAdmin
? {
@@ -92,18 +92,12 @@ export default function HubApp({ config }: { config: ServerConfig }) {
}
: undefined
}
gear={
gearTarget
? {
onClick: () => {
setPanel({ kind: "org", orgId: gearTarget.id });
closeSidebarOnMobile();
},
}
: undefined
}
onOrgSettings={(o) => {
setPanel({ kind: "org", orgId: o.id });
closeSidebarOnMobile();
}}
/>
);
) : undefined;
if (!projects || !orgs) {
return (
@@ -118,6 +112,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
<AppShell
vault={vault}
projectsNav={<ProjectNav projects={projects} />}
orgBar={accountBar}
topbar={<Topbar />}
contentClass="view"
>
@@ -160,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) {
@@ -178,18 +187,54 @@ export default function HubApp({ config }: { config: ServerConfig }) {
canInsights={canInsights}
sidebar={{
vault,
projectsNav: <ProjectNav projects={projects} currentId={current.id} />,
orgBar: (
<OrgBar
org={org}
onManage={(o) => {
setPanel({ kind: "org", orgId: o.id });
closeSidebarOnMobile();
projectsNav: (
<ProjectNav
projects={projects}
currentId={current.id}
menu={{
// Scoped views (/insights/<path>, /history/<path>) belong to
// the file/folder — the tree carries the selection, no menu
// item lights up.
active: panel
? null
: route.view === "insights" && !route.viewTarget
? "dashboard"
: route.view === "install"
? "install"
: route.view === "history" && !route.viewTarget
? "history"
: route.view === "settings"
? "settings"
: null,
// Each page is a URL; explicitly close overlay panels because
// same-path navigation doesn't change pathname.
onDashboard: () => {
setPanel(null);
navigate(urlForView("insights", current.id));
closeSidebarOnMobile();
},
onInstall: () => {
setPanel(null);
navigate(urlForView("install", current.id));
closeSidebarOnMobile();
},
onHistory: () => {
setPanel(null);
navigate(urlForView("history", current.id));
closeSidebarOnMobile();
},
onSettings: () => {
setPanel(null);
navigate(urlForView("settings", current.id));
closeSidebarOnMobile();
},
}}
/>
),
orgBar: accountBar,
}}
panel={activePanel}
panel={activePanel || routePage}
onClosePanel={() => setPanel(null)}
/>
);
}
@@ -218,7 +263,7 @@ function JoinInvite({ token, onDone }: { token: string; onDone: (orgId: string |
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
return (
<AppShell vault={<VaultHeader name="…" showSignout />} topbar={<Topbar />}>
<AppShell vault={<VaultHeader name="BearDrive" />} topbar={<Topbar />}>
<div className="empty">Joining</div>
</AppShell>
);
@@ -21,7 +21,7 @@ export default function VolumeApp({ config }: { config: ServerConfig }) {
apiBase="/api/"
route={route}
hub={false}
sidebar={{ vault: <VaultHeader name={name} showSignout={config.auth.enabled} /> }}
sidebar={{ vault: <VaultHeader name={name} showSignout={config.auth.enabled} search /> }}
/>
);
}
@@ -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<HTMLDivElement>(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 (
<footer id="accountbar" ref={ref}>
{open && (
<div id="account-menu" role="menu" aria-label="Account menu">
{org && (
<>
<div className="menu-sec">Organization</div>
<button
id="menu-org-settings"
role="menuitem"
onClick={() => {
setOpen(false);
onOrgSettings(org);
}}
>
<Icon name="gear" />
<span>
<b>{org.name}</b> Settings
</span>
</button>
</>
)}
{admin && (
<>
<div className="menu-sec">Hub</div>
<button
id="menu-hub-admin"
role="menuitem"
onClick={() => {
setOpen(false);
admin.onClick();
}}
>
<Icon name="shield" />
<span>Signup &amp; access{admin.pending ? ` · ${admin.pending}` : ""}</span>
</button>
</>
)}
<div className="menu-sec">Account</div>
<a id="signout" role="menuitem" href="/auth/logout">
<Icon name="power" />
<span>Log out</span>
</a>
</div>
)}
<button
id="account-btn"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
<span className="avatar" style={{ background: projColor(me.email) }} aria-hidden="true">
{(display.trim()[0] || "?").toUpperCase()}
</span>
<span className="acct">
<b>{display}</b>
{me.name && <small>{me.email}</small>}
</span>
<Icon name="chev" />
</button>
</footer>
);
}
@@ -48,15 +48,29 @@ export function Insights(props: {
flatFiles: Node[];
heatMap: HeatMap | null;
devices: DeviceHeat[] | null;
scope?: string; // "" = whole project; a folder scopes to its subtree, a file to itself
onOpenFile: (path: string) => void;
onOpenFolder: (path: string) => void;
isFolder: (path: string) => boolean;
}) {
const [lens, setLens] = useState<Lens>("all");
const { flatFiles, heatMap, devices } = props;
const { flatFiles, heatMap, devices, scope } = props;
const inScope = (p: string) => !scope || p === scope || p.startsWith(scope + "/");
const scoped = scope ? flatFiles.filter((f) => inScope(f.path)) : flatFiles;
const scopedDevices =
devices && scope
? devices
.map((d) => {
const folders: Record<string, number> = {};
for (const [f, n] of Object.entries(d.folders || {})) if (inScope(f)) folders[f] = n;
return { ...d, folders };
})
.filter((d) => Object.keys(d.folders).length > 0)
: devices;
const now = Date.now();
const pts: Pt[] = flatFiles.map((f) => {
const pts: Pt[] = scoped.map((f) => {
const e = (heatMap && heatMap[f.path]) || {};
const days = f.time ? Math.max(0, (now - new Date(f.time).getTime()) / 86400000) : 0;
const reads = lens === "all" ? heatTotal(e) : e[lens] || 0;
@@ -72,10 +86,11 @@ export function Insights(props: {
return (
<div className="insights">
<h1 className="in-title">Knowledge insights</h1>
<h1 className="in-title">Knowledge insights{scope ? <span className="in-scope"> · {scope}</span> : null}</h1>
<p className="dl-sub">
Reads over the last 30 days × how long since each file changed. Hot but stale knowledge
read a lot, maintained by nobody is the danger zone.
{scope
? `Reads over the last 30 days × freshness, for ${scope} and everything in it.`
: "Reads over the last 30 days × how long since each file changed. Hot but stale knowledge — read a lot, maintained by nobody — is the danger zone."}
</p>
<div className="in-lens">
{(["all", "human", "agent"] as const).map((l) => (
@@ -98,10 +113,10 @@ export function Insights(props: {
<h3 className="dl-h3">Hot path top files by reads</h3>
<HotPath pts={pts} lens={lens} onOpenFile={props.onOpenFile} />
{devices && devices.length > 0 && (
{scopedDevices && scopedDevices.length > 0 && (
<>
<h3 className="dl-h3">Agent coverage which agents read which areas</h3>
<CoverageMatrix devices={devices} />
<CoverageMatrix devices={scopedDevices} />
</>
)}
</div>
@@ -1,32 +0,0 @@
import type { Org } from "../api/types";
// 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;
return (
<footer id="orgbar">
<span
id="org-name"
title="Manage organization"
role="button"
tabIndex={0}
onClick={() => onManage(org)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onManage(org);
}
}}
>
{org.name}
</span>
{org.role === "owner" && (
<button id="invite-btn" title="Manage this organization" onClick={() => onManage(org)}>
Manage
</button>
)}
</footer>
);
}
@@ -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,23 @@ export function projColor(s: string): string {
return PROJ_COLORS[h % PROJ_COLORS.length];
}
export function ProjectNav({ projects, currentId }: { projects: Project[]; currentId?: string }) {
export interface ProjectMenu {
active: "dashboard" | "install" | "history" | "settings" | null;
onDashboard: () => void;
onInstall: () => void;
onHistory: () => void;
onSettings: () => void;
}
export function ProjectNav({
projects,
currentId,
menu,
}: {
projects: Project[];
currentId?: string;
menu?: ProjectMenu;
}) {
const refresh = useHubRefresh();
const create = async () => {
@@ -39,33 +56,67 @@ export function ProjectNav({ projects, currentId }: { projects: Project[]; curre
+
</button>
</div>
<ul>
{projects.map((p) => (
<li key={p.id}>
<div
className={"row" + (currentId === p.id ? " active" : "")}
title={p.name}
tabIndex={0}
role="button"
onClick={() => {
navigate("/" + p.id);
<div className="proj-row">
<span className="proj-select-wrap">
{currentId && (
<span
className="proj-mark"
aria-hidden="true"
style={{ background: projColor(projects.find((p) => p.id === currentId)?.name || "") }}
/>
)}
<select
id="project-select"
aria-label="Switch project"
value={currentId || ""}
onChange={(e) => {
if (e.target.value) {
navigate("/" + e.target.value);
closeSidebarOnMobile();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
(e.currentTarget as HTMLElement).click();
}
}}
>
<span className="proj-mark" style={{ background: projColor(p.name) }}>
{p.name.trim()[0] || "?"}
</span>
<span className="label">{p.name}</span>
</div>
</li>
))}
</ul>
}
}}
>
{!currentId && <option value="" disabled />}
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<Icon name="chevd" />
</span>
</div>
{menu && (
<ul className="nav-menu" aria-label="Project pages">
{(
[
["dashboard", "Dashboard", "dashboard", menu.onDashboard],
["install", "Installation", "terminal", menu.onInstall],
["history", "History", "hist", menu.onHistory],
["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>
);
}
@@ -0,0 +1,30 @@
import type { Org, Project } from "../api/types";
// 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">
<h2>{project.name}</h2>
<dl className="ps-facts">
<dt>Project id</dt>
<dd>
<code>{project.id}</code>
</dd>
{org && (
<>
<dt>Workspace</dt>
<dd>{org.name}</dd>
</>
)}
{project.created && (
<>
<dt>Created</dt>
<dd>{new Date(project.created).toLocaleDateString()}</dd>
</>
)}
</dl>
</div>
);
}
@@ -1,4 +1,35 @@
import type { ReactNode } from "react";
import { requestSearch } from "../search";
import {
Check,
ChevronDown,
ChevronRight,
Clock,
Copy,
Download,
Ellipsis,
FileText,
Folder,
LayoutDashboard,
Globe,
History,
Link,
Lock,
LogOut,
Menu,
Plus,
Search,
Settings,
Share2,
Shield,
SquareTerminal,
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 +42,42 @@ 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 `<Icon name>` API and style.css's
// `.ico` sizing/stroke rules apply unchanged.
const ICONS: Record<string, LucideIcon> = {
alert: TriangleAlert,
check: Check,
chev: ChevronRight,
chevd: ChevronDown,
clock: Clock,
copy: Copy,
doc: FileText,
dots: Ellipsis,
download: Download,
folder: Folder,
dashboard: LayoutDashboard,
gear: Settings,
globe: Globe,
hist: History,
link: Link,
lock: Lock,
menu: Menu,
plus: Plus,
power: LogOut,
search: Search,
share: Share2,
shield: Shield,
terminal: SquareTerminal,
trash: Trash2,
upload: Upload,
users: Users,
x: X,
};
export function Icon({ name }: { name: string }) {
return (
<svg className="ico" aria-hidden="true">
<use href={`#i-${name}`} />
</svg>
);
const C = ICONS[name];
return C ? <C className="ico" aria-hidden="true" /> : null;
}
export function AppShell(props: {
@@ -57,11 +118,10 @@ export function AppShell(props: {
export function VaultHeader(props: {
name: string;
onHome?: () => void; // hub: the project name doubles as a home link
showSignout: boolean;
admin?: { pending: number; onClick: () => void }; // hub admins only
gear?: { onClick: () => void }; // org owners: manage organization
showSignout?: boolean; // volume mode: sign-out stays in the header (no account bar)
search?: boolean; // icon-only ⌘K search trigger beside the brand
}) {
const { name, onHome, showSignout, admin, gear } = props;
const { name, onHome, showSignout, search } = props;
return (
<header id="vault">
<span id="vault-badge" aria-hidden="true">
@@ -83,29 +143,20 @@ export function VaultHeader(props: {
{name}
</span>
<div className="vault-actions">
{admin && (
{search && (
<button
id="adminbar"
className="adminbar"
title={
"Hub administration — signup policy" +
(admin.pending ? " and pending approvals" : "")
}
onClick={admin.onClick}
id="search-btn"
className="icon-btn2 has-tip"
aria-label="Search"
onClick={() => {
requestSearch();
closeSidebarOnMobile();
}}
>
<Icon name="shield" />
<span>Admin{admin.pending ? " · " + admin.pending : ""}</span>
</button>
)}
{gear && (
<button
id="settings-btn"
className="icon-btn2"
title="Manage organization"
aria-label="Manage organization"
onClick={gear.onClick}
>
<Icon name="users" />
<Icon name="search" />
<span className="tip" role="tooltip">
Search <kbd>K</kbd>
</span>
</button>
)}
{showSignout && (
+13 -12
View File
@@ -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,12 +58,8 @@ 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 (view === "history" && target) s += "/" + encodePath(target.replace(/\/+$/, ""));
if (target) s += "/" + encodePath(target.replace(/\/+$/, ""));
return s;
}
+14
View File
@@ -0,0 +1,14 @@
// Tiny cross-component signal: the sidebar's search button asks whichever
// surface owns the ⌘K palette (Browser) to open it — same in-repo-emitter
// spirit as nav.ts, no context plumbing through the shell.
type Listener = () => void;
const listeners = new Set<Listener>();
export function onSearchRequest(l: Listener): () => void {
listeners.add(l);
return () => listeners.delete(l);
}
export function requestSearch(): void {
for (const l of listeners) l();
}
+85 -30
View File
@@ -69,6 +69,9 @@ button, input, a.btn { font-family: inherit; }
display: flex; align-items: center; gap: 9px;
height: 52px; padding: 0 12px 0 14px;
border-bottom: 1px solid var(--border);
/* Own stacking context above the sections below, so the search tooltip
paints over their borders instead of sliding behind them. */
position: relative; z-index: 45;
}
#vault-badge {
width: 22px; height: 22px; border-radius: 6px; flex: none;
@@ -78,20 +81,13 @@ button, input, a.btn { font-family: inherit; }
}
#vault-name { font-weight: 600; font-size: 13px; letter-spacing: -.01em; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vault-actions { display: flex; align-items: center; gap: 4px; }
#signout, .icon-btn2 {
#vault #signout, .icon-btn2 {
width: 28px; height: 28px; border-radius: 6px;
display: inline-flex; align-items: center; justify-content: center;
color: var(--text-ghost); background: transparent; border: none; cursor: pointer; text-decoration: none;
}
#signout:hover, .icon-btn2:hover { color: var(--text); background: var(--hover); }
#signout .ico, .icon-btn2 .ico { width: 16px; height: 16px; }
.adminbar {
display: inline-flex; align-items: center; gap: 6px;
height: 26px; padding: 0 9px; border-radius: var(--r-ctl);
border: 1px solid var(--border); background: var(--surface);
color: var(--accent-bright); font-size: 11.5px; font-weight: 600; cursor: pointer;
}
.adminbar:hover { background: var(--glow); border-color: transparent; }
#vault #signout:hover, .icon-btn2:hover { color: var(--text); background: var(--hover); }
#vault #signout .ico, .icon-btn2 .ico { width: 16px; height: 16px; }
/* projects */
#projects { flex: none; max-height: 32%; overflow-y: auto; padding: 10px 8px 8px; border-bottom: 1px solid var(--border); }
@@ -143,23 +139,63 @@ button, input, a.btn { font-family: inherit; }
#tree li.collapsed > .row .chev { transform: rotate(-90deg); }
/* org bar */
#orgbar { display: flex; align-items: center; gap: 9px; padding: 9px 12px 9px 14px; border-top: 1px solid var(--border); }
#org-name {
flex: 1; min-width: 0; display: inline-flex; align-items: center; gap: 8px;
color: var(--text-dim); font-size: 12.5px; font-weight: 500; cursor: pointer;
/* ---- 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; }
.proj-select-wrap .proj-mark { position: absolute; left: 9px; pointer-events: none; }
.proj-select-wrap > .ico { position: absolute; right: 8px; width: 14px; height: 14px; color: var(--text-ghost); pointer-events: none; }
#project-select {
flex: 1; min-width: 0; height: 30px; padding: 0 26px 0 30px;
appearance: none; -webkit-appearance: none;
border: 1px solid var(--border); border-radius: var(--r-ctl);
background: var(--surface); color: var(--text);
font: inherit; font-size: 12.5px; font-weight: 500; cursor: pointer;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
#org-name::before {
content: ""; width: 15px; height: 15px; flex: none; border-radius: 4px;
background: linear-gradient(160deg, #ffcf85, #d3861a); opacity: .85;
#project-select:hover { background: var(--hover); border-color: var(--border-2); }
/* ---- account bar ---- */
#accountbar { position: relative; border-top: 1px solid var(--border); padding: 7px 10px; }
#account-btn {
width: 100%; display: flex; align-items: center; gap: 9px; text-align: left;
padding: 6px 8px; border: none; border-radius: 7px;
background: transparent; color: var(--text-dim); cursor: pointer; font: inherit;
}
#org-name:hover { color: var(--text); }
#invite-btn {
flex: none; height: 27px; padding: 0 11px; border-radius: var(--r-ctl);
border: 1px solid var(--border); background: var(--surface);
color: var(--text-dim); font-size: 12px; font-weight: 500; cursor: pointer;
#account-btn:hover { background: var(--hover); color: var(--text); }
#account-btn .avatar {
width: 26px; height: 26px; flex: none; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
color: #fff; font-size: 12px; font-weight: 700;
}
#invite-btn:hover { background: var(--hover); color: var(--text); border-color: var(--border-2); }
#account-btn .acct { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
#account-btn .acct b { font-size: 12.5px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#account-btn .acct small { font-size: 11px; color: var(--text-ghost); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#account-btn > .ico { width: 14px; height: 14px; color: var(--text-ghost); }
#account-menu {
position: absolute; left: 10px; right: 10px; bottom: calc(100% + 4px);
padding: 5px; border: 1px solid var(--border-2); border-radius: 9px;
background: var(--surface); box-shadow: 0 10px 32px rgba(0, 0, 0, .35);
display: flex; flex-direction: column; z-index: 30;
}
#account-menu .menu-sec {
padding: 7px 9px 3px; font-size: 10.5px; font-weight: 600; letter-spacing: .04em;
text-transform: uppercase; color: var(--text-ghost);
}
#account-menu [role="menuitem"] {
display: flex; align-items: center; gap: 8px; padding: 7px 9px;
border: none; border-radius: 6px; background: transparent; text-align: left;
color: var(--text-dim); font: inherit; font-size: 12.5px; cursor: pointer; text-decoration: none;
}
#account-menu [role="menuitem"]:hover { background: var(--hover); color: var(--text); }
#account-menu [role="menuitem"] b { font-weight: 600; }
#account-menu [role="menuitem"] .ico { width: 15px; height: 15px; }
#account-menu #signout { color: var(--danger, #e5534b); }
#account-menu #signout:hover { color: var(--danger, #e5534b); background: var(--hover); }
/* ---- main pane ---- */
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
@@ -184,10 +220,29 @@ button, input, a.btn { font-family: inherit; }
.btn:hover { background: var(--hover); color: var(--text); border-color: var(--border-2); }
.btn .ico { width: 15px; height: 15px; }
.btn.ghost { color: var(--text-dim); }
#search-btn kbd {
/* Hover tooltip for icon-only controls (the header search): label + kbd
chip in a floating card below the button, arrow pointing up. */
.has-tip { position: relative; }
.has-tip .tip {
/* Right-aligned (not centered): the sidebar clips at its right edge
(overflow hidden), so the card must grow leftward from the button. */
position: absolute; top: calc(100% + 8px); right: -6px;
display: flex; align-items: center; gap: 7px; white-space: nowrap;
padding: 6px 9px; border-radius: 8px; border: 1px solid var(--border-2);
background: var(--surface); color: var(--text); font-size: 12.5px; font-weight: 500;
box-shadow: 0 8px 24px rgba(0, 0, 0, .35);
opacity: 0; visibility: hidden; transition: opacity .12s ease .15s; z-index: 40;
}
.has-tip .tip::before {
content: ""; position: absolute; top: -4.5px; right: 16px;
width: 8px; height: 8px; transform: rotate(45deg);
background: var(--surface); border-left: 1px solid var(--border-2); border-top: 1px solid var(--border-2);
}
.has-tip:hover .tip, .has-tip:focus-visible .tip { opacity: 1; visibility: visible; }
.has-tip .tip kbd {
font: 11px var(--ui); color: var(--text-faint);
background: var(--hover); border: 1px solid var(--border-2); border-radius: 5px;
padding: 1px 5px; margin-left: 2px;
padding: 1px 5px;
}
#more-menu {
position: absolute; right: 12px; top: calc(100% - 4px); z-index: 80;
@@ -302,6 +357,7 @@ button, input, a.btn { font-family: inherit; }
/* ---- insights (read×write matrix) ---- */
.insights { max-width: 760px; margin: 0 auto; }
.in-title { font-size: 21px; font-weight: 640; letter-spacing: -.02em; margin: 0 0 4px; color: #f4f6f9; }
.in-title .in-scope { color: var(--text-ghost); font-weight: 500; font-size: 15px; }
.in-lens { display: flex; gap: 6px; margin: 0 0 14px; }
.in-lens-btn { font: inherit; font-size: 12px; padding: 5px 12px; border-radius: 999px; border: 1px solid var(--border); background: none; color: var(--text-faint); cursor: pointer; }
.in-lens-btn:hover { color: var(--text); }
@@ -422,7 +478,7 @@ button, input, a.btn { font-family: inherit; }
.icon-btn { display: inline-flex; width: 44px; height: 44px; }
#content { padding: 24px 18px 70px; }
#topbar { padding: 0 8px; gap: 4px; }
#search-btn kbd, .btn .lbl { display: none; }
.btn .lbl { display: none; }
#topbar .btn { min-width: 44px; min-height: 44px; padding: 0; justify-content: center; gap: 0; }
#topbar .btn .ico { width: 18px; height: 18px; }
#more-btn:not([hidden]) { display: inline-flex; }
@@ -433,10 +489,9 @@ button, input, a.btn { font-family: inherit; }
#meta { display: none; }
#crumb { flex: 1; }
#vault { padding: 0 8px 0 12px; }
.icon-btn2, #signout, .adminbar { min-width: 44px; min-height: 44px; }
#tree li > .row, #projects .row { height: 44px; }
#invite-btn { min-height: 44px; padding: 0 14px; }
#org-name { min-height: 44px; }
.icon-btn2, #signout, #tree li > .row, #projects .row { height: 44px; }
#account-btn { min-height: 44px; }
#project-select { min-height: 44px; }
.nav-add { min-width: 44px; min-height: 44px; }
.markdown, .admin, .onboard, .history, .dirlist { max-width: 100%; }
.markdown table, pre.plain { display: block; overflow-x: auto; max-width: 100%; }
-90
View File
@@ -1,90 +0,0 @@
import type { UploadPlan } from "./api/types";
/* The client asks the server how to upload (upload/init): "direct" hands
back a short-lived presigned URL and the bytes go straight to the object
store; "server" means relay the bytes through the bdrive server. */
export async function uploadFile(apiBase: string, dest: string, file: File): Promise<void> {
const buf = await file.arrayBuffer();
const sha = await sha256Hex(buf);
const post = async (url: string, body: unknown) => {
const r = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(await r.text());
return r.json();
};
const req = { path: dest, sha256: sha, size: file.size };
const plan: UploadPlan = await post(apiBase + "upload/init", req);
if (plan.mode === "direct") {
if (!plan.exists) {
// identical content already in the store? skip the PUT
const r = await fetch(plan.url!, {
method: plan.method || "PUT",
headers: plan.headers || {},
body: buf,
});
if (!r.ok) throw new Error("storage upload failed: " + r.status);
}
await post(apiBase + "upload/commit", req);
} else {
const r = await fetch(apiBase + "upload/content?path=" + encodeURIComponent(dest), {
method: "PUT",
body: buf,
});
if (!r.ok) throw new Error(await r.text());
}
}
async function sha256Hex(buf: ArrayBuffer): Promise<string> {
if (crypto.subtle) {
const d = await crypto.subtle.digest("SHA-256", buf);
return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
return sha256Fallback(new Uint8Array(buf)); // plain-http origins have no crypto.subtle
}
/* Minimal SHA-256 (FIPS 180-4) for non-secure contexts. */
function sha256Fallback(bytes: Uint8Array): string {
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
const H = new Uint32Array([
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
]);
const rr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
const len = bytes.length;
const padded = new Uint8Array(((((len + 8) >> 6) + 1) << 6));
padded.set(bytes);
padded[len] = 0x80;
const dv = new DataView(padded.buffer);
dv.setUint32(padded.length - 8, Math.floor((len * 8) / 0x100000000));
dv.setUint32(padded.length - 4, (len * 8) >>> 0);
const w = new Uint32Array(64);
for (let off = 0; off < padded.length; off += 64) {
for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4);
for (let i = 16; i < 64; i++) {
const s0 = rr(w[i - 15], 7) ^ rr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
const s1 = rr(w[i - 2], 17) ^ rr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
}
let [a, b, c, d, e, f, g, h] = H as unknown as number[];
for (let i = 0; i < 64; i++) {
const S1 = rr(e, 6) ^ rr(e, 11) ^ rr(e, 25);
const t1 = (h + S1 + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0;
const S0 = rr(a, 2) ^ rr(a, 13) ^ rr(a, 22);
const t2 = (S0 + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
h = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
}
H[0] += a; H[1] += b; H[2] += c; H[3] += d; H[4] += e; H[5] += f; H[6] += g; H[7] += h;
}
return [...H].map((x) => (x >>> 0).toString(16).padStart(8, "0")).join("");
}
+163
View File
@@ -0,0 +1,163 @@
package webapp
// Temporary manual demo harness: a seeded hub (~500 files, zipf-ish read
// data, four agent devices) for exploring the web UI by hand. Not part of
// the test suite: it only runs with BDRIVE_MANUAL_SERVE=1 and must never be
// committed.
//
// State lives in a STABLE directory (os.TempDir()/bdrive-demo-hub, override
// with BDRIVE_MANUAL_STATE), so restarting the harness — e.g. after a
// frontend change — keeps accounts, browser sessions, the project id, and
// all seeded/demo data. Delete the directory to reset the demo.
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/runbear-io/beardrive/internal/journal"
"github.com/runbear-io/beardrive/internal/remote"
)
func TestManualServe(t *testing.T) {
if os.Getenv("BDRIVE_MANUAL_SERVE") == "" {
t.Skip("manual demo harness; set BDRIVE_MANUAL_SERVE=1 to run")
}
state := os.Getenv("BDRIVE_MANUAL_STATE")
if state == "" {
state = filepath.Join(os.TempDir(), "bdrive-demo-hub")
}
if err := os.MkdirAll(filepath.Join(state, "storage"), 0o755); err != nil {
t.Fatal(err)
}
be, err := remote.Open(t.Context(), "file://"+filepath.Join(state, "storage"))
if err != nil {
t.Fatal(err)
}
db, err := OpenProjectDB(filepath.Join(state, "projects.json"))
if err != nil {
t.Fatal(err)
}
p, _, err := db.GetOrCreate("proj", "") // create-or-join: id is stable across restarts
if err != nil {
t.Fatal(err)
}
srv := &Server{Root: be, Projects: db, Device: webDevice, Refresh: 0, Upload: UploadConfig{Enabled: true}}
prefix := filepath.Join(state, "storage", p.ID)
seedMark := filepath.Join(prefix, "journal", "seed.jsonl")
if _, err := os.Stat(seedMark); os.IsNotExist(err) {
seedDemo(t, state, prefix, p.ID)
}
srv.Reads, err = OpenReadLedger(filepath.Join(state, "reads.json"), 0)
if err != nil {
t.Fatal(err)
}
srv.Devices, _ = OpenDeviceRegistry(filepath.Join(state, "devices.json"))
srv.Devices.Observe(DeviceInfo{ID: "dev-ci", Name: "claude-ci", OS: "linux/amd64"})
srv.Devices.Observe(DeviceInfo{ID: "dev-snow", Name: "claude-snow", OS: "darwin/arm64"})
srv.Devices.Observe(DeviceInfo{ID: "codex-mia", Name: "codex-mia", OS: "darwin/arm64"})
srv.Devices.Observe(DeviceInfo{ID: "gemini-doc", Name: "gemini-doc", OS: "linux/amd64"})
srv.Shares, _ = OpenShareDB(filepath.Join(state, "shares.json"))
auth, err := OpenBuiltinAuth(filepath.Join(state, "auth.json"), false, nil)
if err != nil {
t.Fatal(err)
}
auth.signup("snow@runbear.io", "Snow", "password1") // no-op if the account exists
auth.Admins = map[string]bool{"snow@runbear.io": true}
srv.Auth = auth
t.Logf("serving on http://0.0.0.0:8993 (state: %s) — snow@runbear.io / password1", state)
go http.ListenAndServe("0.0.0.0:8993", srv.Handler())
time.Sleep(8 * time.Hour)
}
// seedDemo writes ~500 files (journal + blobs) and their read buckets. Runs
// once per state dir.
func seedDemo(t *testing.T, state, prefix, projectID string) {
t.Helper()
os.MkdirAll(filepath.Join(prefix, "journal"), 0o755)
os.MkdirAll(filepath.Join(prefix, "blobs"), 0o755)
seed := int64(42)
rnd := func() float64 {
seed = (seed*16807 + 7) % 2147483647
return float64(seed) / 2147483647
}
folders := []struct {
dir string
n int
}{
{"wiki/onboarding", 28}, {"wiki/architecture", 40}, {"wiki/runbooks", 55},
{"wiki/api", 70}, {"wiki/decisions", 45}, {"docs/product", 60},
{"docs/design", 35}, {"notes/meetings", 80}, {"notes/research", 45},
{"shared/reports", 42},
}
humans := []string{"alice@x.io", "bob@x.io", "carol@x.io"}
agents := []string{"dev-ci", "dev-snow", "codex-mia", "gemini-doc"}
now := time.Now().UTC()
var ops []journal.Op
var stats []ReadStat
var lam, seq int64
fileNo := 0
for _, f := range folders {
short := filepath.Base(f.dir)[:3]
for i := 0; i < f.n; i++ {
fileNo++
path := fmt.Sprintf("%s/%s-%03d.md", f.dir, short, i+1)
content := fmt.Sprintf("# %s\n\nSeeded demo file %d in %s.\n", path, fileNo, f.dir)
sum := sha256.Sum256([]byte(content))
blob := hex.EncodeToString(sum[:])
os.WriteFile(filepath.Join(prefix, "blobs", blob), []byte(content), 0o644)
stale := int(400 * rnd() * rnd())
lam++
seq++
ops = append(ops, journal.Op{
Seq: seq, Lamport: lam, Time: now.AddDate(0, 0, -stale),
Device: "seed", DeviceName: "seed", Author: "alice@x.io",
User: "alice@x.io", UserName: "Alice",
Kind: journal.KindPut, Path: path, Blob: blob,
Size: int64(len(content)), Mode: 0o644,
})
hot := rnd() < 0.15
day := now.AddDate(0, 0, -int(rnd()*20)).Format("2006-01-02")
for _, a := range humans {
n := int64(math.Floor((map[bool]float64{true: 30, false: 3}[hot]) * rnd() * rnd()))
if n > 0 {
stats = append(stats, ReadStat{Project: projectID, Path: path, Day: day,
Kind: ReadKindHuman, Actor: a, Count: n, Last: now})
}
}
for ai, a := range agents {
boost := 1.0
if f.dir == "wiki/runbooks" && ai < 2 {
boost = 4
}
if f.dir == "notes/research" {
boost = 0.05
}
n := int64(math.Floor((map[bool]float64{true: 60, false: 5}[hot]) * rnd() * rnd() * boost))
if n > 0 {
stats = append(stats, ReadStat{Project: projectID, Path: path, Day: day,
Kind: ReadKindAgent, Actor: a, Count: n, Last: now})
}
}
}
}
if err := journal.Append(filepath.Join(prefix, "journal", "seed.jsonl"), ops); err != nil {
t.Fatal(err)
}
if err := newFileReadRepo(filepath.Join(state, "reads.json")).PutBatch(stats); err != nil {
t.Fatal(err)
}
}
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 -32
View File
@@ -5,40 +5,10 @@
<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-Dz7xLxXQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-h2c92P2n.css">
<script type="module" crossorigin src="/assets/index-DnTochR5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BoVbUYAg.css">
</head>
<body>
<svg width="0" height="0" class="sprite" aria-hidden="true" focusable="false">
<symbol id="i-chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></symbol>
<symbol id="i-chevd" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
<symbol id="i-folder" viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></symbol>
<symbol id="i-doc" viewBox="0 0 24 24"><path d="M14 3v5h5"/><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.2-3.2"/></symbol>
<symbol id="i-share" viewBox="0 0 24 24"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 10.5 6.8-4M8.6 13.5l6.8 4"/></symbol>
<symbol id="i-hist" viewBox="0 0 24 24"><path d="M3 3v6h6"/><path d="M3.5 9a9 9 0 1 0 2-4"/><path d="M12 8v4l3 2"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-gear" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 13a7.6 7.6 0 0 0 0-2l1.7-1.3-1.7-3-2 .8a7.6 7.6 0 0 0-1.7-1l-.3-2.1H10l-.3 2.1a7.6 7.6 0 0 0-1.7 1l-2-.8-1.7 3L6 11a7.6 7.6 0 0 0 0 2l-1.7 1.3 1.7 3 2-.8a7.6 7.6 0 0 0 1.7 1l.3 2.1h3.4l.3-2.1a7.6 7.6 0 0 0 1.7-1l2 .8 1.7-3z"/></symbol>
<symbol id="i-users" viewBox="0 0 24 24"><path d="M16 20v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="3.2"/><path d="M22 20v-2a4 4 0 0 0-3-3.8"/><path d="M16 3.5a4 4 0 0 1 0 7"/></symbol>
<symbol id="i-link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M12 3 5 6v5c0 4.5 3 7.5 7 9 4-1.5 7-4.5 7-9V6z"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="m5 13 4 4L19 7"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m2 0v12a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V7"/></symbol>
<symbol id="i-lock" viewBox="0 0 24 24"><rect x="4.5" y="10" width="15" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 8v4l3 2"/></symbol>
<symbol id="i-globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"/></symbol>
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h8"/></symbol>
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 9v4M12 17h.01"/><path d="M10.3 4 3 17a2 2 0 0 0 1.7 3h14.6a2 2 0 0 0 1.7-3L13.7 4a2 2 0 0 0-3.4 0z"/></symbol>
<symbol id="i-power" viewBox="0 0 24 24"><path d="M12 4v8M7.5 7a7 7 0 1 0 9 0"/></symbol>
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 4v11m0 0 4-4m-4 4-4-4M5 19h14"/></symbol>
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 20V9m0 0 4 4m-4-4-4 4M5 5h14"/></symbol>
<symbol id="i-dot" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.5" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-edit" viewBox="0 0 24 24"><path d="M4 20l1.2-4.2L16.6 4.4a2 2 0 0 1 2.9 2.9L8.2 18.8z"/></symbol>
<symbol id="i-enter" viewBox="0 0 24 24"><path d="M9 10 4 15l5 5"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></symbol>
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
<symbol id="i-dots" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none"/></symbol>
</svg>
<div id="root"></div>
</body>
</html>
+82 -40
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive — Google Drive for AI agents</title>
<meta name="description" content="Google Drive for AI agents: one synced folder for your whole team. Share any file with people as a public URL; share context across agents, so your agent knows what their agent knows. Open source and self-hostable.">
<meta name="description" content="Google Drive for AI agents: one synced folder for your whole team. Share any file as a public URL; give every agent the same memory, so your agent knows what their agent knows. Managed cloud, or self-host the open-source hub in one binary.">
<meta property="og:title" content="BearDrive — Google Drive for AI agents">
<meta property="og:description" content="One synced folder for humans and agents: files become public URLs for people, and shared memory for every teammate's agent. Open source, self-host in one binary.">
<meta property="og:type" content="website">
@@ -220,6 +220,35 @@ h2 { font-size: clamp(26px, 3.2vw, 34px); margin: 0 0 12px; letter-spacing: -.02
.claude ul li::before { content: "✓"; position: absolute; left: 0; color: var(--brand); font-weight: 600; }
.claude ul b { color: var(--text); font-weight: 600; }
/* auto-share reply mock */
.autoshare { text-align: center; }
.autoshare .lede { margin-left: auto; margin-right: auto; }
.reply {
max-width: 660px; margin: 6px auto 0; text-align: left;
background: #101010; border: 1px solid var(--border); border-radius: 10px;
box-shadow: 0 0 0 1px rgba(245,166,35,.05), 0 24px 64px rgba(0,0,0,.55);
overflow: hidden;
}
.reply-line {
padding: 24px 26px; font: 15px/1.7 var(--mono); color: var(--text);
display: flex; gap: 13px; align-items: baseline;
}
.reply-dot { color: var(--brand); font-size: 11px; line-height: 1.7; }
.reply-path { color: #6fbfff; word-break: break-all; }
.reply-link {
color: var(--brand); text-decoration: none; cursor: pointer;
padding: 1px 4px; border-radius: 5px; transition: background .15s;
}
.reply-link:hover { background: rgba(245,166,35,.16); }
.reply-cap {
border-top: 1px solid #1e1e1e; background: #0c0c0c;
padding: 13px 26px; font: 12.5px/1.6 var(--mono); color: var(--text-faint);
}
.reply-cap code {
font: 11.5px var(--mono); background: var(--card);
border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; color: var(--text-dim);
}
/* open source / cloud */
.oss { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
.oss-card {
@@ -235,14 +264,6 @@ h2 { font-size: clamp(26px, 3.2vw, 34px); margin: 0 0 12px; letter-spacing: -.02
.oss-card ul { list-style: none; padding: 0; margin: 0 0 26px; }
.oss-card li { padding: 6px 0 6px 24px; font-size: 14px; color: var(--text-dim); position: relative; }
.oss-card li::before { content: "✓"; position: absolute; left: 0; color: var(--brand); font-weight: 600; }
.waitlist { display: flex; gap: 10px; flex-wrap: wrap; }
.waitlist input {
flex: 1 1 190px; padding: 8px 13px; border-radius: 6px;
border: 1px solid var(--border-strong); background: var(--bg); color: var(--text);
font: 14px/1.4 inherit; outline: none;
}
.waitlist input:focus { border-color: var(--brand-dark); }
/* closing CTA */
.closer { text-align: center; padding: 96px 0; }
.closer h2 { font-size: clamp(28px, 4vw, 44px); margin-bottom: 10px; }
@@ -280,7 +301,8 @@ footer a:hover { color: var(--text); }
<a href="#claude">Claude Code</a>
<a href="#get">Open source</a>
<a href="https://github.com/runbear-io/beardrive" title="GitHub">GitHub</a>
<a class="btn" href="https://github.com/runbear-io/beardrive">Star on GitHub</a>
<a href="https://app.beardrive.ai/auth/login">Log in</a>
<a class="btn" href="https://app.beardrive.ai/auth/signup">Sign up free</a>
</div>
</div>
</nav>
@@ -291,9 +313,10 @@ footer a:hover { color: var(--text); }
<h1>Google Drive for AI agents.<span class="green">Your agent knows what their agent knows.</span></h1>
<p class="sub">One synced folder for your whole team — humans and agents.
<b>Share any file with people</b> as a public URL; <b>give every agent the same
memory</b>, synced across machines in seconds. Real files. Self-host the whole thing in one Go binary.</p>
memory</b>, synced across machines in seconds. Real files. <b>Use the managed cloud</b>, or self-host the whole thing in one Go binary.</p>
<div class="cta-row">
<a class="btn" href="#get">Start syncing</a>
<a class="btn" href="https://app.beardrive.ai/auth/signup">Start free →</a>
<a class="btn ghost" href="#get">Self-host it</a>
<button class="install" id="install" title="Copy">
<span class="dollar">$</span>
<span id="install-cmd">brew install runbear-io/tap/beardrive</span>
@@ -351,6 +374,25 @@ every tool works content-addressed blobs append-only, no locks, offl
</div>
</section>
<section id="autoshare" class="autoshare">
<div class="wrap">
<p class="kicker">SHARE WITHOUT A SHARE STEP</p>
<h2>The link is born with the file.</h2>
<p class="lede">The moment your agent writes a file, the path in its reply is
already a clickable, members-only hub link. You didn't run a command, you didn't
copy a path — any teammate who's signed in opens it in one click, rendered and at
the latest version. It's gated to your project, so it's safe to paste in any
internal channel.</p>
<div class="reply" aria-label="Example agent reply with an auto-generated hub link">
<div class="reply-line">
<span class="reply-dot"></span>
<span>Deal page written to <span class="reply-path">shared/wiki/deals/motive.md</span> <span class="reply-link" role="link" tabindex="0" title="Opens for signed-in teammates on the project">🔗</span></span>
</div>
<div class="reply-cap">↑ your agent's own reply — the <span class="reply-link" style="cursor:default">🔗</span> is a live hub link it added automatically. No <code>bdrive share</code>, no copy-paste, nobody asking you to resend it.</div>
</div>
</div>
</section>
<section id="why">
<div class="wrap">
<p class="kicker">WHY BEARDRIVE</p>
@@ -399,10 +441,16 @@ every tool works content-addressed blobs append-only, no locks, offl
which device — every past version viewable and downloadable. An agent
overwriting your doc is an annoyance, not a loss.</p>
</div>
<div class="card"><div class="ico">🔒</div>
<p><b>Gated links your agents hand you.</b> When Claude writes or updates a
file, it drops a link right beside the path — a hub link that only opens for
signed-in teammates on the project. Safe to paste in any internal channel;
the turn-start hook teaches every agent to do it automatically.</p>
</div>
<div class="card"><div class="ico">🔗</div>
<p><b>Public share links.</b> Any synced file becomes a rendered page at an
unguessable URL: HTML as a page, markdown Obsidian-style, PDFs inline.
Sandboxed, rate-limited, living until you revoke it.</p>
<p><b>Public share links.</b> Need it truly public? Any synced file becomes a
rendered page at an unguessable URL: HTML as a page, markdown Obsidian-style,
PDFs inline. Sandboxed, rate-limited, living until you revoke it.</p>
</div>
<div class="card"><div class="ico">👥</div>
<p><b>Teams &amp; organizations.</b> Projects belong to your org; only
@@ -427,17 +475,19 @@ every tool works content-addressed blobs append-only, no locks, offl
<section id="claude">
<div class="wrap claude">
<div class="copy">
<p class="kicker">CLAUDE CODE</p>
<p class="kicker">CLAUDE CODE &amp; COWORK</p>
<h2>Your agents become fluent in it.</h2>
<ul>
<li><b>/beardrive:install</b> sets a whole team project up
conversationally: CLI, sign-in, project, and sync hooks.</li>
conversationally: CLI, sign-in, project, and sync hooks — one plugin that
works the same in Claude Code and Claude Cowork.</li>
<li><b>Hooks keep everything fresh</b> — a pull when you submit a prompt
(Claude reads the team's latest files) and an async push after every edit
(artifacts hit the server seconds after Claude writes them). Works for
every teammate, plugin or not.</li>
<li><b>The skill teaches Claude the CLI</b>, so "share this report with
the team" just works.</li>
<li><b>Every file it writes comes with a link</b> — the turn-start hook
teaches Claude to drop a gated hub link beside any synced path it mentions,
so "share this report with the team" just works.</li>
</ul>
<a class="btn ghost" href="https://github.com/runbear-io/beardrive/tree/main/plugin">Explore the plugin →</a>
</div>
@@ -468,8 +518,8 @@ every tool works content-addressed blobs append-only, no locks, offl
<p class="kicker">GET BEARDRIVE</p>
<h2>Self-host everything. Or let us run it.</h2>
<p class="lede">The entire product is open source under AGPL-3.0 — server, teams,
history, share links, <b>nothing held back</b>. The cloud exists for teams who'd
rather never think about buckets.</p>
history, share links, <b>nothing held back</b>. Or skip the buckets entirely —
BearDrive Cloud is live at app.beardrive.ai.</p>
<div class="oss">
<div class="oss-card">
<h3>Open source</h3>
@@ -487,16 +537,16 @@ every tool works content-addressed blobs append-only, no locks, offl
</div>
<div class="oss-card cloud">
<h3>BearDrive Cloud</h3>
<div class="tag">beardrive.ai · managed hosting · coming soon</div>
<div class="tag">app.beardrive.ai · managed hosting · live now</div>
<ul>
<li>Zero setup — <code>bdrive login</code> and go</li>
<li>SSO, backups, and support handled for you</li>
<li>Zero setup — sign up, <code>bdrive login</code>, and go</li>
<li>Managed storage, backups, and support handled for you</li>
<li>Funds the open source project</li>
</ul>
<form class="waitlist" id="waitlist">
<input type="email" id="waitlist-email" placeholder="you@company.com" aria-label="Email" required>
<button class="btn" type="submit">Join the waitlist</button>
</form>
<div class="cta-row" style="justify-content:flex-start">
<a class="btn" href="https://app.beardrive.ai/auth/signup">Start free →</a>
<a class="btn ghost" href="https://app.beardrive.ai/auth/login">Log in</a>
</div>
</div>
</div>
</div>
@@ -504,10 +554,11 @@ every tool works content-addressed blobs append-only, no locks, offl
<section class="closer">
<div class="wrap">
<h2>Share files. Share context.<br><span class="green">Self-host in one binary.</span></h2>
<p>brew install, run one hub, bdrive init. Files travel to people as URLs; context travels to every agent on the team. Cloud — zero-setup — is on the waitlist.</p>
<h2>Share files. Share context.<br><span class="green">Cloud or self-hosted.</span></h2>
<p>Sign up for the managed cloud and go, or brew install and self-host in one binary. Files travel to people as URLs; context travels to every agent on the team.</p>
<div class="cta-row">
<a class="btn" href="https://github.com/runbear-io/beardrive">Start on GitHub</a>
<a class="btn" href="https://app.beardrive.ai/auth/signup">Start free →</a>
<a class="btn ghost" href="https://github.com/runbear-io/beardrive">Self-host on GitHub</a>
<button class="install" id="install2" title="Copy">
<span class="dollar">$</span>
<span id="install-cmd2">brew install runbear-io/tap/beardrive</span>
@@ -528,15 +579,6 @@ every tool works content-addressed blobs append-only, no locks, offl
</footer>
<script>
/* waitlist: no backend yet, so hand off to email */
document.getElementById("waitlist").addEventListener("submit", (e) => {
e.preventDefault();
const email = document.getElementById("waitlist-email").value;
location.href = "mailto:hello@runbear.io" +
"?subject=" + encodeURIComponent("BearDrive Cloud waitlist") +
"&body=" + encodeURIComponent("Please add " + email + " to the BearDrive Cloud waitlist.");
});
/* copy-to-clipboard on both install commands */
for (const [btn, cmd] of [["install", "install-cmd"], ["install2", "install-cmd2"]]) {
document.getElementById(btn).addEventListener("click", async () => {