Feature: Add i18n (en/ko/ja), 3-way theme (system/light/dark), and local demo preview mode
This commit is contained in:
+282
-147
@@ -1,28 +1,31 @@
|
||||
"use client";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
|
||||
interface Task {
|
||||
id: string; listId: string; parentId: string | null; title: string; note: string | null;
|
||||
completed: boolean; completedAt: string | null; dueDate: string | null; priority: number;
|
||||
sortOrder: number; createdAt: string; updatedAt: string;
|
||||
children: Task[]; tags: { tag: { id: string; name: string; color: string } }[];
|
||||
}
|
||||
|
||||
const PRIORITY_MAP = [
|
||||
{ label: "None", color: "var(--text-tertiary)", icon: "" },
|
||||
{ label: "Low", color: "var(--priority-low)", icon: "▼" },
|
||||
{ label: "Medium", color: "var(--priority-medium)", icon: "▶" },
|
||||
{ label: "High", color: "var(--priority-high)", icon: "▲" },
|
||||
];
|
||||
"use client";
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { Task } from "./TaskList";
|
||||
|
||||
interface Props {
|
||||
task: Task; listId: string;
|
||||
task: Task;
|
||||
listId: string;
|
||||
onClose: () => void;
|
||||
onUpdate: (t: Task) => void;
|
||||
onDelete: () => void;
|
||||
isDemo?: boolean;
|
||||
onDemoUpdateTask?: (updated: Task) => void;
|
||||
onDemoDeleteTask?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props) {
|
||||
export function TaskDetail({
|
||||
task,
|
||||
listId,
|
||||
onClose,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
isDemo = false,
|
||||
onDemoUpdateTask,
|
||||
onDemoDeleteTask,
|
||||
}: Props) {
|
||||
const { t, lang } = useI18n();
|
||||
|
||||
const [title, setTitle] = useState(task.title);
|
||||
const [note, setNote] = useState(task.note || "");
|
||||
const [dueDate, setDueDate] = useState(task.dueDate ? task.dueDate.split("T")[0] : "");
|
||||
@@ -31,12 +34,18 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
||||
const [subtasks, setSubtasks] = useState(task.children || []);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Store timer + current task id in refs to prevent stale saves across task switches
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const currentTaskId = useRef(task.id);
|
||||
const noteRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Sync state when switching to a different task — clear any pending timer first
|
||||
const priorityMap = [
|
||||
{ label: t("priorityNone"), color: "var(--text-tertiary)", icon: "" },
|
||||
{ label: t("priorityLow"), color: "var(--priority-low)", icon: "▼" },
|
||||
{ label: t("priorityMedium"), color: "var(--priority-medium)", icon: "▶" },
|
||||
{ label: t("priorityHigh"), color: "var(--priority-high)", icon: "▲" },
|
||||
];
|
||||
|
||||
// Sync state when switching task
|
||||
useEffect(() => {
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
@@ -48,100 +57,172 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
||||
setDueDate(task.dueDate ? task.dueDate.split("T")[0] : "");
|
||||
setPriority(task.priority);
|
||||
setSubtasks(task.children || []);
|
||||
}, [task.id]);
|
||||
}, [task.id, task.title, task.note, task.dueDate, task.priority, task.children]);
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async (taskId: string, data: Record<string, unknown>) => {
|
||||
// Guard: don't save if task has changed since debounce was scheduled
|
||||
if (taskId !== currentTaskId.current) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${taskId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
const save = useCallback(
|
||||
async (taskId: string, data: Record<string, unknown>) => {
|
||||
if (taskId !== currentTaskId.current) return;
|
||||
setSaving(true);
|
||||
|
||||
if (isDemo) {
|
||||
const updatedTask: Task = {
|
||||
...task,
|
||||
...data,
|
||||
children: subtasks,
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as Task;
|
||||
if (onDemoUpdateTask) onDemoUpdateTask(updatedTask);
|
||||
onUpdate(updatedTask);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${taskId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
if (taskId === currentTaskId.current) {
|
||||
onUpdate({ ...updated, children: subtasks });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[TaskDetail] save failed", err);
|
||||
} finally {
|
||||
if (taskId === currentTaskId.current) setSaving(false);
|
||||
}
|
||||
},
|
||||
[isDemo, onDemoUpdateTask, onUpdate, subtasks, task]
|
||||
);
|
||||
|
||||
const debounceSave = useCallback(
|
||||
(overrides: Record<string, unknown>) => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
const taskId = currentTaskId.current;
|
||||
saveTimer.current = setTimeout(() => save(taskId, overrides), 600);
|
||||
},
|
||||
[save]
|
||||
);
|
||||
|
||||
// Insert markdown at cursor position
|
||||
const insertAtCursor = useCallback(
|
||||
(before: string, after = "", placeholder = "") => {
|
||||
const ta = noteRef.current;
|
||||
if (!ta) {
|
||||
setNote((n) => {
|
||||
const v = n + before + placeholder + after;
|
||||
debounceSave({ note: v });
|
||||
return v;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const start = ta.selectionStart ?? ta.value.length;
|
||||
const end = ta.selectionEnd ?? ta.value.length;
|
||||
const selected = ta.value.slice(start, end) || placeholder;
|
||||
const newVal = ta.value.slice(0, start) + before + selected + after + ta.value.slice(end);
|
||||
setNote(newVal);
|
||||
debounceSave({ note: newVal });
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
ta.selectionStart = start + before.length;
|
||||
ta.selectionEnd = start + before.length + selected.length;
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
// Only update if we're still on the same task
|
||||
if (taskId === currentTaskId.current) {
|
||||
},
|
||||
[debounceSave]
|
||||
);
|
||||
|
||||
const handleToggleCompleted = useCallback(
|
||||
async (newCompleted: boolean) => {
|
||||
if (isDemo) {
|
||||
const updatedTask: Task = {
|
||||
...task,
|
||||
completed: newCompleted,
|
||||
completedAt: newCompleted ? new Date().toISOString() : null,
|
||||
children: subtasks,
|
||||
};
|
||||
if (onDemoUpdateTask) onDemoUpdateTask(updatedTask);
|
||||
onUpdate(updatedTask);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${task.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ completed: newCompleted }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
onUpdate({ ...updated, children: subtasks });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[TaskDetail] toggle failed", err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[TaskDetail] save failed", err);
|
||||
} finally {
|
||||
if (taskId === currentTaskId.current) setSaving(false);
|
||||
}
|
||||
}, [onUpdate, subtasks]);
|
||||
|
||||
const debounceSave = useCallback((overrides: Record<string, unknown>) => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
const taskId = currentTaskId.current;
|
||||
saveTimer.current = setTimeout(() => save(taskId, overrides), 800);
|
||||
}, [save]);
|
||||
|
||||
// Insert text at cursor position in the note textarea
|
||||
const insertAtCursor = useCallback((before: string, after = "", placeholder = "") => {
|
||||
const ta = noteRef.current;
|
||||
if (!ta) {
|
||||
setNote((n) => { const v = n + before + placeholder + after; debounceSave({ note: v }); return v; });
|
||||
return;
|
||||
}
|
||||
const start = ta.selectionStart ?? ta.value.length;
|
||||
const end = ta.selectionEnd ?? ta.value.length;
|
||||
const selected = ta.value.slice(start, end) || placeholder;
|
||||
const newVal = ta.value.slice(0, start) + before + selected + after + ta.value.slice(end);
|
||||
setNote(newVal);
|
||||
debounceSave({ note: newVal });
|
||||
// Restore cursor after React re-render
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
ta.selectionStart = start + before.length;
|
||||
ta.selectionEnd = start + before.length + selected.length;
|
||||
});
|
||||
}, [debounceSave]);
|
||||
|
||||
const handleToggleCompleted = useCallback(async (newCompleted: boolean) => {
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${task.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ completed: newCompleted }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
onUpdate({ ...updated, children: subtasks });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[TaskDetail] toggle failed", err);
|
||||
}
|
||||
}, [task.id, subtasks, onUpdate]);
|
||||
},
|
||||
[isDemo, onDemoUpdateTask, task, subtasks, onUpdate]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!confirm("Delete this task?")) return;
|
||||
if (!confirm(t("deleteTaskConfirm"))) return;
|
||||
|
||||
if (isDemo) {
|
||||
if (onDemoDeleteTask) onDemoDeleteTask(task.id);
|
||||
onDelete();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(`/api/tasks/${task.id}`, { method: "DELETE" });
|
||||
onDelete();
|
||||
} catch (err) {
|
||||
console.error("[TaskDetail] delete failed", err);
|
||||
}
|
||||
}, [task.id, onDelete]);
|
||||
}, [t, isDemo, onDemoDeleteTask, task.id, onDelete]);
|
||||
|
||||
const addSubtask = useCallback(async () => {
|
||||
const t = newSubtitle.trim();
|
||||
if (!t) return;
|
||||
const tTitle = newSubtitle.trim();
|
||||
if (!tTitle) return;
|
||||
|
||||
if (isDemo) {
|
||||
const newSub: Task = {
|
||||
id: "demo-sub-" + Date.now(),
|
||||
listId,
|
||||
parentId: task.id,
|
||||
title: tTitle,
|
||||
note: null,
|
||||
completed: false,
|
||||
completedAt: null,
|
||||
dueDate: null,
|
||||
priority: 0,
|
||||
sortOrder: subtasks.length,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
children: [],
|
||||
tags: [],
|
||||
};
|
||||
const nextSubs = [...subtasks, newSub];
|
||||
setSubtasks(nextSubs);
|
||||
setNewSubtitle("");
|
||||
const updatedParent = { ...task, children: nextSubs };
|
||||
if (onDemoUpdateTask) onDemoUpdateTask(updatedParent);
|
||||
onUpdate(updatedParent);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/tasks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: t, listId, parentId: task.id }),
|
||||
body: JSON.stringify({ title: tTitle, listId, parentId: task.id }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const sub = await res.json();
|
||||
@@ -155,40 +236,70 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
||||
} catch (err) {
|
||||
console.error("[TaskDetail] addSubtask failed", err);
|
||||
}
|
||||
}, [newSubtitle, listId, task, onUpdate]);
|
||||
}, [newSubtitle, isDemo, listId, task, subtasks, onDemoUpdateTask, onUpdate]);
|
||||
|
||||
const toggleSubtask = useCallback(async (sub: Task) => {
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${sub.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ completed: !sub.completed }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
const toggleSubtask = useCallback(
|
||||
async (sub: Task) => {
|
||||
const newCompleted = !sub.completed;
|
||||
|
||||
if (isDemo) {
|
||||
const nextSubs = subtasks.map((s) =>
|
||||
s.id === sub.id
|
||||
? { ...s, completed: newCompleted, completedAt: newCompleted ? new Date().toISOString() : null }
|
||||
: s
|
||||
);
|
||||
setSubtasks(nextSubs);
|
||||
const updatedParent = { ...task, children: nextSubs };
|
||||
if (onDemoUpdateTask) onDemoUpdateTask(updatedParent);
|
||||
onUpdate(updatedParent);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${sub.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ completed: newCompleted }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
setSubtasks((prev) => {
|
||||
const next = prev.map((s) => (s.id === sub.id ? updated : s));
|
||||
onUpdate({ ...task, children: next });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[TaskDetail] toggleSubtask failed", err);
|
||||
}
|
||||
},
|
||||
[isDemo, subtasks, task, onDemoUpdateTask, onUpdate]
|
||||
);
|
||||
|
||||
const deleteSubtask = useCallback(
|
||||
async (id: string) => {
|
||||
if (isDemo) {
|
||||
const nextSubs = subtasks.filter((s) => s.id !== id);
|
||||
setSubtasks(nextSubs);
|
||||
const updatedParent = { ...task, children: nextSubs };
|
||||
if (onDemoUpdateTask) onDemoUpdateTask(updatedParent);
|
||||
onUpdate(updatedParent);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(`/api/tasks/${id}`, { method: "DELETE" });
|
||||
setSubtasks((prev) => {
|
||||
const next = prev.map((s) => (s.id === sub.id ? updated : s));
|
||||
const next = prev.filter((s) => s.id !== id);
|
||||
onUpdate({ ...task, children: next });
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[TaskDetail] deleteSubtask failed", err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[TaskDetail] toggleSubtask failed", err);
|
||||
}
|
||||
}, [task, onUpdate]);
|
||||
|
||||
const deleteSubtask = useCallback(async (id: string) => {
|
||||
try {
|
||||
await fetch(`/api/tasks/${id}`, { method: "DELETE" });
|
||||
setSubtasks((prev) => {
|
||||
const next = prev.filter((s) => s.id !== id);
|
||||
onUpdate({ ...task, children: next });
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[TaskDetail] deleteSubtask failed", err);
|
||||
}
|
||||
}, [task, onUpdate]);
|
||||
},
|
||||
[isDemo, subtasks, task, onDemoUpdateTask, onUpdate]
|
||||
);
|
||||
|
||||
const completedCount = subtasks.filter((s) => s.completed).length;
|
||||
const progressPct = subtasks.length > 0 ? Math.round((completedCount / subtasks.length) * 100) : 0;
|
||||
@@ -204,13 +315,17 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
||||
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
|
||||
/>
|
||||
<span style={{ flex: 1, fontSize: 12, color: "var(--text-tertiary)", fontWeight: 500 }}>
|
||||
{saving ? "Saving…" : "Auto-saved"}
|
||||
{saving ? t("saving") : t("autoSaved")}
|
||||
</span>
|
||||
<button className="btn btn-ghost btn-sm btn-danger" id="delete-task-btn" onClick={handleDelete} title="Delete task">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
|
||||
<button className="btn btn-ghost btn-sm btn-danger" id="delete-task-btn" onClick={handleDelete} title={t("deleteTaskConfirm")}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="detail-close-btn" id="detail-close-btn" onClick={onClose} title="Close">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
<button className="detail-close-btn" id="detail-close-btn" onClick={onClose} title={t("cancel")}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -225,7 +340,7 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
||||
setTitle(e.target.value);
|
||||
debounceSave({ title: e.target.value });
|
||||
}}
|
||||
placeholder="Task title"
|
||||
placeholder={t("taskTitlePlaceholder")}
|
||||
rows={2}
|
||||
style={{
|
||||
textDecoration: task.completed ? "line-through" : "none",
|
||||
@@ -235,15 +350,18 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
||||
|
||||
{/* Priority */}
|
||||
<div className="detail-section">
|
||||
<div className="detail-section-label">Priority</div>
|
||||
<div className="detail-section-label">{t("priority")}</div>
|
||||
<div className="priority-selector">
|
||||
{PRIORITY_MAP.map((p, i) => (
|
||||
{priorityMap.map((p, i) => (
|
||||
<button
|
||||
key={i}
|
||||
id={`priority-${i}`}
|
||||
className={`priority-btn${priority === i ? ` active-${p.label.toLowerCase()}` : ""}`}
|
||||
className={`priority-btn${priority === i ? ` active-${["none", "low", "medium", "high"][i]}` : ""}`}
|
||||
style={{ color: priority === i ? p.color : undefined }}
|
||||
onClick={() => { setPriority(i); debounceSave({ priority: i }); }}
|
||||
onClick={() => {
|
||||
setPriority(i);
|
||||
debounceSave({ priority: i });
|
||||
}}
|
||||
>
|
||||
{p.icon && <span style={{ color: p.color }}>{p.icon}</span>}
|
||||
{p.label}
|
||||
@@ -254,7 +372,7 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
||||
|
||||
{/* Due date */}
|
||||
<div className="detail-section">
|
||||
<div className="detail-section-label">Due Date</div>
|
||||
<div className="detail-section-label">{t("dueDate")}</div>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<input
|
||||
id="due-date-input"
|
||||
@@ -270,32 +388,35 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
||||
{dueDate && (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => { setDueDate(""); debounceSave({ dueDate: null }); }}
|
||||
onClick={() => {
|
||||
setDueDate("");
|
||||
debounceSave({ dueDate: null });
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
{t("clear")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note / Memo */}
|
||||
{/* Notes — wide memo area */}
|
||||
<div className="detail-section" style={{ flex: 1 }}>
|
||||
<div className="detail-section-label">Notes</div>
|
||||
<div className="detail-section-label">{t("notes")}</div>
|
||||
<div className="note-editor-wrap">
|
||||
<div className="note-editor-toolbar">
|
||||
<button className="note-toolbar-btn" title="Bold (Ctrl+B)" onClick={() => insertAtCursor("**", "**", "bold")}>B</button>
|
||||
<button className="note-toolbar-btn" title="Italic (Ctrl+I)" style={{ fontStyle: "italic" }} onClick={() => insertAtCursor("*", "*", "italic")}>I</button>
|
||||
<button className="note-toolbar-btn" title="Heading" onClick={() => insertAtCursor("## ", "", "Heading")}>H</button>
|
||||
<button className="note-toolbar-btn" title="Bullet list" onClick={() => insertAtCursor("\n- ", "", "item")}>•</button>
|
||||
<button className="note-toolbar-btn" title="Numbered list" onClick={() => insertAtCursor("\n1. ", "", "item")}>1.</button>
|
||||
<button className="note-toolbar-btn" title="Checkbox" onClick={() => insertAtCursor("\n- [ ] ", "", "task")}>☐</button>
|
||||
<button className="note-toolbar-btn" title="Code" style={{ fontFamily: "monospace", fontSize: 11 }} onClick={() => insertAtCursor("`", "`", "code")}>{"`"}</button>
|
||||
<button className="note-toolbar-btn" title={t("bold")} onClick={() => insertAtCursor("**", "**", "bold")}>B</button>
|
||||
<button className="note-toolbar-btn" title={t("italic")} style={{ fontStyle: "italic" }} onClick={() => insertAtCursor("*", "*", "italic")}>I</button>
|
||||
<button className="note-toolbar-btn" title={t("heading")} onClick={() => insertAtCursor("## ", "", "Heading")}>H</button>
|
||||
<button className="note-toolbar-btn" title={t("bulletList")} onClick={() => insertAtCursor("\n- ", "", "item")}>•</button>
|
||||
<button className="note-toolbar-btn" title={t("numberedList")} onClick={() => insertAtCursor("\n1. ", "", "item")}>1.</button>
|
||||
<button className="note-toolbar-btn" title={t("checkbox")} onClick={() => insertAtCursor("\n- [ ] ", "", "task")}>☐</button>
|
||||
<button className="note-toolbar-btn" title={t("code")} style={{ fontFamily: "monospace", fontSize: 11 }} onClick={() => insertAtCursor("`", "`", "code")}>{"`"}</button>
|
||||
</div>
|
||||
<textarea
|
||||
ref={noteRef}
|
||||
id="note-textarea"
|
||||
className="note-textarea"
|
||||
placeholder={"Add notes, details, or anything you need to remember…\n\nMarkdown: **bold**, *italic*, # heading, - list, - [ ] checkbox"}
|
||||
placeholder={t("notesPlaceholder")}
|
||||
value={note}
|
||||
onChange={(e) => {
|
||||
setNote(e.target.value);
|
||||
@@ -308,9 +429,11 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
||||
{/* Sub-tasks */}
|
||||
<div className="detail-section">
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
|
||||
<div className="detail-section-label">Sub-tasks</div>
|
||||
<div className="detail-section-label">{t("subtasks")}</div>
|
||||
{subtasks.length > 0 && (
|
||||
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{completedCount}/{subtasks.length}</span>
|
||||
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>
|
||||
{completedCount}/{subtasks.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -339,7 +462,9 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
||||
onClick={() => deleteSubtask(sub.id)}
|
||||
aria-label="Delete subtask"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -347,16 +472,21 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
||||
<span style={{ fontSize: 16, lineHeight: 1, color: "var(--accent)" }}>+</span>
|
||||
<input
|
||||
id="add-subtask-input"
|
||||
placeholder="Add sub-task…"
|
||||
placeholder={t("addSubtaskPlaceholder")}
|
||||
style={{ flex: 1, background: "none", fontSize: 13, color: "var(--text-primary)" }}
|
||||
value={newSubtitle}
|
||||
onChange={(e) => setNewSubtitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); addSubtask(); }
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addSubtask();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{newSubtitle.trim() && (
|
||||
<button className="btn btn-primary btn-sm" id="add-subtask-btn" onClick={addSubtask}>Add</button>
|
||||
<button className="btn btn-primary btn-sm" id="add-subtask-btn" onClick={addSubtask}>
|
||||
{t("add")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -364,7 +494,12 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
|
||||
|
||||
{/* Metadata */}
|
||||
<div style={{ fontSize: 11, color: "var(--text-tertiary)", paddingTop: 8, borderTop: "1px solid var(--border)" }}>
|
||||
Created {new Date(task.createdAt).toLocaleDateString("ko-KR", { year: "numeric", month: "short", day: "numeric" })}
|
||||
{t("created")}{" "}
|
||||
{new Date(task.createdAt).toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
"use client";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
"use client";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
interface Task {
|
||||
id: string; listId: string; parentId: string | null; title: string; note: string | null;
|
||||
completed: boolean; completedAt: string | null; dueDate: string | null; priority: number;
|
||||
sortOrder: number; createdAt: string; updatedAt: string;
|
||||
children: Task[]; tags: { tag: { id: string; name: string; color: string } }[];
|
||||
export interface Task {
|
||||
id: string;
|
||||
listId: string;
|
||||
parentId: string | null;
|
||||
title: string;
|
||||
note: string | null;
|
||||
completed: boolean;
|
||||
completedAt: string | null;
|
||||
dueDate: string | null;
|
||||
priority: number;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
children: Task[];
|
||||
tags: { tag: { id: string; name: string; color: string } }[];
|
||||
}
|
||||
interface List { id: string; name: string; color: string; icon: string }
|
||||
interface User { id: string; name?: string | null; email?: string | null }
|
||||
|
||||
export interface List { id: string; name: string; color: string; icon: string }
|
||||
export interface User { id: string; name?: string | null; email?: string | null }
|
||||
|
||||
const PRIORITY_COLORS = ["transparent", "var(--priority-low)", "var(--priority-medium)", "var(--priority-high)"];
|
||||
const PRIORITY_LABELS = ["", "Low", "Medium", "High"];
|
||||
|
||||
function formatDate(d: string | null) {
|
||||
if (!d) return null;
|
||||
const date = new Date(d);
|
||||
const now = new Date();
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
const isTomorrow = date.toDateString() === new Date(now.getTime() + 86400000).toDateString();
|
||||
if (isToday) return "Today";
|
||||
if (isTomorrow) return "Tomorrow";
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function isOverdue(d: string | null) {
|
||||
if (!d) return false;
|
||||
@@ -30,14 +30,29 @@ function isOverdue(d: string | null) {
|
||||
}
|
||||
|
||||
interface TaskItemProps {
|
||||
task: Task; isSelected: boolean;
|
||||
task: Task;
|
||||
isSelected: boolean;
|
||||
onSelect: (t: Task) => void;
|
||||
onToggle: (id: string, completed: boolean) => void;
|
||||
}
|
||||
|
||||
function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const completedChildren = task.children.filter((c) => c.completed).length;
|
||||
const { t, lang } = useI18n();
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const completedChildren = task.children?.filter((c) => c.completed).length || 0;
|
||||
|
||||
const formatDate = (d: string | null) => {
|
||||
if (!d) return null;
|
||||
const date = new Date(d);
|
||||
const now = new Date();
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
const isTomorrow = date.toDateString() === new Date(now.getTime() + 86400000).toDateString();
|
||||
if (isToday) return t("today");
|
||||
if (isTomorrow) return t("tomorrow");
|
||||
return date.toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", { month: "short", day: "numeric" });
|
||||
};
|
||||
|
||||
const priorityLabels = [t("priorityNone"), t("priorityLow"), t("priorityMedium"), t("priorityHigh")];
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -48,27 +63,49 @@ function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
|
||||
>
|
||||
<button
|
||||
className={`task-check-btn${task.completed ? " checked" : ""}`}
|
||||
onClick={(e) => { e.stopPropagation(); onToggle(task.id, !task.completed); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle(task.id, !task.completed);
|
||||
}}
|
||||
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
|
||||
/>
|
||||
<div className="task-body">
|
||||
<div className="task-title">{task.title}</div>
|
||||
<div className="task-meta">
|
||||
{task.priority > 0 && (
|
||||
<div className="task-priority-dot" style={{ background: PRIORITY_COLORS[task.priority] }} title={PRIORITY_LABELS[task.priority]} />
|
||||
<div
|
||||
className="task-priority-dot"
|
||||
style={{ background: PRIORITY_COLORS[task.priority] }}
|
||||
title={priorityLabels[task.priority]}
|
||||
/>
|
||||
)}
|
||||
{task.dueDate && (
|
||||
<span className={`task-due${isOverdue(task.dueDate) && !task.completed ? " overdue" : ""}`}>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
{formatDate(task.dueDate)}
|
||||
</span>
|
||||
)}
|
||||
{task.children.length > 0 && (
|
||||
{task.children && task.children.length > 0 && (
|
||||
<span
|
||||
className="task-sub-count"
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded((p) => !p); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded((p) => !p);
|
||||
}}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ transform: expanded ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.15s" }}
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
{completedChildren}/{task.children.length}
|
||||
</span>
|
||||
)}
|
||||
@@ -80,7 +117,7 @@ function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
|
||||
</div>
|
||||
|
||||
{/* Sub-tasks */}
|
||||
{task.children.length > 0 && expanded && (
|
||||
{task.children && task.children.length > 0 && expanded && (
|
||||
<div className="subtask-list">
|
||||
{task.children.map((child) => (
|
||||
<div
|
||||
@@ -91,7 +128,10 @@ function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
|
||||
>
|
||||
<button
|
||||
className={`subtask-check-btn${child.completed ? " checked" : ""}`}
|
||||
onClick={(e) => { e.stopPropagation(); onToggle(child.id, !child.completed); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle(child.id, !child.completed);
|
||||
}}
|
||||
aria-label={child.completed ? "Mark incomplete" : "Mark complete"}
|
||||
/>
|
||||
<span className="subtask-title">{child.title}</span>
|
||||
@@ -104,21 +144,46 @@ function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
|
||||
}
|
||||
|
||||
interface Props {
|
||||
user: User; listId: string | null; lists: List[]; tasks: Task[];
|
||||
user: User;
|
||||
listId: string | null;
|
||||
lists: List[];
|
||||
tasks: Task[];
|
||||
setTasks: React.Dispatch<React.SetStateAction<Task[]>>;
|
||||
selectedTaskId: string | null; onTaskSelect: (t: Task | null) => void;
|
||||
showCompleted: boolean; onToggleCompleted: () => void;
|
||||
onMenuOpen: () => void; onRefresh: () => void;
|
||||
selectedTaskId: string | null;
|
||||
onTaskSelect: (t: Task | null) => void;
|
||||
showCompleted: boolean;
|
||||
onToggleCompleted: () => void;
|
||||
onMenuOpen: () => void;
|
||||
onRefresh: () => void;
|
||||
isDemo?: boolean;
|
||||
onDemoAddTask?: (title: string, listId: string) => void;
|
||||
onDemoToggleTask?: (id: string, completed: boolean) => void;
|
||||
}
|
||||
|
||||
export function TaskList({ user, listId, lists, tasks, setTasks, selectedTaskId, onTaskSelect, showCompleted, onToggleCompleted, onMenuOpen, onRefresh }: Props) {
|
||||
export function TaskList({
|
||||
user,
|
||||
listId,
|
||||
lists,
|
||||
tasks,
|
||||
setTasks,
|
||||
selectedTaskId,
|
||||
onTaskSelect,
|
||||
showCompleted,
|
||||
onToggleCompleted,
|
||||
onMenuOpen,
|
||||
onRefresh,
|
||||
isDemo = false,
|
||||
onDemoAddTask,
|
||||
onDemoToggleTask,
|
||||
}: Props) {
|
||||
const { t } = useI18n();
|
||||
const [newTaskTitle, setNewTaskTitle] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const currentList = lists.find((l) => l.id === listId);
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
if (!listId) return;
|
||||
if (!listId || isDemo) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/tasks?listId=${listId}&showCompleted=${showCompleted}`);
|
||||
@@ -130,9 +195,11 @@ export function TaskList({ user, listId, lists, tasks, setTasks, selectedTaskId,
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [listId, showCompleted]);
|
||||
}, [listId, showCompleted, isDemo, setTasks]);
|
||||
|
||||
useEffect(() => { fetchTasks(); }, [fetchTasks]);
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, [fetchTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => inputRef.current?.focus();
|
||||
@@ -140,32 +207,48 @@ export function TaskList({ user, listId, lists, tasks, setTasks, selectedTaskId,
|
||||
return () => document.removeEventListener("checkflow:addTask", handler);
|
||||
}, []);
|
||||
|
||||
const handleToggle = useCallback(async (id: string, completed: boolean) => {
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ completed }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const updated = await res.json();
|
||||
// Functional update avoids stale closure on `tasks`
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id === id) return { ...t, completed, completedAt: updated.completedAt };
|
||||
return { ...t, children: t.children.map((c) => (c.id === id ? { ...c, completed } : c)) };
|
||||
}).filter((t) => showCompleted || !t.completed)
|
||||
);
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
console.error("[TaskList] handleToggle failed", err);
|
||||
}
|
||||
}, [showCompleted, onRefresh]);
|
||||
const handleToggle = useCallback(
|
||||
async (id: string, completed: boolean) => {
|
||||
if (isDemo) {
|
||||
if (onDemoToggleTask) onDemoToggleTask(id, completed);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ completed }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const updated = await res.json();
|
||||
setTasks((prev) =>
|
||||
prev
|
||||
.map((t) => {
|
||||
if (t.id === id) return { ...t, completed, completedAt: updated.completedAt };
|
||||
return { ...t, children: t.children?.map((c) => (c.id === id ? { ...c, completed } : c)) || [] };
|
||||
})
|
||||
.filter((t) => showCompleted || !t.completed)
|
||||
);
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
console.error("[TaskList] handleToggle failed", err);
|
||||
}
|
||||
},
|
||||
[isDemo, onDemoToggleTask, showCompleted, onRefresh, setTasks]
|
||||
);
|
||||
|
||||
const handleAddTask = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const title = newTaskTitle.trim();
|
||||
if (!title || !listId) return;
|
||||
|
||||
if (isDemo) {
|
||||
if (onDemoAddTask) onDemoAddTask(title, listId);
|
||||
setNewTaskTitle("");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/tasks", {
|
||||
method: "POST",
|
||||
@@ -185,8 +268,10 @@ export function TaskList({ user, listId, lists, tasks, setTasks, selectedTaskId,
|
||||
if (!listId) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--text-tertiary)" }}>
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ opacity: 0.3, marginBottom: 12 }}><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
<p>Select a list to get started</p>
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ opacity: 0.3, marginBottom: 12 }}>
|
||||
<path d="M9 11l3 3L22 4" /><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
<p>{t("selectListToStart")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -199,20 +284,24 @@ export function TaskList({ user, listId, lists, tasks, setTasks, selectedTaskId,
|
||||
{/* Header */}
|
||||
<div className="main-header">
|
||||
<button className="icon-btn mobile-only" id="menu-btn" onClick={onMenuOpen} aria-label="Menu">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="main-header-title" style={{ color: currentList?.color }}>
|
||||
{currentList?.name || "Tasks"}
|
||||
{currentList?.name || t("tasks")}
|
||||
</div>
|
||||
<div className="main-header-actions">
|
||||
<button
|
||||
id="toggle-completed-btn"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={onToggleCompleted}
|
||||
title={showCompleted ? "Hide completed" : "Show completed"}
|
||||
title={showCompleted ? t("hideDone") : t("showDone")}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
{showCompleted ? "Hide done" : "Show done"}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
{showCompleted ? t("hideDone") : t("showDone")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,47 +309,68 @@ export function TaskList({ user, listId, lists, tasks, setTasks, selectedTaskId,
|
||||
{/* Task list */}
|
||||
<div className="task-list-container">
|
||||
{loading && (
|
||||
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>Loading...</div>
|
||||
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>{t("loading")}</div>
|
||||
)}
|
||||
{!loading && incompleteTasks.length === 0 && completedTasks.length === 0 && (
|
||||
<div className="task-list-empty">
|
||||
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
<p>No tasks yet. Add one below!</p>
|
||||
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M9 11l3 3L22 4" /><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
<p>{t("noTasksYet")}</p>
|
||||
</div>
|
||||
)}
|
||||
{incompleteTasks.map((task) => (
|
||||
<TaskItem key={task.id} task={task} isSelected={selectedTaskId === task.id} onSelect={onTaskSelect} onToggle={handleToggle} />
|
||||
<TaskItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
isSelected={selectedTaskId === task.id}
|
||||
onSelect={onTaskSelect}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
))}
|
||||
{showCompleted && completedTasks.length > 0 && (
|
||||
<div>
|
||||
<div style={{ padding: "12px 20px 4px", fontSize: 11, fontWeight: 600, color: "var(--text-tertiary)", textTransform: "uppercase", letterSpacing: "0.5px" }}>
|
||||
Completed ({completedTasks.length})
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 20px 4px",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-tertiary)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.5px",
|
||||
}}
|
||||
>
|
||||
{t("completedSection")} ({completedTasks.length})
|
||||
</div>
|
||||
{completedTasks.map((task) => (
|
||||
<TaskItem key={task.id} task={task} isSelected={selectedTaskId === task.id} onSelect={onTaskSelect} onToggle={handleToggle} />
|
||||
<TaskItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
isSelected={selectedTaskId === task.id}
|
||||
onSelect={onTaskSelect}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add task */}
|
||||
{/* Add task bar */}
|
||||
<div className="add-task-bar">
|
||||
<button
|
||||
className="task-check-btn"
|
||||
style={{ opacity: 0.4, flexShrink: 0 }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<button className="task-check-btn" style={{ opacity: 0.4, flexShrink: 0 }} aria-hidden="true" />
|
||||
<form onSubmit={handleAddTask} style={{ flex: 1, display: "flex", gap: 8 }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id="add-task-input"
|
||||
className="add-task-input"
|
||||
placeholder="Add a task..."
|
||||
placeholder={t("addTaskPlaceholder")}
|
||||
value={newTaskTitle}
|
||||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||||
/>
|
||||
{newTaskTitle.trim() && (
|
||||
<button type="submit" className="btn btn-primary btn-sm" id="add-task-btn">Add</button>
|
||||
<button type="submit" className="btn btn-primary btn-sm" id="add-task-btn">
|
||||
{t("add")}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user