Checkpoint: Initial stable CheckFlow base before i18n and demo mode

This commit is contained in:
Wonhee Han
2026-08-20 14:01:12 +09:00
parent ed076cf5ab
commit dbfaa0fcb2
42 changed files with 4727 additions and 167 deletions
+11
View File
@@ -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
+12 -35
View File
@@ -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
npm-debug.log*
# debug
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
View File
@@ -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"]
+102 -21
View File
@@ -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: ![CheckFlow](public/icons/icon.svg)
## 주요 기능
-**계층형 체크리스트** — 메인 태스크 + 하위 태스크
- 📝 **태스크 메모장** — 넓은 노트 영역 (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
+49
View File
@@ -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:
+7
View File
@@ -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.
+11 -2
View File
@@ -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;
+611 -2
View File
@@ -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
View File
@@ -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.
+88
View File
@@ -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;
+2
View File
@@ -0,0 +1,2 @@
# Please do not edit this file manually
provider = "postgresql"
+101
View File
@@ -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])
}
+1
View File
@@ -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">&#x2713;</text></svg>

After

Width:  |  Height:  |  Size: 247 B

+1
View File
@@ -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">&#x2713;</text></svg>

After

Width:  |  Height:  |  Size: 247 B

+1
View File
@@ -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">&#x2713;</text></svg>

After

Width:  |  Height:  |  Size: 247 B

+1
View File
@@ -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"}
+34
View File
@@ -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))
);
});
+5
View File
@@ -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 };
+49
View File
@@ -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 }
);
}
}
+77
View File
@@ -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",
},
});
}
+132
View File
@@ -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 });
}
+49
View File
@@ -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 });
}
}
+51
View File
@@ -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 });
}
}
+81
View File
@@ -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 });
}
}
+70
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+31 -21
View File
@@ -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>
); );
} }
+74
View File
@@ -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&apos;t have an account?{" "}
<Link href="/register" className="auth-link">Create one</Link>
</p>
</div>
</div>
);
}
+9 -68
View File
@@ -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>
);
}
+43
View File
@@ -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>
);
}
+103
View File
@@ -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>
);
}
+106
View File
@@ -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>
);
}
+246
View File
@@ -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>
);
}
+372
View File
@@ -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>
);
}
+269
View File
@@ -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>
);
}
+41
View File
@@ -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;
},
},
};
+288
View File
@@ -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: "🇯🇵" },
];
+13
View File
@@ -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;
+24
View File
@@ -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).*)"],
};
+21
View File
@@ -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;
}
}