"use client"; import React, { useState, useEffect, useCallback, useRef } from "react"; import { useI18n } from "@/lib/i18n"; import { Task, Tag } from "./TaskList"; import { MarkdownNoteEditor } from "./MarkdownNoteEditor"; import { getCustomTags, MockTag } from "@/lib/mockData"; import { useUserPrefs } from "@/lib/useUserPrefs"; interface Props { task: Task; listId: string; listName?: string; lists?: { id: string; name: string; color: string }[]; onMoveList?: (targetListId: string) => void; onClose: () => void; onUpdate: (t: Task) => void; onDelete: () => void; isDemo?: boolean; onDemoUpdateTask?: (updated: Task) => void; onDemoDeleteTask?: (id: string) => void; } export function TaskDetail({ task, listId, listName, lists = [], onMoveList, 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 [tags, setTags] = useState<{ tag: Tag }[]>(task.tags || []); const [allAvailableTags, setAllAvailableTags] = useState(() => (typeof window !== "undefined" ? getCustomTags() : [])); const [showTagPicker, setShowTagPicker] = useState(false); const [showListPicker, setShowListPicker] = useState(false); const [newTagName, setNewTagName] = useState(""); // Modular Blocks Customization via global prefs const { prefs, updatePrefs } = useUserPrefs(); const blockOrder = prefs.detailBlockOrder; const splitRatio = prefs.detailSplitRatio; const setBlockOrder = useCallback((next: ("subtasks" | "note")[]) => { updatePrefs({ detailBlockOrder: next }); }, [updatePrefs]); const setSplitRatio = useCallback((r: number) => { updatePrefs({ detailSplitRatio: r }); }, [updatePrefs]); const [subtasksCollapsed, setSubtasksCollapsed] = useState(false); const [noteCollapsed, setNoteCollapsed] = useState(false); const [isDraggingSplit, setIsDraggingSplit] = useState(false); const containerRef = useRef(null); const [newSubtitle, setNewSubtitle] = useState(""); const [subtasks, setSubtasks] = useState(task.children || []); const [saving, setSaving] = useState(false); // Mobile Bottom-Sheet: swipe down to close const touchStartY = useRef(null); const touchStartX = useRef(null); const gestureDirection = useRef<"vertical" | "horizontal" | null>(null); const [panelTranslateY, setPanelTranslateY] = useState(0); const [isMobile, setIsMobile] = useState(false); useEffect(() => { const check = () => setIsMobile(window.innerWidth <= 768); check(); window.addEventListener("resize", check); return () => window.removeEventListener("resize", check); }, []); const saveTimer = useRef | 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: "▲" }, ]; // Toggle block order — prefs store handles persistence const toggleBlockOrder = () => { const next = blockOrder[0] === "subtasks" ? ["note", "subtasks"] as ("subtasks" | "note")[] : ["subtasks", "note"] as ("subtasks" | "note")[]; setBlockOrder(next); }; const handleSplitMouseDown = (e: React.MouseEvent) => { e.preventDefault(); setIsDraggingSplit(true); }; useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!isDraggingSplit || !containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); const relativeY = e.clientY - rect.top; let ratio = (relativeY / rect.height) * 100; ratio = Math.max(15, Math.min(85, ratio)); setSplitRatio(ratio); }; const handleMouseUp = () => { if (isDraggingSplit) { setIsDraggingSplit(false); } }; if (isDraggingSplit) { window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseup", handleMouseUp); } return () => { window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); }; }, [isDraggingSplit, setSplitRatio]); // 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); setTags(task.tags || []); setSubtasks(task.children || []); }, [task.id, task.title, task.note, task.dueDate, task.priority, task.children, task.tags]); useEffect(() => { return () => { if (saveTimer.current) clearTimeout(saveTimer.current); }; }, []); const save = useCallback( async (taskId: string, data: Record) => { if (taskId !== currentTaskId.current) return; setSaving(true); if (isDemo) { const updatedTask: Task = { ...task, ...data, children: subtasks, tags, 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, tags }); } } } catch (err) { console.error("[TaskDetail] save failed", err); } finally { setSaving(false); } }, [isDemo, onDemoUpdateTask, onUpdate, subtasks, tags, task] ); const debounceSave = useCallback( (data: Record) => { if (saveTimer.current) clearTimeout(saveTimer.current); saveTimer.current = setTimeout(() => { save(task.id, data); }, 500); }, [save, task.id] ); const handleNoteChange = (newNote: string) => { setNote(newNote); debounceSave({ note: newNote }); }; const handleToggleComplete = useCallback(async () => { const nextCompleted = !task.completed; save(task.id, { completed: nextCompleted, completedAt: nextCompleted ? new Date().toISOString() : null, }); }, [save, task.id, task.completed]); 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]); // Tag Management const toggleTag = (tag: MockTag) => { const exists = tags.some((tItem) => tItem.tag.id === tag.id); let nextTags: { tag: Tag }[]; if (exists) { nextTags = tags.filter((tItem) => tItem.tag.id !== tag.id); } else { nextTags = [...tags, { tag }]; } setTags(nextTags); save(task.id, { tags: nextTags }); }; const addCustomTag = () => { const trimmed = newTagName.trim().replace(/^#/, ""); if (!trimmed) return; const newTag: MockTag = { id: "tag-" + Date.now(), name: trimmed, color: ["#4B7BF5", "#10B981", "#EF4444", "#F59E0B", "#8B5CF6"][Math.floor(Math.random() * 5)], }; setAllAvailableTags((p) => [...p, newTag]); toggleTag(newTag); setNewTagName(""); }; 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 = async (sub: Task) => { const nextCompleted = !sub.completed; const nextSubs = subtasks.map((s) => (s.id === sub.id ? { ...s, completed: nextCompleted } : s)); setSubtasks(nextSubs); const updatedParent = { ...task, children: nextSubs }; if (isDemo && onDemoUpdateTask) { onDemoUpdateTask(updatedParent); } onUpdate(updatedParent); if (!isDemo) { try { await fetch(`/api/tasks/${sub.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ completed: nextCompleted }), }); } catch (err) { console.error("[TaskDetail] toggleSubtask failed", err); } } }; const deleteSubtask = async (subId: string) => { const nextSubs = subtasks.filter((s) => s.id !== subId); setSubtasks(nextSubs); const updatedParent = { ...task, children: nextSubs }; if (isDemo && onDemoUpdateTask) { onDemoUpdateTask(updatedParent); } onUpdate(updatedParent); if (!isDemo) { try { await fetch(`/api/tasks/${subId}`, { method: "DELETE" }); } catch (err) { console.error("[TaskDetail] deleteSubtask failed", err); } } }; const completedCount = subtasks.filter((s) => s.completed).length; // Render Subtasks Block const renderSubtasksBlock = () => { return (
setSubtasksCollapsed((p) => !p)} > ☑️ {t("subtasks").toUpperCase()} {subtasks.length > 0 && ( {completedCount}/{subtasks.length} )}
{!subtasksCollapsed && (
{/* Progress Bar */} {subtasks.length > 0 && (
)} {/* List of subtasks */}
{subtasks.map((sub) => (
))}
{/* Quick Add Subtask Input */}
setNewSubtitle(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addSubtask(); } }} /> {newSubtitle.trim() && ( )}
)}
); }; // Render Note Block const renderNoteBlock = () => { return (
setNoteCollapsed((p) => !p)} > 📝 {t("notes").toUpperCase()}
{!noteCollapsed && (
save(task.id, { note })} />
)}
); }; return ( ); }