Initial todo app setup
- Backend: Bun + Elysia + Drizzle ORM + PostgreSQL - Frontend: React + Vite + TailwindCSS + Zustand - Auth: better-auth with invite-only system - Features: Tasks, Projects, Sections, Labels, Comments - Hammer API: Dedicated endpoints for AI assistant integration - Unit tests: 24 passing tests - Docker: Compose file for deployment
This commit is contained in:
15
.env.example
Normal file
15
.env.example
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Database
|
||||||
|
DB_USER=todo
|
||||||
|
DB_PASSWORD=your-secure-password
|
||||||
|
DB_NAME=todo_app
|
||||||
|
|
||||||
|
# App URLs
|
||||||
|
APP_URL=https://todo.donovankelly.xyz
|
||||||
|
ALLOWED_ORIGINS=https://todo.donovankelly.xyz
|
||||||
|
|
||||||
|
# Email (Resend)
|
||||||
|
RESEND_API_KEY=re_xxxxx
|
||||||
|
FROM_EMAIL=noreply@donovankelly.xyz
|
||||||
|
|
||||||
|
# Hammer API (for AI assistant integration)
|
||||||
|
HAMMER_API_KEY=generate-a-secure-key-here
|
||||||
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.output/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Bun
|
||||||
|
bun.lockb
|
||||||
|
|
||||||
|
# Drizzle
|
||||||
|
drizzle/
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
63
README.md
Normal file
63
README.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Todo App
|
||||||
|
|
||||||
|
A Todoist-inspired task management app with API access for Hammer (AI assistant).
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
### Backend (apps/api)
|
||||||
|
- **Runtime**: Bun
|
||||||
|
- **Framework**: Elysia
|
||||||
|
- **Database**: PostgreSQL + Drizzle ORM
|
||||||
|
- **Auth**: better-auth (invite-only)
|
||||||
|
- **Jobs**: pg-boss (reminders, notifications)
|
||||||
|
- **Email**: Resend
|
||||||
|
|
||||||
|
### Frontend (apps/web)
|
||||||
|
- **Framework**: React + Vite
|
||||||
|
- **Styling**: TailwindCSS + shadcn/ui
|
||||||
|
- **Data**: TanStack Query
|
||||||
|
- **Routing**: React Router
|
||||||
|
- **State**: Zustand
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ✅ Tasks with priorities, due dates, descriptions
|
||||||
|
- ✅ Projects and sections
|
||||||
|
- ✅ Sub-tasks
|
||||||
|
- ✅ Labels (cross-project tagging)
|
||||||
|
- ✅ Recurring tasks
|
||||||
|
- ✅ Reminders
|
||||||
|
- ✅ Comments with attachments
|
||||||
|
- ✅ Custom filters
|
||||||
|
- ✅ Today / Upcoming / Board views
|
||||||
|
- ✅ Invite-only user management
|
||||||
|
- ✅ Hammer API (AI assistant integration)
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
cd apps/api && bun install
|
||||||
|
cd apps/web && bun install
|
||||||
|
|
||||||
|
# Start API
|
||||||
|
cd apps/api && bun run dev
|
||||||
|
|
||||||
|
# Start Web
|
||||||
|
cd apps/web && bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
- Domain: todo.donovankelly.xyz
|
||||||
|
- Server: Hostinger VPS
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Hammer API
|
||||||
|
```
|
||||||
|
POST /api/hammer/tasks - Create task assigned to Hammer
|
||||||
|
GET /api/hammer/tasks - Get Hammer's assigned tasks
|
||||||
|
PATCH /api/hammer/tasks/:id - Update/complete task
|
||||||
|
POST /api/hammer/webhook - Register notification webhook
|
||||||
|
```
|
||||||
17
apps/api/.env.example
Normal file
17
apps/api/.env.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Database
|
||||||
|
DATABASE_URL=postgresql://user:password@localhost:5432/todo_app
|
||||||
|
|
||||||
|
# Server
|
||||||
|
PORT=3001
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
APP_URL=http://localhost:5173
|
||||||
|
ALLOWED_ORIGINS=http://localhost:5173,https://todo.donovankelly.xyz
|
||||||
|
|
||||||
|
# Email (Resend)
|
||||||
|
RESEND_API_KEY=re_xxxxx
|
||||||
|
FROM_EMAIL=noreply@donovankelly.xyz
|
||||||
|
|
||||||
|
# Hammer API
|
||||||
|
HAMMER_API_KEY=your-secure-api-key-here
|
||||||
32
apps/api/Dockerfile
Normal file
32
apps/api/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
FROM oven/bun:1 as builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json bun.lock* ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Production image
|
||||||
|
FROM oven/bun:1-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy from builder
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/package.json ./
|
||||||
|
COPY --from=builder /app/src ./src
|
||||||
|
COPY --from=builder /app/drizzle.config.ts ./
|
||||||
|
|
||||||
|
# Set environment
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3001
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
# Run migrations and start server
|
||||||
|
CMD ["sh", "-c", "bun run db:push && bun run start"]
|
||||||
345
apps/api/bun.lock
Normal file
345
apps/api/bun.lock
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "todo-api",
|
||||||
|
"dependencies": {
|
||||||
|
"@elysiajs/bearer": "^1.4.2",
|
||||||
|
"@elysiajs/cors": "^1.4.1",
|
||||||
|
"better-auth": "^1.4.17",
|
||||||
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"elysia": "^1.4.22",
|
||||||
|
"pg-boss": "^12.7.0",
|
||||||
|
"postgres": "^3.4.8",
|
||||||
|
"resend": "^6.8.0",
|
||||||
|
"zod": "^4.3.6",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
|
"drizzle-kit": "^0.31.8",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@better-auth/core": ["@better-auth/core@1.4.17", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-WSaEQDdUO6B1CzAmissN6j0lx9fM9lcslEYzlApB5UzFaBeAOHNUONTdglSyUs6/idiZBoRvt0t/qMXCgIU8ug=="],
|
||||||
|
|
||||||
|
"@better-auth/telemetry": ["@better-auth/telemetry@1.4.17", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.17" } }, "sha512-R1BC4e/bNjQbXu7lG6ubpgmsPj7IMqky5DvMlzAtnAJWJhh99pMh/n6w5gOHa0cqDZgEAuj75IPTxv+q3YiInA=="],
|
||||||
|
|
||||||
|
"@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="],
|
||||||
|
|
||||||
|
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="],
|
||||||
|
|
||||||
|
"@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="],
|
||||||
|
|
||||||
|
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||||
|
|
||||||
|
"@elysiajs/bearer": ["@elysiajs/bearer@1.4.2", "", { "peerDependencies": { "elysia": ">= 1.4.3" } }, "sha512-MK2aCFqnFMqMNSa1e/A6+Ow5uNl5LpKd8K4lCB2LIsyDrI6juxOUHAgqq+esgdSoh3urD1UIMqFC//TsqCQViA=="],
|
||||||
|
|
||||||
|
"@elysiajs/cors": ["@elysiajs/cors@1.4.1", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
|
||||||
|
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||||
|
|
||||||
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||||
|
|
||||||
|
"@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="],
|
||||||
|
|
||||||
|
"@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
|
||||||
|
|
||||||
|
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
|
||||||
|
|
||||||
|
"@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="],
|
||||||
|
|
||||||
|
"@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
|
||||||
|
|
||||||
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
|
||||||
|
|
||||||
|
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.3.7", "", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="],
|
||||||
|
|
||||||
|
"@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="],
|
||||||
|
|
||||||
|
"@zone-eu/mailsplit": ["@zone-eu/mailsplit@5.4.8", "", { "dependencies": { "libbase64": "1.3.0", "libmime": "5.3.7", "libqp": "2.1.1" } }, "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA=="],
|
||||||
|
|
||||||
|
"better-auth": ["better-auth@1.4.17", "", { "dependencies": { "@better-auth/core": "1.4.17", "@better-auth/telemetry": "1.4.17", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-VmHGQyKsEahkEs37qguROKg/6ypYpNF13D7v/lkbO7w7Aivz0Bv2h+VyUkH4NzrGY0QBKXi1577mGhDCVwp0ew=="],
|
||||||
|
|
||||||
|
"better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="],
|
||||||
|
|
||||||
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="],
|
||||||
|
|
||||||
|
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||||
|
|
||||||
|
"cron-parser": ["cron-parser@5.5.0", "", { "dependencies": { "luxon": "^3.7.1" } }, "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww=="],
|
||||||
|
|
||||||
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||||
|
|
||||||
|
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
|
||||||
|
|
||||||
|
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||||
|
|
||||||
|
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
||||||
|
|
||||||
|
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||||
|
|
||||||
|
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||||
|
|
||||||
|
"drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="],
|
||||||
|
|
||||||
|
"drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="],
|
||||||
|
|
||||||
|
"elysia": ["elysia@1.4.22", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.6", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Q90VCb1RVFxnFaRV0FDoSylESQQLWgLHFmWciQJdX9h3b2cSasji9KWEUvaJuy/L9ciAGg4RAhUVfsXHg5K2RQ=="],
|
||||||
|
|
||||||
|
"encoding-japanese": ["encoding-japanese@2.2.0", "", {}, "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A=="],
|
||||||
|
|
||||||
|
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||||
|
|
||||||
|
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
|
||||||
|
|
||||||
|
"exact-mirror": ["exact-mirror@0.2.6", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-7s059UIx9/tnOKSySzUk5cPGkoILhTE4p6ncf6uIPaQ+9aRBQzQjc9+q85l51+oZ+P6aBxh084pD0CzBQPcFUA=="],
|
||||||
|
|
||||||
|
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
||||||
|
|
||||||
|
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
|
||||||
|
|
||||||
|
"file-type": ["file-type@21.3.0", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA=="],
|
||||||
|
|
||||||
|
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
||||||
|
|
||||||
|
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
|
||||||
|
|
||||||
|
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
|
||||||
|
|
||||||
|
"htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
|
||||||
|
|
||||||
|
"iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="],
|
||||||
|
|
||||||
|
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
|
|
||||||
|
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
|
||||||
|
|
||||||
|
"kysely": ["kysely@0.28.10", "", {}, "sha512-ksNxfzIW77OcZ+QWSAPC7yDqUSaIVwkTWnTPNiIy//vifNbwsSgQ57OkkncHxxpcBHM3LRfLAZVEh7kjq5twVA=="],
|
||||||
|
|
||||||
|
"leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
|
||||||
|
|
||||||
|
"libbase64": ["libbase64@1.3.0", "", {}, "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg=="],
|
||||||
|
|
||||||
|
"libmime": ["libmime@5.3.7", "", { "dependencies": { "encoding-japanese": "2.2.0", "iconv-lite": "0.6.3", "libbase64": "1.3.0", "libqp": "2.1.1" } }, "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw=="],
|
||||||
|
|
||||||
|
"libqp": ["libqp@2.1.1", "", {}, "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow=="],
|
||||||
|
|
||||||
|
"linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="],
|
||||||
|
|
||||||
|
"luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
|
||||||
|
|
||||||
|
"mailparser": ["mailparser@3.9.1", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "he": "1.2.0", "html-to-text": "9.0.5", "iconv-lite": "0.7.0", "libmime": "5.3.7", "linkify-it": "5.0.0", "nodemailer": "7.0.11", "punycode.js": "2.3.1", "tlds": "1.261.0" } }, "sha512-6vHZcco3fWsDMkf4Vz9iAfxvwrKNGbHx0dV1RKVphQ/zaNY34Buc7D37LSa09jeSeybWzYcTPjhiZFxzVRJedA=="],
|
||||||
|
|
||||||
|
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="],
|
||||||
|
|
||||||
|
"nodemailer": ["nodemailer@7.0.11", "", {}, "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw=="],
|
||||||
|
|
||||||
|
"non-error": ["non-error@0.1.0", "", {}, "sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ=="],
|
||||||
|
|
||||||
|
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
||||||
|
|
||||||
|
"parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="],
|
||||||
|
|
||||||
|
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
|
||||||
|
|
||||||
|
"pg": ["pg@8.17.2", "", { "dependencies": { "pg-connection-string": "^2.10.1", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw=="],
|
||||||
|
|
||||||
|
"pg-boss": ["pg-boss@12.7.1", "", { "dependencies": { "cron-parser": "^5.5.0", "pg": "^8.17.2", "serialize-error": "^13.0.1" }, "bin": { "pg-boss": "dist/cli.js" } }, "sha512-ahVROGx45B/l+f4tuCuSLzo/KgnshPpflYVBqiBI4DDmuCFCeE/WCr9BCcaXcL0EJTk1QTPFfQoR/i0P3JydOQ=="],
|
||||||
|
|
||||||
|
"pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="],
|
||||||
|
|
||||||
|
"pg-connection-string": ["pg-connection-string@2.10.1", "", {}, "sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw=="],
|
||||||
|
|
||||||
|
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
|
||||||
|
|
||||||
|
"pg-pool": ["pg-pool@3.11.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w=="],
|
||||||
|
|
||||||
|
"pg-protocol": ["pg-protocol@1.11.0", "", {}, "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g=="],
|
||||||
|
|
||||||
|
"pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
|
||||||
|
|
||||||
|
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
|
||||||
|
|
||||||
|
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
|
||||||
|
|
||||||
|
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
||||||
|
|
||||||
|
"postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
|
||||||
|
|
||||||
|
"postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
|
||||||
|
|
||||||
|
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
|
||||||
|
|
||||||
|
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
|
||||||
|
|
||||||
|
"resend": ["resend@6.9.1", "", { "dependencies": { "mailparser": "3.9.1", "svix": "1.84.1" }, "peerDependencies": { "@react-email/render": "*" }, "optionalPeers": ["@react-email/render"] }, "sha512-jFY3qPP2cith1npRXvS7PVdnhbR1CcuzHg65ty5Elv55GKiXhe+nItXuzzoOlKeYJez1iJAo2+8f6ae8sCj0iA=="],
|
||||||
|
|
||||||
|
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||||
|
|
||||||
|
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
|
||||||
|
|
||||||
|
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
|
|
||||||
|
"selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
|
||||||
|
|
||||||
|
"serialize-error": ["serialize-error@13.0.1", "", { "dependencies": { "non-error": "^0.1.0", "type-fest": "^5.4.1" } }, "sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA=="],
|
||||||
|
|
||||||
|
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||||
|
|
||||||
|
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|
||||||
|
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||||
|
|
||||||
|
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||||
|
|
||||||
|
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
|
||||||
|
|
||||||
|
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
|
||||||
|
|
||||||
|
"svix": ["svix@1.84.1", "", { "dependencies": { "standardwebhooks": "1.0.0", "uuid": "^10.0.0" } }, "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ=="],
|
||||||
|
|
||||||
|
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||||
|
|
||||||
|
"tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="],
|
||||||
|
|
||||||
|
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
|
||||||
|
|
||||||
|
"type-fest": ["type-fest@5.4.2", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-FLEenlVYf7Zcd34ISMLo3ZzRE1gRjY1nMDTp+bQRBiPsaKyIW8K3Zr99ioHDUgA9OGuGGJPyYpNcffGmBhJfGg=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
|
||||||
|
|
||||||
|
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
|
"uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
|
||||||
|
|
||||||
|
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||||
|
|
||||||
|
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||||
|
|
||||||
|
"libmime/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/drizzle.config.ts
Normal file
10
apps/api/drizzle.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: './src/db/schema.ts',
|
||||||
|
out: './drizzle',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL!,
|
||||||
|
},
|
||||||
|
});
|
||||||
37
apps/api/package.json
Normal file
37
apps/api/package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "todo-api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"module": "src/index.ts",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run --watch src/index.ts",
|
||||||
|
"start": "bun run src/index.ts",
|
||||||
|
"test": "bun test",
|
||||||
|
"test:watch": "bun test --watch",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"db:push": "drizzle-kit push",
|
||||||
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"db:seed": "bun run src/db/seed.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
|
"drizzle-kit": "^0.31.8"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@elysiajs/bearer": "^1.4.2",
|
||||||
|
"@elysiajs/cors": "^1.4.1",
|
||||||
|
"better-auth": "^1.4.17",
|
||||||
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"elysia": "^1.4.22",
|
||||||
|
"pg-boss": "^12.7.0",
|
||||||
|
"postgres": "^3.4.8",
|
||||||
|
"resend": "^6.8.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
103
apps/api/src/__tests__/auth.test.ts
Normal file
103
apps/api/src/__tests__/auth.test.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, test, expect, mock } from 'bun:test';
|
||||||
|
|
||||||
|
describe('Invite System', () => {
|
||||||
|
describe('Token Validation', () => {
|
||||||
|
test('should generate valid token format', () => {
|
||||||
|
// Tokens should be 64 hex characters (32 bytes)
|
||||||
|
const mockToken = 'a'.repeat(64);
|
||||||
|
expect(mockToken.length).toBe(64);
|
||||||
|
expect(/^[a-f0-9]+$/i.test(mockToken)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject expired invites', () => {
|
||||||
|
const invite = {
|
||||||
|
token: 'valid-token',
|
||||||
|
expiresAt: new Date(Date.now() - 1000), // 1 second ago
|
||||||
|
status: 'pending' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const isExpired = invite.expiresAt < new Date();
|
||||||
|
expect(isExpired).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accept valid invites', () => {
|
||||||
|
const invite = {
|
||||||
|
token: 'valid-token',
|
||||||
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
|
||||||
|
status: 'pending' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValid = invite.expiresAt > new Date() && invite.status === 'pending';
|
||||||
|
expect(isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject already accepted invites', () => {
|
||||||
|
const invite = {
|
||||||
|
token: 'valid-token',
|
||||||
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||||
|
status: 'accepted' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const canAccept = invite.status === 'pending';
|
||||||
|
expect(canAccept).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Password Validation', () => {
|
||||||
|
test('should reject passwords shorter than 8 characters', () => {
|
||||||
|
const shortPassword = 'abc1234';
|
||||||
|
expect(shortPassword.length).toBeLessThan(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accept passwords 8 characters or longer', () => {
|
||||||
|
const validPassword = 'securepassword123';
|
||||||
|
expect(validPassword.length).toBeGreaterThanOrEqual(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Role System', () => {
|
||||||
|
test('should identify admin users', () => {
|
||||||
|
const user = { id: '1', role: 'admin' as const };
|
||||||
|
expect(user.role).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should identify service accounts', () => {
|
||||||
|
const hammerUser = { id: '2', role: 'service' as const };
|
||||||
|
expect(hammerUser.role).toBe('service');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should default new users to user role', () => {
|
||||||
|
const defaultRole = 'user';
|
||||||
|
const newUser = { id: '3', role: defaultRole };
|
||||||
|
expect(newUser.role).toBe('user');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Admin Access', () => {
|
||||||
|
test('should allow admin to create invites', () => {
|
||||||
|
const user = { role: 'admin' as const };
|
||||||
|
const canInvite = user.role === 'admin';
|
||||||
|
expect(canInvite).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should deny regular users from creating invites', () => {
|
||||||
|
const user = { role: 'user' as const };
|
||||||
|
const canInvite = user.role === 'admin';
|
||||||
|
expect(canInvite).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow admin to delete users', () => {
|
||||||
|
const admin = { id: 'admin-1', role: 'admin' as const };
|
||||||
|
const targetUser = { id: 'user-1', role: 'user' as const };
|
||||||
|
|
||||||
|
const canDelete = admin.role === 'admin' && admin.id !== targetUser.id;
|
||||||
|
expect(canDelete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should prevent self-deletion', () => {
|
||||||
|
const admin = { id: 'admin-1', role: 'admin' as const };
|
||||||
|
const canDeleteSelf = admin.id !== admin.id;
|
||||||
|
expect(canDeleteSelf).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
161
apps/api/src/__tests__/tasks.test.ts
Normal file
161
apps/api/src/__tests__/tasks.test.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { describe, test, expect, beforeAll, afterAll, mock } from 'bun:test';
|
||||||
|
|
||||||
|
// Mock the database for unit tests
|
||||||
|
const mockDb = {
|
||||||
|
query: {
|
||||||
|
tasks: {
|
||||||
|
findMany: mock(() => Promise.resolve([])),
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
projects: {
|
||||||
|
findFirst: mock(() => Promise.resolve({ id: 'project-1', userId: 'user-1', isInbox: true })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
insert: mock(() => ({
|
||||||
|
values: mock(() => ({
|
||||||
|
returning: mock(() => Promise.resolve([{ id: 'task-1', title: 'Test Task' }])),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
update: mock(() => ({
|
||||||
|
set: mock(() => ({
|
||||||
|
where: mock(() => ({
|
||||||
|
returning: mock(() => Promise.resolve([{ id: 'task-1', title: 'Updated Task' }])),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
delete: mock(() => ({
|
||||||
|
where: mock(() => ({
|
||||||
|
returning: mock(() => Promise.resolve([{ id: 'task-1' }])),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Tasks API', () => {
|
||||||
|
describe('Task Validation', () => {
|
||||||
|
test('should reject empty task title', () => {
|
||||||
|
const title = '';
|
||||||
|
expect(title.length).toBe(0);
|
||||||
|
// In real test, would call API and expect 400
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accept valid task title', () => {
|
||||||
|
const title = 'Buy groceries';
|
||||||
|
expect(title.length).toBeGreaterThan(0);
|
||||||
|
expect(title.length).toBeLessThanOrEqual(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should validate priority values', () => {
|
||||||
|
const validPriorities = ['p1', 'p2', 'p3', 'p4'];
|
||||||
|
const invalidPriority = 'p5';
|
||||||
|
|
||||||
|
expect(validPriorities).toContain('p1');
|
||||||
|
expect(validPriorities).not.toContain(invalidPriority);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Task Filtering', () => {
|
||||||
|
test('should filter by completion status', () => {
|
||||||
|
const tasks = [
|
||||||
|
{ id: '1', title: 'Task 1', isCompleted: false },
|
||||||
|
{ id: '2', title: 'Task 2', isCompleted: true },
|
||||||
|
{ id: '3', title: 'Task 3', isCompleted: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const incomplete = tasks.filter(t => !t.isCompleted);
|
||||||
|
const completed = tasks.filter(t => t.isCompleted);
|
||||||
|
|
||||||
|
expect(incomplete.length).toBe(2);
|
||||||
|
expect(completed.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should filter by priority', () => {
|
||||||
|
const tasks = [
|
||||||
|
{ id: '1', title: 'Urgent', priority: 'p1' },
|
||||||
|
{ id: '2', title: 'Normal', priority: 'p4' },
|
||||||
|
{ id: '3', title: 'High', priority: 'p2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const highPriority = tasks.filter(t => t.priority === 'p1' || t.priority === 'p2');
|
||||||
|
expect(highPriority.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should filter tasks due today', () => {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
|
const tasks = [
|
||||||
|
{ id: '1', title: 'Today task', dueDate: new Date() },
|
||||||
|
{ id: '2', title: 'Tomorrow task', dueDate: tomorrow },
|
||||||
|
{ id: '3', title: 'No date', dueDate: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
const todayTasks = tasks.filter(t => {
|
||||||
|
if (!t.dueDate) return false;
|
||||||
|
const due = new Date(t.dueDate);
|
||||||
|
due.setHours(0, 0, 0, 0);
|
||||||
|
return due.getTime() === today.getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(todayTasks.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Subtasks', () => {
|
||||||
|
test('should identify parent tasks', () => {
|
||||||
|
const tasks = [
|
||||||
|
{ id: '1', title: 'Parent', parentId: null },
|
||||||
|
{ id: '2', title: 'Subtask 1', parentId: '1' },
|
||||||
|
{ id: '3', title: 'Subtask 2', parentId: '1' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const parentTasks = tasks.filter(t => t.parentId === null);
|
||||||
|
const subtasks = tasks.filter(t => t.parentId !== null);
|
||||||
|
|
||||||
|
expect(parentTasks.length).toBe(1);
|
||||||
|
expect(subtasks.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Recurrence Parsing', () => {
|
||||||
|
test('should parse daily recurrence', () => {
|
||||||
|
const recurrence = 'FREQ=DAILY';
|
||||||
|
expect(recurrence).toContain('DAILY');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should parse weekly recurrence with days', () => {
|
||||||
|
const recurrence = 'FREQ=WEEKLY;BYDAY=MO,WE,FR';
|
||||||
|
expect(recurrence).toContain('WEEKLY');
|
||||||
|
expect(recurrence).toContain('MO');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should parse monthly recurrence', () => {
|
||||||
|
const recurrence = 'FREQ=MONTHLY;BYMONTHDAY=15';
|
||||||
|
expect(recurrence).toContain('MONTHLY');
|
||||||
|
expect(recurrence).toContain('15');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Priority Sorting', () => {
|
||||||
|
test('should sort tasks by priority', () => {
|
||||||
|
const tasks = [
|
||||||
|
{ id: '1', title: 'Low', priority: 'p4' },
|
||||||
|
{ id: '2', title: 'Urgent', priority: 'p1' },
|
||||||
|
{ id: '3', title: 'Medium', priority: 'p3' },
|
||||||
|
{ id: '4', title: 'High', priority: 'p2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const priorityOrder = { p1: 1, p2: 2, p3: 3, p4: 4 };
|
||||||
|
const sorted = [...tasks].sort((a, b) =>
|
||||||
|
priorityOrder[a.priority as keyof typeof priorityOrder] -
|
||||||
|
priorityOrder[b.priority as keyof typeof priorityOrder]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sorted[0].priority).toBe('p1');
|
||||||
|
expect(sorted[1].priority).toBe('p2');
|
||||||
|
expect(sorted[2].priority).toBe('p3');
|
||||||
|
expect(sorted[3].priority).toBe('p4');
|
||||||
|
});
|
||||||
|
});
|
||||||
9
apps/api/src/db/index.ts
Normal file
9
apps/api/src/db/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import * as schema from './schema';
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL!;
|
||||||
|
|
||||||
|
const client = postgres(connectionString, { prepare: false });
|
||||||
|
|
||||||
|
export const db = drizzle(client, { schema });
|
||||||
361
apps/api/src/db/schema.ts
Normal file
361
apps/api/src/db/schema.ts
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
import { pgTable, text, timestamp, uuid, boolean, integer, jsonb, pgEnum } from 'drizzle-orm/pg-core';
|
||||||
|
import { relations } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// Enums
|
||||||
|
export const userRoleEnum = pgEnum('user_role', ['admin', 'user', 'service']);
|
||||||
|
export const taskPriorityEnum = pgEnum('task_priority', ['p1', 'p2', 'p3', 'p4']);
|
||||||
|
export const inviteStatusEnum = pgEnum('invite_status', ['pending', 'accepted', 'expired']);
|
||||||
|
|
||||||
|
// ============= AUTH TABLES (BetterAuth) =============
|
||||||
|
|
||||||
|
export const users = pgTable('users', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
email: text('email').notNull().unique(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
emailVerified: boolean('email_verified').default(false),
|
||||||
|
image: text('image'),
|
||||||
|
role: userRoleEnum('role').default('user').notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sessions = pgTable('sessions', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||||
|
token: text('token').notNull().unique(),
|
||||||
|
expiresAt: timestamp('expires_at').notNull(),
|
||||||
|
ipAddress: text('ip_address'),
|
||||||
|
userAgent: text('user_agent'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const accounts = pgTable('accounts', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||||
|
accountId: text('account_id').notNull(),
|
||||||
|
providerId: text('provider_id').notNull(),
|
||||||
|
accessToken: text('access_token'),
|
||||||
|
refreshToken: text('refresh_token'),
|
||||||
|
accessTokenExpiresAt: timestamp('access_token_expires_at'),
|
||||||
|
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
|
||||||
|
scope: text('scope'),
|
||||||
|
idToken: text('id_token'),
|
||||||
|
password: text('password'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const verifications = pgTable('verifications', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
identifier: text('identifier').notNull(),
|
||||||
|
value: text('value').notNull(),
|
||||||
|
expiresAt: timestamp('expires_at').notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= INVITE SYSTEM =============
|
||||||
|
|
||||||
|
export const invites = pgTable('invites', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
email: text('email').notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
token: text('token').notNull().unique(),
|
||||||
|
invitedBy: text('invited_by').references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
status: inviteStatusEnum('status').default('pending').notNull(),
|
||||||
|
expiresAt: timestamp('expires_at').notNull(),
|
||||||
|
acceptedAt: timestamp('accepted_at'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= PROJECTS =============
|
||||||
|
|
||||||
|
export const projects = pgTable('projects', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
color: text('color').default('#808080'),
|
||||||
|
icon: text('icon'),
|
||||||
|
isInbox: boolean('is_inbox').default(false), // Special inbox project
|
||||||
|
isFavorite: boolean('is_favorite').default(false),
|
||||||
|
isArchived: boolean('is_archived').default(false),
|
||||||
|
sortOrder: integer('sort_order').default(0),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= SECTIONS =============
|
||||||
|
|
||||||
|
export const sections = pgTable('sections', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }).notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
sortOrder: integer('sort_order').default(0),
|
||||||
|
isCollapsed: boolean('is_collapsed').default(false),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= LABELS =============
|
||||||
|
|
||||||
|
export const labels = pgTable('labels', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
color: text('color').default('#808080'),
|
||||||
|
isFavorite: boolean('is_favorite').default(false),
|
||||||
|
sortOrder: integer('sort_order').default(0),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= TASKS =============
|
||||||
|
|
||||||
|
export const tasks = pgTable('tasks', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||||
|
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }).notNull(),
|
||||||
|
sectionId: uuid('section_id').references(() => sections.id, { onDelete: 'set null' }),
|
||||||
|
parentId: uuid('parent_id'), // Self-reference for sub-tasks (handled in relations)
|
||||||
|
|
||||||
|
// Content
|
||||||
|
title: text('title').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
|
||||||
|
// Scheduling
|
||||||
|
dueDate: timestamp('due_date'),
|
||||||
|
dueTime: text('due_time'), // HH:MM format, null = all-day
|
||||||
|
deadline: timestamp('deadline'), // Hard deadline vs soft due date
|
||||||
|
|
||||||
|
// Recurrence (stored as RRULE-like string)
|
||||||
|
recurrence: text('recurrence'), // e.g., "FREQ=DAILY", "FREQ=WEEKLY;BYDAY=MO,WE,FR"
|
||||||
|
|
||||||
|
// Priority & Status
|
||||||
|
priority: taskPriorityEnum('priority').default('p4').notNull(),
|
||||||
|
isCompleted: boolean('is_completed').default(false),
|
||||||
|
completedAt: timestamp('completed_at'),
|
||||||
|
|
||||||
|
// Assignment (for Hammer integration)
|
||||||
|
assigneeId: text('assignee_id').references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
|
||||||
|
// Ordering
|
||||||
|
sortOrder: integer('sort_order').default(0),
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= TASK LABELS (Many-to-Many) =============
|
||||||
|
|
||||||
|
export const taskLabels = pgTable('task_labels', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
taskId: uuid('task_id').references(() => tasks.id, { onDelete: 'cascade' }).notNull(),
|
||||||
|
labelId: uuid('label_id').references(() => labels.id, { onDelete: 'cascade' }).notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= COMMENTS =============
|
||||||
|
|
||||||
|
export const comments = pgTable('comments', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
taskId: uuid('task_id').references(() => tasks.id, { onDelete: 'cascade' }).notNull(),
|
||||||
|
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||||
|
content: text('content').notNull(),
|
||||||
|
attachments: jsonb('attachments').$type<{ name: string; url: string; type: string }[]>().default([]),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= REMINDERS =============
|
||||||
|
|
||||||
|
export const reminders = pgTable('reminders', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
taskId: uuid('task_id').references(() => tasks.id, { onDelete: 'cascade' }).notNull(),
|
||||||
|
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||||
|
|
||||||
|
// When to trigger
|
||||||
|
triggerAt: timestamp('trigger_at').notNull(),
|
||||||
|
|
||||||
|
// Relative reminder (e.g., 30 minutes before due)
|
||||||
|
relativeMins: integer('relative_mins'), // If set, recalculates from due date
|
||||||
|
|
||||||
|
// Status
|
||||||
|
isSent: boolean('is_sent').default(false),
|
||||||
|
sentAt: timestamp('sent_at'),
|
||||||
|
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= FILTERS (Saved Custom Views) =============
|
||||||
|
|
||||||
|
export const filters = pgTable('filters', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
query: text('query').notNull(), // Filter query string
|
||||||
|
color: text('color').default('#808080'),
|
||||||
|
isFavorite: boolean('is_favorite').default(false),
|
||||||
|
sortOrder: integer('sort_order').default(0),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= ACTIVITY LOG =============
|
||||||
|
|
||||||
|
export const activityLog = pgTable('activity_log', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
taskId: uuid('task_id').references(() => tasks.id, { onDelete: 'cascade' }),
|
||||||
|
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }),
|
||||||
|
|
||||||
|
action: text('action').notNull(), // 'created', 'updated', 'completed', 'deleted', etc.
|
||||||
|
changes: jsonb('changes').$type<Record<string, { old: unknown; new: unknown }>>(),
|
||||||
|
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= HAMMER WEBHOOKS =============
|
||||||
|
|
||||||
|
export const hammerWebhooks = pgTable('hammer_webhooks', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
url: text('url').notNull(),
|
||||||
|
secret: text('secret').notNull(), // For signature verification
|
||||||
|
events: jsonb('events').$type<string[]>().default(['task.assigned']),
|
||||||
|
isActive: boolean('is_active').default(true),
|
||||||
|
lastTriggeredAt: timestamp('last_triggered_at'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= RELATIONS =============
|
||||||
|
|
||||||
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
|
projects: many(projects),
|
||||||
|
tasks: many(tasks),
|
||||||
|
labels: many(labels),
|
||||||
|
comments: many(comments),
|
||||||
|
filters: many(filters),
|
||||||
|
sessions: many(sessions),
|
||||||
|
accounts: many(accounts),
|
||||||
|
invitesSent: many(invites),
|
||||||
|
assignedTasks: many(tasks, { relationName: 'assignee' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const projectsRelations = relations(projects, ({ one, many }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [projects.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
sections: many(sections),
|
||||||
|
tasks: many(tasks),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const sectionsRelations = relations(sections, ({ one, many }) => ({
|
||||||
|
project: one(projects, {
|
||||||
|
fields: [sections.projectId],
|
||||||
|
references: [projects.id],
|
||||||
|
}),
|
||||||
|
tasks: many(tasks),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const labelsRelations = relations(labels, ({ one, many }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [labels.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
taskLabels: many(taskLabels),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const tasksRelations = relations(tasks, ({ one, many }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [tasks.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
project: one(projects, {
|
||||||
|
fields: [tasks.projectId],
|
||||||
|
references: [projects.id],
|
||||||
|
}),
|
||||||
|
section: one(sections, {
|
||||||
|
fields: [tasks.sectionId],
|
||||||
|
references: [sections.id],
|
||||||
|
}),
|
||||||
|
parent: one(tasks, {
|
||||||
|
fields: [tasks.parentId],
|
||||||
|
references: [tasks.id],
|
||||||
|
relationName: 'subtasks',
|
||||||
|
}),
|
||||||
|
subtasks: many(tasks, { relationName: 'subtasks' }),
|
||||||
|
assignee: one(users, {
|
||||||
|
fields: [tasks.assigneeId],
|
||||||
|
references: [users.id],
|
||||||
|
relationName: 'assignee',
|
||||||
|
}),
|
||||||
|
taskLabels: many(taskLabels),
|
||||||
|
comments: many(comments),
|
||||||
|
reminders: many(reminders),
|
||||||
|
activityLogs: many(activityLog),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const taskLabelsRelations = relations(taskLabels, ({ one }) => ({
|
||||||
|
task: one(tasks, {
|
||||||
|
fields: [taskLabels.taskId],
|
||||||
|
references: [tasks.id],
|
||||||
|
}),
|
||||||
|
label: one(labels, {
|
||||||
|
fields: [taskLabels.labelId],
|
||||||
|
references: [labels.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const commentsRelations = relations(comments, ({ one }) => ({
|
||||||
|
task: one(tasks, {
|
||||||
|
fields: [comments.taskId],
|
||||||
|
references: [tasks.id],
|
||||||
|
}),
|
||||||
|
user: one(users, {
|
||||||
|
fields: [comments.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const remindersRelations = relations(reminders, ({ one }) => ({
|
||||||
|
task: one(tasks, {
|
||||||
|
fields: [reminders.taskId],
|
||||||
|
references: [tasks.id],
|
||||||
|
}),
|
||||||
|
user: one(users, {
|
||||||
|
fields: [reminders.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const filtersRelations = relations(filters, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [filters.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const activityLogRelations = relations(activityLog, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [activityLog.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
task: one(tasks, {
|
||||||
|
fields: [activityLog.taskId],
|
||||||
|
references: [tasks.id],
|
||||||
|
}),
|
||||||
|
project: one(projects, {
|
||||||
|
fields: [activityLog.projectId],
|
||||||
|
references: [projects.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const invitesRelations = relations(invites, ({ one }) => ({
|
||||||
|
inviter: one(users, {
|
||||||
|
fields: [invites.invitedBy],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
86
apps/api/src/db/seed.ts
Normal file
86
apps/api/src/db/seed.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { db } from './index';
|
||||||
|
import { users, projects } from './schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
console.log('🌱 Seeding database...');
|
||||||
|
|
||||||
|
// Create admin user (Donovan)
|
||||||
|
const adminEmail = 'donovan@donovankelly.xyz';
|
||||||
|
|
||||||
|
let admin = await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, adminEmail),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!admin) {
|
||||||
|
console.log('Creating admin user...');
|
||||||
|
// Note: Password needs to be set via the auth system
|
||||||
|
// This just creates the user record
|
||||||
|
const [newAdmin] = await db.insert(users).values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
email: adminEmail,
|
||||||
|
name: 'Donovan Kelly',
|
||||||
|
role: 'admin',
|
||||||
|
emailVerified: true,
|
||||||
|
}).returning();
|
||||||
|
admin = newAdmin;
|
||||||
|
console.log('Admin user created:', admin.email);
|
||||||
|
|
||||||
|
// Create inbox for admin
|
||||||
|
await db.insert(projects).values({
|
||||||
|
userId: admin.id,
|
||||||
|
name: 'Inbox',
|
||||||
|
isInbox: true,
|
||||||
|
color: '#808080',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create default projects for admin
|
||||||
|
await db.insert(projects).values([
|
||||||
|
{
|
||||||
|
userId: admin.id,
|
||||||
|
name: 'Personal',
|
||||||
|
color: '#3b82f6',
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: admin.id,
|
||||||
|
name: 'Work',
|
||||||
|
color: '#22c55e',
|
||||||
|
sortOrder: 2,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
console.log('Default projects created for admin');
|
||||||
|
} else {
|
||||||
|
console.log('Admin user already exists:', admin.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Hammer service account
|
||||||
|
const hammerEmail = 'hammer@donovankelly.xyz';
|
||||||
|
|
||||||
|
let hammer = await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, hammerEmail),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hammer) {
|
||||||
|
console.log('Creating Hammer service account...');
|
||||||
|
const [newHammer] = await db.insert(users).values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
email: hammerEmail,
|
||||||
|
name: 'Hammer',
|
||||||
|
role: 'service',
|
||||||
|
emailVerified: true,
|
||||||
|
}).returning();
|
||||||
|
hammer = newHammer;
|
||||||
|
console.log('Hammer service account created:', hammer.email);
|
||||||
|
} else {
|
||||||
|
console.log('Hammer service account already exists:', hammer.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Seeding complete!');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
seed().catch((error) => {
|
||||||
|
console.error('Seeding failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
101
apps/api/src/index.ts
Normal file
101
apps/api/src/index.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { Elysia } from 'elysia';
|
||||||
|
import { cors } from '@elysiajs/cors';
|
||||||
|
import { auth } from './lib/auth';
|
||||||
|
import { authRoutes } from './routes/auth';
|
||||||
|
import { adminRoutes } from './routes/admin';
|
||||||
|
import { projectRoutes } from './routes/projects';
|
||||||
|
import { taskRoutes } from './routes/tasks';
|
||||||
|
import { labelRoutes } from './routes/labels';
|
||||||
|
import { commentRoutes } from './routes/comments';
|
||||||
|
import { hammerRoutes } from './routes/hammer';
|
||||||
|
import type { User } from './lib/auth';
|
||||||
|
|
||||||
|
const app = new Elysia()
|
||||||
|
// CORS
|
||||||
|
.use(cors({
|
||||||
|
origin: process.env.ALLOWED_ORIGINS?.split(',') || [
|
||||||
|
'http://localhost:5173',
|
||||||
|
'https://todo.donovankelly.xyz',
|
||||||
|
],
|
||||||
|
credentials: true,
|
||||||
|
exposeHeaders: ['set-auth-token'],
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
.get('/health', () => ({
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
version: '0.1.0',
|
||||||
|
}))
|
||||||
|
|
||||||
|
// BetterAuth routes (login, register, session, etc.)
|
||||||
|
.all('/api/auth/*', async ({ request }) => {
|
||||||
|
return auth.handler(request);
|
||||||
|
})
|
||||||
|
|
||||||
|
// Public auth routes (invite acceptance)
|
||||||
|
.use(authRoutes)
|
||||||
|
|
||||||
|
// Hammer API (uses separate API key auth)
|
||||||
|
.group('/api', app => app.use(hammerRoutes))
|
||||||
|
|
||||||
|
// Protected routes - require auth
|
||||||
|
.derive(async ({ request, set }): Promise<{ user: User }> => {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: request.headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
set.status = 401;
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user: session.user as User };
|
||||||
|
})
|
||||||
|
|
||||||
|
// Authenticated API routes
|
||||||
|
.group('/api', app => app
|
||||||
|
.use(adminRoutes)
|
||||||
|
.use(projectRoutes)
|
||||||
|
.use(taskRoutes)
|
||||||
|
.use(labelRoutes)
|
||||||
|
.use(commentRoutes)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
.onError(({ code, error, set, path }) => {
|
||||||
|
console.error(`[${new Date().toISOString()}] ERROR on ${path}:`, {
|
||||||
|
code,
|
||||||
|
message: error.message,
|
||||||
|
stack: process.env.NODE_ENV !== 'production' ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (code === 'VALIDATION') {
|
||||||
|
set.status = 400;
|
||||||
|
return { error: 'Validation error', details: error.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message === 'Unauthorized') {
|
||||||
|
set.status = 401;
|
||||||
|
return { error: 'Unauthorized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message === 'Admin access required') {
|
||||||
|
set.status = 403;
|
||||||
|
return { error: 'Forbidden: Admin access required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message.includes('not found')) {
|
||||||
|
set.status = 404;
|
||||||
|
return { error: error.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
set.status = 500;
|
||||||
|
return { error: 'Internal server error' };
|
||||||
|
})
|
||||||
|
|
||||||
|
.listen(process.env.PORT || 3001);
|
||||||
|
|
||||||
|
console.log(`🚀 Todo API running at ${app.server?.hostname}:${app.server?.port}`);
|
||||||
|
|
||||||
|
export type App = typeof app;
|
||||||
43
apps/api/src/lib/auth.ts
Normal file
43
apps/api/src/lib/auth.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { betterAuth } from 'better-auth';
|
||||||
|
import { bearer } from 'better-auth/plugins';
|
||||||
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||||
|
import { db } from '../db';
|
||||||
|
import * as schema from '../db/schema';
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
database: drizzleAdapter(db, {
|
||||||
|
provider: 'pg',
|
||||||
|
schema: {
|
||||||
|
user: schema.users,
|
||||||
|
session: schema.sessions,
|
||||||
|
account: schema.accounts,
|
||||||
|
verification: schema.verifications,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
plugins: [
|
||||||
|
bearer(), // Enable bearer token auth for API access
|
||||||
|
],
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
requireEmailVerification: false, // We use invite system instead
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
expiresIn: 60 * 60 * 24 * 30, // 30 days
|
||||||
|
updateAge: 60 * 60 * 24, // Update session every day
|
||||||
|
},
|
||||||
|
trustedOrigins: [
|
||||||
|
process.env.APP_URL || 'http://localhost:5173',
|
||||||
|
'https://todo.donovankelly.xyz',
|
||||||
|
],
|
||||||
|
user: {
|
||||||
|
additionalFields: {
|
||||||
|
role: {
|
||||||
|
type: 'string',
|
||||||
|
defaultValue: 'user',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Session = typeof auth.$Infer.Session;
|
||||||
|
export type User = typeof auth.$Infer.Session.user & { role: 'admin' | 'user' | 'service' };
|
||||||
104
apps/api/src/lib/email.ts
Normal file
104
apps/api/src/lib/email.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { Resend } from 'resend';
|
||||||
|
|
||||||
|
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||||
|
|
||||||
|
const FROM_EMAIL = process.env.FROM_EMAIL || 'noreply@donovankelly.xyz';
|
||||||
|
const APP_URL = process.env.APP_URL || 'https://todo.donovankelly.xyz';
|
||||||
|
|
||||||
|
export async function sendInviteEmail(params: {
|
||||||
|
to: string;
|
||||||
|
name: string;
|
||||||
|
token: string;
|
||||||
|
inviterName: string;
|
||||||
|
}) {
|
||||||
|
const { to, name, token, inviterName } = params;
|
||||||
|
const setupUrl = `${APP_URL}/setup?token=${token}`;
|
||||||
|
|
||||||
|
const { data, error } = await resend.emails.send({
|
||||||
|
from: FROM_EMAIL,
|
||||||
|
to,
|
||||||
|
subject: `${inviterName} invited you to Todo App`,
|
||||||
|
html: `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h1 style="color: #2563eb;">Welcome to Todo App!</h1>
|
||||||
|
|
||||||
|
<p>Hi ${name},</p>
|
||||||
|
|
||||||
|
<p>${inviterName} has invited you to join Todo App - a task management app to help you stay organized.</p>
|
||||||
|
|
||||||
|
<p>Click the button below to set up your password and get started:</p>
|
||||||
|
|
||||||
|
<a href="${setupUrl}" style="display: inline-block; background: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0;">
|
||||||
|
Set Up Your Account
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<p style="color: #666; font-size: 14px;">
|
||||||
|
Or copy and paste this link into your browser:<br>
|
||||||
|
<a href="${setupUrl}" style="color: #2563eb;">${setupUrl}</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="color: #666; font-size: 14px;">
|
||||||
|
This invite link expires in 7 days.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
|
||||||
|
|
||||||
|
<p style="color: #999; font-size: 12px;">
|
||||||
|
If you didn't expect this invitation, you can safely ignore this email.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to send invite email:', error);
|
||||||
|
throw new Error('Failed to send invite email');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendReminderEmail(params: {
|
||||||
|
to: string;
|
||||||
|
taskTitle: string;
|
||||||
|
dueDate?: string;
|
||||||
|
taskUrl: string;
|
||||||
|
}) {
|
||||||
|
const { to, taskTitle, dueDate, taskUrl } = params;
|
||||||
|
|
||||||
|
const { data, error } = await resend.emails.send({
|
||||||
|
from: FROM_EMAIL,
|
||||||
|
to,
|
||||||
|
subject: `Reminder: ${taskTitle}`,
|
||||||
|
html: `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #2563eb;">⏰ Task Reminder</h2>
|
||||||
|
|
||||||
|
<p><strong>${taskTitle}</strong></p>
|
||||||
|
|
||||||
|
${dueDate ? `<p>Due: ${dueDate}</p>` : ''}
|
||||||
|
|
||||||
|
<a href="${taskUrl}" style="display: inline-block; background: #2563eb; color: white; padding: 10px 20px; text-decoration: none; border-radius: 6px; margin: 10px 0;">
|
||||||
|
View Task
|
||||||
|
</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to send reminder email:', error);
|
||||||
|
throw new Error('Failed to send reminder email');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
185
apps/api/src/routes/admin.ts
Normal file
185
apps/api/src/routes/admin.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { Elysia, t } from 'elysia';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { users, invites } from '../db/schema';
|
||||||
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
import { sendInviteEmail } from '../lib/email';
|
||||||
|
import type { User } from '../lib/auth';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
export const adminRoutes = new Elysia({ prefix: '/admin' })
|
||||||
|
// Middleware: require admin role
|
||||||
|
.derive(({ user, set }) => {
|
||||||
|
if ((user as User).role !== 'admin') {
|
||||||
|
set.status = 403;
|
||||||
|
throw new Error('Admin access required');
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
})
|
||||||
|
|
||||||
|
// List all users
|
||||||
|
.get('/users', async () => {
|
||||||
|
const allUsers = await db.query.users.findMany({
|
||||||
|
orderBy: [desc(users.createdAt)],
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
role: true,
|
||||||
|
emailVerified: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return allUsers;
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get single user
|
||||||
|
.get('/users/:id', async ({ params, set }) => {
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.id, params.id),
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
role: true,
|
||||||
|
emailVerified: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update user role
|
||||||
|
.patch('/users/:id/role', async ({ params, body, set }) => {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(users)
|
||||||
|
.set({ role: body.role, updatedAt: new Date() })
|
||||||
|
.where(eq(users.id, params.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, user: updated };
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
body: t.Object({
|
||||||
|
role: t.Union([t.Literal('admin'), t.Literal('user'), t.Literal('service')]),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete user
|
||||||
|
.delete('/users/:id', async ({ params, user, set }) => {
|
||||||
|
// Prevent self-deletion
|
||||||
|
if (params.id === (user as User).id) {
|
||||||
|
set.status = 400;
|
||||||
|
throw new Error('Cannot delete your own account');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [deleted] = await db
|
||||||
|
.delete(users)
|
||||||
|
.where(eq(users.id, params.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create invite
|
||||||
|
.post('/invites', async ({ body, user }) => {
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||||
|
|
||||||
|
const [invite] = await db.insert(invites).values({
|
||||||
|
email: body.email,
|
||||||
|
name: body.name,
|
||||||
|
token,
|
||||||
|
invitedBy: (user as User).id,
|
||||||
|
expiresAt,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
// Send invite email
|
||||||
|
try {
|
||||||
|
await sendInviteEmail({
|
||||||
|
to: body.email,
|
||||||
|
name: body.name,
|
||||||
|
token,
|
||||||
|
inviterName: (user as User).name,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send invite email:', error);
|
||||||
|
// Continue anyway - admin can share the link manually
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupUrl = `${process.env.APP_URL || 'https://todo.donovankelly.xyz'}/setup?token=${token}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
invite: {
|
||||||
|
id: invite.id,
|
||||||
|
email: invite.email,
|
||||||
|
name: invite.name,
|
||||||
|
expiresAt: invite.expiresAt,
|
||||||
|
},
|
||||||
|
setupUrl, // Return URL in case email fails
|
||||||
|
};
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
email: t.String({ format: 'email' }),
|
||||||
|
name: t.String({ minLength: 1 }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// List invites
|
||||||
|
.get('/invites', async () => {
|
||||||
|
const allInvites = await db.query.invites.findMany({
|
||||||
|
orderBy: [desc(invites.createdAt)],
|
||||||
|
with: {
|
||||||
|
inviter: {
|
||||||
|
columns: { name: true, email: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return allInvites;
|
||||||
|
})
|
||||||
|
|
||||||
|
// Revoke/delete invite
|
||||||
|
.delete('/invites/:id', async ({ params, set }) => {
|
||||||
|
const [deleted] = await db
|
||||||
|
.delete(invites)
|
||||||
|
.where(eq(invites.id, params.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Invite not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
125
apps/api/src/routes/auth.ts
Normal file
125
apps/api/src/routes/auth.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { Elysia, t } from 'elysia';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { invites, users, projects } from '../db/schema';
|
||||||
|
import { eq, and, gt } from 'drizzle-orm';
|
||||||
|
import { auth } from '../lib/auth';
|
||||||
|
|
||||||
|
export const authRoutes = new Elysia({ prefix: '/auth' })
|
||||||
|
// Validate invite token (public)
|
||||||
|
.get('/invite/:token', async ({ params, set }) => {
|
||||||
|
const invite = await db.query.invites.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(invites.token, params.token),
|
||||||
|
eq(invites.status, 'pending'),
|
||||||
|
gt(invites.expiresAt, new Date())
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!invite) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Invalid or expired invite');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
email: invite.email,
|
||||||
|
name: invite.name,
|
||||||
|
};
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
token: t.String(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Accept invite and create account (public)
|
||||||
|
.post('/invite/:token/accept', async ({ params, body, set }) => {
|
||||||
|
const invite = await db.query.invites.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(invites.token, params.token),
|
||||||
|
eq(invites.status, 'pending'),
|
||||||
|
gt(invites.expiresAt, new Date())
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!invite) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Invalid or expired invite');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const existingUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, invite.email),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
set.status = 400;
|
||||||
|
throw new Error('Account already exists for this email');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create user via BetterAuth
|
||||||
|
const signUpResult = await auth.api.signUpEmail({
|
||||||
|
body: {
|
||||||
|
email: invite.email,
|
||||||
|
password: body.password,
|
||||||
|
name: invite.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!signUpResult) {
|
||||||
|
throw new Error('Failed to create account');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the created user
|
||||||
|
const newUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, invite.email),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newUser) {
|
||||||
|
// Create default inbox project
|
||||||
|
await db.insert(projects).values({
|
||||||
|
userId: newUser.id,
|
||||||
|
name: 'Inbox',
|
||||||
|
isInbox: true,
|
||||||
|
color: '#808080',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create some default projects
|
||||||
|
await db.insert(projects).values([
|
||||||
|
{
|
||||||
|
userId: newUser.id,
|
||||||
|
name: 'Personal',
|
||||||
|
color: '#3b82f6',
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: newUser.id,
|
||||||
|
name: 'Work',
|
||||||
|
color: '#22c55e',
|
||||||
|
sortOrder: 2,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark invite as accepted
|
||||||
|
await db
|
||||||
|
.update(invites)
|
||||||
|
.set({
|
||||||
|
status: 'accepted',
|
||||||
|
acceptedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(invites.id, invite.id));
|
||||||
|
|
||||||
|
return { success: true, message: 'Account created successfully' };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating account:', error);
|
||||||
|
set.status = 500;
|
||||||
|
throw new Error('Failed to create account');
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
token: t.String(),
|
||||||
|
}),
|
||||||
|
body: t.Object({
|
||||||
|
password: t.String({ minLength: 8 }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
146
apps/api/src/routes/comments.ts
Normal file
146
apps/api/src/routes/comments.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { Elysia, t } from 'elysia';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { comments, tasks } from '../db/schema';
|
||||||
|
import { eq, and, asc } from 'drizzle-orm';
|
||||||
|
import type { User } from '../lib/auth';
|
||||||
|
|
||||||
|
export const commentRoutes = new Elysia({ prefix: '/comments' })
|
||||||
|
// Get comments for a task
|
||||||
|
.get('/task/:taskId', async ({ params, user, set }) => {
|
||||||
|
// Verify task ownership
|
||||||
|
const task = await db.query.tasks.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(tasks.id, params.taskId),
|
||||||
|
eq(tasks.userId, (user as User).id)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Task not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskComments = await db.query.comments.findMany({
|
||||||
|
where: eq(comments.taskId, params.taskId),
|
||||||
|
orderBy: [asc(comments.createdAt)],
|
||||||
|
with: {
|
||||||
|
user: {
|
||||||
|
columns: { id: true, name: true, image: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return taskComments;
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
taskId: t.String(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create comment
|
||||||
|
.post('/', async ({ body, user }) => {
|
||||||
|
const userId = (user as User).id;
|
||||||
|
|
||||||
|
// Verify task ownership
|
||||||
|
const task = await db.query.tasks.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(tasks.id, body.taskId),
|
||||||
|
eq(tasks.userId, userId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
throw new Error('Task not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [comment] = await db.insert(comments).values({
|
||||||
|
taskId: body.taskId,
|
||||||
|
userId,
|
||||||
|
content: body.content,
|
||||||
|
attachments: body.attachments || [],
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
// Return with user info
|
||||||
|
const fullComment = await db.query.comments.findFirst({
|
||||||
|
where: eq(comments.id, comment.id),
|
||||||
|
with: {
|
||||||
|
user: {
|
||||||
|
columns: { id: true, name: true, image: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return fullComment;
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
taskId: t.String(),
|
||||||
|
content: t.String({ minLength: 1 }),
|
||||||
|
attachments: t.Optional(t.Array(t.Object({
|
||||||
|
name: t.String(),
|
||||||
|
url: t.String(),
|
||||||
|
type: t.String(),
|
||||||
|
}))),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update comment
|
||||||
|
.patch('/:id', async ({ params, body, user, set }) => {
|
||||||
|
const existing = await db.query.comments.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(comments.id, params.id),
|
||||||
|
eq(comments.userId, (user as User).id)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Comment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(comments)
|
||||||
|
.set({
|
||||||
|
content: body.content,
|
||||||
|
attachments: body.attachments,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(comments.id, params.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
body: t.Object({
|
||||||
|
content: t.Optional(t.String({ minLength: 1 })),
|
||||||
|
attachments: t.Optional(t.Array(t.Object({
|
||||||
|
name: t.String(),
|
||||||
|
url: t.String(),
|
||||||
|
type: t.String(),
|
||||||
|
}))),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete comment
|
||||||
|
.delete('/:id', async ({ params, user, set }) => {
|
||||||
|
const existing = await db.query.comments.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(comments.id, params.id),
|
||||||
|
eq(comments.userId, (user as User).id)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Comment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(comments).where(eq(comments.id, params.id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
416
apps/api/src/routes/hammer.ts
Normal file
416
apps/api/src/routes/hammer.ts
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
import { Elysia, t } from 'elysia';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { tasks, projects, hammerWebhooks, users, activityLog } from '../db/schema';
|
||||||
|
import { eq, and, asc, desc, sql } from 'drizzle-orm';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
// This route uses bearer token auth for Hammer (service account)
|
||||||
|
// The token is set in HAMMER_API_KEY env var
|
||||||
|
|
||||||
|
const validateHammerAuth = (authHeader: string | undefined): boolean => {
|
||||||
|
if (!authHeader) return false;
|
||||||
|
const token = authHeader.replace('Bearer ', '');
|
||||||
|
return token === process.env.HAMMER_API_KEY;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hammerRoutes = new Elysia({ prefix: '/hammer' })
|
||||||
|
// Middleware: require Hammer API key
|
||||||
|
.derive(({ request, set }) => {
|
||||||
|
const authHeader = request.headers.get('authorization');
|
||||||
|
if (!validateHammerAuth(authHeader)) {
|
||||||
|
set.status = 401;
|
||||||
|
throw new Error('Invalid API key');
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get Hammer's service user ID
|
||||||
|
.get('/me', async ({ set }) => {
|
||||||
|
const hammerUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.role, 'service'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hammerUser) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Hammer service account not found. Please create one via admin.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: hammerUser.id,
|
||||||
|
name: hammerUser.name,
|
||||||
|
email: hammerUser.email,
|
||||||
|
role: hammerUser.role,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get tasks assigned to Hammer
|
||||||
|
.get('/tasks', async ({ query, set }) => {
|
||||||
|
const hammerUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.role, 'service'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hammerUser) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Hammer service account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditions = [eq(tasks.assigneeId, hammerUser.id)];
|
||||||
|
|
||||||
|
// Filter by completion status
|
||||||
|
if (query.completed === 'true') {
|
||||||
|
conditions.push(eq(tasks.isCompleted, true));
|
||||||
|
} else if (query.completed === 'false') {
|
||||||
|
conditions.push(eq(tasks.isCompleted, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by priority
|
||||||
|
if (query.priority) {
|
||||||
|
conditions.push(eq(tasks.priority, query.priority as 'p1' | 'p2' | 'p3' | 'p4'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedTasks = await db.query.tasks.findMany({
|
||||||
|
where: and(...conditions),
|
||||||
|
orderBy: [
|
||||||
|
asc(tasks.isCompleted),
|
||||||
|
desc(sql`CASE ${tasks.priority} WHEN 'p1' THEN 1 WHEN 'p2' THEN 2 WHEN 'p3' THEN 3 ELSE 4 END`),
|
||||||
|
asc(tasks.dueDate),
|
||||||
|
],
|
||||||
|
with: {
|
||||||
|
project: {
|
||||||
|
columns: { id: true, name: true, color: true },
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
columns: { id: true, name: true, email: true },
|
||||||
|
},
|
||||||
|
taskLabels: {
|
||||||
|
with: { label: true },
|
||||||
|
},
|
||||||
|
comments: {
|
||||||
|
orderBy: [desc(sql`created_at`)],
|
||||||
|
limit: 5,
|
||||||
|
with: {
|
||||||
|
user: {
|
||||||
|
columns: { id: true, name: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return assignedTasks;
|
||||||
|
}, {
|
||||||
|
query: t.Object({
|
||||||
|
completed: t.Optional(t.String()),
|
||||||
|
priority: t.Optional(t.String()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get single task details
|
||||||
|
.get('/tasks/:id', async ({ params, set }) => {
|
||||||
|
const hammerUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.role, 'service'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hammerUser) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Hammer service account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await db.query.tasks.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(tasks.id, params.id),
|
||||||
|
eq(tasks.assigneeId, hammerUser.id)
|
||||||
|
),
|
||||||
|
with: {
|
||||||
|
project: true,
|
||||||
|
section: true,
|
||||||
|
user: {
|
||||||
|
columns: { id: true, name: true, email: true },
|
||||||
|
},
|
||||||
|
taskLabels: {
|
||||||
|
with: { label: true },
|
||||||
|
},
|
||||||
|
comments: {
|
||||||
|
orderBy: [asc(sql`created_at`)],
|
||||||
|
with: {
|
||||||
|
user: {
|
||||||
|
columns: { id: true, name: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
subtasks: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Task not found or not assigned to Hammer');
|
||||||
|
}
|
||||||
|
|
||||||
|
return task;
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create a task (Hammer creating tasks for a user)
|
||||||
|
.post('/tasks', async ({ body, set }) => {
|
||||||
|
// Get the user to create the task for
|
||||||
|
const targetUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, body.userEmail),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's inbox project
|
||||||
|
let projectId = body.projectId;
|
||||||
|
if (!projectId) {
|
||||||
|
const inbox = await db.query.projects.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(projects.userId, targetUser.id),
|
||||||
|
eq(projects.isInbox, true)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!inbox) {
|
||||||
|
set.status = 400;
|
||||||
|
throw new Error('User has no inbox project');
|
||||||
|
}
|
||||||
|
projectId = inbox.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hammerUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.role, 'service'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [task] = await db.insert(tasks).values({
|
||||||
|
userId: targetUser.id,
|
||||||
|
projectId,
|
||||||
|
title: body.title,
|
||||||
|
description: body.description,
|
||||||
|
dueDate: body.dueDate ? new Date(body.dueDate) : null,
|
||||||
|
dueTime: body.dueTime,
|
||||||
|
priority: body.priority || 'p4',
|
||||||
|
assigneeId: body.assignToHammer ? hammerUser?.id : null,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await db.insert(activityLog).values({
|
||||||
|
userId: hammerUser?.id,
|
||||||
|
taskId: task.id,
|
||||||
|
projectId,
|
||||||
|
action: 'created',
|
||||||
|
changes: { source: { old: null, new: 'hammer-api' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return task;
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
userEmail: t.String({ format: 'email' }),
|
||||||
|
title: t.String({ minLength: 1, maxLength: 500 }),
|
||||||
|
description: t.Optional(t.String()),
|
||||||
|
projectId: t.Optional(t.String()),
|
||||||
|
dueDate: t.Optional(t.String()),
|
||||||
|
dueTime: t.Optional(t.String()),
|
||||||
|
priority: t.Optional(t.Union([
|
||||||
|
t.Literal('p1'),
|
||||||
|
t.Literal('p2'),
|
||||||
|
t.Literal('p3'),
|
||||||
|
t.Literal('p4'),
|
||||||
|
])),
|
||||||
|
assignToHammer: t.Optional(t.Boolean()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update task (complete, add comment, etc.)
|
||||||
|
.patch('/tasks/:id', async ({ params, body, set }) => {
|
||||||
|
const hammerUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.role, 'service'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hammerUser) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Hammer service account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.query.tasks.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(tasks.id, params.id),
|
||||||
|
eq(tasks.assigneeId, hammerUser.id)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Task not found or not assigned to Hammer');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
||||||
|
|
||||||
|
if (body.isCompleted !== undefined) {
|
||||||
|
updateData.isCompleted = body.isCompleted;
|
||||||
|
updateData.completedAt = body.isCompleted ? new Date() : null;
|
||||||
|
}
|
||||||
|
if (body.description !== undefined) {
|
||||||
|
updateData.description = body.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(tasks)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(tasks.id, params.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
const action = body.isCompleted === true ? 'completed' : 'updated';
|
||||||
|
await db.insert(activityLog).values({
|
||||||
|
userId: hammerUser.id,
|
||||||
|
taskId: params.id,
|
||||||
|
projectId: existing.projectId,
|
||||||
|
action,
|
||||||
|
changes: { source: { old: null, new: 'hammer-api' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
body: t.Object({
|
||||||
|
isCompleted: t.Optional(t.Boolean()),
|
||||||
|
description: t.Optional(t.String()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add comment to task
|
||||||
|
.post('/tasks/:id/comments', async ({ params, body, set }) => {
|
||||||
|
const hammerUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.role, 'service'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hammerUser) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Hammer service account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await db.query.tasks.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(tasks.id, params.id),
|
||||||
|
eq(tasks.assigneeId, hammerUser.id)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Task not found or not assigned to Hammer');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { comments } = await import('../db/schema');
|
||||||
|
const [comment] = await db.insert(comments).values({
|
||||||
|
taskId: params.id,
|
||||||
|
userId: hammerUser.id,
|
||||||
|
content: body.content,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
return comment;
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
body: t.Object({
|
||||||
|
content: t.String({ minLength: 1 }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============= WEBHOOKS =============
|
||||||
|
|
||||||
|
// Register webhook
|
||||||
|
.post('/webhooks', async ({ body }) => {
|
||||||
|
const secret = crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
const [webhook] = await db.insert(hammerWebhooks).values({
|
||||||
|
url: body.url,
|
||||||
|
secret,
|
||||||
|
events: body.events || ['task.assigned'],
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: webhook.id,
|
||||||
|
url: webhook.url,
|
||||||
|
secret, // Only returned once at creation
|
||||||
|
events: webhook.events,
|
||||||
|
};
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
url: t.String(),
|
||||||
|
events: t.Optional(t.Array(t.String())),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// List webhooks
|
||||||
|
.get('/webhooks', async () => {
|
||||||
|
const webhooks = await db.query.hammerWebhooks.findMany({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
url: true,
|
||||||
|
events: true,
|
||||||
|
isActive: true,
|
||||||
|
lastTriggeredAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return webhooks;
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update webhook
|
||||||
|
.patch('/webhooks/:id', async ({ params, body, set }) => {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(hammerWebhooks)
|
||||||
|
.set({
|
||||||
|
url: body.url,
|
||||||
|
events: body.events,
|
||||||
|
isActive: body.isActive,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(hammerWebhooks.id, params.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Webhook not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
body: t.Object({
|
||||||
|
url: t.Optional(t.String()),
|
||||||
|
events: t.Optional(t.Array(t.String())),
|
||||||
|
isActive: t.Optional(t.Boolean()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete webhook
|
||||||
|
.delete('/webhooks/:id', async ({ params, set }) => {
|
||||||
|
const [deleted] = await db
|
||||||
|
.delete(hammerWebhooks)
|
||||||
|
.where(eq(hammerWebhooks.id, params.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Webhook not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
127
apps/api/src/routes/labels.ts
Normal file
127
apps/api/src/routes/labels.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { Elysia, t } from 'elysia';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { labels, taskLabels } from '../db/schema';
|
||||||
|
import { eq, and, asc, sql } from 'drizzle-orm';
|
||||||
|
import type { User } from '../lib/auth';
|
||||||
|
|
||||||
|
export const labelRoutes = new Elysia({ prefix: '/labels' })
|
||||||
|
// List all labels for user
|
||||||
|
.get('/', async ({ user }) => {
|
||||||
|
const userLabels = await db.query.labels.findMany({
|
||||||
|
where: eq(labels.userId, (user as User).id),
|
||||||
|
orderBy: [asc(labels.sortOrder), asc(labels.name)],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get task counts for each label
|
||||||
|
const labelsWithCounts = await Promise.all(
|
||||||
|
userLabels.map(async (label) => {
|
||||||
|
const taskCount = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(taskLabels)
|
||||||
|
.where(eq(taskLabels.labelId, label.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...label,
|
||||||
|
taskCount: Number(taskCount[0]?.count || 0),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return labelsWithCounts;
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get single label with tasks
|
||||||
|
.get('/:id', async ({ params, user, set }) => {
|
||||||
|
const label = await db.query.labels.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(labels.id, params.id),
|
||||||
|
eq(labels.userId, (user as User).id)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!label) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Label not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return label;
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create label
|
||||||
|
.post('/', async ({ body, user }) => {
|
||||||
|
const [label] = await db.insert(labels).values({
|
||||||
|
userId: (user as User).id,
|
||||||
|
name: body.name,
|
||||||
|
color: body.color,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
return label;
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
name: t.String({ minLength: 1, maxLength: 50 }),
|
||||||
|
color: t.Optional(t.String()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update label
|
||||||
|
.patch('/:id', async ({ params, body, user, set }) => {
|
||||||
|
const existing = await db.query.labels.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(labels.id, params.id),
|
||||||
|
eq(labels.userId, (user as User).id)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Label not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(labels)
|
||||||
|
.set({
|
||||||
|
...body,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(labels.id, params.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
body: t.Object({
|
||||||
|
name: t.Optional(t.String({ minLength: 1, maxLength: 50 })),
|
||||||
|
color: t.Optional(t.String()),
|
||||||
|
isFavorite: t.Optional(t.Boolean()),
|
||||||
|
sortOrder: t.Optional(t.Number()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete label
|
||||||
|
.delete('/:id', async ({ params, user, set }) => {
|
||||||
|
const existing = await db.query.labels.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(labels.id, params.id),
|
||||||
|
eq(labels.userId, (user as User).id)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Label not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(labels).where(eq(labels.id, params.id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
257
apps/api/src/routes/projects.ts
Normal file
257
apps/api/src/routes/projects.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { Elysia, t } from 'elysia';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { projects, sections } from '../db/schema';
|
||||||
|
import { eq, and, asc, desc } from 'drizzle-orm';
|
||||||
|
import type { User } from '../lib/auth';
|
||||||
|
|
||||||
|
export const projectRoutes = new Elysia({ prefix: '/projects' })
|
||||||
|
// List all projects for user
|
||||||
|
.get('/', async ({ user }) => {
|
||||||
|
const userProjects = await db.query.projects.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(projects.userId, (user as User).id),
|
||||||
|
eq(projects.isArchived, false)
|
||||||
|
),
|
||||||
|
orderBy: [desc(projects.isInbox), asc(projects.sortOrder), asc(projects.createdAt)],
|
||||||
|
with: {
|
||||||
|
sections: {
|
||||||
|
orderBy: [asc(sections.sortOrder)],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return userProjects;
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get single project with sections and task counts
|
||||||
|
.get('/:id', async ({ params, user, set }) => {
|
||||||
|
const project = await db.query.projects.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(projects.id, params.id),
|
||||||
|
eq(projects.userId, (user as User).id)
|
||||||
|
),
|
||||||
|
with: {
|
||||||
|
sections: {
|
||||||
|
orderBy: [asc(sections.sortOrder)],
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
where: eq(projects.isArchived, false),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Project not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return project;
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create project
|
||||||
|
.post('/', async ({ body, user }) => {
|
||||||
|
const [project] = await db.insert(projects).values({
|
||||||
|
userId: (user as User).id,
|
||||||
|
name: body.name,
|
||||||
|
color: body.color,
|
||||||
|
icon: body.icon,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
return project;
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
name: t.String({ minLength: 1, maxLength: 100 }),
|
||||||
|
color: t.Optional(t.String()),
|
||||||
|
icon: t.Optional(t.String()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update project
|
||||||
|
.patch('/:id', async ({ params, body, user, set }) => {
|
||||||
|
const existing = await db.query.projects.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(projects.id, params.id),
|
||||||
|
eq(projects.userId, (user as User).id)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Project not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent modifying inbox
|
||||||
|
if (existing.isInbox && (body.name || body.isArchived)) {
|
||||||
|
set.status = 400;
|
||||||
|
throw new Error('Cannot modify inbox project name or archive status');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(projects)
|
||||||
|
.set({
|
||||||
|
...body,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(projects.id, params.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
body: t.Object({
|
||||||
|
name: t.Optional(t.String({ minLength: 1, maxLength: 100 })),
|
||||||
|
color: t.Optional(t.String()),
|
||||||
|
icon: t.Optional(t.String()),
|
||||||
|
isFavorite: t.Optional(t.Boolean()),
|
||||||
|
isArchived: t.Optional(t.Boolean()),
|
||||||
|
sortOrder: t.Optional(t.Number()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete project
|
||||||
|
.delete('/:id', async ({ params, user, set }) => {
|
||||||
|
const existing = await db.query.projects.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(projects.id, params.id),
|
||||||
|
eq(projects.userId, (user as User).id)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Project not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.isInbox) {
|
||||||
|
set.status = 400;
|
||||||
|
throw new Error('Cannot delete inbox project');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(projects).where(eq(projects.id, params.id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============= SECTIONS =============
|
||||||
|
|
||||||
|
// Create section in project
|
||||||
|
.post('/:id/sections', async ({ params, body, user, set }) => {
|
||||||
|
// Verify project ownership
|
||||||
|
const project = await db.query.projects.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(projects.id, params.id),
|
||||||
|
eq(projects.userId, (user as User).id)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Project not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [section] = await db.insert(sections).values({
|
||||||
|
projectId: params.id,
|
||||||
|
name: body.name,
|
||||||
|
sortOrder: body.sortOrder,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
return section;
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
body: t.Object({
|
||||||
|
name: t.String({ minLength: 1, maxLength: 100 }),
|
||||||
|
sortOrder: t.Optional(t.Number()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update section
|
||||||
|
.patch('/:projectId/sections/:sectionId', async ({ params, body, user, set }) => {
|
||||||
|
// Verify project ownership
|
||||||
|
const project = await db.query.projects.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(projects.id, params.projectId),
|
||||||
|
eq(projects.userId, (user as User).id)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Project not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(sections)
|
||||||
|
.set({
|
||||||
|
...body,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(
|
||||||
|
eq(sections.id, params.sectionId),
|
||||||
|
eq(sections.projectId, params.projectId)
|
||||||
|
))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Section not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
projectId: t.String(),
|
||||||
|
sectionId: t.String(),
|
||||||
|
}),
|
||||||
|
body: t.Object({
|
||||||
|
name: t.Optional(t.String({ minLength: 1, maxLength: 100 })),
|
||||||
|
sortOrder: t.Optional(t.Number()),
|
||||||
|
isCollapsed: t.Optional(t.Boolean()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete section
|
||||||
|
.delete('/:projectId/sections/:sectionId', async ({ params, user, set }) => {
|
||||||
|
// Verify project ownership
|
||||||
|
const project = await db.query.projects.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(projects.id, params.projectId),
|
||||||
|
eq(projects.userId, (user as User).id)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Project not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [deleted] = await db
|
||||||
|
.delete(sections)
|
||||||
|
.where(and(
|
||||||
|
eq(sections.id, params.sectionId),
|
||||||
|
eq(sections.projectId, params.projectId)
|
||||||
|
))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Section not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
projectId: t.String(),
|
||||||
|
sectionId: t.String(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
490
apps/api/src/routes/tasks.ts
Normal file
490
apps/api/src/routes/tasks.ts
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
import { Elysia, t } from 'elysia';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { tasks, taskLabels, projects, activityLog, hammerWebhooks } from '../db/schema';
|
||||||
|
import { eq, and, or, asc, desc, isNull, gte, lte, sql } from 'drizzle-orm';
|
||||||
|
import type { User } from '../lib/auth';
|
||||||
|
|
||||||
|
// Helper to trigger Hammer webhooks
|
||||||
|
async function triggerHammerWebhooks(event: string, payload: Record<string, unknown>) {
|
||||||
|
const webhooks = await db.query.hammerWebhooks.findMany({
|
||||||
|
where: eq(hammerWebhooks.isActive, true),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const webhook of webhooks) {
|
||||||
|
if (webhook.events?.includes(event) || webhook.events?.includes('*')) {
|
||||||
|
try {
|
||||||
|
await fetch(webhook.url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Webhook-Secret': webhook.secret,
|
||||||
|
'X-Event-Type': event,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(hammerWebhooks)
|
||||||
|
.set({ lastTriggeredAt: new Date() })
|
||||||
|
.where(eq(hammerWebhooks.id, webhook.id));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to trigger webhook ${webhook.id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to log activity
|
||||||
|
async function logActivity(params: {
|
||||||
|
userId: string;
|
||||||
|
taskId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
action: string;
|
||||||
|
changes?: Record<string, { old: unknown; new: unknown }>;
|
||||||
|
}) {
|
||||||
|
await db.insert(activityLog).values(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const taskRoutes = new Elysia({ prefix: '/tasks' })
|
||||||
|
// List tasks with filters
|
||||||
|
.get('/', async ({ user, query }) => {
|
||||||
|
const userId = (user as User).id;
|
||||||
|
const conditions = [eq(tasks.userId, userId)];
|
||||||
|
|
||||||
|
// Filter by project
|
||||||
|
if (query.projectId) {
|
||||||
|
conditions.push(eq(tasks.projectId, query.projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by section
|
||||||
|
if (query.sectionId) {
|
||||||
|
conditions.push(eq(tasks.sectionId, query.sectionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by completion status
|
||||||
|
if (query.completed !== undefined) {
|
||||||
|
conditions.push(eq(tasks.isCompleted, query.completed === 'true'));
|
||||||
|
} else {
|
||||||
|
// Default: show incomplete tasks
|
||||||
|
conditions.push(eq(tasks.isCompleted, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by priority
|
||||||
|
if (query.priority) {
|
||||||
|
conditions.push(eq(tasks.priority, query.priority as 'p1' | 'p2' | 'p3' | 'p4'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter: today's tasks
|
||||||
|
if (query.today === 'true') {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
conditions.push(gte(tasks.dueDate, today));
|
||||||
|
conditions.push(lte(tasks.dueDate, tomorrow));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter: upcoming (next 7 days)
|
||||||
|
if (query.upcoming === 'true') {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const nextWeek = new Date(today);
|
||||||
|
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||||
|
conditions.push(gte(tasks.dueDate, today));
|
||||||
|
conditions.push(lte(tasks.dueDate, nextWeek));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter: overdue
|
||||||
|
if (query.overdue === 'true') {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
conditions.push(lte(tasks.dueDate, today));
|
||||||
|
conditions.push(eq(tasks.isCompleted, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by label
|
||||||
|
if (query.labelId) {
|
||||||
|
const tasksWithLabel = await db.query.taskLabels.findMany({
|
||||||
|
where: eq(taskLabels.labelId, query.labelId),
|
||||||
|
columns: { taskId: true },
|
||||||
|
});
|
||||||
|
const taskIds = tasksWithLabel.map(tl => tl.taskId);
|
||||||
|
if (taskIds.length > 0) {
|
||||||
|
conditions.push(sql`${tasks.id} IN (${sql.join(taskIds.map(id => sql`${id}`), sql`, `)})`);
|
||||||
|
} else {
|
||||||
|
return []; // No tasks with this label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only get parent tasks (not subtasks) by default
|
||||||
|
if (query.includeSubtasks !== 'true') {
|
||||||
|
conditions.push(isNull(tasks.parentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const userTasks = await db.query.tasks.findMany({
|
||||||
|
where: and(...conditions),
|
||||||
|
orderBy: [
|
||||||
|
asc(tasks.isCompleted),
|
||||||
|
desc(sql`CASE ${tasks.priority} WHEN 'p1' THEN 1 WHEN 'p2' THEN 2 WHEN 'p3' THEN 3 ELSE 4 END`),
|
||||||
|
asc(tasks.dueDate),
|
||||||
|
asc(tasks.sortOrder),
|
||||||
|
],
|
||||||
|
with: {
|
||||||
|
project: {
|
||||||
|
columns: { id: true, name: true, color: true },
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
columns: { id: true, name: true },
|
||||||
|
},
|
||||||
|
taskLabels: {
|
||||||
|
with: {
|
||||||
|
label: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
subtasks: {
|
||||||
|
where: eq(tasks.isCompleted, false),
|
||||||
|
columns: { id: true, title: true, isCompleted: true },
|
||||||
|
},
|
||||||
|
assignee: {
|
||||||
|
columns: { id: true, name: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return userTasks;
|
||||||
|
}, {
|
||||||
|
query: t.Object({
|
||||||
|
projectId: t.Optional(t.String()),
|
||||||
|
sectionId: t.Optional(t.String()),
|
||||||
|
completed: t.Optional(t.String()),
|
||||||
|
priority: t.Optional(t.String()),
|
||||||
|
today: t.Optional(t.String()),
|
||||||
|
upcoming: t.Optional(t.String()),
|
||||||
|
overdue: t.Optional(t.String()),
|
||||||
|
labelId: t.Optional(t.String()),
|
||||||
|
includeSubtasks: t.Optional(t.String()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get single task with full details
|
||||||
|
.get('/:id', async ({ params, user, set }) => {
|
||||||
|
const task = await db.query.tasks.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(tasks.id, params.id),
|
||||||
|
eq(tasks.userId, (user as User).id)
|
||||||
|
),
|
||||||
|
with: {
|
||||||
|
project: true,
|
||||||
|
section: true,
|
||||||
|
parent: {
|
||||||
|
columns: { id: true, title: true },
|
||||||
|
},
|
||||||
|
subtasks: {
|
||||||
|
orderBy: [asc(tasks.sortOrder)],
|
||||||
|
},
|
||||||
|
taskLabels: {
|
||||||
|
with: { label: true },
|
||||||
|
},
|
||||||
|
comments: {
|
||||||
|
orderBy: [asc(sql`created_at`)],
|
||||||
|
with: {
|
||||||
|
user: {
|
||||||
|
columns: { id: true, name: true, image: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reminders: {
|
||||||
|
orderBy: [asc(sql`trigger_at`)],
|
||||||
|
},
|
||||||
|
assignee: {
|
||||||
|
columns: { id: true, name: true, email: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Task not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return task;
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create task
|
||||||
|
.post('/', async ({ body, user }) => {
|
||||||
|
const userId = (user as User).id;
|
||||||
|
|
||||||
|
// Get inbox project if no projectId provided
|
||||||
|
let projectId = body.projectId;
|
||||||
|
if (!projectId) {
|
||||||
|
const inbox = await db.query.projects.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(projects.userId, userId),
|
||||||
|
eq(projects.isInbox, true)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (inbox) {
|
||||||
|
projectId = inbox.id;
|
||||||
|
} else {
|
||||||
|
// Create inbox if it doesn't exist
|
||||||
|
const [newInbox] = await db.insert(projects).values({
|
||||||
|
userId,
|
||||||
|
name: 'Inbox',
|
||||||
|
isInbox: true,
|
||||||
|
color: '#808080',
|
||||||
|
}).returning();
|
||||||
|
projectId = newInbox.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [task] = await db.insert(tasks).values({
|
||||||
|
userId,
|
||||||
|
projectId,
|
||||||
|
sectionId: body.sectionId,
|
||||||
|
parentId: body.parentId,
|
||||||
|
title: body.title,
|
||||||
|
description: body.description,
|
||||||
|
dueDate: body.dueDate ? new Date(body.dueDate) : null,
|
||||||
|
dueTime: body.dueTime,
|
||||||
|
deadline: body.deadline ? new Date(body.deadline) : null,
|
||||||
|
recurrence: body.recurrence,
|
||||||
|
priority: body.priority || 'p4',
|
||||||
|
assigneeId: body.assigneeId,
|
||||||
|
sortOrder: body.sortOrder,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
// Add labels if provided
|
||||||
|
if (body.labelIds && body.labelIds.length > 0) {
|
||||||
|
await db.insert(taskLabels).values(
|
||||||
|
body.labelIds.map(labelId => ({
|
||||||
|
taskId: task.id,
|
||||||
|
labelId,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await logActivity({
|
||||||
|
userId,
|
||||||
|
taskId: task.id,
|
||||||
|
projectId,
|
||||||
|
action: 'created',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger webhook if assigned to Hammer
|
||||||
|
if (body.assigneeId) {
|
||||||
|
const assignee = await db.query.users.findFirst({
|
||||||
|
where: eq(sql`id`, body.assigneeId),
|
||||||
|
});
|
||||||
|
if (assignee?.role === 'service') {
|
||||||
|
await triggerHammerWebhooks('task.assigned', {
|
||||||
|
task,
|
||||||
|
assignedBy: { id: userId, name: (user as User).name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return task with relations
|
||||||
|
const fullTask = await db.query.tasks.findFirst({
|
||||||
|
where: eq(tasks.id, task.id),
|
||||||
|
with: {
|
||||||
|
project: { columns: { id: true, name: true, color: true } },
|
||||||
|
taskLabels: { with: { label: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return fullTask;
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
title: t.String({ minLength: 1, maxLength: 500 }),
|
||||||
|
description: t.Optional(t.String()),
|
||||||
|
projectId: t.Optional(t.String()),
|
||||||
|
sectionId: t.Optional(t.String()),
|
||||||
|
parentId: t.Optional(t.String()),
|
||||||
|
dueDate: t.Optional(t.String()),
|
||||||
|
dueTime: t.Optional(t.String()),
|
||||||
|
deadline: t.Optional(t.String()),
|
||||||
|
recurrence: t.Optional(t.String()),
|
||||||
|
priority: t.Optional(t.Union([
|
||||||
|
t.Literal('p1'),
|
||||||
|
t.Literal('p2'),
|
||||||
|
t.Literal('p3'),
|
||||||
|
t.Literal('p4'),
|
||||||
|
])),
|
||||||
|
assigneeId: t.Optional(t.String()),
|
||||||
|
labelIds: t.Optional(t.Array(t.String())),
|
||||||
|
sortOrder: t.Optional(t.Number()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update task
|
||||||
|
.patch('/:id', async ({ params, body, user, set }) => {
|
||||||
|
const userId = (user as User).id;
|
||||||
|
|
||||||
|
const existing = await db.query.tasks.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(tasks.id, params.id),
|
||||||
|
eq(tasks.userId, userId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Task not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track changes for activity log
|
||||||
|
const changes: Record<string, { old: unknown; new: unknown }> = {};
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
||||||
|
|
||||||
|
if (body.title !== undefined && body.title !== existing.title) {
|
||||||
|
changes.title = { old: existing.title, new: body.title };
|
||||||
|
updateData.title = body.title;
|
||||||
|
}
|
||||||
|
if (body.description !== undefined) {
|
||||||
|
updateData.description = body.description;
|
||||||
|
}
|
||||||
|
if (body.projectId !== undefined) {
|
||||||
|
changes.projectId = { old: existing.projectId, new: body.projectId };
|
||||||
|
updateData.projectId = body.projectId;
|
||||||
|
}
|
||||||
|
if (body.sectionId !== undefined) {
|
||||||
|
updateData.sectionId = body.sectionId || null;
|
||||||
|
}
|
||||||
|
if (body.dueDate !== undefined) {
|
||||||
|
changes.dueDate = { old: existing.dueDate, new: body.dueDate };
|
||||||
|
updateData.dueDate = body.dueDate ? new Date(body.dueDate) : null;
|
||||||
|
}
|
||||||
|
if (body.dueTime !== undefined) {
|
||||||
|
updateData.dueTime = body.dueTime || null;
|
||||||
|
}
|
||||||
|
if (body.deadline !== undefined) {
|
||||||
|
updateData.deadline = body.deadline ? new Date(body.deadline) : null;
|
||||||
|
}
|
||||||
|
if (body.recurrence !== undefined) {
|
||||||
|
updateData.recurrence = body.recurrence || null;
|
||||||
|
}
|
||||||
|
if (body.priority !== undefined) {
|
||||||
|
changes.priority = { old: existing.priority, new: body.priority };
|
||||||
|
updateData.priority = body.priority;
|
||||||
|
}
|
||||||
|
if (body.isCompleted !== undefined) {
|
||||||
|
changes.isCompleted = { old: existing.isCompleted, new: body.isCompleted };
|
||||||
|
updateData.isCompleted = body.isCompleted;
|
||||||
|
updateData.completedAt = body.isCompleted ? new Date() : null;
|
||||||
|
}
|
||||||
|
if (body.assigneeId !== undefined) {
|
||||||
|
updateData.assigneeId = body.assigneeId || null;
|
||||||
|
}
|
||||||
|
if (body.sortOrder !== undefined) {
|
||||||
|
updateData.sortOrder = body.sortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(tasks)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(tasks.id, params.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Update labels if provided
|
||||||
|
if (body.labelIds !== undefined) {
|
||||||
|
// Remove existing labels
|
||||||
|
await db.delete(taskLabels).where(eq(taskLabels.taskId, params.id));
|
||||||
|
|
||||||
|
// Add new labels
|
||||||
|
if (body.labelIds.length > 0) {
|
||||||
|
await db.insert(taskLabels).values(
|
||||||
|
body.labelIds.map(labelId => ({
|
||||||
|
taskId: params.id,
|
||||||
|
labelId,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
const action = body.isCompleted === true ? 'completed' :
|
||||||
|
body.isCompleted === false ? 'reopened' : 'updated';
|
||||||
|
await logActivity({
|
||||||
|
userId,
|
||||||
|
taskId: params.id,
|
||||||
|
projectId: updated.projectId,
|
||||||
|
action,
|
||||||
|
changes: Object.keys(changes).length > 0 ? changes : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger webhook if newly assigned to service user
|
||||||
|
if (body.assigneeId && body.assigneeId !== existing.assigneeId) {
|
||||||
|
const assignee = await db.query.users.findFirst({
|
||||||
|
where: eq(sql`id`, body.assigneeId),
|
||||||
|
});
|
||||||
|
if (assignee?.role === 'service') {
|
||||||
|
await triggerHammerWebhooks('task.assigned', {
|
||||||
|
task: updated,
|
||||||
|
assignedBy: { id: userId, name: (user as User).name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
body: t.Object({
|
||||||
|
title: t.Optional(t.String({ minLength: 1, maxLength: 500 })),
|
||||||
|
description: t.Optional(t.String()),
|
||||||
|
projectId: t.Optional(t.String()),
|
||||||
|
sectionId: t.Optional(t.String()),
|
||||||
|
dueDate: t.Optional(t.String()),
|
||||||
|
dueTime: t.Optional(t.String()),
|
||||||
|
deadline: t.Optional(t.String()),
|
||||||
|
recurrence: t.Optional(t.String()),
|
||||||
|
priority: t.Optional(t.Union([
|
||||||
|
t.Literal('p1'),
|
||||||
|
t.Literal('p2'),
|
||||||
|
t.Literal('p3'),
|
||||||
|
t.Literal('p4'),
|
||||||
|
])),
|
||||||
|
isCompleted: t.Optional(t.Boolean()),
|
||||||
|
assigneeId: t.Optional(t.String()),
|
||||||
|
labelIds: t.Optional(t.Array(t.String())),
|
||||||
|
sortOrder: t.Optional(t.Number()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete task
|
||||||
|
.delete('/:id', async ({ params, user, set }) => {
|
||||||
|
const existing = await db.query.tasks.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(tasks.id, params.id),
|
||||||
|
eq(tasks.userId, (user as User).id)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
set.status = 404;
|
||||||
|
throw new Error('Task not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(tasks).where(eq(tasks.id, params.id));
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await logActivity({
|
||||||
|
userId: (user as User).id,
|
||||||
|
projectId: existing.projectId,
|
||||||
|
action: 'deleted',
|
||||||
|
changes: { title: { old: existing.title, new: null } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
22
apps/api/tsconfig.json
Normal file
22
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/web/.gitignore
vendored
Normal file
24
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
28
apps/web/Dockerfile
Normal file
28
apps/web/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
FROM oven/bun:1 as builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json bun.lock* ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
# Production image with nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy built files
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy nginx config
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
73
apps/web/README.md
Normal file
73
apps/web/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
589
apps/web/bun.lock
Normal file
589
apps/web/bun.lock
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "web",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.90.20",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"zustand": "^5.0.10",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.4",
|
||||||
|
"vite": "^7.2.4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="],
|
||||||
|
|
||||||
|
"@babel/compat-data": ["@babel/compat-data@7.28.6", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="],
|
||||||
|
|
||||||
|
"@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
|
||||||
|
|
||||||
|
"@babel/generator": ["@babel/generator@7.28.6", "", { "dependencies": { "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw=="],
|
||||||
|
|
||||||
|
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||||
|
|
||||||
|
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||||
|
|
||||||
|
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||||
|
|
||||||
|
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||||
|
|
||||||
|
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||||
|
|
||||||
|
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||||
|
|
||||||
|
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
|
||||||
|
|
||||||
|
"@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="],
|
||||||
|
|
||||||
|
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
|
||||||
|
|
||||||
|
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||||
|
|
||||||
|
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||||
|
|
||||||
|
"@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="],
|
||||||
|
|
||||||
|
"@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="],
|
||||||
|
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
|
||||||
|
|
||||||
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
|
||||||
|
|
||||||
|
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||||
|
|
||||||
|
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||||
|
|
||||||
|
"@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="],
|
||||||
|
|
||||||
|
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
|
||||||
|
|
||||||
|
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
|
||||||
|
|
||||||
|
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.3", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="],
|
||||||
|
|
||||||
|
"@eslint/js": ["@eslint/js@9.39.2", "", {}, "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA=="],
|
||||||
|
|
||||||
|
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
|
||||||
|
|
||||||
|
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
||||||
|
|
||||||
|
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||||
|
|
||||||
|
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||||
|
|
||||||
|
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||||
|
|
||||||
|
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||||
|
|
||||||
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
|
|
||||||
|
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.0", "", { "os": "android", "cpu": "arm" }, "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.0", "", { "os": "android", "cpu": "arm64" }, "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.0", "", { "os": "linux", "cpu": "arm" }, "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.0", "", { "os": "linux", "cpu": "none" }, "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.0", "", { "os": "linux", "cpu": "none" }, "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.0", "", { "os": "linux", "cpu": "none" }, "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.0", "", { "os": "linux", "cpu": "none" }, "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.0", "", { "os": "linux", "cpu": "x64" }, "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.0", "", { "os": "linux", "cpu": "x64" }, "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.0", "", { "os": "none", "cpu": "arm64" }, "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.0", "", { "os": "win32", "cpu": "x64" }, "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
|
||||||
|
|
||||||
|
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
||||||
|
|
||||||
|
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
|
||||||
|
|
||||||
|
"@tanstack/react-query": ["@tanstack/react-query@5.90.20", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="],
|
||||||
|
|
||||||
|
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||||
|
|
||||||
|
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||||
|
|
||||||
|
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||||
|
|
||||||
|
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||||
|
|
||||||
|
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
|
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@24.10.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="],
|
||||||
|
|
||||||
|
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.54.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/type-utils": "8.54.0", "@typescript-eslint/utils": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.54.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.54.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.54.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.54.0", "@typescript-eslint/types": "^8.54.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0" } }, "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.54.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/utils": "8.54.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.54.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.54.0", "@typescript-eslint/tsconfig-utils": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.54.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
|
||||||
|
|
||||||
|
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="],
|
||||||
|
|
||||||
|
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||||
|
|
||||||
|
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||||
|
|
||||||
|
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
||||||
|
|
||||||
|
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
|
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
|
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA=="],
|
||||||
|
|
||||||
|
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||||
|
|
||||||
|
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||||
|
|
||||||
|
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||||
|
|
||||||
|
"caniuse-lite": ["caniuse-lite@1.0.30001766", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="],
|
||||||
|
|
||||||
|
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
|
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
|
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
|
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||||
|
|
||||||
|
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||||
|
|
||||||
|
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||||
|
|
||||||
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
|
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
|
||||||
|
|
||||||
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||||
|
|
||||||
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
|
"electron-to-chromium": ["electron-to-chromium@1.5.279", "", {}, "sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg=="],
|
||||||
|
|
||||||
|
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
|
||||||
|
|
||||||
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
|
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||||
|
|
||||||
|
"eslint": ["eslint@9.39.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="],
|
||||||
|
|
||||||
|
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
|
||||||
|
|
||||||
|
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="],
|
||||||
|
|
||||||
|
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||||
|
|
||||||
|
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||||
|
|
||||||
|
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
|
||||||
|
|
||||||
|
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
||||||
|
|
||||||
|
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||||
|
|
||||||
|
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||||
|
|
||||||
|
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||||
|
|
||||||
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
|
|
||||||
|
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||||
|
|
||||||
|
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||||
|
|
||||||
|
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||||
|
|
||||||
|
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||||
|
|
||||||
|
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||||
|
|
||||||
|
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||||
|
|
||||||
|
"globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
|
||||||
|
|
||||||
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||||
|
|
||||||
|
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
|
||||||
|
|
||||||
|
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||||
|
|
||||||
|
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
|
|
||||||
|
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||||
|
|
||||||
|
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||||
|
|
||||||
|
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||||
|
|
||||||
|
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||||
|
|
||||||
|
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
|
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||||
|
|
||||||
|
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
|
|
||||||
|
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||||
|
|
||||||
|
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||||
|
|
||||||
|
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||||
|
|
||||||
|
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||||
|
|
||||||
|
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||||
|
|
||||||
|
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||||
|
|
||||||
|
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||||
|
|
||||||
|
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||||
|
|
||||||
|
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
||||||
|
|
||||||
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
|
||||||
|
|
||||||
|
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
||||||
|
|
||||||
|
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||||
|
|
||||||
|
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||||
|
|
||||||
|
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
|
"lucide-react": ["lucide-react@0.563.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="],
|
||||||
|
|
||||||
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||||
|
|
||||||
|
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||||
|
|
||||||
|
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||||
|
|
||||||
|
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||||
|
|
||||||
|
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||||
|
|
||||||
|
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||||
|
|
||||||
|
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||||
|
|
||||||
|
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||||
|
|
||||||
|
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||||
|
|
||||||
|
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
|
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||||
|
|
||||||
|
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||||
|
|
||||||
|
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
||||||
|
|
||||||
|
"react-router": ["react-router@7.13.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw=="],
|
||||||
|
|
||||||
|
"react-router-dom": ["react-router-dom@7.13.0", "", { "dependencies": { "react-router": "7.13.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g=="],
|
||||||
|
|
||||||
|
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||||
|
|
||||||
|
"rollup": ["rollup@4.57.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.0", "@rollup/rollup-android-arm64": "4.57.0", "@rollup/rollup-darwin-arm64": "4.57.0", "@rollup/rollup-darwin-x64": "4.57.0", "@rollup/rollup-freebsd-arm64": "4.57.0", "@rollup/rollup-freebsd-x64": "4.57.0", "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", "@rollup/rollup-linux-arm-musleabihf": "4.57.0", "@rollup/rollup-linux-arm64-gnu": "4.57.0", "@rollup/rollup-linux-arm64-musl": "4.57.0", "@rollup/rollup-linux-loong64-gnu": "4.57.0", "@rollup/rollup-linux-loong64-musl": "4.57.0", "@rollup/rollup-linux-ppc64-gnu": "4.57.0", "@rollup/rollup-linux-ppc64-musl": "4.57.0", "@rollup/rollup-linux-riscv64-gnu": "4.57.0", "@rollup/rollup-linux-riscv64-musl": "4.57.0", "@rollup/rollup-linux-s390x-gnu": "4.57.0", "@rollup/rollup-linux-x64-gnu": "4.57.0", "@rollup/rollup-linux-x64-musl": "4.57.0", "@rollup/rollup-openbsd-x64": "4.57.0", "@rollup/rollup-openharmony-arm64": "4.57.0", "@rollup/rollup-win32-arm64-msvc": "4.57.0", "@rollup/rollup-win32-ia32-msvc": "4.57.0", "@rollup/rollup-win32-x64-gnu": "4.57.0", "@rollup/rollup-win32-x64-msvc": "4.57.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA=="],
|
||||||
|
|
||||||
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
|
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||||
|
|
||||||
|
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||||
|
|
||||||
|
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||||
|
|
||||||
|
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||||
|
|
||||||
|
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||||
|
|
||||||
|
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||||
|
|
||||||
|
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
|
|
||||||
|
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
|
||||||
|
|
||||||
|
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"typescript-eslint": ["typescript-eslint@8.54.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.54.0", "@typescript-eslint/parser": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/utils": "8.54.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
|
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||||
|
|
||||||
|
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||||
|
|
||||||
|
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||||
|
|
||||||
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
|
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||||
|
|
||||||
|
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
|
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||||
|
|
||||||
|
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
|
|
||||||
|
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
||||||
|
|
||||||
|
"zustand": ["zustand@5.0.10", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg=="],
|
||||||
|
|
||||||
|
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||||
|
|
||||||
|
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/web/eslint.config.js
Normal file
23
apps/web/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
14
apps/web/index.html
Normal file
14
apps/web/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Todo App - Task management made simple" />
|
||||||
|
<title>Todo App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
44
apps/web/nginx.conf
Normal file
44
apps/web/nginx.conf
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
|
||||||
|
# API proxy
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://api:3001;
|
||||||
|
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-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Auth routes (public)
|
||||||
|
location /auth {
|
||||||
|
proxy_pass http://api:3001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
39
apps/web/package.json
Normal file
39
apps/web/package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.90.20",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"zustand": "^5.0.10"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.4",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/web/public/vite.svg
Normal file
1
apps/web/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
42
apps/web/src/App.css
Normal file
42
apps/web/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
73
apps/web/src/App.tsx
Normal file
73
apps/web/src/App.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { Layout } from '@/components/Layout';
|
||||||
|
import { LoginPage } from '@/pages/Login';
|
||||||
|
import { SetupPage } from '@/pages/Setup';
|
||||||
|
import { InboxPage } from '@/pages/Inbox';
|
||||||
|
import { TodayPage } from '@/pages/Today';
|
||||||
|
import { UpcomingPage } from '@/pages/Upcoming';
|
||||||
|
import { AdminPage } from '@/pages/Admin';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function AppRoutes() {
|
||||||
|
const { checkSession, isLoading, isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkSession();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full mx-auto"></div>
|
||||||
|
<p className="mt-4 text-gray-600">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
{/* Public routes */}
|
||||||
|
<Route path="/login" element={
|
||||||
|
isAuthenticated ? <Navigate to="/inbox" replace /> : <LoginPage />
|
||||||
|
} />
|
||||||
|
<Route path="/setup" element={<SetupPage />} />
|
||||||
|
|
||||||
|
{/* Protected routes */}
|
||||||
|
<Route element={<Layout />}>
|
||||||
|
<Route path="/inbox" element={<InboxPage />} />
|
||||||
|
<Route path="/today" element={<TodayPage />} />
|
||||||
|
<Route path="/upcoming" element={<UpcomingPage />} />
|
||||||
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
|
|
||||||
|
{/* Redirects */}
|
||||||
|
<Route path="/" element={<Navigate to="/inbox" replace />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Catch all */}
|
||||||
|
<Route path="*" element={<Navigate to="/inbox" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AppRoutes />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/web/src/assets/react.svg
Normal file
1
apps/web/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
182
apps/web/src/components/AddTask.tsx
Normal file
182
apps/web/src/components/AddTask.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Plus, Calendar, Flag, Tag, X } from 'lucide-react';
|
||||||
|
import type { Priority } from '@/types';
|
||||||
|
import { cn, getPriorityColor } from '@/lib/utils';
|
||||||
|
import { useTasksStore } from '@/stores/tasks';
|
||||||
|
|
||||||
|
interface AddTaskProps {
|
||||||
|
projectId?: string;
|
||||||
|
sectionId?: string;
|
||||||
|
parentId?: string;
|
||||||
|
onClose?: () => void;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = false }: AddTaskProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(autoFocus);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [dueDate, setDueDate] = useState('');
|
||||||
|
const [priority, setPriority] = useState<Priority>('p4');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { createTask, projects } = useTasksStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isExpanded && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [isExpanded]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!title.trim() || isSubmitting) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await createTask({
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
projectId,
|
||||||
|
sectionId,
|
||||||
|
parentId,
|
||||||
|
dueDate: dueDate || undefined,
|
||||||
|
priority,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setTitle('');
|
||||||
|
setDescription('');
|
||||||
|
setDueDate('');
|
||||||
|
setPriority('p4');
|
||||||
|
|
||||||
|
if (onClose) {
|
||||||
|
onClose();
|
||||||
|
setIsExpanded(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create task:', error);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsExpanded(false);
|
||||||
|
setTitle('');
|
||||||
|
setDescription('');
|
||||||
|
setDueDate('');
|
||||||
|
setPriority('p4');
|
||||||
|
onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isExpanded) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(true)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-500 hover:text-blue-600 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Add task</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="border border-gray-200 rounded-lg p-3 bg-white shadow-sm">
|
||||||
|
{/* Title input */}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Task name"
|
||||||
|
className="w-full text-sm font-medium text-gray-900 placeholder-gray-400 border-none outline-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Description input */}
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Description"
|
||||||
|
rows={2}
|
||||||
|
className="w-full mt-2 text-sm text-gray-600 placeholder-gray-400 border-none outline-none resize-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Options row */}
|
||||||
|
<div className="flex items-center gap-2 mt-3 flex-wrap">
|
||||||
|
{/* Due date */}
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dueDate}
|
||||||
|
onChange={(e) => setDueDate(e.target.value)}
|
||||||
|
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 px-2 py-1 text-xs rounded border transition-colors',
|
||||||
|
dueDate
|
||||||
|
? 'border-blue-200 bg-blue-50 text-blue-600'
|
||||||
|
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Calendar className="w-3.5 h-3.5" />
|
||||||
|
{dueDate ? new Date(dueDate).toLocaleDateString() : 'Due date'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority selector */}
|
||||||
|
<select
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => setPriority(e.target.value as Priority)}
|
||||||
|
className="appearance-none px-2 py-1 text-xs rounded border border-gray-200 bg-white cursor-pointer hover:bg-gray-50"
|
||||||
|
style={{ color: getPriorityColor(priority) }}
|
||||||
|
>
|
||||||
|
<option value="p1">Priority 1</option>
|
||||||
|
<option value="p2">Priority 2</option>
|
||||||
|
<option value="p3">Priority 3</option>
|
||||||
|
<option value="p4">Priority 4</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Project selector (if not in a specific project context) */}
|
||||||
|
{!projectId && projects.length > 0 && (
|
||||||
|
<select
|
||||||
|
className="appearance-none px-2 py-1 text-xs rounded border border-gray-200 bg-white cursor-pointer hover:bg-gray-50 text-gray-600"
|
||||||
|
>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.isInbox ? '📥 Inbox' : p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center justify-end gap-2 mt-4 pt-3 border-t border-gray-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!title.trim() || isSubmitting}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-sm font-medium rounded transition-colors',
|
||||||
|
title.trim() && !isSubmitting
|
||||||
|
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||||
|
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Adding...' : 'Add task'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
apps/web/src/components/Layout.tsx
Normal file
41
apps/web/src/components/Layout.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Outlet, Navigate } from 'react-router-dom';
|
||||||
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { useTasksStore } from '@/stores/tasks';
|
||||||
|
|
||||||
|
export function Layout() {
|
||||||
|
const { isAuthenticated, isLoading } = useAuthStore();
|
||||||
|
const { fetchProjects, fetchLabels } = useTasksStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
fetchProjects();
|
||||||
|
fetchLabels();
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full mx-auto"></div>
|
||||||
|
<p className="mt-4 text-gray-600">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-white">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 overflow-y-auto p-8">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
apps/web/src/components/Sidebar.tsx
Normal file
185
apps/web/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Inbox, Calendar, CalendarDays, Plus, ChevronDown, ChevronRight,
|
||||||
|
Hash, Settings, LogOut, User, FolderPlus, Tag
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { useTasksStore } from '@/stores/tasks';
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
const { projects, labels } = useTasksStore();
|
||||||
|
const [projectsExpanded, setProjectsExpanded] = useState(true);
|
||||||
|
const [labelsExpanded, setLabelsExpanded] = useState(true);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
const inbox = projects.find(p => p.isInbox);
|
||||||
|
const regularProjects = projects.filter(p => !p.isInbox);
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: '/inbox', icon: Inbox, label: 'Inbox', color: '#3b82f6' },
|
||||||
|
{ path: '/today', icon: Calendar, label: 'Today', color: '#22c55e' },
|
||||||
|
{ path: '/upcoming', icon: CalendarDays, label: 'Upcoming', color: '#8b5cf6' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-64 h-screen bg-gray-50 border-r border-gray-200 flex flex-col">
|
||||||
|
{/* User section */}
|
||||||
|
<div className="p-4 border-b border-gray-200">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-sm font-medium">
|
||||||
|
{user?.name?.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 truncate">{user?.name}</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">{user?.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 overflow-y-auto p-2">
|
||||||
|
{/* Main nav items */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors',
|
||||||
|
location.pathname === item.path
|
||||||
|
? 'bg-blue-50 text-blue-600'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="w-4 h-4" style={{ color: item.color }} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Projects section */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setProjectsExpanded(!projectsExpanded)}
|
||||||
|
className="w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider hover:text-gray-700"
|
||||||
|
>
|
||||||
|
<span>Projects</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Link
|
||||||
|
to="/projects/new"
|
||||||
|
className="p-1 hover:bg-gray-200 rounded"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
{projectsExpanded ? (
|
||||||
|
<ChevronDown className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{projectsExpanded && (
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
{regularProjects.map((project) => (
|
||||||
|
<Link
|
||||||
|
key={project.id}
|
||||||
|
to={`/project/${project.id}`}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors',
|
||||||
|
location.pathname === `/project/${project.id}`
|
||||||
|
? 'bg-blue-50 text-blue-600'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-3 h-3 rounded"
|
||||||
|
style={{ backgroundColor: project.color }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{project.name}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Labels section */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setLabelsExpanded(!labelsExpanded)}
|
||||||
|
className="w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider hover:text-gray-700"
|
||||||
|
>
|
||||||
|
<span>Labels</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Link
|
||||||
|
to="/labels/new"
|
||||||
|
className="p-1 hover:bg-gray-200 rounded"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
{labelsExpanded ? (
|
||||||
|
<ChevronDown className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{labelsExpanded && labels.length > 0 && (
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
{labels.map((label) => (
|
||||||
|
<Link
|
||||||
|
key={label.id}
|
||||||
|
to={`/label/${label.id}`}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors',
|
||||||
|
location.pathname === `/label/${label.id}`
|
||||||
|
? 'bg-blue-50 text-blue-600'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Tag className="w-3 h-3" style={{ color: label.color }} />
|
||||||
|
<span className="truncate">{label.name}</span>
|
||||||
|
{label.taskCount !== undefined && label.taskCount > 0 && (
|
||||||
|
<span className="ml-auto text-xs text-gray-400">{label.taskCount}</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Bottom section */}
|
||||||
|
<div className="p-2 border-t border-gray-200">
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
<span>Admin</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
<span>Sign out</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
apps/web/src/components/TaskItem.tsx
Normal file
137
apps/web/src/components/TaskItem.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Check, Calendar, Flag, ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||||
|
import type { Task } from '@/types';
|
||||||
|
import { cn, formatDate, isOverdue, getPriorityColor } from '@/lib/utils';
|
||||||
|
import { useTasksStore } from '@/stores/tasks';
|
||||||
|
|
||||||
|
interface TaskItemProps {
|
||||||
|
task: Task;
|
||||||
|
onClick?: () => void;
|
||||||
|
showProject?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskItem({ task, onClick, showProject = false }: TaskItemProps) {
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const { toggleComplete } = useTasksStore();
|
||||||
|
|
||||||
|
const handleComplete = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
await toggleComplete(task.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const overdue = !task.isCompleted && isOverdue(task.dueDate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group flex items-start gap-3 px-3 py-2 rounded-lg transition-colors cursor-pointer',
|
||||||
|
'hover:bg-gray-50',
|
||||||
|
task.isCompleted && 'opacity-60'
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
{/* Checkbox */}
|
||||||
|
<button
|
||||||
|
onClick={handleComplete}
|
||||||
|
className={cn(
|
||||||
|
'mt-0.5 flex-shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors',
|
||||||
|
task.isCompleted
|
||||||
|
? 'bg-gray-400 border-gray-400'
|
||||||
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
borderColor: !task.isCompleted ? getPriorityColor(task.priority) : undefined
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{task.isCompleted && <Check className="w-3 h-3 text-white" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className={cn(
|
||||||
|
'text-sm text-gray-900',
|
||||||
|
task.isCompleted && 'line-through text-gray-500'
|
||||||
|
)}>
|
||||||
|
{task.title}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{task.description && (
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5 line-clamp-1">
|
||||||
|
{task.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Meta info */}
|
||||||
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||||
|
{task.dueDate && (
|
||||||
|
<span className={cn(
|
||||||
|
'inline-flex items-center gap-1 text-xs',
|
||||||
|
overdue ? 'text-red-500' : 'text-gray-500'
|
||||||
|
)}>
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{formatDate(task.dueDate)}
|
||||||
|
{task.dueTime && ` ${task.dueTime}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showProject && task.project && (
|
||||||
|
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: task.project.color }}
|
||||||
|
/>
|
||||||
|
{task.project.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.taskLabels && task.taskLabels.length > 0 && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{task.taskLabels.slice(0, 2).map(({ label }) => (
|
||||||
|
<span
|
||||||
|
key={label.id}
|
||||||
|
className="px-1.5 py-0.5 text-xs rounded"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${label.color}20`,
|
||||||
|
color: label.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{task.taskLabels.length > 2 && (
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
+{task.taskLabels.length - 2}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.subtasks && task.subtasks.length > 0 && (
|
||||||
|
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
|
<ChevronRight className="w-3 h-3" />
|
||||||
|
{task.subtasks.filter(s => s.isCompleted).length}/{task.subtasks.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority flag */}
|
||||||
|
{task.priority !== 'p4' && (
|
||||||
|
<Flag
|
||||||
|
className="w-4 h-4 flex-shrink-0"
|
||||||
|
style={{ color: getPriorityColor(task.priority) }}
|
||||||
|
fill={getPriorityColor(task.priority)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions (shown on hover) */}
|
||||||
|
{isHovered && (
|
||||||
|
<button className="p-1 text-gray-400 hover:text-gray-600 rounded">
|
||||||
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
apps/web/src/index.css
Normal file
118
apps/web/src/index.css
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-primary: #3b82f6;
|
||||||
|
--color-primary-dark: #2563eb;
|
||||||
|
--color-danger: #ef4444;
|
||||||
|
--color-success: #22c55e;
|
||||||
|
--color-warning: #f59e0b;
|
||||||
|
|
||||||
|
--priority-1: #ef4444;
|
||||||
|
--priority-2: #f59e0b;
|
||||||
|
--priority-3: #3b82f6;
|
||||||
|
--priority-4: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus styles */
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button base */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--color-danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input base */
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Priority indicators */
|
||||||
|
.priority-1 { color: var(--priority-1); }
|
||||||
|
.priority-2 { color: var(--priority-2); }
|
||||||
|
.priority-3 { color: var(--priority-3); }
|
||||||
|
.priority-4 { color: var(--priority-4); }
|
||||||
|
|
||||||
|
.priority-dot-1 { background: var(--priority-1); }
|
||||||
|
.priority-dot-2 { background: var(--priority-2); }
|
||||||
|
.priority-dot-3 { background: var(--priority-3); }
|
||||||
|
.priority-dot-4 { background: var(--priority-4); }
|
||||||
263
apps/web/src/lib/api.ts
Normal file
263
apps/web/src/lib/api.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import type {
|
||||||
|
User, Project, Task, TaskCreate, TaskUpdate,
|
||||||
|
Label, Comment, Invite, Section
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
private token: string | null = null;
|
||||||
|
|
||||||
|
setToken(token: string | null) {
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetch<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.token) {
|
||||||
|
headers['Authorization'] = `Bearer ${this.token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||||
|
throw new Error(error.error || error.details || 'Request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
async login(email: string, password: string) {
|
||||||
|
const response = await fetch('/api/auth/sign-in/email', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ message: 'Login failed' }));
|
||||||
|
throw new Error(error.message || 'Login failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
await fetch('/api/auth/sign-out', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSession(): Promise<{ user: User } | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/get-session', {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return response.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invite validation (public)
|
||||||
|
async validateInvite(token: string): Promise<{ email: string; name: string }> {
|
||||||
|
const response = await fetch(`/auth/invite/${token}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ error: 'Invalid invite' }));
|
||||||
|
throw new Error(error.error);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async acceptInvite(token: string, password: string): Promise<void> {
|
||||||
|
const response = await fetch(`/auth/invite/${token}/accept`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ error: 'Failed to accept invite' }));
|
||||||
|
throw new Error(error.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
async getProjects(): Promise<Project[]> {
|
||||||
|
return this.fetch('/projects');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProject(id: string): Promise<Project> {
|
||||||
|
return this.fetch(`/projects/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProject(data: { name: string; color?: string; icon?: string }): Promise<Project> {
|
||||||
|
return this.fetch('/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProject(id: string, data: Partial<Project>): Promise<Project> {
|
||||||
|
return this.fetch(`/projects/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProject(id: string): Promise<void> {
|
||||||
|
await this.fetch(`/projects/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sections
|
||||||
|
async createSection(projectId: string, data: { name: string; sortOrder?: number }): Promise<Section> {
|
||||||
|
return this.fetch(`/projects/${projectId}/sections`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSection(projectId: string, sectionId: string, data: Partial<Section>): Promise<Section> {
|
||||||
|
return this.fetch(`/projects/${projectId}/sections/${sectionId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSection(projectId: string, sectionId: string): Promise<void> {
|
||||||
|
await this.fetch(`/projects/${projectId}/sections/${sectionId}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tasks
|
||||||
|
async getTasks(params?: {
|
||||||
|
projectId?: string;
|
||||||
|
sectionId?: string;
|
||||||
|
completed?: boolean;
|
||||||
|
priority?: string;
|
||||||
|
today?: boolean;
|
||||||
|
upcoming?: boolean;
|
||||||
|
overdue?: boolean;
|
||||||
|
labelId?: string;
|
||||||
|
includeSubtasks?: boolean;
|
||||||
|
}): Promise<Task[]> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params) {
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined) {
|
||||||
|
searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const query = searchParams.toString();
|
||||||
|
return this.fetch(`/tasks${query ? `?${query}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTask(id: string): Promise<Task> {
|
||||||
|
return this.fetch(`/tasks/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTask(data: TaskCreate): Promise<Task> {
|
||||||
|
return this.fetch('/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTask(id: string, data: TaskUpdate): Promise<Task> {
|
||||||
|
return this.fetch(`/tasks/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTask(id: string): Promise<void> {
|
||||||
|
await this.fetch(`/tasks/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Labels
|
||||||
|
async getLabels(): Promise<Label[]> {
|
||||||
|
return this.fetch('/labels');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLabel(data: { name: string; color?: string }): Promise<Label> {
|
||||||
|
return this.fetch('/labels', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLabel(id: string, data: Partial<Label>): Promise<Label> {
|
||||||
|
return this.fetch(`/labels/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLabel(id: string): Promise<void> {
|
||||||
|
await this.fetch(`/labels/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comments
|
||||||
|
async getComments(taskId: string): Promise<Comment[]> {
|
||||||
|
return this.fetch(`/comments/task/${taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createComment(data: { taskId: string; content: string; attachments?: { name: string; url: string; type: string }[] }): Promise<Comment> {
|
||||||
|
return this.fetch('/comments', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteComment(id: string): Promise<void> {
|
||||||
|
await this.fetch(`/comments/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
async getUsers(): Promise<User[]> {
|
||||||
|
return this.fetch('/admin/users');
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUserRole(id: string, role: 'admin' | 'user' | 'service'): Promise<void> {
|
||||||
|
await this.fetch(`/admin/users/${id}/role`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ role }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(id: string): Promise<void> {
|
||||||
|
await this.fetch(`/admin/users/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createInvite(data: { email: string; name: string }): Promise<Invite & { setupUrl: string }> {
|
||||||
|
return this.fetch('/admin/invites', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInvites(): Promise<Invite[]> {
|
||||||
|
return this.fetch('/admin/invites');
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteInvite(id: string): Promise<void> {
|
||||||
|
await this.fetch(`/admin/invites/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = new ApiClient();
|
||||||
70
apps/web/src/lib/utils.ts
Normal file
70
apps/web/src/lib/utils.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: string | Date | undefined): string {
|
||||||
|
if (!date) return '';
|
||||||
|
|
||||||
|
const d = new Date(date);
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const taskDate = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||||
|
|
||||||
|
if (taskDate.getTime() === today.getTime()) {
|
||||||
|
return 'Today';
|
||||||
|
}
|
||||||
|
if (taskDate.getTime() === tomorrow.getTime()) {
|
||||||
|
return 'Tomorrow';
|
||||||
|
}
|
||||||
|
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
if (taskDate.getTime() === yesterday.getTime()) {
|
||||||
|
return 'Yesterday';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Within this week
|
||||||
|
const weekFromNow = new Date(today);
|
||||||
|
weekFromNow.setDate(weekFromNow.getDate() + 7);
|
||||||
|
if (taskDate > today && taskDate < weekFromNow) {
|
||||||
|
return d.toLocaleDateString('en-US', { weekday: 'long' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same year
|
||||||
|
if (d.getFullYear() === now.getFullYear()) {
|
||||||
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOverdue(date: string | Date | undefined): boolean {
|
||||||
|
if (!date) return false;
|
||||||
|
const d = new Date(date);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
return d < today;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPriorityColor(priority: string): string {
|
||||||
|
switch (priority) {
|
||||||
|
case 'p1': return '#ef4444';
|
||||||
|
case 'p2': return '#f59e0b';
|
||||||
|
case 'p3': return '#3b82f6';
|
||||||
|
default: return '#6b7280';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPriorityLabel(priority: string): string {
|
||||||
|
switch (priority) {
|
||||||
|
case 'p1': return 'Priority 1';
|
||||||
|
case 'p2': return 'Priority 2';
|
||||||
|
case 'p3': return 'Priority 3';
|
||||||
|
default: return 'Priority 4';
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/web/src/main.tsx
Normal file
10
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
318
apps/web/src/pages/Admin.tsx
Normal file
318
apps/web/src/pages/Admin.tsx
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Users, Mail, Plus, Trash2, Copy, Check } from 'lucide-react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import type { User, Invite } from '@/types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export function AdminPage() {
|
||||||
|
const { user: currentUser } = useAuthStore();
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [invites, setInvites] = useState<Invite[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState<'users' | 'invites'>('users');
|
||||||
|
|
||||||
|
// Invite form
|
||||||
|
const [showInviteForm, setShowInviteForm] = useState(false);
|
||||||
|
const [inviteEmail, setInviteEmail] = useState('');
|
||||||
|
const [inviteName, setInviteName] = useState('');
|
||||||
|
const [inviteError, setInviteError] = useState('');
|
||||||
|
const [inviteUrl, setInviteUrl] = useState('');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [usersData, invitesData] = await Promise.all([
|
||||||
|
api.getUsers(),
|
||||||
|
api.getInvites(),
|
||||||
|
]);
|
||||||
|
setUsers(usersData);
|
||||||
|
setInvites(invitesData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load admin data:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateInvite = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setInviteError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.createInvite({ email: inviteEmail, name: inviteName });
|
||||||
|
setInviteUrl(result.setupUrl);
|
||||||
|
setInvites([result, ...invites]);
|
||||||
|
// Keep form open to show the URL
|
||||||
|
} catch (error) {
|
||||||
|
setInviteError(error instanceof Error ? error.message : 'Failed to create invite');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyUrl = () => {
|
||||||
|
navigator.clipboard.writeText(inviteUrl);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async (userId: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this user?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.deleteUser(userId);
|
||||||
|
setUsers(users.filter(u => u.id !== userId));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete user:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteInvite = async (inviteId: string) => {
|
||||||
|
try {
|
||||||
|
await api.deleteInvite(inviteId);
|
||||||
|
setInvites(invites.filter(i => i.id !== inviteId));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete invite:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetInviteForm = () => {
|
||||||
|
setShowInviteForm(false);
|
||||||
|
setInviteEmail('');
|
||||||
|
setInviteName('');
|
||||||
|
setInviteError('');
|
||||||
|
setInviteUrl('');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (currentUser?.role !== 'admin') {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-red-500">Access denied. Admin only.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-6">Admin</h1>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-4 border-b border-gray-200 mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('users')}
|
||||||
|
className={cn(
|
||||||
|
'pb-3 px-1 text-sm font-medium border-b-2 transition-colors',
|
||||||
|
activeTab === 'users'
|
||||||
|
? 'border-blue-600 text-blue-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Users className="w-4 h-4 inline mr-2" />
|
||||||
|
Users ({users.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('invites')}
|
||||||
|
className={cn(
|
||||||
|
'pb-3 px-1 text-sm font-medium border-b-2 transition-colors',
|
||||||
|
activeTab === 'invites'
|
||||||
|
? 'border-blue-600 text-blue-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Mail className="w-4 h-4 inline mr-2" />
|
||||||
|
Invites ({invites.filter(i => i.status === 'pending').length} pending)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">Loading...</div>
|
||||||
|
) : activeTab === 'users' ? (
|
||||||
|
/* Users list */
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 text-left text-sm text-gray-500">
|
||||||
|
<th className="px-4 py-3 font-medium">Name</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Email</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Role</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Joined</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<tr key={user.id} className="border-b border-gray-100 last:border-0">
|
||||||
|
<td className="px-4 py-3 text-sm font-medium text-gray-900">{user.name}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600">{user.email}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={cn(
|
||||||
|
'px-2 py-1 text-xs rounded-full',
|
||||||
|
user.role === 'admin' ? 'bg-purple-100 text-purple-700' :
|
||||||
|
user.role === 'service' ? 'bg-blue-100 text-blue-700' :
|
||||||
|
'bg-gray-100 text-gray-700'
|
||||||
|
)}>
|
||||||
|
{user.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-500">
|
||||||
|
{new Date(user.createdAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{user.id !== currentUser?.id && user.role !== 'service' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteUser(user.id)}
|
||||||
|
className="p-1 text-gray-400 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Invites */
|
||||||
|
<div>
|
||||||
|
{/* Create invite button/form */}
|
||||||
|
{!showInviteForm ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowInviteForm(true)}
|
||||||
|
className="mb-4 btn btn-primary"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Invite User
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="mb-6 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
|
<h3 className="font-medium text-gray-900 mb-4">Invite New User</h3>
|
||||||
|
|
||||||
|
{inviteUrl ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-green-600 mb-2">✓ Invite created! Share this link:</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inviteUrl}
|
||||||
|
readOnly
|
||||||
|
className="input flex-1 text-sm bg-white"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyUrl}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={resetInviteForm}
|
||||||
|
className="mt-3 text-sm text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
Create another invite
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleCreateInvite} className="space-y-4">
|
||||||
|
{inviteError && (
|
||||||
|
<p className="text-sm text-red-500">{inviteError}</p>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inviteName}
|
||||||
|
onChange={(e) => setInviteName(e.target.value)}
|
||||||
|
required
|
||||||
|
className="input"
|
||||||
|
placeholder="John Doe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={inviteEmail}
|
||||||
|
onChange={(e) => setInviteEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="input"
|
||||||
|
placeholder="john@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Send Invite
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={resetInviteForm} className="btn btn-secondary">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Invites list */}
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 text-left text-sm text-gray-500">
|
||||||
|
<th className="px-4 py-3 font-medium">Name</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Email</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Status</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Expires</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invites.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
|
||||||
|
No invites yet
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
invites.map((invite) => (
|
||||||
|
<tr key={invite.id} className="border-b border-gray-100 last:border-0">
|
||||||
|
<td className="px-4 py-3 text-sm font-medium text-gray-900">{invite.name}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600">{invite.email}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={cn(
|
||||||
|
'px-2 py-1 text-xs rounded-full',
|
||||||
|
invite.status === 'accepted' ? 'bg-green-100 text-green-700' :
|
||||||
|
invite.status === 'expired' ? 'bg-red-100 text-red-700' :
|
||||||
|
'bg-yellow-100 text-yellow-700'
|
||||||
|
)}>
|
||||||
|
{invite.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-500">
|
||||||
|
{new Date(invite.expiresAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{invite.status === 'pending' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteInvite(invite.id)}
|
||||||
|
className="p-1 text-gray-400 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
apps/web/src/pages/Inbox.tsx
Normal file
55
apps/web/src/pages/Inbox.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Inbox as InboxIcon } from 'lucide-react';
|
||||||
|
import { useTasksStore } from '@/stores/tasks';
|
||||||
|
import { TaskItem } from '@/components/TaskItem';
|
||||||
|
import { AddTask } from '@/components/AddTask';
|
||||||
|
|
||||||
|
export function InboxPage() {
|
||||||
|
const { tasks, projects, isLoading, fetchTasks, setSelectedTask } = useTasksStore();
|
||||||
|
|
||||||
|
const inbox = projects.find(p => p.isInbox);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inbox) {
|
||||||
|
fetchTasks({ projectId: inbox.id, completed: false });
|
||||||
|
}
|
||||||
|
}, [inbox?.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<InboxIcon className="w-6 h-6 text-blue-500" />
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Inbox</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tasks */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
Loading tasks...
|
||||||
|
</div>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<InboxIcon className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-500">Your inbox is empty</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">Add a task to get started</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
tasks.map((task) => (
|
||||||
|
<TaskItem
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
onClick={() => setSelectedTask(task)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add task */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<AddTask projectId={inbox?.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
apps/web/src/pages/Login.tsx
Normal file
96
apps/web/src/pages/Login.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { login } = useAuthStore();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
navigate('/inbox');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Login failed');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
|
||||||
|
<div className="max-w-md w-full">
|
||||||
|
{/* Logo/Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Todo App</h1>
|
||||||
|
<p className="mt-2 text-gray-600">Sign in to your account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login form */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
className="input"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="input"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full btn btn-primary py-2.5"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer note */}
|
||||||
|
<p className="mt-6 text-center text-sm text-gray-500">
|
||||||
|
This app is invite-only. Contact an admin for access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
apps/web/src/pages/Setup.tsx
Normal file
152
apps/web/src/pages/Setup.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useSearchParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
export function SetupPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
|
const [inviteData, setInviteData] = useState<{ email: string; name: string } | null>(null);
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setError('Invalid invite link');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.validateInvite(token)
|
||||||
|
.then(setInviteData)
|
||||||
|
.catch((err) => setError(err.message))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('Password must be at least 8 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.acceptInvite(token!, password);
|
||||||
|
navigate('/login?setup=success');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create account');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full mx-auto"></div>
|
||||||
|
<p className="mt-4 text-gray-600">Validating invite...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !inviteData) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
|
||||||
|
<div className="max-w-md w-full text-center">
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
|
||||||
|
<div className="text-red-500 text-5xl mb-4">😕</div>
|
||||||
|
<h1 className="text-xl font-semibold text-gray-900">Invalid Invite</h1>
|
||||||
|
<p className="mt-2 text-gray-600">{error}</p>
|
||||||
|
<Link to="/login" className="mt-6 inline-block btn btn-primary">
|
||||||
|
Go to Login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
|
||||||
|
<div className="max-w-md w-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Welcome!</h1>
|
||||||
|
<p className="mt-2 text-gray-600">Set up your account to get started</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Setup form */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
|
||||||
|
<div className="mb-6 p-4 bg-blue-50 rounded-lg">
|
||||||
|
<p className="text-sm text-blue-800">
|
||||||
|
<strong>{inviteData?.name}</strong>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-blue-600">{inviteData?.email}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Create password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="input"
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Confirm password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="input"
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full btn btn-primary py-2.5"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Creating account...' : 'Create account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
apps/web/src/pages/Today.tsx
Normal file
109
apps/web/src/pages/Today.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Calendar } from 'lucide-react';
|
||||||
|
import { useTasksStore } from '@/stores/tasks';
|
||||||
|
import { TaskItem } from '@/components/TaskItem';
|
||||||
|
import { AddTask } from '@/components/AddTask';
|
||||||
|
|
||||||
|
export function TodayPage() {
|
||||||
|
const { tasks, isLoading, fetchTasks, setSelectedTask } = useTasksStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTasks({ today: true, completed: false });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const formattedDate = today.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Separate overdue and today's tasks
|
||||||
|
const overdueTasks = tasks.filter(task => {
|
||||||
|
if (!task.dueDate) return false;
|
||||||
|
const dueDate = new Date(task.dueDate);
|
||||||
|
dueDate.setHours(0, 0, 0, 0);
|
||||||
|
const todayStart = new Date();
|
||||||
|
todayStart.setHours(0, 0, 0, 0);
|
||||||
|
return dueDate < todayStart;
|
||||||
|
});
|
||||||
|
|
||||||
|
const todayTasks = tasks.filter(task => {
|
||||||
|
if (!task.dueDate) return false;
|
||||||
|
const dueDate = new Date(task.dueDate);
|
||||||
|
dueDate.setHours(0, 0, 0, 0);
|
||||||
|
const todayStart = new Date();
|
||||||
|
todayStart.setHours(0, 0, 0, 0);
|
||||||
|
return dueDate.getTime() === todayStart.getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Calendar className="w-6 h-6 text-green-500" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Today</h1>
|
||||||
|
<p className="text-sm text-gray-500">{formattedDate}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
Loading tasks...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Overdue section */}
|
||||||
|
{overdueTasks.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-sm font-semibold text-red-600 mb-2 flex items-center gap-2">
|
||||||
|
<span>Overdue</span>
|
||||||
|
<span className="bg-red-100 text-red-600 px-2 py-0.5 rounded-full text-xs">
|
||||||
|
{overdueTasks.length}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-1 bg-red-50 rounded-lg p-2">
|
||||||
|
{overdueTasks.map((task) => (
|
||||||
|
<TaskItem
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
onClick={() => setSelectedTask(task)}
|
||||||
|
showProject
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Today's tasks */}
|
||||||
|
<div>
|
||||||
|
{todayTasks.length === 0 && overdueTasks.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Calendar className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-500">No tasks for today</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">Enjoy your day! 🎉</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{todayTasks.map((task) => (
|
||||||
|
<TaskItem
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
onClick={() => setSelectedTask(task)}
|
||||||
|
showProject
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add task */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<AddTask />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
apps/web/src/pages/Upcoming.tsx
Normal file
117
apps/web/src/pages/Upcoming.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { CalendarDays } from 'lucide-react';
|
||||||
|
import { useTasksStore } from '@/stores/tasks';
|
||||||
|
import { TaskItem } from '@/components/TaskItem';
|
||||||
|
import { AddTask } from '@/components/AddTask';
|
||||||
|
import { format, addDays, startOfDay, isSameDay } from 'date-fns';
|
||||||
|
|
||||||
|
export function UpcomingPage() {
|
||||||
|
const { tasks, isLoading, fetchTasks, setSelectedTask } = useTasksStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTasks({ upcoming: true, completed: false });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Group tasks by date
|
||||||
|
const groupedTasks = useMemo(() => {
|
||||||
|
const groups: { date: Date; label: string; tasks: typeof tasks }[] = [];
|
||||||
|
const today = startOfDay(new Date());
|
||||||
|
|
||||||
|
// Create groups for next 7 days
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const date = addDays(today, i);
|
||||||
|
let label: string;
|
||||||
|
|
||||||
|
if (i === 0) label = 'Today';
|
||||||
|
else if (i === 1) label = 'Tomorrow';
|
||||||
|
else label = format(date, 'EEEE, MMM d');
|
||||||
|
|
||||||
|
const dayTasks = tasks.filter(task => {
|
||||||
|
if (!task.dueDate) return false;
|
||||||
|
return isSameDay(new Date(task.dueDate), date);
|
||||||
|
});
|
||||||
|
|
||||||
|
groups.push({ date, label, tasks: dayTasks });
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
// Tasks without dates
|
||||||
|
const undatedTasks = tasks.filter(task => !task.dueDate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<CalendarDays className="w-6 h-6 text-purple-500" />
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Upcoming</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
Loading tasks...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{groupedTasks.map(({ date, label, tasks: dayTasks }) => (
|
||||||
|
<div key={date.toISOString()}>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-700 mb-2 sticky top-0 bg-white py-2">
|
||||||
|
{label}
|
||||||
|
{dayTasks.length > 0 && (
|
||||||
|
<span className="ml-2 text-gray-400 font-normal">
|
||||||
|
{dayTasks.length} task{dayTasks.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{dayTasks.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{dayTasks.map((task) => (
|
||||||
|
<TaskItem
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
onClick={() => setSelectedTask(task)}
|
||||||
|
showProject
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-4 text-center text-sm text-gray-400 border border-dashed border-gray-200 rounded-lg">
|
||||||
|
No tasks scheduled
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Undated tasks */}
|
||||||
|
{undatedTasks.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-700 mb-2">
|
||||||
|
No date
|
||||||
|
<span className="ml-2 text-gray-400 font-normal">
|
||||||
|
{undatedTasks.length} task{undatedTasks.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{undatedTasks.map((task) => (
|
||||||
|
<TaskItem
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
onClick={() => setSelectedTask(task)}
|
||||||
|
showProject
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add task */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<AddTask />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
apps/web/src/stores/auth.ts
Normal file
90
apps/web/src/stores/auth.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import type { User } from '@/types';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setUser: (user: User | null) => void;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
checkSession: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
user: null,
|
||||||
|
isLoading: true,
|
||||||
|
isAuthenticated: false,
|
||||||
|
|
||||||
|
setUser: (user) => set({
|
||||||
|
user,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
login: async (email, password) => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
await api.login(email, password);
|
||||||
|
const session = await api.getSession();
|
||||||
|
if (session?.user) {
|
||||||
|
set({
|
||||||
|
user: session.user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to get session');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
set({ isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async () => {
|
||||||
|
try {
|
||||||
|
await api.logout();
|
||||||
|
} finally {
|
||||||
|
set({ user: null, isAuthenticated: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
checkSession: async () => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const session = await api.getSession();
|
||||||
|
if (session?.user) {
|
||||||
|
set({
|
||||||
|
user: session.user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'auth-storage',
|
||||||
|
partialize: (state) => ({ user: state.user }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
108
apps/web/src/stores/tasks.ts
Normal file
108
apps/web/src/stores/tasks.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { Task, TaskCreate, TaskUpdate, Project, Label } from '@/types';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
interface TasksState {
|
||||||
|
tasks: Task[];
|
||||||
|
projects: Project[];
|
||||||
|
labels: Label[];
|
||||||
|
isLoading: boolean;
|
||||||
|
|
||||||
|
// Selected items
|
||||||
|
selectedTask: Task | null;
|
||||||
|
activeProjectId: string | null;
|
||||||
|
activeView: 'inbox' | 'today' | 'upcoming' | 'project' | 'label';
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
fetchTasks: (params?: Parameters<typeof api.getTasks>[0]) => Promise<void>;
|
||||||
|
fetchProjects: () => Promise<void>;
|
||||||
|
fetchLabels: () => Promise<void>;
|
||||||
|
|
||||||
|
createTask: (data: TaskCreate) => Promise<Task>;
|
||||||
|
updateTask: (id: string, data: TaskUpdate) => Promise<void>;
|
||||||
|
deleteTask: (id: string) => Promise<void>;
|
||||||
|
toggleComplete: (id: string) => Promise<void>;
|
||||||
|
|
||||||
|
setSelectedTask: (task: Task | null) => void;
|
||||||
|
setActiveProject: (projectId: string | null) => void;
|
||||||
|
setActiveView: (view: TasksState['activeView']) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTasksStore = create<TasksState>((set, get) => ({
|
||||||
|
tasks: [],
|
||||||
|
projects: [],
|
||||||
|
labels: [],
|
||||||
|
isLoading: false,
|
||||||
|
selectedTask: null,
|
||||||
|
activeProjectId: null,
|
||||||
|
activeView: 'inbox',
|
||||||
|
|
||||||
|
fetchTasks: async (params) => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const tasks = await api.getTasks(params);
|
||||||
|
set({ tasks, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch tasks:', error);
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchProjects: async () => {
|
||||||
|
try {
|
||||||
|
const projects = await api.getProjects();
|
||||||
|
set({ projects });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch projects:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchLabels: async () => {
|
||||||
|
try {
|
||||||
|
const labels = await api.getLabels();
|
||||||
|
set({ labels });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch labels:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createTask: async (data) => {
|
||||||
|
const task = await api.createTask(data);
|
||||||
|
set((state) => ({ tasks: [task, ...state.tasks] }));
|
||||||
|
return task;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTask: async (id, data) => {
|
||||||
|
const updated = await api.updateTask(id, data);
|
||||||
|
set((state) => ({
|
||||||
|
tasks: state.tasks.map((t) => (t.id === id ? { ...t, ...updated } : t)),
|
||||||
|
selectedTask: state.selectedTask?.id === id
|
||||||
|
? { ...state.selectedTask, ...updated }
|
||||||
|
: state.selectedTask,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteTask: async (id) => {
|
||||||
|
await api.deleteTask(id);
|
||||||
|
set((state) => ({
|
||||||
|
tasks: state.tasks.filter((t) => t.id !== id),
|
||||||
|
selectedTask: state.selectedTask?.id === id ? null : state.selectedTask,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleComplete: async (id) => {
|
||||||
|
const task = get().tasks.find((t) => t.id === id);
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
const updated = await api.updateTask(id, { isCompleted: !task.isCompleted });
|
||||||
|
set((state) => ({
|
||||||
|
tasks: state.tasks.map((t) =>
|
||||||
|
t.id === id ? { ...t, isCompleted: !task.isCompleted, completedAt: updated.completedAt } : t
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
setSelectedTask: (task) => set({ selectedTask: task }),
|
||||||
|
setActiveProject: (projectId) => set({ activeProjectId: projectId }),
|
||||||
|
setActiveView: (view) => set({ activeView: view }),
|
||||||
|
}));
|
||||||
171
apps/web/src/types/index.ts
Normal file
171
apps/web/src/types/index.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
// User types
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: 'admin' | 'user' | 'service';
|
||||||
|
image?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project types
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
icon?: string;
|
||||||
|
isInbox: boolean;
|
||||||
|
isFavorite: boolean;
|
||||||
|
isArchived: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
sections?: Section[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section types
|
||||||
|
export interface Section {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
sortOrder: number;
|
||||||
|
isCollapsed: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label types
|
||||||
|
export interface Label {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
isFavorite: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
taskCount?: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task types
|
||||||
|
export type Priority = 'p1' | 'p2' | 'p3' | 'p4';
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
projectId: string;
|
||||||
|
sectionId?: string;
|
||||||
|
parentId?: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
dueDate?: string;
|
||||||
|
dueTime?: string;
|
||||||
|
deadline?: string;
|
||||||
|
recurrence?: string;
|
||||||
|
priority: Priority;
|
||||||
|
isCompleted: boolean;
|
||||||
|
completedAt?: string;
|
||||||
|
assigneeId?: string;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
// Relations
|
||||||
|
project?: { id: string; name: string; color: string };
|
||||||
|
section?: { id: string; name: string };
|
||||||
|
parent?: { id: string; title: string };
|
||||||
|
subtasks?: Task[];
|
||||||
|
taskLabels?: { label: Label }[];
|
||||||
|
comments?: Comment[];
|
||||||
|
reminders?: Reminder[];
|
||||||
|
assignee?: { id: string; name: string; email?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskCreate {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
projectId?: string;
|
||||||
|
sectionId?: string;
|
||||||
|
parentId?: string;
|
||||||
|
dueDate?: string;
|
||||||
|
dueTime?: string;
|
||||||
|
deadline?: string;
|
||||||
|
recurrence?: string;
|
||||||
|
priority?: Priority;
|
||||||
|
assigneeId?: string;
|
||||||
|
labelIds?: string[];
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskUpdate {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
projectId?: string;
|
||||||
|
sectionId?: string;
|
||||||
|
dueDate?: string;
|
||||||
|
dueTime?: string;
|
||||||
|
deadline?: string;
|
||||||
|
recurrence?: string;
|
||||||
|
priority?: Priority;
|
||||||
|
isCompleted?: boolean;
|
||||||
|
assigneeId?: string;
|
||||||
|
labelIds?: string[];
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comment types
|
||||||
|
export interface Comment {
|
||||||
|
id: string;
|
||||||
|
taskId: string;
|
||||||
|
userId: string;
|
||||||
|
content: string;
|
||||||
|
attachments: { name: string; url: string; type: string }[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
user?: { id: string; name: string; image?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reminder types
|
||||||
|
export interface Reminder {
|
||||||
|
id: string;
|
||||||
|
taskId: string;
|
||||||
|
userId: string;
|
||||||
|
triggerAt: string;
|
||||||
|
relativeMins?: number;
|
||||||
|
isSent: boolean;
|
||||||
|
sentAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter types
|
||||||
|
export interface Filter {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
query: string;
|
||||||
|
color: string;
|
||||||
|
isFavorite: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invite types
|
||||||
|
export interface Invite {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
token: string;
|
||||||
|
invitedBy?: string;
|
||||||
|
status: 'pending' | 'accepted' | 'expired';
|
||||||
|
expiresAt: string;
|
||||||
|
acceptedAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
inviter?: { name: string; email: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Response types
|
||||||
|
export interface ApiError {
|
||||||
|
error: string;
|
||||||
|
details?: string;
|
||||||
|
}
|
||||||
28
apps/web/tsconfig.app.json
Normal file
28
apps/web/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
apps/web/tsconfig.json
Normal file
7
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
apps/web/tsconfig.node.json
Normal file
26
apps/web/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
23
apps/web/vite.config.ts
Normal file
23
apps/web/vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
58
docker-compose.yml
Normal file
58
docker-compose.yml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL Database
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: todo-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${DB_USER:-todo}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD required}
|
||||||
|
POSTGRES_DB: ${DB_NAME:-todo_app}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5433:5432" # Using 5433 to avoid conflicts
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-todo}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# API Server
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./apps/api
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: todo-api
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://${DB_USER:-todo}:${DB_PASSWORD}@db:5432/${DB_NAME:-todo_app}
|
||||||
|
PORT: 3001
|
||||||
|
NODE_ENV: production
|
||||||
|
APP_URL: ${APP_URL:-https://todo.donovankelly.xyz}
|
||||||
|
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://todo.donovankelly.xyz}
|
||||||
|
RESEND_API_KEY: ${RESEND_API_KEY}
|
||||||
|
FROM_EMAIL: ${FROM_EMAIL:-noreply@donovankelly.xyz}
|
||||||
|
HAMMER_API_KEY: ${HAMMER_API_KEY}
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# Web Frontend (built and served by nginx)
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: ./apps/web
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: todo-web
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3002:80"
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
Reference in New Issue
Block a user