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:
parent
6c6f889ef5
commit
39cb04b127
22 changed files with 830 additions and 258 deletions
3
.github/workflows/deploy-demo.yml
vendored
3
.github/workflows/deploy-demo.yml
vendored
|
|
@ -89,9 +89,12 @@ jobs:
|
|||
# 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).
|
||||
- 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: >
|
||||
node scripts/deploy/build-demo.mjs --tag "${{ steps.tag.outputs.tag }}"
|
||||
--cdn "$CDN" --lib-tag "$LIB_TAG"
|
||||
--plausible "${{ vars.PLAUSIBLE_DOMAIN }}"
|
||||
|
||||
# 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
|
||||
|
|
|
|||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
|
|
@ -112,9 +112,12 @@ jobs:
|
|||
# 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.
|
||||
- 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: >
|
||||
node scripts/deploy/build-demo.mjs --tag "${{ needs.meta.outputs.tag }}"
|
||||
--cdn "$CDN" --lib-tag "$LIB_TAG"
|
||||
--plausible "${{ vars.PLAUSIBLE_DOMAIN }}"
|
||||
|
||||
- name: Ensure Pages project exists
|
||||
run: >
|
||||
|
|
|
|||
|
|
@ -27,6 +27,13 @@ function parseArgs(argv) {
|
|||
cdn: "https://cdn.pcbjam.com",
|
||||
repo: "https://github.com/emergence-engineering/pcbjam",
|
||||
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++) {
|
||||
const next = () => argv[++i];
|
||||
|
|
@ -37,12 +44,17 @@ function parseArgs(argv) {
|
|||
// KiCad library snapshot tag (published once to libs/kicad/<libTag>/).
|
||||
// Omitted ⇒ the offline built-in example symbols (back-compat).
|
||||
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]}`);
|
||||
}
|
||||
}
|
||||
if (!a.tag) throw new Error("--tag <release tag> is required");
|
||||
a.cdn = a.cdn.replace(/\/+$/, "");
|
||||
a.repo = a.repo.replace(/\/+$/, "");
|
||||
a.landing = a.landing.replace(/\/+$/, "");
|
||||
a.waitlist = a.waitlist || `${a.landing}/api/waitlist`;
|
||||
return a;
|
||||
}
|
||||
|
||||
|
|
@ -92,6 +104,11 @@ function main() {
|
|||
VITE_APP_TAG: a.tag,
|
||||
VITE_GIT_SHA: gitSha(repoRoot),
|
||||
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}`);
|
||||
|
|
@ -100,6 +117,8 @@ function main() {
|
|||
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_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
|
||||
// dist/; the CDN serves it). In CI it isn't present, so this is a no-op there.
|
||||
|
|
|
|||
|
|
@ -42,6 +42,15 @@ export default defineConfig({
|
|||
optional: true,
|
||||
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',
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,12 @@ import { Resend } from 'resend';
|
|||
// 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
|
||||
// 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
|
||||
// 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]+$/;
|
||||
|
||||
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), {
|
||||
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 }) => {
|
||||
const cors = corsHeaders(request);
|
||||
const ct = request.headers.get('content-type') ?? '';
|
||||
const wantsJson = ct.includes('application/json');
|
||||
|
||||
|
|
@ -37,7 +72,7 @@ export const POST: APIRoute = async ({ request }) => {
|
|||
? await request.json()
|
||||
: Object.fromEntries(await request.formData());
|
||||
} 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();
|
||||
|
|
@ -45,16 +80,16 @@ export const POST: APIRoute = async ({ request }) => {
|
|||
const source = String(data.source ?? 'unknown');
|
||||
|
||||
// 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) {
|
||||
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.
|
||||
if (!RESEND_API_KEY) {
|
||||
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);
|
||||
|
|
@ -98,14 +133,14 @@ export const POST: APIRoute = async ({ request }) => {
|
|||
// rejected it (bad key, unverified domain, invalid from-address, …).
|
||||
if (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) {
|
||||
// Defensive: unexpected throw (network error, bad construction).
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
147
web/standalone/src/components/FileTree.tsx
Normal file
147
web/standalone/src/components/FileTree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
100
web/standalone/src/components/WaitlistForm.tsx
Normal file
100
web/standalone/src/components/WaitlistForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
24
web/standalone/src/lib/analytics.ts
Normal file
24
web/standalone/src/lib/analytics.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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) : [];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export function remoteLibsSource(
|
|||
name: l.name,
|
||||
description: l.description ?? null,
|
||||
type: l.type,
|
||||
itemCount: l.itemCount,
|
||||
}));
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue