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