feat: enhance DND nesting & promotion, isolated tags API, fix completion bounce UI, add Admin i18n switcher, refine typography & polish UX (v0.7.2)
Build and Push Docker Image / build-and-push (push) Successful in 9m59s

This commit is contained in:
2026-08-22 10:01:05 +09:00
parent 0f44090210
commit 8e5b9238e2
10 changed files with 189 additions and 76 deletions
+5
View File
@@ -15,6 +15,11 @@
- `src/middleware.ts`: Route-level auth and admin role verification guard.
- **Completed So Far:**
- Standardized user-isolated Tag database REST API (`/api/tags`) ensuring private custom tags per account, preventing cross-user data leakage and providing live tag autocomplete (v0.7.2).
- Streamlined Task Drag-and-Drop (DND) interaction model: hovering over a task nests as a subtask, dropping on blank list background elevates to root level, and prevents duplicate/looping trees (v0.7.2).
- Fixed completed task toggle button layout shift and centered list loader animations (v0.7.2).
- Constrained inline title edit double-click trigger zone strictly to task title text span (v0.7.2).
- Integrated i18n localization with language switcher in Admin console (`/admin`), and upgraded placeholders/dialogs across EN/KO/JA to polite, natural phrasing (v0.7.2).
- Standardized complete localization across all 3 supported languages (EN, KO, JA) without partial fallback leaks; translated all Settings Modal tabs (Profile, Preferences, Labs, Sync Integrations, Admin) and interactive guides (v0.7.1).
- Resolved list header rename layout shift bug where editing list title pushed the completed items toggle button (v0.7.1).
- Enhanced Markdown Note Editor preview/editor canvas alignment and unified description typography heights (v0.7.1).
+2 -1
View File
@@ -85,7 +85,8 @@ src/
| `checkpoint-v0.6.3` | 노션 스타일 6도트 드래그 핸들(`⋮⋮`) & DND 태스크 재정렬, F2 단축키 인라인 제목 수정, 우측 호버 서브태스크 추가 버튼, 6초 플로팅 인터랙티브 실행 취소(Undo) 토스트 배너 탑재, 휴지통 및 로컬 스토리지 트리 중복 버그 수정 | 2026-08-21 |
| `checkpoint-v0.6.4` | i18n 모듈러 사전 아키텍처(`types.ts`, `locales/en.ts`, `locales/ko.ts`, `locales/ja.ts`) 분리 및 번역 완성도 100%, 어드민 콘솔(`/admin`) 데모 프리뷰 및 멀티유저 게이트 연동, 설정 모달 Admin 탭 정보 고도화, 사이드바 프로필 메뉴 내 Admin Console 진입점 추가 | 2026-08-21 |
| `checkpoint-v0.7.0` | 좌측 사이드바 UI/UX 전면 리디자인(브랜드 로고 SVG, 중앙정렬 매트 검색창, 바닥 밀착 프로필), 뷰 전환(리스트/칸반) 미니멀 원터치 토글 및 실험실 연동 버그 수정, 휴지통 카운트 실시간 동기화 & 기본화면 튕김 버그 수정, 첫 가입 계정 자동 ADMIN 승격 및 실시간 Admin API(/api/admin/users, /api/admin/stats) 구축, 서브태스크 인라인 추가 및 체크박스 Optimistic UI 실시간 렌더링, 메모장 기본 Preview 모드 & 원터치 토글, N-depth 계층형 DND 드래그 앤 드롭 재정렬 및 재귀 하위 태스크 캐스케이드 삭제 지원 | 2026-08-21 |
| `checkpoint-v0.7.1` | **(현재 최신)** 설정 모달(5개 탭 및 가이드/프리뷰 전면) i18n 3개국어(EN/KO/JA) 100% 완전 번역 및 언어 로컬스토리지 영구 기억/자동 감지 적용, 태스크 목록 제목 인라인 수정 시 완료항목 토글 버튼 밀림 flexbox 레이아웃 버그 수정, 메모장 프리뷰/에디터 높이 및 플레이스홀더 타이포그래피 일관성 보정, 우측 상세 패널 우선순위 브라우저 기본 드롭다운을 중앙 퀵애드와 동일한 세련된 컬러 팝오버 칩 UI로 통일 | 2026-08-22 |
| `checkpoint-v0.7.1` | 설정 모달(5개 탭 및 가이드/프리뷰 전면) i18n 3개국어(EN/KO/JA) 100% 완전 번역 및 언어 로컬스토리지 영구 기억/자동 감지 적용, 태스크 목록 제목 인라인 수정 시 완료항목 토글 버튼 밀림 flexbox 레이아웃 버그 수정, 메모장 프리뷰/에디터 높이 및 플레이스홀더 타이포그래피 일관성 보정, 우측 상세 패널 우선순위 브라우저 기본 드롭다운을 중앙 퀵애드와 동일한 세련된 컬러 팝오버 칩 UI로 통일 | 2026-08-22 |
| `checkpoint-v0.7.2` | **(현재 최신)** 태그(Tag) 사용자 계정별 데이터베이스 격리 API 구축(/api/tags), DND 드래그앤드롭 상위 태스크 하위 편입 단일화 및 빈 배경 드롭 1단계 승격 지원, 완료항목 토글 시 로딩 UI 밀림 버그 수정, 인라인 제목 수정 더블클릭 영역 텍스트 한정, 어드민 콘솔 다국어(i18n) 언어 선택기 지원, 친절하고 간결한 톤앤매너 플레이스홀더 전면 적용 | 2026-08-22 |
---
+7 -5
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { useSession } from "next-auth/react";
import { getDemoStore } from "@/lib/mockData";
import { useI18n } from "@/lib/i18n";
import { LanguageSelector } from "@/components/ui/LanguageSelector";
interface AdminUser {
id: string;
@@ -47,7 +48,7 @@ const INITIAL_DEMO_USERS: AdminUser[] = [
export default function AdminPage() {
const { data: session, status } = useSession();
const { t } = useI18n();
const { t, lang } = useI18n();
const isDemo = status === "unauthenticated" || !session;
@@ -232,18 +233,19 @@ export default function AdminPage() {
</div>
</div>
<div style={{ display: "flex", gap: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<LanguageSelector />
{isDemo ? (
<Link href="/demo" className="btn btn-ghost btn-sm">
Back to Demo
{lang === "ko" ? "데모로 돌아가기" : lang === "ja" ? "デモに戻る" : "Back to Demo"}
</Link>
) : (
<button type="button" className="btn btn-ghost btn-sm" onClick={fetchAdminData}>
🔄 Refresh Data
🔄 {lang === "ko" ? "새로고침" : lang === "ja" ? "更新" : "Refresh"}
</button>
)}
<Link href="/" className="btn btn-primary btn-sm">
🚀 Open Dashboard
🚀 {lang === "ko" ? "할 일 대시보드" : lang === "ja" ? "タスク一覧" : "Open Dashboard"}
</Link>
</div>
</div>
+4 -2
View File
@@ -5,6 +5,7 @@ import { TaskList, Task, List, User } from "../tasks/TaskList";
import { useUserPrefs, usePrefsStyle } from "@/lib/useUserPrefs";
import { TaskDetail } from "../tasks/TaskDetail";
import { CommandPalette } from "../ui/CommandPalette";
import { useI18n } from "@/lib/i18n";
import {
getDemoStore,
saveDemoStore,
@@ -58,6 +59,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
const [showCompleted, setShowCompleted] = useState(false);
const [cmdPaletteOpen, setCmdPaletteOpen] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const { t } = useI18n();
// Global user preferences (reactive)
const { prefs, updatePrefs } = useUserPrefs();
@@ -677,12 +679,12 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
<div className="undo-toast-content">
<span className="undo-toast-icon">🗑</span>
<span className="undo-toast-msg">
<strong>{undoToast.title || "할 일"}</strong>() .
<strong>{undoToast.title || t("tasks")}</strong> {t("taskUndoHint")}
</span>
</div>
<div className="undo-toast-actions">
<button type="button" className="undo-toast-btn" onClick={handleUndoDelete}>
{t("undoDelete")}
</button>
<button
type="button"
+21
View File
@@ -101,6 +101,27 @@ export function KanbanView({
</span>
)}
{task.tags && task.tags.length > 0 && task.tags.map((tg) => {
const tagName = typeof tg === "string" ? tg : tg.tag?.name;
const tagKey = typeof tg === "string" ? tg : tg.tag?.id || tg.tag?.name;
if (!tagName) return null;
return (
<span
key={tagKey}
style={{
fontSize: 10,
fontWeight: 600,
padding: "2px 6px",
borderRadius: "var(--radius-sm)",
background: "rgba(59, 130, 246, 0.12)",
color: "#3B82F6",
}}
>
#{tagName}
</span>
);
})}
{subtaskCount > 0 && (
<span
style={{
+42 -3
View File
@@ -40,12 +40,30 @@ export function TaskDetail({
const [dueDate, setDueDate] = useState(task.dueDate ? task.dueDate.split("T")[0] : "");
const [priority, setPriority] = useState(task.priority);
const [tags, setTags] = useState<{ tag: Tag }[]>(task.tags || []);
const [allAvailableTags, setAllAvailableTags] = useState<MockTag[]>(() => (typeof window !== "undefined" ? getCustomTags() : []));
const [allAvailableTags, setAllAvailableTags] = useState<MockTag[]>(() => (isDemo && typeof window !== "undefined" ? getCustomTags() : []));
const [showTagPicker, setShowTagPicker] = useState(false);
const [showListPicker, setShowListPicker] = useState(false);
const [showPriorityPicker, setShowPriorityPicker] = useState(false);
const [newTagName, setNewTagName] = useState("");
// Load user's actual tags in non-demo mode
useEffect(() => {
if (isDemo) {
if (typeof window !== "undefined") {
setAllAvailableTags(getCustomTags());
}
} else {
fetch("/api/tags")
.then((res) => (res.ok ? res.json() : []))
.then((data) => {
if (Array.isArray(data)) {
setAllAvailableTags(data);
}
})
.catch((err) => console.error("Failed to fetch user tags", err));
}
}, [isDemo]);
// Modular Blocks Customization via global prefs
const { prefs, updatePrefs } = useUserPrefs();
const blockOrder = prefs.detailBlockOrder;
@@ -244,13 +262,16 @@ export function TaskDetail({
save(task.id, { tags: nextTags });
};
const addCustomTag = () => {
const addCustomTag = async () => {
const trimmed = newTagName.trim().replace(/^#/, "");
if (!trimmed) return;
const randomColor = ["#4B7BF5", "#10B981", "#EF4444", "#F59E0B", "#8B5CF6"][Math.floor(Math.random() * 5)];
if (isDemo) {
const newTag: MockTag = {
id: "tag-" + Date.now(),
name: trimmed,
color: ["#4B7BF5", "#10B981", "#EF4444", "#F59E0B", "#8B5CF6"][Math.floor(Math.random() * 5)],
color: randomColor,
};
const updatedAvailable = [...allAvailableTags, newTag];
setAllAvailableTags(updatedAvailable);
@@ -260,6 +281,24 @@ export function TaskDetail({
}
toggleTag(newTag);
setNewTagName("");
return;
}
try {
const res = await fetch("/api/tags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: trimmed, color: randomColor }),
});
if (res.ok) {
const createdTag = await res.json();
setAllAvailableTags((prev) => [...prev.filter((tItem) => tItem.id !== createdTag.id), createdTag]);
toggleTag(createdTag);
setNewTagName("");
}
} catch (err) {
console.error("Failed to create tag", err);
}
};
const addSubtask = useCallback(async () => {
+70 -27
View File
@@ -44,7 +44,7 @@ interface TaskItemProps {
isTrashMode?: boolean;
onRestore?: (id: string) => void;
onPermanentDelete?: (id: string) => void;
onDragTask?: (draggedId: string, targetId: string, position: "before" | "after" | "inside") => void;
onDragTask?: (draggedId: string, targetId: string, position: "before" | "after" | "inside" | "root") => void;
}
// Recursive Task Tree Item (Supports 1st, 2nd, 3rd, N-level sub-tasks seamlessly)
@@ -157,12 +157,12 @@ function RecursiveTaskItem({
touchStartX.current = null;
};
const [dragOverPos, setDragOverPos] = useState<"top" | "bottom" | "inside" | null>(null);
const [isDragOver, setIsDragOver] = useState(false);
return (
<div style={{ paddingLeft: depth > 0 ? 24 : 0, position: "relative" }}>
<div
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}${dragOverPos ? ` drag-over-${dragOverPos}` : ""}`}
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}${isDragOver ? " drag-over-nested" : ""}`}
onClick={() => onSelect(task)}
tabIndex={0}
onKeyDown={(e) => {
@@ -183,29 +183,25 @@ function RecursiveTaskItem({
onDragOver={(e) => {
if (isTrashMode) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "move";
const rect = e.currentTarget.getBoundingClientRect();
const relY = e.clientY - rect.top;
const h = rect.height;
if (relY < h * 0.25) {
setDragOverPos("top");
} else if (relY > h * 0.75) {
setDragOverPos("bottom");
} else {
setDragOverPos("inside");
}
if (!isDragOver) setIsDragOver(true);
}}
onDragLeave={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}}
onDragLeave={() => setDragOverPos(null)}
onDrop={(e) => {
if (isTrashMode) return;
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const draggedId = e.dataTransfer.getData("text/plain");
if (draggedId && draggedId !== task.id && onDragTask) {
const pos: "before" | "after" | "inside" =
dragOverPos === "top" ? "before" : dragOverPos === "bottom" ? "after" : "inside";
onDragTask(draggedId, task.id, pos);
// Unified interaction: drop on task to nest as subtask
onDragTask(draggedId, task.id, "inside");
}
setDragOverPos(null);
}}
id={`task-${task.id}`}
style={{
@@ -213,9 +209,9 @@ function RecursiveTaskItem({
marginLeft: depth > 0 ? 8 : 0,
transform: `translateX(${swipeOffset}px)`,
transition: swipeOffset === 0 ? "transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)" : "none",
outline: dragOverPos === "inside" ? "2px dashed var(--accent)" : undefined,
outline: isDragOver ? "2px dashed var(--accent)" : undefined,
outlineOffset: -2,
background: dragOverPos === "inside" ? "var(--accent-light, rgba(75, 123, 245, 0.12))" : undefined,
background: isDragOver ? "var(--accent-light, rgba(75, 123, 245, 0.12))" : undefined,
}}
>
{/* Notion-style 6-dot Drag Handle */}
@@ -307,7 +303,8 @@ function RecursiveTaskItem({
style={{ padding: "2px 6px", fontSize: depth > 0 ? 13 : 14, fontWeight: 500, height: 26, width: "100%" }}
/>
) : (
<div
<div style={{ display: "inline-flex", alignItems: "center", maxWidth: "100%" }}>
<span
className="task-title"
onDoubleClick={(e) => {
if (isTrashMode) return;
@@ -315,9 +312,15 @@ function RecursiveTaskItem({
setEditingTitle(true);
}}
title="Double click or press F2 to edit"
style={{ fontSize: depth > 0 ? 13 : 14, fontWeight: depth === 0 ? 600 : 500 }}
style={{
fontSize: depth > 0 ? 13 : 14,
fontWeight: depth === 0 ? 600 : 500,
cursor: "text",
display: "inline-block",
}}
>
{task.title}
</span>
</div>
)}
@@ -804,9 +807,9 @@ export function TaskList({
};
// Robust Tree-aware reordering function (1st, 2nd, 3rd depth task reordering, promoting to top-level, and demoting into subtask)
const handleReorderTasks = useCallback((draggedId: string, targetId: string, position: "before" | "after" | "inside") => {
const handleReorderTasks = useCallback((draggedId: string, targetId: string, position: "before" | "after" | "inside" | "root") => {
// Prevent dropping onto itself or into its own subtree (which causes loops/disappearing tasks)
if (draggedId === targetId) return;
if (draggedId === targetId && position !== "root") return;
setTasks((prev) => {
if (isDescendantNode(prev, draggedId, targetId)) {
@@ -837,6 +840,25 @@ export function TaskList({
let determinedParentId: string | null = null;
if (position === "root") {
// Promote directly to top-level (1st depth root task)
determinedParentId = null;
const nodeAsRoot: Task = { ...safeExtractedNode, parentId: null };
const finalTree = [...treeWithoutDragged, nodeAsRoot];
if (isDemo && typeof window !== "undefined") {
const store = getDemoStore();
saveDemoStore(store.lists, finalTree as unknown as MockTask[]);
} else {
fetch(`/api/tasks/${draggedId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parentId: null }),
}).catch((err) => console.error("Failed to promote task to root", err));
}
return finalTree;
}
// 2. Insert into the target position
if (position === "inside") {
determinedParentId = targetId;
@@ -1100,17 +1122,38 @@ export function TaskList({
/>
</div>
) : (
/* Task list container */
/* Task list container with background drop support for promoting subtask to 1st depth */
<div
className="task-list-container"
onDragOver={(e) => {
if (e.target === e.currentTarget) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
}
}}
onDrop={(e) => {
if (e.target === e.currentTarget) {
e.preventDefault();
const draggedId = e.dataTransfer.getData("text/plain");
if (draggedId) {
// Promote to 1st depth root task
handleReorderTasks(draggedId, "", "root");
}
}
}}
onContextMenu={(e) => {
if (e.target === e.currentTarget) {
e.preventDefault();
}
}}
>
{loading && (
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>{t("loading")}</div>
{loading && tasks.length === 0 && (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: "40px", color: "var(--text-tertiary)" }}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 10 }}>
<div style={{ width: 24, height: 24, border: "2px solid var(--border)", borderTopColor: "var(--accent)", borderRadius: "50%", animation: "spin 0.8s linear infinite" }} />
<span style={{ fontSize: 13 }}>{t("loading")}</span>
</div>
</div>
)}
{!loading && tasks.length === 0 && (
<div className="task-list-empty">
+7 -7
View File
@@ -155,16 +155,16 @@ export const en: TranslationDict = {
hideDone: "Hide Done",
showDone: "Show Done",
completedSection: "Completed",
addTaskPlaceholder: "Add a task, press Enter...",
addTaskPlaceholder: "What would you like to accomplish?",
add: "Add",
noTasksYet: "No tasks in this list yet",
noTasksYet: "No tasks in this list",
selectListToStart: "Select a list to get started",
loading: "Loading...",
loading: "Loading tasks...",
today: "Today",
tomorrow: "Tomorrow",
// Task Detail
taskTitlePlaceholder: "Task title...",
taskTitlePlaceholder: "Task title",
priority: "Priority",
priorityNone: "None",
priorityLow: "Low",
@@ -173,10 +173,10 @@ export const en: TranslationDict = {
dueDate: "Due date",
clear: "Clear",
notes: "Notes",
notesPlaceholder: "Add detailed notes, markdown supported...",
notesPlaceholder: "Add details, markdown supported...",
subtasks: "Subtasks",
addSubtaskPlaceholder: "Add a subtask, press Enter...",
deleteTaskConfirm: "Are you sure you want to delete this task?",
addSubtaskPlaceholder: "Add a subtask...",
deleteTaskConfirm: "Delete this task?",
autoSaved: "Saved",
saving: "Saving...",
created: "Created",
+7 -7
View File
@@ -155,28 +155,28 @@ export const ja: TranslationDict = {
hideDone: "完了項目を非表示",
showDone: "完了項目を表示",
completedSection: "完了済み",
addTaskPlaceholder: "新しいタスクを入力してEnter...",
addTaskPlaceholder: "新しいタスクを書き留めましょう",
add: "追加",
noTasksYet: "このリストにはタスクがありません",
selectListToStart: "リストを選択して始めましょう",
selectListToStart: "リストを選択して開始してください",
loading: "読み込み中...",
today: "今日",
tomorrow: "明日",
// Task Detail
taskTitlePlaceholder: "タスク名を入力...",
taskTitlePlaceholder: "タスク名",
priority: "優先度",
priorityNone: "なし",
priorityLow: "低",
priorityMedium: "中",
priorityHigh: "高",
dueDate: "期日",
dueDate: "期日",
clear: "クリア",
notes: "メモ",
notesPlaceholder: "詳細なメモを入力 (Markdown対応)...",
notesPlaceholder: "詳細を入力 (Markdown対応)...",
subtasks: "サブタスク",
addSubtaskPlaceholder: "サブタスクを入力してEnter...",
deleteTaskConfirm: "このタスクを削除してもよろしいですか?",
addSubtaskPlaceholder: "サブタスクを追加...",
deleteTaskConfirm: "タスクを削除しすか?",
autoSaved: "保存済み",
saving: "保存中...",
created: "作成日時",
+4 -4
View File
@@ -155,7 +155,7 @@ export const ko: TranslationDict = {
hideDone: "완료항목 숨기기",
showDone: "완료항목 표시",
completedSection: "완료됨",
addTaskPlaceholder: "새 할 일 입력 후 Enter...",
addTaskPlaceholder: "새로운 할 일을 적어볼까요?",
add: "추가",
noTasksYet: "이 목록에 등록된 할 일이 없습니다",
selectListToStart: "시작할 목록을 선택하세요",
@@ -164,7 +164,7 @@ export const ko: TranslationDict = {
tomorrow: "내일",
// Task Detail
taskTitlePlaceholder: "작업 제목 입력...",
taskTitlePlaceholder: "할 일 제목",
priority: "우선순위",
priorityNone: "없음",
priorityLow: "낮음",
@@ -175,8 +175,8 @@ export const ko: TranslationDict = {
notes: "메모",
notesPlaceholder: "상세 내용을 입력하세요 (마크다운 지원)...",
subtasks: "하위작업",
addSubtaskPlaceholder: "하위작업 추가 후 Enter...",
deleteTaskConfirm: "이 작업을 정말 삭제하시겠습니까?",
addSubtaskPlaceholder: "하위 작업을 적어보세요...",
deleteTaskConfirm: "태스크를 삭제하시겠습니까?",
autoSaved: "저장됨",
saving: "저장 중...",
created: "생성일",