Accept a KiCad project drop so hierarchical sheets and the board upload in one step.
A single .kicad_sch was parsed in isolation, so child sheets looked like an empty netlist. Report 404s now surface the real API error instead of a generic fetch failure. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -41,6 +41,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
||||
import { ImpedancePanel } from "@/components/project/impedance-panel";
|
||||
import { PcbUploadButton } from "@/components/project/pcb-upload";
|
||||
|
||||
export default function ProjectDetailPage({
|
||||
params,
|
||||
@@ -190,6 +191,11 @@ export default function ProjectDetailPage({
|
||||
<h1 className="text-lg font-semibold">{project.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{new Date(project.created).toLocaleDateString()}
|
||||
{project.hasPcb ? (
|
||||
<span className="ml-2">· PCB uploaded</span>
|
||||
) : (
|
||||
<span className="ml-2">· no PCB</span>
|
||||
)}
|
||||
{typeof project.totalCostUsd === "number" && project.totalCostUsd > 0 && (
|
||||
<span className="ml-2 font-mono tabular-nums text-foreground">
|
||||
${project.totalCostUsd.toFixed(4)}
|
||||
@@ -348,7 +354,11 @@ export default function ProjectDetailPage({
|
||||
)}
|
||||
|
||||
{tab === "impedance" && (
|
||||
<ImpedancePanel projectId={id} hasPcb={Boolean(project.hasPcb)} />
|
||||
<ImpedancePanel
|
||||
projectId={id}
|
||||
hasPcb={Boolean(project.hasPcb)}
|
||||
onPcbUploaded={reload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "logs" && (
|
||||
@@ -357,6 +367,19 @@ export default function ProjectDetailPage({
|
||||
|
||||
{tab === "settings" && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">KiCad PCB</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.hasPcb
|
||||
? "A .kicad_pcb is on this project. Replace it here, then re-run the pipeline for layout and net Z0."
|
||||
: "No board yet. Schematic review does not need one. Layout checks and net Z0 do."}
|
||||
</p>
|
||||
<PcbUploadButton projectId={id} onUploaded={reload} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CollaboratorsSection projectId={id} />
|
||||
<SkippedComponentsSection skipped={project.skippedComponents} />
|
||||
<ReportVersionSection pinscopeVersion={project.pinscopeVersion} />
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { PipelineStepper } from "@/components/progress/pipeline-stepper";
|
||||
import { PausedRunBanner } from "@/components/billing/paused-run-banner";
|
||||
import { usePipelineProgress } from "@/hooks/use-pipeline-progress";
|
||||
import { cancelPipeline, fetchProject, resumePipeline, reprocessPipeline } from "@/lib/api";
|
||||
import { cancelPipeline, fetchProject, fetchReport, resumePipeline, reprocessPipeline } from "@/lib/api";
|
||||
import type { PauseCheckpoint } from "@/lib/types";
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -107,14 +107,24 @@ export default function ProgressPage({
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-navigate to report when pipeline completes successfully
|
||||
// Auto-navigate to report when pipeline completes and the file exists
|
||||
useEffect(() => {
|
||||
if (done && !error && !cancelled && !projectPaused) {
|
||||
const timer = setTimeout(() => {
|
||||
router.push(`/project/${id}/report`);
|
||||
}, 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
if (!done || error || cancelled || projectPaused) return;
|
||||
let stopped = false;
|
||||
(async () => {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
try {
|
||||
await fetchReport(id);
|
||||
if (!stopped) router.push(`/project/${id}/report`);
|
||||
return;
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
stopped = true;
|
||||
};
|
||||
}, [done, error, cancelled, projectPaused, id, router]);
|
||||
|
||||
// Auto-navigate to dashboard when pipeline is cancelled
|
||||
|
||||
@@ -220,8 +220,18 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
|
||||
if (error || !report || !graph) {
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto w-full text-center py-12 text-sm text-muted-foreground">
|
||||
{error ?? "Report not found."}
|
||||
<div className="p-6 max-w-5xl mx-auto w-full text-center py-12 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{error ?? "Report not found."}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<a
|
||||
href={`/project/${projectId}/progress`}
|
||||
className="text-blue-600 dark:text-blue-500 hover:underline"
|
||||
>
|
||||
Open pipeline progress
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -352,6 +352,7 @@ function summarizeLcscModel(model: Record<string, unknown>): string {
|
||||
|
||||
type WizardStep =
|
||||
| "details"
|
||||
| "pcb"
|
||||
| "columns"
|
||||
| "subdesigns"
|
||||
| "lcsc-passives"
|
||||
@@ -361,6 +362,7 @@ type WizardStep =
|
||||
|
||||
const ALL_STEPS: { key: WizardStep; label: string }[] = [
|
||||
{ key: "details", label: "Project Details" },
|
||||
{ key: "pcb", label: "Board (optional)" },
|
||||
{ key: "columns", label: "BOM Columns" },
|
||||
{ key: "subdesigns", label: "Sub-designs" },
|
||||
{ key: "lcsc-passives", label: "Resolving Passive Specs" },
|
||||
@@ -374,6 +376,45 @@ const ALL_STEPS: { key: WizardStep; label: string }[] = [
|
||||
const NULL_SUBDESIGN_KEY = "";
|
||||
const _subKey = (id: string | null): string => id ?? NULL_SUBDESIGN_KEY;
|
||||
|
||||
const _NET_EXT = new Set([
|
||||
".kicad_sch",
|
||||
".kicad_net",
|
||||
".kicad_pro",
|
||||
".asc",
|
||||
".net",
|
||||
".edn",
|
||||
".edif",
|
||||
".edf",
|
||||
".xml",
|
||||
".zip",
|
||||
]);
|
||||
|
||||
function _fileExt(name: string): string {
|
||||
const i = name.lastIndexOf(".");
|
||||
return i >= 0 ? name.slice(i).toLowerCase() : "";
|
||||
}
|
||||
|
||||
function splitProjectFiles(incoming: File[]): {
|
||||
netlist: File[];
|
||||
pcb: File[];
|
||||
bom: File[];
|
||||
} {
|
||||
const netlist: File[] = [];
|
||||
const pcb: File[] = [];
|
||||
const bom: File[] = [];
|
||||
for (const f of incoming) {
|
||||
const rel = (
|
||||
(f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name
|
||||
).toLowerCase();
|
||||
if (rel.includes("-backups/") || rel.includes(".pretty/")) continue;
|
||||
const ext = _fileExt(f.name);
|
||||
if (ext === ".kicad_pcb") pcb.push(f);
|
||||
else if (ext === ".csv" || ext === ".xlsx") bom.push(f);
|
||||
else if (_NET_EXT.has(ext)) netlist.push(f);
|
||||
}
|
||||
return { netlist, pcb, bom };
|
||||
}
|
||||
|
||||
// Concurrency cap for the per-row LCSC passive resolve. Backend charges one
|
||||
// credit-billing API call per request; small batch keeps latency reasonable
|
||||
// without overloading the resolve endpoint.
|
||||
@@ -533,7 +574,7 @@ export function CreateProjectDialog({
|
||||
// Rerun mode: operate on an existing project rather than creating new.
|
||||
const [existingProjectId, setExistingProjectId] = useState<string | null>(null);
|
||||
const [initialBomFile, setInitialBomFile] = useState<File | null>(null);
|
||||
const [initialNetlistFile, setInitialNetlistFile] = useState<File | null>(null);
|
||||
const [initialNetlistFiles, setInitialNetlistFiles] = useState<File[]>([]);
|
||||
const [existingDatasheetStems, setExistingDatasheetStems] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
@@ -548,8 +589,12 @@ export function CreateProjectDialog({
|
||||
// Step 1
|
||||
const [name, setName] = useState("");
|
||||
const [bomFile, setBomFile] = useState<File | null>(null);
|
||||
const [netlistFile, setNetlistFile] = useState<File | null>(null);
|
||||
const [netlistFiles, setNetlistFiles] = useState<File[]>([]);
|
||||
const netlistFile = netlistFiles[0] ?? null;
|
||||
const [pcbFile, setPcbFile] = useState<File | null>(null);
|
||||
const skipPcbStep =
|
||||
Boolean(pcbFile) ||
|
||||
netlistFiles.some((f) => f.name.toLowerCase().endsWith(".zip"));
|
||||
const [netlistNetCount, setNetlistNetCount] = useState<number | null>(null);
|
||||
const [netlistError, setNetlistError] = useState<string | null>(null);
|
||||
|
||||
@@ -840,6 +885,7 @@ export function CreateProjectDialog({
|
||||
// lcsc-passives drains to zero before the auto-advance effect
|
||||
// moves us off the step on the next tick).
|
||||
if (s.key === step) return true;
|
||||
if (s.key === "pcb") return !skipPcbStep;
|
||||
if (s.key === "subdesigns") return hasSubdesignChoice;
|
||||
if (s.key === "lcsc-passives") return hasLcscPassives;
|
||||
if (s.key === "datasheets") return hasIcs;
|
||||
@@ -847,14 +893,15 @@ export function CreateProjectDialog({
|
||||
if (s.key === "passives") return hasPassives;
|
||||
return true;
|
||||
});
|
||||
}, [step, hasSubdesignChoice, hasLcscPassives, hasIcs, hasSimple, hasPassives]);
|
||||
}, [step, skipPcbStep, hasSubdesignChoice, hasLcscPassives, hasIcs, hasSimple, hasPassives]);
|
||||
|
||||
const stepIndex = activeSteps.findIndex((s) => s.key === step);
|
||||
|
||||
// ---- Handlers ----
|
||||
|
||||
const handleBomChange = useCallback((files: File[]) => {
|
||||
const file = files[0] || null;
|
||||
const file =
|
||||
files.find((f) => /\.(csv|xlsx)$/i.test(f.name)) || files[0] || null;
|
||||
setBomFile(file);
|
||||
if (!file) {
|
||||
setCsvData(null);
|
||||
@@ -880,9 +927,13 @@ export function CreateProjectDialog({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleNetlistChange = useCallback((files: File[]) => {
|
||||
const handleNetlistChange = useCallback((incoming: File[]) => {
|
||||
const { netlist, pcb, bom } = splitProjectFiles(incoming);
|
||||
if (pcb[0]) setPcbFile(pcb[0]);
|
||||
if (bom[0]) handleBomChange([bom[0]]);
|
||||
const files = netlist;
|
||||
const file = files[0] || null;
|
||||
setNetlistFile(file);
|
||||
setNetlistFiles(files);
|
||||
setNetlistNetCount(null);
|
||||
setNetlistError(null);
|
||||
// Invalidate any cached netlist preview, since it was relative to the
|
||||
@@ -895,6 +946,11 @@ export function CreateProjectDialog({
|
||||
setNetlistUploadedEarly(false);
|
||||
setNetlistIsEdif(false);
|
||||
if (!file) return;
|
||||
const lower = file.name.toLowerCase();
|
||||
if (lower.endsWith(".zip") || files.length > 1) {
|
||||
setNetlistPreview([]);
|
||||
return;
|
||||
}
|
||||
file.text().then((text) => {
|
||||
// EDIF: skip browser-side preview — the net-count badge will populate
|
||||
// after the server upload parses the file.
|
||||
@@ -920,14 +976,15 @@ export function CreateProjectDialog({
|
||||
);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
}, [handleBomChange]);
|
||||
|
||||
const resetAndClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
setStep("details");
|
||||
setName("");
|
||||
setBomFile(null);
|
||||
setNetlistFile(null);
|
||||
setNetlistFiles([]);
|
||||
setPcbFile(null);
|
||||
setNetlistNetCount(null);
|
||||
setNetlistError(null);
|
||||
setCsvData(null);
|
||||
@@ -954,7 +1011,7 @@ export function CreateProjectDialog({
|
||||
setError(null);
|
||||
setExistingProjectId(null);
|
||||
setInitialBomFile(null);
|
||||
setInitialNetlistFile(null);
|
||||
setInitialNetlistFiles([]);
|
||||
setExistingDatasheetStems(new Set());
|
||||
setPrefillLoading(false);
|
||||
setPrefillError(null);
|
||||
@@ -1049,8 +1106,8 @@ export function CreateProjectDialog({
|
||||
if (netlist) {
|
||||
const netlistText = await netlist.text();
|
||||
if (cancelled) return;
|
||||
setNetlistFile(netlist);
|
||||
setInitialNetlistFile(netlist);
|
||||
setNetlistFiles([netlist]);
|
||||
setInitialNetlistFiles([netlist]);
|
||||
if (isEdifNetlist(netlistText)) {
|
||||
setNetlistIsEdif(true);
|
||||
// EDIF: PADS-shape browser preview doesn't apply.
|
||||
@@ -1135,7 +1192,7 @@ export function CreateProjectDialog({
|
||||
if (netlist) {
|
||||
const netlistText = await netlist.text();
|
||||
if (cancelled) return;
|
||||
setNetlistFile(netlist);
|
||||
setNetlistFiles([netlist]);
|
||||
if (isEdifNetlist(netlistText)) {
|
||||
setNetlistIsEdif(true);
|
||||
setNetlistNetCount(null);
|
||||
@@ -1488,7 +1545,7 @@ export function CreateProjectDialog({
|
||||
setBomUploadedEarly(true);
|
||||
setInitialBomFile(bomFile);
|
||||
// Treat the netlist as fresh so it still uploads in handleCreate.
|
||||
setInitialNetlistFile(null);
|
||||
setInitialNetlistFiles([]);
|
||||
const map = uploadRes.lcsc_to_mpn ?? {};
|
||||
if (Object.keys(map).length > 0) setLcscToMpn(map);
|
||||
if (fullProj.lcscPayloads && Object.keys(fullProj.lcscPayloads).length > 0) {
|
||||
@@ -1547,13 +1604,13 @@ export function CreateProjectDialog({
|
||||
createdProjectIdHere = proj.id;
|
||||
setExistingProjectId(proj.id);
|
||||
}
|
||||
const result = await uploadNetlist(projectId, netlistFile);
|
||||
const result = await uploadNetlist(projectId, netlistFiles);
|
||||
setEdifSubDesigns(result.sub_designs);
|
||||
if (result.designator_pins.length > 0) {
|
||||
setNetlistPreview(result.designator_pins);
|
||||
}
|
||||
setNetlistUploadedEarly(true);
|
||||
setInitialNetlistFile(netlistFile);
|
||||
setInitialNetlistFiles(netlistFiles);
|
||||
return { ok: true, subDesigns: result.sub_designs };
|
||||
} catch (e) {
|
||||
// Only delete the project if we created it just now — leave LCSC's
|
||||
@@ -1573,7 +1630,7 @@ export function CreateProjectDialog({
|
||||
} finally {
|
||||
setEarlyUploading(false);
|
||||
}
|
||||
}, [netlistFile, existingProjectId, name]);
|
||||
}, [netlistFile, netlistFiles, existingProjectId, name]);
|
||||
|
||||
// ---- LCSC per-row passive resolve ----
|
||||
//
|
||||
@@ -1689,6 +1746,7 @@ export function CreateProjectDialog({
|
||||
|
||||
const canAdvance = (): boolean => {
|
||||
if (step === "details") return !!(name.trim() && bomFile && netlistFile && !netlistError);
|
||||
if (step === "pcb") return true;
|
||||
if (step === "columns") return !!(refCol && mpnCol);
|
||||
if (step === "subdesigns")
|
||||
return !!(selectedSubdesignIds && selectedSubdesignIds.size > 0);
|
||||
@@ -1729,6 +1787,9 @@ export function CreateProjectDialog({
|
||||
const res = await runEarlyNetlistUpload();
|
||||
if (!res.ok) return;
|
||||
}
|
||||
setStep(skipPcbStep ? "columns" : "pcb");
|
||||
}
|
||||
else if (step === "pcb") {
|
||||
setStep("columns");
|
||||
}
|
||||
else if (step === "columns") {
|
||||
@@ -1806,7 +1867,8 @@ export function CreateProjectDialog({
|
||||
|
||||
const goBack = () => {
|
||||
setError(null);
|
||||
if (step === "columns") setStep("details");
|
||||
if (step === "pcb") setStep("details");
|
||||
else if (step === "columns") setStep(skipPcbStep ? "details" : "pcb");
|
||||
else if (step === "subdesigns") setStep("columns");
|
||||
else if (step === "lcsc-passives") {
|
||||
if (hasSubdesignChoice) setStep("subdesigns");
|
||||
@@ -1877,9 +1939,9 @@ export function CreateProjectDialog({
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectAlreadyExists || netlistFile !== initialNetlistFile) {
|
||||
if (!projectAlreadyExists || netlistFiles !== initialNetlistFiles) {
|
||||
setProgress("Uploading netlist...");
|
||||
await uploadNetlist(project.id, netlistFile!);
|
||||
await uploadNetlist(project.id, netlistFiles);
|
||||
}
|
||||
if (pcbFile) {
|
||||
setProgress("Uploading PCB...");
|
||||
@@ -2065,14 +2127,13 @@ export function CreateProjectDialog({
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<FileUploadZone
|
||||
label="BOM (.csv, .xlsx)"
|
||||
label="BOM"
|
||||
accept=".csv,.xlsx"
|
||||
files={bomFile ? [bomFile] : []}
|
||||
onFilesChange={handleBomChange}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
||||
Requires columns: Designator, Manufacturer Part Number, and
|
||||
Comment (for passive values / mismatch detection).{" "}
|
||||
CSV or Excel.{" "}
|
||||
<a
|
||||
href="/file-guide#the-bom"
|
||||
target="_blank"
|
||||
@@ -2085,9 +2146,10 @@ export function CreateProjectDialog({
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<FileUploadZone
|
||||
label="Netlist (.asc / .net / .edn / .kicad_sch)"
|
||||
accept=".asc,.net,.NET,.txt,.edn,.edif,.edf,.xml,.kicad_sch,.kicad_net"
|
||||
files={netlistFile ? [netlistFile] : []}
|
||||
label="Schematic"
|
||||
accept=".asc,.net,.NET,.txt,.edn,.edif,.edf,.xml,.kicad_sch,.kicad_net,.zip"
|
||||
multiple
|
||||
files={netlistFiles}
|
||||
onFilesChange={handleNetlistChange}
|
||||
/>
|
||||
{netlistError ? (
|
||||
@@ -2099,13 +2161,17 @@ export function CreateProjectDialog({
|
||||
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
||||
{netlistNetCount} nets detected
|
||||
</p>
|
||||
) : netlistFile ? (
|
||||
) : netlistFiles.length > 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
||||
Net count will appear after upload.
|
||||
{pcbFile
|
||||
? "Board included."
|
||||
: netlistFiles.length > 1
|
||||
? `${netlistFiles.length} schematic files.`
|
||||
: "Ready."}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
||||
PADS-PCB, EDIF, or KiCad netlist / .kicad_sch.{" "}
|
||||
Drop the KiCad folder, or one netlist.{" "}
|
||||
<a
|
||||
href="/file-guide#the-netlist"
|
||||
target="_blank"
|
||||
@@ -2118,19 +2184,20 @@ export function CreateProjectDialog({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<FileUploadZone
|
||||
label="PCB (.kicad_pcb, optional)"
|
||||
accept=".kicad_pcb"
|
||||
files={pcbFile ? [pcbFile] : []}
|
||||
onFilesChange={(files) => setPcbFile(files[0] ?? null)}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
||||
Optional. Layout checks (trace width, 3W, placement mm) stay
|
||||
off until a board is present. Schema review still runs
|
||||
without it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "pcb" && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Optional. Skip if you only want schematic review.
|
||||
</p>
|
||||
<FileUploadZone
|
||||
label="KiCad board (.kicad_pcb)"
|
||||
accept=".kicad_pcb"
|
||||
files={pcbFile ? [pcbFile] : []}
|
||||
onFilesChange={(files) => setPcbFile(files[0] ?? null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
computeImpedance,
|
||||
fetchImpedanceNets,
|
||||
} from "@/lib/api";
|
||||
import { PcbUploadButton } from "@/components/project/pcb-upload";
|
||||
import type {
|
||||
ImpedanceKind,
|
||||
ImpedanceNetsReport,
|
||||
@@ -29,9 +30,11 @@ function fmt(n: number | null | undefined, digits = 2): string {
|
||||
export function ImpedancePanel({
|
||||
projectId,
|
||||
hasPcb,
|
||||
onPcbUploaded,
|
||||
}: {
|
||||
projectId: string;
|
||||
hasPcb: boolean;
|
||||
onPcbUploaded?: () => void;
|
||||
}) {
|
||||
const [kind, setKind] = useState<ImpedanceKind>("microstrip");
|
||||
const [h, setH] = useState("0.20");
|
||||
@@ -225,10 +228,13 @@ export function ImpedancePanel({
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!hasPcb && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upload a `.kicad_pcb` and run analysis. Power/ground nets are
|
||||
skipped; signal traces with stackup εr/h are sampled.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upload a `.kicad_pcb`, then re-run the pipeline. Power/ground
|
||||
nets are skipped; signal traces with stackup εr/h are sampled.
|
||||
</p>
|
||||
<PcbUploadButton projectId={projectId} onUploaded={onPcbUploaded} />
|
||||
</div>
|
||||
)}
|
||||
{hasPcb && boardNets?.skipped && (
|
||||
<p className="text-sm text-muted-foreground">{boardNets.skipped}</p>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Upload, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { uploadPcb } from "@/lib/api";
|
||||
|
||||
export function PcbUploadButton({
|
||||
projectId,
|
||||
onUploaded,
|
||||
}: {
|
||||
projectId: string;
|
||||
onUploaded?: () => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="inline-flex">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
nativeButton={false}
|
||||
render={<span />}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{busy ? "Uploading…" : "Upload .kicad_pcb"}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept=".kicad_pcb"
|
||||
className="hidden"
|
||||
disabled={busy}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await uploadPcb(projectId, file);
|
||||
onUploaded?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Upload failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,80 @@ interface FileUploadZoneProps {
|
||||
preloaded?: string[];
|
||||
}
|
||||
|
||||
function withRelativePath(file: File, rel: string): File {
|
||||
if ((file as File & { webkitRelativePath?: string }).webkitRelativePath === rel) {
|
||||
return file;
|
||||
}
|
||||
try {
|
||||
Object.defineProperty(file, "webkitRelativePath", {
|
||||
value: rel,
|
||||
configurable: true,
|
||||
});
|
||||
} catch {
|
||||
/* ignore — some File objects are sealed */
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
function readDirEntries(
|
||||
reader: FileSystemDirectoryReader,
|
||||
): Promise<FileSystemEntry[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const acc: FileSystemEntry[] = [];
|
||||
const pump = () => {
|
||||
reader.readEntries((chunk) => {
|
||||
if (chunk.length === 0) resolve(acc);
|
||||
else {
|
||||
acc.push(...chunk);
|
||||
pump();
|
||||
}
|
||||
}, reject);
|
||||
};
|
||||
pump();
|
||||
});
|
||||
}
|
||||
|
||||
async function filesFromEntry(
|
||||
entry: FileSystemEntry,
|
||||
prefix: string,
|
||||
): Promise<File[]> {
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise<File>((resolve, reject) => {
|
||||
(entry as FileSystemFileEntry).file(resolve, reject);
|
||||
});
|
||||
return [withRelativePath(file, prefix + file.name)];
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
const skip = /(-backups|\.pretty|3dmodels|__macosx)$/i.test(entry.name);
|
||||
if (skip) return [];
|
||||
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||
const children = await readDirEntries(reader);
|
||||
const nested: File[] = [];
|
||||
for (const child of children) {
|
||||
nested.push(...(await filesFromEntry(child, prefix + entry.name + "/")));
|
||||
}
|
||||
return nested;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function filesFromDrop(e: React.DragEvent): Promise<File[]> {
|
||||
const items = e.dataTransfer?.items;
|
||||
if (items && items.length > 0) {
|
||||
const out: File[] = [];
|
||||
for (const item of Array.from(items)) {
|
||||
const entry = item.webkitGetAsEntry?.();
|
||||
if (entry) out.push(...(await filesFromEntry(entry, "")));
|
||||
else {
|
||||
const f = item.getAsFile();
|
||||
if (f) out.push(f);
|
||||
}
|
||||
}
|
||||
if (out.length > 0) return out;
|
||||
}
|
||||
return Array.from(e.dataTransfer?.files ?? []);
|
||||
}
|
||||
|
||||
export function FileUploadZone({
|
||||
label,
|
||||
accept,
|
||||
@@ -27,10 +101,11 @@ export function FileUploadZone({
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const dropped = Array.from(e.dataTransfer.files);
|
||||
onFilesChange(multiple ? [...files, ...dropped] : dropped.slice(0, 1));
|
||||
void filesFromDrop(e).then((dropped) => {
|
||||
onFilesChange(multiple ? [...files, ...dropped] : dropped.slice(0, 1));
|
||||
});
|
||||
},
|
||||
[files, multiple, onFilesChange]
|
||||
[files, multiple, onFilesChange],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
@@ -38,7 +113,7 @@ export function FileUploadZone({
|
||||
const selected = Array.from(e.target.files ?? []);
|
||||
onFilesChange(multiple ? [...files, ...selected] : selected.slice(0, 1));
|
||||
},
|
||||
[files, multiple, onFilesChange]
|
||||
[files, multiple, onFilesChange],
|
||||
);
|
||||
|
||||
const hasFiles = files.length > 0 || (preloaded && preloaded.length > 0);
|
||||
@@ -48,9 +123,12 @@ export function FileUploadZone({
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 cursor-pointer transition-colors",
|
||||
dragOver ? "border-blue-500 bg-blue-500/5" : "border-border hover:border-foreground/20",
|
||||
hasFiles && "border-emerald-500/40 bg-emerald-500/5"
|
||||
hasFiles && "border-emerald-500/40 bg-emerald-500/5",
|
||||
)}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
@@ -69,12 +147,27 @@ export function FileUploadZone({
|
||||
)}
|
||||
{files.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{files.map((f) => (
|
||||
<span key={f.name} className="font-mono block">{f.name}</span>
|
||||
))}
|
||||
{files.length <= 2 ? (
|
||||
files.map((f) => {
|
||||
const rel =
|
||||
(f as File & { webkitRelativePath?: string }).webkitRelativePath ||
|
||||
f.name;
|
||||
return (
|
||||
<span key={rel} className="font-mono block">{rel.split("/").pop()}</span>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span>{files.length} files</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<input type="file" accept={accept} multiple={multiple} className="hidden" onChange={handleChange} />
|
||||
<input
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
className="hidden"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,13 @@ import { useState, useEffect } from "react";
|
||||
import type { ValidationReport, DesignGraph } from "@/lib/types";
|
||||
import { fetchReport, fetchGraph } from "@/lib/api";
|
||||
|
||||
function describeLoadError(e: unknown): string {
|
||||
if (e instanceof TypeError) {
|
||||
return "Could not reach the Pinscope API. Check that the backend is running and that /api is proxied to it.";
|
||||
}
|
||||
return e instanceof Error ? e.message : "Failed to load report";
|
||||
}
|
||||
|
||||
export function useReport(projectId: string) {
|
||||
const [report, setReport] = useState<ValidationReport | null>(null);
|
||||
const [graph, setGraph] = useState<DesignGraph | null>(null);
|
||||
@@ -11,14 +18,39 @@ export function useReport(projectId: string) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
Promise.all([fetchReport(projectId), fetchGraph(projectId)])
|
||||
.then(([r, g]) => {
|
||||
setReport(r);
|
||||
setGraph(g);
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
setError(null);
|
||||
|
||||
(async () => {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
try {
|
||||
const [r, g] = await Promise.all([
|
||||
fetchReport(projectId),
|
||||
fetchGraph(projectId),
|
||||
]);
|
||||
if (!cancelled) {
|
||||
setReport(r);
|
||||
setGraph(g);
|
||||
}
|
||||
return;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
const msg = e instanceof Error ? e.message : "";
|
||||
const retryable = msg.includes("not found") || e instanceof TypeError;
|
||||
if (!retryable || attempt === 3) break;
|
||||
await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
if (!cancelled) setError(describeLoadError(lastErr));
|
||||
})().finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
return { report, graph, loading, error };
|
||||
|
||||
+40
-4
@@ -56,6 +56,19 @@ async function authFetch(url: string, init?: RequestInit): Promise<Response> {
|
||||
return fetch(url, { ...init, headers });
|
||||
}
|
||||
|
||||
async function throwHttpError(res: Response, fallback: string): Promise<never> {
|
||||
let detail: unknown;
|
||||
try {
|
||||
const body = await res.json();
|
||||
detail = (body as { detail?: unknown; error?: unknown }).detail
|
||||
?? (body as { error?: unknown }).error;
|
||||
} catch {
|
||||
detail = undefined;
|
||||
}
|
||||
const msg = typeof detail === "string" && detail.trim() ? detail : fallback;
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
// --- Projects ---
|
||||
|
||||
function mapProject(p: Record<string, unknown>): Project {
|
||||
@@ -301,10 +314,19 @@ export async function uploadPcb(
|
||||
}
|
||||
|
||||
export async function uploadNetlist(
|
||||
projectId: string, file: File,
|
||||
projectId: string, files: File | File[],
|
||||
): Promise<UploadNetlistResult> {
|
||||
const list = Array.isArray(files) ? files : [files];
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const rels = list.map((f) => {
|
||||
const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath;
|
||||
return rel && rel.length > 0 ? rel : f.name;
|
||||
});
|
||||
if (list[0]) form.append("file", list[0], list[0].name);
|
||||
if (list.length > 1) {
|
||||
for (const f of list) form.append("files", f, f.name);
|
||||
form.append("paths", JSON.stringify(rels));
|
||||
}
|
||||
const res = await authFetch(
|
||||
`${BASE}/api/projects/${projectId}/upload/netlist`,
|
||||
{ method: "POST", body: form },
|
||||
@@ -651,7 +673,14 @@ export async function fetchReport(
|
||||
projectId: string,
|
||||
): Promise<ValidationReport> {
|
||||
const res = await authFetch(`${BASE}/api/report/${projectId}`);
|
||||
if (!res.ok) throw new Error("Failed to fetch report");
|
||||
if (!res.ok) {
|
||||
await throwHttpError(
|
||||
res,
|
||||
res.status === 404
|
||||
? "Report not found — the pipeline has not finished, or it failed before writing a report."
|
||||
: "Failed to fetch report",
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -723,7 +752,14 @@ export async function deleteComment(
|
||||
|
||||
export async function fetchGraph(projectId: string): Promise<DesignGraph> {
|
||||
const res = await authFetch(`${BASE}/api/graph/${projectId}`);
|
||||
if (!res.ok) throw new Error("Failed to fetch graph");
|
||||
if (!res.ok) {
|
||||
await throwHttpError(
|
||||
res,
|
||||
res.status === 404
|
||||
? "Design graph not found — the pipeline has not finished, or it failed during graph build."
|
||||
: "Failed to fetch graph",
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED by scripts/sync-version.mjs from content/changelog.md.
|
||||
// Do not edit by hand — change the top "## X.Y.Z" heading in the changelog.
|
||||
export const APP_VERSION = "2.26.0";
|
||||
export const APP_VERSION = "2.26.1";
|
||||
export const APP_VERSION_DATE = "2026-09-11";
|
||||
|
||||
Reference in New Issue
Block a user