Feature: Add rich interactive Markdown renderer, wide adaptive memo panel, and custom language popover
This commit is contained in:
+143
-145
@@ -2,6 +2,7 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { Task } from "./TaskList";
|
||||
import { MarkdownNoteEditor } from "./MarkdownNoteEditor";
|
||||
|
||||
interface Props {
|
||||
task: Task;
|
||||
@@ -32,11 +33,11 @@ export function TaskDetail({
|
||||
const [priority, setPriority] = useState(task.priority);
|
||||
const [newSubtitle, setNewSubtitle] = useState("");
|
||||
const [subtasks, setSubtasks] = useState(task.children || []);
|
||||
const [showSubtasks, setShowSubtasks] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const currentTaskId = useRef(task.id);
|
||||
const noteRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const priorityMap = [
|
||||
{ label: t("priorityNone"), color: "var(--text-tertiary)", icon: "" },
|
||||
@@ -113,32 +114,10 @@ export function TaskDetail({
|
||||
[save]
|
||||
);
|
||||
|
||||
// Insert markdown at cursor position
|
||||
const insertAtCursor = useCallback(
|
||||
(before: string, after = "", placeholder = "") => {
|
||||
const ta = noteRef.current;
|
||||
if (!ta) {
|
||||
setNote((n) => {
|
||||
const v = n + before + placeholder + after;
|
||||
debounceSave({ note: v });
|
||||
return v;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const start = ta.selectionStart ?? ta.value.length;
|
||||
const end = ta.selectionEnd ?? ta.value.length;
|
||||
const selected = ta.value.slice(start, end) || placeholder;
|
||||
const newVal = ta.value.slice(0, start) + before + selected + after + ta.value.slice(end);
|
||||
setNote(newVal);
|
||||
debounceSave({ note: newVal });
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
ta.selectionStart = start + before.length;
|
||||
ta.selectionEnd = start + before.length + selected.length;
|
||||
});
|
||||
},
|
||||
[debounceSave]
|
||||
);
|
||||
const handleNoteChange = (newNote: string) => {
|
||||
setNote(newNote);
|
||||
debounceSave({ note: newNote });
|
||||
};
|
||||
|
||||
const handleToggleCompleted = useCallback(
|
||||
async (newCompleted: boolean) => {
|
||||
@@ -306,7 +285,7 @@ export function TaskDetail({
|
||||
|
||||
return (
|
||||
<aside className="detail-panel">
|
||||
{/* Header */}
|
||||
{/* Header Bar */}
|
||||
<div className="detail-header">
|
||||
<button
|
||||
className={`task-check-btn${task.completed ? " checked" : ""}`}
|
||||
@@ -329,8 +308,8 @@ export function TaskDetail({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="detail-body">
|
||||
{/* Main Body - Centered on Wide Memo Experience */}
|
||||
<div className="detail-body" style={{ display: "flex", flexDirection: "column", height: "100%", gap: 14 }}>
|
||||
{/* Title */}
|
||||
<textarea
|
||||
id="detail-title"
|
||||
@@ -341,39 +320,42 @@ export function TaskDetail({
|
||||
debounceSave({ title: e.target.value });
|
||||
}}
|
||||
placeholder={t("taskTitlePlaceholder")}
|
||||
rows={2}
|
||||
rows={1}
|
||||
style={{
|
||||
textDecoration: task.completed ? "line-through" : "none",
|
||||
color: task.completed ? "var(--text-tertiary)" : "var(--text-primary)",
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Priority */}
|
||||
<div className="detail-section">
|
||||
<div className="detail-section-label">{t("priority")}</div>
|
||||
<div className="priority-selector">
|
||||
{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 }}
|
||||
onClick={() => {
|
||||
setPriority(i);
|
||||
debounceSave({ priority: i });
|
||||
}}
|
||||
>
|
||||
{p.icon && <span style={{ color: p.color }}>{p.icon}</span>}
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
{/* 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)" }}>
|
||||
{/* 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>
|
||||
</div>
|
||||
|
||||
{/* Due date */}
|
||||
<div className="detail-section">
|
||||
<div className="detail-section-label">{t("dueDate")}</div>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
{/* Due Date */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, marginLeft: "auto" }}>
|
||||
<input
|
||||
id="due-date-input"
|
||||
type="date"
|
||||
@@ -383,15 +365,17 @@ export function TaskDetail({
|
||||
setDueDate(e.target.value);
|
||||
debounceSave({ dueDate: e.target.value || null });
|
||||
}}
|
||||
style={{ width: "auto" }}
|
||||
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>
|
||||
@@ -399,107 +383,121 @@ export function TaskDetail({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes — wide memo area */}
|
||||
<div className="detail-section" style={{ flex: 1 }}>
|
||||
<div className="detail-section-label">{t("notes")}</div>
|
||||
<div className="note-editor-wrap">
|
||||
<div className="note-editor-toolbar">
|
||||
<button className="note-toolbar-btn" title={t("bold")} onClick={() => insertAtCursor("**", "**", "bold")}>B</button>
|
||||
<button className="note-toolbar-btn" title={t("italic")} style={{ fontStyle: "italic" }} onClick={() => insertAtCursor("*", "*", "italic")}>I</button>
|
||||
<button className="note-toolbar-btn" title={t("heading")} onClick={() => insertAtCursor("## ", "", "Heading")}>H</button>
|
||||
<button className="note-toolbar-btn" title={t("bulletList")} onClick={() => insertAtCursor("\n- ", "", "item")}>•</button>
|
||||
<button className="note-toolbar-btn" title={t("numberedList")} onClick={() => insertAtCursor("\n1. ", "", "item")}>1.</button>
|
||||
<button className="note-toolbar-btn" title={t("checkbox")} onClick={() => insertAtCursor("\n- [ ] ", "", "task")}>☐</button>
|
||||
<button className="note-toolbar-btn" title={t("code")} style={{ fontFamily: "monospace", fontSize: 11 }} onClick={() => insertAtCursor("`", "`", "code")}>{"`"}</button>
|
||||
</div>
|
||||
<textarea
|
||||
ref={noteRef}
|
||||
id="note-textarea"
|
||||
className="note-textarea"
|
||||
placeholder={t("notesPlaceholder")}
|
||||
value={note}
|
||||
onChange={(e) => {
|
||||
setNote(e.target.value);
|
||||
debounceSave({ note: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* The Wide Adaptive Markdown Note Editor (Takes ALL Remaining Space) */}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
|
||||
<MarkdownNoteEditor
|
||||
value={note}
|
||||
onChange={handleNoteChange}
|
||||
onSave={() => save(task.id, { note })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sub-tasks */}
|
||||
<div className="detail-section">
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
|
||||
<div className="detail-section-label">{t("subtasks")}</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)" }}>
|
||||
<span style={{ fontSize: 11, color: "var(--text-tertiary)", fontWeight: 600 }}>
|
||||
{completedCount}/{subtasks.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{subtasks.length > 0 && (
|
||||
<div className="progress-bar" style={{ marginBottom: 10 }}>
|
||||
<div className="progress-bar-fill" style={{ width: `${progressPct}%` }} />
|
||||
{showSubtasks && (
|
||||
<div>
|
||||
{subtasks.length > 0 && (
|
||||
<div className="progress-bar" style={{ marginBottom: 8, height: 3 }}>
|
||||
<div className="progress-bar-fill" style={{ width: `${progressPct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="detail-subtasks" style={{ maxHeight: 160, overflowY: "auto" }}>
|
||||
{subtasks.map((sub) => (
|
||||
<div
|
||||
key={sub.id}
|
||||
className={`detail-subtask-row${sub.completed ? " completed" : ""}`}
|
||||
id={`detail-sub-${sub.id}`}
|
||||
>
|
||||
<button
|
||||
className={`subtask-check-btn${sub.completed ? " checked" : ""}`}
|
||||
onClick={() => toggleSubtask(sub)}
|
||||
aria-label="Toggle subtask"
|
||||
type="button"
|
||||
/>
|
||||
<span className="detail-subtask-title">{sub.title}</span>
|
||||
<button
|
||||
className="icon-btn"
|
||||
style={{ width: 20, height: 20, opacity: 0.4 }}
|
||||
onClick={() => deleteSubtask(sub.id)}
|
||||
aria-label="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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="detail-subtasks">
|
||||
{subtasks.map((sub) => (
|
||||
<div
|
||||
key={sub.id}
|
||||
className={`detail-subtask-row${sub.completed ? " completed" : ""}`}
|
||||
id={`detail-sub-${sub.id}`}
|
||||
>
|
||||
<button
|
||||
className={`subtask-check-btn${sub.completed ? " checked" : ""}`}
|
||||
onClick={() => toggleSubtask(sub)}
|
||||
aria-label="Toggle subtask"
|
||||
/>
|
||||
<span className="detail-subtask-title">{sub.title}</span>
|
||||
<button
|
||||
className="icon-btn"
|
||||
style={{ width: 20, height: 20, opacity: 0.4 }}
|
||||
onClick={() => deleteSubtask(sub.id)}
|
||||
aria-label="Delete subtask"
|
||||
>
|
||||
<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">
|
||||
<span style={{ fontSize: 16, lineHeight: 1, color: "var(--accent)" }}>+</span>
|
||||
<input
|
||||
id="add-subtask-input"
|
||||
placeholder={t("addSubtaskPlaceholder")}
|
||||
style={{ flex: 1, background: "none", fontSize: 13, 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}>
|
||||
{t("add")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div style={{ fontSize: 11, color: "var(--text-tertiary)", paddingTop: 8, borderTop: "1px solid var(--border)" }}>
|
||||
{t("created")}{" "}
|
||||
{new Date(task.createdAt).toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
{/* Footer Timestamp */}
|
||||
<div style={{ fontSize: 11, color: "var(--text-tertiary)", display: "flex", justifyContent: "space-between" }}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
Reference in New Issue
Block a user