Rewrite leftover frontend pages into src overlay.

Dashboard, project, report, progress, admin, feedback, and marketing
keep routes and layout. Billing/Clerk/analytics stay imported seams.
This commit is contained in:
2026-09-20 19:53:33 +02:00
parent 2613678442
commit a011351828
49 changed files with 12034 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,404 @@
"use client";
import { Suspense, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import {
ArrowRight,
CheckCircle2,
LayoutGrid,
List,
Loader2,
X,
} from "lucide-react";
import { useCredits } from "@/components/billing/credits-context";
import { CreateProjectDialog } from "@/components/dashboard/create-project-dialog";
import { OnboardingSurvey } from "@/components/dashboard/onboarding-survey";
import { ProjectCard } from "@/components/dashboard/project-card";
import { ProjectsTable } from "@/components/dashboard/projects-table";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
fetchProject,
fetchProjects,
reconcileCheckoutSession,
} from "@/lib/api";
import type { CreditSnapshot, Project } from "@/lib/types";
import { cn } from "@/lib/utils";
type LayoutKind = "cards" | "table";
type TopupBanner = "pending" | "activated" | "timeout" | "dismissed";
const LAYOUT_KEY = "periscopex:dashboard:view";
const CLONE_STASH_KEY = "periscopex:cloneAsNewProjectId";
export default function DashboardPage() {
return (
<Suspense>
<DashboardBody />
</Suspense>
);
}
function newestFirst(list: Project[]): Project[] {
return [...list].sort((left, right) => {
const a = new Date(left.created).getTime();
const b = new Date(right.created).getTime();
return (Number.isFinite(b) ? b : 0) - (Number.isFinite(a) ? a : 0);
});
}
function DashboardBody() {
const router = useRouter();
const searchParams = useSearchParams();
const { credits, refresh: refreshCredits } = useCredits();
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [rerunProject, setRerunProject] = useState<Project | null>(null);
const [cloneAsNewProject, setCloneAsNewProject] = useState<Project | null>(
null,
);
const [bannerVisible, setBannerVisible] = useState(false);
const [topupPhase, setTopupPhase] = useState<TopupBanner>("pending");
const [activatedDetail, setActivatedDetail] = useState<string | null>(null);
const [layout, setLayout] = useState<LayoutKind>("cards");
const ordered = useMemo(() => newestFirst(projects), [projects]);
useEffect(() => {
try {
const stored = localStorage.getItem(LAYOUT_KEY);
if (stored === "cards" || stored === "table") setLayout(stored);
} catch {
/* storage blocked */
}
}, []);
function persistLayout(next: LayoutKind) {
setLayout(next);
try {
localStorage.setItem(LAYOUT_KEY, next);
} catch {
/* ignore */
}
}
function stripCheckoutQuery() {
const url = new URL(window.location.href);
url.searchParams.delete("topup");
url.searchParams.delete("session_id");
router.replace(url.pathname + (url.search || ""));
}
function hideBanner() {
setTopupPhase("dismissed");
setBannerVisible(false);
stripCheckoutQuery();
}
useEffect(() => {
fetchProjects()
.then(setProjects)
.finally(() => setLoading(false));
const cloneId =
typeof window !== "undefined"
? window.sessionStorage.getItem(CLONE_STASH_KEY)
: null;
if (cloneId) {
window.sessionStorage.removeItem(CLONE_STASH_KEY);
fetchProject(cloneId)
.then(setCloneAsNewProject)
.catch(() => {
/* source project missing — leave the dialog closed */
});
}
if (searchParams.get("topup") !== "success") return;
setBannerVisible(true);
setTopupPhase("pending");
const sessionId = searchParams.get("session_id");
let cancelled = false;
let poll: ReturnType<typeof setInterval> | null = null;
const markActivated = (balance: number) => {
if (cancelled) return;
setActivatedDetail(`Balance is now ${balance.toFixed(2)} credits.`);
setTopupPhase("activated");
};
(async () => {
const initial = await refreshCredits();
const startBalance: number | null = initial?.balance ?? null;
if (sessionId) {
try {
const r = await reconcileCheckoutSession(sessionId);
if (
!cancelled &&
r.ok &&
r.kind === "topup" &&
r.payment_status === "paid"
) {
const c = await refreshCredits();
markActivated(c?.balance ?? startBalance ?? 0);
return;
}
} catch {
/* poll until the webhook lands */
}
}
let ticks = 0;
const TICK_CAP = 8;
poll = setInterval(async () => {
if (cancelled) return;
ticks++;
const c = await refreshCredits();
const grew =
c != null &&
startBalance != null &&
c.balance > startBalance + 0.001;
if (grew && c) {
markActivated(c.balance);
if (poll) clearInterval(poll);
} else if (ticks >= TICK_CAP) {
setTopupPhase("timeout");
if (poll) clearInterval(poll);
}
}, 2000);
})();
return () => {
cancelled = true;
if (poll) clearInterval(poll);
};
}, [searchParams, refreshCredits]);
useEffect(() => {
if (topupPhase !== "activated") return;
const url = new URL(window.location.href);
if (url.searchParams.has("topup") || url.searchParams.has("session_id")) {
stripCheckoutQuery();
}
const t = setTimeout(() => {
setTopupPhase("dismissed");
setBannerVisible(false);
}, 4000);
return () => clearTimeout(t);
// stripCheckoutQuery is stable enough for this effect; router is captured inside.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [topupPhase, router]);
function upsertProject(p: Project) {
setProjects((prev) => {
if (prev.some((x) => x.id === p.id)) {
return prev.map((x) => (x.id === p.id ? p : x));
}
return [p, ...prev];
});
router.push(`/project/${p.id}/progress`);
}
function dropProject(id: string) {
setProjects((prev) => prev.filter((x) => x.id !== id));
}
return (
<div className="flex-1 p-6 max-w-5xl mx-auto w-full">
<OnboardingSurvey />
{bannerVisible && topupPhase !== "dismissed" && (
<TopupBanner
phase={topupPhase}
detail={activatedDetail}
onDismiss={hideBanner}
/>
)}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-lg font-semibold">Projects</h1>
<CreditsLine credits={credits} />
</div>
<CreateProjectDialog
rerunProject={rerunProject}
onRerunDone={() => setRerunProject(null)}
cloneAsNewProject={cloneAsNewProject}
onCloneAsNewDone={() => setCloneAsNewProject(null)}
onCreateProject={upsertProject}
/>
</div>
{!loading && projects.length > 0 && (
<div className="flex items-center justify-end gap-2 mb-4">
<div className="inline-flex rounded-lg border border-input p-0.5">
<button
type="button"
onClick={() => persistLayout("cards")}
aria-pressed={layout === "cards"}
title="Card view"
className={cn(
"p-1 rounded-md transition-colors",
layout === "cards"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<LayoutGrid className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => persistLayout("table")}
aria-pressed={layout === "table"}
title="Table view"
className={cn(
"p-1 rounded-md transition-colors",
layout === "table"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<List className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-36 rounded-lg" />
))}
</div>
) : projects.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-12">
No projects yet. Create one to get started.
</p>
) : layout === "table" ? (
<ProjectsTable
projects={ordered}
onDeleted={dropProject}
onRerun={setRerunProject}
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{ordered.map((p) => (
<ProjectCard
key={p.id}
project={p}
onDeleted={() => dropProject(p.id)}
onRerun={setRerunProject}
/>
))}
</div>
)}
</div>
);
}
function CreditsLine({ credits }: { credits: CreditSnapshot | null }) {
if (!credits) {
return (
<p className="text-sm text-muted-foreground">
Schematic validation projects
</p>
);
}
return (
<p className="text-sm text-muted-foreground">
{credits.balance.toFixed(2)} credits
{" · "}
<Link
href="/billing"
className="underline underline-offset-2 hover:text-foreground"
>
buy more
</Link>
{" · "}
<Link
href="/credits"
className="underline underline-offset-2 hover:text-foreground"
>
ledger
</Link>
</p>
);
}
function TopupBanner({
phase,
detail,
onDismiss,
}: {
phase: TopupBanner;
detail: string | null;
onDismiss: () => void;
}) {
const tone =
phase === "timeout"
? "border-amber-500/30 bg-amber-500/5 text-amber-600 dark:text-amber-400"
: "border-emerald-500/30 bg-emerald-500/5 text-emerald-600 dark:text-emerald-400";
let icon;
let heading;
let body;
let action;
if (phase === "pending") {
icon = <Loader2 className="h-4 w-4 shrink-0 animate-spin" />;
heading = "Payment received";
body = "Applying your top-up to your balance…";
} else if (phase === "activated") {
icon = <CheckCircle2 className="h-4 w-4 shrink-0" />;
heading = "Top-up complete";
body = detail;
action = (
<Link href="/credits">
<Button size="sm" variant="outline" className="h-7 text-xs">
View ledger
<ArrowRight className="h-3 w-3 ml-1" />
</Button>
</Link>
);
} else {
icon = <Loader2 className="h-4 w-4 shrink-0" />;
heading = "Still processing";
body =
"Stripe is taking longer than usual. Refresh the page in a moment — your credits will appear automatically once the webhook completes.";
action = (
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={() => window.location.reload()}
>
Refresh
</Button>
);
}
return (
<div
className={`rounded-lg border px-4 py-3 text-sm mb-4 flex items-start gap-3 ${tone}`}
>
{icon}
<div className="flex-1 min-w-0">
<div className="font-medium">{heading}</div>
{body && (
<div className="text-xs text-muted-foreground mt-0.5">{body}</div>
)}
</div>
{action}
<button
type="button"
onClick={onDismiss}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
</div>
);
}
@@ -0,0 +1,130 @@
"use client";
import { useEffect, useState } from "react";
import { MessageSquareWarning } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { fetchMyFeedback, type FeedbackTicket } from "@/lib/api";
const STATUS_STYLES: Record<string, string> = {
open: "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/30",
acknowledged: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30",
resolved: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30",
};
function relativeTime(iso: string): string {
const minutes = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
export default function FeedbackPage() {
const [tickets, setTickets] = useState<FeedbackTicket[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState<string | null>(null);
useEffect(() => {
fetchMyFeedback()
.then(setTickets)
.catch(() => {
/* keep empty list */
})
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="flex-1 p-6 max-w-4xl mx-auto w-full space-y-4">
<Skeleton className="h-8 w-48" />
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-16 rounded-lg" />
))}
</div>
);
}
return (
<div className="flex-1 p-6 max-w-4xl mx-auto w-full space-y-6">
<div>
<h1 className="text-lg font-semibold">My Feedback</h1>
<p className="text-sm text-muted-foreground">
{tickets.length} ticket{tickets.length !== 1 ? "s" : ""} submitted
</p>
</div>
{tickets.length === 0 ? (
<div className="rounded-lg border border-border bg-card p-12 text-center space-y-3">
<MessageSquareWarning className="h-8 w-8 text-muted-foreground mx-auto" />
<p className="text-sm text-muted-foreground">
No feedback submitted yet. Use the Feedback button in the sidebar or the flag
icon on a finding to submit.
</p>
</div>
) : (
<div className="space-y-2">
{tickets.map((ticket) => {
const open = expanded === ticket.ticket_id;
return (
<button
key={ticket.ticket_id}
type="button"
onClick={() => setExpanded(open ? null : ticket.ticket_id)}
className="w-full text-left rounded-lg border border-border bg-card p-4 hover:bg-accent/30 transition-colors"
>
<div className="flex items-center gap-3">
<Badge
variant="outline"
className={`text-xs shrink-0 ${STATUS_STYLES[ticket.status] ?? ""}`}
>
{ticket.status}
</Badge>
{ticket.finding_id && (
<span className="font-mono text-xs text-muted-foreground shrink-0">
{ticket.finding_id}
</span>
)}
{ticket.project_name && (
<span className="text-xs text-muted-foreground truncate">
{ticket.project_name}
</span>
)}
<span className="ml-auto text-xs text-muted-foreground shrink-0">
{relativeTime(ticket.created_at)}
</span>
</div>
<p className={`text-sm text-muted-foreground mt-2 ${open ? "" : "line-clamp-2"}`}>
{ticket.message}
</p>
{open && ticket.finding_text && (
<div className="mt-3 rounded border border-border/60 bg-muted/30 p-3 space-y-1">
<p className="text-xs text-muted-foreground">Reported finding:</p>
<p className="text-sm">{ticket.finding_text}</p>
<div className="flex gap-2 text-xs text-muted-foreground">
{ticket.finding_designator && (
<span className="font-mono">{ticket.finding_designator}</span>
)}
{ticket.finding_mpn && (
<span className="font-mono">{ticket.finding_mpn}</span>
)}
</div>
</div>
)}
{open && ticket.admin_notes && (
<div className="mt-3 rounded border border-emerald-500/20 bg-emerald-500/5 p-3">
<p className="text-xs text-emerald-600 dark:text-emerald-400 mb-1">
Periscope Team:
</p>
<p className="text-sm text-muted-foreground">{ticket.admin_notes}</p>
</div>
)}
</button>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
import { CreditsProvider } from "@/components/billing/credits-context";
import { RedditPixelMatchKeys } from "@/components/analytics/reddit-pixel-match-keys";
import { AuthGate } from "@/components/layout/auth-gate";
import { Sidebar } from "@/components/layout/sidebar";
import { TooltipProvider } from "@/components/ui/tooltip";
export default function AppShellLayout({
children,
}: Readonly<{ children: ReactNode }>) {
return (
<TooltipProvider>
<CreditsProvider>
<AuthGate>
<div className="flex h-full">
<Sidebar />
<main className="flex-1 flex flex-col overflow-auto">
{children}
</main>
</div>
</AuthGate>
<RedditPixelMatchKeys />
</CreditsProvider>
</TooltipProvider>
);
}
@@ -0,0 +1,560 @@
"use client";
import { use, useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
fetchProject,
fetchProjectLogs,
fetchBomSummary,
fetchDerating,
startPipeline,
startPlacementPipeline,
startPcbPipeline,
reprocessPipeline,
resumePipeline,
fetchPipelineEstimate,
} from "@/lib/api";
import { CreateProjectDialog } from "@/components/dashboard/create-project-dialog";
import { PausedRunBanner } from "@/components/billing/paused-run-banner";
import type {
Project,
ApiLogEntry,
BomSummaryRow,
DeratingRow,
DeratingSettings,
CostEstimate,
} from "@/lib/types";
import {
ArrowRight,
Play,
RotateCcw,
Coins,
Upload,
LayoutGrid,
CircuitBoard,
} from "lucide-react";
import { ImpedancePanel } from "@/components/project/impedance-panel";
import { TopologyPanel } from "@/components/project/topology-panel";
import { PcbUploadButton } from "@/components/project/pcb-upload";
import { PdfViewerSheet } from "@/components/pdf/pdf-viewer-sheet";
import { ApiLogsSection } from "@/components/project/api-logs-section";
import { BomSummaryTable } from "@/components/project/bom-summary-table";
import { DeratingTable } from "@/components/project/derating-table";
import { CollaboratorsSection } from "@/components/project/collaborators-section";
import { SkippedComponentsSection } from "@/components/project/skipped-components-section";
import { ReportVersionSection } from "@/components/project/report-version-section";
import { PipelineErrorBanner } from "@/components/project/pipeline-error-banner";
import {
deratingOverridesKey,
deratingSettingsKey,
legacyDeratingOverridesKey,
legacyDeratingSettingsKey,
migrateLocalKey,
} from "@/lib/storage-keys";
export default function ProjectDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const router = useRouter();
const [project, setProject] = useState<Project | null>(null);
const [logs, setLogs] = useState<ApiLogEntry[]>([]);
const [bomRows, setBomRows] = useState<BomSummaryRow[]>([]);
const [deratingRows, setDeratingRows] = useState<DeratingRow[]>([]);
const [deratingSettings, setDeratingSettings] = useState<DeratingSettings>(() => {
if (typeof window === "undefined") return { ceramic: 50, tantalum: 50, electrolytic: 50 };
try {
const stored = migrateLocalKey(
deratingSettingsKey(id),
legacyDeratingSettingsKey(id),
);
return stored ? JSON.parse(stored) : { ceramic: 50, tantalum: 50, electrolytic: 50 };
} catch {
return { ceramic: 50, tantalum: 50, electrolytic: 50 };
}
});
const [manualVoltages, setManualVoltages] = useState<Record<string, number>>(() => {
if (typeof window === "undefined") return {};
try {
const stored = migrateLocalKey(
deratingOverridesKey(id),
legacyDeratingOverridesKey(id),
);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
});
const [pdfState, setPdfState] = useState<{
open: boolean;
mpn: string | null;
}>({ open: false, mpn: null });
// Persist derating settings to localStorage
useEffect(() => {
localStorage.setItem(deratingSettingsKey(id), JSON.stringify(deratingSettings));
}, [deratingSettings, id]);
// Persist manual voltage overrides to localStorage
useEffect(() => {
localStorage.setItem(deratingOverridesKey(id), JSON.stringify(manualVoltages));
}, [manualVoltages, id]);
const reload = useCallback(() => {
fetchProject(id).then(setProject);
fetchProjectLogs(id).then(setLogs);
fetchBomSummary(id).then(setBomRows);
fetchDerating(id).then(setDeratingRows);
}, [id]);
useEffect(() => {
reload();
}, [reload]);
// Redirect to progress page if pipeline is running or queued
useEffect(() => {
if (project?.status === "running" || project?.status === "queued") {
router.replace(`/project/${id}/progress`);
}
}, [project?.status, id, router]);
// Placement progress page when placement is active
useEffect(() => {
if (
project?.placementStatus === "running" ||
project?.placementStatus === "queued"
) {
router.replace(`/project/${id}/placement`);
}
}, [project?.placementStatus, id, router]);
useEffect(() => {
if (
project?.pcbStatus === "running" ||
project?.pcbStatus === "queued"
) {
router.replace(`/project/${id}/pcb`);
}
}, [project?.pcbStatus, id, router]);
const searchParams = useSearchParams();
const tab = searchParams.get("tab") ?? "bom";
const [starting, setStarting] = useState(false);
const [startingPlacement, setStartingPlacement] = useState(false);
const [startingPcb, setStartingPcb] = useState(false);
const [estimate, setEstimate] = useState<CostEstimate | null>(null);
const [rerunProject, setRerunProject] = useState<Project | null>(null);
const canRun = Boolean(project?.hasBom && project?.hasNetlist);
const canReprocess =
project?.status === "complete" ||
project?.status === "error" ||
project?.status === "cancelled";
const canStartFresh =
project?.status !== "complete" &&
project?.status !== "paused_insufficient_credits" &&
!canReprocess;
// Pull a cost estimate when the project is ready to run, so we can show
// the "~X credits" hint under the button. No blocking — the user can
// start the run even if the balance is below the estimate.
useEffect(() => {
if (!canRun || !canStartFresh) {
setEstimate(null);
return;
}
let cancelled = false;
fetchPipelineEstimate(id)
.then((est) => {
if (!cancelled) setEstimate(est);
})
.catch(() => {
if (!cancelled) setEstimate(null);
});
return () => {
cancelled = true;
};
}, [id, canRun, canStartFresh]);
if (!project) return null;
const hasSkipped = project.skippedComponents && project.skippedComponents.length > 0;
const isPaused = project.status === "paused_insufficient_credits";
const handleRunPipeline = async () => {
if (!canRun) return;
setStarting(true);
try {
await startPipeline(id);
router.push(`/project/${id}/progress`);
} catch (e) {
setStarting(false);
alert(e instanceof Error ? e.message : "Failed to start pipeline");
}
};
const handleResumeRun = async () => {
setStarting(true);
try {
await resumePipeline(id);
router.push(`/project/${id}/progress`);
} catch (e) {
setStarting(false);
alert(e instanceof Error ? e.message : "Failed to resume pipeline");
}
};
const handleReprocess = async (mode: "failed" | "all") => {
if (!canRun) return;
setStarting(true);
try {
await reprocessPipeline(id, mode);
router.push(`/project/${id}/progress`);
} catch (e) {
setStarting(false);
alert(e instanceof Error ? e.message : "Failed to reprocess");
}
};
const analysisBusy =
project?.status === "running" || project?.status === "queued";
const placementBusy =
project?.placementStatus === "running" ||
project?.placementStatus === "queued";
const pcbBusy =
project?.pcbStatus === "running" ||
project?.pcbStatus === "queued";
const canStartPlacement =
Boolean(canRun) && !analysisBusy && !placementBusy && !pcbBusy;
const canStartPcb =
Boolean(project?.hasPcb && canRun) && !analysisBusy && !placementBusy && !pcbBusy;
const handlePlacement = async () => {
if (!canStartPlacement) return;
setStartingPlacement(true);
try {
await startPlacementPipeline(id);
router.push(`/project/${id}/placement`);
} catch (e) {
setStartingPlacement(false);
alert(e instanceof Error ? e.message : "Failed to start placement");
}
};
const handlePcbReview = async () => {
if (!canStartPcb) return;
setStartingPcb(true);
try {
await startPcbPipeline(id);
router.push(`/project/${id}/pcb`);
} catch (e) {
setStartingPcb(false);
alert(e instanceof Error ? e.message : "Failed to start PCB review");
}
};
const hasFailedReviews = Boolean(hasSkipped);
return (
<div className="flex-1 p-6 max-w-4xl mx-auto w-full space-y-6">
<div className="flex items-center justify-between">
<div>
<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)}
</span>
)}
</p>
</div>
<div className="flex items-center gap-2">
{project.status !== "running" && project.status !== "queued" && (
<Button
size="sm"
variant="outline"
onClick={() => setRerunProject(project)}
>
<Upload className="h-4 w-4 mr-1" />
Replace BOM & netlist
</Button>
)}
<Badge variant="outline" className="capitalize text-xs">
{project.status}
</Badge>
</div>
</div>
{isPaused && (
<PausedRunBanner
projectId={id}
checkpoint={project.pauseCheckpoint ?? null}
resuming={starting}
onResume={handleResumeRun}
/>
)}
{project.status === "error" && (
<PipelineErrorBanner
projectId={id}
message={project.pipelineError ?? null}
/>
)}
<Card>
<CardContent className="py-3">
{project.status === "complete" ? (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<span className="text-sm text-emerald-600 dark:text-emerald-400">
Validation complete
</span>
<div className="flex flex-wrap items-center gap-2 sm:ml-auto">
{hasFailedReviews && (
<Button
size="sm"
variant="outline"
disabled={starting}
onClick={() => handleReprocess("failed")}
>
<RotateCcw className="h-4 w-4 mr-1" />
{starting ? "Starting..." : "Retry failed reviews"}
</Button>
)}
<Button
size="sm"
variant="outline"
disabled={starting}
onClick={() => handleReprocess("all")}
>
<RotateCcw className="h-4 w-4 mr-1" />
Reprocess all
</Button>
<Link href={`/project/${id}/report`}>
<Button size="sm">
View Report
<ArrowRight className="h-4 w-4 ml-1" />
</Button>
</Link>
<Button
size="sm"
variant="outline"
disabled={!canStartPlacement || startingPlacement}
onClick={handlePlacement}
>
<LayoutGrid className="h-4 w-4 mr-1" />
{startingPlacement
? "Starting…"
: project.placementStatus === "complete"
? "Rebuild placement"
: "Build placement plan"}
</Button>
{project.placementStatus === "complete" && (
<Link href={`/project/${id}/placement`}>
<Button size="sm" variant="ghost">
View placement
</Button>
</Link>
)}
<Button
size="sm"
variant="outline"
disabled={!canStartPcb || startingPcb}
onClick={handlePcbReview}
>
<CircuitBoard className="h-4 w-4 mr-1" />
{startingPcb
? "Starting…"
: project.pcbStatus === "complete"
? "Re-run PCB review"
: "Run PCB review"}
</Button>
{project.pcbStatus === "complete" && (
<Link href={`/project/${id}/pcb`}>
<Button size="sm" variant="ghost">
View PCB review
</Button>
</Link>
)}
</div>
</div>
) : isPaused ? (
<span className="text-sm text-amber-600 dark:text-amber-400">
Paused waiting for credits. Use the banner above to resume.
</span>
) : (
<div className="flex flex-col items-start gap-2">
<div className="flex flex-wrap items-center gap-3">
{(project.status === "error" || project.status === "cancelled") && canRun ? (
<>
<Button
size="sm"
disabled={starting}
onClick={() => handleReprocess("failed")}
>
<RotateCcw className="h-4 w-4 mr-1" />
{starting ? "Starting..." : "Reprocess"}
</Button>
<Button
size="sm"
variant="outline"
disabled={starting}
onClick={() => handleReprocess("all")}
>
Reprocess all
</Button>
</>
) : (
<Button size="sm" disabled={!canRun || starting} onClick={handleRunPipeline}>
<Play className="h-4 w-4 mr-1" />
{starting ? "Starting..." : "Run Pipeline"}
</Button>
)}
<Button
size="sm"
variant="outline"
disabled={!canStartPlacement || startingPlacement}
onClick={handlePlacement}
>
<LayoutGrid className="h-4 w-4 mr-1" />
{startingPlacement
? "Starting…"
: project.placementStatus === "complete"
? "Rebuild placement"
: "Build placement plan"}
</Button>
<Button
size="sm"
variant="outline"
disabled={!canStartPcb || startingPcb}
onClick={handlePcbReview}
>
<CircuitBoard className="h-4 w-4 mr-1" />
{startingPcb
? "Starting…"
: project.pcbStatus === "complete"
? "Re-run PCB review"
: "Run PCB review"}
</Button>
{!canRun && (
<span className="text-xs text-muted-foreground">
Upload BOM and netlist to enable
</span>
)}
</div>
{canRun && estimate && estimate.review_ic_count > 0 && (
<div className="inline-flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Coins className="h-3 w-3" />
<span className="tabular-nums">
${estimate.api_cost_low.toFixed(2)}${estimate.api_cost_high.toFixed(2)}
</span>
<span>
· {estimate.review_ic_count} IC
{estimate.review_ic_count === 1 ? "" : "s"} to review
</span>
</div>
)}
</div>
)}
</CardContent>
</Card>
{tab === "bom" && (
<BomSummaryTable
rows={bomRows}
onViewDatasheet={(mpn) => setPdfState({ open: true, mpn })}
/>
)}
{tab === "domains" && (
<TopologyPanel projectId={id} mode="domains" />
)}
{tab === "rails" && (
<TopologyPanel projectId={id} mode="rails" />
)}
{tab === "derating" && (
<DeratingTable
rows={deratingRows}
settings={deratingSettings}
onSettingsChange={setDeratingSettings}
manualVoltages={manualVoltages}
onManualVoltageChange={(designator, voltage) => {
setManualVoltages((prev) => {
if (voltage === null) {
const next = { ...prev };
delete next[designator];
return next;
}
return { ...prev, [designator]: voltage };
});
}}
/>
)}
{tab === "impedance" && (
<ImpedancePanel
projectId={id}
hasPcb={Boolean(project.hasPcb)}
onPcbUploaded={reload}
/>
)}
{tab === "logs" && (
<ApiLogsSection logs={logs} />
)}
{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 periscopeVersion={project.periscopeVersion} />
</div>
)}
<PdfViewerSheet
open={pdfState.open}
onOpenChange={(open) => setPdfState((s) => ({ ...s, open }))}
projectId={id}
mpn={pdfState.mpn}
initialPage={1}
/>
<CreateProjectDialog
hideTrigger
rerunProject={rerunProject}
onRerunDone={() => {
setRerunProject(null);
reload();
}}
onCreateProject={(p) => {
setRerunProject(null);
router.push(`/project/${p.id}/progress`);
}}
/>
</div>
);
}
@@ -0,0 +1,436 @@
"use client";
import { use, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { PipelineStepper } from "@/components/progress/pipeline-stepper";
import { PausedRunBanner } from "@/components/billing/paused-run-banner";
import { usePipelineProgress } from "@/hooks/use-pipeline-progress";
import {
cancelPipeline,
fetchPipelineStatus,
fetchProject,
fetchReport,
resumePipeline,
reprocessPipeline,
} from "@/lib/api";
import type { PauseCheckpoint } from "@/lib/types";
import {
AlertTriangle,
ArrowRight,
CheckCircle2,
Coffee,
Coins,
Loader2,
OctagonX,
Ban,
RotateCcw,
} from "lucide-react";
type ProgressGate = "loading" | "live" | "paused" | "idle";
export default function ProgressPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const router = useRouter();
const [gate, setGate] = useState<ProgressGate>("loading");
const { steps, done, cancelled, error, summary, autoTopupFailure, credits, started, paused } =
usePipelineProgress(gate === "live" ? id : null);
const [topupDismissed, setTopupDismissed] = useState(false);
const [projectName, setProjectName] = useState<string>("");
const [confirmInput, setConfirmInput] = useState("");
const [cancelling, setCancelling] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [projectPaused, setProjectPaused] = useState(false);
const [projectCheckpoint, setProjectCheckpoint] = useState<PauseCheckpoint | null>(null);
const [resuming, setResuming] = useState(false);
const [reprocessing, setReprocessing] = useState(false);
async function handleReprocess(mode: "failed" | "all") {
setReprocessing(true);
try {
await reprocessPipeline(id, mode);
window.location.reload();
} catch (e) {
setReprocessing(false);
alert(e instanceof Error ? e.message : "Failed to reprocess");
}
}
useEffect(() => {
let cancelledFetch = false;
(async () => {
try {
const st = (await fetchPipelineStatus(id)) as { status?: string };
if (cancelledFetch) return;
const nameP = fetchProject(id)
.then((p) => {
if (!cancelledFetch) {
setProjectName(p.name);
if (p.pauseCheckpoint) setProjectCheckpoint(p.pauseCheckpoint);
}
})
.catch(() => {});
if (st.status === "paused_insufficient_credits" || st.status === "paused_by_user") {
setProjectPaused(true);
setGate("paused");
await nameP;
return;
}
if (st.status === "running" || st.status === "queued") {
setGate("live");
await nameP;
return;
}
setGate("idle");
router.replace(`/project/${id}`);
} catch {
if (!cancelledFetch) setGate("live");
}
})();
return () => {
cancelledFetch = true;
};
}, [id, router]);
useEffect(() => {
if (!paused) return;
setProjectPaused(true);
setGate("paused");
setProjectCheckpoint({
paused_at: paused.unit_id,
paused_stage: paused.stage,
last_completed_label: paused.last_completed,
completed_review_refs: paused.completed_review_refs ?? [],
pending_review_refs: paused.pending_review_refs ?? [],
});
}, [paused]);
const handleResume = async () => {
setResuming(true);
try {
await resumePipeline(id);
window.location.reload();
} catch (e) {
setResuming(false);
alert(e instanceof Error ? e.message : "Failed to resume pipeline");
}
};
useEffect(() => {
if (gate !== "live") return;
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));
}
}
if (!stopped) router.replace(`/project/${id}`);
})();
return () => {
stopped = true;
};
}, [gate, done, error, cancelled, projectPaused, id, router]);
useEffect(() => {
if (!cancelled) return;
const timer = setTimeout(() => {
router.push("/dashboard");
}, 1500);
return () => clearTimeout(timer);
}, [cancelled, router]);
const handleCancel = async () => {
setCancelling(true);
try {
await cancelPipeline(id);
} catch {
// already finished
} finally {
setCancelling(false);
setDialogOpen(false);
setConfirmInput("");
}
};
const isRunning = gate === "live" && !done && !projectPaused;
const isQueued = isRunning && !started;
if (gate === "loading" || gate === "idle") {
return (
<div className="flex-1 p-6 max-w-3xl mx-auto w-full flex items-center gap-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{gate === "idle" ? "Opening project…" : "Checking pipeline status…"}
</div>
);
}
return (
<div className="flex-1 p-6 max-w-3xl mx-auto w-full space-y-6">
<div>
<h1 className="text-lg font-semibold">Pipeline Progress</h1>
<p className="text-sm text-muted-foreground">
{cancelled
? "Pipeline cancelled"
: projectPaused
? "Pipeline paused — out of credits"
: done
? "Validation complete"
: isQueued
? "Project queued, starting pipeline..."
: "Running validation pipeline..."}
</p>
</div>
{projectPaused && (
<PausedRunBanner
projectId={id}
checkpoint={projectCheckpoint}
resuming={resuming}
onResume={handleResume}
/>
)}
{isQueued && (
<div className="relative flex items-center gap-4 p-5 rounded-xl border border-blue-500/30 bg-gradient-to-r from-blue-500/10 via-blue-500/[0.04] to-transparent overflow-hidden">
<div className="relative shrink-0">
<span
aria-hidden
className="absolute inset-0 -m-2 rounded-full bg-blue-400/30 blur-xl animate-pulse"
/>
<Loader2
className="relative h-8 w-8 text-blue-700 dark:text-blue-300 animate-spin drop-shadow-[0_0_10px_rgba(96,165,250,0.75)]"
strokeWidth={2.25}
/>
</div>
<div className="flex-1">
<p className="text-base font-medium text-foreground">Project queued, starting pipeline</p>
<p className="text-sm text-muted-foreground mt-0.5">
Waiting for a worker to pick this run up. Usually takes a few seconds.
</p>
</div>
</div>
)}
{isRunning && !isQueued && (
<div className="relative flex items-center gap-4 p-5 rounded-xl border border-amber-500/30 bg-gradient-to-r from-amber-500/10 via-amber-500/[0.04] to-transparent overflow-hidden">
<div className="relative shrink-0">
<span
aria-hidden
className="absolute inset-0 -m-2 rounded-full bg-amber-400/30 blur-xl animate-pulse"
/>
<Coffee
className="relative h-8 w-8 text-amber-700 dark:text-amber-300 drop-shadow-[0_0_10px_rgba(251,191,36,0.75)]"
strokeWidth={2.25}
/>
</div>
<div className="flex-1">
<p className="text-base font-medium text-foreground">Feel free to grab a coffee</p>
<p className="text-sm text-muted-foreground mt-0.5">
You can close this tab we&apos;ll email you when it&apos;s ready.
</p>
</div>
</div>
)}
{autoTopupFailure && !topupDismissed && (
<div className="flex items-start gap-3 p-4 rounded-lg border border-amber-500/30 bg-amber-500/5">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5" />
<div className="flex-1 text-sm">
<p className="font-medium text-amber-800 dark:text-amber-200">Auto top-up failed</p>
<p className="text-xs text-muted-foreground mt-0.5">
{autoTopupFailure.amount_usd
? `The $${autoTopupFailure.amount_usd.toFixed(0)} charge`
: "Your scheduled top-up"}{" "}
was declined ({autoTopupFailure.reason}). Auto top-up has been disabled top up
manually to avoid pausing.
</p>
</div>
<div className="flex items-center gap-2">
<Link href="/credits">
<Button variant="outline" size="sm">
Top up
</Button>
</Link>
<Button variant="ghost" size="sm" onClick={() => setTopupDismissed(true)}>
Dismiss
</Button>
</div>
</div>
)}
{(credits || isRunning) && (
<div className="flex items-center justify-between gap-4 px-3 py-1.5 rounded-md border border-border/40 bg-muted/20 text-xs text-muted-foreground">
<div className="flex items-center gap-2 min-w-0">
<Coins
key={credits?.ts ?? 0}
className="h-3.5 w-3.5 text-amber-600/60 dark:text-amber-400/60 shrink-0 data-[bump=true]:animate-pulse"
data-bump={credits ? "true" : "false"}
/>
<span>Spent this run</span>
<span className="font-mono tabular-nums text-foreground/80">
{credits ? credits.credits_spent.toFixed(2) : "0.00"}
</span>
{credits?.stage && (
<span className="hidden sm:inline text-muted-foreground/70 truncate">
· {credits.stage}
{credits.unit_id ? ` · ${credits.unit_id}` : ""}
</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<span>Balance</span>
<span className="font-mono tabular-nums text-foreground/80">
{credits ? credits.balance_after.toFixed(2) : "—"}
</span>
</div>
</div>
)}
<Card>
<CardHeader>
<CardTitle className="text-sm">Steps</CardTitle>
</CardHeader>
<CardContent>
<PipelineStepper steps={steps} />
</CardContent>
</Card>
{isRunning && (
<AlertDialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) setConfirmInput("");
}}
>
<AlertDialogTrigger
render={
<Button
variant="outline"
size="sm"
className="text-rose-600 dark:text-rose-400 border-rose-500/30 hover:bg-rose-500/10"
>
<OctagonX className="h-4 w-4 mr-1.5" />
Cancel Project
</Button>
}
/>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel pipeline?</AlertDialogTitle>
<AlertDialogDescription>
This will stop the validation pipeline. The project will still count toward your
project limit.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2 py-2">
<p className="text-sm text-muted-foreground">
Type{" "}
<span className="font-semibold font-mono text-foreground">{projectName}</span> to
confirm:
</p>
<Input
value={confirmInput}
onChange={(e) => setConfirmInput(e.target.value)}
placeholder={projectName}
autoFocus
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Go back</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={confirmInput !== projectName || cancelling}
onClick={(e) => {
e.preventDefault();
handleCancel();
}}
>
{cancelling ? "Cancelling..." : "Cancel project"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{done && !error && !cancelled && (
<div className="flex items-center gap-3 p-4 rounded-lg border border-emerald-500/30 bg-emerald-500/5">
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400 shrink-0" />
<span className="text-sm flex-1">
All checks complete.{" "}
{summary &&
`${summary.total} findings: ${summary.PASS} pass, ${summary.WARNING} warnings, ${summary.ERROR} errors.`}
</span>
<Link href={`/project/${id}/report`}>
<Button size="sm">
View Report
<ArrowRight className="h-4 w-4 ml-1" />
</Button>
</Link>
<Button
size="sm"
variant="outline"
disabled={reprocessing}
onClick={() => handleReprocess("failed")}
>
<RotateCcw className="h-4 w-4 mr-1" />
{reprocessing ? "Starting..." : "Reprocess"}
</Button>
</div>
)}
{done && cancelled && (
<div className="flex items-center gap-3 p-4 rounded-lg border border-amber-500/30 bg-amber-500/5">
<Ban className="h-5 w-5 text-amber-600 dark:text-amber-400 shrink-0" />
<span className="text-sm text-amber-600 dark:text-amber-400 flex-1">
Pipeline was cancelled. This project still counts toward your project limit.
</span>
<Link href="/dashboard">
<Button variant="outline" size="sm">
Back to Dashboard
</Button>
</Link>
<Button size="sm" disabled={reprocessing} onClick={() => handleReprocess("failed")}>
<RotateCcw className="h-4 w-4 mr-1" />
{reprocessing ? "Starting..." : "Reprocess"}
</Button>
</div>
)}
{done && error && !cancelled && (
<div className="flex items-center gap-3 p-4 rounded-lg border border-rose-500/30 bg-rose-500/5">
<span className="text-sm text-rose-600 dark:text-rose-400 flex-1">{error}</span>
<Button size="sm" disabled={reprocessing} onClick={() => handleReprocess("failed")}>
<RotateCcw className="h-4 w-4 mr-1" />
{reprocessing ? "Starting..." : "Reprocess"}
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,441 @@
"use client";
import {
use,
useState,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
Suspense,
} from "react";
import { Download, RotateCcw } from "lucide-react";
import { useRouter } from "next/navigation";
import { useOptionalUser } from "@/hooks/use-optional-auth";
import { useReport } from "@/hooks/use-report";
import { useReviewedFindings } from "@/hooks/use-reviewed-findings";
import { ReportSummary } from "@/components/report/report-summary";
import { FindingsList } from "@/components/report/findings-list";
import { FindingFocusView } from "@/components/report/finding-focus-view";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Toast, useToast } from "@/components/ui/toast";
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
import {
fetchCollaborators,
fetchProject,
fetchMyFeedback,
reprocessPipeline,
signReport,
downloadEcoCsv,
} from "@/lib/api";
import { exportReportToExcel } from "@/lib/report-export";
import { cn, getFindingKey } from "@/lib/utils";
import type { Finding, FindingComment, FindingReview, Collaborator } from "@/lib/types";
interface FocusState {
key: string;
finding: Finding;
mpn: string;
page: number;
quote?: string;
}
function ReportContent({ projectId }: { projectId: string }) {
const router = useRouter();
const { report, graph, loading, error } = useReport(projectId);
const { user } = useOptionalUser();
const [focus, setFocus] = useState<FocusState | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const savedScrollRef = useRef(0);
const prevInFocusRef = useRef(false);
const [collaborators, setCollaborators] = useState<Collaborator[]>([]);
const [comments, setComments] = useState<Record<string, FindingComment[]>>({});
const [reviews, setReviews] = useState<Record<string, FindingReview>>({});
const [creditsSpent, setCreditsSpent] = useState<number | undefined>();
const [totalCostUsd, setTotalCostUsd] = useState<number | null>(null);
const [projectName, setProjectName] = useState<string>("");
const [feedbackFinding, setFeedbackFinding] = useState<Finding | null>(null);
const [feedbackOpen, setFeedbackOpen] = useState(false);
const [reportedFindingIds, setReportedFindingIds] = useState<Set<string>>(new Set());
const [reprocessing, setReprocessing] = useState(false);
async function handleReprocessFailed() {
setReprocessing(true);
try {
await reprocessPipeline(projectId, "failed");
router.push(`/project/${projectId}/progress`);
} catch (e) {
setReprocessing(false);
alert(e instanceof Error ? e.message : "Failed to reprocess");
}
}
useEffect(() => {
fetchMyFeedback()
.then((tickets) => {
const ids = new Set<string>();
for (const t of tickets) {
if (t.finding_id && t.project_id === projectId) ids.add(t.finding_id);
}
setReportedFindingIds(ids);
})
.catch(() => {});
}, [projectId]);
useEffect(() => {
fetchProject(projectId)
.then((p) => {
setCreditsSpent(p.creditsSpent);
setTotalCostUsd(p.totalCostUsd ?? null);
setProjectName(p.name);
})
.catch(() => {});
}, [projectId]);
const { reviewedCount, isReviewed, toggleReviewed } = useReviewedFindings(
projectId,
report?.findings ?? [],
);
const { toast, show: showToast } = useToast();
const findingByKey = useMemo(() => {
const map = new Map<string, Finding>();
(report?.findings ?? []).forEach((f, i) => map.set(getFindingKey(f, i), f));
return map;
}, [report?.findings]);
const keyByFinding = useMemo(() => {
const map = new Map<Finding, string>();
(report?.findings ?? []).forEach((f, i) => map.set(f, getFindingKey(f, i)));
return map;
}, [report?.findings]);
const handleToggleReviewed = useCallback(
(key: string) => {
const wasReviewed = isReviewed(key);
toggleReviewed(key);
if (!wasReviewed) {
const finding = findingByKey.get(key);
const label = finding?.finding_id ?? finding?.designator ?? "Rule";
showToast(`${label} marked as reviewed`);
}
},
[isReviewed, toggleReviewed, findingByKey, showToast],
);
useEffect(() => {
fetchCollaborators(projectId)
.then((data) => setCollaborators(data.collaborators))
.catch(() => {});
}, [projectId]);
useEffect(() => {
if (report?.comments) setComments(report.comments);
if (report?.review_states) setReviews(report.review_states);
}, [report]);
const handleCommentAdded = useCallback((comment: FindingComment) => {
setComments((prev) => ({
...prev,
[comment.finding_id]: [...(prev[comment.finding_id] ?? []), comment],
}));
}, []);
const handleReviewSaved = useCallback((findingId: string, review: FindingReview) => {
setReviews((prev) => {
const next = { ...prev };
if (review.state === "open") delete next[findingId];
else next[findingId] = review;
return next;
});
}, []);
const handleCommentDeleted = useCallback((commentId: string, findingId: string) => {
setComments((prev) => {
const list = (prev[findingId] ?? []).filter((c) => c.comment_id !== commentId);
const next = { ...prev };
if (list.length === 0) delete next[findingId];
else next[findingId] = list;
return next;
});
}, []);
const handleReportFinding = useCallback((finding: Finding) => {
setFeedbackFinding(finding);
setFeedbackOpen(true);
}, []);
const handleViewReference = useCallback(
(finding: Finding) => {
if (!graph) return;
const sourceDesignator = finding.source_designator ?? finding.designator;
const mpn =
graph.components[sourceDesignator]?.mpn ?? graph.components[finding.designator]?.mpn;
if (!mpn) {
showToast(`No datasheet on file for ${sourceDesignator}`);
return;
}
if (!focus) {
savedScrollRef.current = rootRef.current?.closest("main")?.scrollTop ?? 0;
}
setFocus({
key: keyByFinding.get(finding) ?? finding.finding_id ?? finding.designator,
finding,
mpn,
page: finding.source_page ?? 1,
quote: finding.source_quote,
});
},
[graph, focus, keyByFinding, showToast],
);
const inFocus = focus !== null;
useLayoutEffect(() => {
const wasInFocus = prevInFocusRef.current;
prevInFocusRef.current = inFocus;
if (wasInFocus === inFocus) return;
const scroller = rootRef.current?.closest("main");
if (!scroller) return;
scroller.scrollTop = inFocus ? 0 : savedScrollRef.current;
}, [inFocus]);
if (loading) {
return (
<div className="p-6 max-w-5xl mx-auto w-full space-y-4">
<div className="grid grid-cols-5 gap-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-20 rounded-lg" />
))}
</div>
<Skeleton className="h-8 rounded" />
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32 rounded-lg" />
))}
</div>
);
}
if (error || !report || !graph) {
return (
<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>
);
}
const failedReviews = report.review_errors ? Object.keys(report.review_errors).length : 0;
return (
<div
ref={rootRef}
className={cn("p-6 mx-auto w-full", focus ? "max-w-[1920px]" : "max-w-5xl")}
>
<div className={cn("space-y-6", focus && "hidden")}>
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-lg font-semibold">Validation Report</h1>
<p className="text-sm text-muted-foreground">
{report.findings.length} findings &middot;{" "}
{new Date(report.timestamp).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={reprocessing}
onClick={handleReprocessFailed}
>
<RotateCcw />
{reprocessing ? "Starting..." : "Retry failed reviews"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => exportReportToExcel(report, graph, projectName)}
disabled={report.findings.length === 0}
>
<Download /> Export Excel
</Button>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await downloadEcoCsv(projectId);
} catch (e) {
showToast(e instanceof Error ? e.message : "ECO export failed");
}
}}
>
<Download /> ECO CSV
</Button>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
const rel = await signReport(projectId);
showToast(`Signed ${rel.sha256.slice(0, 12)}`);
} catch (e) {
showToast(e instanceof Error ? e.message : "Sign failed");
}
}}
>
Sign release
</Button>
</div>
</div>
<ReportSummary
summary={report.summary}
reviewedCount={reviewedCount}
creditsSpent={creditsSpent}
totalCostUsd={totalCostUsd}
/>
{failedReviews > 0 && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-4 space-y-2">
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
{failedReviews} IC review{failedReviews === 1 ? "" : "s"} failed
</p>
<p className="text-xs text-muted-foreground">
These ICs could not be reviewed against their datasheets. Use Retry
failed reviews to run them again without repeating ICs that already succeeded.
</p>
<ul className="space-y-1 text-xs">
{Object.entries(report.review_errors!).map(([ref, err]) => (
<li key={ref} className="flex gap-2">
<span className="font-mono font-medium text-amber-700 dark:text-amber-300 shrink-0">
{ref}
</span>
<span className="text-muted-foreground break-all">{err}</span>
</li>
))}
</ul>
</div>
)}
{report.not_reviewed && report.not_reviewed.length > 0 && (
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-2">
<p className="text-sm font-medium">
{report.not_reviewed.length} component{report.not_reviewed.length === 1 ? "" : "s"} not reviewed
</p>
<p className="text-xs text-muted-foreground">
These components have no datasheet on file, so they were not checked against one. A reversed or mis-wired pin on an unreviewed part (e.g. a DNP footprint with no BOM entry) cannot be caught here verify these manually.
</p>
<ul className="space-y-1 text-xs">
{report.not_reviewed.map((nr) => (
<li key={nr.designator} className="flex gap-2">
<span className="font-mono font-medium shrink-0">{nr.designator}</span>
<span className="text-muted-foreground break-all">{nr.reason}</span>
</li>
))}
</ul>
</div>
)}
{report.summary.total === 0 ? (
<div className="rounded-lg border border-border bg-card p-8 text-center space-y-2">
<p className="text-sm font-medium">No findings</p>
<p className="text-xs text-muted-foreground">
No IC datasheets were available for review. Upload datasheets for each IC
on the project page and re-run the pipeline to get findings.
</p>
</div>
) : (
<FindingsList
findings={report.findings}
graph={graph}
onViewReference={handleViewReference}
projectId={projectId}
isReviewed={isReviewed}
toggleReviewed={handleToggleReviewed}
comments={comments}
collaborators={collaborators}
currentUserId={user?.id}
currentUserName={user?.name ?? user?.email ?? "User"}
onCommentAdded={handleCommentAdded}
onCommentDeleted={handleCommentDeleted}
onReportFinding={handleReportFinding}
reportedFindingIds={reportedFindingIds}
reviews={reviews}
onReviewSaved={handleReviewSaved}
/>
)}
</div>
{focus && (
<FindingFocusView
finding={focus.finding}
component={graph.components[focus.finding.designator]}
mpn={focus.mpn}
page={focus.page}
quote={focus.quote}
projectId={projectId}
onExit={() => setFocus(null)}
escapeDisabled={feedbackOpen}
onViewReference={handleViewReference}
checked={isReviewed(focus.key)}
onCheckedChange={() => handleToggleReviewed(focus.key)}
comments={focus.finding.finding_id ? comments[focus.finding.finding_id] : undefined}
collaborators={collaborators}
currentUserId={user?.id}
currentUserName={user?.name ?? user?.email ?? "User"}
onCommentAdded={handleCommentAdded}
onCommentDeleted={handleCommentDeleted}
onReportFinding={handleReportFinding}
isReported={
!!(focus.finding.finding_id && reportedFindingIds.has(focus.finding.finding_id))
}
review={focus.finding.finding_id ? reviews[focus.finding.finding_id] : undefined}
onReviewSaved={handleReviewSaved}
/>
)}
<Toast toast={toast} />
<FeedbackDialog
open={feedbackOpen}
onOpenChange={(open) => {
setFeedbackOpen(open);
if (!open) setFeedbackFinding(null);
}}
projectId={projectId}
projectName={projectName}
findingContext={
feedbackFinding?.finding_id
? {
finding_id: feedbackFinding.finding_id,
finding_text: feedbackFinding.finding,
designator: feedbackFinding.designator,
mpn: feedbackFinding.mpn,
status: feedbackFinding.status,
}
: undefined
}
onSubmitted={() => {
showToast("Feedback submitted");
if (feedbackFinding?.finding_id) {
setReportedFindingIds((prev) => new Set(prev).add(feedbackFinding.finding_id!));
}
}}
/>
</div>
);
}
export default function ReportPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
return (
<div className="flex-1 w-full">
<Suspense>
<ReportContent projectId={id} />
</Suspense>
</div>
);
}
@@ -0,0 +1,16 @@
import fs from "fs";
import path from "path";
import { ChangelogTimeline } from "@/components/legal/changelog-timeline";
import { pageMetadata } from "@/lib/site";
export const metadata = pageMetadata({
title: "Changelog",
description:
"Recent updates and improvements to Periscope — new EDA tool support, review accuracy improvements, and platform changes.",
path: "/changelog",
});
export default function ChangelogPage() {
const content = fs.readFileSync(path.join(process.cwd(), "content", "changelog.md"), "utf-8");
return <ChangelogTimeline content={content} />;
}
@@ -0,0 +1,43 @@
import { CONTACT_EMAIL } from "@/lib/site";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:18741";
export type ActionState = {
success: boolean;
message: string;
} | null;
export async function submitContactForm(data: {
name: string;
email: string;
company: string;
subject: string;
message: string;
_honey: string;
}): Promise<ActionState> {
try {
const resp = await fetch(`${API_BASE}/api/contact`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const result = await resp.json();
if (!resp.ok) {
if (Array.isArray(result.detail)) {
return { success: false, message: "Please check your input and try again." };
}
return {
success: false,
message:
result.message ||
`Something went wrong. Please try again or email us directly at ${CONTACT_EMAIL}.`,
};
}
return result as ActionState;
} catch {
return {
success: false,
message: `Could not reach the server. Please try again or email us directly at ${CONTACT_EMAIL}.`,
};
}
}
@@ -0,0 +1,119 @@
"use client";
import { FormEvent, useState } from "react";
import { CheckCircle2, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { submitContactForm, type ActionState } from "./actions";
export function ContactForm() {
const [state, setState] = useState<ActionState>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
const formData = new FormData(event.currentTarget);
const result = await submitContactForm({
name: String(formData.get("name") ?? ""),
email: String(formData.get("email") ?? ""),
company: String(formData.get("company") ?? ""),
subject: String(formData.get("subject") ?? ""),
message: String(formData.get("message") ?? ""),
_honey: String(formData.get("_honey") ?? ""),
});
setState(result);
setPending(false);
}
if (state?.success) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<CheckCircle2 className="h-12 w-12 text-emerald-500 mb-4" />
<h3 className="font-headline text-xl tracking-tight">Message sent</h3>
<p className="mt-2 text-sm text-muted-foreground max-w-sm">{state.message}</p>
</div>
);
}
return (
<form onSubmit={onSubmit} className="space-y-5">
<input
name="_honey"
type="text"
className="sr-only"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" name="name" required maxLength={200} placeholder="Your name" />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
required
maxLength={254}
placeholder="you@company.com"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="company">
Company <span className="text-muted-foreground font-normal">(optional)</span>
</Label>
<Input id="company" name="company" maxLength={200} placeholder="Company name" />
</div>
<div className="space-y-2">
<Label htmlFor="subject">
Subject <span className="text-muted-foreground font-normal">(optional)</span>
</Label>
<Input
id="subject"
name="subject"
maxLength={200}
placeholder="What's this about?"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="message">Message</Label>
<Textarea
id="message"
name="message"
required
maxLength={5000}
rows={6}
placeholder="How can we help?"
/>
</div>
{state?.success === false && (
<p className="text-sm text-destructive" role="alert">
{state.message}
</p>
)}
<Button
type="submit"
disabled={pending}
className="bg-blue-600 hover:bg-blue-500 text-white border-0 px-8 text-base h-12 w-full sm:w-auto"
>
{pending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Sending...
</>
) : (
"Send message"
)}
</Button>
</form>
);
}
@@ -0,0 +1,34 @@
"use client";
import Link from "next/link";
import { PeriscopeMark } from "@/components/brand/periscope-mark";
import { ThemeToggle } from "@/components/theme/theme-toggle";
import { NavAuthCluster } from "../landing-cta";
export function Nav() {
return (
<header className="sticky top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-lg">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-6">
<Link href="/" className="flex items-center gap-2">
<PeriscopeMark className="h-5 w-5" />
<span className="text-sm font-semibold tracking-tight">Periscope</span>
</Link>
<nav className="hidden sm:flex items-center gap-6 text-sm text-muted-foreground">
<Link href="/#features" className="hover:text-foreground transition-colors">
Features
</Link>
<Link href="/#security" className="hover:text-foreground transition-colors">
Security
</Link>
<Link href="/contact" className="text-foreground">
Contact
</Link>
</nav>
<div className="flex items-center gap-3">
<ThemeToggle />
<NavAuthCluster />
</div>
</div>
</header>
);
}
@@ -0,0 +1,63 @@
import Link from "next/link";
import { ContactForm } from "./contact-form";
import { Nav } from "./nav";
import { PeriscopeMark } from "@/components/brand/periscope-mark";
import { CONTACT_EMAIL, OPERATOR_NAME, pageMetadata } from "@/lib/site";
export const metadata = pageMetadata({
title: "Contact",
description:
"Talk to the Periscope team — questions, account help, or enterprise deployment.",
path: "/contact",
});
export default function ContactPage() {
return (
<div className="flex flex-col min-h-full">
<Nav />
<section className="mx-auto max-w-3xl px-6 pt-24 sm:pt-36 pb-12">
<h1 className="font-headline text-3xl sm:text-4xl tracking-tight animate-fade-up">
Get in touch
</h1>
<p className="mt-4 text-muted-foreground max-w-lg leading-relaxed animate-fade-up [animation-delay:100ms]">
Have a question about Periscope, need help with your account, or want to discuss
enterprise deployment? We&rsquo;d love to hear from you.
</p>
</section>
<section className="mx-auto max-w-3xl px-6 pb-24 w-full animate-fade-up [animation-delay:200ms]">
<div className="rounded-xl border border-border bg-card/40 p-6 sm:p-8">
<ContactForm />
</div>
</section>
<section className="border-t border-border/50 mt-auto">
<div className="mx-auto max-w-3xl px-6 py-12 text-center">
<p className="text-sm text-muted-foreground">
Prefer email? Reach us directly at{" "}
<a href={`mailto:${CONTACT_EMAIL}`} className="text-foreground hover:underline">
{CONTACT_EMAIL}
</a>
</p>
</div>
</section>
<footer className="border-t border-border/50">
<div className="mx-auto max-w-6xl px-6 py-8 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<PeriscopeMark size={16} className="h-4 w-4" />
<span>Periscope</span>
</div>
<div className="flex items-center gap-4">
<Link href="/privacy" className="hover:text-foreground transition-colors">
Privacy
</Link>
<Link href="/terms" className="hover:text-foreground transition-colors">
Terms
</Link>
<span>
&copy; {new Date().getFullYear()} {OPERATOR_NAME}
</span>
</div>
</div>
</footer>
</div>
);
}
@@ -0,0 +1,16 @@
import fs from "fs";
import path from "path";
import { FileGuidePage } from "@/components/legal/file-guide-page";
import { pageMetadata } from "@/lib/site";
export const metadata = pageMetadata({
title: "File Upload Guide",
description:
"Step-by-step export instructions for KiCad, Altium, OrCAD, Cadence Allegro, Siemens Xpedition, EasyEDA, and Autodesk EAGLE — netlists, BOMs, and datasheets ready for Periscope.",
path: "/file-guide",
});
export default function FileGuideRoute() {
const content = fs.readFileSync(path.join(process.cwd(), "content", "file-guide.md"), "utf-8");
return <FileGuidePage content={content} />;
}
@@ -0,0 +1,51 @@
"use client";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { useOptionalAuth } from "@/hooks/use-optional-auth";
import { Button } from "@/components/ui/button";
export function NavAuthCluster() {
const { isSignedIn } = useOptionalAuth();
if (isSignedIn) {
return (
<Link href="/dashboard">
<Button size="sm" className="bg-blue-600 hover:bg-blue-500 text-white border-0">
Dashboard
<ArrowRight className="h-3.5 w-3.5" />
</Button>
</Link>
);
}
return (
<>
<Link href="/sign-in">
<Button variant="ghost" size="sm">
Sign in
</Button>
</Link>
<Link href="/sign-up">
<Button size="sm" className="bg-blue-600 hover:bg-blue-500 text-white border-0">
Get started
</Button>
</Link>
</>
);
}
export function PrimaryCta() {
const { isSignedIn } = useOptionalAuth();
const href = isSignedIn ? "/dashboard" : "/sign-up";
const label = isSignedIn ? "Go to Dashboard" : "Start for free";
return (
<Link href={href}>
<Button
size="lg"
className="bg-blue-600 hover:bg-blue-500 text-white border-0 px-8 text-base h-12"
>
{label}
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
);
}
@@ -0,0 +1,7 @@
export default function MarketingLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return <div className="min-h-full flex flex-col">{children}</div>;
}
@@ -0,0 +1,387 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { GitBranch, Lock, ServerCog, Shield, Users } from "lucide-react";
import { PeriscopeMark } from "@/components/brand/periscope-mark";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/theme/theme-toggle";
import { APP_VERSION_DATE } from "@/lib/version";
import {
CONTACT_EMAIL,
OPERATOR_NAME,
SITE_DESCRIPTION,
SITE_NAME,
SITE_URL,
} from "@/lib/site";
import {
PRICING_JSON_LD_OFFERS,
PRICING_NAV_LINK,
PricingSection,
} from "@/components/marketing/pricing-section";
import { NavAuthCluster, PrimaryCta } from "./landing-cta";
export const metadata: Metadata = {};
const latestChangeLabel = APP_VERSION_DATE
? new Date(`${APP_VERSION_DATE}T00:00:00Z`).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
timeZone: "UTC",
})
: null;
const PIPELINE_STEPS = [
{ label: "Upload", detail: "Your design files" },
{ label: "Read", detail: "Every datasheet" },
{ label: "Verify", detail: "Pin by pin" },
{ label: "Report", detail: "Findings and fixes" },
];
const FEATURE_BLOCKS = [
{
title: "Every finding, traceable",
description:
"Each recommendation links straight to the datasheet page and figure that backs it up. No black box — verify the reasoning, not just the verdict.",
placeholder: "Datasheet grounded recommendations",
image: "/datasheet.gif",
},
{
title: "Auto-documentation",
description:
"For mission-critical projects. The artifacts your reviewers expect — generated on every run, ready to share.",
placeholder: "Derating documentation",
image: "/derating.png",
},
];
const EDA_TOOLS = [
{ name: "KiCad", logo: "/eda-logos/kicad.svg" },
{ name: "Altium Designer", logo: "/eda-logos/altium.svg" },
{ name: "OrCAD", logo: "/eda-logos/orcad.svg" },
{ name: "Cadence Allegro", logo: "/eda-logos/cadence.svg" },
{ name: "Siemens Xpedition", logo: "/eda-logos/siemens.svg" },
{ name: "EasyEDA", logo: "/eda-logos/easyeda.svg" },
{ name: "Autodesk EAGLE", logo: "/eda-logos/autodesk.svg" },
];
const SECURITY_ITEMS = [
{
icon: Lock,
title: "Encrypted end-to-end",
detail: "AES-256 at rest, TLS 1.3 in transit.",
},
{
icon: Shield,
title: "Never used for training",
detail: "Your files never train anyone's model. Zero retention from our AI providers.",
},
{
icon: ServerCog,
title: "SOC 2 infrastructure",
detail: "Audit logging, continuous monitoring, and incident response.",
},
{
icon: Users,
title: "Role-based access",
detail: "Owner and collaborator roles. You decide who sees what.",
},
];
const jsonLd = [
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: SITE_NAME,
applicationCategory: "DeveloperApplication",
operatingSystem: "Web",
description: SITE_DESCRIPTION,
url: SITE_URL,
image: `${SITE_URL}/opengraph-image`,
screenshot: `${SITE_URL}/report.png`,
...(PRICING_JSON_LD_OFFERS ? { offers: PRICING_JSON_LD_OFFERS } : {}),
publisher: {
"@type": "Person",
name: OPERATOR_NAME,
url: SITE_URL,
},
},
{
"@context": "https://schema.org",
"@type": "Organization",
name: SITE_NAME,
url: SITE_URL,
contactPoint: {
"@type": "ContactPoint",
contactType: "customer support",
email: CONTACT_EMAIL,
},
},
];
export default function LandingPage() {
return (
<div className="flex flex-col min-h-full">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<header className="sticky top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-lg">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-6">
<Link href="/" className="flex items-center gap-2">
<PeriscopeMark className="h-5 w-5" size={20} />
<span className="text-sm font-semibold tracking-tight">Periscope</span>
</Link>
<nav className="hidden sm:flex items-center gap-6 text-sm text-muted-foreground">
<a href="#features" className="hover:text-foreground transition-colors">
Features
</a>
<a href="#security" className="hover:text-foreground transition-colors">
Security
</a>
{PRICING_NAV_LINK && (
<a
href={PRICING_NAV_LINK.href}
className="hover:text-foreground transition-colors"
>
{PRICING_NAV_LINK.label}
</a>
)}
<Link href="/file-guide" className="hover:text-foreground transition-colors">
Docs
</Link>
<Link href="/contact" className="hover:text-foreground transition-colors">
Contact
</Link>
</nav>
<div className="flex items-center gap-3">
<ThemeToggle />
<NavAuthCluster />
</div>
</div>
</header>
<section className="mx-auto max-w-5xl px-6 pt-24 sm:pt-36 pb-12">
<h1 className="font-headline text-[2.75rem] leading-[1.08] sm:text-6xl lg:text-7xl tracking-tight animate-fade-up">
Ship hardware that
<br className="hidden lg:block" /> works the
<br className="hidden lg:block" /> first time
</h1>
<p className="mt-6 text-lg sm:text-xl text-muted-foreground max-w-xl leading-relaxed animate-fade-up [animation-delay:100ms]">
Periscope reviews your schematic against every datasheet and catches the errors
that would otherwise surface at bring-up.
</p>
<div className="mt-8 flex flex-col items-start gap-4 animate-fade-up [animation-delay:200ms]">
<PrimaryCta />
{latestChangeLabel && (
<Link
href="/changelog"
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<GitBranch className="h-4 w-4" />
Read Latest Changes: {latestChangeLabel}
</Link>
)}
</div>
</section>
<section className="mx-auto max-w-5xl px-6 pb-24 animate-fade-up [animation-delay:350ms]">
<div className="rounded-xl border border-border overflow-hidden bg-card/40">
<Image
src="/report.png"
alt="Periscope validation report"
width={2400}
height={1500}
className="w-full h-auto"
priority
unoptimized
/>
</div>
</section>
<section className="border-t border-border/50">
<div className="mx-auto max-w-6xl px-6 py-14">
<p className="text-center text-xs font-mono uppercase tracking-[0.18em] text-muted-foreground">
Works with your EDA tool
</p>
<div className="mt-8 grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-x-8 gap-y-10 items-center">
{EDA_TOOLS.map((tool) => (
<div
key={tool.name}
className="flex flex-col items-center justify-center gap-2"
title={tool.name}
>
<Image
src={tool.logo}
alt={`${tool.name} logo`}
width={140}
height={40}
className="h-7 sm:h-8 w-auto object-contain [filter:brightness(0)] dark:[filter:brightness(0)_invert(1)] opacity-50 hover:opacity-90 transition-opacity"
unoptimized
/>
<span className="text-[11px] text-muted-foreground/80">{tool.name}</span>
</div>
))}
</div>
<p className="mt-10 text-center text-xs text-muted-foreground">
<Link href="/file-guide" className="hover:text-foreground transition-colors">
See export instructions for each tool
</Link>
</p>
</div>
</section>
<section className="border-y border-border/50">
<div className="mx-auto max-w-3xl px-6 py-16">
<div className="hidden sm:flex items-start justify-between relative">
<div className="absolute top-3 left-[12.5%] right-[12.5%] h-px bg-border" />
{PIPELINE_STEPS.map((step, index) => (
<div key={step.label} className="relative flex flex-col items-center text-center flex-1">
<div className="h-6 w-6 rounded-full bg-background border border-border flex items-center justify-center text-[11px] font-mono text-muted-foreground z-10">
{index + 1}
</div>
<span className="mt-3 text-sm font-medium">{step.label}</span>
<span className="mt-1 text-xs text-muted-foreground">{step.detail}</span>
</div>
))}
</div>
<div className="sm:hidden flex flex-col gap-6 relative pl-8">
<div className="absolute left-[11px] top-3 bottom-3 w-px bg-border" />
{PIPELINE_STEPS.map((step, index) => (
<div key={step.label} className="relative flex items-start gap-4">
<div className="absolute -left-8 h-6 w-6 rounded-full bg-background border border-border flex items-center justify-center text-[11px] font-mono text-muted-foreground z-10">
{index + 1}
</div>
<div>
<span className="text-sm font-medium">{step.label}</span>
<span className="block text-xs text-muted-foreground mt-0.5">{step.detail}</span>
</div>
</div>
))}
</div>
</div>
</section>
<section id="features" className="scroll-mt-16">
{FEATURE_BLOCKS.map((feature, index) => (
<div
key={feature.title}
className={index < FEATURE_BLOCKS.length - 1 ? "border-b border-border/30" : ""}
>
<div className="mx-auto max-w-6xl px-6 py-20 sm:py-24 grid grid-cols-1 lg:grid-cols-2 gap-10 lg:gap-16 items-center">
<div className={index % 2 === 1 ? "lg:order-2" : ""}>
<h2 className="font-headline text-3xl sm:text-4xl tracking-tight leading-tight">
{feature.title}
</h2>
<p className="mt-4 text-muted-foreground leading-relaxed">{feature.description}</p>
</div>
<div
className={`rounded-xl border border-border bg-card/40 aspect-[4/3] flex items-center justify-center overflow-hidden ${
index % 2 === 1 ? "lg:order-1" : ""
}`}
>
{feature.image ? (
<Image
src={feature.image}
alt={feature.placeholder}
width={800}
height={600}
className="w-full h-full object-cover"
unoptimized
/>
) : (
<p className="text-sm text-muted-foreground/60">{feature.placeholder}</p>
)}
</div>
</div>
</div>
))}
</section>
<section id="security" className="border-y border-border/50 scroll-mt-16">
<div className="mx-auto max-w-5xl px-6 py-20 sm:py-24">
<h2 className="font-headline text-3xl sm:text-4xl tracking-tight text-center">
Your designs stay yours
</h2>
<p className="mt-3 text-muted-foreground text-center max-w-lg mx-auto">
Hardware IP is sensitive. Periscope is built to keep it that way.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mt-12">
{SECURITY_ITEMS.map((item) => (
<div
key={item.title}
className="rounded-xl border border-border bg-card/40 p-5 flex flex-col gap-3"
>
<item.icon className="h-5 w-5 text-emerald-500" />
<h3 className="text-sm font-semibold">{item.title}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">{item.detail}</p>
</div>
))}
</div>
</div>
</section>
<section className="border-t border-border/50">
<div className="mx-auto max-w-3xl px-6 py-20 sm:py-24 text-center">
<h2 className="font-headline text-3xl sm:text-4xl tracking-tight">
Stop reviewing schematics by hand
</h2>
<p className="mt-4 text-muted-foreground max-w-md mx-auto">
Upload your first design. Get a full review in minutes.
</p>
<div className="mt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
<PrimaryCta />
<Link href="/contact">
<Button size="lg" variant="outline" className="px-8 text-base h-12">
Contact us
</Button>
</Link>
</div>
<p className="mt-4 text-xs text-muted-foreground">
Questions? Reach us at{" "}
<a href={`mailto:${CONTACT_EMAIL}`} className="text-foreground hover:underline">
{CONTACT_EMAIL}
</a>
</p>
</div>
</section>
<PricingSection />
<footer className="border-t border-border/50 mt-auto">
<div className="mx-auto max-w-6xl px-6 py-10">
<div className="flex flex-col gap-8 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 text-sm text-foreground">
<PeriscopeMark size={16} className="h-4 w-4" />
<span>{SITE_NAME}</span>
</div>
<p className="text-xs leading-relaxed text-muted-foreground max-w-sm">
Operated by {OPERATOR_NAME}. Software derived from Faradworks/Pinscope (AGPL-3.0).
</p>
</div>
<div className="flex flex-col items-start gap-4 sm:items-end">
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<Link href="/contact" className="hover:text-foreground transition-colors">
Contact
</Link>
<Link href="/changelog" className="hover:text-foreground transition-colors">
Changelog
</Link>
<Link href="/privacy" className="hover:text-foreground transition-colors">
Privacy
</Link>
<Link href="/terms" className="hover:text-foreground transition-colors">
Terms
</Link>
</div>
<span className="text-xs text-muted-foreground">
&copy; {new Date().getFullYear()} {OPERATOR_NAME}
</span>
</div>
</div>
</div>
</footer>
</div>
);
}
@@ -0,0 +1,16 @@
import fs from "fs";
import path from "path";
import { LegalPage } from "@/components/legal/legal-page";
import { pageMetadata } from "@/lib/site";
export const metadata = pageMetadata({
title: "Privacy Policy",
description:
"How Periscope collects, stores, and protects the schematics, datasheets, and BOMs you upload.",
path: "/privacy",
});
export default function PrivacyPage() {
const content = fs.readFileSync(path.join(process.cwd(), "content", "privacy.md"), "utf-8");
return <LegalPage content={content} />;
}
@@ -0,0 +1,16 @@
import fs from "fs";
import path from "path";
import { LegalPage } from "@/components/legal/legal-page";
import { pageMetadata } from "@/lib/site";
export const metadata = pageMetadata({
title: "Terms of Service",
description:
"Terms governing your use of Periscope, including account, payment, and acceptable-use rules.",
path: "/terms",
});
export default function TermsPage() {
const content = fs.readFileSync(path.join(process.cwd(), "content", "terms.md"), "utf-8");
return <LegalPage content={content} />;
}
+122
View File
@@ -0,0 +1,122 @@
import type { Metadata, Viewport } from "next";
import { DM_Serif_Display, Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme/theme-provider";
import { ClerkThemeProvider } from "@/components/theme/clerk-theme-provider";
import { RedditPixel } from "@/components/analytics/reddit-pixel";
import {
OPERATOR_NAME,
SITE_DESCRIPTION,
SITE_NAME,
SITE_TAGLINE,
SITE_URL,
TWITTER_HANDLE,
} from "@/lib/site";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const dmSerifDisplay = DM_Serif_Display({
variable: "--font-dm-serif-display",
weight: "400",
subsets: ["latin"],
});
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
default: `${SITE_NAME}${SITE_TAGLINE}`,
template: `%s · ${SITE_NAME}`,
},
description: SITE_DESCRIPTION,
applicationName: SITE_NAME,
authors: [{ name: OPERATOR_NAME, url: SITE_URL }],
creator: OPERATOR_NAME,
publisher: SITE_NAME,
category: "technology",
keywords: [
"schematic review",
"schematic validation",
"PCB design review",
"datasheet review",
"hardware design verification",
"EDA",
"KiCad",
"Altium",
"OrCAD",
"Cadence",
"Siemens Xpedition",
"EasyEDA",
"EAGLE",
],
alternates: { canonical: "/" },
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
"max-video-preview": -1,
},
},
openGraph: {
type: "website",
siteName: SITE_NAME,
url: "/",
title: `${SITE_NAME}${SITE_TAGLINE}`,
description: SITE_DESCRIPTION,
locale: "en_US",
},
twitter: {
card: "summary_large_image",
title: `${SITE_NAME}${SITE_TAGLINE}`,
description: SITE_DESCRIPTION,
...(TWITTER_HANDLE ? { site: TWITTER_HANDLE, creator: TWITTER_HANDLE } : {}),
},
icons: {
icon: [
{ url: "/favicon_io/favicon.ico", sizes: "any" },
{ url: "/favicon_io/favicon-16x16.png", sizes: "16x16", type: "image/png" },
{ url: "/favicon_io/favicon-32x32.png", sizes: "32x32", type: "image/png" },
],
apple: "/favicon_io/apple-touch-icon.png",
},
manifest: "/favicon_io/site.webmanifest",
};
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
],
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} ${dmSerifDisplay.variable} h-full antialiased`}
>
<body className="h-full">
<ThemeProvider>
<ClerkThemeProvider>{children}</ClerkThemeProvider>
</ThemeProvider>
<RedditPixel />
</body>
</html>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/lib/site";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: [
"/admin",
"/admin/",
"/dashboard",
"/dashboard/",
"/project",
"/project/",
"/credits",
"/credits/",
"/billing",
"/billing/",
"/api/",
],
},
sitemap: `${SITE_URL}/sitemap.xml`,
host: SITE_URL,
};
}
+48
View File
@@ -0,0 +1,48 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/lib/site";
import { APP_VERSION_DATE } from "@/lib/version";
export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = APP_VERSION_DATE
? new Date(`${APP_VERSION_DATE}T00:00:00Z`)
: new Date();
return [
{
url: `${SITE_URL}/`,
lastModified,
changeFrequency: "weekly",
priority: 1,
},
{
url: `${SITE_URL}/changelog`,
lastModified,
changeFrequency: "weekly",
priority: 0.7,
},
{
url: `${SITE_URL}/file-guide`,
lastModified,
changeFrequency: "monthly",
priority: 0.7,
},
{
url: `${SITE_URL}/contact`,
lastModified,
changeFrequency: "yearly",
priority: 0.5,
},
{
url: `${SITE_URL}/privacy`,
lastModified,
changeFrequency: "yearly",
priority: 0.3,
},
{
url: `${SITE_URL}/terms`,
lastModified,
changeFrequency: "yearly",
priority: 0.3,
},
];
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,121 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { fetchSurveyStatus, submitSurvey } from "@/lib/api";
const HEARD_ABOUT = [
{ value: "google", label: "Google search" },
{ value: "linkedin", label: "LinkedIn" },
{ value: "twitter", label: "Twitter / X" },
{ value: "word_of_mouth", label: "Word of mouth" },
{ value: "conference", label: "Conference / event" },
{ value: "other", label: "Other" },
];
const ROLE = [
{ value: "hobbyist", label: "Hobbyist / maker" },
{ value: "professional", label: "Professional engineer" },
{ value: "student", label: "Student" },
{ value: "manager", label: "Engineering manager" },
];
export function OnboardingSurvey() {
const [open, setOpen] = useState(false);
const [heard, setHeard] = useState<string | null>(null);
const [role, setRole] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
fetchSurveyStatus().then((status) => {
if (!status.completed) setOpen(true);
});
}, []);
async function save() {
if (!heard || !role) return;
setSaving(true);
await submitSurvey({
referral_source: heard,
user_profile: role,
});
setSaving(false);
setOpen(false);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent showCloseButton={false} className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Welcome to Periscope</DialogTitle>
<DialogDescription>
Two quick questions to help us improve the product.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-1.5">
<Label htmlFor="referral">How did you hear about us?</Label>
<Select value={heard} onValueChange={(val) => setHeard(val)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select one..." />
</SelectTrigger>
<SelectContent>
{HEARD_ABOUT.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="profile">What best describes you?</Label>
<Select value={role} onValueChange={(val) => setRole(val)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select one..." />
</SelectTrigger>
<SelectContent>
{ROLE.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
Skip
</Button>
<Button
size="sm"
disabled={!heard || !role || saving}
onClick={save}
>
{saving ? "Saving..." : "Continue"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,205 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { RotateCcw, Trash2, Users } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useOptionalUser } from "@/hooks/use-optional-auth";
import { useReviewedCount } from "@/hooks/use-reviewed-count";
import { deleteProject } from "@/lib/api";
import type { Project } from "@/lib/types";
import { cn } from "@/lib/utils";
const STATUS_TONE: Record<string, string> = {
draft: "bg-muted text-muted-foreground",
running:
"bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/30",
complete:
"bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30",
error: "bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30",
cancelled:
"bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30",
};
function pipelineLive(status: Project["status"]) {
return (
status === "running" ||
status === "paused_insufficient_credits" ||
status === "paused_by_user"
);
}
export function ProjectCard({
project,
onDeleted,
onRerun,
}: {
project: Project;
onDeleted?: () => void;
onRerun?: (project: Project) => void;
}) {
const { user } = useOptionalUser();
const [busyDelete, setBusyDelete] = useState(false);
const reviewed = useReviewedCount(project.id);
const { summary } = project;
const findingTotal = summary?.total ?? 0;
const foreign =
project.userId != null && user?.id != null && project.userId !== user.id;
const cancelled = project.status === "cancelled";
const draft = project.status === "draft";
const replaceOk =
!foreign &&
onRerun != null &&
(project.status === "complete" ||
project.status === "error" ||
project.status === "cancelled" ||
project.status === "draft");
const clickOpensWizard = draft && !foreign && onRerun != null;
async function remove(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (!confirm(`Delete "${project.name}"?`)) return;
setBusyDelete(true);
try {
await deleteProject(project.id);
onDeleted?.();
} catch {
setBusyDelete(false);
}
}
function replace(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
onRerun?.(project);
}
const body = (
<Card
className={cn(
"h-full transition-colors",
cancelled
? "opacity-70"
: "hover:border-foreground/20 cursor-pointer",
)}
>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold">
{project.name}
</CardTitle>
<div className="flex items-center gap-1.5">
{foreign && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 text-blue-600 dark:text-blue-400 border-blue-500/40"
>
<Users className="h-3 w-3 mr-0.5" />
Shared
</Badge>
)}
<Badge
variant="outline"
className={cn("text-xs capitalize", STATUS_TONE[project.status])}
>
{project.status}
</Badge>
{replaceOk && (
<button
onClick={replace}
title="Replace files and re-analyze"
className="p-1 rounded-md text-muted-foreground hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-500/10 transition-colors"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
)}
{!foreign && (
<button
onClick={remove}
disabled={busyDelete}
className="p-1 rounded-md text-muted-foreground hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-500/10 transition-colors disabled:opacity-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
<p className="text-xs text-muted-foreground">
{new Date(project.created).toLocaleDateString()}
</p>
</CardHeader>
<CardContent>
{summary && findingTotal > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-3 text-xs font-mono tabular-nums">
<span className="text-rose-600 dark:text-rose-400">
{summary.ERROR ?? 0} err
</span>
<span className="text-amber-600 dark:text-amber-400">
{summary.WARNING ?? 0} warn
</span>
<span className="text-blue-600 dark:text-blue-400">
{summary.INFO ?? 0} info
</span>
{reviewed > 0 && (
<span className="text-emerald-600 dark:text-emerald-400">
{reviewed} checked
</span>
)}
</div>
<div className="flex h-1.5 rounded-full overflow-hidden bg-muted">
{(summary.ERROR ?? 0) > 0 && (
<div
className="h-full bg-rose-500"
style={{
width: `${((summary.ERROR ?? 0) / findingTotal) * 100}%`,
}}
/>
)}
{(summary.WARNING ?? 0) > 0 && (
<div
className="h-full bg-amber-500"
style={{
width: `${((summary.WARNING ?? 0) / findingTotal) * 100}%`,
}}
/>
)}
{(summary.INFO ?? 0) > 0 && (
<div
className="h-full bg-blue-500"
style={{
width: `${((summary.INFO ?? 0) / findingTotal) * 100}%`,
}}
/>
)}
</div>
</div>
)}
{(!summary || findingTotal === 0) && (
<p className="text-xs text-muted-foreground">No report yet</p>
)}
</CardContent>
</Card>
);
if (cancelled) return body;
if (clickOpensWizard) {
return (
<button
type="button"
onClick={() => onRerun?.(project)}
className="text-left w-full"
>
{body}
</button>
);
}
const href = pipelineLive(project.status)
? `/project/${project.id}/progress`
: `/project/${project.id}`;
return <Link href={href}>{body}</Link>;
}
@@ -0,0 +1,214 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { RotateCcw, Trash2, Users } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useOptionalUser } from "@/hooks/use-optional-auth";
import { useReviewedCount } from "@/hooks/use-reviewed-count";
import { deleteProject } from "@/lib/api";
import type { Project } from "@/lib/types";
import { cn } from "@/lib/utils";
const STATUS_TONE: Record<string, string> = {
draft: "bg-muted text-muted-foreground",
running:
"bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/30",
complete:
"bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30",
error: "bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30",
cancelled:
"bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30",
};
function pipelineLive(status: Project["status"]) {
return (
status === "running" ||
status === "paused_insufficient_credits" ||
status === "paused_by_user"
);
}
export function ProjectsTable({
projects,
onDeleted,
onRerun,
}: {
projects: Project[];
onDeleted?: (id: string) => void;
onRerun?: (project: Project) => void;
}) {
return (
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs text-muted-foreground">
<tr>
<th className="text-left font-medium px-3 py-2">Name</th>
<th className="text-left font-medium px-3 py-2 w-28">Status</th>
<th className="text-left font-medium px-3 py-2 w-56">Findings</th>
<th className="text-left font-medium px-3 py-2 w-32">Created</th>
<th className="w-16 px-3 py-2"></th>
</tr>
</thead>
<tbody>
{projects.map((p) => (
<TableRow
key={p.id}
project={p}
onDeleted={() => onDeleted?.(p.id)}
onRerun={onRerun}
/>
))}
</tbody>
</table>
</div>
);
}
function TableRow({
project,
onDeleted,
onRerun,
}: {
project: Project;
onDeleted?: () => void;
onRerun?: (project: Project) => void;
}) {
const { user } = useOptionalUser();
const [busyDelete, setBusyDelete] = useState(false);
const reviewed = useReviewedCount(project.id);
const { summary } = project;
const findingTotal = summary?.total ?? 0;
const foreign =
project.userId != null && user?.id != null && project.userId !== user.id;
const cancelled = project.status === "cancelled";
const draft = project.status === "draft";
const replaceOk =
!foreign &&
onRerun != null &&
(project.status === "complete" ||
project.status === "error" ||
project.status === "cancelled" ||
project.status === "draft");
const clickOpensWizard = draft && !foreign && onRerun != null;
async function remove(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (!confirm(`Delete "${project.name}"?`)) return;
setBusyDelete(true);
try {
await deleteProject(project.id);
onDeleted?.();
} catch {
setBusyDelete(false);
}
}
function replace(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
onRerun?.(project);
}
const href = pipelineLive(project.status)
? `/project/${project.id}/progress`
: `/project/${project.id}`;
const title = (
<div className="flex items-center gap-1.5 min-w-0">
<span className="truncate font-medium">{project.name}</span>
{foreign && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 text-blue-600 dark:text-blue-400 border-blue-500/40 shrink-0"
>
<Users className="h-3 w-3 mr-0.5" />
Shared
</Badge>
)}
</div>
);
const nameCell = cancelled ? (
title
) : clickOpensWizard ? (
<button
type="button"
onClick={() => onRerun?.(project)}
className="text-left w-full hover:underline"
>
{title}
</button>
) : (
<Link href={href} className="block hover:underline">
{title}
</Link>
);
return (
<tr
className={cn(
"border-t border-border transition-colors",
cancelled ? "opacity-70" : "hover:bg-muted/30",
)}
>
<td className="px-3 py-2 max-w-0">{nameCell}</td>
<td className="px-3 py-2">
<Badge
variant="outline"
className={cn("text-xs capitalize", STATUS_TONE[project.status])}
>
{project.status}
</Badge>
</td>
<td className="px-3 py-2">
{summary && findingTotal > 0 ? (
<div className="flex items-center gap-3 text-xs font-mono tabular-nums">
<span className="text-rose-600 dark:text-rose-400">
{summary.ERROR ?? 0} err
</span>
<span className="text-amber-600 dark:text-amber-400">
{summary.WARNING ?? 0} warn
</span>
<span className="text-blue-600 dark:text-blue-400">
{summary.INFO ?? 0} info
</span>
{reviewed > 0 && (
<span className="text-emerald-600 dark:text-emerald-400">
{reviewed}
</span>
)}
</div>
) : (
<span className="text-xs text-muted-foreground">No report yet</span>
)}
</td>
<td className="px-3 py-2 text-xs text-muted-foreground tabular-nums">
{new Date(project.created).toLocaleDateString()}
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-end gap-0.5">
{replaceOk && (
<button
onClick={replace}
title="Replace files and re-analyze"
className="p-1 rounded-md text-muted-foreground hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-500/10 transition-colors"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
)}
{!foreign && (
<button
onClick={remove}
disabled={busyDelete}
className="p-1 rounded-md text-muted-foreground hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-500/10 transition-colors disabled:opacity-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</td>
</tr>
);
}
@@ -0,0 +1,164 @@
"use client";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { useOptionalUser } from "@/hooks/use-optional-auth";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { submitFeedback } from "@/lib/api";
import type { FindingStatus } from "@/lib/types";
const FINDING_STATUS_STYLES: Record<string, string> = {
ERROR: "bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30",
WARNING: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30",
INFO: "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/30",
};
export interface FindingContext {
finding_id: string;
finding_text: string;
designator: string;
mpn: string;
status: FindingStatus;
}
interface FeedbackDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectId?: string;
projectName?: string;
findingContext?: FindingContext;
onSubmitted?: () => void;
}
export function FeedbackDialog({
open,
onOpenChange,
projectId,
projectName,
findingContext,
onSubmitted,
}: FeedbackDialogProps) {
const { user } = useOptionalUser();
const [message, setMessage] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
function reset() {
setMessage("");
setError(null);
setSuccess(false);
setSubmitting(false);
}
function closeOrOpen(next: boolean) {
if (!next) reset();
onOpenChange(next);
}
async function handleSubmit() {
if (!message.trim()) return;
setSubmitting(true);
setError(null);
try {
await submitFeedback({
type: findingContext ? "rule_feedback" : "bug",
message: message.trim(),
project_id: projectId,
project_name: projectName,
user_name: user?.name ?? undefined,
user_email: user?.email ?? undefined,
...(findingContext
? {
finding_id: findingContext.finding_id,
finding_text: findingContext.finding_text,
finding_designator: findingContext.designator,
finding_mpn: findingContext.mpn,
finding_status: findingContext.status,
}
: {}),
});
setSuccess(true);
onSubmitted?.();
setTimeout(() => closeOrOpen(false), 1200);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to submit feedback");
} finally {
setSubmitting(false);
}
}
return (
<Dialog open={open} onOpenChange={closeOrOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Send Feedback</DialogTitle>
<DialogDescription>
{findingContext
? "Let us know what's wrong with this finding."
: "Report a bug, suggest a feature, or give us feedback."}
</DialogDescription>
</DialogHeader>
{success ? (
<div className="py-6 text-center text-sm text-emerald-600 dark:text-emerald-400">
Feedback submitted. Thank you!
</div>
) : (
<div className="space-y-4">
{findingContext && (
<div className="rounded-lg border border-border bg-muted/30 p-3 space-y-1.5">
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={`text-xs font-medium ${FINDING_STATUS_STYLES[findingContext.status] ?? ""}`}
>
{findingContext.status}
</Badge>
<span className="font-mono text-xs text-muted-foreground">
{findingContext.finding_id}
</span>
</div>
<p className="text-sm leading-snug">{findingContext.finding_text}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{findingContext.designator}</span>
<span className="font-mono">{findingContext.mpn}</span>
</div>
</div>
)}
<Textarea
placeholder={
findingContext
? "What's wrong with this finding? Is it incorrect, misleading, or missing context?"
: "Tell us what's on your mind..."
}
value={message}
onChange={(e) => setMessage(e.target.value)}
className="min-h-[100px]"
/>
{error && <p className="text-sm text-rose-600 dark:text-rose-400">{error}</p>}
</div>
)}
{!success && (
<DialogFooter>
<Button onClick={handleSubmit} disabled={submitting || !message.trim()}>
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Submit
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,294 @@
"use client";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState, type ReactNode } from "react";
import {
ArrowLeft,
Boxes,
CircuitBoard,
ClipboardList,
LayoutDashboard,
Library,
Loader2,
MessageSquareWarning,
Ruler,
ScrollText,
Settings,
Shield,
TableProperties,
Zap,
} from "lucide-react";
import { PeriscopeMark } from "@/components/brand/periscope-mark";
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
import { SidebarCredits, SidebarUserButton } from "@/components/layout/sidebar-auth";
import { ThemeToggle } from "@/components/theme/theme-toggle";
import { useAuthApi } from "@/hooks/use-auth-api";
import { useOptionalUser } from "@/hooks/use-optional-auth";
import { fetchProject } from "@/lib/api";
import type { Project } from "@/lib/types";
import { cn } from "@/lib/utils";
import { APP_VERSION } from "@/lib/version";
function useProjectFromPath(pathname: string): {
projectId: string | null;
project: Project | null;
} {
const match = pathname.match(/^\/project\/([^/]+)/);
const projectId = match ? match[1] : null;
const [project, setProject] = useState<Project | null>(null);
useEffect(() => {
if (!projectId) {
setProject(null);
return;
}
fetchProject(projectId)
.then(setProject)
.catch(() => setProject(null));
}, [projectId]);
useEffect(() => {
if (!projectId || project?.status !== "running") return;
const interval = setInterval(() => {
fetchProject(projectId)
.then(setProject)
.catch(() => {
/* keep last known project */
});
}, 3000);
return () => clearInterval(interval);
}, [projectId, project?.status]);
return { projectId, project };
}
export function Sidebar() {
const pathname = usePathname();
const { user } = useOptionalUser();
useAuthApi();
const [feedbackOpen, setFeedbackOpen] = useState(false);
const { projectId, project } = useProjectFromPath(pathname);
const isAdmin = user?.isAdmin ?? false;
return (
<aside className="w-56 shrink-0 border-r border-border bg-card flex flex-col min-h-0">
<div className="px-4 py-4 border-b border-border">
<Link href="/dashboard" className="flex items-center gap-2">
<PeriscopeMark className="h-5 w-5" />
<span className="text-sm font-semibold tracking-tight">Periscope</span>
</Link>
</div>
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
{projectId ? (
<Suspense fallback={<nav className="flex-1 px-2 py-3" aria-hidden />}>
<ProjectNav
pathname={pathname}
projectId={projectId}
project={project}
isAdmin={isAdmin}
/>
</Suspense>
) : (
<DefaultNav pathname={pathname} isAdmin={isAdmin} />
)}
</div>
<div className="border-t border-border">
<SidebarCredits />
<div className="px-2 py-1.5 border-b border-border/60">
<button
type="button"
onClick={() => setFeedbackOpen(true)}
className="flex items-center gap-2 w-full px-3 py-1.5 rounded-md text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
>
<MessageSquareWarning className="h-3.5 w-3.5" />
Feedback
</button>
</div>
<div className="px-4 py-3 flex items-center gap-2">
<SidebarUserButton />
<Link
href="/changelog"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
v{APP_VERSION}
</Link>
<ThemeToggle className="ml-auto" />
</div>
</div>
<FeedbackDialog open={feedbackOpen} onOpenChange={setFeedbackOpen} />
</aside>
);
}
function navItemClass(active: boolean) {
return cn(
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-accent/50",
);
}
function DefaultNav({ pathname, isAdmin }: { pathname: string; isAdmin: boolean }) {
return (
<nav className="flex-1 px-2 py-3 space-y-0.5">
<Link href="/dashboard" className={navItemClass(pathname === "/dashboard")}>
<LayoutDashboard className="h-4 w-4" />
Projects
</Link>
<Link href="/library" className={navItemClass(pathname === "/library")}>
<Library className="h-4 w-4" />
Library
</Link>
<Link href="/feedback" className={navItemClass(pathname === "/feedback")}>
<MessageSquareWarning className="h-4 w-4" />
My Feedback
</Link>
{isAdmin && (
<Link
href="/admin"
className={navItemClass(pathname === "/admin" || pathname.startsWith("/admin/"))}
>
<Shield className="h-4 w-4" />
Admin
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
</Link>
)}
</nav>
);
}
type NavItem =
| { type: "route"; path: string; label: string; icon: typeof ClipboardList; adminOnly?: boolean }
| { type: "tab"; tab: string; label: string; icon: typeof ClipboardList; adminOnly?: boolean };
const PROJECT_NAV_ITEMS: NavItem[] = [
{ type: "route", path: "/report", label: "Report", icon: ClipboardList },
{ type: "route", path: "/pcb", label: "Layout", icon: CircuitBoard },
{ type: "tab", tab: "bom", label: "BOM", icon: TableProperties },
{ type: "tab", tab: "domains", label: "Domains", icon: Boxes },
{ type: "tab", tab: "rails", label: "Power rails", icon: CircuitBoard },
{ type: "tab", tab: "derating", label: "Derating", icon: Zap },
{ type: "tab", tab: "impedance", label: "RF / Impedance", icon: Ruler },
{ type: "tab", tab: "logs", label: "Logs", icon: ScrollText, adminOnly: true },
{ type: "tab", tab: "settings", label: "Settings", icon: Settings },
];
function NavLink({
href,
active,
children,
forceDocument,
}: {
href: string;
active?: boolean;
children: ReactNode;
forceDocument?: boolean;
}) {
const className = navItemClass(Boolean(active));
if (forceDocument) {
return (
<a href={href} className={className}>
{children}
</a>
);
}
return (
<Link href={href} className={className}>
{children}
</Link>
);
}
function ProjectNav({
pathname,
projectId,
project,
isAdmin,
}: {
pathname: string;
projectId: string;
project: Project | null;
isAdmin: boolean;
}) {
const searchParams = useSearchParams();
const base = `/project/${projectId}`;
const currentTab = searchParams.get("tab");
const isRunning = project?.status === "running";
const isOnProgress = pathname === `${base}/progress`;
const onNestedRoute = pathname.startsWith(`${base}/`);
function isActive(item: NavItem): boolean {
if (item.type === "route") {
return pathname === `${base}${item.path}` && !currentTab;
}
if (item.tab === "bom") {
return pathname === base && (currentTab === "bom" || currentTab === null);
}
return pathname === base && currentTab === item.tab;
}
function getHref(item: NavItem): string {
if (item.type === "route") return `${base}${item.path}`;
return `${base}?tab=${item.tab}`;
}
return (
<nav className="flex-1 px-2 py-3 space-y-1">
<NavLink href="/dashboard" forceDocument={onNestedRoute}>
<ArrowLeft className="h-4 w-4" />
Dashboard
</NavLink>
<NavLink href="/library" forceDocument={onNestedRoute}>
<Library className="h-4 w-4" />
Library
</NavLink>
<div className="px-3 pt-3 pb-1">
<p className="text-xs font-semibold text-foreground truncate">
{project?.name ?? "Loading..."}
</p>
</div>
<div className="space-y-0.5">
{isRunning && (
<NavLink href={`${base}/progress`} active={isOnProgress}>
<Loader2 className="h-4 w-4 animate-spin" />
Processing
</NavLink>
)}
{PROJECT_NAV_ITEMS.filter((item) => !item.adminOnly || isAdmin).map((item) => {
const active = isActive(item);
const href = getHref(item);
const forceDocument = item.type === "tab" && onNestedRoute;
if (isRunning) {
return (
<span
key={item.label}
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-muted-foreground/40 cursor-not-allowed"
>
<item.icon className="h-4 w-4" />
{item.label}
{item.adminOnly && (
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
)}
</span>
);
}
return (
<NavLink key={item.label} href={href} active={active} forceDocument={forceDocument}>
<item.icon className="h-4 w-4" />
{item.label}
{item.adminOnly && (
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
)}
</NavLink>
);
})}
</div>
</nav>
);
}
@@ -0,0 +1,316 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import dynamic from "next/dynamic";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ChevronLeft, ChevronRight, XIcon } from "lucide-react";
import { fetchDatasheetUrl } from "@/lib/api";
import { cn } from "@/lib/utils";
import type { DocumentProps, PageProps } from "react-pdf";
import "react-pdf/dist/Page/AnnotationLayer.css";
import "react-pdf/dist/Page/TextLayer.css";
const Document = dynamic(
() =>
import("react-pdf").then((mod) => {
mod.pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
return mod.Document;
}),
{ ssr: false },
) as React.ComponentType<DocumentProps>;
const Page = dynamic(
() => import("react-pdf").then((mod) => mod.Page),
{ ssr: false },
) as React.ComponentType<PageProps>;
interface PdfViewerPanelProps {
projectId: string;
mpn: string;
initialPage: number;
highlightQuote?: string;
onClose?: () => void;
className?: string;
}
const HIGHLIGHT_CLASS = "periscope-quote-hl";
const MARK = 'span[role="presentation"]';
function foldMu(s: string): string {
return s.replace(/µ/g, "μ");
}
function quoteKey(s: string): string {
return foldMu(s)
.replace(/­/g, "")
.replace(/-\s+/g, "")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
function spanKey(s: string): string {
return foldMu(s).replace(/­/g, "").replace(/\s+/g, " ").toLowerCase();
}
function stripHighlights(root: HTMLElement): void {
root.querySelectorAll<HTMLElement>("." + HIGHLIGHT_CLASS).forEach((el) => {
el.classList.remove(HIGHLIGHT_CLASS);
el.style.backgroundColor = "";
});
}
function paintQuote(root: HTMLElement, quote: string): boolean {
const layer = root.querySelector(".react-pdf__Page__textContent");
stripHighlights(root);
if (!layer) return false;
const spans = Array.from(layer.querySelectorAll<HTMLElement>(MARK));
let concat = "";
const ranges: { span: HTMLElement; start: number; end: number }[] = [];
for (const span of spans) {
const piece = spanKey(span.textContent ?? "");
if (!piece) continue;
if (concat && !concat.endsWith(" ") && !piece.startsWith(" ")) concat += " ";
const start = concat.length;
concat += piece;
ranges.push({ span, start, end: concat.length });
}
const target = quoteKey(quote);
if (target.length < 4) return false;
let idx = concat.indexOf(target);
let matchLen = target.length;
if (idx === -1) {
const tokens = target.split(" ").filter(Boolean);
const MIN_TOKENS = 4;
search: for (let len = tokens.length; len >= MIN_TOKENS; len--) {
for (let s = 0; s + len <= tokens.length; s++) {
const at = concat.indexOf(tokens.slice(s, s + len).join(" "));
if (at !== -1) {
idx = at;
matchLen = tokens.slice(s, s + len).join(" ").length;
break search;
}
}
}
if (idx === -1) return false;
}
const matchEnd = idx + matchLen;
let first: HTMLElement | null = null;
for (const r of ranges) {
if (r.end > idx && r.start < matchEnd) {
r.span.classList.add(HIGHLIGHT_CLASS);
r.span.style.backgroundColor = "rgba(245, 158, 11, 0.4)";
if (!first) first = r.span;
}
}
if (!first) return false;
first.scrollIntoView({ block: "center", behavior: "smooth" });
return true;
}
export function PdfViewerPanel({
projectId,
mpn,
initialPage,
highlightQuote,
onClose,
className,
}: PdfViewerPanelProps) {
const [numPages, setNumPages] = useState<number>(0);
const [pageNumber, setPageNumber] = useState(initialPage);
const [pageInput, setPageInput] = useState(String(initialPage));
const [containerHeight, setContainerHeight] = useState<number>(0);
const [pageAspect, setPageAspect] = useState<number>(8.5 / 11);
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [pdfError, setPdfError] = useState<string | null>(null);
const obsRef = useRef<ResizeObserver | null>(null);
const scrollElRef = useRef<HTMLDivElement | null>(null);
const urlRef = useRef<string | null>(null);
useEffect(() => {
let cancelled = false;
setPdfError(null);
fetchDatasheetUrl(projectId, mpn).then((url) => {
if (cancelled) {
if (url) URL.revokeObjectURL(url);
return;
}
if (!url) {
setPdfError("Datasheet not found");
return;
}
setPdfUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return url;
});
});
return () => {
cancelled = true;
};
}, [projectId, mpn]);
useEffect(() => {
urlRef.current = pdfUrl;
}, [pdfUrl]);
useEffect(
() => () => {
if (urlRef.current) URL.revokeObjectURL(urlRef.current);
},
[],
);
useEffect(() => {
setPageNumber(initialPage);
setPageInput(String(initialPage));
}, [initialPage]);
const containerRef = useCallback((node: HTMLDivElement | null) => {
if (obsRef.current) {
obsRef.current.disconnect();
obsRef.current = null;
}
scrollElRef.current = node;
if (node) {
const obs = new ResizeObserver(([entry]) => {
setContainerHeight(entry.contentRect.height);
});
obs.observe(node);
obsRef.current = obs;
}
}, []);
const applyHighlight = useCallback(() => {
const root = scrollElRef.current;
if (!root) return;
if (!highlightQuote) {
stripHighlights(root);
return;
}
paintQuote(root, highlightQuote);
}, [highlightQuote]);
useEffect(() => {
const id = requestAnimationFrame(applyHighlight);
return () => cancelAnimationFrame(id);
}, [applyHighlight, pageNumber, pdfUrl]);
const onDocumentLoadSuccess = useCallback(
({ numPages: n }: { numPages: number }) => {
setNumPages(n);
setPageNumber(initialPage);
setPageInput(String(initialPage));
},
[initialPage],
);
const goToPage = (n: number) => {
const clamped = Math.max(1, Math.min(n, numPages));
setPageNumber(clamped);
setPageInput(String(clamped));
};
return (
<div
className={cn("flex h-full max-w-full flex-col", className)}
style={{
width:
containerHeight > 0
? `min(95vw, ${containerHeight * pageAspect}px)`
: `min(95vw, calc((100vh - 80px) * ${pageAspect}))`,
}}
>
<div className="flex flex-col gap-0.5 px-4 py-3 border-b border-border shrink-0">
<div className="flex items-center">
<h2 className="text-sm font-mono font-medium">{mpn}</h2>
{onClose && (
<Button
variant="ghost"
size="icon-sm"
className="ml-auto"
onClick={onClose}
aria-label="Close datasheet"
>
<XIcon />
</Button>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-7 w-7 p-0"
onClick={() => goToPage(pageNumber - 1)}
disabled={pageNumber <= 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>Page</span>
<Input
className="h-7 w-12 text-xs text-center px-1"
value={pageInput}
onChange={(e) => setPageInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") goToPage(Number(pageInput));
}}
onBlur={() => goToPage(Number(pageInput))}
/>
<span>of {numPages}</span>
</div>
<Button
variant="outline"
size="sm"
className="h-7 w-7 p-0"
onClick={() => goToPage(pageNumber + 1)}
disabled={pageNumber >= numPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
<div ref={containerRef} className="flex-1 overflow-auto bg-muted/50 flex justify-center">
{pdfError ? (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
{pdfError}
</div>
) : containerHeight > 0 && pdfUrl ? (
<Document
file={pdfUrl}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={() => setPdfError("Failed to load PDF")}
loading={
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
Loading PDF...
</div>
}
>
<Page
pageNumber={pageNumber}
height={containerHeight}
onRenderTextLayerSuccess={applyHighlight}
onLoadSuccess={(page: { originalWidth: number; originalHeight: number }) => {
if (page.originalHeight > 0) {
setPageAspect(page.originalWidth / page.originalHeight);
}
}}
loading={
<div
style={{ height: containerHeight, width: containerHeight * pageAspect }}
className="bg-card animate-pulse rounded"
/>
}
/>
</Document>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,90 @@
"use client";
import { Check, Circle, Loader2 } from "lucide-react";
import type { PipelineStep } from "@/lib/types";
import { cn } from "@/lib/utils";
function StatusGlyph({ status }: { status: string }) {
if (status === "complete") {
return (
<div className="h-7 w-7 rounded-full bg-emerald-500/20 flex items-center justify-center">
<Check className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
</div>
);
}
if (status === "running") {
return (
<div className="h-7 w-7 rounded-full bg-blue-500/20 flex items-center justify-center">
<Loader2 className="h-4 w-4 text-blue-600 dark:text-blue-400 animate-spin" />
</div>
);
}
return (
<div className="h-7 w-7 rounded-full bg-muted flex items-center justify-center">
<Circle className="h-3 w-3 text-muted-foreground" />
</div>
);
}
export function PipelineStepper({ steps }: { steps: PipelineStep[] }) {
return (
<div className="space-y-0">
{steps.map((step, i) => {
const finishedNew =
step.status === "running" && step.totalNew != null && step.totalNew > 0
? step.substeps.filter((s) => s.status === "complete" && !s.cached).length
: null;
return (
<div key={i} className="relative flex gap-4">
{i < steps.length - 1 && (
<div
className={cn(
"absolute left-[13px] top-9 w-px bottom-0",
step.status === "complete" ? "bg-emerald-500/40" : "bg-border",
)}
/>
)}
<div className="pt-1">
<StatusGlyph status={step.status} />
</div>
<div className="flex-1 pb-8">
<p className="text-sm font-semibold leading-7">
{step.title}
{finishedNew != null && (
<span className="ml-2 text-xs font-normal text-muted-foreground">
({finishedNew}/{step.totalNew})
</span>
)}
</p>
<p className="text-xs text-muted-foreground mb-2">{step.description}</p>
<div className="space-y-1">
{step.substeps.map((sub) => (
<div key={sub.key} className="flex items-center gap-2 text-xs">
{sub.status === "complete" && (
<Check className="h-3 w-3 text-emerald-600 dark:text-emerald-400 shrink-0" />
)}
{sub.status === "running" && (
<Loader2 className="h-3 w-3 text-blue-600 dark:text-blue-400 animate-spin shrink-0" />
)}
{sub.status === "pending" && (
<Circle className="h-3 w-3 text-muted-foreground shrink-0" />
)}
<span
className={cn(
"text-muted-foreground",
sub.status === "complete" && "text-foreground",
sub.status === "running" && "text-blue-600 dark:text-blue-400",
)}
>
{sub.label}
</span>
</div>
))}
</div>
</div>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,108 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import type { ApiLogEntry } from "@/lib/types";
const LOG_STAGE_LABELS: Record<string, string> = {
pintable: "Pin Table",
rules: "Rules",
specs: "Specs",
pattern: "Pattern",
validation: "Validation",
};
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function formatTokens(n: number): string {
if (n < 1000) return String(n);
return `${(n / 1000).toFixed(1)}k`;
}
export function ApiLogsSection({ logs }: { logs: ApiLogEntry[] }) {
if (logs.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">API Calls</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
No API call logs yet. Run the pipeline to generate logs.
</p>
</CardContent>
</Card>
);
}
const totalInput = logs.reduce((s, l) => s + l.input_tokens, 0);
const totalOutput = logs.reduce((s, l) => s + l.output_tokens, 0);
const totalDuration = logs.reduce((s, l) => s + l.duration_ms, 0);
const totalCost = logs.reduce((s, l) => s + (l.cost_usd ?? 0), 0);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm">
API Calls
<Badge variant="outline" className="text-xs">
{logs.length}
</Badge>
</CardTitle>
<div className="flex gap-4 text-xs text-muted-foreground">
<span>{formatTokens(totalInput)} input tokens</span>
<span>{formatTokens(totalOutput)} output tokens</span>
<span>{formatDuration(totalDuration)} total</span>
{totalCost > 0 && (
<span className="font-medium text-foreground">${totalCost.toFixed(4)}</span>
)}
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
{logs.map((log, i) => (
<div
key={`${log.identifier}-${log.stage}-${i}`}
className="flex items-start gap-3 rounded-md border border-border/50 p-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium font-mono">
{log.identifier}
</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{LOG_STAGE_LABELS[log.stage] ?? log.stage}
</Badge>
{log.skill_id && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-blue-600 dark:text-blue-400 border-blue-500/40">
skill
</Badge>
)}
{log.turns && log.turns > 1 && (
<span className="text-[10px] text-muted-foreground">
{log.turns} turns
</span>
)}
</div>
<div className="flex gap-3 mt-1 text-xs text-muted-foreground">
<span>{formatTokens(log.input_tokens)} in</span>
<span>{formatTokens(log.output_tokens)} out</span>
<span>{formatDuration(log.duration_ms)}</span>
<span className="font-mono">{log.model}</span>
{log.cost_usd != null && (
<span className="font-medium text-foreground">
${log.cost_usd.toFixed(4)}
</span>
)}
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,155 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import type { BomSummaryRow } from "@/lib/types";
import { ExternalLink } from "lucide-react";
const SPEC_LABELS: Record<string, string> = {
value_formatted: "Value",
tolerance: "Tolerance",
package: "Package",
power_rating_w: "Power",
voltage_rating_v: "Voltage",
dielectric: "Dielectric",
current_rating_a: "Current",
dcr_ohms: "DCR",
// discrete
forward_voltage_v: "Vf",
reverse_voltage_v: "Vr",
forward_current_a: "If",
zener_voltage_v: "Vz",
zener_impedance_ohm: "Zzt",
standoff_voltage_v: "Vrwm",
clamping_voltage_v: "Vclamp",
peak_pulse_current_a: "Ipp",
vds_max_v: "Vds",
vce_max_v: "Vce",
id_max_a: "Id",
ic_max_a: "Ic",
rds_on_ohm: "Rds(on)",
vgs_th_v: "Vgs(th)",
qg_c: "Qg",
hfe: "hFE",
color: "Color",
power_dissipation_w: "Pd",
// crystal
frequency_hz: "Freq",
load_capacitance_f: "CL",
esr_ohm: "ESR",
// connector
pin_count: "Pins",
// general
turns_ratio: "Turns",
voltage_primary_v: "Vpri",
voltage_secondary_v: "Vsec",
breaking_capacity_a: "Breaking",
};
export function BomSummaryTable({
rows,
onViewDatasheet,
}: {
rows: BomSummaryRow[];
onViewDatasheet: (mpn: string) => void;
}) {
if (rows.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">Bill of Materials</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
No BOM summary yet. Run the pipeline to generate.
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm">
Bill of Materials
<Badge variant="outline" className="text-xs">
{rows.length} parts
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground">
<th className="pb-2 pr-4 font-medium">MPN</th>
<th className="pb-2 pr-4 font-medium">Designators</th>
<th className="pb-2 pr-4 font-medium">Value</th>
<th className="pb-2 pr-4 font-medium">Category</th>
<th className="pb-2 pr-4 font-medium">Specs</th>
<th className="pb-2 font-medium">Datasheet</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{rows.map((row, i) => (
<tr key={row.mpn ?? `row-${i}`}>
<td className="py-2 pr-4 font-mono text-xs">
{row.mpn ?? <span className="text-muted-foreground"></span>}
</td>
<td className="py-2 pr-4 font-mono text-xs">
{row.designators.join(", ")}
</td>
<td className="py-2 pr-4 font-mono text-xs">
{row.value}
</td>
<td className="py-2 pr-4">
{row.category ? (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 font-mono">
{row.category}
</Badge>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</td>
<td className="py-2 pr-4 text-xs text-muted-foreground">
{row.description ? (
<span className="text-foreground/80">{row.description}</span>
) : row.specs ? (
<span className="flex flex-wrap gap-x-3 gap-y-0.5">
{Object.entries(row.specs).map(([k, v]) => (
<span key={k}>
<span className="text-muted-foreground/60">{SPEC_LABELS[k] ?? k}:</span>{" "}
<span className="font-mono text-foreground">{String(v)}</span>
</span>
))}
</span>
) : (
"—"
)}
</td>
<td className="py-2 text-xs">
{row.hasDatasheet && row.mpn ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-blue-600 hover:text-blue-700 dark:text-blue-500 dark:hover:text-blue-400"
onClick={() => onViewDatasheet(row.mpn!)}
>
<ExternalLink className="h-3 w-3 mr-1" />
View
</Button>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,186 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
fetchCollaborators,
addCollaborator,
removeCollaborator,
makeCollaboratorOwner,
} from "@/lib/api";
import type { Collaborator } from "@/lib/types";
import { UserPlus, Trash2, Crown } from "lucide-react";
import { useOptionalUser } from "@/hooks/use-optional-auth";
export function CollaboratorsSection({ projectId }: { projectId: string }) {
const { user } = useOptionalUser();
const [collaborators, setCollaborators] = useState<Collaborator[]>([]);
const [ownerUserId, setOwnerUserId] = useState<string>("");
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(true);
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
const isOwner = ownerUserId ? ownerUserId === user?.id || ownerUserId === "local" : false;
const isAdmin = user?.isAdmin ?? false;
const reload = useCallback(() => {
setLoading(true);
fetchCollaborators(projectId)
.then((data) => {
setCollaborators(data.collaborators);
setOwnerUserId(data.owner_user_id);
})
.finally(() => setLoading(false));
}, [projectId]);
useEffect(() => {
reload();
}, [reload]);
const handleAdd = async () => {
if (!email.trim()) return;
setAdding(true);
setError(null);
try {
await addCollaborator(projectId, email.trim());
setEmail("");
reload();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to add collaborator");
} finally {
setAdding(false);
}
};
const handleRemove = async (userId: string) => {
setError(null);
try {
await removeCollaborator(projectId, userId);
setCollaborators((prev) => prev.filter((c) => c.user_id !== userId));
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to remove collaborator");
}
};
const handleMakeOwner = async (userId: string) => {
setError(null);
try {
await makeCollaboratorOwner(projectId, userId);
reload();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to transfer ownership");
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm">
Collaborators
{collaborators.length > 0 && (
<Badge variant="outline" className="text-xs">
{collaborators.length}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{isOwner && (
<div className="flex gap-2">
<input
type="email"
placeholder="Add collaborator by email..."
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAdd();
}}
className="flex-1 rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<Button size="sm" onClick={handleAdd} disabled={adding || !email.trim()}>
<UserPlus className="h-4 w-4 mr-1" />
{adding ? "Adding..." : "Add"}
</Button>
</div>
)}
{error && (
<p className="text-xs text-rose-600 dark:text-rose-400">{error}</p>
)}
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : collaborators.length === 0 ? (
<p className="text-sm text-muted-foreground">
No collaborators yet.{isOwner ? " Add team members by email to give them access to this project." : ""}
</p>
) : (
<div className="space-y-2">
{collaborators.map((collab) => (
<div
key={collab.user_id}
className="flex items-center gap-3 rounded-md border border-border/50 p-3"
>
{collab.image_url ? (
<img
src={collab.image_url}
alt=""
className="h-8 w-8 rounded-full"
/>
) : (
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center">
<span className="text-xs text-muted-foreground">
{(collab.name?.[0] || collab.email?.[0] || "?").toUpperCase()}
</span>
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">
{collab.name || "Unknown"}
</p>
{collab.role === "owner" && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
Owner
</Badge>
)}
</div>
{collab.email && (
<p className="text-xs text-muted-foreground truncate">
{collab.email}
</p>
)}
</div>
{isAdmin && collab.role !== "owner" && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground hover:text-amber-600 dark:hover:text-amber-500"
onClick={() => handleMakeOwner(collab.user_id)}
title="Promote to owner"
>
<Crown className="h-3.5 w-3.5 mr-1" />
Make owner
</Button>
)}
{(isOwner || isAdmin) && collab.role !== "owner" && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-rose-600 dark:hover:text-rose-400"
onClick={() => handleRemove(collab.user_id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,303 @@
"use client";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import type { DeratingRow, DeratingSettings } from "@/lib/types";
import { X } from "lucide-react";
function deratingStatus(
row: DeratingRow,
settings: DeratingSettings,
manualVoltage: number | undefined,
): "pass" | "fail" | "unknown" {
const rated = row.rated_voltage_v;
const operating = manualVoltage ?? row.operating_voltage_v;
if (rated == null || operating == null) return "unknown";
const category = row.dielectric_category ?? "ceramic";
const pct = settings[category] / 100;
const threshold = rated * (1 - pct);
return operating <= threshold ? "pass" : "fail";
}
const STATUS_ROW_STYLES: Record<string, string> = {
pass: "bg-emerald-500/10",
fail: "bg-rose-500/10",
unknown: "",
};
const CATEGORY_LABELS: Record<string, string> = {
ceramic: "Ceramic",
tantalum: "Tantalum",
electrolytic: "Electrolytic",
};
export function DeratingTable({
rows,
settings,
onSettingsChange,
manualVoltages,
onManualVoltageChange,
}: {
rows: DeratingRow[];
settings: DeratingSettings;
onSettingsChange: (s: DeratingSettings) => void;
manualVoltages: Record<string, number>;
onManualVoltageChange: (designator: string, voltage: number | null) => void;
}) {
const [editingCell, setEditingCell] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
if (rows.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">Capacitor Voltage Derating</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
No derating data yet. Run the pipeline to generate.
</p>
</CardContent>
</Card>
);
}
const passCount = rows.filter(
(r) => deratingStatus(r, settings, manualVoltages[r.designator]) === "pass",
).length;
const failCount = rows.filter(
(r) => deratingStatus(r, settings, manualVoltages[r.designator]) === "fail",
).length;
return (
<div className="space-y-4">
{/* Settings card */}
<Card>
<CardContent className="py-3">
<div className="flex items-center gap-6 flex-wrap">
<span className="text-xs font-medium text-muted-foreground">Derating %</span>
{(["ceramic", "tantalum", "electrolytic"] as const).map((cat) => (
<label key={cat} className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{CATEGORY_LABELS[cat]}</span>
<input
type="number"
min={0}
max={100}
value={settings[cat]}
onChange={(e) => {
const val = Math.max(0, Math.min(100, Number(e.target.value) || 0));
onSettingsChange({ ...settings, [cat]: val });
}}
className="w-14 rounded border border-border bg-background px-1.5 py-0.5 text-xs font-mono text-center focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<span className="text-xs text-muted-foreground">%</span>
</label>
))}
</div>
</CardContent>
</Card>
{/* Table */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm">
Capacitor Voltage Derating
<Badge variant="outline" className="text-xs">
{rows.length} caps
</Badge>
{passCount > 0 && (
<span className="text-[10px] text-emerald-600 dark:text-emerald-400">{passCount} pass</span>
)}
{failCount > 0 && (
<span className="text-[10px] text-rose-600 dark:text-rose-400">{failCount} fail</span>
)}
</CardTitle>
<p className="text-[11px] text-muted-foreground pt-1">
C_eff is an empirical DC-bias stima (C0G/X7R/X5R), not a Murata lot curve.
</p>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground">
<th className="pb-2 pr-4 font-medium">Designator</th>
<th className="pb-2 pr-4 font-medium">MPN</th>
<th className="pb-2 pr-4 font-medium">Value</th>
<th className="pb-2 pr-4 font-medium">C_eff</th>
<th className="pb-2 pr-4 font-medium">Type</th>
<th className="pb-2 pr-4 font-medium">Engine</th>
<th className="pb-2 pr-4 font-medium">Net+</th>
<th className="pb-2 pr-4 font-medium">Net</th>
<th className="pb-2 pr-4 font-medium text-right">Rated V</th>
<th className="pb-2 pr-4 font-medium text-right">Operating V</th>
<th className="pb-2 pr-4 font-medium">Source</th>
<th className="pb-2 font-medium text-right">Margin</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{rows.map((row) => {
const manual = manualVoltages[row.designator];
const status = deratingStatus(row, settings, manual);
const operatingV = manual ?? row.operating_voltage_v;
const source = manual != null ? "user" : row.operating_voltage_source;
const isEditing = editingCell === row.designator;
// Compute margin
let margin: string | null = null;
if (row.rated_voltage_v != null && operatingV != null) {
const cat = row.dielectric_category ?? "ceramic";
const threshold = row.rated_voltage_v * (1 - settings[cat] / 100);
const pct = ((threshold - operatingV) / threshold) * 100;
margin = `${pct >= 0 ? "+" : ""}${pct.toFixed(0)}%`;
}
return (
<tr
key={row.designator}
className={STATUS_ROW_STYLES[status]}
>
<td className="py-2 pr-4 font-mono text-xs font-medium">
{row.designator}
</td>
<td className="py-2 pr-4 font-mono text-xs">
{row.mpn ?? <span className="text-muted-foreground"></span>}
</td>
<td className="py-2 pr-4 font-mono text-xs">
{row.value_formatted ?? <span className="text-muted-foreground"></span>}
</td>
<td className="py-2 pr-4 font-mono text-xs">
{row.c_eff_formatted ? (
<span title="Empirical DC-bias stima, not a vendor lot curve">
{row.c_eff_formatted}
{row.dc_bias_model === "stima" && (
<span className="ml-1 text-[10px] text-muted-foreground">stima</span>
)}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="py-2 pr-4 text-xs">
{row.dielectric_category ? (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 capitalize">
{row.dielectric_category}
</Badge>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="py-2 pr-4 text-xs">
{row.stress && row.stress !== "UNKNOWN" ? (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{row.stress}
</Badge>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="py-2 pr-4 font-mono text-xs">
{row.net_plus ?? <span className="text-muted-foreground"></span>}
</td>
<td className="py-2 pr-4 font-mono text-xs">
{row.net_minus ?? <span className="text-muted-foreground"></span>}
</td>
<td className="py-2 pr-4 font-mono text-xs text-right">
{row.rated_voltage_v != null ? (
`${row.rated_voltage_v}V`
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="py-2 pr-4 text-right">
{isEditing ? (
<input
type="number"
step="0.1"
autoFocus
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
const v = parseFloat(editValue);
if (!isNaN(v) && v >= 0) {
onManualVoltageChange(row.designator, v);
}
setEditingCell(null);
}
if (e.key === "Escape") setEditingCell(null);
}}
onBlur={() => {
const v = parseFloat(editValue);
if (!isNaN(v) && v >= 0) {
onManualVoltageChange(row.designator, v);
}
setEditingCell(null);
}}
className="w-16 rounded border border-blue-500 bg-background px-1.5 py-0.5 text-xs font-mono text-right focus:outline-none"
/>
) : (
<span
className="inline-flex items-center gap-1 cursor-pointer group"
onClick={() => {
setEditingCell(row.designator);
setEditValue(
String(manual ?? row.operating_voltage_v ?? ""),
);
}}
>
<span className="font-mono text-xs">
{operatingV != null ? (
`${operatingV}V`
) : (
<span className="text-muted-foreground/50 group-hover:text-muted-foreground text-[10px]">
click to set
</span>
)}
</span>
{manual != null && (
<button
onClick={(e) => {
e.stopPropagation();
onManualVoltageChange(row.designator, null);
}}
className="text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
)}
</span>
)}
</td>
<td className="py-2 pr-4 text-xs">
{source === "user" ? (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-blue-600 dark:text-blue-400 border-blue-500/40">
user
</Badge>
) : source ? (
<span className="font-mono text-muted-foreground">{source}</span>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="py-2 font-mono text-xs text-right">
{margin != null ? (
<span className={status === "pass" ? "text-emerald-600 dark:text-emerald-400" : "text-rose-600 dark:text-rose-400"}>
{margin}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,69 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { OctagonX, Copy, Check } from "lucide-react";
export function PipelineErrorBanner({
projectId,
message,
}: {
projectId: string;
message: string | null;
}) {
const [copied, setCopied] = useState(false);
const detail = message ?? "Unknown error — no details were recorded.";
const shareText = `PeriscopeX project ${projectId} failed: ${detail}`;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(shareText);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// clipboard blocked — fall back to no-op; user can still select the text
}
};
return (
<Card className="border-rose-500/30 bg-rose-500/5">
<CardContent className="py-4">
<div className="flex items-start gap-3">
<OctagonX className="h-5 w-5 text-rose-600 dark:text-rose-400 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium text-rose-700 dark:text-rose-300">
Pipeline run failed
</p>
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={handleCopy}
>
{copied ? (
<><Check className="h-3 w-3 mr-1" /> Copied</>
) : (
<><Copy className="h-3 w-3 mr-1" /> Copy details</>
)}
</Button>
</div>
<pre className="text-xs font-mono whitespace-pre-wrap break-all select-text bg-muted/60 dark:bg-black/30 rounded p-2 text-rose-800/90 dark:text-rose-200/90 border border-rose-500/20">
{detail}
</pre>
<p className="text-xs text-muted-foreground">
Project ID: <span className="font-mono">{projectId}</span>. Try
running again if the error persists,{" "}
<Link href="/contact" className="underline hover:text-foreground">
contact support
</Link>{" "}
and include the error above.
</p>
</div>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,26 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function ReportVersionSection({
periscopeVersion,
}: {
periscopeVersion?: string | null;
}) {
if (!periscopeVersion) return null;
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">Report Version</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Generated with Periscope
</p>
<span className="font-mono text-sm">v{periscopeVersion}</span>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,81 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import type { SkippedComponent } from "@/lib/types";
import { AlertTriangle } from "lucide-react";
const STAGE_LABELS: Record<string, string> = {
ic_extraction: "IC Extraction",
simple_extraction: "Specs Extraction",
passive_extraction: "Passive Extraction",
passive_pattern_load: "Pattern Loading",
passive_resolve: "Passive Resolution",
passive_specs: "Passive Specs",
graph_build: "Graph Build",
validation: "Datasheet Review",
simple_digikey_resolve: "DigiKey Auto-Resolve",
};
export function SkippedComponentsSection({
skipped,
}: {
skipped?: SkippedComponent[];
}) {
if (!skipped || skipped.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">Skipped Components</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
No components were skipped during analysis.
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
Skipped Components
<Badge variant="outline" className="text-xs text-amber-600 dark:text-amber-400 border-amber-500/40">
{skipped.length}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-3">
These components were skipped during analysis due to errors. The rest of the pipeline continued without them.
</p>
<div className="space-y-2">
{skipped.map((item, i) => (
<div
key={`${item.identifier}-${item.stage}-${i}`}
className="flex items-start gap-3 rounded-md border border-amber-500/20 bg-amber-500/5 p-3"
>
<AlertTriangle className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium font-mono">
{item.identifier}
</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{STAGE_LABELS[item.stage] ?? item.stage}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5 break-all">
{item.error}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,114 @@
"use client";
import { useState } from "react";
import { ChevronRight } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Badge } from "@/components/ui/badge";
import { FindingCard } from "./finding-card";
import { StatusBadge } from "./status-badge";
import type { Finding, FindingComment, FindingReview, Collaborator, Component } from "@/lib/types";
import { cn, sortFindings, subtypeLabel } from "@/lib/utils";
interface ComponentGroupProps {
designator: string;
findings: Finding[];
component?: Component;
onViewReference: (finding: Finding) => void;
findingKeys?: Map<Finding, string>;
isReviewed?: (key: string) => boolean;
onToggleReviewed?: (key: string) => void;
comments?: Record<string, FindingComment[]>;
projectId?: string;
collaborators?: Collaborator[];
currentUserId?: string;
currentUserName?: string;
onCommentAdded?: (comment: FindingComment) => void;
onCommentDeleted?: (commentId: string, findingId: string) => void;
onReportFinding?: (finding: Finding) => void;
reportedFindingIds?: Set<string>;
reviews?: Record<string, FindingReview>;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
}
export function ComponentGroup({
designator,
findings,
component,
onViewReference,
findingKeys,
isReviewed,
onToggleReviewed,
comments,
projectId,
collaborators,
currentUserId,
currentUserName,
onCommentAdded,
onCommentDeleted,
onReportFinding,
reportedFindingIds,
reviews,
onReviewSaved,
}: ComponentGroupProps) {
const [open, setOpen] = useState(false);
const sorted = sortFindings(findings);
const errorCount = findings.filter((f) => f.status === "ERROR").length;
const warnCount = findings.filter((f) => f.status === "WARNING").length;
const infoCount = findings.filter((f) => f.status === "INFO").length;
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex items-center gap-3 w-full py-3 group">
<ChevronRight
className={cn("h-4 w-4 text-muted-foreground transition-transform", open && "rotate-90")}
/>
<Badge variant="secondary" className="font-mono text-sm">
{designator}
</Badge>
{component?.mpn && (
<span className="text-sm text-muted-foreground font-mono">{component.mpn}</span>
)}
{component?.component_subtype && (
<span className="text-xs text-muted-foreground">
{subtypeLabel(component.component_subtype)}
</span>
)}
<div className="ml-auto flex items-center gap-1.5">
{errorCount > 0 && <StatusBadge status="ERROR" />}
{warnCount > 0 && <StatusBadge status="WARNING" />}
{infoCount > 0 && <StatusBadge status="INFO" />}
<span className="text-xs text-muted-foreground ml-1">
{findings.length} {findings.length === 1 ? "finding" : "findings"}
</span>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-3 pl-7 pb-4">
{sorted.map((f, i) => {
const key = findingKeys?.get(f);
return (
<FindingCard
key={key ?? i}
finding={f}
onViewReference={onViewReference}
checked={key && isReviewed ? isReviewed(key) : undefined}
onCheckedChange={key && onToggleReviewed ? () => onToggleReviewed(key) : undefined}
comments={f.finding_id ? comments?.[f.finding_id] : undefined}
projectId={projectId}
collaborators={collaborators}
currentUserId={currentUserId}
currentUserName={currentUserName}
onCommentAdded={onCommentAdded}
onCommentDeleted={onCommentDeleted}
onReportFinding={onReportFinding}
isReported={!!(f.finding_id && reportedFindingIds?.has(f.finding_id))}
review={f.finding_id ? reviews?.[f.finding_id] : undefined}
onReviewSaved={onReviewSaved}
/>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
);
}
@@ -0,0 +1,279 @@
"use client";
import { useState } from "react";
import { ChevronDown, FileText, Check, MessageSquare, Star, Flag, Cpu } from "lucide-react";
import { Checkbox } from "@base-ui/react/checkbox";
import { Button } from "@/components/ui/button";
import { StatusBadge } from "./status-badge";
import { FindingComments } from "./finding-comments";
import { FindingReviewControls } from "./finding-review-controls";
import type { Finding, FindingComment, FindingReview, Collaborator } from "@/lib/types";
import { cn } from "@/lib/utils";
import { isLayoutFinding } from "@/lib/layout-finding";
const EDGE: Record<string, string> = {
ERROR: "border-l-rose-500",
WARNING: "border-l-amber-500",
INFO: "border-l-blue-500",
};
const DEFAULT_ACTION =
"Review this finding against the datasheet and the board, then change the design if it applies.";
interface FindingCardProps {
finding: Finding;
onViewReference: (finding: Finding) => void;
checked?: boolean;
onCheckedChange?: () => void;
comments?: FindingComment[];
projectId?: string;
collaborators?: Collaborator[];
currentUserId?: string;
currentUserName?: string;
onCommentAdded?: (comment: FindingComment) => void;
onCommentDeleted?: (commentId: string, findingId: string) => void;
onReportFinding?: (finding: Finding) => void;
isReported?: boolean;
defaultOpen?: boolean;
review?: FindingReview;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
}
export function FindingCard({
finding,
onViewReference,
checked,
onCheckedChange,
comments,
projectId,
collaborators,
currentUserId,
currentUserName,
onCommentAdded,
onCommentDeleted,
onReportFinding,
isReported,
defaultOpen,
review,
onReviewSaved,
}: FindingCardProps) {
const [open, setOpen] = useState(defaultOpen ?? false);
const commentCount = comments?.length ?? 0;
const commentsEnabled = !!(projectId && collaborators && onCommentAdded && onCommentDeleted);
const actionText = (finding.action || finding.recommendation || "").trim() || DEFAULT_ACTION;
const expandable =
!!finding.facts ||
!!finding.requirement ||
!!finding.inference ||
!!finding.calculation ||
(commentsEnabled && !!finding.finding_id) ||
!!(projectId && finding.finding_id && onReviewSaved);
return (
<div
role={expandable ? "button" : undefined}
tabIndex={expandable ? 0 : undefined}
aria-expanded={expandable ? open : undefined}
onClick={expandable ? () => setOpen((o) => !o) : undefined}
onKeyDown={
expandable
? (e) => {
if (e.currentTarget !== e.target) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen((o) => !o);
}
}
: undefined
}
className={cn(
"rounded-lg border border-border bg-card p-4 border-l-4 transition-colors",
expandable && "cursor-pointer hover:bg-accent/30",
EDGE[finding.status],
)}
>
<div className="flex items-start gap-3">
{onCheckedChange && (
<div onClick={(e) => e.stopPropagation()}>
<Checkbox.Root
checked={checked}
onCheckedChange={() => onCheckedChange()}
aria-label="Mark as reviewed"
className="mt-0.5 h-5 w-5 shrink-0 rounded border border-border hover:border-muted-foreground data-[checked]:bg-emerald-500 data-[checked]:border-emerald-500 flex items-center justify-center cursor-pointer transition-colors"
>
<Checkbox.Indicator>
<Check className="h-3.5 w-3.5 text-white" />
</Checkbox.Indicator>
</Checkbox.Root>
</div>
)}
<StatusBadge status={finding.status} />
<div className="flex-1 min-w-0 space-y-1.5">
<p className="text-sm font-semibold leading-snug flex items-center gap-1.5">
{commentCount > 0 && (
<Star
className="h-3.5 w-3.5 shrink-0 text-amber-600 fill-amber-600 dark:text-amber-400 dark:fill-amber-400"
aria-label="Has comments"
/>
)}
<span>{finding.finding}</span>
</p>
{finding.why && !finding.facts && !finding.requirement && (
<p className="text-sm text-muted-foreground leading-relaxed">{finding.why}</p>
)}
<p className="text-sm text-muted-foreground mt-2 pl-1 border-l-2 border-muted leading-relaxed">
<span className="font-medium text-foreground">Action. </span>
{actionText}
</p>
<div className="flex items-center gap-2 pt-1">
{expandable && (
<span className="inline-flex items-center h-7 px-2 text-xs text-muted-foreground">
<ChevronDown
className={cn("h-3.5 w-3.5 mr-1 transition-transform", open && "rotate-180")}
/>
Details
</span>
)}
{commentsEnabled && finding.finding_id && (
<span className="inline-flex items-center h-7 px-2 text-xs text-muted-foreground gap-1">
<MessageSquare className="h-3.5 w-3.5" />
{commentCount > 0
? `${commentCount} comment${commentCount > 1 ? "s" : ""}`
: "Comment"}
</span>
)}
{onReportFinding && finding.finding_id && (
<button
type="button"
className={cn(
"inline-flex items-center h-7 px-2 text-xs transition-colors",
isReported ? "text-rose-500" : "text-muted-foreground hover:text-amber-500",
)}
onClick={(e) => {
e.stopPropagation();
onReportFinding(finding);
}}
aria-label={isReported ? "Reported" : "Report this finding"}
>
<Flag className={cn("h-3.5 w-3.5", isReported && "fill-rose-500")} />
</button>
)}
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
onClick={(e) => {
e.stopPropagation();
onViewReference(finding);
}}
>
<FileText className="h-3.5 w-3.5 mr-1" />
{finding.source_page ? `p.${finding.source_page}` : "ref"}
</Button>
{finding.finding_id && (
<span className="font-mono text-[11px] text-muted-foreground">{finding.finding_id}</span>
)}
{finding.source && finding.source !== "review" && (
<span
title="Found by a deterministic rule check, not the datasheet review"
className="inline-flex items-center gap-1 rounded border border-blue-500/30 bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:text-blue-300"
>
<Cpu className="h-3 w-3" />
Automated check
</span>
)}
{isLayoutFinding(finding) && (
<span className="inline-flex items-center rounded border border-emerald-500/30 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-800 dark:text-emerald-300">
Layout
</span>
)}
{finding.rule_id && (
<span className="font-mono text-[11px] text-muted-foreground">{finding.rule_id}</span>
)}
{finding.finding_class && (
<span className="inline-flex items-center rounded border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{finding.finding_class}
</span>
)}
{finding.provenance && (
<span className="inline-flex items-center rounded border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{finding.provenance}
</span>
)}
{finding.evidence_status === "INSUFFICIENT" && (
<span className="inline-flex items-center rounded border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 dark:text-amber-300">
Insufficient evidence
</span>
)}
{finding.suppressed && (
<span className="inline-flex items-center rounded border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
Decision
</span>
)}
</div>
{open && (
<div onClick={(e) => e.stopPropagation()}>
{(finding.facts || finding.requirement || finding.inference) && (
<dl className="mt-2 space-y-1.5 text-xs text-muted-foreground">
{finding.facts ? (
<div>
<dt className="font-medium text-foreground">Fact</dt>
<dd>{finding.facts}</dd>
</div>
) : null}
{finding.requirement ? (
<div>
<dt className="font-medium text-foreground">Requirement</dt>
<dd>{finding.requirement}</dd>
</div>
) : null}
{finding.inference ? (
<div>
<dt className="font-medium text-foreground">Inference</dt>
<dd>{finding.inference}</dd>
</div>
) : null}
{finding.calculation ? (
<div>
<dt className="font-medium text-foreground">Calculation</dt>
<dd className="font-mono">{finding.calculation}</dd>
</div>
) : null}
{typeof finding.confidence === "number" ? (
<div>
<dt className="font-medium text-foreground">Confidence</dt>
<dd>{Math.round(finding.confidence * 100)}%</dd>
</div>
) : null}
</dl>
)}
{commentsEnabled && finding.finding_id && (
<FindingComments
findingId={finding.finding_id}
comments={comments ?? []}
projectId={projectId}
collaborators={collaborators}
currentUserId={currentUserId ?? ""}
currentUserName={currentUserName ?? "User"}
onCommentAdded={onCommentAdded}
onCommentDeleted={onCommentDeleted}
/>
)}
{projectId && finding.finding_id && onReviewSaved && (
<FindingReviewControls
projectId={projectId}
findingId={finding.finding_id}
review={review}
userName={currentUserName ?? "User"}
onSaved={onReviewSaved}
/>
)}
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,155 @@
"use client";
import { useState, useCallback, Fragment } from "react";
import { Trash2, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import { MentionInput } from "./mention-input";
import { addComment, deleteComment } from "@/lib/api";
import type { FindingComment, Collaborator } from "@/lib/types";
function relativeTime(iso: string): string {
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
function regexEscape(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function mentionSpans(text: string, collaborators: Collaborator[]) {
const names = collaborators
.map((c) => c.name || c.email)
.filter((n): n is string => !!n)
.sort((a, b) => b.length - a.length)
.map(regexEscape);
const named = names.length > 0 ? `@(?:${names.join("|")})|` : "";
const parts = text.split(new RegExp(`(${named}@\\w+)`, "g"));
return parts.map((part, i) =>
part && part.startsWith("@") ? (
<span key={i} className="text-blue-600 dark:text-blue-400 font-medium">
{part}
</span>
) : (
<Fragment key={i}>{part}</Fragment>
),
);
}
interface FindingCommentsProps {
findingId: string;
comments: FindingComment[];
projectId: string;
collaborators: Collaborator[];
currentUserId: string;
currentUserName: string;
onCommentAdded: (comment: FindingComment) => void;
onCommentDeleted: (commentId: string, findingId: string) => void;
}
export function FindingComments({
findingId,
comments,
projectId,
collaborators,
currentUserId,
currentUserName,
onCommentAdded,
onCommentDeleted,
}: FindingCommentsProps) {
const [text, setText] = useState("");
const [mentions, setMentions] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = useCallback(async () => {
const body = text.trim();
if (!body || submitting) return;
setSubmitting(true);
setError(null);
try {
const comment = await addComment(projectId, findingId, body, currentUserName, mentions);
onCommentAdded(comment);
setText("");
setMentions([]);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to add comment");
} finally {
setSubmitting(false);
}
}, [text, mentions, submitting, projectId, findingId, currentUserName, onCommentAdded]);
const handleDelete = useCallback(
async (commentId: string) => {
await deleteComment(projectId, commentId);
onCommentDeleted(commentId, findingId);
},
[projectId, findingId, onCommentDeleted],
);
const handleMention = useCallback((userId: string) => {
setMentions((prev) => (prev.includes(userId) ? prev : [...prev, userId]));
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit],
);
return (
<div className="mt-3 pt-3 border-t border-border/50 space-y-2">
{comments.map((c) => (
<div key={c.comment_id} className="flex items-start gap-2 group">
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-1.5">
<span className="text-xs font-medium">{c.user_name}</span>
<span className="text-[10px] text-muted-foreground">{relativeTime(c.created_at)}</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed mt-0.5">
{mentionSpans(c.text, collaborators)}
</p>
</div>
{c.user_id === currentUserId && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onClick={() => handleDelete(c.comment_id)}
>
<Trash2 className="h-3 w-3 text-muted-foreground" />
</Button>
)}
</div>
))}
<div className="flex items-center gap-1.5">
<MentionInput
value={text}
onChange={setText}
onMention={handleMention}
collaborators={collaborators}
placeholder="Add a comment... (@ to mention)"
onKeyDown={handleKeyDown}
className="flex-1"
/>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 shrink-0"
onClick={handleSubmit}
disabled={!text.trim() || submitting}
>
<Send className="h-3.5 w-3.5" />
</Button>
</div>
{error && <p className="text-[11px] text-rose-600 dark:text-rose-400">{error}</p>}
</div>
);
}
@@ -0,0 +1,129 @@
"use client";
import { useEffect, useRef } from "react";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { PdfViewerPanel } from "@/components/pdf/pdf-viewer-panel";
import { FindingCard } from "./finding-card";
import type { Finding, FindingComment, FindingReview, Collaborator, Component } from "@/lib/types";
import { subtypeLabel } from "@/lib/utils";
interface FindingFocusViewProps {
finding: Finding;
component?: Component;
mpn: string;
page: number;
quote?: string;
projectId: string;
onExit: () => void;
escapeDisabled?: boolean;
onViewReference: (finding: Finding) => void;
checked: boolean;
onCheckedChange: () => void;
comments?: FindingComment[];
collaborators?: Collaborator[];
currentUserId?: string;
currentUserName?: string;
onCommentAdded?: (comment: FindingComment) => void;
onCommentDeleted?: (commentId: string, findingId: string) => void;
onReportFinding?: (finding: Finding) => void;
isReported?: boolean;
review?: FindingReview;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
}
export function FindingFocusView({
finding,
component,
mpn,
page,
quote,
projectId,
onExit,
escapeDisabled,
onViewReference,
checked,
onCheckedChange,
comments,
collaborators,
currentUserId,
currentUserName,
onCommentAdded,
onCommentDeleted,
onReportFinding,
isReported,
review,
onReviewSaved,
}: FindingFocusViewProps) {
const backRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
backRef.current?.focus();
}, []);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Escape" || escapeDisabled || e.defaultPrevented) return;
const el = e.target as HTMLElement | null;
if (
el &&
(el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)
) {
return;
}
onExit();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [escapeDisabled, onExit]);
return (
<div className="flex flex-col lg:flex-row lg:items-start gap-6">
<div className="flex-1 min-w-0 w-full space-y-4">
<Button ref={backRef} variant="ghost" size="sm" onClick={onExit}>
<ArrowLeft className="h-4 w-4 mr-1" />
All findings
</Button>
<div className="flex items-center gap-3">
<Badge variant="secondary" className="font-mono text-sm">
{finding.designator}
</Badge>
<span className="text-sm text-muted-foreground font-mono">{mpn}</span>
{component?.component_subtype && (
<span className="text-xs text-muted-foreground">
{subtypeLabel(component.component_subtype)}
</span>
)}
</div>
<FindingCard
finding={finding}
onViewReference={onViewReference}
checked={checked}
onCheckedChange={onCheckedChange}
comments={comments}
projectId={projectId}
collaborators={collaborators}
currentUserId={currentUserId}
currentUserName={currentUserName}
onCommentAdded={onCommentAdded}
onCommentDeleted={onCommentDeleted}
onReportFinding={onReportFinding}
isReported={isReported}
defaultOpen
review={review}
onReviewSaved={onReviewSaved}
/>
</div>
<div className="w-full lg:w-auto lg:shrink-0 lg:sticky lg:top-6 h-[70vh] lg:h-[calc(100vh-3rem)] rounded-lg border border-border bg-card overflow-hidden">
<PdfViewerPanel
projectId={projectId}
mpn={mpn}
initialPage={page}
highlightQuote={quote}
onClose={onExit}
/>
</div>
</div>
);
}
@@ -0,0 +1,198 @@
"use client";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { useCallback, useMemo } from "react";
import { FindingsTree, FindingsTreeEmpty } from "./findings-tree";
import { ReportFilters } from "./report-filters";
import { ReviewedSection } from "./reviewed-section";
import type {
Finding,
FindingComment,
FindingReview,
FindingStatus,
DesignGraph,
Collaborator,
} from "@/lib/types";
import { groupBy, getFindingKey } from "@/lib/utils";
import { isLayoutFinding } from "@/lib/layout-finding";
interface FindingsListProps {
findings: Finding[];
graph: DesignGraph;
onViewReference: (finding: Finding) => void;
projectId: string;
isReviewed: (key: string) => boolean;
toggleReviewed: (key: string) => void;
comments?: Record<string, FindingComment[]>;
collaborators?: Collaborator[];
currentUserId?: string;
currentUserName?: string;
onCommentAdded?: (comment: FindingComment) => void;
onCommentDeleted?: (commentId: string, findingId: string) => void;
onReportFinding?: (finding: Finding) => void;
reportedFindingIds?: Set<string>;
reviews?: Record<string, FindingReview>;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
}
const DEFAULT_STATUS: FindingStatus[] = ["ERROR", "WARNING", "INFO"];
export function FindingsList({
findings,
graph,
onViewReference,
projectId,
isReviewed,
toggleReviewed,
comments,
collaborators,
currentUserId,
currentUserName,
onCommentAdded,
onCommentDeleted,
onReportFinding,
reportedFindingIds,
reviews,
onReviewSaved,
}: FindingsListProps) {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const findingKeyMap = useMemo(() => {
const map = new Map<Finding, string>();
findings.forEach((f, i) => map.set(f, getFindingKey(f, i)));
return map;
}, [findings]);
const statusParam = searchParams.get("status");
const componentParam = searchParams.get("component");
const reviewParam = searchParams.get("review");
const searchParam = searchParams.get("q") ?? "";
const domainParam = searchParams.get("domain");
const statusFilters = useMemo(() => {
if (!statusParam) return new Set<FindingStatus>(DEFAULT_STATUS);
return new Set(statusParam.split(",") as FindingStatus[]);
}, [statusParam]);
const updateParams = useCallback(
(updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value === null || value === "") params.delete(key);
else params.set(key, value);
}
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
},
[searchParams, router, pathname],
);
const toggleStatus = useCallback(
(status: FindingStatus) => {
const next = new Set(statusFilters);
if (next.has(status)) next.delete(status);
else next.add(status);
const isDefault =
next.size === 3 && next.has("ERROR") && next.has("WARNING") && next.has("INFO");
updateParams({ status: isDefault ? null : Array.from(next).join(",") });
},
[statusFilters, updateParams],
);
const matchesFilters = useCallback(
(f: Finding) => {
if (!statusFilters.has(f.status)) return false;
if (componentParam && componentParam !== "all" && f.designator !== componentParam) return false;
const q = searchParam.toLowerCase();
if (q && !f.finding.toLowerCase().includes(q) && !(f.why ?? "").toLowerCase().includes(q)) {
return false;
}
if (reviewParam === "open") {
const st = f.finding_id ? reviews?.[f.finding_id]?.state : undefined;
if (st && st !== "open") return false;
}
if (domainParam === "layout") {
if (!isLayoutFinding(f)) return false;
} else if (domainParam === "schema") {
if (isLayoutFinding(f)) return false;
}
return true;
},
[statusFilters, componentParam, searchParam, reviewParam, reviews, domainParam],
);
const filtered = useMemo(
() => findings.filter((f) => matchesFilters(f) && !isReviewed(findingKeyMap.get(f)!)),
[findings, matchesFilters, findingKeyMap, isReviewed],
);
const reviewedFindings = useMemo(
() => findings.filter((f) => matchesFilters(f) && isReviewed(findingKeyMap.get(f)!)),
[findings, matchesFilters, findingKeyMap, isReviewed],
);
const designators = useMemo(() => {
const all = [...new Set(findings.map((f) => f.designator))];
const byDesignator = groupBy(findings, (f) => f.designator);
const rank = (d: string): number => {
const group = byDesignator[d] ?? [];
if (group.some((f) => f.status === "ERROR")) return 0;
if (group.some((f) => f.status === "WARNING")) return 1;
return 2;
};
return all.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
}, [findings]);
const treeProps = {
graph,
onViewReference,
findingKeys: findingKeyMap,
isReviewed,
onToggleReviewed: toggleReviewed,
comments,
projectId,
collaborators,
currentUserId,
currentUserName,
onCommentAdded,
onCommentDeleted,
onReportFinding,
reportedFindingIds,
reviews,
onReviewSaved,
};
return (
<div className="space-y-4">
<ReportFilters
statusFilters={statusFilters}
onToggleStatus={toggleStatus}
componentFilter={componentParam ?? "all"}
onComponentChange={(v) => updateParams({ component: v === "all" ? null : v })}
search={searchParam}
onSearchChange={(v) => updateParams({ q: v || null })}
needsReview={reviewParam === "open"}
onToggleNeedsReview={() =>
updateParams({ review: reviewParam === "open" ? null : "open" })
}
domain={domainParam ?? "all"}
onDomainChange={(v) => updateParams({ domain: v })}
designators={designators}
/>
{filtered.length > 0 ? (
<FindingsTree findings={filtered} {...treeProps} />
) : reviewedFindings.length === 0 && findings.length > 0 ? (
<FindingsTreeEmpty />
) : null}
{reviewedFindings.length > 0 && (
<ReviewedSection
findings={reviewedFindings}
findingKeys={findingKeyMap}
onToggleReviewed={toggleReviewed}
onViewReference={onViewReference}
/>
)}
</div>
);
}
@@ -0,0 +1,165 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { Input } from "@/components/ui/input";
import type { Collaborator } from "@/lib/types";
import { cn } from "@/lib/utils";
interface MentionInputProps {
value: string;
onChange: (value: string) => void;
onMention: (userId: string) => void;
collaborators: Collaborator[];
placeholder?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
className?: string;
}
function matchesQuery(c: Collaborator, q: string) {
const needle = q.toLowerCase();
return (
(c.name && c.name.toLowerCase().includes(needle)) ||
(c.email && c.email.toLowerCase().includes(needle))
);
}
export function MentionInput({
value,
onChange,
onMention,
collaborators,
placeholder,
onKeyDown: passthroughKeyDown,
className,
}: MentionInputProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const [atPos, setAtPos] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const hits = collaborators.filter((c) => matchesQuery(c, query));
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const next = e.target.value;
onChange(next);
const cursor = e.target.selectionStart ?? next.length;
const prefix = next.slice(0, cursor);
const at = prefix.lastIndexOf("@");
if (at >= 0 && (at === 0 || prefix[at - 1] === " ")) {
const fragment = prefix.slice(at + 1);
if (!fragment.includes(" ")) {
setAtPos(at);
setQuery(fragment);
setOpen(true);
setActiveIndex(0);
return;
}
}
setOpen(false);
},
[onChange],
);
const pick = useCallback(
(c: Collaborator) => {
const name = c.name || c.email || "user";
const head = value.slice(0, atPos);
const tail = value.slice(atPos + 1 + query.length);
onChange(`${head}@${name}${tail ? tail : " "}`);
onMention(c.user_id);
setOpen(false);
inputRef.current?.focus();
},
[value, atPos, query, onChange, onMention],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (open && hits.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => (i + 1) % hits.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => (i - 1 + hits.length) % hits.length);
return;
}
if (e.key === "Enter") {
e.preventDefault();
pick(hits[activeIndex]);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
return;
}
}
passthroughKeyDown?.(e);
},
[open, hits, activeIndex, pick, passthroughKeyDown],
);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
const t = e.target as Node;
if (menuRef.current?.contains(t) || inputRef.current?.contains(t)) return;
setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
return (
<div className={cn("relative", className)}>
<Input
ref={inputRef}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="text-xs"
/>
{open && hits.length > 0 && (
<div
ref={menuRef}
className="absolute bottom-full left-0 mb-1 w-64 rounded-lg border border-border bg-popover p-1 shadow-md z-50"
>
{hits.map((c, i) => (
<button
key={c.user_id}
type="button"
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs cursor-pointer",
i === activeIndex ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
)}
onMouseDown={(e) => {
e.preventDefault();
pick(c);
}}
onMouseEnter={() => setActiveIndex(i)}
>
{c.image_url ? (
<img src={c.image_url} alt="" className="h-5 w-5 rounded-full" />
) : (
<div className="h-5 w-5 rounded-full bg-muted flex items-center justify-center text-[10px] font-medium">
{(c.name || c.email || "?")[0].toUpperCase()}
</div>
)}
<div className="min-w-0">
{c.name && <span className="font-medium">{c.name}</span>}
{c.email && <span className="text-muted-foreground ml-1">{c.email}</span>}
</div>
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,135 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { FindingStatus } from "@/lib/types";
import { cn } from "@/lib/utils";
import { Search } from "lucide-react";
interface ReportFiltersProps {
statusFilters: Set<FindingStatus>;
onToggleStatus: (status: FindingStatus) => void;
componentFilter: string;
onComponentChange: (value: string) => void;
search: string;
onSearchChange: (value: string) => void;
designators: string[];
needsReview?: boolean;
onToggleNeedsReview?: () => void;
domain?: string;
onDomainChange?: (value: string | null) => void;
}
const STATUS_CHIPS: { key: FindingStatus; label: string; activeClass: string }[] = [
{
key: "ERROR",
label: "Error",
activeClass: "bg-rose-500/20 text-rose-600 dark:text-rose-400 border-rose-500/40",
},
{
key: "WARNING",
label: "Warning",
activeClass: "bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/40",
},
{
key: "INFO",
label: "Info",
activeClass: "bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-500/40",
},
];
const DOMAINS = ["all", "schema", "layout"] as const;
export function ReportFilters({
statusFilters,
onToggleStatus,
componentFilter,
onComponentChange,
search,
onSearchChange,
designators,
needsReview,
onToggleNeedsReview,
domain,
onDomainChange,
}: ReportFiltersProps) {
return (
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1.5">
{STATUS_CHIPS.map(({ key, label, activeClass }) => (
<Button
key={key}
variant="outline"
size="sm"
className={cn("h-8 text-xs", statusFilters.has(key) && activeClass)}
onClick={() => onToggleStatus(key)}
>
{label}
</Button>
))}
</div>
{onToggleNeedsReview && (
<Button
variant="outline"
size="sm"
className={cn(
"h-8 text-xs",
needsReview && "bg-amber-500/20 text-amber-700 dark:text-amber-400 border-amber-500/40",
)}
onClick={onToggleNeedsReview}
>
Needs review
</Button>
)}
{onDomainChange && (
<div className="flex items-center gap-1.5">
{DOMAINS.map((d) => (
<Button
key={d}
variant="outline"
size="sm"
className={cn(
"h-8 text-xs capitalize",
(domain || "all") === d && "bg-emerald-500/15 border-emerald-500/40",
)}
onClick={() => onDomainChange(d === "all" ? null : d)}
>
{d === "all" ? "All" : d === "schema" ? "Schematic" : "Layout"}
</Button>
))}
</div>
)}
<Select value={componentFilter} onValueChange={(v) => onComponentChange(v ?? "all")}>
<SelectTrigger className="w-[140px] h-8 text-xs">
<SelectValue placeholder="All components" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All components</SelectItem>
{designators.map((d) => (
<SelectItem key={d} value={d}>
<span className="font-mono">{d}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search findings..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="h-8 pl-8 text-xs"
/>
</div>
</div>
);
}
@@ -0,0 +1,90 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
interface ReportSummaryProps {
summary: Record<string, number>;
reviewedCount: number;
creditsSpent?: number;
totalCostUsd?: number | null;
}
const CELLS = [
{ key: "ERROR", label: "Error", color: "text-rose-600 dark:text-rose-400", barColor: "bg-rose-500" },
{ key: "WARNING", label: "Warning", color: "text-amber-600 dark:text-amber-400", barColor: "bg-amber-500" },
{ key: "INFO", label: "Info", color: "text-blue-600 dark:text-blue-400", barColor: "bg-blue-500" },
{ key: "total", label: "Total", color: "text-foreground", barColor: "" },
];
export function ReportSummary({
summary,
reviewedCount,
creditsSpent,
totalCostUsd,
}: ReportSummaryProps) {
const total = summary.total || 0;
const showCredits = typeof creditsSpent === "number" && creditsSpent > 0;
const showCost = typeof totalCostUsd === "number" && totalCostUsd > 0;
const extraCols = (showCredits ? 1 : 0) + (showCost ? 1 : 0);
const grid =
extraCols === 2 ? "grid-cols-7" : extraCols === 1 ? "grid-cols-6" : "grid-cols-5";
return (
<div className="space-y-4">
<div className={cn("grid gap-4", grid)}>
{CELLS.map(({ key, label, color }) => (
<Card key={key}>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">{label}</p>
<p className={cn("text-3xl font-semibold font-mono tabular-nums", color)}>
{summary[key] ?? 0}
</p>
</CardContent>
</Card>
))}
<Card>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">Checked</p>
<p className="text-3xl font-semibold font-mono tabular-nums text-emerald-600 dark:text-emerald-400">
{reviewedCount}
</p>
</CardContent>
</Card>
{showCost && (
<Card>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">API cost</p>
<p className="text-3xl font-semibold font-mono tabular-nums text-foreground">
${totalCostUsd!.toFixed(2)}
</p>
</CardContent>
</Card>
)}
{showCredits && (
<Card>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">Credits</p>
<p className="text-3xl font-semibold font-mono tabular-nums text-amber-600 dark:text-amber-400">
{creditsSpent!.toFixed(2)}
</p>
</CardContent>
</Card>
)}
</div>
{total > 0 && (
<div className="flex h-2 rounded-full overflow-hidden bg-muted">
{CELLS.filter((s) => s.key !== "total" && (summary[s.key] ?? 0) > 0).map(
({ key, barColor }) => (
<div
key={key}
className={cn("h-full", barColor)}
style={{ width: `${((summary[key] ?? 0) / total) * 100}%` }}
/>
),
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,74 @@
"use client";
import { useState } from "react";
import { ChevronRight, CircleCheck, Check } from "lucide-react";
import { Checkbox } from "@base-ui/react/checkbox";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Badge } from "@/components/ui/badge";
import { StatusBadge } from "./status-badge";
import type { Finding } from "@/lib/types";
import { cn, sortFindings } from "@/lib/utils";
interface ReviewedSectionProps {
findings: Finding[];
findingKeys: Map<Finding, string>;
onToggleReviewed: (key: string) => void;
onViewReference: (finding: Finding) => void;
}
export function ReviewedSection({
findings,
findingKeys,
onToggleReviewed,
}: ReviewedSectionProps) {
const [open, setOpen] = useState(false);
const sorted = sortFindings(findings);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex items-center gap-2 w-full py-3 group">
<ChevronRight
className={cn(
"h-4 w-4 text-emerald-600 dark:text-emerald-400 transition-transform",
open && "rotate-90",
)}
/>
<CircleCheck className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
<span className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
Reviewed ({findings.length})
</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-2 pl-7 pb-4">
{sorted.map((f) => {
const key = findingKeys.get(f);
return (
<div
key={key}
className="flex items-center gap-3 rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3 border-l-4 border-l-emerald-500"
>
<Checkbox.Root
checked={true}
onCheckedChange={() => key && onToggleReviewed(key)}
aria-label="Unmark as reviewed"
className="h-5 w-5 shrink-0 rounded border border-emerald-500/30 bg-emerald-500 flex items-center justify-center cursor-pointer transition-colors hover:bg-emerald-600"
>
<Checkbox.Indicator>
<Check className="h-3.5 w-3.5 text-white" />
</Checkbox.Indicator>
</Checkbox.Root>
<StatusBadge status={f.status} />
<Badge variant="secondary" className="font-mono text-xs shrink-0">
{f.designator}
</Badge>
<p className="text-sm text-emerald-700/70 dark:text-emerald-300/70 truncate min-w-0 flex-1">
{f.finding}
</p>
</div>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
);
}
@@ -0,0 +1,17 @@
import { Badge } from "@/components/ui/badge";
import type { FindingStatus } from "@/lib/types";
import { cn } from "@/lib/utils";
const TONE: Record<FindingStatus, string> = {
ERROR: "bg-rose-500/10 text-rose-600 border-rose-500/30 dark:bg-rose-500/15 dark:text-rose-400",
WARNING: "bg-amber-500/10 text-amber-600 border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-400",
INFO: "bg-blue-500/10 text-blue-600 border-blue-500/30 dark:bg-blue-500/15 dark:text-blue-400",
};
export function StatusBadge({ status }: { status: FindingStatus }) {
return (
<Badge variant="outline" className={cn("text-xs font-medium", TONE[status])}>
{status}
</Badge>
);
}
@@ -0,0 +1,302 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import type { PipelineStep } from "@/lib/types";
import { pipelineEventsUrl } from "@/lib/api";
import { useOptionalAuth } from "@/hooks/use-optional-auth";
const SKIPPED_DETAIL = new Set([
"already extracted",
"from library",
"no datasheet (optional)",
"specs already cached",
"specs from library",
"already resolved",
"all passives already resolved",
"reusing previous extraction",
]);
const STAGES = [
{
id: "bom_parse",
title: "Parse BOM",
description: "Classify components from BOM",
seed: [{ key: "parsing-bom", label: "Parsing BOM" }],
},
{
id: "ic_extraction",
title: "IC Datasheet Extraction",
description: "Extract pin tables from datasheets",
seed: [] as { key: string; label: string }[],
},
{
id: "simple_extraction",
title: "Component Specs Extraction",
description: "Extract specifications from discrete/simple datasheets",
seed: [] as { key: string; label: string }[],
},
{
id: "passive_extraction",
title: "Passive Pattern Extraction",
description: "Resolve passive component values",
seed: [] as { key: string; label: string }[],
},
{
id: "graph_build",
title: "Build Design Graph",
description: "Combine netlist, BOM, and extracted data",
seed: [{ key: "building-graph", label: "Building graph" }],
},
{
id: "validation",
title: "Review Design",
description: "Review each IC against its datasheet",
seed: [] as { key: string; label: string }[],
},
] as const;
const INDEX: Record<string, number> = Object.fromEntries(STAGES.map((s, i) => [s.id, i]));
function emptySteps(): PipelineStep[] {
return STAGES.map((s) => ({
title: s.title,
description: s.description,
status: "pending" as const,
substeps: s.seed.map((ss) => ({ ...ss, status: "pending" as const })),
}));
}
export interface AutoTopupFailure {
reason: string;
amount_usd?: number;
ts: number;
}
export interface PipelinePaused {
reason?: string;
last_completed?: string | null;
stage?: string | null;
unit_id?: string | null;
completed_review_refs?: string[];
pending_review_refs?: string[];
ts: number;
}
export interface CreditsUpdate {
credits_spent: number;
balance_after: number;
delta: number;
stage?: string | null;
unit_id?: string | null;
ts: number;
}
const SSE_NAMES = [
"step_update",
"pipeline_complete",
"pipeline_error",
"pipeline_cancelled",
"pipeline_paused",
"auto_topup_failed",
"credits_update",
"heartbeat",
];
export function usePipelineProgress(projectId: string | null) {
const [steps, setSteps] = useState<PipelineStep[]>(emptySteps);
const [done, setDone] = useState(false);
const [cancelled, setCancelled] = useState(false);
const [error, setError] = useState<string | null>(null);
const [summary, setSummary] = useState<Record<string, number> | null>(null);
const [autoTopupFailure, setAutoTopupFailure] = useState<AutoTopupFailure | null>(null);
const [credits, setCredits] = useState<CreditsUpdate | null>(null);
const [paused, setPaused] = useState<PipelinePaused | null>(null);
const [started, setStarted] = useState(false);
const esRef = useRef<EventSource | null>(null);
const terminalRef = useRef(false);
const { getToken } = useOptionalAuth();
const finish = () => {
terminalRef.current = true;
esRef.current?.close();
};
const handleEvent = useCallback((event: MessageEvent) => {
const kind = event.type || "message";
if (kind === "heartbeat") return;
let payload: Record<string, unknown>;
try {
payload = JSON.parse(event.data);
} catch {
return;
}
const id = event.lastEventId;
if (kind === "pipeline_complete" || id === "pipeline_complete") {
setSummary(payload.summary as Record<string, number>);
setSteps((prev) =>
prev.map((s) => ({
...s,
status: "complete" as const,
substeps: s.substeps.map((ss) => ({ ...ss, status: "complete" as const })),
})),
);
setDone(true);
finish();
return;
}
if (kind === "pipeline_cancelled" || id === "pipeline_cancelled") {
setCancelled(true);
setDone(true);
finish();
return;
}
if (kind === "pipeline_error" || id === "pipeline_error") {
setError(payload.error as string);
setDone(true);
finish();
return;
}
if (kind === "pipeline_paused" || id === "pipeline_paused") {
setPaused({
reason: (payload.reason as string | undefined) ?? "insufficient_credits",
last_completed: (payload.last_completed as string | null | undefined) ?? null,
stage: (payload.stage as string | null | undefined) ?? null,
unit_id: (payload.unit_id as string | null | undefined) ?? null,
completed_review_refs: (payload.completed_review_refs as string[] | undefined) ?? [],
pending_review_refs: (payload.pending_review_refs as string[] | undefined) ?? [],
ts: Date.now(),
});
finish();
return;
}
if (kind === "credits_update") {
setCredits({
credits_spent: Number(payload.credits_spent) || 0,
balance_after: Number(payload.balance_after) || 0,
delta: Number(payload.delta) || 0,
stage: (payload.stage as string | null | undefined) ?? null,
unit_id: (payload.unit_id as string | null | undefined) ?? null,
ts: Date.now(),
});
return;
}
if (kind === "auto_topup_failed") {
setAutoTopupFailure({
reason: (payload.reason as string) ?? "unknown",
amount_usd: payload.amount_usd as number | undefined,
ts: Date.now(),
});
return;
}
setStarted(true);
const stage = payload.stage as string;
const substep = payload.substep as string | undefined;
const status = payload.status as "pending" | "running" | "complete" | "failed";
const detail = payload.detail as string | undefined;
setSteps((prev) => {
const next = prev.map((s) => ({
...s,
substeps: s.substeps.map((ss) => ({ ...ss })),
}));
const idx = INDEX[stage];
if (idx === undefined) return next;
const step = next[idx];
if (!substep) {
if (status === "running") {
step.status = "running";
const totalNew = payload.total_new as number | undefined;
if (typeof totalNew === "number") step.totalNew = totalNew;
} else if (status === "complete") {
step.status = "complete";
for (const ss of step.substeps) ss.status = "complete";
}
if (step.substeps.length === 1) {
step.substeps[0].status = status === "failed" ? "complete" : status;
}
return next;
}
let sub = step.substeps.find((s) => s.key === substep);
if (!sub) {
sub = { key: substep, label: substep, status: "pending" };
step.substeps.push(sub);
}
sub.status = status === "failed" ? "complete" : status;
if (detail) sub.label = `${substep}${detail}`;
if (status === "running") sub.cached = false;
else if ((status === "complete" || status === "failed") && detail && SKIPPED_DETAIL.has(detail)) {
sub.cached = true;
}
if (step.substeps.some((s) => s.status === "running")) step.status = "running";
if (step.substeps.every((s) => s.status === "complete")) step.status = "complete";
return next;
});
}, []);
useEffect(() => {
if (!projectId) return;
let es: EventSource | null = null;
let retries = 0;
const MAX_RETRIES = 50;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let closed = false;
async function connect() {
if (closed) return;
if (es) {
es.close();
es = null;
}
const token = await getToken();
const baseUrl = pipelineEventsUrl(projectId!);
const url = token ? `${baseUrl}?token=${token}` : baseUrl;
es = new EventSource(url);
esRef.current = es;
for (const name of SSE_NAMES) {
es.addEventListener(name, (event: MessageEvent) => {
retries = 0;
handleEvent(event);
});
}
es.onerror = () => {
if (closed || terminalRef.current) return;
es?.close();
es = null;
esRef.current = null;
if (retries >= MAX_RETRIES) {
setError("Lost connection to pipeline. Please refresh the page to reconnect.");
setDone(true);
return;
}
retries++;
reconnectTimer = setTimeout(connect, 30_000);
};
}
connect();
return () => {
closed = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
es?.close();
esRef.current = null;
};
}, [projectId, getToken, handleEvent]);
return { steps, done, cancelled, error, summary, autoTopupFailure, credits, started, paused };
}
@@ -0,0 +1,63 @@
"use client";
import { useEffect, useState } from "react";
import { fetchGraph, fetchReport } from "@/lib/api";
import type { DesignGraph, ValidationReport } from "@/lib/types";
const MAX_ATTEMPTS = 4;
function loadFailureMessage(err: unknown): string {
if (err instanceof TypeError) {
return "Could not reach the Periscope API. Check that the backend is running and that /api is proxied to it.";
}
return err instanceof Error ? err.message : "Failed to load report";
}
function isTransient(err: unknown): boolean {
if (err instanceof TypeError) return true;
const msg = err instanceof Error ? err.message : "";
return msg.includes("not found");
}
export function useReport(projectId: string) {
const [report, setReport] = useState<ValidationReport | null>(null);
const [graph, setGraph] = useState<DesignGraph | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
(async () => {
let lastErr: unknown;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
try {
const [nextReport, nextGraph] = await Promise.all([
fetchReport(projectId),
fetchGraph(projectId),
]);
if (!cancelled) {
setReport(nextReport);
setGraph(nextGraph);
}
return;
} catch (err) {
lastErr = err;
if (!isTransient(err) || attempt === MAX_ATTEMPTS - 1) break;
await new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1)));
}
}
if (!cancelled) setError(loadFailureMessage(lastErr));
})().finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [projectId]);
return { report, graph, loading, error };
}
@@ -0,0 +1,47 @@
"""Leftover frontend pages resolve from periscope/src."""
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "periscope" / "src" / "frontend" / "src"
def test_rewritten_pages_are_src():
rels = [
"app/(app)/layout.tsx",
"app/(app)/dashboard/page.tsx",
"app/(app)/project/[id]/page.tsx",
"app/(app)/project/[id]/report/page.tsx",
"app/(app)/project/[id]/progress/page.tsx",
"app/(app)/admin/page.tsx",
"app/(app)/feedback/page.tsx",
"app/(marketing)/page.tsx",
"app/(marketing)/contact/page.tsx",
"app/(marketing)/changelog/page.tsx",
"app/(marketing)/privacy/page.tsx",
"app/(marketing)/terms/page.tsx",
"app/(marketing)/file-guide/page.tsx",
"app/layout.tsx",
"components/layout/sidebar.tsx",
"hooks/use-pipeline-progress.ts",
"hooks/use-report.ts",
]
for rel in rels:
path = SRC / rel
assert path.is_file(), rel
head = path.read_text(encoding="utf-8")[:400]
assert "Native Periscope overlay" not in head
def test_landing_keeps_pricing_seam_import():
text = (SRC / "app/(marketing)/page.tsx").read_text(encoding="utf-8")
assert "PricingSection" in text
assert "PRICING_NAV_LINK" in text
def test_root_layout_keeps_auth_and_analytics_seams():
text = (SRC / "app/layout.tsx").read_text(encoding="utf-8")
assert "ClerkThemeProvider" in text
assert "RedditPixel" in text