Feature: Add rich interactive Markdown renderer, wide adaptive memo panel, and custom language popover
This commit is contained in:
Vendored
+2
-2
@@ -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.
|
||||||
|
|||||||
Generated
+1112
-9
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+143
-145
@@ -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,39 +320,42 @@ 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,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Priority */}
|
{/* Compact Metadata Strip (Priority & Due Date in a single line) */}
|
||||||
<div className="detail-section">
|
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap", paddingBottom: 4, borderBottom: "1px solid var(--border)" }}>
|
||||||
<div className="detail-section-label">{t("priority")}</div>
|
{/* Priority */}
|
||||||
<div className="priority-selector">
|
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||||
{priorityMap.map((p, i) => (
|
<span style={{ fontSize: 11, fontWeight: 600, color: "var(--text-tertiary)", textTransform: "uppercase" }}>{t("priority")}:</span>
|
||||||
<button
|
<div className="priority-selector" style={{ gap: 4 }}>
|
||||||
key={i}
|
{priorityMap.map((p, i) => (
|
||||||
id={`priority-${i}`}
|
<button
|
||||||
className={`priority-btn${priority === i ? ` active-${["none", "low", "medium", "high"][i]}` : ""}`}
|
key={i}
|
||||||
style={{ color: priority === i ? p.color : undefined }}
|
id={`priority-${i}`}
|
||||||
onClick={() => {
|
className={`priority-btn${priority === i ? ` active-${["none", "low", "medium", "high"][i]}` : ""}`}
|
||||||
setPriority(i);
|
style={{ color: priority === i ? p.color : undefined, padding: "3px 8px", fontSize: 11 }}
|
||||||
debounceSave({ priority: i });
|
onClick={() => {
|
||||||
}}
|
setPriority(i);
|
||||||
>
|
debounceSave({ priority: i });
|
||||||
{p.icon && <span style={{ color: p.color }}>{p.icon}</span>}
|
}}
|
||||||
{p.label}
|
type="button"
|
||||||
</button>
|
>
|
||||||
))}
|
{p.icon && <span style={{ color: p.color }}>{p.icon}</span>}
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</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,107 +383,121 @@ 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">
|
value={note}
|
||||||
<div className="note-editor-toolbar">
|
onChange={handleNoteChange}
|
||||||
<button className="note-toolbar-btn" title={t("bold")} onClick={() => insertAtCursor("**", "**", "bold")}>B</button>
|
onSave={() => save(task.id, { note })}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sub-tasks */}
|
{/* Sub-tasks Section (Collapsible Accordion at Bottom) */}
|
||||||
<div className="detail-section">
|
<div style={{ borderTop: "1px solid var(--border)", paddingTop: 10 }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
|
<div
|
||||||
<div className="detail-section-label">{t("subtasks")}</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 && (
|
{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>
|
||||||
|
|
||||||
{subtasks.length > 0 && (
|
{showSubtasks && (
|
||||||
<div className="progress-bar" style={{ marginBottom: 10 }}>
|
<div>
|
||||||
<div className="progress-bar-fill" style={{ width: `${progressPct}%` }} />
|
{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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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>
|
</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>
|
||||||
{new Date(task.createdAt).toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", {
|
{t("created")}:{" "}
|
||||||
year: "numeric",
|
{new Date(task.createdAt).toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", {
|
||||||
month: "short",
|
year: "numeric",
|
||||||
day: "numeric",
|
month: "short",
|
||||||
})}
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user