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. - `src/middleware.ts`: Route-level auth and admin role verification guard.
- **Completed So Far:** - **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). - 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). - 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). - 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.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.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.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 { useSession } from "next-auth/react";
import { getDemoStore } from "@/lib/mockData"; import { getDemoStore } from "@/lib/mockData";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { LanguageSelector } from "@/components/ui/LanguageSelector";
interface AdminUser { interface AdminUser {
id: string; id: string;
@@ -47,7 +48,7 @@ const INITIAL_DEMO_USERS: AdminUser[] = [
export default function AdminPage() { export default function AdminPage() {
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const { t } = useI18n(); const { t, lang } = useI18n();
const isDemo = status === "unauthenticated" || !session; const isDemo = status === "unauthenticated" || !session;
@@ -232,18 +233,19 @@ export default function AdminPage() {
</div> </div>
</div> </div>
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<LanguageSelector />
{isDemo ? ( {isDemo ? (
<Link href="/demo" className="btn btn-ghost btn-sm"> <Link href="/demo" className="btn btn-ghost btn-sm">
Back to Demo {lang === "ko" ? "데모로 돌아가기" : lang === "ja" ? "デモに戻る" : "Back to Demo"}
</Link> </Link>
) : ( ) : (
<button type="button" className="btn btn-ghost btn-sm" onClick={fetchAdminData}> <button type="button" className="btn btn-ghost btn-sm" onClick={fetchAdminData}>
🔄 Refresh Data 🔄 {lang === "ko" ? "새로고침" : lang === "ja" ? "更新" : "Refresh"}
</button> </button>
)} )}
<Link href="/" className="btn btn-primary btn-sm"> <Link href="/" className="btn btn-primary btn-sm">
🚀 Open Dashboard 🚀 {lang === "ko" ? "할 일 대시보드" : lang === "ja" ? "タスク一覧" : "Open Dashboard"}
</Link> </Link>
</div> </div>
</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 { useUserPrefs, usePrefsStyle } from "@/lib/useUserPrefs";
import { TaskDetail } from "../tasks/TaskDetail"; import { TaskDetail } from "../tasks/TaskDetail";
import { CommandPalette } from "../ui/CommandPalette"; import { CommandPalette } from "../ui/CommandPalette";
import { useI18n } from "@/lib/i18n";
import { import {
getDemoStore, getDemoStore,
saveDemoStore, saveDemoStore,
@@ -58,6 +59,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
const [showCompleted, setShowCompleted] = useState(false); const [showCompleted, setShowCompleted] = useState(false);
const [cmdPaletteOpen, setCmdPaletteOpen] = useState(false); const [cmdPaletteOpen, setCmdPaletteOpen] = useState(false);
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const { t } = useI18n();
// Global user preferences (reactive) // Global user preferences (reactive)
const { prefs, updatePrefs } = useUserPrefs(); const { prefs, updatePrefs } = useUserPrefs();
@@ -677,12 +679,12 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
<div className="undo-toast-content"> <div className="undo-toast-content">
<span className="undo-toast-icon">🗑</span> <span className="undo-toast-icon">🗑</span>
<span className="undo-toast-msg"> <span className="undo-toast-msg">
<strong>{undoToast.title || "할 일"}</strong>() . <strong>{undoToast.title || t("tasks")}</strong> {t("taskUndoHint")}
</span> </span>
</div> </div>
<div className="undo-toast-actions"> <div className="undo-toast-actions">
<button type="button" className="undo-toast-btn" onClick={handleUndoDelete}> <button type="button" className="undo-toast-btn" onClick={handleUndoDelete}>
{t("undoDelete")}
</button> </button>
<button <button
type="button" type="button"
+21
View File
@@ -101,6 +101,27 @@ export function KanbanView({
</span> </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 && ( {subtaskCount > 0 && (
<span <span
style={{ style={{
+42 -3
View File
@@ -40,12 +40,30 @@ export function TaskDetail({
const [dueDate, setDueDate] = useState(task.dueDate ? task.dueDate.split("T")[0] : ""); const [dueDate, setDueDate] = useState(task.dueDate ? task.dueDate.split("T")[0] : "");
const [priority, setPriority] = useState(task.priority); const [priority, setPriority] = useState(task.priority);
const [tags, setTags] = useState<{ tag: Tag }[]>(task.tags || []); 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 [showTagPicker, setShowTagPicker] = useState(false);
const [showListPicker, setShowListPicker] = useState(false); const [showListPicker, setShowListPicker] = useState(false);
const [showPriorityPicker, setShowPriorityPicker] = useState(false); const [showPriorityPicker, setShowPriorityPicker] = useState(false);
const [newTagName, setNewTagName] = useState(""); 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 // Modular Blocks Customization via global prefs
const { prefs, updatePrefs } = useUserPrefs(); const { prefs, updatePrefs } = useUserPrefs();
const blockOrder = prefs.detailBlockOrder; const blockOrder = prefs.detailBlockOrder;
@@ -244,13 +262,16 @@ export function TaskDetail({
save(task.id, { tags: nextTags }); save(task.id, { tags: nextTags });
}; };
const addCustomTag = () => { const addCustomTag = async () => {
const trimmed = newTagName.trim().replace(/^#/, ""); const trimmed = newTagName.trim().replace(/^#/, "");
if (!trimmed) return; if (!trimmed) return;
const randomColor = ["#4B7BF5", "#10B981", "#EF4444", "#F59E0B", "#8B5CF6"][Math.floor(Math.random() * 5)];
if (isDemo) {
const newTag: MockTag = { const newTag: MockTag = {
id: "tag-" + Date.now(), id: "tag-" + Date.now(),
name: trimmed, name: trimmed,
color: ["#4B7BF5", "#10B981", "#EF4444", "#F59E0B", "#8B5CF6"][Math.floor(Math.random() * 5)], color: randomColor,
}; };
const updatedAvailable = [...allAvailableTags, newTag]; const updatedAvailable = [...allAvailableTags, newTag];
setAllAvailableTags(updatedAvailable); setAllAvailableTags(updatedAvailable);
@@ -260,6 +281,24 @@ export function TaskDetail({
} }
toggleTag(newTag); toggleTag(newTag);
setNewTagName(""); 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 () => { const addSubtask = useCallback(async () => {
+70 -27
View File
@@ -44,7 +44,7 @@ interface TaskItemProps {
isTrashMode?: boolean; isTrashMode?: boolean;
onRestore?: (id: string) => void; onRestore?: (id: string) => void;
onPermanentDelete?: (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) // Recursive Task Tree Item (Supports 1st, 2nd, 3rd, N-level sub-tasks seamlessly)
@@ -157,12 +157,12 @@ function RecursiveTaskItem({
touchStartX.current = null; touchStartX.current = null;
}; };
const [dragOverPos, setDragOverPos] = useState<"top" | "bottom" | "inside" | null>(null); const [isDragOver, setIsDragOver] = useState(false);
return ( return (
<div style={{ paddingLeft: depth > 0 ? 24 : 0, position: "relative" }}> <div style={{ paddingLeft: depth > 0 ? 24 : 0, position: "relative" }}>
<div <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)} onClick={() => onSelect(task)}
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -183,29 +183,25 @@ function RecursiveTaskItem({
onDragOver={(e) => { onDragOver={(e) => {
if (isTrashMode) return; if (isTrashMode) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "move"; e.dataTransfer.dropEffect = "move";
const rect = e.currentTarget.getBoundingClientRect(); if (!isDragOver) setIsDragOver(true);
const relY = e.clientY - rect.top; }}
const h = rect.height; onDragLeave={(e) => {
if (relY < h * 0.25) { e.preventDefault();
setDragOverPos("top"); e.stopPropagation();
} else if (relY > h * 0.75) { setIsDragOver(false);
setDragOverPos("bottom");
} else {
setDragOverPos("inside");
}
}} }}
onDragLeave={() => setDragOverPos(null)}
onDrop={(e) => { onDrop={(e) => {
if (isTrashMode) return; if (isTrashMode) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const draggedId = e.dataTransfer.getData("text/plain"); const draggedId = e.dataTransfer.getData("text/plain");
if (draggedId && draggedId !== task.id && onDragTask) { if (draggedId && draggedId !== task.id && onDragTask) {
const pos: "before" | "after" | "inside" = // Unified interaction: drop on task to nest as subtask
dragOverPos === "top" ? "before" : dragOverPos === "bottom" ? "after" : "inside"; onDragTask(draggedId, task.id, "inside");
onDragTask(draggedId, task.id, pos);
} }
setDragOverPos(null);
}} }}
id={`task-${task.id}`} id={`task-${task.id}`}
style={{ style={{
@@ -213,9 +209,9 @@ function RecursiveTaskItem({
marginLeft: depth > 0 ? 8 : 0, marginLeft: depth > 0 ? 8 : 0,
transform: `translateX(${swipeOffset}px)`, transform: `translateX(${swipeOffset}px)`,
transition: swipeOffset === 0 ? "transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)" : "none", 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, 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 */} {/* 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%" }} 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" className="task-title"
onDoubleClick={(e) => { onDoubleClick={(e) => {
if (isTrashMode) return; if (isTrashMode) return;
@@ -315,9 +312,15 @@ function RecursiveTaskItem({
setEditingTitle(true); setEditingTitle(true);
}} }}
title="Double click or press F2 to edit" 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} {task.title}
</span>
</div> </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) // 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) // 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) => { setTasks((prev) => {
if (isDescendantNode(prev, draggedId, targetId)) { if (isDescendantNode(prev, draggedId, targetId)) {
@@ -837,6 +840,25 @@ export function TaskList({
let determinedParentId: string | null = null; 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 // 2. Insert into the target position
if (position === "inside") { if (position === "inside") {
determinedParentId = targetId; determinedParentId = targetId;
@@ -1100,17 +1122,38 @@ export function TaskList({
/> />
</div> </div>
) : ( ) : (
/* Task list container */ /* Task list container with background drop support for promoting subtask to 1st depth */
<div <div
className="task-list-container" 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) => { onContextMenu={(e) => {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
e.preventDefault(); e.preventDefault();
} }
}} }}
> >
{loading && ( {loading && tasks.length === 0 && (
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>{t("loading")}</div> <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 && ( {!loading && tasks.length === 0 && (
<div className="task-list-empty"> <div className="task-list-empty">
+7 -7
View File
@@ -155,16 +155,16 @@ export const en: TranslationDict = {
hideDone: "Hide Done", hideDone: "Hide Done",
showDone: "Show Done", showDone: "Show Done",
completedSection: "Completed", completedSection: "Completed",
addTaskPlaceholder: "Add a task, press Enter...", addTaskPlaceholder: "What would you like to accomplish?",
add: "Add", add: "Add",
noTasksYet: "No tasks in this list yet", noTasksYet: "No tasks in this list",
selectListToStart: "Select a list to get started", selectListToStart: "Select a list to get started",
loading: "Loading...", loading: "Loading tasks...",
today: "Today", today: "Today",
tomorrow: "Tomorrow", tomorrow: "Tomorrow",
// Task Detail // Task Detail
taskTitlePlaceholder: "Task title...", taskTitlePlaceholder: "Task title",
priority: "Priority", priority: "Priority",
priorityNone: "None", priorityNone: "None",
priorityLow: "Low", priorityLow: "Low",
@@ -173,10 +173,10 @@ export const en: TranslationDict = {
dueDate: "Due date", dueDate: "Due date",
clear: "Clear", clear: "Clear",
notes: "Notes", notes: "Notes",
notesPlaceholder: "Add detailed notes, markdown supported...", notesPlaceholder: "Add details, markdown supported...",
subtasks: "Subtasks", subtasks: "Subtasks",
addSubtaskPlaceholder: "Add a subtask, press Enter...", addSubtaskPlaceholder: "Add a subtask...",
deleteTaskConfirm: "Are you sure you want to delete this task?", deleteTaskConfirm: "Delete this task?",
autoSaved: "Saved", autoSaved: "Saved",
saving: "Saving...", saving: "Saving...",
created: "Created", created: "Created",
+7 -7
View File
@@ -155,28 +155,28 @@ export const ja: TranslationDict = {
hideDone: "完了項目を非表示", hideDone: "完了項目を非表示",
showDone: "完了項目を表示", showDone: "完了項目を表示",
completedSection: "完了済み", completedSection: "完了済み",
addTaskPlaceholder: "新しいタスクを入力してEnter...", addTaskPlaceholder: "新しいタスクを書き留めましょう",
add: "追加", add: "追加",
noTasksYet: "このリストにはタスクがありません", noTasksYet: "このリストにはタスクがありません",
selectListToStart: "リストを選択して始めましょう", selectListToStart: "リストを選択して開始してください",
loading: "読み込み中...", loading: "読み込み中...",
today: "今日", today: "今日",
tomorrow: "明日", tomorrow: "明日",
// Task Detail // Task Detail
taskTitlePlaceholder: "タスク名を入力...", taskTitlePlaceholder: "タスク名",
priority: "優先度", priority: "優先度",
priorityNone: "なし", priorityNone: "なし",
priorityLow: "低", priorityLow: "低",
priorityMedium: "中", priorityMedium: "中",
priorityHigh: "高", priorityHigh: "高",
dueDate: "期日", dueDate: "期日",
clear: "クリア", clear: "クリア",
notes: "メモ", notes: "メモ",
notesPlaceholder: "詳細なメモを入力 (Markdown対応)...", notesPlaceholder: "詳細を入力 (Markdown対応)...",
subtasks: "サブタスク", subtasks: "サブタスク",
addSubtaskPlaceholder: "サブタスクを入力してEnter...", addSubtaskPlaceholder: "サブタスクを追加...",
deleteTaskConfirm: "このタスクを削除してもよろしいですか?", deleteTaskConfirm: "タスクを削除しすか?",
autoSaved: "保存済み", autoSaved: "保存済み",
saving: "保存中...", saving: "保存中...",
created: "作成日時", created: "作成日時",
+4 -4
View File
@@ -155,7 +155,7 @@ export const ko: TranslationDict = {
hideDone: "완료항목 숨기기", hideDone: "완료항목 숨기기",
showDone: "완료항목 표시", showDone: "완료항목 표시",
completedSection: "완료됨", completedSection: "완료됨",
addTaskPlaceholder: "새 할 일 입력 후 Enter...", addTaskPlaceholder: "새로운 할 일을 적어볼까요?",
add: "추가", add: "추가",
noTasksYet: "이 목록에 등록된 할 일이 없습니다", noTasksYet: "이 목록에 등록된 할 일이 없습니다",
selectListToStart: "시작할 목록을 선택하세요", selectListToStart: "시작할 목록을 선택하세요",
@@ -164,7 +164,7 @@ export const ko: TranslationDict = {
tomorrow: "내일", tomorrow: "내일",
// Task Detail // Task Detail
taskTitlePlaceholder: "작업 제목 입력...", taskTitlePlaceholder: "할 일 제목",
priority: "우선순위", priority: "우선순위",
priorityNone: "없음", priorityNone: "없음",
priorityLow: "낮음", priorityLow: "낮음",
@@ -175,8 +175,8 @@ export const ko: TranslationDict = {
notes: "메모", notes: "메모",
notesPlaceholder: "상세 내용을 입력하세요 (마크다운 지원)...", notesPlaceholder: "상세 내용을 입력하세요 (마크다운 지원)...",
subtasks: "하위작업", subtasks: "하위작업",
addSubtaskPlaceholder: "하위작업 추가 후 Enter...", addSubtaskPlaceholder: "하위 작업을 적어보세요...",
deleteTaskConfirm: "이 작업을 정말 삭제하시겠습니까?", deleteTaskConfirm: "태스크를 삭제하시겠습니까?",
autoSaved: "저장됨", autoSaved: "저장됨",
saving: "저장 중...", saving: "저장 중...",
created: "생성일", created: "생성일",