mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(webapp): [phase 2] file browsing — tree, listings, files, upload, share, palette
- FileTree with fold state, lone-root auto-open, reveal-on-deep-link; folder listings with heat dots and the journals-backed change feed - FileView: markdown (HTML transformed BEFORE render, link clicks delegated — React re-applies dangerouslySetInnerHTML markup on unrelated updates, so post-commit DOM patching loses handlers), images, text, download card - breadcrumbs, per-route scroll restoration (location.key memo) - topbar actions: share dialog (mint/copy/open/revoke), history/upload/ download buttons, ⋯ overflow menu; upload via upload/init direct or relay path, then tree refresh + open - ⌘K command palette: fuzzy files/projects/actions with stemming - e2e: 11 new browse specs (22 total green in ~12s); helpers cache one session cookie per identity to stay under the 10/min auth rate limit 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
1c4e178b39
commit
2db399a766
+22
-11
@@ -137,16 +137,27 @@ refresh; invalidate after uploads/renames/admin actions — mirror today's
|
||||
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.
|
||||
- [ ] Folder listing incl. heat dots (members) + folder history strip.
|
||||
- [ ] File view: server-rendered markdown injected; wikilinks + relative
|
||||
links rewritten (`fixLinks` semantics); meta/provenance line.
|
||||
- [ ] Breadcrumbs; per-route scroll restoration.
|
||||
- [ ] Download + raw file view; share button state; share dialog.
|
||||
- [ ] Drag-drop upload (presign and relay paths) with refresh after commit.
|
||||
- [ ] Command palette (⌘K: file names, projects, actions — port the
|
||||
palette overlay from the old shell) and the topbar overflow menu
|
||||
(`more-btn`/`more-menu`) for narrow viewports.
|
||||
- [x] Tree with expansion persistence, active marking, reveal-in-tree
|
||||
(deep links unfold the way to the file — e2e covered).
|
||||
- [x] Folder listing incl. heat dots (members) + folder history strip.
|
||||
- [x] File view: server-rendered markdown; wikilinks + relative links;
|
||||
meta/provenance line. LESSON (do not regress): never patch the
|
||||
dangerouslySetInnerHTML subtree after commit (the classic fixLinks
|
||||
approach) — React re-applies the markup on unrelated updates and
|
||||
silently discards DOM patches. Instead: transform the HTML string
|
||||
before rendering (img src, target=_blank) and handle link clicks by
|
||||
delegation on the container (`FileView.tsx`).
|
||||
- [x] Breadcrumbs; per-route scroll restoration (location.key memo,
|
||||
restore on POP, re-applied as async sections grow).
|
||||
- [x] Download + raw file view; share button state; share dialog (e2e:
|
||||
mint → public fetch → revoke → 404).
|
||||
- [x] Upload via the topbar button + file picker (direct/relay per
|
||||
upload/init) with tree refresh + open after commit.
|
||||
- [x] Command palette (⌘K fuzzy files/projects/actions) and the topbar
|
||||
overflow menu.
|
||||
Harness note: helpers.login caches one session cookie per identity —
|
||||
the server rate-limits credential POSTs (10/min/IP) and per-spec
|
||||
form logins trip it with flaky timeouts.
|
||||
|
||||
### Phase 3 — project home, insights, history
|
||||
- [ ] Project home at `/<pid>`: connect guide (3 tabs: "Claude Code &
|
||||
@@ -226,7 +237,7 @@ file path; reload on `/insights`.
|
||||
|
||||
- [x] Phase 0
|
||||
- [x] Phase 1
|
||||
- [ ] Phase 2
|
||||
- [x] Phase 2
|
||||
- [ ] Phase 3
|
||||
- [ ] Phase 4
|
||||
- [ ] Phase 5
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { login, wikiId } from "./helpers";
|
||||
|
||||
// Phase 2: tree, folder listings (heat dots + change feed), file views
|
||||
// (markdown/wikilinks/images), breadcrumbs, upload, share, palette.
|
||||
|
||||
test("tree lists the seeded folders and files", async ({ page }) => {
|
||||
await login(page);
|
||||
await expect(page.locator('#tree .row[data-path="notes"]')).toBeVisible();
|
||||
await expect(page.locator('#tree .row[data-path="index.md"]')).toBeVisible();
|
||||
await expect(page.locator('#tree .row[data-path="guide.md"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("markdown file: rendered content, crumb, meta, download + share buttons", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.click('#tree .row[data-path="index.md"]');
|
||||
await page.waitForURL(`/${pid}/index.md`);
|
||||
await expect(page.locator("#content h1")).toHaveText("Wiki");
|
||||
await expect(page.locator("#crumb")).toContainText("index.md");
|
||||
await expect(page.locator("#meta")).toContainText("alice@x.io");
|
||||
await expect(page.locator("#download")).toBeVisible();
|
||||
await expect(page.locator("#share-btn")).toBeVisible();
|
||||
});
|
||||
|
||||
test("wikilink navigates to the target file", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/index.md`);
|
||||
await page.click('#content a:has-text("guide")');
|
||||
await page.waitForURL(`/${pid}/guide.md`);
|
||||
await expect(page.locator("#content")).toContainText("Second version");
|
||||
});
|
||||
|
||||
test("folder listing: counts, change feed, heat dot on a read file", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.click('#tree .row[data-path="notes"]');
|
||||
await page.waitForURL(`/${pid}/notes`);
|
||||
await expect(page.locator(".dl-title")).toContainText("notes");
|
||||
await expect(page.locator(".dl-sub")).toContainText("1 folder");
|
||||
await expect(page.locator(".dl-sub")).toContainText("1 file");
|
||||
await expect(page.locator(".dl-history .dl-h3")).toHaveText("Recent changes");
|
||||
await expect(page.locator(".dl-history .hentry").first()).toBeVisible();
|
||||
// notes/readme.md has seeded agent reads → a heat dot on its row
|
||||
await expect(page.locator('.dl-row[title="notes/readme.md"] .heatdot')).toBeVisible();
|
||||
});
|
||||
|
||||
test("image file renders an <img>", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/assets/logo.png`);
|
||||
await expect(page.locator("#content img")).toBeVisible();
|
||||
});
|
||||
|
||||
test("breadcrumb ancestor opens that folder", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/notes/deep/topic.md`);
|
||||
await expect(page.locator("#content h1")).toHaveText("Topic");
|
||||
await page.click('#crumb .crumb-seg[title="notes"]');
|
||||
await page.waitForURL(`/${pid}/notes`);
|
||||
await expect(page.locator(".dl-title")).toContainText("notes");
|
||||
});
|
||||
|
||||
test("deep file link resolves after a hard reload", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/notes/readme.md`);
|
||||
await expect(page.locator("#content h1")).toHaveText("Notes");
|
||||
await page.reload();
|
||||
await expect(page.locator("#content h1")).toHaveText("Notes");
|
||||
// The tree unfolds the way to the deep-linked file
|
||||
await expect(page.locator('#tree .row[data-path="notes/readme.md"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("back/forward walks file → folder → file", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/index.md`);
|
||||
await page.click('#tree .row[data-path="notes"]');
|
||||
await page.waitForURL(`/${pid}/notes`);
|
||||
await page.goBack();
|
||||
await expect(page.locator("#content h1")).toHaveText("Wiki");
|
||||
await page.goForward();
|
||||
await expect(page.locator(".dl-title")).toContainText("notes");
|
||||
});
|
||||
|
||||
test("palette (⌘K) fuzzy-jumps to a file", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.keyboard.press("ControlOrMeta+k");
|
||||
await expect(page.locator("#palette")).toBeVisible();
|
||||
await page.fill("#palette-input", "topic");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForURL(`/${pid}/notes/deep/topic.md`);
|
||||
await expect(page.locator("#content h1")).toHaveText("Topic");
|
||||
});
|
||||
|
||||
test("share mints a public link that serves the file, revoke kills it", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/guide.md`);
|
||||
await page.click("#share-btn");
|
||||
const url = await page.locator(".modal-url").textContent();
|
||||
expect(url).toContain("/s/");
|
||||
const publicRes = await page.request.get(url!);
|
||||
expect(publicRes.status()).toBe(200);
|
||||
expect(await publicRes.text()).toContain("Second version");
|
||||
await page.click(".modal .ai-del"); // revoke
|
||||
await expect(page.locator("#toast")).toContainText("revoked");
|
||||
const gone = await page.request.get(url!);
|
||||
expect(gone.status()).toBe(404);
|
||||
});
|
||||
|
||||
test("upload into the selected folder, then the file opens", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/notes`);
|
||||
await page.locator("#upload-btn").waitFor();
|
||||
await page.setInputFiles('input[type="file"]', {
|
||||
name: "dropped.md",
|
||||
mimeType: "text/markdown",
|
||||
buffer: Buffer.from("# Dropped\n\nUploaded through the browser.\n"),
|
||||
});
|
||||
await page.waitForURL(`/${pid}/notes/dropped.md`);
|
||||
await expect(page.locator("#content h1")).toHaveText("Dropped");
|
||||
await expect(page.locator('#tree .row[data-path="notes/dropped.md"]')).toBeVisible();
|
||||
});
|
||||
@@ -4,13 +4,32 @@ export const ADMIN = "e2e@example.com";
|
||||
export const MEMBER = "member@example.com";
|
||||
export const PASSWORD = "e2e-pass-1";
|
||||
|
||||
// Signs in through the server-rendered /auth pages and waits for the SPA
|
||||
// shell to render.
|
||||
// One real form login per identity per run, then the session cookie is
|
||||
// reused — the server rate-limits credential POSTs to 10/min per IP, which
|
||||
// a fresh login in every spec would trip.
|
||||
type Cookies = Awaited<ReturnType<ReturnType<Page["context"]>["cookies"]>>;
|
||||
const sessions = new Map<string, Cookies>();
|
||||
|
||||
// Signs in (through the server-rendered /auth pages on first use) and waits
|
||||
// for the SPA shell to render.
|
||||
export async function login(page: Page, email: string = ADMIN) {
|
||||
const cached = sessions.get(email);
|
||||
if (cached) {
|
||||
await page.context().addCookies(cached);
|
||||
await page.goto("/");
|
||||
await page.waitForSelector("#sidebar");
|
||||
return;
|
||||
}
|
||||
await page.goto("/");
|
||||
await page.waitForURL(/auth\/login/);
|
||||
await page.fill('input[name="email"]', email);
|
||||
await page.fill('input[name="password"]', PASSWORD);
|
||||
await page.click("form button");
|
||||
await page.waitForSelector("#sidebar");
|
||||
sessions.set(email, await page.context().cookies());
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
import { login, MEMBER, PASSWORD } from "./helpers";
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { login, wikiId, 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);
|
||||
|
||||
@@ -16,6 +16,18 @@ export async function getJSON<T>(url: string): Promise<T> {
|
||||
return r.json();
|
||||
}
|
||||
|
||||
/* fetch wrapper for methods without a body-returning helper */
|
||||
export async function api<T = unknown>(method: string, url: string, body?: unknown): Promise<T> {
|
||||
const opt: RequestInit = { method };
|
||||
if (body !== undefined) {
|
||||
opt.headers = { "Content-Type": "application/json" };
|
||||
opt.body = JSON.stringify(body);
|
||||
}
|
||||
const r = await fetch(url, opt);
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
return r.status === 204 ? ({} as T) : r.json();
|
||||
}
|
||||
|
||||
export async function postJSON<T>(url: string, body?: unknown): Promise<T> {
|
||||
const r = await fetch(url, {
|
||||
method: "POST",
|
||||
|
||||
@@ -65,3 +65,70 @@ export interface InviteAccepted {
|
||||
export interface PendingList {
|
||||
pending: Array<{ id: string; email: string; name: string }>;
|
||||
}
|
||||
|
||||
// GET .../tree (handleTree → Node, server.go)
|
||||
export interface Node {
|
||||
name: string;
|
||||
path: string;
|
||||
dir?: boolean;
|
||||
size?: number;
|
||||
time?: string;
|
||||
author?: string;
|
||||
device?: string;
|
||||
children?: Node[];
|
||||
}
|
||||
|
||||
// GET .../render (handleRender, server.go)
|
||||
export interface RenderDoc {
|
||||
path: string;
|
||||
html: string;
|
||||
size: number;
|
||||
time?: string;
|
||||
author?: string;
|
||||
device?: string;
|
||||
}
|
||||
|
||||
// GET .../heat (handleHeat, reads.go) — counts only, never who.
|
||||
export interface HeatEntry {
|
||||
human?: number;
|
||||
agent?: number;
|
||||
share?: number;
|
||||
readers?: number;
|
||||
last?: string;
|
||||
}
|
||||
export type HeatMap = Record<string, HeatEntry>;
|
||||
|
||||
// GET .../history (HistoryEntry, history.go)
|
||||
export interface DeviceInfo {
|
||||
id?: string;
|
||||
name?: string;
|
||||
os?: string;
|
||||
ip?: string;
|
||||
}
|
||||
export interface HistoryEntry {
|
||||
time: string;
|
||||
kind: string; // add | edit | delete (older servers: raw "put")
|
||||
path: string;
|
||||
size?: number;
|
||||
blob?: string;
|
||||
user?: string;
|
||||
user_name?: string;
|
||||
author?: string;
|
||||
device: DeviceInfo;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
// POST .../shares (handleShareCreate, shares.go)
|
||||
export interface ShareCreated {
|
||||
token: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
// POST .../upload/init (handleUploadInit, upload.go)
|
||||
export interface UploadPlan {
|
||||
mode: "direct" | "server";
|
||||
exists?: boolean;
|
||||
url?: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useLocation, useNavigate, useNavigationType } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { Project, ServerConfig } from "../api/types";
|
||||
import { useHeat, useTree } from "../hooks/useBrowse";
|
||||
import { urlForPath, urlForView, type Route } from "../router";
|
||||
import { uploadFile } from "../upload";
|
||||
import { copyText } from "../util";
|
||||
import { toast } from "../toast";
|
||||
import { AppShell, Icon, Topbar, closeSidebarOnMobile } from "../components/shell";
|
||||
import { FileTree, ancestorsOf } from "../components/FileTree";
|
||||
import { Breadcrumbs } from "../components/Breadcrumbs";
|
||||
import { FolderListing } from "../components/FolderListing";
|
||||
import { FileView } from "../components/FileView";
|
||||
import { ShareDialog } from "../components/ShareDialog";
|
||||
import { Palette, type PaletteItem } from "../components/Palette";
|
||||
|
||||
// The browsing surface shared by hub projects and single-volume mode: the
|
||||
// file tree, folder listings, file views, and every topbar action. Sidebar
|
||||
// chrome (vault header, project nav, org bar) is injected by the caller;
|
||||
// key this component by project id so tree state resets on project switch.
|
||||
export default function Browser(props: {
|
||||
config: ServerConfig;
|
||||
apiBase: string;
|
||||
route: Route;
|
||||
hub: boolean;
|
||||
project?: Project;
|
||||
projects?: Project[];
|
||||
canInsights?: boolean;
|
||||
sidebar: { vault: ReactNode; projectsNav?: ReactNode; orgBar?: ReactNode };
|
||||
home?: ReactNode; // hub landing view (project home); default prompt otherwise
|
||||
}) {
|
||||
const { config, apiBase, route, hub, project } = props;
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const navType = useNavigationType();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { tree, flatFiles, dirIndex, loaded } = useTree(apiBase, !hub || !!project);
|
||||
const heatMap = useHeat(apiBase, hub && !!project && !!config.reads?.enabled);
|
||||
|
||||
const path = route.path;
|
||||
const isDir = !!path && dirIndex.has(path);
|
||||
const isFile = !!path && loaded && !dirIndex.has(path);
|
||||
const listingShowing = isDir && !route.view;
|
||||
|
||||
/* ---- tree expansion ---- */
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
const firstLoad = useRef(true);
|
||||
useEffect(() => {
|
||||
// First render of the tree: every folder starts closed, except a lone
|
||||
// root folder — opening it spares the user a single shut folder.
|
||||
if (!tree || !firstLoad.current) return;
|
||||
firstLoad.current = false;
|
||||
const rootDirs = (tree.children || []).filter((c) => c.dir);
|
||||
if (rootDirs.length === 1) setExpanded((s) => new Set(s).add(rootDirs[0].path));
|
||||
}, [tree]);
|
||||
useEffect(() => {
|
||||
// Opening any path (tree click, palette, wikilink, deep link) unfolds
|
||||
// the way to it; a selected folder itself opens too.
|
||||
if (!path || !loaded) return;
|
||||
setExpanded((s) => {
|
||||
const next = new Set(s);
|
||||
for (const a of ancestorsOf(path)) next.add(a);
|
||||
if (dirIndex.has(path)) next.add(path);
|
||||
return next;
|
||||
});
|
||||
const row = document.querySelector(`#tree .row[data-path="${CSS.escape(path)}"]`);
|
||||
if (row) row.scrollIntoView({ block: "nearest" });
|
||||
}, [path, loaded, dirIndex]);
|
||||
const onToggle = useCallback((p: string) => {
|
||||
setExpanded((s) => {
|
||||
const next = new Set(s);
|
||||
if (next.has(p)) next.delete(p);
|
||||
else next.add(p);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/* ---- per-route scroll restoration ----
|
||||
Back/forward returns to where the reader was; fresh navigations start
|
||||
at the top. Views call onRendered when their content lands (and again
|
||||
when async sections grow), and we re-apply the target until it fits. */
|
||||
const contentRef = useRef<HTMLElement>(null);
|
||||
const memo = useRef(new Map<string, number>());
|
||||
const scrollGoal = useRef({ key: "", want: 0, attempts: 0 });
|
||||
useEffect(() => {
|
||||
scrollGoal.current = {
|
||||
key: location.key,
|
||||
want: navType === "POP" ? (memo.current.get(location.key) ?? 0) : 0,
|
||||
attempts: 0,
|
||||
};
|
||||
}, [location.key, navType]);
|
||||
const onRendered = useCallback(() => {
|
||||
const c = contentRef.current;
|
||||
const g = scrollGoal.current;
|
||||
if (!c || g.key !== location.key || g.attempts >= 3) return;
|
||||
g.attempts++;
|
||||
c.scrollTo({ top: g.want, behavior: "instant" });
|
||||
}, [location.key]);
|
||||
const onScroll = useCallback(() => {
|
||||
if (contentRef.current) memo.current.set(location.key, contentRef.current.scrollTop);
|
||||
}, [location.key]);
|
||||
|
||||
/* ---- navigation ---- */
|
||||
const openPath = useCallback(
|
||||
(p: string) => {
|
||||
navigate(urlForPath(p, project?.id));
|
||||
closeSidebarOnMobile();
|
||||
},
|
||||
[navigate, project?.id],
|
||||
);
|
||||
const openHistory = useCallback(
|
||||
(target: string) => navigate(urlForView("history", project?.id, target)),
|
||||
[navigate, project?.id],
|
||||
);
|
||||
|
||||
/* ---- topbar state + actions ---- */
|
||||
const [meta, setMeta] = useState("");
|
||||
const [uploadStatus, setUploadStatus] = useState("");
|
||||
const [share, setShare] = useState<{ url: string; copied: boolean } | null>(null);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const uploadInput = useRef<HTMLInputElement>(null);
|
||||
const downloadRef = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const canShare = hub && !!project && isFile;
|
||||
const canHistory = hub && !!project;
|
||||
const canUpload = !!config.upload?.enabled && (!hub || !!project);
|
||||
const canDownload = isFile;
|
||||
const canMore = isFile || (hub && !!project && isDir);
|
||||
const downloadURL = apiBase + "download?path=" + encodeURIComponent(path);
|
||||
|
||||
const shareNow = useCallback(async () => {
|
||||
// Shares are per-file; a selected folder has nothing to mint.
|
||||
try {
|
||||
const r = await fetch(apiBase + "shares", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
const s = await r.json();
|
||||
const copied = await copyText(s.url);
|
||||
setShare({ url: s.url, copied });
|
||||
} catch (err) {
|
||||
toast("Share failed: " + (err as Error).message, true);
|
||||
}
|
||||
}, [apiBase, path]);
|
||||
|
||||
const historyNow = useCallback(() => {
|
||||
if (!path) return openHistory("");
|
||||
openHistory(isDir ? path + "/" : path);
|
||||
}, [path, isDir, openHistory]);
|
||||
|
||||
const uploadNow = useCallback(() => uploadInput.current?.click(), []);
|
||||
const onUploadPick = async () => {
|
||||
const input = uploadInput.current!;
|
||||
const file = input.files?.[0];
|
||||
input.value = "";
|
||||
if (!file) return;
|
||||
// A selected folder receives the upload; a selected file means "next
|
||||
// to it".
|
||||
const dir = !path ? "" : isDir ? path : path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
|
||||
const dest = dir ? dir + "/" + file.name : file.name;
|
||||
try {
|
||||
setUploadStatus(`Uploading ${dest}…`);
|
||||
await uploadFile(apiBase, dest, file);
|
||||
setUploadStatus(`Uploaded ${dest}`);
|
||||
await qc.invalidateQueries({ queryKey: ["tree", apiBase] });
|
||||
openPath(dest);
|
||||
} catch (err) {
|
||||
setUploadStatus("Upload failed: " + (err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Any navigation clears a stale upload status from the meta slot.
|
||||
setUploadStatus("");
|
||||
}, [location.key]);
|
||||
|
||||
/* ---- ⌘K palette ---- */
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setPaletteOpen((v) => !v);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
const paletteCandidates = useCallback((): PaletteItem[] => {
|
||||
const items: PaletteItem[] = [];
|
||||
const add = (icon: string, label: string, kind: string, run: () => void) =>
|
||||
items.push({ icon, label, kind, run });
|
||||
if (hub && project && path) {
|
||||
if (isFile) add("share", "Share: " + path, "action", shareNow);
|
||||
add("hist", "History: " + path, "action", historyNow);
|
||||
if (isFile) add("download", "Download: " + path, "action", () => downloadRef.current?.click());
|
||||
}
|
||||
if (hub && project) add("hist", "History: whole project", "action", () => openHistory(""));
|
||||
if (canUpload) add("upload", "Upload a file…", "action", uploadNow);
|
||||
if (hub) {
|
||||
for (const p of props.projects || []) {
|
||||
if (!project || p.id !== project.id) {
|
||||
add("folder", "Switch to project: " + p.name, "project", () => navigate("/" + p.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (config.auth?.enabled) {
|
||||
add("power", "Sign out", "action", () => (window.location.href = "/auth/logout"));
|
||||
}
|
||||
for (const d of dirIndex.keys()) add("folder", d, "folder", () => openPath(d));
|
||||
for (const f of flatFiles) add("doc", f.path, "file", () => openPath(f.path));
|
||||
return items;
|
||||
}, [hub, project, path, isFile, canUpload, config.auth?.enabled, dirIndex, flatFiles, props.projects, shareNow, historyNow, uploadNow, openHistory, openPath, navigate]);
|
||||
|
||||
/* ---- "⋯ More" menu (secondary actions on narrow screens) ---- */
|
||||
useEffect(() => {
|
||||
if (!moreOpen) return;
|
||||
const close = () => setMoreOpen(false);
|
||||
document.addEventListener("click", close);
|
||||
return () => document.removeEventListener("click", close);
|
||||
}, [moreOpen]);
|
||||
|
||||
/* ---- content view ---- */
|
||||
let contentClass = "markdown";
|
||||
let view: ReactNode;
|
||||
if (route.view === "insights" || route.view === "history") {
|
||||
// Phase 3 delivers these views.
|
||||
contentClass = "view";
|
||||
view = <div className="empty">{route.view} view is on its way.</div>;
|
||||
} else if (path) {
|
||||
if (!loaded) {
|
||||
view = <div className="empty">Loading…</div>;
|
||||
} else if (isDir) {
|
||||
contentClass = "view";
|
||||
view = (
|
||||
<FolderListing
|
||||
node={dirIndex.get(path)!}
|
||||
heatMap={heatMap}
|
||||
hub={hub && !!project}
|
||||
apiBase={apiBase}
|
||||
onOpen={openPath}
|
||||
onFullHistory={openHistory}
|
||||
onRendered={onRendered}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
view = (
|
||||
<FileView
|
||||
apiBase={apiBase}
|
||||
path={path}
|
||||
heatMap={heatMap}
|
||||
flatFiles={flatFiles}
|
||||
onOpenFile={openPath}
|
||||
onMeta={setMeta}
|
||||
onRendered={onRendered}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (props.home) {
|
||||
contentClass = "view";
|
||||
view = props.home;
|
||||
} else {
|
||||
view = <div className="empty">Select a file to read it.</div>;
|
||||
}
|
||||
|
||||
const crumb = path ? <Breadcrumbs path={path} onOpenFolder={openPath} /> : null;
|
||||
|
||||
const topbar = (
|
||||
<Topbar
|
||||
crumb={crumb}
|
||||
meta={uploadStatus || meta}
|
||||
actions={
|
||||
<>
|
||||
<button className="btn ghost" title="Search (⌘K)" onClick={() => setPaletteOpen(true)}>
|
||||
<Icon name="search" /> <span className="lbl">Search</span> <kbd>⌘K</kbd>
|
||||
</button>
|
||||
{canShare && (
|
||||
<button id="share-btn" className="btn" onClick={shareNow}>
|
||||
<Icon name="share" /> <span className="lbl">Share</span>
|
||||
</button>
|
||||
)}
|
||||
{canHistory && (
|
||||
<button id="history-btn" className="btn" onClick={historyNow}>
|
||||
<Icon name="hist" /> <span className="lbl">History</span>
|
||||
</button>
|
||||
)}
|
||||
{canUpload && (
|
||||
<button id="upload-btn" className="btn" onClick={uploadNow}>
|
||||
<Icon name="upload" /> <span className="lbl">Upload</span>
|
||||
</button>
|
||||
)}
|
||||
<input type="file" hidden ref={uploadInput} onChange={onUploadPick} />
|
||||
{canDownload && (
|
||||
<a id="download" className="btn" download href={downloadURL} ref={downloadRef}>
|
||||
<Icon name="download" /> <span className="lbl">Download</span>
|
||||
</a>
|
||||
)}
|
||||
{canMore && (
|
||||
<button
|
||||
id="more-btn"
|
||||
className="btn icon-only"
|
||||
title="More actions"
|
||||
aria-label="More actions"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMoreOpen(!moreOpen);
|
||||
}}
|
||||
>
|
||||
<Icon name="dots" />
|
||||
</button>
|
||||
)}
|
||||
{moreOpen && (
|
||||
<div id="more-menu" role="menu">
|
||||
{canHistory && (
|
||||
<button className="more-item" onClick={historyNow}>
|
||||
History
|
||||
</button>
|
||||
)}
|
||||
{canUpload && (
|
||||
<button className="more-item" onClick={uploadNow}>
|
||||
Upload
|
||||
</button>
|
||||
)}
|
||||
{canDownload && (
|
||||
<button className="more-item" onClick={() => downloadRef.current?.click()}>
|
||||
Download
|
||||
</button>
|
||||
)}
|
||||
{props.canInsights && (
|
||||
<button
|
||||
className="more-item"
|
||||
onClick={() => navigate(urlForView("insights", project?.id))}
|
||||
>
|
||||
Insights
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppShell
|
||||
vault={props.sidebar.vault}
|
||||
projectsNav={props.sidebar.projectsNav}
|
||||
orgBar={props.sidebar.orgBar}
|
||||
tree={
|
||||
<FileTree
|
||||
root={tree}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
currentPath={path}
|
||||
listingShowing={listingShowing}
|
||||
onOpen={openPath}
|
||||
/>
|
||||
}
|
||||
topbar={topbar}
|
||||
contentClass={contentClass}
|
||||
contentRef={contentRef}
|
||||
onContentScroll={onScroll}
|
||||
>
|
||||
{view}
|
||||
</AppShell>
|
||||
{share && <ShareDialog url={share.url} copied={share.copied} onClose={() => setShare(null)} />}
|
||||
<Palette open={paletteOpen} onClose={() => setPaletteOpen(false)} candidates={paletteCandidates} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { ProjectNav } from "../components/ProjectNav";
|
||||
import { OrgBar } from "../components/OrgBar";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { toast } from "../toast";
|
||||
import Browser from "./Browser";
|
||||
|
||||
export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
const location = useLocation();
|
||||
@@ -69,6 +70,9 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
// account that owns an org (or is a hub admin) gets it, whatever project
|
||||
// is open. The panels it opens arrive in Phase 4.
|
||||
const gearTarget = org && org.role === "owner" ? org : ownedOrg;
|
||||
// Insights (embedded on the project home and behind the ⋯ menu) is for
|
||||
// hub admins and owners of the project's org.
|
||||
const canInsights = isAdmin || (org ? org.role === "owner" : false);
|
||||
|
||||
const vault = (
|
||||
<VaultHeader
|
||||
@@ -124,16 +128,21 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
}
|
||||
|
||||
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>
|
||||
<Browser
|
||||
key={current.id} // fresh tree/fold state per project
|
||||
config={config}
|
||||
apiBase={"/api/p/" + current.id + "/"}
|
||||
route={route}
|
||||
hub
|
||||
project={current}
|
||||
projects={projects}
|
||||
canInsights={canInsights}
|
||||
sidebar={{
|
||||
vault,
|
||||
projectsNav: <ProjectNav projects={projects} currentId={current.id} />,
|
||||
orgBar: <OrgBar org={org} onManage={() => {}} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import type { ServerConfig } from "../api/types";
|
||||
import { AppShell, Topbar, VaultHeader } from "../components/shell";
|
||||
import { VaultHeader } from "../components/shell";
|
||||
import { parseRoute } from "../router";
|
||||
import Browser from "./Browser";
|
||||
|
||||
// Single-volume mode: one folder, no projects or orgs. File browsing
|
||||
// arrives in Phase 2.
|
||||
// Single-volume mode: one folder, no projects or orgs — but the full
|
||||
// browsing surface (tree, listings, files, upload when enabled).
|
||||
export default function VolumeApp({ config }: { config: ServerConfig }) {
|
||||
const location = useLocation();
|
||||
const name = config.volume || "BearDrive";
|
||||
useEffect(() => {
|
||||
document.title = config.brand || name;
|
||||
}, [config, name]);
|
||||
const route = useMemo(
|
||||
() => parseRoute(location.pathname, "volume"),
|
||||
[location.pathname],
|
||||
);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
vault={<VaultHeader name={name} showSignout={config.auth.enabled} />}
|
||||
topbar={<Topbar />}
|
||||
>
|
||||
<div className="empty">Select a file to read it.</div>
|
||||
</AppShell>
|
||||
<Browser
|
||||
config={config}
|
||||
apiBase="/api/"
|
||||
route={route}
|
||||
hub={false}
|
||||
sidebar={{ vault: <VaultHeader name={name} showSignout={config.auth.enabled} /> }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Every ancestor segment is a link to that folder's listing; the last
|
||||
// segment is the current page.
|
||||
export function Breadcrumbs({
|
||||
path,
|
||||
onOpenFolder,
|
||||
}: {
|
||||
path: string;
|
||||
onOpenFolder: (dir: string) => void;
|
||||
}) {
|
||||
const parts = path.split("/");
|
||||
let acc = "";
|
||||
return (
|
||||
<>
|
||||
{parts.map((seg, i) => {
|
||||
acc = acc ? acc + "/" + seg : seg;
|
||||
const target = acc;
|
||||
const last = i === parts.length - 1;
|
||||
return (
|
||||
<span key={target}>
|
||||
{i > 0 && <span className="crumb-sep">/</span>}
|
||||
{last ? (
|
||||
<span>{seg}</span>
|
||||
) : (
|
||||
<span className="crumb-seg" title={target} onClick={() => onOpenFolder(target)}>
|
||||
{seg}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Node } from "../api/types";
|
||||
import { Icon, closeSidebarOnMobile } from "./shell";
|
||||
|
||||
// The sidebar file tree. The chevron only folds; the row selects (opens a
|
||||
// folder's listing / a file). Clicking the folder whose listing is already
|
||||
// showing folds/unfolds it, like a plain tree.
|
||||
export function FileTree(props: {
|
||||
root: Node | undefined;
|
||||
expanded: Set<string>;
|
||||
onToggle: (path: string) => void;
|
||||
currentPath: string;
|
||||
listingShowing: boolean; // current view is a folder listing
|
||||
onOpen: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav id="tree" aria-label="Files">
|
||||
{props.root && <TreeChildren nodes={props.root.children || []} {...props} />}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
type RowProps = Omit<Parameters<typeof FileTree>[0], "root">;
|
||||
|
||||
function TreeChildren({ nodes, ...rest }: RowProps & { nodes: Node[] }) {
|
||||
return (
|
||||
<ul>
|
||||
{nodes.map((n) => (
|
||||
<TreeNode key={n.path} node={n} {...rest} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeNode({ node: n, ...rest }: RowProps & { node: Node }) {
|
||||
const { expanded, onToggle, currentPath, listingShowing, onOpen } = rest;
|
||||
const open = n.dir ? expanded.has(n.path) : false;
|
||||
const click = () => {
|
||||
if (n.dir) {
|
||||
// Folding beats re-opening when this folder's listing is already up.
|
||||
if (currentPath === n.path && listingShowing) {
|
||||
onToggle(n.path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
onOpen(n.path);
|
||||
if (!n.dir) closeSidebarOnMobile();
|
||||
};
|
||||
return (
|
||||
<li className={(n.dir ? "dir" : "file") + (n.dir && !open ? " collapsed" : "")}>
|
||||
<div
|
||||
className={"row" + (currentPath === n.path ? " active" : "")}
|
||||
data-path={n.path}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
title={n.name}
|
||||
aria-expanded={n.dir ? open : undefined}
|
||||
onClick={click}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="chev"
|
||||
onClick={(e) => {
|
||||
if (!n.dir) return;
|
||||
e.stopPropagation();
|
||||
onToggle(n.path);
|
||||
}}
|
||||
>
|
||||
<Icon name="chevd" />
|
||||
</span>
|
||||
<span className="ticon">
|
||||
<Icon name={n.dir ? "folder" : "doc"} />
|
||||
</span>
|
||||
<span className="label">{n.name}</span>
|
||||
</div>
|
||||
{n.dir && <TreeChildren nodes={n.children || []} {...rest} />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/* Every ancestor folder of a path (for unfolding the way to it). */
|
||||
export function ancestorsOf(filePath: string): string[] {
|
||||
const parts = filePath.split("/");
|
||||
const out: string[] = [];
|
||||
let acc = "";
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
acc = acc ? acc + "/" + parts[i] : parts[i];
|
||||
out.push(acc);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getJSON } from "../api/http";
|
||||
import type { HeatMap, Node, RenderDoc } from "../api/types";
|
||||
import { heatTotal, heatText } from "../hooks/useBrowse";
|
||||
import { IMG_EXT, MD_EXT, TEXT_EXT, joinPath } from "../util";
|
||||
|
||||
export function FileView(props: {
|
||||
apiBase: string;
|
||||
path: string;
|
||||
heatMap: HeatMap | null;
|
||||
flatFiles: Node[];
|
||||
onOpenFile: (path: string) => void;
|
||||
onMeta: (meta: string) => void;
|
||||
onRendered?: () => void;
|
||||
}) {
|
||||
const { apiBase, path, onMeta } = props;
|
||||
const fileURL = apiBase + "file?path=" + encodeURIComponent(path);
|
||||
|
||||
useEffect(() => () => onMeta(""), [path, onMeta]); // leaving a file clears its meta line
|
||||
|
||||
if (MD_EXT.test(path)) return <MarkdownView {...props} />;
|
||||
if (IMG_EXT.test(path)) {
|
||||
return <ImgView src={fileURL} alt={path} onRendered={props.onRendered} />;
|
||||
}
|
||||
if (TEXT_EXT.test(path)) return <TextView {...props} fileURL={fileURL} />;
|
||||
return (
|
||||
<div className="filecard">
|
||||
<div className="name">{path.split("/").pop()}</div>
|
||||
<p>No preview for this file type.</p>
|
||||
<a className="btn" download href={apiBase + "download?path=" + encodeURIComponent(path)}>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownView(props: Parameters<typeof FileView>[0]) {
|
||||
const { apiBase, path, heatMap, flatFiles, onOpenFile, onMeta, onRendered } = props;
|
||||
const { data: doc, error } = useQuery({
|
||||
queryKey: ["render", apiBase, path],
|
||||
queryFn: () => getJSON<RenderDoc>(apiBase + "render?path=" + encodeURIComponent(path)),
|
||||
});
|
||||
|
||||
// Rewrite the HTML BEFORE rendering (relative image sources, external
|
||||
// link targets) rather than patching the live DOM afterwards: React owns
|
||||
// the dangerouslySetInnerHTML subtree and may re-apply the markup on any
|
||||
// update, silently discarding post-commit DOM patches. Link navigation
|
||||
// is delegated on the container for the same reason.
|
||||
const html = useMemo(
|
||||
() => (doc ? transformHTML(doc.html, path, apiBase) : ""),
|
||||
[doc, path, apiBase],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!doc) return;
|
||||
const parts: string[] = [];
|
||||
if (doc.author) parts.push(doc.author + (doc.device ? " on " + doc.device : ""));
|
||||
if (doc.time) parts.push(new Date(doc.time).toLocaleString());
|
||||
const he = heatMap && heatMap[doc.path];
|
||||
if (he && heatTotal(he)) parts.push(heatText(he) + " / 30d");
|
||||
onMeta(parts.join(" · "));
|
||||
onRendered?.();
|
||||
}, [doc, heatMap, onMeta, onRendered]);
|
||||
|
||||
if (error) return <div className="empty">Could not load file: {(error as Error).message}</div>;
|
||||
if (!doc) return null;
|
||||
// Server-rendered, server-sanitized markdown — same trust model as the
|
||||
// classic app assigning innerHTML.
|
||||
return (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
onClick={(e) => handleLinkClick(e, path, flatFiles, onOpenFile)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* Delegated click handling for rendered-markdown links: wiki: targets
|
||||
resolve by basename, relative links resolve against the current file's
|
||||
folder, everything else keeps its native behavior. */
|
||||
function handleLinkClick(
|
||||
e: React.MouseEvent,
|
||||
p: string,
|
||||
flatFiles: Node[],
|
||||
openFile: (path: string) => void,
|
||||
) {
|
||||
const a = (e.target as HTMLElement).closest("a");
|
||||
if (!a || !(e.currentTarget as HTMLElement).contains(a)) return;
|
||||
const href = a.getAttribute("href") || "";
|
||||
const dir = p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "";
|
||||
if (href.startsWith("wiki:")) {
|
||||
e.preventDefault();
|
||||
openWikilink(decodeURIComponent(href.slice(5)), flatFiles, openFile);
|
||||
} else if (!/^([a-z]+:|\/|#)/i.test(href)) {
|
||||
e.preventDefault();
|
||||
openFile(joinPath(dir, decodeURIComponent(href)));
|
||||
}
|
||||
}
|
||||
|
||||
/* String-level rewrite of the server's HTML: relative image sources point
|
||||
at the file API, external links open in a new tab. */
|
||||
function transformHTML(html: string, p: string, apiBase: string): string {
|
||||
const dir = p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "";
|
||||
const fileURL = (path: string) => apiBase + "file?path=" + encodeURIComponent(path);
|
||||
const parsed = new DOMParser().parseFromString(html, "text/html");
|
||||
for (const img of parsed.querySelectorAll("img")) {
|
||||
const src = img.getAttribute("src") || "";
|
||||
if (!/^([a-z]+:|\/)/i.test(src)) img.setAttribute("src", fileURL(joinPath(dir, src)));
|
||||
}
|
||||
for (const a of parsed.querySelectorAll("a")) {
|
||||
const href = a.getAttribute("href") || "";
|
||||
if (/^https?:/i.test(href)) {
|
||||
a.setAttribute("target", "_blank");
|
||||
a.setAttribute("rel", "noopener");
|
||||
}
|
||||
}
|
||||
return parsed.body.innerHTML;
|
||||
}
|
||||
|
||||
function ImgView({ src, alt, onRendered }: { src: string; alt: string; onRendered?: () => void }) {
|
||||
return <img src={src} alt={alt} onLoad={onRendered} />;
|
||||
}
|
||||
|
||||
function TextView(props: Parameters<typeof FileView>[0] & { fileURL: string }) {
|
||||
const { path, fileURL, onRendered } = props;
|
||||
const { data, error } = useQuery({
|
||||
queryKey: ["text", fileURL],
|
||||
queryFn: async () => {
|
||||
const r = await fetch(fileURL);
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
return r.text();
|
||||
},
|
||||
});
|
||||
useEffect(() => {
|
||||
if (data != null) onRendered?.();
|
||||
}, [data, onRendered]);
|
||||
if (error) return <div className="empty">Could not load file: {(error as Error).message}</div>;
|
||||
if (data == null) return null;
|
||||
return (
|
||||
<pre className="plain" key={path}>
|
||||
{data}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function openWikilink(target: string, flatFiles: Node[], openFile: (path: string) => void) {
|
||||
const want = target.toLowerCase();
|
||||
const hit =
|
||||
flatFiles.find((f) => f.path.toLowerCase() === want || f.path.toLowerCase() === want + ".md") ||
|
||||
flatFiles.find((f) => {
|
||||
const n = f.name.toLowerCase();
|
||||
return n === want || n === want + ".md";
|
||||
});
|
||||
if (hit) openFile(hit.path);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect } from "react";
|
||||
import type { HeatMap, Node } from "../api/types";
|
||||
import { heatFor, heatLevel, heatText, useFolderHistory } from "../hooks/useBrowse";
|
||||
import { humanSize } from "../util";
|
||||
import { Icon } from "./shell";
|
||||
import { HistoryRow } from "./HistoryRow";
|
||||
|
||||
export function FolderListing(props: {
|
||||
node: Node;
|
||||
heatMap: HeatMap | null;
|
||||
hub: boolean; // hub feeds exist; a plain-folder viewer has no journals
|
||||
apiBase: string;
|
||||
onOpen: (path: string) => void;
|
||||
onFullHistory: (prefix: string) => void;
|
||||
onRendered?: () => void; // scroll restoration: content height just grew
|
||||
}) {
|
||||
const { node, heatMap, onOpen } = props;
|
||||
const kids = (node.children || [])
|
||||
.slice()
|
||||
.sort((a, b) => Number(b.dir || false) - Number(a.dir || false) || a.name.localeCompare(b.name));
|
||||
const dirs = kids.filter((c) => c.dir).length;
|
||||
const files = kids.length - dirs;
|
||||
const counts: string[] = [];
|
||||
if (dirs) counts.push(dirs + (dirs === 1 ? " folder" : " folders"));
|
||||
if (files) counts.push(files + (files === 1 ? " file" : " files"));
|
||||
const folderHeat = heatFor(heatMap, node.path, true);
|
||||
if (folderHeat) counts.push(heatText(folderHeat) + " in 30 days");
|
||||
|
||||
return (
|
||||
<div className="dirlist">
|
||||
<h1 className="dl-title">
|
||||
<span className="dl-title-icon">
|
||||
<Icon name="folder" />
|
||||
</span>
|
||||
<span>{node.name}</span>
|
||||
</h1>
|
||||
<p className="dl-sub">{counts.join(" · ") || "Empty folder"}</p>
|
||||
{kids.length === 0 ? (
|
||||
<div className="dl-empty">Nothing in this folder yet.</div>
|
||||
) : (
|
||||
<div className="dl-items">
|
||||
{kids.map((c) => {
|
||||
let meta = "";
|
||||
if (c.dir) {
|
||||
const n = (c.children || []).length;
|
||||
meta = n + (n === 1 ? " item" : " items");
|
||||
} else {
|
||||
meta = [c.size ? humanSize(c.size) : "", c.time ? new Date(c.time).toLocaleDateString() : ""]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
const he = heatFor(heatMap, c.path, !!c.dir);
|
||||
if (he) meta = heatText(he) + (meta ? " · " + meta : "");
|
||||
return (
|
||||
<div
|
||||
key={c.path}
|
||||
className="dl-row"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
title={c.path}
|
||||
onClick={() => onOpen(c.path)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen(c.path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="ticon">
|
||||
<Icon name={c.dir ? "folder" : "doc"} />
|
||||
</span>
|
||||
<span className="dl-name">{c.name}</span>
|
||||
{he && <span className={"heatdot lvl" + heatLevel(he)} title={heatText(he) + " in 30 days"} />}
|
||||
<span className="dl-meta">{meta}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{props.hub && (
|
||||
<FolderHistory
|
||||
apiBase={props.apiBase}
|
||||
prefix={node.path + "/"}
|
||||
onOpen={onOpen}
|
||||
onFullHistory={() => props.onFullHistory(node.path + "/")}
|
||||
onRendered={props.onRendered}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* The folder's change feed, straight from the journals: files added,
|
||||
edited, and deleted anywhere under it, newest first. */
|
||||
function FolderHistory(props: {
|
||||
apiBase: string;
|
||||
prefix: string;
|
||||
onOpen: (path: string) => void;
|
||||
onFullHistory: () => void;
|
||||
onRendered?: () => void;
|
||||
}) {
|
||||
const entries = useFolderHistory(props.apiBase, props.prefix, true);
|
||||
const { onRendered } = props;
|
||||
useEffect(() => {
|
||||
// The feed adds height after the listing rendered; a restored scroll
|
||||
// position (back/forward) may only fit now.
|
||||
if (entries && entries.length && onRendered) onRendered();
|
||||
}, [entries, onRendered]);
|
||||
if (!entries || entries.length === 0) return null;
|
||||
return (
|
||||
<div className="dl-history">
|
||||
<h3 className="dl-h3">Recent changes</h3>
|
||||
<div className="history dl-hlist">
|
||||
{entries.map((e, i) => (
|
||||
<HistoryRow key={i} entry={e} onOpen={props.onOpen} />
|
||||
))}
|
||||
</div>
|
||||
<button className="ai-btn dl-more" onClick={props.onFullHistory}>
|
||||
Full history
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState } from "react";
|
||||
import type { HistoryEntry } from "../api/types";
|
||||
import { humanSize } from "../util";
|
||||
import { Icon } from "./shell";
|
||||
|
||||
/* One change as a row: what happened (added / edited / deleted), to which
|
||||
file, by whom, from where — with the note (session link) expandable. */
|
||||
const KIND_ICON: Record<string, string> = { add: "plus", edit: "edit", delete: "x" };
|
||||
const KIND_LABEL: Record<string, string> = { add: "added", edit: "edited", delete: "deleted" };
|
||||
|
||||
export function HistoryRow({
|
||||
entry: e,
|
||||
onOpen,
|
||||
}: {
|
||||
entry: HistoryEntry;
|
||||
onOpen: (path: string) => void;
|
||||
}) {
|
||||
const [noteOpen, setNoteOpen] = useState(false);
|
||||
const kind = e.kind === "put" ? "edit" : e.kind; // older servers report raw "put" ops
|
||||
const who = e.user_name ? `${e.user_name} <${e.user}>` : e.user || e.author || "unknown";
|
||||
const dev = [e.device.name || e.device.id, e.device.os, e.device.ip].filter(Boolean).join(" · ");
|
||||
const clickable = kind !== "delete";
|
||||
const open = (ev: React.MouseEvent | React.KeyboardEvent) => {
|
||||
if ((ev.target as HTMLElement).tagName === "A") return;
|
||||
if (clickable) onOpen(e.path);
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={"hentry " + kind + (clickable ? " clickable" : "")}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
role={clickable ? "button" : undefined}
|
||||
onClick={open}
|
||||
onKeyDown={(ev) => {
|
||||
if (clickable && (ev.key === "Enter" || ev.key === " ")) {
|
||||
ev.preventDefault();
|
||||
onOpen(e.path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="hline">
|
||||
<span className="hkind">
|
||||
<Icon name={KIND_ICON[kind] || "dot"} />
|
||||
</span>
|
||||
<span className="hpath">{e.path}</span>
|
||||
<span className="htag">{KIND_LABEL[kind] || kind}</span>
|
||||
<span className="htime">{new Date(e.time).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="hmeta">
|
||||
<span className="hwho">{who}</span>
|
||||
<span className="hdev">{dev}</span>
|
||||
<span className="hsize">{e.size ? humanSize(e.size) : ""}</span>
|
||||
</div>
|
||||
{e.note && (
|
||||
<div
|
||||
className={"hnote" + (noteOpen ? " open" : "")}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
title={noteOpen ? "Collapse note" : "Show full note"}
|
||||
aria-expanded={noteOpen}
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation(); // expanding a note is not a navigation
|
||||
if ((ev.target as HTMLElement).tagName === "A") return;
|
||||
setNoteOpen(!noteOpen);
|
||||
}}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setNoteOpen(!noteOpen);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Linkify http(s) URLs (e.g. a Claude session link); everything
|
||||
else stays plain text — notes are user/agent input, never
|
||||
markup. */}
|
||||
{e.note.split(/(https?:\/\/\S+)/).map((tok, i) =>
|
||||
/^https?:\/\//.test(tok) ? (
|
||||
<a key={i} href={tok} target="_blank" rel="noopener">
|
||||
{tok}
|
||||
</a>
|
||||
) : (
|
||||
tok
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Icon } from "./shell";
|
||||
|
||||
/* ---- command palette (⌘K / Ctrl+K) ----
|
||||
One box for everything: fuzzy-jump to any file, switch projects, and run
|
||||
quick actions (share, history, upload, download, sign out). */
|
||||
|
||||
export interface PaletteItem {
|
||||
icon: string;
|
||||
label: string;
|
||||
kind: string; // action | project | folder | file
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
/* Subsequence fuzzy match. Returns a score (higher = better), plus the
|
||||
matched positions for highlighting; null when it doesn't match. */
|
||||
function fuzzy(query: string, text: string): { score: number; hits: number[] } | null {
|
||||
if (!query) return { score: 0, hits: [] };
|
||||
const q = query.toLowerCase();
|
||||
const t = text.toLowerCase();
|
||||
let ti = 0,
|
||||
score = 0,
|
||||
streak = 0;
|
||||
const hits: number[] = [];
|
||||
for (let qi = 0; qi < q.length; qi++) {
|
||||
const found = t.indexOf(q[qi], ti);
|
||||
if (found === -1) return null;
|
||||
streak = found === ti ? streak + 1 : 1;
|
||||
score += streak * 3; // consecutive runs
|
||||
if (found === 0 || "/ -_.".includes(t[found - 1])) score += 8; // word starts
|
||||
hits.push(found);
|
||||
ti = found + 1;
|
||||
}
|
||||
score -= Math.floor(t.length / 8); // mild preference for short targets
|
||||
return { score, hits };
|
||||
}
|
||||
|
||||
/* Match a query against a label, tolerating a simple English plural so
|
||||
"ideas" still finds idea.md. Tries the raw query first, then a lightly
|
||||
de-pluralized form (…ies→…y, …es→…, …s→…). */
|
||||
function fuzzyStemmed(query: string, label: string) {
|
||||
const m = fuzzy(query, label);
|
||||
if (m) return m;
|
||||
const q = query.toLowerCase();
|
||||
let stem: string | null = null;
|
||||
if (q.length > 3 && q.endsWith("ies")) stem = q.slice(0, -3) + "y";
|
||||
else if (q.length > 3 && q.endsWith("es")) stem = q.slice(0, -2);
|
||||
else if (q.length > 2 && q.endsWith("s")) stem = q.slice(0, -1);
|
||||
return stem ? fuzzy(stem, label) : null;
|
||||
}
|
||||
|
||||
function Highlight({ text, hits }: { text: string; hits: number[] }) {
|
||||
const out: React.ReactNode[] = [];
|
||||
let last = 0;
|
||||
hits.forEach((h, i) => {
|
||||
if (h > last) out.push(text.slice(last, h));
|
||||
out.push(<b key={i}>{text[h]}</b>);
|
||||
last = h + 1;
|
||||
});
|
||||
out.push(text.slice(last));
|
||||
return <span className="plabel">{out}</span>;
|
||||
}
|
||||
|
||||
export function Palette({
|
||||
open,
|
||||
onClose,
|
||||
candidates,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
candidates: () => PaletteItem[];
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [sel, setSel] = useState(0);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!open) return [];
|
||||
const scored: Array<PaletteItem & { score: number; hits: number[] }> = [];
|
||||
for (const c of candidates()) {
|
||||
const m = fuzzyStemmed(query, c.label);
|
||||
if (m) scored.push({ ...c, score: m.score, hits: m.hits });
|
||||
}
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
return scored.slice(0, 40);
|
||||
}, [open, query, candidates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
setSel(0);
|
||||
input.current?.focus();
|
||||
}
|
||||
}, [open]);
|
||||
useEffect(() => setSel(0), [query]);
|
||||
useEffect(() => {
|
||||
listRef.current?.children[sel]?.scrollIntoView({ block: "nearest" });
|
||||
}, [sel, items]);
|
||||
|
||||
const run = (item: PaletteItem) => {
|
||||
onClose();
|
||||
item.run();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const n = items.length;
|
||||
if (n) setSel((s) => (s + (e.key === "ArrowDown" ? 1 : n - 1)) % n);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (items[sel]) run(items[sel]);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, items, sel]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div id="palette-overlay" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div id="palette" role="dialog" aria-label="Search and quick actions">
|
||||
<div id="palette-inputwrap">
|
||||
<Icon name="search" />
|
||||
<input
|
||||
id="palette-input"
|
||||
type="text"
|
||||
placeholder="Search file names, projects, actions…"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
ref={input}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<ul id="palette-results" ref={listRef}>
|
||||
{items.length === 0 ? (
|
||||
<li className="pempty">No matches — search covers file names, projects, and actions</li>
|
||||
) : (
|
||||
items.map((item, i) => (
|
||||
<li
|
||||
key={item.kind + ":" + item.label}
|
||||
className={i === sel ? "selected" : undefined}
|
||||
onClick={() => run(item)}
|
||||
onMouseMove={() => sel !== i && setSel(i)}
|
||||
>
|
||||
<span className="picon">
|
||||
<Icon name={item.icon} />
|
||||
</span>
|
||||
<Highlight text={item.label} hits={item.hits} />
|
||||
<span className="pkind">{item.kind}</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
<footer id="palette-hint">↑↓ navigate · ⏎ select · esc close</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { api } from "../api/http";
|
||||
import { copyText } from "../util";
|
||||
import { toast } from "../toast";
|
||||
|
||||
/* A clear, explicitly-public share confirmation: warns that anyone with the
|
||||
link can view, and offers copy / open / revoke. */
|
||||
export function ShareDialog({
|
||||
url,
|
||||
copied,
|
||||
onClose,
|
||||
}: {
|
||||
url: string;
|
||||
copied: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const token = url.split("/s/")[1];
|
||||
return (
|
||||
<div className="modal-back" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal">
|
||||
<h3>Public link created</h3>
|
||||
<p>
|
||||
<b>Anyone with this link can view this file</b> — no account needed. It always shows the
|
||||
latest version until you revoke it.
|
||||
</p>
|
||||
<div className="modal-url">{url}</div>
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
className="pbtn"
|
||||
onClick={() =>
|
||||
copyText(url).then((ok) => toast(ok ? "Copied." : "Select and copy the link above."))
|
||||
}
|
||||
>
|
||||
{copied ? "Copied ✓" : "Copy link"}
|
||||
</button>
|
||||
<button className="ai-btn" onClick={() => window.open(url, "_blank")}>
|
||||
Open
|
||||
</button>
|
||||
<button
|
||||
className="ai-del"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api("DELETE", "/api/shares/" + token);
|
||||
toast("Link revoked — it no longer works.");
|
||||
onClose();
|
||||
} catch (e) {
|
||||
toast((e as Error).message, true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
<button className="ai-btn" onClick={onClose}>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,8 @@ export function AppShell(props: {
|
||||
orgBar?: ReactNode;
|
||||
topbar: ReactNode;
|
||||
contentClass?: string;
|
||||
contentRef?: React.Ref<HTMLElement>;
|
||||
onContentScroll?: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
@@ -39,7 +41,12 @@ export function AppShell(props: {
|
||||
</aside>
|
||||
<main id="main">
|
||||
{props.topbar}
|
||||
<article id="content" className={props.contentClass ?? "markdown"}>
|
||||
<article
|
||||
id="content"
|
||||
className={props.contentClass ?? "markdown"}
|
||||
ref={props.contentRef}
|
||||
onScroll={props.onContentScroll}
|
||||
>
|
||||
{props.children}
|
||||
</article>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getJSON } from "../api/http";
|
||||
import type { HeatEntry, HeatMap, HistoryEntry, Node } from "../api/types";
|
||||
|
||||
// The volume's file tree, polled so synced changes appear without a
|
||||
// reload. react-query's structural sharing keeps identical polls from
|
||||
// re-rendering (the classic app compared JSON strings for the same
|
||||
// reason).
|
||||
export function useTree(apiBase: string, enabled = true) {
|
||||
const q = useQuery({
|
||||
queryKey: ["tree", apiBase],
|
||||
queryFn: () => getJSON<Node>(apiBase + "tree"),
|
||||
enabled,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
// Flattened lookups: every file (wikilink resolution, palette) and every
|
||||
// directory (folder listings, path-kind dispatch).
|
||||
const index = useMemo(() => {
|
||||
const flatFiles: Node[] = [];
|
||||
const dirIndex = new Map<string, Node>();
|
||||
const walk = (n: Node) => {
|
||||
for (const c of n.children || []) {
|
||||
if (c.dir) {
|
||||
dirIndex.set(c.path, c);
|
||||
walk(c);
|
||||
} else {
|
||||
flatFiles.push(c);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (q.data) walk(q.data);
|
||||
return { flatFiles, dirIndex };
|
||||
}, [q.data]);
|
||||
return { tree: q.data, ...index, loaded: !!q.data };
|
||||
}
|
||||
|
||||
/* ---- read heat ----
|
||||
30-day read counts per path from the heat API (hub only). Counts only —
|
||||
the server never says who read what. */
|
||||
export function useHeat(apiBase: string, enabled: boolean) {
|
||||
const q = useQuery({
|
||||
queryKey: ["heat", apiBase],
|
||||
queryFn: () => getJSON<{ entries: HeatMap }>(apiBase + "heat?days=30"),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
return q.data?.entries ?? null;
|
||||
}
|
||||
|
||||
// The folder's change feed, straight from the journals (hub only).
|
||||
export function useFolderHistory(apiBase: string, prefix: string, enabled: boolean) {
|
||||
const q = useQuery({
|
||||
queryKey: ["history", apiBase, "prefix", prefix, 20],
|
||||
queryFn: () =>
|
||||
getJSON<{ entries: HistoryEntry[] }>(
|
||||
apiBase + "history?prefix=" + encodeURIComponent(prefix) + "&n=20",
|
||||
),
|
||||
enabled,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
return q.data?.entries ?? null;
|
||||
}
|
||||
|
||||
/* Heat for one listing entry: a file's own bucket, or the subtree sum for a
|
||||
folder. Null when there is nothing to show. */
|
||||
export function heatFor(heatMap: HeatMap | null, path: string, isDir: boolean): HeatEntry | null {
|
||||
if (!heatMap) return null;
|
||||
if (!isDir) return heatMap[path] || null;
|
||||
const agg = { human: 0, agent: 0, share: 0 };
|
||||
for (const [p, e] of Object.entries(heatMap)) {
|
||||
if (!p.startsWith(path + "/")) continue;
|
||||
agg.human += e.human || 0;
|
||||
agg.agent += e.agent || 0;
|
||||
agg.share += e.share || 0;
|
||||
}
|
||||
return agg.human || agg.agent || agg.share ? agg : null;
|
||||
}
|
||||
|
||||
export function heatTotal(e: HeatEntry): number {
|
||||
return (e.human || 0) + (e.agent || 0) + (e.share || 0);
|
||||
}
|
||||
|
||||
export function heatText(e: HeatEntry): string {
|
||||
const total = heatTotal(e);
|
||||
if (!total) return "";
|
||||
let s = total + (total === 1 ? " read" : " reads");
|
||||
if (e.agent) s += " (" + e.agent + " agent)";
|
||||
return s;
|
||||
}
|
||||
|
||||
/* Dot intensity 1–4, log-ish steps: 1–2, 3–9, 10–29, 30+ reads. */
|
||||
export function heatLevel(e: HeatEntry): number {
|
||||
const total = heatTotal(e);
|
||||
if (!total) return 0;
|
||||
if (total < 3) return 1;
|
||||
if (total < 10) return 2;
|
||||
if (total < 30) return 3;
|
||||
return 4;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { UploadPlan } from "./api/types";
|
||||
|
||||
/* The client asks the server how to upload (upload/init): "direct" hands
|
||||
back a short-lived presigned URL and the bytes go straight to the object
|
||||
store; "server" means relay the bytes through the bdrive server. */
|
||||
export async function uploadFile(apiBase: string, dest: string, file: File): Promise<void> {
|
||||
const buf = await file.arrayBuffer();
|
||||
const sha = await sha256Hex(buf);
|
||||
const post = async (url: string, body: unknown) => {
|
||||
const r = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
return r.json();
|
||||
};
|
||||
const req = { path: dest, sha256: sha, size: file.size };
|
||||
const plan: UploadPlan = await post(apiBase + "upload/init", req);
|
||||
if (plan.mode === "direct") {
|
||||
if (!plan.exists) {
|
||||
// identical content already in the store? skip the PUT
|
||||
const r = await fetch(plan.url!, {
|
||||
method: plan.method || "PUT",
|
||||
headers: plan.headers || {},
|
||||
body: buf,
|
||||
});
|
||||
if (!r.ok) throw new Error("storage upload failed: " + r.status);
|
||||
}
|
||||
await post(apiBase + "upload/commit", req);
|
||||
} else {
|
||||
const r = await fetch(apiBase + "upload/content?path=" + encodeURIComponent(dest), {
|
||||
method: "PUT",
|
||||
body: buf,
|
||||
});
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256Hex(buf: ArrayBuffer): Promise<string> {
|
||||
if (crypto.subtle) {
|
||||
const d = await crypto.subtle.digest("SHA-256", buf);
|
||||
return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
return sha256Fallback(new Uint8Array(buf)); // plain-http origins have no crypto.subtle
|
||||
}
|
||||
|
||||
/* Minimal SHA-256 (FIPS 180-4) for non-secure contexts. */
|
||||
function sha256Fallback(bytes: Uint8Array): string {
|
||||
const K = new Uint32Array([
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
]);
|
||||
const H = new Uint32Array([
|
||||
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
||||
]);
|
||||
const rr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
|
||||
const len = bytes.length;
|
||||
const padded = new Uint8Array(((((len + 8) >> 6) + 1) << 6));
|
||||
padded.set(bytes);
|
||||
padded[len] = 0x80;
|
||||
const dv = new DataView(padded.buffer);
|
||||
dv.setUint32(padded.length - 8, Math.floor((len * 8) / 0x100000000));
|
||||
dv.setUint32(padded.length - 4, (len * 8) >>> 0);
|
||||
const w = new Uint32Array(64);
|
||||
for (let off = 0; off < padded.length; off += 64) {
|
||||
for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4);
|
||||
for (let i = 16; i < 64; i++) {
|
||||
const s0 = rr(w[i - 15], 7) ^ rr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
|
||||
const s1 = rr(w[i - 2], 17) ^ rr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
|
||||
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
|
||||
}
|
||||
let [a, b, c, d, e, f, g, h] = H as unknown as number[];
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const S1 = rr(e, 6) ^ rr(e, 11) ^ rr(e, 25);
|
||||
const t1 = (h + S1 + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0;
|
||||
const S0 = rr(a, 2) ^ rr(a, 13) ^ rr(a, 22);
|
||||
const t2 = (S0 + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
|
||||
h = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
|
||||
}
|
||||
H[0] += a; H[1] += b; H[2] += c; H[3] += d; H[4] += e; H[5] += f; H[6] += g; H[7] += h;
|
||||
}
|
||||
return [...H].map((x) => (x >>> 0).toString(16).padStart(8, "0")).join("");
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export const MD_EXT = /\.(md|markdown)$/i;
|
||||
export const IMG_EXT = /\.(png|jpe?g|gif|svg|webp|ico|bmp|avif)$/i;
|
||||
export const TEXT_EXT =
|
||||
/\.(txt|log|json|ya?ml|toml|csv|go|py|js|ts|jsx|tsx|sh|bash|zsh|rb|rs|c|h|cpp|java|kt|swift|sql|html|css|xml|ini|conf|env|mod|sum|jsonl)$/i;
|
||||
|
||||
export function humanSize(n: number): string {
|
||||
if (n < 1024) return n + " B";
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let i = -1;
|
||||
do {
|
||||
n /= 1024;
|
||||
i++;
|
||||
} while (n >= 1024 && i < units.length - 1);
|
||||
return n.toFixed(1) + " " + units[i];
|
||||
}
|
||||
|
||||
// Resolve a relative link against a directory, folding "." and "..".
|
||||
export function joinPath(dir: string, rel: string): string {
|
||||
const parts = (dir ? dir.split("/") : []).concat(rel.split("/"));
|
||||
const out: string[] = [];
|
||||
for (const s of parts) {
|
||||
if (s === "" || s === ".") continue;
|
||||
if (s === "..") out.pop();
|
||||
else out.push(s);
|
||||
}
|
||||
return out.join("/");
|
||||
}
|
||||
|
||||
/* clipboard copy that never throws on a non-HTTPS origin (where
|
||||
navigator.clipboard is undefined). Returns true on success. */
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
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-BoqNtp9Y.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DAT78awr.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-oVhdizP9.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user