mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
@@ -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 (0–10)** — 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'>🐻</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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 & 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 && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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%; }
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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
@@ -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'>🐻</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
@@ -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 & 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 & 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 () => {
|
||||
|
||||
Reference in New Issue
Block a user