Checkpoint: Initial stable CheckFlow base before i18n and demo mode
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
# Database password (change in production!)
|
||||||
|
POSTGRES_PASSWORD=changeme
|
||||||
|
|
||||||
|
# App URL (set to your domain or server IP)
|
||||||
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Auth secret — generate with: openssl rand -base64 32
|
||||||
|
NEXTAUTH_SECRET=change-this-to-a-random-string
|
||||||
|
|
||||||
|
# Port to expose (default 3000)
|
||||||
|
PORT=3000
|
||||||
+11
-34
@@ -1,41 +1,18 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# deps
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
# dependencies
|
# next
|
||||||
/node_modules
|
.next/
|
||||||
/.pnp
|
out/
|
||||||
.pnp.*
|
|
||||||
.yarn/*
|
|
||||||
!.yarn/patches
|
|
||||||
!.yarn/plugins
|
|
||||||
!.yarn/releases
|
|
||||||
!.yarn/versions
|
|
||||||
|
|
||||||
# testing
|
# env
|
||||||
/coverage
|
.env
|
||||||
|
.env.local
|
||||||
# next.js
|
.env.production
|
||||||
/.next/
|
|
||||||
/out/
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
|
||||||
.env*
|
|
||||||
|
|
||||||
# vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# typescript
|
|
||||||
*.tsbuildinfo
|
|
||||||
next-env.d.ts
|
|
||||||
|
|||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
FROM node:20-alpine AS base
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --legacy-peer-deps
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -1,36 +1,117 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# CheckFlow
|
||||||
|
|
||||||
## Getting Started
|
> 셀프호스팅 TickTick-like Todo 앱 — 계층형 체크리스트 + 메모장 + CardDAV + PWA
|
||||||
|
|
||||||
First, run the development server:
|

|
||||||
|
|
||||||
|
## 주요 기능
|
||||||
|
|
||||||
|
- ✅ **계층형 체크리스트** — 메인 태스크 + 하위 태스크
|
||||||
|
- 📝 **태스크 메모장** — 넓은 노트 영역 (Markdown 지원)
|
||||||
|
- 👥 **멀티유저** — 각 사용자 데이터 완전 격리
|
||||||
|
- 📱 **PWA** — Android 홈 화면 추가, 오프라인 지원
|
||||||
|
- 📲 **CardDAV** — DAVx⁵ 앱으로 Galaxy 동기화
|
||||||
|
- 📥 **TickTick Import** — CSV/ICS 내보내기 파일 import
|
||||||
|
- 🌙 **다크모드** — 시스템/수동 전환
|
||||||
|
- 🐳 **Docker** — 원클릭 배포
|
||||||
|
|
||||||
|
## 빠른 시작
|
||||||
|
|
||||||
|
### 1. 환경 변수 설정
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
cp .env.example .env
|
||||||
# or
|
# .env 파일에서 NEXTAUTH_SECRET 및 POSTGRES_PASSWORD 변경
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
### 2. Docker로 실행
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
앱이 시작되면:
|
||||||
|
```bash
|
||||||
|
# DB 마이그레이션 (최초 1회)
|
||||||
|
docker compose exec app npx prisma migrate deploy
|
||||||
|
```
|
||||||
|
|
||||||
## Learn More
|
`http://localhost:3000` 접속 후 계정 생성.
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
---
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
## 개발 환경 실행
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
### 요구사항
|
||||||
|
- Node.js 20+
|
||||||
|
- PostgreSQL (또는 Docker)
|
||||||
|
|
||||||
## Deploy on Vercel
|
```bash
|
||||||
|
# 의존성 설치
|
||||||
|
npm install
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
# DB 설정
|
||||||
|
cp .env.example .env.local
|
||||||
|
# .env.local의 DATABASE_URL을 DB에 맞게 수정
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
# DB 마이그레이션
|
||||||
|
npx prisma migrate dev
|
||||||
|
|
||||||
|
# 개발 서버 시작
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
→ `http://localhost:3000`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DAVx⁵로 Galaxy 연동
|
||||||
|
|
||||||
|
1. Play Store에서 **[DAVx⁵](https://play.google.com/store/apps/details?id=at.bitfire.davdroid)** 설치 (무료)
|
||||||
|
2. DAVx⁵ 앱 → **+** → Login with URL and user name
|
||||||
|
- **URL**: `http://your-server:3000/api/dav`
|
||||||
|
- **Username**: CheckFlow 이메일
|
||||||
|
- **Password**: CheckFlow 비밀번호
|
||||||
|
3. Task 목록 동기화 → Samsung Reminder 또는 Tasks 앱에서 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TickTick에서 가져오기
|
||||||
|
|
||||||
|
1. TickTick 앱 → Settings → Export
|
||||||
|
2. **Export as CSV** 또는 **Export as iCalendar** 선택
|
||||||
|
3. CheckFlow → 사이드바 → **Import Tasks**
|
||||||
|
4. 파일 선택 후 대상 목록 지정 → Import
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NPM (Nginx Proxy Manager) 연동
|
||||||
|
|
||||||
|
Docker compose가 기본으로 `3000` 포트에서 실행됩니다.
|
||||||
|
NPM에서 Proxy Host 추가:
|
||||||
|
- **Destination**: `http://checkflow-app:3000`
|
||||||
|
- SSL 인증서 설정
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 환경 변수
|
||||||
|
|
||||||
|
| 변수 | 설명 | 기본값 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `DATABASE_URL` | PostgreSQL 연결 문자열 | — |
|
||||||
|
| `NEXTAUTH_URL` | 외부 접근 URL | `http://localhost:3000` |
|
||||||
|
| `NEXTAUTH_SECRET` | JWT 시크릿 (32자 이상 랜덤) | — |
|
||||||
|
| `POSTGRES_PASSWORD` | DB 비밀번호 | `changeme` |
|
||||||
|
| `PORT` | 노출 포트 | `3000` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기술 스택
|
||||||
|
|
||||||
|
- **Frontend**: Next.js 16 (App Router) + TypeScript + Vanilla CSS
|
||||||
|
- **Backend**: Next.js API Routes
|
||||||
|
- **DB**: PostgreSQL + Prisma ORM
|
||||||
|
- **Auth**: NextAuth.js (JWT)
|
||||||
|
- **PWA**: Service Worker + Web App Manifest
|
||||||
|
- **CardDAV**: 커스텀 iCalendar VTODO 구현
|
||||||
|
- **Container**: Docker + docker-compose
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: checkflow-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: checkflow
|
||||||
|
POSTGRES_USER: checkflow
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U checkflow"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
build: .
|
||||||
|
container_name: checkflow-migrate
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://checkflow:${POSTGRES_PASSWORD:-changeme}@postgres:5432/checkflow
|
||||||
|
command: npx prisma migrate deploy
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
container_name: checkflow-app
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
ports:
|
||||||
|
- "${PORT:-3000}:3000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://checkflow:${POSTGRES_PASSWORD:-changeme}@postgres:5432/checkflow
|
||||||
|
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
|
||||||
|
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-change-this-secret-in-production}
|
||||||
|
NODE_ENV: production
|
||||||
|
volumes:
|
||||||
|
- ./prisma:/app/prisma
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
import "./.next/types/root-params.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
+10
-1
@@ -1,7 +1,16 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
output: "standalone",
|
||||||
|
// PWA service worker (manual - no next-pwa needed)
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/manifest.json",
|
||||||
|
headers: [{ key: "Content-Type", value: "application/manifest+json" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
Generated
+611
-2
@@ -8,15 +8,26 @@
|
|||||||
"name": "checkflow",
|
"name": "checkflow",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@prisma/client": "^6.19.3",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"csv-parse": "^7.0.2",
|
||||||
"next": "16.3.1",
|
"next": "16.3.1",
|
||||||
|
"next-auth": "^4.24.15",
|
||||||
|
"prisma": "^6.19.3",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8",
|
||||||
|
"uuid": "^14.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/uuid": "^11.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.3.1",
|
"eslint-config-next": "16.3.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
@@ -214,6 +225,14 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||||
@@ -259,6 +278,55 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
@@ -1233,12 +1301,97 @@
|
|||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@panva/hkdf": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/client": {
|
||||||
|
"version": "6.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
|
||||||
|
"integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"prisma": "*",
|
||||||
|
"typescript": ">=5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"prisma": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/config": {
|
||||||
|
"version": "6.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz",
|
||||||
|
"integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"c12": "3.1.0",
|
||||||
|
"deepmerge-ts": "7.1.5",
|
||||||
|
"effect": "3.21.0",
|
||||||
|
"empathic": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/debug": {
|
||||||
|
"version": "6.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
|
||||||
|
"integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw=="
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/engines": {
|
||||||
|
"version": "6.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz",
|
||||||
|
"integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/debug": "6.19.3",
|
||||||
|
"@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||||
|
"@prisma/fetch-engine": "6.19.3",
|
||||||
|
"@prisma/get-platform": "6.19.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/engines-version": {
|
||||||
|
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
|
||||||
|
"integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA=="
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/fetch-engine": {
|
||||||
|
"version": "6.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz",
|
||||||
|
"integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/debug": "6.19.3",
|
||||||
|
"@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||||
|
"@prisma/get-platform": "6.19.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/get-platform": {
|
||||||
|
"version": "6.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz",
|
||||||
|
"integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/debug": "6.19.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
"integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
|
"integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.23",
|
"version": "0.5.23",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||||
@@ -1513,6 +1666,16 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/bcryptjs": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==",
|
||||||
|
"deprecated": "This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -1558,6 +1721,16 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/uuid": {
|
||||||
|
"version": "11.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-11.0.0.tgz",
|
||||||
|
"integrity": "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==",
|
||||||
|
"deprecated": "This is a stub types definition. uuid provides its own type definitions, so you do not need this installed.",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"uuid": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.67.0",
|
"version": "8.67.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz",
|
||||||
@@ -2422,6 +2595,14 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||||
|
"bin": {
|
||||||
|
"bcrypt": "bin/bcrypt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.18",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
@@ -2477,6 +2658,33 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/c12": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": "^4.0.3",
|
||||||
|
"confbox": "^0.2.2",
|
||||||
|
"defu": "^6.1.4",
|
||||||
|
"dotenv": "^16.6.1",
|
||||||
|
"exsolve": "^1.0.7",
|
||||||
|
"giget": "^2.0.0",
|
||||||
|
"jiti": "^2.4.2",
|
||||||
|
"ohash": "^2.0.11",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"perfect-debounce": "^1.0.0",
|
||||||
|
"pkg-types": "^2.2.0",
|
||||||
|
"rc9": "^2.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"magicast": "^0.3.5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"magicast": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/call-bind": {
|
"node_modules/call-bind": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||||
@@ -2568,6 +2776,28 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chokidar": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||||
|
"dependencies": {
|
||||||
|
"readdirp": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.16.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/citty": {
|
||||||
|
"version": "0.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||||
|
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"consola": "^3.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/client-only": {
|
"node_modules/client-only": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||||
@@ -2597,12 +2827,33 @@
|
|||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/confbox": {
|
||||||
|
"version": "0.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||||
|
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="
|
||||||
|
},
|
||||||
|
"node_modules/consola": {
|
||||||
|
"version": "3.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
|
||||||
|
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.18.0 || >=16.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -2623,6 +2874,11 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/csv-parse": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-uKZghv9UmPkMVLYy//KZ9HFAIJsl7wkhoEdIL0+rhuSY9pZQlhaeGEDPIe+/w7eh81MOql8Q/9+inAGWG6ZHYA=="
|
||||||
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
@@ -2703,6 +2959,14 @@
|
|||||||
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
|
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/deepmerge-ts": {
|
||||||
|
"version": "7.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||||
|
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/define-data-property": {
|
"node_modules/define-data-property": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||||
@@ -2737,6 +3001,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/defu": {
|
||||||
|
"version": "6.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
||||||
|
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="
|
||||||
|
},
|
||||||
|
"node_modules/destr": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -2758,6 +3032,17 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "16.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
|
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -2772,6 +3057,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/effect": {
|
||||||
|
"version": "3.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
|
||||||
|
"integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"fast-check": "^3.23.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.411",
|
"version": "1.5.411",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.411.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.411.tgz",
|
||||||
@@ -2784,6 +3078,14 @@
|
|||||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/empathic": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.24.5",
|
"version": "5.24.5",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
|
||||||
@@ -3394,6 +3696,32 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/exsolve": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g=="
|
||||||
|
},
|
||||||
|
"node_modules/fast-check": {
|
||||||
|
"version": "3.23.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
||||||
|
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/dubzzz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fast-check"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"pure-rand": "^6.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -3648,6 +3976,22 @@
|
|||||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/giget": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
|
||||||
|
"dependencies": {
|
||||||
|
"citty": "^0.1.6",
|
||||||
|
"consola": "^3.4.0",
|
||||||
|
"defu": "^6.1.4",
|
||||||
|
"node-fetch-native": "^1.6.6",
|
||||||
|
"nypm": "^0.6.0",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"giget": "dist/cli.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@@ -4292,11 +4636,18 @@
|
|||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||||
"dev": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "4.15.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||||
|
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -4870,6 +5221,49 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-auth": {
|
||||||
|
"version": "4.24.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.15.tgz",
|
||||||
|
"integrity": "sha512-NnjYtjrSOAx/TIVFGTX4IfI/9yHnNpi4B7FuLUwuV20v2Zxgr2OGP/YN0ynJuI7y8QOnTBPitfOdEXZrVvhIuA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"@panva/hkdf": "^1.0.2",
|
||||||
|
"cookie": "^0.7.0",
|
||||||
|
"jose": "^4.15.5",
|
||||||
|
"oauth": "^0.9.15",
|
||||||
|
"openid-client": "^5.4.0",
|
||||||
|
"preact": "^10.6.3",
|
||||||
|
"preact-render-to-string": "^5.1.19",
|
||||||
|
"uuid": "^11.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@auth/core": "0.34.3",
|
||||||
|
"next": "^12.2.5 || ^13 || ^14 || ^15 || ^16",
|
||||||
|
"nodemailer": "^7.0.7",
|
||||||
|
"react": "^17.0.2 || ^18 || ^19",
|
||||||
|
"react-dom": "^17.0.2 || ^18 || ^19"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@auth/core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"nodemailer": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/next-auth/node_modules/uuid": {
|
||||||
|
"version": "11.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
|
||||||
|
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/esm/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/next/node_modules/postcss": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.5.23",
|
"version": "8.5.23",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||||
@@ -4915,6 +5309,11 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch-native": {
|
||||||
|
"version": "1.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||||
|
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.53",
|
"version": "2.0.53",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
|
||||||
@@ -4924,6 +5323,32 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nypm": {
|
||||||
|
"version": "0.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.9.tgz",
|
||||||
|
"integrity": "sha512-zxlE2yvSWZWmHcNdT3+5zV2lrCogeE9YOklHrR3dFjqutq5wO7GFDYLFDRXLsYnJzwvy/im9fYoxePvS0VTW0w==",
|
||||||
|
"dependencies": {
|
||||||
|
"citty": "^0.2.2",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"tinyexec": "^1.2.4"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"nypm": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nypm/node_modules/citty": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="
|
||||||
|
},
|
||||||
|
"node_modules/oauth": {
|
||||||
|
"version": "0.9.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
|
||||||
|
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA=="
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -4933,6 +5358,14 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-hash": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@@ -5039,6 +5472,49 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ohash": {
|
||||||
|
"version": "2.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz",
|
||||||
|
"integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw=="
|
||||||
|
},
|
||||||
|
"node_modules/oidc-token-hash": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
|
||||||
|
"engines": {
|
||||||
|
"node": "^10.13.0 || >=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client": {
|
||||||
|
"version": "5.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||||
|
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "^4.15.9",
|
||||||
|
"lru-cache": "^6.0.0",
|
||||||
|
"object-hash": "^2.2.0",
|
||||||
|
"oidc-token-hash": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client/node_modules/lru-cache": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||||
|
"dependencies": {
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client/node_modules/yallist": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -5140,6 +5616,16 @@
|
|||||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/pathe": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="
|
||||||
|
},
|
||||||
|
"node_modules/perfect-debounce": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -5157,6 +5643,16 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pkg-types": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
|
||||||
|
"dependencies": {
|
||||||
|
"confbox": "^0.2.4",
|
||||||
|
"exsolve": "^1.0.8",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@@ -5194,6 +5690,34 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/preact": {
|
||||||
|
"version": "10.29.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz",
|
||||||
|
"integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"preact-render-to-string": ">=5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"preact-render-to-string": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/preact-render-to-string": {
|
||||||
|
"version": "5.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz",
|
||||||
|
"integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==",
|
||||||
|
"dependencies": {
|
||||||
|
"pretty-format": "^3.8.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"preact": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -5203,6 +5727,35 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pretty-format": {
|
||||||
|
"version": "3.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
|
||||||
|
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="
|
||||||
|
},
|
||||||
|
"node_modules/prisma": {
|
||||||
|
"version": "6.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
|
||||||
|
"integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/config": "6.19.3",
|
||||||
|
"@prisma/engines": "6.19.3"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"prisma": "build/index.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@@ -5223,6 +5776,21 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pure-rand": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/dubzzz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fast-check"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -5243,6 +5811,15 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/rc9": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
|
||||||
|
"dependencies": {
|
||||||
|
"defu": "^6.1.4",
|
||||||
|
"destr": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.8",
|
"version": "19.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||||
@@ -5268,6 +5845,18 @@
|
|||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/readdirp": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.18.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -5871,6 +6460,14 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyexec": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
@@ -6191,6 +6788,18 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "14.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz",
|
||||||
|
"integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist-node/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
+14
-2
@@ -6,18 +6,30 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"dev:sqlite": "set DATABASE_URL=file:./prisma/dev.db && next dev"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@prisma/client": "^6.19.3",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"csv-parse": "^7.0.2",
|
||||||
"next": "16.3.1",
|
"next": "16.3.1",
|
||||||
|
"next-auth": "^4.24.15",
|
||||||
|
"prisma": "^6.19.3",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8",
|
||||||
|
"uuid": "^14.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/uuid": "^11.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.3.1",
|
"eslint-config-next": "16.3.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,88 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
name String
|
||||||
|
passwordHash String
|
||||||
|
avatarColor String @default("#4B7BF5")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
lists List[]
|
||||||
|
tasks Task[]
|
||||||
|
tags Tag[]
|
||||||
|
sessions Session[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
token String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model List {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
name String
|
||||||
|
color String @default("#4B7BF5")
|
||||||
|
icon String @default("list")
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
tasks Task[]
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Task {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
listId String
|
||||||
|
parentId String?
|
||||||
|
title String
|
||||||
|
note String?
|
||||||
|
completed Boolean @default(false)
|
||||||
|
completedAt DateTime?
|
||||||
|
dueDate DateTime?
|
||||||
|
priority Int @default(0)
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
list List @relation(fields: [listId], references: [id], onDelete: Cascade)
|
||||||
|
parent Task? @relation("TaskChildren", fields: [parentId], references: [id], onDelete: Cascade)
|
||||||
|
children Task[] @relation("TaskChildren")
|
||||||
|
tags TaskTag[]
|
||||||
|
@@index([userId])
|
||||||
|
@@index([listId])
|
||||||
|
@@index([parentId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Tag {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
name String
|
||||||
|
color String @default("#6B6B6B")
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
tasks TaskTag[]
|
||||||
|
@@unique([userId, name])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TaskTag {
|
||||||
|
taskId String
|
||||||
|
tagId String
|
||||||
|
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
|
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||||
|
@@id([taskId, tagId])
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"avatarColor" TEXT NOT NULL DEFAULT '#4B7BF5',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Session" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "List" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"color" TEXT NOT NULL DEFAULT '#4B7BF5',
|
||||||
|
"icon" TEXT NOT NULL DEFAULT 'list',
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "List_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Task" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"listId" TEXT NOT NULL,
|
||||||
|
"parentId" TEXT,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"note" TEXT,
|
||||||
|
"completed" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"dueDate" TIMESTAMP(3),
|
||||||
|
"priority" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Tag" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"color" TEXT NOT NULL DEFAULT '#6B6B6B',
|
||||||
|
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TaskTag" (
|
||||||
|
"taskId" TEXT NOT NULL,
|
||||||
|
"tagId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "TaskTag_pkey" PRIMARY KEY ("taskId","tagId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token");
|
||||||
|
CREATE UNIQUE INDEX "Tag_userId_name_key" ON "Tag"("userId", "name");
|
||||||
|
CREATE INDEX "List_userId_idx" ON "List"("userId");
|
||||||
|
CREATE INDEX "Task_userId_idx" ON "Task"("userId");
|
||||||
|
CREATE INDEX "Task_listId_idx" ON "Task"("listId");
|
||||||
|
CREATE INDEX "Task_parentId_idx" ON "Task"("parentId");
|
||||||
|
CREATE INDEX "Tag_userId_idx" ON "Tag"("userId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "List" ADD CONSTRAINT "List_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Task" ADD CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Task" ADD CONSTRAINT "Task_listId_fkey" FOREIGN KEY ("listId") REFERENCES "List"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Task" ADD CONSTRAINT "Task_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Tag" ADD CONSTRAINT "Tag_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "TaskTag" ADD CONSTRAINT "TaskTag_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "TaskTag" ADD CONSTRAINT "TaskTag_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
provider = "postgresql"
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
name String
|
||||||
|
passwordHash String
|
||||||
|
avatarColor String @default("#4B7BF5")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
lists List[]
|
||||||
|
tasks Task[]
|
||||||
|
tags Tag[]
|
||||||
|
sessions Session[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
token String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model List {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
name String
|
||||||
|
color String @default("#4B7BF5")
|
||||||
|
icon String @default("list")
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
tasks Task[]
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Task {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
listId String
|
||||||
|
parentId String?
|
||||||
|
|
||||||
|
title String
|
||||||
|
note String? @db.Text
|
||||||
|
completed Boolean @default(false)
|
||||||
|
completedAt DateTime?
|
||||||
|
|
||||||
|
dueDate DateTime?
|
||||||
|
priority Int @default(0)
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
list List @relation(fields: [listId], references: [id], onDelete: Cascade)
|
||||||
|
parent Task? @relation("TaskChildren", fields: [parentId], references: [id], onDelete: Cascade)
|
||||||
|
children Task[] @relation("TaskChildren")
|
||||||
|
tags TaskTag[]
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([listId])
|
||||||
|
@@index([parentId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Tag {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
name String
|
||||||
|
color String @default("#6B6B6B")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
tasks TaskTag[]
|
||||||
|
|
||||||
|
@@unique([userId, name])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TaskTag {
|
||||||
|
taskId String
|
||||||
|
tagId String
|
||||||
|
|
||||||
|
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
|
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([taskId, tagId])
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#4B7BF5"/><text x="50" y="67" font-family="Arial" font-size="55" font-weight="bold" fill="white" text-anchor="middle">✓</text></svg>
|
||||||
|
After Width: | Height: | Size: 247 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#4B7BF5"/><text x="50" y="67" font-family="Arial" font-size="55" font-weight="bold" fill="white" text-anchor="middle">✓</text></svg>
|
||||||
|
After Width: | Height: | Size: 247 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#4B7BF5"/><text x="50" y="67" font-family="Arial" font-size="55" font-weight="bold" fill="white" text-anchor="middle">✓</text></svg>
|
||||||
|
After Width: | Height: | Size: 247 B |
@@ -0,0 +1 @@
|
|||||||
|
{"name":"CheckFlow","short_name":"CheckFlow","description":"Clean, fast self-hosted todo app","start_url":"/","display":"standalone","background_color":"#FFFFFF","theme_color":"#4B7BF5","orientation":"any","icons":[{"src":"/icons/icon.svg","sizes":"any","type":"image/svg+xml","purpose":"any maskable"}],"categories":["productivity","utilities"],"lang":"ko"}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const CACHE_NAME = "checkflow-v1";
|
||||||
|
const STATIC_ASSETS = ["/", "/login", "/register", "/manifest.json", "/icons/icon.svg"];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Network-first for API
|
||||||
|
if (url.pathname.startsWith("/api")) {
|
||||||
|
event.respondWith(fetch(request).catch(() => new Response(JSON.stringify({ error: "Offline" }), { headers: { "Content-Type": "application/json" } })));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache-first for static
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(request).then((cached) => cached || fetch(request))
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
|
||||||
|
const handler = NextAuth(authOptions);
|
||||||
|
export { handler as GET, handler as POST };
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
if (!body) {
|
||||||
|
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const { name, email, password } = body;
|
||||||
|
|
||||||
|
if (!name || !email || !password) {
|
||||||
|
return NextResponse.json({ error: "All fields required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
return NextResponse.json({ error: "Password must be at least 8 characters" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json({ error: "Email already in use" }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { name, email, passwordHash },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create default list
|
||||||
|
await prisma.list.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
name: "My Tasks",
|
||||||
|
color: "#4B7BF5",
|
||||||
|
icon: "inbox",
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ id: user.id, email: user.email, name: user.name }, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[register]", err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Server error. Make sure the database is running." },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
async function authenticate(req: NextRequest) {
|
||||||
|
const authHeader = req.headers.get("authorization");
|
||||||
|
if (!authHeader?.startsWith("Basic ")) return null;
|
||||||
|
const decoded = Buffer.from(authHeader.slice(6), "base64").toString();
|
||||||
|
const [email, password] = decoded.split(":");
|
||||||
|
const user = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (!user) return null;
|
||||||
|
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||||
|
return valid ? user : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskToVTodo(task: { id: string; title: string; note: string | null; completed: boolean; completedAt: Date | null; dueDate: Date | null; priority: number; createdAt: Date; updatedAt: Date }) {
|
||||||
|
const now = new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
|
||||||
|
const priorityMap: Record<number, number> = { 0: 0, 1: 9, 2: 5, 3: 1 };
|
||||||
|
let vtodo = [
|
||||||
|
"BEGIN:VTODO",
|
||||||
|
`UID:${task.id}@checkflow`,
|
||||||
|
`DTSTAMP:${now}`,
|
||||||
|
`CREATED:${task.createdAt.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`,
|
||||||
|
`LAST-MODIFIED:${task.updatedAt.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`,
|
||||||
|
`SUMMARY:${task.title.replace(/\n/g, "\\n")}`,
|
||||||
|
`STATUS:${task.completed ? "COMPLETED" : "NEEDS-ACTION"}`,
|
||||||
|
`PRIORITY:${priorityMap[task.priority] ?? 0}`,
|
||||||
|
];
|
||||||
|
if (task.note) vtodo.push(`DESCRIPTION:${task.note.replace(/\n/g, "\\n")}`);
|
||||||
|
if (task.dueDate) vtodo.push(`DUE:${task.dueDate.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`);
|
||||||
|
if (task.completedAt) vtodo.push(`COMPLETED:${task.completedAt.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`);
|
||||||
|
vtodo.push("END:VTODO");
|
||||||
|
return vtodo.join("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle all CalDAV/CardDAV requests
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await authenticate(req);
|
||||||
|
if (!user) {
|
||||||
|
return new NextResponse("Unauthorized", {
|
||||||
|
status: 401,
|
||||||
|
headers: { "WWW-Authenticate": 'Basic realm="CheckFlow"' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = await prisma.task.findMany({
|
||||||
|
where: { userId: user.id, parentId: null },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const icsContent = [
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
"PRODID:-//CheckFlow//CheckFlow//EN",
|
||||||
|
"CALSCALE:GREGORIAN",
|
||||||
|
"METHOD:PUBLISH",
|
||||||
|
...tasks.map(taskToVTodo),
|
||||||
|
"END:VCALENDAR",
|
||||||
|
].join("\r\n");
|
||||||
|
|
||||||
|
return new NextResponse(icsContent, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/calendar; charset=utf-8",
|
||||||
|
"Content-Disposition": "attachment; filename=checkflow.ics",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// OPTIONS for CORS
|
||||||
|
export async function OPTIONS() {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
headers: {
|
||||||
|
Allow: "GET, OPTIONS",
|
||||||
|
"DAV": "1, 2, calendar-access",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
// TickTick CSV import
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const formData = await req.formData();
|
||||||
|
const file = formData.get("file") as File | null;
|
||||||
|
const listId = formData.get("listId") as string | null;
|
||||||
|
|
||||||
|
if (!file || !listId) {
|
||||||
|
return NextResponse.json({ error: "file and listId required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = await prisma.list.findFirst({ where: { id: listId, userId: session.user.id } });
|
||||||
|
if (!list) return NextResponse.json({ error: "List not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const text = await file.text();
|
||||||
|
const ext = file.name.split(".").pop()?.toLowerCase();
|
||||||
|
|
||||||
|
let imported = 0;
|
||||||
|
|
||||||
|
if (ext === "csv") {
|
||||||
|
const lines = text.split("\n");
|
||||||
|
const header = lines[0].split(",").map((h) => h.trim().replace(/"/g, ""));
|
||||||
|
const titleIdx = header.findIndex((h) => h.toLowerCase().includes("title") || h.toLowerCase().includes("content"));
|
||||||
|
const noteIdx = header.findIndex((h) => h.toLowerCase().includes("note") || h.toLowerCase().includes("description"));
|
||||||
|
const dueIdx = header.findIndex((h) => h.toLowerCase().includes("due"));
|
||||||
|
const priorityIdx = header.findIndex((h) => h.toLowerCase().includes("priority"));
|
||||||
|
const completedIdx = header.findIndex((h) => h.toLowerCase().includes("status") || h.toLowerCase().includes("completed"));
|
||||||
|
|
||||||
|
const priorityMap: Record<string, number> = { high: 3, medium: 2, low: 1, none: 0, "3": 3, "2": 2, "1": 1, "0": 0 };
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
// Handle quoted CSV fields
|
||||||
|
const fields: string[] = [];
|
||||||
|
let inQuotes = false;
|
||||||
|
let current = "";
|
||||||
|
for (const ch of line + ",") {
|
||||||
|
if (ch === '"') { inQuotes = !inQuotes; }
|
||||||
|
else if (ch === "," && !inQuotes) { fields.push(current.trim()); current = ""; }
|
||||||
|
else { current += ch; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = titleIdx >= 0 ? fields[titleIdx]?.replace(/"/g, "") : "";
|
||||||
|
if (!title) continue;
|
||||||
|
|
||||||
|
const note = noteIdx >= 0 ? fields[noteIdx]?.replace(/"/g, "") : null;
|
||||||
|
const dueRaw = dueIdx >= 0 ? fields[dueIdx] : null;
|
||||||
|
const priorityRaw = priorityIdx >= 0 ? fields[priorityIdx]?.toLowerCase() : "0";
|
||||||
|
const completedRaw = completedIdx >= 0 ? fields[completedIdx]?.toLowerCase() : "";
|
||||||
|
|
||||||
|
let dueDate: Date | null = null;
|
||||||
|
if (dueRaw) {
|
||||||
|
const d = new Date(dueRaw);
|
||||||
|
if (!isNaN(d.getTime())) dueDate = d;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priority = priorityMap[priorityRaw || "0"] ?? 0;
|
||||||
|
const completed = completedRaw === "completed" || completedRaw === "true" || completedRaw === "1";
|
||||||
|
|
||||||
|
await prisma.task.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user.id,
|
||||||
|
listId,
|
||||||
|
title,
|
||||||
|
note: note || null,
|
||||||
|
dueDate,
|
||||||
|
priority,
|
||||||
|
completed,
|
||||||
|
completedAt: completed ? new Date() : null,
|
||||||
|
sortOrder: imported,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
imported++;
|
||||||
|
}
|
||||||
|
} else if (ext === "ics") {
|
||||||
|
// Parse ICS / iCalendar VTODO
|
||||||
|
const todos = text.split("BEGIN:VTODO").slice(1);
|
||||||
|
for (const todo of todos) {
|
||||||
|
const get = (key: string) => {
|
||||||
|
const match = todo.match(new RegExp(`^${key}[^:]*:(.*)$`, "m"));
|
||||||
|
return match ? match[1].trim().replace(/\\n/g, "\n") : null;
|
||||||
|
};
|
||||||
|
const title = get("SUMMARY");
|
||||||
|
if (!title) continue;
|
||||||
|
const note = get("DESCRIPTION");
|
||||||
|
const dueDateRaw = get("DUE") || get("DTSTART");
|
||||||
|
let dueDate: Date | null = null;
|
||||||
|
if (dueDateRaw) {
|
||||||
|
const d = new Date(dueDateRaw.replace(/(\d{4})(\d{2})(\d{2})/, "$1-$2-$3"));
|
||||||
|
if (!isNaN(d.getTime())) dueDate = d;
|
||||||
|
}
|
||||||
|
const statusRaw = get("STATUS");
|
||||||
|
const completed = statusRaw === "COMPLETED";
|
||||||
|
const priorityRaw = get("PRIORITY");
|
||||||
|
let priority = 0;
|
||||||
|
if (priorityRaw) {
|
||||||
|
const p = parseInt(priorityRaw);
|
||||||
|
if (p >= 1 && p <= 3) priority = 4 - p; // ICS: 1=high, ours: 3=high
|
||||||
|
else if (p >= 4 && p <= 6) priority = 2;
|
||||||
|
else if (p >= 7) priority = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.task.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user.id,
|
||||||
|
listId,
|
||||||
|
title,
|
||||||
|
note: note || null,
|
||||||
|
dueDate,
|
||||||
|
priority,
|
||||||
|
completed,
|
||||||
|
completedAt: completed ? new Date() : null,
|
||||||
|
sortOrder: imported,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
imported++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: "Unsupported file format. Use CSV or ICS." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ imported });
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
type Ctx = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest, { params }: Ctx) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
if (!body) return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||||
|
|
||||||
|
const list = await prisma.list.findFirst({ where: { id, userId: session.user.id } });
|
||||||
|
if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const allowed = ["name", "color", "icon", "sortOrder"];
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
for (const key of allowed) {
|
||||||
|
if (key in body) data[key] = body[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.list.update({ where: { id }, data });
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[lists/id:PATCH]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to update list" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_: NextRequest, { params }: Ctx) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const list = await prisma.list.findFirst({ where: { id, userId: session.user.id } });
|
||||||
|
if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
await prisma.list.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[lists/id:DELETE]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to delete list" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const lists = await prisma.list.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
include: {
|
||||||
|
_count: { select: { tasks: { where: { completed: false, parentId: null } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(lists);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[lists:GET]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch lists" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
if (!body) return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||||
|
|
||||||
|
const { name, color, icon } = body;
|
||||||
|
if (!name?.trim()) return NextResponse.json({ error: "Name required" }, { status: 400 });
|
||||||
|
|
||||||
|
const count = await prisma.list.count({ where: { userId: session.user.id } });
|
||||||
|
const list = await prisma.list.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user.id,
|
||||||
|
name: name.trim(),
|
||||||
|
color: color || "#4B7BF5",
|
||||||
|
icon: icon || "list",
|
||||||
|
sortOrder: count,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(list, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[lists:POST]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to create list" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
type Ctx = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function GET(_: NextRequest, { params }: Ctx) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const task = await prisma.task.findFirst({
|
||||||
|
where: { id, userId: session.user.id },
|
||||||
|
include: { children: { orderBy: { sortOrder: "asc" } }, tags: { include: { tag: true } } },
|
||||||
|
});
|
||||||
|
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
return NextResponse.json(task);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[tasks/id:GET]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch task" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest, { params }: Ctx) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
if (!body) return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||||
|
|
||||||
|
const task = await prisma.task.findFirst({ where: { id, userId: session.user.id } });
|
||||||
|
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
// Sanitize allowed fields
|
||||||
|
const allowed = ["title", "note", "completed", "completedAt", "dueDate", "priority", "sortOrder", "listId"];
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
for (const key of allowed) {
|
||||||
|
if (key in body) data[key] = body[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-set completedAt
|
||||||
|
if ("completed" in data) {
|
||||||
|
data.completedAt = data.completed ? new Date() : null;
|
||||||
|
}
|
||||||
|
// Convert dueDate string to Date
|
||||||
|
if ("dueDate" in data) {
|
||||||
|
data.dueDate = data.dueDate ? new Date(data.dueDate as string) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.task.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
include: { children: { orderBy: { sortOrder: "asc" } }, tags: { include: { tag: true } } },
|
||||||
|
});
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[tasks/id:PATCH]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to update task" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_: NextRequest, { params }: Ctx) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const task = await prisma.task.findFirst({ where: { id, userId: session.user.id } });
|
||||||
|
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
await prisma.task.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[tasks/id:DELETE]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to delete task" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const listId = searchParams.get("listId");
|
||||||
|
const showCompleted = searchParams.get("showCompleted") === "true";
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = {
|
||||||
|
userId: session.user.id,
|
||||||
|
parentId: null,
|
||||||
|
...(listId ? { listId } : {}),
|
||||||
|
...(showCompleted ? {} : { completed: false }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const tasks = await prisma.task.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||||
|
include: {
|
||||||
|
children: { orderBy: [{ sortOrder: "asc" }] },
|
||||||
|
tags: { include: { tag: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(tasks);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[tasks:GET]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch tasks" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
if (!body) return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||||
|
|
||||||
|
const { title, listId, parentId, dueDate, priority, note } = body;
|
||||||
|
if (!title || !listId) return NextResponse.json({ error: "title and listId required" }, { status: 400 });
|
||||||
|
|
||||||
|
const list = await prisma.list.findFirst({ where: { id: listId, userId: session.user.id } });
|
||||||
|
if (!list) return NextResponse.json({ error: "List not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const count = await prisma.task.count({ where: { listId, parentId: parentId || null } });
|
||||||
|
const task = await prisma.task.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user.id,
|
||||||
|
listId,
|
||||||
|
parentId: parentId || null,
|
||||||
|
title: title.trim(),
|
||||||
|
note: note?.trim() || null,
|
||||||
|
dueDate: dueDate ? new Date(dueDate) : null,
|
||||||
|
priority: Number(priority) || 0,
|
||||||
|
sortOrder: count,
|
||||||
|
},
|
||||||
|
include: { children: true, tags: { include: { tag: true } } },
|
||||||
|
});
|
||||||
|
return NextResponse.json(task, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[tasks:POST]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to create task" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+1410
-16
File diff suppressed because it is too large
Load Diff
+30
-20
@@ -1,29 +1,39 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { Providers } from "./providers";
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "CheckFlow — Your Personal Todo",
|
||||||
description: "Generated by create next app",
|
description: "A clean, fast, self-hosted todo app with hierarchical tasks and rich notes.",
|
||||||
|
manifest: "/manifest.json",
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: "default",
|
||||||
|
title: "CheckFlow",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
export const viewport: Viewport = {
|
||||||
|
themeColor: "#4B7BF5",
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
viewportFit: "cover",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html lang="ko" suppressHydrationWarning>
|
||||||
lang="en"
|
<head>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<Providers>{children}</Providers>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
const res = await signIn("credentials", { email, password, redirect: false });
|
||||||
|
setLoading(false);
|
||||||
|
if (res?.error) {
|
||||||
|
setError("Invalid email or password.");
|
||||||
|
} else {
|
||||||
|
router.push("/");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-page">
|
||||||
|
<div className="auth-card">
|
||||||
|
<div className="auth-logo">
|
||||||
|
<div className="auth-logo-icon">✓</div>
|
||||||
|
<span className="auth-logo-name">CheckFlow</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="auth-title">Welcome back</h1>
|
||||||
|
<p className="auth-subtitle">Sign in to your account</p>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="form-error" style={{ marginBottom: "12px" }}>{error}</p>}
|
||||||
|
<button id="login-btn" type="submit" className="btn btn-primary w-full" disabled={loading} style={{ height: "44px" }}>
|
||||||
|
{loading ? "Signing in..." : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="auth-footer">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link href="/register" className="auth-link">Create one</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+8
-67
@@ -1,69 +1,10 @@
|
|||||||
import Image from "next/image";
|
import { getServerSession } from "next-auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { AppShell } from "@/components/layout/AppShell";
|
||||||
|
|
||||||
export default function Home() {
|
export default async function HomePage() {
|
||||||
return (
|
const session = await getServerSession(authOptions);
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
if (!session) redirect("/login");
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
return <AppShell user={session.user} />;
|
||||||
<Image
|
|
||||||
className="dark:invert h-5 w-[100px]"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the{" "}
|
|
||||||
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
|
|
||||||
page.tsx
|
|
||||||
</code>{" "}
|
|
||||||
file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert h-[14px] w-4"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={14}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { I18nProvider } from "@/lib/i18n";
|
||||||
|
|
||||||
|
export type ThemeMode = "system" | "light" | "dark";
|
||||||
|
|
||||||
|
export function applyTheme(mode: ThemeMode) {
|
||||||
|
const isDark =
|
||||||
|
mode === "dark" ||
|
||||||
|
(mode === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
document.documentElement.setAttribute("data-theme", isDark ? "dark" : "light");
|
||||||
|
localStorage.setItem("theme", mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
useEffect(() => {
|
||||||
|
// Apply saved theme
|
||||||
|
const saved = (localStorage.getItem("theme") as ThemeMode) || "system";
|
||||||
|
applyTheme(saved);
|
||||||
|
|
||||||
|
// Watch system preference changes (only affects "system" mode)
|
||||||
|
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const onMqChange = () => {
|
||||||
|
const current = (localStorage.getItem("theme") as ThemeMode) || "system";
|
||||||
|
if (current === "system") applyTheme("system");
|
||||||
|
};
|
||||||
|
mq.addEventListener("change", onMqChange);
|
||||||
|
|
||||||
|
// Service Worker
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
navigator.serviceWorker.register("/sw.js").catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => mq.removeEventListener("change", onMqChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionProvider>
|
||||||
|
<I18nProvider>{children}</I18nProvider>
|
||||||
|
</SessionProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const res = await fetch("/api/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name, email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = "Registration failed.";
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
msg = data.error || msg;
|
||||||
|
} catch {
|
||||||
|
// non-JSON response (e.g. 500 HTML page)
|
||||||
|
}
|
||||||
|
setError(msg);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await signIn("credentials", { email, password, redirect: false });
|
||||||
|
router.push("/");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-page">
|
||||||
|
<div className="auth-card">
|
||||||
|
<div className="auth-logo">
|
||||||
|
<div className="auth-logo-icon">✓</div>
|
||||||
|
<span className="auth-logo-name">CheckFlow</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="auth-title">Create account</h1>
|
||||||
|
<p className="auth-subtitle">Start organizing your tasks today</p>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Display Name</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Your name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Email</label>
|
||||||
|
<input
|
||||||
|
id="reg-email"
|
||||||
|
type="email"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Password</label>
|
||||||
|
<input
|
||||||
|
id="reg-password"
|
||||||
|
type="password"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Min. 8 characters"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="form-error" style={{ marginBottom: "12px" }}>{error}</p>}
|
||||||
|
<button id="register-btn" type="submit" className="btn btn-primary w-full" disabled={loading} style={{ height: "44px" }}>
|
||||||
|
{loading ? "Creating account..." : "Create account"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="auth-footer">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link href="/login" className="auth-link">Sign in</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { Sidebar } from "./Sidebar";
|
||||||
|
import { TaskList } from "../tasks/TaskList";
|
||||||
|
import { TaskDetail } from "../tasks/TaskDetail";
|
||||||
|
|
||||||
|
interface User { id: string; name?: string | null; email?: string | null; }
|
||||||
|
interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } }
|
||||||
|
interface Task {
|
||||||
|
id: string; listId: string; parentId: string | null; title: string; note: string | null;
|
||||||
|
completed: boolean; completedAt: string | null; dueDate: string | null; priority: number;
|
||||||
|
sortOrder: number; createdAt: string; updatedAt: string;
|
||||||
|
children: Task[]; tags: { tag: { id: string; name: string; color: string } }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppShell({ user }: { user: User }) {
|
||||||
|
const [lists, setLists] = useState<List[]>([]);
|
||||||
|
const [selectedListId, setSelectedListId] = useState<string | null>(null);
|
||||||
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [showCompleted, setShowCompleted] = useState(false);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
|
||||||
|
|
||||||
|
const handleTaskSelect = useCallback((task: Task | null) => {
|
||||||
|
setSelectedTask(task);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTaskUpdate = useCallback((updated: Task) => {
|
||||||
|
setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
|
||||||
|
setSelectedTask(updated);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleListSelect = useCallback((id: string) => {
|
||||||
|
setSelectedListId(id);
|
||||||
|
setSelectedTask(null);
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-layout">
|
||||||
|
{/* Mobile overlay */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="modal-overlay"
|
||||||
|
style={{ zIndex: 25 }}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Sidebar
|
||||||
|
user={user}
|
||||||
|
lists={lists}
|
||||||
|
setLists={setLists}
|
||||||
|
selectedListId={selectedListId}
|
||||||
|
onListSelect={handleListSelect}
|
||||||
|
mobileOpen={sidebarOpen}
|
||||||
|
onClose={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="main-content">
|
||||||
|
<TaskList
|
||||||
|
key={`${selectedListId}-${refreshKey}`}
|
||||||
|
user={user}
|
||||||
|
listId={selectedListId}
|
||||||
|
lists={lists}
|
||||||
|
tasks={tasks}
|
||||||
|
setTasks={setTasks}
|
||||||
|
selectedTaskId={selectedTask?.id ?? null}
|
||||||
|
onTaskSelect={handleTaskSelect}
|
||||||
|
showCompleted={showCompleted}
|
||||||
|
onToggleCompleted={() => setShowCompleted((p) => !p)}
|
||||||
|
onMenuOpen={() => setSidebarOpen(true)}
|
||||||
|
onRefresh={refresh}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedTask && (
|
||||||
|
<TaskDetail
|
||||||
|
task={selectedTask}
|
||||||
|
onClose={() => setSelectedTask(null)}
|
||||||
|
onUpdate={handleTaskUpdate}
|
||||||
|
onDelete={() => { setSelectedTask(null); refresh(); }}
|
||||||
|
listId={selectedTask.listId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile FAB */}
|
||||||
|
<button
|
||||||
|
className="fab"
|
||||||
|
id="mobile-add-task"
|
||||||
|
aria-label="Add task"
|
||||||
|
onClick={() => {
|
||||||
|
// trigger add task from task list — use custom event
|
||||||
|
document.dispatchEvent(new CustomEvent("checkflow:addTask"));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { signOut } from "next-auth/react";
|
||||||
|
|
||||||
|
interface User { id: string; name?: string | null; email?: string | null }
|
||||||
|
interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } }
|
||||||
|
|
||||||
|
const LIST_COLORS = ["#4B7BF5","#EF4444","#10B981","#F59E0B","#8B5CF6","#EC4899","#06B6D4","#F97316","#6366F1","#14B8A6"];
|
||||||
|
const LIST_ICONS: Record<string, string> = { list:"≡", inbox:"⌂", star:"★", work:"💼", personal:"👤", shopping:"🛒", health:"❤️", study:"📚" };
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
user: User; lists: List[]; setLists: (l: List[]) => void;
|
||||||
|
selectedListId: string | null; onListSelect: (id: string) => void;
|
||||||
|
mobileOpen: boolean; onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ user, lists, setLists, selectedListId, onListSelect, mobileOpen, onClose }: SidebarProps) {
|
||||||
|
const [showNewList, setShowNewList] = useState(false);
|
||||||
|
const [newListName, setNewListName] = useState("");
|
||||||
|
const [newListColor, setNewListColor] = useState(LIST_COLORS[0]);
|
||||||
|
const [editList, setEditList] = useState<List | null>(null);
|
||||||
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
|
const [showImport, setShowImport] = useState(false);
|
||||||
|
const [importListId, setImportListId] = useState("");
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [importResult, setImportResult] = useState("");
|
||||||
|
const [theme, setTheme] = useState("light");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = localStorage.getItem("theme") || "light";
|
||||||
|
setTheme(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/lists")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
setLists(data);
|
||||||
|
if (!selectedListId && data.length > 0) onListSelect(data[0].id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showNewList) setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
|
}, [showNewList]);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const next = theme === "light" ? "dark" : "light";
|
||||||
|
setTheme(next);
|
||||||
|
localStorage.setItem("theme", next);
|
||||||
|
document.documentElement.setAttribute("data-theme", next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createList = async () => {
|
||||||
|
const name = newListName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const res = await fetch("/api/lists", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name, color: newListColor }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const list = await res.json();
|
||||||
|
setLists([...lists, list]);
|
||||||
|
setNewListName("");
|
||||||
|
setShowNewList(false);
|
||||||
|
onListSelect(list.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteList = async (id: string) => {
|
||||||
|
if (!confirm("Delete this list and all its tasks?")) return;
|
||||||
|
await fetch(`/api/lists/${id}`, { method: "DELETE" });
|
||||||
|
const updated = lists.filter((l) => l.id !== id);
|
||||||
|
setLists(updated);
|
||||||
|
if (selectedListId === id) onListSelect(updated[0]?.id || "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
const file = fileRef.current?.files?.[0];
|
||||||
|
if (!file || !importListId) return;
|
||||||
|
setImporting(true);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("listId", importListId);
|
||||||
|
const res = await fetch("/api/import", { method: "POST", body: formData });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok) {
|
||||||
|
setImportResult(`✓ Imported ${data.imported} tasks`);
|
||||||
|
// Reset file input
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
// Refresh list counts
|
||||||
|
const listsRes = await fetch("/api/lists");
|
||||||
|
if (listsRes.ok) { const updated = await listsRes.json(); if (Array.isArray(updated)) setLists(updated); }
|
||||||
|
setTimeout(() => { setShowImport(false); setImportResult(""); }, 2000);
|
||||||
|
} else {
|
||||||
|
setImportResult(`Error: ${data.error || "Import failed"}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Sidebar] import failed", err);
|
||||||
|
setImportResult("Error: Network error");
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initials = user.name?.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2) || "?";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className={`sidebar${mobileOpen ? " mobile-open" : ""}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sidebar-header">
|
||||||
|
<div className="sidebar-logo">✓</div>
|
||||||
|
<span className="sidebar-title">CheckFlow</span>
|
||||||
|
<button className="icon-btn" id="theme-toggle" onClick={toggleTheme} title="Toggle theme" style={{ marginLeft: "auto" }}>
|
||||||
|
{theme === "light" ? (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||||
|
) : (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav */}
|
||||||
|
<nav className="sidebar-nav">
|
||||||
|
<div className="sidebar-section">
|
||||||
|
<div className="sidebar-section-label">Lists</div>
|
||||||
|
{lists.map((list) => (
|
||||||
|
<div
|
||||||
|
key={list.id}
|
||||||
|
className={`sidebar-item${selectedListId === list.id ? " active" : ""}`}
|
||||||
|
onClick={() => onListSelect(list.id)}
|
||||||
|
id={`list-item-${list.id}`}
|
||||||
|
>
|
||||||
|
<div className="list-dot" style={{ background: list.color }} />
|
||||||
|
<span className="item-label">{list.name}</span>
|
||||||
|
<span className="item-count">{list._count?.tasks || ""}</span>
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
style={{ width: 22, height: 22, opacity: 0.5 }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); deleteList(list.id); }}
|
||||||
|
title="Delete list"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New list */}
|
||||||
|
{showNewList ? (
|
||||||
|
<div style={{ padding: "4px 8px" }}>
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, padding: "6px 4px 8px" }}>
|
||||||
|
{LIST_COLORS.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c}
|
||||||
|
className={`color-swatch${newListColor === c ? " selected" : ""}`}
|
||||||
|
style={{ background: c, color: c }}
|
||||||
|
onClick={() => setNewListColor(c)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="form-input"
|
||||||
|
placeholder="List name"
|
||||||
|
value={newListName}
|
||||||
|
onChange={(e) => setNewListName(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") createList(); if (e.key === "Escape") setShowNewList(false); }}
|
||||||
|
style={{ marginBottom: 6 }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: "flex", gap: 6 }}>
|
||||||
|
<button className="btn btn-primary btn-sm" style={{ flex: 1 }} onClick={createList}>Create</button>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => setShowNewList(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button id="new-list-btn" className="sidebar-add-btn" onClick={() => setShowNewList(true)}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
New List
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="sidebar-section" style={{ borderTop: "1px solid var(--border)", paddingTop: 12, marginTop: 4 }}>
|
||||||
|
<button id="import-btn" className="sidebar-add-btn" onClick={() => { setShowImport(true); setImportListId(lists[0]?.id || ""); }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||||
|
Import Tasks
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User footer */}
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<div className="user-card" id="user-menu-btn" onClick={() => setUserMenuOpen((p) => !p)} style={{ position: "relative" }}>
|
||||||
|
<div className="user-avatar" style={{ background: "#4B7BF5" }}>{initials}</div>
|
||||||
|
<div className="user-info">
|
||||||
|
<div className="user-name">{user.name}</div>
|
||||||
|
<div className="user-email">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
{userMenuOpen && (
|
||||||
|
<div className="dropdown" style={{ bottom: "100%", left: 0, right: 0, marginBottom: 4 }}>
|
||||||
|
<div className="dropdown-item danger" id="signout-btn" onClick={() => signOut({ callbackUrl: "/login" })}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||||
|
Sign out
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Import modal */}
|
||||||
|
{showImport && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowImport(false)}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2 className="modal-title">Import Tasks</h2>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Target List</label>
|
||||||
|
<select className="form-input" value={importListId} onChange={(e) => setImportListId(e.target.value)}>
|
||||||
|
{lists.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">File (CSV or ICS from TickTick)</label>
|
||||||
|
<input ref={fileRef} type="file" accept=".csv,.ics" className="form-input" />
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginBottom: 12 }}>
|
||||||
|
TickTick: Settings → Export → Export as CSV or iCalendar
|
||||||
|
</p>
|
||||||
|
{importResult && <p style={{ fontSize: 13, color: importResult.startsWith("✓") ? "var(--success)" : "var(--danger)", marginBottom: 12 }}>{importResult}</p>}
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn btn-ghost" onClick={() => setShowImport(false)}>Cancel</button>
|
||||||
|
<button id="import-submit-btn" className="btn btn-primary" onClick={handleImport} disabled={importing}>
|
||||||
|
{importing ? "Importing..." : "Import"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
|
||||||
|
interface Task {
|
||||||
|
id: string; listId: string; parentId: string | null; title: string; note: string | null;
|
||||||
|
completed: boolean; completedAt: string | null; dueDate: string | null; priority: number;
|
||||||
|
sortOrder: number; createdAt: string; updatedAt: string;
|
||||||
|
children: Task[]; tags: { tag: { id: string; name: string; color: string } }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIORITY_MAP = [
|
||||||
|
{ label: "None", color: "var(--text-tertiary)", icon: "" },
|
||||||
|
{ label: "Low", color: "var(--priority-low)", icon: "▼" },
|
||||||
|
{ label: "Medium", color: "var(--priority-medium)", icon: "▶" },
|
||||||
|
{ label: "High", color: "var(--priority-high)", icon: "▲" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
task: Task; listId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onUpdate: (t: Task) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props) {
|
||||||
|
const [title, setTitle] = useState(task.title);
|
||||||
|
const [note, setNote] = useState(task.note || "");
|
||||||
|
const [dueDate, setDueDate] = useState(task.dueDate ? task.dueDate.split("T")[0] : "");
|
||||||
|
const [priority, setPriority] = useState(task.priority);
|
||||||
|
const [newSubtitle, setNewSubtitle] = useState("");
|
||||||
|
const [subtasks, setSubtasks] = useState(task.children || []);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Store timer + current task id in refs to prevent stale saves across task switches
|
||||||
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const currentTaskId = useRef(task.id);
|
||||||
|
const noteRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// Sync state when switching to a different task — clear any pending timer first
|
||||||
|
useEffect(() => {
|
||||||
|
if (saveTimer.current) {
|
||||||
|
clearTimeout(saveTimer.current);
|
||||||
|
saveTimer.current = null;
|
||||||
|
}
|
||||||
|
currentTaskId.current = task.id;
|
||||||
|
setTitle(task.title);
|
||||||
|
setNote(task.note || "");
|
||||||
|
setDueDate(task.dueDate ? task.dueDate.split("T")[0] : "");
|
||||||
|
setPriority(task.priority);
|
||||||
|
setSubtasks(task.children || []);
|
||||||
|
}, [task.id]);
|
||||||
|
|
||||||
|
// Cleanup timer on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const save = useCallback(async (taskId: string, data: Record<string, unknown>) => {
|
||||||
|
// Guard: don't save if task has changed since debounce was scheduled
|
||||||
|
if (taskId !== currentTaskId.current) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/tasks/${taskId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const updated = await res.json();
|
||||||
|
// Only update if we're still on the same task
|
||||||
|
if (taskId === currentTaskId.current) {
|
||||||
|
onUpdate({ ...updated, children: subtasks });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[TaskDetail] save failed", err);
|
||||||
|
} finally {
|
||||||
|
if (taskId === currentTaskId.current) setSaving(false);
|
||||||
|
}
|
||||||
|
}, [onUpdate, subtasks]);
|
||||||
|
|
||||||
|
const debounceSave = useCallback((overrides: Record<string, unknown>) => {
|
||||||
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||||
|
const taskId = currentTaskId.current;
|
||||||
|
saveTimer.current = setTimeout(() => save(taskId, overrides), 800);
|
||||||
|
}, [save]);
|
||||||
|
|
||||||
|
// Insert text at cursor position in the note textarea
|
||||||
|
const insertAtCursor = useCallback((before: string, after = "", placeholder = "") => {
|
||||||
|
const ta = noteRef.current;
|
||||||
|
if (!ta) {
|
||||||
|
setNote((n) => { const v = n + before + placeholder + after; debounceSave({ note: v }); return v; });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = ta.selectionStart ?? ta.value.length;
|
||||||
|
const end = ta.selectionEnd ?? ta.value.length;
|
||||||
|
const selected = ta.value.slice(start, end) || placeholder;
|
||||||
|
const newVal = ta.value.slice(0, start) + before + selected + after + ta.value.slice(end);
|
||||||
|
setNote(newVal);
|
||||||
|
debounceSave({ note: newVal });
|
||||||
|
// Restore cursor after React re-render
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
ta.focus();
|
||||||
|
ta.selectionStart = start + before.length;
|
||||||
|
ta.selectionEnd = start + before.length + selected.length;
|
||||||
|
});
|
||||||
|
}, [debounceSave]);
|
||||||
|
|
||||||
|
const handleToggleCompleted = useCallback(async (newCompleted: boolean) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/tasks/${task.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ completed: newCompleted }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const updated = await res.json();
|
||||||
|
onUpdate({ ...updated, children: subtasks });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[TaskDetail] toggle failed", err);
|
||||||
|
}
|
||||||
|
}, [task.id, subtasks, onUpdate]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!confirm("Delete this task?")) return;
|
||||||
|
try {
|
||||||
|
await fetch(`/api/tasks/${task.id}`, { method: "DELETE" });
|
||||||
|
onDelete();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[TaskDetail] delete failed", err);
|
||||||
|
}
|
||||||
|
}, [task.id, onDelete]);
|
||||||
|
|
||||||
|
const addSubtask = useCallback(async () => {
|
||||||
|
const t = newSubtitle.trim();
|
||||||
|
if (!t) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/tasks", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: t, listId, parentId: task.id }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const sub = await res.json();
|
||||||
|
setSubtasks((prev) => {
|
||||||
|
const next = [...prev, sub];
|
||||||
|
onUpdate({ ...task, children: next });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setNewSubtitle("");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[TaskDetail] addSubtask failed", err);
|
||||||
|
}
|
||||||
|
}, [newSubtitle, listId, task, onUpdate]);
|
||||||
|
|
||||||
|
const toggleSubtask = useCallback(async (sub: Task) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/tasks/${sub.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ completed: !sub.completed }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const updated = await res.json();
|
||||||
|
setSubtasks((prev) => {
|
||||||
|
const next = prev.map((s) => (s.id === sub.id ? updated : s));
|
||||||
|
onUpdate({ ...task, children: next });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[TaskDetail] toggleSubtask failed", err);
|
||||||
|
}
|
||||||
|
}, [task, onUpdate]);
|
||||||
|
|
||||||
|
const deleteSubtask = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/tasks/${id}`, { method: "DELETE" });
|
||||||
|
setSubtasks((prev) => {
|
||||||
|
const next = prev.filter((s) => s.id !== id);
|
||||||
|
onUpdate({ ...task, children: next });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[TaskDetail] deleteSubtask failed", err);
|
||||||
|
}
|
||||||
|
}, [task, onUpdate]);
|
||||||
|
|
||||||
|
const completedCount = subtasks.filter((s) => s.completed).length;
|
||||||
|
const progressPct = subtasks.length > 0 ? Math.round((completedCount / subtasks.length) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="detail-panel">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="detail-header">
|
||||||
|
<button
|
||||||
|
className={`task-check-btn${task.completed ? " checked" : ""}`}
|
||||||
|
style={{ width: 22, height: 22 }}
|
||||||
|
onClick={() => handleToggleCompleted(!task.completed)}
|
||||||
|
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
|
||||||
|
/>
|
||||||
|
<span style={{ flex: 1, fontSize: 12, color: "var(--text-tertiary)", fontWeight: 500 }}>
|
||||||
|
{saving ? "Saving…" : "Auto-saved"}
|
||||||
|
</span>
|
||||||
|
<button className="btn btn-ghost btn-sm btn-danger" id="delete-task-btn" onClick={handleDelete} title="Delete task">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
|
||||||
|
</button>
|
||||||
|
<button className="detail-close-btn" id="detail-close-btn" onClick={onClose} title="Close">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="detail-body">
|
||||||
|
{/* Title */}
|
||||||
|
<textarea
|
||||||
|
id="detail-title"
|
||||||
|
className="detail-title-input"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTitle(e.target.value);
|
||||||
|
debounceSave({ title: e.target.value });
|
||||||
|
}}
|
||||||
|
placeholder="Task title"
|
||||||
|
rows={2}
|
||||||
|
style={{
|
||||||
|
textDecoration: task.completed ? "line-through" : "none",
|
||||||
|
color: task.completed ? "var(--text-tertiary)" : "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Priority */}
|
||||||
|
<div className="detail-section">
|
||||||
|
<div className="detail-section-label">Priority</div>
|
||||||
|
<div className="priority-selector">
|
||||||
|
{PRIORITY_MAP.map((p, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
id={`priority-${i}`}
|
||||||
|
className={`priority-btn${priority === i ? ` active-${p.label.toLowerCase()}` : ""}`}
|
||||||
|
style={{ color: priority === i ? p.color : undefined }}
|
||||||
|
onClick={() => { setPriority(i); debounceSave({ priority: i }); }}
|
||||||
|
>
|
||||||
|
{p.icon && <span style={{ color: p.color }}>{p.icon}</span>}
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Due date */}
|
||||||
|
<div className="detail-section">
|
||||||
|
<div className="detail-section-label">Due Date</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
|
<input
|
||||||
|
id="due-date-input"
|
||||||
|
type="date"
|
||||||
|
className="form-input"
|
||||||
|
value={dueDate}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDueDate(e.target.value);
|
||||||
|
debounceSave({ dueDate: e.target.value || null });
|
||||||
|
}}
|
||||||
|
style={{ width: "auto" }}
|
||||||
|
/>
|
||||||
|
{dueDate && (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => { setDueDate(""); debounceSave({ dueDate: null }); }}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Note / Memo */}
|
||||||
|
<div className="detail-section" style={{ flex: 1 }}>
|
||||||
|
<div className="detail-section-label">Notes</div>
|
||||||
|
<div className="note-editor-wrap">
|
||||||
|
<div className="note-editor-toolbar">
|
||||||
|
<button className="note-toolbar-btn" title="Bold (Ctrl+B)" onClick={() => insertAtCursor("**", "**", "bold")}>B</button>
|
||||||
|
<button className="note-toolbar-btn" title="Italic (Ctrl+I)" style={{ fontStyle: "italic" }} onClick={() => insertAtCursor("*", "*", "italic")}>I</button>
|
||||||
|
<button className="note-toolbar-btn" title="Heading" onClick={() => insertAtCursor("## ", "", "Heading")}>H</button>
|
||||||
|
<button className="note-toolbar-btn" title="Bullet list" onClick={() => insertAtCursor("\n- ", "", "item")}>•</button>
|
||||||
|
<button className="note-toolbar-btn" title="Numbered list" onClick={() => insertAtCursor("\n1. ", "", "item")}>1.</button>
|
||||||
|
<button className="note-toolbar-btn" title="Checkbox" onClick={() => insertAtCursor("\n- [ ] ", "", "task")}>☐</button>
|
||||||
|
<button className="note-toolbar-btn" title="Code" style={{ fontFamily: "monospace", fontSize: 11 }} onClick={() => insertAtCursor("`", "`", "code")}>{"`"}</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
ref={noteRef}
|
||||||
|
id="note-textarea"
|
||||||
|
className="note-textarea"
|
||||||
|
placeholder={"Add notes, details, or anything you need to remember…\n\nMarkdown: **bold**, *italic*, # heading, - list, - [ ] checkbox"}
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNote(e.target.value);
|
||||||
|
debounceSave({ note: e.target.value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sub-tasks */}
|
||||||
|
<div className="detail-section">
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
|
||||||
|
<div className="detail-section-label">Sub-tasks</div>
|
||||||
|
{subtasks.length > 0 && (
|
||||||
|
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{completedCount}/{subtasks.length}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subtasks.length > 0 && (
|
||||||
|
<div className="progress-bar" style={{ marginBottom: 10 }}>
|
||||||
|
<div className="progress-bar-fill" style={{ width: `${progressPct}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="detail-subtasks">
|
||||||
|
{subtasks.map((sub) => (
|
||||||
|
<div
|
||||||
|
key={sub.id}
|
||||||
|
className={`detail-subtask-row${sub.completed ? " completed" : ""}`}
|
||||||
|
id={`detail-sub-${sub.id}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className={`subtask-check-btn${sub.completed ? " checked" : ""}`}
|
||||||
|
onClick={() => toggleSubtask(sub)}
|
||||||
|
aria-label="Toggle subtask"
|
||||||
|
/>
|
||||||
|
<span className="detail-subtask-title">{sub.title}</span>
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
style={{ width: 20, height: 20, opacity: 0.4 }}
|
||||||
|
onClick={() => deleteSubtask(sub.id)}
|
||||||
|
aria-label="Delete subtask"
|
||||||
|
>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="add-subtask-row">
|
||||||
|
<span style={{ fontSize: 16, lineHeight: 1, color: "var(--accent)" }}>+</span>
|
||||||
|
<input
|
||||||
|
id="add-subtask-input"
|
||||||
|
placeholder="Add sub-task…"
|
||||||
|
style={{ flex: 1, background: "none", fontSize: 13, color: "var(--text-primary)" }}
|
||||||
|
value={newSubtitle}
|
||||||
|
onChange={(e) => setNewSubtitle(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") { e.preventDefault(); addSubtask(); }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{newSubtitle.trim() && (
|
||||||
|
<button className="btn btn-primary btn-sm" id="add-subtask-btn" onClick={addSubtask}>Add</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div style={{ fontSize: 11, color: "var(--text-tertiary)", paddingTop: 8, borderTop: "1px solid var(--border)" }}>
|
||||||
|
Created {new Date(task.createdAt).toLocaleDateString("ko-KR", { year: "numeric", month: "short", day: "numeric" })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
interface Task {
|
||||||
|
id: string; listId: string; parentId: string | null; title: string; note: string | null;
|
||||||
|
completed: boolean; completedAt: string | null; dueDate: string | null; priority: number;
|
||||||
|
sortOrder: number; createdAt: string; updatedAt: string;
|
||||||
|
children: Task[]; tags: { tag: { id: string; name: string; color: string } }[];
|
||||||
|
}
|
||||||
|
interface List { id: string; name: string; color: string; icon: string }
|
||||||
|
interface User { id: string; name?: string | null; email?: string | null }
|
||||||
|
|
||||||
|
const PRIORITY_COLORS = ["transparent", "var(--priority-low)", "var(--priority-medium)", "var(--priority-high)"];
|
||||||
|
const PRIORITY_LABELS = ["", "Low", "Medium", "High"];
|
||||||
|
|
||||||
|
function formatDate(d: string | null) {
|
||||||
|
if (!d) return null;
|
||||||
|
const date = new Date(d);
|
||||||
|
const now = new Date();
|
||||||
|
const isToday = date.toDateString() === now.toDateString();
|
||||||
|
const isTomorrow = date.toDateString() === new Date(now.getTime() + 86400000).toDateString();
|
||||||
|
if (isToday) return "Today";
|
||||||
|
if (isTomorrow) return "Tomorrow";
|
||||||
|
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOverdue(d: string | null) {
|
||||||
|
if (!d) return false;
|
||||||
|
return new Date(d) < new Date() && new Date(d).toDateString() !== new Date().toDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskItemProps {
|
||||||
|
task: Task; isSelected: boolean;
|
||||||
|
onSelect: (t: Task) => void;
|
||||||
|
onToggle: (id: string, completed: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const completedChildren = task.children.filter((c) => c.completed).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}`}
|
||||||
|
onClick={() => onSelect(task)}
|
||||||
|
id={`task-${task.id}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className={`task-check-btn${task.completed ? " checked" : ""}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onToggle(task.id, !task.completed); }}
|
||||||
|
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
|
||||||
|
/>
|
||||||
|
<div className="task-body">
|
||||||
|
<div className="task-title">{task.title}</div>
|
||||||
|
<div className="task-meta">
|
||||||
|
{task.priority > 0 && (
|
||||||
|
<div className="task-priority-dot" style={{ background: PRIORITY_COLORS[task.priority] }} title={PRIORITY_LABELS[task.priority]} />
|
||||||
|
)}
|
||||||
|
{task.dueDate && (
|
||||||
|
<span className={`task-due${isOverdue(task.dueDate) && !task.completed ? " overdue" : ""}`}>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||||
|
{formatDate(task.dueDate)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{task.children.length > 0 && (
|
||||||
|
<span
|
||||||
|
className="task-sub-count"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setExpanded((p) => !p); }}
|
||||||
|
>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
{completedChildren}/{task.children.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{task.note && !task.completed && (
|
||||||
|
<div className="task-note-preview">{task.note.replace(/[#*`]/g, "").slice(0, 80)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sub-tasks */}
|
||||||
|
{task.children.length > 0 && expanded && (
|
||||||
|
<div className="subtask-list">
|
||||||
|
{task.children.map((child) => (
|
||||||
|
<div
|
||||||
|
key={child.id}
|
||||||
|
className={`subtask-item${child.completed ? " completed" : ""}`}
|
||||||
|
onClick={() => onSelect(child)}
|
||||||
|
id={`subtask-${child.id}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className={`subtask-check-btn${child.completed ? " checked" : ""}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onToggle(child.id, !child.completed); }}
|
||||||
|
aria-label={child.completed ? "Mark incomplete" : "Mark complete"}
|
||||||
|
/>
|
||||||
|
<span className="subtask-title">{child.title}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User; listId: string | null; lists: List[]; tasks: Task[];
|
||||||
|
setTasks: React.Dispatch<React.SetStateAction<Task[]>>;
|
||||||
|
selectedTaskId: string | null; onTaskSelect: (t: Task | null) => void;
|
||||||
|
showCompleted: boolean; onToggleCompleted: () => void;
|
||||||
|
onMenuOpen: () => void; onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskList({ user, listId, lists, tasks, setTasks, selectedTaskId, onTaskSelect, showCompleted, onToggleCompleted, onMenuOpen, onRefresh }: Props) {
|
||||||
|
const [newTaskTitle, setNewTaskTitle] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const currentList = lists.find((l) => l.id === listId);
|
||||||
|
|
||||||
|
const fetchTasks = useCallback(async () => {
|
||||||
|
if (!listId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/tasks?listId=${listId}&showCompleted=${showCompleted}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
setTasks(Array.isArray(data) ? data : []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[TaskList] fetchTasks failed", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [listId, showCompleted]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchTasks(); }, [fetchTasks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => inputRef.current?.focus();
|
||||||
|
document.addEventListener("checkflow:addTask", handler);
|
||||||
|
return () => document.removeEventListener("checkflow:addTask", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggle = useCallback(async (id: string, completed: boolean) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/tasks/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ completed }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const updated = await res.json();
|
||||||
|
// Functional update avoids stale closure on `tasks`
|
||||||
|
setTasks((prev) =>
|
||||||
|
prev.map((t) => {
|
||||||
|
if (t.id === id) return { ...t, completed, completedAt: updated.completedAt };
|
||||||
|
return { ...t, children: t.children.map((c) => (c.id === id ? { ...c, completed } : c)) };
|
||||||
|
}).filter((t) => showCompleted || !t.completed)
|
||||||
|
);
|
||||||
|
onRefresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[TaskList] handleToggle failed", err);
|
||||||
|
}
|
||||||
|
}, [showCompleted, onRefresh]);
|
||||||
|
|
||||||
|
const handleAddTask = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const title = newTaskTitle.trim();
|
||||||
|
if (!title || !listId) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/tasks", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title, listId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const task = await res.json();
|
||||||
|
setTasks((prev) => [...prev, task]);
|
||||||
|
setNewTaskTitle("");
|
||||||
|
onRefresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[TaskList] handleAddTask failed", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!listId) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--text-tertiary)" }}>
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ opacity: 0.3, marginBottom: 12 }}><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||||
|
<p>Select a list to get started</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const incompleteTasks = tasks.filter((t) => !t.completed);
|
||||||
|
const completedTasks = tasks.filter((t) => t.completed);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="main-header">
|
||||||
|
<button className="icon-btn mobile-only" id="menu-btn" onClick={onMenuOpen} aria-label="Menu">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
<div className="main-header-title" style={{ color: currentList?.color }}>
|
||||||
|
{currentList?.name || "Tasks"}
|
||||||
|
</div>
|
||||||
|
<div className="main-header-actions">
|
||||||
|
<button
|
||||||
|
id="toggle-completed-btn"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={onToggleCompleted}
|
||||||
|
title={showCompleted ? "Hide completed" : "Show completed"}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
|
{showCompleted ? "Hide done" : "Show done"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task list */}
|
||||||
|
<div className="task-list-container">
|
||||||
|
{loading && (
|
||||||
|
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>Loading...</div>
|
||||||
|
)}
|
||||||
|
{!loading && incompleteTasks.length === 0 && completedTasks.length === 0 && (
|
||||||
|
<div className="task-list-empty">
|
||||||
|
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||||
|
<p>No tasks yet. Add one below!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{incompleteTasks.map((task) => (
|
||||||
|
<TaskItem key={task.id} task={task} isSelected={selectedTaskId === task.id} onSelect={onTaskSelect} onToggle={handleToggle} />
|
||||||
|
))}
|
||||||
|
{showCompleted && completedTasks.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div style={{ padding: "12px 20px 4px", fontSize: 11, fontWeight: 600, color: "var(--text-tertiary)", textTransform: "uppercase", letterSpacing: "0.5px" }}>
|
||||||
|
Completed ({completedTasks.length})
|
||||||
|
</div>
|
||||||
|
{completedTasks.map((task) => (
|
||||||
|
<TaskItem key={task.id} task={task} isSelected={selectedTaskId === task.id} onSelect={onTaskSelect} onToggle={handleToggle} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add task */}
|
||||||
|
<div className="add-task-bar">
|
||||||
|
<button
|
||||||
|
className="task-check-btn"
|
||||||
|
style={{ opacity: 0.4, flexShrink: 0 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<form onSubmit={handleAddTask} style={{ flex: 1, display: "flex", gap: 8 }}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
id="add-task-input"
|
||||||
|
className="add-task-input"
|
||||||
|
placeholder="Add a task..."
|
||||||
|
value={newTaskTitle}
|
||||||
|
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
{newTaskTitle.trim() && (
|
||||||
|
<button type="submit" className="btn btn-primary btn-sm" id="add-task-btn">Add</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextAuthOptions } from "next-auth";
|
||||||
|
import CredentialsProvider from "next-auth/providers/credentials";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
export const authOptions: NextAuthOptions = {
|
||||||
|
session: { strategy: "jwt" },
|
||||||
|
pages: {
|
||||||
|
signIn: "/login",
|
||||||
|
newUser: "/register",
|
||||||
|
},
|
||||||
|
providers: [
|
||||||
|
CredentialsProvider({
|
||||||
|
name: "credentials",
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "email" },
|
||||||
|
password: { label: "Password", type: "password" },
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
if (!credentials?.email || !credentials?.password) return null;
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: credentials.email },
|
||||||
|
});
|
||||||
|
if (!user) return null;
|
||||||
|
const valid = await bcrypt.compare(credentials.password, user.passwordHash);
|
||||||
|
if (!valid) return null;
|
||||||
|
return { id: user.id, email: user.email, name: user.name };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
jwt({ token, user }) {
|
||||||
|
if (user) token.id = user.id;
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
session({ session, token }) {
|
||||||
|
if (session.user) session.user.id = token.id as string;
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export type Locale = "en" | "ko" | "ja";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// TRANSLATIONS
|
||||||
|
// ============================================================
|
||||||
|
const translations = {
|
||||||
|
en: {
|
||||||
|
auth: {
|
||||||
|
welcome: "Welcome back",
|
||||||
|
signIn: "Sign in",
|
||||||
|
signInSub: "Sign in to your account",
|
||||||
|
createAccount: "Create account",
|
||||||
|
createAccountSub: "Start organizing your tasks today",
|
||||||
|
email: "Email",
|
||||||
|
password: "Password",
|
||||||
|
passwordHint: "Min. 8 characters",
|
||||||
|
displayName: "Display Name",
|
||||||
|
namePlaceholder: "Your name",
|
||||||
|
noAccount: "Don't have an account?",
|
||||||
|
hasAccount: "Already have an account?",
|
||||||
|
createOne: "Create one",
|
||||||
|
signInLink: "Sign in",
|
||||||
|
signingIn: "Signing in...",
|
||||||
|
creating: "Creating account...",
|
||||||
|
invalidCredentials: "Invalid email or password.",
|
||||||
|
registrationFailed: "Registration failed.",
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
lists: "Lists",
|
||||||
|
newList: "New List",
|
||||||
|
create: "Create",
|
||||||
|
cancel: "Cancel",
|
||||||
|
listName: "List name",
|
||||||
|
importTasks: "Import Tasks",
|
||||||
|
signOut: "Sign out",
|
||||||
|
deleteListConfirm: "Delete this list and all its tasks?",
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
addTask: "Add a task...",
|
||||||
|
add: "Add",
|
||||||
|
showDone: "Show done",
|
||||||
|
hideDone: "Hide done",
|
||||||
|
noTasks: "No tasks yet. Add one below!",
|
||||||
|
selectList: "Select a list to get started",
|
||||||
|
completed: "Completed",
|
||||||
|
loading: "Loading...",
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
priority: "Priority",
|
||||||
|
dueDate: "Due Date",
|
||||||
|
notes: "Notes",
|
||||||
|
notesPlaceholder: "Add notes, details, or anything you need to remember\u2026\n\nMarkdown: **bold**, *italic*, # heading, - list, - [ ] checkbox",
|
||||||
|
subtasks: "Sub-tasks",
|
||||||
|
addSubtask: "Add sub-task\u2026",
|
||||||
|
autoSaved: "Auto-saved",
|
||||||
|
saving: "Saving\u2026",
|
||||||
|
delete: "Delete task",
|
||||||
|
deleteConfirm: "Delete this task?",
|
||||||
|
close: "Close",
|
||||||
|
clear: "Clear",
|
||||||
|
created: "Created",
|
||||||
|
},
|
||||||
|
priority: { none: "None", low: "Low", medium: "Medium", high: "High" },
|
||||||
|
settings: {
|
||||||
|
theme: "Theme",
|
||||||
|
language: "Language",
|
||||||
|
system: "System",
|
||||||
|
light: "Light",
|
||||||
|
dark: "Dark",
|
||||||
|
},
|
||||||
|
import: {
|
||||||
|
title: "Import Tasks",
|
||||||
|
targetList: "Target List",
|
||||||
|
file: "File (CSV or ICS)",
|
||||||
|
tip: "TickTick: Settings \u2192 Export \u2192 Export as CSV or iCalendar",
|
||||||
|
cancel: "Cancel",
|
||||||
|
import: "Import",
|
||||||
|
importing: "Importing...",
|
||||||
|
error: "Error",
|
||||||
|
},
|
||||||
|
list: { create: "Create list", color: "Color" },
|
||||||
|
},
|
||||||
|
|
||||||
|
ko: {
|
||||||
|
auth: {
|
||||||
|
welcome: "다시 오셨군요",
|
||||||
|
signIn: "로그인",
|
||||||
|
signInSub: "계정에 로그인하세요",
|
||||||
|
createAccount: "계정 만들기",
|
||||||
|
createAccountSub: "오늘부터 할 일을 정리해보세요",
|
||||||
|
email: "이메일",
|
||||||
|
password: "비밀번호",
|
||||||
|
passwordHint: "최소 8자",
|
||||||
|
displayName: "이름",
|
||||||
|
namePlaceholder: "표시될 이름",
|
||||||
|
noAccount: "계정이 없으신가요?",
|
||||||
|
hasAccount: "이미 계정이 있으신가요?",
|
||||||
|
createOne: "만들기",
|
||||||
|
signInLink: "로그인",
|
||||||
|
signingIn: "로그인 중...",
|
||||||
|
creating: "계정 생성 중...",
|
||||||
|
invalidCredentials: "이메일 또는 비밀번호가 올바르지 않습니다.",
|
||||||
|
registrationFailed: "회원가입에 실패했습니다.",
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
lists: "목록",
|
||||||
|
newList: "새 목록",
|
||||||
|
create: "만들기",
|
||||||
|
cancel: "취소",
|
||||||
|
listName: "목록 이름",
|
||||||
|
importTasks: "가져오기",
|
||||||
|
signOut: "로그아웃",
|
||||||
|
deleteListConfirm: "이 목록과 모든 할 일을 삭제할까요?",
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
addTask: "할 일 추가...",
|
||||||
|
add: "추가",
|
||||||
|
showDone: "완료 표시",
|
||||||
|
hideDone: "완료 숨기기",
|
||||||
|
noTasks: "할 일이 없습니다. 아래에서 추가하세요!",
|
||||||
|
selectList: "목록을 선택하여 시작하세요",
|
||||||
|
completed: "완료됨",
|
||||||
|
loading: "불러오는 중...",
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
priority: "우선순위",
|
||||||
|
dueDate: "마감일",
|
||||||
|
notes: "메모",
|
||||||
|
notesPlaceholder: "메모, 세부 정보, 기억해야 할 내용을 추가하세요\u2026\n\nMarkdown: **굵게**, *기울임*, # 제목, - 목록, - [ ] 체크박스",
|
||||||
|
subtasks: "하위 할 일",
|
||||||
|
addSubtask: "하위 항목 추가\u2026",
|
||||||
|
autoSaved: "자동 저장됨",
|
||||||
|
saving: "저장 중\u2026",
|
||||||
|
delete: "삭제",
|
||||||
|
deleteConfirm: "이 할 일을 삭제할까요?",
|
||||||
|
close: "닫기",
|
||||||
|
clear: "초기화",
|
||||||
|
created: "생성일",
|
||||||
|
},
|
||||||
|
priority: { none: "없음", low: "낮음", medium: "보통", high: "높음" },
|
||||||
|
settings: {
|
||||||
|
theme: "테마",
|
||||||
|
language: "언어",
|
||||||
|
system: "시스템",
|
||||||
|
light: "라이트",
|
||||||
|
dark: "다크",
|
||||||
|
},
|
||||||
|
import: {
|
||||||
|
title: "할 일 가져오기",
|
||||||
|
targetList: "대상 목록",
|
||||||
|
file: "파일 (CSV 또는 ICS)",
|
||||||
|
tip: "TickTick: 설정 \u2192 내보내기 \u2192 CSV 또는 iCalendar로 내보내기",
|
||||||
|
cancel: "취소",
|
||||||
|
import: "가져오기",
|
||||||
|
importing: "가져오는 중...",
|
||||||
|
error: "오류",
|
||||||
|
},
|
||||||
|
list: { create: "목록 만들기", color: "색상" },
|
||||||
|
},
|
||||||
|
|
||||||
|
ja: {
|
||||||
|
auth: {
|
||||||
|
welcome: "おかえりなさい",
|
||||||
|
signIn: "サインイン",
|
||||||
|
signInSub: "アカウントにサインイン",
|
||||||
|
createAccount: "アカウント作成",
|
||||||
|
createAccountSub: "今日からタスクを整理しましょう",
|
||||||
|
email: "メールアドレス",
|
||||||
|
password: "パスワード",
|
||||||
|
passwordHint: "8文字以上",
|
||||||
|
displayName: "表示名",
|
||||||
|
namePlaceholder: "あなたの名前",
|
||||||
|
noAccount: "アカウントをお持ちでない方は",
|
||||||
|
hasAccount: "すでにアカウントをお持ちですか?",
|
||||||
|
createOne: "作成する",
|
||||||
|
signInLink: "サインイン",
|
||||||
|
signingIn: "サインイン中...",
|
||||||
|
creating: "アカウント作成中...",
|
||||||
|
invalidCredentials: "メールアドレスまたはパスワードが正しくありません。",
|
||||||
|
registrationFailed: "登録に失敗しました。",
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
lists: "リスト",
|
||||||
|
newList: "新しいリスト",
|
||||||
|
create: "作成",
|
||||||
|
cancel: "キャンセル",
|
||||||
|
listName: "リスト名",
|
||||||
|
importTasks: "インポート",
|
||||||
|
signOut: "サインアウト",
|
||||||
|
deleteListConfirm: "このリストとすべてのタスクを削除しますか?",
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
addTask: "タスクを追加...",
|
||||||
|
add: "追加",
|
||||||
|
showDone: "完了を表示",
|
||||||
|
hideDone: "完了を非表示",
|
||||||
|
noTasks: "タスクがありません。下から追加してください!",
|
||||||
|
selectList: "リストを選択して始めましょう",
|
||||||
|
completed: "完了済み",
|
||||||
|
loading: "読み込み中...",
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
priority: "優先度",
|
||||||
|
dueDate: "期限",
|
||||||
|
notes: "メモ",
|
||||||
|
notesPlaceholder: "メモ、詳細、覚えておくべきことを追加\u2026\n\nMarkdown: **太字**, *斜体*, # 見出し, - リスト, - [ ] チェックボックス",
|
||||||
|
subtasks: "サブタスク",
|
||||||
|
addSubtask: "サブタスクを追加\u2026",
|
||||||
|
autoSaved: "自動保存済み",
|
||||||
|
saving: "保存中\u2026",
|
||||||
|
delete: "削除",
|
||||||
|
deleteConfirm: "このタスクを削除しますか?",
|
||||||
|
close: "閉じる",
|
||||||
|
clear: "クリア",
|
||||||
|
created: "作成日",
|
||||||
|
},
|
||||||
|
priority: { none: "なし", low: "低", medium: "中", high: "高" },
|
||||||
|
settings: {
|
||||||
|
theme: "テーマ",
|
||||||
|
language: "言語",
|
||||||
|
system: "システム",
|
||||||
|
light: "ライト",
|
||||||
|
dark: "ダーク",
|
||||||
|
},
|
||||||
|
import: {
|
||||||
|
title: "タスクをインポート",
|
||||||
|
targetList: "対象リスト",
|
||||||
|
file: "ファイル (CSV または ICS)",
|
||||||
|
tip: "TickTick: 設定 \u2192 エクスポート \u2192 CSVまたはiCalendarでエクスポート",
|
||||||
|
cancel: "キャンセル",
|
||||||
|
import: "インポート",
|
||||||
|
importing: "インポート中...",
|
||||||
|
error: "エラー",
|
||||||
|
},
|
||||||
|
list: { create: "リスト作成", color: "カラー" },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Translations = typeof translations.en;
|
||||||
|
export type TranslationKey = keyof Translations;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// CONTEXT
|
||||||
|
// ============================================================
|
||||||
|
interface I18nContextValue {
|
||||||
|
locale: Locale;
|
||||||
|
setLocale: (l: Locale) => void;
|
||||||
|
t: Translations;
|
||||||
|
}
|
||||||
|
|
||||||
|
const I18nContext = createContext<I18nContextValue>({
|
||||||
|
locale: "en",
|
||||||
|
setLocale: () => {},
|
||||||
|
t: translations.en,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [locale, setLocaleState] = useState<Locale>("en");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem("locale") as Locale | null;
|
||||||
|
if (saved && saved in translations) setLocaleState(saved);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setLocale = (l: Locale) => {
|
||||||
|
setLocaleState(l);
|
||||||
|
localStorage.setItem("locale", l);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<I18nContext.Provider value={{ locale, setLocale, t: translations[locale] }}>
|
||||||
|
{children}
|
||||||
|
</I18nContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useI18n() {
|
||||||
|
return useContext(I18nContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LOCALES: { value: Locale; label: string; flag: string }[] = [
|
||||||
|
{ value: "en", label: "English", flag: "🇺🇸" },
|
||||||
|
{ value: "ko", label: "한국어", flag: "🇰🇷" },
|
||||||
|
{ value: "ja", label: "日本語", flag: "🇯🇵" },
|
||||||
|
];
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ??
|
||||||
|
new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getToken } from "next-auth/jwt";
|
||||||
|
|
||||||
|
export async function proxy(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
// Public paths
|
||||||
|
// /api/dav: DAVx⁵ uses Basic Auth (not session cookie), so it must bypass the JWT check
|
||||||
|
const publicPaths = ["/login", "/register", "/api/auth", "/api/dav", "/icons", "/manifest.json", "/sw.js", "/_next", "/favicon.ico"];
|
||||||
|
const isPublic = publicPaths.some((p) => pathname.startsWith(p));
|
||||||
|
if (isPublic) return NextResponse.next();
|
||||||
|
|
||||||
|
const token = await getToken({ req: request });
|
||||||
|
if (!token) {
|
||||||
|
const loginUrl = new URL("/login", request.url);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||||
|
};
|
||||||
Vendored
+21
@@ -0,0 +1,21 @@
|
|||||||
|
import "next-auth";
|
||||||
|
import "next-auth/jwt";
|
||||||
|
|
||||||
|
declare module "next-auth" {
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
interface Session {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "next-auth/jwt" {
|
||||||
|
interface JWT {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user