feat(standalone): demo follow-ups — waitlist, analytics, move-to-local, file tree, load progress

- Waitlist signup form on the home page (between Projects and Tools),
  cross-posting to the landing site's /api/waitlist (CORS + OPTIONS added there,
  gated by WAITLIST_ALLOWED_ORIGINS).
- Version badge gains a pcbjam.com landing-page link (VITE_LANDING_URL).
- Optional Plausible analytics from VITE_PLAUSIBLE_DOMAIN (off by default);
  wired into build-demo + both deploy workflows via vars.PLAUSIBLE_DOMAIN.
- Opening a read-only gallery project auto-"moves to local": it forks into a
  writable browser-local project at the same slug, shadowing the gallery row.
- Project + local-folder views use the same iconed tool launcher (ToolGrid)
  and a navigable directory FileTree instead of a flat file list.
- Boot overlay shows real wasm download progress (Module.instantiateWasm +
  streaming byte counter) and a "taking too long" state after 60s.
- Home Libraries section lists the active libs source (the read-only R2/CDN
  set in the demo) with a name filter; useLibs now reads libsSourceConfig.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPJYx4urFVhdMjEmYfMCMD
This commit is contained in:
Gergő Törcsvári 2026-06-27 07:59:09 +02:00
commit 39cb04b127
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
22 changed files with 830 additions and 258 deletions

View file

@ -89,9 +89,12 @@ jobs:
# full KiCad library set is served read-only from libs/kicad/$LIB_TAG # full KiCad library set is served read-only from libs/kicad/$LIB_TAG
# (published once by publish-libs.yml — run it first for a new lib tag). # (published once by publish-libs.yml — run it first for a new lib tag).
- name: Build demo - name: Build demo
# Plausible: set the repo Actions VARIABLE `PLAUSIBLE_DOMAIN` (e.g.
# demo.pcbjam.com) to enable analytics; unset ⇒ "" ⇒ build-demo leaves it off.
run: > run: >
node scripts/deploy/build-demo.mjs --tag "${{ steps.tag.outputs.tag }}" node scripts/deploy/build-demo.mjs --tag "${{ steps.tag.outputs.tag }}"
--cdn "$CDN" --lib-tag "$LIB_TAG" --cdn "$CDN" --lib-tag "$LIB_TAG"
--plausible "${{ vars.PLAUSIBLE_DOMAIN }}"
# 4) Ensure the Pages project exists (first deploy creates it; no-op after). # 4) Ensure the Pages project exists (first deploy creates it; no-op after).
# Its production branch must equal PAGES_PROD_BRANCH or deploys land as # Its production branch must equal PAGES_PROD_BRANCH or deploys land as

View file

@ -112,9 +112,12 @@ jobs:
# Standalone pinned to the CDN + this tag's manifest-<tag>.json (written by # Standalone pinned to the CDN + this tag's manifest-<tag>.json (written by
# publish-wasm above) + the full library set at libs/kicad/$LIB_TAG. # publish-wasm above) + the full library set at libs/kicad/$LIB_TAG.
- name: Build demo - name: Build demo
# Plausible: set the repo Actions VARIABLE `PLAUSIBLE_DOMAIN` (e.g.
# demo.pcbjam.com) to enable analytics; unset ⇒ "" ⇒ build-demo leaves it off.
run: > run: >
node scripts/deploy/build-demo.mjs --tag "${{ needs.meta.outputs.tag }}" node scripts/deploy/build-demo.mjs --tag "${{ needs.meta.outputs.tag }}"
--cdn "$CDN" --lib-tag "$LIB_TAG" --cdn "$CDN" --lib-tag "$LIB_TAG"
--plausible "${{ vars.PLAUSIBLE_DOMAIN }}"
- name: Ensure Pages project exists - name: Ensure Pages project exists
run: > run: >

View file

@ -27,6 +27,13 @@ function parseArgs(argv) {
cdn: "https://cdn.pcbjam.com", cdn: "https://cdn.pcbjam.com",
repo: "https://github.com/emergence-engineering/pcbjam", repo: "https://github.com/emergence-engineering/pcbjam",
libTag: null, libTag: null,
// Marketing site the version badge links to; the in-editor waitlist form
// cross-posts to <landing>/api/waitlist unless --waitlist overrides it.
landing: "https://pcbjam.com",
waitlist: null,
// Plausible analytics data-domain. Off unless given (or VITE_PLAUSIBLE_DOMAIN
// is already in the environment, which passes straight through).
plausible: null,
}; };
for (let i = 2; i < argv.length; i++) { for (let i = 2; i < argv.length; i++) {
const next = () => argv[++i]; const next = () => argv[++i];
@ -37,12 +44,17 @@ function parseArgs(argv) {
// KiCad library snapshot tag (published once to libs/kicad/<libTag>/). // KiCad library snapshot tag (published once to libs/kicad/<libTag>/).
// Omitted ⇒ the offline built-in example symbols (back-compat). // Omitted ⇒ the offline built-in example symbols (back-compat).
case "--lib-tag": a.libTag = next(); break; case "--lib-tag": a.libTag = next(); break;
case "--landing": a.landing = next(); break;
case "--waitlist": a.waitlist = next(); break;
case "--plausible": a.plausible = next(); break;
default: throw new Error(`unknown arg: ${argv[i]}`); default: throw new Error(`unknown arg: ${argv[i]}`);
} }
} }
if (!a.tag) throw new Error("--tag <release tag> is required"); if (!a.tag) throw new Error("--tag <release tag> is required");
a.cdn = a.cdn.replace(/\/+$/, ""); a.cdn = a.cdn.replace(/\/+$/, "");
a.repo = a.repo.replace(/\/+$/, ""); a.repo = a.repo.replace(/\/+$/, "");
a.landing = a.landing.replace(/\/+$/, "");
a.waitlist = a.waitlist || `${a.landing}/api/waitlist`;
return a; return a;
} }
@ -92,6 +104,11 @@ function main() {
VITE_APP_TAG: a.tag, VITE_APP_TAG: a.tag,
VITE_GIT_SHA: gitSha(repoRoot), VITE_GIT_SHA: gitSha(repoRoot),
VITE_REPO_URL: a.repo, VITE_REPO_URL: a.repo,
// Version-badge "pcbjam.com" link + the in-editor waitlist form's POST target.
VITE_LANDING_URL: a.landing,
VITE_WAITLIST_URL: a.waitlist,
// Plausible analytics: explicit --plausible wins, else any env-provided value.
...(a.plausible ? { VITE_PLAUSIBLE_DOMAIN: a.plausible } : {}),
}; };
console.log(`build-demo: tag=${a.tag} cdn=${a.cdn}`); console.log(`build-demo: tag=${a.tag} cdn=${a.cdn}`);
@ -100,6 +117,8 @@ function main() {
console.log(` VITE_PROJECT_MANIFEST_URL=${env.VITE_PROJECT_MANIFEST_URL}`); console.log(` VITE_PROJECT_MANIFEST_URL=${env.VITE_PROJECT_MANIFEST_URL}`);
console.log(` VITE_APP_TAG=${env.VITE_APP_TAG} VITE_GIT_SHA=${env.VITE_GIT_SHA || "(none)"}`); console.log(` VITE_APP_TAG=${env.VITE_APP_TAG} VITE_GIT_SHA=${env.VITE_GIT_SHA || "(none)"}`);
console.log(` VITE_LIBS_SOURCE=${env.VITE_LIBS_SOURCE}${env.VITE_LIBS_MANIFEST_URL ? ` (${env.VITE_LIBS_MANIFEST_URL})` : ""}`); console.log(` VITE_LIBS_SOURCE=${env.VITE_LIBS_SOURCE}${env.VITE_LIBS_MANIFEST_URL ? ` (${env.VITE_LIBS_MANIFEST_URL})` : ""}`);
console.log(` VITE_LANDING_URL=${env.VITE_LANDING_URL} VITE_WAITLIST_URL=${env.VITE_WAITLIST_URL}`);
console.log(` VITE_PLAUSIBLE_DOMAIN=${env.VITE_PLAUSIBLE_DOMAIN || "(off)"}`);
// Keep the dev-only WASM symlink out of the bundle (it'd copy 100s of MB into // Keep the dev-only WASM symlink out of the bundle (it'd copy 100s of MB into
// dist/; the CDN serves it). In CI it isn't present, so this is a no-op there. // dist/; the CDN serves it). In CI it isn't present, so this is a no-op there.

View file

@ -42,6 +42,15 @@ export default defineConfig({
optional: true, optional: true,
default: 'PCBJam <hello@pcbjam.com>', default: 'PCBJam <hello@pcbjam.com>',
}), }),
// Comma-separated origins allowed to cross-post the waitlist form (e.g. the
// static demo at demo.pcbjam.com, which has no backend of its own). Not a
// secret — just config. Same-origin submits never hit this.
WAITLIST_ALLOWED_ORIGINS: envField.string({
context: 'server',
access: 'public',
optional: true,
default: 'https://demo.pcbjam.com',
}),
}, },
}, },
}); });

