512 lines
18 KiB
TypeScript
512 lines
18 KiB
TypeScript
"use client";
|
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
import { useI18n } from "@/lib/i18n";
|
|
import { Task } from "./TaskList";
|
|
import { MarkdownNoteEditor } from "./MarkdownNoteEditor";
|
|
|
|
interface Props {
|
|
task: Task;
|
|
listId: string;
|
|
listName?: string;
|
|
onClose: () => void;
|
|
onUpdate: (t: Task) => void;
|
|
onDelete: () => void;
|
|
isDemo?: boolean;
|
|
onDemoUpdateTask?: (updated: Task) => void;
|
|
onDemoDeleteTask?: (id: string) => void;
|
|
}
|
|
|
|
export function TaskDetail({
|
|
task,
|
|
listId,
|
|
listName,
|
|
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] : "");
|
|
const [priority, setPriority] = useState(task.priority);
|
|
const [newSubtitle, setNewSubtitle] = useState("");
|
|
const [subtasks, setSubtasks] = useState(task.children || []);
|
|
const [showSubtasks, setShowSubtasks] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const currentTaskId = useRef(task.id);
|
|
|
|
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);
|
|
saveTimer.current = null;
|
|
}
|
|
currentTaskId.current = task.id;
|
|
setTitle(task.title);
|
|
setNote(task.note || "");
|
|
setDueDate(task.dueDate ? task.dueDate.split("T")[0] : "");
|
|
setPriority(task.priority);
|
|
setSubtasks(task.children || []);
|
|
}, [task.id, task.title, task.note, task.dueDate, task.priority, task.children]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
|
};
|
|
}, []);
|
|
|
|
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]
|
|
);
|
|
|
|
const handleNoteChange = (newNote: string) => {
|
|
setNote(newNote);
|
|
debounceSave({ note: newNote });
|
|
};
|
|
|
|
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);
|
|
}
|
|
},
|
|
[isDemo, onDemoUpdateTask, task, subtasks, onUpdate]
|
|
);
|
|
|
|
const handleDelete = useCallback(async () => {
|
|
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);
|
|
}
|
|
}, [t, isDemo, onDemoDeleteTask, task.id, onDelete]);
|
|
|
|
const addSubtask = useCallback(async () => {
|
|
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: tTitle, listId, parentId: task.id }),
|
|
});
|
|
if (res.ok) {
|
|
const sub = await res.json();
|
|
setSubtasks((prev) => {
|
|
const next = [...prev, sub];
|
|
onUpdate({ ...task, children: next });
|
|
return next;
|
|
});
|
|
setNewSubtitle("");
|
|
}
|
|
} catch (err) {
|
|
console.error("[TaskDetail] addSubtask failed", err);
|
|
}
|
|
}, [newSubtitle, isDemo, listId, task, subtasks, onDemoUpdateTask, onUpdate]);
|
|
|
|
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.filter((s) => s.id !== id);
|
|
onUpdate({ ...task, children: next });
|
|
return next;
|
|
});
|
|
} catch (err) {
|
|
console.error("[TaskDetail] deleteSubtask failed", err);
|
|
}
|
|
},
|
|
[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;
|
|
|
|
return (
|
|
<aside className="detail-panel">
|
|
{/* Header Bar */}
|
|
<div className="detail-header">
|
|
<button
|
|
className={`task-check-btn${task.completed ? " checked" : ""}`}
|
|
style={{ width: 22, height: 22 }}
|
|
onClick={() => handleToggleCompleted(!task.completed)}
|
|
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
|
|
/>
|
|
<span style={{ flex: 1, fontSize: 12, color: "var(--text-tertiary)", fontWeight: 500 }}>
|
|
{saving ? t("saving") : t("autoSaved")}
|
|
</span>
|
|
<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={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>
|
|
|
|
{/* Main Body - Centered on Wide Memo Experience */}
|
|
<div className="detail-body" style={{ display: "flex", flexDirection: "column", height: "100%", gap: 14 }}>
|
|
{/* Title */}
|
|
<textarea
|
|
id="detail-title"
|
|
className="detail-title-input"
|
|
value={title}
|
|
onChange={(e) => {
|
|
setTitle(e.target.value);
|
|
debounceSave({ title: e.target.value });
|
|
}}
|
|
placeholder={t("taskTitlePlaceholder")}
|
|
rows={1}
|
|
style={{
|
|
textDecoration: task.completed ? "line-through" : "none",
|
|
color: task.completed ? "var(--text-tertiary)" : "var(--text-primary)",
|
|
fontSize: 20,
|
|
fontWeight: 700,
|
|
}}
|
|
/>
|
|
|
|
{/* Compact Metadata Strip (Priority & Due Date in a single line) */}
|
|
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap", paddingBottom: 4, borderBottom: "1px solid var(--border)" }}>
|
|
{/* Priority */}
|
|
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
|
<span style={{ fontSize: 11, fontWeight: 600, color: "var(--text-tertiary)", textTransform: "uppercase" }}>{t("priority")}:</span>
|
|
<div className="priority-selector" style={{ gap: 4 }}>
|
|
{priorityMap.map((p, i) => (
|
|
<button
|
|
key={i}
|
|
id={`priority-${i}`}
|
|
className={`priority-btn${priority === i ? ` active-${["none", "low", "medium", "high"][i]}` : ""}`}
|
|
style={{ color: priority === i ? p.color : undefined, padding: "3px 8px", fontSize: 11 }}
|
|
onClick={() => {
|
|
setPriority(i);
|
|
debounceSave({ priority: i });
|
|
}}
|
|
type="button"
|
|
>
|
|
{p.icon && <span style={{ color: p.color }}>{p.icon}</span>}
|
|
{p.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Due Date */}
|
|
<div style={{ display: "flex", alignItems: "center", gap: 6, marginLeft: "auto" }}>
|
|
<input
|
|
id="due-date-input"
|
|
type="date"
|
|
className="form-input"
|
|
value={dueDate}
|
|
onChange={(e) => {
|
|
setDueDate(e.target.value);
|
|
debounceSave({ dueDate: e.target.value || null });
|
|
}}
|
|
style={{ width: "auto", padding: "3px 8px", fontSize: 12 }}
|
|
/>
|
|
{dueDate && (
|
|
<button
|
|
className="btn btn-ghost btn-sm"
|
|
style={{ padding: "2px 6px", fontSize: 11 }}
|
|
onClick={() => {
|
|
setDueDate("");
|
|
debounceSave({ dueDate: null });
|
|
}}
|
|
type="button"
|
|
>
|
|
{t("clear")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* The Wide Adaptive Markdown Note Editor (Takes ALL Remaining Space) */}
|
|
<div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
|
|
<MarkdownNoteEditor
|
|
value={note}
|
|
onChange={handleNoteChange}
|
|
onSave={() => save(task.id, { note })}
|
|
/>
|
|
</div>
|
|
|
|
{/* Sub-tasks Section (Collapsible Accordion at Bottom) */}
|
|
<div style={{ borderTop: "1px solid var(--border)", paddingTop: 10 }}>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
cursor: "pointer",
|
|
userSelect: "none",
|
|
marginBottom: showSubtasks ? 8 : 0,
|
|
}}
|
|
onClick={() => setShowSubtasks((p) => !p)}
|
|
>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
|
<svg
|
|
width="12"
|
|
height="12"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2.5"
|
|
style={{ transform: showSubtasks ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.15s" }}
|
|
>
|
|
<polyline points="9 18 15 12 9 6" />
|
|
</svg>
|
|
<span className="detail-section-label" style={{ marginBottom: 0 }}>{t("subtasks")}</span>
|
|
</div>
|
|
|
|
{subtasks.length > 0 && (
|
|
<span style={{ fontSize: 11, color: "var(--text-tertiary)", fontWeight: 600 }}>
|
|
{completedCount}/{subtasks.length}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{showSubtasks && (
|
|
<div>
|
|
{subtasks.length > 0 && (
|
|
<div className="progress-bar" style={{ marginBottom: 8, height: 3 }}>
|
|
<div className="progress-bar-fill" style={{ width: `${progressPct}%` }} />
|
|
</div>
|
|
)}
|
|
|
|
<div className="detail-subtasks" style={{ maxHeight: 160, overflowY: "auto" }}>
|
|
{subtasks.map((sub) => (
|
|
<div
|
|
key={sub.id}
|
|
className={`detail-subtask-row${sub.completed ? " completed" : ""}`}
|
|
id={`detail-sub-${sub.id}`}
|
|
>
|
|
<button
|
|
className={`subtask-check-btn${sub.completed ? " checked" : ""}`}
|
|
onClick={() => toggleSubtask(sub)}
|
|
aria-label="Toggle subtask"
|
|
type="button"
|
|
/>
|
|
<span className="detail-subtask-title">{sub.title}</span>
|
|
<button
|
|
className="icon-btn"
|
|
style={{ width: 20, height: 20, opacity: 0.4 }}
|
|
onClick={() => deleteSubtask(sub.id)}
|
|
aria-label="Delete subtask"
|
|
type="button"
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
))}
|
|
<div className="add-subtask-row" style={{ padding: "4px 8px" }}>
|
|
<span style={{ fontSize: 15, lineHeight: 1, color: "var(--accent)" }}>+</span>
|
|
<input
|
|
id="add-subtask-input"
|
|
placeholder={t("addSubtaskPlaceholder")}
|
|
style={{ flex: 1, background: "none", fontSize: 12.5, color: "var(--text-primary)" }}
|
|
value={newSubtitle}
|
|
onChange={(e) => setNewSubtitle(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
addSubtask();
|
|
}
|
|
}}
|
|
/>
|
|
{newSubtitle.trim() && (
|
|
<button className="btn btn-primary btn-sm" id="add-subtask-btn" onClick={addSubtask} type="button">
|
|
{t("add")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer Info: Project Badge & Timestamp */}
|
|
<div style={{ fontSize: 11, color: "var(--text-tertiary)", display: "flex", justifyContent: "space-between", alignItems: "center", borderTop: "1px solid var(--border)", paddingTop: 8 }}>
|
|
{listName && (
|
|
<span className="tick-meta-chip" style={{ fontSize: 11, padding: "2px 8px" }}>
|
|
📁 {listName}
|
|
</span>
|
|
)}
|
|
<span>
|
|
{t("created")}:{" "}
|
|
{new Date(task.createdAt).toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
})}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
);
|
|
} |