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:
+90
-37
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user