View file

@ -4,7 +4,12 @@ import { Resend } from 'resend';
// and RESEND_SEGMENT_ID are `string | undefined`; WAITLIST_FROM_EMAIL has a // and RESEND_SEGMENT_ID are `string | undefined`; WAITLIST_FROM_EMAIL has a
// schema default so it's always a `string`. The Vercel adapter reads these from // schema default so it's always a `string`. The Vercel adapter reads these from
// process.env at runtime — never inlined. // process.env at runtime — never inlined.
import { RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL } from 'astro:env/server'; import {
RESEND_API_KEY,
RESEND_SEGMENT_ID,
WAITLIST_FROM_EMAIL,
WAITLIST_ALLOWED_ORIGINS,
} from 'astro:env/server';
// Opt this single route into on-demand (serverless) rendering. The rest of the // Opt this single route into on-demand (serverless) rendering. The rest of the
// site stays static; Vercel emits exactly one function for /api/waitlist. // site stays static; Vercel emits exactly one function for /api/waitlist.
@ -12,10 +17,36 @@ export const prerender = false;
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
function json(status: number, body: Record<string, unknown>) { /**
* CORS headers when the request Origin is in the configured allowlist — lets the
* static demo (demo.pcbjam.com, no backend of its own) cross-post the form. A
* same-origin submit sends no Origin and gets no CORS headers (it doesn't need
* them). The JSON content-type triggers a preflight, hence the OPTIONS handler.
*/
function corsHeaders(request: Request): Record<string, string> {
const origin = request.headers.get('origin');
if (!origin) return {};
const allowed = WAITLIST_ALLOWED_ORIGINS.split(',')
.map((o) => o.trim())
.filter(Boolean);
if (!allowed.includes(origin)) return {};
return {
'access-control-allow-origin': origin,
'access-control-allow-methods': 'POST, OPTIONS',
'access-control-allow-headers': 'content-type',
'access-control-max-age': '86400',
vary: 'Origin',
};
}
function json(
status: number,
body: Record<string, unknown>,
extra: Record<string, string> = {},
) {
return new Response(JSON.stringify(body), { return new Response(JSON.stringify(body), {
status, status,
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json', ...extra },
}); });
} }
@ -27,7 +58,11 @@ function redirect(status: 'ok' | 'error') {
}); });
} }
export const OPTIONS: APIRoute = ({ request }) =>
new Response(null, { status: 204, headers: corsHeaders(request) });
export const POST: APIRoute = async ({ request }) => { export const POST: APIRoute = async ({ request }) => {
const cors = corsHeaders(request);
const ct = request.headers.get('content-type') ?? ''; const ct = request.headers.get('content-type') ?? '';
const wantsJson = ct.includes('application/json'); const wantsJson = ct.includes('application/json');
@ -37,7 +72,7 @@ export const POST: APIRoute = async ({ request }) => {
? await request.json() ? await request.json()
: Object.fromEntries(await request.formData()); : Object.fromEntries(await request.formData());
} catch { } catch {
return wantsJson ? json(400, { ok: false, error: 'bad_request' }) : redirect('error'); return wantsJson ? json(400, { ok: false, error: 'bad_request' }, cors) : redirect('error');
} }
const email = String(data.email ?? '').trim().toLowerCase(); const email = String(data.email ?? '').trim().toLowerCase();
@ -45,16 +80,16 @@ export const POST: APIRoute = async ({ request }) => {
const source = String(data.source ?? 'unknown'); const source = String(data.source ?? 'unknown');
// Bot caught by honeypot: silently "succeed" so we don't tip them off. // Bot caught by honeypot: silently "succeed" so we don't tip them off.
if (honeypot) return wantsJson ? json(200, { ok: true }) : redirect('ok'); if (honeypot) return wantsJson ? json(200, { ok: true }, cors) : redirect('ok');
if (!EMAIL_RE.test(email) || email.length > 254) { if (!EMAIL_RE.test(email) || email.length > 254) {
return wantsJson ? json(400, { ok: false, error: 'invalid_email' }) : redirect('error'); return wantsJson ? json(400, { ok: false, error: 'invalid_email' }, cors) : redirect('error');
} }
// No key configured (e.g. local dev without secrets): accept + log, don't 500. // No key configured (e.g. local dev without secrets): accept + log, don't 500.
if (!RESEND_API_KEY) { if (!RESEND_API_KEY) {
console.warn(`[waitlist] RESEND_API_KEY not set — skipping send. email=${email} source=${source}`); console.warn(`[waitlist] RESEND_API_KEY not set — skipping send. email=${email} source=${source}`);
return wantsJson ? json(200, { ok: true }) : redirect('ok'); return wantsJson ? json(200, { ok: true }, cors) : redirect('ok');
} }
const resend = new Resend(RESEND_API_KEY); const resend = new Resend(RESEND_API_KEY);
@ -98,14 +133,14 @@ export const POST: APIRoute = async ({ request }) => {
// rejected it (bad key, unverified domain, invalid from-address, …). // rejected it (bad key, unverified domain, invalid from-address, …).
if (sendError) { if (sendError) {
console.error('[waitlist] emails.send failed', sendError); console.error('[waitlist] emails.send failed', sendError);
return wantsJson ? json(502, { ok: false, error: 'send_failed' }) : redirect('error'); return wantsJson ? json(502, { ok: false, error: 'send_failed' }, cors) : redirect('error');
} }
return wantsJson ? json(200, { ok: true }) : redirect('ok'); return wantsJson ? json(200, { ok: true }, cors) : redirect('ok');
} catch (err) { } catch (err) {
// Defensive: unexpected throw (network error, bad construction). // Defensive: unexpected throw (network error, bad construction).
console.error('[waitlist] unexpected error', err); console.error('[waitlist] unexpected error', err);
return wantsJson ? json(502, { ok: false, error: 'send_failed' }) : redirect('error'); return wantsJson ? json(502, { ok: false, error: 'send_failed' }, cors) : redirect('error');
} }
}; };

View file

