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).
@@ -0,0 +1,44 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# clerk configuration (can include secrets)
|
||||
/.clerk/
|
||||
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1,102 @@
|
||||
@AGENTS.md
|
||||
|
||||
# Pinscope Frontend
|
||||
|
||||
Next.js 16 app (App Router, Turbopack) providing a web UI for Pinscope schematic validation. Talks to the FastAPI backend at `localhost:8000`.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Next.js 16** with App Router, Tailwind CSS v4, shadcn/ui (Base UI primitives, not Radix)
|
||||
- **Route groups**: `(app)` for app routes (dashboard, projects, admin), `(marketing)` for public pages (landing, contact, privacy, terms)
|
||||
- **Backend integration**: `src/lib/api.ts` fetches from `NEXT_PUBLIC_API_URL` (defaults to `http://localhost:8000`)
|
||||
- **SSE for pipeline progress**: Streams events from `GET /api/pipeline/{id}/events`
|
||||
- **Sidebar navigation**: Project pages use sidebar nav with tabs via URL query params (`?tab=bom|derating|power|logs|settings`)
|
||||
- **Power tree visualization**: Interactive graph via React Flow (`@xyflow/react`) + dagre layout
|
||||
- **Project collaborators**: Email-based invites, shared access badges, owner/member roles
|
||||
- **Comments on findings**: Reviewers can leave threaded comments with `@mention` support on any finding card
|
||||
|
||||
## Open-core seams
|
||||
|
||||
A handful of files are gateway-owned stubs the hosted-cloud repo replaces
|
||||
with Clerk/billing implementations. Keep their export signatures stable and
|
||||
never import auth/billing SDKs elsewhere:
|
||||
|
||||
- `src/proxy.ts` — pass-through middleware here
|
||||
- `src/hooks/use-optional-auth.ts` — always the local admin user here
|
||||
- `src/components/theme/clerk-theme-provider.tsx` — pass-through here
|
||||
- `src/components/billing/credits-context.tsx` — `useCredits()` always null here
|
||||
- `src/components/billing/paused-run-banner.tsx` — renders null here
|
||||
- `src/components/layout/sidebar-auth.tsx` — renders null here
|
||||
- `src/components/marketing/pricing-section.tsx` — renders null here
|
||||
- `src/components/analytics/*` — render null here
|
||||
- `src/lib/csp-hosts.ts` — empty allow-lists here
|
||||
|
||||
Read auth state only through `useOptionalAuth()`/`useOptionalUser()`, and
|
||||
credit state only through `useCredits()` — both are inert in this repo.
|
||||
|
||||
## Key Paths
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `src/lib/types.ts` | TS types mirroring `pinscopex/models.py` |
|
||||
| `src/lib/api.ts` | All data fetching — single integration point with backend |
|
||||
| `src/lib/mock-data.ts` | Pipeline step definitions for progress UI |
|
||||
| `src/components/report/` | Report viewer components + power tree React Flow graph + derating table + finding comments |
|
||||
| `src/components/progress/` | Pipeline progress stepper |
|
||||
| `src/components/dashboard/` | Project card + create-project dialog |
|
||||
| `src/components/upload/` | File upload components |
|
||||
| `src/components/layout/` | Sidebar and layout shells |
|
||||
| `src/components/pdf/` | PDF viewer (uses `react-pdf` for in-browser datasheet viewing) |
|
||||
| `src/components/legal/` | Shared footer / legal-page scaffolding |
|
||||
| `src/components/ui/` | shadcn primitives — Base UI, not Radix |
|
||||
| `src/hooks/` | `use-auth-api`, `use-pipeline-progress`, `use-report`, `use-reviewed-count`, `use-reviewed-findings` |
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Page |
|
||||
|---|---|
|
||||
| `/` | Marketing landing page |
|
||||
| `/contact`, `/privacy`, `/terms` | Marketing static pages |
|
||||
| `/dashboard` | Project grid |
|
||||
| `/project/[id]` | Project detail — tabbed: Project (uploads), BOM, Derating, Power, Logs, Settings |
|
||||
| `/project/[id]/report` | Report viewer — findings grouped by component, filters in URL params, threaded comments |
|
||||
| `/project/[id]/progress` | Pipeline progress — SSE-driven stepper |
|
||||
| `/admin` | Admin dashboard — tabbed: Components, Users, Usage, Projects, Runs, Settings |
|
||||
|
||||
## shadcn/ui: Base UI, Not Radix
|
||||
|
||||
This project uses **Base UI** primitives (`@base-ui/react`), not Radix. Key differences:
|
||||
- No `asChild` prop — use `render={<Component />}` instead for composition
|
||||
- `Select.onValueChange` signature is `(value: string | null, details) => void`
|
||||
- `CollapsibleTrigger` renders its children directly, no slot forwarding needed
|
||||
|
||||
Always check `src/components/ui/*.tsx` for the actual component API before using a shadcn component.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. Client components call functions from `src/lib/api.ts`
|
||||
2. `api.ts` calls the FastAPI backend (`/api/projects`, `/api/report/{id}`, `/api/graph/{id}`, etc.)
|
||||
3. File uploads (BOM, netlist, datasheets) use multipart form-data with MPN query param for datasheets; datasheet uploads support `also_for` for multi-MPN sharing
|
||||
4. Pipeline progress streams via SSE; polling fallback at `/api/pipeline/{id}/status`; cancel via `cancelPipeline()`
|
||||
5. BOM summary: `fetchBomSummary()`; derating: `fetchDerating()`; power tree: `fetchPowerTree()`; logs: `fetchProjectLogs()`
|
||||
6. Comments: `addComment()`, `deleteComment()` on finding cards
|
||||
7. Collaborators: `fetchCollaborators()`, `addCollaborator()`, `removeCollaborator()`
|
||||
8. DigiKey: `autoResolveSimple()`, `fetchDigikeyDatasheet()`
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev # starts on localhost:3000
|
||||
npm run build # production build (verifies types)
|
||||
```
|
||||
|
||||
Requires the backend running at `localhost:8000` (or set `NEXT_PUBLIC_API_URL`).
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Keep all data fetching in `src/lib/api.ts` — don't scatter fetch calls across components
|
||||
- Report filters persist in URL search params (`?status=ERROR&component=U3&q=decoupling`)
|
||||
- When modifying types, keep `src/lib/types.ts` in sync with `backend/pinscopex/models.py`
|
||||
- Use `font-mono` for technical values: designators (U1), MPNs, pin names, component values
|
||||
- Status colors: emerald = PASS, amber = WARNING, rose = ERROR, blue = accent/active
|
||||
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
# Changelog
|
||||
|
||||
What's new in Pinscope.
|
||||
|
||||
## 2.6.0 — 2026-07-12 — Export Report to Excel
|
||||
|
||||
Download a project's findings as an Excel spreadsheet straight from the report — one click, ready to share, filter, or archive outside Pinscope.
|
||||
|
||||
- [New] "Export Excel" button on the validation report. Every finding becomes a spreadsheet row — designator, part number, ID, severity, title, description, recommendation, and its datasheet source (page included) — sorted most-severe first.
|
||||
|
||||
## 2.5.1 — 2026-07-04 — More Thorough Reviews
|
||||
|
||||
Schematic review now works through every functional area of a component before finishing, so a part with several independent issues has all of them surfaced in one pass instead of just the first.
|
||||
|
||||
- [Improved] For each IC, the review covers power and decoupling, every signal interface, absolute-maximum ratings, and reset/boot/configuration and unused pins before reporting — catching multiple issues on the same component that could previously be missed.
|
||||
|
||||
## 2.5.0 — 2026-07-02 — Light Mode
|
||||
|
||||
Pinscope now has a light theme. Toggle between light and dark with the sun/moon button — in the sidebar next to your account menu, or in the header on the website.
|
||||
|
||||
- [New] Theme toggle. Switch between light and dark mode anywhere in the app; your choice is remembered on this device. Everything defaults to dark, exactly as before, until you flip it.
|
||||
- [Improved] Every status color — error, warning, and pass badges, finding cards, the progress view, billing — is tuned for both themes, so reports stay legible either way.
|
||||
- [Improved] The sign-in page and account menu now follow the app theme instead of always rendering light.
|
||||
|
||||
## 2.4.0 — 2026-07-01 — Automatic Pin & LED Current Checks
|
||||
|
||||
Two datasheet-grounded checks now run on every project, independent of the schematic review — catching a swapped-peripheral pin or an over-driven LED — plus a clear list of any components that had no datasheet to review against.
|
||||
|
||||
- [New] Pin-function feasibility check. Pinscope now flags when a net assigns an IC pin a peripheral function its silicon can't route — for example a `UART5_TX` net on a pin whose alternate-function table only offers `UART5_RX`. It's reported as an error straight from the datasheet's pin table and names the likely swap (TX↔RX, SDA↔SCL). It deliberately does not judge signal *direction* across an interface — a direct UART crosses TX↔RX while a transceiver runs straight through — so it only fires on physically impossible pin assignments, never on wiring style.
|
||||
- [New] LED forward-current check. For each LED, Pinscope computes the forward current from the supply rail, the series resistor, and the LED's rated forward voltage, and flags any channel whose current exceeds the LED's rated maximum. Each color of an RGB LED is checked separately, and a leg with no current-limiting resistor at all is called out as a caution.
|
||||
- [New] "Not reviewed" list on the report. Components with no datasheet on file — for instance a do-not-populate footprint that isn't in the BOM — are now called out explicitly, so a mis-wired pin on an unreviewed part shows up as a known gap instead of being silently absent.
|
||||
- [New] Findings from these automatic checks carry an "Automated check" badge, so they're easy to tell apart from datasheet-review findings.
|
||||
|
||||
## 2.3.3 — 2026-06-08 — Faster Reviews
|
||||
|
||||
Multi-chip designs now review several times faster — Pinscope works through ICs in parallel instead of one at a time.
|
||||
|
||||
- [Improved] Datasheet extraction and schematic review now process multiple ICs at once, so reports on multi-IC projects come back substantially faster. The findings are unchanged — only the wait is shorter.
|
||||
|
||||
## 2.3.2 — 2026-05-26 — Smarter RF Topology Review
|
||||
|
||||
Schematic review now reasons about *what each external part is for* before flagging it — catching valid bias, coupling, and matching circuits that previously looked like errors.
|
||||
|
||||
- [Improved] The reviewer states the role of every external part on an IC pin (choke, blocking cap, divider, decoupling, matching) before judging the connection. Common RF topologies like bias-T (DC injected onto a coax through a choke, with the chip protected by an internal DC block and a downstream load doing the actual draw) are no longer flagged as errors against the chip.
|
||||
- [Improved] Stricter absolute-maximum-rating checks: the cited limit must come from the same pin under stress (a Vdd abs-max no longer counts against an RF or signal pin), and the inequality must be a strict exceed — equal-to-abs-max is at most a Warning.
|
||||
- [Improved] Single-concern deep dives are capped at two follow-up queries; concerns that can't be resolved in that budget are reported as Warnings with the unresolved question stated, so one suspect finding can't starve the rest of the IC review.
|
||||
- [Improved] Inferred rail voltages from the power-tree pass are no longer treated as ground truth by the schematic reviewer. Voltages set by net name (`+5V`, `+3V3`) or by user power-source hints are trusted as before; voltages the power-tree LLM guessed for an adjustable regulator output or propagated through inference are kept on the power-tree view for reference but excluded from review reasoning, so a single misread rail can't anchor a false-positive Error.
|
||||
- [Improved] After each IC's review, a second pass normalizes findings against a fixed Error/Warning/Info rubric and merges any two findings that share a single root cause (e.g. "series resistor drops VIN" and "VOUT setpoint exceeds available VIN" are one defect, not two). Cuts run-to-run severity drift and avoids inflating the error count when one defect can be described from multiple angles.
|
||||
|
||||
## 2.3.1 — 2026-05-25 — EDIF Netlist Support
|
||||
|
||||
EDIF 2.0.0 netlists upload alongside PADS-PCB, with a sub-design picker for files that contain more than one design. Schematic review is also more cautious about polarity / direction-control findings.
|
||||
|
||||
- [New] Upload EDIF 2.0.0 (`.edn`) netlists directly. Format is auto-detected from the file contents — no need to convert to PADS-PCB first. Verified against Siemens xDX Designer exports.
|
||||
- [New] When an EDIF file contains multiple sub-designs, project setup shows a picker so you can choose which one to review. The picker auto-confirms when there's a single clean match against your BOM and only asks when it's ambiguous; unselected sub-designs are filtered out of the design graph.
|
||||
- [Improved] Stronger verification of differential and polarity pin assignments (USB D+/D−, TX/RX, IN+/IN−, anode/cathode) directly against the datasheet.
|
||||
- [Improved] Improved support for bidirectional buffers and level translators (74xx245 and friends) — direction-control truth tables are factored into bus-contention analysis.
|
||||
- [Improved] Findings that share a single root cause on the same chip are grouped into one combined finding.
|
||||
|
||||
## 2.3.0 — 2026-05-24 — LCSC Part Number Support
|
||||
|
||||
JLCPCB-style BOMs with LCSC part numbers (e.g. `C12044`) now work out of the box — Pinscope auto-detects the column, resolves each id to the real manufacturer part number, and shows you what it resolved to before the pipeline runs.
|
||||
|
||||
- [New] LCSC part numbers in the manufacturer part number column are auto-detected at BOM upload and converted to real MPNs. Works with JLCPCB / EasyEDA exports without any column renaming.
|
||||
- [New] Project setup now shows the LCSC → MPN mapping on each IC row in the datasheet step (e.g. `C12044 → TP4057-42-SOT26-R`), so you can see what each LCSC id became before the pipeline starts.
|
||||
- [New] Passive specs (value, voltage, tolerance, dielectric, package) are resolved from the LCSC catalog during project setup, with per-row progress and status — you see what's resolved before spending credits on the full pipeline.
|
||||
- [Improved] Datasheet auto-fetch hit rate is dramatically higher on LCSC BOMs, because DigiKey now sees real MPNs instead of `C…` ids.
|
||||
|
||||
## 2.2.1 — 2026-05-22 — Easier Netlist Uploads & Xpedition Support
|
||||
|
||||
Tabbed file upload guide with per-tool instructions, Xpedition coverage, and direct `.net` / `.txt` uploads.
|
||||
|
||||
- [New] Documentation for exporting a PADS-PCB netlist from Siemens Xpedition Designer / DxDesigner (VX.2.x, including VX.2.14).
|
||||
- [Improved] File upload guide reorganized into tabs — KiCad, Altium, OrCAD/Allegro, Xpedition, EasyEDA, and Eagle each get their own panel.
|
||||
- [Improved] Netlist uploads now accept `.asc`, `.net`, `.NET`, and `.txt` directly — no more renaming required before upload.
|
||||
- [Improved] File guide now calls out the difference between the PADS-PCB schematic netlist Pinscope needs and the `!PADS-POWERPCB` PCB-layout dump that some EDA tools also save as `.asc`.
|
||||
|
||||
## 2.2.0 — 2026-05-20 — Cross-chip Datasheet Review
|
||||
|
||||
The reviewer now reads neighbor-chip datasheets to verify cross-chip constraints, with fewer false errors when a spec can't be confirmed.
|
||||
|
||||
- [Improved] Schematic review now cross-references connected chips: when an issue depends on a neighbor's spec (5V tolerance, absolute-max, drive strength), the reviewer pulls the relevant pages from that chip's datasheet before flagging it.
|
||||
- [Improved] Fewer false errors on cross-chip findings: if a counterpart spec can't be confirmed from the datasheet, the issue is reported as a Warning with the unverified assumption stated — instead of being overstated as an Error.
|
||||
- [Fixed] Some review findings could occasionally fail to appear in the report.
|
||||
|
||||
## 2.1.0 — 2026-05-19 — Datasheet Reference Highlighting
|
||||
|
||||
Datasheet citations now highlight the exact supporting sentence on the PDF page, with more reliable page numbers on large datasheets.
|
||||
|
||||
- [Improved] Datasheet references now highlight the exact supporting sentence on the PDF page, not just the page number.
|
||||
- [Fixed] Datasheet citations landing on the wrong page for large (multi-hundred-page) datasheets.
|
||||
- [Fixed] Reviewed findings losing their checked state on page refresh.
|
||||
|
||||
## 2.0.1 — 2026-05-01 — Flagging & Onboarding
|
||||
|
||||
One-click flags on finding cards, an onboarding survey for new users, and small UI polish.
|
||||
|
||||
- [New] Report findings with one click via the flag button on any finding card.
|
||||
- [New] Onboarding survey for new users to help us improve the product.
|
||||
- [Improved] Comment input box now fills available width.
|
||||
|
||||
## 2.0.0 — 2026-04-29 — Public Changelog
|
||||
|
||||
- [New] Initial public changelog.
|
||||
@@ -0,0 +1,182 @@
|
||||
# File Upload Guide
|
||||
|
||||
Pinscope needs two files from your EDA tool to review a design:
|
||||
|
||||
- A **PADS-PCB ASCII netlist** or an **EDIF 2.0.0 netlist** — the circuit's connectivity. Pinscope accepts `.asc`, `.net`, `.NET`, `.txt` (PADS-PCB) and `.edn`, `.edif`, `.edf` (EDIF); the format is auto-detected from the file's first bytes.
|
||||
- A **Bill of Materials** (CSV or XLSX) — mapping each reference designator to a manufacturer part number.
|
||||
|
||||
## Example files
|
||||
|
||||
New to Pinscope? Here's a complete set of files from [Phil's Lab](https://www.youtube.com/@PhilsLab)' KiCad 9 TI MSPM0 tutorial you can download and upload as a starter project:
|
||||
|
||||
- [TI-MSP-KICAD9-TUTORIAL.asc](/examples/TI-MSP-KICAD9-TUTORIAL.asc) — netlist
|
||||
- [TI-MSP-KICAD9-TUTORIAL.csv](/examples/TI-MSP-KICAD9-TUTORIAL.csv) — BOM
|
||||
- [TI-MSP-KICAD9-TUTORIAL-SCHEMATIC.pdf](/examples/TI-MSP-KICAD9-TUTORIAL-SCHEMATIC.pdf) — schematic (for your reference; Pinscope doesn't need this)
|
||||
|
||||
The BOM below shows the shape Pinscope is looking for.
|
||||
|
||||
## The BOM
|
||||
|
||||
Pinscope auto-detects BOM columns by header name. After upload you'll confirm which column holds designators and which holds part numbers, so the exact header names don't matter — only that these columns exist.
|
||||
|
||||
**Required**
|
||||
|
||||
- **Designator / Reference** — one row per part, or grouped references like `C1,C2,C5` in a single row (Pinscope expands these automatically).
|
||||
- **Manufacturer Part Number (MPN)** — the full orderable part number. Pinscope uses this to look up datasheets, so `10uF 0805` on its own is **not** enough — it needs e.g. `GRM21BR61C106KE15L`.
|
||||
|
||||
**Recommended**
|
||||
|
||||
- **Value** or **Comment** — passive values (`10uF`, `4.7k`, `8MHz`). Used for passive value resolution and mismatch detection when the MPN can't be matched.
|
||||
- **Footprint** — used to enrich the design graph.
|
||||
|
||||
CSV and XLSX both work. For XLSX, the first worksheet is used.
|
||||
|
||||
## The Netlist
|
||||
|
||||
Pinscope accepts either a **PADS-PCB ASCII netlist** or an **EDIF 2.0.0 netlist** — the upload form auto-detects which one you sent based on the file's first bytes, so you don't have to pick a format.
|
||||
|
||||
### PADS-PCB ASCII
|
||||
|
||||
This is the default format most EDA tools can export. It looks like this:
|
||||
|
||||
```text
|
||||
*PADS-PCB*
|
||||
*PART*
|
||||
U1 LQFP48
|
||||
C1 0402
|
||||
...
|
||||
*NET*
|
||||
*SIGNAL* GND
|
||||
U1.1 U1.48 C1.2
|
||||
*SIGNAL* VCC
|
||||
U1.24 C1.1
|
||||
...
|
||||
*END*
|
||||
```
|
||||
|
||||
If your file starts with `*PADS-PCB*` and ends with `*END*`, you're good. Reference designators may contain spaces (e.g. `CV GND`) — Pinscope resolves them against your BOM.
|
||||
|
||||
**Heads up — two different `.asc` files exist.** PADS (and tools that interop with PADS, like Xpedition) use the `.asc` extension for two unrelated things:
|
||||
|
||||
- The **schematic-exported netlist** starts with `*PADS-PCB*` and lists `*PART*` / `*NET*` sections. **This is what Pinscope wants.**
|
||||
- The **full PCB layout dump** starts with `!PADS-POWERPCB-V…` and contains routing/footprint geometry. Pinscope cannot parse this.
|
||||
|
||||
If your upload errors with "No components found", check the first line of the file.
|
||||
|
||||
### EDIF 2.0.0
|
||||
|
||||
EDIF is a vendor-neutral s-expression format. The first form is `(edif …`, with an `(edifVersion 2 0 0)` declaration near the top, libraries that define each cell's pin list, and a design library that lists `(instance …)` and `(net …)` forms. Pinscope has been verified against **Siemens xDX Designer / DxDesigner** exports; other EDIF 2.0.0 exporters (OrCAD, Altium, KiCad, Eagle) follow the same grammar and should work, but haven't been broadly tested. If your EDIF file doesn't parse, [contact us](/contact) and send a snippet — most fixes are small.
|
||||
|
||||
If your tool exports both PADS-PCB and EDIF, PADS-PCB is the path more users have validated; use EDIF when it's the only option.
|
||||
|
||||
## Exporting from your EDA tool
|
||||
|
||||
### KiCad
|
||||
|
||||
Works with KiCad 7.x, 8.x, and 9.x.
|
||||
|
||||
**Netlist**
|
||||
|
||||
1. Open the schematic in eeschema.
|
||||
2. **File → Export → Netlist…**
|
||||
3. Select the **PADS** tab → **Export Netlist**.
|
||||
4. Save the resulting `.net` file and upload it directly.
|
||||
|
||||
**BOM**
|
||||
|
||||
1. In eeschema: **Tools → Generate BOM…**
|
||||
2. Use the built-in `bom_csv_grouped_by_value_with_fp` plugin (or similar) — it produces a CSV with Reference, Value, Footprint, and any custom MPN field on your symbols.
|
||||
3. If your symbols don't have an MPN field yet, add one via **Edit → Edit Symbol Fields** before generating the BOM.
|
||||
|
||||
### Altium Designer
|
||||
|
||||
**Netlist**
|
||||
|
||||
1. In the Schematic Editor: **Design → Netlist for Project → PADS**.
|
||||
2. The `.NET` file lands in `Project Outputs for …`. Upload it directly.
|
||||
|
||||
**BOM**
|
||||
|
||||
1. **Reports → Bill of Materials**.
|
||||
2. In the template, include `Designator`, `Manufacturer Part Number` (or your equivalent parameter), `Comment` (= value), and `Footprint`.
|
||||
3. **Export** → CSV or Excel.
|
||||
|
||||
### OrCAD / Allegro
|
||||
|
||||
For OrCAD Capture and Allegro Design Entry.
|
||||
|
||||
**Netlist**
|
||||
|
||||
1. **Tools → Create Netlist** → format **PADS-PCB (.asc)**.
|
||||
2. Save to the project output directory.
|
||||
|
||||
**BOM**
|
||||
|
||||
1. **Tools → Bill of Materials** → configure columns to include `Reference`, `Manufacturer Part Number`, and `Value`.
|
||||
2. Export CSV.
|
||||
|
||||
### Xpedition
|
||||
|
||||
Works in **Xpedition Designer / DxDesigner** (VX.2.x, including VX.2.14). Xpedition and PADS are both Siemens EDA tools and share a netlist exchange format — but the PADS netlist export is only available in projects created with the **Netlist** project type. Projects on the integrated Xpedition flow (which forward directly into Xpedition Layout) don't expose PADS as a layout target.
|
||||
|
||||
**Netlist**
|
||||
|
||||
1. Open the schematic in Xpedition Designer / DxDesigner.
|
||||
2. **Setup → Settings → Layout Tool** → confirm **PADS** (or PADS Professional) is selected. If this option isn't available, the project was created as an Integrated/Expedition project — create a new Netlist-type project and import the schematics into it.
|
||||
3. **Tools → PCB Interface** → choose the **PADS** template (`pads2007.cfg` or equivalent).
|
||||
4. Run the export. The output's first line should be `*PADS-PCB*` with `*PART*` and `*NET*` sections below. The file extension (`.txt`, `.net`, or `.asc`) doesn't matter — upload it as-is.
|
||||
|
||||
If your project is locked to the integrated Xpedition flow, **export EDIF instead** — DxDesigner's EDIF Exporter runs against any project type and Pinscope accepts the resulting `.edn` as an equivalent input. See **Netlist (EDIF alternative)** below.
|
||||
|
||||
**Netlist (EDIF alternative)**
|
||||
|
||||
1. Open the schematic in Xpedition Designer / DxDesigner.
|
||||
2. **File → Export → EDIF…** (older builds: **Tools → Run Tool → Edif Exporter**).
|
||||
3. In the export dialog:
|
||||
- **EDIF version**: **2.0.0** (Pinscope only supports 2.0.0)
|
||||
- **EDIF level**: **0** (the default)
|
||||
- **Output format**: **Netlist view** — make sure cells, instances, nets, and the `viewMap` back-annotation block are all included
|
||||
- **Designator source**: include back-annotated designators (otherwise instances export as templates like `U?` / `R?` and Pinscope drops them)
|
||||
4. Save as `<design>.edn` and upload it as the netlist. The file should start with `(edif …)` and contain `(edifVersion 2 0 0)` near the top.
|
||||
|
||||
If neither PADS nor EDIF works for your project setup, send us the BOM and schematic PDF via [Contact](/contact) — we can usually help unblock the export.
|
||||
|
||||
**BOM**
|
||||
|
||||
1. **Reports → Bill of Materials** in Xpedition Designer, or run the Variant Manager BOM export.
|
||||
2. Include `Reference Designator`, `Part Number` (manufacturer part), `Value`, and `Footprint` columns.
|
||||
3. Export CSV or XLSX.
|
||||
|
||||
### EasyEDA
|
||||
|
||||
For both EasyEDA Standard and EasyEDA Pro.
|
||||
|
||||
**Netlist**
|
||||
|
||||
1. EasyEDA Std: **File → Export → PADS-PCB Netlist (.asc)**.
|
||||
2. EasyEDA Pro: **Design → Output → Netlist → PADS-PCB**.
|
||||
|
||||
**BOM**
|
||||
|
||||
1. **Fabrication → BOM** → export CSV.
|
||||
2. Confirm the Manufacturer Part Number column is populated — it comes from the LCSC supplier data or from a custom MPN attribute you've set on each part.
|
||||
|
||||
### Autodesk Eagle
|
||||
|
||||
**Netlist**
|
||||
|
||||
1. In the schematic editor: **File → Run ULP → `pads-pcb.ulp`** (or **File → Export → Netlist → PADS** in newer builds).
|
||||
2. Save and upload as-is.
|
||||
|
||||
**BOM**
|
||||
|
||||
1. **File → Export → Bill of Materials** → CSV.
|
||||
2. Ensure your parts carry an `MPN` or `MFR_PART` attribute so the column ends up in the export.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"No components found"** — your netlist is missing the `*PART*` section. Re-export specifically in PADS-PCB format (not Spice, Protel, or a generic text netlist). If the first line is `!PADS-POWERPCB-V…`, you uploaded the PCB layout dump instead of the schematic netlist — re-export from the schematic side.
|
||||
- **"No ground net found"** — your netlist has no net named `GND`, `VSS`, `AGND`, `DGND`, or similar. If you exported a sub-sheet, re-export the top sheet instead.
|
||||
- **Unresolved parts after the pipeline runs** — a BOM row has no MPN, or the MPN wasn't found on DigiKey. Add the MPN, or rely on Pinscope's value fallback (fills in from the `Value` / `Comment` column).
|
||||
|
||||
Still stuck? [Contact us](/contact) with your netlist and BOM attached and we'll take a look.
|
||||
@@ -0,0 +1,185 @@
|
||||
# Pinscope Privacy Policy
|
||||
|
||||
**Last updated: April 5, 2026**
|
||||
|
||||
This Privacy Policy explains how Faradworks, Inc. ("Faradworks," "we," "us," and "our") collects, uses, and discloses information in connection with the Pinscope website (pinscope.ai), platform, and related services (the "Service").
|
||||
|
||||
This Privacy Policy is intended for free users and self-serve paid users. Enterprise customers typically use the Service under a separate agreement and (if applicable) a data processing agreement ("DPA"), which may include additional privacy and security terms.
|
||||
|
||||
## 1. Key definitions
|
||||
|
||||
**"Customer Content"** means any files, data, text, images, netlists, schematics, datasheets, design files, chat inputs, or other materials uploaded or submitted by you.
|
||||
|
||||
**"Outputs"** means analyses, checks, summaries, recommendations, or responses generated by the Service based on Customer Content.
|
||||
|
||||
**"Derived Data"** means technical artifacts generated solely to operate the Service, such as parsed text, indexes, embeddings, summaries, or extracted metadata.
|
||||
|
||||
**"Content"** refers collectively to Customer Content, Outputs, and Derived Data.
|
||||
|
||||
**"Personal Data"** (or "personal information") means information that identifies, relates to, describes, is reasonably capable of being associated with, or could reasonably be linked, directly or indirectly, with an individual, as defined under applicable law.
|
||||
|
||||
## 2. Roles: controller vs. processor (business customers)
|
||||
|
||||
Faradworks is the controller for Personal Data associated with operating the Service for users and prospective customers (for example account data, billing metadata, and website analytics).
|
||||
|
||||
If you upload Personal Data within Customer Content on behalf of a business, you may be the controller of that Personal Data and Faradworks may process it as a service provider/processor. Where required, we will make a DPA available upon request.
|
||||
|
||||
## 3. Information we collect
|
||||
|
||||
We collect only the information reasonably necessary to operate the Service.
|
||||
|
||||
### 3.1 Account information
|
||||
|
||||
- Name, email address, and organization (optional)
|
||||
- Authentication identifiers (for example password hashes, session tokens, and account IDs)
|
||||
- Subscription and plan metadata
|
||||
|
||||
### 3.2 Billing information
|
||||
|
||||
- Payments are processed by Stripe, a PCI-compliant third-party processor.
|
||||
- Faradworks receives billing status and subscription metadata (for example plan name, renewal date, and payment status).
|
||||
- We do not store payment card details.
|
||||
|
||||
### 3.3 Customer Content
|
||||
|
||||
- Engineering files (for example netlists, schematics, datasheets, and images)
|
||||
- Chat inputs and related context
|
||||
- Design rules and configuration preferences
|
||||
|
||||
### 3.4 Usage and operational data
|
||||
|
||||
Usage and performance telemetry (for example request timestamps, feature usage, response times, error rates, and billing/usage measurements). This data is generally operational in nature and is designed not to include the substance of your Customer Content, except as described in Section 6.3 (Support diagnostics).
|
||||
|
||||
### 3.5 Cookies and analytics preferences
|
||||
|
||||
- We use cookies and similar technologies needed for core site operations and security.
|
||||
- We store an analytics preference cookie that records whether analytics tracking is granted or denied.
|
||||
- When analytics is granted, we may collect site and product usage and performance telemetry through providers such as Vercel Analytics and Speed Insights.
|
||||
|
||||
### 3.6 Contact and enterprise inquiry data
|
||||
|
||||
- Contact and profile details submitted through contact or enterprise inquiry forms (for example name, email, business type, and capacity needs)
|
||||
- Interest signals (for example request for a demo, free review evaluation, or higher-limit self-serve access)
|
||||
- Inquiry notes and follow-up communications related to your request
|
||||
|
||||
## 4. How we use information
|
||||
|
||||
We use Personal Data and Content to:
|
||||
|
||||
- Provide, operate, and maintain the Service
|
||||
- Perform AI-assisted engineering analysis
|
||||
- Enforce usage limits and prevent abuse
|
||||
- Diagnose errors and improve reliability
|
||||
- Respond to support requests
|
||||
- Investigate support requests and Issue Reports and, where you explicitly opt in, use those submissions and the related context to evaluate, test, debug, monitor, develop, and improve the Service
|
||||
- Send service-related communications, including onboarding, feedback requests, product updates, and security notices
|
||||
- Review and respond to contact or enterprise inquiries, including demo requests and plan-fit conversations
|
||||
- Comply with legal obligations and enforce our Terms
|
||||
|
||||
## 5. AI model training and service improvement
|
||||
|
||||
### 5.1 No training on Customer Content for foundation models
|
||||
|
||||
Faradworks treats all Customer Content as confidential and does not use it to train, fine-tune, or improve any general-purpose or foundation AI models. Your content remains isolated to your account and is not shared across customers or included in public datasets.
|
||||
|
||||
### 5.2 Aggregated and de-identified service improvement
|
||||
|
||||
To improve and operate the Service, we rely primarily on aggregated or otherwise de-identified usage metrics (for example error rates and feature usage) that are not reasonably capable of being traced back to you.
|
||||
|
||||
## 6. How we share information
|
||||
|
||||
We do not sell Personal Data.
|
||||
|
||||
We may disclose information in the following circumstances:
|
||||
|
||||
### 6.1 Subprocessors and service providers
|
||||
|
||||
We use subprocessors and service providers to host and operate the Service (for example hosting, storage, authentication, billing, observability, and database providers). We may share Personal Data and Content with these providers only as necessary to provide the Service.
|
||||
|
||||
### 6.2 AI model providers
|
||||
|
||||
To generate Outputs, relevant portions of Customer Content are transmitted to AI model providers acting as subprocessors. Providers may include services such as OpenAI, Anthropic, Google, or similar AI platforms.
|
||||
|
||||
These providers process Content to generate responses. Their data handling, retention, and caching practices are governed by their respective terms and our configuration.
|
||||
|
||||
### 6.3 Support and troubleshooting diagnostics
|
||||
|
||||
When needed for reliability, security, abuse prevention, or support troubleshooting, we may process account-linked technical diagnostics (for example request IDs, timestamps, stack traces, provider error payloads, and limited excerpts of inputs or outputs associated with a failing request).
|
||||
|
||||
If you submit a support request, submit an Issue Report, or otherwise ask us to investigate an issue with the Service, we may access and review the Customer Content, Outputs, and related Derived Data reasonably necessary to investigate, reproduce, resolve, remediate, and prevent recurrence of that issue. This may include the affected project, review results, uploaded files or excerpts, citations, configuration context, and associated diagnostics.
|
||||
|
||||
If, through the applicable in-product support or feedback flow, you explicitly opt in to broader improvement use, we may also use the submitted materials and the reasonably necessary related project/review context to evaluate, test, debug, monitor, support, secure, operate, develop, and improve the Service and related features, systems, and workflows, including for the benefit of other users. This may include quality evaluation, failure analysis, prompt and retrieval improvements, ranking or classification improvements, validation logic, reliability engineering, abuse prevention, and other product-quality, safety, and operational improvements.
|
||||
|
||||
We limit this access and use to authorized personnel and permitted service-related purposes. We do not use this content to train, fine-tune, or improve any general-purpose or foundation AI model unless you separately and expressly opt in to that use.
|
||||
|
||||
Outside of those circumstances, we do not access or review the substance of your Customer Content except: (a) at your request, (b) to investigate security issues or incidents, (c) if legally compelled, or (d) with your explicit consent.
|
||||
|
||||
### 6.4 Legal and safety
|
||||
|
||||
We may disclose information to comply with applicable law, lawful requests, and legal process; to protect the rights, property, and safety of Faradworks, our users, and others; and to enforce our agreements and policies.
|
||||
|
||||
### 6.5 Business transfers
|
||||
|
||||
If Faradworks is involved in a merger, acquisition, financing, reorganization, bankruptcy, or sale of assets, information may be transferred as part of that transaction, subject to standard confidentiality protections.
|
||||
|
||||
## 7. Cookies and analytics controls
|
||||
|
||||
You can control analytics cookies through the in-product or site controls (where available) and through your browser settings. If you deny analytics cookies, we will not run optional analytics tracking, but essential cookies may still be required for core functionality and security.
|
||||
|
||||
## 8. Data retention and deletion
|
||||
|
||||
### 8.1 User-controlled deletion
|
||||
|
||||
You may delete uploaded files, chats, and review history from within the Service (where available) and you may request closure of your account.
|
||||
|
||||
### 8.2 Retention periods
|
||||
|
||||
**Active accounts.** We retain Customer Content and Outputs for as long as your account remains active, unless you delete them earlier.
|
||||
|
||||
**Account closure.** When you close your account (or we close it at your request), we will delete Customer Content and Outputs associated with your account within 30 days, except where retention is required by law or for legitimate business purposes described below.
|
||||
|
||||
**Backups.** Residual metadata or encrypted backups may persist for a limited period (generally up to 90 days) for security, integrity, and disaster-recovery purposes.
|
||||
|
||||
**Operational artifacts.** We may retain limited technical artifacts (for example hashed identifiers, aggregated statistics, and anonymized error metadata) for security, abuse prevention, system integrity, and service improvement. These artifacts are not intended to and are not reasonably capable of reconstructing your original designs.
|
||||
|
||||
**Support and improvement records.** If you submit a support request or an Issue Report, we may retain the support record, related investigation notes, and the limited associated project/review context needed to document, resolve, and prevent recurrence of the issue. If you explicitly opt in to broader improvement use, we may also retain the submitted materials and related analyses for the service-improvement purposes described in this Policy, subject to the same confidentiality and deletion framework described in this Policy.
|
||||
|
||||
**Billing and accounting.** We retain billing records and aggregated usage statistics as required by law and for legitimate business purposes (for example taxes, accounting, and audit).
|
||||
|
||||
## 9. Security
|
||||
|
||||
We use commercially reasonable administrative, technical, and organizational safeguards designed to protect information, including encryption in transit (TLS) and at rest, per-user access isolation, secure credential management, and monitoring. No system is perfectly secure.
|
||||
|
||||
## 10. International data transfers
|
||||
|
||||
By default, the Service is hosted in the United States. If you access the Service from outside the United States, information may be transferred to, stored in, and processed in the United States and other countries where we or our subprocessors operate. Where required, we will use appropriate safeguards for international transfers (for example contractual protections in a DPA).
|
||||
|
||||
## 11. Your rights and choices
|
||||
|
||||
### 11.1 GDPR/UK GDPR
|
||||
|
||||
Depending on your jurisdiction, you may have rights to access, correct, delete, restrict, object to processing, and port your Personal Data. You may also have the right to withdraw consent where processing is based on consent.
|
||||
|
||||
### 11.2 California (CCPA/CPRA)
|
||||
|
||||
If you are a California resident, you may have rights to know, access, delete, correct, and opt out of the "sale" or "sharing" of Personal Data, and to limit the use of "sensitive personal information," as those terms are defined under California law. Faradworks does not sell Personal Data. We do not share Personal Data for cross-context behavioral advertising.
|
||||
|
||||
### 11.3 How to exercise rights
|
||||
|
||||
Privacy requests (data access, deletion, correction): dev@faradworks.com
|
||||
|
||||
For faster handling, use subject line: "Privacy Request (Access/Deletion/Correction)".
|
||||
|
||||
We will respond to verified privacy requests within applicable legal timelines (typically within 30 days for GDPR requests).
|
||||
|
||||
## 12. Children
|
||||
|
||||
The Service is not directed to children, and we do not knowingly collect Personal Data from children under 13 (or under the age threshold applicable in your jurisdiction).
|
||||
|
||||
## 13. Changes to this Privacy Policy
|
||||
|
||||
We may update this Privacy Policy from time to time. If we make material changes, we will provide reasonable notice by email or by posting a notice on the Service before the changes take effect. The updated policy will be effective as of the "Last updated" date unless otherwise stated.
|
||||
|
||||
## 14. Contact
|
||||
|
||||
Questions about this Privacy Policy: [dev@faradworks.com](mailto:dev@faradworks.com)
|
||||
@@ -0,0 +1,355 @@
|
||||
# Pinscope Terms of Service
|
||||
|
||||
**Last updated: April 5, 2026**
|
||||
|
||||
These Terms of Service ("Terms") govern access to and use of the Pinscope platform (pinscope.ai) and related services (the "Service"). These Terms apply to free users and self-serve paid users. If you have a separate written agreement signed by Faradworks, Inc. (for example, an enterprise agreement), that agreement governs your use of the Service to the extent it conflicts with these Terms.
|
||||
|
||||
These Terms incorporate Faradworks' [Privacy Policy](/privacy) and any policies referenced in the Service.
|
||||
|
||||
By creating an account, clicking to accept these Terms (for example, by clicking an "I agree" button in our signup flow), or otherwise accessing or using the Service, you agree to be bound by these Terms. If you do not agree, do not use the Service.
|
||||
|
||||
## 1. Definitions
|
||||
|
||||
**"Customer Content"** means any files, data, text, images, netlists, schematics, datasheets, design files, chat inputs, or other materials uploaded or submitted by you.
|
||||
|
||||
**"Outputs"** means analyses, checks, summaries, recommendations, or responses generated by the Service based on Customer Content.
|
||||
|
||||
**"Derived Data"** means technical artifacts generated solely to operate the Service, such as parsed text, indexes, embeddings, summaries, or extracted metadata.
|
||||
|
||||
**"Service"** means the Pinscope platform (pinscope.ai), including all features, APIs, and related services.
|
||||
|
||||
**"Content"** refers collectively to Customer Content, Outputs, and Derived Data.
|
||||
|
||||
**"Faradworks," "we," and "us"** means Faradworks, Inc., a Delaware corporation.
|
||||
|
||||
## 2. Acceptance; eligibility; authority; electronic communications
|
||||
|
||||
### 2.1 Authority.
|
||||
|
||||
If you use the Service on behalf of a company or other entity, you represent and warrant that you have authority to bind that entity. In that case, "you" and "your" refer to that entity.
|
||||
|
||||
### 2.2 Eligibility.
|
||||
|
||||
You must be at least 18 years old (or the age of legal majority where you live) to use the Service.
|
||||
|
||||
### 2.3 Electronic delivery and notices.
|
||||
|
||||
You consent to receive communications from Faradworks electronically (for example, by email and in-product notices). You agree that all agreements, notices, disclosures, and other communications we provide electronically satisfy any legal requirement that such communications be in writing.
|
||||
|
||||
## 3. Service description; informational use; no professional advice
|
||||
|
||||
### 3.1 Informational service.
|
||||
|
||||
The Service analyzes user-submitted electrical design files and datasheets using automated systems, including machine learning models, to generate informational Outputs. Outputs are provided on an "AS IS" and "AS AVAILABLE" basis.
|
||||
|
||||
### 3.2 No professional engineering advice.
|
||||
|
||||
The Service does not provide professional engineering, safety, regulatory, certification, legal, or compliance advice. You are solely responsible for independently reviewing and validating any Outputs before use in real-world designs, fabrication, procurement, manufacturing, or deployment. Outputs may be incomplete, inaccurate, or unsuitable for your specific application.
|
||||
|
||||
### 3.3 AI limitations.
|
||||
|
||||
You acknowledge that AI-generated Outputs are probabilistic and may be inaccurate, incomplete, or misleading; that Output quality depends on input quality; and that models and Outputs may change over time.
|
||||
|
||||
## 4. Accounts; security; administrators
|
||||
|
||||
### 4.1 Account security.
|
||||
|
||||
You are responsible for maintaining the confidentiality of your credentials and for all activity occurring under your account. You must promptly notify Faradworks if you suspect unauthorized access.
|
||||
|
||||
### 4.2 Administrators and workspace users.
|
||||
|
||||
If your account supports multiple users, administrators may be able to manage users, permissions, billing, and settings within your workspace. You are responsible for actions taken by anyone you allow to access the Service through your account.
|
||||
|
||||
### 4.3 Responsibility for systems and backups.
|
||||
|
||||
You are responsible for your own systems, networks, and devices used to access the Service, and for maintaining appropriate backups of your Customer Content.
|
||||
|
||||
## 5. Ownership; licenses; feedback
|
||||
|
||||
### 5.1 Your ownership.
|
||||
|
||||
You retain all right, title, and interest in and to your Customer Content.
|
||||
|
||||
### 5.2 Outputs.
|
||||
|
||||
To the extent permitted by law, you own the Outputs generated from your Customer Content. Ownership of Outputs does not confer ownership of the Service, software, models, or analytical methods used to generate them.
|
||||
|
||||
### 5.3 Faradworks ownership.
|
||||
|
||||
Faradworks retains all right, title, and interest in and to the Service (including its software, models, workflows, analytical methods, user interfaces, and underlying infrastructure), including all improvements and derivatives.
|
||||
|
||||
### 5.4 License to operate the Service.
|
||||
|
||||
You grant Faradworks a limited, non-exclusive, worldwide, royalty-free license to host, store, process, transmit, reproduce (as necessary for processing), and otherwise use your Customer Content and Derived Data solely to provide, maintain, secure, and support the Service. Any broader use of Customer Content, Outputs, support requests, Issue Reports, or related project/review context for service-improvement purposes is permitted only to the extent expressly described in these Terms, the Privacy Policy, and any opt-in choices you make in the Service.
|
||||
|
||||
### 5.5 No model training on Customer Content.
|
||||
|
||||
Faradworks will not use Customer Content to train, fine-tune, or improve any general-purpose or foundation AI model, and will not permit third parties to do so, unless you separately and explicitly agree to that use.
|
||||
|
||||
### 5.6 Support reports and optional improvement consent.
|
||||
|
||||
By default, Faradworks will not use Customer Content, Outputs, support requests, Issue Reports, or related project/review context to improve the Service beyond investigating, resolving, and preventing recurrence of the specific incident for which such materials were submitted.
|
||||
|
||||
If you explicitly opt in through the applicable in-product support or feedback flow, you grant Faradworks a limited, revocable, non-exclusive, worldwide, royalty-free license to access, review, retain, and use the submitted materials and the reasonably necessary related Customer Content, Outputs, Derived Data, and project/review context to evaluate, test, debug, monitor, support, secure, operate, develop, and improve the Service and related features, systems, and workflows. This may include quality evaluation, failure analysis, prompt and retrieval improvements, ranking or classification improvements, validation logic, reliability engineering, abuse prevention, and other product-quality, safety, and operational improvements for the benefit of current and future users.
|
||||
|
||||
This consent does not authorize Faradworks or any third party to train, fine-tune, or improve any general-purpose or foundation AI model unless you separately and expressly agree to that use.
|
||||
|
||||
You may withdraw this optional improvement consent at any time through the Service settings or by contacting Faradworks. Withdrawal will apply prospectively and will not require Faradworks to unwind or delete improvements, evaluations, or analyses already created or completed before withdrawal, subject to the Privacy Policy's retention and deletion terms.
|
||||
|
||||
### 5.7 Feedback.
|
||||
|
||||
If you provide suggestions or feedback, you grant Faradworks a perpetual, irrevocable, worldwide, royalty-free license to use and incorporate it without restriction or obligation.
|
||||
|
||||
## 6. Customer responsibilities; prohibited data; export and restricted data
|
||||
|
||||
### 6.1 Rights in content you upload.
|
||||
|
||||
You represent and warrant that you have all rights necessary to upload and use Customer Content with the Service and to grant the rights in these Terms.
|
||||
|
||||
### 6.2 Prohibited Data.
|
||||
|
||||
Unless Faradworks expressly agrees in writing, you will not submit or upload any of the following ("Prohibited Data"): (a) patient, medical, or other protected health information regulated by HIPAA or similar laws; (b) payment card data subject to PCI DSS; (c) bank account numbers, passwords, or authentication secrets intended to access financial accounts; (d) social security numbers, driver's license numbers, or other unique government ID numbers; (e) "special categories" of personal data under the GDPR (or similar sensitive personal data classifications); or (f) any other similarly sensitive personal information that would impose heightened legal or regulatory obligations on Faradworks.
|
||||
|
||||
### 6.3 GDPR / DPA.
|
||||
|
||||
If you are subject to GDPR/UK GDPR and need a data processing agreement ("DPA"), contact us at [dev@faradworks.com](mailto:dev@faradworks.com). If we provide a DPA for your use case, you must execute it before submitting personal data governed by GDPR/UK GDPR as Customer Content. If a DPA applies, it will govern the parties' rights and obligations with respect to such personal data and will control in the event of conflict with these Terms.
|
||||
|
||||
### 6.4 Export-controlled and restricted technical data.
|
||||
|
||||
You are responsible for compliance with all applicable export control laws and regulations, including U.S. EAR and ITAR. You agree not to upload export-controlled technical data, classified information, or government-restricted information without proper authorization. By default, the Service is hosted in the United States. By using the Service, you acknowledge that Content may be processed and stored in the U.S. or other regions where we or our subprocessors operate.
|
||||
|
||||
## 7. Acceptable use; restrictions
|
||||
|
||||
You agree not to:
|
||||
|
||||
- (a) upload content you do not have rights to use;
|
||||
- (b) upload malware, malicious code, or content intended to disrupt or compromise the Service;
|
||||
- (c) circumvent usage limits or security controls;
|
||||
- (d) interfere with service integrity or access others' data;
|
||||
- (e) systematically extract, scrape, benchmark, or reverse engineer the Service, Outputs, or underlying models for the purpose of building a competing product;
|
||||
- (f) resell, rent, or redistribute the Service without Faradworks' written consent; or
|
||||
- (g) use the Service for illegal purposes or in violation of applicable laws.
|
||||
|
||||
Faradworks may suspend or terminate access for violations of this section.
|
||||
|
||||
## 8. High-risk applications
|
||||
|
||||
The Service is not designed or intended for use in life-critical or safety-critical systems where failure could result in death, bodily injury, or significant property or environmental damage, including medical devices, automotive safety systems, aerospace systems, nuclear facilities, or weapons systems.
|
||||
|
||||
If you choose to use the Service in connection with such applications, you do so at your own risk. You are solely responsible for independently validating all Outputs and for ensuring that any resulting designs, products, or systems meet all applicable safety, regulatory, and certification requirements.
|
||||
|
||||
## 9. Free trials; beta features; no reliance
|
||||
|
||||
### 9.1 Free and trial access.
|
||||
|
||||
Faradworks may offer free plans, free trials, promotional credits, or other no-fee access ("Free Access"). Faradworks may modify, suspend, or end Free Access at any time.
|
||||
|
||||
### 9.2 Beta features.
|
||||
|
||||
Faradworks may make available features labeled alpha, beta, preview, or similar ("Beta Features"). Beta Features are experimental and may be changed or discontinued at any time.
|
||||
|
||||
### 9.3 No SLA; no reliance.
|
||||
|
||||
Free Access and Beta Features are provided "AS IS" and "AS AVAILABLE," without any service level commitment and without any obligation to provide support. You should not rely on Free Access or Beta Features for production use.
|
||||
|
||||
## 10. Third-party providers; subprocessors; third-party services
|
||||
|
||||
### 10.1 Subprocessors.
|
||||
|
||||
The Service relies on third-party providers (including AI model providers, hosting, storage, authentication, billing, and observability providers) to help deliver functionality. Faradworks' data handling practices are described in the Privacy Policy.
|
||||
|
||||
### 10.2 Third-party services/links.
|
||||
|
||||
The Service may integrate with or link to third-party services. Faradworks is not responsible for third-party services, and your use of them is governed by the third party's terms and policies.
|
||||
|
||||
## 11. Usage limits; billing; usage allocations; taxes; refunds
|
||||
|
||||
### 11.1 Usage limits.
|
||||
|
||||
Plans may include limits on reviews, tokens, files, API spend, usage allocations, or other usage metrics. Faradworks may throttle, restrict, or suspend usage to enforce limits or protect system stability.
|
||||
|
||||
### 11.2 Prepaid service credits.
|
||||
|
||||
Certain Services, plans, or features may allow or require you to prepay for future eligible Pinscope review services for professional, business, or organizational use by purchasing prepaid service credits ("Usage Credits"). Usage Credits represent a prepaid, limited, revocable, non-transferable license to access eligible Pinscope review services up to the applicable credited amount and may be used only for eligible Pinscope review charges as described in the Service. Faradworks may also, in its sole discretion, provide free or promotional credits ("Promotional Credits"), which may be subject to additional restrictions or expiration dates stated when issued.
|
||||
|
||||
### 11.3 Credit characteristics and workspace scope.
|
||||
|
||||
Credits may be used only for eligible Pinscope review charges and may not be used for any other product or service unless Faradworks expressly states otherwise in the Service. Credits are not legal tender, are not currency, are not redeemable for cash, are not refundable except as required by law or expressly stated by Faradworks, do not constitute or confer any personal property right, and do not constitute a bank account, deposit account, stored-value account, digital wallet, payment instrument, or other monetary account. Credits are an internal service accounting mechanism that measures the amount of eligible Pinscope review services you have prepaid and are licensed to use. Any credit balance or similar amount displayed in the Service reflects only our record of remaining prepaid eligibility for future eligible review charges and does not represent money held on your behalf. Credits are non-transferable, may not be sold, assigned, gifted, or sublicensed, and may be used only by the workspace or account to which they are issued. If credits are issued to an organization or workspace, they belong to that workspace and may be consumed by authorized users acting within that workspace.
|
||||
|
||||
### 11.4 Credit purchases and application to charges.
|
||||
|
||||
Your order for Usage Credits constitutes an offer to purchase those Usage Credits. Faradworks may accept or reject any purchase request in its discretion. Credits are issued when Faradworks confirms the purchase or otherwise makes the credits available in your account or workspace. Credits are applied to eligible Pinscope review charges in the manner described in the Service. Faradworks may reserve, deduct, reverse, release, or adjust credits to reflect quoted charges, completed usage, failed runs, duplicate requests, fraud checks, refunds, chargebacks, or billing corrections. Credit pricing, minimum purchase amounts, maximum purchase amounts, and applicable taxes will be shown in the Service or at checkout. Fees are exclusive of taxes unless stated otherwise.
|
||||
|
||||
### 11.5 Credit expiration, forfeiture, and promotional credits.
|
||||
|
||||
Unless otherwise stated in the Service or required by law, purchased Usage Credits expire 12 months after the date they are issued to your account or workspace. Expiration does not restart because credits are partially used, because you change plans, or because your workspace or account settings change, unless Faradworks expressly states otherwise. Promotional Credits expire on the date stated when they are issued, or if no date is stated, 12 months after issuance unless required otherwise by law. Credits may be forfeited if the applicable account or workspace is closed, terminated, or suspended, subject to applicable law. Faradworks may also withhold, void, or reverse credits associated with chargebacks, payment reversals, fraud, abuse, or violations of these Terms.
|
||||
|
||||
### 11.6 Per-review usage impact.
|
||||
|
||||
Paid plans may include monthly usage limits, included usage budgets, or similar usage allocations. These allocations reset each billing period unless otherwise stated. Each review has a usage impact based on scope, selected model, and your applicable plan or pricing structure. Before you run a review, Faradworks will show the usage impact of that review, which may be displayed as a dollar amount, credit amount, percentage of plan limit, or similar usage metric. That usage impact may change over time, including for the same or a similar review configuration, and prepaid credits do not lock in a future review price unless Faradworks expressly states otherwise in the Service. Unless Faradworks expressly states otherwise, the applicable review charge is the quoted amount or usage impact shown in the Service at the time you submit or start the review, and any quote may expire or require refresh before use.
|
||||
|
||||
### 11.7 Plan changes.
|
||||
|
||||
If you change plans mid-cycle, Faradworks may apply prorated or other adjustments to your available usage limits, credits, budget, or similar usage allocation for that active billing period.
|
||||
|
||||
### 11.8 Auto-renewal; cancellation.
|
||||
|
||||
Paid subscriptions renew automatically unless canceled. You may cancel at any time through your account settings. Cancellation stops future renewals; it does not retroactively refund fees already paid except where required by law.
|
||||
|
||||
### 11.9 Grandfathered plans.
|
||||
|
||||
Grandfathered plans may remain available only while the subscription stays active and in good standing. If a grandfathered plan is canceled, lapses, or is changed, reactivation may require enrolling in a then-current plan. Faradworks may retire grandfathered plans with reasonable notice where permitted by law.
|
||||
|
||||
### 11.10 Taxes.
|
||||
|
||||
Fees are exclusive of taxes unless stated otherwise. You are responsible for applicable taxes, except taxes based on Faradworks' net income.
|
||||
|
||||
### 11.11 Refunds and billing corrections.
|
||||
|
||||
You may request a refund for completely unused Usage Credits within 24 hours after purchase by contacting [dev@faradworks.com](mailto:dev@faradworks.com). If no refund request is received within 24 hours after purchase, unused Usage Credits become non-refundable except where required by law. Once any portion of purchased Usage Credits has been used, that purchase becomes non-refundable except where required by law or expressly authorized by Faradworks. Otherwise, refunds are provided at Faradworks' discretion or as required by law. Faradworks may correct pricing errors, mistaken issuances, duplicate grants, or accounting mistakes, including by adjusting, removing, or restoring credits where appropriate.
|
||||
|
||||
## 12. Confidentiality; security; support access
|
||||
|
||||
### 12.1 Confidential Customer Content.
|
||||
|
||||
Faradworks treats Customer Content as confidential and does not disclose it to third parties except as necessary to provide the Service (including to subprocessors), as required by law, or with your consent, as further described in the Privacy Policy.
|
||||
|
||||
### 12.2 Security.
|
||||
|
||||
Faradworks uses commercially reasonable administrative, technical, and organizational safeguards designed to protect Content. However, no system is perfectly secure and Faradworks does not guarantee that unauthorized access, hacking, data loss, or other security incidents will never occur.
|
||||
|
||||
### 12.3 Security incident communications.
|
||||
|
||||
Where required by applicable law, Faradworks will provide notice of a confirmed unauthorized access to personal data under our control.
|
||||
|
||||
### 12.4 Your responsibility for sensitive material.
|
||||
|
||||
You are responsible for evaluating whether the Service meets your confidentiality requirements before uploading sensitive or proprietary designs.
|
||||
|
||||
### 12.5 Support troubleshooting access.
|
||||
|
||||
If you submit a support request or an Issue Report, Faradworks may access account-linked diagnostic records and the Customer Content, Outputs, and related project/review context reasonably necessary to investigate, reproduce, resolve, remediate, and prevent recurrence of the reported issue. This may include the affected project, review results, uploaded files or excerpts, citations, and associated diagnostics.
|
||||
|
||||
If you explicitly opt in through the applicable support or feedback flow, Faradworks may also use those submitted materials and related context for the broader service-improvement purposes described in Section 5.6. Faradworks will limit such access and use to authorized personnel and to the permitted purposes described in these Terms and the Privacy Policy.
|
||||
|
||||
## 13. Suspension; termination; effect of termination
|
||||
|
||||
### 13.1 Suspension/termination by Faradworks.
|
||||
|
||||
Faradworks may suspend or terminate access for: (a) violations of these Terms; (b) excessive, abusive, or fraudulent usage; (c) non-payment; (d) legal, regulatory, or infrastructure constraints; or (e) security, abuse-prevention, or risk-management reasons.
|
||||
|
||||
### 13.2 Termination by you.
|
||||
|
||||
You may stop using the Service at any time and may request account closure.
|
||||
|
||||
### 13.3 Effect.
|
||||
|
||||
Upon termination, your right to access the Service ends immediately. Faradworks will delete your Content in accordance with the Privacy Policy's data retention and deletion terms, subject to legal retention requirements and limited residual technical artifacts described in the Privacy Policy.
|
||||
|
||||
### 13.4 Survival.
|
||||
|
||||
Sections that by their nature should survive will survive termination, including ownership, confidentiality, disclaimers, limitation of liability, indemnification, disputes, and general terms.
|
||||
|
||||
## 14. DISCLAIMERS
|
||||
|
||||
TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE SERVICE AND OUTPUTS ARE PROVIDED "AS IS" AND "AS AVAILABLE," WITHOUT WARRANTIES OF ANY KIND, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHERWISE, INCLUDING IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, ACCURACY, AND QUIET ENJOYMENT.
|
||||
|
||||
FARADWORKS DOES NOT WARRANT THAT THE SERVICE WILL BE UNINTERRUPTED, ERROR-FREE, SECURE, OR FREE OF HARMFUL COMPONENTS, OR THAT OUTPUTS WILL MEET YOUR REQUIREMENTS OR BE CORRECT FOR ANY PARTICULAR USE.
|
||||
|
||||
## 15. LIMITATION OF LIABILITY
|
||||
|
||||
### 15.1 EXCLUSION OF CERTAIN DAMAGES.
|
||||
|
||||
TO THE FULLEST EXTENT PERMITTED BY LAW, IN NO EVENT WILL FARADWORKS (OR ITS AFFILIATES, OFFICERS, DIRECTORS, EMPLOYEES, CONTRACTORS, AGENTS, LICENSORS, SUPPLIERS, OR SUBPROCESSORS) BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES, OR FOR ANY LOSS OF PROFITS, REVENUE, BUSINESS OPPORTUNITIES, GOODWILL, OR DATA, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
### 15.2 SPECIFIC EXCLUSIONS.
|
||||
|
||||
TO THE FULLEST EXTENT PERMITTED BY LAW, FARADWORKS IS NOT LIABLE FOR: (a) ERROR OR INTERRUPTION OF USE; (b) LOSS, INACCURACY, CORRUPTION, OR UNAUTHORIZED DISCLOSURE OF DATA OR CONTENT; (c) COST OF PROCUREMENT OF SUBSTITUTE GOODS, SERVICES, OR TECHNOLOGY; (d) HARDWARE FAILURES, DESIGN DEFECTS, MANUFACTURING ISSUES, OR SAFETY INCIDENTS RELATED TO DESIGNS REVIEWED USING THE SERVICE; OR (e) ANY MATTER BEYOND FARADWORKS' REASONABLE CONTROL (INCLUDING THIRD-PARTY PROVIDER FAILURES).
|
||||
|
||||
### 15.3 AGGREGATE CAP.
|
||||
|
||||
TO THE FULLEST EXTENT PERMITTED BY LAW, FARADWORKS' TOTAL AGGREGATE AND CUMULATIVE LIABILITY FOR ALL CLAIMS ARISING OUT OF OR RELATING TO THESE TERMS OR THE SERVICE, UNDER ANY THEORY OF LIABILITY (CONTRACT, TORT (INCLUDING NEGLIGENCE), STRICT LIABILITY, STATUTE, OR OTHERWISE), WILL NOT EXCEED THE FEES PAID BY YOU TO FARADWORKS FOR THE SERVICE IN THE TWELVE (12) MONTHS PRECEDING THE FIRST EVENT GIVING RISE TO THE CLAIM. IF YOU HAVE NOT PAID ANY FEES TO FARADWORKS IN THAT PERIOD (FOR EXAMPLE, DURING FREE ACCESS OR A FREE TRIAL), FARADWORKS' TOTAL LIABILITY WILL NOT EXCEED ONE HUNDRED U.S. DOLLARS (US$100).
|
||||
|
||||
### 15.4 BASIS OF THE BARGAIN; FAILURE OF ESSENTIAL PURPOSE.
|
||||
|
||||
YOU ACKNOWLEDGE THAT THE FEES (IF ANY) REFLECT THE ALLOCATION OF RISK AND THAT FARADWORKS WOULD NOT PROVIDE THE SERVICE WITHOUT THESE LIMITATIONS. THE LIMITATIONS IN THIS SECTION APPLY EVEN IF ANY LIMITED REMEDY FAILS OF ITS ESSENTIAL PURPOSE.
|
||||
|
||||
### 15.5 LIMITATIONS REQUIRED BY LAW.
|
||||
|
||||
NOTHING IN THESE TERMS LIMITS OR EXCLUDES LIABILITY TO THE EXTENT SUCH LIMITATION OR EXCLUSION IS PROHIBITED BY APPLICABLE LAW.
|
||||
|
||||
## 16. Indemnification
|
||||
|
||||
### 16.1 Your indemnity.
|
||||
|
||||
You agree to indemnify, defend, and hold harmless Faradworks and its affiliates, officers, directors, employees, contractors, and agents from and against any claims, damages, losses, liabilities, and expenses (including reasonable attorneys' fees) arising out of or relating to: (a) your use of the Service or Outputs; (b) your Customer Content; (c) your violation of these Terms; (d) your violation of any third-party rights (including IP rights); or (e) designs, products, or systems you create using or informed by Outputs.
|
||||
|
||||
### 16.2 Indemnification procedure.
|
||||
|
||||
Your obligations under this Section 16 are conditioned on Faradworks: (a) providing you prompt notice of the claim (provided that failure to provide prompt notice will relieve you only to the extent materially prejudiced); (b) providing reasonable assistance at your expense; and (c) allowing you sole control of the defense and settlement of the claim, except that you may not settle any claim in a manner that admits fault by Faradworks or imposes obligations on Faradworks without Faradworks' prior written consent. Faradworks may participate in the defense with counsel of its choosing at its own expense.
|
||||
|
||||
## 17. Force majeure
|
||||
|
||||
Faradworks will not be liable for any delay or failure to perform due to events beyond its reasonable control, including acts of God, natural disasters, war, terrorism, riots, labor disputes, pandemics, public utility failures, internet or cloud provider failures, or governmental actions ("Force Majeure Events"). A Force Majeure Event does not excuse your payment obligations for fees accrued prior to the Force Majeure Event.
|
||||
|
||||
## 18. Disputes; governing law; venue; time limit to bring claims
|
||||
|
||||
### 18.1 Informal resolution.
|
||||
|
||||
Before filing a claim (other than for injunctive relief), you agree to first contact Faradworks at [dev@faradworks.com](mailto:dev@faradworks.com) with a brief description of the dispute and your contact information. The parties will attempt in good faith to resolve the dispute for at least 30 days.
|
||||
|
||||
### 18.2 Governing law.
|
||||
|
||||
These Terms are governed by the laws of the State of California, without regard to conflict of law principles.
|
||||
|
||||
### 18.3 Venue.
|
||||
|
||||
Any disputes arising out of or relating to these Terms or the Service must be brought in the state or federal courts located in California, and each party consents to personal jurisdiction and venue there.
|
||||
|
||||
### 18.4 Injunctive relief.
|
||||
|
||||
Nothing in these Terms prevents either party from seeking injunctive or other equitable relief to protect its intellectual property or confidential information.
|
||||
|
||||
### 18.5 Time limit to bring claims.
|
||||
|
||||
To the fullest extent permitted by law, any claim arising out of or relating to these Terms or the Service must be filed within one (1) year after the cause of action accrues; otherwise, it is permanently barred.
|
||||
|
||||
## 19. Changes to Terms
|
||||
|
||||
We may update these Terms from time to time. If we make material changes, we will provide reasonable notice by email or by posting a notice on the Service before the changes take effect. The updated Terms will be effective as of the "Last updated" date unless otherwise stated. Your continued use of the Service after the effective date, including accepting the updated Terms through the Service, constitutes acceptance of the updated Terms. If you do not agree to the changes, you must stop using the Service and close your account.
|
||||
|
||||
## 20. General terms
|
||||
|
||||
### 20.1 Entire agreement; order of precedence.
|
||||
|
||||
These Terms (including the [Privacy Policy](/privacy) and any policies referenced in the Service) are the entire agreement between you and Faradworks regarding the Service and supersede prior or contemporaneous agreements or understandings. If you have a separate written agreement signed by Faradworks that expressly governs the Service, that agreement will control to the extent of conflict.
|
||||
|
||||
### 20.2 Severability.
|
||||
|
||||
If any provision of these Terms is held invalid or unenforceable, the remaining provisions will remain in full force and effect.
|
||||
|
||||
### 20.3 Waiver.
|
||||
|
||||
Failure to enforce any provision is not a waiver of future enforcement.
|
||||
|
||||
### 20.4 Assignment.
|
||||
|
||||
You may not assign these Terms without Faradworks' prior written consent. Faradworks may assign these Terms in connection with a merger, acquisition, corporate reorganization, or sale of all or substantially all of its assets, or otherwise upon notice.
|
||||
|
||||
### 20.5 No third-party beneficiaries.
|
||||
|
||||
Except for Faradworks' affiliates, licensors, suppliers, and subprocessors as intended third-party beneficiaries of Sections 14 (Disclaimers) and 15 (Limitation of Liability), there are no third-party beneficiaries to these Terms.
|
||||
|
||||
### 20.6 Independent contractors.
|
||||
|
||||
The parties are independent contractors. Nothing in these Terms creates any agency, partnership, joint venture, or employment relationship.
|
||||
|
||||
### 20.7 Headings.
|
||||
|
||||
Headings are for convenience only and do not affect interpretation.
|
||||
|
||||
## 21. Contact
|
||||
|
||||
Faradworks, Inc.
|
||||
|
||||
Questions about these Terms or the Privacy Policy: [dev@faradworks.com](mailto:dev@faradworks.com)
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { NextConfig } from "next";
|
||||
import {
|
||||
CSP_CONNECT_HOSTS,
|
||||
CSP_FRAME_HOSTS,
|
||||
CSP_SCRIPT_HOSTS,
|
||||
} from "./src/lib/csp-hosts";
|
||||
|
||||
const extra = (hosts: string[]) => (hosts.length ? ` ${hosts.join(" ")}` : "");
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ["pdfjs-dist"],
|
||||
turbopack: {
|
||||
resolveAlias: {
|
||||
canvas: { browser: "" },
|
||||
},
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{
|
||||
key: "Strict-Transport-Security",
|
||||
value: "max-age=31536000; includeSubDomains",
|
||||
},
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://unpkg.com https://vercel.live" +
|
||||
extra(CSP_SCRIPT_HOSTS),
|
||||
"style-src 'self' 'unsafe-inline' https://vercel.live https://fonts.googleapis.com",
|
||||
"img-src 'self' data: https: blob:",
|
||||
"font-src 'self' data: https://vercel.live https://assets.vercel.com https://fonts.gstatic.com",
|
||||
"connect-src 'self' blob: https://storage.googleapis.com https://vercel.live wss://ws-us3.pusher.com" +
|
||||
extra(CSP_CONNECT_HOSTS) +
|
||||
" " +
|
||||
(process.env.NEXT_PUBLIC_API_URL?.trim() || "http://localhost:8000"),
|
||||
"frame-src 'self' https://vercel.live" + extra(CSP_FRAME_HOSTS),
|
||||
"worker-src 'self' blob:",
|
||||
].join("; "),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "2.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"sync-version": "node scripts/sync-version.mjs",
|
||||
"predev": "node scripts/sync-version.mjs",
|
||||
"prebuild": "node scripts/sync-version.mjs",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dagre": "^0.8.5",
|
||||
"lucide-react": "^1.8.0",
|
||||
"next": "16.2.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-pdf": "^10.4.1",
|
||||
"shadcn": "^4.2.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 378 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
version="1.1"
|
||||
width="664.2135"
|
||||
height="150.37209"
|
||||
id="svg3361">
|
||||
<defs
|
||||
id="defs3363" />
|
||||
<g
|
||||
transform="translate(-16.464666,-437.17614)"
|
||||
id="layer1">
|
||||
<path
|
||||
d="m 127.42714,438.17614 c -20.44972,0.74271 -37.638575,11.92912 -47.344393,28.94383 l -62.618081,116.69732 47.38449,-0.04 11.104486,-25.25569 35.438138,0 -1.363,25.29578 47.42457,0 -5.49211,-145.64114 -24.5341,0 z m 75.16575,0 -36.44036,145.64115 41.09061,0 36.56062,-145.64115 -41.21087,0 z m 60.65374,1.44319 -29.62532,117.41891 c -3.15651,11.84109 -2.20708,19.47656 2.80618,22.97065 4.46468,3.42658 12.95462,5.04904 25.49622,4.93087 7.22451,0 14.9718,-0.35023 23.25127,-1.04229 l 6.17362,-24.49401 -6.85512,0 c -6.84471,0.0844 -9.59593,-2.8103 -8.17803,-8.6591 l 13.1089,-52.59597 17.23802,0 6.13352,-24.73455 -17.11775,0 8.73928,-33.79451 -41.17079,0 z m -146.96406,30.22665 -3.36743,60.73392 -24.694449,0 28.061879,-60.73392 z m 471.68008,0.52115 c -15.07355,0 -26.79998,5.4961 -35.19762,16.47633 l -0.48106,0 3.28725,-13.4697 -41.53158,0 -27.70108,110.40344 41.21087,0 18.28031,-72.84062 c 2.26187,-8.84495 7.37188,-13.20386 15.31377,-13.06881 7.87436,-0.13505 10.62136,4.24707 8.25821,13.1089 l -18.28031,72.80053 41.09061,0 18.2803,-72.84062 c 2.30408,-8.84495 7.52169,-13.20386 15.67457,-13.06881 7.95033,-0.13505 10.68044,4.22386 8.25821,13.06881 l -18.20013,72.84062 18.16004,0 c 12.88763,0 25.1438,-8.52382 28.02179,-20.20455 l 15.83492,-63.09914 c 2.75982,-11.57101 1.54246,-19.60946 -3.64805,-24.13321 -5.3593,-4.05112 -12.89561,-6.02381 -22.60985,-5.97317 -6.54088,0 -12.93787,1.27855 -19.24243,3.76831 -6.524,2.59103 -12.29438,7.0069 -17.35828,13.26925 -1.01278,-5.72221 -3.6142,-10.01571 -7.81724,-12.86837 -4.279,-2.77671 -10.81735,-4.16919 -19.60322,-4.16919 z m -259.97326,3.0868 -27.58081,110.36336 41.05051,0 27.70109,-110.36336 -41.17079,0 z m 59.85198,0 -20.84597,83.38387 c -2.68387,11.52038 -1.29771,19.41957 4.12911,23.57198 5.08078,4.24523 12.34703,6.25168 21.80809,6.13352 14.98914,0 26.62063,-5.3252 34.91699,-16.03536 l 0.48106,0 -3.24716,13.34944 41.05052,0 27.58082,-110.40345 -41.1307,0 -18.28031,72.84062 c -2.27875,8.84495 -7.3993,13.23339 -15.43403,13.14899 -7.76466,0.0844 -10.46945,-4.30404 -8.09786,-13.14899 l 18.24022,-72.84062 -41.17078,0 z"
|
||||
id="path278"
|
||||
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="100"
|
||||
height="11"
|
||||
viewBox="0 0 100 11"
|
||||
fill="none"
|
||||
version="1.1"
|
||||
id="svg20"
|
||||
sodipodi:docname="Autodesk_Logo_2021.svg"
|
||||
inkscape:version="1.1 (c68e22c387, 2021-05-23)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<title
|
||||
id="title841">Autodesk logo</title>
|
||||
<defs
|
||||
id="defs24" />
|
||||
<sodipodi:namedview
|
||||
id="namedview22"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#111111"
|
||||
borderopacity="1"
|
||||
inkscape:pageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="1"
|
||||
showgrid="false"
|
||||
width="100px"
|
||||
height="11px"
|
||||
inkscape:zoom="2.8183615"
|
||||
inkscape:cx="125.42748"
|
||||
inkscape:cy="-2.4837126"
|
||||
inkscape:window-width="1366"
|
||||
inkscape:window-height="746"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg20" />
|
||||
<path
|
||||
id="path2-1"
|
||||
style="fill:#000000;stroke-width:0.296752"
|
||||
d="m 54.504191,0.41597954 c -3.525698,0 -5.351957,2.16877246 -5.351957,5.08939746 0,2.9321968 1.826259,5.112582 5.351957,5.112579 3.537278,0 5.363544,-2.1803822 5.363544,-5.112579 0.0054,-2.920625 -1.826266,-5.08939746 -5.363544,-5.08939746 z m 30.563049,0 c -2.982345,0 -4.479086,1.18561256 -4.479086,3.28512686 0,1.7060221 0.994178,2.5097046 3.051548,2.7293003 l 2.288812,0.2434281 c 0.872746,0.098225 1.271625,0.4333787 1.271625,0.8728668 0,0.4973541 -0.583478,0.942995 -1.988,0.942995 -1.624116,0 -2.277541,-0.4109612 -2.387339,-1.238007 h -2.375166 c 0.109797,2.2324554 1.560273,3.3720664 4.675566,3.3720664 2.843469,0 4.432726,-1.2496001 4.432726,-3.1292201 0,-1.6365829 -0.94156,-2.6196485 -2.721769,-2.822033 L 84.582121,4.4180625 c -1.317874,-0.144517 -1.647204,-0.4743882 -1.647204,-0.9198126 0,-0.4973546 0.676088,-0.942416 1.999596,-0.942416 1.271582,0 1.936179,0.4745323 2.057548,1.1858441 h 2.422126 C 89.292518,1.6884566 87.575681,0.41597954 85.06724,0.41597954 Z M 29.755606,0.64723744 V 6.9282752 c 0,2.7989563 2.496536,3.6722948 4.588636,3.6722948 2.080821,0 4.566024,-0.8733385 4.566024,-3.6722948 V 0.64723744 H 36.436564 V 6.6448533 h 0.01161 c 0,1.260896 -0.774034,1.7694952 -2.09754,1.7694952 -1.283153,0 -2.121311,-0.5317461 -2.121311,-1.7694952 V 0.64723744 Z M 9.0004214,0.68201178 0.3482729,6.0600471 v 4.3202769 h 0.074775 L 9.098383,4.9791071 h 4.502271 c 0.138875,0 0.266028,0.109647 0.266028,0.2660354 0,0.127009 -0.05776,0.1793062 -0.126931,0.2196638 L 9.4797558,8.020806 C 9.2022887,8.188471 9.1041774,8.5180638 9.1041774,8.766741 l -0.00621,1.613583 H 14.513677 V 1.0175947 c 0,-0.17923705 -0.138574,-0.33558363 -0.346598,-0.33558363 z m 13.5305746,0 -3.716926,9.69889522 h 2.554841 l 0.64741,-1.8164438 h 4.149878 l 0.635812,1.8164438 h 2.641196 L 25.680495,0.68201208 Z m 17.512375,0 V 2.7812944 h 3.126898 v 7.5996126 h 2.4679 V 2.7812948 H 48.75348 V 0.68201208 Z m 21.170756,0 V 10.380907 h 4.531836 c 3.473762,0 4.432713,-1.2958946 4.432713,-4.9218961 0,-3.4064069 -0.918586,-4.77699912 -4.432713,-4.77699912 z m 10.346301,0 V 10.380907 h 7.802463 V 8.2810414 H 74.034122 V 6.5231422 h 4.288407 V 4.4180635 H 74.034122 V 2.7812948 h 5.328769 V 0.68201208 Z m 19.257526,0 0.01161,9.69889522 h 2.462678 V 6.6970171 l 3.340776,3.6838899 h 3.126888 L 95.251275,5.5923173 99.759904,0.68201208 H 96.817903 L 93.29224,4.620341 V 0.68201208 Z M 54.504191,2.5964075 c 1.843719,0 2.832466,1.1275738 2.832466,2.9089704 0.0054,1.8217553 -0.988747,2.9321552 -2.832466,2.9321552 -1.826506,0 -2.83189,-1.1163342 -2.83189,-2.9321552 0,-1.7813966 1.005384,-2.9089704 2.83189,-2.9089704 z m -30.395548,0.1043257 1.334803,3.7992237 h -2.693361 z m 39.573969,0.080563 h 1.964814 c 1.549048,0 2.011184,0.4221106 2.011184,2.6777164 0,2.0588589 -0.520007,2.8220306 -2.011184,2.8220306 h -1.964814 z" />
|
||||
<metadata
|
||||
id="metadata839">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:title>Autodesk logo</dc:title>
|
||||
<dc:date>21 sep 2021</dc:date>
|
||||
<cc:license
|
||||
rdf:resource="http://scripts.sil.org/OFL" />
|
||||
</cc:Work>
|
||||
<cc:License
|
||||
rdf:about="http://scripts.sil.org/OFL">
|
||||
<cc:permits
|
||||
rdf:resource="http://scripts.sil.org/pub/OFL/Reproduction" />
|
||||
<cc:permits
|
||||
rdf:resource="http://scripts.sil.org/pub/OFL/Distribution" />
|
||||
<cc:permits
|
||||
rdf:resource="http://scripts.sil.org/pub/OFL/Embedding" />
|
||||
<cc:permits
|
||||
rdf:resource="http://scripts.sil.org/pub/OFL/DerivativeWorks" />
|
||||
<cc:requires
|
||||
rdf:resource="http://scripts.sil.org/pub/OFL/Notice" />
|
||||
<cc:requires
|
||||
rdf:resource="http://scripts.sil.org/pub/OFL/Attribution" />
|
||||
<cc:requires
|
||||
rdf:resource="http://scripts.sil.org/pub/OFL/ShareAlike" />
|
||||
<cc:requires
|
||||
rdf:resource="http://scripts.sil.org/pub/OFL/DerivativeRenaming" />
|
||||
<cc:requires
|
||||
rdf:resource="http://scripts.sil.org/pub/OFL/BundlingWhenSelling" />
|
||||
</cc:License>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="51.02mm" height="9.642mm" version="1.1" viewBox="0 0 51.02 9.642" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<title>Cadence Design Systems logo</title>
|
||||
<desc>A software company based in San Jose, California, United States that specialised in EDA</desc>
|
||||
<metadata>
|
||||
<rdf:RDF>
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
|
||||
<dc:title/>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g transform="translate(10.07 40.7)">
|
||||
<path d="m-3.081-39.63h3.903v-1.074h-3.903z" style="fill:#d21c2e"/>
|
||||
<g transform="matrix(.5857 0 0 -.5857 -6.335 -31.28)">
|
||||
<path d="m0 0c-0.355-0.189-0.99-0.379-1.841-0.379-2.729 0-4.543 2.021-4.543 5.801 0 3.443 1.905 6.028 4.951 6.028 0.679 0 1.12-0.165 1.433-0.334v-2c-0.249 0.102-0.829 0.257-1.383 0.257-1.641 0-2.487-1.786-2.487-3.901 0-2.351 0.95-3.783 2.489-3.783 0.464 0 1.014 0.091 1.381 0.273v-1.962" style="fill:#231f20"/>
|
||||
</g>
|
||||
<g transform="matrix(.5857 0 0 -.5857 -.6332 -32.24)">
|
||||
<path d="m0 0 1e-3 4.054c-0.98-0.013-2.759-0.284-2.759-2.285 0-1.299 0.692-1.768 1.246-1.768zm2.482 0.916c0-0.892 0.055-2.029 0.177-2.791h-4.931c-1.648 0-2.892 1.368-2.892 3.384 0 2.955 2.497 4.202 5.098 4.226v0.203c0 1.198-0.469 2.04-1.718 2.04-0.822 0-1.762-0.24-2.384-0.638v1.72c0.565 0.359 1.531 0.822 2.932 0.822 2.96 0 3.718-1.924 3.718-4.371v-4.595" style="fill:#231f20"/>
|
||||
</g>
|
||||
<g transform="matrix(.5857 0 0 -.5857 7.314 -32.25)">
|
||||
<path d="m0 0h-1.468c-1.057 0-1.669 1.595-1.669 3.891 0 2.056 0.456 3.87 1.731 3.87 0.744 0 1.261-0.818 1.369-1.742 0.037-0.233 0.037-0.489 0.037-0.708zm2.511 0.901-5e-3 12.36h-2.506v-4.614h-0.048c-0.368 0.794-1.064 1.249-2.012 1.249-1.857 0-3.59-1.936-3.59-5.971 0-3.464 1.248-5.809 3.472-5.809l4.866-4e-3c-0.121 0.763-0.177 1.898-0.177 2.79" style="fill:#231f20"/>
|
||||
</g>
|
||||
<g transform="matrix(.5857 0 0 -.5857 15.23 -35.17)">
|
||||
<path d="m0 0c0.019 1.845-0.586 2.941-1.48 2.941-1.095 0-1.588-1.593-1.638-2.941zm-3.129-1.794c0.027-2.57 1.22-3.268 2.562-3.268 0.84 0 1.73 0.189 2.241 0.408v-1.813c-0.717-0.338-1.647-0.541-2.698-0.541-2.964 0-4.555 2.15-4.555 5.709 0 3.803 1.823 6.118 4.278 6.118 2.418 0 3.636-2.185 3.636-5.267 0-0.656-0.026-1.026-0.066-1.341l-5.398-5e-3" style="fill:#231f20"/>
|
||||
</g>
|
||||
<g transform="matrix(.5857 0 0 -.5857 21.26 -36.74)">
|
||||
<path d="m0 0v-9.564h-2.52v8.257c0 1.299-0.065 2.316-0.103 3.334h4.968c1.915 0 3.056-1.394 3.056-4.06v-7.531h-2.52v7.291c0 1.149-0.244 2.271-1.341 2.271l-1.54 2e-3" style="fill:#231f20"/>
|
||||
</g>
|
||||
<g transform="matrix(.5857 0 0 -.5857 31.3 -31.28)">
|
||||
<path d="m0 0c-0.355-0.189-0.991-0.379-1.841-0.379-2.729 0-4.545 2.021-4.545 5.801 0 3.443 1.906 6.028 4.952 6.028 0.68 0 1.12-0.165 1.434-0.334v-2c-0.25 0.102-0.83 0.257-1.384 0.257-1.641 0-2.487-1.786-2.487-3.901 0-2.351 0.949-3.783 2.49-3.783 0.465 0 1.013 0.091 1.381 0.273v-1.962" style="fill:#231f20"/>
|
||||
</g>
|
||||
<g transform="matrix(.5857 0 0 -.5857 35.36 -35.17)">
|
||||
<path d="m0 0c0.051 1.348 0.544 2.941 1.637 2.941 0.896 0 1.501-1.096 1.481-2.941zm5.452-0.448c0 3.083-1.217 5.267-3.635 5.267-2.454 0-4.279-2.315-4.279-6.118 0-3.559 1.591-5.709 4.555-5.709 1.053 0 1.983 0.203 2.699 0.541v1.813c-0.513-0.219-1.401-0.408-2.241-0.408-1.343 0-2.534 0.698-2.561 3.268l5.397 5e-3c0.04 0.315 0.065 0.685 0.065 1.341" style="fill:#231f20"/>
|
||||
</g>
|
||||
<g transform="matrix(.5857 0 0 -.5857 40.06 -40.42)">
|
||||
<path d="m0 0c0.834 0 1.513-0.775 1.513-1.728 0-0.951-0.679-1.726-1.513-1.726-0.836 0-1.512 0.775-1.512 1.726 0 0.953 0.676 1.728 1.512 1.728zm5e-3 -3.196c0.696 0 1.219 0.641 1.219 1.468 0 0.829-0.523 1.469-1.219 1.469-0.704 0-1.228-0.64-1.228-1.469 0-0.827 0.524-1.468 1.228-1.468zm-0.584 2.483h0.68c0.406 0 0.608-0.186 0.608-0.569 0-0.326-0.181-0.559-0.459-0.559l0.504-0.895h-0.305l-0.496 0.895h-0.243v-0.895h-0.289zm0.289-0.87h0.343c0.224 0 0.366 0.052 0.366 0.315 0 0.23-0.18 0.297-0.366 0.297h-0.343v-0.612" style="fill:#231f20"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="335.33405"
|
||||
height="136.76263"
|
||||
viewBox="0 0 335.33405 136.76263"
|
||||
version="1.1">
|
||||
<g transform="translate(-40.547253,-883.62366)" fill="#ffffff">
|
||||
<!-- Rect with K, i, and orange-dot cut out via evenodd -->
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="m 51.678648,901.97614 114.391972,0 c 0.51273,0 0.9255,0.43179 0.9255,0.96814 l 0,109.79552 c 0,0.5364 -0.41277,0.9682 -0.9255,0.9682 l -114.391972,0 c -0.512727,0 -0.925501,-0.4318 -0.925501,-0.9682 l 0,-109.79552 c 0,-0.53635 0.412774,-0.96814 0.925501,-0.96814 z M 80.269477,948.26032 l 16.927605,-21.41018 c 3.460178,-4.36453 5.190288,-7.62815 5.190348,-9.79088 -6e-5,-0.78633 -0.0984,-1.41546 -0.29491,-1.8874 l 29.54958,0 c -2.24136,1.25835 -5.0528,3.91251 -8.43431,7.96246 -0.90445,1.06175 -2.49694,3.00812 -4.77747,5.83914 l -22.176937,27.60321 26.482557,36.45038 c 1.61208,2.20197 3.65676,4.67918 6.13405,7.43165 0.66837,0.7078 1.63173,1.5532 2.89008,2.5362 l -30.31634,0 c 0.23587,-0.9044 0.35383,-1.7105 0.35389,-2.4183 -6e-5,-2.1626 -1.49425,-5.2886 -4.482575,-9.37797 l -17.045568,-23.23859 0,23.5335 c -3.2e-5,5.4656 1.100949,9.29936 3.302948,11.50136 l -27.721166,0 c 1.533502,-1.5335 2.516521,-3.28331 2.949061,-5.24935 0.235913,-1.10098 0.353875,-3.16532 0.353887,-6.19303 l 0,-64.9383 c -1.2e-5,-3.02763 -0.117974,-5.09197 -0.353887,-6.19303 -0.43254,-1.96596 -1.415559,-3.71573 -2.949061,-5.24933 l 27.721166,0 c -2.201999,2.20205 -3.30298,6.01617 -3.302948,11.44236 l 0,21.6461 z M 159.48123,938.46944 l 0,54.14474 c -3e-5,2.83111 0.0786,4.67918 0.23593,5.54423 0.31453,1.76944 1.14027,3.38159 2.47721,4.83649 l -25.95173,0 c 1.3369,-1.4549 2.16263,-3.06705 2.47721,-4.83649 0.15727,-0.86505 0.23591,-2.71312 0.23592,-5.54423 l 0,-43.88201 c -1e-5,-2.83104 -0.0787,-4.65946 -0.23592,-5.48526 -0.31458,-1.73005 -1.12065,-3.32254 -2.41823,-4.77747 l 23.17961,0 z M 149.2185,907.62227 c 3.26361,9e-5 6.03572,1.16006 8.31635,3.47989 2.3199,2.2807 3.47986,5.05281 3.47989,8.31635 -3e-5,3.34235 -1.14033,6.15378 -3.42091,8.43431 -2.24131,2.24136 -5.03308,3.362 -8.37533,3.36193 -3.26364,7e-5 -6.05541,-1.14023 -8.37533,-3.42091 -2.28061,-2.31985 -3.42092,-5.11162 -3.42091,-8.37533 -1e-5,-3.34218 1.12064,-6.13396 3.36193,-8.37533 2.28059,-2.28051 5.09203,-3.42082 8.43431,-3.42091 z M 161.16831,902.00507 c 0,6.60244 -5.36817,11.95472 -11.99015,11.95472 -6.62197,0 -11.99015,-5.35228 -11.99015,-11.95472 0,-6.60244 5.36818,-11.9548 11.99015,-11.9548 6.62198,0 11.99015,5.35236 11.99015,11.9548 z" />
|
||||
<!-- C -->
|
||||
<path
|
||||
d="m 236.09782,999.10211 c -6.25207,3.53889 -13.25117,5.30829 -20.99731,5.30829 -12.30745,0 -22.11798,-4.56119 -29.43162,-13.68362 -6.80251,-8.45395 -10.20376,-18.99191 -10.20375,-31.61392 -1e-5,-14.15543 4.168,-25.40117 12.50402,-33.73725 7.70685,-7.70679 16.82926,-11.56023 27.36727,-11.56032 6.88109,9e-5 13.91951,2.00545 21.11527,6.01609 1.84801,1.02242 3.3422,1.59257 4.48258,1.71045 l -10.55764,15.33511 c -3.46029,-4.40385 -8.08048,-6.60582 -13.86058,-6.60589 -9.04382,7e-5 -14.94194,5.58362 -17.69436,16.75066 -0.90441,3.65689 -1.3566,7.68727 -1.35657,12.09115 -3e-5,12.50404 3.16529,21.03665 9.49597,25.59784 2.94902,2.12334 6.09468,3.185 9.437,3.18499 5.9767,1e-5 11.28501,-2.55584 15.92492,-7.66756 l 9.79088,16.04289 c -0.47192,-0.11796 -2.47728,0.82574 -6.01608,2.83109" />
|
||||
<!-- a -->
|
||||
<path
|
||||
d="m 278.98304,984.88764 0,-10.20374 c -1.49423,-0.27522 -3.06706,-0.41284 -4.71849,-0.41287 -3.30298,3e-5 -6.13408,0.58984 -8.4933,1.76943 -2.9884,1.49422 -4.48259,3.7355 -4.48257,6.72386 -2e-5,2.2413 0.84537,4.0304 2.53619,5.36729 1.53349,1.21896 3.4602,1.82843 5.78016,1.82842 4.28593,1e-5 7.41193,-1.69078 9.37801,-5.07239 m 23.17962,18.10726 -21.99999,0 0,-5.01343 c -4.05008,4.12873 -9.26009,6.23233 -15.63002,6.31103 -4.52192,0.039 -8.63094,-0.9634 -12.32707,-3.0081 -3.89277,-2.16262 -6.72387,-5.22964 -8.4933,-9.20105 -1.25827,-2.83108 -1.8874,-6.03573 -1.8874,-9.61394 0,-8.17869 3.99106,-14.21443 11.97319,-18.10722 4.60051,-2.24125 11.04912,-3.36189 19.34583,-3.36193 1.17959,4e-5 3.22427,0.0787 6.13405,0.23592 -4e-5,-6.72381 -2.92944,-10.08573 -8.7882,-10.08578 -4.99377,5e-5 -10.24309,1.45492 -15.74798,4.3646 l -5.83914,-14.0965 c 1.29757,6e-5 3.83376,-0.47179 7.60857,-1.41555 l 8.02145,-2.00536 c 2.7131,-0.55043 5.66216,-0.82567 8.84718,-0.82574 12.58261,7e-5 20.48608,4.01078 23.71044,12.03217 1.57277,3.93213 2.35919,8.78824 2.35925,14.56835 l 0,28.84181 c -6e-5,2.83111 0.0786,4.67918 0.23592,5.54423 0.31451,1.76944 1.14024,3.38159 2.47722,4.83649" />
|
||||
<!-- d -->
|
||||
<path
|
||||
d="m 347.28917,984.88764 0,-29.43162 c -2.59521,-2.20191 -5.48529,-3.30289 -8.67023,-3.30294 -6.92049,5e-5 -10.38072,6.7239 -10.38069,20.17157 -3e-5,10.85256 3.16529,16.27882 9.49597,16.27881 3.30291,10e-6 6.48789,-1.23859 9.55495,-3.71582 m -2.65415,-75.08307 23.17961,0 0,82.80961 c -6e-5,4.91511 0.90431,8.37532 2.71314,10.38072 l -22.05897,0 0,-5.01343 c -4.28601,4.20733 -9.7516,6.31103 -16.39678,6.31103 -5.6229,0 -10.49867,-1.6908 -14.62733,-5.07242 -3.89278,-3.18498 -6.74353,-7.52993 -8.55228,-13.03485 -1.25827,-3.85342 -1.8874,-8.13938 -1.8874,-12.8579 0,-15.64964 3.99106,-26.34488 11.97319,-32.08578 3.77477,-2.71307 8.11972,-4.06963 13.03484,-4.0697 6.4879,7e-5 11.57994,1.74984 15.27613,5.24933 l 0,-22.35388 c -4e-5,-2.83101 -0.0787,-4.65943 -0.23592,-5.48525 -0.31461,-1.73002 -1.12069,-3.32252 -2.41823,-4.77748" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,81 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="250px" height="100px" viewBox="0 0 250 100" enable-background="new 0 0 250 100" xml:space="preserve">
|
||||
<rect display="none" fill="none" width="1368" height="1008"/>
|
||||
<g>
|
||||
<g>
|
||||
<path fill="#231F20" d="M86.231,72.341c-0.185-0.096-0.495-0.272-1.05-0.272c-0.813,0-1.33,0.604-1.33,1.565
|
||||
c0,0.928,0.583,1.553,1.353,1.553c0.591,0,0.938-0.205,1.057-0.25l0.044,0.993c-0.207,0.062-0.637,0.17-1.293,0.17
|
||||
c-1.413,0-2.405-0.973-2.405-2.48c0-1.743,1.272-2.439,2.322-2.439c0.768,0,1.16,0.16,1.359,0.233L86.231,72.341z"/>
|
||||
<path fill="#231F20" d="M91.178,74.955H89.56l-0.354,1.062h-1.171l1.717-4.753h1.33l1.656,4.753h-1.242L91.178,74.955z
|
||||
M90.408,72.178h-0.016l-0.613,1.974h1.162L90.408,72.178z"/>
|
||||
<path fill="#231F20" d="M94.749,71.264h1.833c1.161,0,2.306,0.627,2.306,2.417c0,1.643-1.159,2.336-2.461,2.336h-1.678V71.264
|
||||
L94.749,71.264z M95.961,75.187h0.383c0.88,0,1.272-0.64,1.272-1.506c0-0.98-0.406-1.585-1.346-1.585H95.96L95.961,75.187
|
||||
L95.961,75.187z"/>
|
||||
<polygon fill="#231F20" points="101.012,71.264 103.99,71.264 103.99,72.096 102.18,72.096 102.18,73.158 103.904,73.158
|
||||
103.904,73.988 102.18,73.988 102.18,75.187 104.08,75.187 104.08,76.017 101.012,76.017 "/>
|
||||
<polygon fill="#231F20" points="106.305,71.264 107.726,71.264 109.227,74.562 109.242,74.562 109.242,71.264 110.305,71.264
|
||||
110.305,76.017 108.872,76.017 107.383,72.579 107.371,72.579 107.371,76.017 106.305,76.017 "/>
|
||||
<path fill="#231F20" d="M116.213,72.341c-0.185-0.096-0.494-0.272-1.048-0.272c-0.814,0-1.332,0.604-1.332,1.565
|
||||
c0,0.928,0.584,1.553,1.354,1.553c0.592,0,0.939-0.205,1.057-0.25l0.045,0.993c-0.208,0.062-0.637,0.17-1.295,0.17
|
||||
c-1.412,0-2.403-0.973-2.403-2.48c0-1.743,1.273-2.439,2.321-2.439c0.77,0,1.162,0.16,1.361,0.233L116.213,72.341z"/>
|
||||
<polygon fill="#231F20" points="118.315,71.264 121.295,71.264 121.295,72.096 119.483,72.096 119.483,73.158 121.204,73.158
|
||||
121.204,73.988 119.483,73.988 119.483,75.187 121.385,75.187 121.385,76.017 118.315,76.017 "/>
|
||||
<path fill="#231F20" d="M127.031,71.264h1.651c1.099,0,1.764,0.484,1.764,1.511c0,0.812-0.486,1.508-1.616,1.508h-0.586v1.734
|
||||
h-1.213V71.264L127.031,71.264z M128.584,73.45c0.376,0,0.651-0.239,0.651-0.68c0-0.472-0.237-0.677-0.718-0.677h-0.318v1.356
|
||||
H128.584L128.584,73.45z"/>
|
||||
<path fill="#231F20" d="M136.1,72.341c-0.188-0.096-0.499-0.272-1.052-0.272c-0.814,0-1.331,0.604-1.331,1.565
|
||||
c0,0.928,0.584,1.553,1.352,1.553c0.593,0,0.941-0.205,1.059-0.25l0.043,0.993c-0.208,0.062-0.638,0.17-1.292,0.17
|
||||
c-1.414,0-2.405-0.973-2.405-2.48c0-1.743,1.271-2.439,2.321-2.439c0.77,0,1.161,0.16,1.36,0.233L136.1,72.341z"/>
|
||||
<path fill="#231F20" d="M138.346,71.264h1.921c1.088,0,1.428,0.677,1.428,1.186c0,0.756-0.637,1.057-0.932,1.088v0.017
|
||||
c0.584,0.106,1.036,0.401,1.036,1.151c0,1.202-1.294,1.312-1.598,1.312h-1.854L138.346,71.264L138.346,71.264z M139.514,73.2
|
||||
h0.294c0.695,0,0.717-0.5,0.717-0.587c0-0.17-0.105-0.573-0.693-0.573h-0.317V73.2L139.514,73.2z M139.514,75.213h0.34
|
||||
c0.16,0,0.774,0,0.774-0.612c0-0.416-0.251-0.627-0.651-0.627h-0.463V75.213L139.514,75.213z"/>
|
||||
<path fill="#231F20" d="M150.279,72.232c-0.288-0.139-0.695-0.246-1.022-0.246c-0.39,0-0.685,0.162-0.685,0.517
|
||||
c0,0.864,2.033,0.464,2.033,2.154c0,0.896-0.777,1.44-1.804,1.44c-0.628,0-1.17-0.135-1.392-0.197l0.066-0.927
|
||||
c0.363,0.13,0.681,0.291,1.132,0.291c0.383,0,0.754-0.178,0.754-0.562c0-0.92-2.032-0.471-2.032-2.167
|
||||
c0-0.144,0.057-1.356,1.788-1.356c0.477,0,0.771,0.077,1.214,0.164L150.279,72.232z"/>
|
||||
<path fill="#231F20" d="M154.96,71.184c1.485,0,2.194,1.062,2.194,2.457c0,1.397-0.709,2.457-2.194,2.457
|
||||
c-1.484,0-2.189-1.062-2.189-2.457C152.77,72.245,153.475,71.184,154.96,71.184 M154.96,75.297c0.703,0,0.952-0.744,0.952-1.656
|
||||
c0-0.913-0.249-1.654-0.952-1.654c-0.702,0-0.948,0.741-0.948,1.654C154.012,74.553,154.257,75.297,154.96,75.297"/>
|
||||
<polygon fill="#231F20" points="159.462,71.264 160.675,71.264 160.675,75.16 162.383,75.16 162.383,76.017 159.462,76.017 "/>
|
||||
<path fill="#231F20" d="M164.444,71.264h1.215v3.206c0,0.477,0.192,0.771,0.681,0.771c0.487,0,0.68-0.292,0.68-0.771v-3.206h1.215
|
||||
v3.028c0,1.179-0.732,1.806-1.894,1.806c-1.163,0-1.894-0.627-1.894-1.806v-3.028H164.444z"/>
|
||||
<polygon fill="#231F20" points="171.389,72.123 170.267,72.123 170.267,71.264 173.726,71.264 173.726,72.123 172.601,72.123
|
||||
172.601,76.017 171.389,76.017 "/>
|
||||
<rect x="175.812" y="71.264" fill="#231F20" width="1.213" height="4.753"/>
|
||||
<path fill="#231F20" d="M181.518,71.184c1.485,0,2.196,1.062,2.196,2.457c0,1.397-0.711,2.457-2.196,2.457
|
||||
s-2.188-1.062-2.188-2.457S180.032,71.184,181.518,71.184 M181.518,75.297c0.701,0,0.954-0.744,0.954-1.656
|
||||
c0-0.913-0.253-1.654-0.954-1.654s-0.944,0.741-0.944,1.654C180.574,74.553,180.816,75.297,181.518,75.297"/>
|
||||
<polygon fill="#231F20" points="185.999,71.264 187.419,71.264 188.92,74.562 188.936,74.562 188.936,71.264 190.001,71.264
|
||||
190.001,76.017 188.565,76.017 187.079,72.579 187.066,72.579 187.066,76.017 185.999,76.017 "/>
|
||||
<path fill="#231F20" d="M195.242,72.232c-0.287-0.139-0.695-0.246-1.021-0.246c-0.392,0-0.687,0.162-0.687,0.517
|
||||
c0,0.864,2.033,0.464,2.033,2.154c0,0.896-0.776,1.44-1.803,1.44c-0.63,0-1.17-0.135-1.394-0.197l0.068-0.927
|
||||
c0.36,0.13,0.679,0.291,1.13,0.291c0.384,0,0.754-0.178,0.754-0.562c0-0.92-2.034-0.471-2.034-2.167
|
||||
c0-0.144,0.061-1.356,1.791-1.356c0.471,0,0.769,0.077,1.212,0.164L195.242,72.232z"/>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<path fill="#E31837" d="M58.978,23.971c11.006,0,16.192,9.015,16.089,18.919c-0.104,11.215-5.555,18.918-16.089,18.918
|
||||
c-10.533,0-15.983-7.703-16.089-18.918C42.785,32.985,47.971,23.971,58.978,23.971z M46.504,42.89
|
||||
c0,7.755,3.876,15.773,12.474,15.773c8.595,0,12.471-8.019,12.471-15.773c0-7.758-3.875-15.774-12.471-15.774
|
||||
S46.504,35.132,46.504,42.89z"/>
|
||||
<path fill="#E31837" d="M82.559,40.429c0-2.938,0-4.039-0.209-5.977h3.303v5.138h0.105c1.204-2.988,3.458-5.766,6.865-5.766
|
||||
c0.786,0,1.729,0.157,2.306,0.313v3.461c-0.684-0.21-1.572-0.316-2.411-0.316c-5.239,0-6.654,5.868-6.654,10.692v13.207h-3.302
|
||||
V40.429H82.559z"/>
|
||||
<path fill="#E31837" d="M126.006,28.688c-2.309-1.207-5.609-1.572-8.176-1.572c-9.487,0-14.833,6.709-14.833,15.774
|
||||
c0,9.224,5.189,15.775,14.833,15.775c2.409,0,6.077-0.313,8.176-1.572l0.205,3.145c-1.991,1.205-6.076,1.57-8.381,1.57
|
||||
c-11.532,0-18.449-7.598-18.449-18.918c0-11.109,7.128-18.919,18.449-18.919c2.147,0,6.498,0.366,8.381,1.363L126.006,28.688z"/>
|
||||
<path fill="#E31837" d="M146.998,24.6h4.087l14.514,36.582h-3.772l-3.93-9.802h-18.447l-3.93,9.802h-3.51L146.998,24.6z
|
||||
M148.779,28.061l-7.967,20.175h15.88L148.779,28.061z"/>
|
||||
<path fill="#E31837" d="M171.544,24.6h9.065c13.104,0,18.291,7.547,18.291,18.239c0,13.258-8.855,18.343-20.963,18.343h-6.393
|
||||
L171.544,24.6L171.544,24.6z M175.161,58.034h2.986c10.375,0,17.138-4.192,17.138-15.406c0-11.162-6.655-14.883-14.832-14.883
|
||||
h-5.292V58.034L175.161,58.034z"/>
|
||||
</g>
|
||||
<path d="M201.269,24.446h-0.989V23.9h2.574v0.546h-0.993v2.679h-0.592V24.446L201.269,24.446z M203.611,23.901h0.917l0.843,2.182
|
||||
l0.825-2.182h0.917v3.226h-0.589v-2.464h-0.013l-0.909,2.464h-0.481l-0.905-2.464h-0.014v2.464h-0.591V23.901L203.611,23.901z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.3 KiB |
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Creator: CorelDRAW -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="210mm" height="50mm" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
|
||||
viewBox="0 0 210 50">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
.fil0 {fill:#009999}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Ebene_x0020_1">
|
||||
<metadata id="CorelCorpID_0Corel-Layer"/>
|
||||
<path class="fil0" d="M200.121 10.3466l0 5.8289c-3.0198,-1.14 -5.7084,-1.7164 -8.0615,-1.7164 -1.3938,0 -2.5037,0.2581 -3.3382,0.7571 -0.8346,0.5033 -1.2605,1.1228 -1.2605,1.8541 0,0.9722 0.9421,1.8368 2.8392,2.6112l5.4805 2.6671c4.4309,2.1122 6.6291,4.9169 6.6291,8.4401 0,2.9295 -1.1658,5.2654 -3.5189,6.9947 -2.3359,1.7466 -5.4805,2.6112 -9.3951,2.6112 -1.8068,0 -3.4285,-0.0774 -4.8696,-0.2409 -1.4411,-0.1548 -3.0973,-0.4732 -4.9342,-0.9292l0 -6.0999c3.3683,1.14 6.4355,1.7164 9.1972,1.7164 3.2952,0 4.9342,-0.955 4.9342,-2.8822 0,-0.9593 -0.6711,-1.7336 -2.0347,-2.3402l-6.0871 -2.594c-2.2455,-1.0152 -3.9146,-2.2455 -5.0073,-3.7038 -1.0754,-1.4712 -1.6218,-3.1575 -1.6218,-5.0847 0,-2.6973 1.1357,-4.8697 3.3812,-6.5216 2.2628,-1.639 5.2655,-2.4606 8.9994,-2.4606 1.2131,0 2.6112,0.1075 4.1599,0.3054 1.5615,0.2108 3.0628,0.4689 4.5082,0.7873z"/>
|
||||
<path class="fil0" d="M27.7222 10.3466l0 5.8289c-3.0199,-1.14 -5.7042,-1.7164 -8.0573,-1.7164 -1.3981,0 -2.5036,0.2581 -3.3382,0.7571 -0.8345,0.5033 -1.2604,1.1228 -1.2604,1.8541 0,0.9722 0.955,1.8368 2.8521,2.6112l5.4805 2.6671c4.4136,2.1122 6.6162,4.9169 6.6162,8.4401 0,2.9295 -1.1701,5.2654 -3.506,6.9947 -2.3531,1.7466 -5.4805,2.6112 -9.408,2.6112 -1.8068,0 -3.4329,-0.0774 -4.874,-0.2409 -1.4411,-0.1548 -3.0801,-0.4732 -4.9298,-0.9292l0 -6.0999c3.3812,1.14 6.4484,1.7164 9.1929,1.7164 3.2952,0 4.9342,-0.955 4.9342,-2.8822 0,-0.9593 -0.6668,-1.7336 -2.0176,-2.3402l-6.087 -2.594c-2.2628,-1.0152 -3.9319,-2.2455 -5.0073,-3.7038 -1.0927,-1.4712 -1.6261,-3.1575 -1.6261,-5.0847 0,-2.6973 1.1271,-4.8697 3.3855,-6.5216 2.2456,-1.639 5.2525,-2.4606 8.9865,-2.4606 1.226,0 2.6069,0.1075 4.1727,0.3054 1.5487,0.2108 3.05,0.4689 4.4911,0.7873z"/>
|
||||
<polygon class="fil0" points="34.0028,9.8002 42.9291,9.8002 42.9291,39.8483 34.0028,39.8483 "/>
|
||||
<polygon class="fil0" points="71.6866,9.8002 71.6866,15.3539 58.4241,15.3539 58.4241,22.0173 69.9272,22.0173 69.9272,27.0246 58.4241,27.0246 58.4241,34.0194 71.9576,34.0194 71.9576,39.8483 49.8335,39.8483 49.8335,9.8002 "/>
|
||||
<polygon class="fil0" points="113.358,9.8002 113.358,39.8483 105.025,39.8483 105.025,20.0299 96.3789,40.1236 91.234,40.1236 82.9186,20.0299 82.9186,39.8483 76.8918,39.8483 76.8918,9.8002 87.7882,9.8002 95.226,28.1947 103.008,9.8002 "/>
|
||||
<polygon class="fil0" points="142.103,9.8002 142.103,15.3539 128.913,15.3539 128.913,22.0173 140.416,22.0173 140.416,27.0246 128.913,27.0246 128.913,34.0194 142.374,34.0194 142.374,39.8483 120.25,39.8483 120.25,9.8002 "/>
|
||||
<polygon class="fil0" points="173.424,9.8002 173.424,39.8483 163.956,39.8483 153.331,20.5762 153.331,39.8483 147.308,39.8483 147.308,9.8002 157.052,9.8002 167.402,28.7411 167.402,9.8002 "/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,122 @@
|
||||
*PADS-PCB*
|
||||
*PART*
|
||||
MH2 MountingHole:MountingHole_2.2mm_M2_Pad_Via
|
||||
FM2 Fiducial:Fiducial_1mm_Mask2mm
|
||||
C12 Capacitor_SMD:C_0603_1608Metric
|
||||
C9 Capacitor_SMD:C_0603_1608Metric
|
||||
FM3 Fiducial:Fiducial_1mm_Mask2mm
|
||||
C6 Capacitor_SMD:C_0603_1608Metric
|
||||
R2 Resistor_SMD:R_0603_1608Metric
|
||||
C11 Capacitor_SMD:C_0603_1608Metric
|
||||
J1 Connector_USB:USB_C_Receptacle_GCT_USB4085
|
||||
J3 Connector_PinHeader_2.54mm:PinHeader_2x05_P2.54mm_Horizontal
|
||||
MH4 MountingHole:MountingHole_2.2mm_M2_Pad_Via
|
||||
U3 Package_QFP:LQFP-48_7x7mm_P0.5mm
|
||||
R8 Resistor_SMD:R_0603_1608Metric
|
||||
J2 Connector:Tag-Connect_TC2030-IDC-FP_2x03_P1.27mm_Vertical
|
||||
R6 Resistor_SMD:R_0603_1608Metric
|
||||
R4 Resistor_SMD:R_0603_1608Metric
|
||||
R7 Resistor_SMD:R_0603_1608Metric
|
||||
C4 Capacitor_SMD:C_0805_2012Metric
|
||||
C10 Capacitor_SMD:C_0603_1608Metric
|
||||
C3 Capacitor_SMD:C_0805_2012Metric
|
||||
FM1 Fiducial:Fiducial_1mm_Mask2mm
|
||||
X1 Crystal:Crystal_SMD_3225-4Pin_3.2x2.5mm
|
||||
R3 Resistor_SMD:R_0603_1608Metric
|
||||
MH1 MountingHole:MountingHole_2.2mm_M2_Pad_Via
|
||||
D1 Package_TO_SOT_SMD:SOT-23-6
|
||||
C1 Capacitor_SMD:C_0603_1608Metric
|
||||
U2 Package_SO:MSOP-10_3x3mm_P0.5mm
|
||||
C2 Capacitor_SMD:C_0603_1608Metric
|
||||
C5 Capacitor_SMD:C_0603_1608Metric
|
||||
C8 Capacitor_SMD:C_0603_1608Metric
|
||||
R1 Resistor_SMD:R_0603_1608Metric
|
||||
D3 LED_SMD:LED_0603_1608Metric
|
||||
U1 Package_TO_SOT_SMD:SOT-23-5
|
||||
C7 Capacitor_SMD:C_0805_2012Metric
|
||||
R5 Resistor_SMD:R_0603_1608Metric
|
||||
D2 LED_SMD:LED_0603_1608Metric
|
||||
MH3 MountingHole:MountingHole_2.2mm_M2_Pad_Via
|
||||
|
||||
*NET*
|
||||
*SIGNAL* +1V35
|
||||
C8.1 U3.48
|
||||
*SIGNAL* +3V3
|
||||
C4.1 C7.1 J2.1 J3.6 R8.1 U1.5 U3.6
|
||||
|
||||
*SIGNAL* +5V
|
||||
C2.1 C3.1 C5.1 R3.2 U1.1 U1.3 U2.7
|
||||
|
||||
*SIGNAL* +VUSB
|
||||
C1.1 D1.5 J1.A4 J1.A9 J1.B4 J1.B9 R3.1
|
||||
|
||||
*SIGNAL* /HFXIN
|
||||
C9.1 U3.11 X1.1
|
||||
*SIGNAL* /HFXOUT
|
||||
C10.1 U3.12 X1.3
|
||||
*SIGNAL* /I2C0.SCL
|
||||
J3.10 U3.19
|
||||
*SIGNAL* /I2C0.SDA
|
||||
J3.8 U3.18
|
||||
*SIGNAL* /LEDGK
|
||||
D3.1 R7.1
|
||||
*SIGNAL* /LEDRK
|
||||
D2.1 R6.1
|
||||
*SIGNAL* /NRST
|
||||
C12.1 J2.3 R8.2 U3.4
|
||||
*SIGNAL* /ROSC
|
||||
R4.1 U3.8
|
||||
*SIGNAL* /SPI0.CLK
|
||||
J3.1 U3.37
|
||||
*SIGNAL* /SPI0.CS0
|
||||
J3.4 U3.16
|
||||
*SIGNAL* /SPI0.MISO
|
||||
J3.2 U3.38
|
||||
*SIGNAL* /SPI0.MOSI
|
||||
J3.3 U3.36
|
||||
*SIGNAL* /SWCLK
|
||||
J2.4 U3.35
|
||||
*SIGNAL* /SWDIO
|
||||
J2.2 U3.34
|
||||
*SIGNAL* /TIMA0.CCP0
|
||||
J3.7 U3.24
|
||||
*SIGNAL* /TIMA0.CCP1
|
||||
J3.9 U3.23
|
||||
*SIGNAL* /TIMA1.CCP0
|
||||
D2.2 U3.14
|
||||
*SIGNAL* /TIMA1.CCP1
|
||||
D3.2 U3.15
|
||||
*SIGNAL* /UART0.NCTS
|
||||
U2.4 U3.29
|
||||
*SIGNAL* /UART0.NRTS
|
||||
U2.5 U3.30
|
||||
*SIGNAL* /UART0.RX
|
||||
U2.8 U3.2
|
||||
*SIGNAL* /UART0.TX
|
||||
U2.9 U3.1
|
||||
*SIGNAL* /UARTV3
|
||||
C6.1 U2.10
|
||||
*SIGNAL* /USB.D+
|
||||
D1.3 U2.1
|
||||
*SIGNAL* /USB.D-
|
||||
D1.1 U2.2
|
||||
*SIGNAL* /USBC.CC1
|
||||
J1.A5 R2.1
|
||||
*SIGNAL* /USBC.CC2
|
||||
J1.B5 R1.1
|
||||
*SIGNAL* /USBC.D+
|
||||
D1.4 J1.A6 J1.B6
|
||||
*SIGNAL* /USBC.D-
|
||||
D1.6 J1.A7 J1.B7
|
||||
*SIGNAL* /VREF+
|
||||
C11.2 U3.43
|
||||
*SIGNAL* /VREF-
|
||||
C11.1 R5.1 U3.39
|
||||
*SIGNAL* GND
|
||||
C1.2 C10.2 C12.2 C2.2 C3.2 C4.2 C5.2
|
||||
C6.2 C7.2 C8.2 C9.2 D1.2 J1.A1
|
||||
J1.A12 J1.B1 J1.B12 J1.S1 J2.5 J3.5
|
||||
MH1.1 MH2.1 MH3.1 MH4.1 R1.2 R2.2
|
||||
R4.2 R5.2 R6.2 R7.2 U1.2 U2.3
|
||||
U3.7 X1.2 X1.4
|
||||
*END*
|
||||
@@ -0,0 +1,17 @@
|
||||
"Reference","Qty","Value","Footprint","Datasheet","LCSC","Manufacturer Part Number"
|
||||
"C1,C2,C5,C6,C8,C11,C12","7","470n","Capacitor_SMD:C_0603_1608Metric","","C1623","CL10B474KA8NNNC"
|
||||
"C3,C4,C7","3","10u","Capacitor_SMD:C_0805_2012Metric","","C15850","CL21A106KAYNNNE"
|
||||
"C9,C10","2","18p","Capacitor_SMD:C_0603_1608Metric","","C1647","CL10C180JB8NNNC"
|
||||
"D1","1","USBLC6-2SC6","Package_TO_SOT_SMD:SOT-23-6","https://www.st.com/resource/en/datasheet/usblc6-2.pdf","C7519","USBLC6-2SC6"
|
||||
"D2","1","RED","LED_SMD:LED_0603_1608Metric","","C2286","KT-0603R"
|
||||
"D3","1","WHITE","LED_SMD:LED_0603_1608Metric","","C2290","KT-0603W"
|
||||
"J1","1","USB4085-GF-A","Connector_USB:USB_C_Receptacle_GCT_USB4085","https://www.usb.org/sites/default/files/documents/usb_type-c.zip","C7095263","USB4085-GF-A"
|
||||
"J3","1","PH-00834","Connector_PinHeader_2.54mm:PinHeader_2x05_P2.54mm_Horizontal","","C2685185","PH-00834"
|
||||
"R1,R2,R8","3","5k1","Resistor_SMD:R_0603_1608Metric","","C23186","0603WAF5101T5E"
|
||||
"R3,R5","2","0R","Resistor_SMD:R_0603_1608Metric","","C21189","0603WAF0000T5E"
|
||||
"R4","1","100k","Resistor_SMD:R_0603_1608Metric","","C25803","0603WAF1003T5E"
|
||||
"R6,R7","2","1k","Resistor_SMD:R_0603_1608Metric","","C21190","0603WAF1001T5E"
|
||||
"U1","1","SPX3819M5-L-3-3","Package_TO_SOT_SMD:SOT-23-5","https://www.exar.com/content/document.ashx?id=22106&languageid=1033&type=Datasheet&partnumber=SPX3819&filename=SPX3819.pdf&part=SPX3819","C9055","SPX3819M5-L-3-3/TR"
|
||||
"U2","1","CH340E","Package_SO:MSOP-10_3x3mm_P0.5mm","https://www.mpja.com/download/35227cpdata.pdf","C99652","CH340E"
|
||||
"U3","1","MSPM0G3507SPTR","Package_QFP:LQFP-48_7x7mm_P0.5mm","https://www.ti.com/lit/ds/symlink/mspm0g3507.pdf?ts=1747484513194&ref_url=https%253A%252F%252Fwww.ti.com%252Fproduct%252FMSPM0G3507","C22362630","MSPM0G3507SPTR"
|
||||
"X1","1","8MHz","Crystal:Crystal_SMD_3225-4Pin_3.2x2.5mm","","C189764","AV08000301"
|
||||
|
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 756 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Pinscope",
|
||||
"short_name": "Pinscope",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon_io/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/favicon_io/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#3B82F6",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 841 KiB |
|
After Width: | Height: | Size: 344 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Single source of truth for the app version: content/changelog.md.
|
||||
*
|
||||
* Parses the topmost `## X.Y.Z — <date>` heading and propagates it to:
|
||||
* - src/lib/version.ts (APP_VERSION, shown in the UI sidebar)
|
||||
* - package.json ("version" field)
|
||||
*
|
||||
* Runs automatically via the `predev` / `prebuild` npm hooks, so the
|
||||
* sidebar version, package.json, and the changelog can never drift.
|
||||
* To cut a release, just add a new `## X.Y.Z — <date>` section at the
|
||||
* top of content/changelog.md.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const changelogPath = join(root, "content", "changelog.md");
|
||||
const versionTsPath = join(root, "src", "lib", "version.ts");
|
||||
const pkgPath = join(root, "package.json");
|
||||
|
||||
const changelog = readFileSync(changelogPath, "utf-8");
|
||||
const match = changelog.match(
|
||||
/^##\s+(\d+\.\d+\.\d+)(?:\s+[—-]\s+(\d{4}-\d{2}-\d{2}))?/m,
|
||||
);
|
||||
if (!match) {
|
||||
console.error(
|
||||
`[sync-version] No "## X.Y.Z" heading found in ${changelogPath}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const version = match[1];
|
||||
const versionDate = match[2] ?? "";
|
||||
|
||||
// --- src/lib/version.ts (generated; do not edit by hand) ---------------
|
||||
const versionTs = `// AUTO-GENERATED by scripts/sync-version.mjs from content/changelog.md.
|
||||
// Do not edit by hand — change the top "## X.Y.Z" heading in the changelog.
|
||||
export const APP_VERSION = "${version}";
|
||||
export const APP_VERSION_DATE = "${versionDate}";
|
||||
`;
|
||||
let tsChanged = false;
|
||||
try {
|
||||
tsChanged = readFileSync(versionTsPath, "utf-8") !== versionTs;
|
||||
} catch {
|
||||
tsChanged = true;
|
||||
}
|
||||
if (tsChanged) writeFileSync(versionTsPath, versionTs);
|
||||
|
||||
// --- package.json "version" -------------------------------------------
|
||||
const pkgRaw = readFileSync(pkgPath, "utf-8");
|
||||
const pkg = JSON.parse(pkgRaw);
|
||||
const pkgChanged = pkg.version !== version;
|
||||
if (pkgChanged) {
|
||||
pkg.version = version;
|
||||
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
||||
}
|
||||
|
||||
if (tsChanged || pkgChanged) {
|
||||
console.log(
|
||||
`[sync-version] Synced to ${version} (` +
|
||||
`${tsChanged ? "version.ts" : ""}` +
|
||||
`${tsChanged && pkgChanged ? " + " : ""}` +
|
||||
`${pkgChanged ? "package.json" : ""}).`,
|
||||
);
|
||||
} else {
|
||||
console.log(`[sync-version] Already at ${version}.`);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const PROJECT_ROOT = path.resolve(process.cwd(), "..");
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const filePath = path.join(PROJECT_ROOT, id, "design_graph.json");
|
||||
try {
|
||||
const data = await readFile(filePath, "utf-8");
|
||||
return NextResponse.json(JSON.parse(data));
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Graph not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PROJECTS } from "@/lib/mock-data";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(PROJECTS);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const PROJECT_ROOT = path.resolve(process.cwd(), "..");
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const filePath = path.join(PROJECT_ROOT, id, "report.json");
|
||||
try {
|
||||
const data = await readFile(filePath, "utf-8");
|
||||
return NextResponse.json(JSON.parse(data));
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Report not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState, useEffect, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { ArrowRight, CheckCircle2, LayoutGrid, List, Loader2, X } from "lucide-react";
|
||||
import { ProjectCard } from "@/components/dashboard/project-card";
|
||||
import { ProjectsTable } from "@/components/dashboard/projects-table";
|
||||
import { CreateProjectDialog } from "@/components/dashboard/create-project-dialog";
|
||||
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";
|
||||
import { useCredits } from "@/components/billing/credits-context";
|
||||
import { OnboardingSurvey } from "@/components/dashboard/onboarding-survey";
|
||||
|
||||
type ViewMode = "cards" | "table";
|
||||
const VIEW_STORAGE_KEY = "pinscopex:dashboard:view";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<DashboardContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
type CheckoutState = "pending" | "activated" | "timeout" | "dismissed";
|
||||
|
||||
function DashboardContent() {
|
||||
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 { credits, refresh: refreshCredits } = useCredits();
|
||||
const [showCheckoutBanner, setShowCheckoutBanner] = useState(false);
|
||||
const [checkoutState, setCheckoutState] =
|
||||
useState<CheckoutState>("pending");
|
||||
const [activatedDetail, setActivatedDetail] = useState<string | null>(null);
|
||||
const [view, setView] = useState<ViewMode>("cards");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Restore persisted UI preferences once on mount.
|
||||
useEffect(() => {
|
||||
try {
|
||||
const v = localStorage.getItem(VIEW_STORAGE_KEY);
|
||||
if (v === "cards" || v === "table") setView(v);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
function updateView(next: ViewMode) {
|
||||
setView(next);
|
||||
try { localStorage.setItem(VIEW_STORAGE_KEY, next); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const sortedProjects = useMemo(
|
||||
() =>
|
||||
[...projects].sort((a, b) => {
|
||||
const ta = new Date(a.created).getTime();
|
||||
const tb = new Date(b.created).getTime();
|
||||
return (Number.isFinite(tb) ? tb : 0) - (Number.isFinite(ta) ? ta : 0);
|
||||
}),
|
||||
[projects],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects()
|
||||
.then(setProjects)
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
// Admin handoff: "Rerun as new project" stashes a project ID here.
|
||||
const cloneId =
|
||||
typeof window !== "undefined"
|
||||
? window.sessionStorage.getItem("pinscopex:cloneAsNewProjectId")
|
||||
: null;
|
||||
if (cloneId) {
|
||||
window.sessionStorage.removeItem("pinscopex:cloneAsNewProjectId");
|
||||
fetchProject(cloneId)
|
||||
.then(setCloneAsNewProject)
|
||||
.catch(() => {
|
||||
// Source project unreachable — silently no-op; dialog stays closed.
|
||||
});
|
||||
}
|
||||
|
||||
const topupSuccess = searchParams.get("topup") === "success";
|
||||
if (!topupSuccess) return;
|
||||
|
||||
setShowCheckoutBanner(true);
|
||||
setCheckoutState("pending");
|
||||
|
||||
const sessionId = searchParams.get("session_id");
|
||||
let cancelled = false;
|
||||
let poll: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const activate = (balance: number) => {
|
||||
if (cancelled) return;
|
||||
setActivatedDetail(`Balance is now ${balance.toFixed(2)} credits.`);
|
||||
setCheckoutState("activated");
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const initial = await refreshCredits();
|
||||
const initialBalance: number | null = initial?.balance ?? null;
|
||||
|
||||
// Reconcile is idempotent — a confirmed paid top-up session means
|
||||
// the grant is already in the ledger, so we skip the balance-delta
|
||||
// poll (which would hang after a refresh since the credits are
|
||||
// already applied).
|
||||
if (sessionId) {
|
||||
try {
|
||||
const r = await reconcileCheckoutSession(sessionId);
|
||||
if (!cancelled && r.ok && r.kind === "topup" && r.payment_status === "paid") {
|
||||
const c = await refreshCredits();
|
||||
activate(c?.balance ?? initialBalance ?? 0);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to polling */
|
||||
}
|
||||
}
|
||||
|
||||
let attempts = 0;
|
||||
const MAX_ATTEMPTS = 8; // ~16s — after that we drop to a Refresh CTA
|
||||
poll = setInterval(async () => {
|
||||
if (cancelled) return;
|
||||
attempts++;
|
||||
const c = await refreshCredits();
|
||||
|
||||
const balanceIncreased =
|
||||
c != null &&
|
||||
initialBalance != null &&
|
||||
c.balance > initialBalance + 0.001;
|
||||
|
||||
if (balanceIncreased && c) {
|
||||
activate(c.balance);
|
||||
if (poll) clearInterval(poll);
|
||||
} else if (attempts >= MAX_ATTEMPTS) {
|
||||
setCheckoutState("timeout");
|
||||
if (poll) clearInterval(poll);
|
||||
}
|
||||
}, 2000);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (poll) clearInterval(poll);
|
||||
};
|
||||
}, [searchParams, refreshCredits]);
|
||||
|
||||
useEffect(() => {
|
||||
if (checkoutState !== "activated") return;
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.has("topup") || url.searchParams.has("session_id")) {
|
||||
url.searchParams.delete("topup");
|
||||
url.searchParams.delete("session_id");
|
||||
router.replace(url.pathname + (url.search || ""));
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
setCheckoutState("dismissed");
|
||||
setShowCheckoutBanner(false);
|
||||
}, 4000);
|
||||
return () => clearTimeout(t);
|
||||
}, [checkoutState, router]);
|
||||
|
||||
function dismissCheckout() {
|
||||
setCheckoutState("dismissed");
|
||||
setShowCheckoutBanner(false);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("topup");
|
||||
url.searchParams.delete("session_id");
|
||||
router.replace(url.pathname + (url.search || ""));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 p-6 max-w-5xl mx-auto w-full">
|
||||
<OnboardingSurvey />
|
||||
{showCheckoutBanner && checkoutState !== "dismissed" && (
|
||||
<CheckoutSuccessBanner
|
||||
state={checkoutState}
|
||||
detail={activatedDetail}
|
||||
onDismiss={dismissCheckout}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Projects</h1>
|
||||
<BalanceSubtitle credits={credits} />
|
||||
</div>
|
||||
<CreateProjectDialog
|
||||
rerunProject={rerunProject}
|
||||
onRerunDone={() => setRerunProject(null)}
|
||||
cloneAsNewProject={cloneAsNewProject}
|
||||
onCloneAsNewDone={() => setCloneAsNewProject(null)}
|
||||
onCreateProject={(p) => {
|
||||
setProjects((prev) => {
|
||||
const existing = prev.find((x) => x.id === p.id);
|
||||
if (existing) {
|
||||
return prev.map((x) => (x.id === p.id ? p : x));
|
||||
}
|
||||
return [p, ...prev];
|
||||
});
|
||||
router.push(`/project/${p.id}/progress`);
|
||||
}}
|
||||
/>
|
||||
</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={() => updateView("cards")}
|
||||
aria-pressed={view === "cards"}
|
||||
title="Card view"
|
||||
className={cn(
|
||||
"p-1 rounded-md transition-colors",
|
||||
view === "cards"
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<LayoutGrid className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateView("table")}
|
||||
aria-pressed={view === "table"}
|
||||
title="Table view"
|
||||
className={cn(
|
||||
"p-1 rounded-md transition-colors",
|
||||
view === "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>
|
||||
) : view === "table" ? (
|
||||
<ProjectsTable
|
||||
projects={sortedProjects}
|
||||
onDeleted={(id) => setProjects((prev) => prev.filter((x) => x.id !== id))}
|
||||
onRerun={setRerunProject}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sortedProjects.map((p) => (
|
||||
<ProjectCard
|
||||
key={p.id}
|
||||
project={p}
|
||||
onDeleted={() => setProjects((prev) => prev.filter((x) => x.id !== p.id))}
|
||||
onRerun={setRerunProject}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BalanceSubtitle({ 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 CheckoutSuccessBanner({
|
||||
state,
|
||||
detail,
|
||||
onDismiss,
|
||||
}: {
|
||||
state: CheckoutState;
|
||||
detail: string | null;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const tone =
|
||||
state === "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 (state === "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 (state === "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 {
|
||||
// timeout
|
||||
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,117 @@
|
||||
"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 formatRelativeTime(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(diff / 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`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}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(() => {})
|
||||
.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((t) => (
|
||||
<button
|
||||
key={t.ticket_id}
|
||||
type="button"
|
||||
onClick={() => setExpanded(expanded === t.ticket_id ? null : t.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[t.status] ?? ""}`}>
|
||||
{t.status}
|
||||
</Badge>
|
||||
{t.finding_id && (
|
||||
<span className="font-mono text-xs text-muted-foreground shrink-0">
|
||||
{t.finding_id}
|
||||
</span>
|
||||
)}
|
||||
{t.project_name && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{t.project_name}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-xs text-muted-foreground shrink-0">
|
||||
{formatRelativeTime(t.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className={`text-sm text-muted-foreground mt-2 ${expanded === t.ticket_id ? "" : "line-clamp-2"}`}>
|
||||
{t.message}
|
||||
</p>
|
||||
{expanded === t.ticket_id && t.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">{t.finding_text}</p>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
{t.finding_designator && <span className="font-mono">{t.finding_designator}</span>}
|
||||
{t.finding_mpn && <span className="font-mono">{t.finding_mpn}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{expanded === t.ticket_id && t.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">Pinscope Team:</p>
|
||||
<p className="text-sm text-muted-foreground">{t.admin_notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { CreditsProvider } from "@/components/billing/credits-context";
|
||||
import { RedditPixelMatchKeys } from "@/components/analytics/reddit-pixel-match-keys";
|
||||
|
||||
export default function AppLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<CreditsProvider>
|
||||
<div className="flex h-full">
|
||||
<Sidebar />
|
||||
<main className="flex-1 flex flex-col overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<RedditPixelMatchKeys />
|
||||
</CreditsProvider>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
"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, fetchProject, resumePipeline } from "@/lib/api";
|
||||
import type { PauseCheckpoint } from "@/lib/types";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Coffee,
|
||||
Coins,
|
||||
Loader2,
|
||||
OctagonX,
|
||||
Ban,
|
||||
} from "lucide-react";
|
||||
|
||||
export default function ProgressPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const { steps, done, cancelled, error, summary, autoTopupFailure, credits, started, paused } =
|
||||
usePipelineProgress(id);
|
||||
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);
|
||||
|
||||
// Fetch project name + initial paused state. The SSE stream only reports
|
||||
// `pipeline_paused` if the page is open when it fires; landing on the
|
||||
// progress page later, we need to read the persisted project status.
|
||||
useEffect(() => {
|
||||
fetchProject(id)
|
||||
.then((p) => {
|
||||
setProjectName(p.name);
|
||||
if (p.status === "paused_insufficient_credits") {
|
||||
setProjectPaused(true);
|
||||
setProjectCheckpoint(p.pauseCheckpoint ?? null);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
// Live `pipeline_paused` event also flips the paused state.
|
||||
useEffect(() => {
|
||||
if (paused) {
|
||||
setProjectPaused(true);
|
||||
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);
|
||||
// Refresh the page so a new SSE connection picks up the resumed run
|
||||
// from a clean slate.
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
setResuming(false);
|
||||
alert(e instanceof Error ? e.message : "Failed to resume pipeline");
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-navigate to report when pipeline completes successfully
|
||||
useEffect(() => {
|
||||
if (done && !error && !cancelled && !projectPaused) {
|
||||
const timer = setTimeout(() => {
|
||||
router.push(`/project/${id}/report`);
|
||||
}, 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [done, error, cancelled, projectPaused, id, router]);
|
||||
|
||||
// Auto-navigate to dashboard when pipeline is cancelled
|
||||
useEffect(() => {
|
||||
if (cancelled) {
|
||||
const timer = setTimeout(() => {
|
||||
router.push("/dashboard");
|
||||
}, 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [cancelled, router]);
|
||||
|
||||
const handleCancel = async () => {
|
||||
setCancelling(true);
|
||||
try {
|
||||
await cancelPipeline(id);
|
||||
} catch {
|
||||
// Pipeline may have already finished
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
setDialogOpen(false);
|
||||
setConfirmInput("");
|
||||
}
|
||||
};
|
||||
|
||||
const isRunning = !done && !projectPaused;
|
||||
const isQueued = isRunning && !started;
|
||||
|
||||
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>
|
||||
</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>
|
||||
</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">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import { use, useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef, Suspense } from "react";
|
||||
import { Download } from "lucide-react";
|
||||
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 } from "@/lib/api";
|
||||
import { exportReportToExcel } from "@/lib/report-export";
|
||||
import { cn, getFindingKey } from "@/lib/utils";
|
||||
import type { Finding, FindingComment, Collaborator } from "@/lib/types";
|
||||
|
||||
interface FocusState {
|
||||
key: string;
|
||||
finding: Finding;
|
||||
mpn: string;
|
||||
page: number;
|
||||
quote?: string;
|
||||
}
|
||||
|
||||
function ReportContent({ projectId }: { projectId: string }) {
|
||||
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 [creditsSpent, setCreditsSpent] = useState<number | undefined>();
|
||||
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());
|
||||
|
||||
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);
|
||||
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]
|
||||
);
|
||||
|
||||
// Load collaborators
|
||||
useEffect(() => {
|
||||
fetchCollaborators(projectId)
|
||||
.then((data) => setCollaborators(data.collaborators))
|
||||
.catch(() => {});
|
||||
}, [projectId]);
|
||||
|
||||
// Sync comments from report
|
||||
useEffect(() => {
|
||||
if (report?.comments) {
|
||||
setComments(report.comments);
|
||||
}
|
||||
}, [report]);
|
||||
|
||||
const handleCommentAdded = useCallback((comment: FindingComment) => {
|
||||
setComments((prev) => ({
|
||||
...prev,
|
||||
[comment.finding_id]: [...(prev[comment.finding_id] ?? []), comment],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
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;
|
||||
// source_page/source_quote may cite a *connected* component's datasheet
|
||||
// (evidence pulled from a neighbor's excerpt during review). Open that
|
||||
// datasheet, not the component under review — else the page is in the
|
||||
// wrong PDF (and often past its end, so nothing renders).
|
||||
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;
|
||||
}
|
||||
// Save the list scroll position only when entering focus mode, not when
|
||||
// swapping between findings while already focused.
|
||||
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 text-sm text-muted-foreground">
|
||||
{error ?? "Report not found."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => exportReportToExcel(report, graph, projectName)}
|
||||
disabled={report.findings.length === 0}
|
||||
>
|
||||
<Download /> Export Excel
|
||||
</Button>
|
||||
</div>
|
||||
<ReportSummary
|
||||
summary={report.summary}
|
||||
reviewedCount={reviewedCount}
|
||||
creditsSpent={creditsSpent}
|
||||
/>
|
||||
{report.review_errors && Object.keys(report.review_errors).length > 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">
|
||||
{Object.keys(report.review_errors).length} IC review{Object.keys(report.review_errors).length === 1 ? "" : "s"} failed
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
These ICs could not be reviewed against their datasheets. Re-run the pipeline to retry; if the failure repeats, share the error with support.
|
||||
</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}
|
||||
/>
|
||||
)}
|
||||
</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))}
|
||||
/>
|
||||
)}
|
||||
<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,19 @@
|
||||
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 Pinscope — 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 @@
|
||||
const BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
|
||||
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(`${BASE}/api/contact`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
|
||||
if (!resp.ok) {
|
||||
// Pydantic validation errors
|
||||
if (result.detail && 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 sid@faradworks.com.",
|
||||
};
|
||||
}
|
||||
|
||||
return result as ActionState;
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
message: "Could not reach the server. Please try again or email us directly at sid@faradworks.com.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2, CheckCircle2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { submitContactForm, type ActionState } from "./actions";
|
||||
|
||||
export function ContactForm() {
|
||||
const [state, setState] = useState<ActionState>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setPending(true);
|
||||
|
||||
const form = e.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
|
||||
const result = await submitContactForm({
|
||||
name: (formData.get("name") as string) ?? "",
|
||||
email: (formData.get("email") as string) ?? "",
|
||||
company: (formData.get("company") as string) ?? "",
|
||||
subject: (formData.get("subject") as string) ?? "",
|
||||
message: (formData.get("message") as string) ?? "",
|
||||
_honey: (formData.get("_honey") as string) ?? "",
|
||||
});
|
||||
|
||||
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={handleSubmit} className="space-y-5">
|
||||
{/* Honeypot */}
|
||||
<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,66 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Cpu, ArrowRight } from "lucide-react";
|
||||
import { useOptionalAuth } from "@/hooks/use-optional-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ThemeToggle } from "@/components/theme/theme-toggle";
|
||||
|
||||
export function Nav() {
|
||||
const { isSignedIn } = useOptionalAuth();
|
||||
|
||||
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">
|
||||
<Cpu className="h-5 w-5 text-blue-500" />
|
||||
<span className="text-sm font-semibold tracking-tight">Pinscope</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="/#pricing" className="hover:text-foreground transition-colors">
|
||||
Pricing
|
||||
</Link>
|
||||
<Link href="/contact" className="text-foreground">
|
||||
Contact
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeToggle />
|
||||
{isSignedIn ? (
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/login">
|
||||
<Button variant="ghost" size="sm">
|
||||
Sign in
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/login">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-blue-600 hover:bg-blue-500 text-white border-0"
|
||||
>
|
||||
Get started
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import Link from "next/link";
|
||||
import { Cpu, ArrowRight } from "lucide-react";
|
||||
import { ContactForm } from "./contact-form";
|
||||
import { Nav } from "./nav";
|
||||
import { pageMetadata } from "@/lib/site";
|
||||
|
||||
export const metadata = pageMetadata({
|
||||
title: "Contact",
|
||||
description:
|
||||
"Talk to the Pinscope team — questions, account help, or enterprise deployment.",
|
||||
path: "/contact",
|
||||
});
|
||||
|
||||
export default function ContactPage() {
|
||||
return (
|
||||
<div className="flex flex-col min-h-full">
|
||||
<Nav />
|
||||
|
||||
{/* ── Hero ── */}
|
||||
<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 Pinscope, need help with your account, or want to
|
||||
discuss enterprise deployment? We’d love to hear from you.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* ── Form ── */}
|
||||
<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>
|
||||
|
||||
{/* ── Alternative ── */}
|
||||
<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:dev@faradworks.com"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
dev@faradworks.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<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">
|
||||
<Cpu className="h-4 w-4 text-blue-500" />
|
||||
<span>Pinscope</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()} Faradworks</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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 Pinscope.",
|
||||
path: "/file-guide",
|
||||
});
|
||||
|
||||
export default function Page() {
|
||||
const content = fs.readFileSync(
|
||||
path.join(process.cwd(), "content", "file-guide.md"),
|
||||
"utf-8",
|
||||
);
|
||||
return <FileGuidePage content={content} />;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"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="/login">
|
||||
<Button variant="ghost" size="sm">
|
||||
Sign in
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/login">
|
||||
<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" : "/login";
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function PricingCta() {
|
||||
const { isSignedIn } = useOptionalAuth();
|
||||
return (
|
||||
<Link href={isSignedIn ? "/billing" : "/login"}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-blue-600 hover:bg-blue-500 text-white border-0 gap-1.5"
|
||||
>
|
||||
{isSignedIn ? "Buy credits" : "Get started"}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export default function MarketingLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<div className="min-h-full flex flex-col">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Cpu, Shield, Lock, ServerCog, Users, GitBranch } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ThemeToggle } from "@/components/theme/theme-toggle";
|
||||
import { APP_VERSION_DATE } from "@/lib/version";
|
||||
import { 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";
|
||||
|
||||
// Landing page uses the root layout's metadata as-is so the file-convention
|
||||
// OG/Twitter images (which Next merges only when no openGraph override exists)
|
||||
// remain in the head. Canonical is already "/" from the root.
|
||||
export const metadata: Metadata = {};
|
||||
|
||||
const LATEST_CHANGE_LABEL = APP_VERSION_DATE
|
||||
? new Date(`${APP_VERSION_DATE}T00:00:00Z`).toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
})
|
||||
: null;
|
||||
|
||||
const 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 FEATURES = [
|
||||
{
|
||||
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 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": "Organization",
|
||||
name: "Faradworks",
|
||||
url: "https://faradworks.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
name: "Faradworks",
|
||||
url: "https://faradworks.com",
|
||||
logo: `${SITE_URL}/faradworks-logo-white.png`,
|
||||
sameAs: [
|
||||
"https://www.linkedin.com/company/faradworks",
|
||||
"https://x.com/getFaradWorks",
|
||||
],
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
streetAddress: "33 W 17th St",
|
||||
addressLocality: "New York",
|
||||
addressRegion: "NY",
|
||||
addressCountry: "US",
|
||||
},
|
||||
contactPoint: {
|
||||
"@type": "ContactPoint",
|
||||
contactType: "customer support",
|
||||
email: "dev@faradworks.com",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="flex flex-col min-h-full">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
{/* ── Nav ── */}
|
||||
<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">
|
||||
<Cpu className="h-5 w-5 text-blue-500" />
|
||||
<span className="text-sm font-semibold tracking-tight">
|
||||
Pinscope
|
||||
</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>
|
||||
|
||||
{/* ── Hero ── */}
|
||||
<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]">
|
||||
Pinscope 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 />
|
||||
{LATEST_CHANGE_LABEL && (
|
||||
<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: {LATEST_CHANGE_LABEL}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Hero product screenshot ── */}
|
||||
<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="Pinscope validation report"
|
||||
width={2400}
|
||||
height={1500}
|
||||
className="w-full h-auto"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Supported EDA tools ── */}
|
||||
<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>
|
||||
|
||||
{/* ── Pipeline ── */}
|
||||
<section className="border-y border-border/50">
|
||||
<div className="mx-auto max-w-3xl px-6 py-16">
|
||||
{/* Desktop: horizontal connected steps */}
|
||||
<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" />
|
||||
{STEPS.map((s, i) => (
|
||||
<div
|
||||
key={s.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">
|
||||
{i + 1}
|
||||
</div>
|
||||
<span className="mt-3 text-sm font-medium">{s.label}</span>
|
||||
<span className="mt-1 text-xs text-muted-foreground">
|
||||
{s.detail}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile: vertical */}
|
||||
<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" />
|
||||
{STEPS.map((s, i) => (
|
||||
<div key={s.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">
|
||||
{i + 1}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium">{s.label}</span>
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||
{s.detail}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Features — alternating text + image ── */}
|
||||
<section id="features" className="scroll-mt-16">
|
||||
{FEATURES.map((f, i) => (
|
||||
<div
|
||||
key={f.title}
|
||||
className={
|
||||
i < FEATURES.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={i % 2 === 1 ? "lg:order-2" : ""}>
|
||||
<h2 className="font-headline text-3xl sm:text-4xl tracking-tight leading-tight">
|
||||
{f.title}
|
||||
</h2>
|
||||
<p className="mt-4 text-muted-foreground leading-relaxed">
|
||||
{f.description}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`rounded-xl border border-border bg-card/40 aspect-[4/3] flex items-center justify-center overflow-hidden ${
|
||||
i % 2 === 1 ? "lg:order-1" : ""
|
||||
}`}
|
||||
>
|
||||
{f.image ? (
|
||||
<Image
|
||||
src={f.image}
|
||||
alt={f.placeholder}
|
||||
width={800}
|
||||
height={600}
|
||||
className="w-full h-full object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground/60">
|
||||
{f.placeholder}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* ── Security ── */}
|
||||
<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. Pinscope 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">
|
||||
{[
|
||||
{
|
||||
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.",
|
||||
},
|
||||
].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>
|
||||
|
||||
<PricingSection />
|
||||
|
||||
{/* ── Final CTA ── */}
|
||||
<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:dev@faradworks.com"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
dev@faradworks.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<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">
|
||||
<Image
|
||||
src="/faradworks-logo-white.png"
|
||||
alt="Faradworks"
|
||||
width={569}
|
||||
height={230}
|
||||
className="h-8 w-auto self-start opacity-90 invert dark:invert-0"
|
||||
/>
|
||||
<address className="text-xs not-italic leading-relaxed text-muted-foreground">
|
||||
33 W 17th St
|
||||
<br />
|
||||
New York, NY
|
||||
</address>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-4 sm:items-end">
|
||||
<a
|
||||
href="https://www.linkedin.com/company/faradworks"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Faradworks on LinkedIn"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20.45 20.45h-3.56v-5.57c0-1.33-.02-3.04-1.85-3.04-1.86 0-2.14 1.45-2.14 2.95v5.66H9.34V9h3.42v1.56h.05a3.75 3.75 0 0 1 3.37-1.85c3.6 0 4.27 2.37 4.27 5.46v6.28zM5.34 7.43a2.07 2.07 0 1 1 0-4.13 2.07 2.07 0 0 1 0 4.13zM7.12 20.45H3.56V9h3.56v11.45zM22.23 0H1.77C.79 0 0 .77 0 1.72v20.56C0 23.23.79 24 1.77 24h20.46c.98 0 1.77-.77 1.77-1.72V1.72C24 .77 23.21 0 22.23 0z" />
|
||||
</svg>
|
||||
</a>
|
||||
<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()} Faradworks</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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 Pinscope 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,19 @@
|
||||
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 Pinscope, 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} />;
|
||||
}
|
||||
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,146 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: "Geist", "Geist Fallback", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "Geist Mono", "Geist Mono Fallback", ui-monospace, monospace;
|
||||
--font-heading: var(--font-sans);
|
||||
--font-headline: var(--font-dm-serif-display), Georgia, serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-up {
|
||||
animation: fade-up 0.7s ease-out both;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono, DM_Serif_Display } 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 {
|
||||
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: "Faradworks", url: "https://faradworks.com" }],
|
||||
creator: "Faradworks",
|
||||
publisher: "Faradworks",
|
||||
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",
|
||||
site: TWITTER_HANDLE,
|
||||
creator: TWITTER_HANDLE,
|
||||
title: `${SITE_NAME} — ${SITE_TAGLINE}`,
|
||||
description: SITE_DESCRIPTION,
|
||||
},
|
||||
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,107 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
|
||||
export const alt = "Pinscope — Agentic schematic validation";
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = "image/png";
|
||||
|
||||
export default function Image() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
padding: "72px",
|
||||
background:
|
||||
"radial-gradient(at 30% 20%, #15233f 0%, #0a0a0a 55%, #050505 100%)",
|
||||
color: "#fafafa",
|
||||
fontFamily: "sans-serif",
|
||||
}}
|
||||
>
|
||||
{/* Brand row */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<rect x="9" y="9" width="6" height="6" />
|
||||
<path d="M9 2v2" />
|
||||
<path d="M15 2v2" />
|
||||
<path d="M9 20v2" />
|
||||
<path d="M15 20v2" />
|
||||
<path d="M20 9h2" />
|
||||
<path d="M20 15h2" />
|
||||
<path d="M2 9h2" />
|
||||
<path d="M2 15h2" />
|
||||
</svg>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 36,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
Pinscope
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main copy */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 88,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.05,
|
||||
letterSpacing: "-0.03em",
|
||||
maxWidth: 980,
|
||||
}}
|
||||
>
|
||||
Ship hardware that works the first time.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 30,
|
||||
lineHeight: 1.3,
|
||||
color: "#a1a1aa",
|
||||
maxWidth: 880,
|
||||
}}
|
||||
>
|
||||
Datasheet-grounded schematic review. Catches the errors that
|
||||
would otherwise surface at bring-up.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer row */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
fontSize: 22,
|
||||
color: "#71717a",
|
||||
borderTop: "1px solid #27272a",
|
||||
paddingTop: 24,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
KiCad · Altium · OrCAD · Cadence · Siemens · EasyEDA · EAGLE
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
pinscope.ai
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ ...size },
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default, alt, size, contentType } from "./opengraph-image";
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
// Open-core seam: the cloud/gateway build replaces this file with its
|
||||
// marketing pixel identity matching. The open-source build ships no
|
||||
// analytics.
|
||||
|
||||
export function RedditPixelMatchKeys() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Open-core seam: the cloud/gateway build replaces this file with its
|
||||
// marketing pixel. The open-source build ships no analytics.
|
||||
|
||||
export function RedditPixel() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
|
||||
import type { CreditSnapshot } from "@/lib/types";
|
||||
|
||||
// Open-core seam: the cloud/gateway build replaces this file with a provider
|
||||
// that polls the credits API. The open-source build has no billing — the
|
||||
// balance stays null and consumers hide their credit UI.
|
||||
|
||||
interface CreditsContextValue {
|
||||
credits: CreditSnapshot | null;
|
||||
refresh: () => Promise<CreditSnapshot | null>;
|
||||
setCredits: (s: CreditSnapshot) => void;
|
||||
}
|
||||
|
||||
const STUB: CreditsContextValue = {
|
||||
credits: null,
|
||||
refresh: async () => null,
|
||||
setCredits: () => {},
|
||||
};
|
||||
|
||||
const CreditsContext = createContext<CreditsContextValue>(STUB);
|
||||
|
||||
export function CreditsProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<CreditsContext.Provider value={STUB}>{children}</CreditsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCredits(): CreditsContextValue {
|
||||
return useContext(CreditsContext);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import type { PauseCheckpoint } from "@/lib/types";
|
||||
|
||||
// Open-core seam: the cloud/gateway build replaces this file with the
|
||||
// paused-on-credits banner. Open-source pipelines never pause on credits.
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
checkpoint: PauseCheckpoint | null;
|
||||
resuming: boolean;
|
||||
onResume: () => void;
|
||||
}
|
||||
|
||||
export function PausedRunBanner(_props: Props) {
|
||||
return null;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { submitFeedback } from "@/lib/api";
|
||||
import type { FindingStatus } from "@/lib/types";
|
||||
|
||||
const 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",
|
||||
};
|
||||
|
||||
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 handleOpenChange(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(() => handleOpenChange(false), 1200);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to submit feedback");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<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">
|
||||
{/* Finding context card */}
|
||||
{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 ${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>
|
||||
)}
|
||||
|
||||
{/* Message */}
|
||||
<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,12 @@
|
||||
"use client";
|
||||
|
||||
// Open-core seam: the cloud/gateway build replaces this file with the Clerk
|
||||
// user button and live credit balance. The open-source build has neither.
|
||||
|
||||
export function SidebarCredits() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SidebarUserButton() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Cpu,
|
||||
Shield,
|
||||
ArrowLeft,
|
||||
ClipboardList,
|
||||
Settings,
|
||||
TableProperties,
|
||||
Loader2,
|
||||
Zap,
|
||||
ScrollText,
|
||||
MessageSquareWarning,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthApi } from "@/hooks/use-auth-api";
|
||||
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
||||
import { fetchProject } from "@/lib/api";
|
||||
import { SidebarCredits, SidebarUserButton } from "@/components/layout/sidebar-auth";
|
||||
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
|
||||
import { ThemeToggle } from "@/components/theme/theme-toggle";
|
||||
import type { Project } from "@/lib/types";
|
||||
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]);
|
||||
|
||||
// Poll for status updates while pipeline is running
|
||||
useEffect(() => {
|
||||
if (!projectId || project?.status !== "running") return;
|
||||
const interval = setInterval(() => {
|
||||
fetchProject(projectId).then(setProject).catch(() => {});
|
||||
}, 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">
|
||||
<Cpu className="h-5 w-5 text-blue-500" />
|
||||
<span className="text-sm font-semibold tracking-tight">Pinscope</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
|
||||
{projectId ? (
|
||||
<ProjectNav pathname={pathname} projectId={projectId} project={project} isAdmin={isAdmin} />
|
||||
) : (
|
||||
<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 DefaultNav({ pathname, isAdmin }: { pathname: string; isAdmin: boolean }) {
|
||||
return (
|
||||
<nav className="flex-1 px-2 py-3 space-y-0.5">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
|
||||
pathname === "/dashboard"
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
Projects
|
||||
</Link>
|
||||
<Link
|
||||
href="/feedback"
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
|
||||
pathname === "/feedback"
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<MessageSquareWarning className="h-4 w-4" />
|
||||
My Feedback
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
|
||||
pathname === "/admin" || pathname.startsWith("/admin/")
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<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: "tab", tab: "bom", label: "BOM", icon: TableProperties },
|
||||
{ type: "tab", tab: "derating", label: "Derating", icon: Zap },
|
||||
{ type: "tab", tab: "logs", label: "Logs", icon: ScrollText, adminOnly: true },
|
||||
{ type: "tab", tab: "settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
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`;
|
||||
|
||||
function isActive(item: NavItem): boolean {
|
||||
if (item.type === "route") {
|
||||
return pathname === `${base}${item.path}` && !currentTab;
|
||||
}
|
||||
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">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Dashboard
|
||||
</Link>
|
||||
|
||||
<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 && (
|
||||
<Link
|
||||
href={`${base}/progress`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
|
||||
isOnProgress
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Processing
|
||||
</Link>
|
||||
)}
|
||||
{PROJECT_NAV_ITEMS.filter((item) => !item.adminOnly || isAdmin).map((item) => {
|
||||
const active = isActive(item);
|
||||
const disabled = isRunning;
|
||||
return disabled ? (
|
||||
<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>
|
||||
) : (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={getHref(item)}
|
||||
className={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"
|
||||
)}
|
||||
>
|
||||
<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" />}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Cpu, ArrowLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
type Tag = "New" | "Improved" | "Fixed";
|
||||
|
||||
interface ChangelogEntry {
|
||||
version: string;
|
||||
date: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
items: { tag: Tag; text: string }[];
|
||||
}
|
||||
|
||||
function parseChangelog(md: string): ChangelogEntry[] {
|
||||
const lines = md.split("\n");
|
||||
const entries: ChangelogEntry[] = [];
|
||||
let current: ChangelogEntry | null = null;
|
||||
let descLines: string[] = [];
|
||||
|
||||
const finalize = () => {
|
||||
if (!current) return;
|
||||
const desc = descLines.join(" ").trim();
|
||||
if (desc) current.description = desc;
|
||||
entries.push(current);
|
||||
current = null;
|
||||
descLines = [];
|
||||
};
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.trimEnd();
|
||||
if (line.startsWith("## ")) {
|
||||
finalize();
|
||||
const parts = line.slice(3).split(" — ");
|
||||
current = {
|
||||
version: parts[0]?.trim() ?? "",
|
||||
date: parts[1]?.trim() ?? "",
|
||||
title: parts[2]?.trim() || undefined,
|
||||
items: [],
|
||||
};
|
||||
} else if (line.startsWith("- ") && current) {
|
||||
const text = line.slice(2);
|
||||
const m = text.match(/^\[(New|Improved|Fixed)\]\s*(.+)$/);
|
||||
if (m) {
|
||||
current.items.push({ tag: m[1] as Tag, text: m[2] });
|
||||
} else {
|
||||
current.items.push({ tag: "Improved", text });
|
||||
}
|
||||
} else if (current && current.items.length === 0 && line.trim() && !line.startsWith("#")) {
|
||||
descLines.push(line.trim());
|
||||
}
|
||||
}
|
||||
finalize();
|
||||
return entries;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso + "T00:00:00Z");
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
|
||||
const TAG_STYLES: Record<Tag, string> = {
|
||||
New: "border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||
Improved: "border-blue-500/40 bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
||||
Fixed: "border-orange-500/40 bg-orange-500/10 text-orange-600 dark:text-orange-400",
|
||||
};
|
||||
|
||||
function TagPill({ tag }: { tag: Tag }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex h-5 w-[68px] flex-shrink-0 items-center justify-center rounded-full border px-2 text-[11px] font-medium ${TAG_STYLES[tag]}`}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangelogTimeline({ content }: { content: string }) {
|
||||
const entries = parseChangelog(content);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<header className="border-b border-border/50">
|
||||
<div className="mx-auto max-w-3xl px-6 py-6 flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to home
|
||||
</Link>
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">Pinscope</span>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-3xl px-6 py-12">
|
||||
<div className="mb-12">
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Changelog</h1>
|
||||
<p className="text-sm text-muted-foreground">What's new in Pinscope.</p>
|
||||
</div>
|
||||
|
||||
<ol className="relative">
|
||||
{entries.map((entry, i) => {
|
||||
const isLast = i === entries.length - 1;
|
||||
return (
|
||||
<li key={entry.version} className="relative pl-10 pb-12 last:pb-0">
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-[5px] top-3 h-3 w-3 rounded-full border-2 border-border bg-background"
|
||||
/>
|
||||
{!isLast && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-[10px] top-7 bottom-0 w-px bg-border/60"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
|
||||
<span>{formatDate(entry.date)}</span>
|
||||
{entry.version && (
|
||||
<>
|
||||
<span aria-hidden className="text-muted-foreground/50">·</span>
|
||||
<span className="font-mono text-xs">v{entry.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{entry.title && (
|
||||
<h2 className="text-2xl font-bold tracking-tight mb-2">
|
||||
{entry.title}
|
||||
</h2>
|
||||
)}
|
||||
{entry.description && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-5">
|
||||
{entry.description}
|
||||
</p>
|
||||
)}
|
||||
<ul className="space-y-2.5">
|
||||
{entry.items.map((item, j) => (
|
||||
<li key={j} className="flex items-start gap-3 text-sm">
|
||||
<TagPill tag={item.tag} />
|
||||
<span className="text-muted-foreground leading-relaxed pt-px">
|
||||
{item.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-border/50">
|
||||
<div className="mx-auto max-w-3xl px-6 py-8 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/changelog" className="hover:text-foreground transition-colors">
|
||||
Changelog
|
||||
</Link>
|
||||
<Link href="/privacy" className="hover:text-foreground transition-colors">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link href="/terms" className="hover:text-foreground transition-colors">
|
||||
Terms of Service
|
||||
</Link>
|
||||
</div>
|
||||
<span>© {new Date().getFullYear()} Faradworks</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { LegalPageShell, MarkdownContent } from "@/components/legal/legal-page";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
/**
|
||||
* The h2 heading that introduces the per-tool export instructions. The body
|
||||
* of this section is split on `### Tool Name` and rendered as tabs; anything
|
||||
* before or after the section renders as plain markdown.
|
||||
*/
|
||||
const EXPORT_HEADING = "## Exporting from your EDA tool";
|
||||
|
||||
type EdaSection = {
|
||||
/** Display name from the `### …` line, e.g. "KiCad". */
|
||||
name: string;
|
||||
/** Body markdown after the `### …` line (no h3 heading itself). */
|
||||
body: string;
|
||||
};
|
||||
|
||||
type ParsedGuide = {
|
||||
before: string;
|
||||
tabs: EdaSection[];
|
||||
after: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits the markdown into (pre-export markdown, EDA tab sections, post-export markdown).
|
||||
* If the document doesn't follow the expected shape, falls back to rendering everything
|
||||
* as a single markdown block — the page degrades gracefully if content/file-guide.md
|
||||
* gets restructured.
|
||||
*/
|
||||
function parseGuide(content: string): ParsedGuide {
|
||||
const exportIdx = content.indexOf(`\n${EXPORT_HEADING}\n`);
|
||||
if (exportIdx === -1) {
|
||||
return { before: content, tabs: [], after: "" };
|
||||
}
|
||||
const before = content.slice(0, exportIdx).trimEnd();
|
||||
const afterHeading = content.slice(exportIdx + EXPORT_HEADING.length + 2); // skip heading + leading newline
|
||||
|
||||
// The export section ends at the next h2 heading.
|
||||
const nextH2 = afterHeading.search(/\n## [^\n]/);
|
||||
const exportBody = nextH2 === -1 ? afterHeading : afterHeading.slice(0, nextH2);
|
||||
const after = nextH2 === -1 ? "" : afterHeading.slice(nextH2).trimStart();
|
||||
|
||||
// Split on `### ` at line start to get one entry per tool.
|
||||
const parts = exportBody.split(/\n### /);
|
||||
// First part is whatever sits between `## Exporting…` and the first `### Tool` (usually empty).
|
||||
const intro = parts.shift()?.trim() ?? "";
|
||||
const tabs: EdaSection[] = parts.map((chunk) => {
|
||||
const newlineIdx = chunk.indexOf("\n");
|
||||
const name = (newlineIdx === -1 ? chunk : chunk.slice(0, newlineIdx)).trim();
|
||||
const body = newlineIdx === -1 ? "" : chunk.slice(newlineIdx + 1).trim();
|
||||
return { name, body };
|
||||
});
|
||||
|
||||
// If there was intro prose, prepend it to the `before` block so it still renders.
|
||||
return {
|
||||
before: intro ? `${before}\n\n${EXPORT_HEADING}\n\n${intro}` : `${before}\n\n${EXPORT_HEADING}`,
|
||||
tabs,
|
||||
after,
|
||||
};
|
||||
}
|
||||
|
||||
/** Slug for the tab value (stable identifier independent of label changes). */
|
||||
function tabSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export function FileGuidePage({ content }: { content: string }) {
|
||||
const guide = useMemo(() => parseGuide(content), [content]);
|
||||
|
||||
if (guide.tabs.length === 0) {
|
||||
return (
|
||||
<LegalPageShell>
|
||||
<MarkdownContent>{content}</MarkdownContent>
|
||||
</LegalPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const defaultValue = tabSlug(guide.tabs[0].name);
|
||||
|
||||
return (
|
||||
<LegalPageShell>
|
||||
<MarkdownContent>{guide.before}</MarkdownContent>
|
||||
|
||||
<Tabs defaultValue={defaultValue} className="mb-10">
|
||||
<TabsList className="flex h-auto w-full flex-wrap justify-start gap-1 bg-transparent p-0 mb-4 border-b border-border/50 rounded-none">
|
||||
{guide.tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tabSlug(tab.name)}
|
||||
value={tabSlug(tab.name)}
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium data-active:bg-muted data-active:text-foreground"
|
||||
>
|
||||
{tab.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{guide.tabs.map((tab) => (
|
||||
<TabsContent
|
||||
key={tabSlug(tab.name)}
|
||||
value={tabSlug(tab.name)}
|
||||
className="pt-2"
|
||||
>
|
||||
<MarkdownContent>{tab.body}</MarkdownContent>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{guide.after && <MarkdownContent>{guide.after}</MarkdownContent>}
|
||||
</LegalPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Cpu, ArrowLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
|
||||
function extractText(node: ReactNode): string {
|
||||
if (node == null || typeof node === "boolean") return "";
|
||||
if (typeof node === "string" || typeof node === "number") return String(node);
|
||||
if (Array.isArray(node)) return node.map(extractText).join("");
|
||||
if (typeof node === "object" && "props" in node) {
|
||||
return extractText((node as { props: { children?: ReactNode } }).props.children);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.trim()
|
||||
.replace(/\s+/g, "-");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared markdown renderer used by the marketing-style pages (privacy, terms,
|
||||
* file-guide). Exported so callers like the file-guide page can reuse the same
|
||||
* typography when they render markdown alongside custom components (tabs).
|
||||
*/
|
||||
export function MarkdownContent({ children }: { children: string }) {
|
||||
return (
|
||||
<Markdown
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2
|
||||
id={slugify(extractText(children))}
|
||||
className="text-xl font-semibold mt-10 mb-4 border-b border-border/50 pb-2 scroll-mt-24"
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3
|
||||
id={slugify(extractText(children))}
|
||||
className="text-base font-semibold mt-6 mb-2 scroll-mt-24"
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">{children}</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="text-sm text-muted-foreground leading-relaxed mb-4 list-disc pl-6 space-y-1">{children}</ul>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li>{children}</li>
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong className="text-foreground font-medium">{children}</strong>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} className="text-blue-500 hover:underline">{children}</a>
|
||||
),
|
||||
em: ({ children }) => (
|
||||
<em className="text-muted-foreground">{children}</em>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="text-sm text-muted-foreground leading-relaxed mb-4 list-decimal pl-6 space-y-1">{children}</ol>
|
||||
),
|
||||
code: ({ className, children, ...props }) => {
|
||||
const text = extractText(children);
|
||||
const isBlock = text.includes("\n") || Boolean(className);
|
||||
if (isBlock) {
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="px-1 py-0.5 rounded bg-muted text-foreground text-[13px] font-mono">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="mb-4 p-3 rounded-md border border-border/60 bg-muted/40 text-[12px] font-mono leading-relaxed overflow-x-auto">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Markdown>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marketing-page shell with header, footer, and a single markdown body.
|
||||
* Used by /privacy and /terms. File-guide uses its own shell so it can
|
||||
* interleave tabs with markdown.
|
||||
*/
|
||||
export function LegalPageShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
{/* Header */}
|
||||
<header className="border-b border-border/50">
|
||||
<div className="mx-auto max-w-3xl px-6 py-6 flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to home
|
||||
</Link>
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">Pinscope</span>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="mx-auto max-w-3xl px-6 py-12">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-border/50">
|
||||
<div className="mx-auto max-w-3xl px-6 py-8 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/changelog" className="hover:text-foreground transition-colors">Changelog</Link>
|
||||
<Link href="/privacy" className="hover:text-foreground transition-colors">Privacy Policy</Link>
|
||||
<Link href="/terms" className="hover:text-foreground transition-colors">Terms of Service</Link>
|
||||
</div>
|
||||
<span>© {new Date().getFullYear()} Faradworks</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LegalPage({ content }: { content: string }) {
|
||||
return (
|
||||
<LegalPageShell>
|
||||
<MarkdownContent>{content}</MarkdownContent>
|
||||
</LegalPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Open-core seam: the cloud/gateway build replaces this file with the
|
||||
// hosted-service pricing section (tiers, JSON-LD offer, nav link). The
|
||||
// open-source build has no pricing.
|
||||
|
||||
export const PRICING_NAV_LINK: { href: string; label: string } | null = null;
|
||||
|
||||
export const PRICING_JSON_LD_OFFERS: Record<string, unknown> | null = null;
|
||||
|
||||
export function PricingSection() {
|
||||
return null;
|
||||
}
|
||||
@@ -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) => {
|
||||
// Bundle the worker same-origin (version-matched to the installed
|
||||
// pdfjs-dist). A CDN URL breaks on http://localhost — a protocol-relative
|
||||
// //unpkg.com resolves to http://unpkg.com there, which the CSP
|
||||
// (worker-src 'self' blob:) blocks; it only "worked" on the https deploy.
|
||||
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 = "pinscope-quote-hl";
|
||||
|
||||
// U+00B5 MICRO SIGN and U+03BC GREEK SMALL MU render identically but the
|
||||
// model and the PDF often disagree on which one — fold them together.
|
||||
function foldChars(s: string): string {
|
||||
return s.replace(/µ/g, "μ");
|
||||
}
|
||||
|
||||
// Collapse whitespace + soft hyphens + line-break hyphenation, lowercase.
|
||||
function normalizeQuote(s: string): string {
|
||||
return foldChars(s)
|
||||
.replace(//g, "")
|
||||
.replace(/-\s+/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
// Per-span normalizer — keeps internal spacing so concat offsets stay aligned.
|
||||
function normalizePiece(s: string): string {
|
||||
return foldChars(s).replace(//g, "").replace(/\s+/g, " ").toLowerCase();
|
||||
}
|
||||
|
||||
function clearHighlights(root: HTMLElement): void {
|
||||
root.querySelectorAll<HTMLElement>("." + HIGHLIGHT_CLASS).forEach((el) => {
|
||||
el.classList.remove(HIGHLIGHT_CLASS);
|
||||
el.style.backgroundColor = "";
|
||||
});
|
||||
}
|
||||
|
||||
// Locate `quote` in the rendered text layer and highlight the overlapping
|
||||
// spans. Returns true on a match. Misses (figure/table evidence or extraction
|
||||
// mismatch) are silent — the page is already scrolled into view.
|
||||
function highlightQuoteInLayer(root: HTMLElement, quote: string): boolean {
|
||||
const layer = root.querySelector(".react-pdf__Page__textContent");
|
||||
clearHighlights(root);
|
||||
if (!layer) return false;
|
||||
|
||||
// pdf.js v5 nests text spans inside zero-height `span.markedContent`
|
||||
// wrappers. Select only the leaf text spans (role="presentation", the same
|
||||
// mapping react-pdf uses) to avoid double-counting text and painting the
|
||||
// highlight onto an invisible height:0 wrapper.
|
||||
const spans = Array.from(
|
||||
layer.querySelectorAll<HTMLElement>('span[role="presentation"]'),
|
||||
);
|
||||
let concat = "";
|
||||
const ranges: { span: HTMLElement; start: number; end: number }[] = [];
|
||||
for (const span of spans) {
|
||||
const piece = normalizePiece(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 = normalizeQuote(quote);
|
||||
if (target.length < 4) return false;
|
||||
|
||||
let idx = concat.indexOf(target);
|
||||
let matchLen = target.length;
|
||||
if (idx === -1) {
|
||||
// The model injects ellipses, column labels (MIN/TYP/MAX), or reordered
|
||||
// cells when quoting tables, so an exact/prefix match fails. Fall back to
|
||||
// the longest contiguous run of quote words that appears verbatim.
|
||||
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]);
|
||||
|
||||
// Revoke the blob URL on unmount (the sheet wrapper unmounts the panel on
|
||||
// close; the effect above only revokes when replacing the URL).
|
||||
useEffect(() => { urlRef.current = pdfUrl; }, [pdfUrl]);
|
||||
useEffect(() => () => {
|
||||
if (urlRef.current) URL.revokeObjectURL(urlRef.current);
|
||||
}, []);
|
||||
|
||||
// Navigate when a different finding on the same datasheet is selected —
|
||||
// onDocumentLoadSuccess only fires on document (mpn) change.
|
||||
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) {
|
||||
clearHighlights(root);
|
||||
return;
|
||||
}
|
||||
highlightQuoteInLayer(root, highlightQuote);
|
||||
}, [highlightQuote]);
|
||||
|
||||
// Re-run when the quote changes but the text layer is already mounted
|
||||
// (same mpn + page, different finding). rAF lets pending DOM settle.
|
||||
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,37 @@
|
||||
"use client";
|
||||
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import { PdfViewerPanel } from "./pdf-viewer-panel";
|
||||
|
||||
interface PdfViewerSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectId: string;
|
||||
mpn: string | null;
|
||||
initialPage: number;
|
||||
highlightQuote?: string;
|
||||
}
|
||||
|
||||
export function PdfViewerSheet({ open, onOpenChange, projectId, mpn, initialPage, highlightQuote }: PdfViewerSheetProps) {
|
||||
if (!mpn) return null;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="sm:max-w-none p-0 flex flex-col"
|
||||
style={{ maxWidth: "none", width: "fit-content" }}
|
||||
>
|
||||
<SheetTitle className="sr-only">{mpn}</SheetTitle>
|
||||
{open && (
|
||||
<PdfViewerPanel
|
||||
projectId={projectId}
|
||||
mpn={mpn}
|
||||
initialPage={initialPage}
|
||||
highlightQuote={highlightQuote}
|
||||
/>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Circle, Loader2 } from "lucide-react";
|
||||
import type { PipelineStep } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function StepIcon({ 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>
|
||||
);
|
||||
}
|
||||
|
||||
interface PipelineStepperProps {
|
||||
steps: PipelineStep[];
|
||||
}
|
||||
|
||||
export function PipelineStepper({ steps }: PipelineStepperProps) {
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{steps.map((step, i) => (
|
||||
<div key={i} className="relative flex gap-4">
|
||||
{/* Vertical line */}
|
||||
{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">
|
||||
<StepIcon status={step.status} />
|
||||
</div>
|
||||
<div className="flex-1 pb-8">
|
||||
<p className="text-sm font-semibold leading-7">
|
||||
{step.title}
|
||||
{step.status === "running" && step.totalNew != null && step.totalNew > 0 && (
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
({step.substeps.filter((s) => s.status === "complete" && !s.cached).length}
|
||||
/{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,92 @@
|
||||
"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, 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>;
|
||||
}
|
||||
|
||||
export function ComponentGroup({ designator, findings, component, onViewReference, findingKeys, isReviewed, onToggleReviewed, comments, projectId, collaborators, currentUserId, currentUserName, onCommentAdded, onCommentDeleted, onReportFinding, reportedFindingIds }: ComponentGroupProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
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))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
"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 type { Finding, FindingComment, Collaborator } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const BORDER_COLOR: Record<string, string> = {
|
||||
ERROR: "border-l-rose-500",
|
||||
WARNING: "border-l-amber-500",
|
||||
INFO: "border-l-blue-500",
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function FindingCard({
|
||||
finding,
|
||||
onViewReference,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
comments,
|
||||
projectId,
|
||||
collaborators,
|
||||
currentUserId,
|
||||
currentUserName,
|
||||
onCommentAdded,
|
||||
onCommentDeleted,
|
||||
onReportFinding,
|
||||
isReported,
|
||||
defaultOpen,
|
||||
}: FindingCardProps) {
|
||||
const [open, setOpen] = useState(defaultOpen ?? false);
|
||||
const commentCount = comments?.length ?? 0;
|
||||
const hasCommentSupport = !!(projectId && collaborators && onCommentAdded && onCommentDeleted);
|
||||
const expandable = !!finding.recommendation || (hasCommentSupport && !!finding.finding_id);
|
||||
|
||||
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",
|
||||
BORDER_COLOR[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 && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{finding.why}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{finding.recommendation && (
|
||||
<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")}
|
||||
/>
|
||||
Recommendation
|
||||
</span>
|
||||
)}
|
||||
{hasCommentSupport && 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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
{finding.recommendation && (
|
||||
<p className="text-sm text-muted-foreground mt-2 pl-1 border-l-2 border-muted leading-relaxed">
|
||||
{finding.recommendation}
|
||||
</p>
|
||||
)}
|
||||
{hasCommentSupport && finding.finding_id && (
|
||||
<FindingComments
|
||||
findingId={finding.finding_id}
|
||||
comments={comments ?? []}
|
||||
projectId={projectId}
|
||||
collaborators={collaborators}
|
||||
currentUserId={currentUserId ?? ""}
|
||||
currentUserName={currentUserName ?? "User"}
|
||||
onCommentAdded={onCommentAdded}
|
||||
onCommentDeleted={onCommentDeleted}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"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 formatRelativeTime(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(diff / 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`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function escapeRegex(s: string) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** Render comment text with @mentions highlighted. Matches known collaborator
|
||||
* names (which may contain spaces) first, then falls back to a single word. */
|
||||
function renderText(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(escapeRegex);
|
||||
const namePattern = names.length > 0 ? `@(?:${names.join("|")})|` : "";
|
||||
const pattern = new RegExp(`(${namePattern}@\\w+)`, "g");
|
||||
const parts = text.split(pattern);
|
||||
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 trimmed = text.trim();
|
||||
if (!trimmed || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const comment = await addComment(projectId, findingId, trimmed, 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">
|
||||
{formatRelativeTime(c.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed mt-0.5">
|
||||
{renderText(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,126 @@
|
||||
"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, 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;
|
||||
}
|
||||
|
||||
export function FindingFocusView({
|
||||
finding,
|
||||
component,
|
||||
mpn,
|
||||
page,
|
||||
quote,
|
||||
projectId,
|
||||
onExit,
|
||||
escapeDisabled,
|
||||
onViewReference,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
comments,
|
||||
collaborators,
|
||||
currentUserId,
|
||||
currentUserName,
|
||||
onCommentAdded,
|
||||
onCommentDeleted,
|
||||
onReportFinding,
|
||||
isReported,
|
||||
}: FindingFocusViewProps) {
|
||||
const backRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// The reference button that opened this view just got hidden — without this,
|
||||
// keyboard focus drops to <body>.
|
||||
useEffect(() => {
|
||||
backRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape" || escapeDisabled || e.defaultPrevented) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.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
|
||||
/>
|
||||
</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,165 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams, useRouter, usePathname } from "next/navigation";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { ComponentGroup } from "./component-group";
|
||||
import { ReportFilters } from "./report-filters";
|
||||
import { ReviewedSection } from "./reviewed-section";
|
||||
import type { Finding, FindingComment, FindingStatus, DesignGraph, Collaborator } from "@/lib/types";
|
||||
import { groupBy, getFindingKey } from "@/lib/utils";
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
export function FindingsList({ findings, graph, onViewReference, projectId, isReviewed, toggleReviewed, comments, collaborators, currentUserId, currentUserName, onCommentAdded, onCommentDeleted, onReportFinding, reportedFindingIds }: 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 searchParam = searchParams.get("q") ?? "";
|
||||
|
||||
const statusFilters = useMemo(() => {
|
||||
if (!statusParam) return new Set<FindingStatus>(["ERROR", "WARNING", "INFO"]);
|
||||
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");
|
||||
const value = isDefault ? null : Array.from(next).join(",");
|
||||
updateParams({ status: value });
|
||||
},
|
||||
[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;
|
||||
return true;
|
||||
},
|
||||
[statusFilters, componentParam, searchParam]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return findings.filter((f) => {
|
||||
if (!matchesFilters(f)) return false;
|
||||
const key = findingKeyMap.get(f)!;
|
||||
return !isReviewed(key);
|
||||
});
|
||||
}, [findings, matchesFilters, findingKeyMap, isReviewed]);
|
||||
|
||||
const reviewedFindings = useMemo(() => {
|
||||
return findings.filter((f) => {
|
||||
if (!matchesFilters(f)) return false;
|
||||
const key = findingKeyMap.get(f)!;
|
||||
return isReviewed(key);
|
||||
});
|
||||
}, [findings, matchesFilters, findingKeyMap, isReviewed]);
|
||||
|
||||
const grouped = useMemo(() => groupBy(filtered, (f) => f.designator), [filtered]);
|
||||
const designators = useMemo(() => {
|
||||
const all = [...new Set(findings.map((f) => f.designator))];
|
||||
const byDesignator = groupBy(findings, (f) => f.designator);
|
||||
const worst = (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) => worst(a) - worst(b) || a.localeCompare(b));
|
||||
}, [findings]);
|
||||
|
||||
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 })}
|
||||
designators={designators}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
{designators
|
||||
.filter((d) => grouped[d])
|
||||
.map((d) => (
|
||||
<ComponentGroup
|
||||
key={d}
|
||||
designator={d}
|
||||
findings={grouped[d]}
|
||||
component={graph.components[d]}
|
||||
onViewReference={onViewReference}
|
||||
findingKeys={findingKeyMap}
|
||||
isReviewed={isReviewed}
|
||||
onToggleReviewed={toggleReviewed}
|
||||
comments={comments}
|
||||
projectId={projectId}
|
||||
collaborators={collaborators}
|
||||
currentUserId={currentUserId}
|
||||
currentUserName={currentUserName}
|
||||
onCommentAdded={onCommentAdded}
|
||||
onCommentDeleted={onCommentDeleted}
|
||||
onReportFinding={onReportFinding}
|
||||
reportedFindingIds={reportedFindingIds}
|
||||
/>
|
||||
))}
|
||||
{filtered.length === 0 && reviewedFindings.length === 0 && findings.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-12">
|
||||
No findings match your filters.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{reviewedFindings.length > 0 && (
|
||||
<ReviewedSection
|
||||
findings={reviewedFindings}
|
||||
findingKeys={findingKeyMap}
|
||||
onToggleReviewed={toggleReviewed}
|
||||
onViewReference={onViewReference}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
"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;
|
||||
}
|
||||
|
||||
export function MentionInput({
|
||||
value,
|
||||
onChange,
|
||||
onMention,
|
||||
collaborators,
|
||||
placeholder,
|
||||
onKeyDown: externalKeyDown,
|
||||
className,
|
||||
}: MentionInputProps) {
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [mentionStart, setMentionStart] = useState(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const filtered = collaborators.filter((c) => {
|
||||
const q = query.toLowerCase();
|
||||
return (
|
||||
(c.name && c.name.toLowerCase().includes(q)) ||
|
||||
(c.email && c.email.toLowerCase().includes(q))
|
||||
);
|
||||
});
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
onChange(newValue);
|
||||
|
||||
const cursor = e.target.selectionStart ?? newValue.length;
|
||||
// Find the last @ before cursor that isn't preceded by a non-space char
|
||||
const before = newValue.slice(0, cursor);
|
||||
const atIndex = before.lastIndexOf("@");
|
||||
if (atIndex >= 0 && (atIndex === 0 || before[atIndex - 1] === " ")) {
|
||||
const partial = before.slice(atIndex + 1);
|
||||
if (!partial.includes(" ")) {
|
||||
setMentionStart(atIndex);
|
||||
setQuery(partial);
|
||||
setShowDropdown(true);
|
||||
setActiveIndex(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setShowDropdown(false);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const selectCollaborator = useCallback(
|
||||
(c: Collaborator) => {
|
||||
const name = c.name || c.email || "user";
|
||||
const before = value.slice(0, mentionStart);
|
||||
const after = value.slice(
|
||||
mentionStart + 1 + query.length,
|
||||
);
|
||||
onChange(`${before}@${name}${after ? after : " "}`);
|
||||
onMention(c.user_id);
|
||||
setShowDropdown(false);
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
[value, mentionStart, query, onChange, onMention],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (showDropdown && filtered.length > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => (i + 1) % filtered.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => (i - 1 + filtered.length) % filtered.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
selectCollaborator(filtered[activeIndex]);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setShowDropdown(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
externalKeyDown?.(e);
|
||||
},
|
||||
[showDropdown, filtered, activeIndex, selectCollaborator, externalKeyDown],
|
||||
);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!showDropdown) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node) &&
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [showDropdown]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="text-xs"
|
||||
/>
|
||||
{showDropdown && filtered.length > 0 && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute bottom-full left-0 mb-1 w-64 rounded-lg border border-border bg-popover p-1 shadow-md z-50"
|
||||
>
|
||||
{filtered.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();
|
||||
selectCollaborator(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,85 @@
|
||||
"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[];
|
||||
}
|
||||
|
||||
const STATUSES: { 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" },
|
||||
];
|
||||
|
||||
export function ReportFilters({
|
||||
statusFilters,
|
||||
onToggleStatus,
|
||||
componentFilter,
|
||||
onComponentChange,
|
||||
search,
|
||||
onSearchChange,
|
||||
designators,
|
||||
}: ReportFiltersProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{STATUSES.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>
|
||||
|
||||
<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,79 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ReportSummaryProps {
|
||||
summary: Record<string, number>;
|
||||
reviewedCount: number;
|
||||
creditsSpent?: number;
|
||||
}
|
||||
|
||||
const STAT_CONFIG = [
|
||||
{ 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,
|
||||
}: ReportSummaryProps) {
|
||||
const total = summary.total || 0;
|
||||
const showCredits = typeof creditsSpent === "number" && creditsSpent > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-4",
|
||||
showCredits ? "grid-cols-6" : "grid-cols-5",
|
||||
)}
|
||||
>
|
||||
{STAT_CONFIG.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>
|
||||
{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">
|
||||
{STAT_CONFIG.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,67 @@
|
||||
"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, onViewReference }: 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 STATUS_STYLES: 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", STATUS_STYLES[status])}>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
// Open-core seam: the cloud/gateway build replaces this file with a
|
||||
// theme-aware ClerkProvider. The open-source build has no auth provider.
|
||||
export function ClerkThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||