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>
227 lines
7.9 KiB
TypeScript
227 lines
7.9 KiB
TypeScript
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";
|
|
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";
|
|
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();
|
|
const navigate = useNavigate();
|
|
const qc = useQueryClient();
|
|
const { data, isLoading, error } = useProject(slug);
|
|
const { data: descriptor } = useSourceDescriptor(slug);
|
|
const isLocal = descriptor?.kind === "local";
|
|
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 () => {
|
|
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">
|
|
<h1 className="flex flex-wrap items-center gap-3 text-2xl font-semibold tracking-tight">
|
|
{data.project.name}
|
|
{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>
|
|
);
|
|
}
|