@ -31,6 +31,21 @@ VITE_WASM_ROOT=/wasm
# <repo>/tests/apps/kicad). Useful when serving prebuilt artifacts from elsewhere. # <repo>/tests/apps/kicad). Useful when serving prebuilt artifacts from elsewhere.
# WASM_SRC_DIR= # WASM_SRC_DIR=
# Marketing / landing page the version badge links to (default https://pcbjam.com).
# VITE_LANDING_URL=https://pcbjam.com
# Where the in-editor waitlist form POSTs (default https://pcbjam.com/api/waitlist).
# The demo is static with no backend, so it cross-posts to the landing site's
# serverless endpoint, which must send CORS for this origin (see
# site/src/pages/api/waitlist.ts). Same JSON contract {email, source, company_url}.
# VITE_WAITLIST_URL=https://pcbjam.com/api/waitlist
# Plausible analytics. Unset ⇒ no tracking (dev/checkout default). Set the
# data-domain to enable; override the script URL only if the default plausible.io
# script won't load under COEP require-corp (then self-host/proxy on cdn.pcbjam.com).
# VITE_PLAUSIBLE_DOMAIN=demo.pcbjam.com
# VITE_PLAUSIBLE_SRC=https://plausible.io/js/script.js
# Where a backend project's DOCUMENT content lives ("api" default | "ydoc"). # Where a backend project's DOCUMENT content lives ("api" default | "ydoc").
# Same /p/<project> URLs either way. "api": file bytes come from the REST # Same /p/<project> URLs either way. "api": file bytes come from the REST
# backend and a user save (File->Save in the editor) is uploaded back to it. # backend and a user save (File->Save in the editor) is uploaded back to it.

View file

@ -0,0 +1,147 @@
import * as React from "react";
import { ChevronRight, CornerLeftUp, Folder } from "lucide-react";
import { formatBytes } from "@/lib/utils";
export interface TreeFile {
path: string;
size?: number;
}
/** Immediate children of `dir` ("" = root, else ends with "/"): subfolder names
* and the files that live directly in `dir` (not in a nested folder). */
function childrenOf(files: TreeFile[], dir: string) {
const folders = new Map<string, number>(); // name -> descendant count
const here: TreeFile[] = [];
for (const f of files) {
if (dir && !f.path.startsWith(dir)) continue;
const rest = f.path.slice(dir.length);
const slash = rest.indexOf("/");
if (slash < 0) {
here.push(f);
} else {
const name = rest.slice(0, slash);
folders.set(name, (folders.get(name) ?? 0) + 1);
}
}
return {
folders: [...folders.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)),
files: here.sort((a, b) => (a.path < b.path ? -1 : 1)),
};
}
function baseName(path: string): string {
const i = path.lastIndexOf("/");
return i < 0 ? path : path.slice(i + 1);
}
/**
* A navigable file list that understands directories: files are grouped into
* folders (e.g. `.history/`, `.git/`) you click to descend into, with a
* breadcrumb to climb back out. The per-file actions (open/download) are
* supplied by the caller via `renderActions(path)` so the same tree serves both
* the backend/gallery ProjectView and the local-folder LocalProjectView.
*/
export function FileTree({
files,
renderActions,
emptyText = "No files.",
}: {
files: TreeFile[];
renderActions?: (path: string) => React.ReactNode;
emptyText?: string;
}) {
const [dir, setDir] = React.useState(""); // "" or "a/b/"
// A folder we were in can vanish (deleted file) — fall back to root.
React.useEffect(() => {
if (dir && !files.some((f) => f.path.startsWith(dir))) setDir("");
}, [dir, files]);
const { folders, files: filesHere } = childrenOf(files, dir);
const segs = dir ? dir.slice(0, -1).split("/") : [];
const parent = segs.length > 1 ? segs.slice(0, -1).join("/") + "/" : "";
return (
<div>
{/* Breadcrumb */}
<div className="mb-2 flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
<button
type="button"
className="hover:text-foreground hover:underline"
onClick={() => setDir("")}
>
root
</button>
{segs.map((s, i) => {
const upto = segs.slice(0, i + 1).join("/") + "/";
return (
<React.Fragment key={upto}>
<ChevronRight size={13} className="opacity-50" />
<button
type="button"
className="font-mono hover:text-foreground hover:underline"
onClick={() => setDir(upto)}
>
{s}
</button>
</React.Fragment>
);
})}
</div>
<div className="divide-y rounded-lg border">
{dir && (
<button
type="button"
className="flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm text-muted-foreground hover:bg-accent"
onClick={() => setDir(parent)}
>
<CornerLeftUp size={15} /> ..
</button>
)}
{folders.map(([name, count]) => (
<button
type="button"
key={name}
className="flex w-full items-center justify-between gap-4 px-4 py-2.5 text-left hover:bg-accent"
onClick={() => setDir(`${dir}${name}/`)}
>
<span className="flex min-w-0 items-center gap-2">
<Folder size={15} className="shrink-0 text-muted-foreground" />
<span className="truncate font-mono text-sm">{name}/</span>
</span>
<span className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
{count} item{count === 1 ? "" : "s"}
<ChevronRight size={14} />
</span>
</button>
))}
{filesHere.map((f) => (
<div
key={f.path}
className="flex items-center justify-between gap-4 px-4 py-2.5"
>
<div className="min-w-0">
<p className="truncate font-mono text-sm">{baseName(f.path)}</p>
{f.size !== undefined && (
<p className="text-xs text-muted-foreground">
{formatBytes(f.size)}
</p>
)}
</div>
{renderActions && (
<div className="flex shrink-0 items-center gap-1">
{renderActions(f.path)}
</div>
)}
</div>
))}
{folders.length === 0 && filesHere.length === 0 && (
<div className="px-4 py-6 text-sm text-muted-foreground">{emptyText}</div>
)}
</div>
</div>
);
}

View file

