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:
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'll email you when it'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 ·{" "}
|
||||
{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’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>
|
||||
© {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">
|
||||
© {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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user