pcbjam/web/standalone/src/pages/ProjectView.tsx

227 lines
7.9 KiB
TypeScript
Raw Normal View History

feat(standalone): browser-local (IndexedDB) virtual projects Loading a folder now imports a COPY into a browser-local IndexedDB project with its own /p/:slug URL (original disk files untouched): edits persist to IDB across visits, and the project exports via Download .zip or per-file. Gated by VITE_LOCAL_PROJECTS=idb (on for the demo; off keeps the File System Access write-back flow), so it's configurable per deployment. Modular, swappable project sources behind the shared ProjectSource interface, each self-describing via a SourceDescriptor whose kind is shown verbatim in the UI — "Local (this browser)", "Remote · read-only", "Remote · editable" — on the home page, the project view, and inside the editor, so the user always knows whether/how Save persists: - remote → REST backend (remote-rw) - static → CDN gallery (remote-ro), saves download - local (new) → idbProjectStore, writable IDB store A composite layers the local store over the configured remote/gallery source, routing per slug so imported/saved projects and the gallery share one namespace. New, dependency-free (matching sync-client's raw-IDB ethos): - lib/idb-project-store.ts raw IndexedDB store + create/delete/rename/export - lib/zip.ts store-only ZIP writer (verified via system unzip) - lib/import-folder.ts FSA/webkitdirectory folder → in-memory bytes - lib/project-source-shared.ts source descriptors + deterministic uuid - components/SourceChip, components/LocalProjectsSection Home lists local projects (open/export/rename/delete). Verified: typecheck, project-source tests, prod build, and IDB key-range project isolation in-browser (prefix-colliding slugs don't leak). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 08:24:14 +02:00
import * as React from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import {
EXTENSION_TOOL,
FILELESS_TOOLS,
TOOL_LABELS,
type Tool,
projectPath,
projectToolPath,
} from "@pcbjam/shared";
import {
ArrowLeft,
Download,
ExternalLink,
Loader2,
Trash2,
} from "lucide-react";
feat(standalone): browser-local (IndexedDB) virtual projects Loading a folder now imports a COPY into a browser-local IndexedDB project with its own /p/:slug URL (original disk files untouched): edits persist to IDB across visits, and the project exports via Download .zip or per-file. Gated by VITE_LOCAL_PROJECTS=idb (on for the demo; off keeps the File System Access write-back flow), so it's configurable per deployment. Modular, swappable project sources behind the shared ProjectSource interface, each self-describing via a SourceDescriptor whose kind is shown verbatim in the UI — "Local (this browser)", "Remote · read-only", "Remote · editable" — on the home page, the project view, and inside the editor, so the user always knows whether/how Save persists: - remote → REST backend (remote-rw) - static → CDN gallery (remote-ro), saves download - local (new) → idbProjectStore, writable IDB store A composite layers the local store over the configured remote/gallery source, routing per slug so imported/saved projects and the gallery share one namespace. New, dependency-free (matching sync-client's raw-IDB ethos): - lib/idb-project-store.ts raw IndexedDB store + create/delete/rename/export - lib/zip.ts store-only ZIP writer (verified via system unzip) - lib/import-folder.ts FSA/webkitdirectory folder → in-memory bytes - lib/project-source-shared.ts source descriptors + deterministic uuid - components/SourceChip, components/LocalProjectsSection Home lists local projects (open/export/rename/delete). Verified: typecheck, project-source tests, prod build, and IDB key-range project isolation in-browser (prefix-colliding slugs don't leak). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 08:24:14 +02:00
import { fetchFileBytes, useProject, useSourceDescriptor } from "@/lib/api";
import { downloadBytes } from "@/lib/download";
import { localProjectStore } from "@/lib/project-source";
import { zipFiles } from "@/lib/zip";
import { Button } from "@/components/ui/button";
feat(standalone): browser-local (IndexedDB) virtual projects Loading a folder now imports a COPY into a browser-local IndexedDB project with its own /p/:slug URL (original disk files untouched): edits persist to IDB across visits, and the project exports via Download .zip or per-file. Gated by VITE_LOCAL_PROJECTS=idb (on for the demo; off keeps the File System Access write-back flow), so it's configurable per deployment. Modular, swappable project sources behind the shared ProjectSource interface, each self-describing via a SourceDescriptor whose kind is shown verbatim in the UI — "Local (this browser)", "Remote · read-only", "Remote · editable" — on the home page, the project view, and inside the editor, so the user always knows whether/how Save persists: - remote → REST backend (remote-rw) - static → CDN gallery (remote-ro), saves download - local (new) → idbProjectStore, writable IDB store A composite layers the local store over the configured remote/gallery source, routing per slug so imported/saved projects and the gallery share one namespace. New, dependency-free (matching sync-client's raw-IDB ethos): - lib/idb-project-store.ts raw IndexedDB store + create/delete/rename/export - lib/zip.ts store-only ZIP writer (verified via system unzip) - lib/import-folder.ts FSA/webkitdirectory folder → in-memory bytes - lib/project-source-shared.ts source descriptors + deterministic uuid - components/SourceChip, components/LocalProjectsSection Home lists local projects (open/export/rename/delete). Verified: typecheck, project-source tests, prod build, and IDB key-range project isolation in-browser (prefix-colliding slugs don't leak). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 08:24:14 +02:00
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 {
const dot = path.lastIndexOf(".");
if (dot < 0) return null;
return EXTENSION_TOOL[path.slice(dot).toLowerCase()] ?? null;
}
/**
* 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 { scope = "", name: slug = "" } = useParams();
feat(standalone): browser-local (IndexedDB) virtual projects Loading a folder now imports a COPY into a browser-local IndexedDB project with its own /p/:slug URL (original disk files untouched): edits persist to IDB across visits, and the project exports via Download .zip or per-file. Gated by VITE_LOCAL_PROJECTS=idb (on for the demo; off keeps the File System Access write-back flow), so it's configurable per deployment. Modular, swappable project sources behind the shared ProjectSource interface, each self-describing via a SourceDescriptor whose kind is shown verbatim in the UI — "Local (this browser)", "Remote · read-only", "Remote · editable" — on the home page, the project view, and inside the editor, so the user always knows whether/how Save persists: - remote → REST backend (remote-rw) - static → CDN gallery (remote-ro), saves download - local (new) → idbProjectStore, writable IDB store A composite layers the local store over the configured remote/gallery source, routing per slug so imported/saved projects and the gallery share one namespace. New, dependency-free (matching sync-client's raw-IDB ethos): - lib/idb-project-store.ts raw IndexedDB store + create/delete/rename/export - lib/zip.ts store-only ZIP writer (verified via system unzip) - lib/import-folder.ts FSA/webkitdirectory folder → in-memory bytes - lib/project-source-shared.ts source descriptors + deterministic uuid - components/SourceChip, components/LocalProjectsSection Home lists local projects (open/export/rename/delete). Verified: typecheck, project-source tests, prod build, and IDB key-range project isolation in-browser (prefix-colliding slugs don't leak). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 08:24:14 +02:00
const navigate = useNavigate();
const qc = useQueryClient();
const { data, isLoading, error } = useProject(slug);
feat(standalone): browser-local (IndexedDB) virtual projects Loading a folder now imports a COPY into a browser-local IndexedDB project with its own /p/:slug URL (original disk files untouched): edits persist to IDB across visits, and the project exports via Download .zip or per-file. Gated by VITE_LOCAL_PROJECTS=idb (on for the demo; off keeps the File System Access write-back flow), so it's configurable per deployment. Modular, swappable project sources behind the shared ProjectSource interface, each self-describing via a SourceDescriptor whose kind is shown verbatim in the UI — "Local (this browser)", "Remote · read-only", "Remote · editable" — on the home page, the project view, and inside the editor, so the user always knows whether/how Save persists: - remote → REST backend (remote-rw) - static → CDN gallery (remote-ro), saves download - local (new) → idbProjectStore, writable IDB store A composite layers the local store over the configured remote/gallery source, routing per slug so imported/saved projects and the gallery share one namespace. New, dependency-free (matching sync-client's raw-IDB ethos): - lib/idb-project-store.ts raw IndexedDB store + create/delete/rename/export - lib/zip.ts store-only ZIP writer (verified via system unzip) - lib/import-folder.ts FSA/webkitdirectory folder → in-memory bytes - lib/project-source-shared.ts source descriptors + deterministic uuid - components/SourceChip, components/LocalProjectsSection Home lists local projects (open/export/rename/delete). Verified: typecheck, project-source tests, prod build, and IDB key-range project isolation in-browser (prefix-colliding slugs don't leak). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 08:24:14 +02:00
const { data: descriptor } = useSourceDescriptor(slug);
const isLocal = descriptor?.kind === "local";
const isRemoteRo = descriptor?.kind === "remote-ro";
const store = localProjectStore();
feat(standalone): browser-local (IndexedDB) virtual projects Loading a folder now imports a COPY into a browser-local IndexedDB project with its own /p/:slug URL (original disk files untouched): edits persist to IDB across visits, and the project exports via Download .zip or per-file. Gated by VITE_LOCAL_PROJECTS=idb (on for the demo; off keeps the File System Access write-back flow), so it's configurable per deployment. Modular, swappable project sources behind the shared ProjectSource interface, each self-describing via a SourceDescriptor whose kind is shown verbatim in the UI — "Local (this browser)", "Remote · read-only", "Remote · editable" — on the home page, the project view, and inside the editor, so the user always knows whether/how Save persists: - remote → REST backend (remote-rw) - static → CDN gallery (remote-ro), saves download - local (new) → idbProjectStore, writable IDB store A composite layers the local store over the configured remote/gallery source, routing per slug so imported/saved projects and the gallery share one namespace. New, dependency-free (matching sync-client's raw-IDB ethos): - lib/idb-project-store.ts raw IndexedDB store + create/delete/rename/export - lib/zip.ts store-only ZIP writer (verified via system unzip) - lib/import-folder.ts FSA/webkitdirectory folder → in-memory bytes - lib/project-source-shared.ts source descriptors + deterministic uuid - components/SourceChip, components/LocalProjectsSection Home lists local projects (open/export/rename/delete). Verified: typecheck, project-source tests, prod build, and IDB key-range project isolation in-browser (prefix-colliding slugs don't leak). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 08:24:14 +02:00
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]);
feat(standalone): browser-local (IndexedDB) virtual projects Loading a folder now imports a COPY into a browser-local IndexedDB project with its own /p/:slug URL (original disk files untouched): edits persist to IDB across visits, and the project exports via Download .zip or per-file. Gated by VITE_LOCAL_PROJECTS=idb (on for the demo; off keeps the File System Access write-back flow), so it's configurable per deployment. Modular, swappable project sources behind the shared ProjectSource interface, each self-describing via a SourceDescriptor whose kind is shown verbatim in the UI — "Local (this browser)", "Remote · read-only", "Remote · editable" — on the home page, the project view, and inside the editor, so the user always knows whether/how Save persists: - remote → REST backend (remote-rw) - static → CDN gallery (remote-ro), saves download - local (new) → idbProjectStore, writable IDB store A composite layers the local store over the configured remote/gallery source, routing per slug so imported/saved projects and the gallery share one namespace. New, dependency-free (matching sync-client's raw-IDB ethos): - lib/idb-project-store.ts raw IndexedDB store + create/delete/rename/export - lib/zip.ts store-only ZIP writer (verified via system unzip) - lib/import-folder.ts FSA/webkitdirectory folder → in-memory bytes - lib/project-source-shared.ts source descriptors + deterministic uuid - components/SourceChip, components/LocalProjectsSection Home lists local projects (open/export/rename/delete). Verified: typecheck, project-source tests, prod build, and IDB key-range project isolation in-browser (prefix-colliding slugs don't leak). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 08:24:14 +02:00
const exportZip = async () => {
if (!store) return;
setBusy(true);
try {
downloadBytes(`${slug}.zip`, zipFiles(await store.readFiles(slug)));
} finally {
setBusy(false);
}
};
const downloadOne = async (path: string) => {
downloadBytes(path, await fetchFileBytes(slug, path));
};
const remove = async () => {
if (!store || !data) return;
if (!window.confirm(`Delete "${data.project.name}" from this browser? This can't be undone.`))
return;
await store.deleteProject(slug);
await qc.invalidateQueries({ queryKey: ["local-projects"] });
navigate("/");
};
// 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(projectToolPath(scope, 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={projectPath(scope, slug, 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">
<Link to="/">
<ArrowLeft /> Home
</Link>
</Button>
{isLoading && (
<p className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="animate-spin" /> loading…
</p>
)}
{error && <p className="text-destructive">{(error as Error).message}</p>}
{data && (
<>
<div className="mb-6">
feat(standalone): browser-local (IndexedDB) virtual projects Loading a folder now imports a COPY into a browser-local IndexedDB project with its own /p/:slug URL (original disk files untouched): edits persist to IDB across visits, and the project exports via Download .zip or per-file. Gated by VITE_LOCAL_PROJECTS=idb (on for the demo; off keeps the File System Access write-back flow), so it's configurable per deployment. Modular, swappable project sources behind the shared ProjectSource interface, each self-describing via a SourceDescriptor whose kind is shown verbatim in the UI — "Local (this browser)", "Remote · read-only", "Remote · editable" — on the home page, the project view, and inside the editor, so the user always knows whether/how Save persists: - remote → REST backend (remote-rw) - static → CDN gallery (remote-ro), saves download - local (new) → idbProjectStore, writable IDB store A composite layers the local store over the configured remote/gallery source, routing per slug so imported/saved projects and the gallery share one namespace. New, dependency-free (matching sync-client's raw-IDB ethos): - lib/idb-project-store.ts raw IndexedDB store + create/delete/rename/export - lib/zip.ts store-only ZIP writer (verified via system unzip) - lib/import-folder.ts FSA/webkitdirectory folder → in-memory bytes - lib/project-source-shared.ts source descriptors + deterministic uuid - components/SourceChip, components/LocalProjectsSection Home lists local projects (open/export/rename/delete). Verified: typecheck, project-source tests, prod build, and IDB key-range project isolation in-browser (prefix-colliding slugs don't leak). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 08:24:14 +02:00
<h1 className="flex flex-wrap items-center gap-3 text-2xl font-semibold tracking-tight">
{data.project.name}
feat(standalone): browser-local (IndexedDB) virtual projects Loading a folder now imports a COPY into a browser-local IndexedDB project with its own /p/:slug URL (original disk files untouched): edits persist to IDB across visits, and the project exports via Download .zip or per-file. Gated by VITE_LOCAL_PROJECTS=idb (on for the demo; off keeps the File System Access write-back flow), so it's configurable per deployment. Modular, swappable project sources behind the shared ProjectSource interface, each self-describing via a SourceDescriptor whose kind is shown verbatim in the UI — "Local (this browser)", "Remote · read-only", "Remote · editable" — on the home page, the project view, and inside the editor, so the user always knows whether/how Save persists: - remote → REST backend (remote-rw) - static → CDN gallery (remote-ro), saves download - local (new) → idbProjectStore, writable IDB store A composite layers the local store over the configured remote/gallery source, routing per slug so imported/saved projects and the gallery share one namespace. New, dependency-free (matching sync-client's raw-IDB ethos): - lib/idb-project-store.ts raw IndexedDB store + create/delete/rename/export - lib/zip.ts store-only ZIP writer (verified via system unzip) - lib/import-folder.ts FSA/webkitdirectory folder → in-memory bytes - lib/project-source-shared.ts source descriptors + deterministic uuid - components/SourceChip, components/LocalProjectsSection Home lists local projects (open/export/rename/delete). Verified: typecheck, project-source tests, prod build, and IDB key-range project isolation in-browser (prefix-colliding slugs don't leak). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 08:24:14 +02:00
{descriptor && <SourceChip descriptor={descriptor} />}
</h1>
<p className="text-sm text-muted-foreground">
{data.project.scope}/projects/{data.project.slug}
</p>
</div>
{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>
)}
<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}
onClose={() => setNewFileTool(null)}
project={{ slug, existingPaths: data.files.map((f) => f.path) }}
/>
</>
)}
</div>
);
}