Infrastructure Sync: Initial migration to custom self-hosted Gitea

This commit is contained in:
2026-08-21 15:40:26 +09:00
parent a8574be2fb
commit 131a709186
47 changed files with 1228 additions and 1460 deletions
+5
View File
@@ -0,0 +1,5 @@
[CRITICAL INSTRUCTION]
You are operating under strict context window pressure, and older chat history may be truncated.
Therefore, before starting a new task or making critical decisions, always read the `.cline-context.md` file in the project root to verify the latest status and rules.
Whenever a sub-task is completed or significant structural changes occur, you MUST update the 'Next Steps (Todo)' and 'Project Current Status' sections in `.cline-context.md` to preserve your memory.
Keep the content of `.cline-context.md` clear, concise, and written in English.
+14 -10
View File
@@ -3,7 +3,6 @@ import React, { useState, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { useI18n } from "@/lib/i18n";
import { getDemoStore } from "@/lib/mockData";
interface AdminUser {
@@ -47,14 +46,25 @@ const INITIAL_ADMIN_USERS: AdminUser[] = [
];
export default function AdminPage() {
const { t } = useI18n();
const router = useRouter();
const { data: session, status } = useSession();
const [users, setUsers] = useState<AdminUser[]>(INITIAL_ADMIN_USERS);
const [search, setSearch] = useState("");
const [totalTasks, setTotalTasks] = useState(67);
const [totalLists, setTotalLists] = useState(9);
const [totalTasks] = useState(() => {
if (typeof window !== "undefined") {
const store = getDemoStore();
return store.tasks ? store.tasks.length : 67;
}
return 67;
});
const [totalLists] = useState(() => {
if (typeof window !== "undefined") {
const store = getDemoStore();
return store.lists ? store.lists.length : 9;
}
return 9;
});
// 어드민 권한 판별
const userRole = session?.user?.role;
@@ -68,12 +78,6 @@ export default function AdminPage() {
}
}, [status, router]);
useEffect(() => {
const store = getDemoStore();
if (store.tasks) setTotalTasks(store.tasks.length);
if (store.lists) setTotalLists(store.lists.length);
}, []);
// 로딩 중 스피너
if (status === "loading" || status === "unauthenticated") {
return (
+332 -12
View File
@@ -2,21 +2,36 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
// Helper: Basic Authentication
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 sepIdx = decoded.indexOf(":");
if (sepIdx === -1) return null;
const email = decoded.substring(0, sepIdx);
const password = decoded.substring(sepIdx + 1);
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 }) {
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 = [
const vtodo = [
"BEGIN:VTODO",
`UID:${task.id}@checkflow`,
`DTSTAMP:${now}`,
@@ -33,13 +48,82 @@ function taskToVTodo(task: { id: string; title: string; note: string | null; com
return vtodo.join("\r\n");
}
// Handle all CalDAV/CardDAV requests
function parseVTodo(vcardBody: string): Partial<{
uid: string;
title: string;
note: string;
completed: boolean;
dueDate: Date | null;
priority: number;
}> {
const lines = vcardBody.split(/\r?\n/);
const result: Partial<{
uid: string;
title: string;
note: string;
completed: boolean;
dueDate: Date | null;
priority: number;
}> = {};
for (const line of lines) {
if (line.startsWith("UID:")) {
result.uid = line.substring(4).replace(/@checkflow$/, "").trim();
} else if (line.startsWith("SUMMARY:")) {
result.title = line.substring(8).replace(/\\n/g, "\n").trim();
} else if (line.startsWith("DESCRIPTION:")) {
result.note = line.substring(12).replace(/\\n/g, "\n").trim();
} else if (line.startsWith("STATUS:")) {
result.completed = line.substring(7).trim().toUpperCase() === "COMPLETED";
} else if (line.startsWith("DUE:")) {
const val = line.substring(4).trim();
try {
// Parse basic ISO or iCal timestamp (YYYYMMDDTHHMMSSZ or YYYYMMDD)
if (val.length === 8) {
const y = parseInt(val.substring(0, 4), 10);
const m = parseInt(val.substring(4, 6), 10) - 1;
const d = parseInt(val.substring(6, 8), 10);
result.dueDate = new Date(Date.UTC(y, m, d));
} else {
result.dueDate = new Date(val);
}
} catch {
// ignore parse error
}
} else if (line.startsWith("PRIORITY:")) {
const p = parseInt(line.substring(9).trim(), 10);
if (p === 1) result.priority = 3; // High
else if (p === 5) result.priority = 2; // Medium
else if (p === 9) result.priority = 1; // Low
else result.priority = 0;
}
}
return result;
}
// OPTIONS for CORS & CalDAV capability discovery
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: {
Allow: "OPTIONS, GET, HEAD, POST, PUT, DELETE, PROPFIND, REPORT",
DAV: "1, 2, 3, calendar-access, extended-mkcol",
"MS-Author-Via": "DAV",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "OPTIONS, GET, HEAD, POST, PUT, DELETE, PROPFIND, REPORT",
"Access-Control-Allow-Headers": "Authorization, Content-Type, Depth, Prefer, If-Match, If-None-Match",
},
});
}
// GET: Direct ICS / VTODO download
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"' },
headers: { "WWW-Authenticate": 'Basic realm="CheckFlow CalDAV"' },
});
}
@@ -61,17 +145,253 @@ export async function GET(req: NextRequest) {
return new NextResponse(icsContent, {
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": "attachment; filename=checkflow.ics",
"Content-Disposition": 'attachment; filename="checkflow-tasks.ics"',
"Cache-Control": "no-cache, no-store, must-revalidate",
},
});
}
// OPTIONS for CORS
export async function OPTIONS() {
return new NextResponse(null, {
// PROPFIND: Standard CalDAV Discovery for DAVx5 / Apple Reminders
export async function PROPFIND(req: NextRequest) {
const user = await authenticate(req);
if (!user) {
return new NextResponse("Unauthorized", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="CheckFlow CalDAV"' },
});
}
const host = req.headers.get("host") || "localhost:3000";
const protocol = req.headers.get("x-forwarded-proto") || "http";
const baseUrl = `${protocol}://${host}/api/dav`;
const principalUrl = `${baseUrl}/principals/${encodeURIComponent(user.email)}`;
const calendarHome = `${baseUrl}/calendars/${encodeURIComponent(user.email)}/`;
const tasksUrl = `${calendarHome}tasks/`;
const xmlResponse = `<?xml version="1.0" encoding="utf-8" ?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">
<!-- Base Principal & Calendar Home -->
<D:response>
<D:href>${baseUrl}/</D:href>
<D:propstat>
<D:prop>
<D:current-user-principal><D:href>${principalUrl}</D:href></D:current-user-principal>
<D:resourcetype><D:collection/></D:resourcetype>
<C:calendar-home-set><D:href>${calendarHome}</D:href></C:calendar-home-set>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<!-- User Principal -->
<D:response>
<D:href>${principalUrl}</D:href>
<D:propstat>
<D:prop>
<D:displayname>${user.name || user.email}</D:displayname>
<D:resourcetype><D:principal/></D:resourcetype>
<C:calendar-home-set><D:href>${calendarHome}</D:href></C:calendar-home-set>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<!-- Tasks Collection (VTODO) -->
<D:response>
<D:href>${tasksUrl}</D:href>
<D:propstat>
<D:prop>
<D:displayname>CheckFlow Tasks</D:displayname>
<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>
<C:supported-calendar-component-set>
<C:comp name="VTODO"/>
</C:supported-calendar-component-set>
<CS:getctag>"${Date.now()}"</CS:getctag>
<D:sync-token>data:sync:${Date.now()}</D:sync-token>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`;
return new NextResponse(xmlResponse, {
status: 207,
headers: {
Allow: "GET, OPTIONS",
"DAV": "1, 2, calendar-access",
"Content-Type": "application/xml; charset=utf-8",
DAV: "1, 2, 3, calendar-access",
},
});
}
}
// REPORT: Query calendar items
export async function REPORT(req: NextRequest) {
const user = await authenticate(req);
if (!user) {
return new NextResponse("Unauthorized", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="CheckFlow CalDAV"' },
});
}
const host = req.headers.get("host") || "localhost:3000";
const protocol = req.headers.get("x-forwarded-proto") || "http";
const tasksUrl = `${protocol}://${host}/api/dav/calendars/${encodeURIComponent(user.email)}/tasks/`;
const tasks = await prisma.task.findMany({
where: { userId: user.id, parentId: null },
orderBy: { sortOrder: "asc" },
});
const responsesXml = tasks
.map((task) => {
const vcal = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//CheckFlow//CheckFlow//EN",
taskToVTodo(task),
"END:VCALENDAR",
].join("\r\n");
return ` <D:response>
<D:href>${tasksUrl}${task.id}.ics</D:href>
<D:propstat>
<D:prop>
<D:getetag>"${new Date(task.updatedAt).getTime()}"</D:getetag>
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav"><![CDATA[${vcal}]]></C:calendar-data>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>`;
})
.join("\n");
const xmlResponse = `<?xml version="1.0" encoding="utf-8" ?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
${responsesXml}
</D:multistatus>`;
return new NextResponse(xmlResponse, {
status: 207,
headers: {
"Content-Type": "application/xml; charset=utf-8",
DAV: "1, 2, 3, calendar-access",
},
});
}
// PUT: Create or update task via CalDAV sync
export async function PUT(req: NextRequest) {
const user = await authenticate(req);
if (!user) {
return new NextResponse("Unauthorized", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="CheckFlow CalDAV"' },
});
}
const body = await req.text();
const parsed = parseVTodo(body);
if (!parsed.title && !parsed.uid) {
return new NextResponse("Bad Request: Missing task data", { status: 400 });
}
// Find existing task by UID or create new one in user's default/first list
let task = null;
if (parsed.uid) {
task = await prisma.task.findFirst({
where: { id: parsed.uid, userId: user.id },
});
}
if (task) {
// Update existing task
task = await prisma.task.update({
where: { id: task.id },
data: {
...(parsed.title !== undefined && { title: parsed.title }),
...(parsed.note !== undefined && { note: parsed.note }),
...(parsed.completed !== undefined && {
completed: parsed.completed,
completedAt: parsed.completed ? new Date() : null,
}),
...(parsed.dueDate !== undefined && { dueDate: parsed.dueDate }),
...(parsed.priority !== undefined && { priority: parsed.priority }),
},
});
return new NextResponse(null, {
status: 204,
headers: {
ETag: `"${new Date(task.updatedAt).getTime()}"`,
},
});
} else {
// Create new task in the user's primary list
let list = await prisma.list.findFirst({
where: { userId: user.id },
orderBy: { createdAt: "asc" },
});
if (!list) {
list = await prisma.list.create({
data: {
name: "Inbox",
userId: user.id,
},
});
}
const newTask = await prisma.task.create({
data: {
...(parsed.uid && { id: parsed.uid }),
title: parsed.title || "Untitled Task",
note: parsed.note || null,
completed: parsed.completed || false,
completedAt: parsed.completed ? new Date() : null,
dueDate: parsed.dueDate || null,
priority: parsed.priority ?? 0,
listId: list.id,
userId: user.id,
},
});
return new NextResponse(null, {
status: 201,
headers: {
ETag: `"${new Date(newTask.updatedAt).getTime()}"`,
},
});
}
}
// DELETE: Remove task via CalDAV sync
export async function DELETE(req: NextRequest) {
const user = await authenticate(req);
if (!user) {
return new NextResponse("Unauthorized", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="CheckFlow CalDAV"' },
});
}
// Extract ID from path URL
const pathname = req.nextUrl.pathname;
const match = pathname.match(/\/([a-zA-Z0-9_-]+)\.ics$/);
const taskId = match ? match[1] : null;
if (!taskId) {
return new NextResponse("Not Found", { status: 404 });
}
const task = await prisma.task.findFirst({
where: { id: taskId, userId: user.id },
});
if (!task) {
return new NextResponse("Not Found", { status: 404 });
}
await prisma.task.delete({
where: { id: task.id },
});
return new NextResponse(null, { status: 204 });
}
+176
View File
@@ -0,0 +1,176 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
function escapeCsvField(field: string | null | undefined): string {
if (field === null || field === undefined) return '""';
const str = String(field);
// Sanitize formula injection
const sanitized = /^[=+\-@\t\r]/.test(str.trim()) ? `'${str.trim()}` : str;
return `"${sanitized.replace(/"/g, '""')}"`;
}
function taskToVTodo(
task: {
id: string;
title: string;
note: string | null;
completed: boolean;
completedAt: Date | null;
dueDate: Date | null;
priority: number;
createdAt: Date;
updatedAt: Date;
list?: { name: string } | null;
}
) {
const now = new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
const priorityMap: Record<number, number> = { 0: 0, 1: 9, 2: 5, 3: 1 };
const 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.list?.name) {
vtodo.push(`CATEGORIES:${task.list.name.replace(/\n/g, "\\n")}`);
}
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");
}
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = req.nextUrl;
const format = (searchParams.get("format") || "csv").toLowerCase();
const listId = searchParams.get("listId");
const includeCompleted = searchParams.get("includeCompleted") !== "false";
const whereClause: {
userId: string;
parentId: null;
listId?: string;
completed?: boolean;
} = {
userId: session.user.id,
parentId: null,
};
if (listId && listId !== "all") {
whereClause.listId = listId;
}
if (!includeCompleted) {
whereClause.completed = false;
}
const tasks = await prisma.task.findMany({
where: whereClause,
include: {
list: {
select: { name: true },
},
},
orderBy: [
{ listId: "asc" },
{ sortOrder: "asc" },
{ createdAt: "desc" },
],
});
const timestamp = new Date().toISOString().split("T")[0];
if (format === "ics") {
const icsContent = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//CheckFlow//CheckFlow Tasks Export//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-export-${timestamp}.ics"`,
"Cache-Control": "no-cache, no-store, must-revalidate",
},
});
}
// Standard TickTick & RFC 4180 Compatible CSV
const priorityLabels: Record<number, string> = { 0: "None", 1: "Low", 2: "Medium", 3: "High" };
const headers = [
"Folder Name",
"List Name",
"Title",
"Tags",
"Content",
"Is Check list",
"Start Date",
"Due Date",
"Reminder",
"Repeat",
"Priority",
"Status",
"Created Time",
"Completed Time",
"Order",
"Timezone",
];
const csvRows = [headers.map((h) => `"${h}"`).join(",")];
for (let i = 0; i < tasks.length; i++) {
const t = tasks[i];
const row = [
escapeCsvField(""), // Folder Name
escapeCsvField(t.list?.name || "Inbox"), // List Name
escapeCsvField(t.title), // Title
escapeCsvField(""), // Tags
escapeCsvField(t.note || ""), // Content / Note
escapeCsvField("0"), // Is Check list
escapeCsvField(""), // Start Date
escapeCsvField(t.dueDate ? t.dueDate.toISOString() : ""), // Due Date
escapeCsvField(""), // Reminder
escapeCsvField(""), // Repeat
escapeCsvField(priorityLabels[t.priority] || "None"), // Priority
escapeCsvField(t.completed ? "Completed" : "Normal"), // Status
escapeCsvField(t.createdAt.toISOString()), // Created Time
escapeCsvField(t.completedAt ? t.completedAt.toISOString() : ""), // Completed Time
escapeCsvField(String(t.sortOrder ?? i)), // Order
escapeCsvField(Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"), // Timezone
];
csvRows.push(row.join(","));
}
const csvContent = "\uFEFF" + csvRows.join("\r\n"); // UTF-8 BOM for Excel compatibility
return new NextResponse(csvContent, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="checkflow-export-${timestamp}.csv"`,
"Cache-Control": "no-cache, no-store, must-revalidate",
},
});
}
+9 -10
View File
@@ -1,7 +1,14 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Providers } from "./providers";
const inter = Inter({
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700", "800"],
display: "swap",
});
export const metadata: Metadata = {
title: "CheckFlow — Your Personal Todo",
description: "A clean, fast, self-hosted todo app with hierarchical tasks and rich notes.",
@@ -22,18 +29,10 @@ export const viewport: Viewport = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<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>
<html lang="ko" className={inter.className} suppressHydrationWarning>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ import { LanguageSelector } from "@/components/ui/LanguageSelector";
export default function LoginPage() {
const router = useRouter();
const { t, lang, setLang } = useI18n();
const { t } = useI18n();
const { theme, toggleTheme } = useTheme();
const [email, setEmail] = useState("");
+19 -6
View File
@@ -22,8 +22,23 @@ const ThemeContext = createContext<ThemeContextType>({
export const useTheme = () => useContext(ThemeContext);
function ThemeManager({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<ThemeMode>("system");
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("light");
const [theme, setThemeState] = useState<ThemeMode>(() => {
if (typeof window !== "undefined") {
return (localStorage.getItem("checkflow_theme") as ThemeMode) || "system";
}
return "system";
});
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">(() => {
if (typeof window !== "undefined") {
const saved = (localStorage.getItem("checkflow_theme") as ThemeMode) || "system";
if (saved === "system") {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
return saved;
}
return "light";
});
const applyTheme = (mode: ThemeMode) => {
let effective: "light" | "dark" = "light";
@@ -38,10 +53,8 @@ function ThemeManager({ children }: { children: React.ReactNode }) {
};
useEffect(() => {
const saved = localStorage.getItem("checkflow_theme") as ThemeMode | null;
const initial = saved || "system";
setThemeState(initial);
applyTheme(initial);
const saved = (localStorage.getItem("checkflow_theme") as ThemeMode) || "system";
applyTheme(saved);
// Listen for system theme changes if set to system
const media = window.matchMedia("(prefers-color-scheme: dark)");
+1 -1
View File
@@ -9,7 +9,7 @@ import { LanguageSelector } from "@/components/ui/LanguageSelector";
export default function RegisterPage() {
const router = useRouter();
const { t, lang, setLang } = useI18n();
const { t } = useI18n();
const { theme, toggleTheme } = useTheme();
const [name, setName] = useState("");
+22 -14
View File
@@ -28,12 +28,30 @@ interface AppShellProps {
}
export function AppShell({ user, isDemo = false }: AppShellProps) {
const [lists, setLists] = useState<List[]>([]);
const [selectedListId, setSelectedListId] = useState<string | null>(null);
const [lists, setLists] = useState<List[]>(() => {
if (isDemo && typeof window !== "undefined") {
return getDemoStore().lists as List[];
}
return [];
});
const [selectedListId, setSelectedListId] = useState<string | null>(() => {
if (isDemo && typeof window !== "undefined") {
const storeLists = getDemoStore().lists;
return storeLists.length > 0 ? storeLists[0].id : null;
}
return null;
});
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [isTrashActive, setIsTrashActive] = useState(false);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [tasks, setTasks] = useState<Task[]>(() => {
if (isDemo && typeof window !== "undefined") {
const store = getDemoStore();
const firstListId = store.lists[0]?.id;
return (store.tasks as Task[]).filter((t) => !t.isDeleted && t.listId === firstListId && !t.completed);
}
return [];
});
const [sidebarOpen, setSidebarOpen] = useState(false);
const [showCompleted, setShowCompleted] = useState(false);
const [cmdPaletteOpen, setCmdPaletteOpen] = useState(false);
@@ -98,21 +116,11 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
return () => document.removeEventListener("checkflow:openCommandPalette", handler);
}, []);
// Demo store loading
useEffect(() => {
if (isDemo) {
const store = getDemoStore();
setLists(store.lists);
if (store.lists.length > 0 && !selectedListId && !isTrashActive && !selectedTag) {
setSelectedListId(store.lists[0].id);
}
}
}, [isDemo, isTrashActive, selectedTag]);
// Demo tasks filter & reconstruct hierarchical structure
useEffect(() => {
if (isDemo) {
const store = getDemoStore();
setLists(store.lists);
if (isTrashActive) {
setTasks(getAllTrashTasks(store.tasks) as Task[]);
} else if (selectedTag) {
+236 -5
View File
@@ -1,5 +1,6 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { signOut } from "next-auth/react";
import { useI18n } from "@/lib/i18n";
import { useTheme } from "@/app/providers";
@@ -48,11 +49,12 @@ export function Sidebar({
isTrashActive,
onTrashSelect,
mobileOpen,
onClose,
onClose: _onClose,
isDemo = false,
onDemoCreateList,
onDemoDeleteList,
}: SidebarProps) {
const router = useRouter();
const { t } = useI18n();
const { theme, toggleTheme } = useTheme();
@@ -61,12 +63,23 @@ export function Sidebar({
const [newListColor, setNewListColor] = useState(LIST_COLORS[0]);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [showImport, setShowImport] = useState(false);
const [showExport, setShowExport] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [importListId, setImportListId] = useState("");
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState("");
const [tags, setTags] = useState<MockTag[]>([]);
const [trashCount, setTrashCount] = useState(0);
const [exportFormat, setExportFormat] = useState<"csv" | "ics">("csv");
const [exportListId, setExportListId] = useState<string>("all");
const [exportIncludeCompleted, setExportIncludeCompleted] = useState<boolean>(true);
const [exporting, setExporting] = useState(false);
const [tags, setTags] = useState<MockTag[]>(() => (typeof window !== "undefined" ? getCustomTags() : []));
const [trashCount, setTrashCount] = useState(() => {
if (typeof window !== "undefined") {
const store = getDemoStore();
return getAllTrashTasks(store.tasks).length;
}
return 0;
});
// Context Menu for Lists
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; listId: string } | null>(null);
@@ -105,7 +118,7 @@ export function Sidebar({
})
.catch((err) => console.error("Failed to load lists", err));
}
}, [isDemo]);
}, [isDemo, onListSelect, selectedListId, setLists]);
useEffect(() => {
if (showNewList) setTimeout(() => inputRef.current?.focus(), 50);
@@ -216,6 +229,139 @@ export function Sidebar({
}
};
const handleExport = () => {
setExporting(true);
const timestamp = new Date().toISOString().split("T")[0];
if (isDemo) {
try {
const store = getDemoStore();
let targetTasks = store.tasks.filter((t) => !t.deletedAt && !t.parentId);
if (exportListId !== "all") {
targetTasks = targetTasks.filter((t) => t.listId === exportListId);
}
if (!exportIncludeCompleted) {
targetTasks = targetTasks.filter((t) => !t.completed);
}
let content = "";
let mimeType = "text/csv;charset=utf-8;";
let filename = `checkflow-demo-export-${timestamp}.csv`;
if (exportFormat === "ics") {
mimeType = "text/calendar;charset=utf-8;";
filename = `checkflow-demo-export-${timestamp}.ics`;
const priorityMap: Record<number, number> = { 0: 0, 1: 9, 2: 5, 3: 1 };
const now = new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
const vtodos = targetTasks.map((t) => {
const listObj = store.lists.find((l) => l.id === t.listId);
const lines = [
"BEGIN:VTODO",
`UID:${t.id}@checkflow`,
`DTSTAMP:${now}`,
`CREATED:${t.createdAt ? new Date(t.createdAt).toISOString().replace(/[-:]/g, "").split(".")[0] + "Z" : now}`,
`SUMMARY:${t.title.replace(/\n/g, "\\n")}`,
`STATUS:${t.completed ? "COMPLETED" : "NEEDS-ACTION"}`,
`PRIORITY:${priorityMap[t.priority] ?? 0}`,
];
if (listObj?.name) lines.push(`CATEGORIES:${listObj.name.replace(/\n/g, "\\n")}`);
if (t.note) lines.push(`DESCRIPTION:${t.note.replace(/\n/g, "\\n")}`);
if (t.dueDate) lines.push(`DUE:${new Date(t.dueDate).toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`);
if (t.completedAt) lines.push(`COMPLETED:${new Date(t.completedAt).toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`);
lines.push("END:VTODO");
return lines.join("\r\n");
});
content = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//CheckFlow//Demo Tasks Export//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
...vtodos,
"END:VCALENDAR",
].join("\r\n");
} else {
// CSV Export
const escapeCsv = (val: string | null | undefined) => {
if (!val) return '""';
const s = String(val).replace(/"/g, '""');
return `"${s}"`;
};
const priorityLabels: Record<number, string> = { 0: "None", 1: "Low", 2: "Medium", 3: "High" };
const headers = [
"Folder Name",
"List Name",
"Title",
"Tags",
"Content",
"Is Check list",
"Start Date",
"Due Date",
"Reminder",
"Repeat",
"Priority",
"Status",
"Created Time",
"Completed Time",
"Order",
"Timezone",
];
const rows = [headers.map((h) => `"${h}"`).join(",")];
targetTasks.forEach((t, i) => {
const listObj = store.lists.find((l) => l.id === t.listId);
rows.push([
escapeCsv(""),
escapeCsv(listObj?.name || "Inbox"),
escapeCsv(t.title),
escapeCsv(""),
escapeCsv(t.note || ""),
escapeCsv("0"),
escapeCsv(""),
escapeCsv(t.dueDate ? new Date(t.dueDate).toISOString() : ""),
escapeCsv(""),
escapeCsv(""),
escapeCsv(priorityLabels[t.priority] || "None"),
escapeCsv(t.completed ? "Completed" : "Normal"),
escapeCsv(t.createdAt ? new Date(t.createdAt).toISOString() : new Date().toISOString()),
escapeCsv(t.completedAt ? new Date(t.completedAt).toISOString() : ""),
escapeCsv(String(i)),
escapeCsv(Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"),
].join(","));
});
content = "\uFEFF" + rows.join("\r\n");
}
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setShowExport(false);
} catch (e) {
console.error("Demo export error", e);
} finally {
setExporting(false);
}
return;
}
// Authenticated API Export
const url = `/api/export?format=${exportFormat}&listId=${exportListId}&includeCompleted=${exportIncludeCompleted}`;
const a = document.createElement("a");
a.href = url;
a.download = `checkflow-export-${timestamp}.${exportFormat}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setExporting(false);
setShowExport(false);
};
const handleImport = async () => {
const file = fileRef.current?.files?.[0];
if (!file || !importListId) return;
@@ -578,10 +724,25 @@ export function Sidebar({
{t("importTasks")}
</div>
<div
className="dropdown-item"
id="sidebar-export-menu-item"
onClick={(e) => {
e.stopPropagation();
setShowExport(true);
setUserMenuOpen(false);
}}
>
<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>
{t("exportTasks")}
</div>
<div className="dropdown-divider" />
{isDemo ? (
<div className="dropdown-item" onClick={() => { window.location.href = "/login"; }}>
<div className="dropdown-item" onClick={() => { router.push("/login"); }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><polyline points="10 17 15 12 10 7" /><line x1="15" y1="12" x2="3" y2="12" />
</svg>
@@ -651,6 +812,76 @@ export function Sidebar({
</div>
)}
{/* Export modal */}
{showExport && (
<div className="modal-overlay" onClick={() => setShowExport(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 440 }}>
<h2 className="modal-title">📤 {t("exportModalTitle")}</h2>
<div className="form-group">
<label className="form-label">{t("exportFormat")}</label>
<div style={{ display: "flex", gap: 8 }}>
<button
type="button"
className={`btn btn-sm ${exportFormat === "csv" ? "btn-primary" : "btn-ghost"}`}
onClick={() => setExportFormat("csv")}
style={{ flex: 1 }}
>
📄 CSV (TickTick/Excel)
</button>
<button
type="button"
className={`btn btn-sm ${exportFormat === "ics" ? "btn-primary" : "btn-ghost"}`}
onClick={() => setExportFormat("ics")}
style={{ flex: 1 }}
>
📅 ICS (iCalendar VTODO)
</button>
</div>
</div>
<div className="form-group">
<label className="form-label">{t("exportScope")}</label>
<select className="form-input" value={exportListId} onChange={(e) => setExportListId(e.target.value)}>
<option value="all">{t("allLists")}</option>
{lists.map((l) => (
<option key={l.id} value={l.id}>
{l.name}
</option>
))}
</select>
</div>
<div className="form-group" style={{ marginBottom: 16 }}>
<label style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer", fontSize: 13, color: "var(--text-primary)" }}>
<input
type="checkbox"
checked={exportIncludeCompleted}
onChange={(e) => setExportIncludeCompleted(e.target.checked)}
style={{ accentColor: "var(--accent)" }}
/>
<span>{t("includeCompleted")}</span>
</label>
</div>
<div className="modal-footer">
<button className="btn btn-ghost" onClick={() => setShowExport(false)} type="button">
{t("cancel")}
</button>
<button
id="export-submit-btn"
className="btn btn-primary"
onClick={handleExport}
disabled={exporting}
type="button"
>
{exporting ? "..." : `📥 ${t("exportBtn")}`}
</button>
</div>
</div>
</div>
)}
{/* Undo Toast */}
{undoToast && (
<div
+163 -24
View File
@@ -125,17 +125,20 @@ function PrefSlider({
);
}
export function SettingsModal({ isOpen, onClose, user, isDemo = false }: SettingsModalProps) {
export function SettingsModal({ isOpen, onClose, user, isDemo: _isDemo = false }: SettingsModalProps) {
const { t, lang, setLang } = useI18n();
const { theme, toggleTheme } = useTheme();
const { prefs, updatePrefs, resetToDefaults } = useUserPrefs();
const [activeTab, setActiveTab] = useState<"profile" | "preferences" | "labs" | "sync" | "admin">("profile");
const [displayName, setDisplayName] = useState(user.name || "Demo User");
const [email, setEmail] = useState(user.email || "demo@checkflow.local");
const [displayName, setDisplayName] = useState(() => user.name || (typeof window !== "undefined" ? getUserSettings().displayName : "Demo User"));
const [email, setEmail] = useState(() => user.email || (typeof window !== "undefined" ? getUserSettings().email : "demo@checkflow.local"));
const [password, setPassword] = useState("");
const [trashRetention, setTrashRetention] = useState(30);
const [trashRetention, setTrashRetention] = useState(() => (typeof window !== "undefined" ? (getUserSettings().trashRetentionDays ?? 30) : 30));
const [savedMsg, setSavedMsg] = useState("");
const [syncPlatform, setSyncPlatform] = useState<"android" | "apple" | "thunderbird">("android");
const [copiedCalDav, setCopiedCalDav] = useState(false);
const [testSyncStatus, setTestSyncStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
useEffect(() => {
if (isOpen) {
@@ -146,6 +149,23 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
}
}, [isOpen, user]);
const handleTestConnection = async () => {
setTestSyncStatus("testing");
try {
const res = await fetch("/api/dav", { method: "OPTIONS" });
if (res.ok || res.status === 401 || res.status === 207) {
setTestSyncStatus("success");
} else {
setTestSyncStatus("error");
}
} catch {
setTestSyncStatus("error");
}
setTimeout(() => {
setTestSyncStatus("idle");
}, 4000);
};
if (!isOpen) return null;
const handleSave = () => {
@@ -267,7 +287,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
</div>
<div className="form-group">
<label className="form-label">{t("language")}</label>
<select className="form-input" value={lang} onChange={(e) => setLang(e.target.value as any)}>
<select className="form-input" value={lang} onChange={(e) => setLang(e.target.value as "en" | "ko" | "ja")}>
<option value="en">English (Default)</option>
<option value="ko"> (Korean)</option>
<option value="ja"> (Japanese)</option>
@@ -428,7 +448,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
min={0}
max={360}
value={prefs.accentHue}
onChange={(e) => updatePrefs({ accentHue: parseInt(e.target.value) })}
onChange={(e) => updatePrefs({ accentHue: parseInt(e.target.value, 10) })}
style={{ width: 100, accentColor: accentPreview }}
/>
<div style={{ width: 18, height: 18, borderRadius: "50%", background: accentPreview, flexShrink: 0 }} />
@@ -490,30 +510,149 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
</div>
)}
{/* Tab: CalDAV / CardDAV Integrations */}
{/* Tab: CalDAV / External Sync */}
{activeTab === "sync" && (
<div className="settings-tab-content">
<h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>📱 Galaxy & Mobile Sync via DAVx</h3>
<p style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.5, marginBottom: 12 }}>
CheckFlow supports native two-way synchronization with Samsung Galaxy Reminder, Apple Reminders, and Thunderbird via CalDAV/CardDAV protocols.
<h3 style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}>
📱 {t("syncTitle") || "Galaxy & External Sync (CalDAV)"}
</h3>
<p style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.5, marginBottom: 14 }}>
{t("syncDesc") || "CheckFlow supports native two-way synchronization with Samsung Galaxy Reminder, Apple Reminders, and Thunderbird via CalDAV."}
</p>
<div className="form-group">
<label className="form-label">CalDAV Base Endpoint</label>
<div style={{ display: "flex", gap: 8 }}>
<input className="form-input" readOnly value={calDavUrl} style={{ fontFamily: "monospace", fontSize: 12 }} />
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { navigator.clipboard.writeText(calDavUrl); }}>
📋 Copy
{/* Endpoint bar & Action buttons */}
<div className="form-group" style={{ marginBottom: 14 }}>
<label className="form-label" style={{ fontWeight: 600 }}>{t("syncBaseUrl") || "CalDAV Server Base URL"}</label>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input
className="form-input"
readOnly
value={calDavUrl}
style={{ fontFamily: "monospace", fontSize: 12.5, background: "var(--bg-secondary)", flex: 1 }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => {
navigator.clipboard.writeText(calDavUrl);
setCopiedCalDav(true);
setTimeout(() => setCopiedCalDav(false), 2000);
}}
style={{ minWidth: 80, fontWeight: 600 }}
>
{copiedCalDav ? `${t("syncCopied") || "Copied!"}` : `📋 ${t("syncCopyUrl") || "Copy"}`}
</button>
</div>
</div>
<div style={{ background: "var(--bg-secondary)", padding: 12, borderRadius: "var(--radius-sm)", fontSize: 12, color: "var(--text-secondary)" }}>
<strong>Setup Instructions for DAVx (Android):</strong>
<ol style={{ paddingLeft: 18, marginTop: 6, lineHeight: 1.6 }}>
<li>Install <strong>DAVx</strong> on your Samsung Galaxy from Google Play or F-Droid.</li>
<li>Add account <strong>Login with URL and user name</strong>.</li>
<li>Base URL: <code>{calDavUrl}</code></li>
<li>User / Password: Your CheckFlow email & password.</li>
</ol>
{/* Actions row: Test & Download */}
<div style={{ display: "flex", gap: 10, marginBottom: 16, flexWrap: "wrap", alignItems: "center" }}>
<button
type="button"
className="btn btn-sm btn-ghost"
onClick={handleTestConnection}
disabled={testSyncStatus === "testing"}
style={{ display: "inline-flex", alignItems: "center", gap: 6 }}
>
{testSyncStatus === "testing" ? `${t("syncTesting") || "Testing..."}` : `🔍 ${t("syncTestConnection") || "Test Endpoint"}`}
</button>
<a
href="/api/dav"
target="_blank"
rel="noreferrer"
className="btn btn-sm btn-ghost"
style={{ display: "inline-flex", alignItems: "center", gap: 6, textDecoration: "none" }}
>
📥 {t("syncDownloadIcs") || "Download .ICS Feed"}
</a>
{testSyncStatus === "success" && (
<span style={{ fontSize: 12, color: "var(--success)", fontWeight: 600 }}>
{t("syncTestSuccess") || "✓ CalDAV endpoint responded successfully"}
</span>
)}
{testSyncStatus === "error" && (
<span style={{ fontSize: 12, color: "var(--danger)", fontWeight: 600 }}>
Endpoint test failed
</span>
)}
</div>
{/* Client setup guides segmented selector */}
<div style={{ marginBottom: 10 }}>
<SegmentedControl
value={syncPlatform}
options={[
{ label: `🤖 ${t("syncTabAndroid") || "Galaxy / DAVx⁵"}`, value: "android" },
{ label: `🍎 ${t("syncTabApple") || "Apple Reminders"}`, value: "apple" },
{ label: `💻 ${t("syncTabThunderbird") || "Thunderbird"}`, value: "thunderbird" },
]}
onChange={(v) => setSyncPlatform(v)}
/>
</div>
{/* Step by step cards */}
<div
style={{
background: "var(--bg-secondary)",
padding: "14px 16px",
borderRadius: "var(--radius-md)",
border: "1px solid var(--border)",
fontSize: 12.5,
color: "var(--text-primary)",
lineHeight: 1.6,
}}
>
{syncPlatform === "android" && (
<div>
<div style={{ fontWeight: 700, marginBottom: 6, color: "var(--accent)" }}>
📱 Samsung Galaxy & Android (DAVx + Reminder / OpenTasks)
</div>
<ol style={{ paddingLeft: 20, margin: 0, display: "flex", flexDirection: "column", gap: 4 }}>
<li>{t("syncAndroidStep1")}</li>
<li>{t("syncAndroidStep2")}</li>
<li>{t("syncAndroidStep3")}</li>
<li>{t("syncAndroidStep4")}</li>
</ol>
<div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}>
💡 <strong>Tip:</strong> In DAVx account settings, set Sync Interval to <strong>15 minutes</strong> for battery efficiency and near real-time sync.
</div>
</div>
)}
{syncPlatform === "apple" && (
<div>
<div style={{ fontWeight: 700, marginBottom: 6, color: "var(--accent)" }}>
🍎 Apple Reminders & Calendar (iOS / iPadOS / macOS)
</div>
<ol style={{ paddingLeft: 20, margin: 0, display: "flex", flexDirection: "column", gap: 4 }}>
<li>{t("syncAppleStep1")}</li>
<li>{t("syncAppleStep2")}</li>
<li>{t("syncAppleStep3")}</li>
<li>{t("syncAppleStep4")}</li>
</ol>
<div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}>
💡 <strong>Tip:</strong> If using HTTPS behind a reverse proxy (Nginx/Caddy), ensure valid SSL certificates are trusted by Apple devices.
</div>
</div>
)}
{syncPlatform === "thunderbird" && (
<div>
<div style={{ fontWeight: 700, marginBottom: 6, color: "var(--accent)" }}>
💻 Mozilla Thunderbird (Windows / Mac / Linux)
</div>
<ol style={{ paddingLeft: 20, margin: 0, display: "flex", flexDirection: "column", gap: 4 }}>
<li>{t("syncThunderbirdStep1")}</li>
<li>{t("syncThunderbirdStep2")}</li>
<li>{t("syncThunderbirdStep3")}</li>
<li>{t("syncThunderbirdStep4")}</li>
</ol>
<div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}>
💡 <strong>Tip:</strong> Thunderbird Tasks view will display CheckFlow priority tags, due dates, and completion status.
</div>
</div>
)}
</div>
</div>
)}
+6 -8
View File
@@ -40,7 +40,7 @@ export function TaskDetail({
const [dueDate, setDueDate] = useState(task.dueDate ? task.dueDate.split("T")[0] : "");
const [priority, setPriority] = useState(task.priority);
const [tags, setTags] = useState<{ tag: Tag }[]>(task.tags || []);
const [allAvailableTags, setAllAvailableTags] = useState<MockTag[]>([]);
const [allAvailableTags, setAllAvailableTags] = useState<MockTag[]>(() => (typeof window !== "undefined" ? getCustomTags() : []));
const [showTagPicker, setShowTagPicker] = useState(false);
const [showListPicker, setShowListPicker] = useState(false);
const [newTagName, setNewTagName] = useState("");
@@ -50,13 +50,13 @@ export function TaskDetail({
const blockOrder = prefs.detailBlockOrder;
const splitRatio = prefs.detailSplitRatio;
const setBlockOrder = (next: ("subtasks" | "note")[]) => {
const setBlockOrder = useCallback((next: ("subtasks" | "note")[]) => {
updatePrefs({ detailBlockOrder: next });
};
}, [updatePrefs]);
const setSplitRatio = (r: number) => {
const setSplitRatio = useCallback((r: number) => {
updatePrefs({ detailSplitRatio: r });
};
}, [updatePrefs]);
const [subtasksCollapsed, setSubtasksCollapsed] = useState(false);
const [noteCollapsed, setNoteCollapsed] = useState(false);
@@ -117,7 +117,6 @@ export function TaskDetail({
const handleMouseUp = () => {
if (isDraggingSplit) {
setIsDraggingSplit(false);
// splitRatio is already saved via updatePrefs in handleMouseMove
}
};
@@ -129,7 +128,7 @@ export function TaskDetail({
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [isDraggingSplit, splitRatio]);
}, [isDraggingSplit, setSplitRatio]);
// Sync state when switching task
useEffect(() => {
@@ -144,7 +143,6 @@ export function TaskDetail({
setPriority(task.priority);
setTags(task.tags || []);
setSubtasks(task.children || []);
setAllAvailableTags(getCustomTags());
}, [task.id, task.title, task.note, task.dueDate, task.priority, task.children, task.tags]);
useEffect(() => {
+2 -7
View File
@@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useI18n } from "@/lib/i18n";
import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu";
import { ContextMenu } from "@/components/ui/ContextMenu";
import { KanbanView } from "./KanbanView";
import { useUserPrefs } from "@/lib/useUserPrefs";
@@ -31,11 +31,6 @@ export interface User { id: string; name?: string | null; email?: string | null
const PRIORITY_COLORS = ["transparent", "var(--priority-low)", "var(--priority-medium)", "var(--priority-high)"];
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;
depth?: number;
@@ -469,7 +464,7 @@ interface Props {
}
export function TaskList({
user,
user: _user,
listId,
lists,
tasks,
+112 -6
View File
@@ -43,6 +43,14 @@ export const translations = {
tickTickExportHint: "TickTick: Settings → Export → Export as CSV or iCalendar",
importBtn: "Import",
importing: "Importing...",
exportTasks: "Export Tasks",
exportModalTitle: "Export Tasks",
exportFormat: "Format",
exportScope: "Target List",
allLists: "All Lists (Entire Tasks)",
includeCompleted: "Include Completed Tasks",
exportBtn: "Export & Download",
exportSuccess: "Tasks exported successfully!",
theme: "Theme",
themeSystem: "System",
themeLight: "Light",
@@ -67,6 +75,32 @@ export const translations = {
days30: "30 Days (Recommended)",
neverDelete: "Never Auto-Delete",
// CalDAV & Sync
syncTitle: "Galaxy & External Sync (CalDAV)",
syncDesc: "CheckFlow supports native two-way synchronization with Samsung Galaxy Reminder, Apple Reminders, and Thunderbird via CalDAV.",
syncBaseUrl: "CalDAV Server Base URL",
syncCopyUrl: "Copy URL",
syncCopied: "Copied!",
syncDownloadIcs: "Download .ICS Feed",
syncTestConnection: "Test Endpoint",
syncTesting: "Testing...",
syncTestSuccess: "✓ CalDAV endpoint responded successfully (200 OK)",
syncTabAndroid: "Galaxy / Android (DAVx⁵)",
syncTabApple: "Apple Reminders (iOS / macOS)",
syncTabThunderbird: "Thunderbird (Desktop)",
syncAndroidStep1: "Install DAVx⁵ from Google Play Store or F-Droid.",
syncAndroidStep2: "Open DAVx⁵ → Add Account → Select 'Login with URL and user name'.",
syncAndroidStep3: "Paste the Base URL above, then enter your CheckFlow email & password.",
syncAndroidStep4: "Enable 'VTODO (Tasks)' collection to sync with Samsung Reminder or OpenTasks.",
syncAppleStep1: "Open Settings → Reminders → Accounts → Add Account.",
syncAppleStep2: "Select 'Other' → Add CalDAV Account.",
syncAppleStep3: "Server: paste base URL without protocol, User/Pass: CheckFlow credentials.",
syncAppleStep4: "Toggle 'Reminders' ON and tap Save.",
syncThunderbirdStep1: "Open Thunderbird → Switch to Calendar view → New Calendar.",
syncThunderbirdStep2: "Select 'On the Network' → Format: CalDAV.",
syncThunderbirdStep3: "Location: paste the Base URL, Username: CheckFlow email.",
syncThunderbirdStep4: "Enter your password when prompted to finalize sync.",
// Task List
tasks: "Tasks",
hideDone: "Hide done",
@@ -189,6 +223,14 @@ export const translations = {
tickTickExportHint: "TickTick: 설정 → 데이터 내보내기 → CSV 또는 iCalendar",
importBtn: "가져오기",
importing: "가져오는 중이에요...",
exportTasks: "내보내기",
exportModalTitle: "할 일 내보내기",
exportFormat: "내보내기 형식",
exportScope: "내보낼 목록",
allLists: "모든 목록 (전체 할 일)",
includeCompleted: "완료된 항목 포함",
exportBtn: "내보내기 및 다운로드",
exportSuccess: "할 일을 성공적으로 내보냈어요!",
theme: "테마",
themeSystem: "시스템",
themeLight: "라이트",
@@ -204,7 +246,7 @@ export const translations = {
settingsModalTitle: "설정",
profile: "프로필",
preferences: "환경설정",
syncIntegrations: "외부 연동",
syncIntegrations: "외부 연동 (CalDAV)",
admin: "관리자",
trashRetention: "휴지통 자동 비우기",
trashRetentionHint: "설정한 기간이 지나면 휴지통이 자동으로 비워져요.",
@@ -213,6 +255,32 @@ export const translations = {
days30: "30일 후 자동 삭제 (권장)",
neverDelete: "자동 삭제 안 함",
// CalDAV & Sync
syncTitle: "갤럭시 및 외부 캘린더 연동 (CalDAV)",
syncDesc: "삼성 갤럭시 리마인더, 애플 미리알림, Thunderbird 등 CalDAV 표준을 지원하는 모든 기기와 양방향으로 동기화됩니다.",
syncBaseUrl: "CalDAV 기본 주소 (Base URL)",
syncCopyUrl: "URL 복사",
syncCopied: "복사 완료!",
syncDownloadIcs: ".ICS 피드 다운로드",
syncTestConnection: "엔드포인트 점검",
syncTesting: "점검 중...",
syncTestSuccess: "✓ CalDAV 엔드포인트가 정상적으로 응답합니다 (200 OK)",
syncTabAndroid: "갤럭시 / 안드로이드 (DAVx⁵)",
syncTabApple: "애플 미리알림 (iOS / macOS)",
syncTabThunderbird: "Thunderbird (PC)",
syncAndroidStep1: "구글 플레이 스토어 또는 F-Droid에서 'DAVx⁵' 앱을 설치합니다.",
syncAndroidStep2: "DAVx⁵ 실행 → 계정 추가(+) → 'URL 및 사용자 이름으로 로그인'을 선택합니다.",
syncAndroidStep3: "위 기본 주소를 붙여넣고, CheckFlow 로그인 이메일과 비밀번호를 입력합니다.",
syncAndroidStep4: "'VTODO (할 일)'을 켜면 삼성 리마인더 또는 OpenTasks와 자동으로 실시간 동기화됩니다.",
syncAppleStep1: "기기 설정 → 미리알림(또는 캘린더) → 계정 → '계정 추가'를 탭합니다.",
syncAppleStep2: "'기타' 선택 → 'CalDAV 계정 추가'를 누릅니다.",
syncAppleStep3: "서버에 위 주소를 입력하고, 사용자 이름/비밀번호에 CheckFlow 계정 정보를 입력합니다.",
syncAppleStep4: "'미리알림' 항목을 활성화하고 저장합니다.",
syncThunderbirdStep1: "Thunderbird 실행 → 캘린더 탭으로 이동 → '새 캘린더'를 클릭합니다.",
syncThunderbirdStep2: "'네트워크에 저장' 선택 → 형식: 'CalDAV'를 선택합니다.",
syncThunderbirdStep3: "위치에 위 기본 주소를 붙여넣고, 사용자 이름에 CheckFlow 이메일을 입력합니다.",
syncThunderbirdStep4: "인증 팝업이 뜨면 비밀번호를 입력하여 완료합니다.",
// Task List
tasks: "할 일",
hideDone: "완료 숨기기",
@@ -335,6 +403,14 @@ export const translations = {
tickTickExportHint: "TickTick: 設定 → エクスポート → CSV または iCalendar",
importBtn: "インポートする",
importing: "インポート中...",
exportTasks: "エクスポート",
exportModalTitle: "タスクをエクスポート",
exportFormat: "エクスポート形式",
exportScope: "対象リスト",
allLists: "すべてのリスト(全体)",
includeCompleted: "完了したタスクを含める",
exportBtn: "エクスポートして保存",
exportSuccess: "タスクをエクスポートしました!",
theme: "テーマ",
themeSystem: "システム",
themeLight: "ライト",
@@ -350,7 +426,7 @@ export const translations = {
settingsModalTitle: "設定",
profile: "プロフィール",
preferences: "環境設定",
syncIntegrations: "外部連携",
syncIntegrations: "外部連携 (CalDAV)",
admin: "管理者",
trashRetention: "ゴミ箱の自動削除",
trashRetentionHint: "設定した期間が過ぎると、ゴミ箱が自動的に空になります。",
@@ -359,6 +435,32 @@ export const translations = {
days30: "30日後に自動削除(推奨)",
neverDelete: "自動削除しない",
// CalDAV & Sync
syncTitle: "Galaxy & 外部カレンダー連携 (CalDAV)",
syncDesc: "Samsung Galaxy リマインダー、Apple リマインダー、Thunderbird など CalDAV 対応アプリと双方向同期できます。",
syncBaseUrl: "CalDAV サーバー基本 URL",
syncCopyUrl: "URL をコピー",
syncCopied: "コピーしました!",
syncDownloadIcs: ".ICS フィードをダウンロード",
syncTestConnection: "接続テスト",
syncTesting: "テスト中...",
syncTestSuccess: "✓ CalDAV エンドポイントは正常に応答しています (200 OK)",
syncTabAndroid: "Galaxy / Android (DAVx⁵)",
syncTabApple: "Apple リマインダー (iOS / macOS)",
syncTabThunderbird: "Thunderbird (PC)",
syncAndroidStep1: "Google Play または F-Droid から「DAVx⁵」アプリをインストールします。",
syncAndroidStep2: "DAVx⁵ を起動 → アカウント追加 →「URL とユーザー名でログイン」を選択します。",
syncAndroidStep3: "上記の基本 URL を貼り付け、CheckFlow のメールアドレスとパスワードを入力します。",
syncAndroidStep4: "「VTODO (タスク)」を有効にすると、Samsung リマインダーや OpenTasks と連携されます。",
syncAppleStep1: "設定 → リマインダー → アカウント →「アカウントを追加」を開きます。",
syncAppleStep2: "「その他」を選択 →「CalDAV アカウントを追加」を選択します。",
syncAppleStep3: "サーバーに上記の URL、ユーザー名/パスワードに CheckFlow のアカウント情報を入力します。",
syncAppleStep4: "「リマインダー」を有効にして保存します。",
syncThunderbirdStep1: "Thunderbird を起動 → カレンダータブ → 新しいカレンダーを作成します。",
syncThunderbirdStep2: "「ネットワーク上」を選択 → 形式: CalDAV を選択します。",
syncThunderbirdStep3: "場所に上記の URL、ユーザー名に CheckFlow のメールアドレスを入力します。",
syncThunderbirdStep4: "ログイン画面でパスワードを入力して完了します。",
// Task List
tasks: "タスク",
hideDone: "完了を非表示",
@@ -468,19 +570,23 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
if (saved && (saved === "en" || saved === "ko" || saved === "ja")) {
setLangState(saved);
}
} catch {}
} catch {
// ignore
}
}, []);
const setLang = (l: Language) => {
setLangState(l);
try {
localStorage.setItem(LANG_STORAGE_KEY, l);
} catch {}
} catch {
// ignore
}
};
const t = (key: TranslationKeys): string => {
const dict = translations[lang] || translations.en;
return (dict as any)[key] || translations.en[key] || key;
const dict = (translations[lang] || translations.en) as Record<string, string>;
return dict[key] || translations.en[key] || key;
};
return <I18nContext.Provider value={{ lang, setLang, t }}>{children}</I18nContext.Provider>;