feat(standalone): browser-local (IndexedDB) virtual projects
Loading a folder now imports a COPY into a browser-local IndexedDB project with its own /p/:slug URL (original disk files untouched): edits persist to IDB across visits, and the project exports via Download .zip or per-file. Gated by VITE_LOCAL_PROJECTS=idb (on for the demo; off keeps the File System Access write-back flow), so it's configurable per deployment. Modular, swappable project sources behind the shared ProjectSource interface, each self-describing via a SourceDescriptor whose kind is shown verbatim in the UI — "Local (this browser)", "Remote · read-only", "Remote · editable" — on the home page, the project view, and inside the editor, so the user always knows whether/how Save persists: - remote → REST backend (remote-rw) - static → CDN gallery (remote-ro), saves download - local (new) → idbProjectStore, writable IDB store A composite layers the local store over the configured remote/gallery source, routing per slug so imported/saved projects and the gallery share one namespace. New, dependency-free (matching sync-client's raw-IDB ethos): - lib/idb-project-store.ts raw IndexedDB store + create/delete/rename/export - lib/zip.ts store-only ZIP writer (verified via system unzip) - lib/import-folder.ts FSA/webkitdirectory folder → in-memory bytes - lib/project-source-shared.ts source descriptors + deterministic uuid - components/SourceChip, components/LocalProjectsSection Home lists local projects (open/export/rename/delete). Verified: typecheck, project-source tests, prod build, and IDB key-range project isolation in-browser (prefix-colliding slugs don't leak). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0f53984407
commit
ce9ede1e9e
16 changed files with 743 additions and 81 deletions
120
web/standalone/src/components/LocalProjectsSection.tsx
Normal file
120
web/standalone/src/components/LocalProjectsSection.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Download, Loader2, Pencil, Trash2 } from "lucide-react";
|
||||
import { useLocalProjects } from "@/lib/api";
|
||||
import { downloadBytes } from "@/lib/download";
|
||||
import { localProjectStore } from "@/lib/project-source";
|
||||
import { SOURCE_DESCRIPTORS } from "@/lib/project-source-shared";
|
||||
import { zipFiles } from "@/lib/zip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SourceChip } from "@/components/SourceChip";
|
||||
|
||||
/**
|
||||
* The home-page list of browser-local (IndexedDB) projects — folders imported
|
||||
* via "Open a local folder" plus anything saved in the editor. Each row opens
|
||||
* the project (its own /p/:slug URL), exports it as a .zip, renames, or deletes.
|
||||
* Only rendered when the local store is enabled (LOCAL_PROJECTS_ENABLED).
|
||||
*/
|
||||
export function LocalProjectsSection() {
|
||||
const { data: projects, isLoading } = useLocalProjects();
|
||||
const qc = useQueryClient();
|
||||
const store = localProjectStore();
|
||||
const [busy, setBusy] = React.useState<string | null>(null);
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ["local-projects"] });
|
||||
|
||||
const exportZip = async (slug: string) => {
|
||||
if (!store) return;
|
||||
setBusy(slug);
|
||||
try {
|
||||
const files = await store.readFiles(slug);
|
||||
downloadBytes(`${slug}.zip`, zipFiles(files));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const rename = async (slug: string, current: string) => {
|
||||
if (!store) return;
|
||||
const name = window.prompt("Rename project", current)?.trim();
|
||||
if (!name || name === current) return;
|
||||
await store.renameProject(slug, name);
|
||||
void refresh();
|
||||
};
|
||||
|
||||
const remove = async (slug: string, name: string) => {
|
||||
if (!store) return;
|
||||
if (!window.confirm(`Delete "${name}" from this browser? This can't be undone.`))
|
||||
return;
|
||||
await store.deleteProject(slug);
|
||||
void refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mb-10">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-lg font-medium">
|
||||
Your projects
|
||||
<SourceChip descriptor={SOURCE_DESCRIPTORS.local} />
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<p className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="animate-spin" /> loading…
|
||||
</p>
|
||||
) : projects && projects.length > 0 ? (
|
||||
<div className="divide-y rounded-lg border">
|
||||
{projects.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex items-center justify-between gap-3 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{p.name}</p>
|
||||
<p className="text-xs text-muted-foreground">/p/{p.slug}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button asChild variant="secondary" size="sm">
|
||||
<Link to={`/p/${p.slug}`}>Open</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Download .zip"
|
||||
disabled={busy === p.slug}
|
||||
onClick={() => void exportZip(p.slug)}
|
||||
>
|
||||
{busy === p.slug ? (
|
||||
<Loader2 className="animate-spin" size={15} />
|
||||
) : (
|
||||
<Download size={15} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Rename"
|
||||
onClick={() => void rename(p.slug, p.name)}
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Delete"
|
||||
onClick={() => void remove(p.slug, p.name)}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-lg border px-4 py-6 text-sm text-muted-foreground">
|
||||
No saved projects yet — open a local folder above to start. Files stay
|
||||
in this browser; export anytime with Download .zip.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
40
web/standalone/src/components/SourceChip.tsx
Normal file
40
web/standalone/src/components/SourceChip.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Cloud, CloudOff, HardDrive } from "lucide-react";
|
||||
import type { SourceKind, SourceDescriptor } from "@/lib/project-source-shared";
|
||||
|
||||
/**
|
||||
* A small chip that says — explicitly — where a project lives and whether saves
|
||||
* persist there: "Local (this browser)", "Remote · read-only", or "Remote ·
|
||||
* editable". Shown on the home page sections, the project view, and inside the
|
||||
* editor so the user always knows what Save does. `title` carries the longer
|
||||
* description for hover.
|
||||
*/
|
||||
const ICONS: Record<SourceKind, typeof HardDrive> = {
|
||||
local: HardDrive,
|
||||
"remote-ro": CloudOff,
|
||||
"remote-rw": Cloud,
|
||||
};
|
||||
|
||||
const TONES: Record<SourceKind, string> = {
|
||||
local: "border-emerald-500/40 bg-emerald-500/10 text-emerald-300",
|
||||
"remote-ro": "border-amber-500/40 bg-amber-500/10 text-amber-300",
|
||||
"remote-rw": "border-sky-500/40 bg-sky-500/10 text-sky-300",
|
||||
};
|
||||
|
||||
export function SourceChip({
|
||||
descriptor,
|
||||
className = "",
|
||||
}: {
|
||||
descriptor: SourceDescriptor;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = ICONS[descriptor.kind];
|
||||
return (
|
||||
<span
|
||||
title={descriptor.description}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${TONES[descriptor.kind]} ${className}`}
|
||||
>
|
||||
<Icon size={13} />
|
||||
{descriptor.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -46,6 +46,8 @@ import { clog, cwarn } from "@/wasm/collab/debug";
|
|||
import type * as Y from "yjs";
|
||||
import { createOomWatch, respawnInNewTab } from "@/recovery/oom-watch";
|
||||
import { MemoryExhaustedDialog } from "@/recovery/MemoryExhaustedDialog";
|
||||
import type { SourceDescriptor } from "@/lib/project-source-shared";
|
||||
import { SourceChip } from "@/components/SourceChip";
|
||||
|
||||
// Tools with the v2 items bridge (kicadCollabSnapshotItems/ApplyItems embind exports).
|
||||
const COLLAB_TOOLS = new Set<Tool>(["pl_editor", "eeschema", "pcbnew"]);
|
||||
|
|
@ -508,6 +510,7 @@ export function WasmTool({
|
|||
docSource,
|
||||
assetBaseUrl,
|
||||
libsSource,
|
||||
sourceDescriptor,
|
||||
}: {
|
||||
tool: Tool;
|
||||
slug: string;
|
||||
|
|
@ -515,6 +518,9 @@ export function WasmTool({
|
|||
projectId: string;
|
||||
files: ToolFile[];
|
||||
targetPath?: string;
|
||||
/** Where this project lives (local / remote-ro / remote-rw) — shown as a chip
|
||||
* so the user knows whether/how Save persists. Omitted ⇒ no chip. */
|
||||
sourceDescriptor?: SourceDescriptor;
|
||||
/**
|
||||
* Override the library source the editor browses. Omitted ⇒ the configured
|
||||
* default (`libsSourceConfig`). Used to open a single library scoped to itself
|
||||
|
|
@ -833,6 +839,13 @@ export function WasmTool({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Where this project lives + whether Save persists (top-right). */}
|
||||
{ready && sourceDescriptor && (
|
||||
<div className="absolute right-3 top-3 z-20">
|
||||
<SourceChip descriptor={sourceDescriptor} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* A library item is being fetched (open/save). */}
|
||||
{ready && libBusy && (
|
||||
<div className="pointer-events-none absolute left-1/2 top-3 z-20 flex -translate-x-1/2 items-center gap-2 rounded bg-black/80 px-3 py-1.5 text-xs text-white">
|
||||
|
|
|
|||
|
|
@ -1,21 +1,49 @@
|
|||
import type { DriftReportBody, Lib } from "@pcbjam/shared";
|
||||
import type { DriftReportBody, Lib, Project } from "@pcbjam/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { API_BASE_URL, PROJECT_SOURCE_KIND } from "./config";
|
||||
import { client } from "./contract-client";
|
||||
import { downloadBytes } from "./download";
|
||||
import { projectSource } from "./project-source";
|
||||
import {
|
||||
ReadOnlyProjectError,
|
||||
descriptorForSlug,
|
||||
listPrimaryProjects,
|
||||
localProjectStore,
|
||||
projectSource,
|
||||
} from "./project-source";
|
||||
import type { SourceDescriptor } from "./project-source-shared";
|
||||
|
||||
/**
|
||||
* Project/file reads go through the active PROJECT SOURCE (lib/project-source.ts):
|
||||
* the @pcbjam/shared REST backend, or the read-only static gallery (demo mode).
|
||||
* Libraries + collab drift reporting are backend-only and stay on the contract
|
||||
* client here.
|
||||
* the @pcbjam/shared REST backend, the read-only static gallery (demo mode), or
|
||||
* the browser-local IndexedDB store — composited per slug. Libraries + collab
|
||||
* drift reporting are backend-only and stay on the contract client here.
|
||||
*/
|
||||
|
||||
/** Remote/gallery projects (excludes browser-local ones — those have their own
|
||||
* hook so the home page can list + manage them as a distinct section). */
|
||||
export function useProjects() {
|
||||
return useQuery({
|
||||
queryKey: ["projects"],
|
||||
queryFn: () => projectSource().listProjects(),
|
||||
queryFn: () => listPrimaryProjects(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Browser-local (IndexedDB) projects; empty when the local store is disabled. */
|
||||
export function useLocalProjects() {
|
||||
return useQuery({
|
||||
queryKey: ["local-projects"],
|
||||
queryFn: (): Promise<Project[]> =>
|
||||
localProjectStore()?.listProjects() ?? Promise.resolve([]),
|
||||
});
|
||||
}
|
||||
|
||||
/** The source kind that owns `slug` (local / remote-ro / remote-rw) — for the
|
||||
* "where your edits go" chip on the project + editor views. */
|
||||
export function useSourceDescriptor(slug: string) {
|
||||
return useQuery({
|
||||
queryKey: ["source-descriptor", slug],
|
||||
queryFn: (): Promise<SourceDescriptor> => descriptorForSlug(slug),
|
||||
enabled: !!slug,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -57,17 +85,26 @@ export function fetchFileBytes(
|
|||
* to the user's machine instead. The remote-vs-static choice is config-driven
|
||||
* (the active project source), so callers just call this.
|
||||
*/
|
||||
export function uploadFileBytes(
|
||||
export async function uploadFileBytes(
|
||||
slug: string,
|
||||
relPath: string,
|
||||
bytes: Uint8Array,
|
||||
): Promise<void> {
|
||||
const source = projectSource();
|
||||
if (source.uploadFileBytes) {
|
||||
return source.uploadFileBytes(slug, relPath, bytes);
|
||||
if (!source.uploadFileBytes) {
|
||||
downloadBytes(relPath, bytes);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await source.uploadFileBytes(slug, relPath, bytes);
|
||||
} catch (e) {
|
||||
// Composite write to a read-only (gallery) project → fall back to download.
|
||||
if (e instanceof ReadOnlyProjectError) {
|
||||
downloadBytes(relPath, bytes);
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
downloadBytes(relPath, bytes);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// --- collaboration drift reporting (ysync; backend-only) ---
|
||||
|
|
|
|||
|
|
@ -60,6 +60,17 @@ export const PROJECT_SOURCE_KIND: ProjectSourceKind =
|
|||
* "https://cdn.pcbjam.com/content/2.7.7/manifest.json". Required for "static". */
|
||||
export const PROJECT_MANIFEST_URL = import.meta.env.VITE_PROJECT_MANIFEST_URL || null;
|
||||
|
||||
/**
|
||||
* When "idb", loaded folders import into a browser-local (IndexedDB) project
|
||||
* with its own /p/:slug URL — editable, persistent across visits, exported via
|
||||
* Download .zip / per-file — instead of the in-page File System Access flow.
|
||||
* The local store is layered (composite) alongside the configured remote/gallery
|
||||
* source. Off by default (plain dev keeps disk write-back); build-demo.mjs turns
|
||||
* it on for the demo. See lib/idb-project-store.ts + lib/project-source.ts.
|
||||
*/
|
||||
export const LOCAL_PROJECTS_ENABLED =
|
||||
import.meta.env.VITE_LOCAL_PROJECTS === "idb";
|
||||
|
||||
import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
|
||||
import { remoteLibsSource } from "@/wasm/libs/remote-source";
|
||||
import { scopedLibsSource } from "@/wasm/libs/scoped-source";
|
||||
|
|
|
|||
BIN
web/standalone/src/lib/idb-project-store.ts
Normal file
BIN
web/standalone/src/lib/idb-project-store.ts
Normal file
Binary file not shown.
55
web/standalone/src/lib/import-folder.ts
Normal file
55
web/standalone/src/lib/import-folder.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import type { NewFile } from "./idb-project-store";
|
||||
|
||||
/**
|
||||
* Read a picked folder's bytes into memory so it can be COPIED into a
|
||||
* browser-local (IndexedDB) project. Unlike the File System Access write-back
|
||||
* flow, this never holds disk handles — the import is a snapshot; the user's
|
||||
* original files are untouched and edits live only in IDB (export to get them
|
||||
* back out). Two pickers, same result: FSA directory handle (Chromium) or a
|
||||
* webkitdirectory FileList (Firefox/Safari).
|
||||
*/
|
||||
export interface ImportedFolder {
|
||||
name: string;
|
||||
files: NewFile[];
|
||||
}
|
||||
|
||||
/** Walk a File System Access directory handle, reading every file's bytes. */
|
||||
export async function importFsaFolder(
|
||||
root: FileSystemDirectoryHandle,
|
||||
): Promise<ImportedFolder> {
|
||||
const files: NewFile[] = [];
|
||||
async function walk(
|
||||
dir: FileSystemDirectoryHandle,
|
||||
prefix: string,
|
||||
): Promise<void> {
|
||||
for await (const [name, handle] of dir.entries()) {
|
||||
if (handle.kind === "file") {
|
||||
const file = await (handle as FileSystemFileHandle).getFile();
|
||||
files.push({
|
||||
path: prefix + name,
|
||||
bytes: new Uint8Array(await file.arrayBuffer()),
|
||||
});
|
||||
} else {
|
||||
await walk(handle as FileSystemDirectoryHandle, `${prefix}${name}/`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(root, "");
|
||||
return { name: root.name, files };
|
||||
}
|
||||
|
||||
/** Read a webkitdirectory FileList, stripping the leading top-folder segment. */
|
||||
export async function importFileList(fileList: FileList): Promise<ImportedFolder> {
|
||||
const list = Array.from(fileList);
|
||||
const first = list[0];
|
||||
const topPrefix = first?.webkitRelativePath?.includes("/")
|
||||
? first.webkitRelativePath.split("/")[0] + "/"
|
||||
: "";
|
||||
const files: NewFile[] = [];
|
||||
for (const f of list) {
|
||||
const rel = f.webkitRelativePath || f.name;
|
||||
const path = rel.startsWith(topPrefix) ? rel.slice(topPrefix.length) : rel;
|
||||
files.push({ path, bytes: new Uint8Array(await f.arrayBuffer()) });
|
||||
}
|
||||
return { name: topPrefix ? topPrefix.slice(0, -1) : "local", files };
|
||||
}
|
||||
73
web/standalone/src/lib/project-source-shared.ts
Normal file
73
web/standalone/src/lib/project-source-shared.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Shared vocabulary for the pluggable PROJECT SOURCES (lib/project-source.ts) and
|
||||
* the local IndexedDB store (lib/idb-project-store.ts). Kept in its own module so
|
||||
* both can import the descriptor types + the deterministic-uuid helper without a
|
||||
* runtime import cycle.
|
||||
*
|
||||
* A source has one of three KINDS, surfaced verbatim in the UI so the user always
|
||||
* knows where their edits go:
|
||||
* - "remote-rw" — a backend the editor reads AND writes (saves upload).
|
||||
* - "remote-ro" — a read-only remote (the demo's CDN gallery); saves download.
|
||||
* - "local" — this browser's IndexedDB; saves persist locally, export by zip.
|
||||
*/
|
||||
export type SourceKind = "remote-rw" | "remote-ro" | "local";
|
||||
|
||||
export interface SourceDescriptor {
|
||||
kind: SourceKind;
|
||||
/** Whether editor saves persist back to this source. */
|
||||
writable: boolean;
|
||||
/** Short chip label, e.g. "Local (this browser)". */
|
||||
label: string;
|
||||
/** One-line explanation of what saving does, for tooltips/help text. */
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const SOURCE_DESCRIPTORS: Record<SourceKind, SourceDescriptor> = {
|
||||
"remote-rw": {
|
||||
kind: "remote-rw",
|
||||
writable: true,
|
||||
label: "Remote · editable",
|
||||
description: "Stored on the backend — your saves are uploaded there.",
|
||||
},
|
||||
"remote-ro": {
|
||||
kind: "remote-ro",
|
||||
writable: false,
|
||||
label: "Remote · read-only",
|
||||
description:
|
||||
"A read-only example from the server — edits aren't saved back; Save downloads the file to your machine.",
|
||||
},
|
||||
local: {
|
||||
kind: "local",
|
||||
writable: true,
|
||||
label: "Local (this browser)",
|
||||
description:
|
||||
"Stored in this browser only — saves persist here across visits. Export with Download .zip (nothing is uploaded).",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Stable v4-format UUID from a seed (cyrb128). The contract ids are UUIDs and the
|
||||
* editor uses project.id for the (broadcast-only here) collab room name, so a
|
||||
* deterministic id keeps that stable across reloads/tabs for the same slug.
|
||||
*/
|
||||
export function deterministicUuid(seed: string): string {
|
||||
let h1 = 1779033703,
|
||||
h2 = 3144134277,
|
||||
h3 = 1013904242,
|
||||
h4 = 2773480762;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
const k = seed.charCodeAt(i);
|
||||
h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
|
||||
h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
|
||||
h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
|
||||
h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
|
||||
}
|
||||
h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);
|
||||
h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);
|
||||
h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);
|
||||
h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);
|
||||
const hex = (n: number) => (n >>> 0).toString(16).padStart(8, "0");
|
||||
const h = hex(h1) + hex(h2) + hex(h3) + hex(h4);
|
||||
const variant = ((parseInt(h.charAt(16), 16) & 0x3) | 0x8).toString(16);
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${variant}${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
||||
}
|
||||
|
|
@ -25,6 +25,9 @@ async function loadStatic() {
|
|||
API_BASE_URL: "http://localhost:3050",
|
||||
PROJECT_SOURCE_KIND: "static",
|
||||
PROJECT_MANIFEST_URL: MANIFEST_URL,
|
||||
// Local IDB store off ⇒ the active source is the plain static gallery
|
||||
// (no composite), which is what these read-only assertions cover.
|
||||
LOCAL_PROJECTS_ENABLED: false,
|
||||
}));
|
||||
return (await import("./project-source")).projectSource;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,33 @@
|
|||
import type { Project, ProjectFile, ProjectWithFiles } from "@pcbjam/shared";
|
||||
import {
|
||||
API_BASE_URL,
|
||||
LOCAL_PROJECTS_ENABLED,
|
||||
PROJECT_MANIFEST_URL,
|
||||
PROJECT_SOURCE_KIND,
|
||||
} from "./config";
|
||||
import { client } from "./contract-client";
|
||||
import { idbProjectStore, type LocalProjectStore } from "./idb-project-store";
|
||||
import {
|
||||
SOURCE_DESCRIPTORS,
|
||||
type SourceDescriptor,
|
||||
deterministicUuid,
|
||||
} from "./project-source-shared";
|
||||
|
||||
/**
|
||||
* Where the standalone gets its PROJECTS. The default `remote` source talks the
|
||||
* @pcbjam/shared REST contract; the `static` source serves a read-only example
|
||||
* gallery (manifest + file bytes) from a CDN with no backend — the
|
||||
* demo.pcbjam.com mode, where Save downloads to local (`uploadFileBytes` absent
|
||||
* ⇒ the caller downloads). One source is active per deployment, selected by
|
||||
* PROJECT_SOURCE_KIND. See docs/features/demo-deploy/.
|
||||
* Where the standalone gets its PROJECTS. Every source implements this one
|
||||
* interface (so they're swappable) and self-describes via `descriptor`:
|
||||
* - remote → REST backend over the @pcbjam/shared contract (remote-rw).
|
||||
* - static → read-only example gallery from a CDN, no backend (remote-ro);
|
||||
* Save downloads to local (`uploadFileBytes` absent).
|
||||
* - local → this browser's IndexedDB (idb-project-store.ts), writable.
|
||||
* The configured PROJECT_SOURCE_KIND picks the remote/gallery source; when
|
||||
* LOCAL_PROJECTS_ENABLED, the local IDB store is layered on top (a composite
|
||||
* that routes per slug) so loaded folders + saved work coexist with the gallery.
|
||||
* See docs/features/demo-deploy/.
|
||||
*/
|
||||
export interface ProjectSource {
|
||||
/** What this source is + whether saves persist (surfaced in the UI). */
|
||||
readonly descriptor: SourceDescriptor;
|
||||
/** No write-back target — the editor should download saves to local. */
|
||||
readonly readOnly: boolean;
|
||||
listProjects(): Promise<Project[]>;
|
||||
|
|
@ -41,6 +54,7 @@ function remoteProjectSource(): ProjectSource {
|
|||
const fileUrl = (slug: string, relPath: string) =>
|
||||
`${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files/${encodePath(relPath)}`;
|
||||
return {
|
||||
descriptor: SOURCE_DESCRIPTORS["remote-rw"],
|
||||
readOnly: false,
|
||||
async listProjects() {
|
||||
const res = await client.listProjects();
|
||||
|
|
@ -92,31 +106,6 @@ interface StaticManifest {
|
|||
projects: StaticManifestProject[];
|
||||
}
|
||||
|
||||
/** Stable v4-format UUID from a seed (cyrb128) — the contract ids are UUIDs and
|
||||
* the editor uses project.id for the (broadcast-only here) collab room name, so
|
||||
* a deterministic id keeps that stable across reloads/tabs. */
|
||||
function deterministicUuid(seed: string): string {
|
||||
let h1 = 1779033703,
|
||||
h2 = 3144134277,
|
||||
h3 = 1013904242,
|
||||
h4 = 2773480762;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
const k = seed.charCodeAt(i);
|
||||
h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
|
||||
h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
|
||||
h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
|
||||
h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
|
||||
}
|
||||
h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);
|
||||
h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);
|
||||
h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);
|
||||
h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);
|
||||
const hex = (n: number) => (n >>> 0).toString(16).padStart(8, "0");
|
||||
const h = hex(h1) + hex(h2) + hex(h3) + hex(h4);
|
||||
const variant = ((parseInt(h.charAt(16), 16) & 0x3) | 0x8).toString(16);
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${variant}${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
||||
}
|
||||
|
||||
function contentTypeFor(path: string): string {
|
||||
if (/\.(kicad_\w+|net|csv|pos|drl|gbr)$/i.test(path))
|
||||
return "text/plain; charset=utf-8";
|
||||
|
|
@ -163,6 +152,7 @@ function staticProjectSource(manifestUrl: string): ProjectSource {
|
|||
};
|
||||
|
||||
return {
|
||||
descriptor: SOURCE_DESCRIPTORS["remote-ro"],
|
||||
readOnly: true,
|
||||
async listProjects() {
|
||||
const m = await load();
|
||||
|
|
@ -184,16 +174,98 @@ function staticProjectSource(manifestUrl: string): ProjectSource {
|
|||
};
|
||||
}
|
||||
|
||||
// --- composite (local IDB layered over a remote/gallery source) ---------------
|
||||
|
||||
/**
|
||||
* Routes each call to the LOCAL store or the REMOTE/gallery `primary` by which
|
||||
* one owns the slug, so browser-saved projects and the read-only gallery share
|
||||
* one `/p/:slug` namespace. A locally-stored slug always wins (local list is
|
||||
* deduped). `descriptorFor(slug)` answers which kind a given project is, for the
|
||||
* UI; reads/writes fall through to `primary` for anything not in IDB.
|
||||
*/
|
||||
function compositeProjectSource(
|
||||
local: LocalProjectStore,
|
||||
primary: ProjectSource,
|
||||
): ProjectSource {
|
||||
const route = async (slug: string): Promise<ProjectSource> =>
|
||||
(await local.hasProject(slug)) ? local : primary;
|
||||
return {
|
||||
descriptor: local.descriptor,
|
||||
readOnly: false,
|
||||
async listProjects() {
|
||||
const [a, b] = await Promise.all([
|
||||
local.listProjects(),
|
||||
primary.listProjects().catch(() => [] as Project[]),
|
||||
]);
|
||||
const localSlugs = new Set(a.map((p) => p.slug));
|
||||
return [...a, ...b.filter((p) => !localSlugs.has(p.slug))];
|
||||
},
|
||||
getProject: (slug) => route(slug).then((s) => s.getProject(slug)),
|
||||
fetchFileBytes: (slug, p) => route(slug).then((s) => s.fetchFileBytes(slug, p)),
|
||||
uploadFileBytes: async (slug, p, bytes) => {
|
||||
const s = await route(slug);
|
||||
if (s.uploadFileBytes) return s.uploadFileBytes(slug, p, bytes);
|
||||
// Read-only gallery project being edited → download (api.ts also guards).
|
||||
throw new ReadOnlyProjectError(slug);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Thrown by a composite write to a read-only project; api.ts maps it to a
|
||||
* browser download (the gallery's save-to-local behavior). */
|
||||
export class ReadOnlyProjectError extends Error {
|
||||
constructor(slug: string) {
|
||||
super(`project is read-only: ${slug}`);
|
||||
this.name = "ReadOnlyProjectError";
|
||||
}
|
||||
}
|
||||
|
||||
// --- selection ----------------------------------------------------------------
|
||||
|
||||
let cached: ProjectSource | null = null;
|
||||
let cachedPrimary: ProjectSource | null = null;
|
||||
let cachedLocal: LocalProjectStore | null = null;
|
||||
let cachedActive: ProjectSource | null = null;
|
||||
|
||||
/** The active project source for this deployment (memoized). */
|
||||
export function projectSource(): ProjectSource {
|
||||
if (cached) return cached;
|
||||
cached =
|
||||
function primarySource(): ProjectSource {
|
||||
if (cachedPrimary) return cachedPrimary;
|
||||
cachedPrimary =
|
||||
PROJECT_SOURCE_KIND === "static" && PROJECT_MANIFEST_URL
|
||||
? staticProjectSource(PROJECT_MANIFEST_URL)
|
||||
: remoteProjectSource();
|
||||
return cached;
|
||||
return cachedPrimary;
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser-local IDB project store, when enabled for this deployment
|
||||
* (LOCAL_PROJECTS_ENABLED) — used by the home page to import folders + manage
|
||||
* saved projects. `null` when the feature is off (then loaded folders use the
|
||||
* in-page File System Access flow instead).
|
||||
*/
|
||||
export function localProjectStore(): LocalProjectStore | null {
|
||||
if (!LOCAL_PROJECTS_ENABLED) return null;
|
||||
return (cachedLocal ??= idbProjectStore());
|
||||
}
|
||||
|
||||
/** The active project source for this deployment (memoized). When the local IDB
|
||||
* store is enabled it's a composite over the configured remote/gallery source. */
|
||||
export function projectSource(): ProjectSource {
|
||||
if (cachedActive) return cachedActive;
|
||||
const local = localProjectStore();
|
||||
cachedActive = local
|
||||
? compositeProjectSource(local, primarySource())
|
||||
: primarySource();
|
||||
return cachedActive;
|
||||
}
|
||||
|
||||
/** The configured remote/gallery list only (excludes browser-local projects) —
|
||||
* the home page shows local + gallery as separate, clearly-labeled sections. */
|
||||
export function listPrimaryProjects(): Promise<Project[]> {
|
||||
return primarySource().listProjects();
|
||||
}
|
||||
|
||||
/** Which source kind owns `slug` — for showing/describing it in the UI. */
|
||||
export async function descriptorForSlug(slug: string): Promise<SourceDescriptor> {
|
||||
const local = localProjectStore();
|
||||
if (local && (await local.hasProject(slug))) return local.descriptor;
|
||||
return primarySource().descriptor;
|
||||
}
|
||||
|
|
|
|||
121
web/standalone/src/lib/zip.ts
Normal file
121
web/standalone/src/lib/zip.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* Minimal store-only (no compression) ZIP writer — enough to bundle a project's
|
||||
* files for a single "Download .zip" export. Hand-rolled to keep the GPL bundle
|
||||
* dependency-free (same ethos as sync-client's raw IndexedDB store). KiCad
|
||||
* projects are a handful of text files, so skipping DEFLATE is a fine trade for
|
||||
* zero deps. Entries are stored with the UTF-8 name flag set; timestamps are
|
||||
* zeroed (we don't track per-file mtimes here).
|
||||
*/
|
||||
|
||||
export interface ZipEntry {
|
||||
/** Forward-slash relative path inside the archive. */
|
||||
path: string;
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[n] = c >>> 0;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
|
||||
function crc32(bytes: Uint8Array): number {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
c = CRC_TABLE[(c ^ bytes[i]!) & 0xff]! ^ (c >>> 8);
|
||||
}
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
/** Build a store-only ZIP archive from the given entries. */
|
||||
export function zipFiles(entries: ZipEntry[]): Uint8Array {
|
||||
const enc = new TextEncoder();
|
||||
const chunks: Uint8Array[] = [];
|
||||
const central: Uint8Array[] = [];
|
||||
let offset = 0;
|
||||
|
||||
const u16 = (n: number) => new Uint8Array([n & 0xff, (n >>> 8) & 0xff]);
|
||||
const u32 = (n: number) =>
|
||||
new Uint8Array([n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]);
|
||||
|
||||
for (const e of entries) {
|
||||
const name = enc.encode(e.path);
|
||||
const crc = crc32(e.bytes);
|
||||
const size = e.bytes.length;
|
||||
const flags = 0x0800; // bit 11: filename is UTF-8
|
||||
|
||||
// Local file header + name + data.
|
||||
const local = concat([
|
||||
u32(0x04034b50),
|
||||
u16(20), // version needed
|
||||
u16(flags),
|
||||
u16(0), // method 0 = store
|
||||
u16(0), // mod time
|
||||
u16(0), // mod date
|
||||
u32(crc),
|
||||
u32(size), // compressed size (== uncompressed for store)
|
||||
u32(size), // uncompressed size
|
||||
u16(name.length),
|
||||
u16(0), // extra len
|
||||
name,
|
||||
e.bytes,
|
||||
]);
|
||||
chunks.push(local);
|
||||
|
||||
// Central directory header for this entry.
|
||||
central.push(
|
||||
concat([
|
||||
u32(0x02014b50),
|
||||
u16(20), // version made by
|
||||
u16(20), // version needed
|
||||
u16(flags),
|
||||
u16(0), // method
|
||||
u16(0), // mod time
|
||||
u16(0), // mod date
|
||||
u32(crc),
|
||||
u32(size),
|
||||
u32(size),
|
||||
u16(name.length),
|
||||
u16(0), // extra len
|
||||
u16(0), // comment len
|
||||
u16(0), // disk number start
|
||||
u16(0), // internal attrs
|
||||
u32(0), // external attrs
|
||||
u32(offset), // local header offset
|
||||
name,
|
||||
]),
|
||||
);
|
||||
|
||||
offset += local.length;
|
||||
}
|
||||
|
||||
const centralBlob = concat(central);
|
||||
const eocd = concat([
|
||||
u32(0x06054b50),
|
||||
u16(0), // this disk
|
||||
u16(0), // disk with central dir
|
||||
u16(entries.length),
|
||||
u16(entries.length),
|
||||
u32(centralBlob.length),
|
||||
u32(offset), // central dir offset
|
||||
u16(0), // comment len
|
||||
]);
|
||||
|
||||
return concat([...chunks, centralBlob, eocd]);
|
||||
}
|
||||
|
||||
function concat(parts: Uint8Array[]): Uint8Array {
|
||||
let total = 0;
|
||||
for (const p of parts) total += p.length;
|
||||
const out = new Uint8Array(total);
|
||||
let at = 0;
|
||||
for (const p of parts) {
|
||||
out.set(p, at);
|
||||
at += p.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -1,14 +1,20 @@
|
|||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { Lib, Tool } from "@pcbjam/shared";
|
||||
import { FolderOpen, Library, Loader2, Package } from "lucide-react";
|
||||
import { useLibs, useProjects } from "@/lib/api";
|
||||
import { PROJECT_SOURCE_KIND } from "@/lib/config";
|
||||
import { LOCAL_PROJECTS_ENABLED, PROJECT_SOURCE_KIND } from "@/lib/config";
|
||||
import { localFileLibsSource } from "@/wasm/libs/local-file-source";
|
||||
import type { LibsSource } from "@/wasm/libs/source";
|
||||
import { downloadBytes } from "@/lib/download";
|
||||
import { importFileList, importFsaFolder } from "@/lib/import-folder";
|
||||
import { localProjectStore } from "@/lib/project-source";
|
||||
import { SOURCE_DESCRIPTORS } from "@/lib/project-source-shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolGrid } from "@/components/ToolGrid";
|
||||
import { SourceChip } from "@/components/SourceChip";
|
||||
import { LocalProjectsSection } from "@/components/LocalProjectsSection";
|
||||
import type { SaveBytes } from "@/wasm/save-flow";
|
||||
import { LocalProjectView, type LocalFile } from "@/components/LocalProjectView";
|
||||
import { WasmTool } from "@/components/WasmTool";
|
||||
|
|
@ -105,6 +111,11 @@ export function HomePage() {
|
|||
// Static (no-backend) demo mode: projects come from a read-only CDN gallery,
|
||||
// there are no backend libraries, and editor saves download to local.
|
||||
const staticMode = PROJECT_SOURCE_KIND === "static";
|
||||
// When on, loaded folders import into a browser-local (IDB) project with its
|
||||
// own URL instead of the in-page File System Access write-back flow.
|
||||
const localEnabled = LOCAL_PROJECTS_ENABLED;
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: projects, isLoading, error } = useProjects();
|
||||
const symbolLibs = useLibs("symbol");
|
||||
const footprintLibs = useLibs("footprint");
|
||||
|
|
@ -128,6 +139,17 @@ export function HomePage() {
|
|||
if (inputRef.current) inputRef.current.setAttribute("webkitdirectory", "");
|
||||
}, []);
|
||||
|
||||
// Import a picked folder into the browser-local store as a new editable
|
||||
// project, then open it at its own /p/:slug URL. The original disk files are
|
||||
// untouched (this is a copy); edits persist to IDB, export via Download .zip.
|
||||
const importToLocal = async (imported: { name: string; files: { path: string; bytes: Uint8Array }[] }) => {
|
||||
const store = localProjectStore();
|
||||
if (!store) return;
|
||||
const project = await store.createProject(imported.name, imported.files);
|
||||
await queryClient.invalidateQueries({ queryKey: ["local-projects"] });
|
||||
navigate(`/p/${project.slug}`);
|
||||
};
|
||||
|
||||
// Tool launched from the home page: no project, no files. File-less editors
|
||||
// read libraries from the backend (libsSourceConfig) and persist through the
|
||||
// lib write bridge; document editors open a blank document. Either way there's
|
||||
|
|
@ -205,8 +227,9 @@ export function HomePage() {
|
|||
<FolderOpen size={18} /> Open a local folder
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
No upload — files stay in your browser. Pick a folder containing a
|
||||
KiCad project.
|
||||
{localEnabled
|
||||
? "No upload — a copy is imported into this browser as an editable project (your original files aren't touched). Save persists here; export anytime with Download .zip."
|
||||
: "No upload — files stay in your browser. Pick a folder containing a KiCad project."}
|
||||
</p>
|
||||
{window.showDirectoryPicker ? (
|
||||
<Button
|
||||
|
|
@ -215,19 +238,24 @@ export function HomePage() {
|
|||
void (async () => {
|
||||
let root: FileSystemDirectoryHandle;
|
||||
try {
|
||||
root = await window.showDirectoryPicker!({ mode: "readwrite" });
|
||||
// Import only needs read; the write-back flow needs readwrite.
|
||||
root = await window.showDirectoryPicker!({
|
||||
mode: localEnabled ? "read" : "readwrite",
|
||||
});
|
||||
} catch {
|
||||
return; // user cancelled the picker / denied write access
|
||||
return; // user cancelled the picker / denied access
|
||||
}
|
||||
setLocal(await buildFsaProject(root));
|
||||
if (localEnabled) await importToLocal(await importFsaFolder(root));
|
||||
else setLocal(await buildFsaProject(root));
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={16} /> Choose folder
|
||||
</Button>
|
||||
) : (
|
||||
// No File System Access API (Firefox/Safari): read-only folder input;
|
||||
// editor saves arrive as browser downloads instead of disk writes.
|
||||
// No File System Access API (Firefox/Safari): folder input. With the
|
||||
// local store on, the files are imported into IDB; otherwise it's a
|
||||
// read-only session where editor saves arrive as browser downloads.
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
|
|
@ -236,12 +264,19 @@ export function HomePage() {
|
|||
onChange={(e) => {
|
||||
const fl = e.target.files;
|
||||
if (!fl || fl.length === 0) return;
|
||||
setLocal(buildLocalProject(fl));
|
||||
if (localEnabled) {
|
||||
void (async () => importToLocal(await importFileList(fl)))();
|
||||
} else {
|
||||
setLocal(buildLocalProject(fl));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* --- Your browser-local projects (imported folders + saved work) --- */}
|
||||
{localEnabled && <LocalProjectsSection />}
|
||||
|
||||
{/* --- Tools (KiCad-style launcher for the standalone tools) --- */}
|
||||
<section className="mb-10">
|
||||
<h2 className="mb-3 text-lg font-medium">Tools</h2>
|
||||
|
|
@ -250,8 +285,15 @@ export function HomePage() {
|
|||
|
||||
{/* --- Projects (backend, or the static example gallery) --- */}
|
||||
<section className="mb-10">
|
||||
<h2 className="mb-3 text-lg font-medium">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-lg font-medium">
|
||||
{staticMode ? "Example projects" : "Projects from the backend"}
|
||||
<SourceChip
|
||||
descriptor={
|
||||
staticMode
|
||||
? SOURCE_DESCRIPTORS["remote-ro"]
|
||||
: SOURCE_DESCRIPTORS["remote-rw"]
|
||||
}
|
||||
/>
|
||||
</h2>
|
||||
{isLoading && (
|
||||
<p className="flex items-center gap-2 text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
import { Link, useParams } from "react-router-dom";
|
||||
import * as React from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
EXTENSION_TOOL,
|
||||
FILELESS_TOOLS,
|
||||
TOOL_LABELS,
|
||||
type Tool,
|
||||
} from "@pcbjam/shared";
|
||||
import { ArrowLeft, ExternalLink, Loader2 } from "lucide-react";
|
||||
import { useProject } from "@/lib/api";
|
||||
import { ArrowLeft, Download, ExternalLink, Loader2, Trash2 } from "lucide-react";
|
||||
import { fetchFileBytes, useProject, useSourceDescriptor } from "@/lib/api";
|
||||
import { downloadBytes } from "@/lib/download";
|
||||
import { localProjectStore } from "@/lib/project-source";
|
||||
import { zipFiles } from "@/lib/zip";
|
||||
import { formatBytes } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SourceChip } from "@/components/SourceChip";
|
||||
|
||||
function toolForPath(path: string): Tool | null {
|
||||
const dot = path.lastIndexOf(".");
|
||||
|
|
@ -17,13 +23,45 @@ function toolForPath(path: string): Tool | null {
|
|||
}
|
||||
|
||||
/**
|
||||
* Read-only view of a backend project: list its files and open them in a tool.
|
||||
* The editor is GPL and intentionally has no create/delete/upload — those live
|
||||
* in the closed application that hosts this editor.
|
||||
* View of a project's files (open them in a tool). For a browser-local project
|
||||
* it also exports — Download .zip for the whole project, or per file — and can
|
||||
* delete it; the source chip says where edits go. Backend projects stay
|
||||
* read-mostly (create/delete live in the closed app that hosts this editor); the
|
||||
* read-only gallery downloads on save.
|
||||
*/
|
||||
export function ProjectView() {
|
||||
const { project: slug = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading, error } = useProject(slug);
|
||||
const { data: descriptor } = useSourceDescriptor(slug);
|
||||
const isLocal = descriptor?.kind === "local";
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
const exportZip = async () => {
|
||||
const store = localProjectStore();
|
||||
if (!store) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
downloadBytes(`${slug}.zip`, zipFiles(await store.readFiles(slug)));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadOne = async (path: string) => {
|
||||
downloadBytes(path, await fetchFileBytes(slug, path));
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
const store = localProjectStore();
|
||||
if (!store || !data) return;
|
||||
if (!window.confirm(`Delete "${data.project.name}" from this browser? This can't be undone.`))
|
||||
return;
|
||||
await store.deleteProject(slug);
|
||||
await qc.invalidateQueries({ queryKey: ["local-projects"] });
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container py-10">
|
||||
|
|
@ -43,14 +81,30 @@ export function ProjectView() {
|
|||
{data && (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
<h1 className="flex flex-wrap items-center gap-3 text-2xl font-semibold tracking-tight">
|
||||
{data.project.name}
|
||||
{descriptor && <SourceChip descriptor={descriptor} />}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
/p/{data.project.slug}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">/p/{data.project.slug}</p>
|
||||
</div>
|
||||
|
||||
{isLocal && (
|
||||
<div className="mb-6 flex flex-wrap gap-3">
|
||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => void exportZip()}>
|
||||
{busy ? <Loader2 className="animate-spin" size={15} /> : <Download size={15} />}
|
||||
Download .zip
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => void remove()}
|
||||
>
|
||||
<Trash2 size={15} /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-3">
|
||||
{/* File-less tools — launched without a target file. Full reload
|
||||
(anchor) so Emscripten boots into a clean page. */}
|
||||
|
|
@ -65,9 +119,7 @@ export function ProjectView() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="mb-3 text-lg font-medium">
|
||||
Files ({data.files.length})
|
||||
</h2>
|
||||
<h2 className="mb-3 text-lg font-medium">Files ({data.files.length})</h2>
|
||||
<div className="divide-y rounded-lg border">
|
||||
{data.files.map((f) => {
|
||||
const tool = toolForPath(f.path);
|
||||
|
|
@ -82,14 +134,25 @@ export function ProjectView() {
|
|||
{formatBytes(f.size)}
|
||||
</p>
|
||||
</div>
|
||||
{tool && (
|
||||
<a
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
|
||||
href={`/p/${slug}/${tool}/${f.path}`}
|
||||
>
|
||||
<ExternalLink size={14} /> Open in {TOOL_LABELS[tool]}
|
||||
</a>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{isLocal && (
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded-md border px-2.5 py-1.5 text-sm hover:bg-accent"
|
||||
title="Download this file"
|
||||
onClick={() => void downloadOne(f.path)}
|
||||
>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
)}
|
||||
{tool && (
|
||||
<a
|
||||
className="inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
|
||||
href={`/p/${slug}/${tool}/${f.path}`}
|
||||
>
|
||||
<ExternalLink size={14} /> Open in {TOOL_LABELS[tool]}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { useParams } from "react-router-dom";
|
||||
import { toolSchema } from "@pcbjam/shared";
|
||||
import { fetchFileBytes, uploadFileBytes, useProject } from "@/lib/api";
|
||||
import {
|
||||
fetchFileBytes,
|
||||
uploadFileBytes,
|
||||
useProject,
|
||||
useSourceDescriptor,
|
||||
} from "@/lib/api";
|
||||
import { docSourceConfig } from "@/lib/config";
|
||||
import { WasmTool } from "@/components/WasmTool";
|
||||
import { PreflightGate } from "@/preflight/PreflightGate";
|
||||
|
|
@ -12,6 +17,7 @@ export function ToolPage() {
|
|||
|
||||
const parsedTool = toolSchema.safeParse(params.tool);
|
||||
const { data, isLoading, error } = useProject(slug);
|
||||
const { data: sourceDescriptor } = useSourceDescriptor(slug);
|
||||
|
||||
if (!parsedTool.success) {
|
||||
return (
|
||||
|
|
@ -56,6 +62,7 @@ export function ToolPage() {
|
|||
fetchBytes={(relPath) => fetchFileBytes(slug, relPath)}
|
||||
saveBytes={(relPath, bytes) => uploadFileBytes(slug, relPath, bytes)}
|
||||
docSource={docSource}
|
||||
sourceDescriptor={sourceDescriptor}
|
||||
/>
|
||||
</PreflightGate>
|
||||
);
|
||||
|
|
|
|||
2
web/standalone/src/vite-env.d.ts
vendored
2
web/standalone/src/vite-env.d.ts
vendored
|
|
@ -18,6 +18,8 @@ interface ImportMetaEnv {
|
|||
readonly VITE_PROJECT_SOURCE?: string;
|
||||
/** Static gallery manifest URL (required when VITE_PROJECT_SOURCE=static), e.g. https://cdn.pcbjam.com/content/2.7.7/manifest.json. */
|
||||
readonly VITE_PROJECT_MANIFEST_URL?: string;
|
||||
/** "idb" ⇒ loaded folders import into a browser-local (IndexedDB) project with its own URL; otherwise the in-page File System Access flow. */
|
||||
readonly VITE_LOCAL_PROJECTS?: string;
|
||||
/** Yjs collab provider: none | broadcastchannel | partykit | hocuspocus. */
|
||||
readonly VITE_YJS_PROVIDER?: string;
|
||||
/** Host/URL for network collab providers (partykit, hocuspocus). */
|
||||
|
|
|
|||
Loading…
Reference in a new issue