chore: initial Vector 2.0 monorepo
Ground-up TypeScript rewrite of the Vector hardware parts inventory
system. Ships the full roadmap (Phases 0-8) in one initial commit:
- pnpm + Turbo monorepo: apps/{api,web,e2e}, packages/{db,shared,ui,config}
- Express 5 + Prisma 5 + zod validation + JWT w/ refresh-token rotation
- React 19 + Vite + shadcn/ui + TanStack Query/Table + nuqs URL state
- Repair/RMA, tags, bulk ops, saved views, CSV audit export
- Analytics dashboard on Recharts + EOL tracking
- Signed webhook subscriptions (HMAC-SHA256) with in-process emitter
- Vitest unit tests (shared schemas, api services/helpers) + Playwright skeleton
- Gitea Actions CI (lint, typecheck, test+coverage, build) + Renovate
Deferred follow-ups: Postgres cutover (data-migration script ready),
BullMQ worker for webhook delivery, @react-pdf PDF export, CSV import wizard.
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@vector/config",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"files": ["tsconfig", "tailwind"],
|
||||
"exports": {
|
||||
"./tsconfig/base.json": "./tsconfig/base.json",
|
||||
"./tsconfig/node.json": "./tsconfig/node.json",
|
||||
"./tsconfig/react.json": "./tsconfig/react.json",
|
||||
"./tailwind/tokens.css": "./tailwind/tokens.css"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Vector design tokens — Tailwind v4 CSS-first theme preset.
|
||||
* Consumers: @import this file, then add @source entries pointing at packages/ui/src
|
||||
* so Tailwind scans the shadcn primitives for class names.
|
||||
*
|
||||
* Palette = shadcn "new-york" with a Vector-specific accent. Radius and typography
|
||||
* are tuned for a dense inventory app (8px grid, 13px base font).
|
||||
*/
|
||||
|
||||
@theme {
|
||||
/* Color tokens are wired through OKLCH for perceptual-linear dark theme mixing. */
|
||||
--color-background: oklch(0.145 0 0);
|
||||
--color-foreground: oklch(0.985 0 0);
|
||||
|
||||
--color-card: oklch(0.175 0 0);
|
||||
--color-card-foreground: oklch(0.985 0 0);
|
||||
|
||||
--color-popover: oklch(0.175 0 0);
|
||||
--color-popover-foreground: oklch(0.985 0 0);
|
||||
|
||||
--color-primary: oklch(0.985 0 0);
|
||||
--color-primary-foreground: oklch(0.205 0 0);
|
||||
|
||||
--color-secondary: oklch(0.269 0 0);
|
||||
--color-secondary-foreground: oklch(0.985 0 0);
|
||||
|
||||
--color-muted: oklch(0.225 0 0);
|
||||
--color-muted-foreground: oklch(0.708 0 0);
|
||||
|
||||
--color-accent: oklch(0.269 0 0);
|
||||
--color-accent-foreground: oklch(0.985 0 0);
|
||||
|
||||
--color-destructive: oklch(0.584 0.205 27.325);
|
||||
--color-destructive-foreground: oklch(0.985 0 0);
|
||||
|
||||
--color-success: oklch(0.696 0.17 162.48);
|
||||
--color-success-foreground: oklch(0.145 0 0);
|
||||
|
||||
--color-warning: oklch(0.769 0.188 70.08);
|
||||
--color-warning-foreground: oklch(0.145 0 0);
|
||||
|
||||
--color-border: oklch(1 0 0 / 0.12);
|
||||
--color-input: oklch(1 0 0 / 0.14);
|
||||
--color-ring: oklch(0.556 0 0);
|
||||
|
||||
/* Brand accent — used for primary CTAs in pages (not in shell chrome). */
|
||||
--color-brand: oklch(0.663 0.186 250);
|
||||
--color-brand-foreground: oklch(0.985 0 0);
|
||||
|
||||
/* Radius (8px grid). */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-xl: 0.75rem;
|
||||
--radius-2xl: 1rem;
|
||||
|
||||
/* Typography. */
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: ui-monospace, "JetBrains Mono", "Menlo", monospace;
|
||||
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.8125rem;
|
||||
--text-base: 0.875rem;
|
||||
--text-lg: 1rem;
|
||||
--text-xl: 1.125rem;
|
||||
--text-2xl: 1.375rem;
|
||||
--text-3xl: 1.75rem;
|
||||
|
||||
/* Spacing extension — adds half-step 2.5/3.5 already in Tailwind plus shell widths. */
|
||||
--spacing-sidebar: 16rem;
|
||||
--spacing-sidebar-collapsed: 3.5rem;
|
||||
--spacing-topbar: 3.25rem;
|
||||
|
||||
/* Shadow. */
|
||||
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.35);
|
||||
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.4);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.5);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.55);
|
||||
|
||||
/* Animation. */
|
||||
--animate-fade-in: fade-in 120ms ease-out;
|
||||
--animate-slide-up: slide-up 180ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Base layer — body and selection defaults. */
|
||||
@layer base {
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
::selection {
|
||||
background-color: color-mix(in oklch, var(--color-brand) 40%, transparent);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--color-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"incremental": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"noEmit": true,
|
||||
"strict": false,
|
||||
"noImplicitAny": false,
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"useDefineForClassFields": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# Postgres-only migrations (apply post-cutover)
|
||||
|
||||
Phase 3 locked in the full schema shape on SQLite. When the datasource flips to
|
||||
`postgresql` we apply a follow-up migration that upgrades a few columns to
|
||||
Postgres-native types and adds the Full-Text Search column required by the plan.
|
||||
|
||||
## 1. Part full-text search (tsvector + GIN)
|
||||
|
||||
```sql
|
||||
ALTER TABLE "Part"
|
||||
ADD COLUMN "searchVector" tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
setweight(to_tsvector('simple', coalesce("serialNumber", '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce("mpn", '')), 'B') ||
|
||||
setweight(to_tsvector('english', coalesce("notes", '')), 'C')
|
||||
) STORED;
|
||||
|
||||
CREATE INDEX "Part_searchVector_idx" ON "Part" USING GIN ("searchVector");
|
||||
```
|
||||
|
||||
Query shape the API will use for the `q=` filter on `/api/parts`:
|
||||
|
||||
```sql
|
||||
SELECT * FROM "Part"
|
||||
WHERE "searchVector" @@ plainto_tsquery('simple', $1)
|
||||
ORDER BY ts_rank("searchVector", plainto_tsquery('simple', $1)) DESC
|
||||
LIMIT $2 OFFSET $3;
|
||||
```
|
||||
|
||||
Expose on the Prisma model with `@ignore` (Prisma can't represent `GENERATED`
|
||||
columns) and read via `prisma.$queryRaw` inside the parts service.
|
||||
|
||||
## 2. Convert JSON-string columns to native Postgres types
|
||||
|
||||
These were stored as `String` for SQLite portability.
|
||||
|
||||
```sql
|
||||
ALTER TABLE "WebhookSubscription"
|
||||
ALTER COLUMN "events" TYPE text[] USING string_to_array(
|
||||
trim(both '[]' from "events"), ','
|
||||
);
|
||||
|
||||
ALTER TABLE "SavedView"
|
||||
ALTER COLUMN "filterJson" TYPE jsonb USING "filterJson"::jsonb;
|
||||
|
||||
ALTER TABLE "CsvImportJob"
|
||||
ALTER COLUMN "errors" TYPE jsonb USING "errors"::jsonb;
|
||||
```
|
||||
|
||||
After the DDL change, update `packages/db/prisma/schema.prisma`:
|
||||
|
||||
```prisma
|
||||
model WebhookSubscription {
|
||||
// ...
|
||||
events String[]
|
||||
}
|
||||
|
||||
model SavedView {
|
||||
// ...
|
||||
filterJson Json
|
||||
}
|
||||
|
||||
model CsvImportJob {
|
||||
// ...
|
||||
errors Json?
|
||||
}
|
||||
```
|
||||
|
||||
Regenerate the Prisma client and tighten the zod → Prisma marshaling in the
|
||||
service layer (drop the `JSON.stringify` / `JSON.parse` bridges).
|
||||
|
||||
## Verification checklist (P3)
|
||||
|
||||
- `prisma migrate deploy` applies cleanly against a fresh Postgres snapshot.
|
||||
- `EXPLAIN ANALYZE` on the FTS query shows a `Bitmap Index Scan` on
|
||||
`Part_searchVector_idx` and returns ranked results in <50ms at 100k rows.
|
||||
- Integration test inserts 3 parts with overlapping serial/mpn tokens, queries
|
||||
`q=...`, and asserts order-by-rank.
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@vector/db",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"clean": "rimraf dist .turbo",
|
||||
"db:generate": "prisma generate --schema=./prisma/schema.prisma",
|
||||
"db:migrate": "prisma migrate dev --schema=./prisma/schema.prisma",
|
||||
"db:migrate:deploy": "prisma migrate deploy --schema=./prisma/schema.prisma",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:studio": "prisma studio --schema=./prisma/schema.prisma",
|
||||
"db:reset": "prisma migrate reset --schema=./prisma/schema.prisma"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.22.0",
|
||||
"bcryptjs": "^3.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@vector/config": "workspace:*",
|
||||
"prisma": "^5.22.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"username" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'TECHNICIAN',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Manufacturer" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Site" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Room" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"siteId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Room_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Bin" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"roomId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Bin_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Part" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"serialNumber" TEXT NOT NULL,
|
||||
"mpn" TEXT NOT NULL,
|
||||
"manufacturerId" TEXT NOT NULL,
|
||||
"price" REAL,
|
||||
"state" TEXT NOT NULL DEFAULT 'SPARE',
|
||||
"binId" TEXT,
|
||||
"notes" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Part_manufacturerId_fkey" FOREIGN KEY ("manufacturerId") REFERENCES "Manufacturer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Part_binId_fkey" FOREIGN KEY ("binId") REFERENCES "Bin" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PartEvent" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"partId" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"type" TEXT NOT NULL,
|
||||
"field" TEXT,
|
||||
"oldValue" TEXT,
|
||||
"newValue" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "PartEvent_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "PartEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Manufacturer_name_key" ON "Manufacturer"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Site_name_key" ON "Site"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Room_siteId_idx" ON "Room"("siteId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Room_siteId_name_key" ON "Room"("siteId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Bin_roomId_idx" ON "Bin"("roomId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Bin_roomId_name_key" ON "Bin"("roomId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Part_serialNumber_key" ON "Part"("serialNumber");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Part_state_idx" ON "Part"("state");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Part_binId_idx" ON "Part"("binId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Part_manufacturerId_idx" ON "Part"("manufacturerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Part_mpn_idx" ON "Part"("mpn");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PartEvent_partId_createdAt_idx" ON "PartEvent"("partId", "createdAt" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PartEvent_userId_idx" ON "PartEvent"("userId");
|
||||
@@ -0,0 +1,20 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "RefreshToken" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" DATETIME NOT NULL,
|
||||
"revokedAt" DATETIME,
|
||||
"replacedBy" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RefreshToken_expiresAt_idx" ON "RefreshToken"("expiresAt");
|
||||
@@ -0,0 +1,173 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Manufacturer" ADD COLUMN "eolDate" DATETIME;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Category" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Tag" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"color" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PartTag" (
|
||||
"partId" TEXT NOT NULL,
|
||||
"tagId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
PRIMARY KEY ("partId", "tagId"),
|
||||
CONSTRAINT "PartTag_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "PartTag_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Host" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"location" TEXT,
|
||||
"notes" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RepairJob" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"partId" TEXT NOT NULL,
|
||||
"hostId" TEXT,
|
||||
"assigneeId" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||
"openedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"closedAt" DATETIME,
|
||||
"notes" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "RepairJob_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "RepairJob_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "RepairJob_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WebhookSubscription" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"url" TEXT NOT NULL,
|
||||
"secret" TEXT NOT NULL,
|
||||
"events" TEXT NOT NULL,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SavedView" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"resource" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"filterJson" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "SavedView_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CsvImportJob" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT,
|
||||
"resource" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||
"filename" TEXT NOT NULL,
|
||||
"stagedRows" INTEGER NOT NULL DEFAULT 0,
|
||||
"errors" TEXT,
|
||||
"startedAt" DATETIME,
|
||||
"finishedAt" DATETIME,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "CsvImportJob_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Part" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"serialNumber" TEXT NOT NULL,
|
||||
"mpn" TEXT NOT NULL,
|
||||
"manufacturerId" TEXT NOT NULL,
|
||||
"price" REAL,
|
||||
"state" TEXT NOT NULL DEFAULT 'SPARE',
|
||||
"binId" TEXT,
|
||||
"categoryId" TEXT,
|
||||
"replacementPartId" TEXT,
|
||||
"notes" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Part_manufacturerId_fkey" FOREIGN KEY ("manufacturerId") REFERENCES "Manufacturer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Part_binId_fkey" FOREIGN KEY ("binId") REFERENCES "Bin" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "Part_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "Part_replacementPartId_fkey" FOREIGN KEY ("replacementPartId") REFERENCES "Part" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Part" ("binId", "createdAt", "id", "manufacturerId", "mpn", "notes", "price", "serialNumber", "state", "updatedAt") SELECT "binId", "createdAt", "id", "manufacturerId", "mpn", "notes", "price", "serialNumber", "state", "updatedAt" FROM "Part";
|
||||
DROP TABLE "Part";
|
||||
ALTER TABLE "new_Part" RENAME TO "Part";
|
||||
CREATE UNIQUE INDEX "Part_serialNumber_key" ON "Part"("serialNumber");
|
||||
CREATE INDEX "Part_state_idx" ON "Part"("state");
|
||||
CREATE INDEX "Part_binId_idx" ON "Part"("binId");
|
||||
CREATE INDEX "Part_manufacturerId_idx" ON "Part"("manufacturerId");
|
||||
CREATE INDEX "Part_mpn_idx" ON "Part"("mpn");
|
||||
CREATE INDEX "Part_categoryId_idx" ON "Part"("categoryId");
|
||||
CREATE INDEX "Part_replacementPartId_idx" ON "Part"("replacementPartId");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Category_name_key" ON "Category"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Tag_name_key" ON "Tag"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PartTag_tagId_idx" ON "PartTag"("tagId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Host_name_key" ON "Host"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RepairJob_partId_idx" ON "RepairJob"("partId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RepairJob_status_idx" ON "RepairJob"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RepairJob_hostId_idx" ON "RepairJob"("hostId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RepairJob_assigneeId_idx" ON "RepairJob"("assigneeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RepairJob_status_openedAt_idx" ON "RepairJob"("status", "openedAt" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WebhookSubscription_active_idx" ON "WebhookSubscription"("active");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SavedView_userId_idx" ON "SavedView"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SavedView_userId_resource_name_key" ON "SavedView"("userId", "resource", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CsvImportJob_userId_idx" ON "CsvImportJob"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CsvImportJob_status_idx" ON "CsvImportJob"("status");
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "sqlite"
|
||||
@@ -0,0 +1,242 @@
|
||||
// NOTE: provider is temporarily set to "sqlite" for Phase 1 local verification.
|
||||
// Flip to "postgresql" once Docker + docker-compose Postgres is available.
|
||||
// All cascade rules and indexes below are portable between providers.
|
||||
//
|
||||
// Postgres-only additions applied post-cutover (see packages/db/POSTGRES_FTS.md):
|
||||
// * Generated tsvector column on Part(serial, mpn, notes) + GIN index (Phase 3 scope).
|
||||
// * WebhookSubscription.events and SavedView.filterJson currently stored as String (JSON
|
||||
// text) for SQLite portability; on Postgres these become String[] and Jsonb respectively.
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
username String @unique
|
||||
email String @unique
|
||||
passwordHash String
|
||||
role String @default("TECHNICIAN")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
partEvents PartEvent[]
|
||||
refreshTokens RefreshToken[]
|
||||
repairAssignments RepairJob[] @relation("RepairAssignee")
|
||||
savedViews SavedView[]
|
||||
csvImportJobs CsvImportJob[]
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
revokedAt DateTime?
|
||||
replacedBy String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
model Manufacturer {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
eolDate DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
parts Part[]
|
||||
}
|
||||
|
||||
model Site {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
rooms Room[]
|
||||
}
|
||||
|
||||
model Room {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
siteId String
|
||||
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
bins Bin[]
|
||||
|
||||
@@unique([siteId, name])
|
||||
@@index([siteId])
|
||||
}
|
||||
|
||||
model Bin {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
roomId String
|
||||
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
parts Part[]
|
||||
|
||||
@@unique([roomId, name])
|
||||
@@index([roomId])
|
||||
}
|
||||
|
||||
model Category {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
parts Part[]
|
||||
}
|
||||
|
||||
model Part {
|
||||
id String @id @default(uuid())
|
||||
serialNumber String @unique
|
||||
mpn String
|
||||
manufacturerId String
|
||||
manufacturer Manufacturer @relation(fields: [manufacturerId], references: [id], onDelete: Restrict)
|
||||
price Float?
|
||||
state String @default("SPARE")
|
||||
binId String?
|
||||
bin Bin? @relation(fields: [binId], references: [id], onDelete: SetNull)
|
||||
categoryId String?
|
||||
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
replacementPartId String?
|
||||
replacement Part? @relation("PartReplacement", fields: [replacementPartId], references: [id], onDelete: SetNull)
|
||||
replacedBy Part[] @relation("PartReplacement")
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
events PartEvent[]
|
||||
tags PartTag[]
|
||||
repairs RepairJob[]
|
||||
|
||||
@@index([state])
|
||||
@@index([binId])
|
||||
@@index([manufacturerId])
|
||||
@@index([mpn])
|
||||
@@index([categoryId])
|
||||
@@index([replacementPartId])
|
||||
}
|
||||
|
||||
model PartEvent {
|
||||
id String @id @default(uuid())
|
||||
partId String
|
||||
part Part @relation(fields: [partId], references: [id], onDelete: Cascade)
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
type String
|
||||
field String?
|
||||
oldValue String?
|
||||
newValue String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([partId, createdAt(sort: Desc)])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Tag {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
color String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
parts PartTag[]
|
||||
}
|
||||
|
||||
model PartTag {
|
||||
partId String
|
||||
tagId String
|
||||
part Part @relation(fields: [partId], references: [id], onDelete: Cascade)
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@id([partId, tagId])
|
||||
@@index([tagId])
|
||||
}
|
||||
|
||||
model Host {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
location String?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
repairs RepairJob[]
|
||||
}
|
||||
|
||||
model RepairJob {
|
||||
id String @id @default(uuid())
|
||||
partId String
|
||||
part Part @relation(fields: [partId], references: [id], onDelete: Cascade)
|
||||
hostId String?
|
||||
host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull)
|
||||
assigneeId String?
|
||||
assignee User? @relation("RepairAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
|
||||
status String @default("PENDING")
|
||||
openedAt DateTime @default(now())
|
||||
closedAt DateTime?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([partId])
|
||||
@@index([status])
|
||||
@@index([hostId])
|
||||
@@index([assigneeId])
|
||||
@@index([status, openedAt(sort: Desc)])
|
||||
}
|
||||
|
||||
model WebhookSubscription {
|
||||
id String @id @default(uuid())
|
||||
url String
|
||||
secret String
|
||||
// JSON array of WebhookEventName values. Becomes String[] on Postgres cutover.
|
||||
events String
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([active])
|
||||
}
|
||||
|
||||
model SavedView {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
resource String
|
||||
name String
|
||||
// JSON blob describing filters/sort/columns. Becomes Jsonb on Postgres cutover.
|
||||
filterJson String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([userId, resource, name])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model CsvImportJob {
|
||||
id String @id @default(uuid())
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
resource String
|
||||
status String @default("PENDING")
|
||||
filename String
|
||||
stagedRows Int @default(0)
|
||||
// JSON array of CsvImportRowError. Nullable until validation runs.
|
||||
errors String?
|
||||
startedAt DateTime?
|
||||
finishedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { prisma } from '../src/client.js';
|
||||
|
||||
async function main() {
|
||||
const passwordHash = await bcrypt.hash('admin', 12);
|
||||
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { username: 'admin' },
|
||||
update: {},
|
||||
create: {
|
||||
username: 'admin',
|
||||
email: 'admin@vector.local',
|
||||
passwordHash,
|
||||
role: 'ADMIN',
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Seeded admin user: ${admin.username} (${admin.email})`);
|
||||
console.log('Default password: admin — change this immediately!');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,33 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __vectorPrisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
function resolveSqliteUrl(raw: string | undefined): string | undefined {
|
||||
if (!raw || !raw.startsWith('file:')) return raw;
|
||||
const rest = raw.slice('file:'.length).replace(/^\/+/, '');
|
||||
if (path.isAbsolute(rest) || /^[A-Za-z]:[\\/]/.test(rest)) {
|
||||
return 'file:' + rest.replace(/\\/g, '/');
|
||||
}
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const schemaDir = path.resolve(here, '..', 'prisma');
|
||||
const absolute = path.resolve(schemaDir, rest);
|
||||
return 'file:' + absolute.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
const url = resolveSqliteUrl(process.env.DATABASE_URL);
|
||||
|
||||
export const prisma: PrismaClient =
|
||||
globalThis.__vectorPrisma ??
|
||||
new PrismaClient({
|
||||
datasources: url ? { db: { url } } : undefined,
|
||||
log: process.env.NODE_ENV === 'production' ? ['error'] : ['warn', 'error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalThis.__vectorPrisma = prisma;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export { prisma } from './client.js';
|
||||
export { Prisma, PrismaClient } from '@prisma/client';
|
||||
export type {
|
||||
User,
|
||||
Manufacturer,
|
||||
Site,
|
||||
Room,
|
||||
Bin,
|
||||
Part,
|
||||
PartEvent,
|
||||
} from '@prisma/client';
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@vector/config/tsconfig/node.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules", "prisma"]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@vector/shared",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"clean": "rimraf dist .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@vector/config": "workspace:*",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod';
|
||||
import { PartState } from './enums.js';
|
||||
|
||||
export interface StateCount {
|
||||
state: z.infer<typeof PartState>;
|
||||
count: number;
|
||||
totalPrice: number;
|
||||
}
|
||||
|
||||
export interface AgeBucket {
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface BinCount {
|
||||
binId: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ManufacturerEolSummary {
|
||||
manufacturerId: string;
|
||||
name: string;
|
||||
eolDate: string | null;
|
||||
deployedCount: number;
|
||||
}
|
||||
|
||||
export interface DashboardAnalytics {
|
||||
totalParts: number;
|
||||
byState: StateCount[];
|
||||
ageBuckets: AgeBucket[];
|
||||
topBins: BinCount[];
|
||||
deployedPastEol: ManufacturerEolSummary[];
|
||||
openRepairs: number;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { LoginRequest, UserPublic } from './auth.js';
|
||||
|
||||
describe('LoginRequest', () => {
|
||||
it('accepts valid credentials', () => {
|
||||
expect(LoginRequest.safeParse({ username: 'u', password: 'p' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty username or password', () => {
|
||||
expect(LoginRequest.safeParse({ username: '', password: 'p' }).success).toBe(false);
|
||||
expect(LoginRequest.safeParse({ username: 'u', password: '' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('caps username at 64 and password at 256', () => {
|
||||
expect(
|
||||
LoginRequest.safeParse({ username: 'a'.repeat(65), password: 'p' }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
LoginRequest.safeParse({ username: 'u', password: 'a'.repeat(257) }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserPublic', () => {
|
||||
it('accepts an ISO string createdAt', () => {
|
||||
const r = UserPublic.safeParse({
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
username: 'u',
|
||||
email: 'u@x.dev',
|
||||
role: 'ADMIN',
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid role', () => {
|
||||
expect(
|
||||
UserPublic.safeParse({
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
username: 'u',
|
||||
email: 'u@x.dev',
|
||||
role: 'SUPER_ADMIN',
|
||||
createdAt: new Date().toISOString(),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
import { Role } from './enums.js';
|
||||
|
||||
export const LoginRequest = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
password: z.string().min(1).max(256),
|
||||
});
|
||||
export type LoginRequest = z.infer<typeof LoginRequest>;
|
||||
|
||||
export const UserPublic = z.object({
|
||||
id: z.string().uuid(),
|
||||
username: z.string(),
|
||||
email: z.string().email(),
|
||||
role: Role,
|
||||
createdAt: z.union([z.string(), z.date()]),
|
||||
});
|
||||
export type UserPublic = z.infer<typeof UserPublic>;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
export const CreateCategoryRequest = z.object({
|
||||
name: z.string().min(1).max(64),
|
||||
description: z.string().max(512).optional().nullable(),
|
||||
});
|
||||
export type CreateCategoryRequest = z.infer<typeof CreateCategoryRequest>;
|
||||
|
||||
export const UpdateCategoryRequest = z
|
||||
.object({
|
||||
name: z.string().min(1).max(64).optional(),
|
||||
description: z.string().max(512).nullable().optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdateCategoryRequest = z.infer<typeof UpdateCategoryRequest>;
|
||||
|
||||
export const CategoryListQuery = PaginationQuery;
|
||||
export type CategoryListQuery = z.infer<typeof CategoryListQuery>;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
import { CsvImportStatus } from './enums.js';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
export const CsvImportResource = z.enum(['parts']);
|
||||
export type CsvImportResource = z.infer<typeof CsvImportResource>;
|
||||
|
||||
export const CreateCsvImportJobRequest = z.object({
|
||||
resource: CsvImportResource,
|
||||
// Presigned-upload semantics are deferred; for now the API accepts a filename + row count hint.
|
||||
filename: z.string().min(1).max(256),
|
||||
});
|
||||
export type CreateCsvImportJobRequest = z.infer<typeof CreateCsvImportJobRequest>;
|
||||
|
||||
export const CsvImportJobListQuery = PaginationQuery.extend({
|
||||
status: CsvImportStatus.optional(),
|
||||
resource: CsvImportResource.optional(),
|
||||
});
|
||||
export type CsvImportJobListQuery = z.infer<typeof CsvImportJobListQuery>;
|
||||
|
||||
// Shape of each entry in the errors JSON column; consumed by the importer UI.
|
||||
export const CsvImportRowError = z.object({
|
||||
row: z.number().int().nonnegative(),
|
||||
column: z.string().max(64).optional(),
|
||||
code: z.string().max(64),
|
||||
message: z.string().max(512),
|
||||
});
|
||||
export type CsvImportRowError = z.infer<typeof CsvImportRowError>;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PartState = z.enum(['SPARE', 'DEPLOYED', 'BROKEN', 'PENDING_DESTRUCTION']);
|
||||
export type PartState = z.infer<typeof PartState>;
|
||||
|
||||
export const Role = z.enum(['ADMIN', 'TECHNICIAN']);
|
||||
export type Role = z.infer<typeof Role>;
|
||||
|
||||
export const PartEventType = z.enum([
|
||||
'CREATED',
|
||||
'STATE_CHANGED',
|
||||
'LOCATION_CHANGED',
|
||||
'FIELD_UPDATED',
|
||||
'REPAIR_STARTED',
|
||||
'REPAIR_COMPLETED',
|
||||
'TAG_ADDED',
|
||||
'TAG_REMOVED',
|
||||
]);
|
||||
export type PartEventType = z.infer<typeof PartEventType>;
|
||||
|
||||
export const RepairStatus = z.enum(['PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']);
|
||||
export type RepairStatus = z.infer<typeof RepairStatus>;
|
||||
|
||||
export const CsvImportStatus = z.enum([
|
||||
'PENDING',
|
||||
'STAGED',
|
||||
'COMMITTED',
|
||||
'FAILED',
|
||||
'CANCELLED',
|
||||
]);
|
||||
export type CsvImportStatus = z.infer<typeof CsvImportStatus>;
|
||||
|
||||
// Catalog of webhook event names the system will emit. Stored per subscription as a JSON array.
|
||||
export const WebhookEventName = z.enum([
|
||||
'part.created',
|
||||
'part.updated',
|
||||
'part.deleted',
|
||||
'part.state_changed',
|
||||
'part.location_changed',
|
||||
'repair.started',
|
||||
'repair.completed',
|
||||
'repair.cancelled',
|
||||
'tag.assigned',
|
||||
'tag.removed',
|
||||
]);
|
||||
export type WebhookEventName = z.infer<typeof WebhookEventName>;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const defaultSecret = /change[-_ ]?me/i;
|
||||
|
||||
export const ApiEnv = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
PORT: z.coerce.number().int().positive().default(3001),
|
||||
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
|
||||
JWT_SECRET: z
|
||||
.string()
|
||||
.min(32, 'JWT_SECRET must be at least 32 characters')
|
||||
.refine((v) => !defaultSecret.test(v), {
|
||||
message: 'JWT_SECRET still matches the default placeholder — generate a real secret',
|
||||
}),
|
||||
CLIENT_ORIGIN: z.string().url().default('http://localhost:5173'),
|
||||
});
|
||||
export type ApiEnv = z.infer<typeof ApiEnv>;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
export const CreateHostRequest = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
location: z.string().max(256).optional().nullable(),
|
||||
notes: z.string().max(4096).optional().nullable(),
|
||||
});
|
||||
export type CreateHostRequest = z.infer<typeof CreateHostRequest>;
|
||||
|
||||
export const UpdateHostRequest = z
|
||||
.object({
|
||||
name: z.string().min(1).max(128).optional(),
|
||||
location: z.string().max(256).nullable().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdateHostRequest = z.infer<typeof UpdateHostRequest>;
|
||||
|
||||
export const HostListQuery = PaginationQuery.extend({
|
||||
q: z.string().max(128).optional(),
|
||||
});
|
||||
export type HostListQuery = z.infer<typeof HostListQuery>;
|
||||
@@ -0,0 +1,16 @@
|
||||
export * from './enums.js';
|
||||
export * from './auth.js';
|
||||
export * from './users.js';
|
||||
export * from './manufacturers.js';
|
||||
export * from './locations.js';
|
||||
export * from './parts.js';
|
||||
export * from './env.js';
|
||||
export * from './pagination.js';
|
||||
export * from './hosts.js';
|
||||
export * from './repairs.js';
|
||||
export * from './tags.js';
|
||||
export * from './categories.js';
|
||||
export * from './webhooks.js';
|
||||
export * from './saved-views.js';
|
||||
export * from './csv-imports.js';
|
||||
export * from './analytics.js';
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateSiteRequest = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
});
|
||||
export type CreateSiteRequest = z.infer<typeof CreateSiteRequest>;
|
||||
|
||||
export const UpdateSiteRequest = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
});
|
||||
export type UpdateSiteRequest = z.infer<typeof UpdateSiteRequest>;
|
||||
|
||||
export const CreateRoomRequest = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
siteId: z.string().uuid(),
|
||||
});
|
||||
export type CreateRoomRequest = z.infer<typeof CreateRoomRequest>;
|
||||
|
||||
export const UpdateRoomRequest = z.object({
|
||||
name: z.string().min(1).max(128).optional(),
|
||||
siteId: z.string().uuid().optional(),
|
||||
});
|
||||
export type UpdateRoomRequest = z.infer<typeof UpdateRoomRequest>;
|
||||
|
||||
export const CreateBinRequest = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
roomId: z.string().uuid(),
|
||||
});
|
||||
export type CreateBinRequest = z.infer<typeof CreateBinRequest>;
|
||||
|
||||
export const UpdateBinRequest = z.object({
|
||||
name: z.string().min(1).max(128).optional(),
|
||||
roomId: z.string().uuid().optional(),
|
||||
});
|
||||
export type UpdateBinRequest = z.infer<typeof UpdateBinRequest>;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// ISO datetime string (e.g. "2027-12-31T00:00:00.000Z"). Clients may send date-only "2027-12-31";
|
||||
// API layer is expected to coerce to Date.
|
||||
const IsoDate = z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/));
|
||||
|
||||
export const CreateManufacturerRequest = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
eolDate: IsoDate.optional().nullable(),
|
||||
});
|
||||
export type CreateManufacturerRequest = z.infer<typeof CreateManufacturerRequest>;
|
||||
|
||||
export const UpdateManufacturerRequest = z
|
||||
.object({
|
||||
name: z.string().min(1).max(128).optional(),
|
||||
eolDate: IsoDate.nullable().optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdateManufacturerRequest = z.infer<typeof UpdateManufacturerRequest>;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PAGINATION_MAX = 100;
|
||||
export const PAGINATION_DEFAULT = 20;
|
||||
|
||||
export const PaginationQuery = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(PAGINATION_MAX).default(PAGINATION_DEFAULT),
|
||||
});
|
||||
export type PaginationQuery = z.infer<typeof PaginationQuery>;
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BulkPartsRequest, CreatePartRequest, PartListQuery, UpdatePartRequest } from './parts.js';
|
||||
|
||||
const mfgId = '11111111-1111-4111-8111-111111111111';
|
||||
const binId = '22222222-2222-4222-8222-222222222222';
|
||||
|
||||
describe('CreatePartRequest', () => {
|
||||
it('accepts a minimal valid payload', () => {
|
||||
const r = CreatePartRequest.parse({
|
||||
serialNumber: 'SN-1',
|
||||
mpn: 'MPN-1',
|
||||
manufacturerId: mfgId,
|
||||
});
|
||||
expect(r.serialNumber).toBe('SN-1');
|
||||
});
|
||||
|
||||
it('rejects empty serial / mpn', () => {
|
||||
expect(
|
||||
CreatePartRequest.safeParse({ serialNumber: '', mpn: 'X', manufacturerId: mfgId }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
CreatePartRequest.safeParse({ serialNumber: 'X', mpn: '', manufacturerId: mfgId }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects negative price', () => {
|
||||
const res = CreatePartRequest.safeParse({
|
||||
serialNumber: 'X',
|
||||
mpn: 'Y',
|
||||
manufacturerId: mfgId,
|
||||
price: -1,
|
||||
});
|
||||
expect(res.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-uuid manufacturer id', () => {
|
||||
expect(
|
||||
CreatePartRequest.safeParse({ serialNumber: 'X', mpn: 'Y', manufacturerId: 'not-uuid' })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('caps tagIds at 32', () => {
|
||||
const tagIds = Array.from({ length: 33 }, () => '33333333-3333-4333-8333-333333333333');
|
||||
expect(
|
||||
CreatePartRequest.safeParse({
|
||||
serialNumber: 'X',
|
||||
mpn: 'Y',
|
||||
manufacturerId: mfgId,
|
||||
tagIds,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdatePartRequest', () => {
|
||||
it('requires at least one field', () => {
|
||||
expect(UpdatePartRequest.safeParse({}).success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a single field', () => {
|
||||
expect(UpdatePartRequest.safeParse({ notes: 'hi' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('permits nullable binId to clear location', () => {
|
||||
const r = UpdatePartRequest.parse({ binId: null });
|
||||
expect(r.binId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PartListQuery', () => {
|
||||
it('defaults page=1, pageSize=20', () => {
|
||||
const r = PartListQuery.parse({});
|
||||
expect(r.page).toBe(1);
|
||||
expect(r.pageSize).toBe(20);
|
||||
});
|
||||
|
||||
it('coerces string numbers from query strings', () => {
|
||||
const r = PartListQuery.parse({ page: '3', pageSize: '50' });
|
||||
expect(r.page).toBe(3);
|
||||
expect(r.pageSize).toBe(50);
|
||||
});
|
||||
|
||||
it('clamps pageSize to the 100 max', () => {
|
||||
expect(PartListQuery.safeParse({ pageSize: '500' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('parses eolOnly from string and boolean', () => {
|
||||
expect(PartListQuery.parse({ eolOnly: 'true' }).eolOnly).toBe(true);
|
||||
expect(PartListQuery.parse({ eolOnly: 'false' }).eolOnly).toBe(false);
|
||||
expect(PartListQuery.parse({ eolOnly: true }).eolOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BulkPartsRequest', () => {
|
||||
it('requires at least one mutation field', () => {
|
||||
expect(BulkPartsRequest.safeParse({ ids: [mfgId] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts state mutation', () => {
|
||||
expect(BulkPartsRequest.safeParse({ ids: [mfgId], state: 'SPARE' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts binId=null to unassign', () => {
|
||||
const r = BulkPartsRequest.parse({ ids: [mfgId], binId: null });
|
||||
expect(r.binId).toBeNull();
|
||||
});
|
||||
|
||||
it('caps ids at 500', () => {
|
||||
const ids = Array.from({ length: 501 }, () => binId);
|
||||
expect(BulkPartsRequest.safeParse({ ids, state: 'SPARE' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { z } from 'zod';
|
||||
import { PartState } from './enums.js';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
export const CreatePartRequest = z.object({
|
||||
serialNumber: z.string().min(1).max(128),
|
||||
mpn: z.string().min(1).max(128),
|
||||
manufacturerId: z.string().uuid(),
|
||||
price: z.number().nonnegative().optional().nullable(),
|
||||
state: PartState.optional(),
|
||||
binId: z.string().uuid().optional().nullable(),
|
||||
notes: z.string().max(4096).optional().nullable(),
|
||||
categoryId: z.string().uuid().optional().nullable(),
|
||||
replacementPartId: z.string().uuid().optional().nullable(),
|
||||
tagIds: z.array(z.string().uuid()).max(32).optional(),
|
||||
});
|
||||
export type CreatePartRequest = z.infer<typeof CreatePartRequest>;
|
||||
|
||||
export const UpdatePartRequest = z
|
||||
.object({
|
||||
serialNumber: z.string().min(1).max(128).optional(),
|
||||
mpn: z.string().min(1).max(128).optional(),
|
||||
manufacturerId: z.string().uuid().optional(),
|
||||
price: z.number().nonnegative().nullable().optional(),
|
||||
state: PartState.optional(),
|
||||
binId: z.string().uuid().nullable().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
categoryId: z.string().uuid().nullable().optional(),
|
||||
replacementPartId: z.string().uuid().nullable().optional(),
|
||||
tagIds: z.array(z.string().uuid()).max(32).optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdatePartRequest = z.infer<typeof UpdatePartRequest>;
|
||||
|
||||
export const PartListQuery = PaginationQuery.extend({
|
||||
state: PartState.optional(),
|
||||
binId: z.string().uuid().optional(),
|
||||
manufacturerId: z.string().uuid().optional(),
|
||||
mpn: z.string().max(128).optional(),
|
||||
serialNumber: z.string().max(128).optional(),
|
||||
q: z.string().max(128).optional(),
|
||||
categoryId: z.string().uuid().optional(),
|
||||
tagId: z.string().uuid().optional(),
|
||||
eolOnly: z
|
||||
.union([z.literal('true'), z.literal('false'), z.boolean()])
|
||||
.transform((v) => v === true || v === 'true')
|
||||
.optional(),
|
||||
});
|
||||
export type PartListQuery = z.infer<typeof PartListQuery>;
|
||||
|
||||
export const PartEventsQuery = PaginationQuery;
|
||||
export type PartEventsQuery = z.infer<typeof PartEventsQuery>;
|
||||
|
||||
export const BulkPartsRequest = z
|
||||
.object({
|
||||
ids: z.array(z.string().uuid()).min(1).max(500),
|
||||
state: PartState.optional(),
|
||||
binId: z.string().uuid().nullable().optional(),
|
||||
addTagIds: z.array(z.string().uuid()).max(32).optional(),
|
||||
removeTagIds: z.array(z.string().uuid()).max(32).optional(),
|
||||
})
|
||||
.refine(
|
||||
(v) =>
|
||||
v.state !== undefined ||
|
||||
v.binId !== undefined ||
|
||||
(v.addTagIds && v.addTagIds.length > 0) ||
|
||||
(v.removeTagIds && v.removeTagIds.length > 0),
|
||||
{ message: 'At least one mutation field is required' },
|
||||
);
|
||||
export type BulkPartsRequest = z.infer<typeof BulkPartsRequest>;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
import { RepairStatus } from './enums.js';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
export const CreateRepairJobRequest = z.object({
|
||||
partId: z.string().uuid(),
|
||||
hostId: z.string().uuid().optional().nullable(),
|
||||
assigneeId: z.string().uuid().optional().nullable(),
|
||||
notes: z.string().max(4096).optional().nullable(),
|
||||
});
|
||||
export type CreateRepairJobRequest = z.infer<typeof CreateRepairJobRequest>;
|
||||
|
||||
export const UpdateRepairJobRequest = z
|
||||
.object({
|
||||
status: RepairStatus.optional(),
|
||||
hostId: z.string().uuid().nullable().optional(),
|
||||
assigneeId: z.string().uuid().nullable().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdateRepairJobRequest = z.infer<typeof UpdateRepairJobRequest>;
|
||||
|
||||
export const RepairJobListQuery = PaginationQuery.extend({
|
||||
status: RepairStatus.optional(),
|
||||
partId: z.string().uuid().optional(),
|
||||
hostId: z.string().uuid().optional(),
|
||||
assigneeId: z.string().uuid().optional(),
|
||||
openOnly: z
|
||||
.union([z.literal('true'), z.literal('false'), z.boolean()])
|
||||
.transform((v) => v === true || v === 'true')
|
||||
.optional(),
|
||||
});
|
||||
export type RepairJobListQuery = z.infer<typeof RepairJobListQuery>;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { z } from 'zod';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
export const SavedViewResource = z.enum(['parts', 'repairs', 'hosts', 'manufacturers']);
|
||||
export type SavedViewResource = z.infer<typeof SavedViewResource>;
|
||||
|
||||
// filterJson is stored as a JSON string in the DB. API accepts/returns a structured object.
|
||||
export const SavedViewFilter = z
|
||||
.object({
|
||||
filters: z.record(z.unknown()).optional(),
|
||||
sort: z
|
||||
.object({
|
||||
field: z.string().max(64),
|
||||
direction: z.enum(['asc', 'desc']),
|
||||
})
|
||||
.optional(),
|
||||
columns: z.array(z.string().max(64)).optional(),
|
||||
search: z.string().max(128).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export type SavedViewFilter = z.infer<typeof SavedViewFilter>;
|
||||
|
||||
export const CreateSavedViewRequest = z.object({
|
||||
resource: SavedViewResource,
|
||||
name: z.string().min(1).max(64),
|
||||
filter: SavedViewFilter,
|
||||
});
|
||||
export type CreateSavedViewRequest = z.infer<typeof CreateSavedViewRequest>;
|
||||
|
||||
export const UpdateSavedViewRequest = z
|
||||
.object({
|
||||
name: z.string().min(1).max(64).optional(),
|
||||
filter: SavedViewFilter.optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdateSavedViewRequest = z.infer<typeof UpdateSavedViewRequest>;
|
||||
|
||||
export const SavedViewListQuery = PaginationQuery.extend({
|
||||
resource: SavedViewResource.optional(),
|
||||
});
|
||||
export type SavedViewListQuery = z.infer<typeof SavedViewListQuery>;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from 'zod';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
const HexColor = z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/, 'Expected hex color like #rrggbb');
|
||||
|
||||
export const CreateTagRequest = z.object({
|
||||
name: z.string().min(1).max(64),
|
||||
color: HexColor.optional().nullable(),
|
||||
});
|
||||
export type CreateTagRequest = z.infer<typeof CreateTagRequest>;
|
||||
|
||||
export const UpdateTagRequest = z
|
||||
.object({
|
||||
name: z.string().min(1).max(64).optional(),
|
||||
color: HexColor.nullable().optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdateTagRequest = z.infer<typeof UpdateTagRequest>;
|
||||
|
||||
export const TagListQuery = PaginationQuery.extend({
|
||||
q: z.string().max(64).optional(),
|
||||
});
|
||||
export type TagListQuery = z.infer<typeof TagListQuery>;
|
||||
|
||||
export const AssignTagsRequest = z.object({
|
||||
tagIds: z.array(z.string().uuid()).min(1).max(32),
|
||||
});
|
||||
export type AssignTagsRequest = z.infer<typeof AssignTagsRequest>;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from 'zod';
|
||||
import { Role } from './enums.js';
|
||||
|
||||
export const CreateUserRequest = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
email: z.string().email().max(256),
|
||||
password: z.string().min(6).max(256),
|
||||
role: Role.optional(),
|
||||
});
|
||||
export type CreateUserRequest = z.infer<typeof CreateUserRequest>;
|
||||
|
||||
export const UpdateUserRequest = z
|
||||
.object({
|
||||
username: z.string().min(1).max(64).optional(),
|
||||
email: z.string().email().max(256).optional(),
|
||||
password: z.string().min(6).max(256).optional(),
|
||||
role: Role.optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdateUserRequest = z.infer<typeof UpdateUserRequest>;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CreateWebhookSubscriptionRequest,
|
||||
UpdateWebhookSubscriptionRequest,
|
||||
WebhookSubscriptionListQuery,
|
||||
} from './webhooks.js';
|
||||
|
||||
describe('CreateWebhookSubscriptionRequest', () => {
|
||||
it('accepts a minimal valid payload', () => {
|
||||
const r = CreateWebhookSubscriptionRequest.parse({
|
||||
url: 'https://receiver.example.com/hook',
|
||||
events: ['part.created'],
|
||||
});
|
||||
expect(r.active).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects non-URL endpoints', () => {
|
||||
expect(
|
||||
CreateWebhookSubscriptionRequest.safeParse({
|
||||
url: 'not-a-url',
|
||||
events: ['part.created'],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('requires at least one event', () => {
|
||||
expect(
|
||||
CreateWebhookSubscriptionRequest.safeParse({ url: 'https://a.b/c', events: [] }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unknown event names', () => {
|
||||
expect(
|
||||
CreateWebhookSubscriptionRequest.safeParse({
|
||||
url: 'https://a.b/c',
|
||||
events: ['part.exploded'],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateWebhookSubscriptionRequest', () => {
|
||||
it('requires at least one field', () => {
|
||||
expect(UpdateWebhookSubscriptionRequest.safeParse({}).success).toBe(false);
|
||||
});
|
||||
|
||||
it('allows toggling active alone', () => {
|
||||
expect(UpdateWebhookSubscriptionRequest.safeParse({ active: false }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebhookSubscriptionListQuery', () => {
|
||||
it('normalizes active=\"true\" to boolean', () => {
|
||||
const r = WebhookSubscriptionListQuery.parse({ active: 'true' });
|
||||
expect(r.active).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes active=\"false\" to boolean', () => {
|
||||
const r = WebhookSubscriptionListQuery.parse({ active: 'false' });
|
||||
expect(r.active).toBe(false);
|
||||
});
|
||||
|
||||
it('omits active when unset', () => {
|
||||
const r = WebhookSubscriptionListQuery.parse({});
|
||||
expect(r.active).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod';
|
||||
import { WebhookEventName } from './enums.js';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
export const CreateWebhookSubscriptionRequest = z.object({
|
||||
url: z.string().url().max(2048),
|
||||
events: z.array(WebhookEventName).min(1),
|
||||
active: z.boolean().optional(),
|
||||
});
|
||||
export type CreateWebhookSubscriptionRequest = z.infer<typeof CreateWebhookSubscriptionRequest>;
|
||||
|
||||
export const UpdateWebhookSubscriptionRequest = z
|
||||
.object({
|
||||
url: z.string().url().max(2048).optional(),
|
||||
events: z.array(WebhookEventName).min(1).optional(),
|
||||
active: z.boolean().optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdateWebhookSubscriptionRequest = z.infer<typeof UpdateWebhookSubscriptionRequest>;
|
||||
|
||||
export const WebhookSubscriptionListQuery = PaginationQuery.extend({
|
||||
active: z
|
||||
.union([z.literal('true'), z.literal('false'), z.boolean()])
|
||||
.transform((v) => v === true || v === 'true')
|
||||
.optional(),
|
||||
});
|
||||
export type WebhookSubscriptionListQuery = z.infer<typeof WebhookSubscriptionListQuery>;
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@vector/config/tsconfig/node.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@vector/ui",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./lib/cn": "./src/lib/cn.ts",
|
||||
"./styles/*": "./src/styles/*"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"clean": "rimraf .turbo"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.1.3",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.4",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.4",
|
||||
"lucide-react": "^0.469.0",
|
||||
"sonner": "^1.7.1",
|
||||
"tailwind-merge": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vector/config": "workspace:*",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '../lib/cn.js';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||
brand: 'border-transparent bg-brand text-brand-foreground',
|
||||
destructive: 'border-transparent bg-destructive text-destructive-foreground',
|
||||
success: 'border-transparent bg-success text-success-foreground',
|
||||
warning: 'border-transparent bg-warning text-warning-foreground',
|
||||
outline: 'text-foreground border-border',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default' },
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
export function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
export { badgeVariants };
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '../lib/cn.js';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
brand: 'bg-brand text-brand-foreground hover:bg-brand/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-border bg-transparent hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-brand underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-3.5 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-5',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default', size: 'default' },
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { buttonVariants };
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '../lib/cn.js';
|
||||
|
||||
export const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-lg border border-border bg-card text-card-foreground shadow-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
|
||||
export const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col gap-1 p-5', className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
export const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref as React.Ref<HTMLHeadingElement>}
|
||||
className={cn('text-lg font-semibold leading-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
export const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref as React.Ref<HTMLParagraphElement>}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
export const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-5 pt-0', className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
export const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-5 pt-0', className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { Check, Minus } from 'lucide-react';
|
||||
import { cn } from '../lib/cn.js';
|
||||
|
||||
export const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer size-4 shrink-0 rounded-sm border border-input shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-brand data-[state=checked]:text-brand-foreground data-[state=indeterminate]:bg-brand data-[state=indeterminate]:text-brand-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
|
||||
{props.checked === 'indeterminate' ? <Minus className="h-3 w-3" /> : <Check className="h-3 w-3" />}
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = 'Checkbox';
|
||||
@@ -0,0 +1,111 @@
|
||||
import * as React from 'react';
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import { Search } from 'lucide-react';
|
||||
import { cn } from '../lib/cn.js';
|
||||
import { Dialog, DialogContent } from './dialog.js';
|
||||
|
||||
export const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = 'Command';
|
||||
|
||||
export interface CommandDialogProps extends React.ComponentProps<typeof Dialog> {}
|
||||
export const CommandDialog = ({ children, ...props }: CommandDialogProps) => (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg sm:max-w-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-input-wrapper]_svg]:size-4 [&_[cmdk-input]]:h-11 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-2 [&_[cmdk-item]_svg]:size-4">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
export const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b border-border px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-60" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md bg-transparent py-2 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
CommandInput.displayName = 'CommandInput';
|
||||
|
||||
export const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('max-h-80 overflow-y-auto overflow-x-hidden', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandList.displayName = 'CommandList';
|
||||
|
||||
export const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm text-muted-foreground" {...props} />
|
||||
));
|
||||
CommandEmpty.displayName = 'CommandEmpty';
|
||||
|
||||
export const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandGroup.displayName = 'CommandGroup';
|
||||
|
||||
export const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandItem.displayName = 'CommandItem';
|
||||
|
||||
export const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator ref={ref} className={cn('-mx-1 h-px bg-border', className)} {...props} />
|
||||
));
|
||||
CommandSeparator.displayName = 'CommandSeparator';
|
||||
|
||||
export const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
|
||||
<span className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} {...props} />
|
||||
);
|
||||
@@ -0,0 +1,80 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '../lib/cn.js';
|
||||
|
||||
export const Dialog = DialogPrimitive.Root;
|
||||
export const DialogTrigger = DialogPrimitive.Trigger;
|
||||
export const DialogClose = DialogPrimitive.Close;
|
||||
export const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
export const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-background/70 backdrop-blur-sm data-[state=open]:animate-[fade-in_120ms_ease-out] data-[state=closed]:opacity-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = 'DialogOverlay';
|
||||
|
||||
export const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 rounded-lg border border-border bg-card p-6 shadow-lg data-[state=open]:animate-[slide-up_180ms_ease-out]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-3 top-3 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = 'DialogContent';
|
||||
|
||||
export const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col gap-1.5 text-left', className)} {...props} />
|
||||
);
|
||||
|
||||
export const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />
|
||||
);
|
||||
|
||||
export const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = 'DialogTitle';
|
||||
|
||||
export const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = 'DialogDescription';
|
||||
@@ -0,0 +1,136 @@
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { Check, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '../lib/cn.js';
|
||||
|
||||
export const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
export const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
export const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
export const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
export const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = 'DropdownMenuSubTrigger';
|
||||
|
||||
export const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-32 overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = 'DropdownMenuSubContent';
|
||||
|
||||
export const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 6, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-36 overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-[fade-in_120ms_ease-out]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = 'DropdownMenuContent';
|
||||
|
||||
export const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = 'DropdownMenuItem';
|
||||
|
||||
export const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground',
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName = 'DropdownMenuCheckboxItem';
|
||||
|
||||
export const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-xs font-medium text-muted-foreground', inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = 'DropdownMenuLabel';
|
||||
|
||||
export const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = 'DropdownMenuSeparator';
|
||||
|
||||
export const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => (
|
||||
<span
|
||||
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,134 @@
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from 'react-hook-form';
|
||||
import { cn } from '../lib/cn.js';
|
||||
import { Label } from './label.js';
|
||||
|
||||
export const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = { name: TName };
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
|
||||
|
||||
export const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
|
||||
type FormItemContextValue = { id: string };
|
||||
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
|
||||
|
||||
export const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const id = React.useId();
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn('space-y-1.5', className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
FormItem.displayName = 'FormItem';
|
||||
|
||||
export function useFormField() {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState, formState } = useFormContext();
|
||||
if (!fieldContext) throw new Error('useFormField must be used within <FormField>');
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
const { id } = itemContext;
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
}
|
||||
|
||||
export const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField();
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && 'text-destructive', className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormLabel.displayName = 'FormLabel';
|
||||
|
||||
export const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={error ? `${formDescriptionId} ${formMessageId}` : formDescriptionId}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormControl.displayName = 'FormControl';
|
||||
|
||||
export const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField();
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn('text-xs text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormDescription.displayName = 'FormDescription';
|
||||
|
||||
export const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error.message) : children;
|
||||
if (!body) return null;
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn('text-xs font-medium text-destructive', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
FormMessage.displayName = 'FormMessage';
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '../lib/cn.js';
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cn } from '../lib/cn.js';
|
||||
|
||||
export const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-60',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = 'Label';
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
import { cn } from '../lib/cn.js';
|
||||
|
||||
export const Popover = PopoverPrimitive.Root;
|
||||
export const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
export const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||
|
||||
export const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 6, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-md border border-border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-[fade-in_120ms_ease-out]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = 'PopoverContent';
|
||||
@@ -0,0 +1,85 @@
|
||||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { cn } from '../lib/cn.js';
|
||||
|
||||
export const Select = SelectPrimitive.Root;
|
||||
export const SelectGroup = SelectPrimitive.Group;
|
||||
export const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
export const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-60" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = 'SelectTrigger';
|
||||
|
||||
export const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-[fade-in_120ms_ease-out]',
|
||||
position === 'popper' && 'data-[side=bottom]:translate-y-1',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
<SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport>
|
||||
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = 'SelectContent';
|
||||
|
||||
export const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = 'SelectItem';
|
||||
|
||||
export const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-border', className)} {...props} />
|
||||
));
|
||||
SelectSeparator.displayName = 'SelectSeparator';
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import { cn } from '../lib/cn.js';
|
||||
|
||||
export const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = 'Separator';
|
||||
@@ -0,0 +1,89 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '../lib/cn.js';
|
||||
|
||||
export const Sheet = DialogPrimitive.Root;
|
||||
export const SheetTrigger = DialogPrimitive.Trigger;
|
||||
export const SheetClose = DialogPrimitive.Close;
|
||||
export const SheetPortal = DialogPrimitive.Portal;
|
||||
|
||||
export const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-background/70 backdrop-blur-sm data-[state=open]:animate-[fade-in_120ms_ease-out] data-[state=closed]:opacity-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = 'SheetOverlay';
|
||||
|
||||
const sheetVariants = cva(
|
||||
'fixed z-50 gap-4 bg-card p-6 shadow-lg transition ease-out data-[state=open]:animate-[slide-up_180ms_ease-out]',
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: 'inset-x-0 top-0 border-b border-border',
|
||||
bottom: 'inset-x-0 bottom-0 border-t border-border',
|
||||
left: 'inset-y-0 left-0 h-full w-3/4 border-r border-border sm:max-w-sm',
|
||||
right: 'inset-y-0 right-0 h-full w-3/4 border-l border-border sm:max-w-sm',
|
||||
},
|
||||
},
|
||||
defaultVariants: { side: 'right' },
|
||||
},
|
||||
);
|
||||
|
||||
export interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
export const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = 'right', className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<DialogPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
SheetContent.displayName = 'SheetContent';
|
||||
|
||||
export const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col gap-1 text-left', className)} {...props} />
|
||||
);
|
||||
|
||||
export const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />
|
||||
);
|
||||
|
||||
export const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} />
|
||||
));
|
||||
SheetTitle.displayName = 'SheetTitle';
|
||||
|
||||
export const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetDescription.displayName = 'SheetDescription';
|
||||
@@ -0,0 +1,6 @@
|
||||
import { cn } from '../lib/cn.js';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
export function Skeleton({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('animate-pulse rounded-md bg-muted/50', className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Toaster as Sonner } from 'sonner';
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
export function Toaster(props: ToasterProps) {
|
||||
return (
|
||||
<Sonner
|
||||
theme="dark"
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
'group toast group-[.toaster]:bg-popover group-[.toaster]:text-popover-foreground group-[.toaster]:border group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||
description: 'group-[.toast]:text-muted-foreground',
|
||||
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||
cancelButton: 'group-[.toast]:bg-secondary group-[.toast]:text-secondary-foreground',
|
||||
error: 'group-[.toaster]:border-destructive/40',
|
||||
success: 'group-[.toaster]:border-success/40',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { toast } from 'sonner';
|
||||
@@ -0,0 +1,89 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '../lib/cn.js';
|
||||
|
||||
export const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
);
|
||||
Table.displayName = 'Table';
|
||||
|
||||
export const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b [&_tr]:border-border', className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = 'TableHeader';
|
||||
|
||||
export const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||
));
|
||||
TableBody.displayName = 'TableBody';
|
||||
|
||||
export const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn('border-t border-border bg-muted/40 font-medium [&>tr]:last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = 'TableFooter';
|
||||
|
||||
export const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b border-border transition-colors hover:bg-muted/40 data-[state=selected]:bg-muted/60',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableRow.displayName = 'TableRow';
|
||||
|
||||
export const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-9 px-3 text-left align-middle text-xs font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = 'TableHead';
|
||||
|
||||
export const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn('p-3 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = 'TableCell';
|
||||
|
||||
export const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption ref={ref} className={cn('mt-3 text-sm text-muted-foreground', className)} {...props} />
|
||||
));
|
||||
TableCaption.displayName = 'TableCaption';
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import { cn } from '../lib/cn.js';
|
||||
|
||||
export const Tabs = TabsPrimitive.Root;
|
||||
|
||||
export const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-9 items-center justify-center rounded-md bg-muted/50 p-1 text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = 'TabsList';
|
||||
|
||||
export const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = 'TabsTrigger';
|
||||
|
||||
export const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn('mt-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = 'TabsContent';
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '../lib/cn.js';
|
||||
|
||||
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
|
||||
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex min-h-20 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Textarea.displayName = 'Textarea';
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import { cn } from '../lib/cn.js';
|
||||
|
||||
export const TooltipProvider = TooltipPrimitive.Provider;
|
||||
export const Tooltip = TooltipPrimitive.Root;
|
||||
export const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
export const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md border border-border bg-popover px-2 py-1 text-xs text-popover-foreground shadow-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TooltipContent.displayName = 'TooltipContent';
|
||||
@@ -0,0 +1,21 @@
|
||||
export { cn } from './lib/cn.js';
|
||||
export * from './components/button.js';
|
||||
export * from './components/input.js';
|
||||
export * from './components/textarea.js';
|
||||
export * from './components/label.js';
|
||||
export * from './components/card.js';
|
||||
export * from './components/table.js';
|
||||
export * from './components/dialog.js';
|
||||
export * from './components/dropdown-menu.js';
|
||||
export * from './components/popover.js';
|
||||
export * from './components/select.js';
|
||||
export * from './components/separator.js';
|
||||
export * from './components/skeleton.js';
|
||||
export * from './components/tabs.js';
|
||||
export * from './components/badge.js';
|
||||
export * from './components/tooltip.js';
|
||||
export * from './components/checkbox.js';
|
||||
export * from './components/command.js';
|
||||
export * from './components/sheet.js';
|
||||
export * from './components/form.js';
|
||||
export * from './components/sonner.js';
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "@vector/config/tsconfig/react.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user