diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..af25751 --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5ef6a52..9e27288 100644 --- a/.gitignore +++ b/.gitignore @@ -1,41 +1,18 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +# deps +node_modules/ +.pnp +.pnp.js -# dependencies -/node_modules -/.pnp -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/versions +# next +.next/ +out/ -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build +# env +.env +.env.local +.env.production # misc .DS_Store *.pem - -# 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 +npm-debug.log* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9848ade --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/README.md b/README.md index e215bc4..8c98ec9 100644 --- a/README.md +++ b/README.md @@ -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 -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +cp .env.example .env +# .env 파일에서 NEXTAUTH_SECRET 및 POSTGRES_PASSWORD 변경 ``` -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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0cc4a26 --- /dev/null +++ b/docker-compose.yml @@ -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: \ No newline at end of file diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..ce4e94a --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,7 @@ +/// +/// +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. diff --git a/next.config.ts b/next.config.ts index e9ffa30..b9aabe5 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,16 @@ import type { NextConfig } from "next"; 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; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8be1585..6872a93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,15 +8,26 @@ "name": "checkflow", "version": "0.1.0", "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-auth": "^4.24.15", + "prisma": "^6.19.3", "react": "19.2.8", - "react-dom": "19.2.8" + "react-dom": "19.2.8", + "uuid": "^14.0.2" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/bcryptjs": "^3.0.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/uuid": "^11.0.0", "eslint": "^9", "eslint-config-next": "16.3.1", "tailwindcss": "^4", @@ -214,6 +225,14 @@ "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": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -259,6 +278,55 @@ "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": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1233,12 +1301,97 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", "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": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -1513,6 +1666,16 @@ "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": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1558,6 +1721,16 @@ "@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": { "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", @@ -2422,6 +2595,14 @@ "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": { "version": "1.1.18", "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_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": { "version": "1.0.9", "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" } }, + "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": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -2597,12 +2827,33 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "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": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2623,6 +2874,11 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "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": { "version": "1.0.8", "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==", "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": { "version": "1.1.4", "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" } }, + "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": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2758,6 +3032,17 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2772,6 +3057,15 @@ "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": { "version": "1.5.411", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.411.tgz", @@ -2784,6 +3078,14 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "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": { "version": "5.24.5", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", @@ -3394,6 +3696,32 @@ "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": { "version": "3.1.3", "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" } }, + "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": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4292,11 +4636,18 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, "bin": { "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": { "version": "4.0.0", "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": { "version": "8.5.23", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", @@ -4915,6 +5309,11 @@ "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": { "version": "2.0.53", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", @@ -4924,6 +5323,32 @@ "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": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4933,6 +5358,14 @@ "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": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -5039,6 +5472,49 @@ "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": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5140,6 +5616,16 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5157,6 +5643,16 @@ "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": { "version": "1.1.0", "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_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": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5203,6 +5727,35 @@ "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": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5223,6 +5776,21 @@ "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": { "version": "1.2.3", "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": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -5268,6 +5845,18 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "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": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5871,6 +6460,14 @@ "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": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -6191,6 +6788,18 @@ "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index e2e1cdc..67603f8 100644 --- a/package.json +++ b/package.json @@ -6,18 +6,30 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "dev:sqlite": "set DATABASE_URL=file:./prisma/dev.db && next dev" }, "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-auth": "^4.24.15", + "prisma": "^6.19.3", "react": "19.2.8", - "react-dom": "19.2.8" + "react-dom": "19.2.8", + "uuid": "^14.0.2" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/bcryptjs": "^3.0.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/uuid": "^11.0.0", "eslint": "^9", "eslint-config-next": "16.3.1", "tailwindcss": "^4", diff --git a/prisma/dev/prisma/dev.db b/prisma/dev/prisma/dev.db new file mode 100644 index 0000000..a6ac93f Binary files /dev/null and b/prisma/dev/prisma/dev.db differ diff --git a/prisma/dev/schema.prisma b/prisma/dev/schema.prisma new file mode 100644 index 0000000..c3ed88a --- /dev/null +++ b/prisma/dev/schema.prisma @@ -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]) +} \ No newline at end of file diff --git a/prisma/migrations/20240101000000_init/migration.sql b/prisma/migrations/20240101000000_init/migration.sql new file mode 100644 index 0000000..f8f55ba --- /dev/null +++ b/prisma/migrations/20240101000000_init/migration.sql @@ -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; \ No newline at end of file diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2cdb8f0 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,2 @@ +# Please do not edit this file manually +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..1bf5a74 --- /dev/null +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/public/icons/icon-192.svg b/public/icons/icon-192.svg new file mode 100644 index 0000000..a286f1e --- /dev/null +++ b/public/icons/icon-192.svg @@ -0,0 +1 @@ + diff --git a/public/icons/icon-512.svg b/public/icons/icon-512.svg new file mode 100644 index 0000000..a286f1e --- /dev/null +++ b/public/icons/icon-512.svg @@ -0,0 +1 @@ + diff --git a/public/icons/icon.svg b/public/icons/icon.svg new file mode 100644 index 0000000..a286f1e --- /dev/null +++ b/public/icons/icon.svg @@ -0,0 +1 @@ + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..3577498 --- /dev/null +++ b/public/manifest.json @@ -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"} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..8712bdc --- /dev/null +++ b/public/sw.js @@ -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)) + ); +}); \ No newline at end of file diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..8d00f05 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -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 }; \ No newline at end of file diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..0cd5818 --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -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 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/dav/[...path]/route.ts b/src/app/api/dav/[...path]/route.ts new file mode 100644 index 0000000..200caab --- /dev/null +++ b/src/app/api/dav/[...path]/route.ts @@ -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 = { 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", + }, + }); +} \ No newline at end of file diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts new file mode 100644 index 0000000..d3c31b3 --- /dev/null +++ b/src/app/api/import/route.ts @@ -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 = { 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 }); +} \ No newline at end of file diff --git a/src/app/api/lists/[id]/route.ts b/src/app/api/lists/[id]/route.ts new file mode 100644 index 0000000..fa4f177 --- /dev/null +++ b/src/app/api/lists/[id]/route.ts @@ -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 = {}; + 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 }); + } +} \ No newline at end of file diff --git a/src/app/api/lists/route.ts b/src/app/api/lists/route.ts new file mode 100644 index 0000000..1c690cd --- /dev/null +++ b/src/app/api/lists/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts new file mode 100644 index 0000000..f33382d --- /dev/null +++ b/src/app/api/tasks/[id]/route.ts @@ -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 = {}; + 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 }); + } +} \ No newline at end of file diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts new file mode 100644 index 0000000..dd035a2 --- /dev/null +++ b/src/app/api/tasks/route.ts @@ -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 = { + 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 }); + } +} \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..c744677 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,26 +1,1420 @@ -@import "tailwindcss"; +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"); +/* ============================================ + DESIGN TOKENS + ============================================ */ :root { - --background: #ffffff; - --foreground: #171717; + /* Brand */ + --accent: #4B7BF5; + --accent-hover: #3A6AE4; + --accent-light: rgba(75, 123, 245, 0.1); + --accent-medium: rgba(75, 123, 245, 0.18); + + /* Backgrounds */ + --bg-primary: #FFFFFF; + --bg-secondary: #F7F8FC; + --bg-sidebar: #F2F4F8; + --bg-hover: rgba(0, 0, 0, 0.04); + --bg-active: rgba(75, 123, 245, 0.08); + --bg-overlay: rgba(0, 0, 0, 0.5); + + /* Text */ + --text-primary: #1A1A2E; + --text-secondary: #6B7280; + --text-tertiary: #9CA3AF; + --text-on-accent: #FFFFFF; + + /* Border */ + --border: rgba(0, 0, 0, 0.08); + --border-medium: rgba(0, 0, 0, 0.12); + --border-strong: rgba(0, 0, 0, 0.2); + + /* Priority colors */ + --priority-high: #EF4444; + --priority-medium: #F59E0B; + --priority-low: #10B981; + + /* Status */ + --success: #10B981; + --danger: #EF4444; + --warning: #F59E0B; + + /* Shadows */ + --shadow-xs: 0 1px 2px rgba(0,0,0,0.06); + --shadow-sm: 0 2px 8px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04); + --shadow-md: 0 4px 16px rgba(0,0,0,0.1), 0 2px 4px rgba(0,0,0,0.06); + --shadow-lg: 0 8px 32px rgba(0,0,0,0.12), 0 4px 8px rgba(0,0,0,0.06); + --shadow-panel: -4px 0 24px rgba(0,0,0,0.08); + + /* Radius */ + --radius-xs: 4px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-full: 9999px; + + /* Transition */ + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --dur-fast: 120ms; + --dur-normal: 200ms; + --dur-slow: 300ms; + + /* Layout */ + --sidebar-width: 240px; + --detail-width: 360px; + --header-height: 56px; } -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); +/* Dark mode */ +[data-theme="dark"] { + --bg-primary: #111827; + --bg-secondary: #1F2937; + --bg-sidebar: #161D2B; + --bg-hover: rgba(255, 255, 255, 0.05); + --bg-active: rgba(75, 123, 245, 0.12); + --text-primary: #F9FAFB; + --text-secondary: #9CA3AF; + --text-tertiary: #6B7280; + --border: rgba(255, 255, 255, 0.08); + --border-medium: rgba(255, 255, 255, 0.12); + --border-strong: rgba(255, 255, 255, 0.2); + --shadow-sm: 0 2px 8px rgba(0,0,0,0.3); + --shadow-md: 0 4px 16px rgba(0,0,0,0.4); + --shadow-lg: 0 8px 32px rgba(0,0,0,0.5); + --shadow-panel: -4px 0 24px rgba(0,0,0,0.4); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } +/* ============================================ + RESET & BASE + ============================================ */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + -webkit-text-size-adjust: 100%; + color-scheme: light dark; } body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 14px; + line-height: 1.5; + color: var(--text-primary); + background: var(--bg-primary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow: hidden; + height: 100vh; + width: 100vw; +} + +button { + font-family: inherit; + cursor: pointer; + border: none; + background: none; + outline: none; +} + +input, textarea { + font-family: inherit; + outline: none; + border: none; +} + +a { color: inherit; text-decoration: none; } + +/* ============================================ + SCROLLBAR + ============================================ */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: var(--radius-full); +} +::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); } + +/* ============================================ + APP LAYOUT + ============================================ */ +.app-layout { + display: flex; + height: 100vh; + width: 100vw; + overflow: hidden; +} + +/* ============================================ + SIDEBAR + ============================================ */ +.sidebar { + width: var(--sidebar-width); + background: var(--bg-sidebar); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + flex-shrink: 0; + overflow: hidden; + transition: width var(--dur-slow) var(--ease-out), + transform var(--dur-slow) var(--ease-out); + z-index: 20; +} + +.sidebar.collapsed { + width: 0; +} + +.sidebar-header { + display: flex; + align-items: center; + gap: 10px; + padding: 16px 16px 12px; + border-bottom: 1px solid var(--border); + min-height: var(--header-height); +} + +.sidebar-logo { + width: 28px; + height: 28px; + background: var(--accent); + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 14px; + font-weight: 700; + letter-spacing: -0.5px; + flex-shrink: 0; +} + +.sidebar-title { + font-size: 15px; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.3px; +} + +.sidebar-nav { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} + +.sidebar-section { + padding: 8px 0 4px; +} + +.sidebar-section-label { + font-size: 11px; + font-weight: 600; + color: var(--text-tertiary); + letter-spacing: 0.6px; + text-transform: uppercase; + padding: 4px 16px 6px; +} + +.sidebar-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + margin: 1px 8px; + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 13.5px; + font-weight: 500; + cursor: pointer; + transition: background var(--dur-fast), color var(--dur-fast); + user-select: none; + position: relative; +} + +.sidebar-item:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.sidebar-item.active { + background: var(--bg-active); + color: var(--accent); +} + +.sidebar-item .list-dot { + width: 8px; + height: 8px; + border-radius: var(--radius-full); + flex-shrink: 0; +} + +.sidebar-item .item-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-item .item-count { + font-size: 11px; + color: var(--text-tertiary); + font-weight: 500; + min-width: 16px; + text-align: right; +} + +.sidebar-add-btn { + display: flex; + align-items: center; + gap: 8px; + width: calc(100% - 16px); + margin: 4px 8px; + padding: 8px 12px; + border-radius: var(--radius-sm); + color: var(--text-tertiary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background var(--dur-fast), color var(--dur-fast); +} + +.sidebar-add-btn:hover { + background: var(--bg-hover); + color: var(--accent); +} + +.sidebar-footer { + padding: 8px 8px 12px; + border-top: 1px solid var(--border); +} + +.user-card { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background var(--dur-fast); +} + +.user-card:hover { background: var(--bg-hover); } + +.user-avatar { + width: 32px; + height: 32px; + border-radius: var(--radius-full); + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 600; + color: white; + flex-shrink: 0; +} + +.user-info { + flex: 1; + overflow: hidden; +} + +.user-name { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-email { + font-size: 11px; + color: var(--text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ============================================ + MAIN CONTENT + ============================================ */ +.main-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bg-primary); + min-width: 0; +} + +.main-header { + display: flex; + align-items: center; + gap: 12px; + padding: 0 20px; + height: var(--header-height); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.main-header-title { + font-size: 18px; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.4px; + flex: 1; +} + +.main-header-actions { + display: flex; + align-items: center; + gap: 6px; +} + +/* ============================================ + TASK LIST AREA + ============================================ */ +.task-list-container { + flex: 1; + overflow-y: auto; + padding: 12px 0 80px; +} + +.task-list-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 12px; + color: var(--text-tertiary); + padding: 40px; +} + +.task-list-empty svg { + opacity: 0.3; +} + +.task-list-empty p { + font-size: 14px; + text-align: center; +} + +/* ============================================ + TASK ITEM + ============================================ */ +.task-item { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 20px; + cursor: pointer; + transition: background var(--dur-fast); + position: relative; + border-radius: 0; +} + +.task-item:hover { + background: var(--bg-hover); +} + +.task-item.selected { + background: var(--bg-active); +} + +.task-item.completed .task-title { + text-decoration: line-through; + color: var(--text-tertiary); +} + +.task-check-btn { + width: 20px; + height: 20px; + border-radius: var(--radius-full); + border: 2px solid var(--border-medium); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-top: 1px; + cursor: pointer; + transition: border-color var(--dur-fast), background var(--dur-fast), transform var(--dur-fast); +} + +.task-check-btn:hover { + border-color: var(--accent); + background: var(--accent-light); + transform: scale(1.1); +} + +.task-check-btn.checked { + border-color: var(--accent); + background: var(--accent); + animation: checkPop var(--dur-normal) var(--ease-out); +} + +.task-check-btn.checked::after { + content: ""; + width: 5px; + height: 9px; + border: 2px solid white; + border-top: none; + border-left: none; + transform: rotate(45deg) translateY(-1px); +} + +@keyframes checkPop { + 0% { transform: scale(1); } + 50% { transform: scale(1.25); } + 100% { transform: scale(1); } +} + +.task-body { + flex: 1; + min-width: 0; +} + +.task-title { + font-size: 14px; + font-weight: 450; + color: var(--text-primary); + line-height: 1.4; + transition: color var(--dur-fast); +} + +.task-meta { + display: flex; + align-items: center; + gap: 8px; + margin-top: 3px; + flex-wrap: wrap; +} + +.task-due { + font-size: 11.5px; + color: var(--text-tertiary); + display: flex; + align-items: center; + gap: 3px; +} + +.task-due.overdue { color: var(--danger); } + +.task-priority-dot { + width: 6px; + height: 6px; + border-radius: var(--radius-full); + flex-shrink: 0; +} + +.task-sub-count { + font-size: 11px; + color: var(--text-tertiary); + display: flex; + align-items: center; + gap: 3px; + background: var(--bg-secondary); + padding: 1px 6px; + border-radius: var(--radius-full); +} + +.task-note-preview { + font-size: 12px; + color: var(--text-tertiary); + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +/* Sub-tasks */ +.subtask-list { + padding-left: 30px; + padding-bottom: 4px; +} + +.subtask-item { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 20px 6px 0; + cursor: pointer; + border-radius: var(--radius-xs); + transition: background var(--dur-fast); +} + +.subtask-item:hover { background: var(--bg-hover); } + +.subtask-check-btn { + width: 16px; + height: 16px; + border-radius: var(--radius-full); + border: 1.5px solid var(--border-medium); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + cursor: pointer; + transition: border-color var(--dur-fast), background var(--dur-fast); +} + +.subtask-check-btn:hover { border-color: var(--accent); } + +.subtask-check-btn.checked { + border-color: var(--accent); + background: var(--accent); +} + +.subtask-check-btn.checked::after { + content: ""; + width: 4px; + height: 7px; + border: 1.5px solid white; + border-top: none; + border-left: none; + transform: rotate(45deg) translateY(-1px); +} + +.subtask-title { + font-size: 13px; + color: var(--text-primary); + flex: 1; +} + +.subtask-item.completed .subtask-title { + text-decoration: line-through; + color: var(--text-tertiary); +} + +/* Add task input */ +.add-task-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 20px; + border-top: 1px solid var(--border); + background: var(--bg-primary); + position: sticky; + bottom: 0; +} + +.add-task-input { + flex: 1; + background: none; + font-size: 14px; + color: var(--text-primary); + padding: 4px 0; +} + +.add-task-input::placeholder { color: var(--text-tertiary); } + +/* ============================================ + DETAIL PANEL + ============================================ */ +.detail-panel { + width: var(--detail-width); + border-left: 1px solid var(--border); + background: var(--bg-primary); + display: flex; + flex-direction: column; + flex-shrink: 0; + overflow: hidden; + transform: translateX(0); + transition: width var(--dur-slow) var(--ease-out), + transform var(--dur-slow) var(--ease-out); +} + +.detail-panel.closed { + width: 0; + border-left: none; +} + +.detail-header { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); + min-height: var(--header-height); +} + +.detail-close-btn { + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + cursor: pointer; + transition: background var(--dur-fast), color var(--dur-fast); + flex-shrink: 0; +} + +.detail-close-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.detail-body { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.detail-title-input { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.4; + width: 100%; + background: none; + resize: none; + letter-spacing: -0.3px; +} + +.detail-title-input::placeholder { color: var(--text-tertiary); } + +.detail-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.detail-section-label { + font-size: 11px; + font-weight: 600; + color: var(--text-tertiary); + letter-spacing: 0.5px; + text-transform: uppercase; +} + +/* Note area - the wide memo */ +.note-editor-wrap { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; + min-height: 200px; + display: flex; + flex-direction: column; + transition: border-color var(--dur-fast), box-shadow var(--dur-fast); +} + +.note-editor-wrap:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} + +.note-editor-toolbar { + display: flex; + gap: 2px; + padding: 6px 8px; + border-bottom: 1px solid var(--border); + background: var(--bg-primary); +} + +.note-toolbar-btn { + width: 26px; + height: 26px; + border-radius: var(--radius-xs); + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + color: var(--text-secondary); + cursor: pointer; + transition: background var(--dur-fast), color var(--dur-fast); +} + +.note-toolbar-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.note-textarea { + flex: 1; + width: 100%; + background: none; + color: var(--text-primary); + font-size: 13.5px; + line-height: 1.7; + padding: 12px; + resize: none; + min-height: 180px; + font-family: inherit; +} + +.note-textarea::placeholder { color: var(--text-tertiary); } + +/* Sub-tasks in detail */ +.detail-subtasks { + display: flex; + flex-direction: column; + gap: 4px; +} + +.detail-subtask-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: var(--radius-sm); + background: var(--bg-secondary); + cursor: pointer; + transition: background var(--dur-fast); +} + +.detail-subtask-row:hover { background: var(--bg-hover); } + +.detail-subtask-title { + flex: 1; + font-size: 13px; + color: var(--text-primary); + background: none; + cursor: text; +} + +.detail-subtask-row.completed .detail-subtask-title { + text-decoration: line-through; + color: var(--text-tertiary); +} + +.add-subtask-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + cursor: pointer; + color: var(--text-tertiary); + font-size: 13px; + border-radius: var(--radius-sm); + transition: color var(--dur-fast), background var(--dur-fast); +} + +.add-subtask-row:hover { + background: var(--bg-hover); + color: var(--accent); +} + +/* Priority selector */ +.priority-selector { + display: flex; + gap: 6px; +} + +.priority-btn { + display: flex; + align-items: center; + gap: 5px; + padding: 5px 10px; + border-radius: var(--radius-full); + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + background: var(--bg-secondary); + border: 1.5px solid var(--border); + cursor: pointer; + transition: all var(--dur-fast); +} + +.priority-btn.active-high { + color: var(--priority-high); + background: rgba(239,68,68,0.08); + border-color: rgba(239,68,68,0.3); +} + +.priority-btn.active-medium { + color: var(--priority-medium); + background: rgba(245,158,11,0.08); + border-color: rgba(245,158,11,0.3); +} + +.priority-btn.active-low { + color: var(--priority-low); + background: rgba(16,185,129,0.08); + border-color: rgba(16,185,129,0.3); +} + +/* Due date */ +.due-date-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: var(--radius-full); + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + background: var(--bg-secondary); + border: 1.5px solid var(--border); + cursor: pointer; + transition: all var(--dur-fast); +} + +.due-date-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +/* ============================================ + BUTTONS + ============================================ */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 16px; + border-radius: var(--radius-sm); + font-size: 13.5px; + font-weight: 600; + cursor: pointer; + transition: all var(--dur-fast); + user-select: none; + white-space: nowrap; +} + +.btn-primary { + background: var(--accent); + color: white; +} + +.btn-primary:hover { + background: var(--accent-hover); + box-shadow: 0 4px 12px rgba(75,123,245,0.35); + transform: translateY(-1px); +} + +.btn-primary:active { + transform: translateY(0); + box-shadow: none; +} + +.btn-ghost { + color: var(--text-secondary); + background: transparent; +} + +.btn-ghost:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.btn-danger { + background: transparent; + color: var(--danger); +} + +.btn-danger:hover { + background: rgba(239,68,68,0.08); +} + +.btn-sm { + padding: 5px 10px; + font-size: 12px; +} + +.icon-btn { + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--text-secondary); + transition: background var(--dur-fast), color var(--dur-fast); +} + +.icon-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +/* ============================================ + MODALS & OVERLAYS + ============================================ */ +.modal-overlay { + position: fixed; + inset: 0; + background: var(--bg-overlay); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + backdrop-filter: blur(4px); + animation: fadeIn var(--dur-normal) var(--ease-out); +} + +.modal { + background: var(--bg-primary); + border-radius: var(--radius-xl); + padding: 28px; + width: 100%; + max-width: 400px; + box-shadow: var(--shadow-lg); + animation: modalIn var(--dur-slow) var(--ease-out); +} + +.modal-lg { + max-width: 560px; +} + +.modal-title { + font-size: 18px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 20px; + letter-spacing: -0.3px; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 20px; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes modalIn { + from { opacity: 0; transform: scale(0.96) translateY(8px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +/* ============================================ + FORM ELEMENTS + ============================================ */ +.form-group { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 14px; +} + +.form-label { + font-size: 12.5px; + font-weight: 600; + color: var(--text-secondary); +} + +.form-input { + width: 100%; + padding: 10px 12px; + background: var(--bg-secondary); + border: 1.5px solid var(--border); + border-radius: var(--radius-sm); + font-size: 14px; + color: var(--text-primary); + transition: border-color var(--dur-fast), box-shadow var(--dur-fast); +} + +.form-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} + +.form-input::placeholder { color: var(--text-tertiary); } + +.form-error { + font-size: 12px; + color: var(--danger); +} + +/* ============================================ + AUTH PAGES + ============================================ */ +.auth-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #EEF2FF 0%, #F5F3FF 50%, #EFF6FF 100%); + padding: 20px; + overflow: auto; +} + +[data-theme="dark"] .auth-page { + background: linear-gradient(135deg, #111827 0%, #1a1035 50%, #111827 100%); +} + +.auth-card { + background: var(--bg-primary); + border-radius: var(--radius-xl); + padding: 40px; + width: 100%; + max-width: 400px; + box-shadow: var(--shadow-lg); + border: 1px solid var(--border); +} + +.auth-logo { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 28px; +} + +.auth-logo-icon { + width: 36px; + height: 36px; + background: var(--accent); + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 16px; + font-weight: 700; +} + +.auth-logo-name { + font-size: 20px; + font-weight: 800; + color: var(--text-primary); + letter-spacing: -0.5px; +} + +.auth-title { + font-size: 22px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 6px; + letter-spacing: -0.4px; +} + +.auth-subtitle { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 28px; +} + +.auth-link { + color: var(--accent); + cursor: pointer; + font-weight: 500; +} + +.auth-link:hover { text-decoration: underline; } + +.auth-footer { + text-align: center; + margin-top: 20px; + font-size: 13.5px; + color: var(--text-secondary); +} + +/* ============================================ + BADGES & CHIPS + ============================================ */ +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: var(--radius-full); + font-size: 11px; + font-weight: 600; +} + +.badge-accent { + background: var(--accent-light); + color: var(--accent); +} + +.badge-neutral { + background: var(--bg-secondary); + color: var(--text-secondary); +} + +/* ============================================ + DROPDOWN MENUS + ============================================ */ +.dropdown { + position: absolute; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + padding: 4px; + min-width: 160px; + z-index: 50; + animation: dropdownIn var(--dur-fast) var(--ease-out); +} + +.dropdown-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: var(--radius-sm); + font-size: 13.5px; + color: var(--text-primary); + cursor: pointer; + transition: background var(--dur-fast); +} + +.dropdown-item:hover { background: var(--bg-hover); } +.dropdown-item.danger { color: var(--danger); } + +.dropdown-divider { + height: 1px; + background: var(--border); + margin: 4px 0; +} + +@keyframes dropdownIn { + from { opacity: 0; transform: translateY(-6px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +/* ============================================ + TOAST NOTIFICATIONS + ============================================ */ +.toast-container { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 200; + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + pointer-events: none; +} + +.toast { + background: var(--text-primary); + color: var(--bg-primary); + padding: 10px 18px; + border-radius: var(--radius-full); + font-size: 13.5px; + font-weight: 500; + box-shadow: var(--shadow-md); + animation: toastIn var(--dur-normal) var(--ease-out); + pointer-events: all; +} + +@keyframes toastIn { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ============================================ + COLOR PICKER + ============================================ */ +.color-swatches { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.color-swatch { + width: 24px; + height: 24px; + border-radius: var(--radius-full); + cursor: pointer; + transition: transform var(--dur-fast), box-shadow var(--dur-fast); + border: 2px solid transparent; +} + +.color-swatch:hover { transform: scale(1.2); } +.color-swatch.selected { + border-color: white; + box-shadow: 0 0 0 2px currentColor; +} + +/* ============================================ + PROGRESS BAR + ============================================ */ +.progress-bar { + width: 100%; + height: 4px; + background: var(--border); + border-radius: var(--radius-full); + overflow: hidden; +} + +.progress-bar-fill { + height: 100%; + background: var(--accent); + border-radius: var(--radius-full); + transition: width var(--dur-slow) var(--ease-out); +} + +/* ============================================ + MOBILE RESPONSIVE + ============================================ */ +@media (max-width: 768px) { + :root { + --sidebar-width: 100%; + --detail-width: 100%; + } + + .app-layout { + position: relative; + } + + .sidebar { + position: fixed; + left: 0; + top: 0; + bottom: 0; + z-index: 30; + transform: translateX(-100%); + transition: transform var(--dur-slow) var(--ease-out); + } + + .sidebar.mobile-open { + transform: translateX(0); + box-shadow: var(--shadow-lg); + } + + .detail-panel { + position: fixed; + right: 0; + top: 0; + bottom: 0; + z-index: 30; + transform: translateX(100%); + transition: transform var(--dur-slow) var(--ease-out); + width: 100% !important; + } + + .detail-panel.mobile-open { + transform: translateX(0); + box-shadow: var(--shadow-panel); + } + + .bottom-nav { + display: flex; + } + + .main-content { + padding-bottom: 60px; + } + + .task-item { padding: 10px 16px; } + .main-header { padding: 0 16px; } +} + +/* Bottom nav (mobile only) */ +.bottom-nav { + display: none; + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: var(--bg-primary); + border-top: 1px solid var(--border); + padding: 8px 0 max(8px, env(safe-area-inset-bottom)); + z-index: 25; +} + +.bottom-nav-inner { + display: flex; + justify-content: space-around; +} + +.bottom-nav-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + padding: 6px 16px; + cursor: pointer; + color: var(--text-tertiary); + font-size: 10px; + font-weight: 500; + transition: color var(--dur-fast); + border-radius: var(--radius-sm); +} + +.bottom-nav-item.active { color: var(--accent); } + +/* FAB */ +.fab { + position: fixed; + bottom: 80px; + right: 20px; + width: 52px; + height: 52px; + border-radius: var(--radius-full); + background: var(--accent); + color: white; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 4px 16px rgba(75,123,245,0.4); + transition: transform var(--dur-fast), box-shadow var(--dur-fast); + z-index: 20; +} + +.fab:hover { + transform: scale(1.05); + box-shadow: 0 6px 20px rgba(75,123,245,0.5); +} + +.fab:active { transform: scale(0.97); } + +@media (min-width: 769px) { + .fab { display: none; } +} + +/* ============================================ + UTILITY CLASSES + ============================================ */ +.flex { display: flex; } +.items-center { align-items: center; } +.gap-2 { gap: 8px; } +.flex-1 { flex: 1; } +.text-sm { font-size: 13px; } +.text-xs { font-size: 11px; } +.font-medium { font-weight: 500; } +.font-semibold { font-weight: 600; } +.font-bold { font-weight: 700; } +.text-muted { color: var(--text-tertiary); } +.text-accent { color: var(--accent); } +.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.w-full { width: 100%; } +.mt-1 { margin-top: 4px; } +.mt-2 { margin-top: 8px; } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; + border-width: 0; +} + +/* Mobile-only: hidden on desktop, flex on mobile */ +.mobile-only { + display: none; +} + +@media (max-width: 768px) { + .mobile-only { + display: flex; + } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9852c15..330762c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,29 +1,39 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import type { Metadata, Viewport } from "next"; import "./globals.css"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +import { Providers } from "./providers"; export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "CheckFlow — Your Personal Todo", + 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 ( - - {children} + + + + + + + + {children} + ); -} +} \ No newline at end of file diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..2ba2589 --- /dev/null +++ b/src/app/login/page.tsx @@ -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 ( +
+
+
+
+ CheckFlow +
+