@ -1,13 +1,13 @@
import { import {
EXTENSION_TOOL, EXTENSION_TOOL,
FILELESS_TOOLS,
LIB_EXTENSION_TOOL, LIB_EXTENSION_TOOL,
TOOL_LABELS, TOOL_LABELS,
type Tool, type Tool,
} from "@pcbjam/shared"; } from "@pcbjam/shared";
import { ArrowLeft, ExternalLink, FolderOpen, Library } from "lucide-react"; import { ArrowLeft, ExternalLink, FolderOpen, Library } from "lucide-react";
import { formatBytes } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ToolGrid } from "@/components/ToolGrid";
import { FileTree } from "@/components/FileTree";
export interface LocalFile { export interface LocalFile {
path: string; path: string;
@ -29,12 +29,12 @@ function libToolForPath(path: string): Tool | null {
} }
/** /**
* The local-folder twin of ProjectView: list the picked folder's files and open * The local-folder twin of ProjectView: the same iconed tool launcher + a
* one in its tool. Unlike backend projects this CANNOT navigate (the folder * navigable folder tree of the picked folder's files. Unlike backend projects
* handles / File objects live in this page's JS memory and don't survive a * this CANNOT navigate (the folder handles / File objects live in this page's JS
* reload), so opening is a callback that swaps this view for the editor * memory and don't survive a reload), so opening is a callback that swaps this
* in-place — and "Back" only works until a tool has launched (the WASM runtime * view for the editor in-place — and "Back" only works until a tool has launched
* is one-shot per page load). * (the WASM runtime is one-shot per page load).
*/ */
export function LocalProjectView({ export function LocalProjectView({
name, name,
@ -45,12 +45,37 @@ export function LocalProjectView({
}: { }: {
name: string; name: string;
files: LocalFile[]; files: LocalFile[];
/** Launch a tool; `path` is undefined for file-less tools. */ /** Launch a tool; `path` is undefined for file-less / blank-document launches. */
onOpen: (tool: Tool, path?: string) => void; onOpen: (tool: Tool, path?: string) => void;
/** Open a local library FILE (.kicad_sym/.kicad_mod) scoped in its editor. */ /** Open a local library FILE (.kicad_sym/.kicad_mod) scoped in its editor. */
onOpenLib: (tool: Tool, path: string) => void; onOpenLib: (tool: Tool, path: string) => void;
onBack: () => void; onBack: () => void;
}) { }) {
const renderActions = (path: string) => {
const tool = toolForPath(path);
const libTool = tool ? null : libToolForPath(path);
return (
<>
{tool && (
<button
className="inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
onClick={() => onOpen(tool, path)}
>
<ExternalLink size={14} /> Open in {TOOL_LABELS[tool]}
</button>
)}
{libTool && (
<button
className="inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
onClick={() => onOpenLib(libTool, path)}
>
<Library size={14} /> Open in {TOOL_LABELS[libTool]}
</button>
)}
</>
);
};
return ( return (
<div className="container py-10"> <div className="container py-10">
<Button variant="ghost" size="sm" className="mb-4" onClick={onBack}> <Button variant="ghost" size="sm" className="mb-4" onClick={onBack}>
@ -66,59 +91,18 @@ export function LocalProjectView({
</p> </p>
</div> </div>
<div className="mb-6 flex flex-wrap gap-3"> {/* Tools — same iconed launcher as the home page. */}
{[...FILELESS_TOOLS].map((tool) => ( <section className="mb-8">
<button <h2 className="mb-3 text-lg font-medium">Tools</h2>
key={tool} <ToolGrid onLaunch={(tool) => onOpen(tool)} />
className="text-sm underline underline-offset-4" </section>
onClick={() => onOpen(tool)}
>
Open {TOOL_LABELS[tool]}
</button>
))}
</div>
<h2 className="mb-3 text-lg font-medium">Files ({files.length})</h2> <h2 className="mb-3 text-lg font-medium">Files ({files.length})</h2>
<div className="divide-y rounded-lg border"> <FileTree
{files.map((f) => { files={files}
const tool = toolForPath(f.path); renderActions={renderActions}
const libTool = tool ? null : libToolForPath(f.path); emptyText="No files in this folder."
return ( />
<div
key={f.path}
className="flex items-center justify-between gap-4 px-4 py-2.5"
>
<div className="min-w-0">
<p className="truncate font-mono text-sm">{f.path}</p>
{f.size !== undefined && (
<p className="text-xs text-muted-foreground">{formatBytes(f.size)}</p>
)}
</div>
{tool && (
<button
className="inline-flex shrink-0 items-center gap-1 rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
onClick={() => onOpen(tool, f.path)}
>
<ExternalLink size={14} /> Open in {TOOL_LABELS[tool]}
</button>
)}
{libTool && (
<button
className="inline-flex shrink-0 items-center gap-1 rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
onClick={() => onOpenLib(libTool, f.path)}
>
<Library size={14} /> Open in {TOOL_LABELS[libTool]}
</button>
)}
</div>
);
})}
{files.length === 0 && (
<div className="px-4 py-6 text-sm text-muted-foreground">
No files in this folder.
</div>
)}
</div>
</div> </div>
); );
} }

View file

@ -1,5 +1,5 @@
import { Github } from "lucide-react"; import { ExternalLink, Github } from "lucide-react";
import { APP_GIT_SHA, APP_TAG, REPO_URL } from "@/lib/config"; import { APP_GIT_SHA, APP_TAG, LANDING_URL, REPO_URL } from "@/lib/config";
/** /**
* Small bottom-right overlay showing this build's version + a link to the * Small bottom-right overlay showing this build's version + a link to the
@ -44,6 +44,16 @@ export function VersionBadge() {
> >
<Github size={12} /> source <Github size={12} /> source
</a> </a>
<span className="text-white/30">·</span>
<a
href={LANDING_URL}
target="_blank"
rel="noreferrer"
title="PCBJam — product page"
className="inline-flex items-center gap-1 hover:text-white"
>
pcbjam.com <ExternalLink size={11} />
</a>
</div> </div>
); );
} }

View file

@ -0,0 +1,100 @@
import * as React from "react";
import { Loader2 } from "lucide-react";
import { LANDING_URL, WAITLIST_URL } from "@/lib/config";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
/**
* Email capture for the standalone home page — a React port of the landing
* site's WaitlistForm.astro. The demo deploy has no backend, so it cross-posts
* the same JSON contract to the landing site's serverless endpoint (WAITLIST_URL,
* which sends CORS for this origin). Honeypot `company_url` mirrors the site form.
*/
export function WaitlistForm({ source = "standalone" }: { source?: string }) {
const [email, setEmail] = React.useState("");
const [honeypot, setHoneypot] = React.useState("");
const [state, setState] = React.useState<"idle" | "busy" | "ok" | "error">(
"idle",
);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (state === "busy") return;
setState("busy");
try {
const res = await fetch(WAITLIST_URL, {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ email, source, company_url: honeypot }),
});
const data = (await res.json().catch(() => ({}))) as { ok?: boolean };
setState(res.ok && data.ok ? "ok" : "error");
} catch {
setState("error");
}
};
return (
<section className="mb-10 rounded-lg border bg-card p-5">
<h2 className="mb-1 text-lg font-medium">Get early-access invites</h2>
{state === "ok" ? (
<p className="mt-2 text-sm">
<strong>You're on the list. 🎉</strong>{" "}
<span className="text-muted-foreground">
We'll be in touch with product updates and your early-access invite.
</span>
</p>
) : (
<>
<p className="mb-4 text-sm text-muted-foreground">
Join the waitlist for product updates and your invite — no spam,
unsubscribe anytime.
</p>
<form onSubmit={submit} className="flex flex-col gap-2 sm:flex-row">
<Input
type="email"
name="email"
required
autoComplete="email"
placeholder="you@company.com"
value={email}
disabled={state === "busy"}
onChange={(e) => setEmail(e.target.value)}
className="sm:max-w-xs"
/>
{/* Honeypot — humans never see/fill this. */}
<input
type="text"
name="company_url"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
className="hidden"
/>
<Button type="submit" disabled={state === "busy"}>
{state === "busy" && <Loader2 className="animate-spin" size={15} />}
Join the waitlist
</Button>
</form>
{state === "error" && (
<p className="mt-2 text-sm text-destructive">
Please enter a valid email address and try again.
</p>
)}
<p className="mt-2 text-xs text-muted-foreground">
<a
href={`${LANDING_URL}/privacy`}
target="_blank"
rel="noreferrer"
className="underline underline-offset-4"
>
Privacy policy
</a>
</p>
</>
)}
</section>
);
}

View file

@ -568,6 +568,13 @@ export function WasmTool({
// Editor lifecycle for the loading chrome: false until the tool has booted + // Editor lifecycle for the loading chrome: false until the tool has booted +
// opened (covers the big WASM-compile freeze with a full-screen overlay). // opened (covers the big WASM-compile freeze with a full-screen overlay).
const [ready, setReady] = React.useState(false); const [ready, setReady] = React.useState(false);
// Download progress for the (large) wasm, and a "this is taking too long" flag
// the overlay raises after a while so a stuck load doesn't read as a silent hang.
const [progress, setProgress] = React.useState<{
loaded: number;
total: number;
} | null>(null);
const [slow, setSlow] = React.useState(false);
// A library item currently being fetched (open/save), for a transient spinner. // A library item currently being fetched (open/save), for a transient spinner.
const [libBusy, setLibBusy] = React.useState<string | null>(null); const [libBusy, setLibBusy] = React.useState<string | null>(null);
// Load-screen pre-sync progress: warming the project's lib bundles into IDB in // Load-screen pre-sync progress: warming the project's lib bundles into IDB in
@ -615,6 +622,15 @@ export function WasmTool({
return () => clearTimeout(t); return () => clearTimeout(t);
}, [libError]); }, [libError]);
// "Taking too long": once the tool has been loading for a while without
// becoming ready, surface a hint (slow link / something may be wrong) + a
// reload, so a stalled boot doesn't look like a frozen blank screen.
React.useEffect(() => {
if (ready) return;
const t = setTimeout(() => setSlow(true), 60_000);
return () => clearTimeout(t);
}, [ready]);
React.useEffect(() => { React.useEffect(() => {
const removeNavigationHook = installToolNavigationHook(window as ToolWindow, { const removeNavigationHook = installToolNavigationHook(window as ToolWindow, {
slug, slug,
@ -700,6 +716,7 @@ export function WasmTool({
log: append, log: append,
onStatus: setStatus, onStatus: setStatus,
onAbort: oom.onAbort, onAbort: oom.onAbort,
onProgress: (loaded, total) => setProgress({ loaded, total }),
libsSource: source, libsSource: source,
}); });
// Register the save sink before the file opens: from here on, every // Register the save sink before the file opens: from here on, every
@ -863,12 +880,27 @@ export function WasmTool({
<p className="font-mono text-sm text-white/80"> <p className="font-mono text-sm text-white/80">
{status || "Loading…"} {status || "Loading…"}
</p> </p>
<DownloadProgress progress={progress} />
{libSync && ( {libSync && (
<p className="font-mono text-xs text-emerald-300/90">{libSync}</p> <p className="font-mono text-xs text-emerald-300/90">{libSync}</p>
)} )}
<p className="font-mono text-xs text-white/40"> <p className="font-mono text-xs text-white/40">
First load downloads the tool (large) — this can take a moment. First load downloads the tool (large) — this can take a moment.
</p> </p>
{slow && (
<>
<p className="max-w-sm px-6 text-center font-mono text-xs text-amber-300/90">
This is taking longer than usual — a slow connection, or
something may be wrong. You can keep waiting, or reload.
</p>
<button
className="rounded border border-white/30 px-3 py-1 text-xs hover:bg-white/10"
onClick={() => window.location.reload()}
>
Reload
</button>
</>
)}
</> </>
)} )}
</div> </div>
@ -931,3 +963,42 @@ export function WasmTool({
</div> </div>
); );
} }
/**
* WASM download progress for the boot overlay. A determinate bar when the server
* sent a Content-Length the decoded stream agrees with; otherwise just MB so far
* (gzip/br makes Content-Length the COMPRESSED size, so `loaded` can pass it).
*/
function DownloadProgress({
progress,
}: {
progress: { loaded: number; total: number } | null;
}) {
if (!progress) return null;
const mb = (n: number) => `${(n / 1e6).toFixed(1)} MB`;
const determinate = progress.total > 0 && progress.loaded <= progress.total;
const pct = determinate
? Math.round((progress.loaded / progress.total) * 100)
: 0;
return (
<div className="w-64 max-w-[80vw]">
{determinate ? (
<>
<div className="h-1.5 w-full overflow-hidden rounded bg-white/15">
<div
className="h-full rounded bg-white/70 transition-[width]"
style={{ width: `${pct}%` }}
/>
</div>
<p className="mt-1 text-center font-mono text-xs text-white/50">
{mb(progress.loaded)} / {mb(progress.total)} ({pct}%)
</p>
</>
) : (
<p className="text-center font-mono text-xs text-white/50">
{mb(progress.loaded)} downloaded…
</p>
)}
</div>
);
}

View file

@ -0,0 +1,24 @@
import { PLAUSIBLE_DOMAIN, PLAUSIBLE_SRC } from "./config";
/**
* Inject the Plausible analytics script when a domain is configured
* (VITE_PLAUSIBLE_DOMAIN). No-op otherwise, so a plain dev checkout — and any
* deploy that doesn't set the var — stays untracked. Cookieless + privacy-
* friendly, so no consent banner is required.
*
* `crossorigin="anonymous"` is required because the demo is served cross-origin
* isolated (COEP `require-corp`): a third-party script must be fetched with CORS.
* If plausible.io ever fails to load under that policy, point VITE_PLAUSIBLE_SRC
* at a self-hosted/proxied copy on cdn.pcbjam.com (which sets CORP).
*/
export function initAnalytics(): void {
if (!PLAUSIBLE_DOMAIN || typeof document === "undefined") return;
if (document.querySelector("script[data-pcbjam-analytics]")) return;
const s = document.createElement("script");
s.defer = true;
s.setAttribute("data-domain", PLAUSIBLE_DOMAIN);
s.setAttribute("data-pcbjam-analytics", "");
s.crossOrigin = "anonymous";
s.src = PLAUSIBLE_SRC;
document.head.appendChild(s);
}

View file

@ -1,7 +1,8 @@
import type { DriftReportBody, Lib, Project } from "@pcbjam/shared"; import type { DriftReportBody, 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, libsSourceConfig } from "./config";
import { client } from "./contract-client"; import { client } from "./contract-client";
import type { LibInfo } from "@/wasm/libs/source";
import { downloadBytes } from "./download"; import { downloadBytes } from "./download";
import { import {
ReadOnlyProjectError, ReadOnlyProjectError,
@ -48,18 +49,18 @@ export function useSourceDescriptor(slug: string) {
} }
/** /**
* Libraries the backend serves, optionally filtered to a kind. In static (no * Libraries the editor can browse, optionally filtered to a kind — sourced from
* backend) mode there are none, so we short-circuit to an empty list rather than * the ACTIVE libs source (lib/config `libsSourceConfig`), not the REST backend
* fire a doomed request. * directly. So the demo's read-only CDN/R2 set (VITE_LIBS_SOURCE=cdn) lists here
* the same as a backend's libs would; "off" ⇒ none. Each lib deep-links to
* /l/<id>/<tool> (LibToolPage), which boots the editor scoped to that one lib.
*/ */
export function useLibs(kind?: "symbol" | "footprint") { export function useLibs(kind?: "symbol" | "footprint") {
return useQuery({ return useQuery({
queryKey: ["libs", kind ?? "all"], queryKey: ["libs", kind ?? "all"],
queryFn: async (): Promise<Lib[]> => { queryFn: async (): Promise<LibInfo[]> => {
if (PROJECT_SOURCE_KIND === "static") return []; const source = libsSourceConfig();
const res = await client.listLibs({ query: { kind } }); return source ? source.listLibs(kind) : [];
if (res.status !== 200) throw new Error("failed to list libraries");
return res.body;
}, },
}); });
} }

View file

@ -44,6 +44,31 @@ export const REPO_URL = (
import.meta.env.VITE_REPO_URL || "https://github.com/emergence-engineering/pcbjam" import.meta.env.VITE_REPO_URL || "https://github.com/emergence-engineering/pcbjam"
).replace(/\/+$/, ""); ).replace(/\/+$/, "");
/** Marketing / landing page (no trailing slash). The version badge links here so
* someone in the editor can reach the product page. */
export const LANDING_URL = (
import.meta.env.VITE_LANDING_URL || "https://pcbjam.com"
).replace(/\/+$/, "");
/**
* Where the in-editor waitlist form POSTs. The demo is a fully static deploy with
* no backend, so it cross-posts to the landing site's serverless endpoint (which
* sends CORS for this origin). Same JSON contract as site/src/pages/api/waitlist.ts.
*/
export const WAITLIST_URL =
import.meta.env.VITE_WAITLIST_URL || "https://pcbjam.com/api/waitlist";
/**
* Plausible analytics (privacy-friendly, cookieless). Off unless a domain is set
* (plain dev/checkout stays untracked). `PLAUSIBLE_DOMAIN` is the `data-domain`
* the dashboard is keyed by; `PLAUSIBLE_SRC` is the script URL (override to a
* self-hosted/proxied script — e.g. on cdn.pcbjam.com — if plausible.io won't
* load under the demo's COEP `require-corp`). See main.tsx for the injection.
*/
export const PLAUSIBLE_DOMAIN = import.meta.env.VITE_PLAUSIBLE_DOMAIN || null;
export const PLAUSIBLE_SRC =
import.meta.env.VITE_PLAUSIBLE_SRC || "https://plausible.io/js/script.js";
/** /**
* Where the standalone reads PROJECTS from (env VITE_PROJECT_SOURCE): * Where the standalone reads PROJECTS from (env VITE_PROJECT_SOURCE):
* "remote" (default) — the @pcbjam/shared REST backend at API_BASE_URL. * "remote" (default) — the @pcbjam/shared REST backend at API_BASE_URL.

View file

@ -48,8 +48,18 @@ export interface NewFile {
* read interface doesn't cover (home-page project management). */ * read interface doesn't cover (home-page project management). */
export interface LocalProjectStore extends ProjectSource { export interface LocalProjectStore extends ProjectSource {
hasProject(slug: string): Promise<boolean>; hasProject(slug: string): Promise<boolean>;
/** Import/create a project from files; returns it with its (deduped) slug. */ /**
createProject(name: string, files: NewFile[]): Promise<Project>; * Import/create a project from files; returns it with its slug. A slug is
* derived from the name and deduped (`-2`, `-3`…) unless `opts.slug` pins an
* explicit one — used by the gallery "move to local" shadow fork, which reuses
* the gallery's slug so the composite source routes that `/p/:slug` to the
* local copy (caller guarantees it isn't already taken).
*/
createProject(
name: string,
files: NewFile[],
opts?: { slug?: string },
): Promise<Project>;
deleteProject(slug: string): Promise<void>; deleteProject(slug: string): Promise<void>;
renameProject(slug: string, name: string): Promise<void>; renameProject(slug: string, name: string): Promise<void>;
/** All of a project's files with bytes, in one read transaction (for export). */ /** All of a project's files with bytes, in one read transaction (for export). */
@ -195,15 +205,25 @@ export function idbProjectStore(): LocalProjectStore {
return (await getRecord(slug)) !== null; return (await getRecord(slug)) !== null;
}, },
async createProject(name: string, files: NewFile[]): Promise<Project> { async createProject(
name: string,
files: NewFile[],
opts?: { slug?: string },
): Promise<Project> {
const d = await db(); const d = await db();
const existing = (await reqDone( const existing = (await reqDone(
d.transaction(PROJECTS, "readonly").objectStore(PROJECTS).getAllKeys(), d.transaction(PROJECTS, "readonly").objectStore(PROJECTS).getAllKeys(),
)) as string[]; )) as string[];
const taken = new Set(existing); const taken = new Set(existing);
const baseSlug = slugify(name); let slug: string;
let slug = baseSlug; if (opts?.slug) {
for (let i = 2; taken.has(slug); i++) slug = `${baseSlug}-${i}`; // Caller pins the slug (shadow fork) — use verbatim, no dedup.
slug = opts.slug;
} else {
const baseSlug = slugify(name);
slug = baseSlug;
for (let i = 2; taken.has(slug); i++) slug = `${baseSlug}-${i}`;
}
const ts = nowIso(); const ts = nowIso();
const rec: ProjectRecord = { slug, name, createdAt: ts, updatedAt: ts }; const rec: ProjectRecord = { slug, name, createdAt: ts, updatedAt: ts };

View file

@ -2,8 +2,12 @@ import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import App from "./App"; import App from "./App";
import { initAnalytics } from "./lib/analytics";
import "./index.css"; import "./index.css";
// Privacy-friendly analytics (Plausible), only when VITE_PLAUSIBLE_DOMAIN is set.
initAnalytics();
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }, defaultOptions: { queries: { retry: false } },
}); });

View file

@ -12,8 +12,10 @@ import { importFileList, importFsaFolder } from "@/lib/import-folder";
import { localProjectStore } from "@/lib/project-source"; import { localProjectStore } from "@/lib/project-source";
import { isDocumentTool } from "@/lib/new-file"; import { isDocumentTool } from "@/lib/new-file";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ToolGrid } from "@/components/ToolGrid"; import { ToolGrid } from "@/components/ToolGrid";
import { ProjectsSection } from "@/components/ProjectsSection"; import { ProjectsSection } from "@/components/ProjectsSection";
import { WaitlistForm } from "@/components/WaitlistForm";
import { NewFileDialog } from "@/components/NewFileDialog"; import { NewFileDialog } from "@/components/NewFileDialog";
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";
@ -280,6 +282,9 @@ export function HomePage() {
a per-row source chip) --- */} a per-row source chip) --- */}
<ProjectsSection /> <ProjectsSection />
{/* --- Waitlist signup (sits between projects and tools) --- */}
<WaitlistForm />
{/* --- 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>
@ -294,34 +299,35 @@ export function HomePage() {
<NewFileDialog tool={newFileTool} onClose={() => setNewFileTool(null)} /> <NewFileDialog tool={newFileTool} onClose={() => setNewFileTool(null)} />
{/* --- Backend libraries (hidden in the no-backend static demo) --- */} {/* --- Libraries (the configured source: the read-only CDN/R2 set in the
{!staticMode && ( demo, or a backend's libs). Each lib opens scoped in its editor. --- */}
<section> <section>
<h2 className="mb-3 text-lg font-medium">Libraries from the backend</h2> <h2 className="mb-3 text-lg font-medium">Libraries</h2>
<div className="space-y-3"> <div className="space-y-3">
<LibGroup <LibGroup
icon={<Library size={16} />} icon={<Library size={16} />}
label="Symbols" label="Symbols"
query={symbolLibs} query={symbolLibs}
tool="symbol_editor" tool="symbol_editor"
/> />
<LibGroup <LibGroup
icon={<Package size={16} />} icon={<Package size={16} />}
label="Footprints" label="Footprints"
query={footprintLibs} query={footprintLibs}
tool="footprint_editor" tool="footprint_editor"
/> />
</div> </div>
</section> </section>
)}
</div> </div>
); );
} }
/** /**
* One backend-library group (Symbols / Footprints): each lib is a deep-link to * One library group (Symbols / Footprints): each lib is a deep-link to
* `/l/<libId>/<tool>` (LibToolPage), which boots the editor scoped to that one * `/l/<libId>/<tool>` (LibToolPage), which boots the editor scoped to that one
* library. A full navigation (anchor) gives Emscripten a clean page. * library. A full navigation (anchor) gives Emscripten a clean page. The full
* KiCad set is hundreds of libs, so past a threshold we add a name filter and
* scroll the chip list.
*/ */
function LibGroup({ function LibGroup({
icon, icon,
@ -335,10 +341,22 @@ function LibGroup({
tool: Tool; tool: Tool;
}) { }) {
const { data: libs, isLoading, error } = query; const { data: libs, isLoading, error } = query;
const [filter, setFilter] = React.useState("");
const showFilter = (libs?.length ?? 0) > 12;
const needle = filter.trim().toLowerCase();
const shown = needle
? (libs ?? []).filter((l) => l.name.toLowerCase().includes(needle))
: libs ?? [];
return ( return (
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<h3 className="mb-2 flex items-center gap-2 text-sm font-medium"> <h3 className="mb-2 flex items-center gap-2 text-sm font-medium">
{icon} {label} {icon} {label}
{libs && libs.length > 0 && (
<span className="text-xs font-normal text-muted-foreground">
{libs.length}
</span>
)}
</h3> </h3>
{isLoading && ( {isLoading && (
<p className="flex items-center gap-2 text-sm text-muted-foreground"> <p className="flex items-center gap-2 text-sm text-muted-foreground">
@ -346,31 +364,47 @@ function LibGroup({
</p> </p>
)} )}
{error && ( {error && (
<p className="text-sm text-muted-foreground">No backend reachable.</p> <p className="text-sm text-muted-foreground">
Couldn't load libraries.
</p>
)} )}
{libs && libs.length === 0 && ( {libs && libs.length === 0 && !isLoading && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No {label.toLowerCase()} libraries. No {label.toLowerCase()} libraries.
</p> </p>
)} )}
{libs && libs.length > 0 && ( {libs && libs.length > 0 && (
<div className="flex flex-wrap gap-2"> <>
{libs.map((lib) => ( {showFilter && (
<a <Input
key={lib.id} type="search"
href={`/l/${encodeURIComponent(lib.id)}/${tool}`} value={filter}
title={lib.description ?? undefined} placeholder={`Filter ${libs.length} ${label.toLowerCase()}…`}
className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-sm hover:bg-accent" onChange={(e) => setFilter(e.target.value)}
> className="mb-2 h-8"
{lib.name} />
{lib.itemCount !== undefined && ( )}
<span className="text-xs text-muted-foreground"> <div className="flex max-h-72 flex-wrap gap-2 overflow-y-auto">
{lib.itemCount} {shown.map((lib) => (
</span> <a
)} key={lib.id}
</a> href={`/l/${encodeURIComponent(lib.id)}/${tool}`}
))} title={lib.description ?? undefined}
</div> className="inline-flex h-fit items-center gap-1.5 rounded-md border px-2.5 py-1 text-sm hover:bg-accent"
>
{lib.name}
{lib.itemCount !== undefined && (
<span className="text-xs text-muted-foreground">
{lib.itemCount}
</span>
)}
</a>
))}
{shown.length === 0 && (
<p className="text-sm text-muted-foreground">No matches.</p>
)}
</div>
</>
)} )}
</div> </div>
); );

View file

@ -9,21 +9,19 @@ import {
} from "@pcbjam/shared"; } from "@pcbjam/shared";
import { import {
ArrowLeft, ArrowLeft,
Copy,
Download, Download,
ExternalLink, ExternalLink,
FilePlus,
Loader2, Loader2,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { fetchFileBytes, useProject, useSourceDescriptor } from "@/lib/api"; import { fetchFileBytes, useProject, useSourceDescriptor } from "@/lib/api";
import { downloadBytes } from "@/lib/download"; import { downloadBytes } from "@/lib/download";
import { localProjectStore } from "@/lib/project-source"; import { localProjectStore } from "@/lib/project-source";
import { DOCUMENT_TOOLS } from "@/lib/new-file";
import { zipFiles } from "@/lib/zip"; import { zipFiles } from "@/lib/zip";
import { formatBytes } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { SourceChip } from "@/components/SourceChip"; import { SourceChip } from "@/components/SourceChip";
import { ToolGrid } from "@/components/ToolGrid";
import { FileTree } from "@/components/FileTree";
import { NewFileDialog } from "@/components/NewFileDialog"; import { NewFileDialog } from "@/components/NewFileDialog";
function toolForPath(path: string): Tool | null { function toolForPath(path: string): Tool | null {
@ -33,11 +31,11 @@ function toolForPath(path: string): Tool | null {
} }
/** /**
* Project-centric view (mirrors the closed app): Files + a "New file" row whose * Project-centric view (mirrors the closed app): an iconed tool launcher (same
* document-tool buttons create a templated file in THIS project and open it, so * cards as the home page) + the project's files as a navigable folder tree.
* you always edit a real file that saves back. Writable projects (local IDB or a * Opening a read-only GALLERY project auto-"moves it to local" — it's copied
* remote-rw backend) get New / export; a read-only gallery project gets "Save a * into a writable browser-local project at the same slug, so the composite source
* copy to browser" (fork into editable IDB). The source chip says where edits go. * shadows the gallery and this view re-resolves as a local, editable project.
*/ */
export function ProjectView() { export function ProjectView() {
const { project: slug = "" } = useParams(); const { project: slug = "" } = useParams();
@ -46,12 +44,41 @@ export function ProjectView() {
const { data, isLoading, error } = useProject(slug); const { data, isLoading, error } = useProject(slug);
const { data: descriptor } = useSourceDescriptor(slug); const { data: descriptor } = useSourceDescriptor(slug);
const isLocal = descriptor?.kind === "local"; const isLocal = descriptor?.kind === "local";
const canFork = descriptor?.kind === "remote-ro" && !!localProjectStore(); const isRemoteRo = descriptor?.kind === "remote-ro";
const store = localProjectStore();
const [busy, setBusy] = React.useState(false); const [busy, setBusy] = React.useState(false);
const [newFileTool, setNewFileTool] = React.useState<Tool | null>(null); const [newFileTool, setNewFileTool] = React.useState<Tool | null>(null);
const [forkError, setForkError] = React.useState<string | null>(null);
const forkStartedRef = React.useRef(false);
// Auto "move to local": a read-only gallery project is copied into a writable
// browser-local project at the SAME slug. The composite source then routes this
// /p/:slug to the local copy (local wins), and invalidating the queries re-
// resolves this view as local — no button, no URL change. Runs once.
React.useEffect(() => {
if (!isRemoteRo || !store || !data || forkStartedRef.current) return;
forkStartedRef.current = true;
void (async () => {
try {
if (!(await store.hasProject(slug))) {
const files = await Promise.all(
data.files.map(async (f) => ({
path: f.path,
bytes: await fetchFileBytes(slug, f.path),
})),
);
await store.createProject(data.project.name, files, { slug });
}
await qc.invalidateQueries({ queryKey: ["local-projects"] });
await qc.invalidateQueries({ queryKey: ["source-descriptor", slug] });
await qc.invalidateQueries({ queryKey: ["project", slug] });
} catch (e) {
setForkError(e instanceof Error ? e.message : String(e));
}
})();
}, [isRemoteRo, store, data, slug, qc]);
const exportZip = async () => { const exportZip = async () => {
const store = localProjectStore();
if (!store) return; if (!store) return;
setBusy(true); setBusy(true);
try { try {
@ -66,7 +93,6 @@ export function ProjectView() {
}; };
const remove = async () => { const remove = async () => {
const store = localProjectStore();
if (!store || !data) return; if (!store || !data) return;
if (!window.confirm(`Delete "${data.project.name}" from this browser? This can't be undone.`)) if (!window.confirm(`Delete "${data.project.name}" from this browser? This can't be undone.`))
return; return;
@ -75,26 +101,44 @@ export function ProjectView() {
navigate("/"); navigate("/");
}; };
// Copy a read-only gallery project into an editable browser-local project. // A tool launched from the grid: file-less tools (gerber/calculator + the
const fork = async () => { // symbol/footprint editors, which load the full project lib set) open project-
const store = localProjectStore(); // scoped with a full reload; document tools create a templated file first.
if (!store || !data) return; const launchTool = (tool: Tool) => {
setBusy(true); if (FILELESS_TOOLS.has(tool)) {
try { window.location.assign(`/p/${encodeURIComponent(slug)}/${tool}/`);
const files = await Promise.all( } else {
data.files.map(async (f) => ({ setNewFileTool(tool);
path: f.path,
bytes: await fetchFileBytes(slug, f.path),
})),
);
const created = await store.createProject(data.project.name, files);
await qc.invalidateQueries({ queryKey: ["local-projects"] });
window.location.assign(`/p/${created.slug}`);
} catch {
setBusy(false);
} }
}; };
const renderFileActions = (path: string) => {
const tool = toolForPath(path);
return (
<>
{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(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}/${path}`}
>
<ExternalLink size={14} /> Open in {TOOL_LABELS[tool]}
</a>
)}
</>
);
};
const movingToLocal = isRemoteRo && !!store && !forkError;
return ( return (
<div className="container py-10"> <div className="container py-10">
<Button asChild variant="ghost" size="sm" className="mb-4"> <Button asChild variant="ghost" size="sm" className="mb-4">
@ -120,110 +164,52 @@ export function ProjectView() {
<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> </div>
{/* New file (writable projects): a templated doc, created then opened. */} {movingToLocal ? (
{descriptor?.writable && ( <p className="flex items-center gap-2 text-muted-foreground">
<div className="mb-6"> <Loader2 className="animate-spin" /> Preparing your editable copy…
<h2 className="mb-2 text-sm font-medium text-muted-foreground"> </p>
New file ) : (
</h2> <>
<div className="flex flex-wrap gap-2"> {forkError && (
{DOCUMENT_TOOLS.map((tool) => ( <p className="mb-4 text-sm text-destructive">
<Button Couldn't prepare a local copy: {forkError}
key={tool} </p>
variant="outline" )}
size="sm"
onClick={() => setNewFileTool(tool)} {/* Tools — same iconed launcher as the home page. */}
> <section className="mb-8">
<FilePlus size={14} /> {TOOL_LABELS[tool]} <h2 className="mb-3 text-lg font-medium">Tools</h2>
<ToolGrid onLaunch={launchTool} />
</section>
{/* Project-level actions (local projects). */}
{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> </Button>
))}
</div>
</div>
)}
{/* Project-level actions. */}
<div className="mb-6 flex flex-wrap gap-3">
{canFork && (
<Button variant="outline" size="sm" disabled={busy} onClick={() => void fork()}>
{busy ? <Loader2 className="animate-spin" size={15} /> : <Copy size={15} />}
Save a copy to browser
</Button>
)}
{isLocal && (
<>
<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>
{/* File-less tools (gerber viewer, calculator, lib editors) — still
project-scoped, opened without a target file. Full reload. */}
<div className="mb-6 flex flex-wrap gap-3">
{[...FILELESS_TOOLS].map((tool) => (
<a
key={tool}
className="text-sm underline underline-offset-4"
href={`/p/${slug}/${tool}/`}
>
Open {TOOL_LABELS[tool]}
</a>
))}
</div>
<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);
return (
<div
key={f.id}
className="flex items-center justify-between gap-4 px-4 py-2.5"
>
<div className="min-w-0">
<p className="truncate font-mono text-sm">{f.path}</p>
<p className="text-xs text-muted-foreground">
{formatBytes(f.size)}
</p>
</div>
<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> </div>
); )}
})}
{data.files.length === 0 && ( <h2 className="mb-3 text-lg font-medium">
<div className="px-4 py-6 text-sm text-muted-foreground"> Files ({data.files.length})
No files yet — use “New file” above to create one. </h2>
</div> <FileTree
)} files={data.files}
</div> renderActions={renderFileActions}
emptyText="No files yet — use the Tools above to create one."
/>
</>
)}
<NewFileDialog <NewFileDialog
tool={newFileTool} tool={newFileTool}

View file

@ -53,6 +53,10 @@ export interface BootOptions {
/** OOM recovery hook (feature 0002): emscripten `abort()` routes here so a /** OOM recovery hook (feature 0002): emscripten `abort()` routes here so a
* soft OOM can respawn a fresh tab. Optional — boot works without it. */ * soft OOM can respawn a fresh tab. Optional — boot works without it. */
onAbort?: (what: string) => void; onAbort?: (what: string) => void;
/** Download progress for the (large) `.wasm`, so the loading overlay can show a
* real bar. `total` is 0 when the server sends no usable Content-Length (or it
* disagrees with the decoded stream under gzip/br) — then show bytes, not a %. */
onProgress?: (loaded: number, total: number) => void;
/** Library source backing `window.kicadLibs`. Null/omitted disables libs /** Library source backing `window.kicadLibs`. Null/omitted disables libs
* (an empty sym-lib-table is seeded). Its libs become sym-lib-table rows. */ * (an empty sym-lib-table is seeded). Its libs become sym-lib-table rows. */
libsSource?: LibsSource | null; libsSource?: LibsSource | null;
@ -114,8 +118,49 @@ function pthreadWorkerScript(base: string, tool: Tool): string | Blob {
}); });
} }
/**
* Fetch the tool's `.wasm` ourselves so we can report download progress (the big,
* slow asset — 175–338 MB). Emscripten otherwise fetches it internally with no
* hook. We wrap the body in a byte-counting stream and return a fresh `Response`,
* so `WebAssembly.instantiateStreaming` still compiles AS IT DOWNLOADS — no full
* buffer, no extra peak memory (which would risk the very OOM we recover from).
*
* `Content-Length` is the COMPRESSED size when the CDN gzip/br-encodes the wasm,
* while the stream yields DECODED bytes — so `loaded` can exceed `total`. The
* caller treats `total` as unknown in that case and shows MB rather than a %.
*/
async function fetchWasmWithProgress(
url: string,
onProgress?: (loaded: number, total: number) => void,
): Promise<Response> {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`);
if (!res.body || !onProgress) return res;
const total = Number(res.headers.get("content-length")) || 0;
let loaded = 0;
const reader = res.body.getReader();
const stream = new ReadableStream<Uint8Array>({
async pull(controller) {
const { done, value } = await reader.read();
if (done) {
controller.close();
return;
}
loaded += value.byteLength;
onProgress(loaded, total);
controller.enqueue(value);
},
cancel(reason) {
return reader.cancel(reason);
},
});
// Preserve content-type (application/wasm) so instantiateStreaming accepts it.
return new Response(stream, { headers: res.headers });
}
async function doBoot(opts: BootOptions): Promise<void> { async function doBoot(opts: BootOptions): Promise<void> {
const { tool, base, container, log, onStatus, onAbort, libsSource } = opts; const { tool, base, container, log, onStatus, onAbort, onProgress, libsSource } =
opts;
const w = window as ToolWindow; const w = window as ToolWindow;
// The wasm reads the top-level frame geometry from a GLOBAL `mainWindow` // The wasm reads the top-level frame geometry from a GLOBAL `mainWindow`
@ -302,6 +347,38 @@ async function doBoot(opts: BootOptions): Promise<void> {
// Pin the pthread worker script. Same-origin → direct URL; cross-origin CDN // Pin the pthread worker script. Same-origin → direct URL; cross-origin CDN
// → a same-origin blob shim that importScripts the glue (see helper above). // → a same-origin blob shim that importScripts the glue (see helper above).
mainScriptUrlOrBlob: pthreadWorkerScript(base, tool), mainScriptUrlOrBlob: pthreadWorkerScript(base, tool),
// Own the wasm fetch so we can report download progress (see helper). Streams
// straight into the compiler; passing `module` to the callback lets emscripten
// share it with the pthread workers (this hook fires on the main thread only).
instantiateWasm: (
imports: WebAssembly.Imports,
success: (instance: WebAssembly.Instance, module: WebAssembly.Module) => void,
): Record<string, never> => {
void (async () => {
try {
onStatus("Downloading…");
const resp = await fetchWasmWithProgress(`${base}/${tool}.wasm`, onProgress);
const ct = resp.headers.get("content-type") ?? "";
if (ct.includes("application/wasm") && WebAssembly.instantiateStreaming) {
const { instance, module } = await WebAssembly.instantiateStreaming(
resp,
imports,
);
success(instance, module);
} else {
// Fallback (no streaming, or a server that mislabels the MIME type):
// buffer then compile. Costs peak memory but always works.
const bytes = await resp.arrayBuffer();
const { instance, module } = await WebAssembly.instantiate(bytes, imports);
success(instance, module);
}
} catch (e) {
log(`[boot] wasm instantiate failed: ${String(e)}`);
onStatus(`Error: ${String(e)}`);
}
})();
return {}; // signal async instantiation (we call `success` ourselves)
},
}; };
// Load order mirrors the harness HTML (tests/apps/kicad/<tool>.html): // Load order mirrors the harness HTML (tests/apps/kicad/<tool>.html):

View file

@ -112,6 +112,7 @@ export function cdnLibsSource(
name: l.name, name: l.name,
description: l.description ?? null, description: l.description ?? null,
type: "origin", type: "origin",
itemCount: l.itemCount,
})); }));
}, },
async listItems(libId: string): Promise<LibItemInfo[]> { async listItems(libId: string): Promise<LibItemInfo[]> {

View file

@ -38,6 +38,7 @@ export function remoteLibsSource(
name: l.name, name: l.name,
description: l.description ?? null, description: l.description ?? null,
type: l.type, type: l.type,
itemCount: l.itemCount,
})); }));
}, },

View file

@ -13,6 +13,9 @@ export interface LibInfo {
description?: string | null; description?: string | null;
/** 'origin' | 'mirror' | 'user' — drives "ensure a user lib exists" at boot. */ /** 'origin' | 'mirror' | 'user' — drives "ensure a user lib exists" at boot. */
type?: string; type?: string;
/** Item count, when the source knows it cheaply (manifest/backend) — shown as a
* badge in the home-page library list. Omitted ⇒ no badge. */
itemCount?: number;
} }
export interface LibItemInfo { export interface LibItemInfo {