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:
@@ -1,6 +1,130 @@
|
|||||||
# CheckFlow — Agent Handbook & Project History
|
# CheckFlow — Agent Handbook & Project History
|
||||||
|
|
||||||
|
> 이 문서는 CheckFlow 프로젝트의 전체 개발 내역, 사용자 요구사항, 의사결정 기록, 기술 아키텍처를 보존하여 향후 작업하는 모든 AI 에이전트와 개발자가 일관되게 작업을 이어갈 수 있도록 작성되었습니다.
|
||||||
|
> **다음 에이전트에게**: 반드시 이 파일을 먼저 읽고, 작업 완료 후 업데이트하라.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 프로젝트 개요 & 핵심 요구사항
|
||||||
|
|
||||||
|
사용자의 핵심 요구사항:
|
||||||
|
1. **TickTick 스타일 To-Do 리스트 독립 웹앱**:
|
||||||
|
- 체크리스트는 **메인 - 하위(Sub-task)** N단계 계층 구조를 갖추며, 양쪽 패널 간 1:1 실시간 동기화.
|
||||||
|
- **적응형(Adaptive) 와이드 메모장**: 우측 패널의 대부분을 메모장이 차지하며, 마크다운이 GUI로 렌더링됨.
|
||||||
|
- **인라인 즉시 수정 UX**: 프로젝트 제목 및 태스크 제목을 클릭으로 즉시 수정 가능.
|
||||||
|
2. **셀프호스팅 & 프라이버시 중심 멀티유저**:
|
||||||
|
- SNS 형태가 아닌 개인별 프라이버시가 완벽히 보장되는 독립 멀티유저 플랫폼.
|
||||||
|
- Docker 배포 지원 (PostgreSQL 16 포함, Dockerfile & docker-compose.yml 완비).
|
||||||
|
3. **외부 플랫폼 연동 & Import**:
|
||||||
|
- TickTick CSV/ICS Import 지원 (프로필 메뉴 내 배치).
|
||||||
|
- Galaxy(Android) 폰 연동: DAVx⁵ 앱을 통한 CalDAV (`/api/dav`) 동기화 지원.
|
||||||
|
4. **모바일 바텀시트 드로어**:
|
||||||
|
- 스마트폰에서 우측 상세 탭이 **바텀 시트**로 아래서 위로 슬라이드업.
|
||||||
|
- 아래로 120px 이상 스와이프하면 패널 닫힘.
|
||||||
|
- 오버레이 클릭으로도 패널 닫힘.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 주요 컴포넌트 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/
|
||||||
|
│ ├── demo/page.tsx ← DB 없이 LocalStorage 기반 데모 페이지
|
||||||
|
│ ├── admin/page.tsx ← 어드민 대시보드
|
||||||
|
│ └── api/ (auth, lists, tasks, import, dav)
|
||||||
|
├── components/
|
||||||
|
│ ├── layout/
|
||||||
|
│ │ ├── AppShell.tsx ← 3-Panel 메인 컨테이너 + 모바일 오버레이 관리
|
||||||
|
│ │ └── Sidebar.tsx ← 프로젝트 목록, 언어, 테마, 프로필/Import
|
||||||
|
│ ├── tasks/
|
||||||
|
│ │ ├── TaskList.tsx ← 체크리스트 (재귀 트리, 인라인 수정, 우클릭 메뉴)
|
||||||
|
│ │ ├── TaskDetail.tsx ← 우측 상세 패널 (TickTick 스타일, 바텀시트 터치)
|
||||||
|
│ │ └── MarkdownNoteEditor.tsx ← Preview/Edit 탭, 툴바, URL 클릭 지원
|
||||||
|
│ ├── settings/
|
||||||
|
│ │ └── SettingsModal.tsx ← Profile/Preferences/Sync/Admin 4탭
|
||||||
|
│ └── ui/
|
||||||
|
│ ├── LanguageSelector.tsx ← 글래스모피즘 언어 팝오버
|
||||||
|
│ ├── ContextMenu.tsx ← 커스텀 우클릭 메뉴
|
||||||
|
│ └── CommandPalette.tsx ← Ctrl+K 글로벌 검색
|
||||||
|
└── lib/
|
||||||
|
├── i18n/ (en, ko, ja + useI18n 훅)
|
||||||
|
└── mockData.ts ← Demo LocalStorage Store (trash, tags, settings)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 롤백 포인트 (Git Tags)
|
||||||
|
|
||||||
|
| Tag | 내용 | 날짜 |
|
||||||
|
|---|---|---|
|
||||||
|
| `checkpoint-v1.0` | 기본 Full-Stack 기반 | 초기 |
|
||||||
|
| `checkpoint-v2.0` | i18n, 3-Way Theme, Demo Mode | - |
|
||||||
|
| `checkpoint-v2.1` | 리치 마크다운 렌더러, 적응형 메모장 | - |
|
||||||
|
| `checkpoint-v2.2` | 하위 태스크 실시간 동기화 & 인라인 수정 | - |
|
||||||
|
| `checkpoint-v3.0` | 사이드바 애니메이션, Import 이동, 커스텀 메뉴, TickTick 스타일 | - |
|
||||||
|
| `checkpoint-v3.2` | N-depth 재귀 체크리스트, 태그, 휴지통, 설정 모달, 타임스탬프 | - |
|
||||||
|
| `checkpoint-v4.0` | **(현재 최신)** 모바일 바텀시트, 스와이프 닫기, 오버레이 | 2026-08-20 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 구현 완료 기능
|
||||||
|
|
||||||
|
- [x] i18n 3개국어 (영어 기본, 한국어, 일본어)
|
||||||
|
- [x] 시스템/라이트/다크 테마 토글
|
||||||
|
- [x] Demo 모드 (LocalStorage 기반)
|
||||||
|
- [x] N단계 재귀 체크리스트 (RecursiveTaskItem)
|
||||||
|
- [x] 인라인 제목 수정
|
||||||
|
- [x] 커스텀 우클릭 컨텍스트 메뉴 (전체 트리)
|
||||||
|
- [x] Created / Edited 타임스탬프
|
||||||
|
- [x] 커스텀 태그 (색상 자동 부여)
|
||||||
|
- [x] 휴지통 (복원/영구삭제/보관 기간 설정)
|
||||||
|
- [x] 마크다운 Preview/Edit + URL 새 탭 열기
|
||||||
|
- [x] 설정 모달 (Profile/Preferences/Sync/Admin)
|
||||||
|
- [x] 어드민 페이지 (/admin)
|
||||||
|
- [x] Ctrl+K 글로벌 명령 팔레트
|
||||||
|
- [x] TickTick CSV/ICS 임포트
|
||||||
|
- [x] CalDAV 동기화 안내
|
||||||
|
- [x] **모바일 바텀시트 드로어** (Y축 스와이프, 오버레이)
|
||||||
|
- [x] FAB 버튼 (모바일 태스크 추가)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 개발 규칙 및 주의사항
|
||||||
|
|
||||||
|
1. **메모장 우선 원칙**: 우측 패널에서 메모장은 항상 `flex: 1`.
|
||||||
|
2. **컨텍스트 메뉴**: `ContextMenu.tsx` 사용, 브라우저 기본 메뉴 비활성화.
|
||||||
|
3. **i18n 무결성**: 새 텍스트는 en/ko/ja 사전에 모두 추가.
|
||||||
|
4. **모바일 CSS 규칙**:
|
||||||
|
- `detail-panel mobile-open` 클래스가 바텀시트를 제어.
|
||||||
|
- **절대** `task-detail-panel` 클래스로 되돌리지 말 것 (구버전).
|
||||||
|
- 모바일: `translateY(100%) → translateY(0)` 애니메이션.
|
||||||
|
- 데스크탑: `detail-panel`이 flex 방향 측면 패널.
|
||||||
|
5. **터치 제스처**: Y축 아래 방향 120px 이상 스와이프 → `onClose()`.
|
||||||
|
6. **언어**: 사용자 응답 및 에이전트 간 소통은 한국어를 기본으로 할 것.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 모델 변경 이력
|
||||||
|
|
||||||
|
| 시점 | 모델 |
|
||||||
|
|---|---|
|
||||||
|
| 초기 ~ Checkpoint v2.x | Claude Sonnet 4.6 |
|
||||||
|
| Checkpoint v2.x ~ v3.x | Gemini 2.5 Flash |
|
||||||
|
| Checkpoint v4.0 | Gemini (현재) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 개발 환경
|
||||||
|
|
||||||
|
- **프레임워크**: Next.js 15 + Turbopack
|
||||||
|
- **DB**: PostgreSQL 16 + Prisma (Demo에서는 LocalStorage 대체)
|
||||||
|
- **스타일**: Vanilla CSS (CSS Variables 디자인 토큰)
|
||||||
|
- **인증**: NextAuth.js
|
||||||
|
- **배포**: Docker + docker-compose.yml
|
||||||
|
- **로컬 실행**: `npm run dev` (포트 3000)
|
||||||
|
- **Demo 페이지**: `http://localhost:3000/demo`
|
||||||
|
|
||||||
> 이 문서는 CheckFlow 프로젝트의 전체 개발 내역, 사용자 요구사항, 의사결정 기록, 기술 아키텍처 및 트러블슈팅을 보존하여 향후 작업하는 모든 AI 에이전트와 개발자가 일관되게 작업을 이어갈 수 있도록 작성되었습니다.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import { getDemoStore } from "@/lib/mockData";
|
||||||
|
|
||||||
|
interface AdminUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: "USER" | "ADMIN";
|
||||||
|
createdAt: string;
|
||||||
|
taskCount: number;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_ADMIN_USERS: AdminUser[] = [
|
||||||
|
{
|
||||||
|
id: "user-1",
|
||||||
|
name: "Admin Master",
|
||||||
|
email: "admin@checkflow.local",
|
||||||
|
role: "ADMIN",
|
||||||
|
createdAt: "2026-08-01T09:00:00Z",
|
||||||
|
taskCount: 42,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "user-2",
|
||||||
|
name: "Demo Explorer",
|
||||||
|
email: "demo@checkflow.local",
|
||||||
|
role: "USER",
|
||||||
|
createdAt: "2026-08-15T14:20:00Z",
|
||||||
|
taskCount: 18,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "user-3",
|
||||||
|
name: "Family Member 1",
|
||||||
|
email: "sarah@home.lan",
|
||||||
|
role: "USER",
|
||||||
|
createdAt: "2026-08-18T11:00:00Z",
|
||||||
|
taskCount: 7,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [users, setUsers] = useState<AdminUser[]>(INITIAL_ADMIN_USERS);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [totalTasks, setTotalTasks] = useState(67);
|
||||||
|
const [totalLists, setTotalLists] = useState(9);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const store = getDemoStore();
|
||||||
|
if (store.tasks) setTotalTasks(store.tasks.length);
|
||||||
|
if (store.lists) setTotalLists(store.lists.length);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleRole = (id: string) => {
|
||||||
|
setUsers((prev) =>
|
||||||
|
prev.map((u) => (u.id === id ? { ...u, role: u.role === "ADMIN" ? "USER" : "ADMIN" } : u))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleStatus = (id: string) => {
|
||||||
|
setUsers((prev) =>
|
||||||
|
prev.map((u) => (u.id === id ? { ...u, active: !u.active } : u))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteUser = (id: string) => {
|
||||||
|
if (confirm("Are you sure you want to delete this user and all their tasks?")) {
|
||||||
|
setUsers((prev) => prev.filter((u) => u.id !== id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredUsers = users.filter(
|
||||||
|
(u) =>
|
||||||
|
u.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
u.email.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: "100vh", background: "var(--bg-primary)", color: "var(--text-primary)", padding: "24px 32px" }}>
|
||||||
|
{/* Top Navbar */}
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: "1px solid var(--border)", paddingBottom: 16, marginBottom: 24 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<div className="sidebar-logo">👑</div>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0, letterSpacing: -0.5 }}>
|
||||||
|
CheckFlow Admin Console
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 12, color: "var(--text-tertiary)", margin: 0 }}>
|
||||||
|
Multi-user platform & self-hosted instance management
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<Link href="/demo" className="btn btn-ghost btn-sm">
|
||||||
|
← Back to App (Demo)
|
||||||
|
</Link>
|
||||||
|
<Link href="/" className="btn btn-primary btn-sm">
|
||||||
|
🚀 Open Main Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: 16, marginBottom: 28 }}>
|
||||||
|
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>Total Users</div>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 800, color: "var(--accent)", marginTop: 4 }}>{users.length}</div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--success)", marginTop: 2 }}>● 100% active instances</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>Total Projects / Lists</div>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 800, color: "#10B981", marginTop: 4 }}>{totalLists}</div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--text-tertiary)", marginTop: 2 }}>Across all users</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>Total Tasks & Notes</div>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 800, color: "#8B5CF6", marginTop: 4 }}>{totalTasks}</div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--text-tertiary)", marginTop: 2 }}>Recursive subtasks included</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>CalDAV / DAVx⁵ Status</div>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 800, color: "#F59E0B", marginTop: 4 }}>Active</div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--success)", marginTop: 2 }}>● Basic Auth Bypass Verified</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Management Section */}
|
||||||
|
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-md)", padding: 20 }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16, flexWrap: "wrap", gap: 12 }}>
|
||||||
|
<h2 style={{ fontSize: 16, fontWeight: 700, margin: 0 }}>Registered Users & Privacy Isolation</h2>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Search users by name or email..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
style={{ maxWidth: 300 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Users Table */}
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<table style={{ width: "100%", borderCollapse: "collapse", textAlign: "left", fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: "1px solid var(--border)", color: "var(--text-tertiary)" }}>
|
||||||
|
<th style={{ padding: "10px 12px" }}>User</th>
|
||||||
|
<th style={{ padding: "10px 12px" }}>Role</th>
|
||||||
|
<th style={{ padding: "10px 12px" }}>Joined Date</th>
|
||||||
|
<th style={{ padding: "10px 12px" }}>Tasks</th>
|
||||||
|
<th style={{ padding: "10px 12px" }}>Status</th>
|
||||||
|
<th style={{ padding: "10px 12px", textAlign: "right" }}>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredUsers.map((u) => (
|
||||||
|
<tr key={u.id} style={{ borderBottom: "1px solid var(--border)" }}>
|
||||||
|
<td style={{ padding: "12px" }}>
|
||||||
|
<div style={{ fontWeight: 600 }}>{u.name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{u.email}</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "12px" }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: "3px 8px",
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
background: u.role === "ADMIN" ? "rgba(75, 123, 245, 0.15)" : "var(--bg-primary)",
|
||||||
|
color: u.role === "ADMIN" ? "var(--accent)" : "var(--text-secondary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{u.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "12px", color: "var(--text-secondary)" }}>
|
||||||
|
{new Date(u.createdAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "12px", color: "var(--text-secondary)" }}>
|
||||||
|
{u.taskCount} tasks
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "12px" }}>
|
||||||
|
<span style={{ color: u.active ? "var(--success)" : "var(--danger)", fontWeight: 600, fontSize: 12 }}>
|
||||||
|
{u.active ? "● Active" : "○ Inactive"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "12px", textAlign: "right" }}>
|
||||||
|
<div style={{ display: "inline-flex", gap: 6 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => toggleRole(u.id)}
|
||||||
|
title="Change role"
|
||||||
|
style={{ fontSize: 11, padding: "3px 8px" }}
|
||||||
|
>
|
||||||
|
{u.role === "ADMIN" ? "Demote" : "Promote Admin"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => toggleStatus(u.id)}
|
||||||
|
title="Toggle active status"
|
||||||
|
style={{ fontSize: 11, padding: "3px 8px" }}
|
||||||
|
>
|
||||||
|
{u.active ? "Deactivate" : "Activate"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => deleteUser(u.id)}
|
||||||
|
title="Delete user"
|
||||||
|
style={{ fontSize: 11, padding: "3px 8px", color: "var(--danger)" }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+44
-6
@@ -1553,20 +1553,39 @@ a { color: inherit; text-decoration: none; }
|
|||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 모바일: 우측 패널을 바닥에서 위로 슬라이드 업하는 바텀 시트 */
|
||||||
.detail-panel {
|
.detail-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 30;
|
top: auto;
|
||||||
transform: translateX(100%);
|
height: 92dvh; /* 전체 화면의 92% */
|
||||||
transition: transform var(--dur-slow) var(--ease-out);
|
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
|
z-index: 35;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform var(--dur-slow) var(--ease-out);
|
||||||
|
box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.18);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel.mobile-open {
|
.detail-panel.mobile-open {
|
||||||
transform: translateX(0);
|
transform: translateY(0);
|
||||||
box-shadow: var(--shadow-panel);
|
}
|
||||||
|
|
||||||
|
/* 모바일 학 스와이프 핸들 */
|
||||||
|
.mobile-swipe-handle {
|
||||||
|
display: block !important;
|
||||||
|
width: 40px;
|
||||||
|
height: 5px;
|
||||||
|
background: var(--border-medium);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
margin: 8px auto 4px;
|
||||||
|
cursor: grab;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-nav {
|
.bottom-nav {
|
||||||
@@ -1579,6 +1598,20 @@ a { color: inherit; text-decoration: none; }
|
|||||||
|
|
||||||
.task-item { padding: 10px 16px; }
|
.task-item { padding: 10px 16px; }
|
||||||
.main-header { padding: 0 16px; }
|
.main-header { padding: 0 16px; }
|
||||||
|
|
||||||
|
.mobile-only {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 바텀시트 배경 오버레이 */
|
||||||
|
.bottom-sheet-overlay {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
z-index: 34;
|
||||||
|
animation: fadeIn var(--dur-normal) var(--ease-out);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bottom nav (mobile only) */
|
/* Bottom nav (mobile only) */
|
||||||
@@ -1680,6 +1713,11 @@ a { color: inherit; text-decoration: none; }
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 바텀시트 배경 오버레이 (모바일 전용) */
|
||||||
|
.bottom-sheet-overlay {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
CUSTOM CONTEXT MENU
|
CUSTOM CONTEXT MENU
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|||||||
@@ -3,15 +3,21 @@ import { useState, useCallback, useEffect } from "react";
|
|||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
import { TaskList, Task, List, User } from "../tasks/TaskList";
|
import { TaskList, Task, List, User } from "../tasks/TaskList";
|
||||||
import { TaskDetail } from "../tasks/TaskDetail";
|
import { TaskDetail } from "../tasks/TaskDetail";
|
||||||
|
import { CommandPalette } from "../ui/CommandPalette";
|
||||||
import {
|
import {
|
||||||
getDemoStore,
|
getDemoStore,
|
||||||
saveDemoStore,
|
saveDemoStore,
|
||||||
MockList,
|
MockList,
|
||||||
MockTask,
|
MockTask,
|
||||||
updateTaskInTree,
|
updateTaskInTree,
|
||||||
|
moveToTrashInTree,
|
||||||
|
restoreTaskInTree,
|
||||||
deleteTaskInTree,
|
deleteTaskInTree,
|
||||||
|
emptyTrashInTree,
|
||||||
addTaskToTree,
|
addTaskToTree,
|
||||||
findTaskInTree,
|
findTaskInTree,
|
||||||
|
getAllTrashTasks,
|
||||||
|
filterTasksByTag,
|
||||||
} from "@/lib/mockData";
|
} from "@/lib/mockData";
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
@@ -22,33 +28,49 @@ interface AppShellProps {
|
|||||||
export function AppShell({ user, isDemo = false }: AppShellProps) {
|
export function AppShell({ user, isDemo = false }: AppShellProps) {
|
||||||
const [lists, setLists] = useState<List[]>([]);
|
const [lists, setLists] = useState<List[]>([]);
|
||||||
const [selectedListId, setSelectedListId] = useState<string | null>(null);
|
const [selectedListId, setSelectedListId] = useState<string | null>(null);
|
||||||
|
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||||
|
const [isTrashActive, setIsTrashActive] = useState(false);
|
||||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [showCompleted, setShowCompleted] = useState(false);
|
const [showCompleted, setShowCompleted] = useState(false);
|
||||||
|
const [cmdPaletteOpen, setCmdPaletteOpen] = useState(false);
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
// Listen for Ctrl+K event
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setCmdPaletteOpen(true);
|
||||||
|
document.addEventListener("checkflow:openCommandPalette", handler);
|
||||||
|
return () => document.removeEventListener("checkflow:openCommandPalette", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Demo store loading
|
// Demo store loading
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDemo) {
|
if (isDemo) {
|
||||||
const store = getDemoStore();
|
const store = getDemoStore();
|
||||||
setLists(store.lists);
|
setLists(store.lists);
|
||||||
if (store.lists.length > 0) {
|
if (store.lists.length > 0 && !selectedListId && !isTrashActive && !selectedTag) {
|
||||||
setSelectedListId(store.lists[0].id);
|
setSelectedListId(store.lists[0].id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isDemo]);
|
}, [isDemo, isTrashActive, selectedTag]);
|
||||||
|
|
||||||
// Demo tasks filter & reconstruct hierarchical structure
|
// Demo tasks filter & reconstruct hierarchical structure
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDemo && selectedListId) {
|
if (isDemo) {
|
||||||
const store = getDemoStore();
|
const store = getDemoStore();
|
||||||
const listTasks = (store.tasks as Task[]).filter(
|
if (isTrashActive) {
|
||||||
(t) => t.listId === selectedListId && (showCompleted ? true : !t.completed)
|
setTasks(getAllTrashTasks(store.tasks) as Task[]);
|
||||||
);
|
} else if (selectedTag) {
|
||||||
setTasks(listTasks);
|
setTasks(filterTasksByTag(store.tasks, selectedTag) as Task[]);
|
||||||
|
} else if (selectedListId) {
|
||||||
|
const listTasks = (store.tasks as Task[]).filter(
|
||||||
|
(t) => !t.isDeleted && t.listId === selectedListId && (showCompleted ? true : !t.completed)
|
||||||
|
);
|
||||||
|
setTasks(listTasks);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [isDemo, selectedListId, showCompleted, refreshKey]);
|
}, [isDemo, selectedListId, isTrashActive, selectedTag, showCompleted, refreshKey]);
|
||||||
|
|
||||||
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
|
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
|
||||||
|
|
||||||
@@ -68,12 +90,29 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleListSelect = useCallback((id: string) => {
|
const handleListSelect = useCallback((id: string) => {
|
||||||
|
setIsTrashActive(false);
|
||||||
|
setSelectedTag(null);
|
||||||
setSelectedListId(id);
|
setSelectedListId(id);
|
||||||
setSelectedTask(null);
|
setSelectedTask(null);
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Update List Name (Inline edit on header or sidebar)
|
const handleTagSelect = useCallback((tagName: string | null) => {
|
||||||
|
setIsTrashActive(false);
|
||||||
|
setSelectedTag(tagName);
|
||||||
|
setSelectedTask(null);
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTrashSelect = useCallback(() => {
|
||||||
|
setIsTrashActive(true);
|
||||||
|
setSelectedTag(null);
|
||||||
|
setSelectedListId(null);
|
||||||
|
setSelectedTask(null);
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update List Name
|
||||||
const handleUpdateListName = async (id: string, newName: string) => {
|
const handleUpdateListName = async (id: string, newName: string) => {
|
||||||
const trimmed = newName.trim();
|
const trimmed = newName.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
@@ -101,7 +140,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Demo Handlers with Full N-Depth Recursive Tree Support
|
// Demo Handlers
|
||||||
const handleDemoCreateList = (name: string, color: string) => {
|
const handleDemoCreateList = (name: string, color: string) => {
|
||||||
const newList: MockList = {
|
const newList: MockList = {
|
||||||
id: "demo-list-" + Date.now(),
|
id: "demo-list-" + Date.now(),
|
||||||
@@ -142,6 +181,8 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
sortOrder: tasks.length,
|
sortOrder: tasks.length,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
deletedAt: null,
|
||||||
|
isDeleted: false,
|
||||||
children: [],
|
children: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
};
|
};
|
||||||
@@ -151,7 +192,6 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
|
|
||||||
setTasks((prev) => addTaskToTree(prev as MockTask[], parentId, newTask as MockTask) as Task[]);
|
setTasks((prev) => addTaskToTree(prev as MockTask[], parentId, newTask as MockTask) as Task[]);
|
||||||
|
|
||||||
// If currently selected task is the parent, update its children in the detail panel immediately
|
|
||||||
if (selectedTask && selectedTask.id === parentId) {
|
if (selectedTask && selectedTask.id === parentId) {
|
||||||
setSelectedTask((prev) =>
|
setSelectedTask((prev) =>
|
||||||
prev ? { ...prev, children: [...(prev.children || []), newTask] } : prev
|
prev ? { ...prev, children: [...(prev.children || []), newTask] } : prev
|
||||||
@@ -175,7 +215,6 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
(updateTaskInTree(prev as MockTask[], updated) as Task[]).filter((t) => showCompleted || !t.completed)
|
(updateTaskInTree(prev as MockTask[], updated) as Task[]).filter((t) => showCompleted || !t.completed)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keep selectedTask 100% in sync with the tree
|
|
||||||
if (selectedTask) {
|
if (selectedTask) {
|
||||||
const latestSelected = findTaskInTree(newTasks, selectedTask.id);
|
const latestSelected = findTaskInTree(newTasks, selectedTask.id);
|
||||||
if (latestSelected) {
|
if (latestSelected) {
|
||||||
@@ -193,18 +232,52 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
handleTaskUpdate(updated);
|
handleTaskUpdate(updated);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Move to Trash (Soft Delete)
|
||||||
const handleDemoDeleteTask = (id: string) => {
|
const handleDemoDeleteTask = (id: string) => {
|
||||||
const store = getDemoStore();
|
const store = getDemoStore();
|
||||||
const newTasks = deleteTaskInTree(store.tasks, id);
|
const newTasks = moveToTrashInTree(store.tasks, id);
|
||||||
saveDemoStore(store.lists, newTasks);
|
saveDemoStore(store.lists, newTasks);
|
||||||
setTasks((prev) => deleteTaskInTree(prev as MockTask[], id) as Task[]);
|
setTasks((prev) => prev.filter((t) => t.id !== id));
|
||||||
if (selectedTask?.id === id) {
|
if (selectedTask?.id === id) {
|
||||||
setSelectedTask(null);
|
setSelectedTask(null);
|
||||||
}
|
}
|
||||||
refresh();
|
refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Inline update task title from main list
|
// Restore from Trash
|
||||||
|
const handleRestoreTask = (id: string) => {
|
||||||
|
if (isDemo) {
|
||||||
|
const store = getDemoStore();
|
||||||
|
const newTasks = restoreTaskInTree(store.tasks, id);
|
||||||
|
saveDemoStore(store.lists, newTasks);
|
||||||
|
setTasks((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Permanent Delete
|
||||||
|
const handlePermanentDeleteTask = (id: string) => {
|
||||||
|
if (isDemo) {
|
||||||
|
const store = getDemoStore();
|
||||||
|
const newTasks = deleteTaskInTree(store.tasks, id);
|
||||||
|
saveDemoStore(store.lists, newTasks);
|
||||||
|
setTasks((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Empty Trash
|
||||||
|
const handleEmptyTrash = () => {
|
||||||
|
if (!confirm("Permanently empty all items in trash?")) return;
|
||||||
|
if (isDemo) {
|
||||||
|
const store = getDemoStore();
|
||||||
|
const newTasks = emptyTrashInTree(store.tasks);
|
||||||
|
saveDemoStore(store.lists, newTasks);
|
||||||
|
setTasks([]);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpdateTaskTitle = async (taskId: string, newTitle: string) => {
|
const handleUpdateTaskTitle = async (taskId: string, newTitle: string) => {
|
||||||
const trimmed = newTitle.trim();
|
const trimmed = newTitle.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
@@ -238,7 +311,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-layout">
|
<div className="app-layout">
|
||||||
{/* Mobile overlay */}
|
{/* Sidebar 모바일 오버레이 */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div
|
<div
|
||||||
className="modal-overlay"
|
className="modal-overlay"
|
||||||
@@ -247,12 +320,24 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 바텀시트 모바일 오버레이 — 태스크 상세 패널이 열릴 때 */}
|
||||||
|
{selectedTask && (
|
||||||
|
<div
|
||||||
|
className="bottom-sheet-overlay"
|
||||||
|
onClick={() => setSelectedTask(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Sidebar
|
<Sidebar
|
||||||
user={user}
|
user={user}
|
||||||
lists={lists}
|
lists={lists}
|
||||||
setLists={setLists}
|
setLists={setLists}
|
||||||
selectedListId={selectedListId}
|
selectedListId={selectedListId}
|
||||||
onListSelect={handleListSelect}
|
onListSelect={handleListSelect}
|
||||||
|
selectedTag={selectedTag}
|
||||||
|
onTagSelect={handleTagSelect}
|
||||||
|
isTrashActive={isTrashActive}
|
||||||
|
onTrashSelect={handleTrashSelect}
|
||||||
mobileOpen={sidebarOpen}
|
mobileOpen={sidebarOpen}
|
||||||
onClose={() => setSidebarOpen(false)}
|
onClose={() => setSidebarOpen(false)}
|
||||||
isDemo={isDemo}
|
isDemo={isDemo}
|
||||||
@@ -262,7 +347,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
|
|
||||||
<div className="main-content">
|
<div className="main-content">
|
||||||
<TaskList
|
<TaskList
|
||||||
key={`${selectedListId}-${refreshKey}`}
|
key={`${selectedListId}-${isTrashActive}-${selectedTag}-${refreshKey}`}
|
||||||
user={user}
|
user={user}
|
||||||
listId={selectedListId}
|
listId={selectedListId}
|
||||||
lists={lists}
|
lists={lists}
|
||||||
@@ -275,6 +360,11 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
onMenuOpen={() => setSidebarOpen(true)}
|
onMenuOpen={() => setSidebarOpen(true)}
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
isDemo={isDemo}
|
isDemo={isDemo}
|
||||||
|
isTrashActive={isTrashActive}
|
||||||
|
selectedTag={selectedTag}
|
||||||
|
onEmptyTrash={handleEmptyTrash}
|
||||||
|
onRestoreTask={handleRestoreTask}
|
||||||
|
onPermanentDeleteTask={handlePermanentDeleteTask}
|
||||||
onDemoAddTask={handleDemoAddTask}
|
onDemoAddTask={handleDemoAddTask}
|
||||||
onDemoToggleTask={handleDemoToggleTask}
|
onDemoToggleTask={handleDemoToggleTask}
|
||||||
onUpdateTaskTitle={handleUpdateTaskTitle}
|
onUpdateTaskTitle={handleUpdateTaskTitle}
|
||||||
@@ -303,6 +393,14 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Global Command Palette (Ctrl+K) */}
|
||||||
|
<CommandPalette
|
||||||
|
isOpen={cmdPaletteOpen}
|
||||||
|
onClose={() => setCmdPaletteOpen(false)}
|
||||||
|
tasks={tasks}
|
||||||
|
onSelectTask={handleTaskSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Mobile FAB */}
|
{/* Mobile FAB */}
|
||||||
<button
|
<button
|
||||||
className="fab"
|
className="fab"
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useI18n } from "@/lib/i18n";
|
|||||||
import { useTheme } from "@/app/providers";
|
import { useTheme } from "@/app/providers";
|
||||||
import { LanguageSelector } from "@/components/ui/LanguageSelector";
|
import { LanguageSelector } from "@/components/ui/LanguageSelector";
|
||||||
import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu";
|
import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu";
|
||||||
|
import { SettingsModal } from "@/components/settings/SettingsModal";
|
||||||
|
import { getCustomTags, MockTag, getAllTrashTasks, getDemoStore } from "@/lib/mockData";
|
||||||
|
|
||||||
interface User { id: string; name?: string | null; email?: string | null }
|
interface User { id: string; name?: string | null; email?: string | null }
|
||||||
interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } }
|
interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } }
|
||||||
@@ -17,6 +19,10 @@ interface SidebarProps {
|
|||||||
setLists: React.Dispatch<React.SetStateAction<List[]>>;
|
setLists: React.Dispatch<React.SetStateAction<List[]>>;
|
||||||
selectedListId: string | null;
|
selectedListId: string | null;
|
||||||
onListSelect: (id: string) => void;
|
onListSelect: (id: string) => void;
|
||||||
|
selectedTag: string | null;
|
||||||
|
onTagSelect: (tag: string | null) => void;
|
||||||
|
isTrashActive: boolean;
|
||||||
|
onTrashSelect: () => void;
|
||||||
mobileOpen: boolean;
|
mobileOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
isDemo?: boolean;
|
isDemo?: boolean;
|
||||||
@@ -30,6 +36,10 @@ export function Sidebar({
|
|||||||
setLists,
|
setLists,
|
||||||
selectedListId,
|
selectedListId,
|
||||||
onListSelect,
|
onListSelect,
|
||||||
|
selectedTag,
|
||||||
|
onTagSelect,
|
||||||
|
isTrashActive,
|
||||||
|
onTrashSelect,
|
||||||
mobileOpen,
|
mobileOpen,
|
||||||
onClose,
|
onClose,
|
||||||
isDemo = false,
|
isDemo = false,
|
||||||
@@ -44,9 +54,12 @@ export function Sidebar({
|
|||||||
const [newListColor, setNewListColor] = useState(LIST_COLORS[0]);
|
const [newListColor, setNewListColor] = useState(LIST_COLORS[0]);
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
const [showImport, setShowImport] = useState(false);
|
const [showImport, setShowImport] = useState(false);
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [importListId, setImportListId] = useState("");
|
const [importListId, setImportListId] = useState("");
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const [importResult, setImportResult] = useState("");
|
const [importResult, setImportResult] = useState("");
|
||||||
|
const [tags, setTags] = useState<MockTag[]>([]);
|
||||||
|
const [trashCount, setTrashCount] = useState(0);
|
||||||
|
|
||||||
// Context Menu for Lists
|
// Context Menu for Lists
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; listId: string } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; listId: string } | null>(null);
|
||||||
@@ -54,6 +67,13 @@ export function Sidebar({
|
|||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTags(getCustomTags());
|
||||||
|
const store = getDemoStore();
|
||||||
|
const trashed = getAllTrashTasks(store.tasks);
|
||||||
|
setTrashCount(trashed.length);
|
||||||
|
}, [lists]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDemo) {
|
if (!isDemo) {
|
||||||
fetch("/api/lists")
|
fetch("/api/lists")
|
||||||
@@ -186,8 +206,20 @@ export function Sidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Controls: Language & Theme — Fixed without clipping */}
|
{/* Top Controls: Search, Language & Theme */}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
|
||||||
|
{/* Quick Search Button */}
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
id="quick-search-btn"
|
||||||
|
onClick={() => document.dispatchEvent(new CustomEvent("checkflow:openCommandPalette"))}
|
||||||
|
title="Search (Ctrl+K)"
|
||||||
|
style={{ width: 28, height: 28, flexShrink: 0 }}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
🔍
|
||||||
|
</button>
|
||||||
|
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
|
|
||||||
{/* Theme 3-Way Toggle Button */}
|
{/* Theme 3-Way Toggle Button */}
|
||||||
@@ -220,13 +252,17 @@ export function Sidebar({
|
|||||||
|
|
||||||
{/* Nav List */}
|
{/* Nav List */}
|
||||||
<nav className="sidebar-nav">
|
<nav className="sidebar-nav">
|
||||||
|
{/* Lists Section */}
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
<div className="sidebar-section-label">{t("lists")}</div>
|
<div className="sidebar-section-label">{t("lists")}</div>
|
||||||
{lists.map((list) => (
|
{lists.map((list) => (
|
||||||
<div
|
<div
|
||||||
key={list.id}
|
key={list.id}
|
||||||
className={`sidebar-item${selectedListId === list.id ? " active" : ""}`}
|
className={`sidebar-item${!isTrashActive && !selectedTag && selectedListId === list.id ? " active" : ""}`}
|
||||||
onClick={() => onListSelect(list.id)}
|
onClick={() => {
|
||||||
|
onTagSelect(null);
|
||||||
|
onListSelect(list.id);
|
||||||
|
}}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, listId: list.id });
|
setContextMenu({ x: e.clientX, y: e.clientY, listId: list.id });
|
||||||
@@ -254,6 +290,35 @@ export function Sidebar({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Tags Section */}
|
||||||
|
<div className="sidebar-section">
|
||||||
|
<div className="sidebar-section-label">🏷️ {t("tags") || "Tags"}</div>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<div
|
||||||
|
key={tag.id}
|
||||||
|
className={`sidebar-item${selectedTag === tag.name ? " active" : ""}`}
|
||||||
|
onClick={() => onTagSelect(selectedTag === tag.name ? null : tag.name)}
|
||||||
|
>
|
||||||
|
<span style={{ color: tag.color, fontSize: 13 }}>#</span>
|
||||||
|
<span className="item-label">{tag.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trash Smart List */}
|
||||||
|
<div className="sidebar-section">
|
||||||
|
<div
|
||||||
|
className={`sidebar-item${isTrashActive ? " active" : ""}`}
|
||||||
|
id="trash-menu-btn"
|
||||||
|
onClick={onTrashSelect}
|
||||||
|
style={{ color: isTrashActive ? "var(--danger)" : "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 14 }}>🗑️</span>
|
||||||
|
<span className="item-label">{t("trash") || "Trash"}</span>
|
||||||
|
{trashCount > 0 && <span className="item-count" style={{ color: "var(--danger)" }}>{trashCount}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* New list form */}
|
{/* New list form */}
|
||||||
{showNewList ? (
|
{showNewList ? (
|
||||||
<div style={{ padding: "4px 10px" }}>
|
<div style={{ padding: "4px 10px" }}>
|
||||||
@@ -298,7 +363,7 @@ export function Sidebar({
|
|||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User footer with Profile Menu containing Import */}
|
{/* User footer with Profile Menu containing Settings & Import */}
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
<div className="user-card" id="user-menu-btn" onClick={() => setUserMenuOpen((p) => !p)} style={{ position: "relative" }}>
|
<div className="user-card" id="user-menu-btn" onClick={() => setUserMenuOpen((p) => !p)} style={{ position: "relative" }}>
|
||||||
<div className="user-avatar" style={{ background: "#4B7BF5" }}>{initials}</div>
|
<div className="user-avatar" style={{ background: "#4B7BF5" }}>{initials}</div>
|
||||||
@@ -313,6 +378,19 @@ export function Sidebar({
|
|||||||
{/* User Popover Menu */}
|
{/* User Popover Menu */}
|
||||||
{userMenuOpen && (
|
{userMenuOpen && (
|
||||||
<div className="dropdown" style={{ bottom: "calc(100% + 6px)", left: 0, right: 0, marginBottom: 0 }}>
|
<div className="dropdown" style={{ bottom: "calc(100% + 6px)", left: 0, right: 0, marginBottom: 0 }}>
|
||||||
|
{/* Settings Action */}
|
||||||
|
<div
|
||||||
|
className="dropdown-item"
|
||||||
|
id="sidebar-settings-menu-item"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowSettings(true);
|
||||||
|
setUserMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚙️ {t("settingsModalTitle") || "Settings"}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Import Action */}
|
{/* Import Action */}
|
||||||
<div
|
<div
|
||||||
className="dropdown-item"
|
className="dropdown-item"
|
||||||
@@ -324,10 +402,7 @@ export function Sidebar({
|
|||||||
setUserMenuOpen(false);
|
setUserMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
📥 {t("importTasks")}
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" />
|
|
||||||
</svg>
|
|
||||||
{t("importTasks")}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="context-menu-divider" />
|
<div className="context-menu-divider" />
|
||||||
@@ -337,17 +412,11 @@ export function Sidebar({
|
|||||||
className="dropdown-item"
|
className="dropdown-item"
|
||||||
onClick={() => { window.location.href = "/login"; }}
|
onClick={() => { window.location.href = "/login"; }}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
🚪 {t("signIn")}
|
||||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><polyline points="10 17 15 12 10 7" /><line x1="15" y1="12" x2="3" y2="12" />
|
|
||||||
</svg>
|
|
||||||
{t("signIn")}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="dropdown-item danger" id="signout-btn" onClick={() => signOut({ callbackUrl: "/login" })}>
|
<div className="dropdown-item danger" id="signout-btn" onClick={() => signOut({ callbackUrl: "/login" })}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
🚪 {t("signOut")}
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" />
|
|
||||||
</svg>
|
|
||||||
{t("signOut")}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -365,6 +434,14 @@ export function Sidebar({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Settings Modal */}
|
||||||
|
<SettingsModal
|
||||||
|
isOpen={showSettings}
|
||||||
|
onClose={() => setShowSettings(false)}
|
||||||
|
user={user}
|
||||||
|
isDemo={isDemo}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Import modal */}
|
{/* Import modal */}
|
||||||
{showImport && (
|
{showImport && (
|
||||||
<div className="modal-overlay" onClick={() => setShowImport(false)}>
|
<div className="modal-overlay" onClick={() => setShowImport(false)}>
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import { useTheme } from "@/app/providers";
|
||||||
|
import { getUserSettings, saveUserSettings, UserSettings } from "@/lib/mockData";
|
||||||
|
|
||||||
|
interface SettingsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
user: { id: string; name?: string | null; email?: string | null };
|
||||||
|
isDemo?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsModal({ isOpen, onClose, user, isDemo = false }: SettingsModalProps) {
|
||||||
|
const { t, lang, setLang } = useI18n();
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<"profile" | "preferences" | "sync" | "admin">("profile");
|
||||||
|
const [displayName, setDisplayName] = useState(user.name || "Demo User");
|
||||||
|
const [email, setEmail] = useState(user.email || "demo@checkflow.local");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [trashRetention, setTrashRetention] = useState(30);
|
||||||
|
const [savedMsg, setSavedMsg] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
const s = getUserSettings();
|
||||||
|
setDisplayName(user.name || s.displayName);
|
||||||
|
setEmail(user.email || s.email);
|
||||||
|
setTrashRetention(s.trashRetentionDays ?? 30);
|
||||||
|
}
|
||||||
|
}, [isOpen, user]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const newSettings: UserSettings = {
|
||||||
|
displayName,
|
||||||
|
email,
|
||||||
|
trashRetentionDays: trashRetention,
|
||||||
|
theme,
|
||||||
|
language: lang,
|
||||||
|
};
|
||||||
|
saveUserSettings(newSettings);
|
||||||
|
setSavedMsg("✓ " + (lang === "ko" ? "설정이 저장되었습니다" : lang === "ja" ? "設定が保存されました" : "Settings saved"));
|
||||||
|
setTimeout(() => {
|
||||||
|
setSavedMsg("");
|
||||||
|
onClose();
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calDavUrl = typeof window !== "undefined" ? `${window.location.origin}/api/dav` : "https://todo.yourdomain.com/api/dav";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal settings-modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 640, width: "90%" }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
|
||||||
|
<h2 className="modal-title" style={{ marginBottom: 0 }}>⚙️ {t("settingsModalTitle") || "Settings"}</h2>
|
||||||
|
<button className="icon-btn" onClick={onClose} aria-label="Close" type="button">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Bar */}
|
||||||
|
<div className="settings-tab-bar" style={{ display: "flex", gap: 6, borderBottom: "1px solid var(--border)", marginBottom: 18, paddingBottom: 6 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${activeTab === "profile" ? "btn-primary" : "btn-ghost"}`}
|
||||||
|
onClick={() => setActiveTab("profile")}
|
||||||
|
>
|
||||||
|
👤 {t("profile") || "Profile"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${activeTab === "preferences" ? "btn-primary" : "btn-ghost"}`}
|
||||||
|
onClick={() => setActiveTab("preferences")}
|
||||||
|
>
|
||||||
|
⚙️ {t("preferences") || "Preferences"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${activeTab === "sync" ? "btn-primary" : "btn-ghost"}`}
|
||||||
|
onClick={() => setActiveTab("sync")}
|
||||||
|
>
|
||||||
|
📱 {t("syncIntegrations") || "Integrations (DAVx⁵)"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${activeTab === "admin" ? "btn-primary" : "btn-ghost"}`}
|
||||||
|
onClick={() => setActiveTab("admin")}
|
||||||
|
>
|
||||||
|
👑 {t("admin") || "Admin"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab 1: Profile */}
|
||||||
|
{activeTab === "profile" && (
|
||||||
|
<div className="settings-tab-content">
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">{t("displayName")}</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder="Your Name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">{t("email")}</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="your.email@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">{t("password")} (Change)</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="New password (leave blank to keep current)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab 2: Preferences (Trash Retention, Theme, Language) */}
|
||||||
|
{activeTab === "preferences" && (
|
||||||
|
<div className="settings-tab-content">
|
||||||
|
{/* Trash Retention Days */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label" style={{ fontWeight: 600 }}>
|
||||||
|
🗑️ {t("trashRetention") || "Trash Auto-Delete Retention Period"}
|
||||||
|
</label>
|
||||||
|
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginBottom: 8 }}>
|
||||||
|
{t("trashRetentionHint") || "Deleted tasks will be permanently removed after the specified period."}
|
||||||
|
</p>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={trashRetention}
|
||||||
|
onChange={(e) => setTrashRetention(parseInt(e.target.value, 10))}
|
||||||
|
>
|
||||||
|
<option value={7}>{t("days7") || "7 Days"}</option>
|
||||||
|
<option value={14}>{t("days14") || "14 Days"}</option>
|
||||||
|
<option value={30}>{t("days30") || "30 Days (Recommended)"}</option>
|
||||||
|
<option value={0}>{t("neverDelete") || "Never Auto-Delete (Manual empty only)"}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Language Selection */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">{t("language")}</label>
|
||||||
|
<select className="form-input" value={lang} onChange={(e) => setLang(e.target.value as any)}>
|
||||||
|
<option value="en">English (Default)</option>
|
||||||
|
<option value="ko">한국어 (Korean)</option>
|
||||||
|
<option value="ja">日本語 (Japanese)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme Toggle */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">{t("theme")}</label>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${theme === "system" ? "btn-primary" : "btn-ghost"}`}
|
||||||
|
onClick={() => { if (theme !== "system") toggleTheme(); }}
|
||||||
|
>
|
||||||
|
💻 {t("themeSystem")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${theme === "light" ? "btn-primary" : "btn-ghost"}`}
|
||||||
|
onClick={() => { if (theme !== "light") toggleTheme(); }}
|
||||||
|
>
|
||||||
|
☀️ {t("themeLight")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${theme === "dark" ? "btn-primary" : "btn-ghost"}`}
|
||||||
|
onClick={() => { if (theme !== "dark") toggleTheme(); }}
|
||||||
|
>
|
||||||
|
🌙 {t("themeDark")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab 3: CalDAV / CardDAV Integrations */}
|
||||||
|
{activeTab === "sync" && (
|
||||||
|
<div className="settings-tab-content">
|
||||||
|
<h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>📱 Galaxy & Mobile Sync via DAVx⁵</h3>
|
||||||
|
<p style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.5, marginBottom: 12 }}>
|
||||||
|
CheckFlow supports native two-way synchronization with Samsung Galaxy Reminder, Apple Reminders, and Thunderbird via CalDAV/CardDAV protocols.
|
||||||
|
</p>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">CalDAV Base Endpoint</label>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<input className="form-input" readOnly value={calDavUrl} style={{ fontFamily: "monospace", fontSize: 12 }} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(calDavUrl);
|
||||||
|
alert("Copied to clipboard!");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📋 Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "var(--bg-secondary)", padding: 12, borderRadius: "var(--radius-sm)", fontSize: 12, color: "var(--text-secondary)" }}>
|
||||||
|
<strong>Setup Instructions for DAVx⁵ (Android):</strong>
|
||||||
|
<ol style={{ paddingLeft: 18, marginTop: 6, lineHeight: 1.6 }}>
|
||||||
|
<li>Install <strong>DAVx⁵</strong> on your Samsung Galaxy from Google Play or F-Droid.</li>
|
||||||
|
<li>Add account → <strong>Login with URL and user name</strong>.</li>
|
||||||
|
<li>Base URL: <code>{calDavUrl}</code></li>
|
||||||
|
<li>User / Password: Your CheckFlow email & password.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab 4: Admin Quick Access */}
|
||||||
|
{activeTab === "admin" && (
|
||||||
|
<div className="settings-tab-content">
|
||||||
|
<h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>👑 Multi-User Admin Console</h3>
|
||||||
|
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: 14 }}>
|
||||||
|
Manage registered users, user roles (User/Admin), system statistics, and storage allocations.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/admin"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ display: "inline-flex", alignItems: "center", gap: 8 }}
|
||||||
|
>
|
||||||
|
🚀 Open Admin Dashboard →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="modal-footer" style={{ marginTop: 20, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
{savedMsg && <span style={{ fontSize: 13, color: "var(--success)", fontWeight: 600 }}>{savedMsg}</span>}
|
||||||
|
<div style={{ display: "flex", gap: 8, marginLeft: "auto" }}>
|
||||||
|
<button className="btn btn-ghost" onClick={onClose} type="button">{t("cancel")}</button>
|
||||||
|
<button className="btn btn-primary" onClick={handleSave} type="button">
|
||||||
|
{t("save") || "Save Changes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState, useRef, useCallback } from "react";
|
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
@@ -13,6 +13,17 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [mode, setMode] = useState<"edit" | "preview">("preview");
|
const [mode, setMode] = useState<"edit" | "preview">("preview");
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Configure marked to open links in new tabs safely
|
||||||
|
useEffect(() => {
|
||||||
|
const renderer = new marked.Renderer();
|
||||||
|
renderer.link = ({ href, title, text }: { href: string; title?: string | null; text: string }) => {
|
||||||
|
const titleAttr = title ? ` title="${title}"` : "";
|
||||||
|
return `<a href="${href}" target="_blank" rel="noopener noreferrer"${titleAttr} class="markdown-link">${text}</a>`;
|
||||||
|
};
|
||||||
|
marked.use({ renderer, breaks: true, gfm: true });
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Insert markdown syntax at selection or cursor position
|
// Insert markdown syntax at selection or cursor position
|
||||||
const insertSyntax = useCallback(
|
const insertSyntax = useCallback(
|
||||||
@@ -74,14 +85,23 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
|
|||||||
onChange(newLines.join("\n"));
|
onChange(newLines.join("\n"));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert markdown to sanitized HTML with interactive checkboxes
|
// Convert markdown to sanitized HTML with interactive checkboxes and autolinks
|
||||||
const renderMarkdownHtml = () => {
|
const renderMarkdownHtml = () => {
|
||||||
if (!value || !value.trim()) {
|
if (!value || !value.trim()) {
|
||||||
return `<p style="color: var(--text-tertiary); font-style: italic;">${t("notesPlaceholder").split("\n")[0]}</p>`;
|
return `<p style="color: var(--text-tertiary); font-style: italic;">${t("notesPlaceholder").split("\n")[0]}</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let rawHtml = marked.parse(value, { breaks: true, gfm: true }) as string;
|
// Auto-detect plain URLs and wrap them in markdown links if not already wrapped
|
||||||
|
const textWithLinks = value.replace(
|
||||||
|
/(^|[^"'])(https?:\/\/[^\s<]+)/g,
|
||||||
|
(match, prefix, url) => {
|
||||||
|
if (match.includes("](") || match.includes('href="')) return match;
|
||||||
|
return `${prefix}[${url}](${url})`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let rawHtml = marked.parse(textWithLinks, { breaks: true, gfm: true }) as string;
|
||||||
|
|
||||||
// Replace task list checkboxes with interactive ones
|
// Replace task list checkboxes with interactive ones
|
||||||
let cbIdx = 0;
|
let cbIdx = 0;
|
||||||
@@ -303,10 +323,22 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
ref={previewRef}
|
||||||
className="markdown-body"
|
className="markdown-body"
|
||||||
dangerouslySetInnerHTML={{ __html: renderMarkdownHtml() }}
|
dangerouslySetInnerHTML={{ __html: renderMarkdownHtml() }}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
|
|
||||||
|
// Handle link clicks cleanly
|
||||||
|
const link = target.closest("a");
|
||||||
|
if (link && link.href) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
window.open(link.href, "_blank", "noopener,noreferrer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle interactive checkbox clicks
|
||||||
if (target.classList.contains("markdown-checkbox-box")) {
|
if (target.classList.contains("markdown-checkbox-box")) {
|
||||||
const idxStr = target.getAttribute("data-idx");
|
const idxStr = target.getAttribute("data-idx");
|
||||||
if (idxStr) {
|
if (idxStr) {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { Task } from "./TaskList";
|
import { Task, Tag } from "./TaskList";
|
||||||
import { MarkdownNoteEditor } from "./MarkdownNoteEditor";
|
import { MarkdownNoteEditor } from "./MarkdownNoteEditor";
|
||||||
|
import { getCustomTags, MockTag } from "@/lib/mockData";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
task: Task;
|
task: Task;
|
||||||
@@ -33,11 +34,28 @@ export function TaskDetail({
|
|||||||
const [note, setNote] = useState(task.note || "");
|
const [note, setNote] = useState(task.note || "");
|
||||||
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 [allAvailableTags, setAllAvailableTags] = useState<MockTag[]>([]);
|
||||||
|
const [showTagPicker, setShowTagPicker] = useState(false);
|
||||||
|
const [newTagName, setNewTagName] = useState("");
|
||||||
|
|
||||||
const [newSubtitle, setNewSubtitle] = useState("");
|
const [newSubtitle, setNewSubtitle] = useState("");
|
||||||
const [subtasks, setSubtasks] = useState(task.children || []);
|
const [subtasks, setSubtasks] = useState(task.children || []);
|
||||||
const [showSubtasks, setShowSubtasks] = useState(true);
|
const [showSubtasks, setShowSubtasks] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Mobile Bottom-Sheet: swipe down to close
|
||||||
|
const touchStartY = useRef<number | null>(null);
|
||||||
|
const [panelTranslateY, setPanelTranslateY] = useState(0);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const check = () => setIsMobile(window.innerWidth <= 768);
|
||||||
|
check();
|
||||||
|
window.addEventListener("resize", check);
|
||||||
|
return () => window.removeEventListener("resize", check);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const currentTaskId = useRef(task.id);
|
const currentTaskId = useRef(task.id);
|
||||||
|
|
||||||
@@ -59,8 +77,10 @@ export function TaskDetail({
|
|||||||
setNote(task.note || "");
|
setNote(task.note || "");
|
||||||
setDueDate(task.dueDate ? task.dueDate.split("T")[0] : "");
|
setDueDate(task.dueDate ? task.dueDate.split("T")[0] : "");
|
||||||
setPriority(task.priority);
|
setPriority(task.priority);
|
||||||
|
setTags(task.tags || []);
|
||||||
setSubtasks(task.children || []);
|
setSubtasks(task.children || []);
|
||||||
}, [task.id, task.title, task.note, task.dueDate, task.priority, task.children]);
|
setAllAvailableTags(getCustomTags());
|
||||||
|
}, [task.id, task.title, task.note, task.dueDate, task.priority, task.children, task.tags]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -78,6 +98,7 @@ export function TaskDetail({
|
|||||||
...task,
|
...task,
|
||||||
...data,
|
...data,
|
||||||
children: subtasks,
|
children: subtasks,
|
||||||
|
tags,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
} as Task;
|
} as Task;
|
||||||
if (onDemoUpdateTask) onDemoUpdateTask(updatedTask);
|
if (onDemoUpdateTask) onDemoUpdateTask(updatedTask);
|
||||||
@@ -95,25 +116,26 @@ export function TaskDetail({
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const updated = await res.json();
|
const updated = await res.json();
|
||||||
if (taskId === currentTaskId.current) {
|
if (taskId === currentTaskId.current) {
|
||||||
onUpdate({ ...updated, children: subtasks });
|
onUpdate({ ...updated, children: subtasks, tags });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[TaskDetail] save failed", err);
|
console.error("[TaskDetail] save failed", err);
|
||||||
} finally {
|
} finally {
|
||||||
if (taskId === currentTaskId.current) setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isDemo, onDemoUpdateTask, onUpdate, subtasks, task]
|
[isDemo, onDemoUpdateTask, onUpdate, subtasks, tags, task]
|
||||||
);
|
);
|
||||||
|
|
||||||
const debounceSave = useCallback(
|
const debounceSave = useCallback(
|
||||||
(overrides: Record<string, unknown>) => {
|
(data: Record<string, unknown>) => {
|
||||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||||
const taskId = currentTaskId.current;
|
saveTimer.current = setTimeout(() => {
|
||||||
saveTimer.current = setTimeout(() => save(taskId, overrides), 600);
|
save(task.id, data);
|
||||||
|
}, 500);
|
||||||
},
|
},
|
||||||
[save]
|
[save, task.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNoteChange = (newNote: string) => {
|
const handleNoteChange = (newNote: string) => {
|
||||||
@@ -121,40 +143,16 @@ export function TaskDetail({
|
|||||||
debounceSave({ note: newNote });
|
debounceSave({ note: newNote });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleCompleted = useCallback(
|
const handleToggleComplete = useCallback(async () => {
|
||||||
async (newCompleted: boolean) => {
|
const nextCompleted = !task.completed;
|
||||||
if (isDemo) {
|
save(task.id, {
|
||||||
const updatedTask: Task = {
|
completed: nextCompleted,
|
||||||
...task,
|
completedAt: nextCompleted ? new Date().toISOString() : null,
|
||||||
completed: newCompleted,
|
});
|
||||||
completedAt: newCompleted ? new Date().toISOString() : null,
|
}, [save, task.id, task.completed]);
|
||||||
children: subtasks,
|
|
||||||
};
|
|
||||||
if (onDemoUpdateTask) onDemoUpdateTask(updatedTask);
|
|
||||||
onUpdate(updatedTask);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/tasks/${task.id}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ completed: newCompleted }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const updated = await res.json();
|
|
||||||
onUpdate({ ...updated, children: subtasks });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[TaskDetail] toggle failed", err);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isDemo, onDemoUpdateTask, task, subtasks, onUpdate]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDelete = useCallback(async () => {
|
const handleDelete = useCallback(async () => {
|
||||||
if (!confirm(t("deleteTaskConfirm"))) return;
|
if (!confirm(t("deleteTaskConfirm"))) return;
|
||||||
|
|
||||||
if (isDemo) {
|
if (isDemo) {
|
||||||
if (onDemoDeleteTask) onDemoDeleteTask(task.id);
|
if (onDemoDeleteTask) onDemoDeleteTask(task.id);
|
||||||
onDelete();
|
onDelete();
|
||||||
@@ -169,6 +167,32 @@ export function TaskDetail({
|
|||||||
}
|
}
|
||||||
}, [t, isDemo, onDemoDeleteTask, task.id, onDelete]);
|
}, [t, isDemo, onDemoDeleteTask, task.id, onDelete]);
|
||||||
|
|
||||||
|
// Tag Management
|
||||||
|
const toggleTag = (tag: MockTag) => {
|
||||||
|
const exists = tags.some((tItem) => tItem.tag.id === tag.id);
|
||||||
|
let nextTags: { tag: Tag }[];
|
||||||
|
if (exists) {
|
||||||
|
nextTags = tags.filter((tItem) => tItem.tag.id !== tag.id);
|
||||||
|
} else {
|
||||||
|
nextTags = [...tags, { tag }];
|
||||||
|
}
|
||||||
|
setTags(nextTags);
|
||||||
|
save(task.id, { tags: nextTags });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCustomTag = () => {
|
||||||
|
const trimmed = newTagName.trim().replace(/^#/, "");
|
||||||
|
if (!trimmed) return;
|
||||||
|
const newTag: MockTag = {
|
||||||
|
id: "tag-" + Date.now(),
|
||||||
|
name: trimmed,
|
||||||
|
color: ["#4B7BF5", "#10B981", "#EF4444", "#F59E0B", "#8B5CF6"][Math.floor(Math.random() * 5)],
|
||||||
|
};
|
||||||
|
setAllAvailableTags((p) => [...p, newTag]);
|
||||||
|
toggleTag(newTag);
|
||||||
|
setNewTagName("");
|
||||||
|
};
|
||||||
|
|
||||||
const addSubtask = useCallback(async () => {
|
const addSubtask = useCallback(async () => {
|
||||||
const tTitle = newSubtitle.trim();
|
const tTitle = newSubtitle.trim();
|
||||||
if (!tTitle) return;
|
if (!tTitle) return;
|
||||||
@@ -254,13 +278,13 @@ export function TaskDetail({
|
|||||||
console.error("[TaskDetail] toggleSubtask failed", err);
|
console.error("[TaskDetail] toggleSubtask failed", err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isDemo, subtasks, task, onDemoUpdateTask, onUpdate]
|
[isDemo, onDemoUpdateTask, onUpdate, subtasks, task]
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteSubtask = useCallback(
|
const deleteSubtask = useCallback(
|
||||||
async (id: string) => {
|
async (subId: string) => {
|
||||||
if (isDemo) {
|
if (isDemo) {
|
||||||
const nextSubs = subtasks.filter((s) => s.id !== id);
|
const nextSubs = subtasks.filter((s) => s.id !== subId);
|
||||||
setSubtasks(nextSubs);
|
setSubtasks(nextSubs);
|
||||||
const updatedParent = { ...task, children: nextSubs };
|
const updatedParent = { ...task, children: nextSubs };
|
||||||
if (onDemoUpdateTask) onDemoUpdateTask(updatedParent);
|
if (onDemoUpdateTask) onDemoUpdateTask(updatedParent);
|
||||||
@@ -269,9 +293,9 @@ export function TaskDetail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/tasks/${id}`, { method: "DELETE" });
|
await fetch(`/api/tasks/${subId}`, { method: "DELETE" });
|
||||||
setSubtasks((prev) => {
|
setSubtasks((prev) => {
|
||||||
const next = prev.filter((s) => s.id !== id);
|
const next = prev.filter((s) => s.id !== subId);
|
||||||
onUpdate({ ...task, children: next });
|
onUpdate({ ...task, children: next });
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -279,42 +303,91 @@ export function TaskDetail({
|
|||||||
console.error("[TaskDetail] deleteSubtask failed", err);
|
console.error("[TaskDetail] deleteSubtask failed", err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isDemo, subtasks, task, onDemoUpdateTask, onUpdate]
|
[isDemo, onDemoUpdateTask, onUpdate, subtasks, task]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Touch handlers: 모바일에서 아래로 스와이프 → 패널 닫기
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
touchStartY.current = e.touches[0].clientY;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: React.TouchEvent) => {
|
||||||
|
if (touchStartY.current === null) return;
|
||||||
|
const deltaY = e.touches[0].clientY - touchStartY.current;
|
||||||
|
if (deltaY > 0) {
|
||||||
|
setPanelTranslateY(deltaY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
if (panelTranslateY > 120) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
setPanelTranslateY(0);
|
||||||
|
touchStartY.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
const completedCount = subtasks.filter((s) => s.completed).length;
|
const completedCount = subtasks.filter((s) => s.completed).length;
|
||||||
const progressPct = subtasks.length > 0 ? Math.round((completedCount / subtasks.length) * 100) : 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="detail-panel">
|
<aside
|
||||||
{/* Header Bar */}
|
className="detail-panel mobile-open"
|
||||||
<div className="detail-header">
|
onTouchStart={handleTouchStart}
|
||||||
<button
|
onTouchMove={handleTouchMove}
|
||||||
className={`task-check-btn${task.completed ? " checked" : ""}`}
|
onTouchEnd={handleTouchEnd}
|
||||||
style={{ width: 22, height: 22 }}
|
style={{
|
||||||
onClick={() => handleToggleCompleted(!task.completed)}
|
transform: isMobile && panelTranslateY > 0 ? `translateY(${panelTranslateY}px)` : undefined,
|
||||||
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
|
transition: panelTranslateY === 0 ? "transform 0.3s cubic-bezier(0.16, 1, 0.3, 1)" : "none",
|
||||||
/>
|
}}
|
||||||
<span style={{ flex: 1, fontSize: 12, color: "var(--text-tertiary)", fontWeight: 500 }}>
|
>
|
||||||
{saving ? t("saving") : t("autoSaved")}
|
{/* Mobile Swipe Handle Indicator */}
|
||||||
</span>
|
<div className="mobile-swipe-handle mobile-only" style={{ width: 36, height: 4, background: "var(--border)", borderRadius: 2, margin: "6px auto 0" }} />
|
||||||
<button className="btn btn-ghost btn-sm btn-danger" id="delete-task-btn" onClick={handleDelete} title={t("deleteTaskConfirm")}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
{/* Top action bar */}
|
||||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
|
<div className="detail-header" style={{ padding: "10px 16px 8px" }}>
|
||||||
</svg>
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
</button>
|
<button
|
||||||
<button className="detail-close-btn" id="detail-close-btn" onClick={onClose} title={t("cancel")}>
|
className={`task-check-btn${task.completed ? " checked" : ""}`}
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
onClick={handleToggleComplete}
|
||||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
|
||||||
</svg>
|
type="button"
|
||||||
</button>
|
/>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>
|
||||||
|
{saving ? t("saving") : t("autoSaved")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-actions">
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
id="delete-task-btn"
|
||||||
|
onClick={handleDelete}
|
||||||
|
title={t("deleteTaskConfirm").split("?")[0]}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
id="close-detail-btn"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Body - Centered on Wide Memo Experience */}
|
{/* Main scrollable body */}
|
||||||
<div className="detail-body" style={{ display: "flex", flexDirection: "column", height: "100%", gap: 14 }}>
|
<div className="detail-scroll" style={{ display: "flex", flexDirection: "column", height: "calc(100% - 48px)", padding: "8px 16px 12px", gap: 10 }}>
|
||||||
{/* Title */}
|
{/* Title input */}
|
||||||
<textarea
|
<textarea
|
||||||
id="detail-title"
|
id="task-title-input"
|
||||||
className="detail-title-input"
|
className="detail-title-input"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -331,8 +404,8 @@ export function TaskDetail({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Compact Metadata Strip (Priority & Due Date in a single line) */}
|
{/* Compact Metadata Strip (Priority, Due Date & Tags) */}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap", paddingBottom: 4, borderBottom: "1px solid var(--border)" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap", paddingBottom: 6, borderBottom: "1px solid var(--border)" }}>
|
||||||
{/* Priority */}
|
{/* Priority */}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||||
<span style={{ fontSize: 11, fontWeight: 600, color: "var(--text-tertiary)", textTransform: "uppercase" }}>{t("priority")}:</span>
|
<span style={{ fontSize: 11, fontWeight: 600, color: "var(--text-tertiary)", textTransform: "uppercase" }}>{t("priority")}:</span>
|
||||||
@@ -357,7 +430,7 @@ export function TaskDetail({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Due Date */}
|
{/* Due Date */}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 6, marginLeft: "auto" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
<input
|
<input
|
||||||
id="due-date-input"
|
id="due-date-input"
|
||||||
type="date"
|
type="date"
|
||||||
@@ -383,9 +456,101 @@ export function TaskDetail({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tags Trigger Chip & Popover */}
|
||||||
|
<div style={{ position: "relative", marginLeft: "auto" }}>
|
||||||
|
<button
|
||||||
|
className="tick-meta-chip"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowTagPicker((p) => !p)}
|
||||||
|
style={{ padding: "3px 8px", fontSize: 11 }}
|
||||||
|
>
|
||||||
|
🏷️ {tags.length > 0 ? `${tags.length} tags` : "+ Tag"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showTagPicker && (
|
||||||
|
<div
|
||||||
|
className="dropdown"
|
||||||
|
style={{ right: 0, top: "calc(100% + 4px)", minWidth: 200, padding: 8, zIndex: 100 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", marginBottom: 6 }}>CUSTOM TAGS</div>
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 8 }}>
|
||||||
|
{allAvailableTags.map((at) => {
|
||||||
|
const active = tags.some((tg) => tg.tag.id === at.id);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={at.id}
|
||||||
|
className="badge"
|
||||||
|
style={{
|
||||||
|
background: active ? at.color : "var(--bg-primary)",
|
||||||
|
color: active ? "#fff" : at.color,
|
||||||
|
border: `1px solid ${at.color}`,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 11,
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: 4,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
onClick={() => toggleTag(at)}
|
||||||
|
>
|
||||||
|
#{at.name} {active ? "✓" : ""}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* Create custom tag input */}
|
||||||
|
<div style={{ display: "flex", gap: 4 }}>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
placeholder="New tag..."
|
||||||
|
value={newTagName}
|
||||||
|
onChange={(e) => setNewTagName(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") addCustomTag(); }}
|
||||||
|
style={{ fontSize: 11, height: 26, padding: "2px 6px" }}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary btn-sm" type="button" onClick={addCustomTag} style={{ fontSize: 11, padding: "2px 8px" }}>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* The Wide Adaptive Markdown Note Editor (Takes ALL Remaining Space) */}
|
{/* Selected Tags Display */}
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, paddingBottom: 4 }}>
|
||||||
|
{tags.map((tg) => (
|
||||||
|
<span
|
||||||
|
key={tg.tag.id}
|
||||||
|
className="badge"
|
||||||
|
style={{
|
||||||
|
background: tg.tag.color + "22",
|
||||||
|
color: tg.tag.color,
|
||||||
|
fontSize: 11,
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: 4,
|
||||||
|
fontWeight: 600,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
#{tg.tag.name}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleTag(tg.tag as MockTag)}
|
||||||
|
style={{ background: "none", border: "none", color: "inherit", cursor: "pointer", padding: 0, fontSize: 10, opacity: 0.7 }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* The Wide Adaptive Markdown Note Editor */}
|
||||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
|
<div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
|
||||||
<MarkdownNoteEditor
|
<MarkdownNoteEditor
|
||||||
value={note}
|
value={note}
|
||||||
@@ -431,42 +596,68 @@ export function TaskDetail({
|
|||||||
|
|
||||||
{showSubtasks && (
|
{showSubtasks && (
|
||||||
<div>
|
<div>
|
||||||
|
{/* Progress bar */}
|
||||||
{subtasks.length > 0 && (
|
{subtasks.length > 0 && (
|
||||||
<div className="progress-bar" style={{ marginBottom: 8, height: 3 }}>
|
<div style={{ height: 3, background: "var(--bg-hover)", borderRadius: 2, marginBottom: 10, overflow: "hidden" }}>
|
||||||
<div className="progress-bar-fill" style={{ width: `${progressPct}%` }} />
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
width: `${(completedCount / subtasks.length) * 100}%`,
|
||||||
|
background: "var(--accent)",
|
||||||
|
borderRadius: 2,
|
||||||
|
transition: "width var(--dur-normal) var(--ease-out)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="detail-subtasks" style={{ maxHeight: 160, overflowY: "auto" }}>
|
{/* Sub-tasks list */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 4, maxHeight: 180, overflowY: "auto" }}>
|
||||||
{subtasks.map((sub) => (
|
{subtasks.map((sub) => (
|
||||||
<div
|
<div
|
||||||
key={sub.id}
|
key={sub.id}
|
||||||
className={`detail-subtask-row${sub.completed ? " completed" : ""}`}
|
style={{
|
||||||
id={`detail-sub-${sub.id}`}
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
background: "var(--bg-secondary)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className={`subtask-check-btn${sub.completed ? " checked" : ""}`}
|
className={`task-check-btn${sub.completed ? " checked" : ""}`}
|
||||||
|
style={{ width: 15, height: 15, flexShrink: 0 }}
|
||||||
onClick={() => toggleSubtask(sub)}
|
onClick={() => toggleSubtask(sub)}
|
||||||
aria-label="Toggle subtask"
|
aria-label="Toggle subtask"
|
||||||
type="button"
|
type="button"
|
||||||
/>
|
/>
|
||||||
<span className="detail-subtask-title">{sub.title}</span>
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 12.5,
|
||||||
|
textDecoration: sub.completed ? "line-through" : "none",
|
||||||
|
color: sub.completed ? "var(--text-tertiary)" : "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sub.title}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
style={{ width: 20, height: 20, opacity: 0.4 }}
|
style={{ width: 20, height: 20, opacity: 0.4 }}
|
||||||
onClick={() => deleteSubtask(sub.id)}
|
onClick={() => deleteSubtask(sub.id)}
|
||||||
aria-label="Delete subtask"
|
title="Delete subtask"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
✕
|
||||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="add-subtask-row" style={{ padding: "4px 8px" }}>
|
|
||||||
<span style={{ fontSize: 15, lineHeight: 1, color: "var(--accent)" }}>+</span>
|
{/* Add Subtask Input */}
|
||||||
|
<div style={{ display: "flex", gap: 6, marginTop: 4 }}>
|
||||||
<input
|
<input
|
||||||
|
className="form-input"
|
||||||
id="add-subtask-input"
|
id="add-subtask-input"
|
||||||
placeholder={t("addSubtaskPlaceholder")}
|
placeholder={t("addSubtaskPlaceholder")}
|
||||||
style={{ flex: 1, background: "none", fontSize: 12.5, color: "var(--text-primary)" }}
|
style={{ flex: 1, background: "none", fontSize: 12.5, color: "var(--text-primary)" }}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu";
|
import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu";
|
||||||
|
|
||||||
|
export interface Tag { id: string; name: string; color: string }
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
listId: string;
|
listId: string;
|
||||||
@@ -16,8 +18,10 @@ export interface Task {
|
|||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
deletedAt?: string | null;
|
||||||
|
isDeleted?: boolean;
|
||||||
children: Task[];
|
children: Task[];
|
||||||
tags?: { tag: { id: string; name: string; color: string } }[];
|
tags?: { tag: Tag }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface List { id: string; name: string; color: string; icon: string }
|
export interface List { id: string; name: string; color: string; icon: string }
|
||||||
@@ -39,6 +43,9 @@ interface TaskItemProps {
|
|||||||
onUpdateTitle: (id: string, title: string) => void;
|
onUpdateTitle: (id: string, title: string) => void;
|
||||||
onAddSubtask: (title: string, parentId: string) => void;
|
onAddSubtask: (title: string, parentId: string) => void;
|
||||||
onContextMenu: (x: number, y: number, task: Task) => void;
|
onContextMenu: (x: number, y: number, task: Task) => void;
|
||||||
|
isTrashMode?: boolean;
|
||||||
|
onRestore?: (id: string) => void;
|
||||||
|
onPermanentDelete?: (id: string) => 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)
|
||||||
@@ -51,6 +58,9 @@ function RecursiveTaskItem({
|
|||||||
onUpdateTitle,
|
onUpdateTitle,
|
||||||
onAddSubtask,
|
onAddSubtask,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
|
isTrashMode = false,
|
||||||
|
onRestore,
|
||||||
|
onPermanentDelete,
|
||||||
}: TaskItemProps) {
|
}: TaskItemProps) {
|
||||||
const { t, lang } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
@@ -60,6 +70,10 @@ function RecursiveTaskItem({
|
|||||||
const [addingSubtask, setAddingSubtask] = useState(false);
|
const [addingSubtask, setAddingSubtask] = useState(false);
|
||||||
const [subtaskInput, setSubtaskInput] = useState("");
|
const [subtaskInput, setSubtaskInput] = useState("");
|
||||||
|
|
||||||
|
// Touch swipe support
|
||||||
|
const touchStartX = useRef<number | null>(null);
|
||||||
|
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||||
|
|
||||||
const editInputRef = useRef<HTMLInputElement>(null);
|
const editInputRef = useRef<HTMLInputElement>(null);
|
||||||
const subInputRef = useRef<HTMLInputElement>(null);
|
const subInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -117,11 +131,37 @@ function RecursiveTaskItem({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Touch Swipe handlers
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
if (isTrashMode) return;
|
||||||
|
touchStartX.current = e.touches[0].clientX;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: React.TouchEvent) => {
|
||||||
|
if (touchStartX.current === null) return;
|
||||||
|
const deltaX = e.touches[0].clientX - touchStartX.current;
|
||||||
|
if (Math.abs(deltaX) < 120) {
|
||||||
|
setSwipeOffset(deltaX);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
if (swipeOffset > 70) {
|
||||||
|
// Swiped Right -> Toggle Complete
|
||||||
|
onToggle(task.id, !task.completed);
|
||||||
|
}
|
||||||
|
setSwipeOffset(0);
|
||||||
|
touchStartX.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
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" : ""}`}
|
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}`}
|
||||||
onClick={() => onSelect(task)}
|
onClick={() => onSelect(task)}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -131,6 +171,8 @@ function RecursiveTaskItem({
|
|||||||
style={{
|
style={{
|
||||||
borderLeft: depth > 0 ? "2px solid var(--border)" : "none",
|
borderLeft: depth > 0 ? "2px solid var(--border)" : "none",
|
||||||
marginLeft: depth > 0 ? 8 : 0,
|
marginLeft: depth > 0 ? 8 : 0,
|
||||||
|
transform: `translateX(${swipeOffset}px)`,
|
||||||
|
transition: swipeOffset === 0 ? "transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)" : "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Toggle Expand Arrow if has children */}
|
{/* Toggle Expand Arrow if has children */}
|
||||||
@@ -161,21 +203,25 @@ function RecursiveTaskItem({
|
|||||||
<span style={{ width: 12, flexShrink: 0 }} />
|
<span style={{ width: 12, flexShrink: 0 }} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Check button */}
|
{/* Check button (hidden in trash mode) */}
|
||||||
<button
|
{!isTrashMode ? (
|
||||||
className={`task-check-btn${task.completed ? " checked" : ""}`}
|
<button
|
||||||
onClick={(e) => {
|
className={`task-check-btn${task.completed ? " checked" : ""}`}
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
onToggle(task.id, !task.completed);
|
e.stopPropagation();
|
||||||
}}
|
onToggle(task.id, !task.completed);
|
||||||
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
|
}}
|
||||||
type="button"
|
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
|
||||||
style={{ width: depth > 0 ? 17 : 19, height: depth > 0 ? 17 : 19, flexShrink: 0 }}
|
type="button"
|
||||||
/>
|
style={{ width: depth > 0 ? 17 : 19, height: depth > 0 ? 17 : 19, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: 13, opacity: 0.5, flexShrink: 0 }}>🗑️</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Title and Meta */}
|
{/* Title, Meta and Tags */}
|
||||||
<div className="task-body">
|
<div className="task-body">
|
||||||
{editingTitle ? (
|
{editingTitle && !isTrashMode ? (
|
||||||
<input
|
<input
|
||||||
ref={editInputRef}
|
ref={editInputRef}
|
||||||
className="form-input"
|
className="form-input"
|
||||||
@@ -196,6 +242,7 @@ function RecursiveTaskItem({
|
|||||||
<div
|
<div
|
||||||
className="task-title"
|
className="task-title"
|
||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
|
if (isTrashMode) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setEditingTitle(true);
|
setEditingTitle(true);
|
||||||
}}
|
}}
|
||||||
@@ -206,15 +253,15 @@ function RecursiveTaskItem({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="task-meta">
|
<div className="task-meta" style={{ flexWrap: "wrap", gap: 6 }}>
|
||||||
{task.priority > 0 && (
|
{task.priority > 0 && !isTrashMode && (
|
||||||
<div
|
<div
|
||||||
className="task-priority-dot"
|
className="task-priority-dot"
|
||||||
style={{ background: PRIORITY_COLORS[task.priority] }}
|
style={{ background: PRIORITY_COLORS[task.priority] }}
|
||||||
title={priorityLabels[task.priority]}
|
title={priorityLabels[task.priority]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{task.dueDate && (
|
{task.dueDate && !isTrashMode && (
|
||||||
<span className={`task-due${isOverdue(task.dueDate) && !task.completed ? " overdue" : ""}`}>
|
<span className={`task-due${isOverdue(task.dueDate) && !task.completed ? " overdue" : ""}`}>
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" />
|
<rect x="3" y="4" width="18" height="18" rx="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" />
|
||||||
@@ -222,7 +269,7 @@ function RecursiveTaskItem({
|
|||||||
{formatDate(task.dueDate)}
|
{formatDate(task.dueDate)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{totalChildren > 0 && (
|
{totalChildren > 0 && !isTrashMode && (
|
||||||
<span
|
<span
|
||||||
className="task-sub-count"
|
className="task-sub-count"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -235,20 +282,44 @@ function RecursiveTaskItem({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tags Badges */}
|
||||||
|
{task.tags && task.tags.length > 0 && (
|
||||||
|
<div style={{ display: "flex", gap: 4 }}>
|
||||||
|
{task.tags.map((tg) => (
|
||||||
|
<span
|
||||||
|
key={tg.tag.id}
|
||||||
|
className="badge"
|
||||||
|
style={{
|
||||||
|
background: tg.tag.color + "22",
|
||||||
|
color: tg.tag.color,
|
||||||
|
fontSize: 10,
|
||||||
|
padding: "1px 6px",
|
||||||
|
borderRadius: 4,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
#{tg.tag.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Quick Inline Add Subtask button */}
|
{/* Quick Inline Add Subtask button */}
|
||||||
<button
|
{!isTrashMode && (
|
||||||
className="badge badge-neutral"
|
<button
|
||||||
style={{ cursor: "pointer", fontSize: 10, padding: "1px 6px", border: "1px solid var(--border)", background: "transparent" }}
|
className="badge badge-neutral"
|
||||||
onClick={(e) => {
|
style={{ cursor: "pointer", fontSize: 10, padding: "1px 6px", border: "1px solid var(--border)", background: "transparent" }}
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
setAddingSubtask(true);
|
e.stopPropagation();
|
||||||
setExpanded(true);
|
setAddingSubtask(true);
|
||||||
}}
|
setExpanded(true);
|
||||||
title="Add subtask"
|
}}
|
||||||
type="button"
|
title="Add subtask"
|
||||||
>
|
type="button"
|
||||||
+ {t("subtasks")}
|
>
|
||||||
</button>
|
+ {t("subtasks")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{task.note && !task.completed && depth === 0 && (
|
{task.note && !task.completed && depth === 0 && (
|
||||||
@@ -256,21 +327,50 @@ function RecursiveTaskItem({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Edit icon on hover */}
|
{/* Trash Mode Actions or Edit Icon */}
|
||||||
<button
|
{isTrashMode ? (
|
||||||
className="icon-btn"
|
<div style={{ display: "flex", gap: 6 }}>
|
||||||
style={{ width: 22, height: 22, opacity: 0.35, flexShrink: 0 }}
|
<button
|
||||||
onClick={(e) => {
|
className="btn btn-ghost btn-sm"
|
||||||
e.stopPropagation();
|
style={{ fontSize: 11, padding: "2px 8px" }}
|
||||||
setEditingTitle(true);
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
title="Edit title"
|
if (onRestore) onRestore(task.id);
|
||||||
type="button"
|
}}
|
||||||
>
|
title="Restore task"
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
type="button"
|
||||||
<path d="M12 20h9" /><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
>
|
||||||
</svg>
|
↩️ Restore
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
style={{ fontSize: 11, padding: "2px 8px", color: "var(--danger)" }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onPermanentDelete) onPermanentDelete(task.id);
|
||||||
|
}}
|
||||||
|
title="Delete permanently"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
❌
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
style={{ width: 22, height: 22, opacity: 0.35, flexShrink: 0 }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditingTitle(true);
|
||||||
|
}}
|
||||||
|
title="Edit title"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 20h9" /><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recursive Children (N-Depth Sub-tasks) */}
|
{/* Recursive Children (N-Depth Sub-tasks) */}
|
||||||
@@ -287,13 +387,16 @@ function RecursiveTaskItem({
|
|||||||
onUpdateTitle={onUpdateTitle}
|
onUpdateTitle={onUpdateTitle}
|
||||||
onAddSubtask={onAddSubtask}
|
onAddSubtask={onAddSubtask}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
|
isTrashMode={isTrashMode}
|
||||||
|
onRestore={onRestore}
|
||||||
|
onPermanentDelete={onPermanentDelete}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Inline Add Subtask form for this node */}
|
{/* Inline Add Subtask form for this node */}
|
||||||
{addingSubtask && (
|
{addingSubtask && !isTrashMode && (
|
||||||
<form
|
<form
|
||||||
onSubmit={handleCreateSubtask}
|
onSubmit={handleCreateSubtask}
|
||||||
style={{ display: "flex", gap: 6, padding: "4px 0 4px 32px" }}
|
style={{ display: "flex", gap: 6, padding: "4px 0 4px 32px" }}
|
||||||
@@ -336,6 +439,11 @@ interface Props {
|
|||||||
onMenuOpen: () => void;
|
onMenuOpen: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
isDemo?: boolean;
|
isDemo?: boolean;
|
||||||
|
isTrashActive?: boolean;
|
||||||
|
selectedTag?: string | null;
|
||||||
|
onEmptyTrash?: () => void;
|
||||||
|
onRestoreTask?: (id: string) => void;
|
||||||
|
onPermanentDeleteTask?: (id: string) => void;
|
||||||
onDemoAddTask?: (title: string, listId: string, parentId?: string | null) => void;
|
onDemoAddTask?: (title: string, listId: string, parentId?: string | null) => void;
|
||||||
onDemoToggleTask?: (id: string, completed: boolean) => void;
|
onDemoToggleTask?: (id: string, completed: boolean) => void;
|
||||||
onUpdateTaskTitle?: (id: string, title: string) => void;
|
onUpdateTaskTitle?: (id: string, title: string) => void;
|
||||||
@@ -356,6 +464,11 @@ export function TaskList({
|
|||||||
onMenuOpen,
|
onMenuOpen,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
isDemo = false,
|
isDemo = false,
|
||||||
|
isTrashActive = false,
|
||||||
|
selectedTag = null,
|
||||||
|
onEmptyTrash,
|
||||||
|
onRestoreTask,
|
||||||
|
onPermanentDeleteTask,
|
||||||
onDemoAddTask,
|
onDemoAddTask,
|
||||||
onDemoToggleTask,
|
onDemoToggleTask,
|
||||||
onUpdateTaskTitle,
|
onUpdateTaskTitle,
|
||||||
@@ -385,7 +498,7 @@ export function TaskList({
|
|||||||
}, [editingHeader]);
|
}, [editingHeader]);
|
||||||
|
|
||||||
const fetchTasks = useCallback(async () => {
|
const fetchTasks = useCallback(async () => {
|
||||||
if (!listId || isDemo) return;
|
if (!listId || isDemo || isTrashActive || selectedTag) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/tasks?listId=${listId}&showCompleted=${showCompleted}`);
|
const res = await fetch(`/api/tasks?listId=${listId}&showCompleted=${showCompleted}`);
|
||||||
@@ -397,7 +510,7 @@ export function TaskList({
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [listId, showCompleted, isDemo, setTasks]);
|
}, [listId, showCompleted, isDemo, isTrashActive, selectedTag, setTasks]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTasks();
|
fetchTasks();
|
||||||
@@ -489,7 +602,7 @@ export function TaskList({
|
|||||||
setEditingHeader(false);
|
setEditingHeader(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!listId) {
|
if (!listId && !isTrashActive && !selectedTag) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--text-tertiary)" }}>
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--text-tertiary)" }}>
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ opacity: 0.3, marginBottom: 12 }}>
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ opacity: 0.3, marginBottom: 12 }}>
|
||||||
@@ -514,8 +627,18 @@ export function TaskList({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Inline Editable List Title */}
|
{/* Header Title / Trash Header / Tag Header */}
|
||||||
{editingHeader ? (
|
{isTrashActive ? (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 18, fontWeight: 700, color: "var(--danger)" }}>🗑️ {t("trash") || "Trash"}</span>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--text-tertiary)" }}>({tasks.length})</span>
|
||||||
|
</div>
|
||||||
|
) : selectedTag ? (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 18, fontWeight: 700, color: "var(--accent)" }}>🏷️ #{selectedTag}</span>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--text-tertiary)" }}>({tasks.length})</span>
|
||||||
|
</div>
|
||||||
|
) : editingHeader ? (
|
||||||
<input
|
<input
|
||||||
ref={headerInputRef}
|
ref={headerInputRef}
|
||||||
id="header-rename-input"
|
id="header-rename-input"
|
||||||
@@ -559,27 +682,39 @@ export function TaskList({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Header Actions */}
|
||||||
<div className="main-header-actions">
|
<div className="main-header-actions">
|
||||||
<button
|
{isTrashActive ? (
|
||||||
id="toggle-completed-btn"
|
<button
|
||||||
className="btn btn-ghost btn-sm"
|
id="empty-trash-btn"
|
||||||
onClick={onToggleCompleted}
|
className="btn btn-ghost btn-sm"
|
||||||
title={showCompleted ? t("hideDone") : t("showDone")}
|
style={{ color: "var(--danger)" }}
|
||||||
type="button"
|
onClick={onEmptyTrash}
|
||||||
>
|
type="button"
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
>
|
||||||
<polyline points="20 6 9 17 4 12" />
|
🧹 {t("emptyTrash") || "Empty Trash"}
|
||||||
</svg>
|
</button>
|
||||||
{showCompleted ? t("hideDone") : t("showDone")}
|
) : (
|
||||||
</button>
|
<button
|
||||||
|
id="toggle-completed-btn"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={onToggleCompleted}
|
||||||
|
title={showCompleted ? t("hideDone") : t("showDone")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
{showCompleted ? t("hideDone") : t("showDone")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task list container with background context menu handler */}
|
{/* Task list container */}
|
||||||
<div
|
<div
|
||||||
className="task-list-container"
|
className="task-list-container"
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
// If clicked directly on empty container area, prevent native browser menu
|
|
||||||
if (e.target === e.currentTarget) {
|
if (e.target === e.currentTarget) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
@@ -588,16 +723,16 @@ export function TaskList({
|
|||||||
{loading && (
|
{loading && (
|
||||||
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>{t("loading")}</div>
|
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>{t("loading")}</div>
|
||||||
)}
|
)}
|
||||||
{!loading && incompleteTasks.length === 0 && completedTasks.length === 0 && (
|
{!loading && tasks.length === 0 && (
|
||||||
<div className="task-list-empty">
|
<div className="task-list-empty">
|
||||||
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
<path d="M9 11l3 3L22 4" /><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
<path d="M9 11l3 3L22 4" /><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||||
</svg>
|
</svg>
|
||||||
<p>{t("noTasksYet")}</p>
|
<p>{isTrashActive ? "Trash is empty" : selectedTag ? "No tasks with this tag" : t("noTasksYet")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Incomplete Tasks Recursive Tree */}
|
{/* Tasks Tree */}
|
||||||
{incompleteTasks.map((task) => (
|
{incompleteTasks.map((task) => (
|
||||||
<RecursiveTaskItem
|
<RecursiveTaskItem
|
||||||
key={task.id}
|
key={task.id}
|
||||||
@@ -609,11 +744,14 @@ export function TaskList({
|
|||||||
onUpdateTitle={(id, title) => onUpdateTaskTitle && onUpdateTaskTitle(id, title)}
|
onUpdateTitle={(id, title) => onUpdateTaskTitle && onUpdateTaskTitle(id, title)}
|
||||||
onAddSubtask={handleAddSubtaskInline}
|
onAddSubtask={handleAddSubtaskInline}
|
||||||
onContextMenu={(x, y, tItem) => setContextMenu({ x, y, task: tItem })}
|
onContextMenu={(x, y, tItem) => setContextMenu({ x, y, task: tItem })}
|
||||||
|
isTrashMode={isTrashActive}
|
||||||
|
onRestore={onRestoreTask}
|
||||||
|
onPermanentDelete={onPermanentDeleteTask}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Completed Tasks Recursive Tree */}
|
{/* Completed Tasks */}
|
||||||
{showCompleted && completedTasks.length > 0 && (
|
{!isTrashActive && showCompleted && completedTasks.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -638,14 +776,17 @@ export function TaskList({
|
|||||||
onUpdateTitle={(id, title) => onUpdateTaskTitle && onUpdateTaskTitle(id, title)}
|
onUpdateTitle={(id, title) => onUpdateTaskTitle && onUpdateTaskTitle(id, title)}
|
||||||
onAddSubtask={handleAddSubtaskInline}
|
onAddSubtask={handleAddSubtaskInline}
|
||||||
onContextMenu={(x, y, tItem) => setContextMenu({ x, y, task: tItem })}
|
onContextMenu={(x, y, tItem) => setContextMenu({ x, y, task: tItem })}
|
||||||
|
isTrashMode={isTrashActive}
|
||||||
|
onRestore={onRestoreTask}
|
||||||
|
onPermanentDelete={onPermanentDeleteTask}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Full-featured Context Menu on ANY Task at ANY Depth */}
|
{/* Full-featured Context Menu */}
|
||||||
{contextMenu && (
|
{contextMenu && !isTrashActive && (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
x={contextMenu.x}
|
x={contextMenu.x}
|
||||||
y={contextMenu.y}
|
y={contextMenu.y}
|
||||||
@@ -686,25 +827,27 @@ export function TaskList({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add task bar */}
|
{/* Add task bar (hidden in trash mode) */}
|
||||||
<div className="add-task-bar">
|
{!isTrashActive && !selectedTag && (
|
||||||
<button className="task-check-btn" style={{ opacity: 0.4, flexShrink: 0 }} aria-hidden="true" type="button" />
|
<div className="add-task-bar">
|
||||||
<form onSubmit={handleAddTask} style={{ flex: 1, display: "flex", gap: 8 }}>
|
<button className="task-check-btn" style={{ opacity: 0.4, flexShrink: 0 }} aria-hidden="true" type="button" />
|
||||||
<input
|
<form onSubmit={handleAddTask} style={{ flex: 1, display: "flex", gap: 8 }}>
|
||||||
ref={inputRef}
|
<input
|
||||||
id="add-task-input"
|
ref={inputRef}
|
||||||
className="add-task-input"
|
id="add-task-input"
|
||||||
placeholder={t("addTaskPlaceholder")}
|
className="add-task-input"
|
||||||
value={newTaskTitle}
|
placeholder={t("addTaskPlaceholder")}
|
||||||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
value={newTaskTitle}
|
||||||
/>
|
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||||||
{newTaskTitle.trim() && (
|
/>
|
||||||
<button type="submit" className="btn btn-primary btn-sm" id="add-task-btn">
|
{newTaskTitle.trim() && (
|
||||||
{t("add")}
|
<button type="submit" className="btn btn-primary btn-sm" id="add-task-btn">
|
||||||
</button>
|
{t("add")}
|
||||||
)}
|
</button>
|
||||||
</form>
|
)}
|
||||||
</div>
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import { Task } from "../tasks/TaskList";
|
||||||
|
|
||||||
|
interface CommandPaletteProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
tasks: Task[];
|
||||||
|
onSelectTask: (task: Task) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandPalette({ isOpen, onClose, tasks, onSelectTask }: CommandPaletteProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isOpen) onClose();
|
||||||
|
else {
|
||||||
|
// Open handled by parent
|
||||||
|
document.dispatchEvent(new CustomEvent("checkflow:openCommandPalette"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === "Escape" && isOpen) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
// Flatten recursive tasks for search
|
||||||
|
const flattenTasks = (list: Task[]): Task[] => {
|
||||||
|
let result: Task[] = [];
|
||||||
|
for (const item of list) {
|
||||||
|
result.push(item);
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
result = result.concat(flattenTasks(item.children));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const allTasks = flattenTasks(tasks);
|
||||||
|
const filtered = query.trim()
|
||||||
|
? allTasks.filter(
|
||||||
|
(task) =>
|
||||||
|
task.title.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
|
(task.note && task.note.toLowerCase().includes(query.toLowerCase())) ||
|
||||||
|
(task.tags && task.tags.some((t) => t.tag.name.toLowerCase().includes(query.toLowerCase())))
|
||||||
|
)
|
||||||
|
: allTasks.slice(0, 8);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose} style={{ zIndex: 99999, alignItems: "flex-start", paddingTop: "12vh" }}>
|
||||||
|
<div
|
||||||
|
className="modal command-palette-modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ maxWidth: 580, width: "90%", padding: 0, overflow: "hidden", borderRadius: "var(--radius-md)" }}
|
||||||
|
>
|
||||||
|
{/* Search Header */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "14px 18px", borderBottom: "1px solid var(--border)" }}>
|
||||||
|
<span style={{ fontSize: 16, opacity: 0.6 }}>🔍</span>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="form-input"
|
||||||
|
placeholder={t("searchPlaceholder") || "Search all tasks, notes, tags (Ctrl+K)..."}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
style={{ border: "none", background: "none", fontSize: 15, padding: 0, outline: "none", boxShadow: "none" }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--text-tertiary)", background: "var(--bg-secondary)", padding: "2px 6px", borderRadius: 4 }}>
|
||||||
|
ESC
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results List */}
|
||||||
|
<div style={{ maxHeight: 340, overflowY: "auto", padding: "8px 0" }}>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div style={{ padding: "24px 20px", textAlign: "center", color: "var(--text-tertiary)", fontSize: 13 }}>
|
||||||
|
No matching tasks or notes found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filtered.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className="context-menu-item"
|
||||||
|
style={{ padding: "10px 18px", gap: 12, borderRadius: 0 }}
|
||||||
|
onClick={() => {
|
||||||
|
onSelectTask(task);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className={`task-check-btn${task.completed ? " checked" : ""}`}
|
||||||
|
style={{ width: 16, height: 16, flexShrink: 0 }}
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13.5, fontWeight: 600, color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
|
{task.title}
|
||||||
|
</div>
|
||||||
|
{task.note && (
|
||||||
|
<div style={{ fontSize: 11, color: "var(--text-tertiary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
|
{task.note.replace(/[#*`]/g, "").slice(0, 60)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{task.tags && task.tags.length > 0 && (
|
||||||
|
<div style={{ display: "flex", gap: 4 }}>
|
||||||
|
{task.tags.map((tg) => (
|
||||||
|
<span key={tg.tag.id} className="badge" style={{ background: tg.tag.color + "22", color: tg.tag.color, fontSize: 10, padding: "1px 6px" }}>
|
||||||
|
#{tg.tag.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+90
-37
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||||
|
|
||||||
export type Language = "en" | "ko" | "ja";
|
export type Language = "en" | "ko" | "ja";
|
||||||
@@ -32,6 +32,7 @@ export const translations = {
|
|||||||
listNamePlaceholder: "List name",
|
listNamePlaceholder: "List name",
|
||||||
create: "Create",
|
create: "Create",
|
||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
|
save: "Save",
|
||||||
deleteListConfirm: "Delete this list and all its tasks?",
|
deleteListConfirm: "Delete this list and all its tasks?",
|
||||||
importTasks: "Import Tasks",
|
importTasks: "Import Tasks",
|
||||||
importModalTitle: "Import Tasks",
|
importModalTitle: "Import Tasks",
|
||||||
@@ -45,6 +46,23 @@ export const translations = {
|
|||||||
themeLight: "Light",
|
themeLight: "Light",
|
||||||
themeDark: "Dark",
|
themeDark: "Dark",
|
||||||
language: "Language",
|
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
|
// Task List
|
||||||
tasks: "Tasks",
|
tasks: "Tasks",
|
||||||
@@ -101,7 +119,7 @@ export const translations = {
|
|||||||
min8Chars: "8자 이상 입력",
|
min8Chars: "8자 이상 입력",
|
||||||
signIn: "로그인",
|
signIn: "로그인",
|
||||||
signingIn: "로그인 중...",
|
signingIn: "로그인 중...",
|
||||||
createBtn: "계정 만들기",
|
createBtn: "계정 생성",
|
||||||
creatingBtn: "계정 생성 중...",
|
creatingBtn: "계정 생성 중...",
|
||||||
alreadyHaveAccount: "이미 계정이 있으신가요?",
|
alreadyHaveAccount: "이미 계정이 있으신가요?",
|
||||||
dontHaveAccount: "계정이 없으신가요?",
|
dontHaveAccount: "계정이 없으신가요?",
|
||||||
@@ -115,29 +133,47 @@ export const translations = {
|
|||||||
listNamePlaceholder: "목록 이름",
|
listNamePlaceholder: "목록 이름",
|
||||||
create: "생성",
|
create: "생성",
|
||||||
cancel: "취소",
|
cancel: "취소",
|
||||||
|
save: "저장",
|
||||||
deleteListConfirm: "이 목록과 포함된 모든 할 일을 삭제하시겠습니까?",
|
deleteListConfirm: "이 목록과 포함된 모든 할 일을 삭제하시겠습니까?",
|
||||||
importTasks: "할 일 가져오기 (Import)",
|
importTasks: "할 일 가져오기 (Import)",
|
||||||
importModalTitle: "할 일 가져오기",
|
importModalTitle: "할 일 가져오기 (Import)",
|
||||||
targetList: "저장할 목록",
|
targetList: "가져올 대상 목록",
|
||||||
fileSelectLabel: "파일 선택 (TickTick CSV 또는 ICS)",
|
fileSelectLabel: "파일 선택 (TickTick CSV 또는 ICS)",
|
||||||
tickTickExportHint: "TickTick: 설정 → 데이터 내보내기 → CSV 또는 iCalendar",
|
tickTickExportHint: "TickTick: 설정 → 데이터 내보내기 → CSV 또는 iCalendar 다운로드",
|
||||||
importBtn: "가져오기",
|
importBtn: "가져오기",
|
||||||
importing: "가져오는 중...",
|
importing: "가져오는 중...",
|
||||||
theme: "테마",
|
theme: "화면 테마",
|
||||||
themeSystem: "시스템 설정",
|
themeSystem: "시스템",
|
||||||
themeLight: "라이트 모드",
|
themeLight: "라이트",
|
||||||
themeDark: "다크 모드",
|
themeDark: "다크",
|
||||||
language: "언어",
|
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
|
// Task List
|
||||||
tasks: "할 일",
|
tasks: "할 일",
|
||||||
hideDone: "완료 숨김",
|
hideDone: "완료 숨기기",
|
||||||
showDone: "완료 보기",
|
showDone: "완료 보기",
|
||||||
completedSection: "완료됨",
|
completedSection: "완료된 항목",
|
||||||
addTaskPlaceholder: "새로운 할 일 추가...",
|
addTaskPlaceholder: "새로운 할 일 추가...",
|
||||||
add: "추가",
|
add: "추가",
|
||||||
noTasksYet: "아직 등록된 할 일이 없습니다. 아래에서 추가해보세요!",
|
noTasksYet: "아직 등록된 할 일이 없습니다. 아래에서 추가해보세요!",
|
||||||
selectListToStart: "목록을 선택해 시작하세요",
|
selectListToStart: "목록을 선택하여 할 일을 시작하세요",
|
||||||
loading: "불러오는 중...",
|
loading: "불러오는 중...",
|
||||||
today: "오늘",
|
today: "오늘",
|
||||||
tomorrow: "내일",
|
tomorrow: "내일",
|
||||||
@@ -198,6 +234,7 @@ export const translations = {
|
|||||||
listNamePlaceholder: "リスト名",
|
listNamePlaceholder: "リスト名",
|
||||||
create: "作成",
|
create: "作成",
|
||||||
cancel: "キャンセル",
|
cancel: "キャンセル",
|
||||||
|
save: "保存",
|
||||||
deleteListConfirm: "このリストとすべてのタスクを削除しますか?",
|
deleteListConfirm: "このリストとすべてのタスクを削除しますか?",
|
||||||
importTasks: "タスクのインポート",
|
importTasks: "タスクのインポート",
|
||||||
importModalTitle: "タスクのインポート",
|
importModalTitle: "タスクのインポート",
|
||||||
@@ -211,6 +248,23 @@ export const translations = {
|
|||||||
themeLight: "ライト",
|
themeLight: "ライト",
|
||||||
themeDark: "ダーク",
|
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
|
// Task List
|
||||||
tasks: "タスク",
|
tasks: "タスク",
|
||||||
@@ -251,52 +305,51 @@ export const translations = {
|
|||||||
bulletList: "箇条書き",
|
bulletList: "箇条書き",
|
||||||
numberedList: "番号付きリスト",
|
numberedList: "番号付きリスト",
|
||||||
checkbox: "チェックボックス",
|
checkbox: "チェックボックス",
|
||||||
code: "コード",
|
code: "コードブロック",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TranslationKeys = keyof typeof translations.en;
|
||||||
|
|
||||||
interface I18nContextType {
|
interface I18nContextType {
|
||||||
lang: Language;
|
lang: Language;
|
||||||
setLang: (lang: Language) => void;
|
setLang: (l: Language) => void;
|
||||||
t: (key: keyof typeof translations["en"]) => string;
|
t: (key: TranslationKeys) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const I18nContext = createContext<I18nContextType>({
|
const I18nContext = createContext<I18nContextType>({
|
||||||
lang: "en",
|
lang: "en",
|
||||||
setLang: () => {},
|
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 }) {
|
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [lang, setLangState] = useState<Language>("en");
|
const [lang, setLangState] = useState<Language>("en");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = localStorage.getItem("checkflow_lang") as Language;
|
try {
|
||||||
if (saved && (saved === "en" || saved === "ko" || saved === "ja")) {
|
const saved = localStorage.getItem(LANG_STORAGE_KEY) as Language | null;
|
||||||
setLangState(saved);
|
if (saved && (saved === "en" || saved === "ko" || saved === "ja")) {
|
||||||
} else {
|
setLangState(saved);
|
||||||
// Default to English as requested, or match browser if preferred
|
}
|
||||||
const navLang = navigator.language.toLowerCase();
|
} catch {}
|
||||||
if (navLang.startsWith("ko")) setLangState("ko");
|
|
||||||
else if (navLang.startsWith("ja")) setLangState("ja");
|
|
||||||
else setLangState("en");
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setLang = (newLang: Language) => {
|
const setLang = (l: Language) => {
|
||||||
setLangState(newLang);
|
setLangState(l);
|
||||||
localStorage.setItem("checkflow_lang", newLang);
|
try {
|
||||||
|
localStorage.setItem(LANG_STORAGE_KEY, l);
|
||||||
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
const t = (key: keyof typeof translations["en"]): string => {
|
const t = (key: TranslationKeys): string => {
|
||||||
return translations[lang]?.[key] || translations.en[key] || (key as string);
|
const dict = translations[lang] || translations.en;
|
||||||
|
return (dict as any)[key] || translations.en[key] || key;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return <I18nContext.Provider value={{ lang, setLang, t }}>{children}</I18nContext.Provider>;
|
||||||
<I18nContext.Provider value={{ lang, setLang, t }}>
|
|
||||||
{children}
|
|
||||||
</I18nContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useI18n = () => useContext(I18nContext);
|
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;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
@@ -19,12 +25,39 @@ export interface MockTask {
|
|||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
deletedAt?: string | null;
|
||||||
|
isDeleted?: boolean;
|
||||||
children: MockTask[];
|
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_LISTS = "checkflow_demo_lists";
|
||||||
const STORAGE_KEY_TASKS = "checkflow_demo_tasks";
|
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[] = [
|
export const INITIAL_DEMO_LISTS: MockList[] = [
|
||||||
{ id: "list-1", name: "🚀 Project Launch", color: "#4B7BF5", icon: "work" },
|
{ id: "list-1", name: "🚀 Project Launch", color: "#4B7BF5", icon: "work" },
|
||||||
@@ -38,7 +71,7 @@ export const INITIAL_DEMO_TASKS: MockTask[] = [
|
|||||||
listId: "list-1",
|
listId: "list-1",
|
||||||
parentId: null,
|
parentId: null,
|
||||||
title: "Setup Self-Hosted CheckFlow on NAS",
|
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,
|
completed: false,
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
dueDate: new Date(Date.now() + 86400000).toISOString(),
|
dueDate: new Date(Date.now() + 86400000).toISOString(),
|
||||||
@@ -46,6 +79,9 @@ export const INITIAL_DEMO_TASKS: MockTask[] = [
|
|||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
deletedAt: null,
|
||||||
|
isDeleted: false,
|
||||||
|
tags: [{ tag: DEFAULT_TAGS[1] }, { tag: DEFAULT_TAGS[2] }],
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: "sub-1-1",
|
id: "sub-1-1",
|
||||||
@@ -53,13 +89,16 @@ export const INITIAL_DEMO_TASKS: MockTask[] = [
|
|||||||
parentId: "task-1",
|
parentId: "task-1",
|
||||||
title: "Configure .env environment variables",
|
title: "Configure .env environment variables",
|
||||||
note: "Database URL, NextAuth secret, and port settings",
|
note: "Database URL, NextAuth secret, and port settings",
|
||||||
completed: true,
|
completed: false,
|
||||||
completedAt: new Date().toISOString(),
|
completedAt: null,
|
||||||
dueDate: null,
|
dueDate: null,
|
||||||
priority: 0,
|
priority: 0,
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
deletedAt: null,
|
||||||
|
isDeleted: false,
|
||||||
|
tags: [],
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: "sub-1-1-1",
|
id: "sub-1-1-1",
|
||||||
@@ -74,11 +113,12 @@ export const INITIAL_DEMO_TASKS: MockTask[] = [
|
|||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
deletedAt: null,
|
||||||
|
isDeleted: false,
|
||||||
children: [],
|
children: [],
|
||||||
tags: [],
|
tags: [{ tag: DEFAULT_TAGS[0] }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
tags: [],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sub-1-2",
|
id: "sub-1-2",
|
||||||
@@ -93,11 +133,30 @@ export const INITIAL_DEMO_TASKS: MockTask[] = [
|
|||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: 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: [],
|
children: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
tags: [],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "task-2",
|
id: "task-2",
|
||||||
@@ -112,8 +171,10 @@ export const INITIAL_DEMO_TASKS: MockTask[] = [
|
|||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
deletedAt: null,
|
||||||
|
isDeleted: false,
|
||||||
children: [],
|
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
|
// Recursive tree helpers for N-depth sub-tasks
|
||||||
export function updateTaskInTree(tree: MockTask[], updated: MockTask): MockTask[] {
|
export function updateTaskInTree(tree: MockTask[], updated: MockTask): MockTask[] {
|
||||||
return tree.map((node) => {
|
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[] {
|
export function deleteTaskInTree(tree: MockTask[], idToDelete: string): MockTask[] {
|
||||||
return tree
|
return tree
|
||||||
.filter((node) => node.id !== idToDelete)
|
.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[] {
|
export function addTaskToTree(tree: MockTask[], parentId: string | null, newTask: MockTask): MockTask[] {
|
||||||
if (!parentId) {
|
if (!parentId) {
|
||||||
return [...tree, newTask];
|
return [...tree, newTask];
|
||||||
@@ -199,4 +332,32 @@ export function findTaskInTree(tree: MockTask[], id: string): MockTask | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 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