- 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
139 lines
4.7 KiB
TypeScript
139 lines
4.7 KiB
TypeScript
import type { DriftReportBody, Project } from "@pcbjam/shared";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
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,
|
|
descriptorForSlug,
|
|
listPrimaryProjects,
|
|
localProjectStore,
|
|
projectSource,
|
|
} from "./project-source";
|
|
import type { SourceDescriptor } from "./project-source-shared";
|
|
|
|
/**
|
|
* Project/file reads go through the active PROJECT SOURCE (lib/project-source.ts):
|
|
* the @pcbjam/shared REST backend, the read-only static gallery (demo mode), or
|
|
* the browser-local IndexedDB store — composited per slug. Libraries + collab
|
|
* drift reporting are backend-only and stay on the contract client here.
|
|
*/
|
|
|
|
/** Remote/gallery projects (excludes browser-local ones — those have their own
|
|
* hook so the home page can list + manage them as a distinct section). */
|
|
export function useProjects() {
|
|
return useQuery({
|
|
queryKey: ["projects"],
|
|
queryFn: () => listPrimaryProjects(),
|
|
});
|
|
}
|
|
|
|
/** Browser-local (IndexedDB) projects; empty when the local store is disabled. */
|
|
export function useLocalProjects() {
|
|
return useQuery({
|
|
queryKey: ["local-projects"],
|
|
queryFn: (): Promise<Project[]> =>
|
|
localProjectStore()?.listProjects() ?? Promise.resolve([]),
|
|
});
|
|
}
|
|
|
|
/** The source kind that owns `slug` (local / remote-ro / remote-rw) — for the
|
|
* "where your edits go" chip on the project + editor views. */
|
|
export function useSourceDescriptor(slug: string) {
|
|
return useQuery({
|
|
queryKey: ["source-descriptor", slug],
|
|
queryFn: (): Promise<SourceDescriptor> => descriptorForSlug(slug),
|
|
enabled: !!slug,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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<LibInfo[]> => {
|
|
const source = libsSourceConfig();
|
|
return source ? source.listLibs(kind) : [];
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useProject(slug: string) {
|
|
return useQuery({
|
|
queryKey: ["project", slug],
|
|
queryFn: () => projectSource().getProject(slug),
|
|
});
|
|
}
|
|
|
|
/** File bytes from the active source (backend stream, or the static CDN gallery). */
|
|
export function fetchFileBytes(
|
|
slug: string,
|
|
relPath: string,
|
|
): Promise<Uint8Array> {
|
|
return projectSource().fetchFileBytes(slug, relPath);
|
|
}
|
|
|
|
/**
|
|
* Persist a saved file. A writable source (the backend) uploads it; a read-only
|
|
* source (the static demo gallery) has no upload target, so the save downloads
|
|
* to the user's machine instead. The remote-vs-static choice is config-driven
|
|
* (the active project source), so callers just call this.
|
|
*/
|
|
export async function uploadFileBytes(
|
|
slug: string,
|
|
relPath: string,
|
|
bytes: Uint8Array,
|
|
): Promise<void> {
|
|
const source = projectSource();
|
|
if (!source.uploadFileBytes) {
|
|
downloadBytes(relPath, bytes);
|
|
return;
|
|
}
|
|
try {
|
|
await source.uploadFileBytes(slug, relPath, bytes);
|
|
} catch (e) {
|
|
// Composite write to a read-only (gallery) project → fall back to download.
|
|
if (e instanceof ReadOnlyProjectError) {
|
|
downloadBytes(relPath, bytes);
|
|
return;
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// --- collaboration drift reporting (ysync; backend-only) ---
|
|
|
|
/**
|
|
* Report a detected ydoc/wasm drift (the editor's periodic, every-N-edits check).
|
|
* Best-effort: a failed report must never disrupt editing, so callers ignore
|
|
* rejections.
|
|
*/
|
|
export async function reportDrift(
|
|
slug: string,
|
|
body: DriftReportBody,
|
|
): Promise<void> {
|
|
await client.reportDrift({ params: { project: slug }, body });
|
|
}
|
|
|
|
/**
|
|
* Fire-and-forget drift report that survives the page closing — used by the
|
|
* session-end (`beforeunload`) check. `sendBeacon` queues the POST past unload;
|
|
* a keepalive `fetch` is the fallback when the beacon is rejected (too large).
|
|
*/
|
|
export function reportDriftBeacon(slug: string, body: DriftReportBody): void {
|
|
const url = `${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/drift`;
|
|
const blob = new Blob([JSON.stringify(body)], { type: "application/json" });
|
|
try {
|
|
if (navigator.sendBeacon(url, blob)) return;
|
|
} catch {
|
|
/* fall through to keepalive fetch */
|
|
}
|
|
void fetch(url, { method: "POST", body: blob, keepalive: true }).catch(() => {});
|
|
}
|