Pinscope open-source core
Agentic schematic validation: datasheet extraction via Claude Console Skills, netlist/BOM design graph, per-IC direct datasheet review with page citations, capacitor derating, Next.js report UI. Extracted from the Pinscope cloud codebase. Auth and billing live in the private gateway repo behind stable seams (billing_hook.py, adapter files listed in CLAUDE.md).
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { fetchSurveyStatus, submitSurvey } from "@/lib/api";
|
||||
|
||||
const REFERRAL_OPTIONS = [
|
||||
{ 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 PROFILE_OPTIONS = [
|
||||
{ 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 [referral, setReferral] = useState<string | null>(null);
|
||||
const [profile, setProfile] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSurveyStatus().then((s) => {
|
||||
if (!s.completed) setOpen(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!referral || !profile) return;
|
||||
setSubmitting(true);
|
||||
await submitSurvey({
|
||||
referral_source: referral,
|
||||
user_profile: profile,
|
||||
});
|
||||
setSubmitting(false);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Welcome to Pinscope</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={referral}
|
||||
onValueChange={(val) => setReferral(val)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select one..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REFERRAL_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="profile">What best describes you?</Label>
|
||||
<Select
|
||||
value={profile}
|
||||
onValueChange={(val) => setProfile(val)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select one..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROFILE_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!referral || !profile || submitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{submitting ? "Saving..." : "Continue"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { RotateCcw, Trash2, Users } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
||||
import { deleteProject } from "@/lib/api";
|
||||
import type { Project } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useReviewedCount } from "@/hooks/use-reviewed-count";
|
||||
|
||||
const STATUS_STYLES: 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",
|
||||
};
|
||||
|
||||
export function ProjectCard({
|
||||
project,
|
||||
onDeleted,
|
||||
onRerun,
|
||||
}: {
|
||||
project: Project;
|
||||
onDeleted?: () => void;
|
||||
onRerun?: (project: Project) => void;
|
||||
}) {
|
||||
const { user } = useOptionalUser();
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const { summary } = project;
|
||||
const total = summary?.total ?? 0;
|
||||
const isShared = project.userId != null && user?.id != null && project.userId !== user.id;
|
||||
const checkedCount = useReviewedCount(project.id);
|
||||
const isCancelled = project.status === "cancelled";
|
||||
const isDraft = project.status === "draft";
|
||||
const opensModalOnClick = isDraft && !isShared && onRerun != null;
|
||||
|
||||
async function handleDelete(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!confirm(`Delete "${project.name}"?`)) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteProject(project.id);
|
||||
onDeleted?.();
|
||||
} catch {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRerun(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onRerun?.(project);
|
||||
}
|
||||
|
||||
const card = (
|
||||
<Card
|
||||
className={cn(
|
||||
"h-full transition-colors",
|
||||
isCancelled
|
||||
? "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">
|
||||
{isShared && (
|
||||
<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_STYLES[project.status])}>
|
||||
{project.status}
|
||||
</Badge>
|
||||
{isCancelled && !isShared && onRerun && (
|
||||
<button
|
||||
onClick={handleRerun}
|
||||
title="Rerun project"
|
||||
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>
|
||||
)}
|
||||
{!isShared && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
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 && total > 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>
|
||||
{checkedCount > 0 && (
|
||||
<span className="text-emerald-600 dark:text-emerald-400">{checkedCount} 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) / total) * 100}%` }} />
|
||||
)}
|
||||
{(summary.WARNING ?? 0) > 0 && (
|
||||
<div className="h-full bg-amber-500" style={{ width: `${((summary.WARNING ?? 0) / total) * 100}%` }} />
|
||||
)}
|
||||
{(summary.INFO ?? 0) > 0 && (
|
||||
<div className="h-full bg-blue-500" style={{ width: `${((summary.INFO ?? 0) / total) * 100}%` }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(!summary || total === 0) && (
|
||||
<p className="text-xs text-muted-foreground">No report yet</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
if (isCancelled) return card;
|
||||
|
||||
if (opensModalOnClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRerun?.(project)}
|
||||
className="text-left w-full"
|
||||
>
|
||||
{card}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Open the report by default — that's what users want to see for a finished
|
||||
// project. In-flight or paused runs go to the progress page where the SSE
|
||||
// stepper and resume controls live.
|
||||
const inFlight =
|
||||
project.status === "running"
|
||||
|| project.status === "paused_insufficient_credits"
|
||||
|| project.status === "paused_by_user";
|
||||
const href = inFlight
|
||||
? `/project/${project.id}/progress`
|
||||
: `/project/${project.id}/report`;
|
||||
return <Link href={href}>{card}</Link>;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { RotateCcw, Trash2, Users } from "lucide-react";
|
||||
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { deleteProject } from "@/lib/api";
|
||||
import type { Project } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useReviewedCount } from "@/hooks/use-reviewed-count";
|
||||
|
||||
const STATUS_STYLES: 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",
|
||||
};
|
||||
|
||||
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) => (
|
||||
<ProjectRow
|
||||
key={p.id}
|
||||
project={p}
|
||||
onDeleted={() => onDeleted?.(p.id)}
|
||||
onRerun={onRerun}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectRow({
|
||||
project,
|
||||
onDeleted,
|
||||
onRerun,
|
||||
}: {
|
||||
project: Project;
|
||||
onDeleted?: () => void;
|
||||
onRerun?: (project: Project) => void;
|
||||
}) {
|
||||
const { user } = useOptionalUser();
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const { summary } = project;
|
||||
const total = summary?.total ?? 0;
|
||||
const isShared = project.userId != null && user?.id != null && project.userId !== user.id;
|
||||
const checkedCount = useReviewedCount(project.id);
|
||||
const isCancelled = project.status === "cancelled";
|
||||
const isDraft = project.status === "draft";
|
||||
const opensModalOnClick = isDraft && !isShared && onRerun != null;
|
||||
|
||||
async function handleDelete(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!confirm(`Delete "${project.name}"?`)) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteProject(project.id);
|
||||
onDeleted?.();
|
||||
} catch {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRerun(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onRerun?.(project);
|
||||
}
|
||||
|
||||
const inFlight =
|
||||
project.status === "running"
|
||||
|| project.status === "paused_insufficient_credits"
|
||||
|| project.status === "paused_by_user";
|
||||
const href = inFlight
|
||||
? `/project/${project.id}/progress`
|
||||
: `/project/${project.id}/report`;
|
||||
|
||||
const nameCell = (
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="truncate font-medium">{project.name}</span>
|
||||
{isShared && (
|
||||
<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 wrappedName = isCancelled ? (
|
||||
nameCell
|
||||
) : opensModalOnClick ? (
|
||||
<button type="button" onClick={() => onRerun?.(project)} className="text-left w-full hover:underline">
|
||||
{nameCell}
|
||||
</button>
|
||||
) : (
|
||||
<Link href={href} className="block hover:underline">
|
||||
{nameCell}
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
"border-t border-border transition-colors",
|
||||
isCancelled ? "opacity-70" : "hover:bg-muted/30",
|
||||
)}
|
||||
>
|
||||
<td className="px-3 py-2 max-w-0">{wrappedName}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant="outline" className={cn("text-xs capitalize", STATUS_STYLES[project.status])}>
|
||||
{project.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{summary && total > 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>
|
||||
{checkedCount > 0 && (
|
||||
<span className="text-emerald-600 dark:text-emerald-400">{checkedCount} ✓</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">
|
||||
{isCancelled && !isShared && onRerun && (
|
||||
<button
|
||||
onClick={handleRerun}
|
||||
title="Rerun project"
|
||||
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>
|
||||
)}
|
||||
{!isShared && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user