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
|
|
@ -71,6 +71,9 @@ function main() {
|
||||||
// Read-only example gallery, saves download to local.
|
// Read-only example gallery, saves download to local.
|
||||||
VITE_PROJECT_SOURCE: "static",
|
VITE_PROJECT_SOURCE: "static",
|
||||||
VITE_PROJECT_MANIFEST_URL: `${a.cdn}/content/${a.tag}/manifest.json`,
|
VITE_PROJECT_MANIFEST_URL: `${a.cdn}/content/${a.tag}/manifest.json`,
|
||||||
|
// Loaded folders import into a browser-local (IndexedDB) project — editable,
|
||||||
|
// persistent, exported via Download .zip — layered over the gallery.
|
||||||
|
VITE_LOCAL_PROJECTS: "idb",
|
||||||
// Built-in offline symbols (no backend); cross-tab collab only.
|
// Built-in offline symbols (no backend); cross-tab collab only.
|
||||||
VITE_LIBS_SOURCE: "static",
|
VITE_LIBS_SOURCE: "static",
|
||||||
VITE_YJS_PROVIDER: "broadcastchannel",
|
VITE_YJS_PROVIDER: "broadcastchannel",
|
||||||
|
|
|
||||||
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 type * as Y from "yjs";
|
||||||
import { createOomWatch, respawnInNewTab } from "@/recovery/oom-watch";
|
import { createOomWatch, respawnInNewTab } from "@/recovery/oom-watch";
|
||||||
import { MemoryExhaustedDialog } from "@/recovery/MemoryExhaustedDialog";
|
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).
|
// Tools with the v2 items bridge (kicadCollabSnapshotItems/ApplyItems embind exports).
|
||||||
const COLLAB_TOOLS = new Set<Tool>(["pl_editor", "eeschema", "pcbnew"]);
|
const COLLAB_TOOLS = new Set<Tool>(["pl_editor", "eeschema", "pcbnew"]);
|
||||||
|
|
@ -508,6 +510,7 @@ export function WasmTool({
|
||||||
docSource,
|
docSource,
|
||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
libsSource,
|
libsSource,
|
||||||
|
sourceDescriptor,
|
||||||
}: {
|
}: {
|
||||||
tool: Tool;
|
tool: Tool;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
|
@ -515,6 +518,9 @@ export function WasmTool({
|
||||||
projectId: string;
|
projectId: string;
|
||||||
files: ToolFile[];
|
files: ToolFile[];
|
||||||
targetPath?: string;
|
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
|
* Override the library source the editor browses. Omitted ⇒ the configured
|
||||||
* default (`libsSourceConfig`). Used to open a single library scoped to itself
|
* default (`libsSourceConfig`). Used to open a single library scoped to itself
|
||||||
|
|
@ -833,6 +839,13 @@ export function WasmTool({
|
||||||
</div>
|
</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). */}
|
{/* A library item is being fetched (open/save). */}
|
||||||
{ready && libBusy && (
|
{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">
|
<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 { useQuery } from "@tanstack/react-query";
|
||||||
import { API_BASE_URL, PROJECT_SOURCE_KIND } from "./config";
|
import { API_BASE_URL, PROJECT_SOURCE_KIND } from "./config";
|
||||||
import { client } from "./contract-client";
|
import { client } from "./contract-client";
|
||||||
import { downloadBytes } from "./download";
|
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):
|
* 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).
|
* the @pcbjam/shared REST backend, the read-only static gallery (demo mode), or
|
||||||
* Libraries + collab drift reporting are backend-only and stay on the contract
|
* the browser-local IndexedDB store — composited per slug. Libraries + collab
|
||||||
* client here.
|
* 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() {
|
export function useProjects() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["projects"],
|
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
|
* to the user's machine instead. The remote-vs-static choice is config-driven
|
||||||
* (the active project source), so callers just call this.
|
* (the active project source), so callers just call this.
|
||||||
*/
|
*/
|
||||||
export function uploadFileBytes(
|
export async function uploadFileBytes(
|
||||||
slug: string,
|
slug: string,
|
||||||
relPath: string,
|
relPath: string,
|
||||||
bytes: Uint8Array,
|
bytes: Uint8Array,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const source = projectSource();
|
const source = projectSource();
|
||||||
if (source.uploadFileBytes) {
|
if (!source.uploadFileBytes) {
|
||||||
return source.uploadFileBytes(slug, relPath, bytes);
|
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) ---
|
// --- 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". */
|
* "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;
|
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 type { ProviderConfig, ProviderKind } from "@/wasm/collab";
|
||||||
import { remoteLibsSource } from "@/wasm/libs/remote-source";
|
import { remoteLibsSource } from "@/wasm/libs/remote-source";
|
||||||
import { scopedLibsSource } from "@/wasm/libs/scoped-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",
|
API_BASE_URL: "http://localhost:3050",
|
||||||
PROJECT_SOURCE_KIND: "static",
|
PROJECT_SOURCE_KIND: "static",
|
||||||
PROJECT_MANIFEST_URL: MANIFEST_URL,
|
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;
|
return (await import("./project-source")).projectSource;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,33 @@
|
||||||
import type { Project, ProjectFile, ProjectWithFiles } from "@pcbjam/shared";
|
import type { Project, ProjectFile, ProjectWithFiles } from "@pcbjam/shared";
|
||||||
import {
|
import {
|
||||||
API_BASE_URL,
|
API_BASE_URL,
|
||||||
|
LOCAL_PROJECTS_ENABLED,
|
||||||
PROJECT_MANIFEST_URL,
|
PROJECT_MANIFEST_URL,
|
||||||
PROJECT_SOURCE_KIND,
|
PROJECT_SOURCE_KIND,
|
||||||
} from "./config";
|
} from "./config";
|
||||||
import { client } from "./contract-client";
|
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
|
* Where the standalone gets its PROJECTS. Every source implements this one
|
||||||
* @pcbjam/shared REST contract; the `static` source serves a read-only example
|
* interface (so they're swappable) and self-describes via `descriptor`:
|
||||||
* gallery (manifest + file bytes) from a CDN with no backend — the
|
* - remote → REST backend over the @pcbjam/shared contract (remote-rw).
|
||||||
* demo.pcbjam.com mode, where Save downloads to local (`uploadFileBytes` absent
|
* - static → read-only example gallery from a CDN, no backend (remote-ro);
|
||||||
* ⇒ the caller downloads). One source is active per deployment, selected by
|
* Save downloads to local (`uploadFileBytes` absent).
|
||||||
* PROJECT_SOURCE_KIND. See docs/features/demo-deploy/.
|
* - 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 {
|
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. */
|
/** No write-back target — the editor should download saves to local. */
|
||||||
readonly readOnly: boolean;
|
readonly readOnly: boolean;
|
||||||
listProjects(): Promise<Project[]>;
|
listProjects(): Promise<Project[]>;
|
||||||
|
|
@ -41,6 +54,7 @@ function remoteProjectSource(): ProjectSource {
|
||||||
const fileUrl = (slug: string, relPath: string) =>
|
const fileUrl = (slug: string, relPath: string) =>
|
||||||
`${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files/${encodePath(relPath)}`;
|
`${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files/${encodePath(relPath)}`;
|
||||||
return {
|
return {
|
||||||
|
descriptor: SOURCE_DESCRIPTORS["remote-rw"],
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
async listProjects() {
|
async listProjects() {
|
||||||
const res = await client.listProjects();
|
const res = await client.listProjects();
|
||||||
|
|
@ -92,31 +106,6 @@ interface StaticManifest {
|
||||||
projects: StaticManifestProject[];
|
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 {
|
function contentTypeFor(path: string): string {
|
||||||
if (/\.(kicad_\w+|net|csv|pos|drl|gbr)$/i.test(path))
|
if (/\.(kicad_\w+|net|csv|pos|drl|gbr)$/i.test(path))
|
||||||
return "text/plain; charset=utf-8";
|
return "text/plain; charset=utf-8";
|
||||||
|
|
@ -163,6 +152,7 @@ function staticProjectSource(manifestUrl: string): ProjectSource {
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
descriptor: SOURCE_DESCRIPTORS["remote-ro"],
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
async listProjects() {
|
async listProjects() {
|
||||||
const m = await load();
|
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 ----------------------------------------------------------------
|
// --- 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). */
|
function primarySource(): ProjectSource {
|
||||||
export function projectSource(): ProjectSource {
|
if (cachedPrimary) return cachedPrimary;
|
||||||
if (cached) return cached;
|
cachedPrimary =
|
||||||
cached =
|
|
||||||
PROJECT_SOURCE_KIND === "static" && PROJECT_MANIFEST_URL
|
PROJECT_SOURCE_KIND === "static" && PROJECT_MANIFEST_URL
|
||||||
? staticProjectSource(PROJECT_MANIFEST_URL)
|
? staticProjectSource(PROJECT_MANIFEST_URL)
|
||||||
: remoteProjectSource();
|
: 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 * 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 type { Lib, Tool } from "@pcbjam/shared";
|
||||||
import { FolderOpen, Library, Loader2, Package } from "lucide-react";
|
import { FolderOpen, Library, Loader2, Package } from "lucide-react";
|
||||||
import { useLibs, useProjects } from "@/lib/api";
|
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 { localFileLibsSource } from "@/wasm/libs/local-file-source";
|
||||||
import type { LibsSource } from "@/wasm/libs/source";
|
import type { LibsSource } from "@/wasm/libs/source";
|
||||||
import { downloadBytes } from "@/lib/download";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { ToolGrid } from "@/components/ToolGrid";
|
import { ToolGrid } from "@/components/ToolGrid";
|
||||||
|
import { SourceChip } from "@/components/SourceChip";
|
||||||
|
import { LocalProjectsSection } from "@/components/LocalProjectsSection";
|
||||||
import type { SaveBytes } from "@/wasm/save-flow";
|
import type { SaveBytes } from "@/wasm/save-flow";
|
||||||
import { LocalProjectView, type LocalFile } from "@/components/LocalProjectView";
|
import { LocalProjectView, type LocalFile } from "@/components/LocalProjectView";
|
||||||
import { WasmTool } from "@/components/WasmTool";
|
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,
|
// Static (no-backend) demo mode: projects come from a read-only CDN gallery,
|
||||||
// there are no backend libraries, and editor saves download to local.
|
// there are no backend libraries, and editor saves download to local.
|
||||||
const staticMode = PROJECT_SOURCE_KIND === "static";
|
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 { data: projects, isLoading, error } = useProjects();
|
||||||
const symbolLibs = useLibs("symbol");
|
const symbolLibs = useLibs("symbol");
|
||||||
const footprintLibs = useLibs("footprint");
|
const footprintLibs = useLibs("footprint");
|
||||||
|
|
@ -128,6 +139,17 @@ export function HomePage() {
|
||||||
if (inputRef.current) inputRef.current.setAttribute("webkitdirectory", "");
|
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
|
// Tool launched from the home page: no project, no files. File-less editors
|
||||||
// read libraries from the backend (libsSourceConfig) and persist through the
|
// read libraries from the backend (libsSourceConfig) and persist through the
|
||||||
// lib write bridge; document editors open a blank document. Either way there's
|
// 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
|
<FolderOpen size={18} /> Open a local folder
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
No upload — files stay in your browser. Pick a folder containing a
|
{localEnabled
|
||||||
KiCad project.
|
? "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>
|
</p>
|
||||||
{window.showDirectoryPicker ? (
|
{window.showDirectoryPicker ? (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -215,19 +238,24 @@ export function HomePage() {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
let root: FileSystemDirectoryHandle;
|
let root: FileSystemDirectoryHandle;
|
||||||
try {
|
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 {
|
} 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
|
<FolderOpen size={16} /> Choose folder
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
// No File System Access API (Firefox/Safari): read-only folder input;
|
// No File System Access API (Firefox/Safari): folder input. With the
|
||||||
// editor saves arrive as browser downloads instead of disk writes.
|
// local store on, the files are imported into IDB; otherwise it's a
|
||||||
|
// read-only session where editor saves arrive as browser downloads.
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
|
@ -236,12 +264,19 @@ export function HomePage() {
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const fl = e.target.files;
|
const fl = e.target.files;
|
||||||
if (!fl || fl.length === 0) return;
|
if (!fl || fl.length === 0) return;
|
||||||
setLocal(buildLocalProject(fl));
|
if (localEnabled) {
|
||||||
|
void (async () => importToLocal(await importFileList(fl)))();
|
||||||
|
} else {
|
||||||
|
setLocal(buildLocalProject(fl));
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* --- Your browser-local projects (imported folders + saved work) --- */}
|
||||||
|
{localEnabled && <LocalProjectsSection />}
|
||||||
|
|
||||||
{/* --- Tools (KiCad-style launcher for the standalone tools) --- */}
|
{/* --- Tools (KiCad-style launcher for the standalone tools) --- */}
|
||||||
<section className="mb-10">
|
<section className="mb-10">
|
||||||
<h2 className="mb-3 text-lg font-medium">Tools</h2>
|
<h2 className="mb-3 text-lg font-medium">Tools</h2>
|
||||||
|
|
@ -250,8 +285,15 @@ export function HomePage() {
|
||||||
|
|
||||||
{/* --- Projects (backend, or the static example gallery) --- */}
|
{/* --- Projects (backend, or the static example gallery) --- */}
|
||||||
<section className="mb-10">
|
<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"}
|
{staticMode ? "Example projects" : "Projects from the backend"}
|
||||||
|
<SourceChip
|
||||||
|
descriptor={
|
||||||
|
staticMode
|
||||||
|
? SOURCE_DESCRIPTORS["remote-ro"]
|
||||||
|
: SOURCE_DESCRIPTORS["remote-rw"]
|
||||||
|
}
|
||||||
|
/>
|
||||||
</h2>
|
</h2>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<p className="flex items-center gap-2 text-muted-foreground">
|
<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 {
|
import {
|
||||||
EXTENSION_TOOL,
|
EXTENSION_TOOL,
|
||||||
FILELESS_TOOLS,
|
FILELESS_TOOLS,
|
||||||
TOOL_LABELS,
|
TOOL_LABELS,
|
||||||
type Tool,
|
type Tool,
|
||||||
} from "@pcbjam/shared";
|
} from "@pcbjam/shared";
|
||||||
import { ArrowLeft, ExternalLink, Loader2 } from "lucide-react";
|
import { ArrowLeft, Download, ExternalLink, Loader2, Trash2 } from "lucide-react";
|
||||||
import { useProject } from "@/lib/api";
|
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 { formatBytes } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { SourceChip } from "@/components/SourceChip";
|
||||||
|
|
||||||
function toolForPath(path: string): Tool | null {
|
function toolForPath(path: string): Tool | null {
|
||||||
const dot = path.lastIndexOf(".");
|
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.
|
* View of a project's files (open them in a tool). For a browser-local project
|
||||||
* The editor is GPL and intentionally has no create/delete/upload — those live
|
* it also exports — Download .zip for the whole project, or per file — and can
|
||||||
* in the closed application that hosts this editor.
|
* 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() {
|
export function ProjectView() {
|
||||||
const { project: slug = "" } = useParams();
|
const { project: slug = "" } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const qc = useQueryClient();
|
||||||
const { data, isLoading, error } = useProject(slug);
|
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 (
|
return (
|
||||||
<div className="container py-10">
|
<div className="container py-10">
|
||||||
|
|
@ -43,14 +81,30 @@ export function ProjectView() {
|
||||||
{data && (
|
{data && (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6">
|
<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}
|
{data.project.name}
|
||||||
|
{descriptor && <SourceChip descriptor={descriptor} />}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">/p/{data.project.slug}</p>
|
||||||
/p/{data.project.slug}
|
|
||||||
</p>
|
|
||||||
</div>
|
</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">
|
<div className="mb-6 flex flex-wrap gap-3">
|
||||||
{/* File-less tools — launched without a target file. Full reload
|
{/* File-less tools — launched without a target file. Full reload
|
||||||
(anchor) so Emscripten boots into a clean page. */}
|
(anchor) so Emscripten boots into a clean page. */}
|
||||||
|
|
@ -65,9 +119,7 @@ export function ProjectView() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="mb-3 text-lg font-medium">
|
<h2 className="mb-3 text-lg font-medium">Files ({data.files.length})</h2>
|
||||||
Files ({data.files.length})
|
|
||||||
</h2>
|
|
||||||
<div className="divide-y rounded-lg border">
|
<div className="divide-y rounded-lg border">
|
||||||
{data.files.map((f) => {
|
{data.files.map((f) => {
|
||||||
const tool = toolForPath(f.path);
|
const tool = toolForPath(f.path);
|
||||||
|
|
@ -82,14 +134,25 @@ export function ProjectView() {
|
||||||
{formatBytes(f.size)}
|
{formatBytes(f.size)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{tool && (
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<a
|
{isLocal && (
|
||||||
className="inline-flex shrink-0 items-center gap-1 rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
|
<button
|
||||||
href={`/p/${slug}/${tool}/${f.path}`}
|
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"
|
||||||
<ExternalLink size={14} /> Open in {TOOL_LABELS[tool]}
|
onClick={() => void downloadOne(f.path)}
|
||||||
</a>
|
>
|
||||||
)}
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { toolSchema } from "@pcbjam/shared";
|
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 { docSourceConfig } from "@/lib/config";
|
||||||
import { WasmTool } from "@/components/WasmTool";
|
import { WasmTool } from "@/components/WasmTool";
|
||||||
import { PreflightGate } from "@/preflight/PreflightGate";
|
import { PreflightGate } from "@/preflight/PreflightGate";
|
||||||
|
|
@ -12,6 +17,7 @@ export function ToolPage() {
|
||||||
|
|
||||||
const parsedTool = toolSchema.safeParse(params.tool);
|
const parsedTool = toolSchema.safeParse(params.tool);
|
||||||
const { data, isLoading, error } = useProject(slug);
|
const { data, isLoading, error } = useProject(slug);
|
||||||
|
const { data: sourceDescriptor } = useSourceDescriptor(slug);
|
||||||
|
|
||||||
if (!parsedTool.success) {
|
if (!parsedTool.success) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -56,6 +62,7 @@ export function ToolPage() {
|
||||||
fetchBytes={(relPath) => fetchFileBytes(slug, relPath)}
|
fetchBytes={(relPath) => fetchFileBytes(slug, relPath)}
|
||||||
saveBytes={(relPath, bytes) => uploadFileBytes(slug, relPath, bytes)}
|
saveBytes={(relPath, bytes) => uploadFileBytes(slug, relPath, bytes)}
|
||||||
docSource={docSource}
|
docSource={docSource}
|
||||||
|
sourceDescriptor={sourceDescriptor}
|
||||||
/>
|
/>
|
||||||
</PreflightGate>
|
</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;
|
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. */
|
/** 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;
|
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. */
|
/** Yjs collab provider: none | broadcastchannel | partykit | hocuspocus. */
|
||||||
readonly VITE_YJS_PROVIDER?: string;
|
readonly VITE_YJS_PROVIDER?: string;
|
||||||
/** Host/URL for network collab providers (partykit, hocuspocus). */
|
/** Host/URL for network collab providers (partykit, hocuspocus). */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue