mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(web): PropelAuth-style sidebar layout — brand header, project dropdown, account menu
- header is the brand only (BearDrive), linking home - Projects section is a dropdown (native select, styled) with the project mark, plus the project-settings gear beside it and + to create - sidebar footer is the account row (avatar, name, email) opening a popover: Organization → <org> Settings, Hub → Signup & access (admins), Account → Log out (danger red) - volume mode unchanged (sign-out stays in its header) - Playwright suite reworked for the new layout — 44/44 green; 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
77735660c9
commit
40a0b19aa4
@@ -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("#org-settings-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("#org-settings-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("#org-settings-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("#org-settings-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("#org-settings-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");
|
||||
|
||||
@@ -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("#org-settings-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("#org-settings-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");
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AppShell, Topbar, VaultHeader, closeSidebarOnMobile } from "../componen
|
||||
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 { EmptyState } from "../components/EmptyState";
|
||||
import { toast } from "../toast";
|
||||
@@ -72,11 +72,14 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
// 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 && !current}
|
||||
// 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("/")} />;
|
||||
|
||||
const accountBar = config.me ? (
|
||||
<AccountBar
|
||||
me={config.me}
|
||||
org={org}
|
||||
admin={
|
||||
isAdmin
|
||||
? {
|
||||
@@ -88,18 +91,12 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
projectSettings={
|
||||
current
|
||||
? {
|
||||
onClick: () => {
|
||||
setPanel({ kind: "project" });
|
||||
closeSidebarOnMobile();
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onOrgSettings={(o) => {
|
||||
setPanel({ kind: "org", orgId: o.id });
|
||||
closeSidebarOnMobile();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
) : undefined;
|
||||
|
||||
if (!projects || !orgs) {
|
||||
return (
|
||||
@@ -114,6 +111,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
<AppShell
|
||||
vault={vault}
|
||||
projectsNav={<ProjectNav projects={projects} />}
|
||||
orgBar={accountBar}
|
||||
topbar={<Topbar />}
|
||||
contentClass="view"
|
||||
>
|
||||
@@ -176,17 +174,17 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
canInsights={canInsights}
|
||||
sidebar={{
|
||||
vault,
|
||||
projectsNav: <ProjectNav projects={projects} currentId={current.id} />,
|
||||
orgBar: (
|
||||
<OrgBar
|
||||
org={org}
|
||||
showSignout={config.auth.enabled}
|
||||
onManage={(o) => {
|
||||
setPanel({ kind: "org", orgId: o.id });
|
||||
projectsNav: (
|
||||
<ProjectNav
|
||||
projects={projects}
|
||||
currentId={current.id}
|
||||
onOpenSettings={() => {
|
||||
setPanel({ kind: "project" });
|
||||
closeSidebarOnMobile();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
orgBar: accountBar,
|
||||
}}
|
||||
panel={activePanel}
|
||||
/>
|
||||
@@ -217,7 +215,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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { Org } from "../api/types";
|
||||
import { Icon } from "./shell";
|
||||
|
||||
// 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">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -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,15 @@ export function projColor(s: string): string {
|
||||
return PROJ_COLORS[h % PROJ_COLORS.length];
|
||||
}
|
||||
|
||||
export function ProjectNav({ projects, currentId }: { projects: Project[]; currentId?: string }) {
|
||||
export function ProjectNav({
|
||||
projects,
|
||||
currentId,
|
||||
onOpenSettings,
|
||||
}: {
|
||||
projects: Project[];
|
||||
currentId?: string;
|
||||
onOpenSettings?: () => void;
|
||||
}) {
|
||||
const refresh = useHubRefresh();
|
||||
|
||||
const create = async () => {
|
||||
@@ -39,33 +48,47 @@ 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>
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
id="project-settings-btn"
|
||||
className="icon-btn2"
|
||||
title="Project settings"
|
||||
aria-label="Project settings"
|
||||
onClick={onOpenSettings}
|
||||
>
|
||||
<Icon name="gear" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,11 +113,9 @@ export function AppShell(props: {
|
||||
export function VaultHeader(props: {
|
||||
name: string;
|
||||
onHome?: () => void; // hub: the project name doubles as a home link
|
||||
showSignout?: boolean; // volume mode: sign-out stays in the header (no org bar)
|
||||
admin?: { pending: number; onClick: () => void }; // hub admins only
|
||||
projectSettings?: { onClick: () => void }; // hub: settings for the open project
|
||||
showSignout?: boolean; // volume mode: sign-out stays in the header (no account bar)
|
||||
}) {
|
||||
const { name, onHome, showSignout, admin, projectSettings } = props;
|
||||
const { name, onHome, showSignout } = props;
|
||||
return (
|
||||
<header id="vault">
|
||||
<span id="vault-badge" aria-hidden="true">
|
||||
@@ -139,31 +137,6 @@ export function VaultHeader(props: {
|
||||
{name}
|
||||
</span>
|
||||
<div className="vault-actions">
|
||||
{admin && (
|
||||
<button
|
||||
id="adminbar"
|
||||
className="adminbar"
|
||||
title={
|
||||
"Hub administration — signup policy" +
|
||||
(admin.pending ? " and pending approvals" : "")
|
||||
}
|
||||
onClick={admin.onClick}
|
||||
>
|
||||
<Icon name="shield" />
|
||||
<span>Admin{admin.pending ? " · " + admin.pending : ""}</span>
|
||||
</button>
|
||||
)}
|
||||
{projectSettings && (
|
||||
<button
|
||||
id="project-settings-btn"
|
||||
className="icon-btn2"
|
||||
title="Project settings"
|
||||
aria-label="Project settings"
|
||||
onClick={projectSettings.onClick}
|
||||
>
|
||||
<Icon name="gear" />
|
||||
</button>
|
||||
)}
|
||||
{showSignout && (
|
||||
<a id="signout" href="/auth/logout" title="Sign out" aria-label="Sign out">
|
||||
<Icon name="power" />
|
||||
|
||||
@@ -78,20 +78,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,19 +136,58 @@ 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 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); }
|
||||
#orgbar { gap: 4px; }
|
||||
#orgbar #org-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#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;
|
||||
}
|
||||
#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; }
|
||||
@@ -429,9 +461,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; }
|
||||
#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%; }
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
-8
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<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-CGPKgglj.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DbihATmy.css">
|
||||
<script type="module" crossorigin src="/assets/index-lt5n9mM7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BWf7Qnlg.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user