mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Fable 5
parent
e701555257
commit
1c4e178b39
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 2–3. */}
|
||||
<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(() => {});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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'>🐻</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>
|
||||
|
||||
Reference in New Issue
Block a user