Welcome back

+

Sign in to your account

+
+
+ + setEmail(e.target.value)} + required + autoFocus + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ {error &&

{error}

} + +
+

+ Don't have an account?{" "} + Create one +

+
+
+ ); +} \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index c887311..91462ab 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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() { - return ( -
-
- Next.js logo -
-

- To get started, edit the{" "} - - page.tsx - {" "} - file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- -
-
- ); -} +export default async function HomePage() { + const session = await getServerSession(authOptions); + if (!session) redirect("/login"); + return ; +} \ No newline at end of file diff --git a/src/app/providers.tsx b/src/app/providers.tsx new file mode 100644 index 0000000..5929332 --- /dev/null +++ b/src/app/providers.tsx @@ -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 ( + + {children} + + ); +} \ No newline at end of file diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx new file mode 100644 index 0000000..bf2fc7b --- /dev/null +++ b/src/app/register/page.tsx @@ -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 ( +
+
+
+
+ CheckFlow +
+

Create account

+

Start organizing your tasks today

+
+
+ + setName(e.target.value)} + required + autoFocus + /> +
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + minLength={8} + /> +
+ {error &&

{error}

} + +
+

+ Already have an account?{" "} + Sign in +

+
+
+ ); +} \ No newline at end of file diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..c7b8cd3 --- /dev/null +++ b/src/components/layout/AppShell.tsx @@ -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([]); + const [selectedListId, setSelectedListId] = useState(null); + const [selectedTask, setSelectedTask] = useState(null); + const [tasks, setTasks] = useState([]); + 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 ( +
+ {/* Mobile overlay */} + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + + setSidebarOpen(false)} + /> + +
+ setShowCompleted((p) => !p)} + onMenuOpen={() => setSidebarOpen(true)} + onRefresh={refresh} + /> +
+ + {selectedTask && ( + setSelectedTask(null)} + onUpdate={handleTaskUpdate} + onDelete={() => { setSelectedTask(null); refresh(); }} + listId={selectedTask.listId} + /> + )} + + {/* Mobile FAB */} + +
+ ); +} \ No newline at end of file diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..5b66a0f --- /dev/null +++ b/src/components/layout/Sidebar.tsx @@ -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 = { 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(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(null); + const fileRef = useRef(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 ( + + ); +} \ No newline at end of file diff --git a/src/components/tasks/TaskDetail.tsx b/src/components/tasks/TaskDetail.tsx new file mode 100644 index 0000000..89d7ea7 --- /dev/null +++ b/src/components/tasks/TaskDetail.tsx @@ -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 | null>(null); + const currentTaskId = useRef(task.id); + const noteRef = useRef(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) => { + // 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) => { + 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 ( +