mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(web): nav redesign — lucide icons; project settings in header, workspace gear + sign-out in org bar
Design feedback applied: - icons are lucide-react (lucide.dev) behind the existing <Icon name> API; the inline SVG sprite is gone - org bar (sidebar footer): the Manage text button is a gear icon opening the org admin panel; sign-out moved here, after the gear - header: the org-manage (people) button is removed; the header gear now opens a new per-PROJECT settings panel (identity facts + connect guide); sign-out stays in the header only when no project/org bar is present (volume mode, loading, empty state) Playwright suite updated and green (44/44); static/ rebuilt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbiaaVM2ACxeRi8ySG9ybc
This commit is contained in:
co-authored by
Claude Fable 5
parent
fce5cdb670
commit
77735660c9
@@ -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.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { login, wikiId, ADMIN, MEMBER } from "./helpers";
|
||||
|
||||
test("org admin: members with roles, self marked, rename round-trip", async ({ page }) => {
|
||||
await login(page);
|
||||
await page.click("#invite-btn"); // owner's Manage button
|
||||
await page.click("#org-settings-btn"); // owner's Manage button
|
||||
await expect(page.locator("#org-title")).toHaveText("default");
|
||||
await expect(page.locator("#crumb")).toHaveText("default");
|
||||
await expect(page.locator(".admin-item", { hasText: ADMIN })).toContainText("(you)");
|
||||
@@ -27,7 +27,7 @@ test("org admin: members with roles, self marked, rename round-trip", async ({ p
|
||||
|
||||
test("org admin: member role change round-trip", async ({ page }) => {
|
||||
await login(page);
|
||||
await page.click("#invite-btn");
|
||||
await page.click("#org-settings-btn");
|
||||
const sel = page.locator(".admin-item", { hasText: MEMBER }).locator("select");
|
||||
await sel.selectOption("owner");
|
||||
await expect(page.locator("#toast")).toContainText("Role updated");
|
||||
@@ -38,7 +38,7 @@ test("org admin: member role change round-trip", async ({ page }) => {
|
||||
|
||||
test("org admin: invite create shows in list, revoke removes it", async ({ page }) => {
|
||||
await login(page);
|
||||
await page.click("#invite-btn");
|
||||
await page.click("#org-settings-btn");
|
||||
await page.click(".admin-h .pbtn"); // New invite
|
||||
await expect(page.locator("#toast")).toContainText("Invite");
|
||||
const row = page.locator(".admin-item", { hasText: "/join/" }).first();
|
||||
@@ -54,7 +54,7 @@ test("org admin: public share audit lists and revokes", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.request.post(`/api/p/${pid}/shares`, { data: { path: "index.md" } });
|
||||
await page.click("#invite-btn");
|
||||
await page.click("#org-settings-btn");
|
||||
const row = page.locator(".admin-item", { hasText: "index.md" });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.locator(".ai-tag")).toContainText("wiki");
|
||||
@@ -68,7 +68,7 @@ test("org admin: project rename and delete", async ({ page }) => {
|
||||
await login(page);
|
||||
await page.request.post("/api/projects", { data: { name: "doomed" } });
|
||||
await page.reload(); // pick up the new project
|
||||
await page.click("#invite-btn");
|
||||
await page.click("#org-settings-btn");
|
||||
const row = page.locator(".admin-item", { hasText: "doomed" });
|
||||
await row.locator(".ai-btn", { hasText: "Rename" }).click();
|
||||
await page.fill(".modal-input", "doomed-2");
|
||||
|
||||
@@ -33,7 +33,7 @@ test("admin sees admin bar and org Manage; member does not", async ({ page, brow
|
||||
await login(page); // admin, owner of "default"
|
||||
await expect(page.locator("#adminbar")).toBeVisible();
|
||||
await expect(page.locator("#orgbar #org-name")).toHaveText("default");
|
||||
await expect(page.locator("#invite-btn")).toBeVisible();
|
||||
await expect(page.locator("#org-settings-btn")).toBeVisible();
|
||||
await expect(page.locator("#signout")).toBeVisible();
|
||||
|
||||
const ctx = await browser.newContext();
|
||||
@@ -41,7 +41,7 @@ test("admin sees admin bar and org Manage; member does not", async ({ page, brow
|
||||
await login(p2, MEMBER);
|
||||
await expect(p2.locator("#orgbar #org-name")).toHaveText("default");
|
||||
await expect(p2.locator("#adminbar")).toHaveCount(0);
|
||||
await expect(p2.locator("#invite-btn")).toHaveCount(0);
|
||||
await expect(p2.locator("#org-settings-btn")).toHaveCount(0);
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import { OrgAdmin } from "../components/OrgAdmin";
|
||||
import { HubSettings } from "../components/HubSettings";
|
||||
import { ProjectNav } from "../components/ProjectNav";
|
||||
import { OrgBar } from "../components/OrgBar";
|
||||
import { ProjectSettings } from "../components/ProjectSettings";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { toast } from "../toast";
|
||||
import Browser from "./Browser";
|
||||
@@ -21,7 +22,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
const [joinedOrgId, setJoinedOrgId] = useState<string | null>(null);
|
||||
// Admin panels replace the content pane without touching the URL (they
|
||||
// were never routes in the classic app); any navigation closes them.
|
||||
const [panel, setPanel] = useState<null | { kind: "hub" } | { kind: "org"; orgId: string }>(null);
|
||||
const [panel, setPanel] = useState<null | { kind: "hub" } | { kind: "org"; orgId: string } | { kind: "project" }>(null);
|
||||
useEffect(() => setPanel(null), [pathname]);
|
||||
|
||||
const joinToken = useMemo(() => {
|
||||
@@ -67,11 +68,6 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
|
||||
const brand = config.brand || config.volume || "BearDrive";
|
||||
const org = (current && orgs?.find((o) => o.id === current.org)) || null;
|
||||
const ownedOrg = orgs?.find((o) => o.role === "owner") || null;
|
||||
// The top-of-sidebar gear is the always-visible admin entry point: any
|
||||
// account that owns an org (or is a hub admin) gets it, whatever project
|
||||
// is open. The panels it opens arrive in Phase 4.
|
||||
const gearTarget = org && org.role === "owner" ? org : ownedOrg;
|
||||
// Insights (embedded on the project home and behind the ⋯ menu) is for
|
||||
// hub admins and owners of the project's org.
|
||||
const canInsights = isAdmin || (org ? org.role === "owner" : false);
|
||||
@@ -80,7 +76,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
<VaultHeader
|
||||
name={projects ? (current ? current.name : brand) : "…"}
|
||||
onHome={current ? () => navigate("/" + current.id) : undefined}
|
||||
showSignout={config.auth.enabled}
|
||||
showSignout={config.auth.enabled && !current}
|
||||
admin={
|
||||
isAdmin
|
||||
? {
|
||||
@@ -92,11 +88,11 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
gear={
|
||||
gearTarget
|
||||
projectSettings={
|
||||
current
|
||||
? {
|
||||
onClick: () => {
|
||||
setPanel({ kind: "org", orgId: gearTarget.id });
|
||||
setPanel({ kind: "project" });
|
||||
closeSidebarOnMobile();
|
||||
},
|
||||
}
|
||||
@@ -146,7 +142,9 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
const activePanel =
|
||||
panel?.kind === "hub"
|
||||
? { crumb: "Signup & access", body: <HubSettings /> }
|
||||
: panelOrg
|
||||
: panel?.kind === "project"
|
||||
? { crumb: "Project settings", body: <ProjectSettings project={current} org={org} /> }
|
||||
: panelOrg
|
||||
? {
|
||||
crumb: panelOrg.name,
|
||||
body: (
|
||||
@@ -182,6 +180,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
orgBar: (
|
||||
<OrgBar
|
||||
org={org}
|
||||
showSignout={config.auth.enabled}
|
||||
onManage={(o) => {
|
||||
setPanel({ kind: "org", orgId: o.id });
|
||||
closeSidebarOnMobile();
|
||||
|
||||
@@ -1,32 +1,54 @@
|
||||
import type { Org } from "../api/types";
|
||||
import { Icon } from "./shell";
|
||||
|
||||
// The sidebar footer names the project's org; clicking it opens the org
|
||||
// admin panel, and owners get a Manage button that does the same. The
|
||||
// panel itself arrives with the admin surfaces (Phase 4).
|
||||
export function OrgBar({ org, onManage }: { org: Org | null; onManage: (org: Org) => void }) {
|
||||
if (!org) return null;
|
||||
// The sidebar footer is the workspace (org) row: the org name, a gear that
|
||||
// opens the org admin panel (owners), and sign-out. Project-scoped actions
|
||||
// live in the header; workspace-scoped ones end here.
|
||||
export function OrgBar({
|
||||
org,
|
||||
onManage,
|
||||
showSignout,
|
||||
}: {
|
||||
org: Org | null;
|
||||
onManage: (org: Org) => void;
|
||||
showSignout?: boolean;
|
||||
}) {
|
||||
if (!org && !showSignout) return null;
|
||||
return (
|
||||
<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
|
||||
{org && (
|
||||
<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 && org.role === "owner" && (
|
||||
<button
|
||||
id="org-settings-btn"
|
||||
className="icon-btn2"
|
||||
title="Manage this organization"
|
||||
aria-label="Manage this organization"
|
||||
onClick={() => onManage(org)}
|
||||
>
|
||||
<Icon name="gear" />
|
||||
</button>
|
||||
)}
|
||||
{showSignout && (
|
||||
<a id="signout" href="/auth/logout" title="Sign out" aria-label="Sign out">
|
||||
<Icon name="power" />
|
||||
</a>
|
||||
)}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Org, Project } from "../api/types";
|
||||
import { ConnectGuide } from "./ConnectGuide";
|
||||
|
||||
// Settings for the open project (the header gear). Today: identity facts
|
||||
// plus the connect guide; per-project knobs land here as they grow.
|
||||
export function ProjectSettings({ project, org }: { project: Project; org: Org | null }) {
|
||||
return (
|
||||
<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>
|
||||
<h3>Connect a device</h3>
|
||||
<ConnectGuide project={project} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,32 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Copy,
|
||||
Download,
|
||||
Ellipsis,
|
||||
FileText,
|
||||
Folder,
|
||||
Globe,
|
||||
History,
|
||||
Link,
|
||||
Lock,
|
||||
LogOut,
|
||||
Menu,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Share2,
|
||||
Shield,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
Upload,
|
||||
Users,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
// The app's fixed layout: off-canvas sidebar (mobile: body.sb-open toggles
|
||||
// it), topbar, and the content pane. Ids and classes match the classic app
|
||||
@@ -11,12 +39,40 @@ export function closeSidebarOnMobile() {
|
||||
document.body.classList.remove("sb-open");
|
||||
}
|
||||
|
||||
// Icons are lucide (lucide.dev) components behind the historical sprite
|
||||
// names, so call sites keep the tiny `<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,
|
||||
gear: Settings,
|
||||
globe: Globe,
|
||||
hist: History,
|
||||
link: Link,
|
||||
lock: Lock,
|
||||
menu: Menu,
|
||||
plus: Plus,
|
||||
power: LogOut,
|
||||
search: Search,
|
||||
share: Share2,
|
||||
shield: Shield,
|
||||
trash: Trash2,
|
||||
upload: Upload,
|
||||
users: Users,
|
||||
x: X,
|
||||
};
|
||||
|
||||
export function Icon({ name }: { name: string }) {
|
||||
return (
|
||||
<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 +113,11 @@ export function AppShell(props: {
|
||||
export function VaultHeader(props: {
|
||||
name: string;
|
||||
onHome?: () => void; // hub: the project name doubles as a home link
|
||||
showSignout: boolean;
|
||||
showSignout?: boolean; // volume mode: sign-out stays in the header (no org bar)
|
||||
admin?: { pending: number; onClick: () => void }; // hub admins only
|
||||
gear?: { onClick: () => void }; // org owners: manage organization
|
||||
projectSettings?: { onClick: () => void }; // hub: settings for the open project
|
||||
}) {
|
||||
const { name, onHome, showSignout, admin, gear } = props;
|
||||
const { name, onHome, showSignout, admin, projectSettings } = props;
|
||||
return (
|
||||
<header id="vault">
|
||||
<span id="vault-badge" aria-hidden="true">
|
||||
@@ -97,15 +153,15 @@ export function VaultHeader(props: {
|
||||
<span>Admin{admin.pending ? " · " + admin.pending : ""}</span>
|
||||
</button>
|
||||
)}
|
||||
{gear && (
|
||||
{projectSettings && (
|
||||
<button
|
||||
id="settings-btn"
|
||||
id="project-settings-btn"
|
||||
className="icon-btn2"
|
||||
title="Manage organization"
|
||||
aria-label="Manage organization"
|
||||
onClick={gear.onClick}
|
||||
title="Project settings"
|
||||
aria-label="Project settings"
|
||||
onClick={projectSettings.onClick}
|
||||
>
|
||||
<Icon name="users" />
|
||||
<Icon name="gear" />
|
||||
</button>
|
||||
)}
|
||||
{showSignout && (
|
||||
|
||||
@@ -154,12 +154,8 @@ button, input, a.btn { font-family: inherit; }
|
||||
background: linear-gradient(160deg, #ffcf85, #d3861a); opacity: .85;
|
||||
}
|
||||
#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;
|
||||
}
|
||||
#invite-btn:hover { background: var(--hover); color: var(--text); border-color: var(--border-2); }
|
||||
#orgbar { gap: 4px; }
|
||||
#orgbar #org-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ---- main pane ---- */
|
||||
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
@@ -435,7 +431,6 @@ button, input, a.btn { font-family: inherit; }
|
||||
#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; }
|
||||
.nav-add { min-width: 44px; min-height: 44px; }
|
||||
.markdown, .admin, .onboard, .history, .dirlist { max-width: 100%; }
|
||||
|
||||
@@ -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-CGPKgglj.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DbihATmy.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>
|
||||
|
||||
Reference in New Issue
Block a user