feat(scopes): phase 0003 — standalone scope routing, tool inference, scoped API client, Demo/Local/Cloud badges
Routes → /:scope/projects/:name/* (+ -/:tool) and /:scope/libs/:name; tool inferred from file ext / lib kind (?tool= override); currentScope()/userSlug() in config; SCOPE/USER headers + scoped paths through project + libs sources; @local/demo scopes on constructed projects; badge relabel (drop scary read-only). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e28e5e8e57
commit
234e5fe189
18 changed files with 184 additions and 102 deletions
|
|
@ -10,9 +10,12 @@ export default function App() {
|
|||
<>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/p/:project" element={<ProjectView />} />
|
||||
<Route path="/p/:project/:tool/*" element={<ToolPage />} />
|
||||
<Route path="/l/:lib/:tool" element={<LibToolPage />} />
|
||||
{/* scope/kind/name grammar (see @pcbjam/shared routes.ts). The tool is
|
||||
inferred from the file (or lib kind); `-/:tool` boots a fileless tool. */}
|
||||
<Route path="/:scope/projects/:name" element={<ProjectView />} />
|
||||
<Route path="/:scope/projects/:name/-/:tool" element={<ToolPage />} />
|
||||
<Route path="/:scope/projects/:name/*" element={<ToolPage />} />
|
||||
<Route path="/:scope/libs/:name" element={<LibToolPage />} />
|
||||
</Routes>
|
||||
{/* Version + source link, bottom-right on every route (home + editor). */}
|
||||
<VersionBadge />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import * as React from "react";
|
||||
import { TOOL_LABELS, type Tool } from "@pcbjam/shared";
|
||||
import {
|
||||
LOCAL_SCOPE,
|
||||
TOOL_LABELS,
|
||||
type Tool,
|
||||
projectPath,
|
||||
} from "@pcbjam/shared";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { uploadFileBytes } from "@/lib/api";
|
||||
import { currentScope } from "@/lib/config";
|
||||
import { localProjectStore } from "@/lib/project-source";
|
||||
import { useLocalProjects } from "@/lib/api";
|
||||
import {
|
||||
|
|
@ -92,9 +98,10 @@ export function NewFileDialog({
|
|||
await uploadFileBytes(slug, finalName, bytes);
|
||||
}
|
||||
// Full navigation so Emscripten boots into a clean page opening the file.
|
||||
window.location.assign(
|
||||
`/p/${encodeURIComponent(slug)}/${tool}/${finalName}`,
|
||||
);
|
||||
// A home-created project is browser-local (@local scope); an in-project new
|
||||
// file keeps the current scope. The tool is inferred from the file's ext.
|
||||
const scope = homeMode ? LOCAL_SCOPE : currentScope();
|
||||
window.location.assign(projectPath(scope, slug, finalName));
|
||||
} catch (e) {
|
||||
setBusy(false);
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { Project } from "@pcbjam/shared";
|
||||
import { type Project, projectPath } from "@pcbjam/shared";
|
||||
import { Download, Loader2, Pencil, Trash2 } from "lucide-react";
|
||||
import { useLocalProjects, useProjects } from "@/lib/api";
|
||||
import { PROJECT_SOURCE_KIND } from "@/lib/config";
|
||||
|
|
@ -149,7 +149,7 @@ function ProjectRow({
|
|||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button asChild variant="secondary" size="sm">
|
||||
<Link to={`/p/${project.slug}`}>Open</Link>
|
||||
<Link to={projectPath(project.scope, project.slug)}>Open</Link>
|
||||
</Button>
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import {
|
|||
EXTENSION_TOOL,
|
||||
FILELESS_TOOLS,
|
||||
fileToDoc,
|
||||
projectPath,
|
||||
projectToolPath,
|
||||
toolSchema,
|
||||
ydocHasState,
|
||||
yToDoc,
|
||||
|
|
@ -13,6 +15,7 @@ import {
|
|||
} from "@pcbjam/shared";
|
||||
import { ChevronDown, ChevronUp, Loader2 } from "lucide-react";
|
||||
import {
|
||||
currentScope,
|
||||
libsSourceConfig,
|
||||
yjsProviderConfig,
|
||||
type DocSource,
|
||||
|
|
@ -160,10 +163,6 @@ function chooseToolFile(
|
|||
return candidates[0]?.path;
|
||||
}
|
||||
|
||||
function encodeRelPath(path: string): string {
|
||||
return path.split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
function installToolNavigationHook(
|
||||
win: ToolWindow,
|
||||
opts: {
|
||||
|
|
@ -191,10 +190,13 @@ function installToolNavigationHook(
|
|||
return false;
|
||||
}
|
||||
|
||||
// Scope/kind/name grammar: a fileless tool boots at `…/-/:tool`; a file route
|
||||
// carries the path (its tool is inferred). Scope = the current URL's scope.
|
||||
const scope = currentScope();
|
||||
const url =
|
||||
`/p/${encodeURIComponent(opts.slug)}/${nextTool}` +
|
||||
(nextPath ? `/${encodeRelPath(nextPath)}` : "") +
|
||||
win.location.search;
|
||||
(FILELESS_TOOLS.has(nextTool)
|
||||
? projectToolPath(scope, opts.slug, nextTool)
|
||||
: projectPath(scope, opts.slug, nextPath)) + win.location.search;
|
||||
|
||||
opts.log(`[nav] ${rawToolName} ${rawFileName || "(no file)"} -> ${url}`);
|
||||
win.location.assign(url);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { DriftReportBody, Project } from "@pcbjam/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { API_BASE_URL, libsSourceConfig } from "./config";
|
||||
import { API_BASE_URL, currentScope, libsSourceConfig } from "./config";
|
||||
import { client } from "./contract-client";
|
||||
import type { LibInfo } from "@/wasm/libs/source";
|
||||
import { downloadBytes } from "./download";
|
||||
|
|
@ -119,7 +119,10 @@ export async function reportDrift(
|
|||
slug: string,
|
||||
body: DriftReportBody,
|
||||
): Promise<void> {
|
||||
await client.reportDrift({ params: { project: slug }, body });
|
||||
await client.reportDrift({
|
||||
params: { scope: currentScope(), project: slug },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -128,7 +131,7 @@ export async function reportDrift(
|
|||
* 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 url = `${API_BASE_URL}/api/scopes/${encodeURIComponent(currentScope())}/projects/${encodeURIComponent(slug)}/drift`;
|
||||
const blob = new Blob([JSON.stringify(body)], { type: "application/json" });
|
||||
try {
|
||||
if (navigator.sendBeacon(url, blob)) return;
|
||||
|
|
|
|||
|
|
@ -151,16 +151,38 @@ export function docSourceConfig(): DocSource {
|
|||
* "off" — disable libs (empty sym-lib-table).
|
||||
*/
|
||||
/**
|
||||
* The (thin, pre-auth) owner the editor writes libs as, sent on every lib
|
||||
* request via OWNER_HEADER. `?libowner=` (e2e isolation) wins over
|
||||
* `VITE_LIBS_OWNER`, else a stable local default.
|
||||
* The (thin, pre-auth) current user — sent on every request via USER_HEADER and
|
||||
* doubling as the personal scope slug. `?user=`/`?libowner=` (e2e isolation) win
|
||||
* over `VITE_USER`/`VITE_LIBS_OWNER`, else a stable local default.
|
||||
*/
|
||||
export function libsOwner(): string {
|
||||
export function userSlug(): string {
|
||||
if (typeof window !== "undefined") {
|
||||
const p = new URLSearchParams(window.location.search).get("libowner");
|
||||
const q = new URLSearchParams(window.location.search);
|
||||
const p = q.get("user") ?? q.get("libowner");
|
||||
if (p) return p;
|
||||
}
|
||||
return import.meta.env.VITE_LIBS_OWNER ?? "local-user";
|
||||
return (
|
||||
import.meta.env.VITE_USER ?? import.meta.env.VITE_LIBS_OWNER ?? "local-user"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The active scope (first URL segment) for API calls. Mirrors how `userSlug()`
|
||||
* reads the URL, so the source layer scopes requests without threading scope
|
||||
* through every signature. Falls back to `?scope=` / `VITE_SCOPE` / the personal
|
||||
* scope (the user slug). Client-only scopes (e.g. `@local`) are routed by the
|
||||
* project source and never sent to a backend.
|
||||
*/
|
||||
export function currentScope(): string {
|
||||
if (typeof window !== "undefined") {
|
||||
const seg = window.location.pathname.split("/").filter(Boolean)[0];
|
||||
if (seg && seg !== "projects" && seg !== "libs") {
|
||||
return decodeURIComponent(seg);
|
||||
}
|
||||
const q = new URLSearchParams(window.location.search).get("scope");
|
||||
if (q) return q;
|
||||
}
|
||||
return import.meta.env.VITE_SCOPE ?? userSlug();
|
||||
}
|
||||
|
||||
/** Full URL of the CDN libs top manifest (required for VITE_LIBS_SOURCE=cdn),
|
||||
|
|
@ -187,7 +209,7 @@ export function libsSourceConfig(projectId?: string): LibsSource | null {
|
|||
? CDN_LIBS_MANIFEST_URL
|
||||
? cdnLibsSource(CDN_LIBS_MANIFEST_URL)
|
||||
: staticLibsSource() // misconfigured cdn ⇒ offline fallback
|
||||
: remoteLibsSource(API_BASE_URL, libsOwner(), project);
|
||||
: remoteLibsSource(API_BASE_URL, currentScope(), userSlug(), project);
|
||||
|
||||
// 0004-A spike: `?libwrite=1` adds one in-memory writable user SYMBOL lib so the
|
||||
// editor save path works with no backend (a dev/test aid). The real remote
|
||||
|
|
@ -221,7 +243,8 @@ export function libsSourceForLib(
|
|||
if (import.meta.env.VITE_LIBS_SOURCE === "synced") {
|
||||
return syncedLibsSource(libId, {
|
||||
apiBase: API_BASE_URL,
|
||||
owner: libsOwner(),
|
||||
scope: currentScope(),
|
||||
user: userSlug(),
|
||||
project,
|
||||
log: (m) => console.log(m),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import { contract } from "@pcbjam/shared";
|
||||
import { contract, USER_HEADER } from "@pcbjam/shared";
|
||||
import { initClient } from "@ts-rest/core";
|
||||
import { API_BASE_URL } from "./config";
|
||||
import { API_BASE_URL, userSlug } from "./config";
|
||||
|
||||
/**
|
||||
* ts-rest client over the shared contract (the REST backend). Shared by the
|
||||
* remote project source (lib/project-source.ts) and the lib/drift endpoints
|
||||
* (lib/api.ts). Kept in its own module so project-source and api don't import
|
||||
* each other (avoids a cycle).
|
||||
* each other (avoids a cycle). The scope is a PATH param (per call); the user is
|
||||
* a header (the thin pre-auth identity, used for per-user lib pins).
|
||||
*/
|
||||
export const client = initClient(contract, {
|
||||
baseUrl: API_BASE_URL,
|
||||
baseHeaders: {},
|
||||
baseHeaders: { [USER_HEADER]: userSlug() },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import type { Project, ProjectFile, ProjectWithFiles } from "@pcbjam/shared";
|
||||
import {
|
||||
LOCAL_SCOPE,
|
||||
type Project,
|
||||
type ProjectFile,
|
||||
type ProjectWithFiles,
|
||||
} from "@pcbjam/shared";
|
||||
import type { ProjectSource } from "./project-source";
|
||||
import {
|
||||
SOURCE_DESCRIPTORS,
|
||||
|
|
@ -114,6 +119,7 @@ function slugify(name: string): string {
|
|||
function toProject(r: ProjectRecord): Project {
|
||||
return {
|
||||
id: deterministicUuid(`local:project:${r.slug}`),
|
||||
scope: LOCAL_SCOPE,
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
createdAt: r.createdAt,
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@
|
|||
* both can import the descriptor types + the deterministic-uuid helper without a
|
||||
* runtime import cycle.
|
||||
*
|
||||
* A source has one of three KINDS, surfaced verbatim in the UI so the user always
|
||||
* knows where their edits go:
|
||||
* - "remote-rw" — a backend the editor reads AND writes (saves upload).
|
||||
* - "remote-ro" — a read-only remote (the demo's CDN gallery); saves download.
|
||||
* - "local" — this browser's IndexedDB; saves persist locally, export by zip.
|
||||
* A source has one of three KINDS, surfaced in the UI so the user always knows
|
||||
* where their edits go:
|
||||
* - "remote-rw" — a backend the editor reads AND writes (Cloud; saves upload).
|
||||
* - "remote-ro" — the curated CDN gallery (Demo); editing auto-forks a local copy.
|
||||
* - "local" — this browser's IndexedDB (Local); saves persist, export by zip.
|
||||
*/
|
||||
export type SourceKind = "remote-rw" | "remote-ro" | "local";
|
||||
|
||||
|
|
@ -26,22 +26,24 @@ export const SOURCE_DESCRIPTORS: Record<SourceKind, SourceDescriptor> = {
|
|||
"remote-rw": {
|
||||
kind: "remote-rw",
|
||||
writable: true,
|
||||
label: "Remote · editable",
|
||||
label: "Cloud",
|
||||
description: "Stored on the backend — your saves are uploaded there.",
|
||||
},
|
||||
"remote-ro": {
|
||||
kind: "remote-ro",
|
||||
// Not directly writable, but editing transparently forks a local copy, so
|
||||
// the UI never shows a scary "read-only" — it's just a Demo you can edit.
|
||||
writable: false,
|
||||
label: "Remote · read-only",
|
||||
label: "Demo",
|
||||
description:
|
||||
"A read-only example from the server — edits aren't saved back; Save downloads the file to your machine.",
|
||||
"A demo project — open it and your edits become a local copy you can save and export.",
|
||||
},
|
||||
local: {
|
||||
kind: "local",
|
||||
writable: true,
|
||||
label: "Local (this browser)",
|
||||
label: "Local",
|
||||
description:
|
||||
"Stored in this browser only — saves persist here across visits. Export with Download .zip (nothing is uploaded).",
|
||||
"Stored in this browser — saves persist here across visits. Export with Download .zip.",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ async function loadStatic() {
|
|||
// Local IDB store off ⇒ the active source is the plain static gallery
|
||||
// (no composite), which is what these read-only assertions cover.
|
||||
LOCAL_PROJECTS_ENABLED: false,
|
||||
// The contract client (imported transitively) reads these at module load.
|
||||
userSlug: () => "test-user",
|
||||
currentScope: () => "demo",
|
||||
}));
|
||||
return (await import("./project-source")).projectSource;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import type { Project, ProjectFile, ProjectWithFiles } from "@pcbjam/shared";
|
||||
import {
|
||||
DEMO_SCOPE,
|
||||
type Project,
|
||||
type ProjectFile,
|
||||
type ProjectWithFiles,
|
||||
} from "@pcbjam/shared";
|
||||
import {
|
||||
API_BASE_URL,
|
||||
LOCAL_PROJECTS_ENABLED,
|
||||
PROJECT_MANIFEST_URL,
|
||||
PROJECT_SOURCE_KIND,
|
||||
currentScope,
|
||||
} from "./config";
|
||||
import { client } from "./contract-client";
|
||||
import { idbProjectStore, type LocalProjectStore } from "./idb-project-store";
|
||||
|
|
@ -51,18 +57,24 @@ function encodePath(relPath: string): string {
|
|||
}
|
||||
|
||||
function remoteProjectSource(): ProjectSource {
|
||||
// The active scope is the URL's first segment (config.currentScope), read at
|
||||
// call time so a single source instance serves whatever scope is open.
|
||||
const projectsBase = () =>
|
||||
`${API_BASE_URL}/api/scopes/${encodeURIComponent(currentScope())}/projects`;
|
||||
const fileUrl = (slug: string, relPath: string) =>
|
||||
`${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files/${encodePath(relPath)}`;
|
||||
`${projectsBase()}/${encodeURIComponent(slug)}/files/${encodePath(relPath)}`;
|
||||
return {
|
||||
descriptor: SOURCE_DESCRIPTORS["remote-rw"],
|
||||
readOnly: false,
|
||||
async listProjects() {
|
||||
const res = await client.listProjects();
|
||||
const res = await client.listProjects({ params: { scope: currentScope() } });
|
||||
if (res.status !== 200) throw new Error("failed to list projects");
|
||||
return res.body;
|
||||
},
|
||||
async getProject(slug) {
|
||||
const res = await client.getProject({ params: { project: slug } });
|
||||
const res = await client.getProject({
|
||||
params: { scope: currentScope(), project: slug },
|
||||
});
|
||||
if (res.status === 404) throw new Error("project not found");
|
||||
if (res.status !== 200) throw new Error("failed to load project");
|
||||
return res.body;
|
||||
|
|
@ -78,10 +90,10 @@ function remoteProjectSource(): ProjectSource {
|
|||
// The form FIELD NAME carries the project-relative path (upsert by
|
||||
// (project, path)) — same convention as the management app's folder upload.
|
||||
form.append(relPath, new File([bytes as BlobPart], name));
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files`,
|
||||
{ method: "POST", body: form },
|
||||
);
|
||||
const res = await fetch(`${projectsBase()}/${encodeURIComponent(slug)}/files`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error(`upload failed (${res.status}): ${relPath}`);
|
||||
},
|
||||
};
|
||||
|
|
@ -125,6 +137,8 @@ function staticProjectSource(manifestUrl: string): ProjectSource {
|
|||
|
||||
const toProject = (p: StaticManifestProject, ts: string): Project => ({
|
||||
id: deterministicUuid(`project:${p.slug}`),
|
||||
// The curated gallery lives under the reserved `demo` scope.
|
||||
scope: DEMO_SCOPE,
|
||||
slug: p.slug,
|
||||
name: p.name,
|
||||
createdAt: ts,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import * as React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { Tool } from "@pcbjam/shared";
|
||||
import { type Tool, libPath, projectPath } from "@pcbjam/shared";
|
||||
import { FolderOpen, Library, Loader2, Package } from "lucide-react";
|
||||
import { useLibs } from "@/lib/api";
|
||||
import { LOCAL_PROJECTS_ENABLED, PROJECT_SOURCE_KIND } from "@/lib/config";
|
||||
import {
|
||||
LOCAL_PROJECTS_ENABLED,
|
||||
PROJECT_SOURCE_KIND,
|
||||
currentScope,
|
||||
} from "@/lib/config";
|
||||
import { localFileLibsSource } from "@/wasm/libs/local-file-source";
|
||||
import type { LibsSource } from "@/wasm/libs/source";
|
||||
import { downloadBytes } from "@/lib/download";
|
||||
|
|
@ -151,7 +155,7 @@ export function HomePage() {
|
|||
if (!store) return;
|
||||
const project = await store.createProject(imported.name, imported.files);
|
||||
await queryClient.invalidateQueries({ queryKey: ["local-projects"] });
|
||||
navigate(`/p/${project.slug}`);
|
||||
navigate(projectPath(project.scope, project.slug));
|
||||
};
|
||||
|
||||
// Tool launched from the home page: no project, no files. File-less editors
|
||||
|
|
@ -388,7 +392,7 @@ function LibGroup({
|
|||
{shown.map((lib) => (
|
||||
<a
|
||||
key={lib.id}
|
||||
href={`/l/${encodeURIComponent(lib.id)}/${tool}`}
|
||||
href={`${libPath(currentScope(), lib.id)}?tool=${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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,37 +1,31 @@
|
|||
import { useParams } from "react-router-dom";
|
||||
import { toolSchema } from "@pcbjam/shared";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { parseToolParam } from "@pcbjam/shared";
|
||||
import { libsSourceForLib } from "@/lib/config";
|
||||
import { WasmTool } from "@/components/WasmTool";
|
||||
import { PreflightGate } from "@/preflight/PreflightGate";
|
||||
|
||||
/**
|
||||
* Open one BACKEND library scoped to itself in its editor, addressed by URL:
|
||||
* /l/<libId>/<tool> e.g. /l/Diode/symbol_editor
|
||||
* Open one library scoped to itself in its editor, addressed by URL:
|
||||
* /:scope/libs/<name> (symbol_editor — the default)
|
||||
* /:scope/libs/<name>?tool=footprint_editor
|
||||
*
|
||||
* Deep-linkable + reload-safe (unlike the home page's in-place lib launch). The
|
||||
* editor boots with no project/files and a `scopedLibsSource` so its lib tree
|
||||
* shows exactly this one library. (Local lib FILES can't be URL-addressed — they
|
||||
* stay an in-place launch on the home page.)
|
||||
* The lib's editor is chosen by `?tool=` (the home page appends it from the lib's
|
||||
* kind); absent ⇒ the symbol editor. `<name>` is the lib's opaque token (its CDN
|
||||
* name or backend id). Deep-linkable + reload-safe. The editor boots with no
|
||||
* project/files and a `scopedLibsSource` showing exactly this one library.
|
||||
*/
|
||||
export function LibToolPage() {
|
||||
const params = useParams();
|
||||
const libId = params.lib ?? "";
|
||||
const parsedTool = toolSchema.safeParse(params.tool);
|
||||
|
||||
if (!parsedTool.success) {
|
||||
return (
|
||||
<div className="container py-10 text-destructive">
|
||||
Unknown tool: {params.tool}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const [search] = useSearchParams();
|
||||
const libId = params.name ?? "";
|
||||
const tool = parseToolParam(search.get("tool")) ?? "symbol_editor";
|
||||
|
||||
const libsSource = libsSourceForLib(libId, "local");
|
||||
|
||||
return (
|
||||
<PreflightGate>
|
||||
<WasmTool
|
||||
tool={parsedTool.data}
|
||||
tool={tool}
|
||||
slug="local"
|
||||
projectId="local"
|
||||
files={[]}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import {
|
|||
FILELESS_TOOLS,
|
||||
TOOL_LABELS,
|
||||
type Tool,
|
||||
projectPath,
|
||||
projectToolPath,
|
||||
} from "@pcbjam/shared";
|
||||
import {
|
||||
ArrowLeft,
|
||||
|
|
@ -38,7 +40,7 @@ function toolForPath(path: string): Tool | null {
|
|||
* shadows the gallery and this view re-resolves as a local, editable project.
|
||||
*/
|
||||
export function ProjectView() {
|
||||
const { project: slug = "" } = useParams();
|
||||
const { scope = "", name: slug = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading, error } = useProject(slug);
|
||||
|
|
@ -106,7 +108,7 @@ export function ProjectView() {
|
|||
// 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}/`);
|
||||
window.location.assign(projectToolPath(scope, slug, tool));
|
||||
} else {
|
||||
setNewFileTool(tool);
|
||||
}
|
||||
|
|
@ -128,7 +130,7 @@ export function ProjectView() {
|
|||
{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}`}
|
||||
href={projectPath(scope, slug, path)}
|
||||
>
|
||||
<ExternalLink size={14} /> Open in {TOOL_LABELS[tool]}
|
||||
</a>
|
||||
|
|
@ -161,7 +163,9 @@ export function ProjectView() {
|
|||
{data.project.name}
|
||||
{descriptor && <SourceChip descriptor={descriptor} />}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">/p/{data.project.slug}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data.project.scope}/projects/{data.project.slug}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{movingToLocal ? (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useParams } from "react-router-dom";
|
||||
import { toolSchema } from "@pcbjam/shared";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { parseToolParam, toolForFile, type Tool } from "@pcbjam/shared";
|
||||
import {
|
||||
fetchFileBytes,
|
||||
uploadFileBytes,
|
||||
|
|
@ -12,17 +12,24 @@ import { PreflightGate } from "@/preflight/PreflightGate";
|
|||
|
||||
export function ToolPage() {
|
||||
const params = useParams();
|
||||
const slug = params.project ?? "";
|
||||
const targetPath = params["*"] || undefined;
|
||||
const [search] = useSearchParams();
|
||||
const slug = params.name ?? "";
|
||||
// Two shapes render here: a fileless tool boot (`…/-/:tool`) sets params.tool;
|
||||
// a file route (`…/*`) sets the splat — the tool is inferred from its extension
|
||||
// unless `?tool=` overrides it.
|
||||
const splat = params["*"] || undefined;
|
||||
const tool: Tool | null = params.tool
|
||||
? parseToolParam(params.tool)
|
||||
: (parseToolParam(search.get("tool")) ?? (splat ? toolForFile(splat) : null));
|
||||
const targetPath = params.tool ? undefined : splat;
|
||||
|
||||
const parsedTool = toolSchema.safeParse(params.tool);
|
||||
const { data, isLoading, error } = useProject(slug);
|
||||
const { data: sourceDescriptor } = useSourceDescriptor(slug);
|
||||
|
||||
if (!parsedTool.success) {
|
||||
if (!tool) {
|
||||
return (
|
||||
<div className="container py-10 text-destructive">
|
||||
Unknown tool: {params.tool}
|
||||
Unknown tool: {params.tool ?? splat}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -54,7 +61,7 @@ export function ToolPage() {
|
|||
return (
|
||||
<PreflightGate>
|
||||
<WasmTool
|
||||
tool={parsedTool.data}
|
||||
tool={tool}
|
||||
slug={slug}
|
||||
projectId={data.project.id}
|
||||
files={data.files}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { contract, OWNER_HEADER, PROJECT_HEADER } from "@pcbjam/shared";
|
||||
import {
|
||||
contract,
|
||||
PROJECT_HEADER,
|
||||
SCOPE_HEADER,
|
||||
USER_HEADER,
|
||||
} from "@pcbjam/shared";
|
||||
import { initClient } from "@ts-rest/core";
|
||||
import type { LibInfo, LibItemInfo, LibsSource } from "./source";
|
||||
|
||||
|
|
@ -6,32 +11,34 @@ import type { LibInfo, LibItemInfo, LibsSource } from "./source";
|
|||
* A `LibsSource` backed by a contract-conforming backend (the closed registry
|
||||
* server, or the GPL example backend). Read list ops go through the ts-rest
|
||||
* client; item bodies stream from the raw text route
|
||||
* `GET /api/libs/:lib/items/:kind/:name`, and writes hit the symmetric
|
||||
* `PUT` route (binary/text doesn't round-trip ts-rest). Every request carries
|
||||
* the `owner` (thin per-user) and the `project` (project-scoped server-side
|
||||
* resolution) via `OWNER_HEADER`/`PROJECT_HEADER`; absent ⇒ backend default.
|
||||
* `GET /api/scopes/:scope/libs/:lib/items/:kind/:name`, and writes hit the
|
||||
* symmetric `PUT` route. The `scope` is a path param; `user` (thin per-user) and
|
||||
* `project` (project-scoped resolution) ride USER_HEADER/PROJECT_HEADER.
|
||||
*/
|
||||
export function remoteLibsSource(
|
||||
apiBase: string,
|
||||
owner?: string,
|
||||
scope: string,
|
||||
user?: string,
|
||||
project?: string,
|
||||
): LibsSource {
|
||||
const reqHeaders: Record<string, string> = {
|
||||
...(owner ? { [OWNER_HEADER]: owner } : {}),
|
||||
[SCOPE_HEADER]: scope,
|
||||
...(user ? { [USER_HEADER]: user } : {}),
|
||||
...(project ? { [PROJECT_HEADER]: project } : {}),
|
||||
};
|
||||
const client = initClient(contract, {
|
||||
baseUrl: apiBase,
|
||||
baseHeaders: reqHeaders,
|
||||
});
|
||||
const enc = encodeURIComponent;
|
||||
|
||||
const itemUrl = (libId: string, kind: string, name: string) =>
|
||||
`${apiBase}/api/libs/${encodeURIComponent(libId)}/items/` +
|
||||
`${encodeURIComponent(kind)}/${encodeURIComponent(name)}`;
|
||||
`${apiBase}/api/scopes/${enc(scope)}/libs/${enc(libId)}/items/` +
|
||||
`${enc(kind)}/${enc(name)}`;
|
||||
|
||||
return {
|
||||
async listLibs(kind?: string): Promise<LibInfo[]> {
|
||||
const res = await client.listLibs({ query: { kind } });
|
||||
const res = await client.listLibs({ params: { scope }, query: { kind } });
|
||||
if (res.status !== 200) return [];
|
||||
return res.body.map((l) => ({
|
||||
id: l.id,
|
||||
|
|
@ -43,7 +50,7 @@ export function remoteLibsSource(
|
|||
},
|
||||
|
||||
async listItems(libId: string): Promise<LibItemInfo[]> {
|
||||
const res = await client.listLibItems({ params: { lib: libId } });
|
||||
const res = await client.listLibItems({ params: { scope, lib: libId } });
|
||||
if (res.status !== 200) return [];
|
||||
return res.body.map((i) => ({ kind: i.kind, name: i.name }));
|
||||
},
|
||||
|
|
@ -75,7 +82,7 @@ export function remoteLibsSource(
|
|||
},
|
||||
|
||||
async createLib(name: string): Promise<LibInfo | null> {
|
||||
const res = await client.createLib({ body: { name } });
|
||||
const res = await client.createLib({ params: { scope }, body: { name } });
|
||||
if (res.status !== 201) return null;
|
||||
return {
|
||||
id: res.body.id,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { OWNER_HEADER, PROJECT_HEADER } from "@pcbjam/shared";
|
||||
import { PROJECT_HEADER, SCOPE_HEADER, USER_HEADER } from "@pcbjam/shared";
|
||||
import { SyncStack, type LayerDescriptor } from "@pcbjam/sync-client";
|
||||
import type { LibInfo, LibItemInfo, LibsSource } from "./source";
|
||||
|
||||
|
|
@ -18,7 +18,8 @@ export function syncedLibsSource(
|
|||
libId: string,
|
||||
opts: {
|
||||
apiBase: string;
|
||||
owner?: string;
|
||||
scope: string;
|
||||
user?: string;
|
||||
project?: string;
|
||||
log?: (msg: string) => void;
|
||||
},
|
||||
|
|
@ -85,15 +86,16 @@ export function syncedLibsSource(
|
|||
|
||||
async function resolveAndOpen(
|
||||
libId: string,
|
||||
opts: { apiBase: string; owner?: string; project?: string },
|
||||
opts: { apiBase: string; scope: string; user?: string; project?: string },
|
||||
log: (msg: string) => void,
|
||||
): Promise<{ stack: SyncStack; info: LibInfo }> {
|
||||
const headers: Record<string, string> = {
|
||||
...(opts.owner ? { [OWNER_HEADER]: opts.owner } : {}),
|
||||
[SCOPE_HEADER]: opts.scope,
|
||||
...(opts.user ? { [USER_HEADER]: opts.user } : {}),
|
||||
...(opts.project ? { [PROJECT_HEADER]: opts.project } : {}),
|
||||
};
|
||||
const res = await fetch(
|
||||
`${opts.apiBase}/api/libs/${encodeURIComponent(libId)}/sync-stack`,
|
||||
`${opts.apiBase}/api/scopes/${encodeURIComponent(opts.scope)}/libs/${encodeURIComponent(libId)}/sync-stack`,
|
||||
{ method: "POST", headers },
|
||||
);
|
||||
if (!res.ok) throw new Error(`sync-stack resolve failed: HTTP ${res.status}`);
|
||||
|
|
|
|||
Loading…
Reference in a new issue