Feature: Add rich interactive Markdown renderer, wide adaptive memo panel, and custom language popover

This commit is contained in:
Wonhee Han
2026-08-20 14:14:27 +09:00
parent 3710c313e9
commit 633e760599
10 changed files with 1950 additions and 229 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts"; import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1112 -9
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -16,11 +16,13 @@
"@prisma/client": "^6.19.3", "@prisma/client": "^6.19.3",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"csv-parse": "^7.0.2", "csv-parse": "^7.0.2",
"marked": "^18.0.10",
"next": "16.3.1", "next": "16.3.1",
"next-auth": "^4.24.15", "next-auth": "^4.24.15",
"prisma": "^6.19.3", "prisma": "^6.19.3",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"react-markdown": "^10.1.0",
"uuid": "^14.0.2" "uuid": "^14.0.2"
}, },
"devDependencies": { "devDependencies": {
+276 -9
View File
@@ -63,7 +63,7 @@
/* Layout */ /* Layout */
--sidebar-width: 240px; --sidebar-width: 240px;
--detail-width: 360px; --detail-width: min(560px, 48vw);
--header-height: 56px; --header-height: 56px;
} }
@@ -701,38 +701,56 @@ a { color: inherit; text-decoration: none; }
/* Note area - the wide memo */ /* Note area - the wide memo */
.note-editor-wrap { .note-editor-wrap {
background: var(--bg-secondary); background: var(--bg-primary);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
overflow: hidden; overflow: hidden;
min-height: 200px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1;
min-height: 280px;
box-shadow: var(--shadow-xs);
transition: border-color var(--dur-fast), box-shadow var(--dur-fast); transition: border-color var(--dur-fast), box-shadow var(--dur-fast);
} }
.note-editor-wrap:focus-within { .note-editor-wrap:focus-within {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-light); box-shadow: 0 0 0 2px var(--accent-light);
} }
.note-editor-toolbar { .note-editor-toolbar {
display: flex; display: flex;
gap: 2px; align-items: center;
padding: 6px 8px; gap: 3px;
padding: 6px 10px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--bg-primary); background: var(--bg-secondary);
flex-wrap: wrap;
}
.note-toolbar-group {
display: flex;
align-items: center;
gap: 2px;
}
.note-toolbar-divider {
width: 1px;
height: 18px;
background: var(--border-medium);
margin: 0 4px;
} }
.note-toolbar-btn { .note-toolbar-btn {
width: 26px;
height: 26px; height: 26px;
min-width: 26px;
padding: 0 5px;
border-radius: var(--radius-xs); border-radius: var(--radius-xs);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 600;
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
transition: background var(--dur-fast), color var(--dur-fast); transition: background var(--dur-fast), color var(--dur-fast);
@@ -743,6 +761,255 @@ a { color: inherit; text-decoration: none; }
color: var(--text-primary); color: var(--text-primary);
} }
.note-toolbar-btn.active {
background: var(--accent-light);
color: var(--accent);
}
.note-textarea {
flex: 1;
width: 100%;
background: transparent;
color: var(--text-primary);
font-size: 14px;
line-height: 1.7;
padding: 16px;
resize: none;
min-height: 240px;
font-family: inherit;
}
.note-textarea::placeholder { color: var(--text-tertiary); }
/* ============================================
MARKDOWN PREVIEW STYLES
============================================ */
.markdown-body {
flex: 1;
padding: 16px;
color: var(--text-primary);
font-size: 14px;
line-height: 1.7;
overflow-y: auto;
min-height: 240px;
background: var(--bg-primary);
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4 {
font-weight: 700;
color: var(--text-primary);
margin-top: 18px;
margin-bottom: 8px;
line-height: 1.35;
letter-spacing: -0.3px;
}
.markdown-body h1:first-child,
.markdown-body h2:first-child,
.markdown-body h3:first-child {
margin-top: 0;
}
.markdown-body h1 { font-size: 20px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
.markdown-body h2 { font-size: 17px; }
.markdown-body h3 { font-size: 15px; }
.markdown-body p {
margin-bottom: 12px;
}
.markdown-body p:last-child {
margin-bottom: 0;
}
.markdown-body strong {
font-weight: 700;
color: var(--text-primary);
}
.markdown-body em {
font-style: italic;
}
.markdown-body del {
text-decoration: line-through;
opacity: 0.6;
}
.markdown-body ul,
.markdown-body ol {
margin-bottom: 12px;
padding-left: 22px;
}
.markdown-body li {
margin-bottom: 4px;
}
.markdown-body blockquote {
border-left: 3px solid var(--accent);
padding: 6px 12px;
margin: 12px 0;
background: var(--accent-light);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
color: var(--text-secondary);
font-size: 13.5px;
}
.markdown-body code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12.5px;
background: var(--bg-secondary);
border: 1px solid var(--border);
padding: 2px 5px;
border-radius: var(--radius-xs);
color: var(--accent);
}
.markdown-body pre {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px;
margin: 12px 0;
overflow-x: auto;
}
.markdown-body pre code {
background: none;
border: none;
padding: 0;
color: var(--text-primary);
font-size: 13px;
}
.markdown-body a {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.markdown-body hr {
border: none;
border-top: 1px solid var(--border);
margin: 16px 0;
}
.markdown-body img {
max-width: 100%;
border-radius: var(--radius-sm);
margin: 8px 0;
}
/* Interactive Checkbox in Markdown */
.markdown-checkbox-item {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 6px 0;
cursor: pointer;
user-select: none;
}
.markdown-checkbox-box {
width: 16px;
height: 16px;
border-radius: 4px;
border: 1.5px solid var(--border-strong);
margin-top: 3px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all var(--dur-fast);
}
.markdown-checkbox-box.checked {
background: var(--accent);
border-color: var(--accent);
}
.markdown-checkbox-box.checked::after {
content: "";
width: 4px;
height: 8px;
border: 1.5px solid white;
border-top: none;
border-left: none;
transform: rotate(45deg) translateY(-1px);
}
/* Custom Language Selector Popover */
.lang-selector-wrap {
position: relative;
}
.lang-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 4px 8px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all var(--dur-fast);
}
.lang-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--border-medium);
}
.lang-dropdown {
position: absolute;
top: calc(100% + 4px);
right: 0;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
padding: 4px;
min-width: 120px;
z-index: 60;
animation: dropdownIn var(--dur-fast) var(--ease-out);
}
.lang-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
font-size: 12.5px;
font-weight: 500;
color: var(--text-primary);
border-radius: var(--radius-xs);
cursor: pointer;
transition: background var(--dur-fast);
}
.lang-option:hover {
background: var(--bg-hover);
}
.lang-option.selected {
color: var(--accent);
font-weight: 700;
background: var(--accent-light);
}
.note-toolbar-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.note-textarea { .note-textarea {
flex: 1; flex: 1;
width: 100%; width: 100%;
+4 -21
View File
@@ -1,10 +1,11 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { useI18n, Language } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useTheme } from "@/app/providers"; import { useTheme } from "@/app/providers";
import { LanguageSelector } from "@/components/ui/LanguageSelector";
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
@@ -44,25 +45,7 @@ export default function LoginPage() {
<span className="auth-logo-name">{t("appName")}</span> <span className="auth-logo-name">{t("appName")}</span>
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}> <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<select <LanguageSelector />
aria-label={t("language")}
value={lang}
onChange={(e) => setLang(e.target.value as Language)}
style={{
background: "var(--bg-secondary)",
color: "var(--text-secondary)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-xs)",
fontSize: 11,
padding: "3px 6px",
cursor: "pointer",
fontWeight: 600,
}}
>
<option value="en">EN</option>
<option value="ko"></option>
<option value="ja"></option>
</select>
<button <button
className="icon-btn" className="icon-btn"
+4 -21
View File
@@ -1,10 +1,11 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { useI18n, Language } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useTheme } from "@/app/providers"; import { useTheme } from "@/app/providers";
import { LanguageSelector } from "@/components/ui/LanguageSelector";
export default function RegisterPage() { export default function RegisterPage() {
const router = useRouter(); const router = useRouter();
@@ -60,25 +61,7 @@ export default function RegisterPage() {
<span className="auth-logo-name">{t("appName")}</span> <span className="auth-logo-name">{t("appName")}</span>
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}> <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<select <LanguageSelector />
aria-label={t("language")}
value={lang}
onChange={(e) => setLang(e.target.value as Language)}
style={{
background: "var(--bg-secondary)",
color: "var(--text-secondary)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-xs)",
fontSize: 11,
padding: "3px 6px",
cursor: "pointer",
fontWeight: 600,
}}
>
<option value="en">EN</option>
<option value="ko"></option>
<option value="ja"></option>
</select>
<button <button
className="icon-btn" className="icon-btn"
+5 -22
View File
@@ -1,8 +1,9 @@
"use client"; "use client";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import { useI18n, Language } from "@/lib/i18n"; import { useI18n, Language } from "@/lib/i18n";
import { useTheme, ThemeMode } from "@/app/providers"; import { useTheme, ThemeMode } from "@/app/providers";
import { LanguageSelector } from "@/components/ui/LanguageSelector";
interface User { id: string; name?: string | null; email?: string | null } interface User { id: string; name?: string | null; email?: string | null }
interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } } interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } }
@@ -170,27 +171,9 @@ export function Sidebar({
</div> </div>
{/* Top Quick Actions (Lang & Theme) */} {/* Top Quick Actions (Lang & Theme) */}
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 4 }}> <div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
{/* Language Selector */} {/* Custom Language Selector Popover */}
<select <LanguageSelector />
aria-label={t("language")}
value={lang}
onChange={(e) => setLang(e.target.value as Language)}
style={{
background: "var(--bg-secondary)",
color: "var(--text-secondary)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-xs)",
fontSize: 11,
padding: "3px 4px",
cursor: "pointer",
fontWeight: 600,
}}
>
<option value="en">EN</option>
<option value="ko"></option>
<option value="ja"></option>
</select>
{/* Theme 3-Way Toggle Button */} {/* Theme 3-Way Toggle Button */}
<button <button
+325
View File
@@ -0,0 +1,325 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
import { marked } from "marked";
import { useI18n } from "@/lib/i18n";
interface MarkdownNoteEditorProps {
value: string;
onChange: (newValue: string) => void;
onSave?: () => void;
}
export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEditorProps) {
const { t } = useI18n();
const [mode, setMode] = useState<"edit" | "preview">("preview");
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Insert markdown syntax at selection or cursor position
const insertSyntax = useCallback(
(before: string, after = "", placeholder = "") => {
const ta = textareaRef.current;
if (!ta) {
onChange(value + before + placeholder + after);
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);
onChange(newVal);
requestAnimationFrame(() => {
ta.focus();
ta.selectionStart = start + before.length;
ta.selectionEnd = start + before.length + selected.length;
});
},
[onChange, value]
);
// Keyboard shortcuts
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.ctrlKey || e.metaKey) && e.key === "b") {
e.preventDefault();
insertSyntax("**", "**", "bold");
} else if ((e.ctrlKey || e.metaKey) && e.key === "i") {
e.preventDefault();
insertSyntax("*", "*", "italic");
} else if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
if (onSave) onSave();
} else if (e.key === "Tab") {
e.preventDefault();
insertSyntax(" ");
}
};
// Toggle interactive checkboxes in markdown preview
const handlePreviewCheckboxToggle = (index: number) => {
const lines = value.split("\n");
let checkboxCount = 0;
const newLines = lines.map((line) => {
const match = line.match(/^(\s*[-*+]\s*\[)([ xX])(\])(.*)$/);
if (match) {
if (checkboxCount === index) {
const currentChecked = match[2].toLowerCase() === "x";
const newChecked = currentChecked ? " " : "x";
checkboxCount++;
return `${match[1]}${newChecked}${match[3]}${match[4]}`;
}
checkboxCount++;
}
return line;
});
onChange(newLines.join("\n"));
};
// Convert markdown to sanitized HTML with interactive checkboxes
const renderMarkdownHtml = () => {
if (!value || !value.trim()) {
return `<p style="color: var(--text-tertiary); font-style: italic;">${t("notesPlaceholder").split("\n")[0]}</p>`;
}
try {
let rawHtml = marked.parse(value, { breaks: true, gfm: true }) as string;
// Replace task list checkboxes with interactive ones
let cbIdx = 0;
rawHtml = rawHtml.replace(/<input[^>]*type="checkbox"[^>]*checked[^>]*>/gi, () => {
const id = `cb-${cbIdx++}`;
return `<span class="markdown-checkbox-box checked" data-idx="${id}"></span>`;
});
rawHtml = rawHtml.replace(/<input[^>]*type="checkbox"[^>]*>/gi, () => {
const id = `cb-${cbIdx++}`;
return `<span class="markdown-checkbox-box" data-idx="${id}"></span>`;
});
return rawHtml;
} catch {
return value;
}
};
return (
<div className="note-editor-wrap">
{/* Top Toolbar */}
<div className="note-editor-toolbar">
{/* Mode Switcher Tabs */}
<div className="note-toolbar-group">
<button
type="button"
className={`note-toolbar-btn${mode === "preview" ? " active" : ""}`}
onClick={() => setMode("preview")}
title="Preview rendered Markdown"
>
👁 Preview
</button>
<button
type="button"
className={`note-toolbar-btn${mode === "edit" ? " active" : ""}`}
onClick={() => {
setMode("edit");
setTimeout(() => textareaRef.current?.focus(), 50);
}}
title="Edit raw Markdown"
>
Edit
</button>
</div>
<div className="note-toolbar-divider" />
{/* Formatting actions */}
<div className="note-toolbar-group">
<button
type="button"
className="note-toolbar-btn"
title="H1 Heading"
onClick={() => {
setMode("edit");
insertSyntax("# ", "", "Heading 1");
}}
>
H1
</button>
<button
type="button"
className="note-toolbar-btn"
title="H2 Heading"
onClick={() => {
setMode("edit");
insertSyntax("## ", "", "Heading 2");
}}
>
H2
</button>
<button
type="button"
className="note-toolbar-btn"
title="H3 Heading"
onClick={() => {
setMode("edit");
insertSyntax("### ", "", "Heading 3");
}}
>
H3
</button>
</div>
<div className="note-toolbar-divider" />
<div className="note-toolbar-group">
<button
type="button"
className="note-toolbar-btn"
title="Bold (Ctrl+B)"
onClick={() => {
setMode("edit");
insertSyntax("**", "**", "bold");
}}
>
<strong>B</strong>
</button>
<button
type="button"
className="note-toolbar-btn"
title="Italic (Ctrl+I)"
style={{ fontStyle: "italic" }}
onClick={() => {
setMode("edit");
insertSyntax("*", "*", "italic");
}}
>
<em>I</em>
</button>
<button
type="button"
className="note-toolbar-btn"
title="Strikethrough"
style={{ textDecoration: "line-through" }}
onClick={() => {
setMode("edit");
insertSyntax("~~", "~~", "strikethrough");
}}
>
S
</button>
</div>
<div className="note-toolbar-divider" />
<div className="note-toolbar-group">
<button
type="button"
className="note-toolbar-btn"
title="Checkbox item"
onClick={() => {
setMode("edit");
insertSyntax("\n- [ ] ", "", "task");
}}
>
</button>
<button
type="button"
className="note-toolbar-btn"
title="Bullet list"
onClick={() => {
setMode("edit");
insertSyntax("\n- ", "", "list item");
}}
>
</button>
<button
type="button"
className="note-toolbar-btn"
title="Numbered list"
onClick={() => {
setMode("edit");
insertSyntax("\n1. ", "", "list item");
}}
>
1.
</button>
<button
type="button"
className="note-toolbar-btn"
title="Quote"
onClick={() => {
setMode("edit");
insertSyntax("\n> ", "", "quote");
}}
>
</button>
<button
type="button"
className="note-toolbar-btn"
title="Code Block"
style={{ fontFamily: "monospace" }}
onClick={() => {
setMode("edit");
insertSyntax("\n```\n", "\n```\n", "code");
}}
>
{"< >"}
</button>
<button
type="button"
className="note-toolbar-btn"
title="Horizontal Divider"
onClick={() => {
setMode("edit");
insertSyntax("\n---\n");
}}
>
</button>
<button
type="button"
className="note-toolbar-btn"
title="Link"
onClick={() => {
setMode("edit");
insertSyntax("[", "](https://example.com)", "link title");
}}
>
🔗
</button>
</div>
</div>
{/* Editor / Preview Content Area */}
{mode === "edit" ? (
<textarea
ref={textareaRef}
id="note-textarea"
className="note-textarea"
placeholder={t("notesPlaceholder")}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
/>
) : (
<div
className="markdown-body"
dangerouslySetInnerHTML={{ __html: renderMarkdownHtml() }}
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.classList.contains("markdown-checkbox-box")) {
const idxStr = target.getAttribute("data-idx");
if (idxStr) {
const idx = parseInt(idxStr.replace("cb-", ""), 10);
if (!isNaN(idx)) handlePreviewCheckboxToggle(idx);
}
} else if (e.detail === 2) {
// Double click anywhere to quickly switch to edit mode
setMode("edit");
}
}}
/>
)}
</div>
);
}
+74 -76
View File
@@ -2,6 +2,7 @@
import React, { useState, useEffect, useCallback, useRef } from "react"; import React, { useState, useEffect, useCallback, useRef } from "react";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { Task } from "./TaskList"; import { Task } from "./TaskList";
import { MarkdownNoteEditor } from "./MarkdownNoteEditor";
interface Props { interface Props {
task: Task; task: Task;
@@ -32,11 +33,11 @@ export function TaskDetail({
const [priority, setPriority] = useState(task.priority); const [priority, setPriority] = useState(task.priority);
const [newSubtitle, setNewSubtitle] = useState(""); const [newSubtitle, setNewSubtitle] = useState("");
const [subtasks, setSubtasks] = useState(task.children || []); const [subtasks, setSubtasks] = useState(task.children || []);
const [showSubtasks, setShowSubtasks] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentTaskId = useRef(task.id); const currentTaskId = useRef(task.id);
const noteRef = useRef<HTMLTextAreaElement>(null);
const priorityMap = [ const priorityMap = [
{ label: t("priorityNone"), color: "var(--text-tertiary)", icon: "" }, { label: t("priorityNone"), color: "var(--text-tertiary)", icon: "" },
@@ -113,32 +114,10 @@ export function TaskDetail({
[save] [save]
); );
// Insert markdown at cursor position const handleNoteChange = (newNote: string) => {
const insertAtCursor = useCallback( setNote(newNote);
(before: string, after = "", placeholder = "") => { debounceSave({ note: newNote });
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 handleToggleCompleted = useCallback( const handleToggleCompleted = useCallback(
async (newCompleted: boolean) => { async (newCompleted: boolean) => {
@@ -306,7 +285,7 @@ export function TaskDetail({
return ( return (
<aside className="detail-panel"> <aside className="detail-panel">
{/* Header */} {/* Header Bar */}
<div className="detail-header"> <div className="detail-header">
<button <button
className={`task-check-btn${task.completed ? " checked" : ""}`} className={`task-check-btn${task.completed ? " checked" : ""}`}
@@ -329,8 +308,8 @@ export function TaskDetail({
</button> </button>
</div> </div>
{/* Body */} {/* Main Body - Centered on Wide Memo Experience */}
<div className="detail-body"> <div className="detail-body" style={{ display: "flex", flexDirection: "column", height: "100%", gap: 14 }}>
{/* Title */} {/* Title */}
<textarea <textarea
id="detail-title" id="detail-title"
@@ -341,27 +320,32 @@ export function TaskDetail({
debounceSave({ title: e.target.value }); debounceSave({ title: e.target.value });
}} }}
placeholder={t("taskTitlePlaceholder")} placeholder={t("taskTitlePlaceholder")}
rows={2} rows={1}
style={{ style={{
textDecoration: task.completed ? "line-through" : "none", textDecoration: task.completed ? "line-through" : "none",
color: task.completed ? "var(--text-tertiary)" : "var(--text-primary)", color: task.completed ? "var(--text-tertiary)" : "var(--text-primary)",
fontSize: 20,
fontWeight: 700,
}} }}
/> />
{/* 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 */} {/* Priority */}
<div className="detail-section"> <div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<div className="detail-section-label">{t("priority")}</div> <span style={{ fontSize: 11, fontWeight: 600, color: "var(--text-tertiary)", textTransform: "uppercase" }}>{t("priority")}:</span>
<div className="priority-selector"> <div className="priority-selector" style={{ gap: 4 }}>
{priorityMap.map((p, i) => ( {priorityMap.map((p, i) => (
<button <button
key={i} key={i}
id={`priority-${i}`} id={`priority-${i}`}
className={`priority-btn${priority === i ? ` active-${["none", "low", "medium", "high"][i]}` : ""}`} className={`priority-btn${priority === i ? ` active-${["none", "low", "medium", "high"][i]}` : ""}`}
style={{ color: priority === i ? p.color : undefined }} style={{ color: priority === i ? p.color : undefined, padding: "3px 8px", fontSize: 11 }}
onClick={() => { onClick={() => {
setPriority(i); setPriority(i);
debounceSave({ priority: i }); debounceSave({ priority: i });
}} }}
type="button"
> >
{p.icon && <span style={{ color: p.color }}>{p.icon}</span>} {p.icon && <span style={{ color: p.color }}>{p.icon}</span>}
{p.label} {p.label}
@@ -370,10 +354,8 @@ export function TaskDetail({
</div> </div>
</div> </div>
{/* Due date */} {/* Due Date */}
<div className="detail-section"> <div style={{ display: "flex", alignItems: "center", gap: 6, marginLeft: "auto" }}>
<div className="detail-section-label">{t("dueDate")}</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input <input
id="due-date-input" id="due-date-input"
type="date" type="date"
@@ -383,15 +365,17 @@ export function TaskDetail({
setDueDate(e.target.value); setDueDate(e.target.value);
debounceSave({ dueDate: e.target.value || null }); debounceSave({ dueDate: e.target.value || null });
}} }}
style={{ width: "auto" }} style={{ width: "auto", padding: "3px 8px", fontSize: 12 }}
/> />
{dueDate && ( {dueDate && (
<button <button
className="btn btn-ghost btn-sm" className="btn btn-ghost btn-sm"
style={{ padding: "2px 6px", fontSize: 11 }}
onClick={() => { onClick={() => {
setDueDate(""); setDueDate("");
debounceSave({ dueDate: null }); debounceSave({ dueDate: null });
}} }}
type="button"
> >
{t("clear")} {t("clear")}
</button> </button>
@@ -399,51 +383,59 @@ export function TaskDetail({
</div> </div>
</div> </div>
{/* Notes — wide memo area */} {/* The Wide Adaptive Markdown Note Editor (Takes ALL Remaining Space) */}
<div className="detail-section" style={{ flex: 1 }}> <div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<div className="detail-section-label">{t("notes")}</div> <MarkdownNoteEditor
<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} value={note}
onChange={(e) => { onChange={handleNoteChange}
setNote(e.target.value); onSave={() => save(task.id, { note })}
debounceSave({ note: e.target.value });
}}
/> />
</div> </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> </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>
{subtasks.length > 0 && ( {subtasks.length > 0 && (
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}> <span style={{ fontSize: 11, color: "var(--text-tertiary)", fontWeight: 600 }}>
{completedCount}/{subtasks.length} {completedCount}/{subtasks.length}
</span> </span>
)} )}
</div> </div>
{showSubtasks && (
<div>
{subtasks.length > 0 && ( {subtasks.length > 0 && (
<div className="progress-bar" style={{ marginBottom: 10 }}> <div className="progress-bar" style={{ marginBottom: 8, height: 3 }}>
<div className="progress-bar-fill" style={{ width: `${progressPct}%` }} /> <div className="progress-bar-fill" style={{ width: `${progressPct}%` }} />
</div> </div>
)} )}
<div className="detail-subtasks"> <div className="detail-subtasks" style={{ maxHeight: 160, overflowY: "auto" }}>
{subtasks.map((sub) => ( {subtasks.map((sub) => (
<div <div
key={sub.id} key={sub.id}
@@ -454,6 +446,7 @@ export function TaskDetail({
className={`subtask-check-btn${sub.completed ? " checked" : ""}`} className={`subtask-check-btn${sub.completed ? " checked" : ""}`}
onClick={() => toggleSubtask(sub)} onClick={() => toggleSubtask(sub)}
aria-label="Toggle subtask" aria-label="Toggle subtask"
type="button"
/> />
<span className="detail-subtask-title">{sub.title}</span> <span className="detail-subtask-title">{sub.title}</span>
<button <button
@@ -461,6 +454,7 @@ export function TaskDetail({
style={{ width: 20, height: 20, opacity: 0.4 }} style={{ width: 20, height: 20, opacity: 0.4 }}
onClick={() => deleteSubtask(sub.id)} onClick={() => deleteSubtask(sub.id)}
aria-label="Delete subtask" aria-label="Delete subtask"
type="button"
> >
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <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" /> <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
@@ -468,12 +462,12 @@ export function TaskDetail({
</button> </button>
</div> </div>
))} ))}
<div className="add-subtask-row"> <div className="add-subtask-row" style={{ padding: "4px 8px" }}>
<span style={{ fontSize: 16, lineHeight: 1, color: "var(--accent)" }}>+</span> <span style={{ fontSize: 15, lineHeight: 1, color: "var(--accent)" }}>+</span>
<input <input
id="add-subtask-input" id="add-subtask-input"
placeholder={t("addSubtaskPlaceholder")} placeholder={t("addSubtaskPlaceholder")}
style={{ flex: 1, background: "none", fontSize: 13, color: "var(--text-primary)" }} style={{ flex: 1, background: "none", fontSize: 12.5, color: "var(--text-primary)" }}
value={newSubtitle} value={newSubtitle}
onChange={(e) => setNewSubtitle(e.target.value)} onChange={(e) => setNewSubtitle(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -484,22 +478,26 @@ export function TaskDetail({
}} }}
/> />
{newSubtitle.trim() && ( {newSubtitle.trim() && (
<button className="btn btn-primary btn-sm" id="add-subtask-btn" onClick={addSubtask}> <button className="btn btn-primary btn-sm" id="add-subtask-btn" onClick={addSubtask} type="button">
{t("add")} {t("add")}
</button> </button>
)} )}
</div> </div>
</div> </div>
</div> </div>
)}
</div>
{/* Metadata */} {/* Footer Timestamp */}
<div style={{ fontSize: 11, color: "var(--text-tertiary)", paddingTop: 8, borderTop: "1px solid var(--border)" }}> <div style={{ fontSize: 11, color: "var(--text-tertiary)", display: "flex", justifyContent: "space-between" }}>
{t("created")}{" "} <span>
{t("created")}:{" "}
{new Date(task.createdAt).toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", { {new Date(task.createdAt).toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
})} })}
</span>
</div> </div>
</div> </div>
</aside> </aside>
+77
View File
@@ -0,0 +1,77 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import { useI18n, Language } from "@/lib/i18n";
const LANGUAGES: { code: Language; label: string; flag: string }[] = [
{ code: "en", label: "English", flag: "🇺🇸" },
{ code: "ko", label: "한국어", flag: "🇰🇷" },
{ code: "ja", label: "日本語", flag: "🇯🇵" },
];
export function LanguageSelector() {
const { lang, setLang } = useI18n();
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const current = LANGUAGES.find((l) => l.code === lang) || LANGUAGES[0];
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
if (open) document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
return (
<div className="lang-selector-wrap" ref={wrapRef}>
<button
className="lang-btn"
onClick={() => setOpen((p) => !p)}
aria-label="Select Language"
type="button"
>
<span style={{ fontSize: 13, lineHeight: 1 }}>🌐</span>
<span>{current.code.toUpperCase()}</span>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
style={{ transform: open ? "rotate(180deg)" : "rotate(0deg)", transition: "transform 0.15s" }}
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{open && (
<div className="lang-dropdown">
{LANGUAGES.map((item) => (
<div
key={item.code}
className={`lang-option${lang === item.code ? " selected" : ""}`}
onClick={() => {
setLang(item.code);
setOpen(false);
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span>{item.flag}</span>
<span>{item.label}</span>
</div>
{lang === item.code && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</div>
))}
</div>
)}
</div>
);
}