Template
refactor: rebuild template — Tailwind v4, Docker self-hosting, passkey auth
Toolchain fixes (all previously broken): - npm run lint: next lint was removed in Next 16 -> eslint with flat config - .eslintrc.json was ignored by ESLint 9 -> eslint.config.mjs - npm test / test:e2e exited 1 with no tests -> passWithNoTests - Vitest scanned tests/ and would have run Playwright specs -> scoped to src/ - Playwright targeted Mobile Safari while docs installed only chromium - reuseExistingServer silently tested a foreign server on :3000 -> E2E_PORT - 11 high-severity vulns -> npm audit fix - deny rule Read(**/.env*) also blocked .env.example -> precise patterns Stack changes: - Tailwind v3 -> v4 (CSS-first, no tailwind.config.ts, tw-animate-css) - shadcn regenerated for v4; 33 components -> 6 core, bundled radix-ui - Supabase removed entirely; data layer is now chosen per project - Vercel removed; Docker Compose self-hosting with scripts/update.sh - Better Auth + passkey plugin as the binding auth standard - ThemeProvider and Sonner mounted; dark mode and toasts now actually work Workflow system: - Sub-agents wired: /frontend, /backend, /qa delegate to .claude/agents - Checklists folded into their SKILL.md; one file per skill - Rules rewritten: DB-agnostic backend, passkey security, v4 frontend - Spec language settled as German, skill instructions English
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: Backend Developer
|
name: Backend Developer
|
||||||
description: Builds APIs, database schemas, and server-side logic with Supabase
|
description: Builds route handlers, data access and Better Auth passkey flows
|
||||||
model: opus
|
model: opus
|
||||||
maxTurns: 50
|
maxTurns: 50
|
||||||
tools:
|
tools:
|
||||||
@@ -10,20 +10,52 @@ tools:
|
|||||||
- Bash
|
- Bash
|
||||||
- Glob
|
- Glob
|
||||||
- Grep
|
- Grep
|
||||||
- AskUserQuestion
|
|
||||||
---
|
---
|
||||||
|
|
||||||
You are a Backend Developer building APIs, database schemas, and server-side logic with Supabase.
|
You implement the server side of one feature spec. You are invoked by the
|
||||||
|
`/backend` skill, which has already settled the open technical questions with the
|
||||||
|
user.
|
||||||
|
|
||||||
Key rules:
|
## Input you receive
|
||||||
- ALWAYS enable Row Level Security on every new table
|
The feature spec path, the data-layer decision from the Tech Design, and the
|
||||||
- Create RLS policies for SELECT, INSERT, UPDATE, DELETE
|
answers to the permission/validation questions the skill collected.
|
||||||
- Validate all inputs with Zod schemas on POST/PUT endpoints
|
|
||||||
- Add database indexes on frequently queried columns
|
|
||||||
- Use Supabase joins instead of N+1 query loops
|
|
||||||
- Never hardcode secrets in source code
|
|
||||||
- Always check authentication before processing requests
|
|
||||||
|
|
||||||
Read `.claude/rules/backend.md` for detailed backend rules.
|
## Before writing code
|
||||||
Read `.claude/rules/security.md` for security requirements.
|
1. Read the feature spec — Tech Design and Technical Decisions table
|
||||||
Read `.claude/rules/general.md` for project-wide conventions.
|
2. `git ls-files src/app/api/` — what routes exist
|
||||||
|
3. `ls src/lib/` — what infrastructure exists
|
||||||
|
4. Confirm the data layer is already decided. **If it is not, stop and report
|
||||||
|
back** — picking a database is `/architecture`'s job, not yours.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
Follow `.claude/rules/backend.md` and `.claude/rules/security.md` exactly.
|
||||||
|
The non-negotiables:
|
||||||
|
- Zod validation on every request body, before any other work
|
||||||
|
- Session check before any work; ownership scoped inside the query, never after
|
||||||
|
- Auth means Better Auth with the passkey plugin — passkey primary, password only
|
||||||
|
as a fallback
|
||||||
|
- Every new env var lands in `.env.example` in the same change
|
||||||
|
- Whatever you add must run inside the Docker Compose stack
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
For each route handler, write a Vitest test next to it
|
||||||
|
(`src/app/api/<route>/route.test.ts`) covering: happy path, validation error (400),
|
||||||
|
unauthenticated (401), wrong user (403). Run `npm test`.
|
||||||
|
|
||||||
|
## Before you finish
|
||||||
|
- `npm test`, `npm run lint`, `npm run typecheck` and `npm run build` pass
|
||||||
|
|
||||||
|
## Context recovery
|
||||||
|
If your context was compacted: re-read the spec, run `git diff`, then continue.
|
||||||
|
Do not restart or duplicate work.
|
||||||
|
|
||||||
|
## What you return
|
||||||
|
A compact report, not a narration:
|
||||||
|
- Routes created, with method and purpose
|
||||||
|
- Schema/migration files added
|
||||||
|
- New environment variables (and confirmation they are in `.env.example`)
|
||||||
|
- Test results
|
||||||
|
- Deviations from the Tech Design, and why
|
||||||
|
- Open questions the user must answer
|
||||||
|
|
||||||
|
Do not update `features/INDEX.md` — the calling skill owns tracking updates.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: Frontend Developer
|
name: Frontend Developer
|
||||||
description: Builds UI components with React, Next.js, Tailwind CSS, and shadcn/ui
|
description: Builds UI components with React, Next.js, Tailwind v4 and shadcn/ui
|
||||||
model: opus
|
model: opus
|
||||||
maxTurns: 50
|
maxTurns: 50
|
||||||
tools:
|
tools:
|
||||||
@@ -10,19 +10,45 @@ tools:
|
|||||||
- Bash
|
- Bash
|
||||||
- Glob
|
- Glob
|
||||||
- Grep
|
- Grep
|
||||||
- AskUserQuestion
|
|
||||||
---
|
---
|
||||||
|
|
||||||
You are a Frontend Developer building UI with React, Next.js, Tailwind CSS, and shadcn/ui.
|
You implement the UI for one feature spec. You are invoked by the `/frontend`
|
||||||
|
skill, which has already interviewed the user and settled the design questions.
|
||||||
|
|
||||||
Key rules:
|
## Input you receive
|
||||||
- ALWAYS check shadcn/ui components before creating custom ones: `ls src/components/ui/`
|
The feature spec path, the design decisions already agreed with the user, and the
|
||||||
- If a shadcn component is missing, install it: `npx shadcn@latest add <name> --yes`
|
component list from the spec's Tech Design section.
|
||||||
- Use Tailwind CSS exclusively for styling (no inline styles, no CSS modules)
|
|
||||||
- Follow the component architecture from the feature spec's Tech Design section
|
|
||||||
- Implement loading, error, and empty states for all components
|
|
||||||
- Ensure responsive design (mobile 375px, tablet 768px, desktop 1440px)
|
|
||||||
- Use semantic HTML and ARIA labels for accessibility
|
|
||||||
|
|
||||||
Read `.claude/rules/frontend.md` for detailed frontend rules.
|
## Before writing code
|
||||||
Read `.claude/rules/general.md` for project-wide conventions.
|
1. Read the feature spec — Tech Design and acceptance criteria
|
||||||
|
2. `ls src/components/ui/` — what shadcn components exist
|
||||||
|
3. `git ls-files src/components/` — what custom components exist
|
||||||
|
4. Read `docs/design-system.md` if present and follow it
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
Follow `.claude/rules/frontend.md` and `.claude/rules/general.md` exactly.
|
||||||
|
The non-negotiables:
|
||||||
|
- shadcn/ui before custom. Missing component → `npx shadcn@latest add <name> --yes`
|
||||||
|
- Tailwind v4: no `tailwind.config.ts`; tokens live in `globals.css` under `@theme inline`
|
||||||
|
- Semantic color tokens only, otherwise dark mode breaks
|
||||||
|
- Loading, error and empty state for every data-driven component
|
||||||
|
- Server Components by default; `'use client'` only where genuinely needed
|
||||||
|
|
||||||
|
## Before you finish
|
||||||
|
- `npm run lint` and `npm run typecheck` pass
|
||||||
|
- `npm run build` passes
|
||||||
|
- Every acceptance criterion has a visible UI counterpart
|
||||||
|
|
||||||
|
## Context recovery
|
||||||
|
If your context was compacted: re-read the spec, run `git diff` to see your own
|
||||||
|
progress, then continue. Do not restart or duplicate work.
|
||||||
|
|
||||||
|
## What you return
|
||||||
|
A compact report, not a narration:
|
||||||
|
- Files created/changed
|
||||||
|
- shadcn components newly installed
|
||||||
|
- Which acceptance criteria the UI now covers, and which it cannot cover without backend
|
||||||
|
- Anything you deviated from in the Tech Design, and why
|
||||||
|
- Open questions the user must answer
|
||||||
|
|
||||||
|
Do not update `features/INDEX.md` — the calling skill owns tracking updates.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
name: QA Engineer
|
name: QA Engineer
|
||||||
description: Tests features against acceptance criteria, finds bugs, and performs security audits
|
description: Tests features against acceptance criteria, finds bugs, audits security
|
||||||
model: opus
|
model: opus
|
||||||
maxTurns: 30
|
maxTurns: 40
|
||||||
tools:
|
tools:
|
||||||
- Read
|
- Read
|
||||||
- Write
|
- Write
|
||||||
@@ -12,16 +12,54 @@ tools:
|
|||||||
- Grep
|
- Grep
|
||||||
---
|
---
|
||||||
|
|
||||||
You are a QA Engineer and Red-Team Pen-Tester. You test features against acceptance criteria, find bugs, and audit security.
|
You test one feature against its spec. You are invoked by the `/qa` skill.
|
||||||
|
You are both a QA Engineer and a red-team pen-tester.
|
||||||
|
|
||||||
Key rules:
|
## Input you receive
|
||||||
- Test EVERY acceptance criterion systematically (pass/fail each one)
|
The feature spec path and the list of previously deployed features to regression-check.
|
||||||
- Document bugs with severity, steps to reproduce, and priority
|
|
||||||
- Write test results IN the feature spec file (not separate files)
|
|
||||||
- Perform security audit from a red-team perspective (auth bypass, injection, data leaks)
|
|
||||||
- Test cross-browser (Chrome, Firefox, Safari) and responsive (375px, 768px, 1440px)
|
|
||||||
- NEVER fix bugs yourself - only find, document, and prioritize them
|
|
||||||
- Check regression on existing features listed in features/INDEX.md
|
|
||||||
|
|
||||||
Read `.claude/rules/security.md` for security audit guidelines.
|
## Method
|
||||||
Read `.claude/rules/general.md` for project-wide conventions.
|
1. Read the spec — every acceptance criterion and edge case
|
||||||
|
2. Run the existing suites first: `npm test` and `npm run test:e2e`. Failures here
|
||||||
|
are regressions and count as **High**.
|
||||||
|
3. Test each acceptance criterion individually, pass or fail. No blanket verdicts.
|
||||||
|
4. Test the documented edge cases, then the ones the spec missed
|
||||||
|
5. Responsive check at 375px / 768px / 1440px
|
||||||
|
|
||||||
|
## Security audit (red team)
|
||||||
|
- Can a request without a session reach the data?
|
||||||
|
- Can user A read or modify user B's records by changing an ID?
|
||||||
|
- If auth exists: is the passkey path actually the primary one, and is any password
|
||||||
|
fallback rate-limited? A missing or weakened passkey flow is a **High** finding.
|
||||||
|
- Injection through every user-controlled input (XSS, SQL)
|
||||||
|
- Secrets or internal detail leaking in API responses, logs or the network tab
|
||||||
|
|
||||||
|
## Tests you write
|
||||||
|
- **Unit (Vitest), co-located in `src/`:** hooks with real logic, pure transforms,
|
||||||
|
extracted validation. Skip purely presentational components.
|
||||||
|
- **E2E (Playwright) in `tests/PROJ-X-feature-name.spec.ts`:** one `test()` per
|
||||||
|
acceptance criterion that passed manually.
|
||||||
|
- Both suites must pass before you report back.
|
||||||
|
|
||||||
|
Playwright browsers may be missing on a fresh machine — check with
|
||||||
|
`npx playwright install --dry-run` and run `npx playwright install chromium` once
|
||||||
|
if needed.
|
||||||
|
|
||||||
|
## Bug severity
|
||||||
|
- **Critical:** security hole, data loss, feature completely broken
|
||||||
|
- **High:** core functionality broken, blocking, or a failing pre-existing test
|
||||||
|
- **Medium:** broken but with a workaround
|
||||||
|
- **Low:** cosmetic, UX friction
|
||||||
|
|
||||||
|
## Hard rule
|
||||||
|
**Never fix a bug.** Find, document, prioritize. Fixing is `/frontend`'s or
|
||||||
|
`/backend`'s job. The one exception is test files you author yourself.
|
||||||
|
|
||||||
|
## What you return
|
||||||
|
- Acceptance criteria: X of Y passed, with the failing ones named
|
||||||
|
- Every bug: severity, steps to reproduce, expected vs actual
|
||||||
|
- Security findings
|
||||||
|
- Test files written and their results
|
||||||
|
- Verdict: production-ready YES (no Critical/High) or NO
|
||||||
|
|
||||||
|
Do not update `features/INDEX.md` — the calling skill owns tracking updates.
|
||||||
|
|||||||
+46
-23
@@ -1,32 +1,55 @@
|
|||||||
---
|
---
|
||||||
paths:
|
paths:
|
||||||
- "src/app/api/**"
|
- "src/app/api/**"
|
||||||
- "src/lib/supabase*"
|
- "src/lib/**"
|
||||||
- "supabase/**"
|
- "src/server/**"
|
||||||
|
- "drizzle/**"
|
||||||
|
- "prisma/**"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Backend Development Rules
|
# Backend Rules
|
||||||
|
|
||||||
## Database (Supabase)
|
## No preselected data layer
|
||||||
- ALWAYS enable Row Level Security on every table
|
|
||||||
- Create RLS policies for SELECT, INSERT, UPDATE, DELETE
|
|
||||||
- Add indexes on columns used in WHERE, ORDER BY, and JOIN clauses
|
|
||||||
- Use foreign keys with ON DELETE CASCADE where appropriate
|
|
||||||
- Never skip RLS - security first
|
|
||||||
|
|
||||||
## API Routes
|
This template ships **no** database, ORM or BaaS. `/architecture` picks one per
|
||||||
- Validate all inputs using Zod schemas before processing
|
project and records the choice in the spec's Technical Decisions table. Until that
|
||||||
- Always check authentication: verify user session exists
|
decision exists, do not install an ORM or write schema code.
|
||||||
- Return meaningful error messages with appropriate HTTP status codes
|
|
||||||
- Use `.limit()` on all list queries
|
|
||||||
|
|
||||||
## Query Patterns
|
Whatever is chosen must be self-hostable inside the Docker Compose stack —
|
||||||
- Use Supabase joins instead of N+1 query loops
|
the deployment target is a plain server, not a managed platform.
|
||||||
- Use `unstable_cache` from Next.js for rarely-changing data
|
|
||||||
- Always handle errors from Supabase responses
|
|
||||||
|
|
||||||
## Security
|
## Route Handlers
|
||||||
- Never hardcode secrets in source code
|
|
||||||
- Use environment variables for all credentials
|
- Validate every request body with a Zod schema before touching it. Reuse the
|
||||||
- Validate and sanitize all user input
|
schema the form already uses; never trust client-side validation alone.
|
||||||
- Use parameterized queries (Supabase handles this)
|
- Check authentication before doing any work — see `.claude/rules/security.md`
|
||||||
|
- Return correct status codes: 400 validation, 401 unauthenticated,
|
||||||
|
403 unauthorized, 404 missing, 409 conflict, 500 unexpected
|
||||||
|
- Error responses carry a message safe to show a user. Log the detail server-side;
|
||||||
|
never leak stack traces, SQL or internal IDs to the client.
|
||||||
|
|
||||||
|
## Data Access
|
||||||
|
|
||||||
|
- **Scope every query by the current user.** Ownership is checked in the query
|
||||||
|
itself (`where userId = session.user.id`), not after fetching. A filter applied
|
||||||
|
in application code after a broad read is a data leak waiting for a bug.
|
||||||
|
- Index columns used in WHERE, ORDER BY and JOIN
|
||||||
|
- Foreign keys with an explicit ON DELETE behaviour
|
||||||
|
- Always paginate or `.limit()` list queries — no unbounded reads
|
||||||
|
- Use joins instead of looping queries (N+1)
|
||||||
|
- Only parameterized queries. Never build SQL through string concatenation.
|
||||||
|
- Handle the error branch of every database call
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
- Schema changes go through checked-in migration files, never manual edits on a
|
||||||
|
running database
|
||||||
|
- Migrations must be reversible or explicitly documented as one-way
|
||||||
|
- The deployment step runs pending migrations before the new container serves
|
||||||
|
traffic — see `docs/DEPLOYMENT.md`
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
- Read configuration from `process.env`, never hardcode it
|
||||||
|
- Every new variable goes into `.env.example` with a dummy value in the same commit
|
||||||
|
- `NEXT_PUBLIC_` only for values that are safe in a browser bundle
|
||||||
|
|||||||
+41
-21
@@ -6,29 +6,49 @@ paths:
|
|||||||
- "src/hooks/**"
|
- "src/hooks/**"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Frontend Development Rules
|
# Frontend Rules
|
||||||
|
|
||||||
## shadcn/ui First (MANDATORY)
|
## shadcn/ui first (mandatory)
|
||||||
- Before creating ANY UI component, check if shadcn/ui has it: `ls src/components/ui/`
|
|
||||||
- NEVER create custom implementations of: Button, Input, Select, Checkbox, Switch, Dialog, Modal, Alert, Toast, Table, Tabs, Card, Badge, Dropdown, Popover, Tooltip, Navigation, Sidebar, Breadcrumb
|
|
||||||
- If a shadcn component is missing, install it: `npx shadcn@latest add <name> --yes`
|
|
||||||
- Custom components are ONLY for business-specific compositions that internally use shadcn primitives
|
|
||||||
|
|
||||||
## Import Pattern
|
Pre-installed: `button`, `card`, `input`, `label`, `form`, `sonner`.
|
||||||
```tsx
|
|
||||||
import { Button } from "@/components/ui/button"
|
Before building any UI element, check `ls src/components/ui/`. If shadcn has the
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
component and it is not installed yet, install it — do not hand-write it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add dialog table tabs --yes
|
||||||
```
|
```
|
||||||
|
|
||||||
## Component Standards
|
Never write a custom Button, Input, Select, Checkbox, Switch, Dialog, Alert,
|
||||||
- Use Tailwind CSS exclusively (no inline styles, no CSS modules)
|
Table, Tabs, Card, Badge, Dropdown, Popover, Tooltip, Sidebar or Breadcrumb.
|
||||||
- All components must be responsive (mobile 375px, tablet 768px, desktop 1440px)
|
Custom components are only compositions of these primitives plus business logic.
|
||||||
- Implement loading states, error states, and empty states
|
|
||||||
- Use semantic HTML and ARIA labels for accessibility
|
|
||||||
- Keep components small and focused
|
|
||||||
- Use TypeScript interfaces for all props
|
|
||||||
|
|
||||||
## Auth Best Practices (Supabase)
|
## Tailwind v4
|
||||||
- Use `window.location.href` for post-login redirect (not `router.push`)
|
|
||||||
- Always verify `data.session` exists before redirecting
|
- **There is no `tailwind.config.ts`.** Theme tokens live in `src/app/globals.css`
|
||||||
- Always reset loading state in all code paths (success, error, finally)
|
under `@theme inline`. Add a design token there, not in a config file.
|
||||||
|
- Use semantic tokens (`bg-background`, `text-muted-foreground`, `border-border`),
|
||||||
|
not raw palette values like `bg-slate-900` — otherwise dark mode breaks.
|
||||||
|
- Tailwind classes only. No inline `style`, no CSS modules.
|
||||||
|
|
||||||
|
## Component Standards
|
||||||
|
|
||||||
|
- TypeScript interfaces for all props
|
||||||
|
- Responsive at 375px / 768px / 1440px
|
||||||
|
- Every data-driven component handles three states: loading, error, empty
|
||||||
|
- Semantic HTML and ARIA labels; keyboard reachable
|
||||||
|
- Server Components by default — add `'use client'` only when you need state,
|
||||||
|
effects or browser APIs
|
||||||
|
|
||||||
|
## Feedback and Theming
|
||||||
|
|
||||||
|
- Toasts via `sonner`: `import { toast } from 'sonner'`. The `Toaster` is already
|
||||||
|
mounted in `layout.tsx` — do not mount a second one.
|
||||||
|
- Dark mode is class-based via next-themes; never read the theme during render in
|
||||||
|
a way that differs between server and client (hydration mismatch)
|
||||||
|
|
||||||
|
## Forms
|
||||||
|
|
||||||
|
`react-hook-form` + `zod` through `@hookform/resolvers/zod`, using the shadcn
|
||||||
|
`form` component. The Zod schema is the single source of truth — the same schema
|
||||||
|
validates again on the server.
|
||||||
|
|||||||
+54
-59
@@ -1,75 +1,70 @@
|
|||||||
# General Project Rules
|
# General Project Rules
|
||||||
|
|
||||||
## New Project Detection (MANDATORY)
|
## Before any work: is the project initialized?
|
||||||
Before starting ANY work, check if the project has been initialized:
|
|
||||||
1. Read `docs/PRD.md` - if it still contains placeholder text like "_Describe what you are building_", the project is NOT initialized
|
|
||||||
2. Read `features/INDEX.md` - if the features table is empty, no features have been defined
|
|
||||||
|
|
||||||
**If the project is not initialized:**
|
1. Read `docs/PRD.md` — placeholder text like "_Describe what you are building_" means **not** initialized
|
||||||
- Do NOT write any code or create any components
|
2. Read `features/INDEX.md` — an empty feature table means no features defined
|
||||||
- Do NOT skip ahead to implementation
|
|
||||||
- Instead, tell the user: "This project hasn't been set up yet. Let's start by defining what you want to build. Run `/init` with a description of your idea (e.g. `/init I want to build a task management app`)."
|
|
||||||
- If the user already described their idea in the current message, run `/init` automatically with their description
|
|
||||||
|
|
||||||
**If the project is initialized but the user requests a feature not yet in INDEX.md:**
|
**Not initialized:** do not write code or create components. Say:
|
||||||
- Guide them to run `/write-spec` first to create the feature spec before any implementation
|
> "Dieses Projekt ist noch nicht aufgesetzt. Starte mit `/init` und beschreibe kurz deine Idee
|
||||||
|
> (z. B. `/init Ich will eine Aufgabenverwaltung bauen`)."
|
||||||
|
|
||||||
|
If the user already described their idea in that same message, run `/init` with it directly.
|
||||||
|
|
||||||
|
**Initialized, but the requested feature is not in INDEX.md:** guide the user to `/write-spec` first.
|
||||||
|
|
||||||
## Feature Tracking
|
## Feature Tracking
|
||||||
- All features are tracked in `features/INDEX.md` - read it before starting any work
|
|
||||||
- Feature specs live in `features/PROJ-X-feature-name.md`
|
|
||||||
- Feature IDs are sequential: check INDEX.md for the next available number
|
|
||||||
- One feature per spec file (Single Responsibility)
|
|
||||||
- Never combine multiple independent functionalities in one spec
|
|
||||||
|
|
||||||
## Git Conventions
|
- `features/INDEX.md` is the index — read it before starting anything
|
||||||
- Commit format: `type(PROJ-X): description`
|
- Specs live in `features/PROJ-X-feature-name.md`, IDs are sequential
|
||||||
- Types: feat, fix, refactor, test, docs, deploy, chore
|
- One feature per spec file. Never combine independent functionality.
|
||||||
- Check existing features before creating new ones: `ls features/ | grep PROJ-`
|
|
||||||
- Check existing components before building: `git ls-files src/components/`
|
|
||||||
- Check existing APIs before building: `git ls-files src/app/api/`
|
|
||||||
|
|
||||||
## Human-in-the-Loop
|
## Status Flow
|
||||||
- Always ask for user approval before finalizing deliverables
|
|
||||||
- Present options using clear choices rather than open-ended questions
|
|
||||||
- Never proceed to the next workflow phase without user confirmation
|
|
||||||
|
|
||||||
## Status Updates (MANDATORY - Write-Then-Verify)
|
`Roadmap` → `Planned` → `Architected` → `In Progress` → `In Review` → `Approved` → `Deployed`
|
||||||
After completing work on any feature, you MUST update tracking files. Follow this exact sequence:
|
|
||||||
|
|
||||||
1. **Read** the feature spec (`features/PROJ-X-*.md`) and `features/INDEX.md` BEFORE editing
|
| Status | Set by |
|
||||||
2. **Write** your changes using the Edit tool — do NOT just describe what you would write
|
|--------|--------|
|
||||||
3. **Re-read** the file AFTER editing to verify the changes are actually present
|
| Roadmap | `/init` — feature identified, no spec file yet |
|
||||||
4. **If changes are missing**, repeat step 2 — never claim updates were made without verifying
|
| Planned | `/write-spec` |
|
||||||
|
| Architected | `/architecture` |
|
||||||
|
| In Progress | `/frontend` or `/backend` |
|
||||||
|
| In Review | `/qa` starts |
|
||||||
|
| Approved | `/qa` passes with no Critical/High bugs |
|
||||||
|
| Deployed | `/deploy` |
|
||||||
|
|
||||||
**What to update in the feature spec:**
|
## Status Updates (write, then verify)
|
||||||
- Status field in the header (Planned → In Progress → In Review → Deployed)
|
|
||||||
- Implementation notes: what was built, what changed, any deviations from the original spec
|
|
||||||
- Bug fixes or design changes discovered during implementation
|
|
||||||
|
|
||||||
**What to update in `features/INDEX.md`:**
|
1. **Read** the spec and `features/INDEX.md` before editing
|
||||||
- Feature status column must match the feature spec header
|
2. **Write** with the Edit tool — describing the change in chat is not doing it
|
||||||
- Valid statuses: Roadmap → Planned → Architected → In Progress → In Review → Approved → Deployed
|
3. **Re-read** the file afterwards to confirm the change landed
|
||||||
- **Roadmap**: after `/init` — feature identified, no spec file yet
|
4. If it did not land, repeat step 2
|
||||||
- **Planned**: after `/write-spec`
|
|
||||||
- **Architected**: after `/architecture`
|
|
||||||
- **In Progress**: after `/frontend` or `/backend` starts
|
|
||||||
- **In Review**: after `/qa` starts
|
|
||||||
- **Approved**: after `/qa` passes (no critical/high bugs)
|
|
||||||
- **Deployed**: after `/deploy`
|
|
||||||
|
|
||||||
**NEVER do this:**
|
Update in the spec: status header, implementation notes, deviations, bugs found.
|
||||||
- Do NOT say "I've updated the feature spec" without actually calling the Edit tool
|
Update in `INDEX.md`: the status column must match the spec header.
|
||||||
- Do NOT summarize changes in chat as a substitute for writing them to the file
|
|
||||||
- Do NOT skip updates because "it's obvious" or "minor"
|
Never claim a file was updated without having called Edit and verified the result.
|
||||||
|
|
||||||
## File Handling
|
## File Handling
|
||||||
- ALWAYS read a file before modifying it - never assume contents from memory
|
|
||||||
- After context compaction, re-read files before continuing work
|
|
||||||
- When unsure about current project state, read `features/INDEX.md` first
|
|
||||||
- Run `git diff` to verify what has already been changed in this session
|
|
||||||
- Never guess at import paths, component names, or API routes - verify by reading
|
|
||||||
|
|
||||||
## Handoffs Between Skills
|
- Read a file before modifying it — never work from memory
|
||||||
- After completing a skill, suggest the next skill to the user
|
- After context compaction, re-read before continuing
|
||||||
- Format: "Next step: Run `/skillname` to [action]"
|
- Verify import paths, component names and API routes by reading, never guess
|
||||||
- Handoffs are always user-initiated, never automatic
|
- `git diff` shows what this session already changed
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
- Commit format: `type(PROJ-X): description` (feat, fix, refactor, test, docs, deploy, chore)
|
||||||
|
- Check what exists before building: `git ls-files src/components/`, `git ls-files src/app/api/`
|
||||||
|
|
||||||
|
## Human-in-the-Loop
|
||||||
|
|
||||||
|
- Ask for approval before finalizing a deliverable
|
||||||
|
- Offer clear choices instead of open-ended questions
|
||||||
|
- Never advance to the next phase without confirmation
|
||||||
|
- End each skill with: "Nächster Schritt: `/skillname` — [Aktion]"
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
Specs, PRD and acceptance criteria are written in **German**. Code, comments,
|
||||||
|
commit messages and these instructions stay **English**.
|
||||||
|
|||||||
+60
-23
@@ -1,36 +1,73 @@
|
|||||||
---
|
---
|
||||||
paths:
|
paths:
|
||||||
- "src/app/api/**"
|
- "src/app/api/**"
|
||||||
|
- "src/lib/auth*"
|
||||||
|
- "src/server/**"
|
||||||
- ".env*"
|
- ".env*"
|
||||||
- "supabase/**"
|
|
||||||
- "next.config.*"
|
- "next.config.*"
|
||||||
|
- "Dockerfile"
|
||||||
|
- "compose.yaml"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Security Rules
|
# Security Rules
|
||||||
|
|
||||||
## Secrets Management
|
## Authentication: Better Auth with passkeys (mandatory)
|
||||||
- NEVER commit secrets, API keys, or credentials to git
|
|
||||||
- Use `.env.local` for local development (already in .gitignore)
|
|
||||||
- Use `NEXT_PUBLIC_` prefix ONLY for values safe to expose in browser
|
|
||||||
- Document all required env vars in `.env.local.example` with dummy values
|
|
||||||
|
|
||||||
## Input Validation
|
Whenever a project needs authentication, it uses **Better Auth** with the
|
||||||
- Validate ALL user input on the server side with Zod
|
**passkey plugin**. Do not substitute another auth library, and do not build a
|
||||||
- Never trust client-side validation alone
|
custom session/credential layer.
|
||||||
- Sanitize data before database insertion
|
|
||||||
|
|
||||||
## Authentication
|
```bash
|
||||||
- Always verify authentication before processing API requests
|
npm install better-auth
|
||||||
- Use Supabase RLS as a second line of defense
|
# passkey support ships as a Better Auth plugin — enable it in the auth config
|
||||||
- Implement rate limiting on authentication endpoints
|
```
|
||||||
|
|
||||||
## Security Headers
|
**Passkeys are the primary method.** The sign-in screen offers passkey first and
|
||||||
- X-Frame-Options: DENY
|
presents it as the default path. A password flow is permitted only as a fallback
|
||||||
- X-Content-Type-Options: nosniff
|
for users whose device cannot do WebAuthn.
|
||||||
- Referrer-Policy: origin-when-cross-origin
|
|
||||||
- Strict-Transport-Security with includeSubDomains
|
|
||||||
|
|
||||||
## Code Review Triggers
|
Requirements whenever auth is present:
|
||||||
- Any changes to RLS policies require explicit user approval
|
|
||||||
- Any changes to authentication flow require explicit user approval
|
- Passkey registration is offered during sign-up, not hidden in settings
|
||||||
- Any new environment variables must be documented in .env.local.example
|
- Existing password users are prompted to add a passkey after signing in
|
||||||
|
- The WebAuthn relying-party ID must match the deployment hostname. It is derived
|
||||||
|
from `NEXT_PUBLIC_APP_URL` / `BETTER_AUTH_URL` — a mismatch makes every passkey
|
||||||
|
fail verification, and it fails silently.
|
||||||
|
- Passkeys require HTTPS in production (localhost is exempt). The reverse proxy
|
||||||
|
terminates TLS — see `docs/DEPLOYMENT.md`.
|
||||||
|
- If the password fallback exists: rate-limit it, never reveal whether an account
|
||||||
|
exists, and store hashes only through Better Auth's own primitives
|
||||||
|
|
||||||
|
## Authorization
|
||||||
|
|
||||||
|
- Verify the session in every route handler before any work
|
||||||
|
- Authorization is per resource, not per route: having a session does not mean
|
||||||
|
owning the record. Scope the query by the session user.
|
||||||
|
- Never trust an ID coming from the client as proof of ownership
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
- Never commit secrets. `.env` is gitignored; only `.env.example` is committed.
|
||||||
|
- Every new variable is documented in `.env.example` with a dummy value
|
||||||
|
- `NEXT_PUBLIC_` only for values safe to expose in the browser bundle
|
||||||
|
- `BETTER_AUTH_SECRET` is generated per environment (`openssl rand -base64 32`)
|
||||||
|
and never shared between staging and production
|
||||||
|
|
||||||
|
## Input Handling
|
||||||
|
|
||||||
|
- Validate all input server-side with Zod, even when the client already did
|
||||||
|
- Never interpolate user input into SQL, shell commands or file paths
|
||||||
|
- Escape or avoid `dangerouslySetInnerHTML`
|
||||||
|
|
||||||
|
## Transport and Headers
|
||||||
|
|
||||||
|
Security headers are configured in `next.config.ts`: `X-Frame-Options: DENY`,
|
||||||
|
`X-Content-Type-Options: nosniff`, `Referrer-Policy: origin-when-cross-origin`,
|
||||||
|
`Strict-Transport-Security`. Verify they survive the reverse proxy after deploying.
|
||||||
|
|
||||||
|
## Changes That Need Explicit User Approval
|
||||||
|
|
||||||
|
- Any change to the authentication flow
|
||||||
|
- Weakening or removing the passkey requirement
|
||||||
|
- Any change to authorization or ownership checks
|
||||||
|
- New environment variables (must land in `.env.example` in the same commit)
|
||||||
|
|||||||
+26
-10
@@ -3,37 +3,53 @@
|
|||||||
"deny": [
|
"deny": [
|
||||||
"Bash(git push --force:*)",
|
"Bash(git push --force:*)",
|
||||||
"Bash(git push -f:*)",
|
"Bash(git push -f:*)",
|
||||||
"Bash(git push --force-with-lease:*)",
|
"Read(**/.env)",
|
||||||
"Read(**/.env*)",
|
"Read(**/.env.local)",
|
||||||
"Read(**/.dev.vars*)",
|
"Read(**/.env.development)",
|
||||||
|
"Read(**/.env.production)",
|
||||||
|
"Read(**/.env.test)",
|
||||||
|
"Read(**/.env.*.local)",
|
||||||
"Read(**/*.pem)",
|
"Read(**/*.pem)",
|
||||||
"Read(**/*.key)",
|
"Read(**/*.key)",
|
||||||
"Read(**/secrets/**)",
|
"Read(**/secrets/**)",
|
||||||
"Read(**/credentials/**)",
|
"Read(**/credentials/**)",
|
||||||
"Read(**/.aws/**)",
|
"Read(**/.aws/**)",
|
||||||
"Read(**/.ssh/**)",
|
"Read(**/.ssh/**)",
|
||||||
"Read(**/config/database.yml)",
|
|
||||||
"Read(**/config/credentials.json)",
|
|
||||||
"Read(**/.npmrc)",
|
"Read(**/.npmrc)",
|
||||||
"Read(**/.pypirc)",
|
"Edit(**/.env)",
|
||||||
"Edit(**/.env*)",
|
"Edit(**/.env.local)",
|
||||||
|
"Edit(**/.env.development)",
|
||||||
|
"Edit(**/.env.production)",
|
||||||
|
"Edit(**/.env.test)",
|
||||||
|
"Edit(**/.env.*.local)",
|
||||||
"Edit(**/secrets/**)",
|
"Edit(**/secrets/**)",
|
||||||
"Edit(**/.ssh/**)"
|
"Edit(**/.ssh/**)"
|
||||||
],
|
],
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(npm install)",
|
"Bash(npm install)",
|
||||||
|
"Bash(npm ci)",
|
||||||
"Bash(npm run dev:*)",
|
"Bash(npm run dev:*)",
|
||||||
"Bash(npm run build:*)",
|
"Bash(npm run build:*)",
|
||||||
"Bash(npm run lint:*)",
|
"Bash(npm run lint:*)",
|
||||||
|
"Bash(npm run typecheck:*)",
|
||||||
"Bash(npm run start:*)",
|
"Bash(npm run start:*)",
|
||||||
|
"Bash(npm test:*)",
|
||||||
|
"Bash(npm run test:*)",
|
||||||
|
"Bash(npx shadcn@latest add:*)",
|
||||||
|
"Bash(npx playwright install:*)",
|
||||||
|
"Bash(git add:*)",
|
||||||
"Bash(git commit:*)",
|
"Bash(git commit:*)",
|
||||||
"Bash(git push:*)",
|
"Bash(git push:*)",
|
||||||
"Bash(git tag:*)",
|
"Bash(git tag:*)",
|
||||||
"Bash(git log:*)",
|
"Bash(git log:*)",
|
||||||
|
"Bash(git diff:*)",
|
||||||
|
"Bash(git status:*)",
|
||||||
"Bash(git ls-files:*)",
|
"Bash(git ls-files:*)",
|
||||||
"Bash(lsof:*)",
|
"Bash(docker compose build:*)",
|
||||||
"Bash(kill:*)",
|
"Bash(docker compose up:*)",
|
||||||
"Bash(npx shadcn@latest add:*)",
|
"Bash(docker compose down:*)",
|
||||||
|
"Bash(docker compose ps:*)",
|
||||||
|
"Bash(docker compose logs:*)",
|
||||||
"Bash(ls:*)"
|
"Bash(ls:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,127 +1,134 @@
|
|||||||
---
|
---
|
||||||
name: architecture
|
name: architecture
|
||||||
description: Design PM-friendly technical architecture for features. No code, only high-level design decisions.
|
description: Design PM-friendly technical architecture for a feature. No code, only high-level decisions.
|
||||||
argument-hint: "feature-spec-path"
|
argument-hint: "PROJ-X or feature-spec-path"
|
||||||
user-invocable: true
|
user-invocable: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# Solution Architect
|
# Architecture
|
||||||
|
|
||||||
## Role
|
You translate a feature spec into a design a non-technical stakeholder can follow —
|
||||||
You are a Solution Architect who translates feature specs into understandable architecture plans. Your audience is product managers and non-technical stakeholders.
|
and you make the binding technical decisions the developers will implement.
|
||||||
|
|
||||||
## CRITICAL Rule
|
## Hard rule: no code
|
||||||
NEVER write code or show implementation details:
|
|
||||||
- No SQL queries
|
|
||||||
- No TypeScript/JavaScript code
|
|
||||||
- No API implementation snippets
|
|
||||||
- Focus: WHAT gets built and WHY, not HOW in detail
|
|
||||||
|
|
||||||
## Before Starting
|
No SQL, no TypeScript, no API snippets. Describe **what** gets built and **why**,
|
||||||
1. Read `features/INDEX.md` to understand project context
|
never **how** in detail. Structure and plain language only.
|
||||||
2. Verify the feature has a full spec — check that:
|
|
||||||
- The feature's status in INDEX.md is **"Planned"** (not "Roadmap")
|
|
||||||
- A spec file `features/PROJ-X-*.md` actually exists on disk
|
|
||||||
3. Check existing components: `git ls-files src/components/`
|
|
||||||
4. Check existing APIs: `git ls-files src/app/api/`
|
|
||||||
5. Read the feature spec the user references
|
|
||||||
|
|
||||||
**If the feature status is "Roadmap" or no spec file exists:**
|
## 1. Load context
|
||||||
> "This feature doesn't have a spec yet. Run `/write-spec PROJ-X` first — the architecture design needs user stories and acceptance criteria to work from."
|
|
||||||
→ Stop here.
|
|
||||||
|
|
||||||
## Workflow
|
1. Read `features/INDEX.md`
|
||||||
|
2. Read `docs/PRD.md`
|
||||||
|
3. Read the feature spec
|
||||||
|
4. `git ls-files src/components/ src/app/api/` — what already exists
|
||||||
|
|
||||||
### 1. Read Feature Spec
|
**If the status is "Roadmap" or no spec file exists:**
|
||||||
- Read `/features/PROJ-X.md`
|
> "PROJ-X hat noch keine Spec. Führe erst `/write-spec PROJ-X` aus — ohne User Stories
|
||||||
- Understand user stories + acceptance criteria
|
> und Acceptance Criteria kann ich das Design nicht entwerfen."
|
||||||
- Determine: Do we need backend? Or frontend-only?
|
→ Stop.
|
||||||
|
|
||||||
### 2. Ask Clarifying Questions (if needed)
|
## 2. Resolve the open decisions
|
||||||
Use `AskUserQuestion` for:
|
|
||||||
- Do we need login/user accounts?
|
|
||||||
- Should data sync across devices? (localStorage vs database)
|
|
||||||
- Are there multiple user roles?
|
|
||||||
- Any third-party integrations?
|
|
||||||
|
|
||||||
### 3. Create High-Level Design
|
One question at a time, each with a recommendation.
|
||||||
|
|
||||||
#### A) Component Structure (Visual Tree)
|
### Data layer (decide this first — it shapes everything else)
|
||||||
Show which UI parts are needed:
|
|
||||||
|
This template deliberately ships **no** database or ORM. Decide per feature:
|
||||||
|
|
||||||
|
> "Wo sollen die Daten liegen?"
|
||||||
|
> Empfehlung: Ohne Nutzerkonten und ohne Gerätewechsel reicht localStorage. Sobald
|
||||||
|
> Daten einen Neustart überleben oder zwischen Nutzern geteilt werden müssen, braucht
|
||||||
|
> es eine Datenbank im Compose-Stack.
|
||||||
|
|
||||||
|
If a database is needed, also settle the ORM/driver, and record both in the
|
||||||
|
Technical Decisions table. Constraint: it must be self-hostable in the Docker
|
||||||
|
Compose stack — no managed-only services.
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
> "Braucht dieses Feature angemeldete Nutzer?"
|
||||||
|
|
||||||
|
If yes, the stack is fixed: **Better Auth with the passkey plugin**, passkey as the
|
||||||
|
primary method, password only as a fallback. Do not re-open that choice — note it
|
||||||
|
as a dependency and move on.
|
||||||
|
|
||||||
|
### Remaining questions
|
||||||
|
|
||||||
|
- Multiple user roles with different permissions?
|
||||||
|
- Third-party integrations?
|
||||||
|
- Anything with unusual performance or volume characteristics?
|
||||||
|
|
||||||
|
## 3. Write the design
|
||||||
|
|
||||||
|
Add a **Tech Design** section to the spec containing:
|
||||||
|
|
||||||
|
**A) Component structure** — a plain tree of the UI parts:
|
||||||
```
|
```
|
||||||
Main Page
|
Board-Seite
|
||||||
+-- Input Area (add item)
|
├── Eingabefeld (neue Aufgabe)
|
||||||
+-- Board
|
├── Spalte "Offen"
|
||||||
| +-- "To Do" Column
|
│ └── Aufgabenkarte (verschiebbar)
|
||||||
| | +-- Task Cards (draggable)
|
├── Spalte "Erledigt"
|
||||||
| +-- "Done" Column
|
└── Leerzustand
|
||||||
| +-- Task Cards (draggable)
|
|
||||||
+-- Empty State Message
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### B) Data Model (plain language)
|
**B) Data model** — in words, no schema:
|
||||||
Describe what information is stored:
|
|
||||||
```
|
```
|
||||||
Each task has:
|
Eine Aufgabe hat:
|
||||||
- Unique ID
|
- eindeutige ID
|
||||||
- Title (max 200 characters)
|
- Titel (max. 200 Zeichen)
|
||||||
- Status (To Do or Done)
|
- Status (offen / erledigt)
|
||||||
- Created timestamp
|
- Erstellzeitpunkt
|
||||||
|
|
||||||
Stored in: Browser localStorage (no server needed)
|
Gespeichert in: Browser-localStorage (kein Server nötig)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### C) Tech Decisions (justified for PM)
|
**C) Tech decisions** — each justified in language a PM understands
|
||||||
Explain WHY specific tools/approaches are chosen in plain language.
|
|
||||||
|
|
||||||
#### D) Dependencies (packages to install)
|
**D) Dependencies** — package names with a one-line purpose each
|
||||||
List only package names with brief purpose.
|
|
||||||
|
|
||||||
### 4. Add Design to Feature Spec
|
## 4. Log the decisions
|
||||||
Add a "Tech Design (Solution Architect)" section to `/features/PROJ-X.md`
|
|
||||||
|
|
||||||
### 5. Log Technical Decisions
|
Every meaningful choice goes into the spec's **Technical Decisions** table:
|
||||||
For every meaningful technical choice made during this session, add an entry to the **Technical Decisions** table in the spec's Decision Log:
|
|
||||||
- Storage approach (localStorage vs. database, and why)
|
|
||||||
- Package choices (why this library over alternatives)
|
|
||||||
- Data model decisions (key names, structure, constraints)
|
|
||||||
- API design choices (REST vs. server action, auth strategy)
|
|
||||||
- Any decision that a future developer might otherwise question
|
|
||||||
|
|
||||||
**Format:**
|
|
||||||
```
|
```
|
||||||
| Decision | Rationale | Date |
|
| Entscheidung | Begründung | Datum |
|
||||||
| localStorage over Supabase | No user accounts needed; data is device-local | 2026-05-19 |
|
| localStorage statt Datenbank | Keine Nutzerkonten, Daten bleiben gerätelokal | 2026-08-09 |
|
||||||
```
|
```
|
||||||
|
|
||||||
If any questions came up during the design that couldn't be resolved, add them to the **Open Questions** section as `- [ ]` items.
|
Log at minimum: storage approach, ORM/driver if any, auth yes/no, package choices,
|
||||||
|
and anything a future developer would otherwise question.
|
||||||
|
|
||||||
### 5. User Review
|
Anything unresolved goes to **Open Questions** as `- [ ]`.
|
||||||
- Present the design for review
|
|
||||||
- Ask: "Does this design make sense? Any questions?"
|
|
||||||
- Wait for approval before suggesting handoff
|
|
||||||
|
|
||||||
## Checklist Before Completion
|
## 5. Review
|
||||||
- [ ] Checked existing architecture via git
|
|
||||||
- [ ] Feature spec read and understood
|
Present the design and ask:
|
||||||
- [ ] Component structure documented (visual tree, PM-readable)
|
> "Ergibt das Design so Sinn? Irgendwo Fragen oder Bauchschmerzen?"
|
||||||
- [ ] Data model described (plain language, no code)
|
|
||||||
- [ ] Backend need clarified (localStorage vs database)
|
Wait for approval.
|
||||||
- [ ] Tech decisions justified (WHY, not HOW)
|
|
||||||
|
## 6. Wrap up
|
||||||
|
|
||||||
|
- [ ] Existing components and APIs checked
|
||||||
|
- [ ] Component structure documented, PM-readable
|
||||||
|
- [ ] Data model in plain language, no code
|
||||||
|
- [ ] Data layer decided and justified
|
||||||
|
- [ ] Auth need clarified (Better Auth + passkey if yes)
|
||||||
- [ ] Dependencies listed
|
- [ ] Dependencies listed
|
||||||
- [ ] Design added to feature spec file
|
- [ ] Technical Decisions table filled
|
||||||
- [ ] Technical Decisions logged in spec's Decision Log
|
- [ ] Open Questions recorded
|
||||||
- [ ] Any new Open Questions added to spec
|
- [ ] Design written into the spec and re-read to verify
|
||||||
- [ ] User has reviewed and approved
|
- [ ] `features/INDEX.md` → **Architected**
|
||||||
- [ ] `features/INDEX.md` status updated to "Architected"
|
- [ ] User approved
|
||||||
|
|
||||||
## Handoff
|
## Handoff
|
||||||
After approval, tell the user:
|
|
||||||
> "Design is ready! Next step: Run `/frontend` to build the UI components for this feature."
|
|
||||||
>
|
|
||||||
> If this feature needs backend work, you'll run `/backend` after frontend is done.
|
|
||||||
|
|
||||||
## Git Commit
|
> "Design steht. Nächster Schritt: `/frontend` für die UI-Komponenten."
|
||||||
|
> Braucht das Feature einen Server-Teil, folgt `/backend` danach.
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
```
|
```
|
||||||
docs(PROJ-X): Add technical design for [feature name]
|
docs(PROJ-X): Add technical design for [feature name]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,113 +1,82 @@
|
|||||||
---
|
---
|
||||||
name: backend
|
name: backend
|
||||||
description: Build APIs, database schemas, and server-side logic with Supabase. Use after frontend is built.
|
description: Build route handlers, data access and Better Auth passkey flows. Use after frontend is built.
|
||||||
argument-hint: "feature-spec-path"
|
argument-hint: "PROJ-X or feature-spec-path"
|
||||||
user-invocable: true
|
user-invocable: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# Backend Developer
|
# Backend
|
||||||
|
|
||||||
## Role
|
You own the server-side phase for one feature. You do the interview and the review
|
||||||
You are an experienced Backend Developer. You read feature specs + tech design and implement APIs, database schemas, and server-side logic using Supabase and Next.js.
|
with the user; the **Backend Developer** sub-agent does the implementation.
|
||||||
|
|
||||||
## Before Starting
|
## 1. Load context
|
||||||
1. Read `features/INDEX.md` for project context
|
|
||||||
2. Read the feature spec referenced by the user (including Tech Design section)
|
|
||||||
3. Check existing APIs: `git ls-files src/app/api/`
|
|
||||||
4. Check existing database patterns: `git log --oneline -S "CREATE TABLE" -10`
|
|
||||||
5. Check existing lib files: `ls src/lib/`
|
|
||||||
|
|
||||||
## Workflow
|
1. Read `features/INDEX.md`
|
||||||
|
2. Read the feature spec — Tech Design and the Technical Decisions table
|
||||||
|
3. `git ls-files src/app/api/` and `ls src/lib/`
|
||||||
|
|
||||||
### 1. Read Feature Spec + Design
|
**If the Tech Design does not name a data layer:**
|
||||||
- Understand the data model from Solution Architect
|
> "Im Tech Design steht noch nicht, wo die Daten liegen sollen. Diese Vorlage bringt
|
||||||
- Identify tables, relationships, and RLS requirements
|
> bewusst keine Datenbank mit — das entscheidet `/architecture` pro Projekt.
|
||||||
- Identify API endpoints needed
|
> Lass uns das dort klären, bevor ich Schema-Code schreibe."
|
||||||
|
→ Stop.
|
||||||
|
|
||||||
### 2. Ask Technical Questions
|
## 2. Clarify the open technical questions
|
||||||
Use `AskUserQuestion` for:
|
|
||||||
- What permissions are needed? (Owner-only vs shared access)
|
|
||||||
- How do we handle concurrent edits?
|
|
||||||
- Do we need rate limiting for this feature?
|
|
||||||
- What specific input validations are required?
|
|
||||||
|
|
||||||
### 3. Create Database Schema
|
One at a time, each with a recommendation:
|
||||||
- Write SQL for new tables in Supabase SQL Editor
|
|
||||||
- Enable Row Level Security on EVERY table
|
|
||||||
- Create RLS policies for all CRUD operations
|
|
||||||
- Add indexes on performance-critical columns (WHERE, ORDER BY, JOIN)
|
|
||||||
- Use foreign keys with ON DELETE CASCADE where appropriate
|
|
||||||
|
|
||||||
### 4. Create API Routes
|
- Who may read and write these records? (owner only, shared, role-based)
|
||||||
- Create route handlers in `/src/app/api/`
|
- What happens on concurrent edits — last write wins, or conflict detection?
|
||||||
- Implement CRUD operations
|
- Which validation rules beyond the obvious types?
|
||||||
- Add Zod input validation on all POST/PUT endpoints
|
- Does this feature need rate limiting?
|
||||||
- Add proper error handling with meaningful messages
|
- Does it need authentication? If yes: Better Auth with the passkey plugin is the
|
||||||
- Always check authentication (verify user session)
|
standard — see `.claude/rules/security.md`. Passkey primary, password only as a
|
||||||
|
fallback.
|
||||||
|
|
||||||
### 5. Connect Frontend
|
## 3. Set status
|
||||||
- Update frontend components to use real API endpoints
|
|
||||||
- Replace any mock data or localStorage with API calls
|
|
||||||
- Handle loading and error states
|
|
||||||
|
|
||||||
### 6. Write Integration Tests
|
Update `features/INDEX.md` and the spec header to **In Progress**. Verify by
|
||||||
For each API route created, write a Vitest integration test in `src/app/api/[route]/[route].test.ts`:
|
re-reading.
|
||||||
- Test the happy path (valid input → expected response)
|
|
||||||
- Test validation errors (invalid input → 400 with error message)
|
|
||||||
- Test authentication (unauthenticated request → 401)
|
|
||||||
- Test authorization (wrong user → 403)
|
|
||||||
- Run tests: `npm test`
|
|
||||||
|
|
||||||
### 7. User Review
|
## 4. Delegate implementation
|
||||||
- Walk user through the API endpoints created
|
|
||||||
- Show test results
|
|
||||||
- Ask: "Do the APIs work correctly? Any edge cases to test?"
|
|
||||||
|
|
||||||
## Context Recovery
|
Invoke the **Backend Developer** sub-agent with:
|
||||||
If your context was compacted mid-task:
|
- the spec path
|
||||||
1. Re-read the feature spec you're implementing
|
- the data-layer decision from the Tech Design
|
||||||
2. Re-read `features/INDEX.md` for current status
|
- the answers from step 2
|
||||||
3. Run `git diff` to see what you've already changed
|
- which frontend components need wiring to real endpoints
|
||||||
4. Run `git ls-files src/app/api/` to see current API state
|
|
||||||
5. Continue from where you left off - don't restart or duplicate work
|
|
||||||
|
|
||||||
## Output Format Examples
|
Wait for its report. Do not implement in parallel yourself.
|
||||||
|
|
||||||
### Database Migration
|
## 5. Review with the user
|
||||||
```sql
|
|
||||||
CREATE TABLE tasks (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
status TEXT CHECK (status IN ('todo', 'in_progress', 'done')) DEFAULT 'todo',
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
|
Walk through the endpoints created and the test results, then:
|
||||||
|
> "Funktionieren die Endpunkte so? Fällt dir ein Randfall ein, den wir noch testen sollten?"
|
||||||
|
|
||||||
CREATE POLICY "Users see own tasks" ON tasks
|
Iterate by sending follow-up instructions to the same sub-agent.
|
||||||
FOR SELECT USING (auth.uid() = user_id);
|
|
||||||
|
|
||||||
CREATE INDEX idx_tasks_user_id ON tasks(user_id);
|
## 6. Wrap up
|
||||||
CREATE INDEX idx_tasks_status ON tasks(status);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Production References
|
Verify before declaring done:
|
||||||
- See [database-optimization.md](../../../docs/production/database-optimization.md) for query optimization
|
- [ ] `npm test`, `npm run lint`, `npm run typecheck`, `npm run build` pass
|
||||||
- See [rate-limiting.md](../../../docs/production/rate-limiting.md) for rate limiting setup
|
- [ ] Zod validation on every write endpoint
|
||||||
|
- [ ] Session checked in every handler; ownership scoped inside the query
|
||||||
## Checklist
|
- [ ] If auth: passkey is the primary flow
|
||||||
See [checklist.md](checklist.md) for the full implementation checklist.
|
- [ ] Indexes on filtered/sorted columns, list queries bounded
|
||||||
|
- [ ] Every new env var is in `.env.example`
|
||||||
After completion, update tracking files:
|
- [ ] No secrets in source
|
||||||
- [ ] Feature spec updated with implementation notes
|
- [ ] Frontend wired to real endpoints, mock data removed
|
||||||
- [ ] `features/INDEX.md` status updated to "In Progress"
|
- [ ] Spec updated with implementation notes (written **and** re-read)
|
||||||
|
- [ ] `features/INDEX.md` status is In Progress
|
||||||
|
- [ ] User approved
|
||||||
|
|
||||||
## Handoff
|
## Handoff
|
||||||
After completion:
|
|
||||||
> "Backend is done! Next step: Run `/qa` to test this feature against its acceptance criteria."
|
|
||||||
|
|
||||||
## Git Commit
|
> "Backend steht. Nächster Schritt: `/qa` zum Testen gegen die Acceptance Criteria."
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
```
|
```
|
||||||
feat(PROJ-X): Implement backend for [feature name]
|
feat(PROJ-X): Implement backend for [feature name]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
# Backend Implementation Checklist
|
|
||||||
|
|
||||||
## Core Checklist
|
|
||||||
- [ ] Checked existing tables/APIs via git before creating new ones
|
|
||||||
- [ ] Database tables created in Supabase
|
|
||||||
- [ ] Row Level Security enabled on ALL new tables
|
|
||||||
- [ ] RLS policies created for SELECT, INSERT, UPDATE, DELETE
|
|
||||||
- [ ] Indexes created on performance-critical columns
|
|
||||||
- [ ] Foreign keys set with appropriate ON DELETE behavior
|
|
||||||
- [ ] All planned API endpoints implemented in `/src/app/api/`
|
|
||||||
- [ ] Authentication verified (no access without valid session)
|
|
||||||
- [ ] Input validation with Zod on all POST/PUT requests
|
|
||||||
- [ ] Meaningful error messages with correct HTTP status codes
|
|
||||||
- [ ] No TypeScript errors in API routes
|
|
||||||
- [ ] All endpoints tested manually
|
|
||||||
- [ ] No hardcoded secrets in source code
|
|
||||||
- [ ] Frontend connected to real API endpoints
|
|
||||||
- [ ] User has reviewed and approved
|
|
||||||
|
|
||||||
## Verification (run before marking complete)
|
|
||||||
- [ ] `npm run build` passes without errors
|
|
||||||
- [ ] All acceptance criteria from feature spec addressed in API
|
|
||||||
- [ ] All API endpoints return correct status codes (test with curl or browser)
|
|
||||||
- [ ] `features/INDEX.md` status updated to "In Progress"
|
|
||||||
- [ ] Code committed to git
|
|
||||||
|
|
||||||
## Performance Checklist
|
|
||||||
- [ ] All frequently filtered columns have indexes
|
|
||||||
- [ ] No N+1 queries (use Supabase joins instead of loops)
|
|
||||||
- [ ] All list queries use `.limit()`
|
|
||||||
- [ ] Zod validation on all write endpoints
|
|
||||||
- [ ] Slow queries cached where appropriate (optional for MVP)
|
|
||||||
- [ ] Rate limiting on public-facing APIs (optional for MVP)
|
|
||||||
@@ -1,114 +1,124 @@
|
|||||||
---
|
---
|
||||||
name: deploy
|
name: deploy
|
||||||
description: Deploy to Vercel with production-ready checks, error tracking, and security headers setup.
|
description: Deploy the Docker Compose stack to a server, with pre-flight checks and rollback.
|
||||||
argument-hint: "feature-spec-path or 'to Vercel'"
|
argument-hint: "PROJ-X or 'production'"
|
||||||
user-invocable: true
|
user-invocable: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# DevOps Engineer
|
# Deploy
|
||||||
|
|
||||||
## Role
|
Self-hosted Docker Compose. There is no Vercel, no managed platform. The stack runs
|
||||||
You are an experienced DevOps Engineer handling deployment, environment setup, and production readiness.
|
one app container; TLS and routing are handled by a reverse proxy already present
|
||||||
|
on the host.
|
||||||
|
|
||||||
## Before Starting
|
## 1. Gate
|
||||||
1. Read `features/INDEX.md` to know what is being deployed
|
|
||||||
2. Check QA status in the feature spec
|
|
||||||
3. Verify no Critical/High bugs exist in QA results
|
|
||||||
4. If QA has not been done, tell the user: "Run `/qa` first before deploying."
|
|
||||||
|
|
||||||
## Workflow
|
1. Read `features/INDEX.md` — what is being shipped
|
||||||
|
2. Read the feature spec's QA section
|
||||||
|
|
||||||
### 1. Pre-Deployment Checks
|
**No QA section:** → "Für PROJ-X gibt es noch kein QA-Ergebnis. Führe erst `/qa` aus." → Stop.
|
||||||
- [ ] `npm run build` succeeds locally
|
**Critical or High bugs open:** → name them and stop. Those get fixed first.
|
||||||
- [ ] `npm run lint` passes
|
|
||||||
- [ ] QA Engineer has approved the feature (check feature spec)
|
|
||||||
- [ ] No Critical/High bugs in test report
|
|
||||||
- [ ] All environment variables documented in `.env.local.example`
|
|
||||||
- [ ] No secrets committed to git
|
|
||||||
- [ ] All database migrations applied in Supabase (if applicable)
|
|
||||||
- [ ] All code committed and pushed to remote
|
|
||||||
|
|
||||||
### 2. Vercel Setup (first deployment only)
|
## 2. Pre-flight
|
||||||
|
|
||||||
> **Prerequisite (manual, do this first):** The user must create a Vercel account in the browser before this step — go to [vercel.com](https://vercel.com) and sign up (e.g. "Sign up with GitHub"). Account creation and login are browser/OAuth steps that cannot be automated by the skill. Also ensure the repo is pushed to a GitHub remote so Vercel can connect to it.
|
Run and show the results — do not proceed on a failure:
|
||||||
|
|
||||||
Guide the user through:
|
```bash
|
||||||
- [ ] Create Vercel project: `npx vercel` or via vercel.com
|
npm run lint
|
||||||
- [ ] Connect GitHub repository for auto-deploy on push
|
npm run typecheck
|
||||||
- [ ] Add all environment variables from `.env.local.example` in Vercel Dashboard
|
npm test
|
||||||
- [ ] Build settings: Framework Preset = Next.js (auto-detected)
|
npm run build
|
||||||
- [ ] Configure domain (or use default `*.vercel.app`)
|
```
|
||||||
|
|
||||||
### 3. Deploy
|
Then check:
|
||||||
- Push to main branch → Vercel auto-deploys
|
- [ ] Every env var used in code is in `.env.example` with a dummy value
|
||||||
- Or manual: `npx vercel --prod`
|
- [ ] No secrets in the diff (`git diff --cached`)
|
||||||
- Monitor build in Vercel Dashboard
|
- [ ] Pending migrations are committed
|
||||||
|
- [ ] Everything committed and pushed to the remote the server pulls from
|
||||||
|
|
||||||
### 4. Post-Deployment Verification
|
## 3. First deployment only
|
||||||
- [ ] Production URL loads correctly
|
|
||||||
- [ ] Deployed feature works as expected
|
|
||||||
- [ ] Database connections work (if applicable)
|
|
||||||
- [ ] Authentication flows work (if applicable)
|
|
||||||
- [ ] No errors in browser console
|
|
||||||
- [ ] No errors in Vercel function logs
|
|
||||||
|
|
||||||
### 5. Production-Ready Essentials
|
Walk the user through this once. It happens on the server, over SSH.
|
||||||
|
|
||||||
For first deployment, guide the user through these setup guides:
|
**Prerequisites the user provides manually:** a server with Docker and the Compose
|
||||||
|
plugin, a reverse proxy (nginx, Caddy, Traefik) terminating TLS, and a DNS record
|
||||||
|
pointing at it.
|
||||||
|
|
||||||
**Error Tracking (5 min):** See [error-tracking.md](../../../docs/production/error-tracking.md)
|
```bash
|
||||||
**Security Headers (copy-paste):** See [security-headers.md](../../../docs/production/security-headers.md)
|
git clone <repo-url> /srv/<app-name> && cd /srv/<app-name>
|
||||||
**Performance Check:** See [performance.md](../../../docs/production/performance.md)
|
cp .env.example .env
|
||||||
**Database Optimization:** See [database-optimization.md](../../../docs/production/database-optimization.md)
|
```
|
||||||
**Rate Limiting (optional):** See [rate-limiting.md](../../../docs/production/rate-limiting.md)
|
|
||||||
|
|
||||||
### 6. Post-Deployment Bookkeeping
|
Then edit `.env`:
|
||||||
- Update feature spec: Add deployment section with production URL and date
|
- `NEXT_PUBLIC_APP_URL` — the real public URL. **If auth is used, this must match
|
||||||
- Update `features/INDEX.md`: Set status to **Deployed**
|
the domain exactly**: the WebAuthn relying-party ID comes from this hostname, and
|
||||||
- Create git tag: `git tag -a v1.X.0-PROJ-X -m "Deploy PROJ-X: [Feature Name]"`
|
a mismatch makes every passkey fail without a useful error.
|
||||||
- Push tag: `git push origin v1.X.0-PROJ-X`
|
- `APP_PORT` — the port the reverse proxy forwards to
|
||||||
|
- `APP_BIND` — leave at `127.0.0.1` so the container is not exposed directly
|
||||||
|
- `BETTER_AUTH_SECRET` — `openssl rand -base64 32`, unique per environment
|
||||||
|
|
||||||
## Common Issues
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
curl -fsS http://127.0.0.1:${APP_PORT:-3000}/api/health
|
||||||
|
```
|
||||||
|
|
||||||
### Build fails on Vercel but works locally
|
Point the reverse proxy at `127.0.0.1:$APP_PORT` and make sure it forwards
|
||||||
- Check Node.js version (Vercel may use different version)
|
`X-Forwarded-Proto` and `X-Forwarded-Host`, otherwise redirects and WebAuthn origin
|
||||||
- Ensure all dependencies are in package.json (not just devDependencies)
|
checks break.
|
||||||
- Review Vercel build logs for specific error
|
|
||||||
|
|
||||||
### Environment variables not available
|
Passkeys need HTTPS in production — confirm TLS works before testing auth.
|
||||||
- Verify vars are set in Vercel Dashboard (Settings → Environment Variables)
|
|
||||||
- Client-side vars need `NEXT_PUBLIC_` prefix
|
|
||||||
- Redeploy after adding new env vars (they don't apply retroactively)
|
|
||||||
|
|
||||||
### Database connection errors
|
## 4. Subsequent deployments
|
||||||
- Verify Supabase URL and anon key in Vercel env vars
|
|
||||||
- Check RLS policies allow the operations being attempted
|
|
||||||
- Verify Supabase project is not paused (free tier pauses after inactivity)
|
|
||||||
|
|
||||||
## Rollback Instructions
|
On the server:
|
||||||
If production is broken:
|
|
||||||
1. **Immediate:** Vercel Dashboard → Deployments → Click "..." on previous working deployment → "Promote to Production"
|
|
||||||
2. **Fix locally:** Debug the issue, `npm run build`, commit, push
|
|
||||||
3. Vercel auto-deploys the fix
|
|
||||||
|
|
||||||
## Full Deployment Checklist
|
```bash
|
||||||
- [ ] Pre-deployment checks all pass
|
./scripts/update.sh
|
||||||
- [ ] Vercel build successful
|
```
|
||||||
- [ ] Production URL loads and works
|
|
||||||
- [ ] Feature tested in production environment
|
It pulls the branch, rebuilds, restarts, waits for the healthcheck, and rolls back
|
||||||
- [ ] No console errors, no Vercel log errors
|
to the previous commit if the new container never turns healthy. `--force` rebuilds
|
||||||
- [ ] Error tracking setup (Sentry or alternative)
|
without a new commit; `--branch <name>` deploys a different branch.
|
||||||
- [ ] Security headers configured in next.config
|
|
||||||
- [ ] Lighthouse score checked (target > 90)
|
If a data layer exists, migrations run before the new container serves traffic —
|
||||||
- [ ] Feature spec updated with deployment info
|
see `docs/DEPLOYMENT.md`.
|
||||||
- [ ] `features/INDEX.md` updated to Deployed
|
|
||||||
- [ ] Git tag created and pushed
|
## 5. Verify
|
||||||
- [ ] User has verified production deployment
|
|
||||||
|
- [ ] Public URL loads over HTTPS
|
||||||
|
- [ ] `/api/health` returns `{"status":"ok"}`
|
||||||
|
- [ ] The deployed feature works end to end
|
||||||
|
- [ ] Sign-in works and offers passkey first (if auth exists)
|
||||||
|
- [ ] No console errors, no errors in `docker compose logs app`
|
||||||
|
- [ ] Security headers survived the proxy (DevTools → Network → Response Headers)
|
||||||
|
|
||||||
|
## 6. Bookkeeping
|
||||||
|
|
||||||
|
- Add a Deployment section to the spec: URL, date, commit SHA
|
||||||
|
- `features/INDEX.md` → **Deployed** (write, then re-read to verify)
|
||||||
|
- Tag: `git tag -a v1.X.0-PROJ-X -m "Deploy PROJ-X: [name]"` and push it
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
`scripts/update.sh` rolls back automatically on an unhealthy container. Manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git reset --hard <previous-sha>
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common failures
|
||||||
|
|
||||||
|
| Symptom | Cause |
|
||||||
|
|---------|-------|
|
||||||
|
| Build works locally, fails in Docker | A dependency sits in devDependencies but is needed at build time |
|
||||||
|
| Env var undefined at runtime | Not in `.env` on the server, or missing `NEXT_PUBLIC_` for client use |
|
||||||
|
| Container healthy, proxy returns 502 | Proxy points at the wrong port, or `APP_BIND` is not reachable from it |
|
||||||
|
| Passkeys fail after deploy | `NEXT_PUBLIC_APP_URL` does not match the real domain, or TLS is missing |
|
||||||
|
| Redirect loop | Proxy not forwarding `X-Forwarded-Proto` |
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
## Git Commit
|
|
||||||
```
|
```
|
||||||
deploy(PROJ-X): Deploy [feature name] to production
|
deploy(PROJ-X): Deploy [feature name] to production
|
||||||
|
|
||||||
- Production URL: https://your-app.vercel.app
|
|
||||||
- Deployed: YYYY-MM-DD
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,95 +1,84 @@
|
|||||||
---
|
---
|
||||||
name: frontend
|
name: frontend
|
||||||
description: Build UI components with React, Next.js, Tailwind CSS, and shadcn/ui. Use after architecture is designed.
|
description: Build UI components with React, Next.js, Tailwind v4 and shadcn/ui. Use after architecture is designed.
|
||||||
argument-hint: "feature-spec-path"
|
argument-hint: "PROJ-X or feature-spec-path"
|
||||||
user-invocable: true
|
user-invocable: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# Frontend Developer
|
# Frontend
|
||||||
|
|
||||||
## Role
|
You own the UI phase for one feature. You do the interview and the review with the
|
||||||
You are an experienced Frontend Developer. You read feature specs + tech design and implement the UI using React, Next.js, Tailwind CSS, and shadcn/ui.
|
user; the **Frontend Developer** sub-agent does the implementation.
|
||||||
|
|
||||||
## Before Starting
|
## 1. Load context
|
||||||
1. Read `features/INDEX.md` for project context
|
|
||||||
2. Read the feature spec referenced by the user (including Tech Design section)
|
|
||||||
3. Check installed shadcn/ui components: `ls src/components/ui/`
|
|
||||||
4. Check existing custom components: `ls src/components/*.tsx 2>/dev/null`
|
|
||||||
5. Check existing hooks: `ls src/hooks/ 2>/dev/null`
|
|
||||||
6. Check existing pages: `ls src/app/`
|
|
||||||
|
|
||||||
## Workflow
|
1. Read `features/INDEX.md`
|
||||||
|
2. Read the feature spec, including its Tech Design section
|
||||||
|
3. `ls src/components/ui/` — installed shadcn components
|
||||||
|
4. `git ls-files src/components/` — existing custom components
|
||||||
|
|
||||||
### 1. Read Feature Spec + Design
|
**If the spec has no Tech Design section:**
|
||||||
- Understand the component architecture from Solution Architect
|
> "Für PROJ-X gibt es noch kein technisches Design. Führe zuerst `/architecture` aus —
|
||||||
- Identify which shadcn/ui components to use
|
> ohne Komponentenstruktur baue ich sonst am Entwurf vorbei."
|
||||||
- Identify what needs to be built custom
|
→ Stop.
|
||||||
|
|
||||||
### 2. Clarify Design Requirements
|
## 2. Clarify design
|
||||||
First check for a project design system: `cat docs/design-system.md 2>/dev/null`
|
|
||||||
|
|
||||||
If `docs/design-system.md` exists → read it and apply its colors, typography, and component guidelines throughout. Do not ask the user about design choices already covered there.
|
Check for a design system: `cat docs/design-system.md 2>/dev/null`
|
||||||
|
|
||||||
If it does not exist, check for other design files: `ls -la design/ mockups/ assets/ 2>/dev/null`
|
If it exists, follow it and do not ask about anything it already answers.
|
||||||
|
Otherwise check `ls design/ mockups/ 2>/dev/null`, and if nothing exists, ask the
|
||||||
|
user — one question at a time, each with a recommendation:
|
||||||
|
|
||||||
If no design specs exist at all, ask the user:
|
- Visual style (modern/minimal, corporate, playful)
|
||||||
- Visual style preference (modern/minimal, corporate, playful, dark mode)
|
- Brand colors, or Tailwind/shadcn defaults
|
||||||
- Reference designs or inspiration URLs
|
- Layout (sidebar, top-nav, centered)
|
||||||
- Brand colors (hex codes or use Tailwind defaults)
|
- Interactions beyond the basics (drag & drop, animations)
|
||||||
- Layout preference (sidebar, top-nav, centered)
|
|
||||||
|
|
||||||
### 3. Clarify Technical Questions
|
## 3. Set status
|
||||||
- Mobile-first or desktop-first?
|
|
||||||
- Any specific interactions needed (hover effects, animations, drag & drop)?
|
|
||||||
- Accessibility requirements beyond defaults (WCAG 2.1 AA)?
|
|
||||||
|
|
||||||
### 4. Implement Components
|
Update `features/INDEX.md` and the spec header to **In Progress**. Verify by
|
||||||
- Create components in `/src/components/`
|
re-reading.
|
||||||
- ALWAYS use shadcn/ui for standard UI elements (check `src/components/ui/` first!)
|
|
||||||
- If a shadcn component is missing, install it: `npx shadcn@latest add <name> --yes`
|
|
||||||
- Only create custom components as compositions of shadcn primitives
|
|
||||||
- Use Tailwind CSS for all styling
|
|
||||||
|
|
||||||
### 5. Integrate into Pages
|
## 4. Delegate implementation
|
||||||
- Add components to pages in `/src/app/`
|
|
||||||
- Set up routing if needed
|
|
||||||
- Connect to backend APIs or localStorage as specified in tech design
|
|
||||||
|
|
||||||
### 6. User Review
|
Invoke the **Frontend Developer** sub-agent with:
|
||||||
- Tell the user to test in browser (localhost:3000)
|
- the spec path
|
||||||
- Ask: "Does the UI look right? Any changes needed?"
|
- the design decisions from step 2
|
||||||
- Iterate based on feedback
|
- the component list from the Tech Design
|
||||||
|
- the shadcn components already installed
|
||||||
|
|
||||||
## Context Recovery
|
Wait for its report. Do not implement in parallel yourself.
|
||||||
If your context was compacted mid-task:
|
|
||||||
1. Re-read the feature spec you're implementing
|
|
||||||
2. Re-read `features/INDEX.md` for current status
|
|
||||||
3. Run `git diff` to see what you've already changed
|
|
||||||
4. Run `git ls-files src/components/ | head -20` to see current component state
|
|
||||||
5. Continue from where you left off - don't restart or duplicate work
|
|
||||||
|
|
||||||
## After Completion: Backend & QA Handoff
|
## 5. Review with the user
|
||||||
|
|
||||||
Check the feature spec - does this feature need backend?
|
Present the sub-agent's report, then:
|
||||||
|
> "Läuft unter http://localhost:3000. Schau es dir an — passt das so?"
|
||||||
|
|
||||||
**Backend needed if:** Database access, user authentication, server-side logic, API endpoints, multi-user data sync
|
Iterate on feedback by sending follow-up instructions to the same sub-agent, so it
|
||||||
|
keeps its context.
|
||||||
|
|
||||||
**No backend if:** localStorage only, no user accounts, no server communication
|
## 6. Wrap up
|
||||||
|
|
||||||
If backend is needed:
|
Verify before declaring done:
|
||||||
> "Frontend is done! This feature needs backend work. Next step: Run `/backend` to build the APIs and database."
|
- [ ] `npm run lint`, `npm run typecheck`, `npm run build` pass
|
||||||
|
- [ ] Every acceptance criterion has a UI counterpart
|
||||||
|
- [ ] shadcn used everywhere it could be; no hand-written duplicates
|
||||||
|
- [ ] Loading, error and empty states exist
|
||||||
|
- [ ] Responsive at 375 / 768 / 1440px
|
||||||
|
- [ ] Spec updated with implementation notes (written **and** re-read)
|
||||||
|
- [ ] `features/INDEX.md` status is In Progress
|
||||||
|
- [ ] User approved
|
||||||
|
|
||||||
If no backend needed:
|
## Handoff
|
||||||
> "Frontend is done! Next step: Run `/qa` to test this feature against its acceptance criteria."
|
|
||||||
|
|
||||||
## Checklist
|
Does the feature need a server side (data storage, auth, APIs, cross-device sync)?
|
||||||
See [checklist.md](checklist.md) for the full implementation checklist.
|
|
||||||
|
|
||||||
After completion, update tracking files:
|
- Yes → "Frontend steht. Nächster Schritt: `/backend` für APIs und Datenhaltung."
|
||||||
- [ ] Feature spec updated with implementation notes
|
- No → "Frontend steht. Nächster Schritt: `/qa` zum Testen gegen die Acceptance Criteria."
|
||||||
- [ ] `features/INDEX.md` status updated to "In Progress"
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
## Git Commit
|
|
||||||
```
|
```
|
||||||
feat(PROJ-X): Implement frontend for [feature name]
|
feat(PROJ-X): Implement frontend for [feature name]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
# Frontend Implementation Checklist
|
|
||||||
|
|
||||||
Before marking frontend as complete:
|
|
||||||
|
|
||||||
## shadcn/ui
|
|
||||||
- [ ] Checked shadcn/ui for EVERY UI component needed
|
|
||||||
- [ ] No custom duplicates of shadcn components created
|
|
||||||
- [ ] Missing shadcn components installed via `npx shadcn@latest add`
|
|
||||||
|
|
||||||
## Existing Code
|
|
||||||
- [ ] Checked existing project components via `git ls-files src/components/`
|
|
||||||
- [ ] Reused existing components where possible
|
|
||||||
|
|
||||||
## Design
|
|
||||||
- [ ] Design preferences clarified with user (if no mockups)
|
|
||||||
- [ ] Component architecture from Solution Architect followed
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
- [ ] All planned components implemented
|
|
||||||
- [ ] All components use Tailwind CSS (no inline styles, no CSS modules)
|
|
||||||
- [ ] Loading states implemented (spinner/skeleton during data fetches)
|
|
||||||
- [ ] Error states implemented (user-friendly error messages)
|
|
||||||
- [ ] Empty states implemented ("No data yet" messages)
|
|
||||||
|
|
||||||
## Quality
|
|
||||||
- [ ] Responsive: Mobile (375px), Tablet (768px), Desktop (1440px)
|
|
||||||
- [ ] Accessibility: Semantic HTML, ARIA labels, keyboard navigation
|
|
||||||
- [ ] TypeScript: No errors (`npm run build` passes)
|
|
||||||
- [ ] ESLint: No warnings (`npm run lint`)
|
|
||||||
|
|
||||||
## Verification (run before marking complete)
|
|
||||||
- [ ] `npm run build` passes without errors
|
|
||||||
- [ ] All acceptance criteria from feature spec addressed in UI
|
|
||||||
- [ ] `features/INDEX.md` status updated to "In Progress"
|
|
||||||
|
|
||||||
## Completion
|
|
||||||
- [ ] User has reviewed and approved the UI in browser
|
|
||||||
- [ ] Code committed to git
|
|
||||||
@@ -1,104 +1,58 @@
|
|||||||
---
|
---
|
||||||
name: help
|
name: help
|
||||||
description: Context-aware guide that tells you where you are in the workflow and what to do next. Use anytime you're unsure.
|
description: Context-aware guide — where the project stands and what to do next.
|
||||||
argument-hint: "optional question"
|
argument-hint: "optional question"
|
||||||
user-invocable: true
|
user-invocable: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project Help Guide
|
# Help
|
||||||
|
|
||||||
You are a helpful project assistant. Your job is to analyze the current project state and tell the user exactly where they are and what to do next.
|
Work out where the project actually stands, then say what to do next. Be concrete,
|
||||||
|
be short.
|
||||||
|
|
||||||
## When Invoked
|
## 1. Read the state
|
||||||
|
|
||||||
### Step 1: Analyze Current State
|
1. `docs/PRD.md` — still the empty template, or filled in?
|
||||||
|
2. `features/INDEX.md` — which features exist, at which status?
|
||||||
|
3. For the active feature, check which sections its spec already has:
|
||||||
|
Tech Design (`/architecture`), QA Test Results (`/qa`), Deployment (`/deploy`)
|
||||||
|
4. Quick scan: `ls src/components/ui/`, `ls src/app/api/ 2>/dev/null`, `ls tests/ 2>/dev/null`
|
||||||
|
|
||||||
Read these files to understand where the project stands:
|
Trust the files over the status column if they disagree — and say so when they do.
|
||||||
|
|
||||||
1. **Check PRD:** Read `docs/PRD.md`
|
## 2. Name the next step
|
||||||
- Is it still the empty template? → Project not initialized yet
|
|
||||||
- Is it filled out? → Project has been set up
|
|
||||||
|
|
||||||
2. **Check Feature Index:** Read `features/INDEX.md`
|
| State | Next step |
|
||||||
- No features listed? → No features created yet
|
|-------|-----------|
|
||||||
- Features exist? → Check their statuses
|
| PRD is the empty template | `/init` mit einer kurzen Beschreibung der Idee |
|
||||||
|
| PRD filled, no features | `/write-spec` für das erste Feature |
|
||||||
|
| Status Roadmap | `/write-spec PROJ-X` — Spec schreiben |
|
||||||
|
| Status Planned | `/architecture PROJ-X` — technisches Design |
|
||||||
|
| Status Architected | `/frontend PROJ-X` — UI bauen |
|
||||||
|
| Frontend done, needs a server side | `/backend PROJ-X` |
|
||||||
|
| Implemented, no QA section | `/qa PROJ-X` |
|
||||||
|
| Status Approved | `/deploy PROJ-X` |
|
||||||
|
| Everything deployed | `/write-spec` für das nächste Feature aus der PRD-Roadmap |
|
||||||
|
|
||||||
3. **Check Feature Specs:** For each feature in INDEX.md, check if:
|
## 3. Answer the question, if there was one
|
||||||
- Tech Design section exists (added by /architecture)
|
|
||||||
- QA Test Results section exists (added by /qa)
|
|
||||||
- Deployment section exists (added by /deploy)
|
|
||||||
|
|
||||||
4. **Check Codebase:** Quick scan of what's been built
|
If the user asked something specific, answer that **first**, then show the status.
|
||||||
- `ls src/components/*.tsx 2>/dev/null` → Custom components
|
Common ones: which skills exist, how to add a feature, how to customize the
|
||||||
- `ls src/app/api/ 2>/dev/null` → API routes
|
template (CLAUDE.md, `.claude/rules/`, `.claude/skills/`), how deployment works
|
||||||
- `ls src/components/ui/` → Installed shadcn components
|
(Docker Compose, `scripts/update.sh` — no Vercel).
|
||||||
|
|
||||||
### Step 2: Determine Next Action
|
## Output
|
||||||
|
|
||||||
Based on the state analysis, determine what the user should do next:
|
### Stand
|
||||||
|
_Zwei, drei Sätze: wo das Projekt steht._
|
||||||
|
|
||||||
**If PRD is empty template:**
|
### Features
|
||||||
> Your project hasn't been initialized yet.
|
_Tabelle aus INDEX.md._
|
||||||
> Run `/init` with a description of what you want to build.
|
|
||||||
> Example: `/init I want to build a task management app for small teams`
|
|
||||||
|
|
||||||
**If PRD exists but no features:**
|
### Nächster Schritt
|
||||||
> Your PRD is set up but no features have been created yet.
|
_Der eine wichtigste Schritt, mit dem exakten Kommando._
|
||||||
> Run `/write-spec` to create your first feature specification.
|
|
||||||
|
|
||||||
**If features exist with status "Planned" (no Tech Design):**
|
### Sonst möglich
|
||||||
> Feature PROJ-X is ready for architecture design.
|
_Kurze Liste weiterer sinnvoller Aktionen._
|
||||||
> Run `/architecture` to create the technical design for `features/PROJ-X-name.md`
|
|
||||||
|
|
||||||
**If features have Tech Design but no implementation:**
|
Give exact commands and real file paths. Do not explain the framework unless asked.
|
||||||
> Feature PROJ-X has a tech design and is ready for implementation.
|
|
||||||
> Run `/frontend` to build the UI for `features/PROJ-X-name.md`
|
|
||||||
> (If backend is needed, run `/backend` after frontend is done)
|
|
||||||
|
|
||||||
**If features are implemented but no QA:**
|
|
||||||
> Feature PROJ-X is implemented and ready for testing.
|
|
||||||
> Run `/qa` to test `features/PROJ-X-name.md` against its acceptance criteria.
|
|
||||||
|
|
||||||
**If features have passed QA but aren't deployed:**
|
|
||||||
> Feature PROJ-X has passed QA and is ready for deployment.
|
|
||||||
> Run `/deploy` to deploy to production.
|
|
||||||
|
|
||||||
**If all features are deployed:**
|
|
||||||
> All current features are deployed! You can:
|
|
||||||
> - Run `/write-spec` to spec out a new feature
|
|
||||||
> - Check `docs/PRD.md` for planned features not yet specified
|
|
||||||
|
|
||||||
### Step 3: Answer User Questions
|
|
||||||
|
|
||||||
If the user asked a specific question (via arguments), answer it in the context of the current project state. Common questions:
|
|
||||||
|
|
||||||
- "What skills are available?" → List all available skills with brief descriptions
|
|
||||||
- "How do I add a new feature?" → Explain `/write-spec` workflow (or `/init` if the project isn't set up yet)
|
|
||||||
- "How do I customize this template?" → Point to CLAUDE.md, rules/, skills/
|
|
||||||
- "What's the project structure?" → Explain the directory layout
|
|
||||||
- "How do I deploy?" → Explain `/deploy` workflow and prerequisites
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
|
|
||||||
Always respond with this structure:
|
|
||||||
|
|
||||||
### Current Project Status
|
|
||||||
_Brief summary of where the project stands_
|
|
||||||
|
|
||||||
### Features Overview
|
|
||||||
_Table of features and their current status (from INDEX.md)_
|
|
||||||
|
|
||||||
### Recommended Next Step
|
|
||||||
_The single most important thing to do next, with the exact command_
|
|
||||||
|
|
||||||
### Other Available Actions
|
|
||||||
_Other things the user could do right now_
|
|
||||||
|
|
||||||
If the user asked a specific question, answer that FIRST, then show the status overview.
|
|
||||||
|
|
||||||
## Important
|
|
||||||
- Be concise and actionable
|
|
||||||
- Always give the exact command to run
|
|
||||||
- Reference specific file paths
|
|
||||||
- Don't explain the framework architecture in detail unless asked
|
|
||||||
- Focus on: "Here's where you are, here's what to do next"
|
|
||||||
|
|||||||
+80
-106
@@ -1,146 +1,120 @@
|
|||||||
---
|
---
|
||||||
name: init
|
name: init
|
||||||
description: Initialize a new project. Creates the PRD and a prioritized feature map. Run once at the very start of a new project. If a PRD is empty (raw template stucture) use this skill to plan out the project togehter with the user.
|
description: Initialize a new project — PRD and prioritized feature map. Run once at the very start.
|
||||||
argument-hint: "description of what you want to build"
|
argument-hint: "description of what you want to build"
|
||||||
user-invocable: true
|
user-invocable: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project Initializer
|
# Init
|
||||||
|
|
||||||
## Role
|
You are a product strategist. You interview the user until the project is genuinely
|
||||||
You are an experienced Product Strategist. Your job is to help the user articulate their project vision and break it down into a prioritized feature map — before any code is written.
|
understood, then write the PRD and break it into a feature map. No code, no specs.
|
||||||
|
|
||||||
## The Grill Me Principle
|
## Grill Me principle
|
||||||
Interview the user relentlessly until you reach a **complete shared understanding** of the project. Follow these rules strictly:
|
|
||||||
|
|
||||||
- **One question at a time** — never list multiple questions
|
- **One question at a time** — never a list
|
||||||
- **Always provide a recommended answer** — the user confirms or corrects it
|
- **Always offer a recommended answer** — the user confirms or corrects it
|
||||||
- **Follow the conversation** — open new branches based on answers, don't follow a fixed script
|
- **Follow the conversation** — open branches based on answers, no fixed script
|
||||||
- **Explore before asking** — if a question can be answered by reading existing files, read them first
|
- **Read before asking** — if a file answers it, read the file
|
||||||
- **No fixed question limit** — stop when you truly understand the project, not after N questions
|
- **No question limit** — stop when you understand, not after N questions
|
||||||
|
|
||||||
## Before Starting
|
## Before starting
|
||||||
1. Read `docs/PRD.md` — check if it's still the empty template
|
|
||||||
2. Read `features/INDEX.md` — check if features already exist
|
|
||||||
|
|
||||||
**If the project is already initialized** (PRD is filled out and not the empty template):
|
Read `docs/PRD.md` and `features/INDEX.md`.
|
||||||
→ Tell the user: "This project is already initialized. Use `/write-spec` to create a feature spec, or `/refine PROJ-X` to update an existing one."
|
|
||||||
→ Stop here.
|
|
||||||
|
|
||||||
## Interview Phase
|
**If the PRD is already filled in:**
|
||||||
|
> "Dieses Projekt ist bereits initialisiert. Nutze `/write-spec` für eine neue
|
||||||
|
> Feature-Spec oder `/refine PROJ-X`, um eine bestehende zu überarbeiten."
|
||||||
|
→ Stop.
|
||||||
|
|
||||||
Start the conversation based on the argument the user provided. If they described their idea, acknowledge it and ask your first clarifying question about the most important open point. If no argument was given, ask:
|
## Interview
|
||||||
|
|
||||||
> "What do you want to build, and what problem does it solve?"
|
Start from whatever the user passed as argument. If nothing, ask:
|
||||||
> My recommendation: Start with the user pain — what frustrates people today that your product will fix?
|
> "Was willst du bauen, und welches Problem löst es?"
|
||||||
|
> Empfehlung: Fang beim Schmerz an — was nervt heute, das dein Produkt beseitigt?
|
||||||
|
|
||||||
Cover these topics through natural conversation (not as a checklist):
|
Cover through natural conversation: the core problem, the target users and their
|
||||||
- Core problem being solved
|
specific pain, MVP versus later, existing alternatives and the difference, constraints
|
||||||
- Primary target users and their specific pain points
|
(time, budget, team), success metrics, and explicit non-goals.
|
||||||
- Must-have features for MVP vs. nice-to-have later
|
|
||||||
- Existing alternatives / competitors — what's different here?
|
|
||||||
- Constraints: timeline, budget, team size
|
|
||||||
- Success metrics: how do you know this product worked?
|
|
||||||
- Non-goals: what are you explicitly NOT building in this version?
|
|
||||||
|
|
||||||
### Mandatory: Backend Decision (ask before building the feature map)
|
### Mandatory: persistence
|
||||||
This question MUST be resolved before you create the feature map — it determines the entire architecture and feature list.
|
|
||||||
|
|
||||||
Ask:
|
Resolve before the feature map — it shapes the architecture.
|
||||||
> "Does the app need to store data persistently or sync between users/devices?"
|
|
||||||
> My recommendation: Yes — most apps need at least local persistence. If multiple users or cross-device sync is needed, a backend is required.
|
|
||||||
|
|
||||||
**If yes → follow up:**
|
> "Müssen Daten dauerhaft gespeichert oder zwischen Nutzern und Geräten synchronisiert werden?"
|
||||||
> "Should we use Supabase (the template's built-in backend: PostgreSQL + Auth + Storage) or keep it frontend-only with localStorage?"
|
> Empfehlung: Ja — sobald Daten einen Browser-Neustart überleben sollen, reicht
|
||||||
> My recommendation: Supabase — if users need accounts or data needs to survive a browser refresh, local storage won't be enough.
|
> localStorage nicht mehr.
|
||||||
|
|
||||||
**If Supabase is chosen:**
|
This template ships **no** database. If persistence is needed, note it as a
|
||||||
- Add **"Supabase Infrastructure Setup"** as **PROJ-1, P0** in the feature map
|
constraint; `/architecture` picks the concrete data layer per feature. The only
|
||||||
- All features that require auth, data storage, or file uploads must list PROJ-1 as a dependency
|
hard requirement: it must run self-hosted in the Docker Compose stack.
|
||||||
- This feature covers: project setup, environment variables, database schema outline, auth configuration
|
|
||||||
|
|
||||||
**If frontend-only (localStorage):**
|
### Mandatory: authentication
|
||||||
- No infrastructure feature needed
|
|
||||||
- Note "No backend — localStorage only" in the PRD Constraints section
|
|
||||||
|
|
||||||
### Mandatory: Design System (ask before building the feature map)
|
> "Brauchen Nutzer eigene Konten?"
|
||||||
Ask:
|
|
||||||
> "Do you have an existing design system, brand guidelines, or UI reference I should follow?"
|
|
||||||
> My recommendation: Even a rough color palette and font preference saves a lot of back-and-forth later.
|
|
||||||
|
|
||||||
**Three ways the user can provide it:**
|
If yes, add **"Auth-Grundlage (Better Auth + Passkeys)"** as **PROJ-1, P0** with no
|
||||||
1. **File upload** — an HTML or Markdown file with colors, typography, component styles
|
dependencies. Every feature that needs a logged-in user depends on PROJ-1.
|
||||||
2. **Manual input** — the user describes it directly (e.g. "dark theme, Inter font, blue primary #2563EB")
|
The stack is fixed: Better Auth with the passkey plugin, passkey primary, password
|
||||||
3. **None** — use the template's default (Tailwind + shadcn/ui defaults)
|
only as a fallback. Do not offer alternatives — record it and move on.
|
||||||
|
|
||||||
**If a design system is provided:**
|
### Mandatory: design system
|
||||||
- Save it to `docs/design-system.md` (create the file with the provided content or a structured summary)
|
|
||||||
- Add a note in `docs/PRD.md` under Constraints: "Design system: see `docs/design-system.md`"
|
|
||||||
- The `/frontend` skill will read this file when building UI components
|
|
||||||
|
|
||||||
## After the Interview: Create the PRD
|
> "Hast du ein bestehendes Design-System, Brand Guidelines oder eine UI-Referenz?"
|
||||||
|
> Empfehlung: Schon eine grobe Farbpalette und Schriftwahl spart später viel Hin und Her.
|
||||||
|
|
||||||
Once you have a complete understanding, write `docs/PRD.md` with:
|
If provided (as a file or described in words), save it to `docs/design-system.md`
|
||||||
- **Vision:** 2-3 sentences — what it is and why it matters
|
and reference it in the PRD constraints. `/frontend` reads it. If nothing, the
|
||||||
- **Target Users:** Who they are, their specific needs and pain points
|
Tailwind/shadcn defaults apply.
|
||||||
- **Core Features (Roadmap):** Prioritized table (P0 = MVP, P1 = next, P2 = later)
|
|
||||||
- **Success Metrics:** Measurable outcomes
|
|
||||||
- **Constraints:** Timeline, team, budget, technical limitations
|
|
||||||
- **Non-Goals:** What will NOT be built in this version
|
|
||||||
|
|
||||||
Present the draft PRD to the user for review before saving. Apply feedback, then save.
|
## Write the PRD
|
||||||
|
|
||||||
## After PRD: Create the Feature Map
|
Fill `docs/PRD.md`, in German:
|
||||||
|
- **Vision** — 2–3 sentences
|
||||||
|
- **Zielgruppe** — who, and their concrete pain
|
||||||
|
- **Roadmap** — prioritized table, P0 = MVP, P1 = next, P2 = later
|
||||||
|
- **Erfolgsmetriken** — measurable
|
||||||
|
- **Rahmenbedingungen** — time, team, budget, technical limits, persistence decision
|
||||||
|
- **Nicht-Ziele** — explicitly out for this version
|
||||||
|
|
||||||
Apply Single Responsibility to break the roadmap into individual features:
|
Present the draft, apply feedback, then save.
|
||||||
- Each feature = ONE testable, deployable unit
|
|
||||||
- Identify dependencies between features
|
|
||||||
- Assign recommended build order (respecting dependencies)
|
|
||||||
- Assign priority: P0 = MVP, P1 = next, P2 = later
|
|
||||||
|
|
||||||
**What each feature entry in `features/INDEX.md` contains:**
|
## Build the feature map
|
||||||
- Feature ID (PROJ-1, PROJ-2, ...)
|
|
||||||
- Feature name
|
|
||||||
- One-line description
|
|
||||||
- Priority (P0/P1/P2)
|
|
||||||
- Dependencies (which other features it needs, or "None")
|
|
||||||
- Status: Roadmap
|
|
||||||
|
|
||||||
Present the feature map to the user:
|
Single Responsibility: each feature is one testable, deployable unit.
|
||||||
> "I've identified X features. Here's the breakdown and recommended build order:"
|
|
||||||
|
|
||||||
Apply feedback, then update `features/INDEX.md` and the "Next Available ID" line.
|
Each entry in `features/INDEX.md` carries: ID, name, one-line description,
|
||||||
|
priority, dependencies, status **Roadmap**.
|
||||||
|
|
||||||
## What NOT to do
|
Present it as a build order that respects dependencies:
|
||||||
- Do NOT create individual `features/PROJ-X-*.md` spec files — that is `/write-spec`'s job
|
> "Ich habe X Features identifiziert. Hier die Aufteilung und die empfohlene Reihenfolge:"
|
||||||
- Do NOT write code or make technical decisions
|
|
||||||
- Do NOT ask multiple questions at once
|
|
||||||
- Do NOT stop early — keep going until you have full clarity on the project
|
|
||||||
|
|
||||||
## Checklist Before Completion
|
Apply feedback, then write `features/INDEX.md` and update "Next Available ID".
|
||||||
- [ ] PRD fully filled out (Vision, Target Users, Roadmap, Metrics, Constraints, Non-Goals)
|
Re-read the file to verify.
|
||||||
- [ ] Backend decision resolved (Supabase vs. localStorage)
|
|
||||||
- [ ] If Supabase: "Supabase Infrastructure Setup" added as PROJ-1, P0, no dependencies
|
## Do not
|
||||||
- [ ] If Supabase: all data/auth-dependent features list PROJ-1 as dependency
|
|
||||||
- [ ] If frontend-only: noted in PRD Constraints
|
- Create `features/PROJ-X-*.md` spec files — that is `/write-spec`
|
||||||
- [ ] Design system decision resolved
|
- Write code or pick concrete libraries beyond the fixed auth stack
|
||||||
- [ ] If design system provided: saved to `docs/design-system.md` and referenced in PRD
|
- Ask several questions at once
|
||||||
- [ ] Every feature respects Single Responsibility
|
- Stop before you actually understand the project
|
||||||
- [ ] Dependencies between features documented
|
|
||||||
- [ ] All features added to `features/INDEX.md` with status "Roadmap"
|
## Wrap up
|
||||||
- [ ] "Next Available ID" updated in INDEX.md
|
|
||||||
- [ ] Build order recommended
|
- [ ] PRD complete (Vision, Zielgruppe, Roadmap, Metriken, Rahmenbedingungen, Nicht-Ziele)
|
||||||
- [ ] User has reviewed and approved PRD and feature map
|
- [ ] Persistence resolved and noted
|
||||||
|
- [ ] Auth resolved; if yes, PROJ-1 auth feature added and depended on
|
||||||
|
- [ ] Design system resolved; if provided, saved to `docs/design-system.md`
|
||||||
|
- [ ] Every feature is a single responsibility, dependencies documented
|
||||||
|
- [ ] `features/INDEX.md` written with status Roadmap, next ID updated, verified
|
||||||
|
- [ ] User approved PRD and feature map
|
||||||
|
|
||||||
## Handoff
|
## Handoff
|
||||||
After user approval:
|
|
||||||
|
|
||||||
> "Project setup complete. Run `/write-spec` to start speccing your first feature: **[recommended first feature name]** (PROJ-1)."
|
> "Projekt steht. Nächster Schritt: `/write-spec` für das erste Feature: **[Name]** (PROJ-1)."
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
## Git Commit
|
|
||||||
```
|
```
|
||||||
feat: Initialize project — PRD and feature map
|
feat: Initialize project — PRD and feature map
|
||||||
|
|
||||||
- Created docs/PRD.md with vision, target users, and roadmap
|
|
||||||
- Added X features to features/INDEX.md
|
|
||||||
```
|
```
|
||||||
+75
-131
@@ -1,159 +1,103 @@
|
|||||||
---
|
---
|
||||||
name: qa
|
name: qa
|
||||||
description: Test features against acceptance criteria, find bugs, and perform security audit. Use after implementation is done.
|
description: Test a feature against acceptance criteria, find bugs, audit security. Use after implementation is done.
|
||||||
argument-hint: "feature-spec-path"
|
argument-hint: "PROJ-X or feature-spec-path"
|
||||||
user-invocable: true
|
user-invocable: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# QA Engineer
|
# QA
|
||||||
|
|
||||||
## Role
|
You own the test phase for one feature. The **QA Engineer** sub-agent does the
|
||||||
You are an experienced QA Engineer AND Red-Team Pen-Tester. You test features against acceptance criteria, identify bugs, and audit for security vulnerabilities.
|
testing; you set up its context, then present and prioritize the results with the
|
||||||
|
user.
|
||||||
|
|
||||||
## Before Starting
|
## 1. Load context
|
||||||
1. Read `features/INDEX.md` for project context
|
|
||||||
2. Read the feature spec referenced by the user
|
|
||||||
3. Check recently implemented features for regression testing: `git log --oneline --grep="PROJ-" -10`
|
|
||||||
4. Check recent bug fixes: `git log --oneline --grep="fix" -10`
|
|
||||||
5. Check recently changed files: `git log --name-only -5 --format=""`
|
|
||||||
|
|
||||||
### Check Playwright Browser Installation
|
1. Read `features/INDEX.md` — note which features are already **Deployed**
|
||||||
Run: `npx playwright install --dry-run 2>&1 | head -5`
|
(those are the regression surface)
|
||||||
|
2. Read the feature spec
|
||||||
|
3. `git log --oneline -10` — what changed recently
|
||||||
|
|
||||||
If browsers are not installed, tell the user:
|
## 2. Set status
|
||||||
> "Playwright browsers need to be installed once. I'll do this now — it downloads ~300MB of browser binaries."
|
|
||||||
> Then run: `npx playwright install chromium`
|
|
||||||
> This is a one-time setup per machine. After cloning the repo, always run this once before E2E tests.
|
|
||||||
|
|
||||||
## Workflow
|
Update `features/INDEX.md` and the spec header to **In Review**. Verify by
|
||||||
|
re-reading.
|
||||||
|
|
||||||
### 1. Read Feature Spec
|
## 3. Delegate testing
|
||||||
- Understand ALL acceptance criteria
|
|
||||||
- Understand ALL documented edge cases
|
|
||||||
- Understand the tech design decisions
|
|
||||||
- Note any dependencies on other features
|
|
||||||
|
|
||||||
### 2. Manual Testing
|
Invoke the **QA Engineer** sub-agent with:
|
||||||
Test the feature systematically in the browser:
|
- the spec path
|
||||||
- Test EVERY acceptance criterion (mark pass/fail)
|
- the list of deployed features to regression-check
|
||||||
- Test ALL documented edge cases
|
- any areas the developer flagged as risky
|
||||||
- Test undocumented edge cases you identify
|
|
||||||
- Cross-browser: Chrome, Firefox, Safari
|
|
||||||
- Responsive: Mobile (375px), Tablet (768px), Desktop (1440px)
|
|
||||||
|
|
||||||
### 3. Security Audit (Red Team)
|
Wait for its report.
|
||||||
Think like an attacker:
|
|
||||||
- Test authentication bypass attempts
|
|
||||||
- Test authorization (can user X access user Y's data?)
|
|
||||||
- Test input injection (XSS, SQL injection via UI inputs)
|
|
||||||
- Test rate limiting (rapid repeated requests)
|
|
||||||
- Check for exposed secrets in browser console/network tab
|
|
||||||
- Check for sensitive data in API responses
|
|
||||||
|
|
||||||
### 4. Regression Testing
|
## 4. Write results into the spec
|
||||||
Verify existing features still work:
|
|
||||||
- Check features listed in `features/INDEX.md` with status "Deployed"
|
|
||||||
- Test core flows of related features
|
|
||||||
- Verify no visual regressions on shared components
|
|
||||||
|
|
||||||
### 5. Run Automated Tests
|
Append this to the feature spec — not to a separate file:
|
||||||
Run existing test suites before manual testing:
|
|
||||||
```bash
|
```markdown
|
||||||
npm test # Vitest: integration tests for API routes
|
---
|
||||||
npm run test:e2e # Playwright: E2E tests from previous QA runs
|
|
||||||
|
## QA Test Results
|
||||||
|
|
||||||
|
**Getestet:** YYYY-MM-DD
|
||||||
|
**Tester:** QA Engineer (AI)
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
- [x] AC-1: [Kriterium]
|
||||||
|
- [ ] AC-2: [Kriterium] — BUG-1
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
- [x] EC-1: [Randfall] — korrekt behandelt
|
||||||
|
|
||||||
|
### Security-Audit
|
||||||
|
- [x] Ohne Session kein Zugriff
|
||||||
|
- [x] Kein Zugriff auf fremde Datensätze
|
||||||
|
- [x] Passkey ist der primäre Anmeldeweg
|
||||||
|
- [x] Eingaben validiert, keine Injection
|
||||||
|
|
||||||
|
### Gefundene Bugs
|
||||||
|
|
||||||
|
#### BUG-1: [Titel]
|
||||||
|
- **Severity:** Critical | High | Medium | Low
|
||||||
|
- **Reproduktion:** 1. … 2. … 3. …
|
||||||
|
- **Erwartet:** …
|
||||||
|
- **Tatsächlich:** …
|
||||||
|
|
||||||
|
### Zusammenfassung
|
||||||
|
- **Acceptance Criteria:** X von Y bestanden
|
||||||
|
- **Bugs:** N gesamt (C critical, H high, M medium, L low)
|
||||||
|
- **Produktionsreif:** JA / NEIN
|
||||||
```
|
```
|
||||||
Note any failures — these are regressions and must be treated as High bugs.
|
|
||||||
|
|
||||||
### 6. Write Unit Tests
|
Re-read the file afterwards to confirm it landed.
|
||||||
Before E2E tests, identify and test isolated logic with Vitest. Place tests **co-located** next to the source file (e.g. `src/hooks/useFeature.test.ts` next to `src/hooks/useFeature.ts`):
|
|
||||||
|
|
||||||
**What to unit test (evaluate each):**
|
## 5. Review with the user
|
||||||
- Custom hooks with non-trivial logic (e.g. `useKanbanStorage`: localStorage read/write, error fallback)
|
|
||||||
- Pure utility/transformation functions (e.g. drag-and-drop reorder logic)
|
|
||||||
- Form validation logic (if extracted from components)
|
|
||||||
|
|
||||||
**What NOT to unit test:**
|
Present: criteria passed/failed, bugs by severity, security findings, verdict.
|
||||||
- Pure presentational components with no logic
|
Then ask:
|
||||||
- Logic already fully covered by E2E tests
|
> "Welche Bugs sollen zuerst behoben werden?"
|
||||||
|
|
||||||
For each unit test:
|
## 6. Wrap up
|
||||||
- Test the happy path
|
|
||||||
- Test error paths and edge cases (e.g. corrupt input, empty state)
|
|
||||||
- Mock only external dependencies (localStorage, fetch) — not internal logic
|
|
||||||
|
|
||||||
Run to confirm all pass: `npm test`
|
- [ ] Every acceptance criterion has an individual pass/fail
|
||||||
|
- [ ] Documented and undocumented edge cases tested
|
||||||
### 7. Write E2E Tests
|
- [ ] Security audit done from a red-team angle
|
||||||
For each acceptance criterion that passed manual testing, write a Playwright test in `tests/PROJ-X-feature-name.spec.ts`:
|
- [ ] Regression check against deployed features
|
||||||
- One `test()` per acceptance criterion
|
- [ ] Unit tests for non-trivial logic (`npm test` passes)
|
||||||
- Tests describe the user journey in plain language
|
- [ ] E2E test per passing criterion (`npm run test:e2e` passes)
|
||||||
- Run to confirm all pass: `npm run test:e2e`
|
- [ ] QA section written into the spec and verified
|
||||||
|
- [ ] `features/INDEX.md` → **Approved** if no Critical/High, otherwise stays **In Review**
|
||||||
These tests become the permanent regression suite for this feature.
|
|
||||||
|
|
||||||
### 8. Document Results
|
|
||||||
- Add QA Test Results section to the feature spec file (NOT a separate file)
|
|
||||||
- Use the template from [test-template.md](test-template.md)
|
|
||||||
|
|
||||||
### 9. User Review
|
|
||||||
Present test results with clear summary:
|
|
||||||
- Total acceptance criteria: X passed, Y failed
|
|
||||||
- Bugs found: breakdown by severity
|
|
||||||
- Security audit: findings
|
|
||||||
- Production-ready recommendation: YES or NO
|
|
||||||
|
|
||||||
Ask: "Which bugs should be fixed first?"
|
|
||||||
|
|
||||||
## Context Recovery
|
|
||||||
If your context was compacted mid-task:
|
|
||||||
1. Re-read the feature spec you're testing
|
|
||||||
2. Re-read `features/INDEX.md` for current status
|
|
||||||
3. Check if you already added QA results to the feature spec: search for "## QA Test Results"
|
|
||||||
4. Run `git diff` to see what you've already documented
|
|
||||||
5. Continue testing from where you left off - don't re-test passed criteria
|
|
||||||
|
|
||||||
## Bug Severity Levels
|
|
||||||
- **Critical:** Security vulnerabilities, data loss, complete feature failure
|
|
||||||
- **High:** Core functionality broken, blocking issues
|
|
||||||
- **Medium:** Non-critical functionality issues, workarounds exist
|
|
||||||
- **Low:** UX issues, cosmetic problems, minor inconveniences
|
|
||||||
|
|
||||||
## Important
|
|
||||||
- NEVER fix bugs yourself - that is for Frontend/Backend skills
|
|
||||||
- Focus: Find, Document, Prioritize
|
|
||||||
- Be thorough and objective: report even small bugs
|
|
||||||
|
|
||||||
## Production-Ready Decision
|
|
||||||
- **READY:** No Critical or High bugs remaining
|
|
||||||
- **NOT READY:** Critical or High bugs exist (must be fixed first)
|
|
||||||
|
|
||||||
## Checklist
|
|
||||||
- [ ] Feature spec fully read and understood
|
|
||||||
- [ ] All acceptance criteria tested (each has pass/fail)
|
|
||||||
- [ ] All documented edge cases tested
|
|
||||||
- [ ] Additional edge cases identified and tested
|
|
||||||
- [ ] Cross-browser tested (Chrome, Firefox, Safari)
|
|
||||||
- [ ] Responsive tested (375px, 768px, 1440px)
|
|
||||||
- [ ] Security audit completed (red-team perspective)
|
|
||||||
- [ ] Regression test on related features
|
|
||||||
- [ ] Every bug documented with severity + steps to reproduce
|
|
||||||
- [ ] Screenshots added for visual bugs
|
|
||||||
- [ ] Unit tests written for non-trivial hooks and utility functions (`npm test` passes)
|
|
||||||
- [ ] E2E tests written for all passing acceptance criteria (`npm run test:e2e` passes)
|
|
||||||
- [ ] QA section added to feature spec file
|
|
||||||
- [ ] User has reviewed results and prioritized bugs
|
|
||||||
- [ ] Production-ready decision made
|
|
||||||
- [ ] `features/INDEX.md` status updated to "In Review" (at QA start)
|
|
||||||
- [ ] `features/INDEX.md` status updated to "Approved" (if production-ready) OR kept "In Review" (if bugs remain)
|
|
||||||
|
|
||||||
## Handoff
|
## Handoff
|
||||||
If production-ready:
|
|
||||||
> "All tests passed! Status updated to **Approved**. Next step: Run `/deploy` to deploy this feature to production."
|
|
||||||
|
|
||||||
If bugs found:
|
- Clean → "Alle Tests bestanden, Status **Approved**. Nächster Schritt: `/deploy`."
|
||||||
> "Found [N] bugs ([severity breakdown]). Status remains **In Review**. The developer needs to fix these before deployment. After fixes, run `/qa` again."
|
- Bugs → "[N] Bugs gefunden ([Aufschlüsselung]). Status bleibt **In Review**.
|
||||||
|
Erst `/frontend` bzw. `/backend` zum Beheben, danach nochmal `/qa`."
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
## Git Commit
|
|
||||||
```
|
```
|
||||||
test(PROJ-X): Add QA test results for [feature name]
|
test(PROJ-X): Add QA test results for [feature name]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
# QA Test Results Template
|
|
||||||
|
|
||||||
Add this section to the END of the feature spec `/features/PROJ-X.md`:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
|
|
||||||
## QA Test Results
|
|
||||||
|
|
||||||
**Tested:** YYYY-MM-DD
|
|
||||||
**App URL:** http://localhost:3000
|
|
||||||
**Tester:** QA Engineer (AI)
|
|
||||||
|
|
||||||
### Acceptance Criteria Status
|
|
||||||
|
|
||||||
#### AC-1: [Criterion Name]
|
|
||||||
- [x] Sub-criterion passed
|
|
||||||
- [ ] BUG: Sub-criterion failed (describe what went wrong)
|
|
||||||
|
|
||||||
#### AC-2: [Criterion Name]
|
|
||||||
- [x] All sub-criteria passed
|
|
||||||
|
|
||||||
### Edge Cases Status
|
|
||||||
|
|
||||||
#### EC-1: [Edge Case Name]
|
|
||||||
- [x] Handled correctly
|
|
||||||
|
|
||||||
#### EC-2: [Edge Case Name]
|
|
||||||
- [ ] BUG: Not handled (describe expected vs actual behavior)
|
|
||||||
|
|
||||||
### Security Audit Results
|
|
||||||
- [x] Authentication: Cannot access without login
|
|
||||||
- [x] Authorization: Users cannot access other users' data
|
|
||||||
- [x] Input validation: XSS attempts blocked
|
|
||||||
- [x] Rate limiting: Excessive requests handled
|
|
||||||
- [ ] BUG: [Security issue description]
|
|
||||||
|
|
||||||
### Bugs Found
|
|
||||||
|
|
||||||
#### BUG-1: [Bug Title]
|
|
||||||
- **Severity:** Critical | High | Medium | Low
|
|
||||||
- **Steps to Reproduce:**
|
|
||||||
1. Go to [page]
|
|
||||||
2. Do [action]
|
|
||||||
3. Expected: [what should happen]
|
|
||||||
4. Actual: [what actually happens]
|
|
||||||
- **Screenshot:** [if visual bug]
|
|
||||||
- **Priority:** Fix before deployment | Fix in next sprint | Nice to have
|
|
||||||
|
|
||||||
### Summary
|
|
||||||
- **Acceptance Criteria:** X/Y passed
|
|
||||||
- **Bugs Found:** N total (C critical, H high, M medium, L low)
|
|
||||||
- **Security:** [Pass / Issues found]
|
|
||||||
- **Production Ready:** YES / NO
|
|
||||||
- **Recommendation:** [Deploy / Fix bugs first]
|
|
||||||
```
|
|
||||||
@@ -1,113 +1,99 @@
|
|||||||
---
|
---
|
||||||
name: refine
|
name: refine
|
||||||
description: Always use when the user wants to discuss an exsiting feature or specification. Open an existing feature spec to improve, extend, or fundamentally challenge it. Pass the feature ID as argument (e.g. /refine PROJ-2).
|
description: Reopen an existing feature spec to improve, extend or fundamentally challenge it. Pass the feature ID.
|
||||||
argument-hint: "PROJ-X"
|
argument-hint: "PROJ-X"
|
||||||
user-invocable: true
|
user-invocable: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# Feature Spec Refiner
|
# Refine
|
||||||
|
|
||||||
## Role
|
You are a product manager reviewing a live spec. Improve it, extend it, or challenge
|
||||||
You are an experienced Product Manager reviewing a live spec. Your job is to improve, extend, or fundamentally challenge the spec based on what the user tells you.
|
it — depending on what brought the user back.
|
||||||
|
|
||||||
## Before Starting
|
## Before starting
|
||||||
1. Read the feature spec `features/PROJ-X-*.md` — understand the full current state
|
|
||||||
2. Read `features/INDEX.md` — understand dependencies, status, and context
|
|
||||||
3. Read `docs/PRD.md` — keep the project vision in mind
|
|
||||||
|
|
||||||
**If no argument was provided** (no PROJ-X ID given):
|
1. Read `features/PROJ-X-*.md` — the full current state
|
||||||
> "Which feature spec would you like to refine?" — list all existing features from INDEX.md.
|
2. Read `features/INDEX.md` — status and dependencies
|
||||||
|
3. Read `docs/PRD.md` — keep the vision in view
|
||||||
|
|
||||||
**If the PROJ-X ID doesn't exist**: tell the user and list existing features.
|
**No argument given:** ask which spec, listing the features from INDEX.md.
|
||||||
|
**Unknown ID:** say so and list what exists.
|
||||||
|
|
||||||
## Opening Question (ALWAYS ask this first)
|
## Always open with this
|
||||||
> "What brought you back to this spec?"
|
|
||||||
|
|
||||||
This answer determines everything. Listen carefully — it will tell you which of the three paths to take.
|
> "Was bringt dich zurück zu dieser Spec?"
|
||||||
|
|
||||||
## Three Paths
|
The answer decides the path.
|
||||||
|
|
||||||
### Path 1: Something Changed
|
## Three paths
|
||||||
*Trigger: "scope changed", "we got user feedback", "business logic is different", "stakeholder changed the requirements"*
|
|
||||||
|
|
||||||
Run a targeted interview on the affected areas only:
|
### Path 1 — Something changed
|
||||||
- What specifically changed?
|
*"Scope hat sich geändert", "wir haben Nutzerfeedback", "die Anforderung ist anders"*
|
||||||
- Which user stories are affected?
|
|
||||||
- Which acceptance criteria need to be updated or removed?
|
|
||||||
- Do any edge cases change?
|
|
||||||
- Do dependencies change?
|
|
||||||
- Does the Out of Scope section need updating? (something previously excluded is now included, or vice versa)
|
|
||||||
|
|
||||||
### Path 2: Implementation Revealed Gaps
|
Targeted interview on the affected areas only: what exactly changed, which user
|
||||||
*Trigger: "during implementation we found...", "the backend doesn't support...", "we didn't think about X scenario"*
|
stories and criteria are affected, which edge cases shift, whether dependencies
|
||||||
|
move, and whether Out of Scope needs updating in either direction.
|
||||||
|
|
||||||
Focus on making the spec tighter:
|
### Path 2 — Implementation revealed a gap
|
||||||
- What specific scenario was missing?
|
*"Bei der Umsetzung kam raus, dass…", "an den Fall haben wir nicht gedacht"*
|
||||||
- Should this become a new acceptance criterion or edge case?
|
|
||||||
- Does this change any existing criteria?
|
|
||||||
- Are there related gaps we should close now while we're here?
|
|
||||||
|
|
||||||
### Path 3: Fundamental Challenge
|
Tighten the spec: which scenario was missing, does it become a new acceptance
|
||||||
*Trigger: "I'm not sure this feature is right", "maybe we should rethink this", "this might actually be two features"*
|
criterion or an edge case, does it invalidate an existing criterion, and are there
|
||||||
|
adjacent gaps worth closing now.
|
||||||
|
|
||||||
Challenge the entire spec from first principles:
|
### Path 3 — Fundamental challenge
|
||||||
- What assumption is being questioned?
|
*"Ich bin nicht sicher, ob das Feature so richtig ist", "das sind vielleicht zwei Features"*
|
||||||
- Is the user story still the right framing?
|
|
||||||
- Should this feature be split? If so, what are the two features?
|
|
||||||
- Should it be merged with another feature?
|
|
||||||
- What would the absolute minimal version of this feature look like?
|
|
||||||
- What moves to Out of Scope as a result of this challenge?
|
|
||||||
|
|
||||||
If the feature should be split: create the new spec file using the `/write-spec` workflow, and update `features/INDEX.md` accordingly.
|
Challenge from first principles: which assumption is in doubt, is the user story
|
||||||
|
still the right framing, should this split or merge, what is the minimal version,
|
||||||
|
and what moves to Out of Scope as a result.
|
||||||
|
|
||||||
## The Grill Me Principle
|
If it should split: create the new spec via the `/write-spec` workflow and update
|
||||||
Same as in `/init` and `/write-spec`:
|
`features/INDEX.md`.
|
||||||
- **One question at a time** — never list multiple questions
|
|
||||||
- **Always provide a recommended answer** — the user confirms or corrects it
|
|
||||||
- **Follow the conversation** — don't follow a fixed script
|
|
||||||
- **Explore codebase first** if it can answer a question
|
|
||||||
- **Stop when you have full clarity** on what needs to change
|
|
||||||
|
|
||||||
## After the Interview: Update the Spec
|
## Grill Me principle
|
||||||
|
|
||||||
Make the changes to `features/PROJ-X-*.md`. After saving, re-read the file to verify the changes are present.
|
One question at a time, always with a recommendation, following the conversation
|
||||||
|
rather than a script. Read the codebase before asking anything it can answer. Stop
|
||||||
|
when the change is fully clear.
|
||||||
|
|
||||||
### Maintain the Decision Log and Open Questions
|
## Update the spec
|
||||||
|
|
||||||
**Close resolved Open Questions:**
|
Edit `features/PROJ-X-*.md`, then re-read it to verify the change landed.
|
||||||
For any `- [ ]` items in Open Questions that are now answered, mark them as `- [x]` and add a brief resolution note:
|
|
||||||
|
**Close resolved open questions** — mark `- [x]` with the resolution:
|
||||||
```
|
```
|
||||||
- [x] Should we support bulk delete? → No, deferred to P1 (2026-05-19)
|
- [x] Sollen wir Massenlöschen unterstützen? → Nein, verschoben auf P1 (2026-08-09)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Log new decisions:**
|
**Log new decisions** in the Entscheidungslog with rationale and date. Decisions
|
||||||
Any decision made during this refinement session belongs in the Decision Log. Add to the relevant sub-section (Product or Technical) with rationale and date. Decisions made here are often the most important — they reflect real-world feedback changing the original plan.
|
made here matter most — they are reality correcting the original plan.
|
||||||
|
|
||||||
**Add new Open Questions:**
|
**Add new open questions** as `- [ ]` if anything stays unresolved.
|
||||||
If the refinement surfaced questions that couldn't be resolved now, add them as `- [ ]` items.
|
|
||||||
|
|
||||||
## Update Tracking Files
|
## Update tracking
|
||||||
- Update `features/INDEX.md` if status or dependencies changed
|
|
||||||
- Update `docs/PRD.md` if the roadmap is affected
|
|
||||||
|
|
||||||
## Checklist Before Completion
|
- `features/INDEX.md` if status or dependencies changed
|
||||||
- [ ] Opening question asked and path determined
|
- `docs/PRD.md` if the roadmap is affected
|
||||||
- [ ] All interview questions resolved
|
|
||||||
- [ ] Spec file updated and verified (re-read after editing)
|
## Wrap up
|
||||||
- [ ] Out of Scope updated if scope boundaries changed
|
|
||||||
- [ ] Resolved Open Questions marked as `- [x]` with resolution note
|
- [ ] Opening question asked, path identified
|
||||||
- [ ] New decisions logged in Decision Log with rationale
|
- [ ] Interview resolved
|
||||||
- [ ] New Open Questions added if anything remains unresolved
|
- [ ] Spec updated and re-read to verify
|
||||||
- [ ] `features/INDEX.md` updated if status or dependencies changed
|
- [ ] Out of Scope adjusted if boundaries moved
|
||||||
- [ ] `docs/PRD.md` updated if roadmap affected
|
- [ ] Resolved questions marked `- [x]` with a note
|
||||||
- [ ] User has reviewed the changes
|
- [ ] New decisions logged with rationale
|
||||||
|
- [ ] INDEX.md / PRD updated where affected
|
||||||
|
- [ ] User reviewed the changes
|
||||||
|
|
||||||
## Handoff
|
## Handoff
|
||||||
Depends on the path taken:
|
|
||||||
- Path 1 or 2: "Spec updated. Continue with the next step in your workflow."
|
|
||||||
- Path 3 (split): "New spec created for PROJ-X. Run `/architecture` to design the technical approach."
|
|
||||||
|
|
||||||
## Git Commit
|
- Path 1 or 2 → "Spec aktualisiert. Mach mit deinem nächsten Workflow-Schritt weiter."
|
||||||
|
- Path 3 with a split → "Neue Spec für PROJ-Y angelegt. Nächster Schritt: `/architecture`."
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
```
|
```
|
||||||
feat(PROJ-X): Refine feature specification — [brief reason]
|
feat(PROJ-X): Refine feature specification — [reason]
|
||||||
```
|
```
|
||||||
@@ -1,159 +1,126 @@
|
|||||||
---
|
---
|
||||||
name: write-spec
|
name: write-spec
|
||||||
description: Write a full feature spec for a feature. Works for features already on the roadmap (status "Roadmap" from /init) and for features added later. Pass a feature name or PROJ-X ID as argument.
|
description: Write a full feature spec — user stories, acceptance criteria, edge cases. Pass a feature name or PROJ-X ID.
|
||||||
argument-hint: "feature name or PROJ-X ID"
|
argument-hint: "feature name or PROJ-X"
|
||||||
user-invocable: true
|
user-invocable: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# Feature Spec Writer
|
# Write Spec
|
||||||
|
|
||||||
## Role
|
You are a product manager turning one feature idea into a complete, testable
|
||||||
You are an experienced Product Manager. Your job is to turn a feature idea into a complete, testable specification — with user stories, acceptance criteria, and edge cases.
|
specification. No code, no technical decisions — that is `/architecture`'s job.
|
||||||
|
|
||||||
## The Grill Me Principle
|
## Grill Me principle
|
||||||
Interview the user until you reach a **complete shared understanding** of the feature. Rules:
|
|
||||||
|
|
||||||
- **One question at a time** — never list multiple questions
|
- **One question at a time** — never a list
|
||||||
- **Always provide a recommended answer** — the user confirms or corrects it
|
- **Always offer a recommended answer**
|
||||||
- **Follow the conversation** — open new branches, resolve dependencies between decisions one by one
|
- **Follow the conversation**, resolve dependencies between decisions as they surface
|
||||||
- **Explore before asking** — if a question can be answered by reading the codebase, read it first
|
- **Read the codebase before asking** anything it can answer
|
||||||
- **No fixed question limit** — stop when you truly understand the feature, not after N questions
|
- **No question limit** — stop when you understand the feature
|
||||||
|
|
||||||
## Before Starting
|
## Before starting
|
||||||
1. Read `docs/PRD.md` — understand the project vision and target users
|
|
||||||
2. Read `features/INDEX.md` — see existing features, find the next available PROJ-X ID, check for duplicates
|
|
||||||
3. Check existing components: `git ls-files src/components/`
|
|
||||||
4. Check existing APIs: `git ls-files src/app/api/`
|
|
||||||
|
|
||||||
**If the project has not been initialized** (PRD is still the empty template):
|
1. Read `docs/PRD.md` and `features/INDEX.md`
|
||||||
> "The project hasn't been set up yet. Run `/init` first to define the project vision and feature map."
|
2. `git ls-files src/components/ src/app/api/`
|
||||||
→ Stop here.
|
|
||||||
|
|
||||||
**If no argument was provided**, ask: "Which feature would you like to spec out?" and list all features with status "Roadmap" from INDEX.md.
|
**If the PRD is still the empty template:**
|
||||||
|
> "Das Projekt ist noch nicht aufgesetzt. Führe erst `/init` aus." → Stop.
|
||||||
|
|
||||||
## Three Entry Points
|
**If no argument:** list all features with status Roadmap and ask which one.
|
||||||
|
|
||||||
### Entry Point A: Feature exists in INDEX.md with status "Roadmap"
|
## Three entry points
|
||||||
The feature was identified during `/init`. Proceed directly to the Interview Phase.
|
|
||||||
|
|
||||||
### Entry Point B: Feature does NOT exist in INDEX.md yet
|
| Situation | What to do |
|
||||||
The feature was forgotten during `/init` or is being added later. Before the full interview, quickly clarify:
|
|-----------|------------|
|
||||||
- What is the feature called?
|
| Status **Roadmap** in INDEX.md | Go straight to the interview |
|
||||||
- What priority? (P0 = MVP, P1 = next, P2 = later) — provide a recommendation based on PRD context
|
| **Not in INDEX.md** | Clarify name, priority (with a recommendation) and dependencies, add it with the next PROJ-X ID and status Roadmap, then interview |
|
||||||
- Does it depend on any existing features?
|
| Status **Planned** or higher | "PROJ-X hat schon eine Spec. Nutze `/refine PROJ-X`." → Stop |
|
||||||
|
|
||||||
Add it to `features/INDEX.md` with status "Roadmap" and the next available PROJ-X ID, then continue directly to the Interview Phase — no separate skill run needed.
|
## Interview
|
||||||
|
|
||||||
### Entry Point C: Feature already has a spec (status "Planned" or higher)
|
Start from the PRD and the INDEX entry. First question targets the biggest open
|
||||||
> "This feature already has a spec. Use `/refine PROJ-X` to update it."
|
point about this specific feature. Cover through conversation:
|
||||||
→ Stop here.
|
|
||||||
|
|
||||||
## Interview Phase
|
- Who exactly uses it (refer to the PRD's user types)
|
||||||
|
- The core action — the job to be done
|
||||||
Start with what you know from `docs/PRD.md` and the feature entry in INDEX.md. Your first question should target the most important open point about this specific feature.
|
- What success looks like from the user's side
|
||||||
|
- Must-have behaviour for the MVP
|
||||||
Cover these topics through natural conversation (not as a checklist):
|
- Validation rules and limits
|
||||||
- Who specifically uses this feature? (be precise — refer to the user types from the PRD)
|
- Error states: what happens when things go wrong
|
||||||
- What is the core user action / job-to-be-done?
|
- Empty states: what the user sees before any data exists
|
||||||
- What does success look like from the user's perspective?
|
|
||||||
- What are the must-have behaviors for MVP?
|
|
||||||
- What are the validation rules and constraints?
|
|
||||||
- Error states: what happens when things go wrong?
|
|
||||||
- Empty states: what does the user see before they have any data?
|
|
||||||
- Edge cases: concurrent edits, network failure, invalid input, permission boundaries
|
- Edge cases: concurrent edits, network failure, invalid input, permission boundaries
|
||||||
- Dependencies on other features (auth, data, etc.)?
|
- Dependencies on other features (auth, data)
|
||||||
- Performance or security requirements?
|
- Performance or security requirements
|
||||||
|
|
||||||
**For edge cases, always be concrete:**
|
Be concrete about edge cases: "Was passiert, wenn zwei Nutzer denselben Datensatz
|
||||||
- "What happens when the user submits an empty form?"
|
gleichzeitig bearbeiten?" beats "Gibt es Randfälle?"
|
||||||
- "What if two users edit the same record simultaneously?"
|
|
||||||
- "What should happen if the API call times out?"
|
|
||||||
|
|
||||||
## After the Interview: Write the Spec
|
## Write the spec
|
||||||
|
|
||||||
Use [template.md](template.md) to create the feature spec:
|
Use `template.md`. Save to `features/PROJ-X-feature-name.md` (kebab-case).
|
||||||
- Use the PROJ-X ID already in INDEX.md (or the one assigned in Entry Point B)
|
**German**, per the project language rule.
|
||||||
- Save to `features/PROJ-X-feature-name.md` (kebab-case filename)
|
|
||||||
|
|
||||||
**Populate Out of Scope, Decision Log, and Open Questions while the interview is fresh:**
|
Three sections carry the memory of this interview — do not skip them:
|
||||||
|
|
||||||
- **Out of Scope** — explicitly list everything that came up in the interview but was consciously excluded from this feature. Reference other features by ID where relevant (e.g. "Bulk delete — deferred to PROJ-5"). This section is critical for developer handoffs: without it, developers don't know what NOT to build.
|
- **Out of Scope** — everything discussed and consciously excluded, referencing
|
||||||
|
other features by ID ("Massenlöschen — verschoben auf PROJ-5"). Without it,
|
||||||
|
developers do not know what *not* to build.
|
||||||
|
- **Produkt-Entscheidungen** — every scoping and UX decision with its rationale
|
||||||
|
- **Offene Fragen** — anything unresolved, as `- [ ]` so it stays visible
|
||||||
|
|
||||||
|
Present the draft, apply feedback, then save.
|
||||||
|
|
||||||
|
## Acceptance criteria format
|
||||||
|
|
||||||
- **Product Decisions** — log every conscious scoping or UX decision made during the interview, with the rationale. Examples: "Why limit to X items?", "Why this user role and not another?", "Why include/exclude this edge case?"
|
German, Angenommen/Wenn/Dann — one testable statement per line:
|
||||||
- **Open Questions** — log anything that couldn't be resolved during the interview (pending user research, dependency on another team, unclear requirements). Mark as `- [ ]` so they're visible as unresolved.
|
|
||||||
|
|
||||||
Do not skip these sections — they are the memory of the spec interview.
|
|
||||||
|
|
||||||
Present the draft spec to the user for review. Apply feedback, then save.
|
|
||||||
|
|
||||||
## After Saving: Update Tracking Files
|
|
||||||
|
|
||||||
Update `features/INDEX.md`:
|
|
||||||
- Change the feature's status from "Roadmap" to "Planned"
|
|
||||||
- If Entry Point B: also update the "Next Available ID" line
|
|
||||||
|
|
||||||
Update `docs/PRD.md`:
|
|
||||||
- Update the status column in the roadmap table for this feature (if listed there)
|
|
||||||
|
|
||||||
## Feature Granularity (Single Responsibility)
|
|
||||||
Each spec = ONE testable, deployable unit.
|
|
||||||
|
|
||||||
**Never combine:**
|
|
||||||
- Multiple independent functionalities
|
|
||||||
- CRUD for different entities
|
|
||||||
- User functions + admin functions
|
|
||||||
- Different UI screens or areas
|
|
||||||
|
|
||||||
**Split when:**
|
|
||||||
1. Can it be tested independently? → Own spec
|
|
||||||
2. Can it be deployed independently? → Own spec
|
|
||||||
3. Does it target a different user role? → Own spec
|
|
||||||
4. Is it a separate UI screen? → Own spec
|
|
||||||
|
|
||||||
**Document dependencies:**
|
|
||||||
```markdown
|
|
||||||
## Dependencies
|
|
||||||
- Requires: PROJ-1 (User Authentication) — for logged-in user checks
|
|
||||||
```
|
|
||||||
|
|
||||||
## Important
|
|
||||||
- NEVER write code — that is for Frontend/Backend skills
|
|
||||||
- NEVER make technical decisions — that is for the Architecture skill
|
|
||||||
- Focus: WHAT the feature does (not HOW)
|
|
||||||
|
|
||||||
## Acceptance Criteria Format
|
|
||||||
Always write acceptance criteria in German using the Angenommen/Wenn/Dann format:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
- [ ] Angenommen [Vorbedingung], wenn [Aktion], dann [Ergebnis]
|
- [ ] Angenommen [Vorbedingung], wenn [Aktion], dann [Ergebnis]
|
||||||
```
|
```
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
- [ ] Angenommen der Nutzer ist eingeloggt, wenn er ein leeres Formular abschickt, dann wird für jedes Pflichtfeld eine Validierungsfehlermeldung angezeigt
|
- [ ] Angenommen der Nutzer ist angemeldet, wenn er ein leeres Formular abschickt, dann erscheint pro Pflichtfeld eine Validierungsmeldung
|
||||||
- [ ] Angenommen eine Aufgabe existiert, wenn der Nutzer auf „Löschen" klickt, dann erscheint ein Bestätigungsdialog bevor die Aufgabe entfernt wird
|
- [ ] Angenommen eine Aufgabe existiert, wenn der Nutzer auf „Löschen" klickt, dann erscheint erst ein Bestätigungsdialog
|
||||||
- [ ] Angenommen die API ist nicht erreichbar, wenn der Nutzer das Formular abschickt, dann wird eine Fehlermeldung angezeigt und die Eingabe bleibt erhalten
|
- [ ] Angenommen die API antwortet nicht, wenn der Nutzer speichert, dann wird eine Fehlermeldung gezeigt und die Eingabe bleibt erhalten
|
||||||
|
|
||||||
This format ensures every criterion is unambiguous and directly testable by QA.
|
Each one must be directly testable by `/qa` without interpretation.
|
||||||
|
|
||||||
## Checklist Before Completion
|
## Granularity (Single Responsibility)
|
||||||
- [ ] At least 3–5 user stories defined
|
|
||||||
- [ ] Out of Scope filled in (everything discussed but excluded, with references to other features where applicable)
|
One spec = one testable, deployable unit. Split when it targets a different user
|
||||||
- [ ] Every acceptance criterion uses Given/When/Then format
|
role, a different screen, or can be tested and shipped on its own. Never combine
|
||||||
- [ ] Product Decisions logged with rationale
|
CRUD for different entities, or user plus admin functions.
|
||||||
- [ ] Open Questions logged for anything unresolved
|
|
||||||
- [ ] At least 3–5 edge cases documented
|
Document dependencies explicitly:
|
||||||
- [ ] Feature ID assigned (PROJ-X)
|
```markdown
|
||||||
- [ ] File saved to `features/PROJ-X-feature-name.md`
|
## Abhängigkeiten
|
||||||
- [ ] `features/INDEX.md` updated (status: Roadmap → Planned; next ID updated if Entry Point B)
|
- Benötigt PROJ-1 (Auth-Grundlage) — für die Prüfung auf angemeldete Nutzer
|
||||||
- [ ] `docs/PRD.md` roadmap table updated if applicable
|
```
|
||||||
- [ ] User has reviewed and approved the spec
|
|
||||||
|
## After saving
|
||||||
|
|
||||||
|
- `features/INDEX.md`: status Roadmap → **Planned** (and next ID if newly added)
|
||||||
|
- `docs/PRD.md`: update the roadmap status column if the feature is listed there
|
||||||
|
- Re-read both to verify
|
||||||
|
|
||||||
|
## Wrap up
|
||||||
|
|
||||||
|
- [ ] 3–5 user stories
|
||||||
|
- [ ] Out of Scope filled in with references
|
||||||
|
- [ ] Every criterion in Angenommen/Wenn/Dann form
|
||||||
|
- [ ] Produkt-Entscheidungen logged with rationale
|
||||||
|
- [ ] Offene Fragen logged
|
||||||
|
- [ ] 3–5 edge cases documented
|
||||||
|
- [ ] Saved as `features/PROJ-X-feature-name.md`
|
||||||
|
- [ ] INDEX.md and PRD updated and verified
|
||||||
|
- [ ] User approved
|
||||||
|
|
||||||
## Handoff
|
## Handoff
|
||||||
> "Spec is ready. Run `/architecture` to design the technical approach for PROJ-X."
|
|
||||||
|
|
||||||
## Git Commit
|
> "Spec steht. Nächster Schritt: `/architecture` für das technische Design von PROJ-X."
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
```
|
```
|
||||||
feat(PROJ-X): Write feature specification for [feature name]
|
feat(PROJ-X): Write feature specification for [feature name]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,63 +1,60 @@
|
|||||||
# PROJ-X: Feature Name
|
# PROJ-X: Feature-Name
|
||||||
|
|
||||||
## Status: Planned
|
**Status:** Planned
|
||||||
**Created:** YYYY-MM-DD
|
**Erstellt:** YYYY-MM-DD
|
||||||
**Last Updated:** YYYY-MM-DD
|
**Zuletzt geändert:** YYYY-MM-DD
|
||||||
|
|
||||||
## Dependencies
|
## Abhängigkeiten
|
||||||
- None
|
- Keine
|
||||||
|
|
||||||
## User Stories
|
## User Stories
|
||||||
- As a [user type], I want to [action] so that [goal]
|
- Als [Nutzertyp] möchte ich [Aktion], um [Ziel] zu erreichen
|
||||||
|
|
||||||
## Out of Scope
|
## Out of Scope
|
||||||
<!-- What this feature explicitly does NOT cover. Critical for developer handoffs. -->
|
<!-- Was dieses Feature bewusst NICHT abdeckt. Kritisch für die Übergabe an die Entwicklung. -->
|
||||||
- _Example: Bulk operations (deferred to P1 — PROJ-X)_
|
- _Beispiel: Massenbearbeitung — verschoben auf PROJ-5_
|
||||||
- _Example: Admin view of other users' data (separate admin feature)_
|
- _Beispiel: Admin-Sicht auf fremde Daten — eigenes Feature_
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
<!-- Format: Angenommen [Vorbedingung], wenn [Aktion], dann [Ergebnis] -->
|
||||||
**Format:** Angenommen [Vorbedingung] / Wenn [Aktion] / Dann [Ergebnis]
|
- [ ] Angenommen …, wenn …, dann …
|
||||||
|
- [ ] Angenommen …, wenn …, dann …
|
||||||
- [ ] Angenommen [Vorbedingung], wenn [Aktion], dann [Ergebnis]
|
|
||||||
- [ ] Angenommen [Vorbedingung], wenn [Aktion], dann [Ergebnis]
|
|
||||||
|
|
||||||
## Edge Cases
|
## Edge Cases
|
||||||
- What happens when...?
|
- Was passiert, wenn …?
|
||||||
- How do we handle...?
|
- Wie behandeln wir …?
|
||||||
|
|
||||||
## Technical Requirements (optional)
|
## Technische Anforderungen (optional)
|
||||||
- Performance: < 200ms response time
|
- Performance: …
|
||||||
- Security: Authentication required
|
- Sicherheit: Anmeldung erforderlich (Better Auth, Passkey primär)
|
||||||
- Browser Support: Chrome, Firefox, Safari
|
- Browser: Chrome, Firefox, Safari
|
||||||
|
|
||||||
## Open Questions
|
## Offene Fragen
|
||||||
<!-- Unresolved questions from the spec interview. Close them in /refine when answered. -->
|
<!-- Ungeklärtes aus dem Interview. Wird in /refine geschlossen. -->
|
||||||
- [ ] Question 1
|
- [ ] Frage 1
|
||||||
|
|
||||||
## Decision Log
|
## Entscheidungslog
|
||||||
<!-- Record of conscious decisions made and why. Added to by /write-spec and /architecture. -->
|
|
||||||
|
|
||||||
### Product Decisions
|
### Produkt-Entscheidungen
|
||||||
<!-- Added by /write-spec -->
|
<!-- von /write-spec -->
|
||||||
| Decision | Rationale | Date |
|
| Entscheidung | Begründung | Datum |
|
||||||
|----------|-----------|------|
|
|--------------|------------|-------|
|
||||||
| _Example: Limit to 3 tags per item_ | _Keeps UI simple for MVP; can be raised later_ | YYYY-MM-DD |
|
| _Beispiel: max. 3 Tags pro Eintrag_ | _Hält die UI im MVP einfach, später erweiterbar_ | YYYY-MM-DD |
|
||||||
|
|
||||||
### Technical Decisions
|
### Technische Entscheidungen
|
||||||
<!-- Added by /architecture -->
|
<!-- von /architecture -->
|
||||||
| Decision | Rationale | Date |
|
| Entscheidung | Begründung | Datum |
|
||||||
|----------|-----------|------|
|
|--------------|------------|-------|
|
||||||
| _Example: localStorage over Supabase_ | _No user accounts needed; data is device-local_ | YYYY-MM-DD |
|
| _Beispiel: localStorage statt Datenbank_ | _Keine Nutzerkonten, Daten bleiben gerätelokal_ | YYYY-MM-DD |
|
||||||
|
|
||||||
---
|
---
|
||||||
<!-- Sections below are added by subsequent skills -->
|
<!-- Die folgenden Abschnitte ergänzen die nachgelagerten Skills -->
|
||||||
|
|
||||||
## Tech Design (Solution Architect)
|
## Tech Design
|
||||||
_To be added by /architecture_
|
_wird von /architecture ergänzt_
|
||||||
|
|
||||||
## QA Test Results
|
## QA Test Results
|
||||||
_To be added by /qa_
|
_wird von /qa ergänzt_
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
_To be added by /deploy_
|
_wird von /deploy ergänzt_
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.claude
|
||||||
|
docs
|
||||||
|
features
|
||||||
|
tests
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
compose.yaml
|
||||||
|
README.md
|
||||||
|
CLAUDE.md
|
||||||
|
npm-debug.log*
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Copy to .env and fill in. .env is gitignored and never committed.
|
||||||
|
# Every variable the app needs must be listed here with a dummy value.
|
||||||
|
|
||||||
|
# --- Application -------------------------------------------------------------
|
||||||
|
# Public base URL of the deployment. Better Auth derives the WebAuthn
|
||||||
|
# relying-party ID from this hostname — a wrong value makes every passkey
|
||||||
|
# fail verification, so keep it in sync with the domain you serve from.
|
||||||
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# --- Docker Compose ----------------------------------------------------------
|
||||||
|
APP_NAME=ai-coding-starter
|
||||||
|
APP_PORT=3000
|
||||||
|
# Keep on loopback: the reverse proxy on the host forwards to this port.
|
||||||
|
APP_BIND=127.0.0.1
|
||||||
|
|
||||||
|
# --- Auth (only when the project uses authentication) ------------------------
|
||||||
|
# BETTER_AUTH_SECRET=generate_with_openssl_rand_base64_32
|
||||||
|
# BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# --- Database (chosen per project by /architecture) --------------------------
|
||||||
|
# DATABASE_URL=postgres://user:password@db:5432/app
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# Supabase (Optional - remove if not using backend)
|
|
||||||
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url_here
|
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key_here
|
|
||||||
|
|
||||||
# Add more environment variables as needed
|
|
||||||
# STRIPE_SECRET_KEY=sk_...
|
|
||||||
# SMTP_HOST=smtp.sendgrid.net
|
|
||||||
# SMTP_USER=your_smtp_user
|
|
||||||
# SMTP_PASS=your_smtp_password
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "next/core-web-vitals"
|
|
||||||
}
|
|
||||||
+16
-23
@@ -1,35 +1,23 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
/.pnp
|
|
||||||
.pnp.js
|
|
||||||
.yarn/install-state.gz
|
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
/build
|
||||||
|
|
||||||
# misc
|
# testing
|
||||||
.DS_Store
|
/coverage
|
||||||
*.pem
|
/playwright-report
|
||||||
|
/test-results
|
||||||
|
|
||||||
# debug
|
# local env files — only .env.example is committed
|
||||||
npm-debug.log*
|
.env
|
||||||
yarn-debug.log*
|
.env.local
|
||||||
yarn-error.log*
|
.env.*.local
|
||||||
|
.env.development
|
||||||
# local env files
|
.env.production
|
||||||
.env*.local
|
.env.test
|
||||||
|
|
||||||
# vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# claude code personal settings
|
# claude code personal settings
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
@@ -37,3 +25,8 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
npm-debug.log*
|
||||||
|
|||||||
@@ -1,67 +1,84 @@
|
|||||||
# AI Coding Starter Kit
|
# AI Coding Starter Kit
|
||||||
|
|
||||||
> A Next.js template with an AI-powered development workflow using specialized skills for Requirements, Architecture, Frontend, Backend, QA, and Deployment.
|
> Next.js template with an AI-driven development workflow. Skills cover Requirements,
|
||||||
|
> Architecture, Frontend, Backend, QA and Deployment. Every phase writes its result
|
||||||
|
> into the feature spec, so state lives in files rather than in the conversation.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack (binding for every new app)
|
||||||
|
|
||||||
- **Framework:** Next.js 16 (App Router), TypeScript
|
| Layer | Choice | Notes |
|
||||||
- **Styling:** Tailwind CSS + shadcn/ui (copy-paste components)
|
|-------|--------|-------|
|
||||||
- **Backend:** Supabase (PostgreSQL + Auth + Storage) - optional
|
| Framework | Next.js 16, App Router | React 19, Server Components by default |
|
||||||
- **Deployment:** Vercel
|
| Language | TypeScript, `strict` | `@/*` maps to `src/*` |
|
||||||
- **Validation:** Zod + react-hook-form
|
| Styling | Tailwind CSS v4 | CSS-first config in `src/app/globals.css`. **There is no `tailwind.config.ts`** — theme tokens live in `@theme inline`. |
|
||||||
- **State:** React useState / Context API
|
| Components | shadcn/ui (`new-york`) | 6 pre-installed. Add more with `npx shadcn@latest add <name> --yes`. Never hand-write an equivalent. |
|
||||||
|
| Icons | lucide-react | |
|
||||||
|
| Forms | react-hook-form + Zod | Zod schema is the single source of truth for a form's validation |
|
||||||
|
| Notifications | sonner | Mounted in `layout.tsx`. The deprecated shadcn `toast` is deliberately absent. |
|
||||||
|
| Theming | next-themes | `attribute="class"`, dark mode via `.dark` |
|
||||||
|
| Auth | Better Auth + passkey plugin | Only when the project needs auth. Passkeys are the primary method — see `.claude/rules/security.md`. |
|
||||||
|
| Data layer | **decided per project** | The template ships none. `/architecture` picks and documents it. |
|
||||||
|
| Tests | Vitest (unit) + Playwright (E2E) | Vitest only scans `src/`, Playwright only `tests/` |
|
||||||
|
| Deployment | Docker Compose, self-hosted | App container only; TLS/routing via a reverse proxy on the host |
|
||||||
|
|
||||||
|
Deliberately **not** part of this template: Supabase, Vercel, any preselected ORM
|
||||||
|
or database. Do not reintroduce them without the user asking.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
app/ Pages (Next.js App Router)
|
app/ Pages and route handlers (App Router)
|
||||||
|
api/health/ Liveness probe used by Docker + update script
|
||||||
components/
|
components/
|
||||||
ui/ shadcn/ui components (NEVER recreate these)
|
ui/ shadcn/ui — NEVER recreate these by hand
|
||||||
hooks/ Custom React hooks
|
hooks/ Custom React hooks
|
||||||
lib/ Utilities (supabase.ts, utils.ts)
|
lib/utils.ts cn() helper
|
||||||
features/ Feature specifications (PROJ-X-name.md)
|
features/ One spec per feature: PROJ-X-name.md
|
||||||
INDEX.md Feature status overview
|
INDEX.md Status of every feature — read this first
|
||||||
docs/
|
docs/
|
||||||
PRD.md Product Requirements Document
|
PRD.md Product Requirements Document
|
||||||
production/ Production guides (Sentry, security, performance)
|
DEPLOYMENT.md Docker Compose operations
|
||||||
|
tests/ Playwright E2E specs
|
||||||
|
scripts/update.sh Pull, rebuild, restart, roll back on failure
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development Workflow
|
## Workflow
|
||||||
|
|
||||||
1. `/init` - Initialize the project: PRD + feature map (run once at the start)
|
| Step | Command | Produces |
|
||||||
2. `/write-spec` - Create a full feature spec for one feature
|
|------|---------|----------|
|
||||||
3. `/architecture` - Design tech architecture (PM-friendly, no code)
|
| 1 | `/init` | PRD + feature map (once per project) |
|
||||||
4. `/frontend` - Build UI components (shadcn/ui first!)
|
| 2 | `/write-spec` | Full spec for one feature |
|
||||||
5. `/backend` - Build APIs, database, RLS policies
|
| 3 | `/architecture` | Tech design inside the spec |
|
||||||
6. `/qa` - Test against acceptance criteria + security audit
|
| 4 | `/frontend` | UI components |
|
||||||
7. `/deploy` - Deploy to Vercel + production-ready checks
|
| 5 | `/backend` | APIs, data layer, auth |
|
||||||
|
| 6 | `/qa` | Test results inside the spec |
|
||||||
|
| 7 | `/deploy` | Live Docker Compose stack |
|
||||||
|
|
||||||
Use `/refine PROJ-X` at any point to revisit and improve an existing feature spec.
|
`/refine PROJ-X` reopens an existing spec. `/help` reports where the project stands.
|
||||||
|
|
||||||
## Feature Tracking
|
## Conventions
|
||||||
|
|
||||||
All features tracked in `features/INDEX.md`. Every skill reads it at start and updates it when done. Feature specs live in `features/PROJ-X-name.md`.
|
- **Feature IDs:** `PROJ-1`, `PROJ-2`, … sequential, tracked in `features/INDEX.md`
|
||||||
|
- **Commits:** `type(PROJ-X): description` — feat, fix, refactor, test, docs, deploy, chore
|
||||||
|
- **One feature per spec file** — never combine independent functionality
|
||||||
|
- **Spec language:** German (PRD, specs, acceptance criteria). Code, comments and
|
||||||
|
skill instructions stay English.
|
||||||
|
- **Human-in-the-loop:** every skill ends with a user approval checkpoint
|
||||||
|
- **Tests:** unit tests co-located (`useThing.test.ts` beside `useThing.ts`), E2E in `tests/`
|
||||||
|
|
||||||
## Key Conventions
|
## Commands
|
||||||
|
|
||||||
- **Feature IDs:** PROJ-1, PROJ-2, etc. (sequential)
|
|
||||||
- **Commits:** `feat(PROJ-X): description`, `fix(PROJ-X): description`
|
|
||||||
- **Single Responsibility:** One feature per spec file
|
|
||||||
- **shadcn/ui first:** NEVER create custom versions of installed shadcn components
|
|
||||||
- **Human-in-the-loop:** All workflows have user approval checkpoints
|
|
||||||
- **Tests:** Unit tests co-located next to source files (`useHook.test.ts` next to `useHook.ts`). E2E tests in `tests/`.
|
|
||||||
|
|
||||||
## Build & Test Commands
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # Development server (localhost:3000)
|
npm run dev # Dev server on :3000
|
||||||
npm run build # Production build
|
npm run build # Production build (standalone output)
|
||||||
npm run lint # ESLint
|
npm run lint # ESLint (flat config)
|
||||||
npm run start # Production server
|
npm run typecheck # tsc --noEmit
|
||||||
npm test # Vitest unit/integration tests
|
npm test # Vitest
|
||||||
npm run test:e2e # Playwright E2E tests
|
npm run test:e2e # Playwright
|
||||||
npm run test:all # Both test suites
|
npm run test:all # Both
|
||||||
|
npm run docker:up # Build and start the Compose stack
|
||||||
|
./scripts/update.sh # Deploy latest commit on the server
|
||||||
```
|
```
|
||||||
|
|
||||||
## Product Context
|
## Product Context
|
||||||
@@ -71,3 +88,13 @@ npm run test:all # Both test suites
|
|||||||
## Feature Overview
|
## Feature Overview
|
||||||
|
|
||||||
@features/INDEX.md
|
@features/INDEX.md
|
||||||
|
|
||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||||
|
|
||||||
|
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||||
|
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
FROM node:22-alpine AS base
|
||||||
|
|
||||||
|
# --- deps: install production-resolvable dependencies once -------------------
|
||||||
|
FROM base AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# --- builder: compile the Next.js standalone bundle --------------------------
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# --- runner: minimal runtime image -------------------------------------------
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs \
|
||||||
|
&& adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
# standalone already contains the pruned node_modules and server.js
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Used by compose's healthcheck and by scripts/update.sh for rollback detection
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -1,330 +1,114 @@
|
|||||||
# AI Coding Starter Kit
|
# AI Coding Starter Kit
|
||||||
|
|
||||||
> Build production-ready web apps faster with AI-powered Skills handling Requirements, Architecture, Development, QA, and Deployment.
|
Next.js template with an AI-driven development workflow for [Claude Code](https://docs.anthropic.com/en/docs/claude-code).
|
||||||
|
Nine skills cover the path from idea to a running deployment; every phase writes its
|
||||||
|
result into a feature spec, so the project state lives in files rather than in a chat log.
|
||||||
|
|
||||||
This template uses [Claude Code](https://docs.anthropic.com/en/docs/claude-code) with modern Skills, Rules, and Sub-Agents to provide a complete AI-powered development workflow.
|
## Quick start
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### 1. Clone & Install
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/YOUR_USERNAME/ai-coding-starter-kit.git my-project
|
git clone <repo-url> my-project
|
||||||
cd my-project
|
cd my-project
|
||||||
npm install
|
npm install
|
||||||
npx playwright install chromium # one-time: installs browser for E2E tests (~300MB)
|
cp .env.example .env
|
||||||
```
|
|
||||||
|
|
||||||
### 2. (Optional) Supabase Setup
|
|
||||||
|
|
||||||
If you need a backend:
|
|
||||||
|
|
||||||
1. Create Supabase Project: [supabase.com](https://supabase.com)
|
|
||||||
2. Copy `.env.local.example` to `.env.local`
|
|
||||||
3. Add your Supabase credentials
|
|
||||||
4. Uncomment the Supabase client in `src/lib/supabase.ts`
|
|
||||||
|
|
||||||
Skip this step if you're building frontend-only (landing pages, portfolios, etc.)
|
|
||||||
|
|
||||||
### 3. Start Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
Then open Claude Code and describe what you want to build:
|
||||||
|
|
||||||
### 4. Initialize Your Project
|
|
||||||
|
|
||||||
Open Claude Code and run `/init` with a brief description of your idea:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
/init I want to build a project management tool for small teams
|
/init Ich will eine Aufgabenverwaltung für kleine Teams bauen
|
||||||
where users can create projects, assign tasks, and track progress.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The skill interviews you one question at a time (**Grill Me** principle — always with a recommended answer you just confirm or correct) until there's a shared understanding. It then:
|
`/init` interviews you one question at a time — always with a recommended answer you
|
||||||
1. Creates your **Product Requirements Document** (`docs/PRD.md`)
|
just confirm or correct — and writes `docs/PRD.md` plus a prioritized feature map.
|
||||||
2. Breaks the project into a prioritized feature map (P0/P1/P2)
|
From there:
|
||||||
3. Updates **feature tracking** (`features/INDEX.md`)
|
|
||||||
4. Recommends which feature to build first
|
|
||||||
|
|
||||||
### 5. Spec Your First Feature
|
|
||||||
|
|
||||||
After initialization, create a detailed spec for the first feature:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
/write-spec PROJ-1
|
/write-spec PROJ-1 Spec: user stories, acceptance criteria, edge cases
|
||||||
|
/architecture PROJ-1 Tech design (no code, PM-readable)
|
||||||
|
/frontend PROJ-1 UI components
|
||||||
|
/backend PROJ-1 Route handlers, data, auth
|
||||||
|
/qa PROJ-1 Test against the criteria + security audit
|
||||||
|
/deploy PROJ-1 Docker Compose stack
|
||||||
```
|
```
|
||||||
|
|
||||||
The skill interviews you about this single feature in depth — user stories, edge cases, acceptance criteria. Use `/refine PROJ-X` at any point to revisit and improve an existing spec.
|
`/refine PROJ-X` reopens a spec. `/help` tells you where you stand.
|
||||||
|
Handoffs are always user-initiated — no skill jumps ahead on its own.
|
||||||
|
|
||||||
### 6. Build Features
|
## Stack
|
||||||
|
|
||||||
```
|
Next.js 16 (App Router) · React 19 · TypeScript · Tailwind CSS v4 · shadcn/ui ·
|
||||||
/architecture Design the tech approach for features/PROJ-1-user-auth.md
|
react-hook-form + Zod · sonner · next-themes · Vitest + Playwright · Docker Compose
|
||||||
/frontend Build the UI for features/PROJ-1-user-auth.md
|
|
||||||
/backend Build the API for features/PROJ-1-user-auth.md
|
|
||||||
/qa Test features/PROJ-1-user-auth.md
|
|
||||||
/deploy Deploy to Vercel
|
|
||||||
```
|
|
||||||
|
|
||||||
Each skill suggests the next step when it finishes. Handoffs are always user-initiated.
|
**No database and no ORM ship with this template.** `/architecture` picks one per
|
||||||
|
project — the only constraint is that it runs self-hosted in the Compose stack.
|
||||||
|
|
||||||
---
|
**Authentication is fixed:** Better Auth with the passkey plugin. Passkeys are the
|
||||||
|
primary sign-in method; a password flow is only a fallback for devices that cannot
|
||||||
|
do WebAuthn.
|
||||||
|
|
||||||
## Available Skills
|
Six shadcn components are pre-installed (`button`, `card`, `input`, `label`, `form`,
|
||||||
|
`sonner`). Everything else is one command away:
|
||||||
| Skill | Command | What It Does |
|
|
||||||
|-------|---------|-------------|
|
|
||||||
| Project Initializer | `/init` | One-time setup: creates PRD + feature map via Grill Me interview |
|
|
||||||
| Feature Spec Writer | `/write-spec` | Creates a full spec for one feature (user stories, AC, edge cases) |
|
|
||||||
| Spec Refiner | `/refine PROJ-X` | Reopens an existing spec to improve, extend, or challenge it |
|
|
||||||
| Solution Architect | `/architecture` | Designs PM-friendly tech architecture (no code, only high-level design) |
|
|
||||||
| Frontend Developer | `/frontend` | Builds UI with React, Tailwind CSS, and shadcn/ui |
|
|
||||||
| Backend Developer | `/backend` | Builds APIs, database schemas, RLS policies with Supabase |
|
|
||||||
| QA Engineer | `/qa` | Tests features against acceptance criteria + security audit |
|
|
||||||
| DevOps | `/deploy` | Deploys to Vercel with production-ready checks |
|
|
||||||
| Help | `/help` | Context-aware guide: shows where you are and what to do next |
|
|
||||||
|
|
||||||
### How Skills Work
|
|
||||||
|
|
||||||
- **Skills** are defined in `.claude/skills/` and auto-discovered by Claude Code
|
|
||||||
- **Rules** in `.claude/rules/` are auto-applied based on file context (no manual loading)
|
|
||||||
- **Sub-Agents** run heavy tasks (frontend, backend, QA) in isolated contexts for cost efficiency
|
|
||||||
- **CLAUDE.md** provides project context automatically at every session start
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
|
|
||||||
```
|
|
||||||
0. Setup /init --> PRD + feature map (once per project)
|
|
||||||
1. Spec /write-spec --> Feature spec in features/PROJ-X.md
|
|
||||||
/refine PROJ-X --> Revisit and improve an existing spec
|
|
||||||
2. Design /architecture --> Tech design added to feature spec
|
|
||||||
3. Build /frontend --> UI components implemented
|
|
||||||
/backend --> APIs + database (if needed)
|
|
||||||
4. Test /qa --> Test results added to feature spec
|
|
||||||
5. Ship /deploy --> Deployed to Vercel
|
|
||||||
```
|
|
||||||
|
|
||||||
### Feature Tracking
|
|
||||||
|
|
||||||
Features are tracked in `features/INDEX.md`:
|
|
||||||
|
|
||||||
| ID | Feature | Status | Spec |
|
|
||||||
|----|---------|--------|------|
|
|
||||||
| PROJ-1 | User Login | Deployed | [Spec](features/PROJ-1-user-login.md) |
|
|
||||||
| PROJ-2 | Dashboard | In Progress | [Spec](features/PROJ-2-dashboard.md) |
|
|
||||||
|
|
||||||
Every skill reads this file at start and updates it when done, preventing duplicate work.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
| Category | Tool | Why? |
|
|
||||||
|----------|------|------|
|
|
||||||
| **Framework** | Next.js 16 | React + Server Components + App Router |
|
|
||||||
| **Language** | TypeScript | Type safety |
|
|
||||||
| **Styling** | Tailwind CSS | Utility-first CSS |
|
|
||||||
| **UI Library** | shadcn/ui | Copy-paste, customizable components |
|
|
||||||
| **Backend** | Supabase (optional) | PostgreSQL + Auth + Storage + Realtime |
|
|
||||||
| **Deployment** | Vercel | Zero-config Next.js hosting |
|
|
||||||
| **Validation** | Zod | Runtime type validation |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
ai-coding-starter-kit/
|
|
||||||
+-- CLAUDE.md <-- Auto-loaded project context
|
|
||||||
+-- .claude/
|
|
||||||
| +-- settings.json <-- Team permissions (committed)
|
|
||||||
| +-- settings.local.json <-- Personal overrides (gitignored)
|
|
||||||
| +-- rules/ <-- Auto-applied coding rules
|
|
||||||
| | +-- general.md Git workflow, feature tracking
|
|
||||||
| | +-- frontend.md shadcn/ui, component standards
|
|
||||||
| | +-- backend.md RLS, validation, queries
|
|
||||||
| | +-- security.md Secrets, headers, auth
|
|
||||||
| +-- skills/ <-- Invocable workflows (/command)
|
|
||||||
| | +-- init/SKILL.md /init
|
|
||||||
| | +-- write-spec/SKILL.md /write-spec
|
|
||||||
| | +-- refine/SKILL.md /refine
|
|
||||||
| | +-- architecture/SKILL.md /architecture
|
|
||||||
| | +-- frontend/SKILL.md /frontend (runs as sub-agent)
|
|
||||||
| | +-- backend/SKILL.md /backend (runs as sub-agent)
|
|
||||||
| | +-- qa/SKILL.md /qa (runs as sub-agent)
|
|
||||||
| | +-- deploy/SKILL.md /deploy
|
|
||||||
| | +-- help/SKILL.md /help
|
|
||||||
| +-- agents/ <-- Sub-agent configs
|
|
||||||
| +-- frontend-dev.md Model, tools, limits
|
|
||||||
| +-- backend-dev.md
|
|
||||||
| +-- qa-engineer.md
|
|
||||||
+-- features/ <-- Feature specifications
|
|
||||||
| +-- INDEX.md Status tracking
|
|
||||||
| +-- README.md Spec format documentation
|
|
||||||
+-- docs/
|
|
||||||
| +-- PRD.md <-- Product Requirements Document
|
|
||||||
| +-- production/ <-- Production setup guides
|
|
||||||
| +-- error-tracking.md Sentry setup (5 min)
|
|
||||||
| +-- security-headers.md XSS/Clickjacking protection
|
|
||||||
| +-- performance.md Lighthouse, optimization
|
|
||||||
| +-- database-optimization.md Indexing, N+1, caching
|
|
||||||
| +-- rate-limiting.md Upstash Redis
|
|
||||||
+-- src/
|
|
||||||
| +-- app/ <-- Pages (Next.js App Router)
|
|
||||||
| +-- components/
|
|
||||||
| | +-- ui/ <-- shadcn/ui components (35+ installed)
|
|
||||||
| +-- hooks/ <-- Custom React hooks
|
|
||||||
| +-- lib/ <-- Utilities
|
|
||||||
+-- public/ <-- Static files
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
### 1. Initialize the Project
|
|
||||||
|
|
||||||
Run `/init` with a brief description of your idea. The skill interviews you one question at a time and fills out `docs/PRD.md` with your vision, target users, and a prioritized feature map.
|
|
||||||
|
|
||||||
### 2. Spec Your First Feature
|
|
||||||
|
|
||||||
Run `/write-spec PROJ-1`. The skill interviews you in depth about this single feature and creates a complete spec in `features/PROJ-1-name.md` — user stories, acceptance criteria, edge cases. Then suggest running `/architecture` as the next step.
|
|
||||||
|
|
||||||
### 3. Add shadcn/ui Components (as needed)
|
|
||||||
|
|
||||||
35+ components are pre-installed. Add more as needed:
|
|
||||||
```bash
|
|
||||||
npx shadcn@latest add [component-name]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Production Setup (first deployment)
|
|
||||||
|
|
||||||
When you're ready to deploy, the `/deploy` skill guides you through:
|
|
||||||
- Vercel setup and deployment
|
|
||||||
- Error tracking with Sentry
|
|
||||||
- Security headers configuration
|
|
||||||
- Performance monitoring with Lighthouse
|
|
||||||
|
|
||||||
See `docs/production/` for detailed setup guides.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How It Works Under the Hood
|
|
||||||
|
|
||||||
### Skills (`.claude/skills/`)
|
|
||||||
Each skill is a structured workflow that Claude Code discovers automatically. Skills can run inline (in the main conversation) or as forked sub-agents (isolated context window).
|
|
||||||
|
|
||||||
| Skill | Execution | Why? |
|
|
||||||
|-------|-----------|------|
|
|
||||||
| `/init` | Inline | Needs live interview with user |
|
|
||||||
| `/write-spec` | Inline | Needs live interview with user |
|
|
||||||
| `/refine` | Inline | Needs live interview with user |
|
|
||||||
| `/architecture` | Inline | Short output, user reviews in real-time |
|
|
||||||
| `/frontend` | Sub-agent (forked) | Heavy file editing, lots of output |
|
|
||||||
| `/backend` | Sub-agent (forked) | Heavy file editing, SQL, API code |
|
|
||||||
| `/qa` | Sub-agent (forked) | Systematic testing, lots of output |
|
|
||||||
| `/deploy` | Inline | Deployment needs user oversight |
|
|
||||||
| `/help` | Inline | Quick status check and guidance |
|
|
||||||
|
|
||||||
### Rules (`.claude/rules/`)
|
|
||||||
Coding standards that are auto-applied based on which files Claude is working with. No manual loading needed.
|
|
||||||
|
|
||||||
### Sub-Agent Configs (`.claude/agents/`)
|
|
||||||
Lightweight configurations that define model, tool access, and turn limits for forked skills.
|
|
||||||
|
|
||||||
### CLAUDE.md
|
|
||||||
Auto-loaded at every session start. Contains tech stack, conventions, and references to PRD and feature index.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context Engineering
|
|
||||||
|
|
||||||
AI agents work best with clean, structured context - not longer prompts. This template is designed around these principles:
|
|
||||||
|
|
||||||
### State lives in files, not in memory
|
|
||||||
|
|
||||||
Every skill reads `features/INDEX.md` and the relevant feature spec at start. After context compaction or a new session, nothing is lost - the agent simply re-reads the files. Progress tracking, acceptance criteria, and tech designs all live in markdown files, not in the conversation.
|
|
||||||
|
|
||||||
### Context is layered
|
|
||||||
|
|
||||||
Not everything is loaded at once. Information is layered by relevance:
|
|
||||||
|
|
||||||
| Layer | What | When loaded |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| `CLAUDE.md` | Tech stack, conventions, commands | Every session (auto) |
|
|
||||||
| `.claude/rules/` | Coding standards | When editing matching files (auto) |
|
|
||||||
| Skill `SKILL.md` | Workflow instructions | When skill is invoked |
|
|
||||||
| Feature spec | Requirements, AC, tech design | On demand (skill reads it) |
|
|
||||||
| `docs/production/` | Deployment guides | Only when referenced |
|
|
||||||
|
|
||||||
### Context is isolated
|
|
||||||
|
|
||||||
Heavy implementation skills (`/frontend`, `/backend`, `/qa`) run as **forked sub-agents** with their own context window. Research noise from one skill doesn't pollute another. Each fork starts clean and loads only what it needs.
|
|
||||||
|
|
||||||
### Context recovery is built in
|
|
||||||
|
|
||||||
All forked skills include a **Context Recovery** section: if the context is compacted mid-task, the agent re-reads the feature spec, checks `git diff` for progress, and continues without restarting or duplicating work.
|
|
||||||
|
|
||||||
### Always read, never guess
|
|
||||||
|
|
||||||
A global rule (`rules/general.md`) enforces: always read a file before modifying it, never assume contents from memory, verify import paths and API routes by reading. This prevents hallucinated code references - the most common source of AI coding errors.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Customization for Your Team
|
|
||||||
|
|
||||||
This template is designed as a starting point. Customize it for your team:
|
|
||||||
|
|
||||||
1. **Edit CLAUDE.md** - Add your project-specific conventions and build commands
|
|
||||||
2. **Edit docs/PRD.md** - Define your product vision and roadmap
|
|
||||||
3. **Edit .claude/rules/** - Adjust coding standards for your team
|
|
||||||
4. **Edit .claude/skills/** - Modify workflows to match your process
|
|
||||||
5. **Edit .claude/settings.json** - Configure team permissions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Production Guides
|
|
||||||
|
|
||||||
Standalone guides in `docs/production/`:
|
|
||||||
|
|
||||||
| Guide | Setup Time | What It Does |
|
|
||||||
|-------|-----------|-------------|
|
|
||||||
| [Error Tracking](docs/production/error-tracking.md) | 5 min | Sentry integration for automatic error capture |
|
|
||||||
| [Security Headers](docs/production/security-headers.md) | 2 min | XSS, Clickjacking, MIME sniffing protection |
|
|
||||||
| [Performance](docs/production/performance.md) | 10 min | Lighthouse checks, image optimization, caching |
|
|
||||||
| [Database Optimization](docs/production/database-optimization.md) | 15 min | Indexing, N+1 prevention, query optimization |
|
|
||||||
| [Rate Limiting](docs/production/rate-limiting.md) | 10 min | Upstash Redis for API abuse prevention |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scripts
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # Development server (localhost:3000)
|
npx shadcn@latest add dialog table tabs --yes
|
||||||
npm run build # Production build
|
```
|
||||||
npm run start # Production server
|
|
||||||
|
Tailwind v4 is CSS-first: there is no `tailwind.config.ts`. Theme tokens live in
|
||||||
|
`src/app/globals.css` under `@theme inline`.
|
||||||
|
|
||||||
|
## How the workflow is wired
|
||||||
|
|
||||||
|
| Directory | Contents |
|
||||||
|
|-----------|----------|
|
||||||
|
| `CLAUDE.md` | Loaded every session — stack, structure, conventions |
|
||||||
|
| `.claude/rules/` | Applied automatically based on which files are being edited |
|
||||||
|
| `.claude/skills/` | The nine `/commands`, one folder each |
|
||||||
|
| `.claude/agents/` | Sub-agents for the implementation-heavy phases |
|
||||||
|
| `features/` | `INDEX.md` plus one spec per feature |
|
||||||
|
| `docs/` | `PRD.md`, `DEPLOYMENT.md` |
|
||||||
|
|
||||||
|
`/frontend`, `/backend` and `/qa` run the interview and the review with you in the
|
||||||
|
main conversation, then hand the heavy file work to their sub-agent. The sub-agent
|
||||||
|
reports back; the skill keeps ownership of the spec and status updates.
|
||||||
|
|
||||||
|
State is deliberately file-based: if the context is compacted mid-task, the skill
|
||||||
|
re-reads the spec and `git diff`, and continues without redoing work.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Self-hosted Docker Compose. The stack publishes the app on loopback; a reverse proxy
|
||||||
|
on the host terminates TLS.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build # first start
|
||||||
|
./scripts/update.sh # deploy the latest commit
|
||||||
|
```
|
||||||
|
|
||||||
|
`update.sh` pulls, rebuilds, restarts and waits for the healthcheck — and rolls back
|
||||||
|
to the previous commit if the new container never turns healthy.
|
||||||
|
Details in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Dev server on :3000
|
||||||
|
npm run build # Production build (standalone output)
|
||||||
npm run lint # ESLint
|
npm run lint # ESLint
|
||||||
npm test # Vitest: integration tests for API routes
|
npm run typecheck # tsc --noEmit
|
||||||
npm run test:e2e # Playwright: E2E tests for user flows
|
npm test # Vitest — unit tests, co-located in src/
|
||||||
npm run test:all # Run both test suites
|
npm run test:e2e # Playwright — E2E specs in tests/
|
||||||
|
npm run test:all # Both
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Playwright needs its browser once per machine: `npx playwright install chromium`
|
||||||
|
|
||||||
## Author
|
## Customizing
|
||||||
|
|
||||||
Created by **Alex Sprogis** – AI Product Engineer & Content Creator.
|
`CLAUDE.md` for stack and conventions · `.claude/rules/` for coding standards ·
|
||||||
|
`.claude/skills/` for the workflow itself · `.claude/settings.json` for permissions.
|
||||||
- [YouTube](https://www.youtube.com/@alex.sprogis)
|
|
||||||
- [Website](https://alexsprogis.de)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License - feel free to use for your projects!
|
MIT
|
||||||
|
|||||||
+7
-3
@@ -1,17 +1,21 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
"style": "default",
|
"style": "new-york",
|
||||||
"rsc": true,
|
"rsc": true,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "tailwind.config.ts",
|
"config": "",
|
||||||
"css": "src/app/globals.css",
|
"css": "src/app/globals.css",
|
||||||
"baseColor": "slate",
|
"baseColor": "slate",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils"
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: ${APP_NAME:-ai-coding-starter}:latest
|
||||||
|
container_name: ${APP_NAME:-ai-coding-starter}
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3000
|
||||||
|
ports:
|
||||||
|
# Bound to loopback: the reverse proxy on the host forwards to this.
|
||||||
|
# Set APP_BIND=0.0.0.0 in .env only if the port must be publicly reachable.
|
||||||
|
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-3000}:3000"
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- node
|
||||||
|
- -e
|
||||||
|
- "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 20s
|
||||||
|
retries: 3
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# A database is deliberately not part of this template — /architecture picks one
|
||||||
|
# per project. When you add one, declare it here as a second service, put it on
|
||||||
|
# an internal network without a published port, and add its volume below.
|
||||||
|
#
|
||||||
|
# db:
|
||||||
|
# image: postgres:17-alpine
|
||||||
|
# restart: unless-stopped
|
||||||
|
# environment:
|
||||||
|
# POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||||
|
# POSTGRES_DB: ${POSTGRES_DB:-app}
|
||||||
|
# volumes:
|
||||||
|
# - db-data:/var/lib/postgresql/data
|
||||||
|
#
|
||||||
|
# volumes:
|
||||||
|
# db-data:
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Deployment
|
||||||
|
|
||||||
|
Self-hosted Docker Compose. One app container, published on loopback; a reverse
|
||||||
|
proxy already running on the host terminates TLS and forwards to it.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `Dockerfile` | Three-stage build → Next.js standalone bundle, runs as an unprivileged user |
|
||||||
|
| `compose.yaml` | The `app` service, healthcheck, log rotation |
|
||||||
|
| `.dockerignore` | Keeps the build context small — no `node_modules`, no `.env`, no docs |
|
||||||
|
| `scripts/update.sh` | Pull, rebuild, restart, roll back if unhealthy |
|
||||||
|
| `.env.example` | Every variable the app needs, with dummy values |
|
||||||
|
|
||||||
|
## First deployment
|
||||||
|
|
||||||
|
Prerequisites on the server: Docker with the Compose plugin, a reverse proxy, and
|
||||||
|
a DNS record pointing at it.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url> /srv/<app-name>
|
||||||
|
cd /srv/<app-name>
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env`:
|
||||||
|
|
||||||
|
| Variable | Notes |
|
||||||
|
|----------|-------|
|
||||||
|
| `NEXT_PUBLIC_APP_URL` | The real public URL. With auth this **must** match the domain exactly — the WebAuthn relying-party ID is derived from it, and a mismatch breaks every passkey without a clear error. |
|
||||||
|
| `APP_PORT` | Host port the reverse proxy forwards to |
|
||||||
|
| `APP_BIND` | Leave at `127.0.0.1` unless the container must be publicly reachable |
|
||||||
|
| `BETTER_AUTH_SECRET` | `openssl rand -base64 32`, unique per environment |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
curl -fsS http://127.0.0.1:3000/api/health # {"status":"ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reverse proxy
|
||||||
|
|
||||||
|
Forward to `127.0.0.1:$APP_PORT` and pass through `X-Forwarded-Proto` and
|
||||||
|
`X-Forwarded-Host` — without them redirects and WebAuthn origin checks fail.
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Caddy:
|
||||||
|
|
||||||
|
```
|
||||||
|
app.example.com {
|
||||||
|
reverse_proxy 127.0.0.1:3000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Passkeys require HTTPS in production. Localhost is the only exemption.
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/update.sh # deploy the current branch's latest commit
|
||||||
|
./scripts/update.sh --force # rebuild even without a new commit
|
||||||
|
./scripts/update.sh --branch dev # deploy another branch
|
||||||
|
```
|
||||||
|
|
||||||
|
What it does: refuses to run on a dirty working tree, fast-forwards to
|
||||||
|
`origin/<branch>`, rebuilds, restarts, then polls the container healthcheck for up
|
||||||
|
to `HEALTH_TIMEOUT` seconds (default 120). If the container never turns healthy it
|
||||||
|
prints the last 50 log lines, resets to the previous commit, rebuilds and restarts
|
||||||
|
that — so a bad deploy self-heals instead of leaving the site down.
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
The template ships none by design. When `/architecture` picks one:
|
||||||
|
|
||||||
|
1. Add it as a second service in `compose.yaml` — no published port, reachable only
|
||||||
|
over the internal Compose network
|
||||||
|
2. Add a named volume for its data
|
||||||
|
3. Add `DATABASE_URL` to `.env.example`
|
||||||
|
4. Run migrations **before** the new container serves traffic. Either add a
|
||||||
|
short-lived `migrate` service that the app depends on, or add the migration
|
||||||
|
command to `scripts/update.sh` between build and restart.
|
||||||
|
5. Set up a backup for the volume — a volume is not a backup
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f app # follow logs
|
||||||
|
docker compose ps # status incl. health
|
||||||
|
docker compose restart app # restart without rebuilding
|
||||||
|
docker compose down # stop the stack
|
||||||
|
docker compose exec app sh # shell inside the container
|
||||||
|
```
|
||||||
|
|
||||||
|
Logs rotate at 10 MB with 3 files kept, configured in `compose.yaml`.
|
||||||
|
|
||||||
|
## Manual rollback
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git reset --hard <previous-sha>
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security headers
|
||||||
|
|
||||||
|
Set in `next.config.ts`: `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`,
|
||||||
|
`Referrer-Policy: origin-when-cross-origin`, `Strict-Transport-Security`.
|
||||||
|
|
||||||
|
Verify after deploying that the proxy did not strip them: DevTools → Network →
|
||||||
|
pick a request → Response Headers.
|
||||||
|
|
||||||
|
A Content-Security-Policy is deliberately not preconfigured — it breaks apps when
|
||||||
|
guessed. Add it per project, starting with `Content-Security-Policy-Report-Only`.
|
||||||
|
|
||||||
|
## Error tracking
|
||||||
|
|
||||||
|
Not preinstalled. When you want it, Sentry is the usual choice:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @sentry/wizard@latest -i nextjs
|
||||||
|
```
|
||||||
|
|
||||||
|
It edits `next.config.ts` — re-check that the `headers()` block survived, and add
|
||||||
|
`SENTRY_DSN` to `.env.example`.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause |
|
||||||
|
|---------|-------|
|
||||||
|
| Builds locally, fails in Docker | Build-time dependency sits in `devDependencies` |
|
||||||
|
| Env var undefined at runtime | Missing in the server's `.env`, or needs the `NEXT_PUBLIC_` prefix for client use |
|
||||||
|
| Container healthy, proxy returns 502 | Proxy points at the wrong port, or `APP_BIND` is not reachable from it |
|
||||||
|
| Redirect loop | Proxy not forwarding `X-Forwarded-Proto` |
|
||||||
|
| Passkeys fail after deploy | `NEXT_PUBLIC_APP_URL` mismatch, or no HTTPS |
|
||||||
|
| `update.sh` refuses to start | Uncommitted changes on the server — commit or discard them |
|
||||||
+19
-17
@@ -1,29 +1,31 @@
|
|||||||
# Product Requirements Document
|
# Product Requirements Document
|
||||||
|
|
||||||
## Vision
|
## Vision
|
||||||
_Describe what you are building and why._
|
_Was wird gebaut und warum? Zwei bis drei Sätze._
|
||||||
|
|
||||||
## Target Users
|
## Zielgruppe
|
||||||
_Who will use this product? Describe their needs and pain points._
|
_Wer nutzt das Produkt? Welchen konkreten Schmerz hat diese Gruppe heute?_
|
||||||
|
|
||||||
## Core Features (Roadmap)
|
## Roadmap
|
||||||
|
|
||||||
| Priority | Feature | Status |
|
| Priorität | Feature | Status |
|
||||||
|----------|---------|--------|
|
|-----------|---------|--------|
|
||||||
| P0 (MVP) | _Feature 1_ | Planned |
|
| P0 (MVP) | _Feature 1_ | Roadmap |
|
||||||
| P0 (MVP) | _Feature 2_ | Planned |
|
| P0 (MVP) | _Feature 2_ | Roadmap |
|
||||||
| P1 | _Feature 3_ | Planned |
|
| P1 | _Feature 3_ | Roadmap |
|
||||||
| P2 | _Feature 4_ | Planned |
|
| P2 | _Feature 4_ | Roadmap |
|
||||||
|
|
||||||
## Success Metrics
|
## Erfolgsmetriken
|
||||||
_How will you measure success? (e.g., user signups, retention, task completion rate)_
|
_Woran wird gemessen, ob das Produkt funktioniert?_
|
||||||
|
|
||||||
## Constraints
|
## Rahmenbedingungen
|
||||||
_Budget, timeline, technical limitations, team size._
|
_Zeit, Budget, Teamgröße, technische Grenzen. Auch: Braucht das Projekt dauerhafte
|
||||||
|
Datenspeicherung? Braucht es Nutzerkonten?_
|
||||||
|
|
||||||
## Non-Goals
|
## Nicht-Ziele
|
||||||
_What are you explicitly NOT building in this version?_
|
_Was in dieser Version bewusst nicht gebaut wird._
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Use `/requirements` to create detailed feature specifications for each item in the roadmap above.
|
Dieses Dokument füllt `/init` im Interview aus. Danach entstehen mit `/write-spec`
|
||||||
|
die einzelnen Feature-Specs.
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
# Database Optimization
|
|
||||||
|
|
||||||
## 1. Indexing
|
|
||||||
|
|
||||||
Create indexes on columns used in WHERE, ORDER BY, or JOIN clauses:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Without index: ~500ms at 100k rows
|
|
||||||
SELECT * FROM tasks WHERE user_id = 'abc123' ORDER BY created_at DESC;
|
|
||||||
|
|
||||||
-- After creating index: <10ms
|
|
||||||
CREATE INDEX idx_tasks_user_id_created ON tasks(user_id, created_at DESC);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rule of thumb:** If a column appears in WHERE or ORDER BY and the table will have >1000 rows, add an index.
|
|
||||||
|
|
||||||
Always include indexes in your migration SQL alongside CREATE TABLE.
|
|
||||||
|
|
||||||
## 2. Avoid N+1 Queries
|
|
||||||
|
|
||||||
The most common performance problem with ORMs and query builders:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Bad: N+1 (1 query for users + N queries for tasks)
|
|
||||||
const { data: users } = await supabase.from('users').select('*')
|
|
||||||
for (const user of users) {
|
|
||||||
const { data: tasks } = await supabase
|
|
||||||
.from('tasks')
|
|
||||||
.select('*')
|
|
||||||
.eq('user_id', user.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Good: Single query with join (1 query total)
|
|
||||||
const { data } = await supabase
|
|
||||||
.from('users')
|
|
||||||
.select('*, tasks(*)')
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Always Limit Results
|
|
||||||
|
|
||||||
Never return unbounded results from the database:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Bad: Returns ALL rows
|
|
||||||
const { data } = await supabase.from('tasks').select('*')
|
|
||||||
|
|
||||||
// Good: Returns max 50 rows
|
|
||||||
const { data } = await supabase.from('tasks').select('*').limit(50)
|
|
||||||
|
|
||||||
// Better: Paginated
|
|
||||||
const { data } = await supabase
|
|
||||||
.from('tasks')
|
|
||||||
.select('*')
|
|
||||||
.range(0, 49) // First 50 rows
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Caching Strategy
|
|
||||||
|
|
||||||
For data that changes rarely (dashboard stats, config, categories):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { unstable_cache } from 'next/cache'
|
|
||||||
|
|
||||||
export const getCategories = unstable_cache(
|
|
||||||
async () => {
|
|
||||||
const { data } = await supabase.from('categories').select('*')
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
['categories'], // Cache key
|
|
||||||
{ revalidate: 3600 } // Refresh every hour
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**When to cache:**
|
|
||||||
- Data that changes less than once per hour
|
|
||||||
- Expensive aggregation queries
|
|
||||||
- Data shared across all users (not user-specific)
|
|
||||||
|
|
||||||
**When NOT to cache:**
|
|
||||||
- User-specific data that changes frequently
|
|
||||||
- Real-time data (use Supabase Realtime instead)
|
|
||||||
|
|
||||||
## 5. Select Only What You Need
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Bad: Fetches all columns
|
|
||||||
const { data } = await supabase.from('users').select('*')
|
|
||||||
|
|
||||||
// Good: Fetches only needed columns
|
|
||||||
const { data } = await supabase.from('users').select('id, name, avatar_url')
|
|
||||||
```
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# Error Tracking Setup (Sentry)
|
|
||||||
|
|
||||||
Track production errors automatically so you know about issues before your users report them.
|
|
||||||
|
|
||||||
## Setup (5 minutes)
|
|
||||||
|
|
||||||
### 1. Create Sentry Account
|
|
||||||
- Go to [sentry.io](https://sentry.io) (free tier available for small apps)
|
|
||||||
- Create a new project and select "Next.js"
|
|
||||||
|
|
||||||
### 2. Install Next.js Integration
|
|
||||||
```bash
|
|
||||||
npx @sentry/wizard@latest -i nextjs
|
|
||||||
```
|
|
||||||
This automatically:
|
|
||||||
- Installs `@sentry/nextjs`
|
|
||||||
- Creates `sentry.client.config.ts` and `sentry.server.config.ts`
|
|
||||||
- Updates `next.config.ts` with Sentry webpack plugin
|
|
||||||
|
|
||||||
### 3. Add Environment Variables
|
|
||||||
Add to `.env.local` (local) and Vercel Dashboard (production):
|
|
||||||
```bash
|
|
||||||
SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx
|
|
||||||
NEXT_PUBLIC_SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx
|
|
||||||
SENTRY_AUTH_TOKEN=sntrys_xxx # For source maps upload
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Verify Setup
|
|
||||||
Trigger a test error and check Sentry Dashboard:
|
|
||||||
```typescript
|
|
||||||
// Temporary test - remove after verification
|
|
||||||
throw new Error("Sentry test error")
|
|
||||||
```
|
|
||||||
|
|
||||||
## What You Get
|
|
||||||
- Automatic error capture (client + server)
|
|
||||||
- Stack traces with source maps
|
|
||||||
- Error grouping and deduplication
|
|
||||||
- Email alerts for new errors
|
|
||||||
- Performance monitoring (optional)
|
|
||||||
|
|
||||||
## Alternative
|
|
||||||
**Vercel Error Tracking** - Built-in, simpler, but fewer features. Available in Vercel Dashboard under "Monitoring".
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# Performance Monitoring
|
|
||||||
|
|
||||||
## Lighthouse Check (after every deployment)
|
|
||||||
|
|
||||||
1. Open Chrome DevTools (F12)
|
|
||||||
2. Go to Lighthouse tab
|
|
||||||
3. Select: Performance, Accessibility, Best Practices, SEO
|
|
||||||
4. Generate Report for both Mobile and Desktop
|
|
||||||
5. **Target: Score > 90** in all categories
|
|
||||||
|
|
||||||
## Common Performance Issues
|
|
||||||
|
|
||||||
### Unoptimized Images
|
|
||||||
```tsx
|
|
||||||
// Bad - unoptimized, no lazy loading
|
|
||||||
<img src="/large-image.jpg" />
|
|
||||||
|
|
||||||
// Good - Next.js Image component
|
|
||||||
import Image from 'next/image'
|
|
||||||
<Image src="/large-image.jpg" width={800} height={600} alt="Description" />
|
|
||||||
```
|
|
||||||
Next.js Image automatically: resizes, lazy-loads, serves WebP format.
|
|
||||||
|
|
||||||
### Large JavaScript Bundle
|
|
||||||
Use dynamic imports for heavy components that aren't needed on initial load:
|
|
||||||
```tsx
|
|
||||||
import dynamic from 'next/dynamic'
|
|
||||||
|
|
||||||
const HeavyChart = dynamic(() => import('./HeavyChart'), {
|
|
||||||
loading: () => <p>Loading chart...</p>,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Missing Loading States
|
|
||||||
Always show feedback during data fetching:
|
|
||||||
```tsx
|
|
||||||
// Use shadcn Skeleton component
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
|
||||||
|
|
||||||
if (isLoading) return <Skeleton className="h-12 w-full" />
|
|
||||||
```
|
|
||||||
|
|
||||||
### No Caching Strategy
|
|
||||||
Cache slow database queries with `unstable_cache`:
|
|
||||||
```typescript
|
|
||||||
import { unstable_cache } from 'next/cache'
|
|
||||||
|
|
||||||
export const getStats = unstable_cache(
|
|
||||||
async () => {
|
|
||||||
const { data } = await supabase.from('stats').select('*')
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
['dashboard-stats'],
|
|
||||||
{ revalidate: 3600 } // Refresh every hour
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick Wins Checklist
|
|
||||||
- [ ] All images use `next/image` component
|
|
||||||
- [ ] Heavy components use dynamic imports
|
|
||||||
- [ ] Loading states show skeleton/spinner
|
|
||||||
- [ ] Fonts loaded with `next/font`
|
|
||||||
- [ ] No unnecessary client-side JavaScript (`"use client"` only when needed)
|
|
||||||
|
|
||||||
## Automated Monitoring
|
|
||||||
- **Vercel Analytics** - Automatic on Pro plan, shows Core Web Vitals
|
|
||||||
- **Vercel Speed Insights** - Real user performance data
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
# Rate Limiting
|
|
||||||
|
|
||||||
Prevent abuse, DDoS attacks, and excessive API usage.
|
|
||||||
|
|
||||||
## When to Add Rate Limiting
|
|
||||||
- **MVP:** Optional (focus on features first)
|
|
||||||
- **Production with users:** Recommended on auth endpoints and public APIs
|
|
||||||
- **Public-facing APIs:** Required
|
|
||||||
|
|
||||||
## Setup with Upstash Redis
|
|
||||||
|
|
||||||
### 1. Install Dependencies
|
|
||||||
```bash
|
|
||||||
npm install @upstash/ratelimit @upstash/redis
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Create Upstash Account
|
|
||||||
- Go to [upstash.com](https://upstash.com) (free tier: 10k requests/day)
|
|
||||||
- Create a Redis database
|
|
||||||
- Copy REST URL and token
|
|
||||||
|
|
||||||
### 3. Add Environment Variables
|
|
||||||
```bash
|
|
||||||
# .env.local
|
|
||||||
UPSTASH_REDIS_REST_URL=https://xxx.upstash.io
|
|
||||||
UPSTASH_REDIS_REST_TOKEN=xxx
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Create Rate Limiter
|
|
||||||
```typescript
|
|
||||||
// src/lib/rate-limit.ts
|
|
||||||
import { Ratelimit } from '@upstash/ratelimit'
|
|
||||||
import { Redis } from '@upstash/redis'
|
|
||||||
|
|
||||||
export const ratelimit = new Ratelimit({
|
|
||||||
redis: Redis.fromEnv(),
|
|
||||||
limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 requests per 10 seconds
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Use in API Routes
|
|
||||||
```typescript
|
|
||||||
// src/app/api/example/route.ts
|
|
||||||
import { ratelimit } from '@/lib/rate-limit'
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const ip = request.headers.get('x-forwarded-for') ?? 'anonymous'
|
|
||||||
const { success, limit, remaining } = await ratelimit.limit(ip)
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Too many requests' },
|
|
||||||
{
|
|
||||||
status: 429,
|
|
||||||
headers: {
|
|
||||||
'X-RateLimit-Limit': limit.toString(),
|
|
||||||
'X-RateLimit-Remaining': remaining.toString(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process request normally...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Use in Middleware (Global)
|
|
||||||
```typescript
|
|
||||||
// middleware.ts
|
|
||||||
import { ratelimit } from '@/lib/rate-limit'
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
|
||||||
// Only rate limit API routes
|
|
||||||
if (request.nextUrl.pathname.startsWith('/api/')) {
|
|
||||||
const ip = request.headers.get('x-forwarded-for') ?? 'anonymous'
|
|
||||||
const { success } = await ratelimit.limit(ip)
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return NextResponse.json({ error: 'Too Many Requests' }, { status: 429 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: '/api/:path*',
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Recommended Limits
|
|
||||||
|
|
||||||
| Endpoint Type | Limit | Window |
|
|
||||||
|--------------|-------|--------|
|
|
||||||
| Login/Register | 5 requests | 1 minute |
|
|
||||||
| Password Reset | 3 requests | 5 minutes |
|
|
||||||
| General API | 30 requests | 10 seconds |
|
|
||||||
| File Upload | 5 requests | 1 minute |
|
|
||||||
|
|
||||||
## Alternative
|
|
||||||
**Vercel Edge Config** - Simpler but less flexible. Built into Vercel, no external service needed.
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# Security Headers Configuration
|
|
||||||
|
|
||||||
Protect against XSS, Clickjacking, MIME sniffing, and other common web attacks.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
Add security headers to `next.config.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import type { NextConfig } from 'next'
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
async headers() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: '/:path*',
|
|
||||||
headers: [
|
|
||||||
{
|
|
||||||
key: 'X-Frame-Options',
|
|
||||||
value: 'DENY',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'X-Content-Type-Options',
|
|
||||||
value: 'nosniff',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'Referrer-Policy',
|
|
||||||
value: 'origin-when-cross-origin',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'Strict-Transport-Security',
|
|
||||||
value: 'max-age=31536000; includeSubDomains',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default nextConfig
|
|
||||||
```
|
|
||||||
|
|
||||||
## What Each Header Does
|
|
||||||
|
|
||||||
| Header | Protection |
|
|
||||||
|--------|-----------|
|
|
||||||
| X-Frame-Options: DENY | Prevents your site from being embedded in iframes (clickjacking) |
|
|
||||||
| X-Content-Type-Options: nosniff | Prevents browsers from guessing content types (MIME sniffing) |
|
|
||||||
| Referrer-Policy | Controls how much URL info is sent to other sites |
|
|
||||||
| Strict-Transport-Security | Forces HTTPS connections |
|
|
||||||
|
|
||||||
## Verify After Deployment
|
|
||||||
1. Open Chrome DevTools
|
|
||||||
2. Go to Network tab
|
|
||||||
3. Click on any request to your site
|
|
||||||
4. Check Response Headers section
|
|
||||||
5. Verify all 4 headers are present
|
|
||||||
|
|
||||||
## Advanced (Optional)
|
|
||||||
**Content-Security-Policy (CSP)** - The most powerful header, but can break your app if misconfigured. Only add after thorough testing:
|
|
||||||
```
|
|
||||||
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'
|
|
||||||
```
|
|
||||||
Start with report-only mode first: `Content-Security-Policy-Report-Only`
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import next from 'eslint-config-next/core-web-vitals'
|
||||||
|
|
||||||
|
const config = [
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'.next/**',
|
||||||
|
'out/**',
|
||||||
|
'build/**',
|
||||||
|
'node_modules/**',
|
||||||
|
'playwright-report/**',
|
||||||
|
'test-results/**',
|
||||||
|
'next-env.d.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
...next,
|
||||||
|
]
|
||||||
|
|
||||||
|
export default config
|
||||||
+17
-14
@@ -1,21 +1,24 @@
|
|||||||
# Feature Index
|
# Feature Index
|
||||||
|
|
||||||
> Central tracking for all features. Updated by skills automatically.
|
> Zentrales Tracking aller Features. Wird von den Skills automatisch gepflegt.
|
||||||
|
|
||||||
## Status Legend
|
## Status
|
||||||
- **Roadmap** - `/init` done, feature identified in feature map, no spec file yet
|
|
||||||
- **Planned** - `/write-spec` done, full spec written, architecture not yet designed
|
`Roadmap` → `Planned` → `Architected` → `In Progress` → `In Review` → `Approved` → `Deployed`
|
||||||
- **Architected** - `/architecture` done, tech design approved, ready to build
|
|
||||||
- **In Progress** - `/frontend` or `/backend` active or completed, not yet in QA
|
| Status | Bedeutung |
|
||||||
- **In Review** - `/qa` active, testing in progress
|
|--------|-----------|
|
||||||
- **Approved** - `/qa` passed, no critical/high bugs, ready to deploy
|
| Roadmap | In `/init` identifiziert, noch keine Spec-Datei |
|
||||||
- **Deployed** - `/deploy` done, live in production
|
| Planned | `/write-spec` fertig — Spec geschrieben |
|
||||||
|
| Architected | `/architecture` fertig — technisches Design steht |
|
||||||
|
| In Progress | `/frontend` oder `/backend` läuft oder ist fertig |
|
||||||
|
| In Review | `/qa` läuft |
|
||||||
|
| Approved | `/qa` bestanden, keine Critical/High-Bugs |
|
||||||
|
| Deployed | `/deploy` fertig, live |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
| ID | Feature | Status | Spec | Created |
|
| ID | Feature | Priorität | Abhängigkeiten | Status | Spec |
|
||||||
|----|---------|--------|------|---------|
|
|----|---------|-----------|----------------|--------|------|
|
||||||
|
|
||||||
<!-- Add features above this line -->
|
## Nächste freie ID: PROJ-1
|
||||||
|
|
||||||
## Next Available ID: PROJ-1
|
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
# Feature Specifications
|
|
||||||
|
|
||||||
Dieser Ordner enthält detaillierte Feature Specs vom Requirements Engineer.
|
|
||||||
|
|
||||||
## Naming Convention
|
|
||||||
`PROJ-X-feature-name.md`
|
|
||||||
|
|
||||||
Beispiele:
|
|
||||||
- `PROJ-1-user-authentication.md`
|
|
||||||
- `PROJ-2-kanban-board.md`
|
|
||||||
- `PROJ-3-file-attachments.md`
|
|
||||||
|
|
||||||
## Was gehört in eine Feature Spec?
|
|
||||||
|
|
||||||
### 1. User Stories
|
|
||||||
Beschreibe, was der User tun möchte:
|
|
||||||
```markdown
|
|
||||||
Als [User-Typ] möchte ich [Aktion] um [Ziel zu erreichen]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Acceptance Criteria
|
|
||||||
Konkrete, testbare Kriterien:
|
|
||||||
```markdown
|
|
||||||
- [ ] User kann Email + Passwort eingeben
|
|
||||||
- [ ] Passwort muss mindestens 8 Zeichen lang sein
|
|
||||||
- [ ] Nach Registration wird User automatisch eingeloggt
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Edge Cases
|
|
||||||
Was passiert bei unerwarteten Situationen:
|
|
||||||
```markdown
|
|
||||||
- Was passiert bei doppelter Email?
|
|
||||||
- Was passiert bei Netzwerkfehler?
|
|
||||||
- Was passiert bei gleichzeitigen Edits?
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Tech Design (vom Solution Architect)
|
|
||||||
```markdown
|
|
||||||
## Database Schema
|
|
||||||
CREATE TABLE tasks (...);
|
|
||||||
|
|
||||||
## Component Architecture
|
|
||||||
ProjectDashboard
|
|
||||||
├── ProjectList
|
|
||||||
│ └── ProjectCard
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. QA Test Results (vom QA Engineer)
|
|
||||||
Am Ende des Feature-Dokuments fügt QA die Test-Ergebnisse hinzu:
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
|
|
||||||
## QA Test Results
|
|
||||||
|
|
||||||
**Tested:** 2026-01-12
|
|
||||||
**App URL:** http://localhost:3000
|
|
||||||
|
|
||||||
### Acceptance Criteria Status
|
|
||||||
- [x] AC-1: User kann Email + Passwort eingeben
|
|
||||||
- [x] AC-2: Passwort mindestens 8 Zeichen
|
|
||||||
- [ ] ❌ BUG: Doppelte Email wird nicht abgelehnt
|
|
||||||
|
|
||||||
### Bugs Found
|
|
||||||
**BUG-1: Doppelte Email-Registrierung**
|
|
||||||
- **Severity:** High
|
|
||||||
- **Steps to Reproduce:** 1. Register with email, 2. Try again with same email
|
|
||||||
- **Expected:** Error message
|
|
||||||
- **Actual:** Silent failure
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Deployment Status (vom DevOps Engineer)
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
**Status:** ✅ Deployed
|
|
||||||
**Deployed:** 2026-01-13
|
|
||||||
**Production URL:** https://your-app.vercel.app
|
|
||||||
**Git Tag:** v1.0.0-PROJ-1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
1. **Requirements Engineer** erstellt Feature Spec
|
|
||||||
2. **User** reviewed Spec und gibt Feedback
|
|
||||||
3. **Solution Architect** fügt Tech-Design hinzu
|
|
||||||
4. **User** approved finales Design
|
|
||||||
5. **Frontend/Backend Devs** implementieren (dokumentiert via Git Commits)
|
|
||||||
6. **QA Engineer** testet und fügt Test-Ergebnisse zum Feature-Dokument hinzu
|
|
||||||
7. **DevOps** deployed und fügt Deployment-Status zum Feature-Dokument hinzu
|
|
||||||
|
|
||||||
## Status-Tracking
|
|
||||||
|
|
||||||
Feature-Status wird direkt im Feature-Dokument getrackt:
|
|
||||||
```markdown
|
|
||||||
# PROJ-1: Feature Name
|
|
||||||
|
|
||||||
**Status:** 🔵 Planned | 🟡 In Progress | ✅ Deployed
|
|
||||||
**Created:** 2026-01-12
|
|
||||||
**Last Updated:** 2026-01-12
|
|
||||||
```
|
|
||||||
|
|
||||||
**Status-Bedeutung:**
|
|
||||||
- 🔵 Planned – Requirements sind geschrieben, ready for development
|
|
||||||
- 🟡 In Progress – Wird gerade gebaut
|
|
||||||
- ✅ Deployed – Live in Production
|
|
||||||
|
|
||||||
**Git als Single Source of Truth:**
|
|
||||||
- Alle Implementierungs-Details sind in Git Commits
|
|
||||||
- `git log --grep="PROJ-1"` zeigt alle Änderungen für dieses Feature
|
|
||||||
- Keine separate FEATURE_CHANGELOG.md nötig!
|
|
||||||
+22
-4
@@ -1,7 +1,25 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from 'next'
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
// Emits .next/standalone — the Docker image copies only that, not node_modules.
|
||||||
};
|
output: 'standalone',
|
||||||
|
|
||||||
export default nextConfig;
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/:path*',
|
||||||
|
headers: [
|
||||||
|
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||||
|
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||||
|
{ key: 'Referrer-Policy', value: 'origin-when-cross-origin' },
|
||||||
|
{
|
||||||
|
key: 'Strict-Transport-Security',
|
||||||
|
value: 'max-age=31536000; includeSubDomains',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default nextConfig
|
||||||
|
|||||||
Generated
+1483
-1876
File diff suppressed because it is too large
Load Diff
+11
-29
@@ -1,53 +1,35 @@
|
|||||||
{
|
{
|
||||||
"name": "ai-coding-starter-kit",
|
"name": "ai-coding-starter-kit",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "AI Coding Starter Kit with AI Agent Team System for Claude Code",
|
"description": "Next.js starter with an AI-driven development workflow (skills, agents, feature specs) for Claude Code",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "eslint .",
|
||||||
"test": "vitest run",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run --passWithNoTests",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test --pass-with-no-tests",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:all": "npm run test && npm run test:e2e",
|
||||||
"test:all": "vitest run && playwright test"
|
"docker:up": "docker compose up -d --build",
|
||||||
|
"docker:down": "docker compose down",
|
||||||
|
"docker:logs": "docker compose logs -f app"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
|
||||||
"@radix-ui/react-toast": "^1.2.15",
|
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
|
||||||
"@supabase/supabase-js": "^2.39.3",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"cmdk": "^1.1.1",
|
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "^16.1.1",
|
"next": "^16.1.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"radix-ui": "^1.6.7",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^2.2.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+14
-6
@@ -1,22 +1,30 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test'
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
|
// Override with E2E_PORT when something else already occupies 3000 — otherwise
|
||||||
|
// reuseExistingServer would silently run the suite against that foreign server.
|
||||||
|
const PORT = Number(process.env.E2E_PORT ?? 3000)
|
||||||
|
const BASE_URL = `http://localhost:${PORT}`
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests',
|
testDir: './tests',
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
reporter: 'html',
|
reporter: process.env.CI ? 'list' : 'html',
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:3000',
|
baseURL: BASE_URL,
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
},
|
},
|
||||||
|
// Both projects run on the chromium binary, so `npx playwright install chromium`
|
||||||
|
// is enough for the whole suite.
|
||||||
projects: [
|
projects: [
|
||||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } },
|
||||||
{ name: 'Mobile Safari', use: { ...devices['iPhone 13'] } },
|
{ name: 'mobile', use: { ...devices['Pixel 5'] } },
|
||||||
],
|
],
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'npm run dev',
|
command: `npm run dev -- --port ${PORT}`,
|
||||||
url: 'http://localhost:3000',
|
url: BASE_URL,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120_000,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
+1
-2
@@ -1,8 +1,7 @@
|
|||||||
/** @type {import('postcss-load-config').Config} */
|
/** @type {import('postcss-load-config').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
'@tailwindcss/postcss': {},
|
||||||
autoprefixer: {},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Keeps public/ in git. The Dockerfile copies this directory, so it must exist
|
||||||
|
# in a fresh clone even while empty.
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"><path fill="currentColor" d="M3 0h10l3 3v10a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3z"/><path fill="white" d="M3 4h10v1H3zM3 7h10v1H3zM3 10h7v1H3z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 233 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"><circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/><path stroke="currentColor" stroke-linecap="round" stroke-width="1.5" d="M1 8h14M8 1c1.5 1 2.5 3.1 2.5 7s-1 6-2.5 7m0-14c-1.5 1-2.5 3.1-2.5 7s1 6 2.5 7"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 303 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6.2c.3 0 .4.1.4.4v12.9c0 .3-.1.4-.4.4h-6.2c-.3 0-.4-.1-.4-.4V45.8c0-.3.1-.4.4-.4zm-22 0h6.2c.3 0 .5.1.5.4v12.9c0 .3-.2.4-.5.4h-6.2c-.3 0-.4-.1-.4-.4V45.8c0-.3.1-.4.4-.4zm-11.4 0h6.2c.3 0 .5.1.5.4v12.9c0 .3-.2.4-.5.4h-6.2c-.3 0-.4-.1-.4-.4V45.8c0-.3.1-.4.4-.4zm-11.4 0h6.2c.3 0 .4.1.4.4v12.9c0 .3-.1.4-.4.4h-6.2c-.3 0-.4-.1-.4-.4V45.8c0-.3.1-.4.4-.4z"/><path fill="url(#a)" d="m317.2 78.9-.4 1.2h.4l21.1-27.3-21.1-27.3h-.4l.4 1.2a50 50 0 0 1 2.3 14.8 50 50 0 0 1-2.3 14.8l21.1 27.3h.4l-.4-1.2a50 50 0 0 0 2.3-14.8 50 50 0 0 0-2.3-14.8l.4-1.2h-.4l-21.1 27.3z"/><defs><linearGradient id="a" x1="267.5" x2="342.8" y1="27.8" y2="27.8" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-opacity="0"/></linearGradient></defs></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 630 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"><rect width="16" height="16" fill="currentColor" rx="1.5"/><path stroke="white" stroke-linecap="round" d="M1 4.5h14M5 4.5V15"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 206 B |
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Pull the latest commit, rebuild the image and restart the stack.
|
||||||
|
# Rolls back to the previous commit if the new container never turns healthy.
|
||||||
|
#
|
||||||
|
# Usage: ./scripts/update.sh [--force] [--branch <name>]
|
||||||
|
# --force rebuild and restart even when there is no new commit
|
||||||
|
# --branch branch to deploy (default: the currently checked out branch)
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
|
||||||
|
FORCE=0
|
||||||
|
BRANCH=""
|
||||||
|
HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-120}"
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--force) FORCE=1; shift ;;
|
||||||
|
--branch) BRANCH="${2:?--branch needs a value}"; shift 2 ;;
|
||||||
|
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
|
||||||
|
err() { printf '\033[1;31m!!\033[0m %s\n' "$*" >&2; }
|
||||||
|
|
||||||
|
compose() { docker compose "$@"; }
|
||||||
|
|
||||||
|
# --- preconditions -----------------------------------------------------------
|
||||||
|
|
||||||
|
command -v git >/dev/null || { err "git not found"; exit 1; }
|
||||||
|
docker compose version >/dev/null 2>&1 || { err "docker compose not available"; exit 1; }
|
||||||
|
|
||||||
|
[ -f .env ] || { err ".env missing — copy .env.example to .env and fill it in"; exit 1; }
|
||||||
|
|
||||||
|
if [ -n "$(git status --porcelain)" ]; then
|
||||||
|
err "working tree has uncommitted changes — refusing to update"
|
||||||
|
git status --short >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -n "$BRANCH" ] || BRANCH="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
PREV_COMMIT="$(git rev-parse HEAD)"
|
||||||
|
|
||||||
|
# --- fetch -------------------------------------------------------------------
|
||||||
|
|
||||||
|
log "Fetching origin/$BRANCH"
|
||||||
|
git fetch --quiet origin "$BRANCH"
|
||||||
|
|
||||||
|
TARGET_COMMIT="$(git rev-parse "origin/$BRANCH")"
|
||||||
|
|
||||||
|
if [ "$PREV_COMMIT" = "$TARGET_COMMIT" ] && [ "$FORCE" -eq 0 ]; then
|
||||||
|
log "Already at $(git rev-parse --short HEAD) — nothing to do (use --force to rebuild anyway)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Updating $(git rev-parse --short "$PREV_COMMIT") -> $(git rev-parse --short "$TARGET_COMMIT")"
|
||||||
|
git checkout --quiet "$BRANCH"
|
||||||
|
git merge --ff-only --quiet "origin/$BRANCH"
|
||||||
|
|
||||||
|
# --- health helper -----------------------------------------------------------
|
||||||
|
|
||||||
|
container_id() { compose ps -q app; }
|
||||||
|
|
||||||
|
wait_for_health() {
|
||||||
|
local deadline=$((SECONDS + HEALTH_TIMEOUT))
|
||||||
|
local cid status
|
||||||
|
while [ "$SECONDS" -lt "$deadline" ]; do
|
||||||
|
cid="$(container_id)"
|
||||||
|
if [ -n "$cid" ]; then
|
||||||
|
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}no-healthcheck{{end}}' "$cid" 2>/dev/null || echo missing)"
|
||||||
|
case "$status" in
|
||||||
|
healthy|no-healthcheck) return 0 ;;
|
||||||
|
unhealthy) return 1 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
rollback() {
|
||||||
|
err "Deployment failed — rolling back to $(git rev-parse --short "$PREV_COMMIT")"
|
||||||
|
compose logs --tail 50 app >&2 || true
|
||||||
|
git reset --hard --quiet "$PREV_COMMIT"
|
||||||
|
compose build app
|
||||||
|
compose up -d app
|
||||||
|
if wait_for_health; then
|
||||||
|
err "Rolled back successfully. The stack runs the previous commit again."
|
||||||
|
else
|
||||||
|
err "ROLLBACK ALSO UNHEALTHY — manual intervention required."
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- build & restart ---------------------------------------------------------
|
||||||
|
|
||||||
|
log "Building image"
|
||||||
|
compose build app || rollback
|
||||||
|
|
||||||
|
log "Restarting stack"
|
||||||
|
compose up -d app || rollback
|
||||||
|
|
||||||
|
log "Waiting for healthcheck (timeout ${HEALTH_TIMEOUT}s)"
|
||||||
|
wait_for_health || rollback
|
||||||
|
|
||||||
|
# --- cleanup -----------------------------------------------------------------
|
||||||
|
|
||||||
|
log "Pruning dangling images"
|
||||||
|
docker image prune -f >/dev/null
|
||||||
|
|
||||||
|
log "Deployed $(git rev-parse --short HEAD) — stack is healthy"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Liveness probe for the Docker healthcheck and scripts/update.sh.
|
||||||
|
// Must stay dependency-free so it answers even when other services are down.
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return Response.json({ status: 'ok' })
|
||||||
|
}
|
||||||
+117
-74
@@ -1,83 +1,126 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
@import "tw-animate-css";
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer base {
|
@custom-variant dark (&:is(.dark *));
|
||||||
:root {
|
|
||||||
--background: 0 0% 100%;
|
|
||||||
--foreground: 240 10% 3.9%;
|
|
||||||
--card: 0 0% 100%;
|
|
||||||
--card-foreground: 240 10% 3.9%;
|
|
||||||
--popover: 0 0% 100%;
|
|
||||||
--popover-foreground: 240 10% 3.9%;
|
|
||||||
--primary: 240 5.9% 10%;
|
|
||||||
--primary-foreground: 0 0% 98%;
|
|
||||||
--secondary: 240 4.8% 95.9%;
|
|
||||||
--secondary-foreground: 240 5.9% 10%;
|
|
||||||
--muted: 240 4.8% 95.9%;
|
|
||||||
--muted-foreground: 240 3.8% 46.1%;
|
|
||||||
--accent: 240 4.8% 95.9%;
|
|
||||||
--accent-foreground: 240 5.9% 10%;
|
|
||||||
--destructive: 0 84.2% 60.2%;
|
|
||||||
--destructive-foreground: 0 0% 98%;
|
|
||||||
--border: 240 5.9% 90%;
|
|
||||||
--input: 240 5.9% 90%;
|
|
||||||
--ring: 240 5.9% 10%;
|
|
||||||
--radius: 0.5rem;
|
|
||||||
--chart-1: 12 76% 61%;
|
|
||||||
--chart-2: 173 58% 39%;
|
|
||||||
--chart-3: 197 37% 24%;
|
|
||||||
--chart-4: 43 74% 66%;
|
|
||||||
--chart-5: 27 87% 67%;
|
|
||||||
--sidebar-background: 0 0% 98%;
|
|
||||||
--sidebar-foreground: 240 5.3% 26.1%;
|
|
||||||
--sidebar-primary: 240 5.9% 10%;
|
|
||||||
--sidebar-primary-foreground: 0 0% 98%;
|
|
||||||
--sidebar-accent: 240 4.8% 95.9%;
|
|
||||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
|
||||||
--sidebar-border: 220 13% 91%;
|
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
:root {
|
||||||
--background: 240 10% 3.9%;
|
--radius: 0.625rem;
|
||||||
--foreground: 0 0% 98%;
|
|
||||||
--card: 240 10% 3.9%;
|
--background: oklch(1 0 0);
|
||||||
--card-foreground: 0 0% 98%;
|
--foreground: oklch(0.129 0.042 264.695);
|
||||||
--popover: 240 10% 3.9%;
|
--card: oklch(1 0 0);
|
||||||
--popover-foreground: 0 0% 98%;
|
--card-foreground: oklch(0.129 0.042 264.695);
|
||||||
--primary: 0 0% 98%;
|
--popover: oklch(1 0 0);
|
||||||
--primary-foreground: 240 5.9% 10%;
|
--popover-foreground: oklch(0.129 0.042 264.695);
|
||||||
--secondary: 240 3.7% 15.9%;
|
--primary: oklch(0.208 0.042 265.755);
|
||||||
--secondary-foreground: 0 0% 98%;
|
--primary-foreground: oklch(0.984 0.003 247.858);
|
||||||
--muted: 240 3.7% 15.9%;
|
--secondary: oklch(0.968 0.007 247.896);
|
||||||
--muted-foreground: 240 5% 64.9%;
|
--secondary-foreground: oklch(0.208 0.042 265.755);
|
||||||
--accent: 240 3.7% 15.9%;
|
--muted: oklch(0.968 0.007 247.896);
|
||||||
--accent-foreground: 0 0% 98%;
|
--muted-foreground: oklch(0.554 0.046 257.417);
|
||||||
--destructive: 0 62.8% 30.6%;
|
--accent: oklch(0.968 0.007 247.896);
|
||||||
--destructive-foreground: 0 0% 98%;
|
--accent-foreground: oklch(0.208 0.042 265.755);
|
||||||
--border: 240 3.7% 15.9%;
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--input: 240 3.7% 15.9%;
|
--border: oklch(0.929 0.013 255.508);
|
||||||
--ring: 240 4.9% 83.9%;
|
--input: oklch(0.929 0.013 255.508);
|
||||||
--chart-1: 220 70% 50%;
|
--ring: oklch(0.704 0.04 256.788);
|
||||||
--chart-2: 160 60% 45%;
|
|
||||||
--chart-3: 30 80% 55%;
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-4: 280 65% 60%;
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-5: 340 75% 55%;
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
--sidebar-background: 240 5.9% 10%;
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
--sidebar-foreground: 240 4.8% 95.9%;
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
--sidebar-primary: 224.3 76.3% 48%;
|
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
--sidebar: oklch(0.984 0.003 247.858);
|
||||||
--sidebar-accent: 240 3.7% 15.9%;
|
--sidebar-foreground: oklch(0.129 0.042 264.695);
|
||||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
--sidebar-primary: oklch(0.208 0.042 265.755);
|
||||||
--sidebar-border: 240 3.7% 15.9%;
|
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
--sidebar-accent: oklch(0.968 0.007 247.896);
|
||||||
}
|
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
|
||||||
|
--sidebar-border: oklch(0.929 0.013 255.508);
|
||||||
|
--sidebar-ring: oklch(0.704 0.04 256.788);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.129 0.042 264.695);
|
||||||
|
--foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--card: oklch(0.208 0.042 265.755);
|
||||||
|
--card-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--popover: oklch(0.208 0.042 265.755);
|
||||||
|
--popover-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--primary: oklch(0.929 0.013 255.508);
|
||||||
|
--primary-foreground: oklch(0.208 0.042 265.755);
|
||||||
|
--secondary: oklch(0.279 0.041 260.031);
|
||||||
|
--secondary-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--muted: oklch(0.279 0.041 260.031);
|
||||||
|
--muted-foreground: oklch(0.704 0.04 256.788);
|
||||||
|
--accent: oklch(0.279 0.041 260.031);
|
||||||
|
--accent-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.551 0.027 264.364);
|
||||||
|
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
|
||||||
|
--sidebar: oklch(0.208 0.042 265.755);
|
||||||
|
--sidebar-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--sidebar-accent: oklch(0.279 0.041 260.031);
|
||||||
|
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.551 0.027 264.364);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
|||||||
+19
-9
@@ -1,21 +1,31 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from 'next'
|
||||||
import "./globals.css";
|
import { ThemeProvider } from '@/components/theme-provider'
|
||||||
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
|
import './globals.css'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "AI Coding Starter Kit",
|
title: 'AI Coding Starter Kit',
|
||||||
description: "Built with AI Agent Team System",
|
description: 'Next.js starter with an AI-driven development workflow',
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="de" suppressHydrationWarning>
|
||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
{children}
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<Toaster />
|
||||||
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-95
@@ -1,101 +1,53 @@
|
|||||||
import Image from 'next/image'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ cmd: '/init', text: 'PRD und Feature-Map anlegen (einmalig)' },
|
||||||
|
{ cmd: '/write-spec', text: 'Feature-Spec schreiben' },
|
||||||
|
{ cmd: '/architecture', text: 'Technisches Design entwerfen' },
|
||||||
|
{ cmd: '/frontend', text: 'UI bauen' },
|
||||||
|
{ cmd: '/backend', text: 'API und Datenhaltung bauen' },
|
||||||
|
{ cmd: '/qa', text: 'Gegen Acceptance Criteria testen' },
|
||||||
|
{ cmd: '/deploy', text: 'Docker-Compose-Stack ausrollen' },
|
||||||
|
]
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
<main className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-8 p-8">
|
||||||
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
<div className="space-y-2">
|
||||||
<Image
|
<h1 className="text-3xl font-semibold tracking-tight">
|
||||||
className="dark:invert"
|
AI Coding Starter Kit
|
||||||
src="/next.svg"
|
</h1>
|
||||||
alt="Next.js logo"
|
<p className="text-muted-foreground">
|
||||||
width={180}
|
Die Vorlage steht. Starte in Claude Code mit{' '}
|
||||||
height={38}
|
<code className="bg-muted rounded px-1.5 py-0.5 text-sm font-medium">
|
||||||
priority
|
/init
|
||||||
/>
|
</code>{' '}
|
||||||
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
und beschreibe, was du bauen willst.
|
||||||
<li className="mb-2">
|
</p>
|
||||||
Get started by editing{' '}
|
</div>
|
||||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
|
|
||||||
src/app/page.tsx
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</li>
|
|
||||||
<li>Save and see your changes instantly.</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
<Card>
|
||||||
<a
|
<CardHeader>
|
||||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
|
<CardTitle className="text-base">Workflow</CardTitle>
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
</CardHeader>
|
||||||
target="_blank"
|
<CardContent>
|
||||||
rel="noopener noreferrer"
|
<ol className="space-y-2">
|
||||||
>
|
{steps.map((step) => (
|
||||||
<Image
|
<li key={step.cmd} className="flex gap-3 text-sm">
|
||||||
className="dark:invert"
|
<code className="text-primary w-32 shrink-0 font-medium">
|
||||||
src="/vercel.svg"
|
{step.cmd}
|
||||||
alt="Vercel logomark"
|
</code>
|
||||||
width={20}
|
<span className="text-muted-foreground">{step.text}</span>
|
||||||
height={20}
|
</li>
|
||||||
/>
|
))}
|
||||||
Deploy now
|
</ol>
|
||||||
</a>
|
</CardContent>
|
||||||
<a
|
</Card>
|
||||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<p className="text-muted-foreground text-sm">
|
||||||
target="_blank"
|
Unsicher, wo du stehst? <code className="font-medium">/help</code> zeigt
|
||||||
rel="noopener noreferrer"
|
den aktuellen Stand und den nächsten Schritt.
|
||||||
>
|
</p>
|
||||||
Read our docs
|
</main>
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/file.svg"
|
|
||||||
alt="File icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Learn
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/window.svg"
|
|
||||||
alt="Window icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Examples
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/globe.svg"
|
|
||||||
alt="Globe icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Go to nextjs.org →
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||||
|
import type { ComponentProps } from 'react'
|
||||||
|
|
||||||
|
export function ThemeProvider({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ComponentProps<typeof NextThemesProvider>) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||||
|
}
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
|
||||||
import { ChevronDown } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Accordion = AccordionPrimitive.Root
|
|
||||||
|
|
||||||
const AccordionItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AccordionPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn("border-b", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
AccordionItem.displayName = "AccordionItem"
|
|
||||||
|
|
||||||
const AccordionTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<AccordionPrimitive.Header className="flex">
|
|
||||||
<AccordionPrimitive.Trigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
|
||||||
</AccordionPrimitive.Trigger>
|
|
||||||
</AccordionPrimitive.Header>
|
|
||||||
))
|
|
||||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
|
||||||
|
|
||||||
const AccordionContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<AccordionPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
|
||||||
</AccordionPrimitive.Content>
|
|
||||||
))
|
|
||||||
|
|
||||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
|
||||||
|
|
||||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
|
||||||
|
|
||||||
const AlertDialog = AlertDialogPrimitive.Root
|
|
||||||
|
|
||||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
|
||||||
|
|
||||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
|
||||||
|
|
||||||
const AlertDialogOverlay = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AlertDialogPrimitive.Overlay
|
|
||||||
className={cn(
|
|
||||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
|
||||||
|
|
||||||
const AlertDialogContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AlertDialogPortal>
|
|
||||||
<AlertDialogOverlay />
|
|
||||||
<AlertDialogPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</AlertDialogPortal>
|
|
||||||
))
|
|
||||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
|
||||||
|
|
||||||
const AlertDialogHeader = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col space-y-2 text-center sm:text-left",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
|
||||||
|
|
||||||
const AlertDialogFooter = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
|
||||||
|
|
||||||
const AlertDialogTitle = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AlertDialogPrimitive.Title
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-lg font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
|
||||||
|
|
||||||
const AlertDialogDescription = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AlertDialogPrimitive.Description
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
AlertDialogDescription.displayName =
|
|
||||||
AlertDialogPrimitive.Description.displayName
|
|
||||||
|
|
||||||
const AlertDialogAction = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AlertDialogPrimitive.Action
|
|
||||||
ref={ref}
|
|
||||||
className={cn(buttonVariants(), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
|
||||||
|
|
||||||
const AlertDialogCancel = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AlertDialogPrimitive.Cancel
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
buttonVariants({ variant: "outline" }),
|
|
||||||
"mt-2 sm:mt-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
|
||||||
|
|
||||||
export {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogPortal,
|
|
||||||
AlertDialogOverlay,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const alertVariants = cva(
|
|
||||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-background text-foreground",
|
|
||||||
destructive:
|
|
||||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const Alert = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
|
||||||
>(({ className, variant, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
role="alert"
|
|
||||||
className={cn(alertVariants({ variant }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
Alert.displayName = "Alert"
|
|
||||||
|
|
||||||
const AlertTitle = React.forwardRef<
|
|
||||||
HTMLParagraphElement,
|
|
||||||
React.HTMLAttributes<HTMLHeadingElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<h5
|
|
||||||
ref={ref}
|
|
||||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
AlertTitle.displayName = "AlertTitle"
|
|
||||||
|
|
||||||
const AlertDescription = React.forwardRef<
|
|
||||||
HTMLParagraphElement,
|
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
AlertDescription.displayName = "AlertDescription"
|
|
||||||
|
|
||||||
export { Alert, AlertTitle, AlertDescription }
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Avatar = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AvatarPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
|
||||||
|
|
||||||
const AvatarImage = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AvatarPrimitive.Image
|
|
||||||
ref={ref}
|
|
||||||
className={cn("aspect-square h-full w-full", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
|
||||||
|
|
||||||
const AvatarFallback = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AvatarPrimitive.Fallback
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
|
||||||
|
|
||||||
export { Avatar, AvatarImage, AvatarFallback }
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const badgeVariants = cva(
|
|
||||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default:
|
|
||||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
|
||||||
secondary:
|
|
||||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
||||||
destructive:
|
|
||||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
|
||||||
outline: "text-foreground",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export interface BadgeProps
|
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
|
||||||
VariantProps<typeof badgeVariants> {}
|
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
|
||||||
return (
|
|
||||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
|
||||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Breadcrumb = React.forwardRef<
|
|
||||||
HTMLElement,
|
|
||||||
React.ComponentPropsWithoutRef<"nav"> & {
|
|
||||||
separator?: React.ReactNode
|
|
||||||
}
|
|
||||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
|
|
||||||
Breadcrumb.displayName = "Breadcrumb"
|
|
||||||
|
|
||||||
const BreadcrumbList = React.forwardRef<
|
|
||||||
HTMLOListElement,
|
|
||||||
React.ComponentPropsWithoutRef<"ol">
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ol
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
BreadcrumbList.displayName = "BreadcrumbList"
|
|
||||||
|
|
||||||
const BreadcrumbItem = React.forwardRef<
|
|
||||||
HTMLLIElement,
|
|
||||||
React.ComponentPropsWithoutRef<"li">
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<li
|
|
||||||
ref={ref}
|
|
||||||
className={cn("inline-flex items-center gap-1.5", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
BreadcrumbItem.displayName = "BreadcrumbItem"
|
|
||||||
|
|
||||||
const BreadcrumbLink = React.forwardRef<
|
|
||||||
HTMLAnchorElement,
|
|
||||||
React.ComponentPropsWithoutRef<"a"> & {
|
|
||||||
asChild?: boolean
|
|
||||||
}
|
|
||||||
>(({ asChild, className, ...props }, ref) => {
|
|
||||||
const Comp = asChild ? Slot : "a"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
ref={ref}
|
|
||||||
className={cn("transition-colors hover:text-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
BreadcrumbLink.displayName = "BreadcrumbLink"
|
|
||||||
|
|
||||||
const BreadcrumbPage = React.forwardRef<
|
|
||||||
HTMLSpanElement,
|
|
||||||
React.ComponentPropsWithoutRef<"span">
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<span
|
|
||||||
ref={ref}
|
|
||||||
role="link"
|
|
||||||
aria-disabled="true"
|
|
||||||
aria-current="page"
|
|
||||||
className={cn("font-normal text-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
BreadcrumbPage.displayName = "BreadcrumbPage"
|
|
||||||
|
|
||||||
const BreadcrumbSeparator = ({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"li">) => (
|
|
||||||
<li
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children ?? <ChevronRight />}
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
|
|
||||||
|
|
||||||
const BreadcrumbEllipsis = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) => (
|
|
||||||
<span
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
<span className="sr-only">More</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
|
|
||||||
|
|
||||||
export {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator,
|
|
||||||
BreadcrumbEllipsis,
|
|
||||||
}
|
|
||||||
@@ -1,29 +1,34 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||||
outline:
|
outline:
|
||||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-10 px-4 py-2",
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
sm: "h-9 rounded-md px-3",
|
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
lg: "h-11 rounded-md px-8",
|
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||||
icon: "h-10 w-10",
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -33,24 +38,27 @@ const buttonVariants = cva(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export interface ButtonProps
|
function Button({
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
className,
|
||||||
VariantProps<typeof buttonVariants> {
|
variant = "default",
|
||||||
asChild?: boolean
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
||||||
const Comp = asChild ? Slot : "button"
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Button.displayName = "Button"
|
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants }
|
||||||
|
|||||||
+82
-69
@@ -2,78 +2,91 @@ import * as React from "react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Card = React.forwardRef<
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
HTMLDivElement,
|
return (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<div
|
||||||
>(({ className, ...props }, ref) => (
|
data-slot="card"
|
||||||
<div
|
className={cn(
|
||||||
ref={ref}
|
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||||
className={cn(
|
className
|
||||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
)}
|
||||||
className
|
{...props}
|
||||||
)}
|
/>
|
||||||
{...props}
|
)
|
||||||
/>
|
}
|
||||||
))
|
|
||||||
Card.displayName = "Card"
|
|
||||||
|
|
||||||
const CardHeader = React.forwardRef<
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
HTMLDivElement,
|
return (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<div
|
||||||
>(({ className, ...props }, ref) => (
|
data-slot="card-header"
|
||||||
<div
|
className={cn(
|
||||||
ref={ref}
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
className
|
||||||
{...props}
|
)}
|
||||||
/>
|
{...props}
|
||||||
))
|
/>
|
||||||
CardHeader.displayName = "CardHeader"
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const CardTitle = React.forwardRef<
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
HTMLDivElement,
|
return (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<div
|
||||||
>(({ className, ...props }, ref) => (
|
data-slot="card-title"
|
||||||
<div
|
className={cn("leading-none font-semibold", className)}
|
||||||
ref={ref}
|
{...props}
|
||||||
className={cn(
|
/>
|
||||||
"text-2xl font-semibold leading-none tracking-tight",
|
)
|
||||||
className
|
}
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CardTitle.displayName = "CardTitle"
|
|
||||||
|
|
||||||
const CardDescription = React.forwardRef<
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
HTMLDivElement,
|
return (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<div
|
||||||
>(({ className, ...props }, ref) => (
|
data-slot="card-description"
|
||||||
<div
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
ref={ref}
|
{...props}
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
/>
|
||||||
{...props}
|
)
|
||||||
/>
|
}
|
||||||
))
|
|
||||||
CardDescription.displayName = "CardDescription"
|
|
||||||
|
|
||||||
const CardContent = React.forwardRef<
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
HTMLDivElement,
|
return (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<div
|
||||||
>(({ className, ...props }, ref) => (
|
data-slot="card-action"
|
||||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
className={cn(
|
||||||
))
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
CardContent.displayName = "CardContent"
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const CardFooter = React.forwardRef<
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
HTMLDivElement,
|
return (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<div
|
||||||
>(({ className, ...props }, ref) => (
|
data-slot="card-content"
|
||||||
<div
|
className={cn("px-6", className)}
|
||||||
ref={ref}
|
{...props}
|
||||||
className={cn("flex items-center p-6 pt-0", className)}
|
/>
|
||||||
{...props}
|
)
|
||||||
/>
|
}
|
||||||
))
|
|
||||||
CardFooter.displayName = "CardFooter"
|
|
||||||
|
|
||||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
|
||||||
import { Check } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Checkbox = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CheckboxPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<CheckboxPrimitive.Indicator
|
|
||||||
className={cn("grid place-content-center text-current")}
|
|
||||||
>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</CheckboxPrimitive.Indicator>
|
|
||||||
</CheckboxPrimitive.Root>
|
|
||||||
))
|
|
||||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
|
||||||
|
|
||||||
export { Checkbox }
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
|
||||||
|
|
||||||
const Collapsible = CollapsiblePrimitive.Root
|
|
||||||
|
|
||||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
|
||||||
|
|
||||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
|
||||||
|
|
||||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
|
||||||
import { Command as CommandPrimitive } from "cmdk"
|
|
||||||
import { Search } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
|
||||||
|
|
||||||
const Command = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CommandPrimitive
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
Command.displayName = CommandPrimitive.displayName
|
|
||||||
|
|
||||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
|
||||||
return (
|
|
||||||
<Dialog {...props}>
|
|
||||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
|
||||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
|
||||||
{children}
|
|
||||||
</Command>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const CommandInput = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
|
||||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
<CommandPrimitive.Input
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
|
|
||||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
|
||||||
|
|
||||||
const CommandList = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.List>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CommandPrimitive.List
|
|
||||||
ref={ref}
|
|
||||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
|
|
||||||
CommandList.displayName = CommandPrimitive.List.displayName
|
|
||||||
|
|
||||||
const CommandEmpty = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
|
||||||
>((props, ref) => (
|
|
||||||
<CommandPrimitive.Empty
|
|
||||||
ref={ref}
|
|
||||||
className="py-6 text-center text-sm"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
|
|
||||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
|
||||||
|
|
||||||
const CommandGroup = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CommandPrimitive.Group
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
|
|
||||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
|
||||||
|
|
||||||
const CommandSeparator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CommandPrimitive.Separator
|
|
||||||
ref={ref}
|
|
||||||
className={cn("-mx-1 h-px bg-border", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
|
||||||
|
|
||||||
const CommandItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CommandPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
|
|
||||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
|
||||||
|
|
||||||
const CommandShortcut = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
CommandShortcut.displayName = "CommandShortcut"
|
|
||||||
|
|
||||||
export {
|
|
||||||
Command,
|
|
||||||
CommandDialog,
|
|
||||||
CommandInput,
|
|
||||||
CommandList,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandItem,
|
|
||||||
CommandShortcut,
|
|
||||||
CommandSeparator,
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
|
||||||
import { X } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Dialog = DialogPrimitive.Root
|
|
||||||
|
|
||||||
const DialogTrigger = DialogPrimitive.Trigger
|
|
||||||
|
|
||||||
const DialogPortal = DialogPrimitive.Portal
|
|
||||||
|
|
||||||
const DialogClose = DialogPrimitive.Close
|
|
||||||
|
|
||||||
const DialogOverlay = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<DialogPrimitive.Overlay
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<DialogPortal>
|
|
||||||
<DialogOverlay />
|
|
||||||
<DialogPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</DialogPrimitive.Close>
|
|
||||||
</DialogPrimitive.Content>
|
|
||||||
</DialogPortal>
|
|
||||||
))
|
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
|
||||||
|
|
||||||
const DialogHeader = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
DialogHeader.displayName = "DialogHeader"
|
|
||||||
|
|
||||||
const DialogFooter = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
DialogFooter.displayName = "DialogFooter"
|
|
||||||
|
|
||||||
const DialogTitle = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<DialogPrimitive.Title
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"text-lg font-semibold leading-none tracking-tight",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
|
||||||
|
|
||||||
const DialogDescription = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<DialogPrimitive.Description
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
|
||||||
|
|
||||||
export {
|
|
||||||
Dialog,
|
|
||||||
DialogPortal,
|
|
||||||
DialogOverlay,
|
|
||||||
DialogClose,
|
|
||||||
DialogTrigger,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogFooter,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
|
||||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
|
||||||
|
|
||||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
|
||||||
|
|
||||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
|
||||||
|
|
||||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
|
||||||
|
|
||||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
|
||||||
|
|
||||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
|
||||||
|
|
||||||
const DropdownMenuSubTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
|
||||||
inset?: boolean
|
|
||||||
}
|
|
||||||
>(({ className, inset, children, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
|
||||||
inset && "pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronRight className="ml-auto" />
|
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
|
||||||
))
|
|
||||||
DropdownMenuSubTrigger.displayName =
|
|
||||||
DropdownMenuPrimitive.SubTrigger.displayName
|
|
||||||
|
|
||||||
const DropdownMenuSubContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.SubContent
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 origin-[--radix-dropdown-menu-content-transform-origin]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
DropdownMenuSubContent.displayName =
|
|
||||||
DropdownMenuPrimitive.SubContent.displayName
|
|
||||||
|
|
||||||
const DropdownMenuContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
|
||||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.Portal>
|
|
||||||
<DropdownMenuPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
|
||||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 origin-[--radix-dropdown-menu-content-transform-origin]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</DropdownMenuPrimitive.Portal>
|
|
||||||
))
|
|
||||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
|
||||||
|
|
||||||
const DropdownMenuItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
|
||||||
inset?: boolean
|
|
||||||
}
|
|
||||||
>(({ className, inset, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
|
||||||
inset && "pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
|
||||||
|
|
||||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
|
||||||
>(({ className, children, checked, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
checked={checked}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
|
||||||
))
|
|
||||||
DropdownMenuCheckboxItem.displayName =
|
|
||||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
|
||||||
|
|
||||||
const DropdownMenuRadioItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.RadioItem
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
<Circle className="h-2 w-2 fill-current" />
|
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
|
||||||
))
|
|
||||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
|
||||||
|
|
||||||
const DropdownMenuLabel = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
|
||||||
inset?: boolean
|
|
||||||
}
|
|
||||||
>(({ className, inset, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.Label
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"px-2 py-1.5 text-sm font-semibold",
|
|
||||||
inset && "pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
|
||||||
|
|
||||||
const DropdownMenuSeparator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.Separator
|
|
||||||
ref={ref}
|
|
||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
|
||||||
|
|
||||||
const DropdownMenuShortcut = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
|
||||||
|
|
||||||
export {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuShortcut,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuPortal,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
}
|
|
||||||
+41
-52
@@ -1,12 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
import type { Label as LabelPrimitive } from "radix-ui"
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "radix-ui"
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
FormProvider,
|
FormProvider,
|
||||||
useFormContext,
|
useFormContext,
|
||||||
|
useFormState,
|
||||||
type ControllerProps,
|
type ControllerProps,
|
||||||
type FieldPath,
|
type FieldPath,
|
||||||
type FieldValues,
|
type FieldValues,
|
||||||
@@ -19,16 +20,18 @@ const Form = FormProvider
|
|||||||
|
|
||||||
type FormFieldContextValue<
|
type FormFieldContextValue<
|
||||||
TFieldValues extends FieldValues = FieldValues,
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
> = {
|
> = {
|
||||||
name: TName
|
name: TName
|
||||||
}
|
}
|
||||||
|
|
||||||
const FormFieldContext = React.createContext<FormFieldContextValue | null>(null)
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue
|
||||||
|
)
|
||||||
|
|
||||||
const FormField = <
|
const FormField = <
|
||||||
TFieldValues extends FieldValues = FieldValues,
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
>({
|
>({
|
||||||
...props
|
...props
|
||||||
}: ControllerProps<TFieldValues, TName>) => {
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
@@ -42,18 +45,14 @@ const FormField = <
|
|||||||
const useFormField = () => {
|
const useFormField = () => {
|
||||||
const fieldContext = React.useContext(FormFieldContext)
|
const fieldContext = React.useContext(FormFieldContext)
|
||||||
const itemContext = React.useContext(FormItemContext)
|
const itemContext = React.useContext(FormItemContext)
|
||||||
const { getFieldState, formState } = useFormContext()
|
const { getFieldState } = useFormContext()
|
||||||
|
const formState = useFormState({ name: fieldContext.name })
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState)
|
||||||
|
|
||||||
if (!fieldContext) {
|
if (!fieldContext) {
|
||||||
throw new Error("useFormField should be used within <FormField>")
|
throw new Error("useFormField should be used within <FormField>")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!itemContext) {
|
|
||||||
throw new Error("useFormField should be used within <FormItem>")
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldState = getFieldState(fieldContext.name, formState)
|
|
||||||
|
|
||||||
const { id } = itemContext
|
const { id } = itemContext
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -70,48 +69,47 @@ type FormItemContextValue = {
|
|||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const FormItemContext = React.createContext<FormItemContextValue | null>(null)
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue
|
||||||
|
)
|
||||||
|
|
||||||
const FormItem = React.forwardRef<
|
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
const id = React.useId()
|
const id = React.useId()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItemContext.Provider value={{ id }}>
|
<FormItemContext.Provider value={{ id }}>
|
||||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
<div
|
||||||
|
data-slot="form-item"
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
</FormItemContext.Provider>
|
</FormItemContext.Provider>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
FormItem.displayName = "FormItem"
|
|
||||||
|
|
||||||
const FormLabel = React.forwardRef<
|
function FormLabel({
|
||||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
...props
|
||||||
>(({ className, ...props }, ref) => {
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
const { error, formItemId } = useFormField()
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Label
|
<Label
|
||||||
ref={ref}
|
data-slot="form-label"
|
||||||
className={cn(error && "text-destructive", className)}
|
data-error={!!error}
|
||||||
|
className={cn("data-[error=true]:text-destructive", className)}
|
||||||
htmlFor={formItemId}
|
htmlFor={formItemId}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
FormLabel.displayName = "FormLabel"
|
|
||||||
|
|
||||||
const FormControl = React.forwardRef<
|
function FormControl({ ...props }: React.ComponentProps<typeof Slot.Root>) {
|
||||||
React.ElementRef<typeof Slot>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof Slot>
|
|
||||||
>(({ ...props }, ref) => {
|
|
||||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Slot
|
<Slot.Root
|
||||||
ref={ref}
|
data-slot="form-control"
|
||||||
id={formItemId}
|
id={formItemId}
|
||||||
aria-describedby={
|
aria-describedby={
|
||||||
!error
|
!error
|
||||||
@@ -122,32 +120,24 @@ const FormControl = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
FormControl.displayName = "FormControl"
|
|
||||||
|
|
||||||
const FormDescription = React.forwardRef<
|
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
HTMLParagraphElement,
|
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
const { formDescriptionId } = useFormField()
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
ref={ref}
|
data-slot="form-description"
|
||||||
id={formDescriptionId}
|
id={formDescriptionId}
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
FormDescription.displayName = "FormDescription"
|
|
||||||
|
|
||||||
const FormMessage = React.forwardRef<
|
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
HTMLParagraphElement,
|
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
|
||||||
>(({ className, children, ...props }, ref) => {
|
|
||||||
const { error, formMessageId } = useFormField()
|
const { error, formMessageId } = useFormField()
|
||||||
const body = error ? String(error?.message ?? "") : children
|
const body = error ? String(error?.message ?? "") : props.children
|
||||||
|
|
||||||
if (!body) {
|
if (!body) {
|
||||||
return null
|
return null
|
||||||
@@ -155,16 +145,15 @@ const FormMessage = React.forwardRef<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
ref={ref}
|
data-slot="form-message"
|
||||||
id={formMessageId}
|
id={formMessageId}
|
||||||
className={cn("text-sm font-medium text-destructive", className)}
|
className={cn("text-sm text-destructive", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{body}
|
{body}
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
FormMessage.displayName = "FormMessage"
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
useFormField,
|
useFormField,
|
||||||
|
|||||||
+15
-16
@@ -2,21 +2,20 @@ import * as React from "react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
({ className, type, ...props }, ref) => {
|
return (
|
||||||
return (
|
<input
|
||||||
<input
|
type={type}
|
||||||
type={type}
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||||||
className
|
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||||
)}
|
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||||
ref={ref}
|
className
|
||||||
{...props}
|
)}
|
||||||
/>
|
{...props}
|
||||||
)
|
/>
|
||||||
}
|
)
|
||||||
)
|
}
|
||||||
Input.displayName = "Input"
|
|
||||||
|
|
||||||
export { Input }
|
export { Input }
|
||||||
|
|||||||
+16
-18
@@ -1,26 +1,24 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
import { Label as LabelPrimitive } from "radix-ui"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const labelVariants = cva(
|
function Label({
|
||||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
className,
|
||||||
)
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
const Label = React.forwardRef<
|
return (
|
||||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
<LabelPrimitive.Root
|
||||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
data-slot="label"
|
||||||
VariantProps<typeof labelVariants>
|
className={cn(
|
||||||
>(({ className, ...props }, ref) => (
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
<LabelPrimitive.Root
|
className
|
||||||
ref={ref}
|
)}
|
||||||
className={cn(labelVariants(), className)}
|
{...props}
|
||||||
{...props}
|
/>
|
||||||
/>
|
)
|
||||||
))
|
}
|
||||||
Label.displayName = LabelPrimitive.Root.displayName
|
|
||||||
|
|
||||||
export { Label }
|
export { Label }
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
|
||||||
import { cva } from "class-variance-authority"
|
|
||||||
import { ChevronDown } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const NavigationMenu = React.forwardRef<
|
|
||||||
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<NavigationMenuPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative z-10 flex max-w-max flex-1 items-center justify-center",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<NavigationMenuViewport />
|
|
||||||
</NavigationMenuPrimitive.Root>
|
|
||||||
))
|
|
||||||
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
|
|
||||||
|
|
||||||
const NavigationMenuList = React.forwardRef<
|
|
||||||
React.ElementRef<typeof NavigationMenuPrimitive.List>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<NavigationMenuPrimitive.List
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"group flex flex-1 list-none items-center justify-center space-x-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
|
|
||||||
|
|
||||||
const NavigationMenuItem = NavigationMenuPrimitive.Item
|
|
||||||
|
|
||||||
const navigationMenuTriggerStyle = cva(
|
|
||||||
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:text-accent-foreground data-[state=open]:bg-accent/50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent"
|
|
||||||
)
|
|
||||||
|
|
||||||
const NavigationMenuTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<NavigationMenuPrimitive.Trigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}{" "}
|
|
||||||
<ChevronDown
|
|
||||||
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</NavigationMenuPrimitive.Trigger>
|
|
||||||
))
|
|
||||||
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
|
|
||||||
|
|
||||||
const NavigationMenuContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<NavigationMenuPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
|
|
||||||
|
|
||||||
const NavigationMenuLink = NavigationMenuPrimitive.Link
|
|
||||||
|
|
||||||
const NavigationMenuViewport = React.forwardRef<
|
|
||||||
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div className={cn("absolute left-0 top-full flex justify-center")}>
|
|
||||||
<NavigationMenuPrimitive.Viewport
|
|
||||||
className={cn(
|
|
||||||
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
NavigationMenuViewport.displayName =
|
|
||||||
NavigationMenuPrimitive.Viewport.displayName
|
|
||||||
|
|
||||||
const NavigationMenuIndicator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<NavigationMenuPrimitive.Indicator
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
|
||||||
</NavigationMenuPrimitive.Indicator>
|
|
||||||
))
|
|
||||||
NavigationMenuIndicator.displayName =
|
|
||||||
NavigationMenuPrimitive.Indicator.displayName
|
|
||||||
|
|
||||||
export {
|
|
||||||
navigationMenuTriggerStyle,
|
|
||||||
NavigationMenu,
|
|
||||||
NavigationMenuList,
|
|
||||||
NavigationMenuItem,
|
|
||||||
NavigationMenuContent,
|
|
||||||
NavigationMenuTrigger,
|
|
||||||
NavigationMenuLink,
|
|
||||||
NavigationMenuIndicator,
|
|
||||||
NavigationMenuViewport,
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { ButtonProps, buttonVariants } from "@/components/ui/button"
|
|
||||||
|
|
||||||
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
|
||||||
<nav
|
|
||||||
role="navigation"
|
|
||||||
aria-label="pagination"
|
|
||||||
className={cn("mx-auto flex w-full justify-center", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
Pagination.displayName = "Pagination"
|
|
||||||
|
|
||||||
const PaginationContent = React.forwardRef<
|
|
||||||
HTMLUListElement,
|
|
||||||
React.ComponentProps<"ul">
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ul
|
|
||||||
ref={ref}
|
|
||||||
className={cn("flex flex-row items-center gap-1", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
PaginationContent.displayName = "PaginationContent"
|
|
||||||
|
|
||||||
const PaginationItem = React.forwardRef<
|
|
||||||
HTMLLIElement,
|
|
||||||
React.ComponentProps<"li">
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<li ref={ref} className={cn("", className)} {...props} />
|
|
||||||
))
|
|
||||||
PaginationItem.displayName = "PaginationItem"
|
|
||||||
|
|
||||||
type PaginationLinkProps = {
|
|
||||||
isActive?: boolean
|
|
||||||
} & Pick<ButtonProps, "size"> &
|
|
||||||
React.ComponentProps<"a">
|
|
||||||
|
|
||||||
const PaginationLink = ({
|
|
||||||
className,
|
|
||||||
isActive,
|
|
||||||
size = "icon",
|
|
||||||
...props
|
|
||||||
}: PaginationLinkProps) => (
|
|
||||||
<a
|
|
||||||
aria-current={isActive ? "page" : undefined}
|
|
||||||
className={cn(
|
|
||||||
buttonVariants({
|
|
||||||
variant: isActive ? "outline" : "ghost",
|
|
||||||
size,
|
|
||||||
}),
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
PaginationLink.displayName = "PaginationLink"
|
|
||||||
|
|
||||||
const PaginationPrevious = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
|
||||||
<PaginationLink
|
|
||||||
aria-label="Go to previous page"
|
|
||||||
size="default"
|
|
||||||
className={cn("gap-1 pl-2.5", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
<span>Previous</span>
|
|
||||||
</PaginationLink>
|
|
||||||
)
|
|
||||||
PaginationPrevious.displayName = "PaginationPrevious"
|
|
||||||
|
|
||||||
const PaginationNext = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
|
||||||
<PaginationLink
|
|
||||||
aria-label="Go to next page"
|
|
||||||
size="default"
|
|
||||||
className={cn("gap-1 pr-2.5", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span>Next</span>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</PaginationLink>
|
|
||||||
)
|
|
||||||
PaginationNext.displayName = "PaginationNext"
|
|
||||||
|
|
||||||
const PaginationEllipsis = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) => (
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
<span className="sr-only">More pages</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
PaginationEllipsis.displayName = "PaginationEllipsis"
|
|
||||||
|
|
||||||
export {
|
|
||||||
Pagination,
|
|
||||||
PaginationContent,
|
|
||||||
PaginationEllipsis,
|
|
||||||
PaginationItem,
|
|
||||||
PaginationLink,
|
|
||||||
PaginationNext,
|
|
||||||
PaginationPrevious,
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Popover = PopoverPrimitive.Root
|
|
||||||
|
|
||||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
|
||||||
|
|
||||||
const PopoverContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
|
||||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
|
||||||
<PopoverPrimitive.Portal>
|
|
||||||
<PopoverPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
align={align}
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
|
||||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 origin-[--radix-popover-content-transform-origin]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</PopoverPrimitive.Portal>
|
|
||||||
))
|
|
||||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
|
||||||
|
|
||||||
export { Popover, PopoverTrigger, PopoverContent }
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Progress = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
|
||||||
>(({ className, value, ...props }, ref) => (
|
|
||||||
<ProgressPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ProgressPrimitive.Indicator
|
|
||||||
className="h-full w-full flex-1 bg-primary transition-all"
|
|
||||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
|
||||||
/>
|
|
||||||
</ProgressPrimitive.Root>
|
|
||||||
))
|
|
||||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
|
||||||
|
|
||||||
export { Progress }
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
|
||||||
import { Circle } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const RadioGroup = React.forwardRef<
|
|
||||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<RadioGroupPrimitive.Root
|
|
||||||
className={cn("grid gap-2", className)}
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
|
||||||
|
|
||||||
const RadioGroupItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<RadioGroupPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
|
||||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
|
||||||
</RadioGroupPrimitive.Indicator>
|
|
||||||
</RadioGroupPrimitive.Item>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
|
||||||
|
|
||||||
export { RadioGroup, RadioGroupItem }
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const ScrollArea = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<ScrollAreaPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn("relative overflow-hidden", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
|
||||||
{children}
|
|
||||||
</ScrollAreaPrimitive.Viewport>
|
|
||||||
<ScrollBar />
|
|
||||||
<ScrollAreaPrimitive.Corner />
|
|
||||||
</ScrollAreaPrimitive.Root>
|
|
||||||
))
|
|
||||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
|
||||||
|
|
||||||
const ScrollBar = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
|
||||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
|
||||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
|
||||||
ref={ref}
|
|
||||||
orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
"flex touch-none select-none transition-colors",
|
|
||||||
orientation === "vertical" &&
|
|
||||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
|
||||||
orientation === "horizontal" &&
|
|
||||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
|
||||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
|
||||||
))
|
|
||||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
|
||||||
|
|
||||||
export { ScrollArea, ScrollBar }
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
|
||||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Select = SelectPrimitive.Root
|
|
||||||
|
|
||||||
const SelectGroup = SelectPrimitive.Group
|
|
||||||
|
|
||||||
const SelectValue = SelectPrimitive.Value
|
|
||||||
|
|
||||||
const SelectTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Trigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<SelectPrimitive.Icon asChild>
|
|
||||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
|
||||||
</SelectPrimitive.Icon>
|
|
||||||
</SelectPrimitive.Trigger>
|
|
||||||
))
|
|
||||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
|
||||||
|
|
||||||
const SelectScrollUpButton = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.ScrollUpButton
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronUp className="h-4 w-4" />
|
|
||||||
</SelectPrimitive.ScrollUpButton>
|
|
||||||
))
|
|
||||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
|
||||||
|
|
||||||
const SelectScrollDownButton = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.ScrollDownButton
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</SelectPrimitive.ScrollDownButton>
|
|
||||||
))
|
|
||||||
SelectScrollDownButton.displayName =
|
|
||||||
SelectPrimitive.ScrollDownButton.displayName
|
|
||||||
|
|
||||||
const SelectContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
|
||||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Portal>
|
|
||||||
<SelectPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 origin-[--radix-select-content-transform-origin]",
|
|
||||||
position === "popper" &&
|
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
position={position}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<SelectScrollUpButton />
|
|
||||||
<SelectPrimitive.Viewport
|
|
||||||
className={cn(
|
|
||||||
"p-1",
|
|
||||||
position === "popper" &&
|
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SelectPrimitive.Viewport>
|
|
||||||
<SelectScrollDownButton />
|
|
||||||
</SelectPrimitive.Content>
|
|
||||||
</SelectPrimitive.Portal>
|
|
||||||
))
|
|
||||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
|
||||||
|
|
||||||
const SelectLabel = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Label
|
|
||||||
ref={ref}
|
|
||||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
|
||||||
|
|
||||||
const SelectItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
||||||
<SelectPrimitive.ItemIndicator>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</SelectPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
|
||||||
</SelectPrimitive.Item>
|
|
||||||
))
|
|
||||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
|
||||||
|
|
||||||
const SelectSeparator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Separator
|
|
||||||
ref={ref}
|
|
||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
|
||||||
|
|
||||||
export {
|
|
||||||
Select,
|
|
||||||
SelectGroup,
|
|
||||||
SelectValue,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectContent,
|
|
||||||
SelectLabel,
|
|
||||||
SelectItem,
|
|
||||||
SelectSeparator,
|
|
||||||
SelectScrollUpButton,
|
|
||||||
SelectScrollDownButton,
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Separator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
|
||||||
>(
|
|
||||||
(
|
|
||||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
|
||||||
ref
|
|
||||||
) => (
|
|
||||||
<SeparatorPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
decorative={decorative}
|
|
||||||
orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 bg-border",
|
|
||||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
|
||||||
|
|
||||||
export { Separator }
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
import { X } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Sheet = SheetPrimitive.Root
|
|
||||||
|
|
||||||
const SheetTrigger = SheetPrimitive.Trigger
|
|
||||||
|
|
||||||
const SheetClose = SheetPrimitive.Close
|
|
||||||
|
|
||||||
const SheetPortal = SheetPrimitive.Portal
|
|
||||||
|
|
||||||
const SheetOverlay = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SheetPrimitive.Overlay
|
|
||||||
className={cn(
|
|
||||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
|
||||||
|
|
||||||
const sheetVariants = cva(
|
|
||||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
side: {
|
|
||||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
|
||||||
bottom:
|
|
||||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
|
||||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
|
||||||
right:
|
|
||||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
side: "right",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
interface SheetContentProps
|
|
||||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
|
||||||
VariantProps<typeof sheetVariants> {}
|
|
||||||
|
|
||||||
const SheetContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
|
||||||
SheetContentProps
|
|
||||||
>(({ side = "right", className, children, ...props }, ref) => (
|
|
||||||
<SheetPortal>
|
|
||||||
<SheetOverlay />
|
|
||||||
<SheetPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className={cn(sheetVariants({ side }), className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</SheetPrimitive.Close>
|
|
||||||
</SheetPrimitive.Content>
|
|
||||||
</SheetPortal>
|
|
||||||
))
|
|
||||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
|
||||||
|
|
||||||
const SheetHeader = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col space-y-2 text-center sm:text-left",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
SheetHeader.displayName = "SheetHeader"
|
|
||||||
|
|
||||||
const SheetFooter = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
SheetFooter.displayName = "SheetFooter"
|
|
||||||
|
|
||||||
const SheetTitle = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SheetPrimitive.Title
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-lg font-semibold text-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
|
||||||
|
|
||||||
const SheetDescription = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SheetPrimitive.Description
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
|
||||||
|
|
||||||
export {
|
|
||||||
Sheet,
|
|
||||||
SheetPortal,
|
|
||||||
SheetOverlay,
|
|
||||||
SheetTrigger,
|
|
||||||
SheetClose,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetFooter,
|
|
||||||
SheetTitle,
|
|
||||||
SheetDescription,
|
|
||||||
}
|
|
||||||
@@ -1,773 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
import { PanelLeft } from "lucide-react"
|
|
||||||
|
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from "@/components/ui/sheet"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip"
|
|
||||||
|
|
||||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
|
||||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
|
||||||
const SIDEBAR_WIDTH = "16rem"
|
|
||||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
|
||||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
|
||||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
|
||||||
|
|
||||||
type SidebarContextProps = {
|
|
||||||
state: "expanded" | "collapsed"
|
|
||||||
open: boolean
|
|
||||||
setOpen: (open: boolean) => void
|
|
||||||
openMobile: boolean
|
|
||||||
setOpenMobile: (open: boolean) => void
|
|
||||||
isMobile: boolean
|
|
||||||
toggleSidebar: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
|
||||||
|
|
||||||
function useSidebar() {
|
|
||||||
const context = React.useContext(SidebarContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
const SidebarProvider = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"div"> & {
|
|
||||||
defaultOpen?: boolean
|
|
||||||
open?: boolean
|
|
||||||
onOpenChange?: (open: boolean) => void
|
|
||||||
}
|
|
||||||
>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
defaultOpen = true,
|
|
||||||
open: openProp,
|
|
||||||
onOpenChange: setOpenProp,
|
|
||||||
className,
|
|
||||||
style,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
const isMobile = useIsMobile()
|
|
||||||
const [openMobile, setOpenMobile] = React.useState(false)
|
|
||||||
|
|
||||||
// This is the internal state of the sidebar.
|
|
||||||
// We use openProp and setOpenProp for control from outside the component.
|
|
||||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
|
||||||
const open = openProp ?? _open
|
|
||||||
const setOpen = React.useCallback(
|
|
||||||
(value: boolean | ((value: boolean) => boolean)) => {
|
|
||||||
const openState = typeof value === "function" ? value(open) : value
|
|
||||||
if (setOpenProp) {
|
|
||||||
setOpenProp(openState)
|
|
||||||
} else {
|
|
||||||
_setOpen(openState)
|
|
||||||
}
|
|
||||||
|
|
||||||
// This sets the cookie to keep the sidebar state.
|
|
||||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
|
||||||
},
|
|
||||||
[setOpenProp, open]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Helper to toggle the sidebar.
|
|
||||||
const toggleSidebar = React.useCallback(() => {
|
|
||||||
return isMobile
|
|
||||||
? setOpenMobile((open) => !open)
|
|
||||||
: setOpen((open) => !open)
|
|
||||||
}, [isMobile, setOpen, setOpenMobile])
|
|
||||||
|
|
||||||
// Adds a keyboard shortcut to toggle the sidebar.
|
|
||||||
React.useEffect(() => {
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (
|
|
||||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
|
||||||
(event.metaKey || event.ctrlKey)
|
|
||||||
) {
|
|
||||||
event.preventDefault()
|
|
||||||
toggleSidebar()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown)
|
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
|
||||||
}, [toggleSidebar])
|
|
||||||
|
|
||||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
|
||||||
// This makes it easier to style the sidebar with Tailwind classes.
|
|
||||||
const state = open ? "expanded" : "collapsed"
|
|
||||||
|
|
||||||
const contextValue = React.useMemo<SidebarContextProps>(
|
|
||||||
() => ({
|
|
||||||
state,
|
|
||||||
open,
|
|
||||||
setOpen,
|
|
||||||
isMobile,
|
|
||||||
openMobile,
|
|
||||||
setOpenMobile,
|
|
||||||
toggleSidebar,
|
|
||||||
}),
|
|
||||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarContext.Provider value={contextValue}>
|
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<div
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--sidebar-width": SIDEBAR_WIDTH,
|
|
||||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
|
||||||
...style,
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
|
||||||
</SidebarContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
SidebarProvider.displayName = "SidebarProvider"
|
|
||||||
|
|
||||||
const Sidebar = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"div"> & {
|
|
||||||
side?: "left" | "right"
|
|
||||||
variant?: "sidebar" | "floating" | "inset"
|
|
||||||
collapsible?: "offcanvas" | "icon" | "none"
|
|
||||||
}
|
|
||||||
>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
side = "left",
|
|
||||||
variant = "sidebar",
|
|
||||||
collapsible = "offcanvas",
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
|
||||||
|
|
||||||
if (collapsible === "none") {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
return (
|
|
||||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
|
||||||
<SheetContent
|
|
||||||
data-sidebar="sidebar"
|
|
||||||
data-mobile="true"
|
|
||||||
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
side={side}
|
|
||||||
>
|
|
||||||
<SheetHeader className="sr-only">
|
|
||||||
<SheetTitle>Sidebar</SheetTitle>
|
|
||||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
<div className="flex h-full w-full flex-col">{children}</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className="group peer hidden text-sidebar-foreground md:block"
|
|
||||||
data-state={state}
|
|
||||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
|
||||||
data-variant={variant}
|
|
||||||
data-side={side}
|
|
||||||
>
|
|
||||||
{/* This is what handles the sidebar gap on desktop */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
|
|
||||||
"group-data-[collapsible=offcanvas]:w-0",
|
|
||||||
"group-data-[side=right]:rotate-180",
|
|
||||||
variant === "floating" || variant === "inset"
|
|
||||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
|
|
||||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
|
|
||||||
side === "left"
|
|
||||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
|
||||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
|
||||||
// Adjust the padding for floating and inset variants.
|
|
||||||
variant === "floating" || variant === "inset"
|
|
||||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
|
|
||||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-sidebar="sidebar"
|
|
||||||
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Sidebar.displayName = "Sidebar"
|
|
||||||
|
|
||||||
const SidebarTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof Button>,
|
|
||||||
React.ComponentProps<typeof Button>
|
|
||||||
>(({ className, onClick, ...props }, ref) => {
|
|
||||||
const { toggleSidebar } = useSidebar()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="trigger"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={cn("h-7 w-7", className)}
|
|
||||||
onClick={(event) => {
|
|
||||||
onClick?.(event)
|
|
||||||
toggleSidebar()
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<PanelLeft />
|
|
||||||
<span className="sr-only">Toggle Sidebar</span>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarTrigger.displayName = "SidebarTrigger"
|
|
||||||
|
|
||||||
const SidebarRail = React.forwardRef<
|
|
||||||
HTMLButtonElement,
|
|
||||||
React.ComponentProps<"button">
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
const { toggleSidebar } = useSidebar()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="rail"
|
|
||||||
aria-label="Toggle Sidebar"
|
|
||||||
tabIndex={-1}
|
|
||||||
onClick={toggleSidebar}
|
|
||||||
title="Toggle Sidebar"
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
|
||||||
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
|
||||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
|
||||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
|
||||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
|
||||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarRail.displayName = "SidebarRail"
|
|
||||||
|
|
||||||
const SidebarInset = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"main">
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<main
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex w-full flex-1 flex-col bg-background",
|
|
||||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarInset.displayName = "SidebarInset"
|
|
||||||
|
|
||||||
const SidebarInput = React.forwardRef<
|
|
||||||
React.ElementRef<typeof Input>,
|
|
||||||
React.ComponentProps<typeof Input>
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<Input
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="input"
|
|
||||||
className={cn(
|
|
||||||
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarInput.displayName = "SidebarInput"
|
|
||||||
|
|
||||||
const SidebarHeader = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"div">
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="header"
|
|
||||||
className={cn("flex flex-col gap-2 p-2", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarHeader.displayName = "SidebarHeader"
|
|
||||||
|
|
||||||
const SidebarFooter = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"div">
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="footer"
|
|
||||||
className={cn("flex flex-col gap-2 p-2", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarFooter.displayName = "SidebarFooter"
|
|
||||||
|
|
||||||
const SidebarSeparator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof Separator>,
|
|
||||||
React.ComponentProps<typeof Separator>
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<Separator
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="separator"
|
|
||||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarSeparator.displayName = "SidebarSeparator"
|
|
||||||
|
|
||||||
const SidebarContent = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"div">
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="content"
|
|
||||||
className={cn(
|
|
||||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarContent.displayName = "SidebarContent"
|
|
||||||
|
|
||||||
const SidebarGroup = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"div">
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="group"
|
|
||||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarGroup.displayName = "SidebarGroup"
|
|
||||||
|
|
||||||
const SidebarGroupLabel = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"div"> & { asChild?: boolean }
|
|
||||||
>(({ className, asChild = false, ...props }, ref) => {
|
|
||||||
const Comp = asChild ? Slot : "div"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="group-label"
|
|
||||||
className={cn(
|
|
||||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
|
||||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarGroupLabel.displayName = "SidebarGroupLabel"
|
|
||||||
|
|
||||||
const SidebarGroupAction = React.forwardRef<
|
|
||||||
HTMLButtonElement,
|
|
||||||
React.ComponentProps<"button"> & { asChild?: boolean }
|
|
||||||
>(({ className, asChild = false, ...props }, ref) => {
|
|
||||||
const Comp = asChild ? Slot : "button"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="group-action"
|
|
||||||
className={cn(
|
|
||||||
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
|
||||||
// Increases the hit area of the button on mobile.
|
|
||||||
"after:absolute after:-inset-2 after:md:hidden",
|
|
||||||
"group-data-[collapsible=icon]:hidden",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarGroupAction.displayName = "SidebarGroupAction"
|
|
||||||
|
|
||||||
const SidebarGroupContent = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"div">
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="group-content"
|
|
||||||
className={cn("w-full text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SidebarGroupContent.displayName = "SidebarGroupContent"
|
|
||||||
|
|
||||||
const SidebarMenu = React.forwardRef<
|
|
||||||
HTMLUListElement,
|
|
||||||
React.ComponentProps<"ul">
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ul
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="menu"
|
|
||||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SidebarMenu.displayName = "SidebarMenu"
|
|
||||||
|
|
||||||
const SidebarMenuItem = React.forwardRef<
|
|
||||||
HTMLLIElement,
|
|
||||||
React.ComponentProps<"li">
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<li
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="menu-item"
|
|
||||||
className={cn("group/menu-item relative", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SidebarMenuItem.displayName = "SidebarMenuItem"
|
|
||||||
|
|
||||||
const sidebarMenuButtonVariants = cva(
|
|
||||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
|
||||||
outline:
|
|
||||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: "h-8 text-sm",
|
|
||||||
sm: "h-7 text-xs",
|
|
||||||
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const SidebarMenuButton = React.forwardRef<
|
|
||||||
HTMLButtonElement,
|
|
||||||
React.ComponentProps<"button"> & {
|
|
||||||
asChild?: boolean
|
|
||||||
isActive?: boolean
|
|
||||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
|
||||||
} & VariantProps<typeof sidebarMenuButtonVariants>
|
|
||||||
>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
asChild = false,
|
|
||||||
isActive = false,
|
|
||||||
variant = "default",
|
|
||||||
size = "default",
|
|
||||||
tooltip,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
const Comp = asChild ? Slot : "button"
|
|
||||||
const { isMobile, state } = useSidebar()
|
|
||||||
|
|
||||||
const button = (
|
|
||||||
<Comp
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="menu-button"
|
|
||||||
data-size={size}
|
|
||||||
data-active={isActive}
|
|
||||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!tooltip) {
|
|
||||||
return button
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof tooltip === "string") {
|
|
||||||
tooltip = {
|
|
||||||
children: tooltip,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
|
||||||
<TooltipContent
|
|
||||||
side="right"
|
|
||||||
align="center"
|
|
||||||
hidden={state !== "collapsed" || isMobile}
|
|
||||||
{...tooltip}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
SidebarMenuButton.displayName = "SidebarMenuButton"
|
|
||||||
|
|
||||||
const SidebarMenuAction = React.forwardRef<
|
|
||||||
HTMLButtonElement,
|
|
||||||
React.ComponentProps<"button"> & {
|
|
||||||
asChild?: boolean
|
|
||||||
showOnHover?: boolean
|
|
||||||
}
|
|
||||||
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
|
||||||
const Comp = asChild ? Slot : "button"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="menu-action"
|
|
||||||
className={cn(
|
|
||||||
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
|
|
||||||
// Increases the hit area of the button on mobile.
|
|
||||||
"after:absolute after:-inset-2 after:md:hidden",
|
|
||||||
"peer-data-[size=sm]/menu-button:top-1",
|
|
||||||
"peer-data-[size=default]/menu-button:top-1.5",
|
|
||||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
|
||||||
"group-data-[collapsible=icon]:hidden",
|
|
||||||
showOnHover &&
|
|
||||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarMenuAction.displayName = "SidebarMenuAction"
|
|
||||||
|
|
||||||
const SidebarMenuBadge = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"div">
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="menu-badge"
|
|
||||||
className={cn(
|
|
||||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
|
|
||||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
|
||||||
"peer-data-[size=sm]/menu-button:top-1",
|
|
||||||
"peer-data-[size=default]/menu-button:top-1.5",
|
|
||||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
|
||||||
"group-data-[collapsible=icon]:hidden",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SidebarMenuBadge.displayName = "SidebarMenuBadge"
|
|
||||||
|
|
||||||
const SidebarMenuSkeleton = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"div"> & {
|
|
||||||
showIcon?: boolean
|
|
||||||
}
|
|
||||||
>(({ className, showIcon = false, ...props }, ref) => {
|
|
||||||
// Random width between 50 to 90%.
|
|
||||||
const width = React.useMemo(() => {
|
|
||||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="menu-skeleton"
|
|
||||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{showIcon && (
|
|
||||||
<Skeleton
|
|
||||||
className="size-4 rounded-md"
|
|
||||||
data-sidebar="menu-skeleton-icon"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Skeleton
|
|
||||||
className="h-4 max-w-[--skeleton-width] flex-1"
|
|
||||||
data-sidebar="menu-skeleton-text"
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--skeleton-width": width,
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
|
|
||||||
|
|
||||||
const SidebarMenuSub = React.forwardRef<
|
|
||||||
HTMLUListElement,
|
|
||||||
React.ComponentProps<"ul">
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ul
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="menu-sub"
|
|
||||||
className={cn(
|
|
||||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
|
|
||||||
"group-data-[collapsible=icon]:hidden",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SidebarMenuSub.displayName = "SidebarMenuSub"
|
|
||||||
|
|
||||||
const SidebarMenuSubItem = React.forwardRef<
|
|
||||||
HTMLLIElement,
|
|
||||||
React.ComponentProps<"li">
|
|
||||||
>(({ ...props }, ref) => <li ref={ref} {...props} />)
|
|
||||||
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
|
|
||||||
|
|
||||||
const SidebarMenuSubButton = React.forwardRef<
|
|
||||||
HTMLAnchorElement,
|
|
||||||
React.ComponentProps<"a"> & {
|
|
||||||
asChild?: boolean
|
|
||||||
size?: "sm" | "md"
|
|
||||||
isActive?: boolean
|
|
||||||
}
|
|
||||||
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
|
|
||||||
const Comp = asChild ? Slot : "a"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="menu-sub-button"
|
|
||||||
data-size={size}
|
|
||||||
data-active={isActive}
|
|
||||||
className={cn(
|
|
||||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
|
||||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
|
||||||
size === "sm" && "text-xs",
|
|
||||||
size === "md" && "text-sm",
|
|
||||||
"group-data-[collapsible=icon]:hidden",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
|
|
||||||
|
|
||||||
export {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
|
||||||
SidebarFooter,
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupAction,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarHeader,
|
|
||||||
SidebarInput,
|
|
||||||
SidebarInset,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuAction,
|
|
||||||
SidebarMenuBadge,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
SidebarMenuSkeleton,
|
|
||||||
SidebarMenuSub,
|
|
||||||
SidebarMenuSubButton,
|
|
||||||
SidebarMenuSubItem,
|
|
||||||
SidebarProvider,
|
|
||||||
SidebarRail,
|
|
||||||
SidebarSeparator,
|
|
||||||
SidebarTrigger,
|
|
||||||
useSidebar,
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Skeleton({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Skeleton }
|
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CircleCheck,
|
CircleCheckIcon,
|
||||||
Info,
|
InfoIcon,
|
||||||
LoaderCircle,
|
Loader2Icon,
|
||||||
OctagonX,
|
OctagonXIcon,
|
||||||
TriangleAlert,
|
TriangleAlertIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import { Toaster as Sonner } from "sonner"
|
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||||
|
|
||||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
const { theme = "system" } = useTheme()
|
const { theme = "system" } = useTheme()
|
||||||
@@ -20,23 +18,20 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||||||
theme={theme as ToasterProps["theme"]}
|
theme={theme as ToasterProps["theme"]}
|
||||||
className="toaster group"
|
className="toaster group"
|
||||||
icons={{
|
icons={{
|
||||||
success: <CircleCheck className="h-4 w-4" />,
|
success: <CircleCheckIcon className="size-4" />,
|
||||||
info: <Info className="h-4 w-4" />,
|
info: <InfoIcon className="size-4" />,
|
||||||
warning: <TriangleAlert className="h-4 w-4" />,
|
warning: <TriangleAlertIcon className="size-4" />,
|
||||||
error: <OctagonX className="h-4 w-4" />,
|
error: <OctagonXIcon className="size-4" />,
|
||||||
loading: <LoaderCircle className="h-4 w-4 animate-spin" />,
|
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||||
}}
|
|
||||||
toastOptions={{
|
|
||||||
classNames: {
|
|
||||||
toast:
|
|
||||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
|
||||||
description: "group-[.toast]:text-muted-foreground",
|
|
||||||
actionButton:
|
|
||||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
|
||||||
cancelButton:
|
|
||||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "var(--popover)",
|
||||||
|
"--normal-text": "var(--popover-foreground)",
|
||||||
|
"--normal-border": "var(--border)",
|
||||||
|
"--border-radius": "var(--radius)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Switch = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SwitchPrimitives.Root
|
|
||||||
className={cn(
|
|
||||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
>
|
|
||||||
<SwitchPrimitives.Thumb
|
|
||||||
className={cn(
|
|
||||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SwitchPrimitives.Root>
|
|
||||||
))
|
|
||||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
|
||||||
|
|
||||||
export { Switch }
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Table = React.forwardRef<
|
|
||||||
HTMLTableElement,
|
|
||||||
React.HTMLAttributes<HTMLTableElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div className="relative w-full overflow-auto">
|
|
||||||
<table
|
|
||||||
ref={ref}
|
|
||||||
className={cn("w-full caption-bottom text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
Table.displayName = "Table"
|
|
||||||
|
|
||||||
const TableHeader = React.forwardRef<
|
|
||||||
HTMLTableSectionElement,
|
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
|
||||||
))
|
|
||||||
TableHeader.displayName = "TableHeader"
|
|
||||||
|
|
||||||
const TableBody = React.forwardRef<
|
|
||||||
HTMLTableSectionElement,
|
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<tbody
|
|
||||||
ref={ref}
|
|
||||||
className={cn("[&_tr:last-child]:border-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TableBody.displayName = "TableBody"
|
|
||||||
|
|
||||||
const TableFooter = React.forwardRef<
|
|
||||||
HTMLTableSectionElement,
|
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<tfoot
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TableFooter.displayName = "TableFooter"
|
|
||||||
|
|
||||||
const TableRow = React.forwardRef<
|
|
||||||
HTMLTableRowElement,
|
|
||||||
React.HTMLAttributes<HTMLTableRowElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<tr
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TableRow.displayName = "TableRow"
|
|
||||||
|
|
||||||
const TableHead = React.forwardRef<
|
|
||||||
HTMLTableCellElement,
|
|
||||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<th
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TableHead.displayName = "TableHead"
|
|
||||||
|
|
||||||
const TableCell = React.forwardRef<
|
|
||||||
HTMLTableCellElement,
|
|
||||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<td
|
|
||||||
ref={ref}
|
|
||||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TableCell.displayName = "TableCell"
|
|
||||||
|
|
||||||
const TableCaption = React.forwardRef<
|
|
||||||
HTMLTableCaptionElement,
|
|
||||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<caption
|
|
||||||
ref={ref}
|
|
||||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TableCaption.displayName = "TableCaption"
|
|
||||||
|
|
||||||
export {
|
|
||||||
Table,
|
|
||||||
TableHeader,
|
|
||||||
TableBody,
|
|
||||||
TableFooter,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
TableCell,
|
|
||||||
TableCaption,
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Tabs = TabsPrimitive.Root
|
|
||||||
|
|
||||||
const TabsList = React.forwardRef<
|
|
||||||
React.ElementRef<typeof TabsPrimitive.List>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<TabsPrimitive.List
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TabsList.displayName = TabsPrimitive.List.displayName
|
|
||||||
|
|
||||||
const TabsTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<TabsPrimitive.Trigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
|
||||||
|
|
||||||
const TabsContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<TabsPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Textarea = React.forwardRef<
|
|
||||||
HTMLTextAreaElement,
|
|
||||||
React.ComponentProps<"textarea">
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
className={cn(
|
|
||||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
Textarea.displayName = "Textarea"
|
|
||||||
|
|
||||||
export { Textarea }
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
import { X } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const ToastProvider = ToastPrimitives.Provider
|
|
||||||
|
|
||||||
const ToastViewport = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ToastPrimitives.Viewport
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
|
||||||
|
|
||||||
const toastVariants = cva(
|
|
||||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "border bg-background text-foreground",
|
|
||||||
destructive:
|
|
||||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const Toast = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
|
||||||
VariantProps<typeof toastVariants>
|
|
||||||
>(({ className, variant, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<ToastPrimitives.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(toastVariants({ variant }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
Toast.displayName = ToastPrimitives.Root.displayName
|
|
||||||
|
|
||||||
const ToastAction = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ToastPrimitives.Action
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
|
||||||
|
|
||||||
const ToastClose = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ToastPrimitives.Close
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
toast-close=""
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</ToastPrimitives.Close>
|
|
||||||
))
|
|
||||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
|
||||||
|
|
||||||
const ToastTitle = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ToastPrimitives.Title
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
|
||||||
|
|
||||||
const ToastDescription = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ToastPrimitives.Description
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm opacity-90", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
|
||||||
|
|
||||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
|
||||||
|
|
||||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
|
||||||
|
|
||||||
export {
|
|
||||||
type ToastProps,
|
|
||||||
type ToastActionElement,
|
|
||||||
ToastProvider,
|
|
||||||
ToastViewport,
|
|
||||||
Toast,
|
|
||||||
ToastTitle,
|
|
||||||
ToastDescription,
|
|
||||||
ToastClose,
|
|
||||||
ToastAction,
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useToast } from "@/hooks/use-toast"
|
|
||||||
import {
|
|
||||||
Toast,
|
|
||||||
ToastClose,
|
|
||||||
ToastDescription,
|
|
||||||
ToastProvider,
|
|
||||||
ToastTitle,
|
|
||||||
ToastViewport,
|
|
||||||
} from "@/components/ui/toast"
|
|
||||||
|
|
||||||
export function Toaster() {
|
|
||||||
const { toasts } = useToast()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToastProvider>
|
|
||||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
|
||||||
return (
|
|
||||||
<Toast key={id} {...props}>
|
|
||||||
<div className="grid gap-1">
|
|
||||||
{title && <ToastTitle>{title}</ToastTitle>}
|
|
||||||
{description && (
|
|
||||||
<ToastDescription>{description}</ToastDescription>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{action}
|
|
||||||
<ToastClose />
|
|
||||||
</Toast>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<ToastViewport />
|
|
||||||
</ToastProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const TooltipProvider = TooltipPrimitive.Provider
|
|
||||||
|
|
||||||
const Tooltip = TooltipPrimitive.Root
|
|
||||||
|
|
||||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
|
||||||
|
|
||||||
const TooltipContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
|
||||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
|
||||||
<TooltipPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
|
||||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 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 origin-[--radix-tooltip-content-transform-origin]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
|
||||||
|
|
||||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
|
|
||||||
const MOBILE_BREAKPOINT = 768
|
|
||||||
|
|
||||||
export function useIsMobile() {
|
|
||||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
|
||||||
const onChange = () => {
|
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
||||||
}
|
|
||||||
mql.addEventListener("change", onChange)
|
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
||||||
return () => mql.removeEventListener("change", onChange)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return !!isMobile
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
// Inspired by react-hot-toast library
|
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ToastActionElement,
|
|
||||||
ToastProps,
|
|
||||||
} from "@/components/ui/toast"
|
|
||||||
|
|
||||||
const TOAST_LIMIT = 1
|
|
||||||
const TOAST_REMOVE_DELAY = 1000000
|
|
||||||
|
|
||||||
type ToasterToast = ToastProps & {
|
|
||||||
id: string
|
|
||||||
title?: React.ReactNode
|
|
||||||
description?: React.ReactNode
|
|
||||||
action?: ToastActionElement
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionTypes = {
|
|
||||||
ADD_TOAST: "ADD_TOAST",
|
|
||||||
UPDATE_TOAST: "UPDATE_TOAST",
|
|
||||||
DISMISS_TOAST: "DISMISS_TOAST",
|
|
||||||
REMOVE_TOAST: "REMOVE_TOAST",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
let count = 0
|
|
||||||
|
|
||||||
function genId() {
|
|
||||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
|
||||||
return count.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActionType = typeof actionTypes
|
|
||||||
|
|
||||||
type Action =
|
|
||||||
| {
|
|
||||||
type: ActionType["ADD_TOAST"]
|
|
||||||
toast: ToasterToast
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: ActionType["UPDATE_TOAST"]
|
|
||||||
toast: Partial<ToasterToast>
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: ActionType["DISMISS_TOAST"]
|
|
||||||
toastId?: ToasterToast["id"]
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: ActionType["REMOVE_TOAST"]
|
|
||||||
toastId?: ToasterToast["id"]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
toasts: ToasterToast[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
|
||||||
|
|
||||||
const addToRemoveQueue = (toastId: string) => {
|
|
||||||
if (toastTimeouts.has(toastId)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
toastTimeouts.delete(toastId)
|
|
||||||
dispatch({
|
|
||||||
type: "REMOVE_TOAST",
|
|
||||||
toastId: toastId,
|
|
||||||
})
|
|
||||||
}, TOAST_REMOVE_DELAY)
|
|
||||||
|
|
||||||
toastTimeouts.set(toastId, timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const reducer = (state: State, action: Action): State => {
|
|
||||||
switch (action.type) {
|
|
||||||
case "ADD_TOAST":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
|
||||||
}
|
|
||||||
|
|
||||||
case "UPDATE_TOAST":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: state.toasts.map((t) =>
|
|
||||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
case "DISMISS_TOAST": {
|
|
||||||
const { toastId } = action
|
|
||||||
|
|
||||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
|
||||||
// but I'll keep it here for simplicity
|
|
||||||
if (toastId) {
|
|
||||||
addToRemoveQueue(toastId)
|
|
||||||
} else {
|
|
||||||
state.toasts.forEach((toast) => {
|
|
||||||
addToRemoveQueue(toast.id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: state.toasts.map((t) =>
|
|
||||||
t.id === toastId || toastId === undefined
|
|
||||||
? {
|
|
||||||
...t,
|
|
||||||
open: false,
|
|
||||||
}
|
|
||||||
: t
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "REMOVE_TOAST":
|
|
||||||
if (action.toastId === undefined) {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const listeners: Array<(state: State) => void> = []
|
|
||||||
|
|
||||||
let memoryState: State = { toasts: [] }
|
|
||||||
|
|
||||||
function dispatch(action: Action) {
|
|
||||||
memoryState = reducer(memoryState, action)
|
|
||||||
listeners.forEach((listener) => {
|
|
||||||
listener(memoryState)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type Toast = Omit<ToasterToast, "id">
|
|
||||||
|
|
||||||
function toast({ ...props }: Toast) {
|
|
||||||
const id = genId()
|
|
||||||
|
|
||||||
const update = (props: ToasterToast) =>
|
|
||||||
dispatch({
|
|
||||||
type: "UPDATE_TOAST",
|
|
||||||
toast: { ...props, id },
|
|
||||||
})
|
|
||||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: "ADD_TOAST",
|
|
||||||
toast: {
|
|
||||||
...props,
|
|
||||||
id,
|
|
||||||
open: true,
|
|
||||||
onOpenChange: (open) => {
|
|
||||||
if (!open) dismiss()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: id,
|
|
||||||
dismiss,
|
|
||||||
update,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function useToast() {
|
|
||||||
const [state, setState] = React.useState<State>(memoryState)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
listeners.push(setState)
|
|
||||||
return () => {
|
|
||||||
const index = listeners.indexOf(setState)
|
|
||||||
if (index > -1) {
|
|
||||||
listeners.splice(index, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [state])
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toast,
|
|
||||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { useToast, toast }
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
// Supabase Client Setup
|
|
||||||
// Uncomment this file when you're ready to use Supabase
|
|
||||||
|
|
||||||
/*
|
|
||||||
import { createClient } from '@supabase/supabase-js'
|
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
|
|
||||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
|
||||||
|
|
||||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
|
|
||||||
*/
|
|
||||||
|
|
||||||
// For now, export a placeholder to avoid import errors
|
|
||||||
export const supabase = null;
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { cn } from './utils'
|
||||||
|
|
||||||
|
describe('cn', () => {
|
||||||
|
it('joins class names', () => {
|
||||||
|
expect(cn('px-2', 'py-1')).toBe('px-2 py-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops falsy values', () => {
|
||||||
|
expect(cn('px-2', false && 'hidden', undefined)).toBe('px-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets the later Tailwind class win over an earlier conflicting one', () => {
|
||||||
|
expect(cn('px-2', 'px-4')).toBe('px-4')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import type { Config } from "tailwindcss";
|
|
||||||
|
|
||||||
const config: Config = {
|
|
||||||
darkMode: ["class"],
|
|
||||||
content: [
|
|
||||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
background: 'hsl(var(--background))',
|
|
||||||
foreground: 'hsl(var(--foreground))',
|
|
||||||
card: {
|
|
||||||
DEFAULT: 'hsl(var(--card))',
|
|
||||||
foreground: 'hsl(var(--card-foreground))'
|
|
||||||
},
|
|
||||||
popover: {
|
|
||||||
DEFAULT: 'hsl(var(--popover))',
|
|
||||||
foreground: 'hsl(var(--popover-foreground))'
|
|
||||||
},
|
|
||||||
primary: {
|
|
||||||
DEFAULT: 'hsl(var(--primary))',
|
|
||||||
foreground: 'hsl(var(--primary-foreground))'
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
DEFAULT: 'hsl(var(--secondary))',
|
|
||||||
foreground: 'hsl(var(--secondary-foreground))'
|
|
||||||
},
|
|
||||||
muted: {
|
|
||||||
DEFAULT: 'hsl(var(--muted))',
|
|
||||||
foreground: 'hsl(var(--muted-foreground))'
|
|
||||||
},
|
|
||||||
accent: {
|
|
||||||
DEFAULT: 'hsl(var(--accent))',
|
|
||||||
foreground: 'hsl(var(--accent-foreground))'
|
|
||||||
},
|
|
||||||
destructive: {
|
|
||||||
DEFAULT: 'hsl(var(--destructive))',
|
|
||||||
foreground: 'hsl(var(--destructive-foreground))'
|
|
||||||
},
|
|
||||||
border: 'hsl(var(--border))',
|
|
||||||
input: 'hsl(var(--input))',
|
|
||||||
ring: 'hsl(var(--ring))',
|
|
||||||
chart: {
|
|
||||||
'1': 'hsl(var(--chart-1))',
|
|
||||||
'2': 'hsl(var(--chart-2))',
|
|
||||||
'3': 'hsl(var(--chart-3))',
|
|
||||||
'4': 'hsl(var(--chart-4))',
|
|
||||||
'5': 'hsl(var(--chart-5))'
|
|
||||||
},
|
|
||||||
sidebar: {
|
|
||||||
DEFAULT: 'hsl(var(--sidebar-background))',
|
|
||||||
foreground: 'hsl(var(--sidebar-foreground))',
|
|
||||||
primary: 'hsl(var(--sidebar-primary))',
|
|
||||||
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
|
|
||||||
accent: 'hsl(var(--sidebar-accent))',
|
|
||||||
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
|
|
||||||
border: 'hsl(var(--sidebar-border))',
|
|
||||||
ring: 'hsl(var(--sidebar-ring))'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
borderRadius: {
|
|
||||||
lg: 'var(--radius)',
|
|
||||||
md: 'calc(var(--radius) - 2px)',
|
|
||||||
sm: 'calc(var(--radius) - 4px)'
|
|
||||||
},
|
|
||||||
keyframes: {
|
|
||||||
'accordion-down': {
|
|
||||||
from: {
|
|
||||||
height: '0'
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
height: 'var(--radix-accordion-content-height)'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'accordion-up': {
|
|
||||||
from: {
|
|
||||||
height: 'var(--radix-accordion-content-height)'
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
height: '0'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
|
||||||
'accordion-up': 'accordion-up 0.2s ease-out'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
export default config;
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
|
||||||
|
// Playwright specs live here and must never be picked up by Vitest —
|
||||||
|
// vitest.config.ts scopes itself to src/ for exactly that reason.
|
||||||
|
|
||||||
|
test('start page renders', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: 'AI Coding Starter Kit' })
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('health endpoint answers', async ({ request }) => {
|
||||||
|
const response = await request.get('/api/health')
|
||||||
|
expect(response.ok()).toBe(true)
|
||||||
|
expect(await response.json()).toEqual({ status: 'ok' })
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user