feat: standalone home rework — tool launcher + backend libraries listing
Rework HomePage into KiCad-launcher-style zones: local file-load, a Tools grid, backend projects, and a backend libraries listing (useLibs over the shared listLibs). The Tools grid shows all seven tools in KiCad's standalone order (Schematic/Symbol/PCB/Footprint editors, Gerber Viewer, Calculator, Drawing Sheet); clicking a tool or a library launches the editor with no project (launchedTool → WasmTool slug=local) — file-less editors browse backend libs, document editors open blank. Harden the web e2e global-setup to assert the self-provisioned origin libs (Device, Resistor_SMD) are present, failing fast. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c2e1214fd1
commit
b0b7f69846
4 changed files with 233 additions and 7 deletions
|
|
@ -13,6 +13,12 @@ import type { FullConfig } from '@playwright/test';
|
|||
const API_BASE = process.env.BACKEND_URL ?? 'http://localhost:3060';
|
||||
const DEMO_SLUG = 'demo';
|
||||
const DEMO_FILES = ['demo.kicad_sch', 'demo.kicad_pcb', 'demo.kicad_wks'];
|
||||
// Origin libs the remote-lib specs browse/place (symbol-write/footprint-browse).
|
||||
// The backend self-provisions these on dev/start (src/extract/ensure-example-libs).
|
||||
const REQUIRED_LIBS: Record<'symbol' | 'footprint', string[]> = {
|
||||
symbol: ['Device'],
|
||||
footprint: ['Resistor_SMD'],
|
||||
};
|
||||
|
||||
async function waitForApi(timeoutMs = 60000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
|
@ -50,8 +56,30 @@ async function verifyDemoProject(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
async function verifyLibs(): Promise<void> {
|
||||
for (const kind of ['symbol', 'footprint'] as const) {
|
||||
const r = await fetch(`${API_BASE}/api/libs?kind=${kind}`);
|
||||
if (!r.ok) {
|
||||
throw new Error(`GET /api/libs?kind=${kind} -> HTTP ${r.status}`);
|
||||
}
|
||||
const libs = (await r.json()) as { id: string }[];
|
||||
const have = new Set(libs.map((l) => l.id));
|
||||
const missing = REQUIRED_LIBS[kind].filter((id) => !have.has(id));
|
||||
if (missing.length) {
|
||||
throw new Error(
|
||||
`backend missing ${kind} origin lib(s): ${missing.join(', ')}. The example ` +
|
||||
`backend self-provisions libs on dev/start (clones + extracts a KiCad slice ` +
|
||||
`into web/backend/.libs) — check that step ran (network on first run).`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default async function globalSetup(_config: FullConfig): Promise<void> {
|
||||
await waitForApi();
|
||||
await verifyDemoProject();
|
||||
console.log(`web e2e setup: backend at ${API_BASE} serving "${DEMO_SLUG}" — OK`);
|
||||
await verifyLibs();
|
||||
console.log(
|
||||
`web e2e setup: backend at ${API_BASE} serving "${DEMO_SLUG}" + origin libs — OK`
|
||||
);
|
||||
}
|
||||
|
|
|
|||
70
web/standalone/src/components/ToolGrid.tsx
Normal file
70
web/standalone/src/components/ToolGrid.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { TOOL_LABELS, TOOLS, type Tool } from "@pcbjam/shared";
|
||||
import {
|
||||
Calculator,
|
||||
CircuitBoard,
|
||||
Component,
|
||||
Cpu,
|
||||
Layers,
|
||||
LayoutTemplate,
|
||||
type LucideIcon,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
|
||||
/** Icon per tool — KiCad-launcher style. */
|
||||
const TOOL_ICONS: Record<Tool, LucideIcon> = {
|
||||
eeschema: Workflow,
|
||||
symbol_editor: Component,
|
||||
pcbnew: CircuitBoard,
|
||||
footprint_editor: Cpu,
|
||||
gerbview: Layers,
|
||||
calculator: Calculator,
|
||||
pl_editor: LayoutTemplate,
|
||||
};
|
||||
|
||||
/**
|
||||
* Display order for the launcher, matching KiCad's standalone project-manager
|
||||
* list (Image Converter + Plugin Manager are KiCad tools we don't ship). Any
|
||||
* tool in TOOLS but missing here is appended, so adding a tool can't drop it.
|
||||
*/
|
||||
const TOOL_ORDER: Tool[] = [
|
||||
"eeschema", // Schematic Editor
|
||||
"symbol_editor", // Symbol Editor
|
||||
"pcbnew", // PCB Editor
|
||||
"footprint_editor", // Footprint Editor
|
||||
"gerbview", // Gerber Viewer
|
||||
"calculator", // PCB Calculator
|
||||
"pl_editor", // Drawing Sheet Editor
|
||||
];
|
||||
|
||||
function orderedTools(): Tool[] {
|
||||
const known = TOOL_ORDER.filter((t) => TOOLS.includes(t));
|
||||
const rest = TOOLS.filter((t) => !known.includes(t));
|
||||
return [...known, ...rest];
|
||||
}
|
||||
|
||||
/**
|
||||
* KiCad-style tool launcher: a grid of icon+text cards, one per tool. Clicking a
|
||||
* card launches that tool in-place with no project — the file-less editors browse
|
||||
* backend libraries; the document editors (schematic/PCB/drawing sheet) boot to a
|
||||
* blank document, the same as opening them from KiCad's project manager.
|
||||
*/
|
||||
export function ToolGrid({ onLaunch }: { onLaunch: (tool: Tool) => void }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{orderedTools().map((tool) => {
|
||||
const Icon = TOOL_ICONS[tool] ?? Component;
|
||||
return (
|
||||
<button
|
||||
key={tool}
|
||||
type="button"
|
||||
onClick={() => onLaunch(tool)}
|
||||
className="flex flex-col items-center gap-2 rounded-lg border bg-card p-4 text-center transition-colors hover:bg-accent"
|
||||
>
|
||||
<Icon size={28} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{TOOL_LABELS[tool]}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
contract,
|
||||
type Lib,
|
||||
type Project,
|
||||
type ProjectWithFiles,
|
||||
} from "@pcbjam/shared";
|
||||
|
|
@ -30,6 +31,23 @@ export function useProjects() {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Libraries the backend serves, optionally filtered to a kind ("symbol" |
|
||||
* "footprint"). Origins are kind-filtered server-side; user libs are
|
||||
* kind-agnostic and always returned. Mirrors `useProjects` — read-only listing
|
||||
* for the home page; the editor consumes libs over its own WASM bridge.
|
||||
*/
|
||||
export function useLibs(kind?: "symbol" | "footprint") {
|
||||
return useQuery({
|
||||
queryKey: ["libs", kind ?? "all"],
|
||||
queryFn: async (): Promise<Lib[]> => {
|
||||
const res = await client.listLibs({ query: { kind } });
|
||||
if (res.status !== 200) throw new Error("failed to list libraries");
|
||||
return res.body;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useProject(slug: string) {
|
||||
return useQuery({
|
||||
queryKey: ["project", slug],
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { Tool } from "@pcbjam/shared";
|
||||
import { FolderOpen, Loader2 } from "lucide-react";
|
||||
import { useProjects } from "@/lib/api";
|
||||
import type { Lib, Tool } from "@pcbjam/shared";
|
||||
import { FolderOpen, Library, Loader2, Package } from "lucide-react";
|
||||
import { useLibs, useProjects } from "@/lib/api";
|
||||
import { downloadBytes } from "@/lib/download";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolGrid } from "@/components/ToolGrid";
|
||||
import type { SaveBytes } from "@/wasm/save-flow";
|
||||
import { LocalProjectView, type LocalFile } from "@/components/LocalProjectView";
|
||||
import { WasmTool } from "@/components/WasmTool";
|
||||
|
|
@ -99,19 +100,43 @@ function buildLocalProject(fileList: FileList): LocalProject {
|
|||
|
||||
export function HomePage() {
|
||||
const { data: projects, isLoading, error } = useProjects();
|
||||
const symbolLibs = useLibs("symbol");
|
||||
const footprintLibs = useLibs("footprint");
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [local, setLocal] = React.useState<LocalProject | null>(null);
|
||||
const [launched, setLaunched] = React.useState<{ tool: Tool; target?: string } | null>(
|
||||
null,
|
||||
);
|
||||
// A tool launched straight from the home page — no local folder, no backend
|
||||
// project. File-less editors browse backend libraries; document editors
|
||||
// (schematic/PCB/drawing sheet) boot to a blank document (KiCad-launcher style).
|
||||
const [launchedTool, setLaunchedTool] = React.useState<{ tool: Tool } | null>(null);
|
||||
|
||||
// <input webkitdirectory> is non-standard; set it imperatively.
|
||||
React.useEffect(() => {
|
||||
if (inputRef.current) inputRef.current.setAttribute("webkitdirectory", "");
|
||||
}, []);
|
||||
|
||||
// Once launched, the WASM runtime is process-global and one-shot for this page
|
||||
// load — render the editor full-screen and nothing else (no going back).
|
||||
// Tool launched from the home page: no project, no files. File-less editors
|
||||
// read libraries from the backend (libsSourceConfig) and persist through the
|
||||
// lib write bridge; document editors open a blank document. Either way there's
|
||||
// no project fetch/save plumbing to wire.
|
||||
if (launchedTool) {
|
||||
return (
|
||||
<WasmTool
|
||||
tool={launchedTool.tool}
|
||||
slug="local"
|
||||
projectId="local"
|
||||
files={[]}
|
||||
fetchBytes={async (p) => {
|
||||
throw new Error(`no project file to fetch: ${p}`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Once launched over a local folder, the WASM runtime is process-global and
|
||||
// one-shot for this page load — render the editor full-screen and nothing else.
|
||||
if (local && launched) {
|
||||
return (
|
||||
<WasmTool
|
||||
|
|
@ -189,8 +214,14 @@ export function HomePage() {
|
|||
)}
|
||||
</section>
|
||||
|
||||
{/* --- Tools (KiCad-style launcher for the standalone tools) --- */}
|
||||
<section className="mb-10">
|
||||
<h2 className="mb-3 text-lg font-medium">Tools</h2>
|
||||
<ToolGrid onLaunch={(tool) => setLaunchedTool({ tool })} />
|
||||
</section>
|
||||
|
||||
{/* --- Backend projects --- */}
|
||||
<section>
|
||||
<section className="mb-10">
|
||||
<h2 className="mb-3 text-lg font-medium">Projects from the backend</h2>
|
||||
{isLoading && (
|
||||
<p className="flex items-center gap-2 text-muted-foreground">
|
||||
|
|
@ -225,6 +256,85 @@ export function HomePage() {
|
|||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- Backend libraries --- */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-medium">Libraries from the backend</h2>
|
||||
<div className="space-y-3">
|
||||
<LibGroup
|
||||
icon={<Library size={16} />}
|
||||
label="Symbols"
|
||||
query={symbolLibs}
|
||||
onOpen={() => setLaunchedTool({ tool: "symbol_editor" })}
|
||||
/>
|
||||
<LibGroup
|
||||
icon={<Package size={16} />}
|
||||
label="Footprints"
|
||||
query={footprintLibs}
|
||||
onOpen={() => setLaunchedTool({ tool: "footprint_editor" })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One backend-library group (Symbols / Footprints): clickable chips that launch
|
||||
* the matching editor to browse the library. The editor lists ALL libs of that
|
||||
* kind in its own tree (lib-scoped open is a later iteration); clicking here
|
||||
* just opens the right tool.
|
||||
*/
|
||||
function LibGroup({
|
||||
icon,
|
||||
label,
|
||||
query,
|
||||
onOpen,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
query: ReturnType<typeof useLibs>;
|
||||
onOpen: (lib: Lib) => void;
|
||||
}) {
|
||||
const { data: libs, isLoading, error } = query;
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
{icon} {label}
|
||||
</h3>
|
||||
{isLoading && (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="animate-spin" size={14} /> loading…
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-sm text-muted-foreground">No backend reachable.</p>
|
||||
)}
|
||||
{libs && libs.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No {label.toLowerCase()} libraries.
|
||||
</p>
|
||||
)}
|
||||
{libs && libs.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{libs.map((lib) => (
|
||||
<button
|
||||
key={lib.id}
|
||||
type="button"
|
||||
onClick={() => onOpen(lib)}
|
||||
title={lib.description ?? undefined}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-sm hover:bg-accent"
|
||||
>
|
||||
{lib.name}
|
||||
{lib.itemCount !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{lib.itemCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue