852 lines
29 KiB
TypeScript
852 lines
29 KiB
TypeScript
"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<MockTag[]>(() => (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<HTMLDivElement>(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<number | null>(null);
|
|
const touchStartX = useRef<number | null>(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<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: "▲" },
|
|
];
|
|
|
|
// 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<string, unknown>) => {
|
|
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<string, unknown>) => {
|
|
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 (
|
|
<div
|
|
className="detail-block-card"
|
|
style={{
|
|
flex: subtasksCollapsed ? "0 0 auto" : `0 0 ${noteCollapsed ? "100%" : `${splitRatio}%`}`,
|
|
minHeight: subtasksCollapsed ? 38 : 120,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
}}
|
|
>
|
|
<div className="detail-block-header">
|
|
<div
|
|
style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}
|
|
onClick={() => setSubtasksCollapsed((p) => !p)}
|
|
>
|
|
<svg
|
|
width="12"
|
|
height="12"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2.5"
|
|
style={{ transform: subtasksCollapsed ? "rotate(0deg)" : "rotate(90deg)", transition: "transform 0.15s" }}
|
|
>
|
|
<polyline points="9 18 15 12 9 6" />
|
|
</svg>
|
|
<span style={{ fontSize: 11.5, fontWeight: 700, letterSpacing: "0.05em", color: "var(--text-primary)" }}>
|
|
☑️ {t("subtasks").toUpperCase()}
|
|
</span>
|
|
{subtasks.length > 0 && (
|
|
<span style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", background: "var(--bg-hover)", padding: "1px 6px", borderRadius: "var(--radius-full)" }}>
|
|
{completedCount}/{subtasks.length}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
|
<button
|
|
type="button"
|
|
className="icon-btn"
|
|
style={{ width: 22, height: 22, fontSize: 11 }}
|
|
onClick={toggleBlockOrder}
|
|
title={t("swapBlocks")}
|
|
>
|
|
⇅
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{!subtasksCollapsed && (
|
|
<div style={{ padding: "8px 12px", flex: 1, display: "flex", flexDirection: "column", gap: 6, overflowY: "auto" }}>
|
|
{/* Progress Bar */}
|
|
{subtasks.length > 0 && (
|
|
<div style={{ height: 3, background: "var(--bg-hover)", borderRadius: 2, marginBottom: 4, overflow: "hidden" }}>
|
|
<div
|
|
style={{
|
|
height: "100%",
|
|
width: `${(completedCount / subtasks.length) * 100}%`,
|
|
background: "var(--accent)",
|
|
borderRadius: 2,
|
|
transition: "width var(--dur-normal) var(--ease-out)",
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* List of subtasks */}
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
|
{subtasks.map((sub) => (
|
|
<div
|
|
key={sub.id}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
padding: "6px 10px",
|
|
borderRadius: "var(--radius-sm)",
|
|
background: "var(--bg-secondary)",
|
|
border: "1px solid var(--border)",
|
|
}}
|
|
>
|
|
<button
|
|
className={`task-check-btn${sub.completed ? " checked" : ""}`}
|
|
style={{ width: 16, height: 16, flexShrink: 0 }}
|
|
onClick={() => toggleSubtask(sub)}
|
|
aria-label="Toggle subtask"
|
|
type="button"
|
|
/>
|
|
<span
|
|
style={{
|
|
flex: 1,
|
|
fontSize: 13,
|
|
textDecoration: sub.completed ? "line-through" : "none",
|
|
color: sub.completed ? "var(--text-tertiary)" : "var(--text-primary)",
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
{sub.title}
|
|
</span>
|
|
<button
|
|
className="icon-btn"
|
|
style={{ width: 20, height: 20, opacity: 0.4 }}
|
|
onClick={() => deleteSubtask(sub.id)}
|
|
title="Delete subtask"
|
|
type="button"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Quick Add Subtask Input */}
|
|
<div style={{ display: "flex", gap: 6, marginTop: "auto", paddingTop: 4 }}>
|
|
<input
|
|
className="form-input"
|
|
placeholder={t("addSubtaskPlaceholder")}
|
|
style={{ flex: 1, fontSize: 12.5, padding: "5px 10px" }}
|
|
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" onClick={addSubtask} type="button">
|
|
{t("add")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Render Note Block
|
|
const renderNoteBlock = () => {
|
|
return (
|
|
<div
|
|
className="detail-block-card"
|
|
style={{
|
|
flex: noteCollapsed ? "0 0 auto" : `0 0 ${subtasksCollapsed ? "100%" : `${100 - splitRatio}%`}`,
|
|
minHeight: noteCollapsed ? 38 : 120,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
}}
|
|
>
|
|
<div className="detail-block-header">
|
|
<div
|
|
style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}
|
|
onClick={() => setNoteCollapsed((p) => !p)}
|
|
>
|
|
<svg
|
|
width="12"
|
|
height="12"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2.5"
|
|
style={{ transform: noteCollapsed ? "rotate(0deg)" : "rotate(90deg)", transition: "transform 0.15s" }}
|
|
>
|
|
<polyline points="9 18 15 12 9 6" />
|
|
</svg>
|
|
<span style={{ fontSize: 11.5, fontWeight: 700, letterSpacing: "0.05em", color: "var(--text-primary)" }}>
|
|
📝 {t("notes").toUpperCase()}
|
|
</span>
|
|
</div>
|
|
|
|
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
|
<button
|
|
type="button"
|
|
className="icon-btn"
|
|
style={{ width: 22, height: 22, fontSize: 11 }}
|
|
onClick={toggleBlockOrder}
|
|
title={t("swapBlocks")}
|
|
>
|
|
⇅
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{!noteCollapsed && (
|
|
<div style={{ flex: 1, padding: 8, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
|
<MarkdownNoteEditor
|
|
value={note}
|
|
onChange={handleNoteChange}
|
|
onSave={() => save(task.id, { note })}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<aside
|
|
className="detail-panel mobile-open"
|
|
style={{
|
|
transform: isMobile ? `translateY(${panelTranslateY}px)` : "none",
|
|
transition: panelTranslateY === 0 ? "transform 0.25s var(--ease-out)" : "none",
|
|
}}
|
|
>
|
|
{/* Mobile swipe indicator */}
|
|
<div
|
|
className="mobile-swipe-handle mobile-only"
|
|
style={{
|
|
width: "100%",
|
|
padding: "10px 0 4px",
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
cursor: "grab",
|
|
}}
|
|
onTouchStart={(e) => {
|
|
touchStartY.current = e.touches[0].clientY;
|
|
touchStartX.current = e.touches[0].clientX;
|
|
gestureDirection.current = null;
|
|
}}
|
|
onTouchMove={(e) => {
|
|
if (touchStartY.current === null) return;
|
|
const deltaY = e.touches[0].clientY - touchStartY.current;
|
|
if (deltaY > 0) setPanelTranslateY(deltaY);
|
|
}}
|
|
onTouchEnd={() => {
|
|
if (panelTranslateY > 120) {
|
|
onClose();
|
|
}
|
|
setPanelTranslateY(0);
|
|
touchStartY.current = null;
|
|
}}
|
|
>
|
|
<div style={{ width: 36, height: 4, borderRadius: 2, background: "var(--border-strong)" }} />
|
|
</div>
|
|
|
|
{/* Top Header */}
|
|
<div className="detail-header">
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8, flex: 1, minWidth: 0, position: "relative" }}>
|
|
{/* Breadcrumb / Project Move selector */}
|
|
<div style={{ position: "relative" }}>
|
|
<button
|
|
type="button"
|
|
className="tick-meta-chip"
|
|
onClick={() => setShowListPicker((p) => !p)}
|
|
style={{
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
color: "var(--accent)",
|
|
background: "var(--accent-light)",
|
|
borderColor: "transparent",
|
|
}}
|
|
title={t("moveToList")}
|
|
>
|
|
📁 {listName || t("tasks")} ▾
|
|
</button>
|
|
|
|
{showListPicker && lists.length > 0 && (
|
|
<div
|
|
className="dropdown"
|
|
style={{ left: 0, top: "calc(100% + 4px)", minWidth: 160, padding: 4, zIndex: 110 }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div style={{ padding: "4px 8px", fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)" }}>
|
|
{t("moveToList")}
|
|
</div>
|
|
{lists.map((l) => (
|
|
<div
|
|
key={l.id}
|
|
className="context-menu-item"
|
|
style={{
|
|
padding: "6px 10px",
|
|
fontSize: 12.5,
|
|
cursor: "pointer",
|
|
background: l.id === listId ? "var(--bg-active)" : "transparent",
|
|
}}
|
|
onClick={() => {
|
|
if (onMoveList && l.id !== listId) {
|
|
onMoveList(l.id);
|
|
}
|
|
setShowListPicker(false);
|
|
}}
|
|
>
|
|
📁 {l.name}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>
|
|
{saving ? `● ${t("saving")}` : `✓ ${t("autoSaved")}`}
|
|
</span>
|
|
</div>
|
|
|
|
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
|
<button
|
|
className="icon-btn"
|
|
onClick={handleDelete}
|
|
title={t("deleteTaskConfirm").split("?")[0]}
|
|
style={{ color: "var(--danger)" }}
|
|
type="button"
|
|
>
|
|
🗑️
|
|
</button>
|
|
<button className="icon-btn" onClick={onClose} title="Close" type="button">
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Body */}
|
|
<div className="detail-scroll" style={{ display: "flex", flexDirection: "column", height: "100%", gap: 10 }}>
|
|
{/* Title and Complete Button */}
|
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
<button
|
|
className={`task-check-btn${task.completed ? " checked" : ""}`}
|
|
onClick={handleToggleComplete}
|
|
aria-label="Toggle completion"
|
|
type="button"
|
|
/>
|
|
<input
|
|
className="detail-title-input"
|
|
value={title}
|
|
onChange={(e) => {
|
|
setTitle(e.target.value);
|
|
debounceSave({ title: e.target.value });
|
|
}}
|
|
placeholder={t("taskTitlePlaceholder")}
|
|
style={{ flex: 1, fontSize: 16, fontWeight: 600 }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Metadata Chips: Due Date, Priority, Tags */}
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
|
{/* Due date chip */}
|
|
<input
|
|
type="date"
|
|
className="tick-meta-chip"
|
|
style={{ fontSize: 11.5, padding: "3px 8px", cursor: "pointer", border: "1px solid var(--border)" }}
|
|
value={dueDate}
|
|
onChange={(e) => {
|
|
const val = e.target.value || null;
|
|
setDueDate(e.target.value);
|
|
save(task.id, { dueDate: val });
|
|
}}
|
|
/>
|
|
|
|
{/* Priority selector */}
|
|
<select
|
|
className="tick-meta-chip"
|
|
style={{
|
|
fontSize: 11.5,
|
|
padding: "3px 8px",
|
|
cursor: "pointer",
|
|
border: "1px solid var(--border)",
|
|
color: priority > 0 ? priorityMap[priority].color : "inherit",
|
|
fontWeight: priority > 0 ? 700 : 500,
|
|
}}
|
|
value={priority}
|
|
onChange={(e) => {
|
|
const val = parseInt(e.target.value, 10);
|
|
setPriority(val);
|
|
save(task.id, { priority: val });
|
|
}}
|
|
>
|
|
{priorityMap.map((p, idx) => (
|
|
<option key={idx} value={idx}>
|
|
{p.icon ? `${p.icon} ` : ""}
|
|
{p.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Tag Selector */}
|
|
<div style={{ position: "relative" }}>
|
|
<button
|
|
type="button"
|
|
className="tick-meta-chip"
|
|
onClick={() => setShowTagPicker((p) => !p)}
|
|
style={{ fontSize: 11.5, padding: "3px 8px" }}
|
|
>
|
|
🏷️ {tags.length > 0 ? `${tags.length} tags` : t("selectTag")}
|
|
</button>
|
|
|
|
{showTagPicker && (
|
|
<div
|
|
className="dropdown"
|
|
style={{ left: 0, top: "calc(100% + 4px)", minWidth: 180, padding: 8, zIndex: 110 }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", marginBottom: 6 }}>
|
|
{t("tags")}
|
|
</div>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 4, maxHeight: 140, overflowY: "auto" }}>
|
|
{allAvailableTags.map((tag) => {
|
|
const active = tags.some((tItem) => tItem.tag.id === tag.id);
|
|
return (
|
|
<div
|
|
key={tag.id}
|
|
className="context-menu-item"
|
|
style={{ padding: "4px 8px", fontSize: 12, cursor: "pointer" }}
|
|
onClick={() => toggleTag(tag)}
|
|
>
|
|
<span style={{ width: 8, height: 8, borderRadius: "50%", background: tag.color || "var(--accent)" }} />
|
|
<span style={{ flex: 1 }}>{tag.name}</span>
|
|
{active && <span>✓</span>}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<div style={{ display: "flex", gap: 4, marginTop: 6, borderTop: "1px solid var(--border)", paddingTop: 6 }}>
|
|
<input
|
|
className="form-input"
|
|
placeholder="New tag..."
|
|
style={{ fontSize: 11, padding: "3px 6px", flex: 1 }}
|
|
value={newTagName}
|
|
onChange={(e) => setNewTagName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
addCustomTag();
|
|
}
|
|
}}
|
|
/>
|
|
<button className="btn btn-primary btn-sm" style={{ fontSize: 10, padding: "2px 6px" }} onClick={addCustomTag} type="button">
|
|
+
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modular Blocks Container (Subtasks & Notes) with Split Resizer */}
|
|
<div
|
|
ref={containerRef}
|
|
style={{
|
|
flex: 1,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
minHeight: 280,
|
|
overflow: "hidden",
|
|
gap: 4,
|
|
}}
|
|
>
|
|
{blockOrder.map((blockType, idx) => (
|
|
<React.Fragment key={blockType}>
|
|
{blockType === "subtasks" ? renderSubtasksBlock() : renderNoteBlock()}
|
|
|
|
{/* Split Resizer bar between blocks if neither is collapsed */}
|
|
{idx === 0 && !subtasksCollapsed && !noteCollapsed && (
|
|
<div
|
|
className={`detail-split-resizer${isDraggingSplit ? " dragging" : ""}`}
|
|
onMouseDown={handleSplitMouseDown}
|
|
title="Drag to resize blocks"
|
|
>
|
|
<div className="detail-split-resizer-line" />
|
|
</div>
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
|
|
{/* Footer Info */}
|
|
<div style={{ fontSize: 11, color: "var(--text-tertiary)", display: "flex", justifyContent: "space-between", alignItems: "center", borderTop: "1px solid var(--border)", paddingTop: 8, flexWrap: "wrap", gap: 6 }}>
|
|
{listName && (
|
|
<span className="tick-meta-chip" style={{ fontSize: 11, padding: "2px 8px" }}>
|
|
📁 {listName}
|
|
</span>
|
|
)}
|
|
<div style={{ display: "flex", alignItems: "center", gap: 12, marginLeft: "auto", flexWrap: "wrap" }}>
|
|
<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>
|
|
{task.updatedAt && (
|
|
<span>
|
|
{t("edited")}:{" "}
|
|
{new Date(task.updatedAt).toLocaleTimeString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
);
|
|
} |