"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"; 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 [tags, setTags] = useState<{ tag: Tag }[]>(task.tags || []); const [allAvailableTags, setAllAvailableTags] = useState([]); const [showTagPicker, setShowTagPicker] = useState(false); const [newTagName, setNewTagName] = useState(""); const [newSubtitle, setNewSubtitle] = useState(""); const [subtasks, setSubtasks] = useState(task.children || []); const [showSubtasks, setShowSubtasks] = useState(true); 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: "▲" }, ]; // 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 || []); setAllAvailableTags(getCustomTags()); }, [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 = 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, onDemoUpdateTask, onUpdate, subtasks, task] ); const deleteSubtask = useCallback( async (subId: string) => { if (isDemo) { const nextSubs = subtasks.filter((s) => s.id !== subId); setSubtasks(nextSubs); const updatedParent = { ...task, children: nextSubs }; if (onDemoUpdateTask) onDemoUpdateTask(updatedParent); onUpdate(updatedParent); return; } try { await fetch(`/api/tasks/${subId}`, { method: "DELETE" }); setSubtasks((prev) => { const next = prev.filter((s) => s.id !== subId); onUpdate({ ...task, children: next }); return next; }); } catch (err) { console.error("[TaskDetail] deleteSubtask failed", err); } }, [isDemo, onDemoUpdateTask, onUpdate, subtasks, task] ); // Touch handlers: 모바일에서 아래로 스와이프 → 패널 닫기 (스크롤과 충돌 방지) const handleTouchStart = (e: React.TouchEvent) => { // 스와이프 핸들 또는 detail-header 영역에서만 드래그 닫기 허용 const target = e.target as HTMLElement; const isHandle = target.closest(".mobile-swipe-handle") || target.closest(".detail-header"); if (!isHandle || !isMobile) return; touchStartY.current = e.touches[0].clientY; touchStartX.current = e.touches[0].clientX; gestureDirection.current = null; }; const handleTouchMove = (e: React.TouchEvent) => { if (touchStartY.current === null || touchStartX.current === null) return; const deltaY = e.touches[0].clientY - touchStartY.current; const deltaX = e.touches[0].clientX - touchStartX.current; // 초기 제스처 방향 결정 (첫 10px 이동 기준) if (gestureDirection.current === null && (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10)) { gestureDirection.current = Math.abs(deltaY) > Math.abs(deltaX) ? "vertical" : "horizontal"; } // 수직 드래그일 때만 패널 이동 if (gestureDirection.current === "vertical" && deltaY > 0) { e.preventDefault(); setPanelTranslateY(deltaY); } }; const handleTouchEnd = () => { if (panelTranslateY > 120) { onClose(); } setPanelTranslateY(0); touchStartY.current = null; touchStartX.current = null; gestureDirection.current = null; }; const completedCount = subtasks.filter((s) => s.completed).length; return (