Checkpoint: Initial stable CheckFlow base before i18n and demo mode

This commit is contained in:
2026-08-20 14:01:12 +09:00
parent ab601d90ac
commit 3e56120b00
42 changed files with 4727 additions and 167 deletions
+5
View File
@@ -0,0 +1,5 @@
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
export async function POST(req: NextRequest) {
try {
const body = await req.json().catch(() => null);
if (!body) {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const { name, email, password } = body;
if (!name || !email || !password) {
return NextResponse.json({ error: "All fields required" }, { status: 400 });
}
if (password.length < 8) {
return NextResponse.json({ error: "Password must be at least 8 characters" }, { status: 400 });
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return NextResponse.json({ error: "Email already in use" }, { status: 409 });
}
const passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: { name, email, passwordHash },
});
// Create default list
await prisma.list.create({
data: {
userId: user.id,
name: "My Tasks",
color: "#4B7BF5",
icon: "inbox",
sortOrder: 0,
},
});
return NextResponse.json({ id: user.id, email: user.email, name: user.name }, { status: 201 });
} catch (err) {
console.error("[register]", err);
return NextResponse.json(
{ error: "Server error. Make sure the database is running." },
{ status: 500 }
);
}
}
+77
View File
@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
async function authenticate(req: NextRequest) {
const authHeader = req.headers.get("authorization");
if (!authHeader?.startsWith("Basic ")) return null;
const decoded = Buffer.from(authHeader.slice(6), "base64").toString();
const [email, password] = decoded.split(":");
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return null;
const valid = await bcrypt.compare(password, user.passwordHash);
return valid ? user : null;
}
function taskToVTodo(task: { id: string; title: string; note: string | null; completed: boolean; completedAt: Date | null; dueDate: Date | null; priority: number; createdAt: Date; updatedAt: Date }) {
const now = new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
const priorityMap: Record<number, number> = { 0: 0, 1: 9, 2: 5, 3: 1 };
let vtodo = [
"BEGIN:VTODO",
`UID:${task.id}@checkflow`,
`DTSTAMP:${now}`,
`CREATED:${task.createdAt.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`,
`LAST-MODIFIED:${task.updatedAt.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`,
`SUMMARY:${task.title.replace(/\n/g, "\\n")}`,
`STATUS:${task.completed ? "COMPLETED" : "NEEDS-ACTION"}`,
`PRIORITY:${priorityMap[task.priority] ?? 0}`,
];
if (task.note) vtodo.push(`DESCRIPTION:${task.note.replace(/\n/g, "\\n")}`);
if (task.dueDate) vtodo.push(`DUE:${task.dueDate.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`);
if (task.completedAt) vtodo.push(`COMPLETED:${task.completedAt.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`);
vtodo.push("END:VTODO");
return vtodo.join("\r\n");
}
// Handle all CalDAV/CardDAV requests
export async function GET(req: NextRequest) {
const user = await authenticate(req);
if (!user) {
return new NextResponse("Unauthorized", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="CheckFlow"' },
});
}
const tasks = await prisma.task.findMany({
where: { userId: user.id, parentId: null },
orderBy: { sortOrder: "asc" },
});
const icsContent = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//CheckFlow//CheckFlow//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
...tasks.map(taskToVTodo),
"END:VCALENDAR",
].join("\r\n");
return new NextResponse(icsContent, {
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": "attachment; filename=checkflow.ics",
},
});
}
// OPTIONS for CORS
export async function OPTIONS() {
return new NextResponse(null, {
headers: {
Allow: "GET, OPTIONS",
"DAV": "1, 2, calendar-access",
},
});
}
+132
View File
@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// TickTick CSV import
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const formData = await req.formData();
const file = formData.get("file") as File | null;
const listId = formData.get("listId") as string | null;
if (!file || !listId) {
return NextResponse.json({ error: "file and listId required" }, { status: 400 });
}
const list = await prisma.list.findFirst({ where: { id: listId, userId: session.user.id } });
if (!list) return NextResponse.json({ error: "List not found" }, { status: 404 });
const text = await file.text();
const ext = file.name.split(".").pop()?.toLowerCase();
let imported = 0;
if (ext === "csv") {
const lines = text.split("\n");
const header = lines[0].split(",").map((h) => h.trim().replace(/"/g, ""));
const titleIdx = header.findIndex((h) => h.toLowerCase().includes("title") || h.toLowerCase().includes("content"));
const noteIdx = header.findIndex((h) => h.toLowerCase().includes("note") || h.toLowerCase().includes("description"));
const dueIdx = header.findIndex((h) => h.toLowerCase().includes("due"));
const priorityIdx = header.findIndex((h) => h.toLowerCase().includes("priority"));
const completedIdx = header.findIndex((h) => h.toLowerCase().includes("status") || h.toLowerCase().includes("completed"));
const priorityMap: Record<string, number> = { high: 3, medium: 2, low: 1, none: 0, "3": 3, "2": 2, "1": 1, "0": 0 };
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Handle quoted CSV fields
const fields: string[] = [];
let inQuotes = false;
let current = "";
for (const ch of line + ",") {
if (ch === '"') { inQuotes = !inQuotes; }
else if (ch === "," && !inQuotes) { fields.push(current.trim()); current = ""; }
else { current += ch; }
}
const title = titleIdx >= 0 ? fields[titleIdx]?.replace(/"/g, "") : "";
if (!title) continue;
const note = noteIdx >= 0 ? fields[noteIdx]?.replace(/"/g, "") : null;
const dueRaw = dueIdx >= 0 ? fields[dueIdx] : null;
const priorityRaw = priorityIdx >= 0 ? fields[priorityIdx]?.toLowerCase() : "0";
const completedRaw = completedIdx >= 0 ? fields[completedIdx]?.toLowerCase() : "";
let dueDate: Date | null = null;
if (dueRaw) {
const d = new Date(dueRaw);
if (!isNaN(d.getTime())) dueDate = d;
}
const priority = priorityMap[priorityRaw || "0"] ?? 0;
const completed = completedRaw === "completed" || completedRaw === "true" || completedRaw === "1";
await prisma.task.create({
data: {
userId: session.user.id,
listId,
title,
note: note || null,
dueDate,
priority,
completed,
completedAt: completed ? new Date() : null,
sortOrder: imported,
},
});
imported++;
}
} else if (ext === "ics") {
// Parse ICS / iCalendar VTODO
const todos = text.split("BEGIN:VTODO").slice(1);
for (const todo of todos) {
const get = (key: string) => {
const match = todo.match(new RegExp(`^${key}[^:]*:(.*)$`, "m"));
return match ? match[1].trim().replace(/\\n/g, "\n") : null;
};
const title = get("SUMMARY");
if (!title) continue;
const note = get("DESCRIPTION");
const dueDateRaw = get("DUE") || get("DTSTART");
let dueDate: Date | null = null;
if (dueDateRaw) {
const d = new Date(dueDateRaw.replace(/(\d{4})(\d{2})(\d{2})/, "$1-$2-$3"));
if (!isNaN(d.getTime())) dueDate = d;
}
const statusRaw = get("STATUS");
const completed = statusRaw === "COMPLETED";
const priorityRaw = get("PRIORITY");
let priority = 0;
if (priorityRaw) {
const p = parseInt(priorityRaw);
if (p >= 1 && p <= 3) priority = 4 - p; // ICS: 1=high, ours: 3=high
else if (p >= 4 && p <= 6) priority = 2;
else if (p >= 7) priority = 1;
}
await prisma.task.create({
data: {
userId: session.user.id,
listId,
title,
note: note || null,
dueDate,
priority,
completed,
completedAt: completed ? new Date() : null,
sortOrder: imported,
},
});
imported++;
}
} else {
return NextResponse.json({ error: "Unsupported file format. Use CSV or ICS." }, { status: 400 });
}
return NextResponse.json({ imported });
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
type Ctx = { params: Promise<{ id: string }> };
export async function PATCH(req: NextRequest, { params }: Ctx) {
try {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const body = await req.json().catch(() => null);
if (!body) return NextResponse.json({ error: "Invalid body" }, { status: 400 });
const list = await prisma.list.findFirst({ where: { id, userId: session.user.id } });
if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 });
const allowed = ["name", "color", "icon", "sortOrder"];
const data: Record<string, unknown> = {};
for (const key of allowed) {
if (key in body) data[key] = body[key];
}
const updated = await prisma.list.update({ where: { id }, data });
return NextResponse.json(updated);
} catch (err) {
console.error("[lists/id:PATCH]", err);
return NextResponse.json({ error: "Failed to update list" }, { status: 500 });
}
}
export async function DELETE(_: NextRequest, { params }: Ctx) {
try {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const list = await prisma.list.findFirst({ where: { id, userId: session.user.id } });
if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 });
await prisma.list.delete({ where: { id } });
return NextResponse.json({ ok: true });
} catch (err) {
console.error("[lists/id:DELETE]", err);
return NextResponse.json({ error: "Failed to delete list" }, { status: 500 });
}
}
+51
View File
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const lists = await prisma.list.findMany({
where: { userId: session.user.id },
orderBy: { sortOrder: "asc" },
include: {
_count: { select: { tasks: { where: { completed: false, parentId: null } } } },
},
});
return NextResponse.json(lists);
} catch (err) {
console.error("[lists:GET]", err);
return NextResponse.json({ error: "Failed to fetch lists" }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json().catch(() => null);
if (!body) return NextResponse.json({ error: "Invalid body" }, { status: 400 });
const { name, color, icon } = body;
if (!name?.trim()) return NextResponse.json({ error: "Name required" }, { status: 400 });
const count = await prisma.list.count({ where: { userId: session.user.id } });
const list = await prisma.list.create({
data: {
userId: session.user.id,
name: name.trim(),
color: color || "#4B7BF5",
icon: icon || "list",
sortOrder: count,
},
});
return NextResponse.json(list, { status: 201 });
} catch (err) {
console.error("[lists:POST]", err);
return NextResponse.json({ error: "Failed to create list" }, { status: 500 });
}
}
+81
View File
@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
type Ctx = { params: Promise<{ id: string }> };
export async function GET(_: NextRequest, { params }: Ctx) {
try {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const task = await prisma.task.findFirst({
where: { id, userId: session.user.id },
include: { children: { orderBy: { sortOrder: "asc" } }, tags: { include: { tag: true } } },
});
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(task);
} catch (err) {
console.error("[tasks/id:GET]", err);
return NextResponse.json({ error: "Failed to fetch task" }, { status: 500 });
}
}
export async function PATCH(req: NextRequest, { params }: Ctx) {
try {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const body = await req.json().catch(() => null);
if (!body) return NextResponse.json({ error: "Invalid body" }, { status: 400 });
const task = await prisma.task.findFirst({ where: { id, userId: session.user.id } });
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
// Sanitize allowed fields
const allowed = ["title", "note", "completed", "completedAt", "dueDate", "priority", "sortOrder", "listId"];
const data: Record<string, unknown> = {};
for (const key of allowed) {
if (key in body) data[key] = body[key];
}
// Auto-set completedAt
if ("completed" in data) {
data.completedAt = data.completed ? new Date() : null;
}
// Convert dueDate string to Date
if ("dueDate" in data) {
data.dueDate = data.dueDate ? new Date(data.dueDate as string) : null;
}
const updated = await prisma.task.update({
where: { id },
data,
include: { children: { orderBy: { sortOrder: "asc" } }, tags: { include: { tag: true } } },
});
return NextResponse.json(updated);
} catch (err) {
console.error("[tasks/id:PATCH]", err);
return NextResponse.json({ error: "Failed to update task" }, { status: 500 });
}
}
export async function DELETE(_: NextRequest, { params }: Ctx) {
try {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const task = await prisma.task.findFirst({ where: { id, userId: session.user.id } });
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
await prisma.task.delete({ where: { id } });
return NextResponse.json({ ok: true });
} catch (err) {
console.error("[tasks/id:DELETE]", err);
return NextResponse.json({ error: "Failed to delete task" }, { status: 500 });
}
}
+70
View File
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(req.url);
const listId = searchParams.get("listId");
const showCompleted = searchParams.get("showCompleted") === "true";
const where: Record<string, unknown> = {
userId: session.user.id,
parentId: null,
...(listId ? { listId } : {}),
...(showCompleted ? {} : { completed: false }),
};
const tasks = await prisma.task.findMany({
where,
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
include: {
children: { orderBy: [{ sortOrder: "asc" }] },
tags: { include: { tag: true } },
},
});
return NextResponse.json(tasks);
} catch (err) {
console.error("[tasks:GET]", err);
return NextResponse.json({ error: "Failed to fetch tasks" }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json().catch(() => null);
if (!body) return NextResponse.json({ error: "Invalid body" }, { status: 400 });
const { title, listId, parentId, dueDate, priority, note } = body;
if (!title || !listId) return NextResponse.json({ error: "title and listId required" }, { status: 400 });
const list = await prisma.list.findFirst({ where: { id: listId, userId: session.user.id } });
if (!list) return NextResponse.json({ error: "List not found" }, { status: 404 });
const count = await prisma.task.count({ where: { listId, parentId: parentId || null } });
const task = await prisma.task.create({
data: {
userId: session.user.id,
listId,
parentId: parentId || null,
title: title.trim(),
note: note?.trim() || null,
dueDate: dueDate ? new Date(dueDate) : null,
priority: Number(priority) || 0,
sortOrder: count,
},
include: { children: true, tags: { include: { tag: true } } },
});
return NextResponse.json(task, { status: 201 });
} catch (err) {
console.error("[tasks:POST]", err);
return NextResponse.json({ error: "Failed to create task" }, { status: 500 });
}
}
+1410 -16
View File
File diff suppressed because it is too large Load Diff
+31 -21
View File
@@ -1,29 +1,39 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import type { Metadata, Viewport } from "next";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
import { Providers } from "./providers";
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "CheckFlow — Your Personal Todo",
description: "A clean, fast, self-hosted todo app with hierarchical tasks and rich notes.",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "CheckFlow",
},
};
export default function RootLayout({ children }: LayoutProps<"/">) {
export const viewport: Viewport = {
themeColor: "#4B7BF5",
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<html lang="ko" suppressHydrationWarning>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet"
/>
</head>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import Link from "next/link";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
const res = await signIn("credentials", { email, password, redirect: false });
setLoading(false);
if (res?.error) {
setError("Invalid email or password.");
} else {
router.push("/");
}
};
return (
<div className="auth-page">
<div className="auth-card">
<div className="auth-logo">
<div className="auth-logo-icon"></div>
<span className="auth-logo-name">CheckFlow</span>
</div>
<h1 className="auth-title">Welcome back</h1>
<p className="auth-subtitle">Sign in to your account</p>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Email</label>
<input
id="email"
type="email"
className="form-input"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
/>
</div>
<div className="form-group">
<label className="form-label">Password</label>
<input
id="password"
type="password"
className="form-input"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <p className="form-error" style={{ marginBottom: "12px" }}>{error}</p>}
<button id="login-btn" type="submit" className="btn btn-primary w-full" disabled={loading} style={{ height: "44px" }}>
{loading ? "Signing in..." : "Sign in"}
</button>
</form>
<p className="auth-footer">
Don&apos;t have an account?{" "}
<Link href="/register" className="auth-link">Create one</Link>
</p>
</div>
</div>
);
}
+9 -68
View File
@@ -1,69 +1,10 @@
import Image from "next/image";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@/lib/auth";
import { AppShell } from "@/components/layout/AppShell";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert h-5 w-[100px]"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the{" "}
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
page.tsx
</code>{" "}
file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert h-[14px] w-4"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={14}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}
export default async function HomePage() {
const session = await getServerSession(authOptions);
if (!session) redirect("/login");
return <AppShell user={session.user} />;
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
import { SessionProvider } from "next-auth/react";
import { useEffect } from "react";
import { I18nProvider } from "@/lib/i18n";
export type ThemeMode = "system" | "light" | "dark";
export function applyTheme(mode: ThemeMode) {
const isDark =
mode === "dark" ||
(mode === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.setAttribute("data-theme", isDark ? "dark" : "light");
localStorage.setItem("theme", mode);
}
export function Providers({ children }: { children: React.ReactNode }) {
useEffect(() => {
// Apply saved theme
const saved = (localStorage.getItem("theme") as ThemeMode) || "system";
applyTheme(saved);
// Watch system preference changes (only affects "system" mode)
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const onMqChange = () => {
const current = (localStorage.getItem("theme") as ThemeMode) || "system";
if (current === "system") applyTheme("system");
};
mq.addEventListener("change", onMqChange);
// Service Worker
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").catch(console.error);
}
return () => mq.removeEventListener("change", onMqChange);
}, []);
return (
<SessionProvider>
<I18nProvider>{children}</I18nProvider>
</SessionProvider>
);
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import Link from "next/link";
export default function RegisterPage() {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password }),
});
if (!res.ok) {
let msg = "Registration failed.";
try {
const data = await res.json();
msg = data.error || msg;
} catch {
// non-JSON response (e.g. 500 HTML page)
}
setError(msg);
setLoading(false);
return;
}
await signIn("credentials", { email, password, redirect: false });
router.push("/");
};
return (
<div className="auth-page">
<div className="auth-card">
<div className="auth-logo">
<div className="auth-logo-icon"></div>
<span className="auth-logo-name">CheckFlow</span>
</div>
<h1 className="auth-title">Create account</h1>
<p className="auth-subtitle">Start organizing your tasks today</p>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Display Name</label>
<input
id="name"
type="text"
className="form-input"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
required
autoFocus
/>
</div>
<div className="form-group">
<label className="form-label">Email</label>
<input
id="reg-email"
type="email"
className="form-input"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="form-group">
<label className="form-label">Password</label>
<input
id="reg-password"
type="password"
className="form-input"
placeholder="Min. 8 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
</div>
{error && <p className="form-error" style={{ marginBottom: "12px" }}>{error}</p>}
<button id="register-btn" type="submit" className="btn btn-primary w-full" disabled={loading} style={{ height: "44px" }}>
{loading ? "Creating account..." : "Create account"}
</button>
</form>
<p className="auth-footer">
Already have an account?{" "}
<Link href="/login" className="auth-link">Sign in</Link>
</p>
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useState, useCallback } from "react";
import { Sidebar } from "./Sidebar";
import { TaskList } from "../tasks/TaskList";
import { TaskDetail } from "../tasks/TaskDetail";
interface User { id: string; name?: string | null; email?: string | null; }
interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } }
interface Task {
id: string; listId: string; parentId: string | null; title: string; note: string | null;
completed: boolean; completedAt: string | null; dueDate: string | null; priority: number;
sortOrder: number; createdAt: string; updatedAt: string;
children: Task[]; tags: { tag: { id: string; name: string; color: string } }[];
}
export function AppShell({ user }: { user: User }) {
const [lists, setLists] = useState<List[]>([]);
const [selectedListId, setSelectedListId] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [showCompleted, setShowCompleted] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
const handleTaskSelect = useCallback((task: Task | null) => {
setSelectedTask(task);
}, []);
const handleTaskUpdate = useCallback((updated: Task) => {
setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
setSelectedTask(updated);
}, []);
const handleListSelect = useCallback((id: string) => {
setSelectedListId(id);
setSelectedTask(null);
setSidebarOpen(false);
}, []);
return (
<div className="app-layout">
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="modal-overlay"
style={{ zIndex: 25 }}
onClick={() => setSidebarOpen(false)}
/>
)}
<Sidebar
user={user}
lists={lists}
setLists={setLists}
selectedListId={selectedListId}
onListSelect={handleListSelect}
mobileOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
/>
<div className="main-content">
<TaskList
key={`${selectedListId}-${refreshKey}`}
user={user}
listId={selectedListId}
lists={lists}
tasks={tasks}
setTasks={setTasks}
selectedTaskId={selectedTask?.id ?? null}
onTaskSelect={handleTaskSelect}
showCompleted={showCompleted}
onToggleCompleted={() => setShowCompleted((p) => !p)}
onMenuOpen={() => setSidebarOpen(true)}
onRefresh={refresh}
/>
</div>
{selectedTask && (
<TaskDetail
task={selectedTask}
onClose={() => setSelectedTask(null)}
onUpdate={handleTaskUpdate}
onDelete={() => { setSelectedTask(null); refresh(); }}
listId={selectedTask.listId}
/>
)}
{/* Mobile FAB */}
<button
className="fab"
id="mobile-add-task"
aria-label="Add task"
onClick={() => {
// trigger add task from task list — use custom event
document.dispatchEvent(new CustomEvent("checkflow:addTask"));
}}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
</div>
);
}
+246
View File
@@ -0,0 +1,246 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { signOut } from "next-auth/react";
interface User { id: string; name?: string | null; email?: string | null }
interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } }
const LIST_COLORS = ["#4B7BF5","#EF4444","#10B981","#F59E0B","#8B5CF6","#EC4899","#06B6D4","#F97316","#6366F1","#14B8A6"];
const LIST_ICONS: Record<string, string> = { list:"≡", inbox:"⌂", star:"★", work:"💼", personal:"👤", shopping:"🛒", health:"❤️", study:"📚" };
interface SidebarProps {
user: User; lists: List[]; setLists: (l: List[]) => void;
selectedListId: string | null; onListSelect: (id: string) => void;
mobileOpen: boolean; onClose: () => void;
}
export function Sidebar({ user, lists, setLists, selectedListId, onListSelect, mobileOpen, onClose }: SidebarProps) {
const [showNewList, setShowNewList] = useState(false);
const [newListName, setNewListName] = useState("");
const [newListColor, setNewListColor] = useState(LIST_COLORS[0]);
const [editList, setEditList] = useState<List | null>(null);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [showImport, setShowImport] = useState(false);
const [importListId, setImportListId] = useState("");
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState("");
const [theme, setTheme] = useState("light");
const inputRef = useRef<HTMLInputElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const t = localStorage.getItem("theme") || "light";
setTheme(t);
}, []);
useEffect(() => {
fetch("/api/lists")
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data)) {
setLists(data);
if (!selectedListId && data.length > 0) onListSelect(data[0].id);
}
});
}, []);
useEffect(() => {
if (showNewList) setTimeout(() => inputRef.current?.focus(), 50);
}, [showNewList]);
const toggleTheme = () => {
const next = theme === "light" ? "dark" : "light";
setTheme(next);
localStorage.setItem("theme", next);
document.documentElement.setAttribute("data-theme", next);
};
const createList = async () => {
const name = newListName.trim();
if (!name) return;
const res = await fetch("/api/lists", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, color: newListColor }),
});
if (res.ok) {
const list = await res.json();
setLists([...lists, list]);
setNewListName("");
setShowNewList(false);
onListSelect(list.id);
}
};
const deleteList = async (id: string) => {
if (!confirm("Delete this list and all its tasks?")) return;
await fetch(`/api/lists/${id}`, { method: "DELETE" });
const updated = lists.filter((l) => l.id !== id);
setLists(updated);
if (selectedListId === id) onListSelect(updated[0]?.id || "");
};
const handleImport = async () => {
const file = fileRef.current?.files?.[0];
if (!file || !importListId) return;
setImporting(true);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("listId", importListId);
const res = await fetch("/api/import", { method: "POST", body: formData });
const data = await res.json().catch(() => ({}));
if (res.ok) {
setImportResult(`✓ Imported ${data.imported} tasks`);
// Reset file input
if (fileRef.current) fileRef.current.value = "";
// Refresh list counts
const listsRes = await fetch("/api/lists");
if (listsRes.ok) { const updated = await listsRes.json(); if (Array.isArray(updated)) setLists(updated); }
setTimeout(() => { setShowImport(false); setImportResult(""); }, 2000);
} else {
setImportResult(`Error: ${data.error || "Import failed"}`);
}
} catch (err) {
console.error("[Sidebar] import failed", err);
setImportResult("Error: Network error");
} finally {
setImporting(false);
}
};
const initials = user.name?.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2) || "?";
return (
<aside className={`sidebar${mobileOpen ? " mobile-open" : ""}`}>
{/* Header */}
<div className="sidebar-header">
<div className="sidebar-logo"></div>
<span className="sidebar-title">CheckFlow</span>
<button className="icon-btn" id="theme-toggle" onClick={toggleTheme} title="Toggle theme" style={{ marginLeft: "auto" }}>
{theme === "light" ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
)}
</button>
</div>
{/* Nav */}
<nav className="sidebar-nav">
<div className="sidebar-section">
<div className="sidebar-section-label">Lists</div>
{lists.map((list) => (
<div
key={list.id}
className={`sidebar-item${selectedListId === list.id ? " active" : ""}`}
onClick={() => onListSelect(list.id)}
id={`list-item-${list.id}`}
>
<div className="list-dot" style={{ background: list.color }} />
<span className="item-label">{list.name}</span>
<span className="item-count">{list._count?.tasks || ""}</span>
<button
className="icon-btn"
style={{ width: 22, height: 22, opacity: 0.5 }}
onClick={(e) => { e.stopPropagation(); deleteList(list.id); }}
title="Delete list"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>
</div>
))}
</div>
{/* New list */}
{showNewList ? (
<div style={{ padding: "4px 8px" }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, padding: "6px 4px 8px" }}>
{LIST_COLORS.map((c) => (
<div
key={c}
className={`color-swatch${newListColor === c ? " selected" : ""}`}
style={{ background: c, color: c }}
onClick={() => setNewListColor(c)}
/>
))}
</div>
<input
ref={inputRef}
className="form-input"
placeholder="List name"
value={newListName}
onChange={(e) => setNewListName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") createList(); if (e.key === "Escape") setShowNewList(false); }}
style={{ marginBottom: 6 }}
/>
<div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-primary btn-sm" style={{ flex: 1 }} onClick={createList}>Create</button>
<button className="btn btn-ghost btn-sm" onClick={() => setShowNewList(false)}>Cancel</button>
</div>
</div>
) : (
<button id="new-list-btn" className="sidebar-add-btn" onClick={() => setShowNewList(true)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New List
</button>
)}
<div className="sidebar-section" style={{ borderTop: "1px solid var(--border)", paddingTop: 12, marginTop: 4 }}>
<button id="import-btn" className="sidebar-add-btn" onClick={() => { setShowImport(true); setImportListId(lists[0]?.id || ""); }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Import Tasks
</button>
</div>
</nav>
{/* User footer */}
<div className="sidebar-footer">
<div className="user-card" id="user-menu-btn" onClick={() => setUserMenuOpen((p) => !p)} style={{ position: "relative" }}>
<div className="user-avatar" style={{ background: "#4B7BF5" }}>{initials}</div>
<div className="user-info">
<div className="user-name">{user.name}</div>
<div className="user-email">{user.email}</div>
</div>
{userMenuOpen && (
<div className="dropdown" style={{ bottom: "100%", left: 0, right: 0, marginBottom: 4 }}>
<div className="dropdown-item danger" id="signout-btn" onClick={() => signOut({ callbackUrl: "/login" })}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
Sign out
</div>
</div>
)}
</div>
</div>
{/* Import modal */}
{showImport && (
<div className="modal-overlay" onClick={() => setShowImport(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h2 className="modal-title">Import Tasks</h2>
<div className="form-group">
<label className="form-label">Target List</label>
<select className="form-input" value={importListId} onChange={(e) => setImportListId(e.target.value)}>
{lists.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">File (CSV or ICS from TickTick)</label>
<input ref={fileRef} type="file" accept=".csv,.ics" className="form-input" />
</div>
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginBottom: 12 }}>
TickTick: Settings Export Export as CSV or iCalendar
</p>
{importResult && <p style={{ fontSize: 13, color: importResult.startsWith("✓") ? "var(--success)" : "var(--danger)", marginBottom: 12 }}>{importResult}</p>}
<div className="modal-footer">
<button className="btn btn-ghost" onClick={() => setShowImport(false)}>Cancel</button>
<button id="import-submit-btn" className="btn btn-primary" onClick={handleImport} disabled={importing}>
{importing ? "Importing..." : "Import"}
</button>
</div>
</div>
</div>
)}
</aside>
);
}
+372
View File
@@ -0,0 +1,372 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
interface Task {
id: string; listId: string; parentId: string | null; title: string; note: string | null;
completed: boolean; completedAt: string | null; dueDate: string | null; priority: number;
sortOrder: number; createdAt: string; updatedAt: string;
children: Task[]; tags: { tag: { id: string; name: string; color: string } }[];
}
const PRIORITY_MAP = [
{ label: "None", color: "var(--text-tertiary)", icon: "" },
{ label: "Low", color: "var(--priority-low)", icon: "▼" },
{ label: "Medium", color: "var(--priority-medium)", icon: "▶" },
{ label: "High", color: "var(--priority-high)", icon: "▲" },
];
interface Props {
task: Task; listId: string;
onClose: () => void;
onUpdate: (t: Task) => void;
onDelete: () => void;
}
export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props) {
const [title, setTitle] = useState(task.title);
const [note, setNote] = useState(task.note || "");
const [dueDate, setDueDate] = useState(task.dueDate ? task.dueDate.split("T")[0] : "");
const [priority, setPriority] = useState(task.priority);
const [newSubtitle, setNewSubtitle] = useState("");
const [subtasks, setSubtasks] = useState(task.children || []);
const [saving, setSaving] = useState(false);
// Store timer + current task id in refs to prevent stale saves across task switches
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentTaskId = useRef(task.id);
const noteRef = useRef<HTMLTextAreaElement>(null);
// Sync state when switching to a different task — clear any pending timer first
useEffect(() => {
if (saveTimer.current) {
clearTimeout(saveTimer.current);
saveTimer.current = null;
}
currentTaskId.current = task.id;
setTitle(task.title);
setNote(task.note || "");
setDueDate(task.dueDate ? task.dueDate.split("T")[0] : "");
setPriority(task.priority);
setSubtasks(task.children || []);
}, [task.id]);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, []);
const save = useCallback(async (taskId: string, data: Record<string, unknown>) => {
// Guard: don't save if task has changed since debounce was scheduled
if (taskId !== currentTaskId.current) return;
setSaving(true);
try {
const res = await fetch(`/api/tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
const updated = await res.json();
// Only update if we're still on the same task
if (taskId === currentTaskId.current) {
onUpdate({ ...updated, children: subtasks });
}
}
} catch (err) {
console.error("[TaskDetail] save failed", err);
} finally {
if (taskId === currentTaskId.current) setSaving(false);
}
}, [onUpdate, subtasks]);
const debounceSave = useCallback((overrides: Record<string, unknown>) => {
if (saveTimer.current) clearTimeout(saveTimer.current);
const taskId = currentTaskId.current;
saveTimer.current = setTimeout(() => save(taskId, overrides), 800);
}, [save]);
// Insert text at cursor position in the note textarea
const insertAtCursor = useCallback((before: string, after = "", placeholder = "") => {
const ta = noteRef.current;
if (!ta) {
setNote((n) => { const v = n + before + placeholder + after; debounceSave({ note: v }); return v; });
return;
}
const start = ta.selectionStart ?? ta.value.length;
const end = ta.selectionEnd ?? ta.value.length;
const selected = ta.value.slice(start, end) || placeholder;
const newVal = ta.value.slice(0, start) + before + selected + after + ta.value.slice(end);
setNote(newVal);
debounceSave({ note: newVal });
// Restore cursor after React re-render
requestAnimationFrame(() => {
ta.focus();
ta.selectionStart = start + before.length;
ta.selectionEnd = start + before.length + selected.length;
});
}, [debounceSave]);
const handleToggleCompleted = useCallback(async (newCompleted: boolean) => {
try {
const res = await fetch(`/api/tasks/${task.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: newCompleted }),
});
if (res.ok) {
const updated = await res.json();
onUpdate({ ...updated, children: subtasks });
}
} catch (err) {
console.error("[TaskDetail] toggle failed", err);
}
}, [task.id, subtasks, onUpdate]);
const handleDelete = useCallback(async () => {
if (!confirm("Delete this task?")) return;
try {
await fetch(`/api/tasks/${task.id}`, { method: "DELETE" });
onDelete();
} catch (err) {
console.error("[TaskDetail] delete failed", err);
}
}, [task.id, onDelete]);
const addSubtask = useCallback(async () => {
const t = newSubtitle.trim();
if (!t) return;
try {
const res = await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: t, listId, parentId: task.id }),
});
if (res.ok) {
const sub = await res.json();
setSubtasks((prev) => {
const next = [...prev, sub];
onUpdate({ ...task, children: next });
return next;
});
setNewSubtitle("");
}
} catch (err) {
console.error("[TaskDetail] addSubtask failed", err);
}
}, [newSubtitle, listId, task, onUpdate]);
const toggleSubtask = useCallback(async (sub: Task) => {
try {
const res = await fetch(`/api/tasks/${sub.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: !sub.completed }),
});
if (res.ok) {
const updated = await res.json();
setSubtasks((prev) => {
const next = prev.map((s) => (s.id === sub.id ? updated : s));
onUpdate({ ...task, children: next });
return next;
});
}
} catch (err) {
console.error("[TaskDetail] toggleSubtask failed", err);
}
}, [task, onUpdate]);
const deleteSubtask = useCallback(async (id: string) => {
try {
await fetch(`/api/tasks/${id}`, { method: "DELETE" });
setSubtasks((prev) => {
const next = prev.filter((s) => s.id !== id);
onUpdate({ ...task, children: next });
return next;
});
} catch (err) {
console.error("[TaskDetail] deleteSubtask failed", err);
}
}, [task, onUpdate]);
const completedCount = subtasks.filter((s) => s.completed).length;
const progressPct = subtasks.length > 0 ? Math.round((completedCount / subtasks.length) * 100) : 0;
return (
<aside className="detail-panel">
{/* Header */}
<div className="detail-header">
<button
className={`task-check-btn${task.completed ? " checked" : ""}`}
style={{ width: 22, height: 22 }}
onClick={() => handleToggleCompleted(!task.completed)}
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
/>
<span style={{ flex: 1, fontSize: 12, color: "var(--text-tertiary)", fontWeight: 500 }}>
{saving ? "Saving…" : "Auto-saved"}
</span>
<button className="btn btn-ghost btn-sm btn-danger" id="delete-task-btn" onClick={handleDelete} title="Delete task">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>
<button className="detail-close-btn" id="detail-close-btn" onClick={onClose} title="Close">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
{/* Body */}
<div className="detail-body">
{/* Title */}
<textarea
id="detail-title"
className="detail-title-input"
value={title}
onChange={(e) => {
setTitle(e.target.value);
debounceSave({ title: e.target.value });
}}
placeholder="Task title"
rows={2}
style={{
textDecoration: task.completed ? "line-through" : "none",
color: task.completed ? "var(--text-tertiary)" : "var(--text-primary)",
}}
/>
{/* Priority */}
<div className="detail-section">
<div className="detail-section-label">Priority</div>
<div className="priority-selector">
{PRIORITY_MAP.map((p, i) => (
<button
key={i}
id={`priority-${i}`}
className={`priority-btn${priority === i ? ` active-${p.label.toLowerCase()}` : ""}`}
style={{ color: priority === i ? p.color : undefined }}
onClick={() => { setPriority(i); debounceSave({ priority: i }); }}
>
{p.icon && <span style={{ color: p.color }}>{p.icon}</span>}
{p.label}
</button>
))}
</div>
</div>
{/* Due date */}
<div className="detail-section">
<div className="detail-section-label">Due Date</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input
id="due-date-input"
type="date"
className="form-input"
value={dueDate}
onChange={(e) => {
setDueDate(e.target.value);
debounceSave({ dueDate: e.target.value || null });
}}
style={{ width: "auto" }}
/>
{dueDate && (
<button
className="btn btn-ghost btn-sm"
onClick={() => { setDueDate(""); debounceSave({ dueDate: null }); }}
>
Clear
</button>
)}
</div>
</div>
{/* Note / Memo */}
<div className="detail-section" style={{ flex: 1 }}>
<div className="detail-section-label">Notes</div>
<div className="note-editor-wrap">
<div className="note-editor-toolbar">
<button className="note-toolbar-btn" title="Bold (Ctrl+B)" onClick={() => insertAtCursor("**", "**", "bold")}>B</button>
<button className="note-toolbar-btn" title="Italic (Ctrl+I)" style={{ fontStyle: "italic" }} onClick={() => insertAtCursor("*", "*", "italic")}>I</button>
<button className="note-toolbar-btn" title="Heading" onClick={() => insertAtCursor("## ", "", "Heading")}>H</button>
<button className="note-toolbar-btn" title="Bullet list" onClick={() => insertAtCursor("\n- ", "", "item")}></button>
<button className="note-toolbar-btn" title="Numbered list" onClick={() => insertAtCursor("\n1. ", "", "item")}>1.</button>
<button className="note-toolbar-btn" title="Checkbox" onClick={() => insertAtCursor("\n- [ ] ", "", "task")}></button>
<button className="note-toolbar-btn" title="Code" style={{ fontFamily: "monospace", fontSize: 11 }} onClick={() => insertAtCursor("`", "`", "code")}>{"`"}</button>
</div>
<textarea
ref={noteRef}
id="note-textarea"
className="note-textarea"
placeholder={"Add notes, details, or anything you need to remember…\n\nMarkdown: **bold**, *italic*, # heading, - list, - [ ] checkbox"}
value={note}
onChange={(e) => {
setNote(e.target.value);
debounceSave({ note: e.target.value });
}}
/>
</div>
</div>
{/* Sub-tasks */}
<div className="detail-section">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
<div className="detail-section-label">Sub-tasks</div>
{subtasks.length > 0 && (
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{completedCount}/{subtasks.length}</span>
)}
</div>
{subtasks.length > 0 && (
<div className="progress-bar" style={{ marginBottom: 10 }}>
<div className="progress-bar-fill" style={{ width: `${progressPct}%` }} />
</div>
)}
<div className="detail-subtasks">
{subtasks.map((sub) => (
<div
key={sub.id}
className={`detail-subtask-row${sub.completed ? " completed" : ""}`}
id={`detail-sub-${sub.id}`}
>
<button
className={`subtask-check-btn${sub.completed ? " checked" : ""}`}
onClick={() => toggleSubtask(sub)}
aria-label="Toggle subtask"
/>
<span className="detail-subtask-title">{sub.title}</span>
<button
className="icon-btn"
style={{ width: 20, height: 20, opacity: 0.4 }}
onClick={() => deleteSubtask(sub.id)}
aria-label="Delete subtask"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
))}
<div className="add-subtask-row">
<span style={{ fontSize: 16, lineHeight: 1, color: "var(--accent)" }}>+</span>
<input
id="add-subtask-input"
placeholder="Add sub-task…"
style={{ flex: 1, background: "none", fontSize: 13, color: "var(--text-primary)" }}
value={newSubtitle}
onChange={(e) => setNewSubtitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); addSubtask(); }
}}
/>
{newSubtitle.trim() && (
<button className="btn btn-primary btn-sm" id="add-subtask-btn" onClick={addSubtask}>Add</button>
)}
</div>
</div>
</div>
{/* Metadata */}
<div style={{ fontSize: 11, color: "var(--text-tertiary)", paddingTop: 8, borderTop: "1px solid var(--border)" }}>
Created {new Date(task.createdAt).toLocaleDateString("ko-KR", { year: "numeric", month: "short", day: "numeric" })}
</div>
</div>
</aside>
);
}
+269
View File
@@ -0,0 +1,269 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
interface Task {
id: string; listId: string; parentId: string | null; title: string; note: string | null;
completed: boolean; completedAt: string | null; dueDate: string | null; priority: number;
sortOrder: number; createdAt: string; updatedAt: string;
children: Task[]; tags: { tag: { id: string; name: string; color: string } }[];
}
interface List { id: string; name: string; color: string; icon: string }
interface User { id: string; name?: string | null; email?: string | null }
const PRIORITY_COLORS = ["transparent", "var(--priority-low)", "var(--priority-medium)", "var(--priority-high)"];
const PRIORITY_LABELS = ["", "Low", "Medium", "High"];
function formatDate(d: string | null) {
if (!d) return null;
const date = new Date(d);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const isTomorrow = date.toDateString() === new Date(now.getTime() + 86400000).toDateString();
if (isToday) return "Today";
if (isTomorrow) return "Tomorrow";
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
function isOverdue(d: string | null) {
if (!d) return false;
return new Date(d) < new Date() && new Date(d).toDateString() !== new Date().toDateString();
}
interface TaskItemProps {
task: Task; isSelected: boolean;
onSelect: (t: Task) => void;
onToggle: (id: string, completed: boolean) => void;
}
function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
const [expanded, setExpanded] = useState(false);
const completedChildren = task.children.filter((c) => c.completed).length;
return (
<div>
<div
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}`}
onClick={() => onSelect(task)}
id={`task-${task.id}`}
>
<button
className={`task-check-btn${task.completed ? " checked" : ""}`}
onClick={(e) => { e.stopPropagation(); onToggle(task.id, !task.completed); }}
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
/>
<div className="task-body">
<div className="task-title">{task.title}</div>
<div className="task-meta">
{task.priority > 0 && (
<div className="task-priority-dot" style={{ background: PRIORITY_COLORS[task.priority] }} title={PRIORITY_LABELS[task.priority]} />
)}
{task.dueDate && (
<span className={`task-due${isOverdue(task.dueDate) && !task.completed ? " overdue" : ""}`}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
{formatDate(task.dueDate)}
</span>
)}
{task.children.length > 0 && (
<span
className="task-sub-count"
onClick={(e) => { e.stopPropagation(); setExpanded((p) => !p); }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="9 18 15 12 9 6"/></svg>
{completedChildren}/{task.children.length}
</span>
)}
</div>
{task.note && !task.completed && (
<div className="task-note-preview">{task.note.replace(/[#*`]/g, "").slice(0, 80)}</div>
)}
</div>
</div>
{/* Sub-tasks */}
{task.children.length > 0 && expanded && (
<div className="subtask-list">
{task.children.map((child) => (
<div
key={child.id}
className={`subtask-item${child.completed ? " completed" : ""}`}
onClick={() => onSelect(child)}
id={`subtask-${child.id}`}
>
<button
className={`subtask-check-btn${child.completed ? " checked" : ""}`}
onClick={(e) => { e.stopPropagation(); onToggle(child.id, !child.completed); }}
aria-label={child.completed ? "Mark incomplete" : "Mark complete"}
/>
<span className="subtask-title">{child.title}</span>
</div>
))}
</div>
)}
</div>
);
}
interface Props {
user: User; listId: string | null; lists: List[]; tasks: Task[];
setTasks: React.Dispatch<React.SetStateAction<Task[]>>;
selectedTaskId: string | null; onTaskSelect: (t: Task | null) => void;
showCompleted: boolean; onToggleCompleted: () => void;
onMenuOpen: () => void; onRefresh: () => void;
}
export function TaskList({ user, listId, lists, tasks, setTasks, selectedTaskId, onTaskSelect, showCompleted, onToggleCompleted, onMenuOpen, onRefresh }: Props) {
const [newTaskTitle, setNewTaskTitle] = useState("");
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const currentList = lists.find((l) => l.id === listId);
const fetchTasks = useCallback(async () => {
if (!listId) return;
setLoading(true);
try {
const res = await fetch(`/api/tasks?listId=${listId}&showCompleted=${showCompleted}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setTasks(Array.isArray(data) ? data : []);
} catch (err) {
console.error("[TaskList] fetchTasks failed", err);
} finally {
setLoading(false);
}
}, [listId, showCompleted]);
useEffect(() => { fetchTasks(); }, [fetchTasks]);
useEffect(() => {
const handler = () => inputRef.current?.focus();
document.addEventListener("checkflow:addTask", handler);
return () => document.removeEventListener("checkflow:addTask", handler);
}, []);
const handleToggle = useCallback(async (id: string, completed: boolean) => {
try {
const res = await fetch(`/api/tasks/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const updated = await res.json();
// Functional update avoids stale closure on `tasks`
setTasks((prev) =>
prev.map((t) => {
if (t.id === id) return { ...t, completed, completedAt: updated.completedAt };
return { ...t, children: t.children.map((c) => (c.id === id ? { ...c, completed } : c)) };
}).filter((t) => showCompleted || !t.completed)
);
onRefresh();
} catch (err) {
console.error("[TaskList] handleToggle failed", err);
}
}, [showCompleted, onRefresh]);
const handleAddTask = async (e: React.FormEvent) => {
e.preventDefault();
const title = newTaskTitle.trim();
if (!title || !listId) return;
try {
const res = await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, listId }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const task = await res.json();
setTasks((prev) => [...prev, task]);
setNewTaskTitle("");
onRefresh();
} catch (err) {
console.error("[TaskList] handleAddTask failed", err);
}
};
if (!listId) {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--text-tertiary)" }}>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ opacity: 0.3, marginBottom: 12 }}><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
<p>Select a list to get started</p>
</div>
);
}
const incompleteTasks = tasks.filter((t) => !t.completed);
const completedTasks = tasks.filter((t) => t.completed);
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{/* Header */}
<div className="main-header">
<button className="icon-btn mobile-only" id="menu-btn" onClick={onMenuOpen} aria-label="Menu">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div className="main-header-title" style={{ color: currentList?.color }}>
{currentList?.name || "Tasks"}
</div>
<div className="main-header-actions">
<button
id="toggle-completed-btn"
className="btn btn-ghost btn-sm"
onClick={onToggleCompleted}
title={showCompleted ? "Hide completed" : "Show completed"}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="20 6 9 17 4 12"/></svg>
{showCompleted ? "Hide done" : "Show done"}
</button>
</div>
</div>
{/* Task list */}
<div className="task-list-container">
{loading && (
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>Loading...</div>
)}
{!loading && incompleteTasks.length === 0 && completedTasks.length === 0 && (
<div className="task-list-empty">
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
<p>No tasks yet. Add one below!</p>
</div>
)}
{incompleteTasks.map((task) => (
<TaskItem key={task.id} task={task} isSelected={selectedTaskId === task.id} onSelect={onTaskSelect} onToggle={handleToggle} />
))}
{showCompleted && completedTasks.length > 0 && (
<div>
<div style={{ padding: "12px 20px 4px", fontSize: 11, fontWeight: 600, color: "var(--text-tertiary)", textTransform: "uppercase", letterSpacing: "0.5px" }}>
Completed ({completedTasks.length})
</div>
{completedTasks.map((task) => (
<TaskItem key={task.id} task={task} isSelected={selectedTaskId === task.id} onSelect={onTaskSelect} onToggle={handleToggle} />
))}
</div>
)}
</div>
{/* Add task */}
<div className="add-task-bar">
<button
className="task-check-btn"
style={{ opacity: 0.4, flexShrink: 0 }}
aria-hidden="true"
/>
<form onSubmit={handleAddTask} style={{ flex: 1, display: "flex", gap: 8 }}>
<input
ref={inputRef}
id="add-task-input"
className="add-task-input"
placeholder="Add a task..."
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
/>
{newTaskTitle.trim() && (
<button type="submit" className="btn btn-primary btn-sm" id="add-task-btn">Add</button>
)}
</form>
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
export const authOptions: NextAuthOptions = {
session: { strategy: "jwt" },
pages: {
signIn: "/login",
newUser: "/register",
},
providers: [
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const user = await prisma.user.findUnique({
where: { email: credentials.email },
});
if (!user) return null;
const valid = await bcrypt.compare(credentials.password, user.passwordHash);
if (!valid) return null;
return { id: user.id, email: user.email, name: user.name };
},
}),
],
callbacks: {
jwt({ token, user }) {
if (user) token.id = user.id;
return token;
},
session({ session, token }) {
if (session.user) session.user.id = token.id as string;
return session;
},
},
};
+288
View File
@@ -0,0 +1,288 @@
"use client";
import React, { createContext, useContext, useState, useEffect } from "react";
export type Locale = "en" | "ko" | "ja";
// ============================================================
// TRANSLATIONS
// ============================================================
const translations = {
en: {
auth: {
welcome: "Welcome back",
signIn: "Sign in",
signInSub: "Sign in to your account",
createAccount: "Create account",
createAccountSub: "Start organizing your tasks today",
email: "Email",
password: "Password",
passwordHint: "Min. 8 characters",
displayName: "Display Name",
namePlaceholder: "Your name",
noAccount: "Don't have an account?",
hasAccount: "Already have an account?",
createOne: "Create one",
signInLink: "Sign in",
signingIn: "Signing in...",
creating: "Creating account...",
invalidCredentials: "Invalid email or password.",
registrationFailed: "Registration failed.",
},
sidebar: {
lists: "Lists",
newList: "New List",
create: "Create",
cancel: "Cancel",
listName: "List name",
importTasks: "Import Tasks",
signOut: "Sign out",
deleteListConfirm: "Delete this list and all its tasks?",
},
tasks: {
addTask: "Add a task...",
add: "Add",
showDone: "Show done",
hideDone: "Hide done",
noTasks: "No tasks yet. Add one below!",
selectList: "Select a list to get started",
completed: "Completed",
loading: "Loading...",
},
detail: {
priority: "Priority",
dueDate: "Due Date",
notes: "Notes",
notesPlaceholder: "Add notes, details, or anything you need to remember\u2026\n\nMarkdown: **bold**, *italic*, # heading, - list, - [ ] checkbox",
subtasks: "Sub-tasks",
addSubtask: "Add sub-task\u2026",
autoSaved: "Auto-saved",
saving: "Saving\u2026",
delete: "Delete task",
deleteConfirm: "Delete this task?",
close: "Close",
clear: "Clear",
created: "Created",
},
priority: { none: "None", low: "Low", medium: "Medium", high: "High" },
settings: {
theme: "Theme",
language: "Language",
system: "System",
light: "Light",
dark: "Dark",
},
import: {
title: "Import Tasks",
targetList: "Target List",
file: "File (CSV or ICS)",
tip: "TickTick: Settings \u2192 Export \u2192 Export as CSV or iCalendar",
cancel: "Cancel",
import: "Import",
importing: "Importing...",
error: "Error",
},
list: { create: "Create list", color: "Color" },
},
ko: {
auth: {
welcome: "다시 오셨군요",
signIn: "로그인",
signInSub: "계정에 로그인하세요",
createAccount: "계정 만들기",
createAccountSub: "오늘부터 할 일을 정리해보세요",
email: "이메일",
password: "비밀번호",
passwordHint: "최소 8자",
displayName: "이름",
namePlaceholder: "표시될 이름",
noAccount: "계정이 없으신가요?",
hasAccount: "이미 계정이 있으신가요?",
createOne: "만들기",
signInLink: "로그인",
signingIn: "로그인 중...",
creating: "계정 생성 중...",
invalidCredentials: "이메일 또는 비밀번호가 올바르지 않습니다.",
registrationFailed: "회원가입에 실패했습니다.",
},
sidebar: {
lists: "목록",
newList: "새 목록",
create: "만들기",
cancel: "취소",
listName: "목록 이름",
importTasks: "가져오기",
signOut: "로그아웃",
deleteListConfirm: "이 목록과 모든 할 일을 삭제할까요?",
},
tasks: {
addTask: "할 일 추가...",
add: "추가",
showDone: "완료 표시",
hideDone: "완료 숨기기",
noTasks: "할 일이 없습니다. 아래에서 추가하세요!",
selectList: "목록을 선택하여 시작하세요",
completed: "완료됨",
loading: "불러오는 중...",
},
detail: {
priority: "우선순위",
dueDate: "마감일",
notes: "메모",
notesPlaceholder: "메모, 세부 정보, 기억해야 할 내용을 추가하세요\u2026\n\nMarkdown: **굵게**, *기울임*, # 제목, - 목록, - [ ] 체크박스",
subtasks: "하위 할 일",
addSubtask: "하위 항목 추가\u2026",
autoSaved: "자동 저장됨",
saving: "저장 중\u2026",
delete: "삭제",
deleteConfirm: "이 할 일을 삭제할까요?",
close: "닫기",
clear: "초기화",
created: "생성일",
},
priority: { none: "없음", low: "낮음", medium: "보통", high: "높음" },
settings: {
theme: "테마",
language: "언어",
system: "시스템",
light: "라이트",
dark: "다크",
},
import: {
title: "할 일 가져오기",
targetList: "대상 목록",
file: "파일 (CSV 또는 ICS)",
tip: "TickTick: 설정 \u2192 내보내기 \u2192 CSV 또는 iCalendar로 내보내기",
cancel: "취소",
import: "가져오기",
importing: "가져오는 중...",
error: "오류",
},
list: { create: "목록 만들기", color: "색상" },
},
ja: {
auth: {
welcome: "おかえりなさい",
signIn: "サインイン",
signInSub: "アカウントにサインイン",
createAccount: "アカウント作成",
createAccountSub: "今日からタスクを整理しましょう",
email: "メールアドレス",
password: "パスワード",
passwordHint: "8文字以上",
displayName: "表示名",
namePlaceholder: "あなたの名前",
noAccount: "アカウントをお持ちでない方は",
hasAccount: "すでにアカウントをお持ちですか?",
createOne: "作成する",
signInLink: "サインイン",
signingIn: "サインイン中...",
creating: "アカウント作成中...",
invalidCredentials: "メールアドレスまたはパスワードが正しくありません。",
registrationFailed: "登録に失敗しました。",
},
sidebar: {
lists: "リスト",
newList: "新しいリスト",
create: "作成",
cancel: "キャンセル",
listName: "リスト名",
importTasks: "インポート",
signOut: "サインアウト",
deleteListConfirm: "このリストとすべてのタスクを削除しますか?",
},
tasks: {
addTask: "タスクを追加...",
add: "追加",
showDone: "完了を表示",
hideDone: "完了を非表示",
noTasks: "タスクがありません。下から追加してください!",
selectList: "リストを選択して始めましょう",
completed: "完了済み",
loading: "読み込み中...",
},
detail: {
priority: "優先度",
dueDate: "期限",
notes: "メモ",
notesPlaceholder: "メモ、詳細、覚えておくべきことを追加\u2026\n\nMarkdown: **太字**, *斜体*, # 見出し, - リスト, - [ ] チェックボックス",
subtasks: "サブタスク",
addSubtask: "サブタスクを追加\u2026",
autoSaved: "自動保存済み",
saving: "保存中\u2026",
delete: "削除",
deleteConfirm: "このタスクを削除しますか?",
close: "閉じる",
clear: "クリア",
created: "作成日",
},
priority: { none: "なし", low: "低", medium: "中", high: "高" },
settings: {
theme: "テーマ",
language: "言語",
system: "システム",
light: "ライト",
dark: "ダーク",
},
import: {
title: "タスクをインポート",
targetList: "対象リスト",
file: "ファイル (CSV または ICS)",
tip: "TickTick: 設定 \u2192 エクスポート \u2192 CSVまたはiCalendarでエクスポート",
cancel: "キャンセル",
import: "インポート",
importing: "インポート中...",
error: "エラー",
},
list: { create: "リスト作成", color: "カラー" },
},
} as const;
export type Translations = typeof translations.en;
export type TranslationKey = keyof Translations;
// ============================================================
// CONTEXT
// ============================================================
interface I18nContextValue {
locale: Locale;
setLocale: (l: Locale) => void;
t: Translations;
}
const I18nContext = createContext<I18nContextValue>({
locale: "en",
setLocale: () => {},
t: translations.en,
});
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [locale, setLocaleState] = useState<Locale>("en");
useEffect(() => {
const saved = localStorage.getItem("locale") as Locale | null;
if (saved && saved in translations) setLocaleState(saved);
}, []);
const setLocale = (l: Locale) => {
setLocaleState(l);
localStorage.setItem("locale", l);
};
return (
<I18nContext.Provider value={{ locale, setLocale, t: translations[locale] }}>
{children}
</I18nContext.Provider>
);
}
export function useI18n() {
return useContext(I18nContext);
}
export const LOCALES: { value: Locale; label: string; flag: string }[] = [
{ value: "en", label: "English", flag: "🇺🇸" },
{ value: "ko", label: "한국어", flag: "🇰🇷" },
{ value: "ja", label: "日本語", flag: "🇯🇵" },
];
+13
View File
@@ -0,0 +1,13 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
+24
View File
@@ -0,0 +1,24 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// Public paths
// /api/dav: DAVx⁵ uses Basic Auth (not session cookie), so it must bypass the JWT check
const publicPaths = ["/login", "/register", "/api/auth", "/api/dav", "/icons", "/manifest.json", "/sw.js", "/_next", "/favicon.ico"];
const isPublic = publicPaths.some((p) => pathname.startsWith(p));
if (isPublic) return NextResponse.next();
const token = await getToken({ req: request });
if (!token) {
const loginUrl = new URL("/login", request.url);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
+21
View File
@@ -0,0 +1,21 @@
import "next-auth";
import "next-auth/jwt";
declare module "next-auth" {
interface User {
id: string;
}
interface Session {
user: {
id: string;
name?: string | null;
email?: string | null;
};
}
}
declare module "next-auth/jwt" {
interface JWT {
id: string;
}
}