chore: initial Vector 2.0 monorepo
CI / Lint · Typecheck · Test · Build (push) Failing after 5m41s
CI / Playwright (smoke) (push) Has been skipped

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:
2026-04-16 20:52:32 -04:00
commit 7c0d422228
216 changed files with 19393 additions and 0 deletions
+12
View File
@@ -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"
}
}
+117
View File
@@ -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;
}
}
+21
View File
@@ -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
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"types": ["node"]
}
}
+18
View File
@@ -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
}
}
+78
View File
@@ -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.
+38
View File
@@ -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"
+242
View File
@@ -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])
}
+27
View File
@@ -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());
+33
View File
@@ -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;
}
+11
View File
@@ -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';
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@vector/config/tsconfig/node.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules", "prisma"]
}
+30
View File
@@ -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"
}
}
+35
View File
@@ -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;
}
+47
View File
@@ -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);
});
});
+17
View File
@@ -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>;
+19
View File
@@ -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>;
+28
View File
@@ -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>;
+46
View File
@@ -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>;
+17
View File
@@ -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>;
+23
View File
@@ -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>;
+16
View File
@@ -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';
+35
View File
@@ -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>;
+19
View File
@@ -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>;
+17
View File
@@ -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;
}
+113
View File
@@ -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);
});
});
+70
View File
@@ -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>;
+33
View File
@@ -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>;
+41
View File
@@ -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>;
+30
View File
@@ -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>;
+20
View File
@@ -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>;
+67
View File
@@ -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();
});
});
+27
View File
@@ -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>;
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@vector/config/tsconfig/node.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
environment: 'node',
},
});
+49
View File
@@ -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"
}
}
+30
View File
@@ -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 };
+47
View File
@@ -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 };
+58
View File
@@ -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';
+23
View File
@@ -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';
+111
View File
@@ -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} />
);
+80
View File
@@ -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}
/>
);
+134
View File
@@ -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';
+19
View File
@@ -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';
+18
View File
@@ -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';
+26
View File
@@ -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';
+85
View File
@@ -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';
+21
View File
@@ -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';
+89
View File
@@ -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';
+6
View File
@@ -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} />;
}
+26
View File
@@ -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';
+89
View File
@@ -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';
+47
View File
@@ -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';
+18
View File
@@ -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';
+23
View File
@@ -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';
+21
View File
@@ -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';
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+13
View File
@@ -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"]
}