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

@ -31,6 +31,21 @@ VITE_WASM_ROOT=/wasm
# <repo>/tests/apps/kicad). Useful when serving prebuilt artifacts from elsewhere.
# 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").
# 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.

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

View file

@ -1,5 +1,5 @@
import { Github } from "lucide-react";
import { APP_GIT_SHA, APP_TAG, REPO_URL } from "@/lib/config";
import { ExternalLink, Github } from "lucide-react";
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
@ -44,6 +44,16 @@ export function VersionBadge() {
>
<Github size={12} /> source
</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>
);
}

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 +
// opened (covers the big WASM-compile freeze with a full-screen overlay).
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.
const [libBusy, setLibBusy] = React.useState<string | null>(null);
// Load-screen pre-sync progress: warming the project's lib bundles into IDB in
@ -615,6 +622,15 @@ export function WasmTool({
return () => clearTimeout(t);
}, [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(() => {
const removeNavigationHook = installToolNavigationHook(window as ToolWindow, {
slug,
@ -700,6 +716,7 @@ export function WasmTool({
log: append,
onStatus: setStatus,
onAbort: oom.onAbort,
onProgress: (loaded, total) => setProgress({ loaded, total }),
libsSource: source,
});
// 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">
{status || "Loading…"}
</p>
<DownloadProgress progress={progress} />
{libSync && (
<p className="font-mono text-xs text-emerald-300/90">{libSync}</p>
)}
<p className="font-mono text-xs text-white/40">
First load downloads the tool (large) — this can take a moment.
</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>
@ -931,3 +963,42 @@ export function WasmTool({
</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 { API_BASE_URL, PROJECT_SOURCE_KIND } from "./config";
import { API_BASE_URL, libsSourceConfig } from "./config";
import { client } from "./contract-client";
import type { LibInfo } from "@/wasm/libs/source";
import { downloadBytes } from "./download";
import {
ReadOnlyProjectError,
@ -48,18 +49,18 @@ export function useSourceDescriptor(slug: string) {
}
/**
* Libraries the backend serves, optionally filtered to a kind. In static (no
* backend) mode there are none, so we short-circuit to an empty list rather than
* fire a doomed request.
* Libraries the editor can browse, optionally filtered to a kind — sourced from
* the ACTIVE libs source (lib/config `libsSourceConfig`), not the REST backend
* 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") {
return useQuery({
queryKey: ["libs", kind ?? "all"],
queryFn: async (): Promise<Lib[]> => {
if (PROJECT_SOURCE_KIND === "static") return [];
const res = await client.listLibs({ query: { kind } });
if (res.status !== 200) throw new Error("failed to list libraries");
return res.body;
queryFn: async (): Promise<LibInfo[]> => {
const source = libsSourceConfig();
return source ? source.listLibs(kind) : [];
},
});
}

View file

@ -44,6 +44,31 @@ export const REPO_URL = (
import.meta.env.VITE_REPO_URL || "https://github.com/emergence-engineering/pcbjam"
).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):
* "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). */
export interface LocalProjectStore extends ProjectSource {
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>;
renameProject(slug: string, name: string): Promise<void>;
/** 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;
},
async createProject(name: string, files: NewFile[]): Promise<Project> {
async createProject(
name: string,
files: NewFile[],
opts?: { slug?: string },
): Promise<Project> {
const d = await db();
const existing = (await reqDone(
d.transaction(PROJECTS, "readonly").objectStore(PROJECTS).getAllKeys(),
)) as string[];
const taken = new Set(existing);
const baseSlug = slugify(name);
let slug = baseSlug;
for (let i = 2; taken.has(slug); i++) slug = `${baseSlug}-${i}`;
let slug: string;
if (opts?.slug) {
// 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 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 { BrowserRouter } from "react-router-dom";
import App from "./App";
import { initAnalytics } from "./lib/analytics";
import "./index.css";
// Privacy-friendly analytics (Plausible), only when VITE_PLAUSIBLE_DOMAIN is set.
initAnalytics();
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});

View file

@ -12,8 +12,10 @@ import { importFileList, importFsaFolder } from "@/lib/import-folder";
import { localProjectStore } from "@/lib/project-source";
import { isDocumentTool } from "@/lib/new-file";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ToolGrid } from "@/components/ToolGrid";
import { ProjectsSection } from "@/components/ProjectsSection";
import { WaitlistForm } from "@/components/WaitlistForm";
import { NewFileDialog } from "@/components/NewFileDialog";
import type { SaveBytes } from "@/wasm/save-flow";
import { LocalProjectView, type LocalFile } from "@/components/LocalProjectView";
@ -280,6 +282,9 @@ export function HomePage() {
a per-row source chip) --- */}
<ProjectsSection />
{/* --- Waitlist signup (sits between projects and tools) --- */}
<WaitlistForm />
{/* --- Tools (KiCad-style launcher for the standalone tools) --- */}
<section className="mb-10">
<h2 className="mb-3 text-lg font-medium">Tools</h2>
@ -294,34 +299,35 @@ export function HomePage() {
<NewFileDialog tool={newFileTool} onClose={() => setNewFileTool(null)} />
{/* --- Backend libraries (hidden in the no-backend static demo) --- */}
{!staticMode && (
<section>
<h2 className="mb-3 text-lg font-medium">Libraries from the backend</h2>
<div className="space-y-3">
<LibGroup
icon={<Library size={16} />}
label="Symbols"
query={symbolLibs}
tool="symbol_editor"
/>
<LibGroup
icon={<Package size={16} />}
label="Footprints"
query={footprintLibs}
tool="footprint_editor"
/>
</div>
</section>
)}
{/* --- Libraries (the configured source: the read-only CDN/R2 set in the
demo, or a backend's libs). Each lib opens scoped in its editor. --- */}
<section>
<h2 className="mb-3 text-lg font-medium">Libraries</h2>
<div className="space-y-3">
<LibGroup
icon={<Library size={16} />}
label="Symbols"
query={symbolLibs}
tool="symbol_editor"
/>
<LibGroup
icon={<Package size={16} />}
label="Footprints"
query={footprintLibs}
tool="footprint_editor"
/>
</div>
</section>
</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
* 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({
icon,
@ -335,10 +341,22 @@ function LibGroup({
tool: Tool;
}) {
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 (
<div className="rounded-lg border p-4">
<h3 className="mb-2 flex items-center gap-2 text-sm font-medium">
{icon} {label}
{libs && libs.length > 0 && (
<span className="text-xs font-normal text-muted-foreground">
{libs.length}
</span>
)}
</h3>
{isLoading && (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
@ -346,31 +364,47 @@ function LibGroup({
</p>
)}
{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">
No {label.toLowerCase()} libraries.
</p>
)}
{libs && libs.length > 0 && (
<div className="flex flex-wrap gap-2">
{libs.map((lib) => (
<a
key={lib.id}
href={`/l/${encodeURIComponent(lib.id)}/${tool}`}
title={lib.description ?? undefined}
className="inline-flex 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>
))}
</div>
<>
{showFilter && (
<Input
type="search"
value={filter}
placeholder={`Filter ${libs.length} ${label.toLowerCase()}…`}
onChange={(e) => setFilter(e.target.value)}
className="mb-2 h-8"
/>
)}
<div className="flex max-h-72 flex-wrap gap-2 overflow-y-auto">
{shown.map((lib) => (
<a
key={lib.id}
href={`/l/${encodeURIComponent(lib.id)}/${tool}`}
title={lib.description ?? undefined}
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>
);

View file

@ -9,21 +9,19 @@ import {
} from "@pcbjam/shared";
import {
ArrowLeft,
Copy,
Download,
ExternalLink,
FilePlus,
Loader2,
Trash2,
} from "lucide-react";
import { fetchFileBytes, useProject, useSourceDescriptor } from "@/lib/api";
import { downloadBytes } from "@/lib/download";
import { localProjectStore } from "@/lib/project-source";
import { DOCUMENT_TOOLS } from "@/lib/new-file";
import { zipFiles } from "@/lib/zip";
import { formatBytes } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { SourceChip } from "@/components/SourceChip";
import { ToolGrid } from "@/components/ToolGrid";
import { FileTree } from "@/components/FileTree";
import { NewFileDialog } from "@/components/NewFileDialog";
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
* document-tool buttons create a templated file in THIS project and open it, so
* you always edit a real file that saves back. Writable projects (local IDB or a
* remote-rw backend) get New / export; a read-only gallery project gets "Save a
* copy to browser" (fork into editable IDB). The source chip says where edits go.
* Project-centric view (mirrors the closed app): an iconed tool launcher (same
* cards as the home page) + the project's files as a navigable folder tree.
* Opening a read-only GALLERY project auto-"moves it to local" — it's copied
* into a writable browser-local project at the same slug, so the composite source
* shadows the gallery and this view re-resolves as a local, editable project.
*/
export function ProjectView() {
const { project: slug = "" } = useParams();
@ -46,12 +44,41 @@ export function ProjectView() {
const { data, isLoading, error } = useProject(slug);
const { data: descriptor } = useSourceDescriptor(slug);
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 [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 store = localProjectStore();
if (!store) return;
setBusy(true);
try {
@ -66,7 +93,6 @@ export function ProjectView() {
};
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;
@ -75,26 +101,44 @@ export function ProjectView() {
navigate("/");
};
// Copy a read-only gallery project into an editable browser-local project.
const fork = async () => {
const store = localProjectStore();
if (!store || !data) return;
setBusy(true);
try {
const files = await Promise.all(
data.files.map(async (f) => ({
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);
// A tool launched from the grid: file-less tools (gerber/calculator + the
// symbol/footprint editors, which load the full project lib set) open project-
// scoped with a full reload; document tools create a templated file first.
const launchTool = (tool: Tool) => {
if (FILELESS_TOOLS.has(tool)) {
window.location.assign(`/p/${encodeURIComponent(slug)}/${tool}/`);
} else {
setNewFileTool(tool);
}
};
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 (
<div className="container py-10">
<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>
</div>
{/* New file (writable projects): a templated doc, created then opened. */}
{descriptor?.writable && (
<div className="mb-6">
<h2 className="mb-2 text-sm font-medium text-muted-foreground">
New file
</h2>
<div className="flex flex-wrap gap-2">
{DOCUMENT_TOOLS.map((tool) => (
<Button
key={tool}
variant="outline"
size="sm"
onClick={() => setNewFileTool(tool)}
>
<FilePlus size={14} /> {TOOL_LABELS[tool]}
{movingToLocal ? (
<p className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="animate-spin" /> Preparing your editable copy…
</p>
) : (
<>
{forkError && (
<p className="mb-4 text-sm text-destructive">
Couldn't prepare a local copy: {forkError}
</p>
)}
{/* Tools — same iconed launcher as the home page. */}
<section className="mb-8">
<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>
))}
</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>
);
})}
{data.files.length === 0 && (
<div className="px-4 py-6 text-sm text-muted-foreground">
No files yet — use “New file” above to create one.
</div>
)}
</div>
)}
<h2 className="mb-3 text-lg font-medium">
Files ({data.files.length})
</h2>
<FileTree
files={data.files}
renderActions={renderFileActions}
emptyText="No files yet — use the Tools above to create one."
/>
</>
)}
<NewFileDialog
tool={newFileTool}

View file

@ -53,6 +53,10 @@ export interface BootOptions {
/** OOM recovery hook (feature 0002): emscripten `abort()` routes here so a
* soft OOM can respawn a fresh tab. Optional — boot works without it. */
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
* (an empty sym-lib-table is seeded). Its libs become sym-lib-table rows. */
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> {
const { tool, base, container, log, onStatus, onAbort, libsSource } = opts;
const { tool, base, container, log, onStatus, onAbort, onProgress, libsSource } =
opts;
const w = window as ToolWindow;
// 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
// → a same-origin blob shim that importScripts the glue (see helper above).
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):

View file

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

View file

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

View file

@ -13,6 +13,9 @@ export interface LibInfo {
description?: string | null;
/** 'origin' | 'mirror' | 'user' — drives "ensure a user lib exists" at boot. */
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 {