refactor(web): split into GPL standalone editor + thin reference backend
Reduce web/ to a single generic editor app plus a minimal example backend, moving all project-specific/app code out to the closed root repo: - apps/frontend -> standalone (@pcbjam/standalone): the GPL editor. Strips the project-management UI (ProjectsPage/ProjectDetailPage/UploadDropzone and the write half of lib/api.ts). WasmTool now takes fetchBytes + assetBaseUrl as injected props (decoupled from the API client) so it can be driven by either a backend or a local folder. New HomePage (local-folder loader + backend project list) and read-only ProjectView. BroadcastChannel collab unchanged. - backend (@pcbjam/backend-example): thin Fastify+ts-rest reference impl of the @pcbjam/shared contract — serves a single project off the local filesystem (PROJECT_DIR), no DB/auth/uploads. - packages/contract -> web/pcbjam-shared (git submodule, MIT @pcbjam/shared). - Remove apps/server, packages/storage, packages/contract, docker-compose (server + storage move to the closed root repo; preserved via subtree branches). Rewrite pnpm-workspace/turbo/.env for the frontend-only layout. - Add tests/fixtures/demo so the editor + example backend run out of the box. Standalone typechecks + builds; backend serves the contract end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b672c54189
commit
b6a77ee93c
80 changed files with 671 additions and 2992 deletions
4
.gitmodules
vendored
4
.gitmodules
vendored
|
|
@ -6,3 +6,7 @@
|
||||||
path = wxwidgets
|
path = wxwidgets
|
||||||
url = git@github.com:emergence-engineering/wxWidgets.git
|
url = git@github.com:emergence-engineering/wxWidgets.git
|
||||||
branch = wasm-port
|
branch = wasm-port
|
||||||
|
[submodule "web/pcbjam-shared"]
|
||||||
|
path = web/pcbjam-shared
|
||||||
|
url = git@github.com:emergence-engineering/pcbjam-shared.git
|
||||||
|
branch = main
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,30 @@
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Copy to web/.env and adjust as needed. docker-compose and the server both
|
# This is a standalone, frontend-only workspace: the `standalone` editor app
|
||||||
# read this file.
|
# and an example `backend`. Each app reads its OWN .env — see:
|
||||||
|
# standalone/.env.example (Vite frontend)
|
||||||
|
# backend/.env.example (thin reference backend)
|
||||||
|
# Copy each to a sibling `.env`. The values below are only a convenience
|
||||||
|
# overview; turbo passes these through to the apps.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# --- Postgres (docker-compose) ---
|
# --- standalone editor (Vite, must be VITE_-prefixed) ---
|
||||||
POSTGRES_USER=kicad
|
# Backend implementing the @pcbjam/shared contract (the example backend, or any
|
||||||
POSTGRES_PASSWORD=kicad
|
# conforming backend such as the closed app's server). Leave unset to use only
|
||||||
POSTGRES_DB=kicad_web
|
# the local-folder loader.
|
||||||
# Non-default host port to avoid colliding with a local Postgres on 5432.
|
VITE_API_BASE_URL=http://localhost:3060
|
||||||
POSTGRES_PORT=54329
|
# Where the WASM glue/artifacts are served from. SAME-ORIGIN "/wasm" is required
|
||||||
|
# because KiCad WASM pthread workers cannot be created cross-origin; on `dev` the
|
||||||
# --- Server ---
|
# artifacts are symlinked into standalone/public/wasm and served by Vite at /wasm.
|
||||||
PORT=3050
|
# For prod, set to an absolute URL whose origin also satisfies the COEP rules.
|
||||||
# Connection string MUST match the POSTGRES_* values + port above.
|
|
||||||
DATABASE_URL=postgres://kicad:kicad@localhost:54329/kicad_web
|
|
||||||
# Where uploaded project file bytes are stored by the local-disk FileStorage.
|
|
||||||
STORAGE_ROOT=./.data/storage
|
|
||||||
# Owner namespace used while there is no auth (seeded on first migrate).
|
|
||||||
DEFAULT_OWNER_SLUG=default
|
|
||||||
# Browser origin allowed to call the API (the Vite dev server, non-default port).
|
|
||||||
CORS_ORIGIN=http://localhost:3048
|
|
||||||
|
|
||||||
# --- Frontend (Vite, must be VITE_-prefixed) ---
|
|
||||||
VITE_API_BASE_URL=http://localhost:3050
|
|
||||||
# SAME-ORIGIN: artifacts are smart-copied into public/wasm on `dev` and served by
|
|
||||||
# Vite at /wasm. Required because KiCad WASM pthread workers can't be cross-origin.
|
|
||||||
# For prod, set to a CDN URL whose origin also satisfies the worker/COEP rules.
|
|
||||||
VITE_WASM_ASSET_BASE_URL=/wasm
|
VITE_WASM_ASSET_BASE_URL=/wasm
|
||||||
|
# Override the artifact source dir the dev symlink points at (default:
|
||||||
|
# <repo>/tests/apps/kicad, populated by tests/scripts/setup-kicad-wasm.sh).
|
||||||
|
# WASM_SRC_DIR=
|
||||||
|
|
||||||
|
# --- example backend ---
|
||||||
|
# Absolute or relative path to a single KiCad project folder to serve.
|
||||||
|
PROJECT_DIR=../../tests/fixtures/demo
|
||||||
|
# Port the backend listens on (the standalone's VITE_API_BASE_URL must match).
|
||||||
|
PORT=3060
|
||||||
|
# Browser origin allowed to call the backend (the Vite dev server).
|
||||||
|
CORS_ORIGIN=http://localhost:3048
|
||||||
|
|
|
||||||
127
web/README.md
127
web/README.md
|
|
@ -1,116 +1,83 @@
|
||||||
# KiCad Web
|
# PCBJam Web — standalone editor (GPL)
|
||||||
|
|
||||||
Single web app to create/open KiCad projects, upload files, and open them in the
|
A self-contained, GPL web app that opens KiCad projects in the WASM tools
|
||||||
WASM tools (pcbnew / eeschema / calculator) by URL:
|
(pcbnew / eeschema / pl_editor / …) — from a local folder, or from any backend
|
||||||
|
that implements the MIT [`@pcbjam/shared`](./pcbjam-shared) contract. It opens a
|
||||||
|
tool by URL:
|
||||||
|
|
||||||
```
|
```
|
||||||
/p/<project>/<tool>/<file-path> e.g. /p/project5/pcbnew/nyak.kicad_pcb
|
/p/<project>/<tool>/<file-path> e.g. /p/demo/pcbnew/nyak.kicad_pcb
|
||||||
```
|
```
|
||||||
|
|
||||||
Design + decisions: [`../docs/features/web-init/0001-web-app-spec.md`](../docs/features/web-init/0001-web-app-spec.md).
|
This workspace contains **only** the generic editor and a thin reference
|
||||||
|
backend. All project-specific concerns (accounts, project management, uploads,
|
||||||
|
auth) live in the separate closed application, which reuses this editor by
|
||||||
|
hosting it standalone and redirecting to it (it must not link the GPL editor).
|
||||||
|
|
||||||
## Stack
|
## Layout
|
||||||
|
|
||||||
- **Monorepo**: pnpm + turbo
|
|
||||||
- **Frontend**: Vite + React + TypeScript + shadcn/ui (`apps/frontend`)
|
|
||||||
- **Backend**: Fastify + ts-rest + Zod (`apps/server`)
|
|
||||||
- **DB**: Postgres + Drizzle (project/file metadata)
|
|
||||||
- **Storage**: pluggable `FileStorage` (local disk now, S3 later) (`packages/storage`)
|
|
||||||
- **Shared types**: ts-rest contract + Zod (`packages/contract`)
|
|
||||||
|
|
||||||
```
|
```
|
||||||
web/
|
web/
|
||||||
├── apps/
|
├── standalone/ # @pcbjam/standalone — the GPL editor (Vite + React)
|
||||||
│ ├── frontend/ # Vite React app
|
├── backend/ # @pcbjam/backend-example — thin reference @pcbjam/shared impl
|
||||||
│ └── server/ # Fastify API + WASM static + Drizzle
|
└── pcbjam-shared/ # @pcbjam/shared — the FE↔BE contract (git submodule, MIT)
|
||||||
└── packages/
|
|
||||||
├── contract/ # ts-rest contract + Zod schemas (FE + BE share this)
|
|
||||||
└── storage/ # FileStorage interface + LocalDiskStorage
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- **Editor**: Vite + React + TypeScript. Boots a tool directly in the document
|
||||||
|
(no iframe), syncs the project tree into MEMFS, drives File→Open, and runs
|
||||||
|
same-tab collaboration over BroadcastChannel.
|
||||||
|
- **Example backend**: Fastify + ts-rest serving a single project off the local
|
||||||
|
filesystem (`PROJECT_DIR`). No DB, no auth, no uploads — the minimum the editor
|
||||||
|
needs, and a worked example of the contract.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd web
|
cd web
|
||||||
cp .env.example .env # Postgres host port defaults to 54329 (non-default)
|
|
||||||
pnpm install
|
pnpm install
|
||||||
|
git submodule update --init web/pcbjam-shared # if not already populated
|
||||||
|
|
||||||
pnpm db:up # start Postgres (docker compose)
|
cp standalone/.env.example standalone/.env
|
||||||
pnpm db:migrate # apply migrations + seed the default owner
|
cp backend/.env.example backend/.env # PROJECT_DIR=../../tests/fixtures/demo
|
||||||
|
|
||||||
pnpm dev # turbo: server :3050 + frontend :3048
|
pnpm dev # turbo: backend :3060 + editor :3048
|
||||||
```
|
```
|
||||||
|
|
||||||
Open http://localhost:3048 — create a project, upload files (multi / folder /
|
Open http://localhost:3048 — either **open a local folder** (no backend needed)
|
||||||
.zip), then open a `.kicad_pcb` / `.kicad_sch` in its tool.
|
or open the backend's project. The editor can point at any conforming backend
|
||||||
|
via `VITE_API_BASE_URL`.
|
||||||
|
|
||||||
## WASM artifacts
|
## WASM artifacts
|
||||||
|
|
||||||
The runtime artifacts (`<tool>.js/.wasm`, `wx.js`, `images.tar.gz`, plus the
|
The runtime artifacts (`<tool>.js/.wasm`, `wx.js`, `images.tar.gz`, `<tool>.html`)
|
||||||
`<tool>.html` harness pages) are build outputs, **not** committed here. The
|
are build outputs, **not** committed. They are synced into `tests/apps/kicad/` by
|
||||||
complete set is synced into `tests/apps/kicad/` by
|
`tests/scripts/setup-kicad-wasm.sh` (from repo-root `output/`).
|
||||||
`tests/scripts/setup-kicad-wasm.sh` from repo-root `output/` (+ `wx.js` from
|
|
||||||
`wxwidgets/`; `output/` alone lacks `wx.js`). That script is a real **sync** —
|
|
||||||
it skips files already byte-identical at the destination, so re-running it does
|
|
||||||
not rewrite the multi-hundred-MB `.wasm`.
|
|
||||||
|
|
||||||
**They must be served same-origin as the app.** Under the document's COEP/
|
**They must be served same-origin as the app.** Under the document's COEP /
|
||||||
cross-origin-isolation (set by the Vite dev server), KiCad WASM refuses to load
|
cross-origin-isolation (set by the Vite dev server), KiCad WASM refuses to load
|
||||||
its glue/wasm from a different origin. So the app serves them from its own
|
its glue/wasm from a different origin. `pnpm dev` runs `scripts/link-wasm.mjs`,
|
||||||
origin with **no extra copy**: `pnpm dev` runs `scripts/link-wasm.mjs`, which
|
which **symlinks** `standalone/public/wasm → tests/apps/kicad`; Vite serves them
|
||||||
**symlinks** `apps/frontend/public/wasm → tests/apps/kicad`. Vite then serves
|
at `/wasm`. `VITE_WASM_ASSET_BASE_URL` defaults to `/wasm`.
|
||||||
them at `/wasm` (same origin). `VITE_WASM_ASSET_BASE_URL` defaults to `/wasm`.
|
|
||||||
|
|
||||||
- Point the symlink elsewhere with
|
- Point the symlink elsewhere with
|
||||||
`WASM_SRC_DIR=/path pnpm --filter @kicad-web/frontend link-wasm`.
|
`WASM_SRC_DIR=/path pnpm --filter @pcbjam/standalone link-wasm`.
|
||||||
- If the tool won't load, the target dir is probably empty — run
|
- If a tool won't load, the target dir is probably empty — run
|
||||||
`tests/scripts/setup-kicad-wasm.sh` to populate `tests/apps/kicad/`.
|
`tests/scripts/setup-kicad-wasm.sh` to populate `tests/apps/kicad/`.
|
||||||
|
- **prod**: point `VITE_WASM_ASSET_BASE_URL` at a URL whose origin also satisfies
|
||||||
The tool view (`WasmTool.tsx` + `src/wasm/boot.ts`) boots the tool **directly in
|
the same-origin / COEP constraints.
|
||||||
the React document** — no iframe. It replicates the proven harness HTML
|
|
||||||
(`tests/apps/kicad/<tool>.html`): builds the same global Emscripten `Module`
|
|
||||||
config and preRun steps (create canvas, write `images.tar.gz`, seed config), then
|
|
||||||
injects the same `wx.js` + `<tool>.js` artifacts into the page. It then syncs the
|
|
||||||
project tree into MEMFS and drives File→Open. The build is non-modularized
|
|
||||||
(global `Module`/`FS`) and pthread-based, so only **one** tool runs per page load;
|
|
||||||
switching tools requires a full navigation. `locateFile` resolves the wasm and
|
|
||||||
the pthread worker against `<base>` so they load regardless of the SPA route.
|
|
||||||
|
|
||||||
**prod**: point `VITE_WASM_ASSET_BASE_URL` at a CDN URL — but that origin must
|
|
||||||
itself satisfy the same-origin / COEP constraints (e.g. served under the app's
|
|
||||||
own origin/path).
|
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
| Command | What |
|
| Command | What |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pnpm dev` | server + frontend (turbo) |
|
| `pnpm dev` | editor + example backend (turbo) |
|
||||||
| `pnpm db:up` / `pnpm db:down` | start/stop Postgres |
|
|
||||||
| `pnpm db:generate` | generate Drizzle migration SQL from schema |
|
|
||||||
| `pnpm db:migrate` | apply migrations + seed default owner |
|
|
||||||
| `pnpm db:seed` | (re)seed the default owner |
|
|
||||||
| `pnpm typecheck` | typecheck all packages |
|
|
||||||
| `pnpm build` | build all packages |
|
| `pnpm build` | build all packages |
|
||||||
|
| `pnpm typecheck` | typecheck all packages |
|
||||||
|
|
||||||
## API (shared via `packages/contract`)
|
## Contract (`@pcbjam/shared`, MIT)
|
||||||
|
|
||||||
JSON (ts-rest): `GET/POST /api/projects`, `GET/DELETE /api/projects/:project`,
|
The editor reads from a backend over the shared contract:
|
||||||
`GET /api/projects/:project/files`.
|
`GET /api/projects`, `GET /api/projects/:project`,
|
||||||
|
`GET /api/projects/:project/files`, and the streamed
|
||||||
Binary (raw Fastify, response shapes still shared via Zod):
|
`GET /api/projects/:project/files/*` (raw bytes). Management/write operations and
|
||||||
`POST /api/projects/:project/files` (multi-file + folder),
|
ownership are **not** part of this contract — they belong to the closed app.
|
||||||
`POST /api/projects/:project/files/zip`,
|
|
||||||
`GET /api/projects/:project/files/*` (stream bytes).
|
|
||||||
|
|
||||||
## Status / next iteration
|
|
||||||
|
|
||||||
Working end-to-end: create / open / upload (files, folder, zip) / file
|
|
||||||
download / WASM static serving / project list & detail UI / URL routing.
|
|
||||||
|
|
||||||
Booting a tool syncs the **whole** project tree into MEMFS, then opens the
|
|
||||||
target file. The open step (`apps/frontend/src/wasm/open-flow.ts`) prefers a
|
|
||||||
programmatic hook (`Module.kicadOpenFile`) and falls back to EXPERIMENTAL UI
|
|
||||||
automation ported from the e2e tests — this needs in-browser validation against
|
|
||||||
built artifacts, and exposing a real embind open-entry-point is the intended
|
|
||||||
follow-up (spec §11.2). Lazy/partial MEMFS loading and save-back land together
|
|
||||||
in a later iteration (spec §§9, 12).
|
|
||||||
|
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
import * as React from "react";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { Files, FolderUp, Loader2, Package } from "lucide-react";
|
|
||||||
import { uploadFiles, uploadZip, type UploadItem } from "@/lib/api";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
export function UploadDropzone({ slug }: { slug: string }) {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const [busy, setBusy] = React.useState(false);
|
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
|
||||||
const filesRef = React.useRef<HTMLInputElement>(null);
|
|
||||||
const folderRef = React.useRef<HTMLInputElement>(null);
|
|
||||||
const zipRef = React.useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// `webkitdirectory` isn't in the React input typings; set it imperatively.
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (folderRef.current) {
|
|
||||||
folderRef.current.setAttribute("webkitdirectory", "");
|
|
||||||
folderRef.current.setAttribute("directory", "");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refresh = () => qc.invalidateQueries({ queryKey: ["project", slug] });
|
|
||||||
|
|
||||||
const run = async (fn: () => Promise<unknown>) => {
|
|
||||||
setBusy(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await fn();
|
|
||||||
await refresh();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const list = e.target.files;
|
|
||||||
if (!list || list.length === 0) return;
|
|
||||||
const items: UploadItem[] = Array.from(list).map((file) => ({
|
|
||||||
// folder picker → webkitRelativePath; multi-file picker → name
|
|
||||||
path:
|
|
||||||
(file as File & { webkitRelativePath?: string }).webkitRelativePath ||
|
|
||||||
file.name,
|
|
||||||
file,
|
|
||||||
}));
|
|
||||||
void run(() => uploadFiles(slug, items));
|
|
||||||
e.target.value = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const onZip = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
void run(() => uploadZip(slug, file));
|
|
||||||
e.target.value = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-dashed p-6">
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => filesRef.current?.click()}
|
|
||||||
>
|
|
||||||
<Files /> Upload files
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => folderRef.current?.click()}
|
|
||||||
>
|
|
||||||
<FolderUp /> Upload folder
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => zipRef.current?.click()}
|
|
||||||
>
|
|
||||||
<Package /> Upload .zip
|
|
||||||
</Button>
|
|
||||||
{busy && (
|
|
||||||
<span className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="animate-spin" /> uploading…
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{error && <p className="mt-3 text-sm text-destructive">{error}</p>}
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref={filesRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
hidden
|
|
||||||
onChange={onFiles}
|
|
||||||
/>
|
|
||||||
<input ref={folderRef} type="file" multiple hidden onChange={onFiles} />
|
|
||||||
<input
|
|
||||||
ref={zipRef}
|
|
||||||
type="file"
|
|
||||||
accept=".zip,application/zip"
|
|
||||||
hidden
|
|
||||||
onChange={onZip}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
import {
|
|
||||||
contract,
|
|
||||||
type Project,
|
|
||||||
type ProjectFile,
|
|
||||||
type ProjectWithFiles,
|
|
||||||
type UploadResponse,
|
|
||||||
} from "@kicad-web/contract";
|
|
||||||
import { initClient } from "@ts-rest/core";
|
|
||||||
import {
|
|
||||||
useMutation,
|
|
||||||
useQuery,
|
|
||||||
useQueryClient,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import { API_BASE_URL } from "./config";
|
|
||||||
|
|
||||||
export const client = initClient(contract, {
|
|
||||||
baseUrl: API_BASE_URL,
|
|
||||||
baseHeaders: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- queries ---
|
|
||||||
|
|
||||||
export function useProjects() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["projects"],
|
|
||||||
queryFn: async (): Promise<Project[]> => {
|
|
||||||
const res = await client.listProjects();
|
|
||||||
if (res.status !== 200) throw new Error("failed to list projects");
|
|
||||||
return res.body;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useProject(slug: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["project", slug],
|
|
||||||
queryFn: async (): Promise<ProjectWithFiles> => {
|
|
||||||
const res = await client.getProject({ params: { 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;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- mutations ---
|
|
||||||
|
|
||||||
export function useCreateProject() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (input: {
|
|
||||||
name: string;
|
|
||||||
slug?: string;
|
|
||||||
}): Promise<Project> => {
|
|
||||||
const res = await client.createProject({ body: input });
|
|
||||||
if (res.status === 409) throw new Error("a project with that slug exists");
|
|
||||||
if (res.status !== 201) throw new Error("failed to create project");
|
|
||||||
return res.body;
|
|
||||||
},
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["projects"] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteProject() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (slug: string): Promise<void> => {
|
|
||||||
const res = await client.deleteProject({
|
|
||||||
params: { project: slug },
|
|
||||||
body: {},
|
|
||||||
});
|
|
||||||
if (res.status !== 200) throw new Error("failed to delete project");
|
|
||||||
},
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["projects"] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- raw binary endpoints (not in the ts-rest contract) ---
|
|
||||||
|
|
||||||
export interface UploadItem {
|
|
||||||
/** project-relative path; folders use webkitRelativePath */
|
|
||||||
path: string;
|
|
||||||
file: File;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uploadFiles(
|
|
||||||
slug: string,
|
|
||||||
items: UploadItem[],
|
|
||||||
): Promise<ProjectFile[]> {
|
|
||||||
const form = new FormData();
|
|
||||||
for (const item of items) {
|
|
||||||
// Field name carries the relative path (server reads part.fieldname).
|
|
||||||
form.append(item.path, item.file, item.file.name);
|
|
||||||
}
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files`,
|
|
||||||
{ method: "POST", body: form },
|
|
||||||
);
|
|
||||||
if (!res.ok) throw new Error(`upload failed: ${res.status}`);
|
|
||||||
return ((await res.json()) as UploadResponse).files;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uploadZip(
|
|
||||||
slug: string,
|
|
||||||
zip: File,
|
|
||||||
): Promise<ProjectFile[]> {
|
|
||||||
const form = new FormData();
|
|
||||||
form.append("zip", zip, zip.name);
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files/zip`,
|
|
||||||
{ method: "POST", body: form },
|
|
||||||
);
|
|
||||||
if (!res.ok) throw new Error(`zip upload failed: ${res.status}`);
|
|
||||||
return ((await res.json()) as UploadResponse).files;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fileBytesUrl(slug: string, relPath: string): string {
|
|
||||||
const encoded = relPath
|
|
||||||
.split("/")
|
|
||||||
.map((seg) => encodeURIComponent(seg))
|
|
||||||
.join("/");
|
|
||||||
return `${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files/${encoded}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchFileBytes(
|
|
||||||
slug: string,
|
|
||||||
relPath: string,
|
|
||||||
): Promise<Uint8Array> {
|
|
||||||
const res = await fetch(fileBytesUrl(slug, relPath));
|
|
||||||
if (!res.ok) throw new Error(`download failed (${res.status}): ${relPath}`);
|
|
||||||
return new Uint8Array(await res.arrayBuffer());
|
|
||||||
}
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
import * as React from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { Loader2, Plus, Trash2 } from "lucide-react";
|
|
||||||
import { useCreateProject, useDeleteProject, useProjects } from "@/lib/api";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
|
|
||||||
function CreateProjectDialog() {
|
|
||||||
const create = useCreateProject();
|
|
||||||
const [open, setOpen] = React.useState(false);
|
|
||||||
const [name, setName] = React.useState("");
|
|
||||||
const [slug, setSlug] = React.useState("");
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
if (!name.trim()) return;
|
|
||||||
await create.mutateAsync({
|
|
||||||
name: name.trim(),
|
|
||||||
slug: slug.trim() || undefined,
|
|
||||||
});
|
|
||||||
setName("");
|
|
||||||
setSlug("");
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button>
|
|
||||||
<Plus /> New project
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create a project</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
A project holds a tree of KiCad files you can open in the browser.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="name">Name</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
value={name}
|
|
||||||
placeholder="My board"
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="slug">Slug (optional)</Label>
|
|
||||||
<Input
|
|
||||||
id="slug"
|
|
||||||
value={slug}
|
|
||||||
placeholder="auto-generated from name"
|
|
||||||
onChange={(e) => setSlug(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{create.error && (
|
|
||||||
<p className="text-sm text-destructive">
|
|
||||||
{(create.error as Error).message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button onClick={submit} disabled={create.isPending}>
|
|
||||||
{create.isPending && <Loader2 className="animate-spin" />} Create
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProjectsPage() {
|
|
||||||
const { data: projects, isLoading, error } = useProjects();
|
|
||||||
const del = useDeleteProject();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container py-10">
|
|
||||||
<div className="mb-8 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Projects</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Create a project, upload KiCad files, open them in the browser.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<CreateProjectDialog />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<p className="flex items-center gap-2 text-muted-foreground">
|
|
||||||
<Loader2 className="animate-spin" /> loading…
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<p className="text-destructive">
|
|
||||||
Could not load projects: {(error as Error).message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{projects?.map((p) => (
|
|
||||||
<Card key={p.id}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{p.name}</CardTitle>
|
|
||||||
<CardDescription>/p/{p.slug}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<Button asChild variant="secondary" size="sm">
|
|
||||||
<Link to={`/p/${p.slug}`}>Open</Link>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => {
|
|
||||||
if (confirm(`Delete project "${p.name}"?`)) {
|
|
||||||
del.mutate(p.slug);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{projects && projects.length === 0 && !isLoading && (
|
|
||||||
<p className="text-muted-foreground">No projects yet. Create one above.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import * as path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { config } from "dotenv";
|
|
||||||
import { defineConfig } from "drizzle-kit";
|
|
||||||
|
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
// web/.env is two levels up from apps/server.
|
|
||||||
config({ path: path.resolve(here, "../../.env") });
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
schema: "./src/db/schema.ts",
|
|
||||||
out: "./drizzle",
|
|
||||||
dialect: "postgresql",
|
|
||||||
dbCredentials: {
|
|
||||||
url: process.env.DATABASE_URL ?? "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
CREATE TABLE "owner" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"slug" text NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
CONSTRAINT "owner_slug_unique" UNIQUE("slug")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "project_file" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"project_id" uuid NOT NULL,
|
|
||||||
"path" text NOT NULL,
|
|
||||||
"size" bigint NOT NULL,
|
|
||||||
"content_type" text NOT NULL,
|
|
||||||
"storage_key" text NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
CONSTRAINT "project_file_path_uq" UNIQUE("project_id","path")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "project" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"owner_id" uuid NOT NULL,
|
|
||||||
"slug" text NOT NULL,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
CONSTRAINT "project_owner_slug_uq" UNIQUE("owner_id","slug")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "project_file" ADD CONSTRAINT "project_file_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "project" ADD CONSTRAINT "project_owner_id_owner_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."owner"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
{
|
|
||||||
"id": "921143e5-34f7-42f8-8c3b-28918d8201fd",
|
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
|
||||||
"version": "7",
|
|
||||||
"dialect": "postgresql",
|
|
||||||
"tables": {
|
|
||||||
"public.owner": {
|
|
||||||
"name": "owner",
|
|
||||||
"schema": "",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "uuid",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "gen_random_uuid()"
|
|
||||||
},
|
|
||||||
"slug": {
|
|
||||||
"name": "slug",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"name": "created_at",
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "now()"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {},
|
|
||||||
"foreignKeys": {},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {
|
|
||||||
"owner_slug_unique": {
|
|
||||||
"name": "owner_slug_unique",
|
|
||||||
"nullsNotDistinct": false,
|
|
||||||
"columns": [
|
|
||||||
"slug"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"policies": {},
|
|
||||||
"checkConstraints": {},
|
|
||||||
"isRLSEnabled": false
|
|
||||||
},
|
|
||||||
"public.project_file": {
|
|
||||||
"name": "project_file",
|
|
||||||
"schema": "",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "uuid",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "gen_random_uuid()"
|
|
||||||
},
|
|
||||||
"project_id": {
|
|
||||||
"name": "project_id",
|
|
||||||
"type": "uuid",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"path": {
|
|
||||||
"name": "path",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"size": {
|
|
||||||
"name": "size",
|
|
||||||
"type": "bigint",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"content_type": {
|
|
||||||
"name": "content_type",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"storage_key": {
|
|
||||||
"name": "storage_key",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"name": "created_at",
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "now()"
|
|
||||||
},
|
|
||||||
"updated_at": {
|
|
||||||
"name": "updated_at",
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "now()"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {},
|
|
||||||
"foreignKeys": {
|
|
||||||
"project_file_project_id_project_id_fk": {
|
|
||||||
"name": "project_file_project_id_project_id_fk",
|
|
||||||
"tableFrom": "project_file",
|
|
||||||
"tableTo": "project",
|
|
||||||
"columnsFrom": [
|
|
||||||
"project_id"
|
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
|
||||||
"onUpdate": "no action"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {
|
|
||||||
"project_file_path_uq": {
|
|
||||||
"name": "project_file_path_uq",
|
|
||||||
"nullsNotDistinct": false,
|
|
||||||
"columns": [
|
|
||||||
"project_id",
|
|
||||||
"path"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"policies": {},
|
|
||||||
"checkConstraints": {},
|
|
||||||
"isRLSEnabled": false
|
|
||||||
},
|
|
||||||
"public.project": {
|
|
||||||
"name": "project",
|
|
||||||
"schema": "",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "uuid",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "gen_random_uuid()"
|
|
||||||
},
|
|
||||||
"owner_id": {
|
|
||||||
"name": "owner_id",
|
|
||||||
"type": "uuid",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"slug": {
|
|
||||||
"name": "slug",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"name": "name",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"name": "created_at",
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "now()"
|
|
||||||
},
|
|
||||||
"updated_at": {
|
|
||||||
"name": "updated_at",
|
|
||||||
"type": "timestamp with time zone",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "now()"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {},
|
|
||||||
"foreignKeys": {
|
|
||||||
"project_owner_id_owner_id_fk": {
|
|
||||||
"name": "project_owner_id_owner_id_fk",
|
|
||||||
"tableFrom": "project",
|
|
||||||
"tableTo": "owner",
|
|
||||||
"columnsFrom": [
|
|
||||||
"owner_id"
|
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
|
||||||
"onUpdate": "no action"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {
|
|
||||||
"project_owner_slug_uq": {
|
|
||||||
"name": "project_owner_slug_uq",
|
|
||||||
"nullsNotDistinct": false,
|
|
||||||
"columns": [
|
|
||||||
"owner_id",
|
|
||||||
"slug"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"policies": {},
|
|
||||||
"checkConstraints": {},
|
|
||||||
"isRLSEnabled": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"enums": {},
|
|
||||||
"schemas": {},
|
|
||||||
"sequences": {},
|
|
||||||
"roles": {},
|
|
||||||
"policies": {},
|
|
||||||
"views": {},
|
|
||||||
"_meta": {
|
|
||||||
"columns": {},
|
|
||||||
"schemas": {},
|
|
||||||
"tables": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"version": "7",
|
|
||||||
"dialect": "postgresql",
|
|
||||||
"entries": [
|
|
||||||
{
|
|
||||||
"idx": 0,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1780307401097,
|
|
||||||
"tag": "0000_lucky_husk",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
{
|
|
||||||
"name": "@kicad-web/server",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "tsx watch src/server.ts",
|
|
||||||
"start": "node dist/server.js",
|
|
||||||
"build": "tsc -p tsconfig.json --noEmit false --declaration false --outDir dist",
|
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"db:generate": "drizzle-kit generate",
|
|
||||||
"db:migrate": "tsx src/db/migrate.ts",
|
|
||||||
"db:push": "drizzle-kit push",
|
|
||||||
"db:seed": "tsx src/db/seed.ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@fastify/cors": "^9.0.1",
|
|
||||||
"@fastify/multipart": "^8.3.0",
|
|
||||||
"@kicad-web/contract": "workspace:*",
|
|
||||||
"@kicad-web/storage": "workspace:*",
|
|
||||||
"@ts-rest/fastify": "^3.52.1",
|
|
||||||
"dotenv": "^16.4.7",
|
|
||||||
"drizzle-orm": "^0.38.3",
|
|
||||||
"fastify": "^4.29.0",
|
|
||||||
"pg": "^8.13.1",
|
|
||||||
"unzipper": "^0.12.3",
|
|
||||||
"zod": "^3.24.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.10.5",
|
|
||||||
"@types/pg": "^8.11.10",
|
|
||||||
"@types/unzipper": "^0.10.10",
|
|
||||||
"drizzle-kit": "^0.30.1",
|
|
||||||
"tsx": "^4.19.2",
|
|
||||||
"typescript": "^5.7.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import cors from "@fastify/cors";
|
|
||||||
import multipart from "@fastify/multipart";
|
|
||||||
import Fastify, { type FastifyInstance } from "fastify";
|
|
||||||
import { env } from "./env.js";
|
|
||||||
import { apiPlugin } from "./routes/api.js";
|
|
||||||
import { fileRoutes } from "./routes/files.js";
|
|
||||||
|
|
||||||
export async function buildApp(): Promise<FastifyInstance> {
|
|
||||||
const app = Fastify({
|
|
||||||
logger: true,
|
|
||||||
// Project files (whole KiCad trees) and zips can be large.
|
|
||||||
bodyLimit: 1024 * 1024 * 1024,
|
|
||||||
});
|
|
||||||
|
|
||||||
await app.register(cors, {
|
|
||||||
origin: env.CORS_ORIGIN === "*" ? true : env.CORS_ORIGIN.split(","),
|
|
||||||
});
|
|
||||||
|
|
||||||
await app.register(multipart, {
|
|
||||||
limits: {
|
|
||||||
fileSize: 1024 * 1024 * 1024, // 1 GiB per file
|
|
||||||
files: 5000, // a KiCad project can have many lib files
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/health", async () => ({ ok: true }));
|
|
||||||
|
|
||||||
await app.register(apiPlugin);
|
|
||||||
await app.register(fileRoutes);
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import { drizzle } from "drizzle-orm/node-postgres";
|
|
||||||
import pg from "pg";
|
|
||||||
import { env } from "../env.js";
|
|
||||||
import * as schema from "./schema.js";
|
|
||||||
|
|
||||||
export const pool = new pg.Pool({ connectionString: env.DATABASE_URL });
|
|
||||||
export const db = drizzle(pool, { schema });
|
|
||||||
export { schema };
|
|
||||||
export type Db = typeof db;
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import * as path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
|
||||||
import { db, pool } from "./index.js";
|
|
||||||
import { seedDefaultOwner, seedDemoProject } from "./seed.js";
|
|
||||||
|
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
await migrate(db, {
|
|
||||||
migrationsFolder: path.resolve(here, "../../drizzle"),
|
|
||||||
});
|
|
||||||
const ownerId = await seedDefaultOwner();
|
|
||||||
console.log(`migrations applied; default owner: ${ownerId}`);
|
|
||||||
// Give a fresh install a ready-to-open demo project (idempotent).
|
|
||||||
await seedDemoProject();
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
import {
|
|
||||||
bigint,
|
|
||||||
pgTable,
|
|
||||||
text,
|
|
||||||
timestamp,
|
|
||||||
unique,
|
|
||||||
uuid,
|
|
||||||
} from "drizzle-orm/pg-core";
|
|
||||||
|
|
||||||
export const owners = pgTable("owner", {
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
slug: text("slug").notNull().unique(),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const projects = pgTable(
|
|
||||||
"project",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
ownerId: uuid("owner_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => owners.id, { onDelete: "cascade" }),
|
|
||||||
slug: text("slug").notNull(),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => [unique("project_owner_slug_uq").on(t.ownerId, t.slug)],
|
|
||||||
);
|
|
||||||
|
|
||||||
export const projectFiles = pgTable(
|
|
||||||
"project_file",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
projectId: uuid("project_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => projects.id, { onDelete: "cascade" }),
|
|
||||||
// POSIX project-relative path, e.g. "pcbnew/nyak.kicad_pcb".
|
|
||||||
path: text("path").notNull(),
|
|
||||||
size: bigint("size", { mode: "number" }).notNull(),
|
|
||||||
contentType: text("content_type").notNull(),
|
|
||||||
// Opaque key handed to FileStorage; decouples logical path from blob layout.
|
|
||||||
storageKey: text("storage_key").notNull(),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => [unique("project_file_path_uq").on(t.projectId, t.path)],
|
|
||||||
);
|
|
||||||
|
|
||||||
export type OwnerRow = typeof owners.$inferSelect;
|
|
||||||
export type ProjectRow = typeof projects.$inferSelect;
|
|
||||||
export type ProjectFileRow = typeof projectFiles.$inferSelect;
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
import * as fs from "node:fs/promises";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { env } from "../env.js";
|
|
||||||
import { db, pool } from "./index.js";
|
|
||||||
import { owners } from "./schema.js";
|
|
||||||
import {
|
|
||||||
createProject,
|
|
||||||
getProjectRowBySlug,
|
|
||||||
writeProjectFile,
|
|
||||||
} from "../services/projects.js";
|
|
||||||
|
|
||||||
/** Ensure the default owner namespace exists (no-auth iteration). */
|
|
||||||
export async function seedDefaultOwner(): Promise<string> {
|
|
||||||
const existing = await db
|
|
||||||
.select()
|
|
||||||
.from(owners)
|
|
||||||
.where(eq(owners.slug, env.DEFAULT_OWNER_SLUG))
|
|
||||||
.limit(1);
|
|
||||||
if (existing[0]) return existing[0].id;
|
|
||||||
|
|
||||||
const inserted = await db
|
|
||||||
.insert(owners)
|
|
||||||
.values({ slug: env.DEFAULT_OWNER_SLUG })
|
|
||||||
.onConflictDoNothing()
|
|
||||||
.returning();
|
|
||||||
if (inserted[0]) return inserted[0].id;
|
|
||||||
|
|
||||||
// Lost a race; re-read.
|
|
||||||
const row = await db
|
|
||||||
.select()
|
|
||||||
.from(owners)
|
|
||||||
.where(eq(owners.slug, env.DEFAULT_OWNER_SLUG))
|
|
||||||
.limit(1);
|
|
||||||
if (!row[0]) throw new Error("failed to seed default owner");
|
|
||||||
return row[0].id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Demo project bytes committed at <server>/seed-data/. Resolved relative to this
|
|
||||||
* module so it works both under tsx (src/db/) and compiled (dist/db/) — both are
|
|
||||||
* two levels below the package root, where seed-data lives.
|
|
||||||
*/
|
|
||||||
const SEED_DATA_DIR = new URL("../../seed-data/", import.meta.url);
|
|
||||||
|
|
||||||
const DEMO_SLUG = "demo";
|
|
||||||
const DEMO_NAME = "Demo Project";
|
|
||||||
/** Committed files → project-relative paths. One per openable tool. */
|
|
||||||
const DEMO_FILES = [
|
|
||||||
"demo.kicad_sch", // eeschema (Schematic Editor)
|
|
||||||
"demo.kicad_pcb", // pcbnew (PCB Editor)
|
|
||||||
"demo.kicad_wks", // pl_editor (Drawing Sheet Editor)
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seed a ready-to-open demo project so a freshly cloned + migrated install has
|
|
||||||
* something to click on (and to exercise the tool wiring). Idempotent: skips if
|
|
||||||
* the "demo" project already exists. Reads bytes from the committed seed-data/
|
|
||||||
* dir, so it needs no submodule checkout at runtime.
|
|
||||||
*/
|
|
||||||
export async function seedDemoProject(): Promise<void> {
|
|
||||||
if (await getProjectRowBySlug(DEMO_SLUG)) {
|
|
||||||
console.log(`demo project "${DEMO_SLUG}" already exists — skipping`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const project = await createProject(DEMO_NAME, DEMO_SLUG);
|
|
||||||
const row = await getProjectRowBySlug(DEMO_SLUG);
|
|
||||||
if (!row) throw new Error("demo project vanished right after creation");
|
|
||||||
|
|
||||||
for (const name of DEMO_FILES) {
|
|
||||||
const data = await fs.readFile(fileURLToPath(new URL(name, SEED_DATA_DIR)));
|
|
||||||
await writeProjectFile({ project: row, rawPath: name, data });
|
|
||||||
console.log(` seeded ${DEMO_SLUG}/${name} (${data.length} bytes)`);
|
|
||||||
}
|
|
||||||
console.log(`seeded demo project "${project.slug}" with ${DEMO_FILES.length} files`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow running standalone: `pnpm db:seed`.
|
|
||||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
||||||
seedDefaultOwner()
|
|
||||||
.then(async (id) => {
|
|
||||||
console.log(`seeded default owner: ${id}`);
|
|
||||||
await seedDemoProject();
|
|
||||||
return pool.end();
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import { config } from "dotenv";
|
|
||||||
import * as path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
// web/.env lives three levels up from apps/server/src. Standard precedence:
|
|
||||||
// real environment variables win over .env (don't override).
|
|
||||||
config({ path: path.resolve(here, "../../../.env") });
|
|
||||||
|
|
||||||
const envSchema = z.object({
|
|
||||||
DATABASE_URL: z.string().min(1),
|
|
||||||
PORT: z.coerce.number().int().positive().default(3050),
|
|
||||||
STORAGE_DRIVER: z.string().default("local"),
|
|
||||||
STORAGE_ROOT: z.string().default("./.data/storage"),
|
|
||||||
CORS_ORIGIN: z.string().default("http://localhost:3048"),
|
|
||||||
DEFAULT_OWNER_SLUG: z.string().default("default"),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const env = envSchema.parse(process.env);
|
|
||||||
export type Env = typeof env;
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import * as path from "node:path";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize an arbitrary client-supplied relative path into a safe POSIX
|
|
||||||
* project-relative path. Strips leading slashes and any `..` traversal.
|
|
||||||
*/
|
|
||||||
export function sanitizeRelPath(input: string): string {
|
|
||||||
const posix = input.replace(/\\/g, "/");
|
|
||||||
const normalized = path.posix
|
|
||||||
.normalize(posix)
|
|
||||||
.replace(/^(\.\.(\/|$))+/, "")
|
|
||||||
.replace(/^\/+/, "");
|
|
||||||
if (!normalized || normalized === "." || normalized.startsWith("..")) {
|
|
||||||
throw new Error(`invalid file path: ${input}`);
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function slugify(name: string): string {
|
|
||||||
const base = name
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
.replace(/[^a-z0-9._-]+/g, "-")
|
|
||||||
.replace(/^-+|-+$/g, "")
|
|
||||||
.replace(/-{2,}/g, "-");
|
|
||||||
return base || "project";
|
|
||||||
}
|
|
||||||
|
|
||||||
const TEXT_EXT = new Set([
|
|
||||||
".kicad_pcb",
|
|
||||||
".kicad_sch",
|
|
||||||
".kicad_pro",
|
|
||||||
".kicad_sym",
|
|
||||||
".kicad_mod",
|
|
||||||
".kicad_dru",
|
|
||||||
".kicad_wks",
|
|
||||||
".net",
|
|
||||||
".txt",
|
|
||||||
".csv",
|
|
||||||
".json",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function guessContentType(relPath: string): string {
|
|
||||||
const ext = path.posix.extname(relPath).toLowerCase();
|
|
||||||
if (TEXT_EXT.has(ext)) return "text/plain; charset=utf-8";
|
|
||||||
return "application/octet-stream";
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import { contract } from "@kicad-web/contract";
|
|
||||||
import { initServer } from "@ts-rest/fastify";
|
|
||||||
import * as svc from "../services/projects.js";
|
|
||||||
|
|
||||||
const s = initServer();
|
|
||||||
|
|
||||||
export const apiRouter = s.router(contract, {
|
|
||||||
listProjects: async () => ({
|
|
||||||
status: 200,
|
|
||||||
body: await svc.listProjects(),
|
|
||||||
}),
|
|
||||||
|
|
||||||
createProject: async ({ body }) => {
|
|
||||||
try {
|
|
||||||
const project = await svc.createProject(body.name, body.slug);
|
|
||||||
return { status: 201 as const, body: project };
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof svc.SlugConflictError) {
|
|
||||||
return { status: 409 as const, body: { message: err.message } };
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
getProject: async ({ params }) => {
|
|
||||||
const result = await svc.getProjectWithFiles(params.project);
|
|
||||||
if (!result) {
|
|
||||||
return { status: 404 as const, body: { message: "project not found" } };
|
|
||||||
}
|
|
||||||
return { status: 200 as const, body: result };
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteProject: async ({ params }) => {
|
|
||||||
const id = await svc.deleteProject(params.project);
|
|
||||||
if (!id) {
|
|
||||||
return { status: 404 as const, body: { message: "project not found" } };
|
|
||||||
}
|
|
||||||
return { status: 200 as const, body: { id } };
|
|
||||||
},
|
|
||||||
|
|
||||||
listFiles: async ({ params }) => {
|
|
||||||
const row = await svc.getProjectRowBySlug(params.project);
|
|
||||||
if (!row) {
|
|
||||||
return { status: 404 as const, body: { message: "project not found" } };
|
|
||||||
}
|
|
||||||
return { status: 200 as const, body: await svc.listFilesApi(row.id) };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Fastify plugin that mounts the ts-rest JSON API. */
|
|
||||||
export const apiPlugin = s.plugin(apiRouter);
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { createWriteStream } from "node:fs";
|
|
||||||
import * as fs from "node:fs/promises";
|
|
||||||
import * as os from "node:os";
|
|
||||||
import * as path from "node:path";
|
|
||||||
import { pipeline } from "node:stream/promises";
|
|
||||||
import type { ProjectFile } from "@kicad-web/contract";
|
|
||||||
import type { FastifyInstance } from "fastify";
|
|
||||||
import unzipper from "unzipper";
|
|
||||||
import { sanitizeRelPath } from "../lib/paths.js";
|
|
||||||
import * as svc from "../services/projects.js";
|
|
||||||
import { storage } from "../storage.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Binary file routes that don't round-trip cleanly through ts-rest:
|
|
||||||
* POST /api/projects/:project/files (multipart; multi-file + folder)
|
|
||||||
* POST /api/projects/:project/files/zip (multipart; one zip)
|
|
||||||
* GET /api/projects/:project/files/* (stream raw bytes)
|
|
||||||
*
|
|
||||||
* For multi-file / folder uploads the client sends each file as a part whose
|
|
||||||
* FIELD NAME is the project-relative path (folder uploads pass
|
|
||||||
* webkitRelativePath). The zip route unpacks entries preserving their paths.
|
|
||||||
*/
|
|
||||||
export async function fileRoutes(app: FastifyInstance): Promise<void> {
|
|
||||||
// --- multi-file / folder upload ---
|
|
||||||
app.post("/api/projects/:project/files", async (req, reply) => {
|
|
||||||
const slug = (req.params as { project: string }).project;
|
|
||||||
const project = await svc.getProjectRowBySlug(slug);
|
|
||||||
if (!project) {
|
|
||||||
return reply.code(404).send({ message: "project not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const written: ProjectFile[] = [];
|
|
||||||
for await (const part of req.parts()) {
|
|
||||||
if (part.type !== "file") continue;
|
|
||||||
// Field name carries the relative path; fall back to the filename.
|
|
||||||
const rawPath = part.fieldname || part.filename || "";
|
|
||||||
if (!rawPath) {
|
|
||||||
part.file.resume();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
written.push(
|
|
||||||
await svc.writeProjectFile({
|
|
||||||
project,
|
|
||||||
rawPath,
|
|
||||||
data: part.file,
|
|
||||||
contentType: part.mimetype,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return reply.code(201).send({ files: written });
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- zip upload ---
|
|
||||||
app.post("/api/projects/:project/files/zip", async (req, reply) => {
|
|
||||||
const slug = (req.params as { project: string }).project;
|
|
||||||
const project = await svc.getProjectRowBySlug(slug);
|
|
||||||
if (!project) {
|
|
||||||
return reply.code(404).send({ message: "project not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const zipPart = await req.file();
|
|
||||||
if (!zipPart) {
|
|
||||||
return reply.code(400).send({ message: "no zip file in request" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const tmp = path.join(os.tmpdir(), `kicad-upload-${randomUUID()}.zip`);
|
|
||||||
const written: ProjectFile[] = [];
|
|
||||||
try {
|
|
||||||
await pipeline(zipPart.file, createWriteStream(tmp));
|
|
||||||
const directory = await unzipper.Open.file(tmp);
|
|
||||||
for (const entry of directory.files) {
|
|
||||||
if (entry.type !== "File") continue;
|
|
||||||
let relPath: string;
|
|
||||||
try {
|
|
||||||
relPath = sanitizeRelPath(entry.path);
|
|
||||||
} catch {
|
|
||||||
continue; // skip traversal / invalid entries
|
|
||||||
}
|
|
||||||
written.push(
|
|
||||||
await svc.writeProjectFile({
|
|
||||||
project,
|
|
||||||
rawPath: relPath,
|
|
||||||
data: entry.stream(),
|
|
||||||
size: entry.uncompressedSize,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await fs.rm(tmp, { force: true });
|
|
||||||
}
|
|
||||||
return reply.code(201).send({ files: written });
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- download raw bytes ---
|
|
||||||
app.get("/api/projects/:project/files/*", async (req, reply) => {
|
|
||||||
const params = req.params as { project: string; "*": string };
|
|
||||||
const project = await svc.getProjectRowBySlug(params.project);
|
|
||||||
if (!project) {
|
|
||||||
return reply.code(404).send({ message: "project not found" });
|
|
||||||
}
|
|
||||||
let relPath: string;
|
|
||||||
try {
|
|
||||||
relPath = sanitizeRelPath(params["*"]);
|
|
||||||
} catch {
|
|
||||||
return reply.code(400).send({ message: "invalid path" });
|
|
||||||
}
|
|
||||||
const file = await svc.getFileRow(project.id, relPath);
|
|
||||||
if (!file) {
|
|
||||||
return reply.code(404).send({ message: "file not found" });
|
|
||||||
}
|
|
||||||
reply.header("Content-Type", file.contentType);
|
|
||||||
reply.header("Content-Length", file.size);
|
|
||||||
reply.header("Cache-Control", "no-cache");
|
|
||||||
return reply.send(storage.createReadStream(file.storageKey));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import { buildApp } from "./app.js";
|
|
||||||
import { env } from "./env.js";
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const app = await buildApp();
|
|
||||||
await app.listen({ port: env.PORT, host: "0.0.0.0" });
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
@ -1,196 +0,0 @@
|
||||||
import type { Project, ProjectFile } from "@kicad-web/contract";
|
|
||||||
import type { Readable } from "node:stream";
|
|
||||||
import { and, asc, eq } from "drizzle-orm";
|
|
||||||
import { db } from "../db/index.js";
|
|
||||||
import {
|
|
||||||
projectFiles,
|
|
||||||
projects,
|
|
||||||
type ProjectFileRow,
|
|
||||||
type ProjectRow,
|
|
||||||
} from "../db/schema.js";
|
|
||||||
import { env } from "../env.js";
|
|
||||||
import { owners } from "../db/schema.js";
|
|
||||||
import { guessContentType, sanitizeRelPath, slugify } from "../lib/paths.js";
|
|
||||||
import { fileStorageKey, projectStoragePrefix, storage } from "../storage.js";
|
|
||||||
|
|
||||||
export class SlugConflictError extends Error {
|
|
||||||
constructor(slug: string) {
|
|
||||||
super(`project slug already exists: ${slug}`);
|
|
||||||
this.name = "SlugConflictError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let cachedOwnerId: string | null = null;
|
|
||||||
|
|
||||||
export async function getDefaultOwnerId(): Promise<string> {
|
|
||||||
if (cachedOwnerId) return cachedOwnerId;
|
|
||||||
const row = await db
|
|
||||||
.select()
|
|
||||||
.from(owners)
|
|
||||||
.where(eq(owners.slug, env.DEFAULT_OWNER_SLUG))
|
|
||||||
.limit(1);
|
|
||||||
if (!row[0]) {
|
|
||||||
throw new Error(
|
|
||||||
`default owner "${env.DEFAULT_OWNER_SLUG}" not found — run db:migrate`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
cachedOwnerId = row[0].id;
|
|
||||||
return cachedOwnerId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toApiProject(row: ProjectRow): Project {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
ownerId: row.ownerId,
|
|
||||||
slug: row.slug,
|
|
||||||
name: row.name,
|
|
||||||
createdAt: row.createdAt.toISOString(),
|
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toApiFile(row: ProjectFileRow): ProjectFile {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
projectId: row.projectId,
|
|
||||||
path: row.path,
|
|
||||||
size: row.size,
|
|
||||||
contentType: row.contentType,
|
|
||||||
createdAt: row.createdAt.toISOString(),
|
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listProjects(): Promise<Project[]> {
|
|
||||||
const ownerId = await getDefaultOwnerId();
|
|
||||||
const rows = await db
|
|
||||||
.select()
|
|
||||||
.from(projects)
|
|
||||||
.where(eq(projects.ownerId, ownerId))
|
|
||||||
.orderBy(asc(projects.createdAt));
|
|
||||||
return rows.map(toApiProject);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function slugExists(ownerId: string, slug: string): Promise<boolean> {
|
|
||||||
const row = await db
|
|
||||||
.select({ id: projects.id })
|
|
||||||
.from(projects)
|
|
||||||
.where(and(eq(projects.ownerId, ownerId), eq(projects.slug, slug)))
|
|
||||||
.limit(1);
|
|
||||||
return !!row[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createProject(
|
|
||||||
name: string,
|
|
||||||
slug?: string,
|
|
||||||
): Promise<Project> {
|
|
||||||
const ownerId = await getDefaultOwnerId();
|
|
||||||
|
|
||||||
if (slug) {
|
|
||||||
if (await slugExists(ownerId, slug)) throw new SlugConflictError(slug);
|
|
||||||
} else {
|
|
||||||
const base = slugify(name);
|
|
||||||
slug = base;
|
|
||||||
for (let i = 2; await slugExists(ownerId, slug); i++) {
|
|
||||||
slug = `${base}-${i}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const inserted = await db
|
|
||||||
.insert(projects)
|
|
||||||
.values({ ownerId, slug, name })
|
|
||||||
.returning();
|
|
||||||
return toApiProject(inserted[0]!);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getProjectRowBySlug(
|
|
||||||
slug: string,
|
|
||||||
): Promise<ProjectRow | null> {
|
|
||||||
const ownerId = await getDefaultOwnerId();
|
|
||||||
const row = await db
|
|
||||||
.select()
|
|
||||||
.from(projects)
|
|
||||||
.where(and(eq(projects.ownerId, ownerId), eq(projects.slug, slug)))
|
|
||||||
.limit(1);
|
|
||||||
return row[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listFiles(projectId: string): Promise<ProjectFileRow[]> {
|
|
||||||
return db
|
|
||||||
.select()
|
|
||||||
.from(projectFiles)
|
|
||||||
.where(eq(projectFiles.projectId, projectId))
|
|
||||||
.orderBy(asc(projectFiles.path));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listFilesApi(projectId: string): Promise<ProjectFile[]> {
|
|
||||||
return (await listFiles(projectId)).map(toApiFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getProjectWithFiles(slug: string): Promise<{
|
|
||||||
project: Project;
|
|
||||||
files: ProjectFile[];
|
|
||||||
} | null> {
|
|
||||||
const row = await getProjectRowBySlug(slug);
|
|
||||||
if (!row) return null;
|
|
||||||
const files = await listFiles(row.id);
|
|
||||||
return { project: toApiProject(row), files: files.map(toApiFile) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getFileRow(
|
|
||||||
projectId: string,
|
|
||||||
relPath: string,
|
|
||||||
): Promise<ProjectFileRow | null> {
|
|
||||||
const row = await db
|
|
||||||
.select()
|
|
||||||
.from(projectFiles)
|
|
||||||
.where(
|
|
||||||
and(eq(projectFiles.projectId, projectId), eq(projectFiles.path, relPath)),
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
return row[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteProject(slug: string): Promise<string | null> {
|
|
||||||
const row = await getProjectRowBySlug(slug);
|
|
||||||
if (!row) return null;
|
|
||||||
// Cascade removes project_file rows; storage prefix removed explicitly.
|
|
||||||
await db.delete(projects).where(eq(projects.id, row.id));
|
|
||||||
await storage.deletePrefix(projectStoragePrefix(row.ownerId, row.id));
|
|
||||||
return row.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stream/buffer a single file into storage and upsert its index row. Used by
|
|
||||||
* the upload routes (multi-file, folder, and zip entries).
|
|
||||||
*/
|
|
||||||
export async function writeProjectFile(opts: {
|
|
||||||
project: ProjectRow;
|
|
||||||
rawPath: string;
|
|
||||||
data: Uint8Array | Readable;
|
|
||||||
size?: number;
|
|
||||||
contentType?: string;
|
|
||||||
}): Promise<ProjectFile> {
|
|
||||||
const relPath = sanitizeRelPath(opts.rawPath);
|
|
||||||
const key = fileStorageKey(opts.project.ownerId, opts.project.id, relPath);
|
|
||||||
await storage.write(key, opts.data, { contentType: opts.contentType });
|
|
||||||
|
|
||||||
const size = opts.size ?? (await storage.stat(key)).size;
|
|
||||||
const contentType = opts.contentType ?? guessContentType(relPath);
|
|
||||||
|
|
||||||
const inserted = await db
|
|
||||||
.insert(projectFiles)
|
|
||||||
.values({
|
|
||||||
projectId: opts.project.id,
|
|
||||||
path: relPath,
|
|
||||||
size,
|
|
||||||
contentType,
|
|
||||||
storageKey: key,
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [projectFiles.projectId, projectFiles.path],
|
|
||||||
set: { size, contentType, storageKey: key, updatedAt: new Date() },
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
return toApiFile(inserted[0]!);
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { createFileStorage } from "@kicad-web/storage";
|
|
||||||
import { env } from "./env.js";
|
|
||||||
|
|
||||||
export const storage = createFileStorage(env);
|
|
||||||
|
|
||||||
export function projectStoragePrefix(ownerId: string, projectId: string): string {
|
|
||||||
return `owners/${ownerId}/projects/${projectId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fileStorageKey(
|
|
||||||
ownerId: string,
|
|
||||||
projectId: string,
|
|
||||||
relPath: string,
|
|
||||||
): string {
|
|
||||||
return `${projectStoragePrefix(ownerId, projectId)}/${relPath}`;
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"types": ["node"],
|
|
||||||
"lib": ["ES2022"]
|
|
||||||
},
|
|
||||||
"include": ["src", "drizzle.config.ts"]
|
|
||||||
}
|
|
||||||
10
web/backend/.env.example
Normal file
10
web/backend/.env.example
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Thin reference backend. Copy to backend/.env.
|
||||||
|
|
||||||
|
# Absolute or relative path to the single KiCad project folder to serve.
|
||||||
|
PROJECT_DIR=../../tests/fixtures/demo
|
||||||
|
|
||||||
|
# Port to listen on (the standalone's VITE_API_BASE_URL must match this).
|
||||||
|
PORT=3060
|
||||||
|
|
||||||
|
# Browser origin allowed to call this backend (the Vite dev server).
|
||||||
|
CORS_ORIGIN=http://localhost:3048
|
||||||
23
web/backend/package.json
Normal file
23
web/backend/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "@pcbjam/backend-example",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"start": "tsx src/server.ts",
|
||||||
|
"build": "tsc -p tsconfig.json --noEmit false --declaration false --outDir dist",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^9.0.1",
|
||||||
|
"@pcbjam/shared": "workspace:*",
|
||||||
|
"@ts-rest/fastify": "^3.52.1",
|
||||||
|
"fastify": "^4.29.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.5",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
175
web/backend/src/server.ts
Normal file
175
web/backend/src/server.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
// Thin REFERENCE backend for the @pcbjam/shared contract.
|
||||||
|
//
|
||||||
|
// It serves a SINGLE KiCad project straight off the local filesystem
|
||||||
|
// (PROJECT_DIR) with no database, no auth, and no uploads — just enough for the
|
||||||
|
// standalone editor to enumerate the project, read its file tree, and stream
|
||||||
|
// file bytes. It exists to (a) let the GPL editor run end-to-end on its own and
|
||||||
|
// (b) document the minimum a "real" backend must implement. Collaboration is
|
||||||
|
// browser-tab only (BroadcastChannel) and needs nothing from the server.
|
||||||
|
|
||||||
|
import { createReadStream } from "node:fs";
|
||||||
|
import * as fs from "node:fs/promises";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import cors from "@fastify/cors";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import { initServer } from "@ts-rest/fastify";
|
||||||
|
import {
|
||||||
|
contract,
|
||||||
|
type Project,
|
||||||
|
type ProjectFile,
|
||||||
|
} from "@pcbjam/shared";
|
||||||
|
|
||||||
|
const PROJECT_DIR = path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
process.env.PROJECT_DIR ?? "./project",
|
||||||
|
);
|
||||||
|
const PORT = Number(process.env.PORT ?? 3060);
|
||||||
|
const CORS_ORIGIN = process.env.CORS_ORIGIN ?? "http://localhost:3048";
|
||||||
|
|
||||||
|
const TEXT_EXT = new Set([
|
||||||
|
".kicad_pcb",
|
||||||
|
".kicad_sch",
|
||||||
|
".kicad_pro",
|
||||||
|
".kicad_sym",
|
||||||
|
".kicad_mod",
|
||||||
|
".kicad_dru",
|
||||||
|
".kicad_wks",
|
||||||
|
".net",
|
||||||
|
".txt",
|
||||||
|
".csv",
|
||||||
|
".json",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function guessContentType(relPath: string): string {
|
||||||
|
const ext = path.posix.extname(relPath).toLowerCase();
|
||||||
|
return TEXT_EXT.has(ext)
|
||||||
|
? "text/plain; charset=utf-8"
|
||||||
|
: "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reject paths that escape PROJECT_DIR (traversal guard). */
|
||||||
|
function safeJoin(relPath: string): string {
|
||||||
|
const normalized = path.posix
|
||||||
|
.normalize(relPath.replace(/\\/g, "/"))
|
||||||
|
.replace(/^(\.\.(\/|$))+/, "")
|
||||||
|
.replace(/^\/+/, "");
|
||||||
|
const abs = path.resolve(PROJECT_DIR, normalized);
|
||||||
|
if (abs !== PROJECT_DIR && !abs.startsWith(PROJECT_DIR + path.sep)) {
|
||||||
|
throw new Error(`path escapes project: ${relPath}`);
|
||||||
|
}
|
||||||
|
return abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLUG = (path.basename(PROJECT_DIR) || "project")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9._-]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "") || "project";
|
||||||
|
|
||||||
|
// Stable-per-process ids (no DB; the editor only needs them to be unique).
|
||||||
|
const PROJECT_ID = randomUUID();
|
||||||
|
const fileIds = new Map<string, string>();
|
||||||
|
function fileId(relPath: string): string {
|
||||||
|
let id = fileIds.get(relPath);
|
||||||
|
if (!id) {
|
||||||
|
id = randomUUID();
|
||||||
|
fileIds.set(relPath, id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walk(dir: string, prefix = ""): Promise<ProjectFile[]> {
|
||||||
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
|
const out: ProjectFile[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name.startsWith(".")) continue; // skip dotfiles
|
||||||
|
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||||
|
const abs = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
out.push(...(await walk(abs, rel)));
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
const st = await fs.stat(abs);
|
||||||
|
out.push({
|
||||||
|
id: fileId(rel),
|
||||||
|
projectId: PROJECT_ID,
|
||||||
|
path: rel,
|
||||||
|
size: st.size,
|
||||||
|
contentType: guessContentType(rel),
|
||||||
|
createdAt: st.birthtime.toISOString(),
|
||||||
|
updatedAt: st.mtime.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function project(): Promise<Project> {
|
||||||
|
const st = await fs.stat(PROJECT_DIR);
|
||||||
|
return {
|
||||||
|
id: PROJECT_ID,
|
||||||
|
slug: SLUG,
|
||||||
|
name: path.basename(PROJECT_DIR) || SLUG,
|
||||||
|
createdAt: st.birthtime.toISOString(),
|
||||||
|
updatedAt: st.mtime.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const app = Fastify({ logger: true, bodyLimit: 1024 * 1024 * 1024 });
|
||||||
|
await app.register(cors, {
|
||||||
|
origin: CORS_ORIGIN === "*" ? true : CORS_ORIGIN.split(","),
|
||||||
|
});
|
||||||
|
app.get("/health", async () => ({ ok: true }));
|
||||||
|
|
||||||
|
const s = initServer();
|
||||||
|
const router = s.router(contract, {
|
||||||
|
listProjects: async () => ({ status: 200, body: [await project()] }),
|
||||||
|
getProject: async ({ params }) => {
|
||||||
|
if (params.project !== SLUG) {
|
||||||
|
return { status: 404 as const, body: { message: "project not found" } };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 200 as const,
|
||||||
|
body: { project: await project(), files: await walk(PROJECT_DIR) },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
listFiles: async ({ params }) => {
|
||||||
|
if (params.project !== SLUG) {
|
||||||
|
return { status: 404 as const, body: { message: "project not found" } };
|
||||||
|
}
|
||||||
|
return { status: 200 as const, body: await walk(PROJECT_DIR) };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.register(s.plugin(router));
|
||||||
|
|
||||||
|
// Streamed file-byte download (binary; intentionally not a ts-rest endpoint).
|
||||||
|
app.get<{ Params: { project: string; "*": string } }>(
|
||||||
|
"/api/projects/:project/files/*",
|
||||||
|
async (req, reply) => {
|
||||||
|
if (req.params.project !== SLUG) {
|
||||||
|
return reply.code(404).send({ message: "project not found" });
|
||||||
|
}
|
||||||
|
let abs: string;
|
||||||
|
try {
|
||||||
|
abs = safeJoin(req.params["*"]);
|
||||||
|
} catch {
|
||||||
|
return reply.code(400).send({ message: "invalid path" });
|
||||||
|
}
|
||||||
|
const st = await fs.stat(abs).catch(() => null);
|
||||||
|
if (!st?.isFile()) {
|
||||||
|
return reply.code(404).send({ message: "file not found" });
|
||||||
|
}
|
||||||
|
reply.header("Content-Type", guessContentType(req.params["*"]));
|
||||||
|
reply.header("Content-Length", st.size);
|
||||||
|
return reply.send(createReadStream(abs));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.listen({ port: PORT, host: "0.0.0.0" });
|
||||||
|
app.log.info(`serving project "${SLUG}" from ${PROJECT_DIR}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
11
web/backend/tsconfig.json
Normal file
11
web/backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["node"],
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"noEmit": true,
|
||||||
|
"verbatimModuleSyntax": false
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
# Local infrastructure for the KiCad web app.
|
|
||||||
#
|
|
||||||
# NOTE: the Postgres host port defaults to a non-default value (54329) so it
|
|
||||||
# does not collide with a Postgres already listening on the standard 5432.
|
|
||||||
# Override with POSTGRES_PORT in web/.env if 54329 is taken. The container
|
|
||||||
# always listens on 5432 internally; only the published host port changes.
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-kicad}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kicad}
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-kicad_web}
|
|
||||||
ports:
|
|
||||||
- "${POSTGRES_PORT:-54329}:5432"
|
|
||||||
volumes:
|
|
||||||
- kicad_web_pgdata:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-kicad} -d ${POSTGRES_DB:-kicad_web}"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
kicad_web_pgdata:
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "kicad-web",
|
"name": "pcbjam-web",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@10.33.0",
|
||||||
|
|
@ -10,12 +10,7 @@
|
||||||
"dev": "turbo run dev",
|
"dev": "turbo run dev",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"typecheck": "turbo run typecheck",
|
"typecheck": "turbo run typecheck",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint"
|
||||||
"db:up": "docker compose up -d",
|
|
||||||
"db:down": "docker compose down",
|
|
||||||
"db:generate": "pnpm --filter @kicad-web/server db:generate",
|
|
||||||
"db:migrate": "pnpm --filter @kicad-web/server db:migrate",
|
|
||||||
"db:seed": "pnpm --filter @kicad-web/server db:seed"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"turbo": "^2.5.0",
|
"turbo": "^2.5.0",
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
{
|
|
||||||
"name": "@kicad-web/contract",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"exports": {
|
|
||||||
".": "./src/index.ts"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@ts-rest/core": "^3.52.1",
|
|
||||||
"zod": "^3.24.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"typescript": "^5.7.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
import { initContract } from "@ts-rest/core";
|
|
||||||
import { z } from "zod";
|
|
||||||
import {
|
|
||||||
createProjectBody,
|
|
||||||
errorBody,
|
|
||||||
projectFileSchema,
|
|
||||||
projectSchema,
|
|
||||||
projectWithFiles,
|
|
||||||
} from "./schemas.js";
|
|
||||||
|
|
||||||
export * from "./schemas.js";
|
|
||||||
|
|
||||||
const c = initContract();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON API surface shared by the Fastify server and the React client.
|
|
||||||
*
|
|
||||||
* NOTE: binary upload (`POST .../files`, `.../files/zip`) and file-byte
|
|
||||||
* download (`GET .../files/*`) are intentionally NOT in this ts-rest contract —
|
|
||||||
* multipart/streamed-binary do not round-trip cleanly through ts-rest. They are
|
|
||||||
* plain Fastify routes; their response shapes are still shared via the Zod
|
|
||||||
* schemas in ./schemas.ts (e.g. `uploadResponse`).
|
|
||||||
*/
|
|
||||||
export const contract = c.router(
|
|
||||||
{
|
|
||||||
listProjects: {
|
|
||||||
method: "GET",
|
|
||||||
path: "/api/projects",
|
|
||||||
responses: { 200: z.array(projectSchema) },
|
|
||||||
summary: "List all projects in the default owner namespace",
|
|
||||||
},
|
|
||||||
createProject: {
|
|
||||||
method: "POST",
|
|
||||||
path: "/api/projects",
|
|
||||||
body: createProjectBody,
|
|
||||||
responses: {
|
|
||||||
201: projectSchema,
|
|
||||||
409: errorBody,
|
|
||||||
400: errorBody,
|
|
||||||
},
|
|
||||||
summary: "Create a project",
|
|
||||||
},
|
|
||||||
getProject: {
|
|
||||||
method: "GET",
|
|
||||||
path: "/api/projects/:project",
|
|
||||||
pathParams: z.object({ project: z.string() }),
|
|
||||||
responses: {
|
|
||||||
200: projectWithFiles,
|
|
||||||
404: errorBody,
|
|
||||||
},
|
|
||||||
summary: "Get a project and its file tree",
|
|
||||||
},
|
|
||||||
deleteProject: {
|
|
||||||
method: "DELETE",
|
|
||||||
path: "/api/projects/:project",
|
|
||||||
body: c.type<Record<string, never>>(),
|
|
||||||
responses: {
|
|
||||||
200: z.object({ id: z.string().uuid() }),
|
|
||||||
404: errorBody,
|
|
||||||
},
|
|
||||||
summary: "Delete a project and all its files",
|
|
||||||
},
|
|
||||||
listFiles: {
|
|
||||||
method: "GET",
|
|
||||||
path: "/api/projects/:project/files",
|
|
||||||
pathParams: z.object({ project: z.string() }),
|
|
||||||
responses: {
|
|
||||||
200: z.array(projectFileSchema),
|
|
||||||
404: errorBody,
|
|
||||||
},
|
|
||||||
summary: "List the files in a project",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
strictStatusCodes: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type Contract = typeof contract;
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
/** WASM tools that can be selected by the `:tool` URL segment. */
|
|
||||||
export const TOOLS = [
|
|
||||||
"pcbnew",
|
|
||||||
"eeschema",
|
|
||||||
"calculator",
|
|
||||||
"pl_editor",
|
|
||||||
"symbol_editor",
|
|
||||||
"gerbview",
|
|
||||||
] as const;
|
|
||||||
export const toolSchema = z.enum(TOOLS);
|
|
||||||
export type Tool = z.infer<typeof toolSchema>;
|
|
||||||
|
|
||||||
/** Human-readable labels for tools (UI links, status text). */
|
|
||||||
export const TOOL_LABELS: Record<Tool, string> = {
|
|
||||||
pcbnew: "PCB Editor",
|
|
||||||
eeschema: "Schematic Editor",
|
|
||||||
calculator: "PCB Calculator",
|
|
||||||
pl_editor: "Drawing Sheet Editor",
|
|
||||||
symbol_editor: "Symbol Editor",
|
|
||||||
gerbview: "Gerber Viewer",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Default file-extension → tool mapping (the explicit URL segment always wins). */
|
|
||||||
export const EXTENSION_TOOL: Record<string, Tool> = {
|
|
||||||
".kicad_pcb": "pcbnew",
|
|
||||||
".kicad_sch": "eeschema",
|
|
||||||
".kicad_wks": "pl_editor",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tools that do not take a file (booted standalone). The calculator has no file
|
|
||||||
* concept; the symbol editor opens libraries through its own UI (its frame does
|
|
||||||
* not implement OpenProjectFiles), so we boot it standalone rather than auto-open.
|
|
||||||
* The gerber viewer likewise opens gerber/drill files through its own File→Open
|
|
||||||
* UI — projects carry no gerber files to auto-open — so it boots standalone too.
|
|
||||||
*/
|
|
||||||
export const FILELESS_TOOLS: ReadonlySet<Tool> = new Set<Tool>([
|
|
||||||
"calculator",
|
|
||||||
"symbol_editor",
|
|
||||||
"gerbview",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const projectSlugSchema = z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.max(64)
|
|
||||||
.regex(
|
|
||||||
/^[a-z0-9][a-z0-9._-]*$/,
|
|
||||||
"slug must start alphanumeric and contain only lowercase letters, digits, '.', '_', '-'",
|
|
||||||
);
|
|
||||||
|
|
||||||
export const projectSchema = z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
ownerId: z.string().uuid(),
|
|
||||||
slug: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
createdAt: z.string(),
|
|
||||||
updatedAt: z.string(),
|
|
||||||
});
|
|
||||||
export type Project = z.infer<typeof projectSchema>;
|
|
||||||
|
|
||||||
export const projectFileSchema = z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
projectId: z.string().uuid(),
|
|
||||||
/** POSIX project-relative path, e.g. "pcbnew/nyak.kicad_pcb". */
|
|
||||||
path: z.string(),
|
|
||||||
size: z.number().int().nonnegative(),
|
|
||||||
contentType: z.string(),
|
|
||||||
createdAt: z.string(),
|
|
||||||
updatedAt: z.string(),
|
|
||||||
});
|
|
||||||
export type ProjectFile = z.infer<typeof projectFileSchema>;
|
|
||||||
|
|
||||||
export const createProjectBody = z.object({
|
|
||||||
name: z.string().min(1).max(200),
|
|
||||||
slug: projectSlugSchema.optional(),
|
|
||||||
});
|
|
||||||
export type CreateProjectBody = z.infer<typeof createProjectBody>;
|
|
||||||
|
|
||||||
export const projectWithFiles = z.object({
|
|
||||||
project: projectSchema,
|
|
||||||
files: z.array(projectFileSchema),
|
|
||||||
});
|
|
||||||
export type ProjectWithFiles = z.infer<typeof projectWithFiles>;
|
|
||||||
|
|
||||||
/** Shared response shape for the (raw-Fastify) upload endpoints. */
|
|
||||||
export const uploadResponse = z.object({
|
|
||||||
files: z.array(projectFileSchema),
|
|
||||||
});
|
|
||||||
export type UploadResponse = z.infer<typeof uploadResponse>;
|
|
||||||
|
|
||||||
export const errorBody = z.object({ message: z.string() });
|
|
||||||
export type ErrorBody = z.infer<typeof errorBody>;
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"name": "@kicad-web/storage",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"exports": {
|
|
||||||
".": "./src/index.ts"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.10.5",
|
|
||||||
"typescript": "^5.7.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
export type { FileStorage, StatResult } from "./types.js";
|
|
||||||
export { LocalDiskStorage } from "./local-disk.js";
|
|
||||||
|
|
||||||
import { LocalDiskStorage } from "./local-disk.js";
|
|
||||||
import type { FileStorage } from "./types.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the storage backend from environment. Today only `local` is wired; an
|
|
||||||
* `s3` driver slots in here later behind the same FileStorage interface.
|
|
||||||
*/
|
|
||||||
export function createFileStorage(env: {
|
|
||||||
STORAGE_DRIVER?: string;
|
|
||||||
STORAGE_ROOT?: string;
|
|
||||||
}): FileStorage {
|
|
||||||
const driver = env.STORAGE_DRIVER ?? "local";
|
|
||||||
switch (driver) {
|
|
||||||
case "local":
|
|
||||||
return new LocalDiskStorage(env.STORAGE_ROOT ?? "./.data/storage");
|
|
||||||
default:
|
|
||||||
throw new Error(`unknown STORAGE_DRIVER: ${driver}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
import { createReadStream as fsCreateReadStream } from "node:fs";
|
|
||||||
import * as fs from "node:fs/promises";
|
|
||||||
import * as path from "node:path";
|
|
||||||
import type { Readable } from "node:stream";
|
|
||||||
import { pipeline } from "node:stream/promises";
|
|
||||||
import { createWriteStream } from "node:fs";
|
|
||||||
import type { FileStorage, StatResult } from "./types.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stores blobs as files under a single root directory. `key` maps directly to a
|
|
||||||
* relative path inside the root; traversal outside the root is rejected.
|
|
||||||
*/
|
|
||||||
export class LocalDiskStorage implements FileStorage {
|
|
||||||
private readonly root: string;
|
|
||||||
|
|
||||||
constructor(root: string) {
|
|
||||||
this.root = path.resolve(root);
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolve(key: string): string {
|
|
||||||
const normalized = path
|
|
||||||
.normalize(key)
|
|
||||||
.replace(/^(\.\.(\/|\\|$))+/, "")
|
|
||||||
.replace(/^[/\\]+/, "");
|
|
||||||
const full = path.resolve(this.root, normalized);
|
|
||||||
if (full !== this.root && !full.startsWith(this.root + path.sep)) {
|
|
||||||
throw new Error(`storage key escapes root: ${key}`);
|
|
||||||
}
|
|
||||||
return full;
|
|
||||||
}
|
|
||||||
|
|
||||||
async exists(key: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await fs.access(this.resolve(key));
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async read(key: string): Promise<Uint8Array> {
|
|
||||||
return new Uint8Array(await fs.readFile(this.resolve(key)));
|
|
||||||
}
|
|
||||||
|
|
||||||
createReadStream(key: string): Readable {
|
|
||||||
return fsCreateReadStream(this.resolve(key));
|
|
||||||
}
|
|
||||||
|
|
||||||
async stat(key: string): Promise<StatResult> {
|
|
||||||
const s = await fs.stat(this.resolve(key));
|
|
||||||
return { size: s.size };
|
|
||||||
}
|
|
||||||
|
|
||||||
async list(prefix: string): Promise<string[]> {
|
|
||||||
const base = this.resolve(prefix);
|
|
||||||
const out: string[] = [];
|
|
||||||
const walk = async (dir: string): Promise<void> => {
|
|
||||||
let entries: import("node:fs").Dirent[];
|
|
||||||
try {
|
|
||||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const entry of entries) {
|
|
||||||
const abs = path.join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
await walk(abs);
|
|
||||||
} else if (entry.isFile()) {
|
|
||||||
out.push(path.relative(this.root, abs).split(path.sep).join("/"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
await walk(base);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
async write(
|
|
||||||
key: string,
|
|
||||||
data: Uint8Array | Readable,
|
|
||||||
_opts?: { contentType?: string },
|
|
||||||
): Promise<void> {
|
|
||||||
const full = this.resolve(key);
|
|
||||||
await fs.mkdir(path.dirname(full), { recursive: true });
|
|
||||||
if (data instanceof Uint8Array) {
|
|
||||||
await fs.writeFile(full, data);
|
|
||||||
} else {
|
|
||||||
await pipeline(data, createWriteStream(full));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(key: string): Promise<void> {
|
|
||||||
await fs.rm(this.resolve(key), { force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
async deletePrefix(prefix: string): Promise<void> {
|
|
||||||
await fs.rm(this.resolve(prefix), { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import type { Readable } from "node:stream";
|
|
||||||
|
|
||||||
export interface StatResult {
|
|
||||||
size: number;
|
|
||||||
contentType?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pluggable blob store for project file bytes.
|
|
||||||
*
|
|
||||||
* `key` is an opaque string the caller chose (see the server's storage-key
|
|
||||||
* scheme); only the implementation interprets it. The current iteration is
|
|
||||||
* read-heavy — the write half exists so save-back (a later iteration) needs no
|
|
||||||
* redesign.
|
|
||||||
*/
|
|
||||||
export interface FileStorage {
|
|
||||||
// --- read ---
|
|
||||||
exists(key: string): Promise<boolean>;
|
|
||||||
read(key: string): Promise<Uint8Array>;
|
|
||||||
createReadStream(key: string): Readable;
|
|
||||||
stat(key: string): Promise<StatResult>;
|
|
||||||
list(prefix: string): Promise<string[]>;
|
|
||||||
|
|
||||||
// --- write (used now by upload; save-back is a later iteration) ---
|
|
||||||
write(
|
|
||||||
key: string,
|
|
||||||
data: Uint8Array | Readable,
|
|
||||||
opts?: { contentType?: string },
|
|
||||||
): Promise<void>;
|
|
||||||
delete(key: string): Promise<void>;
|
|
||||||
/** Remove every key under a prefix (e.g. a whole project). */
|
|
||||||
deletePrefix(prefix: string): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"types": ["node"]
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
1
web/pcbjam-shared
Submodule
1
web/pcbjam-shared
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit b4993a47e897afc6e3a986ae1d598cc2011b3c13
|
||||||
1073
web/pnpm-lock.yaml
generated
1073
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,4 @@
|
||||||
packages:
|
packages:
|
||||||
- "apps/*"
|
- "standalone"
|
||||||
- "packages/*"
|
- "backend"
|
||||||
|
- "pcbjam-shared"
|
||||||
|
|
|
||||||
16
web/standalone/.env.example
Normal file
16
web/standalone/.env.example
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Standalone editor (Vite). Copy to standalone/.env. All vars must be VITE_-prefixed.
|
||||||
|
|
||||||
|
# Backend implementing the @pcbjam/shared contract (the example backend, or any
|
||||||
|
# conforming backend). Leave it pointing anywhere unreachable to use only the
|
||||||
|
# local-folder loader on the home page.
|
||||||
|
VITE_API_BASE_URL=http://localhost:3060
|
||||||
|
|
||||||
|
# Where the WASM glue/artifacts are served from. SAME-ORIGIN "/wasm" is required
|
||||||
|
# (KiCad WASM pthread workers cannot be created cross-origin). On `dev` the
|
||||||
|
# artifacts are symlinked into public/wasm and served by Vite at /wasm. For prod,
|
||||||
|
# set an absolute URL whose origin also satisfies the COEP/COOP rules.
|
||||||
|
VITE_WASM_ASSET_BASE_URL=/wasm
|
||||||
|
|
||||||
|
# Override the artifact source dir the dev symlink points at (default:
|
||||||
|
# <repo>/tests/apps/kicad). Useful when serving prebuilt artifacts from elsewhere.
|
||||||
|
# WASM_SRC_DIR=
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "@kicad-web/frontend",
|
"name": "@pcbjam/standalone",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kicad-web/contract": "workspace:*",
|
"@pcbjam/shared": "workspace:*",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-label": "^2.1.1",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// Make the WASM runtime artifacts available SAME-ORIGIN to the app without a
|
// Make the WASM runtime artifacts available SAME-ORIGIN to the app without a
|
||||||
// third copy: symlink apps/frontend/public/wasm -> the synced artifact dir
|
// third copy: symlink standalone/public/wasm -> the synced artifact dir
|
||||||
// (tests/apps/kicad, populated by tests/scripts/setup-kicad-wasm.sh). Vite then
|
// (tests/apps/kicad, populated by tests/scripts/setup-kicad-wasm.sh). Vite then
|
||||||
// serves them at /wasm from the app's own origin — required because KiCad WASM
|
// serves them at /wasm from the app's own origin — required because KiCad WASM
|
||||||
// (Asyncify build, COEP/cross-origin-isolated document) refuses to load its
|
// (Asyncify build, COEP/cross-origin-isolated document) refuses to load its
|
||||||
|
|
@ -17,7 +17,8 @@ import { fileURLToPath } from "node:url";
|
||||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const publicDir = path.resolve(scriptDir, "../public");
|
const publicDir = path.resolve(scriptDir, "../public");
|
||||||
const linkPath = path.join(publicDir, "wasm");
|
const linkPath = path.join(publicDir, "wasm");
|
||||||
const repoRoot = path.resolve(scriptDir, "../../../..");
|
// scriptDir = web/standalone/scripts → repo root is three levels up.
|
||||||
|
const repoRoot = path.resolve(scriptDir, "../../..");
|
||||||
const setupScript = path.join(repoRoot, "tests/scripts/setup-kicad-wasm.sh");
|
const setupScript = path.join(repoRoot, "tests/scripts/setup-kicad-wasm.sh");
|
||||||
|
|
||||||
const targetAbs = process.env.WASM_SRC_DIR
|
const targetAbs = process.env.WASM_SRC_DIR
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { Route, Routes } from "react-router-dom";
|
import { Route, Routes } from "react-router-dom";
|
||||||
import { ProjectsPage } from "@/pages/ProjectsPage";
|
import { HomePage } from "@/pages/HomePage";
|
||||||
import { ProjectDetailPage } from "@/pages/ProjectDetailPage";
|
import { ProjectView } from "@/pages/ProjectView";
|
||||||
import { ToolPage } from "@/pages/ToolPage";
|
import { ToolPage } from "@/pages/ToolPage";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<ProjectsPage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/p/:project" element={<ProjectDetailPage />} />
|
<Route path="/p/:project" element={<ProjectView />} />
|
||||||
<Route path="/p/:project/:tool/*" element={<ToolPage />} />
|
<Route path="/p/:project/:tool/*" element={<ToolPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import type { ProjectFile, Tool } from "@kicad-web/contract";
|
import type { Tool } from "@pcbjam/shared";
|
||||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { fetchFileBytes } from "@/lib/api";
|
|
||||||
import { WASM_ASSET_BASE_URL } from "@/lib/config";
|
import { WASM_ASSET_BASE_URL } from "@/lib/config";
|
||||||
import { bootKicadTool } from "@/wasm/boot";
|
import { bootKicadTool } from "@/wasm/boot";
|
||||||
import { driveProjectIntoTool } from "@/wasm/kicad-runner";
|
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
|
||||||
import type { CollabWindow } from "@/wasm/collab";
|
import type { CollabWindow } from "@/wasm/collab";
|
||||||
import { clog, cwarn } from "@/wasm/collab/debug";
|
import { clog, cwarn } from "@/wasm/collab/debug";
|
||||||
|
|
||||||
|
|
@ -79,11 +78,17 @@ export function WasmTool({
|
||||||
slug,
|
slug,
|
||||||
files,
|
files,
|
||||||
targetPath,
|
targetPath,
|
||||||
|
fetchBytes,
|
||||||
|
assetBaseUrl,
|
||||||
}: {
|
}: {
|
||||||
tool: Tool;
|
tool: Tool;
|
||||||
slug: string;
|
slug: string;
|
||||||
files: ProjectFile[];
|
files: ToolFile[];
|
||||||
targetPath?: string;
|
targetPath?: string;
|
||||||
|
/** Fetch one project-relative file's bytes (contract loader or local folder). */
|
||||||
|
fetchBytes: (relPath: string) => Promise<Uint8Array>;
|
||||||
|
/** Where the WASM glue/artifacts are served from; defaults to VITE_WASM_ASSET_BASE_URL. */
|
||||||
|
assetBaseUrl?: string;
|
||||||
}) {
|
}) {
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
const startedRef = React.useRef(false);
|
const startedRef = React.useRef(false);
|
||||||
|
|
@ -91,7 +96,7 @@ export function WasmTool({
|
||||||
const [logs, setLogs] = React.useState<string[]>([]);
|
const [logs, setLogs] = React.useState<string[]>([]);
|
||||||
const [showLog, setShowLog] = React.useState(false);
|
const [showLog, setShowLog] = React.useState(false);
|
||||||
|
|
||||||
const base = WASM_ASSET_BASE_URL.replace(/\/$/, "");
|
const base = (assetBaseUrl ?? WASM_ASSET_BASE_URL).replace(/\/$/, "");
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// Guard re-entry: the WASM runtime is process-global and must boot exactly
|
// Guard re-entry: the WASM runtime is process-global and must boot exactly
|
||||||
|
|
@ -117,7 +122,7 @@ export function WasmTool({
|
||||||
slug,
|
slug,
|
||||||
files,
|
files,
|
||||||
targetPath,
|
targetPath,
|
||||||
fetchBytes: (relPath) => fetchFileBytes(slug, relPath),
|
fetchBytes,
|
||||||
log: append,
|
log: append,
|
||||||
onStatus: setStatus,
|
onStatus: setStatus,
|
||||||
});
|
});
|
||||||
61
web/standalone/src/lib/api.ts
Normal file
61
web/standalone/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import {
|
||||||
|
contract,
|
||||||
|
type Project,
|
||||||
|
type ProjectWithFiles,
|
||||||
|
} from "@pcbjam/shared";
|
||||||
|
import { initClient } from "@ts-rest/core";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { API_BASE_URL } from "./config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only client over the shared contract. The standalone editor only ever
|
||||||
|
* READS projects from a backend (enumerate, get file tree, stream bytes) — it
|
||||||
|
* never creates/deletes/uploads. Those management concerns live in the closed
|
||||||
|
* application that hosts this editor.
|
||||||
|
*/
|
||||||
|
export const client = initClient(contract, {
|
||||||
|
baseUrl: API_BASE_URL,
|
||||||
|
baseHeaders: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useProjects() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["projects"],
|
||||||
|
queryFn: async (): Promise<Project[]> => {
|
||||||
|
const res = await client.listProjects();
|
||||||
|
if (res.status !== 200) throw new Error("failed to list projects");
|
||||||
|
return res.body;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProject(slug: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["project", slug],
|
||||||
|
queryFn: async (): Promise<ProjectWithFiles> => {
|
||||||
|
const res = await client.getProject({ params: { 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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- raw file-byte download (streamed binary, not a ts-rest endpoint) ---
|
||||||
|
|
||||||
|
export function fileBytesUrl(slug: string, relPath: string): string {
|
||||||
|
const encoded = relPath
|
||||||
|
.split("/")
|
||||||
|
.map((seg) => encodeURIComponent(seg))
|
||||||
|
.join("/");
|
||||||
|
return `${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files/${encoded}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFileBytes(
|
||||||
|
slug: string,
|
||||||
|
relPath: string,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const res = await fetch(fileBytesUrl(slug, relPath));
|
||||||
|
if (!res.ok) throw new Error(`download failed (${res.status}): ${relPath}`);
|
||||||
|
return new Uint8Array(await res.arrayBuffer());
|
||||||
|
}
|
||||||
187
web/standalone/src/pages/HomePage.tsx
Normal file
187
web/standalone/src/pages/HomePage.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
EXTENSION_TOOL,
|
||||||
|
FILELESS_TOOLS,
|
||||||
|
TOOL_LABELS,
|
||||||
|
TOOLS,
|
||||||
|
type Tool,
|
||||||
|
} from "@pcbjam/shared";
|
||||||
|
import { FolderOpen, Loader2 } from "lucide-react";
|
||||||
|
import { useProjects } from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { ToolFile } from "@/wasm/kicad-runner";
|
||||||
|
import { WasmTool } from "@/components/WasmTool";
|
||||||
|
|
||||||
|
/** A KiCad project picked from the local filesystem (no backend involved). */
|
||||||
|
interface LocalProject {
|
||||||
|
name: string;
|
||||||
|
files: ToolFile[];
|
||||||
|
fetchBytes: (relPath: string) => Promise<Uint8Array>;
|
||||||
|
defaultTool?: Tool;
|
||||||
|
defaultTarget?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolForPath(path: string): Tool | null {
|
||||||
|
const dot = path.lastIndexOf(".");
|
||||||
|
if (dot < 0) return null;
|
||||||
|
return EXTENSION_TOOL[path.slice(dot).toLowerCase()] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a LocalProject from a webkitdirectory FileList, stripping the top folder. */
|
||||||
|
function buildLocalProject(fileList: FileList): LocalProject {
|
||||||
|
const map = new Map<string, File>();
|
||||||
|
const first = fileList[0];
|
||||||
|
const topPrefix =
|
||||||
|
first?.webkitRelativePath?.includes("/")
|
||||||
|
? first.webkitRelativePath.split("/")[0] + "/"
|
||||||
|
: "";
|
||||||
|
for (const f of Array.from(fileList)) {
|
||||||
|
const rel = f.webkitRelativePath || f.name;
|
||||||
|
map.set(rel.startsWith(topPrefix) ? rel.slice(topPrefix.length) : rel, f);
|
||||||
|
}
|
||||||
|
const files: ToolFile[] = [...map.keys()].map((path) => ({ path }));
|
||||||
|
let defaultTool: Tool | undefined;
|
||||||
|
let defaultTarget: string | undefined;
|
||||||
|
for (const { path } of files) {
|
||||||
|
const tool = toolForPath(path);
|
||||||
|
if (tool) {
|
||||||
|
defaultTool = tool;
|
||||||
|
defaultTarget = path;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: topPrefix ? topPrefix.slice(0, -1) : "local",
|
||||||
|
files,
|
||||||
|
defaultTool,
|
||||||
|
defaultTarget,
|
||||||
|
fetchBytes: async (relPath) => {
|
||||||
|
const f = map.get(relPath);
|
||||||
|
if (!f) throw new Error(`local file not found: ${relPath}`);
|
||||||
|
return new Uint8Array(await f.arrayBuffer());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomePage() {
|
||||||
|
const { data: projects, isLoading, error } = useProjects();
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const [local, setLocal] = React.useState<LocalProject | null>(null);
|
||||||
|
const [tool, setTool] = React.useState<Tool | "">("");
|
||||||
|
const [launched, setLaunched] = React.useState(false);
|
||||||
|
|
||||||
|
// <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).
|
||||||
|
if (launched && local && tool) {
|
||||||
|
const target = tool === local.defaultTool ? local.defaultTarget : undefined;
|
||||||
|
return (
|
||||||
|
<WasmTool
|
||||||
|
tool={tool}
|
||||||
|
slug="local"
|
||||||
|
files={local.files}
|
||||||
|
targetPath={FILELESS_TOOLS.has(tool) ? undefined : target}
|
||||||
|
fetchBytes={local.fetchBytes}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-3xl py-10">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">PCBJam</h1>
|
||||||
|
<p className="mb-8 text-sm text-muted-foreground">
|
||||||
|
Open KiCad files in the browser — from a backend, or straight from a
|
||||||
|
local folder.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* --- Local folder --- */}
|
||||||
|
<section className="mb-10 rounded-lg border p-5">
|
||||||
|
<h2 className="mb-1 flex items-center gap-2 text-lg font-medium">
|
||||||
|
<FolderOpen size={18} /> Open a local folder
|
||||||
|
</h2>
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
No upload — files stay in your browser. Pick a folder containing a
|
||||||
|
KiCad project.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="block text-sm"
|
||||||
|
onChange={(e) => {
|
||||||
|
const fl = e.target.files;
|
||||||
|
if (!fl || fl.length === 0) return;
|
||||||
|
const proj = buildLocalProject(fl);
|
||||||
|
setLocal(proj);
|
||||||
|
setTool(proj.defaultTool ?? "");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{local && (
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{local.files.length} files
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className="rounded-md border px-2 py-1.5 text-sm"
|
||||||
|
value={tool}
|
||||||
|
onChange={(e) => setTool(e.target.value as Tool)}
|
||||||
|
>
|
||||||
|
<option value="">Select a tool…</option>
|
||||||
|
{TOOLS.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{TOOL_LABELS[t]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button disabled={!tool} onClick={() => setLaunched(true)}>
|
||||||
|
Open editor
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* --- Backend projects --- */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 text-lg font-medium">Projects from the backend</h2>
|
||||||
|
{isLoading && (
|
||||||
|
<p className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="animate-spin" /> loading…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No backend reachable ({(error as Error).message}). Use a local folder
|
||||||
|
above, or configure VITE_API_BASE_URL.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="divide-y rounded-lg border">
|
||||||
|
{projects?.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className="flex items-center justify-between px-4 py-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{p.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">/p/{p.slug}</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="secondary" size="sm">
|
||||||
|
<Link to={`/p/${p.slug}`}>Open</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{projects && projects.length === 0 && (
|
||||||
|
<div className="px-4 py-6 text-sm text-muted-foreground">
|
||||||
|
The backend has no projects.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,21 +4,24 @@ import {
|
||||||
FILELESS_TOOLS,
|
FILELESS_TOOLS,
|
||||||
TOOL_LABELS,
|
TOOL_LABELS,
|
||||||
type Tool,
|
type Tool,
|
||||||
} from "@kicad-web/contract";
|
} from "@pcbjam/shared";
|
||||||
import { ArrowLeft, ExternalLink, Loader2 } from "lucide-react";
|
import { ArrowLeft, ExternalLink, Loader2 } from "lucide-react";
|
||||||
import { useProject } from "@/lib/api";
|
import { useProject } from "@/lib/api";
|
||||||
import { formatBytes } from "@/lib/utils";
|
import { formatBytes } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { UploadDropzone } from "@/components/UploadDropzone";
|
|
||||||
|
|
||||||
function toolForPath(path: string): Tool | null {
|
function toolForPath(path: string): Tool | null {
|
||||||
const dot = path.lastIndexOf(".");
|
const dot = path.lastIndexOf(".");
|
||||||
if (dot < 0) return null;
|
if (dot < 0) return null;
|
||||||
const ext = path.slice(dot).toLowerCase();
|
return EXTENSION_TOOL[path.slice(dot).toLowerCase()] ?? null;
|
||||||
return EXTENSION_TOOL[ext] ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectDetailPage() {
|
/**
|
||||||
|
* Read-only view of a backend project: list its files and open them in a tool.
|
||||||
|
* The editor is GPL and intentionally has no create/delete/upload — those live
|
||||||
|
* in the closed application that hosts this editor.
|
||||||
|
*/
|
||||||
|
export function ProjectView() {
|
||||||
const { project: slug = "" } = useParams();
|
const { project: slug = "" } = useParams();
|
||||||
const { data, isLoading, error } = useProject(slug);
|
const { data, isLoading, error } = useProject(slug);
|
||||||
|
|
||||||
|
|
@ -26,7 +29,7 @@ export function ProjectDetailPage() {
|
||||||
<div className="container py-10">
|
<div className="container py-10">
|
||||||
<Button asChild variant="ghost" size="sm" className="mb-4">
|
<Button asChild variant="ghost" size="sm" className="mb-4">
|
||||||
<Link to="/">
|
<Link to="/">
|
||||||
<ArrowLeft /> All projects
|
<ArrowLeft /> Home
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|
@ -35,9 +38,7 @@ export function ProjectDetailPage() {
|
||||||
<Loader2 className="animate-spin" /> loading…
|
<Loader2 className="animate-spin" /> loading…
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && <p className="text-destructive">{(error as Error).message}</p>}
|
||||||
<p className="text-destructive">{(error as Error).message}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data && (
|
{data && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -45,12 +46,14 @@ export function ProjectDetailPage() {
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
{data.project.name}
|
{data.project.name}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">/p/{data.project.slug}</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
/p/{data.project.slug}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-6 flex flex-wrap gap-3">
|
<div className="mb-6 flex flex-wrap gap-3">
|
||||||
{/* Standalone (file-less) tools — launched without a target file.
|
{/* File-less tools — launched without a target file. Full reload
|
||||||
Full reload (anchor) so Emscripten boots into a clean page. */}
|
(anchor) so Emscripten boots into a clean page. */}
|
||||||
{[...FILELESS_TOOLS].map((tool) => (
|
{[...FILELESS_TOOLS].map((tool) => (
|
||||||
<a
|
<a
|
||||||
key={tool}
|
key={tool}
|
||||||
|
|
@ -62,10 +65,6 @@ export function ProjectDetailPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-8">
|
|
||||||
<UploadDropzone slug={slug} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 className="mb-3 text-lg font-medium">
|
<h2 className="mb-3 text-lg font-medium">
|
||||||
Files ({data.files.length})
|
Files ({data.files.length})
|
||||||
</h2>
|
</h2>
|
||||||
|
|
@ -96,7 +95,7 @@ export function ProjectDetailPage() {
|
||||||
})}
|
})}
|
||||||
{data.files.length === 0 && (
|
{data.files.length === 0 && (
|
||||||
<div className="px-4 py-6 text-sm text-muted-foreground">
|
<div className="px-4 py-6 text-sm text-muted-foreground">
|
||||||
No files yet — upload some above.
|
No files in this project.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { toolSchema } from "@kicad-web/contract";
|
import { toolSchema } from "@pcbjam/shared";
|
||||||
import { useProject } from "@/lib/api";
|
import { fetchFileBytes, useProject } from "@/lib/api";
|
||||||
import { WasmTool } from "@/components/WasmTool";
|
import { WasmTool } from "@/components/WasmTool";
|
||||||
|
|
||||||
export function ToolPage() {
|
export function ToolPage() {
|
||||||
|
|
@ -36,6 +36,7 @@ export function ToolPage() {
|
||||||
slug={slug}
|
slug={slug}
|
||||||
files={data.files}
|
files={data.files}
|
||||||
targetPath={targetPath}
|
targetPath={targetPath}
|
||||||
|
fetchBytes={(relPath) => fetchFileBytes(slug, relPath)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Tool } from "@kicad-web/contract";
|
import type { Tool } from "@pcbjam/shared";
|
||||||
import {
|
import {
|
||||||
KICAD_CONFIG_DIR,
|
KICAD_CONFIG_DIR,
|
||||||
RESOURCE_PATH,
|
RESOURCE_PATH,
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Tool } from "@kicad-web/contract";
|
import type { Tool } from "@pcbjam/shared";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KiCad config/version dir baked into the WASM build. The File→Open dialog
|
* KiCad config/version dir baked into the WASM build. The File→Open dialog
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
import type { ProjectFile, Tool } from "@kicad-web/contract";
|
import type { Tool } from "@pcbjam/shared";
|
||||||
import { FILELESS_TOOLS } from "@kicad-web/contract";
|
import { FILELESS_TOOLS } from "@pcbjam/shared";
|
||||||
import { memfsFilePath, memfsProjectDir } from "./constants";
|
import { memfsFilePath, memfsProjectDir } from "./constants";
|
||||||
import { openFileInTool } from "./open-flow";
|
import { openFileInTool } from "./open-flow";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only thing the editor needs to know about a file to sync it into MEMFS:
|
||||||
|
* its project-relative POSIX path. Both the contract loader (whose ProjectFile
|
||||||
|
* is a superset of this) and the local-folder loader satisfy it.
|
||||||
|
*/
|
||||||
|
export interface ToolFile {
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DriveOptions {
|
export interface DriveOptions {
|
||||||
tool: Tool;
|
tool: Tool;
|
||||||
slug: string;
|
slug: string;
|
||||||
files: ProjectFile[];
|
files: ToolFile[];
|
||||||
targetPath?: string;
|
targetPath?: string;
|
||||||
fetchBytes: (relPath: string) => Promise<Uint8Array>;
|
fetchBytes: (relPath: string) => Promise<Uint8Array>;
|
||||||
log: (msg: string) => void;
|
log: (msg: string) => void;
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://turbo.build/schema.json",
|
"$schema": "https://turbo.build/schema.json",
|
||||||
"globalEnv": [
|
"globalEnv": [
|
||||||
"DATABASE_URL",
|
|
||||||
"PORT",
|
"PORT",
|
||||||
"STORAGE_ROOT",
|
"PROJECT_DIR",
|
||||||
"STORAGE_DRIVER",
|
|
||||||
"CORS_ORIGIN",
|
"CORS_ORIGIN",
|
||||||
"DEFAULT_OWNER_SLUG",
|
|
||||||
"WASM_SRC_DIR",
|
"WASM_SRC_DIR",
|
||||||
"VITE_API_BASE_URL",
|
"VITE_API_BASE_URL",
|
||||||
"VITE_WASM_ASSET_BASE_URL"
|
"VITE_WASM_ASSET_BASE_URL"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue