feat: ST-001–ST-005 foundation — scaffolding, Prisma, schema, seed, env
- ST-001: Next.js 16 + React 19 + TypeScript strict + Tailwind + shadcn/ui + all deps - ST-002: Prisma v7 setup with better-sqlite3 adapter (local) and pg adapter (cloud) - ST-003: Full schema migration (users, pbis, stories, sprints, tasks, todos, api_tokens) - ST-004: Seed with 9 PBIs, ~40 stories, demo user (demo/demo1234), lars user - ST-005: Zod-validated env vars, .env.example, lib/session, lib/auth, lib/api-auth Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4cf5833c1d
commit
7f94bb6359
32 changed files with 8653 additions and 183 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -39,3 +39,11 @@ yarn-error.log*
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
scrum4me*.md
|
||||||
|
|
||||||
|
# SQLite local database
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
589
CLAUDE.md
589
CLAUDE.md
|
|
@ -1 +1,588 @@
|
||||||
@AGENTS.md
|
# CLAUDE.md — Scrum4Me
|
||||||
|
|
||||||
|
Dit is het centrale instructiedocument voor Claude Code. Lees dit volledig voordat je iets bouwt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wat is Scrum4Me?
|
||||||
|
|
||||||
|
Een desktop-first fullstack webapplicatie voor solo developers en kleine Scrum Teams die meerdere softwareprojecten parallel beheren. De app organiseert werk hiërarchisch (product → PBI → story → taak), biedt gesplitste planningsschermen met drag-and-drop, en integreert met Claude Code via een REST API zodat implementatieplannen, testresultaten en commits automatisch vastgelegd worden in stories.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Specificatiedocumenten
|
||||||
|
|
||||||
|
Lees het relevante document voordat je aan een feature begint. Nooit gokken over requirements.
|
||||||
|
|
||||||
|
| Document | Gebruik voor |
|
||||||
|
|---|---|
|
||||||
|
| `scrum4me-functional-spec.md` | Acceptatiecriteria, randgevallen, user flows per feature |
|
||||||
|
| `scrum4me-architecture.md` | Stack, datamodel, Prisma schema, Zustand stores, projectstructuur |
|
||||||
|
| `scrum4me-backlog.md` | Welke task bouwen, in welke volgorde, "done when"-criteria |
|
||||||
|
| `scrum4me-personas.md` | Lars (primaire gebruiker), Dina, Remi — gebruik bij UI-beslissingen |
|
||||||
|
| `scrum4me-product-backlog.md` | Testdata voor de seed — PBI's en stories van Scrum4Me zelf |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Waar te beginnen
|
||||||
|
|
||||||
|
Volg de backlog strikt op volgorde. Start bij **ST-001**. Sla geen milestone over.
|
||||||
|
|
||||||
|
```
|
||||||
|
M0 (ST-001–008) → M1 (ST-101–110) → M2 (ST-201–210)
|
||||||
|
→ M3 (ST-301–312) → M4 (ST-401–410) → M5 (ST-501–506)
|
||||||
|
→ M6 (ST-601–612)
|
||||||
|
```
|
||||||
|
|
||||||
|
Per task:
|
||||||
|
1. Lees de task in `scrum4me-backlog.md`
|
||||||
|
2. Zoek de bijbehorende feature-spec op in `scrum4me-functional-spec.md`
|
||||||
|
3. Bouw — test — verifieer de "Done when"-criteria
|
||||||
|
4. Commit met de task-ID in het commit-bericht: `feat: ST-001 project scaffolding`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech stack (samenvatting)
|
||||||
|
|
||||||
|
```
|
||||||
|
Next.js 15 (App Router) + React 19
|
||||||
|
TypeScript strict
|
||||||
|
Tailwind CSS + shadcn/ui
|
||||||
|
Zustand (client state)
|
||||||
|
dnd-kit (drag-and-drop)
|
||||||
|
Prisma v7 (ORM)
|
||||||
|
PostgreSQL via Neon (cloud) | SQLite (lokaal)
|
||||||
|
iron-session (auth cookies)
|
||||||
|
bcrypt (wachtwoord hashing)
|
||||||
|
Zod (validatie)
|
||||||
|
Sonner (toasts)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exacte dependencies (package.json)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"next": "^15.0.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"zustand": "^5.0.0",
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.0",
|
||||||
|
"prisma": "^7.0.0",
|
||||||
|
"@prisma/client": "^7.0.0",
|
||||||
|
"@prisma/adapter-pg": "^7.0.0",
|
||||||
|
"iron-session": "^8.0.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"zod": "^3.22.0",
|
||||||
|
"sonner": "^1.5.0",
|
||||||
|
"pg": "^8.11.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/pg": "^8.11.0",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"eslint": "^8.0.0",
|
||||||
|
"eslint-config-next": "^15.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## shadcn/ui componenten om te installeren
|
||||||
|
|
||||||
|
Voer deze uit na `npx shadcn@latest init`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add button
|
||||||
|
npx shadcn@latest add input
|
||||||
|
npx shadcn@latest add textarea
|
||||||
|
npx shadcn@latest add dialog
|
||||||
|
npx shadcn@latest add dropdown-menu
|
||||||
|
npx shadcn@latest add badge
|
||||||
|
npx shadcn@latest add tooltip
|
||||||
|
npx shadcn@latest add separator
|
||||||
|
npx shadcn@latest add sheet # voor story slide-over
|
||||||
|
npx shadcn@latest add select
|
||||||
|
npx shadcn@latest add alert-dialog # voor bevestigingsdialogen
|
||||||
|
npx shadcn@latest add skeleton
|
||||||
|
npx shadcn@latest add toast
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Projectstructuur
|
||||||
|
|
||||||
|
```
|
||||||
|
scrum4me/
|
||||||
|
├── app/
|
||||||
|
│ ├── (auth)/
|
||||||
|
│ │ ├── login/page.tsx
|
||||||
|
│ │ └── register/page.tsx
|
||||||
|
│ ├── (app)/
|
||||||
|
│ │ ├── layout.tsx # Auth-check + navigatie
|
||||||
|
│ │ ├── dashboard/page.tsx
|
||||||
|
│ │ ├── products/
|
||||||
|
│ │ │ ├── new/page.tsx
|
||||||
|
│ │ │ └── [id]/
|
||||||
|
│ │ │ ├── page.tsx # Product Backlog
|
||||||
|
│ │ │ └── sprint/
|
||||||
|
│ │ │ ├── page.tsx # Sprint Backlog
|
||||||
|
│ │ │ └── planning/page.tsx
|
||||||
|
│ │ ├── todos/page.tsx
|
||||||
|
│ │ └── settings/
|
||||||
|
│ │ ├── page.tsx
|
||||||
|
│ │ └── tokens/page.tsx
|
||||||
|
│ └── api/
|
||||||
|
│ ├── products/[id]/next-story/route.ts
|
||||||
|
│ ├── sprints/[id]/tasks/route.ts
|
||||||
|
│ ├── stories/[id]/
|
||||||
|
│ │ ├── log/route.ts
|
||||||
|
│ │ └── tasks/reorder/route.ts
|
||||||
|
│ ├── tasks/[id]/route.ts
|
||||||
|
│ └── todos/route.ts
|
||||||
|
├── components/
|
||||||
|
│ ├── ui/ # shadcn/ui (auto-gegenereerd)
|
||||||
|
│ ├── split-pane/
|
||||||
|
│ │ └── split-pane.tsx
|
||||||
|
│ ├── backlog/
|
||||||
|
│ │ ├── pbi-list.tsx
|
||||||
|
│ │ ├── pbi-item.tsx
|
||||||
|
│ │ ├── story-grid.tsx
|
||||||
|
│ │ └── story-block.tsx
|
||||||
|
│ ├── sprint/
|
||||||
|
│ │ ├── sprint-backlog.tsx
|
||||||
|
│ │ └── sprint-story-item.tsx
|
||||||
|
│ ├── planning/
|
||||||
|
│ │ ├── task-list.tsx
|
||||||
|
│ │ └── task-item.tsx
|
||||||
|
│ └── shared/
|
||||||
|
│ ├── panel-nav-bar.tsx
|
||||||
|
│ ├── confirm-dialog.tsx
|
||||||
|
│ └── story-log.tsx
|
||||||
|
├── stores/
|
||||||
|
│ ├── planner-store.ts
|
||||||
|
│ ├── selection-store.ts
|
||||||
|
│ └── sprint-store.ts
|
||||||
|
├── lib/
|
||||||
|
│ ├── prisma.ts
|
||||||
|
│ ├── session.ts
|
||||||
|
│ ├── auth.ts
|
||||||
|
│ ├── api-auth.ts
|
||||||
|
│ └── env.ts
|
||||||
|
├── actions/
|
||||||
|
│ ├── products.ts
|
||||||
|
│ ├── pbis.ts
|
||||||
|
│ ├── stories.ts
|
||||||
|
│ ├── sprints.ts
|
||||||
|
│ ├── tasks.ts
|
||||||
|
│ └── todos.ts
|
||||||
|
├── prisma/
|
||||||
|
│ ├── schema.prisma
|
||||||
|
│ ├── migrations/
|
||||||
|
│ └── seed.ts
|
||||||
|
├── middleware.ts
|
||||||
|
├── prisma.config.ts
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kritieke implementatiepatronen
|
||||||
|
|
||||||
|
### 1. iron-session configuratie
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// lib/session.ts
|
||||||
|
import { SessionOptions } from 'iron-session'
|
||||||
|
|
||||||
|
export interface SessionData {
|
||||||
|
userId: string
|
||||||
|
isDemo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionOptions: SessionOptions = {
|
||||||
|
password: process.env.SESSION_SECRET!,
|
||||||
|
cookieName: 'scrum4me-session',
|
||||||
|
cookieOptions: {
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Gebruik in Server Action of Route Handler:
|
||||||
|
import { getIronSession } from 'iron-session'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { SessionData, sessionOptions } from '@/lib/session'
|
||||||
|
|
||||||
|
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||||
|
if (!session.userId) redirect('/login')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Prisma Client singleton
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// lib/prisma.ts
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined }
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. prisma.config.ts (Prisma v7 vereiste)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// prisma.config.ts
|
||||||
|
import 'dotenv/config'
|
||||||
|
import { defineConfig } from 'prisma/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: 'prisma/schema.prisma',
|
||||||
|
migrations: { path: 'prisma/migrations' },
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. API Bearer token authenticatie
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// lib/api-auth.ts
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function authenticateApiRequest(request: Request) {
|
||||||
|
const authHeader = request.headers.get('Authorization')
|
||||||
|
if (!authHeader?.startsWith('Bearer ')) {
|
||||||
|
return { error: 'Unauthorized', status: 401 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.slice(7)
|
||||||
|
const tokenHash = createHash('sha256').update(token).digest('hex')
|
||||||
|
|
||||||
|
const apiToken = await prisma.apiToken.findUnique({
|
||||||
|
where: { token_hash: tokenHash },
|
||||||
|
include: { user: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!apiToken || apiToken.revoked_at) {
|
||||||
|
return { error: 'Unauthorized', status: 401 }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { userId: apiToken.user_id, isDemo: apiToken.user.is_demo }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gebruik in Route Handler:
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const auth = await authenticateApiRequest(request)
|
||||||
|
if ('error' in auth) {
|
||||||
|
return Response.json({ error: auth.error }, { status: auth.status })
|
||||||
|
}
|
||||||
|
// auth.userId beschikbaar
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Float sort_order — drag-and-drop volgorde
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Bereken nieuwe sort_order bij tussenvoeging:
|
||||||
|
function getSortOrder(before: number | null, after: number | null): number {
|
||||||
|
if (before === null && after === null) return 1.0
|
||||||
|
if (before === null) return after! / 2
|
||||||
|
if (after === null) return before + 1.0
|
||||||
|
return (before + after) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Herindexeer als precisie opraakt (< 0.001 verschil):
|
||||||
|
async function reindexIfNeeded(items: { id: string; sort_order: number }[]) {
|
||||||
|
const minGap = Math.min(...items.slice(1).map((item, i) =>
|
||||||
|
item.sort_order - items[i].sort_order
|
||||||
|
))
|
||||||
|
if (minGap < 0.001) {
|
||||||
|
// Herindexeer: 1.0, 2.0, 3.0, ...
|
||||||
|
await Promise.all(items.map((item, i) =>
|
||||||
|
prisma.pbi.update({ where: { id: item.id }, data: { sort_order: i + 1.0 } })
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Zustand store patroon (optimistische update + rollback)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Gebruik in dnd-kit onDragEnd:
|
||||||
|
const { pbiOrder, reorderPbis, rollbackPbis } = usePlannerStore()
|
||||||
|
|
||||||
|
async function handleDragEnd(event: DragEndEvent) {
|
||||||
|
const { active, over } = event
|
||||||
|
if (!over || active.id === over.id) return
|
||||||
|
|
||||||
|
const prevOrder = [...pbiOrder[productId]]
|
||||||
|
const newOrder = arrayMove(prevOrder, oldIndex, newIndex)
|
||||||
|
|
||||||
|
// 1. Optimistisch updaten
|
||||||
|
reorderPbis(productId, newOrder)
|
||||||
|
|
||||||
|
// 2. Server Action aanroepen
|
||||||
|
const result = await reorderPbisAction(productId, newOrder)
|
||||||
|
|
||||||
|
// 3. Rollback bij fout
|
||||||
|
if (!result.success) {
|
||||||
|
rollbackPbis(productId, prevOrder)
|
||||||
|
toast.error('Volgorde opslaan mislukt')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Server Action patroon
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// actions/pbis.ts
|
||||||
|
'use server'
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { getIronSession } from 'iron-session'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { SessionData, sessionOptions } from '@/lib/session'
|
||||||
|
|
||||||
|
const createPbiSchema = z.object({
|
||||||
|
productId: z.string().cuid(),
|
||||||
|
title: z.string().min(1).max(200),
|
||||||
|
priority: z.number().int().min(1).max(4),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function createPbi(formData: FormData) {
|
||||||
|
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||||
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||||
|
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||||
|
|
||||||
|
const parsed = createPbiSchema.safeParse({
|
||||||
|
productId: formData.get('productId'),
|
||||||
|
title: formData.get('title'),
|
||||||
|
priority: Number(formData.get('priority')),
|
||||||
|
})
|
||||||
|
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors }
|
||||||
|
|
||||||
|
// Valideer eigenaarschap
|
||||||
|
const product = await prisma.product.findFirst({
|
||||||
|
where: { id: parsed.data.productId, user_id: session.userId }
|
||||||
|
})
|
||||||
|
if (!product) return { error: 'Product niet gevonden' }
|
||||||
|
|
||||||
|
// Bepaal sort_order (onderaan de prioriteitsgroep)
|
||||||
|
const last = await prisma.pbi.findFirst({
|
||||||
|
where: { product_id: parsed.data.productId, priority: parsed.data.priority },
|
||||||
|
orderBy: { sort_order: 'desc' },
|
||||||
|
})
|
||||||
|
const sort_order = (last?.sort_order ?? 0) + 1.0
|
||||||
|
|
||||||
|
const pbi = await prisma.pbi.create({
|
||||||
|
data: { ...parsed.data, product_id: parsed.data.productId, sort_order },
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath(`/products/${parsed.data.productId}`)
|
||||||
|
return { success: true, pbi }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Route Handler patroon (REST API)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/api/products/[id]/next-story/route.ts
|
||||||
|
import { authenticateApiRequest } from '@/lib/api-auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const auth = await authenticateApiRequest(request)
|
||||||
|
if ('error' in auth) {
|
||||||
|
return Response.json({ error: auth.error }, { status: auth.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
// Valideer eigenaarschap
|
||||||
|
const sprint = await prisma.sprint.findFirst({
|
||||||
|
where: { product_id: id, status: 'ACTIVE', product: { user_id: auth.userId } },
|
||||||
|
})
|
||||||
|
if (!sprint) {
|
||||||
|
return Response.json({ error: 'Geen actieve Sprint gevonden' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const story = await prisma.story.findFirst({
|
||||||
|
where: { sprint_id: sprint.id, status: 'IN_SPRINT' },
|
||||||
|
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||||
|
include: { tasks: { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }] } },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!story) {
|
||||||
|
return Response.json({ error: 'Geen open stories in de Sprint' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(story)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Middleware patroon
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// middleware.ts
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import type { NextRequest } from 'next/server'
|
||||||
|
import { getIronSession } from 'iron-session'
|
||||||
|
import { SessionData, sessionOptions } from '@/lib/session'
|
||||||
|
|
||||||
|
const protectedRoutes = ['/dashboard', '/products', '/todos', '/settings']
|
||||||
|
const authRoutes = ['/login', '/register']
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
const response = NextResponse.next()
|
||||||
|
const session = await getIronSession<SessionData>(request.cookies, sessionOptions)
|
||||||
|
|
||||||
|
const isProtected = protectedRoutes.some(r => request.nextUrl.pathname.startsWith(r))
|
||||||
|
const isAuthRoute = authRoutes.some(r => request.nextUrl.pathname.startsWith(r))
|
||||||
|
|
||||||
|
if (isProtected && !session.userId) {
|
||||||
|
return NextResponse.redirect(new URL('/login', request.url))
|
||||||
|
}
|
||||||
|
if (isAuthRoute && session.userId) {
|
||||||
|
return NextResponse.redirect(new URL('/dashboard', request.url))
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scrum-terminologie (gebruik consistent)
|
||||||
|
|
||||||
|
| Correct | Niet gebruiken |
|
||||||
|
|---|---|
|
||||||
|
| Product Backlog Item (PBI) | Feature, Epic, Issue |
|
||||||
|
| Story | User Story, Ticket |
|
||||||
|
| Sprint Goal | Sprint Objective |
|
||||||
|
| Sprint Planning | Sprint Meeting |
|
||||||
|
| Scrum Team | Team |
|
||||||
|
| Definition of Done | DoD criteria |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Env vars
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env.local
|
||||||
|
DATABASE_URL="postgresql://user:password@host/dbname?sslmode=require"
|
||||||
|
DIRECT_URL="postgresql://user:password@host/dbname?sslmode=require"
|
||||||
|
SESSION_SECRET="genereer-met-openssl-rand-base64-32"
|
||||||
|
|
||||||
|
# Lokaal (SQLite):
|
||||||
|
# DATABASE_URL="file:./dev.db"
|
||||||
|
# DIRECT_URL niet nodig bij SQLite
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lokale setup (quickstart)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo>
|
||||||
|
cd scrum4me
|
||||||
|
npm install
|
||||||
|
cp .env.example .env.local
|
||||||
|
# Vul SESSION_SECRET in .env.local
|
||||||
|
|
||||||
|
# SQLite lokaal:
|
||||||
|
npx prisma db push
|
||||||
|
npx prisma db seed
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Demo-gebruiker credentials
|
||||||
|
|
||||||
|
Na seeding:
|
||||||
|
- **Gebruikersnaam:** `demo`
|
||||||
|
- **Wachtwoord:** `demo1234`
|
||||||
|
|
||||||
|
De demo-gebruiker heeft read-only rechten. Alle schrijfacties geven een 403 of zijn uitgeschakeld in de UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## REST API — alle endpoints
|
||||||
|
|
||||||
|
| Methode | Endpoint | Doel |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/products` | Actieve producten ophalen |
|
||||||
|
| GET | `/api/products/:id/next-story` | Hoogst geprioriteerde open story |
|
||||||
|
| GET | `/api/sprints/:id/tasks?limit=10` | Eerste N taken van de Sprint |
|
||||||
|
| PATCH | `/api/stories/:id/tasks/reorder` | Taakvolgorde aanpassen |
|
||||||
|
| POST | `/api/stories/:id/log` | Plan / testresultaat / commit vastleggen |
|
||||||
|
| PATCH | `/api/tasks/:id` | Taakstatus bijwerken |
|
||||||
|
| POST | `/api/todos` | Todo aanmaken |
|
||||||
|
|
||||||
|
Alle endpoints vereisen: `Authorization: Bearer <token>`
|
||||||
|
|
||||||
|
### POST /api/stories/:id/log — body schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Implementatieplan:
|
||||||
|
{ "type": "IMPLEMENTATION_PLAN", "content": "string" }
|
||||||
|
|
||||||
|
// Testresultaat:
|
||||||
|
{ "type": "TEST_RESULT", "content": "string", "status": "PASSED" | "FAILED" }
|
||||||
|
|
||||||
|
// Commit:
|
||||||
|
{ "type": "COMMIT", "content": "string", "commit_hash": "string", "commit_message": "string" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventies
|
||||||
|
|
||||||
|
- **Commit-berichten:** `feat: ST-XXX beschrijving` / `fix: ST-XXX beschrijving`
|
||||||
|
- **Branch-namen:** `feat/ST-001-scaffolding`
|
||||||
|
- **Server Actions:** altijd in `actions/[domein].ts`, nooit inline in page.tsx
|
||||||
|
- **Validatie:** altijd Zod, nooit handmatige checks
|
||||||
|
- **Eigenaarschap:** elke Server Action en Route Handler controleert dat de resource bij de geverifieerde gebruiker hoort
|
||||||
|
- **Demo-check:** elke Server Action controleert `session.isDemo` vóór schrijven
|
||||||
|
- **Foutberichten:** altijd in het Nederlands voor eindgebruikers
|
||||||
|
- **Comments in code:** Engels
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definitie of Done (project)
|
||||||
|
|
||||||
|
De MVP is klaar wanneer:
|
||||||
|
- [ ] Alle 62 tasks (ST-001 t/m ST-612) zijn afgerond
|
||||||
|
- [ ] Volledige Lars-flow doorlopen zonder fouten (ST-612)
|
||||||
|
- [ ] Alle 7 API-endpoints werken via curl
|
||||||
|
- [ ] Demo-gebruiker heeft geen schrijfrechten
|
||||||
|
- [ ] App lokaal opzetbaar via README zonder extra hulp
|
||||||
|
- [ ] CI/CD actief — falende build blokkeert merge
|
||||||
|
- [ ] Beveiligingsreview API geslaagd (cross-user toegang onmogelijk)
|
||||||
|
|
|
||||||
68
actions/auth.ts
Normal file
68
actions/auth.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
'use server'
|
||||||
|
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { getIronSession } from 'iron-session'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { registerUser, verifyUser } from '@/lib/auth'
|
||||||
|
import { SessionData, sessionOptions } from '@/lib/session'
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
username: z.string().min(3, 'Gebruikersnaam moet minimaal 3 tekens bevatten').max(50),
|
||||||
|
password: z.string().min(8, 'Wachtwoord moet minimaal 8 tekens bevatten'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
password: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function registerAction(formData: FormData) {
|
||||||
|
const parsed = registerSchema.safeParse({
|
||||||
|
username: formData.get('username'),
|
||||||
|
password: formData.get('password'),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { error: parsed.error.flatten().fieldErrors }
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await registerUser(parsed.data.username, parsed.data.password)
|
||||||
|
if (result.error) return { error: result.error }
|
||||||
|
|
||||||
|
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||||
|
session.userId = result.user!.id
|
||||||
|
session.isDemo = false
|
||||||
|
await session.save()
|
||||||
|
|
||||||
|
redirect('/dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginAction(formData: FormData) {
|
||||||
|
const parsed = loginSchema.safeParse({
|
||||||
|
username: formData.get('username'),
|
||||||
|
password: formData.get('password'),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { error: 'Ongeldige inloggegevens' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await verifyUser(parsed.data.username, parsed.data.password)
|
||||||
|
if (!user) {
|
||||||
|
return { error: 'Onjuiste gebruikersnaam of wachtwoord' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||||
|
session.userId = user.id
|
||||||
|
session.isDemo = user.is_demo
|
||||||
|
await session.save()
|
||||||
|
|
||||||
|
redirect('/dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logoutAction() {
|
||||||
|
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||||
|
session.destroy()
|
||||||
|
redirect('/login')
|
||||||
|
}
|
||||||
134
app/globals.css
134
app/globals.css
|
|
@ -1,26 +1,130 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
:root {
|
@custom-variant dark (&:is(.dark *));
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--font-heading: var(--font-sans);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
:root {
|
||||||
:root {
|
--background: oklch(1 0 0);
|
||||||
--background: #0a0a0a;
|
--foreground: oklch(0.145 0 0);
|
||||||
--foreground: #ededed;
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
66
app/page.tsx
66
app/page.tsx
|
|
@ -1,65 +1,13 @@
|
||||||
import Image from "next/image";
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
<div className="text-center space-y-4">
|
||||||
<Image
|
<h1 className="text-2xl font-bold">Scrum4Me</h1>
|
||||||
className="dark:invert"
|
<p className="text-muted-foreground">Scaffolding complete — shadcn/ui Button works.</p>
|
||||||
src="/next.svg"
|
<Button>Get Started</Button>
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
)
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
components.json
Normal file
25
components.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "base-nova",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
187
components/ui/alert-dialog.tsx
Normal file
187
components/ui/alert-dialog.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Backdrop
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Popup.Props & {
|
||||||
|
size?: "default" | "sm"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Popup
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn(
|
||||||
|
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogMedia({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-media"
|
||||||
|
className={cn(
|
||||||
|
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="alert-dialog-action"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Close.Props &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Close
|
||||||
|
data-slot="alert-dialog-cancel"
|
||||||
|
className={cn(className)}
|
||||||
|
render={<Button variant={variant} size={size} />}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
}
|
||||||
52
components/ui/badge.tsx
Normal file
52
components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "span",
|
||||||
|
props: mergeProps<"span">(
|
||||||
|
{
|
||||||
|
className: cn(badgeVariants({ variant }), className),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "badge",
|
||||||
|
variant,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
58
components/ui/button.tsx
Normal file
58
components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
160
components/ui/dialog.tsx
Normal file
160
components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Backdrop
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Popup.Props & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Popup
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Popup>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||||
|
Close
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-none font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
268
components/ui/dropdown-menu.tsx
Normal file
268
components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||||
|
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||||
|
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||||
|
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
MenuPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Portal>
|
||||||
|
<MenuPrimitive.Positioner
|
||||||
|
className="isolate z-50 outline-none"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
>
|
||||||
|
<MenuPrimitive.Popup
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.Positioner>
|
||||||
|
</MenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||||
|
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.GroupLabel.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.GroupLabel
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Item.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||||
|
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.SubmenuTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</MenuPrimitive.SubmenuTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = -3,
|
||||||
|
side = "right",
|
||||||
|
sideOffset = 0,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.CheckboxItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.RadioItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-radio-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.RadioItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.RadioItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
20
components/ui/input.tsx
Normal file
20
components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<InputPrimitive
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
201
components/ui/select.tsx
Normal file
201
components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Group
|
||||||
|
data-slot="select-group"
|
||||||
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Value
|
||||||
|
data-slot="select-value"
|
||||||
|
className={cn("flex flex-1 text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Trigger.Props & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon
|
||||||
|
render={
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
alignItemWithTrigger = true,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
SelectPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Positioner
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
alignItemWithTrigger={alignItemWithTrigger}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Popup
|
||||||
|
data-slot="select-content"
|
||||||
|
data-align-trigger={alignItemWithTrigger}
|
||||||
|
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Popup>
|
||||||
|
</SelectPrimitive.Positioner>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.GroupLabel.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.GroupLabel
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.ItemText>
|
||||||
|
<SelectPrimitive.ItemIndicator
|
||||||
|
render={
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpArrow
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollUpArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownArrow
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollDownArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
25
components/ui/separator.tsx
Normal file
25
components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: SeparatorPrimitive.Props) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive
|
||||||
|
data-slot="separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
138
components/ui/sheet.tsx
Normal file
138
components/ui/sheet.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Backdrop
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: SheetPrimitive.Popup.Props & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Popup
|
||||||
|
data-slot="sheet-content"
|
||||||
|
data-side={side}
|
||||||
|
className={cn(
|
||||||
|
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close
|
||||||
|
data-slot="sheet-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-3 right-3"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Popup>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base font-medium text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SheetPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
13
components/ui/skeleton.tsx
Normal file
13
components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
49
components/ui/sonner.tsx
Normal file
49
components/ui/sonner.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||||
|
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
icons={{
|
||||||
|
success: (
|
||||||
|
<CircleCheckIcon className="size-4" />
|
||||||
|
),
|
||||||
|
info: (
|
||||||
|
<InfoIcon className="size-4" />
|
||||||
|
),
|
||||||
|
warning: (
|
||||||
|
<TriangleAlertIcon className="size-4" />
|
||||||
|
),
|
||||||
|
error: (
|
||||||
|
<OctagonXIcon className="size-4" />
|
||||||
|
),
|
||||||
|
loading: (
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "var(--popover)",
|
||||||
|
"--normal-text": "var(--popover-foreground)",
|
||||||
|
"--normal-border": "var(--border)",
|
||||||
|
"--border-radius": "var(--radius)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast: "cn-toast",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
18
components/ui/textarea.tsx
Normal file
18
components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
66
components/ui/tooltip.tsx
Normal file
66
components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delay = 0,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Provider.Props) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delay={delay}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
side = "top",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
TooltipPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Positioner
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<TooltipPrimitive.Popup
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
className={cn(
|
||||||
|
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||||
|
</TooltipPrimitive.Popup>
|
||||||
|
</TooltipPrimitive.Positioner>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
23
lib/api-auth.ts
Normal file
23
lib/api-auth.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function authenticateApiRequest(request: Request) {
|
||||||
|
const authHeader = request.headers.get('Authorization')
|
||||||
|
if (!authHeader?.startsWith('Bearer ')) {
|
||||||
|
return { error: 'Unauthorized', status: 401 as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.slice(7)
|
||||||
|
const tokenHash = createHash('sha256').update(token).digest('hex')
|
||||||
|
|
||||||
|
const apiToken = await prisma.apiToken.findUnique({
|
||||||
|
where: { token_hash: tokenHash },
|
||||||
|
include: { user: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!apiToken || apiToken.revoked_at) {
|
||||||
|
return { error: 'Unauthorized', status: 401 as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { userId: apiToken.user_id, isDemo: apiToken.user.is_demo }
|
||||||
|
}
|
||||||
34
lib/auth.ts
Normal file
34
lib/auth.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function registerUser(username: string, password: string) {
|
||||||
|
const existing = await prisma.user.findUnique({ where: { username } })
|
||||||
|
if (existing) {
|
||||||
|
return { error: 'Gebruikersnaam is al in gebruik' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
return { error: 'Wachtwoord moet minimaal 8 tekens bevatten' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const password_hash = await bcrypt.hash(password, 12)
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
password_hash,
|
||||||
|
roles: { create: [{ role: 'DEVELOPER' }] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { user }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyUser(username: string, password: string) {
|
||||||
|
const user = await prisma.user.findUnique({ where: { username } })
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(password, user.password_hash)
|
||||||
|
if (!valid) return null
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
18
lib/env.ts
Normal file
18
lib/env.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
|
||||||
|
DIRECT_URL: z.string().optional(),
|
||||||
|
SESSION_SECRET: z.string().min(32, 'SESSION_SECRET must be at least 32 characters'),
|
||||||
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const parsed = envSchema.safeParse(process.env)
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.error('❌ Invalid environment variables:')
|
||||||
|
console.error(parsed.error.flatten().fieldErrors)
|
||||||
|
throw new Error('Invalid environment variables. Check .env.local.')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env = parsed.data
|
||||||
32
lib/prisma.ts
Normal file
32
lib/prisma.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
function createPrismaClient() {
|
||||||
|
const url = process.env.DATABASE_URL
|
||||||
|
if (!url) throw new Error('DATABASE_URL is not set')
|
||||||
|
|
||||||
|
if (url.startsWith('file:')) {
|
||||||
|
// SQLite (local development) — use better-sqlite3 adapter
|
||||||
|
const { PrismaBetterSqlite3 } = require('@prisma/adapter-better-sqlite3')
|
||||||
|
const adapter = new PrismaBetterSqlite3({ url })
|
||||||
|
return new PrismaClient({
|
||||||
|
adapter,
|
||||||
|
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostgreSQL (production) — use pg adapter
|
||||||
|
const { Pool } = require('pg')
|
||||||
|
const { PrismaPg } = require('@prisma/adapter-pg')
|
||||||
|
const pool = new Pool({ connectionString: url })
|
||||||
|
const adapter = new PrismaPg(pool)
|
||||||
|
return new PrismaClient({
|
||||||
|
adapter,
|
||||||
|
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined }
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? createPrismaClient()
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||||
16
lib/session.ts
Normal file
16
lib/session.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { SessionOptions } from 'iron-session'
|
||||||
|
|
||||||
|
export interface SessionData {
|
||||||
|
userId: string
|
||||||
|
isDemo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionOptions: SessionOptions = {
|
||||||
|
password: process.env.SESSION_SECRET!,
|
||||||
|
cookieName: 'scrum4me-session',
|
||||||
|
cookieOptions: {
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
},
|
||||||
|
}
|
||||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal 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))
|
||||||
|
}
|
||||||
5892
package-lock.json
generated
5892
package-lock.json
generated
File diff suppressed because it is too large
Load diff
34
package.json
34
package.json
|
|
@ -9,18 +9,50 @@
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.4.1",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@libsql/client": "^0.17.2",
|
||||||
|
"@prisma/adapter-better-sqlite3": "^7.8.0",
|
||||||
|
"@prisma/adapter-libsql": "^7.8.0",
|
||||||
|
"@prisma/adapter-pg": "^7.8.0",
|
||||||
|
"@prisma/client": "^7.8.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"better-sqlite3": "^12.9.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"iron-session": "^8.0.4",
|
||||||
|
"lucide-react": "^1.8.0",
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"pg": "^8.20.0",
|
||||||
|
"prisma": "^7.8.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"shadcn": "^4.4.0",
|
||||||
|
"sonner": "^1.7.4",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zod": "^3.25.76",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.4",
|
"eslint-config-next": "16.2.4",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17
prisma.config.ts
Normal file
17
prisma.config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import * as dotenv from 'dotenv'
|
||||||
|
import { defineConfig } from 'prisma/config'
|
||||||
|
|
||||||
|
// Load .env.local first (Next.js convention), then fall back to .env
|
||||||
|
dotenv.config({ path: '.env.local' })
|
||||||
|
dotenv.config()
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: 'prisma/schema.prisma',
|
||||||
|
migrations: {
|
||||||
|
path: 'prisma/migrations',
|
||||||
|
seed: 'tsx prisma/seed.ts',
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: process.env.DATABASE_URL!,
|
||||||
|
},
|
||||||
|
})
|
||||||
171
prisma/migrations/20260422184304_init/migration.sql
Normal file
171
prisma/migrations/20260422184304_init/migration.sql
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"username" TEXT NOT NULL,
|
||||||
|
"password_hash" TEXT NOT NULL,
|
||||||
|
"is_demo" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "user_roles" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"role" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "user_roles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "api_tokens" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"token_hash" TEXT NOT NULL,
|
||||||
|
"label" TEXT,
|
||||||
|
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"revoked_at" DATETIME,
|
||||||
|
CONSTRAINT "api_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "products" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"repo_url" TEXT,
|
||||||
|
"definition_of_done" TEXT NOT NULL,
|
||||||
|
"archived" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "products_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "pbis" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"product_id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"priority" INTEGER NOT NULL,
|
||||||
|
"sort_order" REAL NOT NULL,
|
||||||
|
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "pbis_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "stories" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"pbi_id" TEXT NOT NULL,
|
||||||
|
"product_id" TEXT NOT NULL,
|
||||||
|
"sprint_id" TEXT,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"acceptance_criteria" TEXT,
|
||||||
|
"priority" INTEGER NOT NULL,
|
||||||
|
"sort_order" REAL NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'OPEN',
|
||||||
|
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "stories_pbi_id_fkey" FOREIGN KEY ("pbi_id") REFERENCES "pbis" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "stories_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "stories_sprint_id_fkey" FOREIGN KEY ("sprint_id") REFERENCES "sprints" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "story_logs" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"story_id" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"status" TEXT,
|
||||||
|
"commit_hash" TEXT,
|
||||||
|
"commit_message" TEXT,
|
||||||
|
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "story_logs_story_id_fkey" FOREIGN KEY ("story_id") REFERENCES "stories" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sprints" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"product_id" TEXT NOT NULL,
|
||||||
|
"sprint_goal" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"completed_at" DATETIME,
|
||||||
|
CONSTRAINT "sprints_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "tasks" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"story_id" TEXT NOT NULL,
|
||||||
|
"sprint_id" TEXT,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"priority" INTEGER NOT NULL,
|
||||||
|
"sort_order" REAL NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'TO_DO',
|
||||||
|
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "tasks_story_id_fkey" FOREIGN KEY ("story_id") REFERENCES "stories" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "tasks_sprint_id_fkey" FOREIGN KEY ("sprint_id") REFERENCES "sprints" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "todos" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"done" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"archived" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "todos_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "user_roles_user_id_role_key" ON "user_roles"("user_id", "role");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "api_tokens_token_hash_key" ON "api_tokens"("token_hash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "api_tokens_token_hash_idx" ON "api_tokens"("token_hash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "products_user_id_archived_idx" ON "products"("user_id", "archived");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "products_user_id_name_key" ON "products"("user_id", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "pbis_product_id_priority_sort_order_idx" ON "pbis"("product_id", "priority", "sort_order");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "stories_pbi_id_priority_sort_order_idx" ON "stories"("pbi_id", "priority", "sort_order");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "stories_sprint_id_sort_order_idx" ON "stories"("sprint_id", "sort_order");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "stories_product_id_status_idx" ON "stories"("product_id", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "story_logs_story_id_created_at_idx" ON "story_logs"("story_id", "created_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sprints_product_id_status_idx" ON "sprints"("product_id", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "tasks_story_id_priority_sort_order_idx" ON "tasks"("story_id", "priority", "sort_order");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "tasks_sprint_id_status_idx" ON "tasks"("sprint_id", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "todos_user_id_done_archived_idx" ON "todos"("user_id", "done", "archived");
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "sqlite"
|
||||||
203
prisma/schema.prisma
Normal file
203
prisma/schema.prisma
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
PRODUCT_OWNER
|
||||||
|
SCRUM_MASTER
|
||||||
|
DEVELOPER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StoryStatus {
|
||||||
|
OPEN
|
||||||
|
IN_SPRINT
|
||||||
|
DONE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TaskStatus {
|
||||||
|
TO_DO
|
||||||
|
IN_PROGRESS
|
||||||
|
DONE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LogType {
|
||||||
|
IMPLEMENTATION_PLAN
|
||||||
|
TEST_RESULT
|
||||||
|
COMMIT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TestStatus {
|
||||||
|
PASSED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SprintStatus {
|
||||||
|
ACTIVE
|
||||||
|
COMPLETED
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
username String @unique
|
||||||
|
password_hash String
|
||||||
|
is_demo Boolean @default(false)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
roles UserRole[]
|
||||||
|
api_tokens ApiToken[]
|
||||||
|
products Product[]
|
||||||
|
todos Todo[]
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserRole {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
|
user_id String
|
||||||
|
role Role
|
||||||
|
|
||||||
|
@@unique([user_id, role])
|
||||||
|
@@map("user_roles")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ApiToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
|
user_id String
|
||||||
|
token_hash String @unique
|
||||||
|
label String?
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
revoked_at DateTime?
|
||||||
|
|
||||||
|
@@index([token_hash])
|
||||||
|
@@map("api_tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Product {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
|
user_id String
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
repo_url String?
|
||||||
|
definition_of_done String
|
||||||
|
archived Boolean @default(false)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
pbis Pbi[]
|
||||||
|
sprints Sprint[]
|
||||||
|
stories Story[]
|
||||||
|
|
||||||
|
@@unique([user_id, name])
|
||||||
|
@@index([user_id, archived])
|
||||||
|
@@map("products")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Pbi {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||||
|
product_id String
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
priority Int
|
||||||
|
sort_order Float
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
stories Story[]
|
||||||
|
|
||||||
|
@@index([product_id, priority, sort_order])
|
||||||
|
@@map("pbis")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Story {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
pbi Pbi @relation(fields: [pbi_id], references: [id], onDelete: Cascade)
|
||||||
|
pbi_id String
|
||||||
|
product Product @relation(fields: [product_id], references: [id])
|
||||||
|
product_id String
|
||||||
|
sprint Sprint? @relation(fields: [sprint_id], references: [id])
|
||||||
|
sprint_id String?
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
acceptance_criteria String?
|
||||||
|
priority Int
|
||||||
|
sort_order Float
|
||||||
|
status StoryStatus @default(OPEN)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
logs StoryLog[]
|
||||||
|
tasks Task[]
|
||||||
|
|
||||||
|
@@index([pbi_id, priority, sort_order])
|
||||||
|
@@index([sprint_id, sort_order])
|
||||||
|
@@index([product_id, status])
|
||||||
|
@@map("stories")
|
||||||
|
}
|
||||||
|
|
||||||
|
model StoryLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
|
||||||
|
story_id String
|
||||||
|
type LogType
|
||||||
|
content String
|
||||||
|
status TestStatus?
|
||||||
|
commit_hash String?
|
||||||
|
commit_message String?
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([story_id, created_at])
|
||||||
|
@@map("story_logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Sprint {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||||
|
product_id String
|
||||||
|
sprint_goal String
|
||||||
|
status SprintStatus @default(ACTIVE)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
completed_at DateTime?
|
||||||
|
stories Story[]
|
||||||
|
tasks Task[]
|
||||||
|
|
||||||
|
@@index([product_id, status])
|
||||||
|
@@map("sprints")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Task {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
|
||||||
|
story_id String
|
||||||
|
sprint Sprint? @relation(fields: [sprint_id], references: [id])
|
||||||
|
sprint_id String?
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
priority Int
|
||||||
|
sort_order Float
|
||||||
|
status TaskStatus @default(TO_DO)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([story_id, priority, sort_order])
|
||||||
|
@@index([sprint_id, status])
|
||||||
|
@@map("tasks")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Todo {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
|
user_id String
|
||||||
|
title String
|
||||||
|
done Boolean @default(false)
|
||||||
|
archived Boolean @default(false)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([user_id, done, archived])
|
||||||
|
@@map("todos")
|
||||||
|
}
|
||||||
238
prisma/seed.ts
Normal file
238
prisma/seed.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
|
||||||
|
import * as dotenv from 'dotenv'
|
||||||
|
import * as path from 'path'
|
||||||
|
import * as bcrypt from 'bcryptjs'
|
||||||
|
|
||||||
|
// Load env from project root
|
||||||
|
const root = path.resolve(__dirname, '..')
|
||||||
|
dotenv.config({ path: path.join(root, '.env.local'), override: true })
|
||||||
|
dotenv.config({ path: path.join(root, '.env') })
|
||||||
|
|
||||||
|
let prisma: PrismaClient
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const url = process.env.DATABASE_URL
|
||||||
|
if (!url) throw new Error('DATABASE_URL is not set. Check .env.local')
|
||||||
|
|
||||||
|
// For SQLite: adapter takes a config object with url
|
||||||
|
const adapter = new PrismaBetterSqlite3({ url })
|
||||||
|
prisma = new PrismaClient({ adapter })
|
||||||
|
|
||||||
|
console.log('Seeding database...')
|
||||||
|
|
||||||
|
// Create main demo user
|
||||||
|
const demoHash = await bcrypt.hash('demo1234', 12)
|
||||||
|
const demo = await prisma.user.upsert({
|
||||||
|
where: { username: 'demo' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
username: 'demo',
|
||||||
|
password_hash: demoHash,
|
||||||
|
is_demo: true,
|
||||||
|
roles: {
|
||||||
|
create: [
|
||||||
|
{ role: 'PRODUCT_OWNER' },
|
||||||
|
{ role: 'DEVELOPER' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Demo user: ${demo.username} (id: ${demo.id})`)
|
||||||
|
|
||||||
|
// Create seed user for the product
|
||||||
|
const userHash = await bcrypt.hash('scrum4me123', 12)
|
||||||
|
const user = await prisma.user.upsert({
|
||||||
|
where: { username: 'lars' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
username: 'lars',
|
||||||
|
password_hash: userHash,
|
||||||
|
is_demo: false,
|
||||||
|
roles: {
|
||||||
|
create: [
|
||||||
|
{ role: 'PRODUCT_OWNER' },
|
||||||
|
{ role: 'SCRUM_MASTER' },
|
||||||
|
{ role: 'DEVELOPER' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Main user: ${user.username} (id: ${user.id})`)
|
||||||
|
|
||||||
|
// Create the Scrum4Me product (using the product backlog doc data)
|
||||||
|
const product = await prisma.product.upsert({
|
||||||
|
where: { user_id_name: { user_id: demo.id, name: 'DevPlanner' } },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
user_id: demo.id,
|
||||||
|
name: 'DevPlanner',
|
||||||
|
description: 'Een lichtgewicht Scrum-gebaseerde projectplanner voor solo developers en kleine Scrum Teams.',
|
||||||
|
repo_url: 'https://github.com/devplanner/devplanner',
|
||||||
|
definition_of_done: 'Feature is geïmplementeerd, getest (unit + integratie), gedocumenteerd in code, en gedeployed naar de staging-omgeving zonder regressies.',
|
||||||
|
archived: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Product created: ${product.name} (id: ${product.id})`)
|
||||||
|
|
||||||
|
// PBI data from the product backlog document
|
||||||
|
const pbis = [
|
||||||
|
{
|
||||||
|
title: 'Authenticatie & gebruikersbeheer',
|
||||||
|
description: 'Het Scrum Team kan een account aanmaken en inloggen met gebruikersnaam en wachtwoord. Een demo-gebruiker heeft alleen leesrechten. Gebruikers kunnen één of meerdere Scrum-rollen aannemen.',
|
||||||
|
priority: 1,
|
||||||
|
stories: [
|
||||||
|
{ title: 'Account aanmaken', description: 'Als bezoeker wil ik een account aanmaken met gebruikersnaam en wachtwoord, zodat ik toegang krijg tot de app.', acceptance_criteria: '- Gebruikersnaam en wachtwoord zijn verplicht\n- Gebruikersnaam is uniek; dubbele aanmelding geeft foutmelding\n- Wachtwoord heeft minimaal 8 tekens\n- Na aanmaken wordt de gebruiker direct ingelogd\n- Geen e-mailverificatie vereist in v1', priority: 1 },
|
||||||
|
{ title: 'Inloggen', description: 'Als geregistreerde gebruiker wil ik inloggen met gebruikersnaam en wachtwoord, zodat ik mijn projecten kan beheren.', acceptance_criteria: '- Incorrecte combinatie geeft generieke foutmelding\n- Na inloggen wordt de gebruiker doorgestuurd naar het dashboard\n- Sessie blijft actief totdat de gebruiker uitlogt', priority: 1 },
|
||||||
|
{ title: 'Uitloggen', description: 'Als ingelogde gebruiker wil ik kunnen uitloggen, zodat mijn sessie veilig afgesloten wordt.', acceptance_criteria: '- Uitlogknop altijd zichtbaar in de navigatie\n- Na uitloggen wordt de gebruiker naar de loginpagina gestuurd\n- Sessiedata wordt gewist', priority: 1 },
|
||||||
|
{ title: 'Demo-gebruiker (read-only)', description: 'Als bezoeker wil ik kunnen inloggen als demo-gebruiker, zodat ik de app kan verkennen zonder een account aan te maken.', acceptance_criteria: '- Vaste inloggegevens voor de demo-gebruiker zijn beschikbaar op de loginpagina\n- Demo-gebruiker ziet alle data maar kan niets aanmaken, aanpassen of verwijderen\n- Alle actieknoppen zijn zichtbaar maar uitgeschakeld', priority: 2 },
|
||||||
|
{ title: 'Roltoewijzing', description: 'Als gebruiker wil ik één of meerdere Scrum-rollen kunnen aannemen (Product Owner, Scrum Master, Developer).', acceptance_criteria: '- Gebruiker kan bij registratie of in instellingen rollen selecteren\n- Minimaal één rol is verplicht\n- Alle drie de rollen tegelijk zijn toegestaan', priority: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Productbeheer',
|
||||||
|
description: 'Het Scrum Team kan producten aanmaken, bekijken, bewerken en archiveren.',
|
||||||
|
priority: 1,
|
||||||
|
stories: [
|
||||||
|
{ title: 'Product aanmaken', description: 'Als Product Owner wil ik een nieuw product aanmaken met naam, beschrijving en git-repo URL.', acceptance_criteria: '- Naam is verplicht en uniek per gebruiker\n- Beschrijving is optioneel\n- Git-repo URL is optioneel maar wordt gevalideerd als geldige URL', priority: 1 },
|
||||||
|
{ title: 'Product bewerken', description: 'Als Product Owner wil ik de naam, beschrijving en git-repo URL van een product kunnen aanpassen.', acceptance_criteria: '- Alle velden zijn bewerkbaar\n- Wijzigingen worden opgeslagen zonder de pagina te verlaten', priority: 2 },
|
||||||
|
{ title: 'Product archiveren', description: 'Als Product Owner wil ik een product kunnen archiveren.', acceptance_criteria: '- Gearchiveerde producten verschijnen niet in de standaardlijst\n- Archiveren is omkeerbaar', priority: 2 },
|
||||||
|
{ title: 'Productenlijst bekijken', description: 'Als gebruiker wil ik een overzicht zien van alle actieve producten.', acceptance_criteria: '- Lijst toont naam, beschrijving (ingekort) en git-repo link\n- Klikken op een product opent de Product Backlog', priority: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Product Backlog',
|
||||||
|
description: 'Het Scrum Team kan de Product Backlog beheren via een gesplitst scherm: links de PBIs, rechts de bijbehorende stories.',
|
||||||
|
priority: 1,
|
||||||
|
stories: [
|
||||||
|
{ title: 'PBI aanmaken', description: 'Als Product Owner wil ik een PBI aanmaken in de Product Backlog.', acceptance_criteria: '- PBI heeft een titel (verplicht) en omschrijving (optioneel)\n- PBI krijgt een prioriteit (1 t/m 4)\n- Nieuw PBI verschijnt onderaan de lijst', priority: 1 },
|
||||||
|
{ title: 'PBI bewerken', description: 'Als Product Owner wil ik de titel, omschrijving en prioriteit van een PBI kunnen aanpassen.', acceptance_criteria: '- Dubbelklikken of via contextmenu opent bewerkingsmodus\n- Alle velden zijn inline bewerkbaar', priority: 2 },
|
||||||
|
{ title: 'PBI verwijderen', description: 'Als Product Owner wil ik een PBI kunnen verwijderen.', acceptance_criteria: '- Verwijderen vereist bevestiging\n- Cascade verwijdering van bijbehorende stories', priority: 2 },
|
||||||
|
{ title: 'PBI prioriteit instellen', description: 'Als Product Owner wil ik per PBI een prioriteit kunnen instellen (1 t/m 4).', acceptance_criteria: '- Prioriteit is instelbaar via dropdown of inline label\n- PBIs worden gegroepeerd per prioriteit', priority: 1 },
|
||||||
|
{ title: 'PBI volgorde aanpassen via drag-and-drop', description: 'Als Product Owner wil ik de volgorde van PBIs binnen dezelfde prioriteit kunnen aanpassen via drag-and-drop.', acceptance_criteria: '- Drag-and-drop werkt vloeiend via dnd-kit\n- Volgorde wordt direct opgeslagen na loslaten', priority: 2 },
|
||||||
|
{ title: 'PBI filteren', description: 'Als gebruiker wil ik PBIs kunnen filteren op prioriteit.', acceptance_criteria: '- Filteropties beschikbaar in de navigatiebar\n- Filter werkt realtime', priority: 3 },
|
||||||
|
{ title: 'Gesplitst scherm Product Backlog', description: 'Als gebruiker wil ik de Product Backlog bekijken als gesplitst scherm.', acceptance_criteria: '- Scherm is standaard 50/50 verdeeld\n- Splitter is horizontaal versleepbaar', priority: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Story-beheer',
|
||||||
|
description: 'Stories kunnen worden aangemaakt, bewerkt, geprioriteerd en gerangschikt binnen een PBI.',
|
||||||
|
priority: 1,
|
||||||
|
stories: [
|
||||||
|
{ title: 'Story aanmaken', description: 'Als Product Owner wil ik een story aanmaken binnen een PBI.', acceptance_criteria: '- Story heeft een titel (verplicht), omschrijving (optioneel) en prioriteit\n- Nieuwe story verschijnt als blok rechts', priority: 1 },
|
||||||
|
{ title: 'Story weergave als blokken', description: 'Als gebruiker wil ik stories zien als compacte blokken (~10% schermbreedte).', acceptance_criteria: '- Elk blok toont: storytitel, prioriteit, status\n- Blokken zijn gerangschikt op prioriteit', priority: 1 },
|
||||||
|
{ title: 'Story prioriteit instellen', description: 'Als Product Owner wil ik per story een prioriteit instellen.', acceptance_criteria: '- Prioriteit instelbaar via het storyblok\n- Prioriteitswijziging herplaatst het blok in de juiste groep', priority: 2 },
|
||||||
|
{ title: 'Story volgorde aanpassen via drag-and-drop', description: 'Als Product Owner wil ik de volgorde van stories aanpassen via drag-and-drop.', acceptance_criteria: '- Drag-and-drop werkt via dnd-kit\n- Volgorde wordt direct opgeslagen', priority: 2 },
|
||||||
|
{ title: 'Story bewerken', description: 'Als Product Owner wil ik de titel, omschrijving en prioriteit van een story kunnen aanpassen.', acceptance_criteria: '- Bewerkbaar via klikken op het storyblok\n- Wijzigingen opgeslagen zonder paginaverversing', priority: 2 },
|
||||||
|
{ title: 'Story verwijderen', description: 'Als Product Owner wil ik een story kunnen verwijderen.', acceptance_criteria: '- Verwijderen vereist bevestiging\n- Cascade verwijdering van gekoppelde taken', priority: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Todo-lijst',
|
||||||
|
description: 'Gebruikers kunnen een snelle todo-lijst bijhouden voor ongeplande of kortstondige taken.',
|
||||||
|
priority: 2,
|
||||||
|
stories: [
|
||||||
|
{ title: 'Todo-item aanmaken', description: 'Als gebruiker wil ik snel een todo-item aanmaken zonder het aan een product te koppelen.', acceptance_criteria: '- Todo heeft alleen een titel (verplicht)\n- Aanmaken via een snel-invoerveld (Enter om op te slaan)', priority: 1 },
|
||||||
|
{ title: 'Todo-item afvinken', description: 'Als gebruiker wil ik een todo-item kunnen afvinken.', acceptance_criteria: '- Afgevinkte items zijn visueel doorgestreept\n- Afgevinkte items kunnen worden gearchiveerd', priority: 1 },
|
||||||
|
{ title: 'Todo promoveren naar PBI', description: 'Als Product Owner wil ik een todo-item promoveren naar een PBI.', acceptance_criteria: '- Promoten opent een dialoog om product en prioriteit te kiezen\n- Todo wordt omgezet naar een PBI', priority: 2 },
|
||||||
|
{ title: 'Todo promoveren naar story', description: 'Als Product Owner wil ik een todo-item promoveren naar een story.', acceptance_criteria: '- Promoten opent een dialoog om product, PBI en prioriteit te kiezen\n- Todo wordt omgezet naar een story', priority: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Sprint Backlog & Sprint Planning',
|
||||||
|
description: 'Het Scrum Team kan een Sprint aanmaken met een Sprint Goal, stories slepen en de volgorde bepalen.',
|
||||||
|
priority: 2,
|
||||||
|
stories: [
|
||||||
|
{ title: 'Sprint aanmaken', description: 'Als Scrum Master wil ik een nieuwe Sprint aanmaken met een Sprint Goal.', acceptance_criteria: '- Sprint heeft een Sprint Goal (verplicht)\n- Sprint is gekoppeld aan een product\n- Er kan maar één actieve Sprint per product zijn', priority: 1 },
|
||||||
|
{ title: 'Sprint Backlog scherm (gesplitst)', description: 'Als gebruiker wil ik de Sprint Backlog kunnen beheren via een gesplitst scherm.', acceptance_criteria: '- Links: Sprint Backlog met geselecteerde stories\n- Rechts: stories uit de Product Backlog', priority: 1 },
|
||||||
|
{ title: 'Story naar Sprint slepen', description: 'Als Developer wil ik een story vanuit de Product Backlog naar de Sprint Backlog kunnen slepen.', acceptance_criteria: '- Drag-and-drop werkt via dnd-kit\n- Story verschijnt in de Sprint Backlog op de gesleepte positie', priority: 1 },
|
||||||
|
{ title: 'Volgorde stories in Sprint bepalen', description: 'Als Developer wil ik de volgorde van stories in de Sprint Backlog kunnen aanpassen.', acceptance_criteria: '- Drag-and-drop werkt binnen de Sprint Backlog\n- Volgorde wordt direct opgeslagen', priority: 2 },
|
||||||
|
{ title: 'Story uit Sprint verwijderen', description: 'Als Developer wil ik een story uit de Sprint Backlog kunnen verwijderen.', acceptance_criteria: '- Story verdwijnt uit de Sprint Backlog\n- Story is weer beschikbaar in de Product Backlog', priority: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Sprint Planning (taken per story)',
|
||||||
|
description: 'Tijdens Sprint Planning worden stories opgedeeld in taken via een gesplitst scherm.',
|
||||||
|
priority: 2,
|
||||||
|
stories: [
|
||||||
|
{ title: 'Sprint Planning scherm', description: 'Als Developer wil ik een Sprint Planning scherm zien met stories links en taken rechts.', acceptance_criteria: '- Links: stories in de Sprint Backlog\n- Rechts: taken van de geselecteerde story', priority: 1 },
|
||||||
|
{ title: 'Taak aanmaken', description: 'Als Developer wil ik een taak aanmaken onder een story.', acceptance_criteria: '- Taak heeft een titel (verplicht), omschrijving (optioneel) en prioriteit\n- Nieuwe taak verschijnt onderaan de takenlijst', priority: 1 },
|
||||||
|
{ title: 'Taak prioriteit instellen', description: 'Als Developer wil ik per taak een prioriteit instellen.', acceptance_criteria: '- Prioriteit instelbaar via taakregel\n- Taken gegroepeerd op prioriteit', priority: 2 },
|
||||||
|
{ title: 'Taak volgorde aanpassen via drag-and-drop', description: 'Als Developer wil ik de volgorde van taken kunnen aanpassen via drag-and-drop.', acceptance_criteria: '- Drag-and-drop via dnd-kit binnen de takenlijst\n- Volgorde direct opgeslagen na loslaten', priority: 2 },
|
||||||
|
{ title: 'Taakstatus bijhouden', description: 'Als Developer wil ik de status van een taak kunnen bijhouden (To Do, In Progress, Done).', acceptance_criteria: '- Status is instelbaar via de UI\n- Story toont een voortgangsindicator op basis van taakstatussen', priority: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Claude Code integratie',
|
||||||
|
description: 'Claude Code kan via een REST API stories en taken ophalen, implementatieplannen vastleggen en commits registreren.',
|
||||||
|
priority: 2,
|
||||||
|
stories: [
|
||||||
|
{ title: 'REST API — story ophalen', description: 'Als Developer (via Claude Code) wil ik de hoogst geprioriteerde open story ophalen via een API-endpoint.', acceptance_criteria: '- Endpoint: GET /api/products/:id/next-story\n- Authentiseerd via API-token\n- Geeft 404 als er geen open stories zijn', priority: 1 },
|
||||||
|
{ title: 'REST API — eerste 10 taken ophalen', description: 'Als Developer (via Claude Code) wil ik de eerste 10 taken van de Sprint Backlog kunnen ophalen.', acceptance_criteria: '- Endpoint: GET /api/sprints/:id/tasks?limit=10\n- Retourneert taken in huidige volgorde', priority: 1 },
|
||||||
|
{ title: 'REST API — taakvolgorde aanpassen', description: 'Als Developer (via Claude Code) wil ik de volgorde van taken kunnen aanpassen via de API.', acceptance_criteria: '- Endpoint: PATCH /api/stories/:id/tasks/reorder\n- Accepteert een geordende lijst van taak-ids', priority: 2 },
|
||||||
|
{ title: 'Implementatieplan vastleggen', description: 'Als Developer (via Claude Code) wil ik een implementatieplan kunnen schrijven naar een story.', acceptance_criteria: '- Endpoint: POST /api/stories/:id/log\n- type: "IMPLEMENTATION_PLAN"', priority: 1 },
|
||||||
|
{ title: 'Teststatus vastleggen', description: 'Als Developer (via Claude Code) wil ik de uitkomst van testruns kunnen vastleggen in een story.', acceptance_criteria: '- Endpoint: POST /api/stories/:id/log\n- type: "TEST_RESULT", status: "PASSED" | "FAILED"', priority: 1 },
|
||||||
|
{ title: 'Commit-hash vastleggen', description: 'Als Developer (via Claude Code) wil ik de commit-hash na een succesvolle commit kunnen vastleggen.', acceptance_criteria: '- Endpoint: POST /api/stories/:id/log\n- type: "COMMIT", commit_hash, commit_message', priority: 1 },
|
||||||
|
{ title: 'Story activiteitenlog in UI', description: 'Als gebruiker wil ik per story een activiteitenlog zien met alle vastgelegde plannen, testresultaten en commits.', acceptance_criteria: '- Log toont alle entries in chronologische volgorde\n- Elk type entry heeft een eigen visuele stijl', priority: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Infrastructuur & deployment',
|
||||||
|
description: 'De app is deployable op Vercel + Neon en volledig lokaal draaibaar zonder externe afhankelijkheden.',
|
||||||
|
priority: 1,
|
||||||
|
stories: [
|
||||||
|
{ title: 'Cloud deployment (Vercel + Neon)', description: 'Als Developer wil ik de app deployen op Vercel met een Neon PostgreSQL-database.', acceptance_criteria: '- next build slaagt zonder fouten\n- Database-migraties worden uitgevoerd via Prisma', priority: 1 },
|
||||||
|
{ title: 'Lokale modus', description: 'Als Developer wil ik de app volledig lokaal kunnen draaien met een lokale SQLite-database.', acceptance_criteria: '- npm run dev start de app lokaal\n- Database wordt aangemaakt via prisma db push', priority: 1 },
|
||||||
|
{ title: 'API-token authenticatie', description: 'Als Developer wil ik een API-token kunnen genereren in de app.', acceptance_criteria: '- Gebruiker kan een API-token aanmaken\n- Token wordt eenmalig getoond\n- Alle API-endpoints vereisen een geldig token', priority: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Create all PBIs and their stories
|
||||||
|
for (let pbiIdx = 0; pbiIdx < pbis.length; pbiIdx++) {
|
||||||
|
const pbiData = pbis[pbiIdx]
|
||||||
|
const pbi = await prisma.pbi.create({
|
||||||
|
data: {
|
||||||
|
product_id: product.id,
|
||||||
|
title: pbiData.title,
|
||||||
|
description: pbiData.description,
|
||||||
|
priority: pbiData.priority,
|
||||||
|
sort_order: (pbiIdx + 1) * 1.0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(` PBI: ${pbi.title} (priority ${pbi.priority})`)
|
||||||
|
|
||||||
|
for (let storyIdx = 0; storyIdx < pbiData.stories.length; storyIdx++) {
|
||||||
|
const storyData = pbiData.stories[storyIdx]
|
||||||
|
await prisma.story.create({
|
||||||
|
data: {
|
||||||
|
pbi_id: pbi.id,
|
||||||
|
product_id: product.id,
|
||||||
|
title: storyData.title,
|
||||||
|
description: storyData.description,
|
||||||
|
acceptance_criteria: storyData.acceptance_criteria,
|
||||||
|
priority: storyData.priority,
|
||||||
|
sort_order: (storyIdx + 1) * 1.0,
|
||||||
|
status: 'OPEN',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nSeeding complete!')
|
||||||
|
console.log('Demo user: username=demo password=demo1234')
|
||||||
|
console.log('Main user: username=lars password=scrum4me123')
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma?.$disconnect()
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue