feat: implement user preference management with persistence, reactive CSS variables, and layout customization
This commit is contained in:
+496
-377
@@ -4,11 +4,14 @@ 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;
|
||||
@@ -21,6 +24,8 @@ export function TaskDetail({
|
||||
task,
|
||||
listId,
|
||||
listName,
|
||||
lists = [],
|
||||
onMoveList,
|
||||
onClose,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
@@ -37,11 +42,29 @@ export function TaskDetail({
|
||||
const [tags, setTags] = useState<{ tag: Tag }[]>(task.tags || []);
|
||||
const [allAvailableTags, setAllAvailableTags] = useState<MockTag[]>([]);
|
||||
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 = (next: ("subtasks" | "note")[]) => {
|
||||
updatePrefs({ detailBlockOrder: next });
|
||||
};
|
||||
|
||||
const setSplitRatio = (r: number) => {
|
||||
updatePrefs({ detailSplitRatio: r });
|
||||
};
|
||||
|
||||
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 [showSubtasks, setShowSubtasks] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Mobile Bottom-Sheet: swipe down to close
|
||||
@@ -68,6 +91,46 @@ export function TaskDetail({
|
||||
{ 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);
|
||||
// splitRatio is already saved via updatePrefs in handleMouseMove
|
||||
}
|
||||
};
|
||||
|
||||
if (isDraggingSplit) {
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isDraggingSplit, splitRatio]);
|
||||
|
||||
// Sync state when switching task
|
||||
useEffect(() => {
|
||||
if (saveTimer.current) {
|
||||
@@ -245,290 +308,479 @@ export function TaskDetail({
|
||||
}
|
||||
}, [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;
|
||||
}
|
||||
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 {
|
||||
const res = await fetch(`/api/tasks/${sub.id}`, {
|
||||
await fetch(`/api/tasks/${sub.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ completed: newCompleted }),
|
||||
body: JSON.stringify({ completed: nextCompleted }),
|
||||
});
|
||||
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;
|
||||
}
|
||||
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" });
|
||||
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;
|
||||
|
||||
// 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"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
style={{
|
||||
transform: isMobile && panelTranslateY > 0 ? `translateY(${panelTranslateY}px)` : undefined,
|
||||
transition: panelTranslateY === 0 ? "transform 0.3s cubic-bezier(0.16, 1, 0.3, 1)" : "none",
|
||||
transform: isMobile ? `translateY(${panelTranslateY}px)` : "none",
|
||||
transition: panelTranslateY === 0 ? "transform 0.25s var(--ease-out)" : "none",
|
||||
}}
|
||||
>
|
||||
{/* Mobile Swipe Handle Indicator */}
|
||||
<div className="mobile-swipe-handle mobile-only" style={{ width: 36, height: 4, background: "var(--border)", borderRadius: 2, margin: "6px auto 0" }} />
|
||||
{/* 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>
|
||||
|
||||
{/* Top action bar */}
|
||||
<div className="detail-header" style={{ padding: "10px 16px 8px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<button
|
||||
className={`task-check-btn${task.completed ? " checked" : ""}`}
|
||||
onClick={handleToggleComplete}
|
||||
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
|
||||
type="button"
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>
|
||||
{saving ? t("saving") : t("autoSaved")}
|
||||
{saving ? `● ${t("saving")}` : `✓ ${t("autoSaved")}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="detail-actions">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<button
|
||||
className="icon-btn"
|
||||
id="delete-task-btn"
|
||||
onClick={handleDelete}
|
||||
title={t("deleteTaskConfirm").split("?")[0]}
|
||||
style={{ color: "var(--danger)" }}
|
||||
type="button"
|
||||
>
|
||||
<svg width="15" height="15" 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="icon-btn"
|
||||
id="close-detail-btn"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
type="button"
|
||||
>
|
||||
<svg width="15" height="15" 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="icon-btn" onClick={onClose} title="Close" type="button">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main scrollable body */}
|
||||
<div className="detail-scroll" style={{ display: "flex", flexDirection: "column", height: "calc(100% - 48px)", padding: "8px 16px 12px", gap: 10 }}>
|
||||
{/* Title input */}
|
||||
<textarea
|
||||
id="task-title-input"
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
{/* 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>
|
||||
|
||||
{/* Compact Metadata Strip (Priority, Due Date & Tags) */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap", paddingBottom: 6, 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>
|
||||
{/* 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 });
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Due Date */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<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>
|
||||
{/* 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>
|
||||
|
||||
{/* Tags Trigger Chip & Popover */}
|
||||
<div style={{ position: "relative", marginLeft: "auto" }}>
|
||||
{/* Tag Selector */}
|
||||
<div style={{ position: "relative" }}>
|
||||
<button
|
||||
className="tick-meta-chip"
|
||||
type="button"
|
||||
className="tick-meta-chip"
|
||||
onClick={() => setShowTagPicker((p) => !p)}
|
||||
style={{ padding: "3px 8px", fontSize: 11 }}
|
||||
style={{ fontSize: 11.5, padding: "3px 8px" }}
|
||||
>
|
||||
🏷️ {tags.length > 0 ? `${tags.length} tags` : "+ Tag"}
|
||||
🏷️ {tags.length > 0 ? `${tags.length} tags` : t("selectTag")}
|
||||
</button>
|
||||
|
||||
{showTagPicker && (
|
||||
<div
|
||||
className="dropdown"
|
||||
style={{ right: 0, top: "calc(100% + 4px)", minWidth: 200, padding: 8, zIndex: 100 }}
|
||||
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 }}>CUSTOM TAGS</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 8 }}>
|
||||
{allAvailableTags.map((at) => {
|
||||
const active = tags.some((tg) => tg.tag.id === at.id);
|
||||
<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 (
|
||||
<span
|
||||
key={at.id}
|
||||
className="badge"
|
||||
style={{
|
||||
background: active ? at.color : "var(--bg-primary)",
|
||||
color: active ? "#fff" : at.color,
|
||||
border: `1px solid ${at.color}`,
|
||||
cursor: "pointer",
|
||||
fontSize: 11,
|
||||
padding: "2px 8px",
|
||||
borderRadius: 4,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
onClick={() => toggleTag(at)}
|
||||
<div
|
||||
key={tag.id}
|
||||
className="context-menu-item"
|
||||
style={{ padding: "4px 8px", fontSize: 12, cursor: "pointer" }}
|
||||
onClick={() => toggleTag(tag)}
|
||||
>
|
||||
#{at.name} {active ? "✓" : ""}
|
||||
</span>
|
||||
<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>
|
||||
{/* Create custom tag input */}
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
<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") addCustomTag(); }}
|
||||
style={{ fontSize: 11, height: 26, padding: "2px 6px" }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addCustomTag();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-primary btn-sm" type="button" onClick={addCustomTag} style={{ fontSize: 11, padding: "2px 8px" }}>
|
||||
<button className="btn btn-primary btn-sm" style={{ fontSize: 10, padding: "2px 6px" }} onClick={addCustomTag} type="button">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
@@ -537,170 +789,37 @@ export function TaskDetail({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Tags Display */}
|
||||
{tags.length > 0 && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, paddingBottom: 4 }}>
|
||||
{tags.map((tg) => (
|
||||
<span
|
||||
key={tg.tag.id}
|
||||
className="badge"
|
||||
style={{
|
||||
background: tg.tag.color + "22",
|
||||
color: tg.tag.color,
|
||||
fontSize: 11,
|
||||
padding: "2px 8px",
|
||||
borderRadius: 4,
|
||||
fontWeight: 600,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
#{tg.tag.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleTag(tg.tag as MockTag)}
|
||||
style={{ background: "none", border: "none", color: "inherit", cursor: "pointer", padding: 0, fontSize: 10, opacity: 0.7 }}
|
||||
{/* 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"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The Wide Adaptive Markdown Note Editor */}
|
||||
<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>
|
||||
{/* Progress bar */}
|
||||
{subtasks.length > 0 && (
|
||||
<div style={{ height: 3, background: "var(--bg-hover)", borderRadius: 2, marginBottom: 10, 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 className="detail-split-resizer-line" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sub-tasks list */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4, maxHeight: 180, overflowY: "auto" }}>
|
||||
{subtasks.map((sub) => (
|
||||
<div
|
||||
key={sub.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "4px 8px",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
background: "var(--bg-secondary)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className={`task-check-btn${sub.completed ? " checked" : ""}`}
|
||||
style={{ width: 15, height: 15, flexShrink: 0 }}
|
||||
onClick={() => toggleSubtask(sub)}
|
||||
aria-label="Toggle subtask"
|
||||
type="button"
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 12.5,
|
||||
textDecoration: sub.completed ? "line-through" : "none",
|
||||
color: sub.completed ? "var(--text-tertiary)" : "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
|
||||
{/* Add Subtask Input */}
|
||||
<div style={{ display: "flex", gap: 6, marginTop: 4 }}>
|
||||
<input
|
||||
className="form-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>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer Info: Project Badge, Created & Edited Timestamps */}
|
||||
{/* 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" }}>
|
||||
|
||||
Reference in New Issue
Block a user