feat(webapp): [phase 1] shell, session, projects, routing

- URL as source of truth: parseRoute/urlForPath/urlForView ported verbatim
  (src/router.ts), single catch-all route so encoded slashes survive
- hub shell: project nav with color chips, org bar, admin bar with pending
  count, sign-out per session flags; volume shell renders too
- empty-state onboarding (invite paste + create project), /join/<token>
  invite accept that survives the login redirect
- toast + modal prompt/confirm primitives (imperative promise API over a
  React host, matching the classic behavior)
- e2e: 7 new hub specs (11 total green); harness gains a no-org 'solo'
  account to reach the empty state

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
This commit is contained in:
Snow Lee
2026-07-13 10:07:30 -07:00
co-authored by Claude Fable 5
parent e701555257
commit 1c4e178b39
19 changed files with 947 additions and 46 deletions
+15 -6
View File
@@ -121,11 +121,20 @@ refresh; invalidate after uploads/renames/admin actions — mirror today's
- [x] `go build ./...`, `go vet ./...`, `go test ./...` green.
### Phase 1 — shell: boot, session, projects, routing
- [ ] Boot from `/api/config`; volume vs hub mode both render.
- [ ] Project list + selection; project color chips (port `projColor`).
- [ ] Empty state + create project; `/join/<token>` invite accept.
- [ ] Deep link + refresh on every route resolves (SPA fallback).
- [ ] Sign-out link, admin bar, org bar render per session flags.
- [x] Boot from `/api/config`; volume vs hub mode both render (hub via the
e2e suite; volume verified against a live `bdrive web <dir>`).
- [x] Project list + selection; project color chips (port `projColor`).
- [x] Empty state + create project; `/join/<token>` invite accept (token
survives the login redirect — covered by e2e).
- [x] Deep link + refresh on every route resolves (SPA fallback); unknown
project ids fall back to a real project.
- [x] Sign-out link, admin bar (with pending count), org bar render per
session flags (admin vs member covered by e2e).
Architecture note: the URL is the source of truth — `parseRoute`
ported verbatim into `src/router.ts`, a single catch-all route, no
route-matching library (encoded slashes must survive). Mutations
must `await useHubRefresh()` before navigating to a new project id,
or the unknown-id fallback bounces off the stale list.
### Phase 2 — file browsing (long pole)
- [ ] Tree with expansion persistence, active marking, reveal-in-tree.
@@ -216,7 +225,7 @@ file path; reload on `/insights`.
## Status
- [x] Phase 0
- [ ] Phase 1
- [x] Phase 1
- [ ] Phase 2
- [ ] Phase 3
- [ ] Phase 4
+6
View File
@@ -28,6 +28,7 @@ const (
e2eAddr = "0.0.0.0:8993"
e2eAdmin = "e2e@example.com"
e2eMember = "member@example.com"
e2eSolo = "solo@example.com"
e2ePassword = "e2e-pass-1"
)
@@ -76,6 +77,11 @@ func TestE2EServe(t *testing.T) {
if _, err := auth.signup(e2eMember, "E2E Member", e2ePassword); err != nil {
t.Fatal(err)
}
// In no org: sees the onboarding empty state (and creating a project
// from it mints a fresh org via orgForCreate).
if _, err := auth.signup(e2eSolo, "E2E Solo", e2ePassword); err != nil {
t.Fatal(err)
}
auth.Admins = map[string]bool{e2eAdmin: true}
srv.Auth = auth
+98
View File
@@ -0,0 +1,98 @@
import { test, expect, Page } from "@playwright/test";
import { login, MEMBER, PASSWORD } from "./helpers";
// Phase 1: shell, session flags, project list/selection, routing, empty
// state, invite accept. Mutating specs (project creation) run last —
// specs share one seeded hub per run.
async function wikiId(page: Page): Promise<string> {
const out = await (await page.request.get("/api/projects")).json();
return out.projects.find((p: { name: string }) => p.name === "wiki").id;
}
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).toHaveTitle("wiki — BearDrive");
await expect(page.locator("#projects .row.active .label")).toHaveText("wiki");
});
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).toHaveURL("/" + pid);
});
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("…");
});
test("admin sees admin bar and org Manage; 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 expect(page.locator("#signout")).toBeVisible();
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 ctx.close();
});
test("join link accepts an invite after sign-in", async ({ page, browser }) => {
await login(page); // admin mints the invite
const orgs = await (await page.request.get("/api/orgs")).json();
const org = orgs.orgs.find((o: { name: string }) => o.name === "default");
const inv = await (
await page.request.post(`/api/orgs/${org.id}/invites`, { data: {} })
).json();
expect(inv.url).toContain("/join/");
const token = inv.url.split("/join/")[1];
// A signed-out visitor keeps the token through the login redirect.
const ctx = await browser.newContext();
const p2 = await ctx.newPage();
await p2.goto("/join/" + token);
await p2.waitForURL(/auth\/login/);
await p2.fill('input[name="email"]', MEMBER);
await p2.fill('input[name="password"]', PASSWORD);
await p2.click("form button");
await p2.waitForSelector("#toast.show");
await expect(p2.locator("#toast")).toContainText("you joined");
await p2.waitForURL(/\/p-[0-9a-f]{8}$/); // lands on the org's project
await ctx.close();
});
test("no-org account gets the onboarding empty state and can create a project", async ({
page,
}) => {
await login(page, "solo@example.com");
await expect(page.locator(".onboard h1")).toHaveText("Welcome to BearDrive");
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
});
test("new project via the sidebar + modal", async ({ page }) => {
await login(page);
await page.click("#projects .nav-add");
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("#toast")).toContainText("Created");
});
+1 -1
View File
@@ -9,7 +9,7 @@ test("unauthenticated visit redirects to the login page", async ({ page }) => {
test("login lands in the app shell", async ({ page }) => {
await login(page);
await expect(page).toHaveTitle("BearDrive");
await expect(page).toHaveTitle(/BearDrive/); // "<project> — BearDrive" in hub mode
await expect(page.locator("#sidebar")).toBeVisible();
await expect(page.locator("#topbar")).toBeVisible();
});
+16 -27
View File
@@ -1,37 +1,26 @@
import { useEffect } from "react";
import { useConfig } from "./hooks/useConfig";
import { AppShell, Topbar, VaultHeader } from "./components/shell";
import { Toaster } from "./toast";
import { ModalHost } from "./modal";
import HubApp from "./apps/HubApp";
import VolumeApp from "./apps/VolumeApp";
// Phase 0 shell: boots /api/config (redirecting to /auth/login when there
// is no session) and renders the static layout so the ported stylesheet can
// be verified. Routing and data views arrive in Phase 1.
export default function App() {
const { data: config } = useConfig();
useEffect(() => {
if (config) document.title = config.brand || config.volume || "BearDrive";
}, [config]);
return (
<>
<div id="sb-backdrop" />
<aside id="sidebar">
<header id="vault">
<span id="vault-badge" aria-hidden="true">
🐻
</span>
<span id="vault-name">{config ? config.brand || config.volume || "BearDrive" : "…"}</span>
</header>
<nav id="tree" aria-label="Files" />
</aside>
<main id="main">
<header id="topbar">
<span id="crumb" />
<span id="meta" />
</header>
<article id="content" className="markdown">
<div className="empty">{config ? "Select a file to read it." : "Loading…"}</div>
</article>
</main>
{!config ? (
<AppShell vault={<VaultHeader name="…" showSignout={false} />} topbar={<Topbar />}>
<div className="empty">Loading</div>
</AppShell>
) : config.mode === "hub" ? (
<HubApp config={config} />
) : (
<VolumeApp config={config} />
)}
<Toaster />
<ModalHost />
</>
);
}
+35
View File
@@ -30,3 +30,38 @@ export interface Project {
export interface ProjectList {
projects: Project[];
}
// POST /api/projects (handleProjectCreate) — create-or-join by name.
export interface ProjectCreated {
project: Project;
created: boolean;
}
// GET /api/orgs (handleOrgList, orgs.go)
export interface OrgMember {
email: string;
role: string; // "owner" | "member"
}
export interface Org {
id: string;
name: string;
role: string; // the signed-in account's role in this org
members: OrgMember[];
created?: string;
}
export interface OrgList {
orgs: Org[];
}
// POST /api/invites/{token} (handleInviteAccept, orgs.go)
export interface InviteAccepted {
ok: boolean;
org: { id: string; name: string };
}
// GET /api/admin/pending (handleAdminPending, admin.go)
export interface PendingList {
pending: Array<{ id: string; email: string; name: string }>;
}
@@ -0,0 +1,168 @@
import { useEffect, useMemo, useState } from "react";
import { Navigate, useLocation, useNavigate } from "react-router-dom";
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 { AppShell, Topbar, VaultHeader } from "../components/shell";
import { ProjectNav } from "../components/ProjectNav";
import { OrgBar } from "../components/OrgBar";
import { EmptyState } from "../components/EmptyState";
import { toast } from "../toast";
export default function HubApp({ config }: { config: ServerConfig }) {
const location = useLocation();
const navigate = useNavigate();
const refresh = useHubRefresh();
// Org just joined via an invite this page-load: prefer its projects over
// whatever happens to be first in the list.
const [joinedOrgId, setJoinedOrgId] = useState<string | null>(null);
const joinToken = useMemo(() => {
const m = location.pathname.match(/^\/join\/([0-9a-f]+)\/?$/);
return m ? m[1] : null;
}, [location.pathname]);
const { data: projects } = useProjects(!joinToken);
const { data: orgs } = useOrgs(!joinToken);
const isAdmin = !!config.auth.admin;
const { data: pending } = usePending(isAdmin);
const route = useMemo(
() => parseRoute(location.pathname, "hub"),
[location.pathname],
);
const current: Project | null = useMemo(() => {
if (!projects) return null;
return (
projects.find((p) => p.id === route.project) ||
(joinedOrgId && projects.find((p) => p.org === joinedOrgId)) ||
projects[0] ||
null
);
}, [projects, route.project, joinedOrgId]);
useEffect(() => {
document.title = current
? current.name + " — BearDrive"
: config.brand || config.volume || "BearDrive";
}, [current, config]);
if (joinToken) {
return (
<JoinInvite
token={joinToken}
onDone={async (orgId) => {
setJoinedOrgId(orgId);
await refresh();
navigate("/", { replace: true });
}}
/>
);
}
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;
const vault = (
<VaultHeader
name={projects ? (current ? current.name : brand) : "…"}
onHome={current ? () => navigate("/" + current.id) : undefined}
showSignout={config.auth.enabled}
admin={isAdmin ? { pending: pending?.length || 0, onClick: () => {} } : undefined}
gear={gearTarget ? { onClick: () => {} } : undefined}
/>
);
if (!projects || !orgs) {
return (
<AppShell vault={vault} topbar={<Topbar />}>
<div className="empty">Loading</div>
</AppShell>
);
}
if (!current) {
return (
<AppShell
vault={vault}
projectsNav={<ProjectNav projects={projects} />}
topbar={<Topbar />}
contentClass="view"
>
<EmptyState
authEnabled={config.auth.enabled}
onCreate={async (name) => {
if (!name) {
toast("Give the project a name.", true);
return;
}
try {
const out = await postJSON<ProjectCreated>("/api/projects", { name });
await refresh();
navigate("/" + out.project.id);
toast(`Created “${out.project.name}”.`);
} catch (e) {
toast("Could not create the project: " + (e as Error).message, true);
}
}}
/>
</AppShell>
);
}
// 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) {
return <Navigate to={"/" + current.id} replace />;
}
return (
<AppShell
vault={vault}
projectsNav={<ProjectNav projects={projects} currentId={current.id} />}
orgBar={<OrgBar org={org} onManage={() => {}} />}
topbar={<Topbar />}
>
{/* Content views (project home, files, insights, history) arrive in
Phases 23. */}
<div className="empty">Select a file to read it.</div>
</AppShell>
);
}
/* Opening "/join/<token>" joins the invite's org. If the visitor isn't
signed in yet, the 401 handler sends them to /auth/login with the /join
path intact in `next`, so after signing in the server re-serves the app
here and the join completes — the token is never lost. */
function JoinInvite({ token, onDone }: { token: string; onDone: (orgId: string | null) => void }) {
useEffect(() => {
let cancelled = false;
postJSON<InviteAccepted>("/api/invites/" + token)
.then((out) => {
if (cancelled) return;
toast(`Welcome — you joined the “${out.org.name}” team. Opening its projects…`);
onDone(out.org.id);
})
.catch((e) => {
if (cancelled || String((e as Error).message).includes("signing in")) return;
toast("Could not accept the invite: " + (e as Error).message, true);
onDone(null);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
return (
<AppShell vault={<VaultHeader name="…" showSignout />} topbar={<Topbar />}>
<div className="empty">Joining</div>
</AppShell>
);
}
@@ -0,0 +1,21 @@
import { useEffect } from "react";
import type { ServerConfig } from "../api/types";
import { AppShell, Topbar, VaultHeader } from "../components/shell";
// Single-volume mode: one folder, no projects or orgs. File browsing
// arrives in Phase 2.
export default function VolumeApp({ config }: { config: ServerConfig }) {
const name = config.volume || "BearDrive";
useEffect(() => {
document.title = config.brand || name;
}, [config, name]);
return (
<AppShell
vault={<VaultHeader name={name} showSignout={config.auth.enabled} />}
topbar={<Topbar />}
>
<div className="empty">Select a file to read it.</div>
</AppShell>
);
}
@@ -0,0 +1,55 @@
import { useRef } from "react";
import { toast } from "../toast";
// Onboarding: a signed-in account with no projects shouldn't hit a blank
// sidebar. Explain that access comes from an invite, let them paste one,
// and — since any member can — offer to start a new project.
export function EmptyState({
authEnabled,
onCreate,
}: {
authEnabled: boolean;
onCreate: (name: string) => void;
}) {
const invite = useRef<HTMLInputElement>(null);
const name = useRef<HTMLInputElement>(null);
const join = () => {
const v = invite.current!.value.trim();
const m = v.match(/join\/([0-9a-f]+)/) || v.match(/^([0-9a-f]{8,})$/);
if (!m) {
toast("That doesn't look like an invite link.", true);
return;
}
location.href = "/join/" + m[1];
};
return (
<div className="onboard">
<h1>Welcome to BearDrive</h1>
<p>You're signed in, but you're not part of any project yet.</p>
{authEnabled && (
<div className="ob-card">
<h3>Have an invite link?</h3>
<p>A teammate can send you a join link. Paste it here:</p>
<div className="ob-row">
<input id="ob-invite" type="text" placeholder="https://…/join/…" autoComplete="off" ref={invite} />
<button id="ob-join" className="pbtn" onClick={join}>
Join
</button>
</div>
</div>
)}
<div className="ob-card">
<h3>Or start a new project</h3>
<p>Create a shared space for your team's files.</p>
<div className="ob-row">
<input id="ob-name" type="text" placeholder="Project name, e.g. wiki" autoComplete="off" ref={name} />
<button id="ob-create" className="pbtn" onClick={() => onCreate(name.current!.value.trim())}>
Create
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,32 @@
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>
);
}
@@ -0,0 +1,72 @@
import { useNavigate } from "react-router-dom";
import { postJSON } from "../api/http";
import type { Project, ProjectCreated } from "../api/types";
import { modalPrompt } from "../modal";
import { toast } from "../toast";
import { useHubRefresh } from "../hooks/useHub";
import { closeSidebarOnMobile } from "./shell";
// Deterministic accent for a project's letter-mark, so each project keeps a
// stable color across reloads without any server state.
const PROJ_COLORS = ["#5b8def", "#f5a623", "#4cc38a", "#e0679b", "#8b7bf0", "#3ec8c8", "#e6934a"];
export function projColor(s: string): string {
let h = 0;
for (const c of s) h = (h * 31 + c.charCodeAt(0)) >>> 0;
return PROJ_COLORS[h % PROJ_COLORS.length];
}
export function ProjectNav({ projects, currentId }: { projects: Project[]; currentId?: string }) {
const navigate = useNavigate();
const refresh = useHubRefresh();
const create = async () => {
const name = await modalPrompt("New project", "Project name", "", "Create");
if (!name) return;
try {
const out = await postJSON<ProjectCreated>("/api/projects", { name });
await refresh();
navigate("/" + out.project.id);
toast(`Created “${out.project.name}”.`);
} catch (e) {
toast("Could not create the project: " + (e as Error).message, true);
}
};
return (
<nav id="projects" aria-label="Projects">
<div className="nav-head">
<span>Projects</span>
<button className="nav-add" title="New project" onClick={create}>
+
</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);
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>
</nav>
);
}
@@ -0,0 +1,125 @@
import type { ReactNode } from "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
// so style.css applies unchanged.
export function toggleSidebar() {
document.body.classList.toggle("sb-open");
}
export function closeSidebarOnMobile() {
document.body.classList.remove("sb-open");
}
export function Icon({ name }: { name: string }) {
return (
<svg className="ico" aria-hidden="true">
<use href={`#i-${name}`} />
</svg>
);
}
export function AppShell(props: {
vault: ReactNode;
projectsNav?: ReactNode;
tree?: ReactNode;
orgBar?: ReactNode;
topbar: ReactNode;
contentClass?: string;
children: ReactNode;
}) {
return (
<>
<div id="sb-backdrop" onClick={closeSidebarOnMobile} />
<aside id="sidebar">
{props.vault}
{props.projectsNav}
{props.tree ?? <nav id="tree" aria-label="Files" />}
{props.orgBar}
</aside>
<main id="main">
{props.topbar}
<article id="content" className={props.contentClass ?? "markdown"}>
{props.children}
</article>
</main>
</>
);
}
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
}) {
const { name, onHome, showSignout, admin, gear } = props;
return (
<header id="vault">
<span id="vault-badge" aria-hidden="true">
🐻
</span>
<span
id="vault-name"
className={onHome ? "vault-link" : undefined}
onClick={onHome}
role={onHome ? "button" : undefined}
tabIndex={onHome ? 0 : undefined}
onKeyDown={(e) => {
if (onHome && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
onHome();
}
}}
>
{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>
)}
{gear && (
<button
id="settings-btn"
className="icon-btn2"
title="Manage organization"
aria-label="Manage organization"
onClick={gear.onClick}
>
<Icon name="users" />
</button>
)}
{showSignout && (
<a id="signout" href="/auth/logout" title="Sign out" aria-label="Sign out">
<Icon name="power" />
</a>
)}
</div>
</header>
);
}
export function Topbar(props: { crumb?: ReactNode; meta?: ReactNode; actions?: ReactNode }) {
return (
<header id="topbar">
<button id="menu-btn" className="icon-btn" title="Menu" aria-label="Menu" onClick={toggleSidebar}>
<Icon name="menu" />
</button>
<span id="crumb">{props.crumb}</span>
<span id="meta">{props.meta}</span>
{props.actions}
</header>
);
}
@@ -0,0 +1,49 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getJSON } from "../api/http";
import type { OrgList, PendingList, ProjectList } from "../api/types";
// Hub-wide server state: the project list (polled — new projects appear
// without a reload, matching the classic app's 30s refresh) and the orgs
// the signed-in account belongs to.
export function useProjects(enabled: boolean) {
return useQuery({
queryKey: ["projects"],
queryFn: () => getJSON<ProjectList>("/api/projects"),
enabled,
refetchInterval: 30_000,
select: (d) => d.projects || [],
});
}
export function useOrgs(enabled: boolean) {
return useQuery({
queryKey: ["orgs"],
queryFn: () => getJSON<OrgList>("/api/orgs"),
enabled,
select: (d) => d.orgs || [],
});
}
// Pending signups; only fetched for hub admins (the admin bar shows the
// count).
export function usePending(enabled: boolean) {
return useQuery({
queryKey: ["admin", "pending"],
queryFn: () => getJSON<PendingList>("/api/admin/pending"),
enabled,
select: (d) => d.pending || [],
});
}
// Resolves once the refetches land — await it before navigating to a
// just-created project, or the router's unknown-id fallback will bounce
// off the stale list.
export function useHubRefresh() {
const qc = useQueryClient();
return () =>
Promise.all([
qc.invalidateQueries({ queryKey: ["projects"] }),
qc.invalidateQueries({ queryKey: ["orgs"] }),
]).then(() => {});
}
+141
View File
@@ -0,0 +1,141 @@
import { useEffect, useRef, useSyncExternalStore } from "react";
// In-app modal prompt/confirm replacing native prompt()/confirm().
// Imperative promise-based API (awaitable from event handlers, like the
// classic app); <ModalHost/> (mounted once in App) renders the active one.
type Prompt = {
kind: "prompt";
title: string;
label: string;
value: string;
okLabel: string;
resolve: (v: string | null) => void;
};
type Confirm = {
kind: "confirm";
title: string;
message: string;
confirmLabel: string;
danger: boolean;
resolve: (v: boolean) => void;
};
type Modal = Prompt | Confirm;
let current: Modal | null = null;
let listeners: Array<() => void> = [];
function emit(next: Modal | null) {
current = next;
listeners.forEach((l) => l());
}
export function modalPrompt(
title: string,
label: string,
value = "",
okLabel = "OK",
): Promise<string | null> {
return new Promise((resolve) =>
emit({ kind: "prompt", title, label, value, okLabel, resolve }),
);
}
export function modalConfirm(
title: string,
message: string,
confirmLabel = "Confirm",
danger = false,
): Promise<boolean> {
return new Promise((resolve) =>
emit({ kind: "confirm", title, message, confirmLabel, danger, resolve }),
);
}
export function ModalHost() {
const m = useSyncExternalStore(
(l) => {
listeners.push(l);
return () => {
listeners = listeners.filter((x) => x !== l);
};
},
() => current,
);
if (!m) return null;
return m.kind === "prompt" ? <PromptModal m={m} /> : <ConfirmModal m={m} />;
}
function close() {
emit(null);
}
function PromptModal({ m }: { m: Prompt }) {
const input = useRef<HTMLInputElement>(null);
const done = (v: string | null) => {
close();
m.resolve(v);
};
const ok = () => done(input.current!.value.trim() || null);
useEffect(() => {
input.current!.focus();
input.current!.select();
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") done(null);
if (e.key === "Enter") ok();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="modal-back" onClick={(e) => e.target === e.currentTarget && done(null)}>
<div className="modal">
<h3>{m.title}</h3>
<label className="modal-label">{m.label}</label>
<input className="modal-input" type="text" autoComplete="off" defaultValue={m.value} ref={input} />
<div className="modal-actions">
<button className="ai-btn" onClick={() => done(null)}>
Cancel
</button>
<button className="pbtn" onClick={ok}>
{m.okLabel}
</button>
</div>
</div>
</div>
);
}
function ConfirmModal({ m }: { m: Confirm }) {
const okBtn = useRef<HTMLButtonElement>(null);
const done = (v: boolean) => {
close();
m.resolve(v);
};
useEffect(() => {
okBtn.current!.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") done(false);
if (e.key === "Enter") done(true);
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="modal-back" onClick={(e) => e.target === e.currentTarget && done(false)}>
<div className="modal">
<h3>{m.title}</h3>
<p className="modal-msg">{m.message}</p>
<div className="modal-actions">
<button className="ai-btn" onClick={() => done(false)}>
Cancel
</button>
<button className={m.danger ? "danger-btn" : "pbtn"} onClick={() => done(true)} ref={okBtn}>
{m.confirmLabel}
</button>
</div>
</div>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
// Native path routing (no hash, no %2F):
// volume mode: /<path>
// hub mode: /<project-id>/<path>
// invite: /join/<token>
// Each path segment is percent-encoded for odd characters, but the "/"
// separators stay literal so the URL reads like a real file path. This is
// why routes are parsed by hand instead of with a route-matching library:
// encoded slashes must survive.
export function encodePath(p: string): string {
return p.split("/").map(encodeURIComponent).join("/");
}
export function decodePath(p: string): string {
return p.split("/").map(decodeURIComponent).join("/");
}
// 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>/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"]);
export interface Route {
project?: string;
path: string;
view?: "insights" | "history";
viewTarget?: string;
}
export function parseRoute(pathname: string, mode: "volume" | "hub"): Route {
const raw = pathname.replace(/^\/+/, "");
if (mode !== "hub") return { path: raw ? decodePath(raw) : "" };
const slash = raw.indexOf("/");
if (slash === -1) return { project: raw, path: "" };
const r: Route = { project: raw.slice(0, slash), path: decodePath(raw.slice(slash + 1)) };
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.viewTarget = seg === -1 ? "" : r.path.slice(seg + 1).replace(/\/+$/, "");
r.path = "";
}
return r;
}
// The URL for a file within a project (hub) or the volume (no project id).
export function urlForPath(path: string, projectId?: string): string {
const enc = encodePath(path);
if (projectId) return "/" + projectId + (enc ? "/" + enc : "");
return "/" + enc;
}
// The URL for a special view of a project.
export function urlForView(
view: "insights" | "history",
projectId?: string,
target?: string,
): string {
let s = (projectId ? "/" + projectId : "") + "/" + view;
if (view === "history" && target) s += "/" + encodePath(target.replace(/\/+$/, ""));
return s;
}
+37
View File
@@ -0,0 +1,37 @@
import { useSyncExternalStore } from "react";
// Transient toast, replacing blocking alert(). Imperative `toast()` from
// anywhere; <Toaster/> (mounted once in App) renders it.
type ToastState = { msg: string; err: boolean; shown: boolean };
let state: ToastState = { msg: "", err: false, shown: false };
let listeners: Array<() => void> = [];
let timer: ReturnType<typeof setTimeout> | undefined;
function emit(next: ToastState) {
state = next;
listeners.forEach((l) => l());
}
export function toast(msg: string, isErr = false) {
emit({ msg, err: isErr, shown: true });
clearTimeout(timer);
timer = setTimeout(() => emit({ ...state, shown: false }), 3200);
}
export function Toaster() {
const s = useSyncExternalStore(
(l) => {
listeners.push(l);
return () => {
listeners = listeners.filter((x) => x !== l);
};
},
() => state,
);
return (
<div id="toast" className={s.shown ? "show" + (s.err ? " err" : "") : ""}>
{s.msg}
</div>
);
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>&#128059;</text></svg>">
<script type="module" crossorigin src="/assets/index-CZjUv2DS.js"></script>
<script type="module" crossorigin src="/assets/index-BoqNtp9Y.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-oVhdizP9.css">
</head>
<body>