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
Build and Push Docker Image / build-and-push (push) Successful in 9m59s
This commit is contained in:
@@ -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).
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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,22 +262,43 @@ 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 newTag: MockTag = {
|
const randomColor = ["#4B7BF5", "#10B981", "#EF4444", "#F59E0B", "#8B5CF6"][Math.floor(Math.random() * 5)];
|
||||||
id: "tag-" + Date.now(),
|
|
||||||
name: trimmed,
|
if (isDemo) {
|
||||||
color: ["#4B7BF5", "#10B981", "#EF4444", "#F59E0B", "#8B5CF6"][Math.floor(Math.random() * 5)],
|
const newTag: MockTag = {
|
||||||
};
|
id: "tag-" + Date.now(),
|
||||||
const updatedAvailable = [...allAvailableTags, newTag];
|
name: trimmed,
|
||||||
setAllAvailableTags(updatedAvailable);
|
color: randomColor,
|
||||||
if (typeof window !== "undefined") {
|
};
|
||||||
const { saveCustomTags } = require("@/lib/mockData");
|
const updatedAvailable = [...allAvailableTags, newTag];
|
||||||
saveCustomTags(updatedAvailable);
|
setAllAvailableTags(updatedAvailable);
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const { saveCustomTags } = require("@/lib/mockData");
|
||||||
|
saveCustomTags(updatedAvailable);
|
||||||
|
}
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
toggleTag(newTag);
|
|
||||||
setNewTagName("");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const addSubtask = useCallback(async () => {
|
const addSubtask = useCallback(async () => {
|
||||||
|
|||||||
@@ -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,17 +303,24 @@ 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%" }}>
|
||||||
className="task-title"
|
<span
|
||||||
onDoubleClick={(e) => {
|
className="task-title"
|
||||||
if (isTrashMode) return;
|
onDoubleClick={(e) => {
|
||||||
e.stopPropagation();
|
if (isTrashMode) return;
|
||||||
setEditingTitle(true);
|
e.stopPropagation();
|
||||||
}}
|
setEditingTitle(true);
|
||||||
title="Double click or press F2 to edit"
|
}}
|
||||||
style={{ fontSize: depth > 0 ? 13 : 14, fontWeight: depth === 0 ? 600 : 500 }}
|
title="Double click or press F2 to edit"
|
||||||
>
|
style={{
|
||||||
{task.title}
|
fontSize: depth > 0 ? 13 : 14,
|
||||||
|
fontWeight: depth === 0 ? 600 : 500,
|
||||||
|
cursor: "text",
|
||||||
|
display: "inline-block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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">
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: "作成日時",
|
||||||
|
|||||||
@@ -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: "생성일",
|
||||||
|
|||||||
Reference in New Issue
Block a user