+
+ {LIST_COLORS.map((c) => (
+
setNewListColor(c)}
+ />
+ ))}
+
+
setNewListName(e.target.value)}
+ onKeyDown={(e) => { if (e.key === "Enter") createList(); if (e.key === "Escape") setShowNewList(false); }}
+ style={{ marginBottom: 6 }}
+ />
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {/* User footer */}
+
+
+
+
+ {/* Import modal */}
+ {showImport && (
+
setShowImport(false)}>
+
e.stopPropagation()}>
+
Import Tasks
+
+
+
+
+
+
+
+
+
+ TickTick: Settings → Export → Export as CSV or iCalendar
+
+ {importResult &&
{importResult}
}
+
+
+
+
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/tasks/TaskDetail.tsx b/src/components/tasks/TaskDetail.tsx
new file mode 100644
index 0000000..89d7ea7
--- /dev/null
+++ b/src/components/tasks/TaskDetail.tsx
@@ -0,0 +1,372 @@
+"use client";
+import { useState, useEffect, useCallback, useRef } from "react";
+
+interface Task {
+ id: string; listId: string; parentId: string | null; title: string; note: string | null;
+ completed: boolean; completedAt: string | null; dueDate: string | null; priority: number;
+ sortOrder: number; createdAt: string; updatedAt: string;
+ children: Task[]; tags: { tag: { id: string; name: string; color: string } }[];
+}
+
+const PRIORITY_MAP = [
+ { label: "None", color: "var(--text-tertiary)", icon: "" },
+ { label: "Low", color: "var(--priority-low)", icon: "▼" },
+ { label: "Medium", color: "var(--priority-medium)", icon: "▶" },
+ { label: "High", color: "var(--priority-high)", icon: "▲" },
+];
+
+interface Props {
+ task: Task; listId: string;
+ onClose: () => void;
+ onUpdate: (t: Task) => void;
+ onDelete: () => void;
+}
+
+export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props) {
+ const [title, setTitle] = useState(task.title);
+ const [note, setNote] = useState(task.note || "");
+ const [dueDate, setDueDate] = useState(task.dueDate ? task.dueDate.split("T")[0] : "");
+ const [priority, setPriority] = useState(task.priority);
+ const [newSubtitle, setNewSubtitle] = useState("");
+ const [subtasks, setSubtasks] = useState(task.children || []);
+ const [saving, setSaving] = useState(false);
+
+ // Store timer + current task id in refs to prevent stale saves across task switches
+ const saveTimer = useRef
| null>(null);
+ const currentTaskId = useRef(task.id);
+ const noteRef = useRef(null);
+
+ // Sync state when switching to a different task — clear any pending timer first
+ useEffect(() => {
+ if (saveTimer.current) {
+ clearTimeout(saveTimer.current);
+ saveTimer.current = null;
+ }
+ currentTaskId.current = task.id;
+ setTitle(task.title);
+ setNote(task.note || "");
+ setDueDate(task.dueDate ? task.dueDate.split("T")[0] : "");
+ setPriority(task.priority);
+ setSubtasks(task.children || []);
+ }, [task.id]);
+
+ // Cleanup timer on unmount
+ useEffect(() => {
+ return () => {
+ if (saveTimer.current) clearTimeout(saveTimer.current);
+ };
+ }, []);
+
+ const save = useCallback(async (taskId: string, data: Record) => {
+ // Guard: don't save if task has changed since debounce was scheduled
+ if (taskId !== currentTaskId.current) return;
+ setSaving(true);
+ try {
+ const res = await fetch(`/api/tasks/${taskId}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ });
+ if (res.ok) {
+ const updated = await res.json();
+ // Only update if we're still on the same task
+ if (taskId === currentTaskId.current) {
+ onUpdate({ ...updated, children: subtasks });
+ }
+ }
+ } catch (err) {
+ console.error("[TaskDetail] save failed", err);
+ } finally {
+ if (taskId === currentTaskId.current) setSaving(false);
+ }
+ }, [onUpdate, subtasks]);
+
+ const debounceSave = useCallback((overrides: Record) => {
+ if (saveTimer.current) clearTimeout(saveTimer.current);
+ const taskId = currentTaskId.current;
+ saveTimer.current = setTimeout(() => save(taskId, overrides), 800);
+ }, [save]);
+
+ // Insert text at cursor position in the note textarea
+ const insertAtCursor = useCallback((before: string, after = "", placeholder = "") => {
+ const ta = noteRef.current;
+ if (!ta) {
+ setNote((n) => { const v = n + before + placeholder + after; debounceSave({ note: v }); return v; });
+ return;
+ }
+ const start = ta.selectionStart ?? ta.value.length;
+ const end = ta.selectionEnd ?? ta.value.length;
+ const selected = ta.value.slice(start, end) || placeholder;
+ const newVal = ta.value.slice(0, start) + before + selected + after + ta.value.slice(end);
+ setNote(newVal);
+ debounceSave({ note: newVal });
+ // Restore cursor after React re-render
+ requestAnimationFrame(() => {
+ ta.focus();
+ ta.selectionStart = start + before.length;
+ ta.selectionEnd = start + before.length + selected.length;
+ });
+ }, [debounceSave]);
+
+ const handleToggleCompleted = useCallback(async (newCompleted: boolean) => {
+ try {
+ const res = await fetch(`/api/tasks/${task.id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ completed: newCompleted }),
+ });
+ if (res.ok) {
+ const updated = await res.json();
+ onUpdate({ ...updated, children: subtasks });
+ }
+ } catch (err) {
+ console.error("[TaskDetail] toggle failed", err);
+ }
+ }, [task.id, subtasks, onUpdate]);
+
+ const handleDelete = useCallback(async () => {
+ if (!confirm("Delete this task?")) return;
+ try {
+ await fetch(`/api/tasks/${task.id}`, { method: "DELETE" });
+ onDelete();
+ } catch (err) {
+ console.error("[TaskDetail] delete failed", err);
+ }
+ }, [task.id, onDelete]);
+
+ const addSubtask = useCallback(async () => {
+ const t = newSubtitle.trim();
+ if (!t) return;
+ try {
+ const res = await fetch("/api/tasks", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: t, listId, parentId: task.id }),
+ });
+ if (res.ok) {
+ const sub = await res.json();
+ setSubtasks((prev) => {
+ const next = [...prev, sub];
+ onUpdate({ ...task, children: next });
+ return next;
+ });
+ setNewSubtitle("");
+ }
+ } catch (err) {
+ console.error("[TaskDetail] addSubtask failed", err);
+ }
+ }, [newSubtitle, listId, task, onUpdate]);
+
+ const toggleSubtask = useCallback(async (sub: Task) => {
+ try {
+ const res = await fetch(`/api/tasks/${sub.id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ completed: !sub.completed }),
+ });
+ if (res.ok) {
+ const updated = await res.json();
+ setSubtasks((prev) => {
+ const next = prev.map((s) => (s.id === sub.id ? updated : s));
+ onUpdate({ ...task, children: next });
+ return next;
+ });
+ }
+ } catch (err) {
+ console.error("[TaskDetail] toggleSubtask failed", err);
+ }
+ }, [task, onUpdate]);
+
+ const deleteSubtask = useCallback(async (id: string) => {
+ try {
+ await fetch(`/api/tasks/${id}`, { method: "DELETE" });
+ setSubtasks((prev) => {
+ const next = prev.filter((s) => s.id !== id);
+ onUpdate({ ...task, children: next });
+ return next;
+ });
+ } catch (err) {
+ console.error("[TaskDetail] deleteSubtask failed", err);
+ }
+ }, [task, onUpdate]);
+
+ const completedCount = subtasks.filter((s) => s.completed).length;
+ const progressPct = subtasks.length > 0 ? Math.round((completedCount / subtasks.length) * 100) : 0;
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/tasks/TaskList.tsx b/src/components/tasks/TaskList.tsx
new file mode 100644
index 0000000..b32e047
--- /dev/null
+++ b/src/components/tasks/TaskList.tsx
@@ -0,0 +1,269 @@
+"use client";
+import { useState, useEffect, useRef, useCallback } from "react";
+
+interface Task {
+ id: string; listId: string; parentId: string | null; title: string; note: string | null;
+ completed: boolean; completedAt: string | null; dueDate: string | null; priority: number;
+ sortOrder: number; createdAt: string; updatedAt: string;
+ children: Task[]; tags: { tag: { id: string; name: string; color: string } }[];
+}
+interface List { id: string; name: string; color: string; icon: string }
+interface User { id: string; name?: string | null; email?: string | null }
+
+const PRIORITY_COLORS = ["transparent", "var(--priority-low)", "var(--priority-medium)", "var(--priority-high)"];
+const PRIORITY_LABELS = ["", "Low", "Medium", "High"];
+
+function formatDate(d: string | null) {
+ if (!d) return null;
+ const date = new Date(d);
+ const now = new Date();
+ const isToday = date.toDateString() === now.toDateString();
+ const isTomorrow = date.toDateString() === new Date(now.getTime() + 86400000).toDateString();
+ if (isToday) return "Today";
+ if (isTomorrow) return "Tomorrow";
+ return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
+}
+
+function isOverdue(d: string | null) {
+ if (!d) return false;
+ return new Date(d) < new Date() && new Date(d).toDateString() !== new Date().toDateString();
+}
+
+interface TaskItemProps {
+ task: Task; isSelected: boolean;
+ onSelect: (t: Task) => void;
+ onToggle: (id: string, completed: boolean) => void;
+}
+
+function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
+ const [expanded, setExpanded] = useState(false);
+ const completedChildren = task.children.filter((c) => c.completed).length;
+
+ return (
+
+
onSelect(task)}
+ id={`task-${task.id}`}
+ >
+
+
+ {/* Sub-tasks */}
+ {task.children.length > 0 && expanded && (
+
+ {task.children.map((child) => (
+
onSelect(child)}
+ id={`subtask-${child.id}`}
+ >
+
+ ))}
+
+ )}
+
+ );
+}
+
+interface Props {
+ user: User; listId: string | null; lists: List[]; tasks: Task[];
+ setTasks: React.Dispatch>;
+ selectedTaskId: string | null; onTaskSelect: (t: Task | null) => void;
+ showCompleted: boolean; onToggleCompleted: () => void;
+ onMenuOpen: () => void; onRefresh: () => void;
+}
+
+export function TaskList({ user, listId, lists, tasks, setTasks, selectedTaskId, onTaskSelect, showCompleted, onToggleCompleted, onMenuOpen, onRefresh }: Props) {
+ const [newTaskTitle, setNewTaskTitle] = useState("");
+ const [loading, setLoading] = useState(false);
+ const inputRef = useRef(null);
+ const currentList = lists.find((l) => l.id === listId);
+
+ const fetchTasks = useCallback(async () => {
+ if (!listId) return;
+ setLoading(true);
+ try {
+ const res = await fetch(`/api/tasks?listId=${listId}&showCompleted=${showCompleted}`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ setTasks(Array.isArray(data) ? data : []);
+ } catch (err) {
+ console.error("[TaskList] fetchTasks failed", err);
+ } finally {
+ setLoading(false);
+ }
+ }, [listId, showCompleted]);
+
+ useEffect(() => { fetchTasks(); }, [fetchTasks]);
+
+ useEffect(() => {
+ const handler = () => inputRef.current?.focus();
+ document.addEventListener("checkflow:addTask", handler);
+ return () => document.removeEventListener("checkflow:addTask", handler);
+ }, []);
+
+ const handleToggle = useCallback(async (id: string, completed: boolean) => {
+ try {
+ const res = await fetch(`/api/tasks/${id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ completed }),
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const updated = await res.json();
+ // Functional update avoids stale closure on `tasks`
+ setTasks((prev) =>
+ prev.map((t) => {
+ if (t.id === id) return { ...t, completed, completedAt: updated.completedAt };
+ return { ...t, children: t.children.map((c) => (c.id === id ? { ...c, completed } : c)) };
+ }).filter((t) => showCompleted || !t.completed)
+ );
+ onRefresh();
+ } catch (err) {
+ console.error("[TaskList] handleToggle failed", err);
+ }
+ }, [showCompleted, onRefresh]);
+
+ const handleAddTask = async (e: React.FormEvent) => {
+ e.preventDefault();
+ const title = newTaskTitle.trim();
+ if (!title || !listId) return;
+ try {
+ const res = await fetch("/api/tasks", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title, listId }),
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const task = await res.json();
+ setTasks((prev) => [...prev, task]);
+ setNewTaskTitle("");
+ onRefresh();
+ } catch (err) {
+ console.error("[TaskList] handleAddTask failed", err);
+ }
+ };
+
+ if (!listId) {
+ return (
+
+
+
Select a list to get started
+
+ );
+ }
+
+ const incompleteTasks = tasks.filter((t) => !t.completed);
+ const completedTasks = tasks.filter((t) => t.completed);
+
+ return (
+
+ {/* Header */}
+
+
+
+ {currentList?.name || "Tasks"}
+
+
+
+
+
+
+ {/* Task list */}
+
+ {loading && (
+
Loading...
+ )}
+ {!loading && incompleteTasks.length === 0 && completedTasks.length === 0 && (
+
+
+
No tasks yet. Add one below!
+
+ )}
+ {incompleteTasks.map((task) => (
+
+ ))}
+ {showCompleted && completedTasks.length > 0 && (
+
+
+ Completed ({completedTasks.length})
+
+ {completedTasks.map((task) => (
+
+ ))}
+
+ )}
+
+
+ {/* Add task */}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
new file mode 100644
index 0000000..0a4013c
--- /dev/null
+++ b/src/lib/auth.ts
@@ -0,0 +1,41 @@
+import { NextAuthOptions } from "next-auth";
+import CredentialsProvider from "next-auth/providers/credentials";
+import { prisma } from "@/lib/prisma";
+import bcrypt from "bcryptjs";
+
+export const authOptions: NextAuthOptions = {
+ session: { strategy: "jwt" },
+ pages: {
+ signIn: "/login",
+ newUser: "/register",
+ },
+ providers: [
+ CredentialsProvider({
+ name: "credentials",
+ credentials: {
+ email: { label: "Email", type: "email" },
+ password: { label: "Password", type: "password" },
+ },
+ async authorize(credentials) {
+ if (!credentials?.email || !credentials?.password) return null;
+ const user = await prisma.user.findUnique({
+ where: { email: credentials.email },
+ });
+ if (!user) return null;
+ const valid = await bcrypt.compare(credentials.password, user.passwordHash);
+ if (!valid) return null;
+ return { id: user.id, email: user.email, name: user.name };
+ },
+ }),
+ ],
+ callbacks: {
+ jwt({ token, user }) {
+ if (user) token.id = user.id;
+ return token;
+ },
+ session({ session, token }) {
+ if (session.user) session.user.id = token.id as string;
+ return session;
+ },
+ },
+};
diff --git a/src/lib/i18n/index.tsx b/src/lib/i18n/index.tsx
new file mode 100644
index 0000000..61cf959
--- /dev/null
+++ b/src/lib/i18n/index.tsx
@@ -0,0 +1,288 @@
+"use client";
+import React, { createContext, useContext, useState, useEffect } from "react";
+
+export type Locale = "en" | "ko" | "ja";
+
+// ============================================================
+// TRANSLATIONS
+// ============================================================
+const translations = {
+ en: {
+ auth: {
+ welcome: "Welcome back",
+ signIn: "Sign in",
+ signInSub: "Sign in to your account",
+ createAccount: "Create account",
+ createAccountSub: "Start organizing your tasks today",
+ email: "Email",
+ password: "Password",
+ passwordHint: "Min. 8 characters",
+ displayName: "Display Name",
+ namePlaceholder: "Your name",
+ noAccount: "Don't have an account?",
+ hasAccount: "Already have an account?",
+ createOne: "Create one",
+ signInLink: "Sign in",
+ signingIn: "Signing in...",
+ creating: "Creating account...",
+ invalidCredentials: "Invalid email or password.",
+ registrationFailed: "Registration failed.",
+ },
+ sidebar: {
+ lists: "Lists",
+ newList: "New List",
+ create: "Create",
+ cancel: "Cancel",
+ listName: "List name",
+ importTasks: "Import Tasks",
+ signOut: "Sign out",
+ deleteListConfirm: "Delete this list and all its tasks?",
+ },
+ tasks: {
+ addTask: "Add a task...",
+ add: "Add",
+ showDone: "Show done",
+ hideDone: "Hide done",
+ noTasks: "No tasks yet. Add one below!",
+ selectList: "Select a list to get started",
+ completed: "Completed",
+ loading: "Loading...",
+ },
+ detail: {
+ priority: "Priority",
+ dueDate: "Due Date",
+ notes: "Notes",
+ notesPlaceholder: "Add notes, details, or anything you need to remember\u2026\n\nMarkdown: **bold**, *italic*, # heading, - list, - [ ] checkbox",
+ subtasks: "Sub-tasks",
+ addSubtask: "Add sub-task\u2026",
+ autoSaved: "Auto-saved",
+ saving: "Saving\u2026",
+ delete: "Delete task",
+ deleteConfirm: "Delete this task?",
+ close: "Close",
+ clear: "Clear",
+ created: "Created",
+ },
+ priority: { none: "None", low: "Low", medium: "Medium", high: "High" },
+ settings: {
+ theme: "Theme",
+ language: "Language",
+ system: "System",
+ light: "Light",
+ dark: "Dark",
+ },
+ import: {
+ title: "Import Tasks",
+ targetList: "Target List",
+ file: "File (CSV or ICS)",
+ tip: "TickTick: Settings \u2192 Export \u2192 Export as CSV or iCalendar",
+ cancel: "Cancel",
+ import: "Import",
+ importing: "Importing...",
+ error: "Error",
+ },
+ list: { create: "Create list", color: "Color" },
+ },
+
+ ko: {
+ auth: {
+ welcome: "다시 오셨군요",
+ signIn: "로그인",
+ signInSub: "계정에 로그인하세요",
+ createAccount: "계정 만들기",
+ createAccountSub: "오늘부터 할 일을 정리해보세요",
+ email: "이메일",
+ password: "비밀번호",
+ passwordHint: "최소 8자",
+ displayName: "이름",
+ namePlaceholder: "표시될 이름",
+ noAccount: "계정이 없으신가요?",
+ hasAccount: "이미 계정이 있으신가요?",
+ createOne: "만들기",
+ signInLink: "로그인",
+ signingIn: "로그인 중...",
+ creating: "계정 생성 중...",
+ invalidCredentials: "이메일 또는 비밀번호가 올바르지 않습니다.",
+ registrationFailed: "회원가입에 실패했습니다.",
+ },
+ sidebar: {
+ lists: "목록",
+ newList: "새 목록",
+ create: "만들기",
+ cancel: "취소",
+ listName: "목록 이름",
+ importTasks: "가져오기",
+ signOut: "로그아웃",
+ deleteListConfirm: "이 목록과 모든 할 일을 삭제할까요?",
+ },
+ tasks: {
+ addTask: "할 일 추가...",
+ add: "추가",
+ showDone: "완료 표시",
+ hideDone: "완료 숨기기",
+ noTasks: "할 일이 없습니다. 아래에서 추가하세요!",
+ selectList: "목록을 선택하여 시작하세요",
+ completed: "완료됨",
+ loading: "불러오는 중...",
+ },
+ detail: {
+ priority: "우선순위",
+ dueDate: "마감일",
+ notes: "메모",
+ notesPlaceholder: "메모, 세부 정보, 기억해야 할 내용을 추가하세요\u2026\n\nMarkdown: **굵게**, *기울임*, # 제목, - 목록, - [ ] 체크박스",
+ subtasks: "하위 할 일",
+ addSubtask: "하위 항목 추가\u2026",
+ autoSaved: "자동 저장됨",
+ saving: "저장 중\u2026",
+ delete: "삭제",
+ deleteConfirm: "이 할 일을 삭제할까요?",
+ close: "닫기",
+ clear: "초기화",
+ created: "생성일",
+ },
+ priority: { none: "없음", low: "낮음", medium: "보통", high: "높음" },
+ settings: {
+ theme: "테마",
+ language: "언어",
+ system: "시스템",
+ light: "라이트",
+ dark: "다크",
+ },
+ import: {
+ title: "할 일 가져오기",
+ targetList: "대상 목록",
+ file: "파일 (CSV 또는 ICS)",
+ tip: "TickTick: 설정 \u2192 내보내기 \u2192 CSV 또는 iCalendar로 내보내기",
+ cancel: "취소",
+ import: "가져오기",
+ importing: "가져오는 중...",
+ error: "오류",
+ },
+ list: { create: "목록 만들기", color: "색상" },
+ },
+
+ ja: {
+ auth: {
+ welcome: "おかえりなさい",
+ signIn: "サインイン",
+ signInSub: "アカウントにサインイン",
+ createAccount: "アカウント作成",
+ createAccountSub: "今日からタスクを整理しましょう",
+ email: "メールアドレス",
+ password: "パスワード",
+ passwordHint: "8文字以上",
+ displayName: "表示名",
+ namePlaceholder: "あなたの名前",
+ noAccount: "アカウントをお持ちでない方は",
+ hasAccount: "すでにアカウントをお持ちですか?",
+ createOne: "作成する",
+ signInLink: "サインイン",
+ signingIn: "サインイン中...",
+ creating: "アカウント作成中...",
+ invalidCredentials: "メールアドレスまたはパスワードが正しくありません。",
+ registrationFailed: "登録に失敗しました。",
+ },
+ sidebar: {
+ lists: "リスト",
+ newList: "新しいリスト",
+ create: "作成",
+ cancel: "キャンセル",
+ listName: "リスト名",
+ importTasks: "インポート",
+ signOut: "サインアウト",
+ deleteListConfirm: "このリストとすべてのタスクを削除しますか?",
+ },
+ tasks: {
+ addTask: "タスクを追加...",
+ add: "追加",
+ showDone: "完了を表示",
+ hideDone: "完了を非表示",
+ noTasks: "タスクがありません。下から追加してください!",
+ selectList: "リストを選択して始めましょう",
+ completed: "完了済み",
+ loading: "読み込み中...",
+ },
+ detail: {
+ priority: "優先度",
+ dueDate: "期限",
+ notes: "メモ",
+ notesPlaceholder: "メモ、詳細、覚えておくべきことを追加\u2026\n\nMarkdown: **太字**, *斜体*, # 見出し, - リスト, - [ ] チェックボックス",
+ subtasks: "サブタスク",
+ addSubtask: "サブタスクを追加\u2026",
+ autoSaved: "自動保存済み",
+ saving: "保存中\u2026",
+ delete: "削除",
+ deleteConfirm: "このタスクを削除しますか?",
+ close: "閉じる",
+ clear: "クリア",
+ created: "作成日",
+ },
+ priority: { none: "なし", low: "低", medium: "中", high: "高" },
+ settings: {
+ theme: "テーマ",
+ language: "言語",
+ system: "システム",
+ light: "ライト",
+ dark: "ダーク",
+ },
+ import: {
+ title: "タスクをインポート",
+ targetList: "対象リスト",
+ file: "ファイル (CSV または ICS)",
+ tip: "TickTick: 設定 \u2192 エクスポート \u2192 CSVまたはiCalendarでエクスポート",
+ cancel: "キャンセル",
+ import: "インポート",
+ importing: "インポート中...",
+ error: "エラー",
+ },
+ list: { create: "リスト作成", color: "カラー" },
+ },
+} as const;
+
+export type Translations = typeof translations.en;
+export type TranslationKey = keyof Translations;
+
+// ============================================================
+// CONTEXT
+// ============================================================
+interface I18nContextValue {
+ locale: Locale;
+ setLocale: (l: Locale) => void;
+ t: Translations;
+}
+
+const I18nContext = createContext({
+ locale: "en",
+ setLocale: () => {},
+ t: translations.en,
+});
+
+export function I18nProvider({ children }: { children: React.ReactNode }) {
+ const [locale, setLocaleState] = useState("en");
+
+ useEffect(() => {
+ const saved = localStorage.getItem("locale") as Locale | null;
+ if (saved && saved in translations) setLocaleState(saved);
+ }, []);
+
+ const setLocale = (l: Locale) => {
+ setLocaleState(l);
+ localStorage.setItem("locale", l);
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useI18n() {
+ return useContext(I18nContext);
+}
+
+export const LOCALES: { value: Locale; label: string; flag: string }[] = [
+ { value: "en", label: "English", flag: "🇺🇸" },
+ { value: "ko", label: "한국어", flag: "🇰🇷" },
+ { value: "ja", label: "日本語", flag: "🇯🇵" },
+];
\ No newline at end of file
diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts
new file mode 100644
index 0000000..12b6124
--- /dev/null
+++ b/src/lib/prisma.ts
@@ -0,0 +1,13 @@
+import { PrismaClient } from "@prisma/client";
+
+const globalForPrisma = globalThis as unknown as {
+ prisma: PrismaClient | undefined;
+};
+
+export const prisma =
+ globalForPrisma.prisma ??
+ new PrismaClient({
+ log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
+ });
+
+if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
diff --git a/src/proxy.ts b/src/proxy.ts
new file mode 100644
index 0000000..dc51800
--- /dev/null
+++ b/src/proxy.ts
@@ -0,0 +1,24 @@
+import type { NextRequest } from "next/server";
+import { NextResponse } from "next/server";
+import { getToken } from "next-auth/jwt";
+
+export async function proxy(request: NextRequest) {
+ const { pathname } = request.nextUrl;
+
+ // Public paths
+ // /api/dav: DAVx⁵ uses Basic Auth (not session cookie), so it must bypass the JWT check
+ const publicPaths = ["/login", "/register", "/api/auth", "/api/dav", "/icons", "/manifest.json", "/sw.js", "/_next", "/favicon.ico"];
+ const isPublic = publicPaths.some((p) => pathname.startsWith(p));
+ if (isPublic) return NextResponse.next();
+
+ const token = await getToken({ req: request });
+ if (!token) {
+ const loginUrl = new URL("/login", request.url);
+ return NextResponse.redirect(loginUrl);
+ }
+ return NextResponse.next();
+}
+
+export const config = {
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
+};
\ No newline at end of file
diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts
new file mode 100644
index 0000000..d53f673
--- /dev/null
+++ b/src/types/next-auth.d.ts
@@ -0,0 +1,21 @@
+import "next-auth";
+import "next-auth/jwt";
+
+declare module "next-auth" {
+ interface User {
+ id: string;
+ }
+ interface Session {
+ user: {
+ id: string;
+ name?: string | null;
+ email?: string | null;
+ };
+ }
+}
+
+declare module "next-auth/jwt" {
+ interface JWT {
+ id: string;
+ }
+}