Feature: Mobile bottom-sheet drawer (slide-up from bottom, swipe-down to close, overlay), update AGENTS.md to v4.0

This commit is contained in:
Wonhee Han
2026-08-20 22:51:43 +09:00
parent d2d2034515
commit 765f670ef0
12 changed files with 1820 additions and 273 deletions
+90 -37
View File
@@ -1,4 +1,4 @@
"use client";
"use client";
import React, { createContext, useContext, useState, useEffect } from "react";
export type Language = "en" | "ko" | "ja";
@@ -32,6 +32,7 @@ export const translations = {
listNamePlaceholder: "List name",
create: "Create",
cancel: "Cancel",
save: "Save",
deleteListConfirm: "Delete this list and all its tasks?",
importTasks: "Import Tasks",
importModalTitle: "Import Tasks",
@@ -45,6 +46,23 @@ export const translations = {
themeLight: "Light",
themeDark: "Dark",
language: "Language",
tags: "Tags",
trash: "Trash",
emptyTrash: "Empty Trash",
searchPlaceholder: "Search tasks, notes, tags (Ctrl+K)...",
// Settings Modal
settingsModalTitle: "Settings",
profile: "Profile",
preferences: "Preferences",
syncIntegrations: "Integrations (DAVx⁵)",
admin: "Admin",
trashRetention: "Trash Retention Period",
trashRetentionHint: "Deleted tasks will be permanently removed after the specified period.",
days7: "7 Days",
days14: "14 Days",
days30: "30 Days (Recommended)",
neverDelete: "Never Auto-Delete",
// Task List
tasks: "Tasks",
@@ -101,7 +119,7 @@ export const translations = {
min8Chars: "8자 이상 입력",
signIn: "로그인",
signingIn: "로그인 중...",
createBtn: "계정 만들기",
createBtn: "계정 생성",
creatingBtn: "계정 생성 중...",
alreadyHaveAccount: "이미 계정이 있으신가요?",
dontHaveAccount: "계정이 없으신가요?",
@@ -115,29 +133,47 @@ export const translations = {
listNamePlaceholder: "목록 이름",
create: "생성",
cancel: "취소",
save: "저장",
deleteListConfirm: "이 목록과 포함된 모든 할 일을 삭제하시겠습니까?",
importTasks: "할 일 가져오기 (Import)",
importModalTitle: "할 일 가져오기",
targetList: "저장할 목록",
importModalTitle: "할 일 가져오기 (Import)",
targetList: "가져올 대상 목록",
fileSelectLabel: "파일 선택 (TickTick CSV 또는 ICS)",
tickTickExportHint: "TickTick: 설정 → 데이터 내보내기 → CSV 또는 iCalendar",
tickTickExportHint: "TickTick: 설정 → 데이터 내보내기 → CSV 또는 iCalendar 다운로드",
importBtn: "가져오기",
importing: "가져오는 중...",
theme: "테마",
themeSystem: "시스템 설정",
themeLight: "라이트 모드",
themeDark: "다크 모드",
language: "언어",
theme: "화면 테마",
themeSystem: "시스템",
themeLight: "라이트",
themeDark: "다크",
language: "언어 (Language)",
tags: "태그",
trash: "휴지통",
emptyTrash: "휴지통 비우기",
searchPlaceholder: "할 일, 메모, 태그 검색 (Ctrl+K)...",
// Settings Modal
settingsModalTitle: "환경설정",
profile: "프로필",
preferences: "환경설정",
syncIntegrations: "외부 연동 (DAVx⁵)",
admin: "관리자",
trashRetention: "휴지통 보관 기간",
trashRetentionHint: "삭제된 항목은 지정한 기간 이후 영구적으로 삭제됩니다.",
days7: "7일",
days14: "14일",
days30: "30일 (권장)",
neverDelete: "자동 삭제 안 함 (수동 비우기만)",
// Task List
tasks: "할 일",
hideDone: "완료 숨",
hideDone: "완료 숨기기",
showDone: "완료 보기",
completedSection: "완료",
completedSection: "완료된 항목",
addTaskPlaceholder: "새로운 할 일 추가...",
add: "추가",
noTasksYet: "아직 등록된 할 일이 없습니다. 아래에서 추가해보세요!",
selectListToStart: "목록을 선택 시작하세요",
selectListToStart: "목록을 선택하여 할 일을 시작하세요",
loading: "불러오는 중...",
today: "오늘",
tomorrow: "내일",
@@ -198,6 +234,7 @@ export const translations = {
listNamePlaceholder: "リスト名",
create: "作成",
cancel: "キャンセル",
save: "保存",
deleteListConfirm: "このリストとすべてのタスクを削除しますか?",
importTasks: "タスクのインポート",
importModalTitle: "タスクのインポート",
@@ -211,6 +248,23 @@ export const translations = {
themeLight: "ライト",
themeDark: "ダーク",
language: "言語",
tags: "タグ",
trash: "ゴミ箱",
emptyTrash: "ゴミ箱を空にする",
searchPlaceholder: "タスク、メモ、タグを検索 (Ctrl+K)...",
// Settings Modal
settingsModalTitle: "設定",
profile: "プロフィール",
preferences: "環境設定",
syncIntegrations: "外部連携 (DAVx⁵)",
admin: "管理者",
trashRetention: "ゴミ箱の保存期間",
trashRetentionHint: "削除されたタスクは指定した期間後に完全に削除されます。",
days7: "7日間",
days14: "14日間",
days30: "30日間 (推奨)",
neverDelete: "自動削除しない",
// Task List
tasks: "タスク",
@@ -251,52 +305,51 @@ export const translations = {
bulletList: "箇条書き",
numberedList: "番号付きリスト",
checkbox: "チェックボックス",
code: "コード",
code: "コードブロック",
},
};
type TranslationKeys = keyof typeof translations.en;
interface I18nContextType {
lang: Language;
setLang: (lang: Language) => void;
t: (key: keyof typeof translations["en"]) => string;
setLang: (l: Language) => void;
t: (key: TranslationKeys) => string;
}
const I18nContext = createContext<I18nContextType>({
lang: "en",
setLang: () => {},
t: (key) => translations.en[key] || (key as string),
t: (k) => k,
});
const LANG_STORAGE_KEY = "checkflow_lang";
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [lang, setLangState] = useState<Language>("en");
useEffect(() => {
const saved = localStorage.getItem("checkflow_lang") as Language;
if (saved && (saved === "en" || saved === "ko" || saved === "ja")) {
setLangState(saved);
} else {
// Default to English as requested, or match browser if preferred
const navLang = navigator.language.toLowerCase();
if (navLang.startsWith("ko")) setLangState("ko");
else if (navLang.startsWith("ja")) setLangState("ja");
else setLangState("en");
}
try {
const saved = localStorage.getItem(LANG_STORAGE_KEY) as Language | null;
if (saved && (saved === "en" || saved === "ko" || saved === "ja")) {
setLangState(saved);
}
} catch {}
}, []);
const setLang = (newLang: Language) => {
setLangState(newLang);
localStorage.setItem("checkflow_lang", newLang);
const setLang = (l: Language) => {
setLangState(l);
try {
localStorage.setItem(LANG_STORAGE_KEY, l);
} catch {}
};
const t = (key: keyof typeof translations["en"]): string => {
return translations[lang]?.[key] || translations.en[key] || (key as string);
const t = (key: TranslationKeys): string => {
const dict = translations[lang] || translations.en;
return (dict as any)[key] || translations.en[key] || key;
};
return (
<I18nContext.Provider value={{ lang, setLang, t }}>
{children}
</I18nContext.Provider>
);
return <I18nContext.Provider value={{ lang, setLang, t }}>{children}</I18nContext.Provider>;
}
export const useI18n = () => useContext(I18nContext);
+170 -9
View File
@@ -1,4 +1,10 @@
export interface MockList {
export interface MockTag {
id: string;
name: string;
color: string;
}
export interface MockList {
id: string;
name: string;
color: string;
@@ -19,12 +25,39 @@ export interface MockTask {
sortOrder: number;
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
isDeleted?: boolean;
children: MockTask[];
tags?: { tag: { id: string; name: string; color: string } }[];
tags: { tag: MockTag }[];
}
export interface UserSettings {
displayName: string;
email: string;
trashRetentionDays: number; // 7, 14, 30, 0 (0 means never auto-delete)
theme: "system" | "light" | "dark";
language: "en" | "ko" | "ja";
}
const STORAGE_KEY_LISTS = "checkflow_demo_lists";
const STORAGE_KEY_TASKS = "checkflow_demo_tasks";
const STORAGE_KEY_SETTINGS = "checkflow_user_settings";
const STORAGE_KEY_TAGS = "checkflow_custom_tags";
export const DEFAULT_TAGS: MockTag[] = [
{ id: "tag-1", name: "Dev", color: "#4B7BF5" },
{ id: "tag-2", name: "NAS", color: "#10B981" },
{ id: "tag-3", name: "Important", color: "#EF4444" },
{ id: "tag-4", name: "Routine", color: "#F59E0B" },
];
export const DEFAULT_SETTINGS: UserSettings = {
displayName: "Demo Explorer",
email: "demo@checkflow.local",
trashRetentionDays: 30,
theme: "system",
language: "en",
};
export const INITIAL_DEMO_LISTS: MockList[] = [
{ id: "list-1", name: "🚀 Project Launch", color: "#4B7BF5", icon: "work" },
@@ -38,7 +71,7 @@ export const INITIAL_DEMO_TASKS: MockTask[] = [
listId: "list-1",
parentId: null,
title: "Setup Self-Hosted CheckFlow on NAS",
note: "## 🐳 Docker Deployment Guide\n\n- Deploy with `docker compose up -d`\n- Forward port 3000 to NPM (Nginx Proxy Manager)\n- Setup SSL certificate for custom domain\n\n### 🔗 Key Endpoints\n- Web App: `https://todo.yourdomain.com`\n- CardDAV: `https://todo.yourdomain.com/api/dav`",
note: "## 🐳 Docker Deployment Guide\n\n- Deploy with `docker compose up -d`\n- Forward port 3000 to NPM (Nginx Proxy Manager)\n- Setup SSL certificate for custom domain\n\n### 🔗 Key Endpoints\n- Web App: https://todo.yourdomain.com\n- CardDAV: https://todo.yourdomain.com/api/dav",
completed: false,
completedAt: null,
dueDate: new Date(Date.now() + 86400000).toISOString(),
@@ -46,6 +79,9 @@ export const INITIAL_DEMO_TASKS: MockTask[] = [
sortOrder: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
isDeleted: false,
tags: [{ tag: DEFAULT_TAGS[1] }, { tag: DEFAULT_TAGS[2] }],
children: [
{
id: "sub-1-1",
@@ -53,13 +89,16 @@ export const INITIAL_DEMO_TASKS: MockTask[] = [
parentId: "task-1",
title: "Configure .env environment variables",
note: "Database URL, NextAuth secret, and port settings",
completed: true,
completedAt: new Date().toISOString(),
completed: false,
completedAt: null,
dueDate: null,
priority: 0,
sortOrder: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
isDeleted: false,
tags: [],
children: [
{
id: "sub-1-1-1",
@@ -74,11 +113,12 @@ export const INITIAL_DEMO_TASKS: MockTask[] = [
sortOrder: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
isDeleted: false,
children: [],
tags: [],
tags: [{ tag: DEFAULT_TAGS[0] }],
},
],
tags: [],
},
{
id: "sub-1-2",
@@ -93,11 +133,30 @@ export const INITIAL_DEMO_TASKS: MockTask[] = [
sortOrder: 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
isDeleted: false,
children: [],
tags: [],
},
{
id: "sub-1-3",
listId: "list-1",
parentId: "task-1",
title: "Verify real-time sync with main list",
note: null,
completed: false,
completedAt: null,
dueDate: null,
priority: 1,
sortOrder: 2,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
isDeleted: false,
children: [],
tags: [],
},
],
tags: [],
},
{
id: "task-2",
@@ -112,8 +171,10 @@ export const INITIAL_DEMO_TASKS: MockTask[] = [
sortOrder: 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
isDeleted: false,
children: [],
tags: [],
tags: [{ tag: DEFAULT_TAGS[3] }],
},
];
@@ -150,6 +211,40 @@ export function saveDemoStore(lists: MockList[], tasks: MockTask[]) {
}
}
// User settings store
export function getUserSettings(): UserSettings {
if (typeof window === "undefined") return DEFAULT_SETTINGS;
try {
const s = localStorage.getItem(STORAGE_KEY_SETTINGS);
if (s) return { ...DEFAULT_SETTINGS, ...JSON.parse(s) };
} catch {}
return DEFAULT_SETTINGS;
}
export function saveUserSettings(settings: UserSettings) {
if (typeof window === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY_SETTINGS, JSON.stringify(settings));
} catch {}
}
// Custom tags store
export function getCustomTags(): MockTag[] {
if (typeof window === "undefined") return DEFAULT_TAGS;
try {
const t = localStorage.getItem(STORAGE_KEY_TAGS);
if (t) return JSON.parse(t);
} catch {}
return DEFAULT_TAGS;
}
export function saveCustomTags(tags: MockTag[]) {
if (typeof window === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY_TAGS, JSON.stringify(tags));
} catch {}
}
// Recursive tree helpers for N-depth sub-tasks
export function updateTaskInTree(tree: MockTask[], updated: MockTask): MockTask[] {
return tree.map((node) => {
@@ -163,6 +258,33 @@ export function updateTaskInTree(tree: MockTask[], updated: MockTask): MockTask[
});
}
// Soft delete to Trash
export function moveToTrashInTree(tree: MockTask[], id: string): MockTask[] {
return tree.map((node) => {
if (node.id === id) {
return { ...node, isDeleted: true, deletedAt: new Date().toISOString() };
}
if (node.children && node.children.length > 0) {
return { ...node, children: moveToTrashInTree(node.children, id) };
}
return node;
});
}
// Restore from Trash
export function restoreTaskInTree(tree: MockTask[], id: string): MockTask[] {
return tree.map((node) => {
if (node.id === id) {
return { ...node, isDeleted: false, deletedAt: null };
}
if (node.children && node.children.length > 0) {
return { ...node, children: restoreTaskInTree(node.children, id) };
}
return node;
});
}
// Permanent delete
export function deleteTaskInTree(tree: MockTask[], idToDelete: string): MockTask[] {
return tree
.filter((node) => node.id !== idToDelete)
@@ -174,6 +296,17 @@ export function deleteTaskInTree(tree: MockTask[], idToDelete: string): MockTask
});
}
export function emptyTrashInTree(tree: MockTask[]): MockTask[] {
return tree
.filter((node) => !node.isDeleted)
.map((node) => {
if (node.children && node.children.length > 0) {
return { ...node, children: emptyTrashInTree(node.children) };
}
return node;
});
}
export function addTaskToTree(tree: MockTask[], parentId: string | null, newTask: MockTask): MockTask[] {
if (!parentId) {
return [...tree, newTask];
@@ -199,4 +332,32 @@ export function findTaskInTree(tree: MockTask[], id: string): MockTask | null {
}
}
return null;
}
// Get all active tasks or all trash tasks flattened
export function getAllTrashTasks(tree: MockTask[]): MockTask[] {
let trash: MockTask[] = [];
for (const node of tree) {
if (node.isDeleted) {
trash.push(node);
}
if (node.children && node.children.length > 0) {
trash = trash.concat(getAllTrashTasks(node.children));
}
}
return trash;
}
// Filter tasks by custom tag
export function filterTasksByTag(tree: MockTask[], tagName: string): MockTask[] {
let matched: MockTask[] = [];
for (const node of tree) {
if (!node.isDeleted && node.tags?.some((t) => t.tag.name.toLowerCase() === tagName.toLowerCase())) {
matched.push(node);
}
if (node.children && node.children.length > 0) {
matched = matched.concat(filterTasksByTag(node.children, tagName));
}
}
return matched;
}