Feature: Add i18n (en/ko/ja), 3-way theme (system/light/dark), and local demo preview mode
This commit is contained in:
@@ -1,3 +1,79 @@
|
|||||||
|
# CheckFlow — Agent Handbook & Project History
|
||||||
|
|
||||||
|
> 이 문서는 CheckFlow 프로젝트의 전체 개발 내역, 사용자 요구사항, 의사결정 기록, 기술 아키텍처 및 트러블슈팅을 보존하여 향후 작업하는 모든 AI 에이전트와 개발자가 일관되게 작업을 이어갈 수 있도록 작성되었습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 프로젝트 개요 & 핵심 요구사항
|
||||||
|
|
||||||
|
사용자의 핵심 요구사항:
|
||||||
|
1. **TickTick 스타일 To-Do 리스트 독립 웹앱**:
|
||||||
|
- 체크리스트는 **메인 - 하위(Sub-task)** 계층 구조를 갖춤.
|
||||||
|
- 메인 체크리스트에 귀속된 **넓직한 메모장(Markdown 노트)** 제공.
|
||||||
|
2. **셀프호스팅 & 프라이버시 중심 멀티유저**:
|
||||||
|
- SNS 형태가 아닌 개인별 프라이버시가 완벽히 보장되는 독립 멀티유저 플랫폼.
|
||||||
|
- Docker 배포 지원 (PostgreSQL 포함).
|
||||||
|
3. **외부 플랫폼 연동 & Import**:
|
||||||
|
- TickTick 등 외부 플랫폼에서 내보내기한 CSV 및 ICS(iCalendar) 파일 Import 지원.
|
||||||
|
- Galaxy(Android) 폰 연동: DAVx⁵ 앱을 통한 CalDAV/CardDAV (`/api/dav`) 동기화 지원.
|
||||||
|
4. **PWA (Progressive Web App)**:
|
||||||
|
- 모바일/데스크톱 설치 가능 및 오프라인 캐싱 지원.
|
||||||
|
5. **디자인 시스템**:
|
||||||
|
- TickTick 급의 심플함 + OneUI / Material You / Vercel 스타일의 직관적이고 미려한 UI.
|
||||||
|
- 3-Panel 반응형 레이아웃 (사이드바 - 태스크 목록 - 넓은 상세 메모 패널).
|
||||||
|
6. **추가 요구사항 (Phase 2)**:
|
||||||
|
- **다국어 (i18n)**: 영어(기본) → 한국어 → 일본어 순서 지원.
|
||||||
|
- **테마 모드**: 시스템(System) / 라이트(Light) / 다크(Dark) 3-Way 토글.
|
||||||
|
- **DB 없는 데모/미리보기 모드**: 로컬 환경에서 DB 구동 없이도 UI/UX 및 모든 기능을 즉시 시연 및 확인할 수 있는 Demo 모드 지원.
|
||||||
|
- **인프라**: Nginx Proxy Manager (NPM)로 역방향 프록시할 예정이므로 자체 SSL/Nginx 없이 포트 3000 컨테이너로 동작.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 기술 스택 (Tech Stack)
|
||||||
|
|
||||||
|
- **Frontend**: Next.js 16 (App Router), TypeScript, Vanilla CSS (TailwindCSS 지양, 고품질 CSS Variable 디자인 시스템)
|
||||||
|
- **Backend / API**: Next.js API Routes (Route Handlers)
|
||||||
|
- **ORM / DB**: Prisma ORM, PostgreSQL (Production/Docker) + Local/Demo In-Memory/LocalStorage Fallback
|
||||||
|
- **Auth**: NextAuth.js (Credentials Provider, JWT 전략)
|
||||||
|
- **DAV Server**: `/api/dav/[...path]` (iCalendar VTODO 표준 구현, Basic Auth 지원)
|
||||||
|
- **PWA**: Service Worker (`public/sw.js`), Web App Manifest (`public/manifest.json`)
|
||||||
|
- **Container**: `Dockerfile` (Node 20 Alpine Standalone), `docker-compose.yml` (PostgreSQL 16 + Auto migration + App)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 주요 결정 및 트러블슈팅 히스토리
|
||||||
|
|
||||||
|
### [버그 1] Next.js 16 `middleware` Deprecation
|
||||||
|
- **현상**: Next.js 16 최신 버전에서 `src/middleware.ts` 빌드 시 오류 발생.
|
||||||
|
- **해결**: Next.js 16 스펙에 맞춰 `src/proxy.ts`로 마이그레이션 (`export async function proxy(...)`).
|
||||||
|
|
||||||
|
### [버그 2] DAVx⁵ CalDAV/CardDAV 인증 차단 문제
|
||||||
|
- **현상**: `src/proxy.ts`에서 미인증 세션을 `/login`으로 리다이렉트하여, DAVx⁵의 HTTP Basic Auth 요청이 차단됨.
|
||||||
|
- **해결**: `publicPaths`에 `/api/dav`를 추가하여 Basic Auth는 라우트 핸들러 자체에서 검증하도록 예외 처리.
|
||||||
|
|
||||||
|
### [버그 3] DB 미연결 시 `register/page.tsx` SyntaxError
|
||||||
|
- **현상**: DB가 닫혀있을 때 서버가 500 에러를 반환하면 프론트엔드의 `res.json()` 호출 시 JSON 파싱 오류 발생.
|
||||||
|
- **해결**: 모든 API Route에 try-catch를 씌우고, 클라이언트 `res.json()` 파싱부를 안전하게 try-catch 처리.
|
||||||
|
|
||||||
|
### [버그 4] TaskDetail 자동저장 Stale Closure & 타이머 누수
|
||||||
|
- **현상**: debounce 저장 타이머가 태스크 전환 시 이전 태스크 ID로 저장되거나 언마운트 시 메모리 누수 위험.
|
||||||
|
- **해결**: `useRef(currentTaskId)`와 언마운트 cleanup 추가.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 롤백 포인트 (Checkpoints)
|
||||||
|
|
||||||
|
- **Git Tag `checkpoint-v1.0` / Branch `backup-v1.0`**: 기본 Full-Stack 구조 및 버그픽스 완료 시점.
|
||||||
|
- 언제든 문제가 발생하면 `git checkout checkpoint-v1.0`으로 복구 가능.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 향후 작업 가이드 (For Next Agents)
|
||||||
|
|
||||||
|
1. **디자인 무결성**: 바닐라 CSS 변수(`var(--accent)`, `var(--bg-primary)` 등)를 준수하고 모바일 반응형(768px 이하)을 항상 고려할 것.
|
||||||
|
2. **다국어(i18n)**: 텍스트 추가 시 `src/lib/i18n`의 en/ko/ja 사전에 키를 반드시 함께 등록할 것.
|
||||||
|
3. **데모 모드 지원**: DB 연결 불가 상태에서도 사용자가 UI를 체험할 수 있는 환경을 유지할 것.
|
||||||
|
|
||||||
<!-- BEGIN:nextjs-agent-rules -->
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
|
||||||
# This is NOT the Next.js you know
|
# This is NOT the Next.js you know
|
||||||
|
|||||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
import "./.next/types/root-params.d.ts";
|
import "./.next/dev/types/root-params.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { AppShell } from "@/components/layout/AppShell";
|
||||||
|
|
||||||
|
export default function DemoPage() {
|
||||||
|
const demoUser = {
|
||||||
|
id: "demo-user",
|
||||||
|
name: "Demo Explorer",
|
||||||
|
email: "demo@checkflow.local",
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AppShell user={demoUser} isDemo={true} />;
|
||||||
|
}
|
||||||
+104
-17
@@ -1,11 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useI18n, Language } from "@/lib/i18n";
|
||||||
|
import { useTheme } from "@/app/providers";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { t, lang, setLang } = useI18n();
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -15,27 +20,109 @@ export default function LoginPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError("");
|
setError("");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await signIn("credentials", { email, password, redirect: false });
|
try {
|
||||||
setLoading(false);
|
const res = await signIn("credentials", { email, password, redirect: false });
|
||||||
if (res?.error) {
|
setLoading(false);
|
||||||
setError("Invalid email or password.");
|
if (res?.error) {
|
||||||
} else {
|
setError("Invalid email or password. (Make sure DB is running)");
|
||||||
router.push("/");
|
} else {
|
||||||
|
router.push("/");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Login failed. Check server status.");
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="auth-page">
|
<div className="auth-page">
|
||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
<div className="auth-logo">
|
{/* Top bar: Lang & Theme switcher */}
|
||||||
<div className="auth-logo-icon">✓</div>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 20 }}>
|
||||||
<span className="auth-logo-name">CheckFlow</span>
|
<div className="auth-logo" style={{ marginBottom: 0 }}>
|
||||||
|
<div className="auth-logo-icon">✓</div>
|
||||||
|
<span className="auth-logo-name">{t("appName")}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<select
|
||||||
|
aria-label={t("language")}
|
||||||
|
value={lang}
|
||||||
|
onChange={(e) => setLang(e.target.value as Language)}
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-secondary)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--radius-xs)",
|
||||||
|
fontSize: 11,
|
||||||
|
padding: "3px 6px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="en">EN</option>
|
||||||
|
<option value="ko">한국어</option>
|
||||||
|
<option value="ja">日本語</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
title={`${t("theme")}: ${theme}`}
|
||||||
|
style={{ width: 28, height: 28 }}
|
||||||
|
>
|
||||||
|
{theme === "system" ? (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" />
|
||||||
|
</svg>
|
||||||
|
) : theme === "light" ? (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="5" /><line x1="12" y1="1" x2="12" y2="3" /><line x1="12" y1="21" x2="12" y2="23" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="auth-title">Welcome back</h1>
|
|
||||||
<p className="auth-subtitle">Sign in to your account</p>
|
<h1 className="auth-title">{t("welcomeBack")}</h1>
|
||||||
|
<p className="auth-subtitle">{t("signInSubtitle")}</p>
|
||||||
|
|
||||||
|
{/* Demo Mode Action Banner */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--accent-light)",
|
||||||
|
border: "1px dashed var(--accent)",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
padding: "12px 14px",
|
||||||
|
marginBottom: 20,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--accent)" }}>✨ {t("demoBadge")}</span>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>No PostgreSQL needed</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 12, color: "var(--text-secondary)", lineHeight: 1.4 }}>
|
||||||
|
Explore full 3-panel UI, sub-tasks, and markdown notes directly in browser storage.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/demo"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
style={{ marginTop: 4, width: "100%", textAlign: "center" }}
|
||||||
|
id="try-demo-btn"
|
||||||
|
>
|
||||||
|
🚀 {t("tryDemoMode")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Email</label>
|
<label className="form-label">{t("email")}</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -48,7 +135,7 @@ export default function LoginPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Password</label>
|
<label className="form-label">{t("password")}</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -61,12 +148,12 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
{error && <p className="form-error" style={{ marginBottom: "12px" }}>{error}</p>}
|
{error && <p className="form-error" style={{ marginBottom: "12px" }}>{error}</p>}
|
||||||
<button id="login-btn" type="submit" className="btn btn-primary w-full" disabled={loading} style={{ height: "44px" }}>
|
<button id="login-btn" type="submit" className="btn btn-primary w-full" disabled={loading} style={{ height: "44px" }}>
|
||||||
{loading ? "Signing in..." : "Sign in"}
|
{loading ? t("signingIn") : t("signIn")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p className="auth-footer">
|
<p className="auth-footer">
|
||||||
Don't have an account?{" "}
|
{t("dontHaveAccount")}{" "}
|
||||||
<Link href="/register" className="auth-link">Create one</Link>
|
<Link href="/register" className="auth-link">{t("createAccount")}</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+73
-21
@@ -1,43 +1,95 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SessionProvider } from "next-auth/react";
|
import { SessionProvider } from "next-auth/react";
|
||||||
import { useEffect } from "react";
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
import { I18nProvider } from "@/lib/i18n";
|
import { I18nProvider } from "@/lib/i18n";
|
||||||
|
|
||||||
export type ThemeMode = "system" | "light" | "dark";
|
export type ThemeMode = "system" | "light" | "dark";
|
||||||
|
|
||||||
export function applyTheme(mode: ThemeMode) {
|
interface ThemeContextType {
|
||||||
const isDark =
|
theme: ThemeMode;
|
||||||
mode === "dark" ||
|
resolvedTheme: "light" | "dark";
|
||||||
(mode === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
setTheme: (mode: ThemeMode) => void;
|
||||||
document.documentElement.setAttribute("data-theme", isDark ? "dark" : "light");
|
toggleTheme: () => void;
|
||||||
localStorage.setItem("theme", mode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
const ThemeContext = createContext<ThemeContextType>({
|
||||||
|
theme: "system",
|
||||||
|
resolvedTheme: "light",
|
||||||
|
setTheme: () => {},
|
||||||
|
toggleTheme: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useTheme = () => useContext(ThemeContext);
|
||||||
|
|
||||||
|
function ThemeManager({ children }: { children: React.ReactNode }) {
|
||||||
|
const [theme, setThemeState] = useState<ThemeMode>("system");
|
||||||
|
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("light");
|
||||||
|
|
||||||
|
const applyTheme = (mode: ThemeMode) => {
|
||||||
|
let effective: "light" | "dark" = "light";
|
||||||
|
if (mode === "system") {
|
||||||
|
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
effective = isDark ? "dark" : "light";
|
||||||
|
} else {
|
||||||
|
effective = mode;
|
||||||
|
}
|
||||||
|
setResolvedTheme(effective);
|
||||||
|
document.documentElement.setAttribute("data-theme", effective);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Apply saved theme
|
const saved = localStorage.getItem("checkflow_theme") as ThemeMode | null;
|
||||||
const saved = (localStorage.getItem("theme") as ThemeMode) || "system";
|
const initial = saved || "system";
|
||||||
applyTheme(saved);
|
setThemeState(initial);
|
||||||
|
applyTheme(initial);
|
||||||
|
|
||||||
// Watch system preference changes (only affects "system" mode)
|
// Listen for system theme changes if set to system
|
||||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
const onMqChange = () => {
|
const listener = (e: MediaQueryListEvent) => {
|
||||||
const current = (localStorage.getItem("theme") as ThemeMode) || "system";
|
const current = (localStorage.getItem("checkflow_theme") as ThemeMode) || "system";
|
||||||
if (current === "system") applyTheme("system");
|
if (current === "system") {
|
||||||
|
const effective = e.matches ? "dark" : "light";
|
||||||
|
setResolvedTheme(effective);
|
||||||
|
document.documentElement.setAttribute("data-theme", effective);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
mq.addEventListener("change", onMqChange);
|
|
||||||
|
|
||||||
// Service Worker
|
media.addEventListener("change", listener);
|
||||||
|
|
||||||
|
// Register Service Worker
|
||||||
if ("serviceWorker" in navigator) {
|
if ("serviceWorker" in navigator) {
|
||||||
navigator.serviceWorker.register("/sw.js").catch(console.error);
|
navigator.serviceWorker.register("/sw.js").catch(console.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => mq.removeEventListener("change", onMqChange);
|
return () => media.removeEventListener("change", listener);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setTheme = (mode: ThemeMode) => {
|
||||||
|
setThemeState(mode);
|
||||||
|
localStorage.setItem("checkflow_theme", mode);
|
||||||
|
applyTheme(mode);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cycles through: System -> Light -> Dark -> System
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const sequence: ThemeMode[] = ["system", "light", "dark"];
|
||||||
|
const nextIndex = (sequence.indexOf(theme) + 1) % sequence.length;
|
||||||
|
setTheme(sequence[nextIndex]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme, toggleTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<I18nProvider>{children}</I18nProvider>
|
<I18nProvider>
|
||||||
|
<ThemeManager>{children}</ThemeManager>
|
||||||
|
</I18nProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+118
-31
@@ -1,11 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useI18n, Language } from "@/lib/i18n";
|
||||||
|
import { useTheme } from "@/app/providers";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { t, lang, setLang } = useI18n();
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -17,41 +22,123 @@ export default function RegisterPage() {
|
|||||||
setError("");
|
setError("");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const res = await fetch("/api/auth/register", {
|
try {
|
||||||
method: "POST",
|
const res = await fetch("/api/auth/register", {
|
||||||
headers: { "Content-Type": "application/json" },
|
method: "POST",
|
||||||
body: JSON.stringify({ name, email, password }),
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
body: JSON.stringify({ name, email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let msg = "Registration failed.";
|
let msg = "Registration failed.";
|
||||||
try {
|
try {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
msg = data.error || msg;
|
msg = data.error || msg;
|
||||||
} catch {
|
} catch {
|
||||||
// non-JSON response (e.g. 500 HTML page)
|
msg = "Database connection failed. Please ensure PostgreSQL is running or use Demo Mode.";
|
||||||
|
}
|
||||||
|
setError(msg);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
setError(msg);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await signIn("credentials", { email, password, redirect: false });
|
await signIn("credentials", { email, password, redirect: false });
|
||||||
router.push("/");
|
router.push("/");
|
||||||
|
} catch {
|
||||||
|
setError("Network or server error. Check database status or try Demo Mode.");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="auth-page">
|
<div className="auth-page">
|
||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
<div className="auth-logo">
|
{/* Top bar: Lang & Theme switcher */}
|
||||||
<div className="auth-logo-icon">✓</div>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 20 }}>
|
||||||
<span className="auth-logo-name">CheckFlow</span>
|
<div className="auth-logo" style={{ marginBottom: 0 }}>
|
||||||
|
<div className="auth-logo-icon">✓</div>
|
||||||
|
<span className="auth-logo-name">{t("appName")}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<select
|
||||||
|
aria-label={t("language")}
|
||||||
|
value={lang}
|
||||||
|
onChange={(e) => setLang(e.target.value as Language)}
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-secondary)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--radius-xs)",
|
||||||
|
fontSize: 11,
|
||||||
|
padding: "3px 6px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="en">EN</option>
|
||||||
|
<option value="ko">한국어</option>
|
||||||
|
<option value="ja">日本語</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
title={`${t("theme")}: ${theme}`}
|
||||||
|
style={{ width: 28, height: 28 }}
|
||||||
|
>
|
||||||
|
{theme === "system" ? (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" />
|
||||||
|
</svg>
|
||||||
|
) : theme === "light" ? (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="5" /><line x1="12" y1="1" x2="12" y2="3" /><line x1="12" y1="21" x2="12" y2="23" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="auth-title">Create account</h1>
|
|
||||||
<p className="auth-subtitle">Start organizing your tasks today</p>
|
<h1 className="auth-title">{t("createAccount")}</h1>
|
||||||
|
<p className="auth-subtitle">{t("createAccountSubtitle")}</p>
|
||||||
|
|
||||||
|
{/* Demo Mode Action Banner */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--accent-light)",
|
||||||
|
border: "1px dashed var(--accent)",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
padding: "12px 14px",
|
||||||
|
marginBottom: 20,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--accent)" }}>✨ {t("demoBadge")}</span>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>No PostgreSQL needed</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 12, color: "var(--text-secondary)", lineHeight: 1.4 }}>
|
||||||
|
Explore full 3-panel UI, sub-tasks, and markdown notes directly in browser storage.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/demo"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
style={{ marginTop: 4, width: "100%", textAlign: "center" }}
|
||||||
|
id="try-demo-btn"
|
||||||
|
>
|
||||||
|
🚀 {t("tryDemoMode")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Display Name</label>
|
<label className="form-label">{t("displayName")}</label>
|
||||||
<input
|
<input
|
||||||
id="name"
|
id="name"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -64,7 +151,7 @@ export default function RegisterPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Email</label>
|
<label className="form-label">{t("email")}</label>
|
||||||
<input
|
<input
|
||||||
id="reg-email"
|
id="reg-email"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -76,12 +163,12 @@ export default function RegisterPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Password</label>
|
<label className="form-label">{t("password")}</label>
|
||||||
<input
|
<input
|
||||||
id="reg-password"
|
id="reg-password"
|
||||||
type="password"
|
type="password"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
placeholder="Min. 8 characters"
|
placeholder={t("min8Chars")}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
@@ -90,12 +177,12 @@ export default function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
{error && <p className="form-error" style={{ marginBottom: "12px" }}>{error}</p>}
|
{error && <p className="form-error" style={{ marginBottom: "12px" }}>{error}</p>}
|
||||||
<button id="register-btn" type="submit" className="btn btn-primary w-full" disabled={loading} style={{ height: "44px" }}>
|
<button id="register-btn" type="submit" className="btn btn-primary w-full" disabled={loading} style={{ height: "44px" }}>
|
||||||
{loading ? "Creating account..." : "Create account"}
|
{loading ? t("creatingBtn") : t("createBtn")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p className="auth-footer">
|
<p className="auth-footer">
|
||||||
Already have an account?{" "}
|
{t("alreadyHaveAccount")}{" "}
|
||||||
<Link href="/login" className="auth-link">Sign in</Link>
|
<Link href="/login" className="auth-link">{t("signIn")}</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
import { TaskList } from "../tasks/TaskList";
|
import { TaskList, Task, List, User } from "../tasks/TaskList";
|
||||||
import { TaskDetail } from "../tasks/TaskDetail";
|
import { TaskDetail } from "../tasks/TaskDetail";
|
||||||
|
import { getDemoStore, saveDemoStore, MockList, MockTask } from "@/lib/mockData";
|
||||||
|
|
||||||
interface User { id: string; name?: string | null; email?: string | null; }
|
interface AppShellProps {
|
||||||
interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } }
|
user: User;
|
||||||
interface Task {
|
isDemo?: boolean;
|
||||||
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 }) {
|
export function AppShell({ user, isDemo = false }: AppShellProps) {
|
||||||
const [lists, setLists] = useState<List[]>([]);
|
const [lists, setLists] = useState<List[]>([]);
|
||||||
const [selectedListId, setSelectedListId] = useState<string | null>(null);
|
const [selectedListId, setSelectedListId] = useState<string | null>(null);
|
||||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||||
@@ -22,6 +19,28 @@ export function AppShell({ user }: { user: User }) {
|
|||||||
const [showCompleted, setShowCompleted] = useState(false);
|
const [showCompleted, setShowCompleted] = useState(false);
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
// Demo store loading
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDemo) {
|
||||||
|
const store = getDemoStore();
|
||||||
|
setLists(store.lists);
|
||||||
|
if (store.lists.length > 0) {
|
||||||
|
setSelectedListId(store.lists[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isDemo]);
|
||||||
|
|
||||||
|
// Demo tasks filter
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDemo && selectedListId) {
|
||||||
|
const store = getDemoStore();
|
||||||
|
const listTasks = (store.tasks as Task[]).filter(
|
||||||
|
(t) => t.listId === selectedListId && (showCompleted ? true : !t.completed)
|
||||||
|
);
|
||||||
|
setTasks(listTasks);
|
||||||
|
}
|
||||||
|
}, [isDemo, selectedListId, showCompleted, refreshKey]);
|
||||||
|
|
||||||
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
|
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
|
||||||
|
|
||||||
const handleTaskSelect = useCallback((task: Task | null) => {
|
const handleTaskSelect = useCallback((task: Task | null) => {
|
||||||
@@ -39,6 +58,89 @@ export function AppShell({ user }: { user: User }) {
|
|||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Demo Handlers
|
||||||
|
const handleDemoCreateList = (name: string, color: string) => {
|
||||||
|
const newList: MockList = {
|
||||||
|
id: "demo-list-" + Date.now(),
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
icon: "list",
|
||||||
|
};
|
||||||
|
const store = getDemoStore();
|
||||||
|
const newLists = [...store.lists, newList];
|
||||||
|
saveDemoStore(newLists, store.tasks);
|
||||||
|
setLists(newLists);
|
||||||
|
setSelectedListId(newList.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDemoDeleteList = (id: string) => {
|
||||||
|
const store = getDemoStore();
|
||||||
|
const newLists = store.lists.filter((l) => l.id !== id);
|
||||||
|
const newTasks = store.tasks.filter((t) => t.listId !== id);
|
||||||
|
saveDemoStore(newLists, newTasks);
|
||||||
|
setLists(newLists);
|
||||||
|
if (selectedListId === id) {
|
||||||
|
setSelectedListId(newLists[0]?.id || null);
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDemoAddTask = (title: string, listId: string) => {
|
||||||
|
const newTask: Task = {
|
||||||
|
id: "demo-task-" + Date.now(),
|
||||||
|
listId,
|
||||||
|
parentId: null,
|
||||||
|
title,
|
||||||
|
note: null,
|
||||||
|
completed: false,
|
||||||
|
completedAt: null,
|
||||||
|
dueDate: null,
|
||||||
|
priority: 0,
|
||||||
|
sortOrder: tasks.length,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
children: [],
|
||||||
|
tags: [],
|
||||||
|
};
|
||||||
|
const store = getDemoStore();
|
||||||
|
const newTasks = [...store.tasks, newTask as MockTask];
|
||||||
|
saveDemoStore(store.lists, newTasks);
|
||||||
|
setTasks((prev) => [...prev, newTask]);
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDemoToggleTask = (id: string, completed: boolean) => {
|
||||||
|
const store = getDemoStore();
|
||||||
|
const newTasks = store.tasks.map((t) => {
|
||||||
|
if (t.id === id) {
|
||||||
|
return { ...t, completed, completedAt: completed ? new Date().toISOString() : null };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
children: t.children?.map((c) => (c.id === id ? { ...c, completed } : c)) || [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
saveDemoStore(store.lists, newTasks);
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDemoUpdateTask = (updated: Task) => {
|
||||||
|
const store = getDemoStore();
|
||||||
|
const newTasks = store.tasks.map((t) => (t.id === updated.id ? (updated as MockTask) : t));
|
||||||
|
saveDemoStore(store.lists, newTasks);
|
||||||
|
setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
|
||||||
|
setSelectedTask(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDemoDeleteTask = (id: string) => {
|
||||||
|
const store = getDemoStore();
|
||||||
|
const newTasks = store.tasks.filter((t) => t.id !== id);
|
||||||
|
saveDemoStore(store.lists, newTasks);
|
||||||
|
setTasks((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
setSelectedTask(null);
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-layout">
|
<div className="app-layout">
|
||||||
{/* Mobile overlay */}
|
{/* Mobile overlay */}
|
||||||
@@ -58,6 +160,9 @@ export function AppShell({ user }: { user: User }) {
|
|||||||
onListSelect={handleListSelect}
|
onListSelect={handleListSelect}
|
||||||
mobileOpen={sidebarOpen}
|
mobileOpen={sidebarOpen}
|
||||||
onClose={() => setSidebarOpen(false)}
|
onClose={() => setSidebarOpen(false)}
|
||||||
|
isDemo={isDemo}
|
||||||
|
onDemoCreateList={handleDemoCreateList}
|
||||||
|
onDemoDeleteList={handleDemoDeleteList}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="main-content">
|
<div className="main-content">
|
||||||
@@ -74,6 +179,9 @@ export function AppShell({ user }: { user: User }) {
|
|||||||
onToggleCompleted={() => setShowCompleted((p) => !p)}
|
onToggleCompleted={() => setShowCompleted((p) => !p)}
|
||||||
onMenuOpen={() => setSidebarOpen(true)}
|
onMenuOpen={() => setSidebarOpen(true)}
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
|
isDemo={isDemo}
|
||||||
|
onDemoAddTask={handleDemoAddTask}
|
||||||
|
onDemoToggleTask={handleDemoToggleTask}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -82,8 +190,14 @@ export function AppShell({ user }: { user: User }) {
|
|||||||
task={selectedTask}
|
task={selectedTask}
|
||||||
onClose={() => setSelectedTask(null)}
|
onClose={() => setSelectedTask(null)}
|
||||||
onUpdate={handleTaskUpdate}
|
onUpdate={handleTaskUpdate}
|
||||||
onDelete={() => { setSelectedTask(null); refresh(); }}
|
onDelete={() => {
|
||||||
|
setSelectedTask(null);
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
listId={selectedTask.listId}
|
listId={selectedTask.listId}
|
||||||
|
isDemo={isDemo}
|
||||||
|
onDemoUpdateTask={handleDemoUpdateTask}
|
||||||
|
onDemoDeleteTask={handleDemoDeleteTask}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -93,7 +207,6 @@ export function AppShell({ user }: { user: User }) {
|
|||||||
id="mobile-add-task"
|
id="mobile-add-task"
|
||||||
aria-label="Add task"
|
aria-label="Add task"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// trigger add task from task list — use custom event
|
|
||||||
document.dispatchEvent(new CustomEvent("checkflow:addTask"));
|
document.dispatchEvent(new CustomEvent("checkflow:addTask"));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,88 +1,131 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
|
import { useI18n, Language } from "@/lib/i18n";
|
||||||
|
import { useTheme, ThemeMode } from "@/app/providers";
|
||||||
|
|
||||||
interface User { id: string; name?: string | null; email?: string | null }
|
interface User { id: string; name?: string | null; email?: string | null }
|
||||||
interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } }
|
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_COLORS = ["#4B7BF5", "#EF4444", "#10B981", "#F59E0B", "#8B5CF6", "#EC4899", "#06B6D4", "#F97316", "#6366F1", "#14B8A6"];
|
||||||
const LIST_ICONS: Record<string, string> = { list:"≡", inbox:"⌂", star:"★", work:"💼", personal:"👤", shopping:"🛒", health:"❤️", study:"📚" };
|
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
user: User; lists: List[]; setLists: (l: List[]) => void;
|
user: User;
|
||||||
selectedListId: string | null; onListSelect: (id: string) => void;
|
lists: List[];
|
||||||
mobileOpen: boolean; onClose: () => void;
|
setLists: React.Dispatch<React.SetStateAction<List[]>>;
|
||||||
|
selectedListId: string | null;
|
||||||
|
onListSelect: (id: string) => void;
|
||||||
|
mobileOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
isDemo?: boolean;
|
||||||
|
onDemoCreateList?: (name: string, color: string) => void;
|
||||||
|
onDemoDeleteList?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ user, lists, setLists, selectedListId, onListSelect, mobileOpen, onClose }: SidebarProps) {
|
export function Sidebar({
|
||||||
|
user,
|
||||||
|
lists,
|
||||||
|
setLists,
|
||||||
|
selectedListId,
|
||||||
|
onListSelect,
|
||||||
|
mobileOpen,
|
||||||
|
onClose,
|
||||||
|
isDemo = false,
|
||||||
|
onDemoCreateList,
|
||||||
|
onDemoDeleteList,
|
||||||
|
}: SidebarProps) {
|
||||||
|
const { t, lang, setLang } = useI18n();
|
||||||
|
const { theme, setTheme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
const [showNewList, setShowNewList] = useState(false);
|
const [showNewList, setShowNewList] = useState(false);
|
||||||
const [newListName, setNewListName] = useState("");
|
const [newListName, setNewListName] = useState("");
|
||||||
const [newListColor, setNewListColor] = useState(LIST_COLORS[0]);
|
const [newListColor, setNewListColor] = useState(LIST_COLORS[0]);
|
||||||
const [editList, setEditList] = useState<List | null>(null);
|
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
const [showImport, setShowImport] = useState(false);
|
const [showImport, setShowImport] = useState(false);
|
||||||
const [importListId, setImportListId] = useState("");
|
const [importListId, setImportListId] = useState("");
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const [importResult, setImportResult] = useState("");
|
const [importResult, setImportResult] = useState("");
|
||||||
const [theme, setTheme] = useState("light");
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = localStorage.getItem("theme") || "light";
|
if (!isDemo) {
|
||||||
setTheme(t);
|
fetch("/api/lists")
|
||||||
}, []);
|
.then((r) => (r.ok ? r.json() : []))
|
||||||
|
.then((data) => {
|
||||||
useEffect(() => {
|
if (Array.isArray(data)) {
|
||||||
fetch("/api/lists")
|
setLists(data);
|
||||||
.then((r) => r.json())
|
if (!selectedListId && data.length > 0) onListSelect(data[0].id);
|
||||||
.then((data) => {
|
}
|
||||||
if (Array.isArray(data)) {
|
})
|
||||||
setLists(data);
|
.catch((err) => console.error("Failed to load lists", err));
|
||||||
if (!selectedListId && data.length > 0) onListSelect(data[0].id);
|
}
|
||||||
}
|
}, [isDemo]);
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showNewList) setTimeout(() => inputRef.current?.focus(), 50);
|
if (showNewList) setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
}, [showNewList]);
|
}, [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 createList = async () => {
|
||||||
const name = newListName.trim();
|
const name = newListName.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
const res = await fetch("/api/lists", {
|
|
||||||
method: "POST",
|
if (isDemo) {
|
||||||
headers: { "Content-Type": "application/json" },
|
if (onDemoCreateList) onDemoCreateList(name, newListColor);
|
||||||
body: JSON.stringify({ name, color: newListColor }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const list = await res.json();
|
|
||||||
setLists([...lists, list]);
|
|
||||||
setNewListName("");
|
setNewListName("");
|
||||||
setShowNewList(false);
|
setShowNewList(false);
|
||||||
onListSelect(list.id);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
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((prev) => [...prev, list]);
|
||||||
|
setNewListName("");
|
||||||
|
setShowNewList(false);
|
||||||
|
onListSelect(list.id);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create list", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteList = async (id: string) => {
|
const deleteList = async (id: string) => {
|
||||||
if (!confirm("Delete this list and all its tasks?")) return;
|
if (!confirm(t("deleteListConfirm"))) return;
|
||||||
await fetch(`/api/lists/${id}`, { method: "DELETE" });
|
|
||||||
const updated = lists.filter((l) => l.id !== id);
|
if (isDemo) {
|
||||||
setLists(updated);
|
if (onDemoDeleteList) onDemoDeleteList(id);
|
||||||
if (selectedListId === id) onListSelect(updated[0]?.id || "");
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/lists/${id}`, { method: "DELETE" });
|
||||||
|
setLists((prev) => {
|
||||||
|
const updated = prev.filter((l) => l.id !== id);
|
||||||
|
if (selectedListId === id) onListSelect(updated[0]?.id || "");
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete list", err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImport = async () => {
|
const handleImport = async () => {
|
||||||
const file = fileRef.current?.files?.[0];
|
const file = fileRef.current?.files?.[0];
|
||||||
if (!file || !importListId) return;
|
if (!file || !importListId) return;
|
||||||
|
|
||||||
|
if (isDemo) {
|
||||||
|
setImportResult("✓ Demo Mode: Import simulated successfully");
|
||||||
|
setTimeout(() => { setShowImport(false); setImportResult(""); }, 1500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setImporting(true);
|
setImporting(true);
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -92,11 +135,12 @@ export function Sidebar({ user, lists, setLists, selectedListId, onListSelect, m
|
|||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setImportResult(`✓ Imported ${data.imported} tasks`);
|
setImportResult(`✓ Imported ${data.imported} tasks`);
|
||||||
// Reset file input
|
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
// Refresh list counts
|
|
||||||
const listsRes = await fetch("/api/lists");
|
const listsRes = await fetch("/api/lists");
|
||||||
if (listsRes.ok) { const updated = await listsRes.json(); if (Array.isArray(updated)) setLists(updated); }
|
if (listsRes.ok) {
|
||||||
|
const updated = await listsRes.json();
|
||||||
|
if (Array.isArray(updated)) setLists(updated);
|
||||||
|
}
|
||||||
setTimeout(() => { setShowImport(false); setImportResult(""); }, 2000);
|
setTimeout(() => { setShowImport(false); setImportResult(""); }, 2000);
|
||||||
} else {
|
} else {
|
||||||
setImportResult(`Error: ${data.error || "Import failed"}`);
|
setImportResult(`Error: ${data.error || "Import failed"}`);
|
||||||
@@ -116,20 +160,69 @@ export function Sidebar({ user, lists, setLists, selectedListId, onListSelect, m
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="sidebar-header">
|
<div className="sidebar-header">
|
||||||
<div className="sidebar-logo">✓</div>
|
<div className="sidebar-logo">✓</div>
|
||||||
<span className="sidebar-title">CheckFlow</span>
|
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||||
<button className="icon-btn" id="theme-toggle" onClick={toggleTheme} title="Toggle theme" style={{ marginLeft: "auto" }}>
|
<span className="sidebar-title">{t("appName")}</span>
|
||||||
{theme === "light" ? (
|
{isDemo && (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
<span style={{ fontSize: 10, color: "var(--accent)", fontWeight: 700, letterSpacing: 0.3 }}>
|
||||||
) : (
|
{t("demoBadge")}
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
|
{/* Top Quick Actions (Lang & Theme) */}
|
||||||
|
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 4 }}>
|
||||||
|
{/* Language Selector */}
|
||||||
|
<select
|
||||||
|
aria-label={t("language")}
|
||||||
|
value={lang}
|
||||||
|
onChange={(e) => setLang(e.target.value as Language)}
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-secondary)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--radius-xs)",
|
||||||
|
fontSize: 11,
|
||||||
|
padding: "3px 4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="en">EN</option>
|
||||||
|
<option value="ko">한국어</option>
|
||||||
|
<option value="ja">日本語</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Theme 3-Way Toggle Button */}
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
id="theme-toggle"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
title={`${t("theme")}: ${theme === "system" ? t("themeSystem") : theme === "light" ? t("themeLight") : t("themeDark")}`}
|
||||||
|
style={{ width: 28, height: 28 }}
|
||||||
|
>
|
||||||
|
{theme === "system" ? (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||||
|
<line x1="8" y1="21" x2="16" y2="21" />
|
||||||
|
<line x1="12" y1="17" x2="12" y2="21" />
|
||||||
|
</svg>
|
||||||
|
) : theme === "light" ? (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="5" /><line x1="12" y1="1" x2="12" y2="3" /><line x1="12" y1="21" x2="12" y2="23" /><line x1="4.22" y1="4.22" x2="5.64" y2="5.64" /><line x1="18.36" y1="18.36" x2="19.78" y2="19.78" /><line x1="1" y1="12" x2="3" y2="12" /><line x1="21" y1="12" x2="23" y2="12" /><line x1="4.22" y1="19.78" x2="5.64" y2="18.36" /><line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
<nav className="sidebar-nav">
|
<nav className="sidebar-nav">
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
<div className="sidebar-section-label">Lists</div>
|
<div className="sidebar-section-label">{t("lists")}</div>
|
||||||
{lists.map((list) => (
|
{lists.map((list) => (
|
||||||
<div
|
<div
|
||||||
key={list.id}
|
key={list.id}
|
||||||
@@ -143,10 +236,15 @@ export function Sidebar({ user, lists, setLists, selectedListId, onListSelect, m
|
|||||||
<button
|
<button
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
style={{ width: 22, height: 22, opacity: 0.5 }}
|
style={{ width: 22, height: 22, opacity: 0.5 }}
|
||||||
onClick={(e) => { e.stopPropagation(); deleteList(list.id); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteList(list.id);
|
||||||
|
}}
|
||||||
title="Delete list"
|
title="Delete list"
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -168,28 +266,35 @@ export function Sidebar({ user, lists, setLists, selectedListId, onListSelect, m
|
|||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className="form-input"
|
className="form-input"
|
||||||
placeholder="List name"
|
placeholder={t("listNamePlaceholder")}
|
||||||
value={newListName}
|
value={newListName}
|
||||||
onChange={(e) => setNewListName(e.target.value)}
|
onChange={(e) => setNewListName(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") createList(); if (e.key === "Escape") setShowNewList(false); }}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") createList();
|
||||||
|
if (e.key === "Escape") setShowNewList(false);
|
||||||
|
}}
|
||||||
style={{ marginBottom: 6 }}
|
style={{ marginBottom: 6 }}
|
||||||
/>
|
/>
|
||||||
<div style={{ display: "flex", gap: 6 }}>
|
<div style={{ display: "flex", gap: 6 }}>
|
||||||
<button className="btn btn-primary btn-sm" style={{ flex: 1 }} onClick={createList}>Create</button>
|
<button className="btn btn-primary btn-sm" style={{ flex: 1 }} onClick={createList}>{t("create")}</button>
|
||||||
<button className="btn btn-ghost btn-sm" onClick={() => setShowNewList(false)}>Cancel</button>
|
<button className="btn btn-ghost btn-sm" onClick={() => setShowNewList(false)}>{t("cancel")}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button id="new-list-btn" className="sidebar-add-btn" onClick={() => setShowNewList(true)}>
|
<button id="new-list-btn" className="sidebar-add-btn" onClick={() => setShowNewList(true)}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
New List
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
{t("newList")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="sidebar-section" style={{ borderTop: "1px solid var(--border)", paddingTop: 12, marginTop: 4 }}>
|
<div className="sidebar-section" style={{ borderTop: "1px solid var(--border)", paddingTop: 12, marginTop: 4 }}>
|
||||||
<button id="import-btn" className="sidebar-add-btn" onClick={() => { setShowImport(true); setImportListId(lists[0]?.id || ""); }}>
|
<button id="import-btn" className="sidebar-add-btn" onClick={() => { setShowImport(true); setImportListId(lists[0]?.id || ""); }}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
Import Tasks
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" />
|
||||||
|
</svg>
|
||||||
|
{t("importTasks")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -199,15 +304,29 @@ export function Sidebar({ user, lists, setLists, selectedListId, onListSelect, m
|
|||||||
<div className="user-card" id="user-menu-btn" onClick={() => setUserMenuOpen((p) => !p)} style={{ position: "relative" }}>
|
<div className="user-card" id="user-menu-btn" onClick={() => setUserMenuOpen((p) => !p)} style={{ position: "relative" }}>
|
||||||
<div className="user-avatar" style={{ background: "#4B7BF5" }}>{initials}</div>
|
<div className="user-avatar" style={{ background: "#4B7BF5" }}>{initials}</div>
|
||||||
<div className="user-info">
|
<div className="user-info">
|
||||||
<div className="user-name">{user.name}</div>
|
<div className="user-name">{user.name || (isDemo ? "Demo User" : "User")}</div>
|
||||||
<div className="user-email">{user.email}</div>
|
<div className="user-email">{user.email || (isDemo ? "demo@checkflow.local" : "")}</div>
|
||||||
</div>
|
</div>
|
||||||
{userMenuOpen && (
|
{userMenuOpen && (
|
||||||
<div className="dropdown" style={{ bottom: "100%", left: 0, right: 0, marginBottom: 4 }}>
|
<div className="dropdown" style={{ bottom: "100%", left: 0, right: 0, marginBottom: 4 }}>
|
||||||
<div className="dropdown-item danger" id="signout-btn" onClick={() => signOut({ callbackUrl: "/login" })}>
|
{isDemo ? (
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
<div
|
||||||
Sign out
|
className="dropdown-item"
|
||||||
</div>
|
onClick={() => { window.location.href = "/login"; }}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><polyline points="10 17 15 12 10 7" /><line x1="15" y1="12" x2="3" y2="12" />
|
||||||
|
</svg>
|
||||||
|
{t("signIn")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="dropdown-item danger" id="signout-btn" onClick={() => signOut({ callbackUrl: "/login" })}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" />
|
||||||
|
</svg>
|
||||||
|
{t("signOut")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -217,25 +336,29 @@ export function Sidebar({ user, lists, setLists, selectedListId, onListSelect, m
|
|||||||
{showImport && (
|
{showImport && (
|
||||||
<div className="modal-overlay" onClick={() => setShowImport(false)}>
|
<div className="modal-overlay" onClick={() => setShowImport(false)}>
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<h2 className="modal-title">Import Tasks</h2>
|
<h2 className="modal-title">{t("importModalTitle")}</h2>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Target List</label>
|
<label className="form-label">{t("targetList")}</label>
|
||||||
<select className="form-input" value={importListId} onChange={(e) => setImportListId(e.target.value)}>
|
<select className="form-input" value={importListId} onChange={(e) => setImportListId(e.target.value)}>
|
||||||
{lists.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
|
{lists.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">File (CSV or ICS from TickTick)</label>
|
<label className="form-label">{t("fileSelectLabel")}</label>
|
||||||
<input ref={fileRef} type="file" accept=".csv,.ics" className="form-input" />
|
<input ref={fileRef} type="file" accept=".csv,.ics" className="form-input" />
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginBottom: 12 }}>
|
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginBottom: 12 }}>
|
||||||
TickTick: Settings → Export → Export as CSV or iCalendar
|
{t("tickTickExportHint")}
|
||||||
</p>
|
</p>
|
||||||
{importResult && <p style={{ fontSize: 13, color: importResult.startsWith("✓") ? "var(--success)" : "var(--danger)", marginBottom: 12 }}>{importResult}</p>}
|
{importResult && (
|
||||||
|
<p style={{ fontSize: 13, color: importResult.startsWith("✓") ? "var(--success)" : "var(--danger)", marginBottom: 12 }}>
|
||||||
|
{importResult}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button className="btn btn-ghost" onClick={() => setShowImport(false)}>Cancel</button>
|
<button className="btn btn-ghost" onClick={() => setShowImport(false)}>{t("cancel")}</button>
|
||||||
<button id="import-submit-btn" className="btn btn-primary" onClick={handleImport} disabled={importing}>
|
<button id="import-submit-btn" className="btn btn-primary" onClick={handleImport} disabled={importing}>
|
||||||
{importing ? "Importing..." : "Import"}
|
{importing ? t("importing") : t("importBtn")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+282
-147
@@ -1,28 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
interface Task {
|
import { Task } from "./TaskList";
|
||||||
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 {
|
interface Props {
|
||||||
task: Task; listId: string;
|
task: Task;
|
||||||
|
listId: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onUpdate: (t: Task) => void;
|
onUpdate: (t: Task) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
isDemo?: boolean;
|
||||||
|
onDemoUpdateTask?: (updated: Task) => void;
|
||||||
|
onDemoDeleteTask?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props) {
|
export function TaskDetail({
|
||||||
|
task,
|
||||||
|
listId,
|
||||||
|
onClose,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
isDemo = false,
|
||||||
|
onDemoUpdateTask,
|
||||||
|
onDemoDeleteTask,
|
||||||
|
}: Props) {
|
||||||
|
const { t, lang } = useI18n();
|
||||||
|
|
||||||
const [title, setTitle] = useState(task.title);
|
const [title, setTitle] = useState(task.title);
|
||||||
const [note, setNote] = useState(task.note || "");
|
const [note, setNote] = useState(task.note || "");
|
||||||
const [dueDate, setDueDate] = useState(task.dueDate ? task.dueDate.split("T")[0] : "");
|
const [dueDate, setDueDate] = useState(task.dueDate ? task.dueDate.split("T")[0] : "");
|
||||||
@@ -31,12 +34,18 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
|||||||
const [subtasks, setSubtasks] = useState(task.children || []);
|
const [subtasks, setSubtasks] = useState(task.children || []);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
// Store timer + current task id in refs to prevent stale saves across task switches
|
|
||||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const currentTaskId = useRef(task.id);
|
const currentTaskId = useRef(task.id);
|
||||||
const noteRef = useRef<HTMLTextAreaElement>(null);
|
const noteRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
// Sync state when switching to a different task — clear any pending timer first
|
const priorityMap = [
|
||||||
|
{ label: t("priorityNone"), color: "var(--text-tertiary)", icon: "" },
|
||||||
|
{ label: t("priorityLow"), color: "var(--priority-low)", icon: "▼" },
|
||||||
|
{ label: t("priorityMedium"), color: "var(--priority-medium)", icon: "▶" },
|
||||||
|
{ label: t("priorityHigh"), color: "var(--priority-high)", icon: "▲" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sync state when switching task
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (saveTimer.current) {
|
if (saveTimer.current) {
|
||||||
clearTimeout(saveTimer.current);
|
clearTimeout(saveTimer.current);
|
||||||
@@ -48,100 +57,172 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
|||||||
setDueDate(task.dueDate ? task.dueDate.split("T")[0] : "");
|
setDueDate(task.dueDate ? task.dueDate.split("T")[0] : "");
|
||||||
setPriority(task.priority);
|
setPriority(task.priority);
|
||||||
setSubtasks(task.children || []);
|
setSubtasks(task.children || []);
|
||||||
}, [task.id]);
|
}, [task.id, task.title, task.note, task.dueDate, task.priority, task.children]);
|
||||||
|
|
||||||
// Cleanup timer on unmount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const save = useCallback(async (taskId: string, data: Record<string, unknown>) => {
|
const save = useCallback(
|
||||||
// Guard: don't save if task has changed since debounce was scheduled
|
async (taskId: string, data: Record<string, unknown>) => {
|
||||||
if (taskId !== currentTaskId.current) return;
|
if (taskId !== currentTaskId.current) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/tasks/${taskId}`, {
|
if (isDemo) {
|
||||||
method: "PATCH",
|
const updatedTask: Task = {
|
||||||
headers: { "Content-Type": "application/json" },
|
...task,
|
||||||
body: JSON.stringify(data),
|
...data,
|
||||||
|
children: subtasks,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
} as Task;
|
||||||
|
if (onDemoUpdateTask) onDemoUpdateTask(updatedTask);
|
||||||
|
onUpdate(updatedTask);
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
if (taskId === currentTaskId.current) {
|
||||||
|
onUpdate({ ...updated, children: subtasks });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[TaskDetail] save failed", err);
|
||||||
|
} finally {
|
||||||
|
if (taskId === currentTaskId.current) setSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isDemo, onDemoUpdateTask, onUpdate, subtasks, task]
|
||||||
|
);
|
||||||
|
|
||||||
|
const debounceSave = useCallback(
|
||||||
|
(overrides: Record<string, unknown>) => {
|
||||||
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||||
|
const taskId = currentTaskId.current;
|
||||||
|
saveTimer.current = setTimeout(() => save(taskId, overrides), 600);
|
||||||
|
},
|
||||||
|
[save]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert markdown at cursor position
|
||||||
|
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 });
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
ta.focus();
|
||||||
|
ta.selectionStart = start + before.length;
|
||||||
|
ta.selectionEnd = start + before.length + selected.length;
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
},
|
||||||
const updated = await res.json();
|
[debounceSave]
|
||||||
// Only update if we're still on the same task
|
);
|
||||||
if (taskId === currentTaskId.current) {
|
|
||||||
|
const handleToggleCompleted = useCallback(
|
||||||
|
async (newCompleted: boolean) => {
|
||||||
|
if (isDemo) {
|
||||||
|
const updatedTask: Task = {
|
||||||
|
...task,
|
||||||
|
completed: newCompleted,
|
||||||
|
completedAt: newCompleted ? new Date().toISOString() : null,
|
||||||
|
children: subtasks,
|
||||||
|
};
|
||||||
|
if (onDemoUpdateTask) onDemoUpdateTask(updatedTask);
|
||||||
|
onUpdate(updatedTask);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
onUpdate({ ...updated, children: subtasks });
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[TaskDetail] toggle failed", err);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
},
|
||||||
console.error("[TaskDetail] save failed", err);
|
[isDemo, onDemoUpdateTask, task, subtasks, onUpdate]
|
||||||
} finally {
|
);
|
||||||
if (taskId === currentTaskId.current) setSaving(false);
|
|
||||||
}
|
|
||||||
}, [onUpdate, subtasks]);
|
|
||||||
|
|
||||||
const debounceSave = useCallback((overrides: Record<string, unknown>) => {
|
|
||||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
|
||||||
const taskId = currentTaskId.current;
|
|
||||||
saveTimer.current = setTimeout(() => save(taskId, overrides), 800);
|
|
||||||
}, [save]);
|
|
||||||
|
|
||||||
// Insert text at cursor position in the note textarea
|
|
||||||
const insertAtCursor = useCallback((before: string, after = "", placeholder = "") => {
|
|
||||||
const ta = noteRef.current;
|
|
||||||
if (!ta) {
|
|
||||||
setNote((n) => { const v = n + before + placeholder + after; debounceSave({ note: v }); return v; });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const start = ta.selectionStart ?? ta.value.length;
|
|
||||||
const end = ta.selectionEnd ?? ta.value.length;
|
|
||||||
const selected = ta.value.slice(start, end) || placeholder;
|
|
||||||
const newVal = ta.value.slice(0, start) + before + selected + after + ta.value.slice(end);
|
|
||||||
setNote(newVal);
|
|
||||||
debounceSave({ note: newVal });
|
|
||||||
// Restore cursor after React re-render
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
ta.focus();
|
|
||||||
ta.selectionStart = start + before.length;
|
|
||||||
ta.selectionEnd = start + before.length + selected.length;
|
|
||||||
});
|
|
||||||
}, [debounceSave]);
|
|
||||||
|
|
||||||
const handleToggleCompleted = useCallback(async (newCompleted: boolean) => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/tasks/${task.id}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ completed: newCompleted }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const updated = await res.json();
|
|
||||||
onUpdate({ ...updated, children: subtasks });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[TaskDetail] toggle failed", err);
|
|
||||||
}
|
|
||||||
}, [task.id, subtasks, onUpdate]);
|
|
||||||
|
|
||||||
const handleDelete = useCallback(async () => {
|
const handleDelete = useCallback(async () => {
|
||||||
if (!confirm("Delete this task?")) return;
|
if (!confirm(t("deleteTaskConfirm"))) return;
|
||||||
|
|
||||||
|
if (isDemo) {
|
||||||
|
if (onDemoDeleteTask) onDemoDeleteTask(task.id);
|
||||||
|
onDelete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/tasks/${task.id}`, { method: "DELETE" });
|
await fetch(`/api/tasks/${task.id}`, { method: "DELETE" });
|
||||||
onDelete();
|
onDelete();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[TaskDetail] delete failed", err);
|
console.error("[TaskDetail] delete failed", err);
|
||||||
}
|
}
|
||||||
}, [task.id, onDelete]);
|
}, [t, isDemo, onDemoDeleteTask, task.id, onDelete]);
|
||||||
|
|
||||||
const addSubtask = useCallback(async () => {
|
const addSubtask = useCallback(async () => {
|
||||||
const t = newSubtitle.trim();
|
const tTitle = newSubtitle.trim();
|
||||||
if (!t) return;
|
if (!tTitle) return;
|
||||||
|
|
||||||
|
if (isDemo) {
|
||||||
|
const newSub: Task = {
|
||||||
|
id: "demo-sub-" + Date.now(),
|
||||||
|
listId,
|
||||||
|
parentId: task.id,
|
||||||
|
title: tTitle,
|
||||||
|
note: null,
|
||||||
|
completed: false,
|
||||||
|
completedAt: null,
|
||||||
|
dueDate: null,
|
||||||
|
priority: 0,
|
||||||
|
sortOrder: subtasks.length,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
children: [],
|
||||||
|
tags: [],
|
||||||
|
};
|
||||||
|
const nextSubs = [...subtasks, newSub];
|
||||||
|
setSubtasks(nextSubs);
|
||||||
|
setNewSubtitle("");
|
||||||
|
const updatedParent = { ...task, children: nextSubs };
|
||||||
|
if (onDemoUpdateTask) onDemoUpdateTask(updatedParent);
|
||||||
|
onUpdate(updatedParent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/tasks", {
|
const res = await fetch("/api/tasks", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ title: t, listId, parentId: task.id }),
|
body: JSON.stringify({ title: tTitle, listId, parentId: task.id }),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const sub = await res.json();
|
const sub = await res.json();
|
||||||
@@ -155,40 +236,70 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[TaskDetail] addSubtask failed", err);
|
console.error("[TaskDetail] addSubtask failed", err);
|
||||||
}
|
}
|
||||||
}, [newSubtitle, listId, task, onUpdate]);
|
}, [newSubtitle, isDemo, listId, task, subtasks, onDemoUpdateTask, onUpdate]);
|
||||||
|
|
||||||
const toggleSubtask = useCallback(async (sub: Task) => {
|
const toggleSubtask = useCallback(
|
||||||
try {
|
async (sub: Task) => {
|
||||||
const res = await fetch(`/api/tasks/${sub.id}`, {
|
const newCompleted = !sub.completed;
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
if (isDemo) {
|
||||||
body: JSON.stringify({ completed: !sub.completed }),
|
const nextSubs = subtasks.map((s) =>
|
||||||
});
|
s.id === sub.id
|
||||||
if (res.ok) {
|
? { ...s, completed: newCompleted, completedAt: newCompleted ? new Date().toISOString() : null }
|
||||||
const updated = await res.json();
|
: s
|
||||||
|
);
|
||||||
|
setSubtasks(nextSubs);
|
||||||
|
const updatedParent = { ...task, children: nextSubs };
|
||||||
|
if (onDemoUpdateTask) onDemoUpdateTask(updatedParent);
|
||||||
|
onUpdate(updatedParent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/tasks/${sub.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ completed: newCompleted }),
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isDemo, subtasks, task, onDemoUpdateTask, onUpdate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteSubtask = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
if (isDemo) {
|
||||||
|
const nextSubs = subtasks.filter((s) => s.id !== id);
|
||||||
|
setSubtasks(nextSubs);
|
||||||
|
const updatedParent = { ...task, children: nextSubs };
|
||||||
|
if (onDemoUpdateTask) onDemoUpdateTask(updatedParent);
|
||||||
|
onUpdate(updatedParent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/tasks/${id}`, { method: "DELETE" });
|
||||||
setSubtasks((prev) => {
|
setSubtasks((prev) => {
|
||||||
const next = prev.map((s) => (s.id === sub.id ? updated : s));
|
const next = prev.filter((s) => s.id !== id);
|
||||||
onUpdate({ ...task, children: next });
|
onUpdate({ ...task, children: next });
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[TaskDetail] deleteSubtask failed", err);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
},
|
||||||
console.error("[TaskDetail] toggleSubtask failed", err);
|
[isDemo, subtasks, task, onDemoUpdateTask, onUpdate]
|
||||||
}
|
);
|
||||||
}, [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 completedCount = subtasks.filter((s) => s.completed).length;
|
||||||
const progressPct = subtasks.length > 0 ? Math.round((completedCount / subtasks.length) * 100) : 0;
|
const progressPct = subtasks.length > 0 ? Math.round((completedCount / subtasks.length) * 100) : 0;
|
||||||
@@ -204,13 +315,17 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
|||||||
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
|
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
|
||||||
/>
|
/>
|
||||||
<span style={{ flex: 1, fontSize: 12, color: "var(--text-tertiary)", fontWeight: 500 }}>
|
<span style={{ flex: 1, fontSize: 12, color: "var(--text-tertiary)", fontWeight: 500 }}>
|
||||||
{saving ? "Saving…" : "Auto-saved"}
|
{saving ? t("saving") : t("autoSaved")}
|
||||||
</span>
|
</span>
|
||||||
<button className="btn btn-ghost btn-sm btn-danger" id="delete-task-btn" onClick={handleDelete} title="Delete task">
|
<button className="btn btn-ghost btn-sm btn-danger" id="delete-task-btn" onClick={handleDelete} title={t("deleteTaskConfirm")}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button className="detail-close-btn" id="detail-close-btn" onClick={onClose} title="Close">
|
<button className="detail-close-btn" id="detail-close-btn" onClick={onClose} title={t("cancel")}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -225,7 +340,7 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
|||||||
setTitle(e.target.value);
|
setTitle(e.target.value);
|
||||||
debounceSave({ title: e.target.value });
|
debounceSave({ title: e.target.value });
|
||||||
}}
|
}}
|
||||||
placeholder="Task title"
|
placeholder={t("taskTitlePlaceholder")}
|
||||||
rows={2}
|
rows={2}
|
||||||
style={{
|
style={{
|
||||||
textDecoration: task.completed ? "line-through" : "none",
|
textDecoration: task.completed ? "line-through" : "none",
|
||||||
@@ -235,15 +350,18 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
|||||||
|
|
||||||
{/* Priority */}
|
{/* Priority */}
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<div className="detail-section-label">Priority</div>
|
<div className="detail-section-label">{t("priority")}</div>
|
||||||
<div className="priority-selector">
|
<div className="priority-selector">
|
||||||
{PRIORITY_MAP.map((p, i) => (
|
{priorityMap.map((p, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
id={`priority-${i}`}
|
id={`priority-${i}`}
|
||||||
className={`priority-btn${priority === i ? ` active-${p.label.toLowerCase()}` : ""}`}
|
className={`priority-btn${priority === i ? ` active-${["none", "low", "medium", "high"][i]}` : ""}`}
|
||||||
style={{ color: priority === i ? p.color : undefined }}
|
style={{ color: priority === i ? p.color : undefined }}
|
||||||
onClick={() => { setPriority(i); debounceSave({ priority: i }); }}
|
onClick={() => {
|
||||||
|
setPriority(i);
|
||||||
|
debounceSave({ priority: i });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{p.icon && <span style={{ color: p.color }}>{p.icon}</span>}
|
{p.icon && <span style={{ color: p.color }}>{p.icon}</span>}
|
||||||
{p.label}
|
{p.label}
|
||||||
@@ -254,7 +372,7 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
|||||||
|
|
||||||
{/* Due date */}
|
{/* Due date */}
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<div className="detail-section-label">Due Date</div>
|
<div className="detail-section-label">{t("dueDate")}</div>
|
||||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
<input
|
<input
|
||||||
id="due-date-input"
|
id="due-date-input"
|
||||||
@@ -270,32 +388,35 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
|||||||
{dueDate && (
|
{dueDate && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost btn-sm"
|
className="btn btn-ghost btn-sm"
|
||||||
onClick={() => { setDueDate(""); debounceSave({ dueDate: null }); }}
|
onClick={() => {
|
||||||
|
setDueDate("");
|
||||||
|
debounceSave({ dueDate: null });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Clear
|
{t("clear")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Note / Memo */}
|
{/* Notes — wide memo area */}
|
||||||
<div className="detail-section" style={{ flex: 1 }}>
|
<div className="detail-section" style={{ flex: 1 }}>
|
||||||
<div className="detail-section-label">Notes</div>
|
<div className="detail-section-label">{t("notes")}</div>
|
||||||
<div className="note-editor-wrap">
|
<div className="note-editor-wrap">
|
||||||
<div className="note-editor-toolbar">
|
<div className="note-editor-toolbar">
|
||||||
<button className="note-toolbar-btn" title="Bold (Ctrl+B)" onClick={() => insertAtCursor("**", "**", "bold")}>B</button>
|
<button className="note-toolbar-btn" title={t("bold")} onClick={() => insertAtCursor("**", "**", "bold")}>B</button>
|
||||||
<button className="note-toolbar-btn" title="Italic (Ctrl+I)" style={{ fontStyle: "italic" }} onClick={() => insertAtCursor("*", "*", "italic")}>I</button>
|
<button className="note-toolbar-btn" title={t("italic")} style={{ fontStyle: "italic" }} onClick={() => insertAtCursor("*", "*", "italic")}>I</button>
|
||||||
<button className="note-toolbar-btn" title="Heading" onClick={() => insertAtCursor("## ", "", "Heading")}>H</button>
|
<button className="note-toolbar-btn" title={t("heading")} onClick={() => insertAtCursor("## ", "", "Heading")}>H</button>
|
||||||
<button className="note-toolbar-btn" title="Bullet list" onClick={() => insertAtCursor("\n- ", "", "item")}>•</button>
|
<button className="note-toolbar-btn" title={t("bulletList")} onClick={() => insertAtCursor("\n- ", "", "item")}>•</button>
|
||||||
<button className="note-toolbar-btn" title="Numbered list" onClick={() => insertAtCursor("\n1. ", "", "item")}>1.</button>
|
<button className="note-toolbar-btn" title={t("numberedList")} onClick={() => insertAtCursor("\n1. ", "", "item")}>1.</button>
|
||||||
<button className="note-toolbar-btn" title="Checkbox" onClick={() => insertAtCursor("\n- [ ] ", "", "task")}>☐</button>
|
<button className="note-toolbar-btn" title={t("checkbox")} onClick={() => insertAtCursor("\n- [ ] ", "", "task")}>☐</button>
|
||||||
<button className="note-toolbar-btn" title="Code" style={{ fontFamily: "monospace", fontSize: 11 }} onClick={() => insertAtCursor("`", "`", "code")}>{"`"}</button>
|
<button className="note-toolbar-btn" title={t("code")} style={{ fontFamily: "monospace", fontSize: 11 }} onClick={() => insertAtCursor("`", "`", "code")}>{"`"}</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
ref={noteRef}
|
ref={noteRef}
|
||||||
id="note-textarea"
|
id="note-textarea"
|
||||||
className="note-textarea"
|
className="note-textarea"
|
||||||
placeholder={"Add notes, details, or anything you need to remember…\n\nMarkdown: **bold**, *italic*, # heading, - list, - [ ] checkbox"}
|
placeholder={t("notesPlaceholder")}
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setNote(e.target.value);
|
setNote(e.target.value);
|
||||||
@@ -308,9 +429,11 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
|||||||
{/* Sub-tasks */}
|
{/* Sub-tasks */}
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
|
||||||
<div className="detail-section-label">Sub-tasks</div>
|
<div className="detail-section-label">{t("subtasks")}</div>
|
||||||
{subtasks.length > 0 && (
|
{subtasks.length > 0 && (
|
||||||
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{completedCount}/{subtasks.length}</span>
|
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>
|
||||||
|
{completedCount}/{subtasks.length}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -339,7 +462,9 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
|||||||
onClick={() => deleteSubtask(sub.id)}
|
onClick={() => deleteSubtask(sub.id)}
|
||||||
aria-label="Delete subtask"
|
aria-label="Delete subtask"
|
||||||
>
|
>
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -347,16 +472,21 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
|||||||
<span style={{ fontSize: 16, lineHeight: 1, color: "var(--accent)" }}>+</span>
|
<span style={{ fontSize: 16, lineHeight: 1, color: "var(--accent)" }}>+</span>
|
||||||
<input
|
<input
|
||||||
id="add-subtask-input"
|
id="add-subtask-input"
|
||||||
placeholder="Add sub-task…"
|
placeholder={t("addSubtaskPlaceholder")}
|
||||||
style={{ flex: 1, background: "none", fontSize: 13, color: "var(--text-primary)" }}
|
style={{ flex: 1, background: "none", fontSize: 13, color: "var(--text-primary)" }}
|
||||||
value={newSubtitle}
|
value={newSubtitle}
|
||||||
onChange={(e) => setNewSubtitle(e.target.value)}
|
onChange={(e) => setNewSubtitle(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") { e.preventDefault(); addSubtask(); }
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
addSubtask();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{newSubtitle.trim() && (
|
{newSubtitle.trim() && (
|
||||||
<button className="btn btn-primary btn-sm" id="add-subtask-btn" onClick={addSubtask}>Add</button>
|
<button className="btn btn-primary btn-sm" id="add-subtask-btn" onClick={addSubtask}>
|
||||||
|
{t("add")}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -364,7 +494,12 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
|||||||
|
|
||||||
{/* Metadata */}
|
{/* Metadata */}
|
||||||
<div style={{ fontSize: 11, color: "var(--text-tertiary)", paddingTop: 8, borderTop: "1px solid var(--border)" }}>
|
<div style={{ fontSize: 11, color: "var(--text-tertiary)", paddingTop: 8, borderTop: "1px solid var(--border)" }}>
|
||||||
Created {new Date(task.createdAt).toLocaleDateString("ko-KR", { year: "numeric", month: "short", day: "numeric" })}
|
{t("created")}{" "}
|
||||||
|
{new Date(task.createdAt).toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
interface Task {
|
export interface Task {
|
||||||
id: string; listId: string; parentId: string | null; title: string; note: string | null;
|
id: string;
|
||||||
completed: boolean; completedAt: string | null; dueDate: string | null; priority: number;
|
listId: string;
|
||||||
sortOrder: number; createdAt: string; updatedAt: string;
|
parentId: string | null;
|
||||||
children: Task[]; tags: { tag: { id: string; name: string; color: string } }[];
|
title: string;
|
||||||
|
note: string | null;
|
||||||
|
completed: boolean;
|
||||||
|
completedAt: string | null;
|
||||||
|
dueDate: string | null;
|
||||||
|
priority: number;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
children: Task[];
|
||||||
|
tags: { tag: { id: string; name: string; color: string } }[];
|
||||||
}
|
}
|
||||||
interface List { id: string; name: string; color: string; icon: string }
|
|
||||||
interface User { id: string; name?: string | null; email?: string | null }
|
export interface List { id: string; name: string; color: string; icon: string }
|
||||||
|
export interface User { id: string; name?: string | null; email?: string | null }
|
||||||
|
|
||||||
const PRIORITY_COLORS = ["transparent", "var(--priority-low)", "var(--priority-medium)", "var(--priority-high)"];
|
const PRIORITY_COLORS = ["transparent", "var(--priority-low)", "var(--priority-medium)", "var(--priority-high)"];
|
||||||
const PRIORITY_LABELS = ["", "Low", "Medium", "High"];
|
|
||||||
|
|
||||||
function formatDate(d: string | null) {
|
|
||||||
if (!d) return null;
|
|
||||||
const date = new Date(d);
|
|
||||||
const now = new Date();
|
|
||||||
const isToday = date.toDateString() === now.toDateString();
|
|
||||||
const isTomorrow = date.toDateString() === new Date(now.getTime() + 86400000).toDateString();
|
|
||||||
if (isToday) return "Today";
|
|
||||||
if (isTomorrow) return "Tomorrow";
|
|
||||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOverdue(d: string | null) {
|
function isOverdue(d: string | null) {
|
||||||
if (!d) return false;
|
if (!d) return false;
|
||||||
@@ -30,14 +30,29 @@ function isOverdue(d: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TaskItemProps {
|
interface TaskItemProps {
|
||||||
task: Task; isSelected: boolean;
|
task: Task;
|
||||||
|
isSelected: boolean;
|
||||||
onSelect: (t: Task) => void;
|
onSelect: (t: Task) => void;
|
||||||
onToggle: (id: string, completed: boolean) => void;
|
onToggle: (id: string, completed: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
|
function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const { t, lang } = useI18n();
|
||||||
const completedChildren = task.children.filter((c) => c.completed).length;
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
const completedChildren = task.children?.filter((c) => c.completed).length || 0;
|
||||||
|
|
||||||
|
const formatDate = (d: string | null) => {
|
||||||
|
if (!d) return null;
|
||||||
|
const date = new Date(d);
|
||||||
|
const now = new Date();
|
||||||
|
const isToday = date.toDateString() === now.toDateString();
|
||||||
|
const isTomorrow = date.toDateString() === new Date(now.getTime() + 86400000).toDateString();
|
||||||
|
if (isToday) return t("today");
|
||||||
|
if (isTomorrow) return t("tomorrow");
|
||||||
|
return date.toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", { month: "short", day: "numeric" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const priorityLabels = [t("priorityNone"), t("priorityLow"), t("priorityMedium"), t("priorityHigh")];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -48,27 +63,49 @@ function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className={`task-check-btn${task.completed ? " checked" : ""}`}
|
className={`task-check-btn${task.completed ? " checked" : ""}`}
|
||||||
onClick={(e) => { e.stopPropagation(); onToggle(task.id, !task.completed); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggle(task.id, !task.completed);
|
||||||
|
}}
|
||||||
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
|
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
|
||||||
/>
|
/>
|
||||||
<div className="task-body">
|
<div className="task-body">
|
||||||
<div className="task-title">{task.title}</div>
|
<div className="task-title">{task.title}</div>
|
||||||
<div className="task-meta">
|
<div className="task-meta">
|
||||||
{task.priority > 0 && (
|
{task.priority > 0 && (
|
||||||
<div className="task-priority-dot" style={{ background: PRIORITY_COLORS[task.priority] }} title={PRIORITY_LABELS[task.priority]} />
|
<div
|
||||||
|
className="task-priority-dot"
|
||||||
|
style={{ background: PRIORITY_COLORS[task.priority] }}
|
||||||
|
title={priorityLabels[task.priority]}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{task.dueDate && (
|
{task.dueDate && (
|
||||||
<span className={`task-due${isOverdue(task.dueDate) && !task.completed ? " overdue" : ""}`}>
|
<span className={`task-due${isOverdue(task.dueDate) && !task.completed ? " overdue" : ""}`}>
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" />
|
||||||
|
</svg>
|
||||||
{formatDate(task.dueDate)}
|
{formatDate(task.dueDate)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{task.children.length > 0 && (
|
{task.children && task.children.length > 0 && (
|
||||||
<span
|
<span
|
||||||
className="task-sub-count"
|
className="task-sub-count"
|
||||||
onClick={(e) => { e.stopPropagation(); setExpanded((p) => !p); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setExpanded((p) => !p);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="9 18 15 12 9 6"/></svg>
|
<svg
|
||||||
|
width="10"
|
||||||
|
height="10"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
style={{ transform: expanded ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.15s" }}
|
||||||
|
>
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
{completedChildren}/{task.children.length}
|
{completedChildren}/{task.children.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -80,7 +117,7 @@ function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sub-tasks */}
|
{/* Sub-tasks */}
|
||||||
{task.children.length > 0 && expanded && (
|
{task.children && task.children.length > 0 && expanded && (
|
||||||
<div className="subtask-list">
|
<div className="subtask-list">
|
||||||
{task.children.map((child) => (
|
{task.children.map((child) => (
|
||||||
<div
|
<div
|
||||||
@@ -91,7 +128,10 @@ function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className={`subtask-check-btn${child.completed ? " checked" : ""}`}
|
className={`subtask-check-btn${child.completed ? " checked" : ""}`}
|
||||||
onClick={(e) => { e.stopPropagation(); onToggle(child.id, !child.completed); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggle(child.id, !child.completed);
|
||||||
|
}}
|
||||||
aria-label={child.completed ? "Mark incomplete" : "Mark complete"}
|
aria-label={child.completed ? "Mark incomplete" : "Mark complete"}
|
||||||
/>
|
/>
|
||||||
<span className="subtask-title">{child.title}</span>
|
<span className="subtask-title">{child.title}</span>
|
||||||
@@ -104,21 +144,46 @@ function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User; listId: string | null; lists: List[]; tasks: Task[];
|
user: User;
|
||||||
|
listId: string | null;
|
||||||
|
lists: List[];
|
||||||
|
tasks: Task[];
|
||||||
setTasks: React.Dispatch<React.SetStateAction<Task[]>>;
|
setTasks: React.Dispatch<React.SetStateAction<Task[]>>;
|
||||||
selectedTaskId: string | null; onTaskSelect: (t: Task | null) => void;
|
selectedTaskId: string | null;
|
||||||
showCompleted: boolean; onToggleCompleted: () => void;
|
onTaskSelect: (t: Task | null) => void;
|
||||||
onMenuOpen: () => void; onRefresh: () => void;
|
showCompleted: boolean;
|
||||||
|
onToggleCompleted: () => void;
|
||||||
|
onMenuOpen: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
isDemo?: boolean;
|
||||||
|
onDemoAddTask?: (title: string, listId: string) => void;
|
||||||
|
onDemoToggleTask?: (id: string, completed: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TaskList({ user, listId, lists, tasks, setTasks, selectedTaskId, onTaskSelect, showCompleted, onToggleCompleted, onMenuOpen, onRefresh }: Props) {
|
export function TaskList({
|
||||||
|
user,
|
||||||
|
listId,
|
||||||
|
lists,
|
||||||
|
tasks,
|
||||||
|
setTasks,
|
||||||
|
selectedTaskId,
|
||||||
|
onTaskSelect,
|
||||||
|
showCompleted,
|
||||||
|
onToggleCompleted,
|
||||||
|
onMenuOpen,
|
||||||
|
onRefresh,
|
||||||
|
isDemo = false,
|
||||||
|
onDemoAddTask,
|
||||||
|
onDemoToggleTask,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [newTaskTitle, setNewTaskTitle] = useState("");
|
const [newTaskTitle, setNewTaskTitle] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const currentList = lists.find((l) => l.id === listId);
|
const currentList = lists.find((l) => l.id === listId);
|
||||||
|
|
||||||
const fetchTasks = useCallback(async () => {
|
const fetchTasks = useCallback(async () => {
|
||||||
if (!listId) return;
|
if (!listId || isDemo) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/tasks?listId=${listId}&showCompleted=${showCompleted}`);
|
const res = await fetch(`/api/tasks?listId=${listId}&showCompleted=${showCompleted}`);
|
||||||
@@ -130,9 +195,11 @@ export function TaskList({ user, listId, lists, tasks, setTasks, selectedTaskId,
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [listId, showCompleted]);
|
}, [listId, showCompleted, isDemo, setTasks]);
|
||||||
|
|
||||||
useEffect(() => { fetchTasks(); }, [fetchTasks]);
|
useEffect(() => {
|
||||||
|
fetchTasks();
|
||||||
|
}, [fetchTasks]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = () => inputRef.current?.focus();
|
const handler = () => inputRef.current?.focus();
|
||||||
@@ -140,32 +207,48 @@ export function TaskList({ user, listId, lists, tasks, setTasks, selectedTaskId,
|
|||||||
return () => document.removeEventListener("checkflow:addTask", handler);
|
return () => document.removeEventListener("checkflow:addTask", handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleToggle = useCallback(async (id: string, completed: boolean) => {
|
const handleToggle = useCallback(
|
||||||
try {
|
async (id: string, completed: boolean) => {
|
||||||
const res = await fetch(`/api/tasks/${id}`, {
|
if (isDemo) {
|
||||||
method: "PATCH",
|
if (onDemoToggleTask) onDemoToggleTask(id, completed);
|
||||||
headers: { "Content-Type": "application/json" },
|
return;
|
||||||
body: JSON.stringify({ completed }),
|
}
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
try {
|
||||||
const updated = await res.json();
|
const res = await fetch(`/api/tasks/${id}`, {
|
||||||
// Functional update avoids stale closure on `tasks`
|
method: "PATCH",
|
||||||
setTasks((prev) =>
|
headers: { "Content-Type": "application/json" },
|
||||||
prev.map((t) => {
|
body: JSON.stringify({ completed }),
|
||||||
if (t.id === id) return { ...t, completed, completedAt: updated.completedAt };
|
});
|
||||||
return { ...t, children: t.children.map((c) => (c.id === id ? { ...c, completed } : c)) };
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
}).filter((t) => showCompleted || !t.completed)
|
const updated = await res.json();
|
||||||
);
|
setTasks((prev) =>
|
||||||
onRefresh();
|
prev
|
||||||
} catch (err) {
|
.map((t) => {
|
||||||
console.error("[TaskList] handleToggle failed", err);
|
if (t.id === id) return { ...t, completed, completedAt: updated.completedAt };
|
||||||
}
|
return { ...t, children: t.children?.map((c) => (c.id === id ? { ...c, completed } : c)) || [] };
|
||||||
}, [showCompleted, onRefresh]);
|
})
|
||||||
|
.filter((t) => showCompleted || !t.completed)
|
||||||
|
);
|
||||||
|
onRefresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[TaskList] handleToggle failed", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isDemo, onDemoToggleTask, showCompleted, onRefresh, setTasks]
|
||||||
|
);
|
||||||
|
|
||||||
const handleAddTask = async (e: React.FormEvent) => {
|
const handleAddTask = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const title = newTaskTitle.trim();
|
const title = newTaskTitle.trim();
|
||||||
if (!title || !listId) return;
|
if (!title || !listId) return;
|
||||||
|
|
||||||
|
if (isDemo) {
|
||||||
|
if (onDemoAddTask) onDemoAddTask(title, listId);
|
||||||
|
setNewTaskTitle("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/tasks", {
|
const res = await fetch("/api/tasks", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -185,8 +268,10 @@ export function TaskList({ user, listId, lists, tasks, setTasks, selectedTaskId,
|
|||||||
if (!listId) {
|
if (!listId) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--text-tertiary)" }}>
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--text-tertiary)" }}>
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ opacity: 0.3, marginBottom: 12 }}><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ opacity: 0.3, marginBottom: 12 }}>
|
||||||
<p>Select a list to get started</p>
|
<path d="M9 11l3 3L22 4" /><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||||
|
</svg>
|
||||||
|
<p>{t("selectListToStart")}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -199,20 +284,24 @@ export function TaskList({ user, listId, lists, tasks, setTasks, selectedTaskId,
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="main-header">
|
<div className="main-header">
|
||||||
<button className="icon-btn mobile-only" id="menu-btn" onClick={onMenuOpen} aria-label="Menu">
|
<button className="icon-btn mobile-only" id="menu-btn" onClick={onMenuOpen} aria-label="Menu">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div className="main-header-title" style={{ color: currentList?.color }}>
|
<div className="main-header-title" style={{ color: currentList?.color }}>
|
||||||
{currentList?.name || "Tasks"}
|
{currentList?.name || t("tasks")}
|
||||||
</div>
|
</div>
|
||||||
<div className="main-header-actions">
|
<div className="main-header-actions">
|
||||||
<button
|
<button
|
||||||
id="toggle-completed-btn"
|
id="toggle-completed-btn"
|
||||||
className="btn btn-ghost btn-sm"
|
className="btn btn-ghost btn-sm"
|
||||||
onClick={onToggleCompleted}
|
onClick={onToggleCompleted}
|
||||||
title={showCompleted ? "Hide completed" : "Show completed"}
|
title={showCompleted ? t("hideDone") : t("showDone")}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="20 6 9 17 4 12"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
{showCompleted ? "Hide done" : "Show done"}
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
{showCompleted ? t("hideDone") : t("showDone")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -220,47 +309,68 @@ export function TaskList({ user, listId, lists, tasks, setTasks, selectedTaskId,
|
|||||||
{/* Task list */}
|
{/* Task list */}
|
||||||
<div className="task-list-container">
|
<div className="task-list-container">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>Loading...</div>
|
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>{t("loading")}</div>
|
||||||
)}
|
)}
|
||||||
{!loading && incompleteTasks.length === 0 && completedTasks.length === 0 && (
|
{!loading && incompleteTasks.length === 0 && completedTasks.length === 0 && (
|
||||||
<div className="task-list-empty">
|
<div className="task-list-empty">
|
||||||
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
<p>No tasks yet. Add one below!</p>
|
<path d="M9 11l3 3L22 4" /><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||||
|
</svg>
|
||||||
|
<p>{t("noTasksYet")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{incompleteTasks.map((task) => (
|
{incompleteTasks.map((task) => (
|
||||||
<TaskItem key={task.id} task={task} isSelected={selectedTaskId === task.id} onSelect={onTaskSelect} onToggle={handleToggle} />
|
<TaskItem
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
isSelected={selectedTaskId === task.id}
|
||||||
|
onSelect={onTaskSelect}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
{showCompleted && completedTasks.length > 0 && (
|
{showCompleted && completedTasks.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ padding: "12px 20px 4px", fontSize: 11, fontWeight: 600, color: "var(--text-tertiary)", textTransform: "uppercase", letterSpacing: "0.5px" }}>
|
<div
|
||||||
Completed ({completedTasks.length})
|
style={{
|
||||||
|
padding: "12px 20px 4px",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text-tertiary)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.5px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("completedSection")} ({completedTasks.length})
|
||||||
</div>
|
</div>
|
||||||
{completedTasks.map((task) => (
|
{completedTasks.map((task) => (
|
||||||
<TaskItem key={task.id} task={task} isSelected={selectedTaskId === task.id} onSelect={onTaskSelect} onToggle={handleToggle} />
|
<TaskItem
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
isSelected={selectedTaskId === task.id}
|
||||||
|
onSelect={onTaskSelect}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add task */}
|
{/* Add task bar */}
|
||||||
<div className="add-task-bar">
|
<div className="add-task-bar">
|
||||||
<button
|
<button className="task-check-btn" style={{ opacity: 0.4, flexShrink: 0 }} aria-hidden="true" />
|
||||||
className="task-check-btn"
|
|
||||||
style={{ opacity: 0.4, flexShrink: 0 }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<form onSubmit={handleAddTask} style={{ flex: 1, display: "flex", gap: 8 }}>
|
<form onSubmit={handleAddTask} style={{ flex: 1, display: "flex", gap: 8 }}>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
id="add-task-input"
|
id="add-task-input"
|
||||||
className="add-task-input"
|
className="add-task-input"
|
||||||
placeholder="Add a task..."
|
placeholder={t("addTaskPlaceholder")}
|
||||||
value={newTaskTitle}
|
value={newTaskTitle}
|
||||||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{newTaskTitle.trim() && (
|
{newTaskTitle.trim() && (
|
||||||
<button type="submit" className="btn btn-primary btn-sm" id="add-task-btn">Add</button>
|
<button type="submit" className="btn btn-primary btn-sm" id="add-task-btn">
|
||||||
|
{t("add")}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+272
-261
@@ -1,288 +1,299 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||||
|
|
||||||
export type Locale = "en" | "ko" | "ja";
|
export type Language = "en" | "ko" | "ja";
|
||||||
|
|
||||||
// ============================================================
|
export const translations = {
|
||||||
// TRANSLATIONS
|
|
||||||
// ============================================================
|
|
||||||
const translations = {
|
|
||||||
en: {
|
en: {
|
||||||
auth: {
|
// Auth
|
||||||
welcome: "Welcome back",
|
appName: "CheckFlow",
|
||||||
signIn: "Sign in",
|
tagline: "Your Personal Todo",
|
||||||
signInSub: "Sign in to your account",
|
welcomeBack: "Welcome back",
|
||||||
createAccount: "Create account",
|
signInSubtitle: "Sign in to your account",
|
||||||
createAccountSub: "Start organizing your tasks today",
|
createAccount: "Create account",
|
||||||
email: "Email",
|
createAccountSubtitle: "Start organizing your tasks today",
|
||||||
password: "Password",
|
displayName: "Display Name",
|
||||||
passwordHint: "Min. 8 characters",
|
email: "Email",
|
||||||
displayName: "Display Name",
|
password: "Password",
|
||||||
namePlaceholder: "Your name",
|
min8Chars: "Min. 8 characters",
|
||||||
noAccount: "Don't have an account?",
|
signIn: "Sign in",
|
||||||
hasAccount: "Already have an account?",
|
signingIn: "Signing in...",
|
||||||
createOne: "Create one",
|
createBtn: "Create account",
|
||||||
signInLink: "Sign in",
|
creatingBtn: "Creating account...",
|
||||||
signingIn: "Signing in...",
|
alreadyHaveAccount: "Already have an account?",
|
||||||
creating: "Creating account...",
|
dontHaveAccount: "Don't have an account?",
|
||||||
invalidCredentials: "Invalid email or password.",
|
tryDemoMode: "Try Demo Mode (No DB Required)",
|
||||||
registrationFailed: "Registration failed.",
|
demoBadge: "Demo Mode",
|
||||||
},
|
signOut: "Sign out",
|
||||||
sidebar: {
|
|
||||||
lists: "Lists",
|
|
||||||
newList: "New List",
|
|
||||||
create: "Create",
|
|
||||||
cancel: "Cancel",
|
|
||||||
listName: "List name",
|
|
||||||
importTasks: "Import Tasks",
|
|
||||||
signOut: "Sign out",
|
|
||||||
deleteListConfirm: "Delete this list and all its tasks?",
|
|
||||||
},
|
|
||||||
tasks: {
|
|
||||||
addTask: "Add a task...",
|
|
||||||
add: "Add",
|
|
||||||
showDone: "Show done",
|
|
||||||
hideDone: "Hide done",
|
|
||||||
noTasks: "No tasks yet. Add one below!",
|
|
||||||
selectList: "Select a list to get started",
|
|
||||||
completed: "Completed",
|
|
||||||
loading: "Loading...",
|
|
||||||
},
|
|
||||||
detail: {
|
|
||||||
priority: "Priority",
|
|
||||||
dueDate: "Due Date",
|
|
||||||
notes: "Notes",
|
|
||||||
notesPlaceholder: "Add notes, details, or anything you need to remember\u2026\n\nMarkdown: **bold**, *italic*, # heading, - list, - [ ] checkbox",
|
|
||||||
subtasks: "Sub-tasks",
|
|
||||||
addSubtask: "Add sub-task\u2026",
|
|
||||||
autoSaved: "Auto-saved",
|
|
||||||
saving: "Saving\u2026",
|
|
||||||
delete: "Delete task",
|
|
||||||
deleteConfirm: "Delete this task?",
|
|
||||||
close: "Close",
|
|
||||||
clear: "Clear",
|
|
||||||
created: "Created",
|
|
||||||
},
|
|
||||||
priority: { none: "None", low: "Low", medium: "Medium", high: "High" },
|
|
||||||
settings: {
|
|
||||||
theme: "Theme",
|
|
||||||
language: "Language",
|
|
||||||
system: "System",
|
|
||||||
light: "Light",
|
|
||||||
dark: "Dark",
|
|
||||||
},
|
|
||||||
import: {
|
|
||||||
title: "Import Tasks",
|
|
||||||
targetList: "Target List",
|
|
||||||
file: "File (CSV or ICS)",
|
|
||||||
tip: "TickTick: Settings \u2192 Export \u2192 Export as CSV or iCalendar",
|
|
||||||
cancel: "Cancel",
|
|
||||||
import: "Import",
|
|
||||||
importing: "Importing...",
|
|
||||||
error: "Error",
|
|
||||||
},
|
|
||||||
list: { create: "Create list", color: "Color" },
|
|
||||||
},
|
|
||||||
|
|
||||||
|
// Sidebar
|
||||||
|
lists: "Lists",
|
||||||
|
newList: "New List",
|
||||||
|
listNamePlaceholder: "List name",
|
||||||
|
create: "Create",
|
||||||
|
cancel: "Cancel",
|
||||||
|
deleteListConfirm: "Delete this list and all its tasks?",
|
||||||
|
importTasks: "Import Tasks",
|
||||||
|
importModalTitle: "Import Tasks",
|
||||||
|
targetList: "Target List",
|
||||||
|
fileSelectLabel: "File (CSV or ICS from TickTick)",
|
||||||
|
tickTickExportHint: "TickTick: Settings → Export → Export as CSV or iCalendar",
|
||||||
|
importBtn: "Import",
|
||||||
|
importing: "Importing...",
|
||||||
|
theme: "Theme",
|
||||||
|
themeSystem: "System",
|
||||||
|
themeLight: "Light",
|
||||||
|
themeDark: "Dark",
|
||||||
|
language: "Language",
|
||||||
|
|
||||||
|
// Task List
|
||||||
|
tasks: "Tasks",
|
||||||
|
hideDone: "Hide done",
|
||||||
|
showDone: "Show done",
|
||||||
|
completedSection: "Completed",
|
||||||
|
addTaskPlaceholder: "Add a task...",
|
||||||
|
add: "Add",
|
||||||
|
noTasksYet: "No tasks yet. Add one below!",
|
||||||
|
selectListToStart: "Select a list to get started",
|
||||||
|
loading: "Loading...",
|
||||||
|
today: "Today",
|
||||||
|
tomorrow: "Tomorrow",
|
||||||
|
|
||||||
|
// Task Detail
|
||||||
|
taskTitlePlaceholder: "Task title",
|
||||||
|
priority: "Priority",
|
||||||
|
priorityNone: "None",
|
||||||
|
priorityLow: "Low",
|
||||||
|
priorityMedium: "Medium",
|
||||||
|
priorityHigh: "High",
|
||||||
|
dueDate: "Due Date",
|
||||||
|
clear: "Clear",
|
||||||
|
notes: "Notes",
|
||||||
|
notesPlaceholder: "Add notes, details, or anything you need to remember…\n\nMarkdown supported: **bold**, *italic*, # heading, - list, - [ ] checkbox",
|
||||||
|
subtasks: "Sub-tasks",
|
||||||
|
addSubtaskPlaceholder: "Add sub-task…",
|
||||||
|
deleteTaskConfirm: "Delete this task?",
|
||||||
|
autoSaved: "Auto-saved",
|
||||||
|
saving: "Saving…",
|
||||||
|
created: "Created",
|
||||||
|
|
||||||
|
// Toolbars
|
||||||
|
bold: "Bold",
|
||||||
|
italic: "Italic",
|
||||||
|
heading: "Heading",
|
||||||
|
bulletList: "Bullet list",
|
||||||
|
numberedList: "Numbered list",
|
||||||
|
checkbox: "Checkbox",
|
||||||
|
code: "Code",
|
||||||
|
},
|
||||||
ko: {
|
ko: {
|
||||||
auth: {
|
// Auth
|
||||||
welcome: "다시 오셨군요",
|
appName: "CheckFlow",
|
||||||
signIn: "로그인",
|
tagline: "나만의 똑똑한 할 일 관리",
|
||||||
signInSub: "계정에 로그인하세요",
|
welcomeBack: "다시 오신 것을 환영합니다",
|
||||||
createAccount: "계정 만들기",
|
signInSubtitle: "계정에 로그인하세요",
|
||||||
createAccountSub: "오늘부터 할 일을 정리해보세요",
|
createAccount: "계정 생성",
|
||||||
email: "이메일",
|
createAccountSubtitle: "오늘부터 할 일을 체계적으로 정리해보세요",
|
||||||
password: "비밀번호",
|
displayName: "이름 (닉네임)",
|
||||||
passwordHint: "최소 8자",
|
email: "이메일",
|
||||||
displayName: "이름",
|
password: "비밀번호",
|
||||||
namePlaceholder: "표시될 이름",
|
min8Chars: "8자 이상 입력",
|
||||||
noAccount: "계정이 없으신가요?",
|
signIn: "로그인",
|
||||||
hasAccount: "이미 계정이 있으신가요?",
|
signingIn: "로그인 중...",
|
||||||
createOne: "만들기",
|
createBtn: "계정 만들기",
|
||||||
signInLink: "로그인",
|
creatingBtn: "계정 생성 중...",
|
||||||
signingIn: "로그인 중...",
|
alreadyHaveAccount: "이미 계정이 있으신가요?",
|
||||||
creating: "계정 생성 중...",
|
dontHaveAccount: "계정이 없으신가요?",
|
||||||
invalidCredentials: "이메일 또는 비밀번호가 올바르지 않습니다.",
|
tryDemoMode: "체험 모드로 둘러보기 (DB 연결 불필요)",
|
||||||
registrationFailed: "회원가입에 실패했습니다.",
|
demoBadge: "체험 모드",
|
||||||
},
|
signOut: "로그아웃",
|
||||||
sidebar: {
|
|
||||||
lists: "목록",
|
|
||||||
newList: "새 목록",
|
|
||||||
create: "만들기",
|
|
||||||
cancel: "취소",
|
|
||||||
listName: "목록 이름",
|
|
||||||
importTasks: "가져오기",
|
|
||||||
signOut: "로그아웃",
|
|
||||||
deleteListConfirm: "이 목록과 모든 할 일을 삭제할까요?",
|
|
||||||
},
|
|
||||||
tasks: {
|
|
||||||
addTask: "할 일 추가...",
|
|
||||||
add: "추가",
|
|
||||||
showDone: "완료 표시",
|
|
||||||
hideDone: "완료 숨기기",
|
|
||||||
noTasks: "할 일이 없습니다. 아래에서 추가하세요!",
|
|
||||||
selectList: "목록을 선택하여 시작하세요",
|
|
||||||
completed: "완료됨",
|
|
||||||
loading: "불러오는 중...",
|
|
||||||
},
|
|
||||||
detail: {
|
|
||||||
priority: "우선순위",
|
|
||||||
dueDate: "마감일",
|
|
||||||
notes: "메모",
|
|
||||||
notesPlaceholder: "메모, 세부 정보, 기억해야 할 내용을 추가하세요\u2026\n\nMarkdown: **굵게**, *기울임*, # 제목, - 목록, - [ ] 체크박스",
|
|
||||||
subtasks: "하위 할 일",
|
|
||||||
addSubtask: "하위 항목 추가\u2026",
|
|
||||||
autoSaved: "자동 저장됨",
|
|
||||||
saving: "저장 중\u2026",
|
|
||||||
delete: "삭제",
|
|
||||||
deleteConfirm: "이 할 일을 삭제할까요?",
|
|
||||||
close: "닫기",
|
|
||||||
clear: "초기화",
|
|
||||||
created: "생성일",
|
|
||||||
},
|
|
||||||
priority: { none: "없음", low: "낮음", medium: "보통", high: "높음" },
|
|
||||||
settings: {
|
|
||||||
theme: "테마",
|
|
||||||
language: "언어",
|
|
||||||
system: "시스템",
|
|
||||||
light: "라이트",
|
|
||||||
dark: "다크",
|
|
||||||
},
|
|
||||||
import: {
|
|
||||||
title: "할 일 가져오기",
|
|
||||||
targetList: "대상 목록",
|
|
||||||
file: "파일 (CSV 또는 ICS)",
|
|
||||||
tip: "TickTick: 설정 \u2192 내보내기 \u2192 CSV 또는 iCalendar로 내보내기",
|
|
||||||
cancel: "취소",
|
|
||||||
import: "가져오기",
|
|
||||||
importing: "가져오는 중...",
|
|
||||||
error: "오류",
|
|
||||||
},
|
|
||||||
list: { create: "목록 만들기", color: "색상" },
|
|
||||||
},
|
|
||||||
|
|
||||||
|
// Sidebar
|
||||||
|
lists: "목록",
|
||||||
|
newList: "새 목록",
|
||||||
|
listNamePlaceholder: "목록 이름",
|
||||||
|
create: "생성",
|
||||||
|
cancel: "취소",
|
||||||
|
deleteListConfirm: "이 목록과 포함된 모든 할 일을 삭제하시겠습니까?",
|
||||||
|
importTasks: "할 일 가져오기 (Import)",
|
||||||
|
importModalTitle: "할 일 가져오기",
|
||||||
|
targetList: "저장할 목록",
|
||||||
|
fileSelectLabel: "파일 선택 (TickTick CSV 또는 ICS)",
|
||||||
|
tickTickExportHint: "TickTick: 설정 → 데이터 내보내기 → CSV 또는 iCalendar",
|
||||||
|
importBtn: "가져오기",
|
||||||
|
importing: "가져오는 중...",
|
||||||
|
theme: "테마",
|
||||||
|
themeSystem: "시스템 설정",
|
||||||
|
themeLight: "라이트 모드",
|
||||||
|
themeDark: "다크 모드",
|
||||||
|
language: "언어",
|
||||||
|
|
||||||
|
// Task List
|
||||||
|
tasks: "할 일",
|
||||||
|
hideDone: "완료 숨김",
|
||||||
|
showDone: "완료 보기",
|
||||||
|
completedSection: "완료됨",
|
||||||
|
addTaskPlaceholder: "새로운 할 일 추가...",
|
||||||
|
add: "추가",
|
||||||
|
noTasksYet: "아직 등록된 할 일이 없습니다. 아래에서 추가해보세요!",
|
||||||
|
selectListToStart: "목록을 선택해 시작하세요",
|
||||||
|
loading: "불러오는 중...",
|
||||||
|
today: "오늘",
|
||||||
|
tomorrow: "내일",
|
||||||
|
|
||||||
|
// Task Detail
|
||||||
|
taskTitlePlaceholder: "할 일 제목",
|
||||||
|
priority: "우선순위",
|
||||||
|
priorityNone: "없음",
|
||||||
|
priorityLow: "낮음",
|
||||||
|
priorityMedium: "보통",
|
||||||
|
priorityHigh: "높음",
|
||||||
|
dueDate: "마감일",
|
||||||
|
clear: "지우기",
|
||||||
|
notes: "메모장",
|
||||||
|
notesPlaceholder: "상세 내용이나 기억해야 할 메모를 자유롭게 작성하세요…\n\n마크다운 지원: **굵게**, *기울임*, # 제목, - 리스트, - [ ] 체크박스",
|
||||||
|
subtasks: "하위 할 일 (Sub-tasks)",
|
||||||
|
addSubtaskPlaceholder: "하위 할 일 추가…",
|
||||||
|
deleteTaskConfirm: "이 할 일을 삭제하시겠습니까?",
|
||||||
|
autoSaved: "자동 저장됨",
|
||||||
|
saving: "저장 중…",
|
||||||
|
created: "생성일",
|
||||||
|
|
||||||
|
// Toolbars
|
||||||
|
bold: "굵게",
|
||||||
|
italic: "기울임",
|
||||||
|
heading: "제목",
|
||||||
|
bulletList: "글머리 기호",
|
||||||
|
numberedList: "번호 매기기",
|
||||||
|
checkbox: "체크박스",
|
||||||
|
code: "코드 블록",
|
||||||
|
},
|
||||||
ja: {
|
ja: {
|
||||||
auth: {
|
// Auth
|
||||||
welcome: "おかえりなさい",
|
appName: "CheckFlow",
|
||||||
signIn: "サインイン",
|
tagline: "シンプルなタスク管理",
|
||||||
signInSub: "アカウントにサインイン",
|
welcomeBack: "おかえりなさい",
|
||||||
createAccount: "アカウント作成",
|
signInSubtitle: "アカウントにサインイン",
|
||||||
createAccountSub: "今日からタスクを整理しましょう",
|
createAccount: "アカウント作成",
|
||||||
email: "メールアドレス",
|
createAccountSubtitle: "今日からタスクを整理しましょう",
|
||||||
password: "パスワード",
|
displayName: "お名前",
|
||||||
passwordHint: "8文字以上",
|
email: "メールアドレス",
|
||||||
displayName: "表示名",
|
password: "パスワード",
|
||||||
namePlaceholder: "あなたの名前",
|
min8Chars: "8文字以上",
|
||||||
noAccount: "アカウントをお持ちでない方は",
|
signIn: "サインイン",
|
||||||
hasAccount: "すでにアカウントをお持ちですか?",
|
signingIn: "サインイン中...",
|
||||||
createOne: "作成する",
|
createBtn: "アカウントを作成",
|
||||||
signInLink: "サインイン",
|
creatingBtn: "作成中...",
|
||||||
signingIn: "サインイン中...",
|
alreadyHaveAccount: "すでにアカウントをお持ちですか?",
|
||||||
creating: "アカウント作成中...",
|
dontHaveAccount: "アカウントをお持ちでないですか?",
|
||||||
invalidCredentials: "メールアドレスまたはパスワードが正しくありません。",
|
tryDemoMode: "デモモードで試す (DB不要)",
|
||||||
registrationFailed: "登録に失敗しました。",
|
demoBadge: "デモモード",
|
||||||
},
|
signOut: "サインアウト",
|
||||||
sidebar: {
|
|
||||||
lists: "リスト",
|
// Sidebar
|
||||||
newList: "新しいリスト",
|
lists: "リスト",
|
||||||
create: "作成",
|
newList: "新しいリスト",
|
||||||
cancel: "キャンセル",
|
listNamePlaceholder: "リスト名",
|
||||||
listName: "リスト名",
|
create: "作成",
|
||||||
importTasks: "インポート",
|
cancel: "キャンセル",
|
||||||
signOut: "サインアウト",
|
deleteListConfirm: "このリストとすべてのタスクを削除しますか?",
|
||||||
deleteListConfirm: "このリストとすべてのタスクを削除しますか?",
|
importTasks: "タスクのインポート",
|
||||||
},
|
importModalTitle: "タスクのインポート",
|
||||||
tasks: {
|
targetList: "対象リスト",
|
||||||
addTask: "タスクを追加...",
|
fileSelectLabel: "ファイル選択 (TickTick CSV または ICS)",
|
||||||
add: "追加",
|
tickTickExportHint: "TickTick: 設定 → エクスポート → CSV または iCalendar",
|
||||||
showDone: "完了を表示",
|
importBtn: "インポート",
|
||||||
hideDone: "完了を非表示",
|
importing: "インポート中...",
|
||||||
noTasks: "タスクがありません。下から追加してください!",
|
theme: "テーマ",
|
||||||
selectList: "リストを選択して始めましょう",
|
themeSystem: "システム",
|
||||||
completed: "完了済み",
|
themeLight: "ライト",
|
||||||
loading: "読み込み中...",
|
themeDark: "ダーク",
|
||||||
},
|
language: "言語",
|
||||||
detail: {
|
|
||||||
priority: "優先度",
|
// Task List
|
||||||
dueDate: "期限",
|
tasks: "タスク",
|
||||||
notes: "メモ",
|
hideDone: "完了を非表示",
|
||||||
notesPlaceholder: "メモ、詳細、覚えておくべきことを追加\u2026\n\nMarkdown: **太字**, *斜体*, # 見出し, - リスト, - [ ] チェックボックス",
|
showDone: "完了を表示",
|
||||||
subtasks: "サブタスク",
|
completedSection: "完了済み",
|
||||||
addSubtask: "サブタスクを追加\u2026",
|
addTaskPlaceholder: "タスクを追加...",
|
||||||
autoSaved: "自動保存済み",
|
add: "追加",
|
||||||
saving: "保存中\u2026",
|
noTasksYet: "タスクがまだありません。下から追加してください!",
|
||||||
delete: "削除",
|
selectListToStart: "リストを選択してください",
|
||||||
deleteConfirm: "このタスクを削除しますか?",
|
loading: "読み込み中...",
|
||||||
close: "閉じる",
|
today: "今日",
|
||||||
clear: "クリア",
|
tomorrow: "明日",
|
||||||
created: "作成日",
|
|
||||||
},
|
// Task Detail
|
||||||
priority: { none: "なし", low: "低", medium: "中", high: "高" },
|
taskTitlePlaceholder: "タスク名",
|
||||||
settings: {
|
priority: "優先度",
|
||||||
theme: "テーマ",
|
priorityNone: "なし",
|
||||||
language: "言語",
|
priorityLow: "低",
|
||||||
system: "システム",
|
priorityMedium: "中",
|
||||||
light: "ライト",
|
priorityHigh: "高",
|
||||||
dark: "ダーク",
|
dueDate: "期限",
|
||||||
},
|
clear: "クリア",
|
||||||
import: {
|
notes: "メモ",
|
||||||
title: "タスクをインポート",
|
notesPlaceholder: "詳細やメモを自由に入力してください…\n\nMarkdown対応: **太字**, *斜体*, # 見出し, - リスト, - [ ] チェックボックス",
|
||||||
targetList: "対象リスト",
|
subtasks: "サブタスク",
|
||||||
file: "ファイル (CSV または ICS)",
|
addSubtaskPlaceholder: "サブタスクを追加…",
|
||||||
tip: "TickTick: 設定 \u2192 エクスポート \u2192 CSVまたはiCalendarでエクスポート",
|
deleteTaskConfirm: "このタスクを削除しますか?",
|
||||||
cancel: "キャンセル",
|
autoSaved: "自動保存済み",
|
||||||
import: "インポート",
|
saving: "保存中…",
|
||||||
importing: "インポート中...",
|
created: "作成日時",
|
||||||
error: "エラー",
|
|
||||||
},
|
// Toolbars
|
||||||
list: { create: "リスト作成", color: "カラー" },
|
bold: "太字",
|
||||||
|
italic: "斜体",
|
||||||
|
heading: "見出し",
|
||||||
|
bulletList: "箇条書き",
|
||||||
|
numberedList: "番号付きリスト",
|
||||||
|
checkbox: "チェックボックス",
|
||||||
|
code: "コード",
|
||||||
},
|
},
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
export type Translations = typeof translations.en;
|
interface I18nContextType {
|
||||||
export type TranslationKey = keyof Translations;
|
lang: Language;
|
||||||
|
setLang: (lang: Language) => void;
|
||||||
// ============================================================
|
t: (key: keyof typeof translations["en"]) => string;
|
||||||
// CONTEXT
|
|
||||||
// ============================================================
|
|
||||||
interface I18nContextValue {
|
|
||||||
locale: Locale;
|
|
||||||
setLocale: (l: Locale) => void;
|
|
||||||
t: Translations;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const I18nContext = createContext<I18nContextValue>({
|
const I18nContext = createContext<I18nContextType>({
|
||||||
locale: "en",
|
lang: "en",
|
||||||
setLocale: () => {},
|
setLang: () => {},
|
||||||
t: translations.en,
|
t: (key) => translations.en[key] || (key as string),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [locale, setLocaleState] = useState<Locale>("en");
|
const [lang, setLangState] = useState<Language>("en");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = localStorage.getItem("locale") as Locale | null;
|
const saved = localStorage.getItem("checkflow_lang") as Language;
|
||||||
if (saved && saved in translations) setLocaleState(saved);
|
if (saved && (saved === "en" || saved === "ko" || saved === "ja")) {
|
||||||
|
setLangState(saved);
|
||||||
|
} else {
|
||||||
|
// Default to English as requested, or match browser if preferred
|
||||||
|
const navLang = navigator.language.toLowerCase();
|
||||||
|
if (navLang.startsWith("ko")) setLangState("ko");
|
||||||
|
else if (navLang.startsWith("ja")) setLangState("ja");
|
||||||
|
else setLangState("en");
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setLocale = (l: Locale) => {
|
const setLang = (newLang: Language) => {
|
||||||
setLocaleState(l);
|
setLangState(newLang);
|
||||||
localStorage.setItem("locale", l);
|
localStorage.setItem("checkflow_lang", newLang);
|
||||||
|
};
|
||||||
|
|
||||||
|
const t = (key: keyof typeof translations["en"]): string => {
|
||||||
|
return translations[lang]?.[key] || translations.en[key] || (key as string);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<I18nContext.Provider value={{ locale, setLocale, t: translations[locale] }}>
|
<I18nContext.Provider value={{ lang, setLang, t }}>
|
||||||
{children}
|
{children}
|
||||||
</I18nContext.Provider>
|
</I18nContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useI18n() {
|
export const useI18n = () => useContext(I18nContext);
|
||||||
return useContext(I18nContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LOCALES: { value: Locale; label: string; flag: string }[] = [
|
|
||||||
{ value: "en", label: "English", flag: "🇺🇸" },
|
|
||||||
{ value: "ko", label: "한국어", flag: "🇰🇷" },
|
|
||||||
{ value: "ja", label: "日本語", flag: "🇯🇵" },
|
|
||||||
];
|
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
export interface MockList {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
icon: string;
|
||||||
|
_count?: { tasks: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockTask {
|
||||||
|
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: MockTask[];
|
||||||
|
tags: { tag: { id: string; name: string; color: string } }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY_LISTS = "checkflow_demo_lists";
|
||||||
|
const STORAGE_KEY_TASKS = "checkflow_demo_tasks";
|
||||||
|
|
||||||
|
export const INITIAL_DEMO_LISTS: MockList[] = [
|
||||||
|
{ id: "list-1", name: "🚀 Project Launch", color: "#4B7BF5", icon: "work" },
|
||||||
|
{ id: "list-2", name: "🏠 Personal & Daily", color: "#10B981", icon: "personal" },
|
||||||
|
{ id: "list-3", name: "📚 Tech Research", color: "#8B5CF6", icon: "study" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const INITIAL_DEMO_TASKS: MockTask[] = [
|
||||||
|
{
|
||||||
|
id: "task-1",
|
||||||
|
listId: "list-1",
|
||||||
|
parentId: null,
|
||||||
|
title: "Setup Self-Hosted CheckFlow on NAS",
|
||||||
|
note: "## 🐳 Docker Deployment Guide\n\n- Deploy with `docker compose up -d`\n- Forward port 3000 to NPM (Nginx Proxy Manager)\n- Setup SSL certificate for custom domain\n\n### 🔗 Key Endpoints\n- Web App: `https://todo.yourdomain.com`\n- CardDAV: `https://todo.yourdomain.com/api/dav`",
|
||||||
|
completed: false,
|
||||||
|
completedAt: null,
|
||||||
|
dueDate: new Date(Date.now() + 86400000).toISOString(),
|
||||||
|
priority: 3,
|
||||||
|
sortOrder: 0,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: "sub-1-1",
|
||||||
|
listId: "list-1",
|
||||||
|
parentId: "task-1",
|
||||||
|
title: "Configure .env environment variables",
|
||||||
|
note: null,
|
||||||
|
completed: true,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
dueDate: null,
|
||||||
|
priority: 0,
|
||||||
|
sortOrder: 0,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
children: [],
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sub-1-2",
|
||||||
|
listId: "list-1",
|
||||||
|
parentId: "task-1",
|
||||||
|
title: "Test DAVx⁵ sync on Samsung Galaxy",
|
||||||
|
note: null,
|
||||||
|
completed: false,
|
||||||
|
completedAt: null,
|
||||||
|
dueDate: null,
|
||||||
|
priority: 2,
|
||||||
|
sortOrder: 1,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
children: [],
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "task-2",
|
||||||
|
listId: "list-1",
|
||||||
|
parentId: null,
|
||||||
|
title: "Import existing tasks from TickTick",
|
||||||
|
note: "1. Export CSV/ICS in TickTick Settings\n2. Click 'Import Tasks' in CheckFlow Sidebar\n3. Select target project",
|
||||||
|
completed: false,
|
||||||
|
completedAt: null,
|
||||||
|
dueDate: null,
|
||||||
|
priority: 2,
|
||||||
|
sortOrder: 1,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
children: [],
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "task-3",
|
||||||
|
listId: "list-2",
|
||||||
|
parentId: null,
|
||||||
|
title: "Install CheckFlow PWA on mobile home screen",
|
||||||
|
note: "Open in Chrome/Samsung Internet -> Menu -> **Install app** or **Add to Home Screen**",
|
||||||
|
completed: true,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
dueDate: new Date().toISOString(),
|
||||||
|
priority: 1,
|
||||||
|
sortOrder: 0,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
children: [],
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getDemoStore() {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return { lists: INITIAL_DEMO_LISTS, tasks: INITIAL_DEMO_TASKS };
|
||||||
|
}
|
||||||
|
|
||||||
|
let lists: MockList[] = INITIAL_DEMO_LISTS;
|
||||||
|
let tasks: MockTask[] = INITIAL_DEMO_TASKS;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedLists = localStorage.getItem(STORAGE_KEY_LISTS);
|
||||||
|
if (savedLists) lists = JSON.parse(savedLists);
|
||||||
|
else localStorage.setItem(STORAGE_KEY_LISTS, JSON.stringify(INITIAL_DEMO_LISTS));
|
||||||
|
|
||||||
|
const savedTasks = localStorage.getItem(STORAGE_KEY_TASKS);
|
||||||
|
if (savedTasks) tasks = JSON.parse(savedTasks);
|
||||||
|
else localStorage.setItem(STORAGE_KEY_TASKS, JSON.stringify(INITIAL_DEMO_TASKS));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to read demo storage", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { lists, tasks };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDemoStore(lists: MockList[], tasks: MockTask[]) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY_LISTS, JSON.stringify(lists));
|
||||||
|
localStorage.setItem(STORAGE_KEY_TASKS, JSON.stringify(tasks));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to save demo storage", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-2
@@ -6,8 +6,9 @@ export async function proxy(request: NextRequest) {
|
|||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
// Public paths
|
// Public paths
|
||||||
// /api/dav: DAVx⁵ uses Basic Auth (not session cookie), so it must bypass the JWT check
|
// /api/dav: DAVx⁵ uses Basic Auth (not session cookie)
|
||||||
const publicPaths = ["/login", "/register", "/api/auth", "/api/dav", "/icons", "/manifest.json", "/sw.js", "/_next", "/favicon.ico"];
|
// /demo: Local preview without DB
|
||||||
|
const publicPaths = ["/login", "/register", "/demo", "/api/auth", "/api/dav", "/icons", "/manifest.json", "/sw.js", "/_next", "/favicon.ico"];
|
||||||
const isPublic = publicPaths.some((p) => pathname.startsWith(p));
|
const isPublic = publicPaths.some((p) => pathname.startsWith(p));
|
||||||
if (isPublic) return NextResponse.next();
|
if (isPublic) return NextResponse.next();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user