pcbjam/web/standalone/src/lib/api.ts
Gergő Törcsvári 39cb04b127
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
2026-06-27 07:59:09 +02:00

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(() => {});
}