Infrastructure Sync: Initial migration to custom self-hosted Gitea
This commit is contained in:
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)");
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user