Feature: Mobile bottom-sheet drawer (slide-up from bottom, swipe-down to close, overlay), update AGENTS.md to v4.0

This commit is contained in:
Wonhee Han
2026-08-20 22:51:43 +09:00
parent d2d2034515
commit 765f670ef0
12 changed files with 1820 additions and 273 deletions
+281 -90
View File
@@ -1,8 +1,9 @@
"use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { useI18n } from "@/lib/i18n";
import { Task } from "./TaskList";
import { Task, Tag } from "./TaskList";
import { MarkdownNoteEditor } from "./MarkdownNoteEditor";
import { getCustomTags, MockTag } from "@/lib/mockData";
interface Props {
task: Task;
@@ -33,11 +34,28 @@ export function TaskDetail({
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[]>([]);
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<number | 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);
@@ -59,8 +77,10 @@ export function TaskDetail({
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]);
setAllAvailableTags(getCustomTags());
}, [task.id, task.title, task.note, task.dueDate, task.priority, task.children, task.tags]);
useEffect(() => {
return () => {
@@ -78,6 +98,7 @@ export function TaskDetail({
...task,
...data,
children: subtasks,
tags,
updatedAt: new Date().toISOString(),
} as Task;
if (onDemoUpdateTask) onDemoUpdateTask(updatedTask);
@@ -95,25 +116,26 @@ export function TaskDetail({
if (res.ok) {
const updated = await res.json();
if (taskId === currentTaskId.current) {
onUpdate({ ...updated, children: subtasks });
onUpdate({ ...updated, children: subtasks, tags });
}
}
} catch (err) {
console.error("[TaskDetail] save failed", err);
} finally {
if (taskId === currentTaskId.current) setSaving(false);
setSaving(false);
}
},
[isDemo, onDemoUpdateTask, onUpdate, subtasks, task]
[isDemo, onDemoUpdateTask, onUpdate, subtasks, tags, task]
);
const debounceSave = useCallback(
(overrides: Record<string, unknown>) => {
(data: Record<string, unknown>) => {
if (saveTimer.current) clearTimeout(saveTimer.current);
const taskId = currentTaskId.current;
saveTimer.current = setTimeout(() => save(taskId, overrides), 600);
saveTimer.current = setTimeout(() => {
save(task.id, data);
}, 500);
},
[save]
[save, task.id]
);
const handleNoteChange = (newNote: string) => {
@@ -121,40 +143,16 @@ export function TaskDetail({
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 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();
@@ -169,6 +167,32 @@ export function TaskDetail({
}
}, [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;
@@ -254,13 +278,13 @@ export function TaskDetail({
console.error("[TaskDetail] toggleSubtask failed", err);
}
},
[isDemo, subtasks, task, onDemoUpdateTask, onUpdate]
[isDemo, onDemoUpdateTask, onUpdate, subtasks, task]
);
const deleteSubtask = useCallback(
async (id: string) => {
async (subId: string) => {
if (isDemo) {
const nextSubs = subtasks.filter((s) => s.id !== id);
const nextSubs = subtasks.filter((s) => s.id !== subId);
setSubtasks(nextSubs);
const updatedParent = { ...task, children: nextSubs };
if (onDemoUpdateTask) onDemoUpdateTask(updatedParent);
@@ -269,9 +293,9 @@ export function TaskDetail({
}
try {
await fetch(`/api/tasks/${id}`, { method: "DELETE" });
await fetch(`/api/tasks/${subId}`, { method: "DELETE" });
setSubtasks((prev) => {
const next = prev.filter((s) => s.id !== id);
const next = prev.filter((s) => s.id !== subId);
onUpdate({ ...task, children: next });
return next;
});
@@ -279,42 +303,91 @@ export function TaskDetail({
console.error("[TaskDetail] deleteSubtask failed", err);
}
},
[isDemo, subtasks, task, onDemoUpdateTask, onUpdate]
[isDemo, onDemoUpdateTask, onUpdate, subtasks, task]
);
// Touch handlers: 모바일에서 아래로 스와이프 → 패널 닫기
const handleTouchStart = (e: React.TouchEvent) => {
touchStartY.current = e.touches[0].clientY;
};
const handleTouchMove = (e: React.TouchEvent) => {
if (touchStartY.current === null) return;
const deltaY = e.touches[0].clientY - touchStartY.current;
if (deltaY > 0) {
setPanelTranslateY(deltaY);
}
};
const handleTouchEnd = () => {
if (panelTranslateY > 120) {
onClose();
}
setPanelTranslateY(0);
touchStartY.current = null;
};
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>
<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",
}}
>
{/* 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" }} />
{/* 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")}
</span>
</div>
<div className="detail-actions">
<button
className="icon-btn"
id="delete-task-btn"
onClick={handleDelete}
title={t("deleteTaskConfirm").split("?")[0]}
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>
</div>
</div>
{/* Main Body - Centered on Wide Memo Experience */}
<div className="detail-body" style={{ display: "flex", flexDirection: "column", height: "100%", gap: 14 }}>
{/* Title */}
{/* 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="detail-title"
id="task-title-input"
className="detail-title-input"
value={title}
onChange={(e) => {
@@ -331,8 +404,8 @@ export function TaskDetail({
}}
/>
{/* 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)" }}>
{/* 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>
@@ -357,7 +430,7 @@ export function TaskDetail({
</div>
{/* Due Date */}
<div style={{ display: "flex", alignItems: "center", gap: 6, marginLeft: "auto" }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<input
id="due-date-input"
type="date"
@@ -383,9 +456,101 @@ export function TaskDetail({
</button>
)}
</div>
{/* Tags Trigger Chip & Popover */}
<div style={{ position: "relative", marginLeft: "auto" }}>
<button
className="tick-meta-chip"
type="button"
onClick={() => setShowTagPicker((p) => !p)}
style={{ padding: "3px 8px", fontSize: 11 }}
>
🏷 {tags.length > 0 ? `${tags.length} tags` : "+ Tag"}
</button>
{showTagPicker && (
<div
className="dropdown"
style={{ right: 0, top: "calc(100% + 4px)", minWidth: 200, padding: 8, zIndex: 100 }}
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);
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)}
>
#{at.name} {active ? "✓" : ""}
</span>
);
})}
</div>
{/* Create custom tag input */}
<div style={{ display: "flex", gap: 4 }}>
<input
className="form-input"
placeholder="New tag..."
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") addCustomTag(); }}
style={{ fontSize: 11, height: 26, padding: "2px 6px" }}
/>
<button className="btn btn-primary btn-sm" type="button" onClick={addCustomTag} style={{ fontSize: 11, padding: "2px 8px" }}>
+
</button>
</div>
</div>
)}
</div>
</div>
{/* The Wide Adaptive Markdown Note Editor (Takes ALL Remaining Space) */}
{/* 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 }}
>
</button>
</span>
))}
</div>
)}
{/* The Wide Adaptive Markdown Note Editor */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<MarkdownNoteEditor
value={note}
@@ -431,42 +596,68 @@ export function TaskDetail({
{showSubtasks && (
<div>
{/* Progress bar */}
{subtasks.length > 0 && (
<div className="progress-bar" style={{ marginBottom: 8, height: 3 }}>
<div className="progress-bar-fill" style={{ width: `${progressPct}%` }} />
<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>
)}
<div className="detail-subtasks" style={{ maxHeight: 160, overflowY: "auto" }}>
{/* Sub-tasks list */}
<div style={{ display: "flex", flexDirection: "column", gap: 4, maxHeight: 180, overflowY: "auto" }}>
{subtasks.map((sub) => (
<div
key={sub.id}
className={`detail-subtask-row${sub.completed ? " completed" : ""}`}
id={`detail-sub-${sub.id}`}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "4px 8px",
borderRadius: "var(--radius-sm)",
background: "var(--bg-secondary)",
}}
>
<button
className={`subtask-check-btn${sub.completed ? " checked" : ""}`}
className={`task-check-btn${sub.completed ? " checked" : ""}`}
style={{ width: 15, height: 15, flexShrink: 0 }}
onClick={() => toggleSubtask(sub)}
aria-label="Toggle subtask"
type="button"
/>
<span className="detail-subtask-title">{sub.title}</span>
<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)}
aria-label="Delete subtask"
title="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>
{/* 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)" }}