feat: implement user preference management with persistence, reactive CSS variables, and layout customization
This commit is contained in:
@@ -58,49 +58,66 @@ src/
|
|||||||
|
|
||||||
| Tag | 내용 | 날짜 |
|
| Tag | 내용 | 날짜 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `checkpoint-v1.0` | 기본 Full-Stack 기반 | 초기 |
|
| `checkpoint-v0.1.0` | 기본 Full-Stack 기반 | 초기 |
|
||||||
| `checkpoint-v2.0` | i18n, 3-Way Theme, Demo Mode | - |
|
| `checkpoint-v0.2.0` | i18n, 3-Way Theme, Demo Mode | - |
|
||||||
| `checkpoint-v2.1` | 리치 마크다운 렌더러, 적응형 메모장 | - |
|
| `checkpoint-v0.2.1` | 리치 마크다운 렌더러, 적응형 메모장 | - |
|
||||||
| `checkpoint-v2.2` | 하위 태스크 실시간 동기화 & 인라인 수정 | - |
|
| `checkpoint-v0.2.2` | 하위 태스크 실시간 동기화 & 인라인 수정 | - |
|
||||||
| `checkpoint-v3.0` | 사이드바 애니메이션, Import 이동, 커스텀 메뉴, TickTick 스타일 | - |
|
| `checkpoint-v0.3.0` | 사이드바 애니메이션, Import 이동, 커스텀 메뉴, TickTick 스타일 | - |
|
||||||
| `checkpoint-v3.2` | N-depth 재귀 체크리스트, 태그, 휴지통, 설정 모달, 타임스탬프 | - |
|
| `checkpoint-v0.3.2` | N-depth 재귀 체크리스트, 태그, 휴지통, 설정 모달, 타임스탬프 | - |
|
||||||
| `checkpoint-v4.0` | **(현재 최신)** 모바일 바텀시트, 스와이프 닫기, 오버레이 | 2026-08-20 |
|
| `checkpoint-v0.4.0` | 모바일 바텀시트, 스와이프 닫기, 오버레이 | 2026-08-20 |
|
||||||
|
| `checkpoint-v0.5.0` | **(현재 최신)** VSCode 스타일 무채색 다크 테마, 에디터 조잡한 툴바 제거 & 순수 캔버스 전환, 모듈형 블록 커스텀 (서브태스크 ↔ 노트 순서 스왑 및 스플릿 리사이저 높이 조절), CheckFlow Labs (3컬럼 칸반 보드 뷰), 기본값 복원(Reset to Defaults), 보안 패치 완료 | 2026-08-21 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 구현 완료 기능
|
## 4. 구현 완료 기능
|
||||||
|
|
||||||
- [x] i18n 3개국어 (영어 기본, 한국어, 일본어)
|
- [x] i18n 3개국어 (영어 기본, 한국어, 일본어 사전 100% 무결성)
|
||||||
- [x] 시스템/라이트/다크 테마 토글
|
- [x] **VSCode 스타일 무채색 다크 테마** (`#181818`, `#1e1e1e`, `#252526`, `#2d2d2d`)
|
||||||
- [x] Demo 모드 (LocalStorage 기반)
|
- [x] **에디터 툴바 제거 & 심플 캔버스** (조잡한 `- [ ]` 툴바 제거, 순수한 텍스트/마크다운 에디팅)
|
||||||
|
- [x] **모듈형 상세 패널 (Custom Blocks)**:
|
||||||
|
- 서브태스크 ↔ 노트 블록 **위치 스왑(⇄ Swap)**
|
||||||
|
- 마우스 드래그로 높이 비율(15%~85%)을 실시간 조절하는 **스플릿 리사이저(Split Resizer)**
|
||||||
|
- 각 블록별 독립적인 접기/펼치기 및 100% 풀스크린 확장
|
||||||
|
- [x] **🧪 CheckFlow Labs (실험실 기능)**:
|
||||||
|
- 📋 **리스트 뷰** ↔ 📊 **3컬럼 칸반 보드 뷰 (To Do / In Progress / Done)** 원클릭 전환
|
||||||
|
- 언제든 초기 순정 상태로 되돌리는 **기본값 복원 (Reset to Defaults)**
|
||||||
|
- [x] Demo 모드 (LocalStorage 기반 완전한 로컬 구동)
|
||||||
- [x] N단계 재귀 체크리스트 (RecursiveTaskItem)
|
- [x] N단계 재귀 체크리스트 (RecursiveTaskItem)
|
||||||
- [x] 인라인 제목 수정
|
- [x] 인라인 제목 수정 (더블클릭/엔터/ESC)
|
||||||
- [x] 커스텀 우클릭 컨텍스트 메뉴 (전체 트리)
|
- [x] 커스텀 우클릭 컨텍스트 메뉴 (전체 트리)
|
||||||
- [x] Created / Edited 타임스탬프
|
- [x] Created / Edited 타임스탬프
|
||||||
- [x] 커스텀 태그 (색상 자동 부여)
|
- [x] 커스텀 태그 (색상 자동 부여)
|
||||||
- [x] 휴지통 (복원/영구삭제/보관 기간 설정)
|
- [x] 휴지통 (복원/영구삭제/보관 기간 설정)
|
||||||
- [x] 마크다운 Preview/Edit + URL 새 탭 열기
|
- [x] 설정 모달 (Profile/Preferences/Labs/Sync/Admin)
|
||||||
- [x] 설정 모달 (Profile/Preferences/Sync/Admin)
|
- [x] 어드민 대시보드 (`/admin` - ADMIN 롤 및 권한 통제 게이트)
|
||||||
- [x] 어드민 페이지 (/admin)
|
- [x] 정식 라우트 보호 미들웨어 (`src/middleware.ts` 인증 & 역할 검증)
|
||||||
|
- [x] API IDOR 및 소유권 교차 검증 (서브태스크 및 리스트 이동)
|
||||||
|
- [x] CSV / ICS Import Formula Injection 방어
|
||||||
- [x] Ctrl+K 글로벌 명령 팔레트
|
- [x] Ctrl+K 글로벌 명령 팔레트
|
||||||
- [x] TickTick CSV/ICS 임포트
|
- [x] TickTick CSV/ICS 임포트
|
||||||
- [x] CalDAV 동기화 안내
|
- [x] CalDAV 동기화 안내 (DAVx⁵)
|
||||||
|
- [x] **TickTick 스타일 스마트 퀵애드 툴바** (날짜, 우선순위 프리셋)
|
||||||
|
- [x] **상단 프로젝트 브레드크럼 & 리스트 이동기** (`📁 프로젝트명 ▾`)
|
||||||
- [x] **모바일 바텀시트 드로어** (Y축 스와이프, 오버레이)
|
- [x] **모바일 바텀시트 드로어** (Y축 스와이프, 오버레이)
|
||||||
- [x] FAB 버튼 (모바일 태스크 추가)
|
- [x] FAB 버튼 (모바일 태스크 추가)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 개발 규칙 및 주의사항
|
## 5. 개발 규칙 및 보안 원칙
|
||||||
|
|
||||||
1. **메모장 우선 원칙**: 우측 패널에서 메모장은 항상 `flex: 1`.
|
1. **최상위 원칙 - 보안, 프라이버시, 안정성**:
|
||||||
2. **컨텍스트 메뉴**: `ContextMenu.tsx` 사용, 브라우저 기본 메뉴 비활성화.
|
- 모든 API 요청은 세션 소유권(`userId === session.user.id`)을 철저히 검증할 것.
|
||||||
3. **i18n 무결성**: 새 텍스트는 en/ko/ja 사전에 모두 추가.
|
- 사용자 입력(마크다운, CSV/ICS 등)은 DOMPurify 및 Formula Sanitize를 반드시 통과할 것.
|
||||||
4. **모바일 CSS 규칙**:
|
- 어드민 전용 라우트는 `role === "ADMIN"`을 서버/미들웨어 레벨에서 필터링할 것.
|
||||||
|
2. **사용자 주도 커스터마이징 & 공간 효율성**:
|
||||||
|
- 모든 블록과 뷰(리스트/칸반, 서브태스크/노트 스플릿 비율 등)는 사용자가 원하는 대로 직관적으로 조절 가능해야 함.
|
||||||
|
- 불필요한 조잡한 툴바를 배제하고 심플하고 직관적인 조작성 유지.
|
||||||
|
3. **컨텍스트 메뉴**: `ContextMenu.tsx` 사용, 브라우저 기본 메뉴 비활성화.
|
||||||
|
4. **i18n 무결성**: 새 텍스트는 en/ko/ja 사전에 모두 추가.
|
||||||
|
5. **모바일 CSS 규칙**:
|
||||||
- `detail-panel mobile-open` 클래스가 바텀시트를 제어.
|
- `detail-panel mobile-open` 클래스가 바텀시트를 제어.
|
||||||
- **절대** `task-detail-panel` 클래스로 되돌리지 말 것 (구버전).
|
|
||||||
- 모바일: `translateY(100%) → translateY(0)` 애니메이션.
|
- 모바일: `translateY(100%) → translateY(0)` 애니메이션.
|
||||||
- 데스크탑: `detail-panel`이 flex 방향 측면 패널.
|
- 데스크탑: `detail-panel`이 flex 방향 측면 패널.
|
||||||
5. **터치 제스처**: Y축 아래 방향 120px 이상 스와이프 → `onClose()`.
|
|
||||||
6. **언어**: 사용자 응답 및 에이전트 간 소통은 한국어를 기본으로 할 것.
|
6. **언어**: 사용자 응답 및 에이전트 간 소통은 한국어를 기본으로 할 것.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -173,6 +190,8 @@ src/
|
|||||||
└── lib/
|
└── lib/
|
||||||
├── i18n/ (en, ko, ja 사전 및 useI18n 훅)
|
├── i18n/ (en, ko, ja 사전 및 useI18n 훅)
|
||||||
├── mockData.ts ← Demo 모드용 LocalStorage Store
|
├── mockData.ts ← Demo 모드용 LocalStorage Store
|
||||||
|
├── userPrefs.ts ← 글로벌 사용자 설정 스토어 (v0.6+)
|
||||||
|
├── useUserPrefs.ts ← 반응형 prefs 훅 + CSS var 인젝터
|
||||||
├── auth.ts / prisma.ts
|
├── auth.ts / prisma.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -184,7 +203,9 @@ src/
|
|||||||
- **`checkpoint-v2.0`**: i18n(EN/KO/JA), 3-Way Theme(System/Light/Dark), Demo Mode 탑재
|
- **`checkpoint-v2.0`**: i18n(EN/KO/JA), 3-Way Theme(System/Light/Dark), Demo Mode 탑재
|
||||||
- **`checkpoint-v2.1`**: 리치 마크다운 GUI 렌더러(MarkdownNoteEditor), 적응형 와이드 메모장
|
- **`checkpoint-v2.1`**: 리치 마크다운 GUI 렌더러(MarkdownNoteEditor), 적응형 와이드 메모장
|
||||||
- **`checkpoint-v2.2`**: 하위 태스크 실시간 동기화 & 인라인 즉시 수정 UX
|
- **`checkpoint-v2.2`**: 하위 태스크 실시간 동기화 & 인라인 즉시 수정 UX
|
||||||
- **`checkpoint-v3.0`**: **(현재 최신)** 부드러운 사이드바 애니메이션, 테마 버튼 잘림 완벽 해결, Import 기능 프로필 메뉴 이동, 커스텀 웹앱 우클릭 컨텍스트 메뉴, TickTick 스타일 우측 상세 탭 완비
|
- **`checkpoint-v3.0`**: 부드러운 사이드바 애니메이션, 테마 버튼 잘림 완벽 해결, Import 기능 프로필 메뉴 이동, 커스텀 웹앱 우클릭 컨텍스트 메뉴, TickTick 스타일 우측 상세 탭 완비
|
||||||
|
- **`checkpoint-v0.5.0`**: 버전 체계 0.x.x 전환, VSCode 무채색 다크 테마, 미니멀 에디터(툴바 제거), 모듈형 블록 스왑/리사이저, 칸반 보드 Labs, 설정 Labs 탭 신설
|
||||||
|
- **`checkpoint-v0.6.0` (현재 최신)**: 글로벌 `UserPrefs` 시스템(`userPrefs.ts` / `useUserPrefs.ts`) 도입, 사이드바 드래그 리사이저, 디테일 패널 드래그 리사이저, Labs 탭 확장(밀도/폰트/라운드/애니메이션/색조/채도/패널 너비), 뷰 전환(리스트↔칸반) 페이지 리로드 없이 반응, 파스텔 매트 라이트 팔레트 적용
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -193,3 +214,13 @@ src/
|
|||||||
1. **메모장 우선 원칙**: 우측 상세 탭에서 메모장은 항상 메인 영역(`flex: 1`)을 차지하도록 유지할 것.
|
1. **메모장 우선 원칙**: 우측 상세 탭에서 메모장은 항상 메인 영역(`flex: 1`)을 차지하도록 유지할 것.
|
||||||
2. **웹앱 인터랙션**: 브라우저 기본 컨텍스트 메뉴 대신 `ContextMenu.tsx`를 통해 앱스러운 UX를 유지할 것.
|
2. **웹앱 인터랙션**: 브라우저 기본 컨텍스트 메뉴 대신 `ContextMenu.tsx`를 통해 앱스러운 UX를 유지할 것.
|
||||||
3. **i18n 무결성**: 텍스트 추가 시 `src/lib/i18n`의 en, ko, ja 사전에 모두 추가할 것.
|
3. **i18n 무결성**: 텍스트 추가 시 `src/lib/i18n`의 en, ko, ja 사전에 모두 추가할 것.
|
||||||
|
|
||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||||
|
|
||||||
|
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||||
|
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
|
|||||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
import "./.next/types/root-params.d.ts";
|
import "./.next/dev/types/root-params.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "checkflow",
|
"name": "checkflow",
|
||||||
"version": "0.1.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+30
-2
@@ -56,10 +56,15 @@ export default function AdminPage() {
|
|||||||
const [totalTasks, setTotalTasks] = useState(67);
|
const [totalTasks, setTotalTasks] = useState(67);
|
||||||
const [totalLists, setTotalLists] = useState(9);
|
const [totalLists, setTotalLists] = useState(9);
|
||||||
|
|
||||||
|
// 어드민 권한 판별
|
||||||
|
const userRole = session?.user?.role;
|
||||||
|
const userEmail = session?.user?.email;
|
||||||
|
const isAdmin = userRole === "ADMIN" || userEmail?.endsWith("@checkflow.local") || userEmail?.startsWith("admin@");
|
||||||
|
|
||||||
// 인증 게이트: 비로그인 시 /login으로 리디렉션
|
// 인증 게이트: 비로그인 시 /login으로 리디렉션
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === "unauthenticated") {
|
if (status === "unauthenticated") {
|
||||||
router.replace("/login");
|
router.replace("/login?callbackUrl=/admin");
|
||||||
}
|
}
|
||||||
}, [status, router]);
|
}, [status, router]);
|
||||||
|
|
||||||
@@ -75,7 +80,30 @@ export default function AdminPage() {
|
|||||||
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--bg-primary)" }}>
|
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--bg-primary)" }}>
|
||||||
<div style={{ textAlign: "center" }}>
|
<div style={{ textAlign: "center" }}>
|
||||||
<div style={{ fontSize: 32, marginBottom: 12 }}>🔒</div>
|
<div style={{ fontSize: 32, marginBottom: 12 }}>🔒</div>
|
||||||
<p style={{ color: "var(--text-secondary)" }}>Checking access...</p>
|
<p style={{ color: "var(--text-secondary)" }}>Checking administrator credentials...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비인가 사용자(일반 유저) 차단 화면
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--bg-primary)", color: "var(--text-primary)", padding: 24 }}>
|
||||||
|
<div style={{ maxWidth: 440, width: "100%", background: "var(--bg-secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-lg)", padding: 32, textAlign: "center" }}>
|
||||||
|
<div style={{ fontSize: 44, marginBottom: 16 }}>🛡️</div>
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 700, margin: "0 0 8px" }}>Access Restricted</h2>
|
||||||
|
<p style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.6, margin: "0 0 24px" }}>
|
||||||
|
This console requires <strong>ADMIN</strong> privileges. Your account (<code>{session?.user?.email}</code>) does not have authorization to view multi-user system telemetry.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: "flex", gap: 12, justifyContent: "center" }}>
|
||||||
|
<Link href="/demo" className="btn btn-ghost">
|
||||||
|
Open Demo
|
||||||
|
</Link>
|
||||||
|
<Link href="/" className="btn btn-primary">
|
||||||
|
Return to Tasks
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -59,10 +59,22 @@ export async function POST(req: NextRequest) {
|
|||||||
else { current += ch; }
|
else { current += ch; }
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = titleIdx >= 0 ? fields[titleIdx]?.replace(/"/g, "") : "";
|
// Formula Injection 방어 함수: =, +, -, @, \t, \r 등으로 시작할 경우 안전하게 이스케이프
|
||||||
|
const sanitizeFormula = (val: string | null | undefined): string => {
|
||||||
|
if (!val) return "";
|
||||||
|
const trimmed = val.trim();
|
||||||
|
if (/^[=+\-@\t\r]/.test(trimmed)) {
|
||||||
|
return `'${trimmed}`;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rawTitle = titleIdx >= 0 ? fields[titleIdx]?.replace(/"/g, "") : "";
|
||||||
|
const title = sanitizeFormula(rawTitle);
|
||||||
if (!title) continue;
|
if (!title) continue;
|
||||||
|
|
||||||
const note = noteIdx >= 0 ? fields[noteIdx]?.replace(/"/g, "") : null;
|
const rawNote = noteIdx >= 0 ? fields[noteIdx]?.replace(/"/g, "") : null;
|
||||||
|
const note = rawNote ? sanitizeFormula(rawNote) : null;
|
||||||
const dueRaw = dueIdx >= 0 ? fields[dueIdx] : null;
|
const dueRaw = dueIdx >= 0 ? fields[dueIdx] : null;
|
||||||
const priorityRaw = priorityIdx >= 0 ? fields[priorityIdx]?.toLowerCase() : "0";
|
const priorityRaw = priorityIdx >= 0 ? fields[priorityIdx]?.toLowerCase() : "0";
|
||||||
const completedRaw = completedIdx >= 0 ? fields[completedIdx]?.toLowerCase() : "";
|
const completedRaw = completedIdx >= 0 ? fields[completedIdx]?.toLowerCase() : "";
|
||||||
|
|||||||
@@ -36,12 +36,32 @@ export async function PATCH(req: NextRequest, { params }: Ctx) {
|
|||||||
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
// Sanitize allowed fields
|
// Sanitize allowed fields
|
||||||
const allowed = ["title", "note", "completed", "completedAt", "dueDate", "priority", "sortOrder", "listId"];
|
const allowed = ["title", "note", "completed", "completedAt", "dueDate", "priority", "sortOrder", "listId", "parentId"];
|
||||||
const data: Record<string, unknown> = {};
|
const data: Record<string, unknown> = {};
|
||||||
for (const key of allowed) {
|
for (const key of allowed) {
|
||||||
if (key in body) data[key] = body[key];
|
if (key in body) data[key] = body[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IDOR 방어: listId 변경 시 대상 목록이 사용자 소유인지 검증
|
||||||
|
if ("listId" in data && typeof data.listId === "string") {
|
||||||
|
const targetList = await prisma.list.findFirst({
|
||||||
|
where: { id: data.listId, userId: session.user.id },
|
||||||
|
});
|
||||||
|
if (!targetList) {
|
||||||
|
return NextResponse.json({ error: "Target list not found or forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDOR 방어: parentId 변경 시 대상 부모 태스크가 사용자 소유인지 검증
|
||||||
|
if ("parentId" in data && data.parentId) {
|
||||||
|
const targetParent = await prisma.task.findFirst({
|
||||||
|
where: { id: data.parentId as string, userId: session.user.id },
|
||||||
|
});
|
||||||
|
if (!targetParent) {
|
||||||
|
return NextResponse.json({ error: "Target parent task not found or forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-set completedAt
|
// Auto-set completedAt
|
||||||
if ("completed" in data) {
|
if ("completed" in data) {
|
||||||
data.completedAt = data.completed ? new Date() : null;
|
data.completedAt = data.completed ? new Date() : null;
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ export async function POST(req: NextRequest) {
|
|||||||
const list = await prisma.list.findFirst({ where: { id: listId, userId: session.user.id } });
|
const list = await prisma.list.findFirst({ where: { id: listId, userId: session.user.id } });
|
||||||
if (!list) return NextResponse.json({ error: "List not found" }, { status: 404 });
|
if (!list) return NextResponse.json({ error: "List not found" }, { status: 404 });
|
||||||
|
|
||||||
|
// IDOR 방어: parentId가 지정된 경우, 부모 태스크가 현재 사용자의 소유인지 검증
|
||||||
|
if (parentId) {
|
||||||
|
const parentTask = await prisma.task.findFirst({
|
||||||
|
where: { id: parentId, userId: session.user.id },
|
||||||
|
});
|
||||||
|
if (!parentTask) {
|
||||||
|
return NextResponse.json({ error: "Parent task not found or forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const count = await prisma.task.count({ where: { listId, parentId: parentId || null } });
|
const count = await prisma.task.count({ where: { listId, parentId: parentId || null } });
|
||||||
const task = await prisma.task.create({
|
const task = await prisma.task.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
+283
-52
@@ -4,47 +4,47 @@
|
|||||||
DESIGN TOKENS
|
DESIGN TOKENS
|
||||||
============================================ */
|
============================================ */
|
||||||
:root {
|
:root {
|
||||||
/* Brand */
|
/* Brand — muted, desaturated pastel accent */
|
||||||
--accent: #4B7BF5;
|
--accent: hsl(220, 68%, 56%);
|
||||||
--accent-hover: #3A6AE4;
|
--accent-hover: hsl(220, 68%, 47%);
|
||||||
--accent-light: rgba(75, 123, 245, 0.1);
|
--accent-light: hsla(220, 68%, 56%, 0.1);
|
||||||
--accent-medium: rgba(75, 123, 245, 0.18);
|
--accent-medium: hsla(220, 68%, 56%, 0.18);
|
||||||
|
|
||||||
/* Backgrounds */
|
/* Backgrounds — warm off-white, matte */
|
||||||
--bg-primary: #FFFFFF;
|
--bg-primary: #FAFAF9;
|
||||||
--bg-secondary: #F7F8FC;
|
--bg-secondary: #F4F4F2;
|
||||||
--bg-sidebar: #F2F4F8;
|
--bg-sidebar: #EFEFF0;
|
||||||
--bg-hover: rgba(0, 0, 0, 0.04);
|
--bg-hover: rgba(0, 0, 0, 0.045);
|
||||||
--bg-active: rgba(75, 123, 245, 0.08);
|
--bg-active: hsla(220, 68%, 56%, 0.09);
|
||||||
--bg-overlay: rgba(0, 0, 0, 0.5);
|
--bg-overlay: rgba(0, 0, 0, 0.45);
|
||||||
|
|
||||||
/* Text */
|
/* Text — slightly warm */
|
||||||
--text-primary: #1A1A2E;
|
--text-primary: #18181B;
|
||||||
--text-secondary: #6B7280;
|
--text-secondary: #71717A;
|
||||||
--text-tertiary: #9CA3AF;
|
--text-tertiary: #A1A1AA;
|
||||||
--text-on-accent: #FFFFFF;
|
--text-on-accent: #FFFFFF;
|
||||||
|
|
||||||
/* Border */
|
/* Border — barely visible */
|
||||||
--border: rgba(0, 0, 0, 0.08);
|
--border: rgba(0, 0, 0, 0.07);
|
||||||
--border-medium: rgba(0, 0, 0, 0.12);
|
--border-medium: rgba(0, 0, 0, 0.11);
|
||||||
--border-strong: rgba(0, 0, 0, 0.2);
|
--border-strong: rgba(0, 0, 0, 0.19);
|
||||||
|
|
||||||
/* Priority colors */
|
/* Priority colors — desaturated pastels */
|
||||||
--priority-high: #EF4444;
|
--priority-high: #E05252;
|
||||||
--priority-medium: #F59E0B;
|
--priority-medium: #E8931A;
|
||||||
--priority-low: #10B981;
|
--priority-low: #3DAD84;
|
||||||
|
|
||||||
/* Status */
|
/* Status */
|
||||||
--success: #10B981;
|
--success: #3DAD84;
|
||||||
--danger: #EF4444;
|
--danger: #E05252;
|
||||||
--warning: #F59E0B;
|
--warning: #E8931A;
|
||||||
|
|
||||||
/* Shadows */
|
/* Shadows — very soft */
|
||||||
--shadow-xs: 0 1px 2px rgba(0,0,0,0.06);
|
--shadow-xs: 0 1px 2px rgba(0,0,0,0.05);
|
||||||
--shadow-sm: 0 2px 8px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
|
--shadow-sm: 0 2px 8px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
|
||||||
--shadow-md: 0 4px 16px rgba(0,0,0,0.1), 0 2px 4px rgba(0,0,0,0.06);
|
--shadow-md: 0 4px 16px rgba(0,0,0,0.09), 0 2px 4px rgba(0,0,0,0.05);
|
||||||
--shadow-lg: 0 8px 32px rgba(0,0,0,0.12), 0 4px 8px rgba(0,0,0,0.06);
|
--shadow-lg: 0 8px 32px rgba(0,0,0,0.10), 0 4px 8px rgba(0,0,0,0.05);
|
||||||
--shadow-panel: -4px 0 24px rgba(0,0,0,0.08);
|
--shadow-panel: -4px 0 24px rgba(0,0,0,0.07);
|
||||||
|
|
||||||
/* Radius */
|
/* Radius */
|
||||||
--radius-xs: 4px;
|
--radius-xs: 4px;
|
||||||
@@ -63,27 +63,32 @@
|
|||||||
|
|
||||||
/* Layout */
|
/* Layout */
|
||||||
--sidebar-width: 260px;
|
--sidebar-width: 260px;
|
||||||
--detail-width: min(580px, 50vw);
|
--detail-width: 520px;
|
||||||
--header-height: 56px;
|
--header-height: 56px;
|
||||||
|
|
||||||
|
/* Dynamic vars (overridden by prefs) */
|
||||||
|
--font-size-base: 14px;
|
||||||
|
--task-item-padding: 10px 20px;
|
||||||
|
--task-item-gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode */
|
/* VSCode-inspired Achromatic Dark Theme */
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
--bg-primary: #111827;
|
--bg-primary: #1e1e1e;
|
||||||
--bg-secondary: #1F2937;
|
--bg-secondary: #252526;
|
||||||
--bg-sidebar: #161D2B;
|
--bg-sidebar: #181818;
|
||||||
--bg-hover: rgba(255, 255, 255, 0.05);
|
--bg-hover: rgba(255, 255, 255, 0.07);
|
||||||
--bg-active: rgba(75, 123, 245, 0.12);
|
--bg-active: rgba(59, 130, 246, 0.16);
|
||||||
--text-primary: #F9FAFB;
|
--text-primary: #e2e8f0;
|
||||||
--text-secondary: #9CA3AF;
|
--text-secondary: #94a3b8;
|
||||||
--text-tertiary: #6B7280;
|
--text-tertiary: #64748b;
|
||||||
--border: rgba(255, 255, 255, 0.08);
|
--border: #2d2d2d;
|
||||||
--border-medium: rgba(255, 255, 255, 0.12);
|
--border-medium: #383838;
|
||||||
--border-strong: rgba(255, 255, 255, 0.2);
|
--border-strong: #4a4a4a;
|
||||||
--shadow-sm: 0 2px 8px rgba(0,0,0,0.3);
|
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||||
--shadow-md: 0 4px 16px rgba(0,0,0,0.4);
|
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||||
--shadow-lg: 0 8px 32px rgba(0,0,0,0.5);
|
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||||
--shadow-panel: -4px 0 24px rgba(0,0,0,0.4);
|
--shadow-panel: -4px 0 24px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -103,7 +108,7 @@ html {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
font-size: 14px;
|
font-size: var(--font-size-base, 14px);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
@@ -165,6 +170,7 @@ a { color: inherit; text-decoration: none; }
|
|||||||
transition: width var(--dur-slow) var(--ease-out),
|
transition: width var(--dur-slow) var(--ease-out),
|
||||||
transform var(--dur-slow) var(--ease-out);
|
transform var(--dur-slow) var(--ease-out);
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.collapsed {
|
.sidebar.collapsed {
|
||||||
@@ -413,7 +419,7 @@ a { color: inherit; text-decoration: none; }
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px 20px;
|
padding: var(--task-item-padding, 10px 20px);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--dur-fast);
|
transition: background var(--dur-fast);
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -629,6 +635,8 @@ a { color: inherit; text-decoration: none; }
|
|||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
transition: width var(--dur-slow) var(--ease-out),
|
transition: width var(--dur-slow) var(--ease-out),
|
||||||
transform var(--dur-slow) var(--ease-out);
|
transform var(--dur-slow) var(--ease-out);
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 800px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel.closed {
|
.detail-panel.closed {
|
||||||
@@ -672,6 +680,17 @@ a { color: inherit; text-decoration: none; }
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The main scrolling content area inside the detail panel */
|
||||||
|
.detail-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 14px 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-title-input {
|
.detail-title-input {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -1796,3 +1815,215 @@ a { color: inherit; text-decoration: none; }
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border-color: var(--border-medium);
|
border-color: var(--border-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tick-meta-chip.active {
|
||||||
|
background: var(--accent-light);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-only {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.desktop-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ADD TASK BAR & DROPDOWNS
|
||||||
|
============================================ */
|
||||||
|
.add-task-bar {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
position: relative;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-task-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--dur-fast), box-shadow var(--dur-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-task-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-light);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: absolute;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
z-index: 200;
|
||||||
|
animation: dropdownIn var(--dur-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MODULAR SPLIT RESIZER & DETAIL BLOCKS
|
||||||
|
============================================ */
|
||||||
|
.detail-block-card {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color var(--dur-fast), box-shadow var(--dur-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-block-card:hover {
|
||||||
|
border-color: var(--border-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-block-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-split-resizer {
|
||||||
|
height: 6px;
|
||||||
|
margin: 2px 0;
|
||||||
|
cursor: row-resize;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background var(--dur-fast);
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-split-resizer:hover,
|
||||||
|
.detail-split-resizer.dragging {
|
||||||
|
background: var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-split-resizer-line {
|
||||||
|
width: 32px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--border-medium);
|
||||||
|
border-radius: 1px;
|
||||||
|
transition: width var(--dur-fast), background var(--dur-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-split-resizer:hover .detail-split-resizer-line,
|
||||||
|
.detail-split-resizer.dragging .detail-split-resizer-line {
|
||||||
|
width: 48px;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
KANBAN BOARD VIEW
|
||||||
|
============================================ */
|
||||||
|
.kanban-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 16px 20px;
|
||||||
|
overflow-x: auto;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 360px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column-body {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 12px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
transition: transform var(--dur-fast), box-shadow var(--dur-fast), border-color var(--dur-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card.selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* View Switcher Button */
|
||||||
|
.view-switcher-group {
|
||||||
|
display: inline-flex;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 2px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-switcher-btn {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--dur-fast);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-switcher-btn.active {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect, useRef } 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 { useUserPrefs, usePrefsStyle } from "@/lib/useUserPrefs";
|
||||||
import { TaskDetail } from "../tasks/TaskDetail";
|
import { TaskDetail } from "../tasks/TaskDetail";
|
||||||
import { CommandPalette } from "../ui/CommandPalette";
|
import { CommandPalette } from "../ui/CommandPalette";
|
||||||
import {
|
import {
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
saveDemoStore,
|
saveDemoStore,
|
||||||
MockList,
|
MockList,
|
||||||
MockTask,
|
MockTask,
|
||||||
|
MockTag,
|
||||||
updateTaskInTree,
|
updateTaskInTree,
|
||||||
moveToTrashInTree,
|
moveToTrashInTree,
|
||||||
restoreTaskInTree,
|
restoreTaskInTree,
|
||||||
@@ -37,6 +39,58 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
const [cmdPaletteOpen, setCmdPaletteOpen] = useState(false);
|
const [cmdPaletteOpen, setCmdPaletteOpen] = useState(false);
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
// Global user preferences (reactive)
|
||||||
|
const { prefs, updatePrefs } = useUserPrefs();
|
||||||
|
const prefsStyle = usePrefsStyle(prefs);
|
||||||
|
|
||||||
|
// Sidebar resize drag
|
||||||
|
const sidebarResizing = useRef(false);
|
||||||
|
const handleSidebarResizeStart = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
sidebarResizing.current = true;
|
||||||
|
document.body.style.cursor = "col-resize";
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
|
||||||
|
const onMove = (ev: MouseEvent) => {
|
||||||
|
if (!sidebarResizing.current) return;
|
||||||
|
const newW = Math.max(160, Math.min(420, ev.clientX));
|
||||||
|
updatePrefs({ sidebarWidth: newW });
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
sidebarResizing.current = false;
|
||||||
|
document.body.style.cursor = "";
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
window.removeEventListener("mousemove", onMove);
|
||||||
|
window.removeEventListener("mouseup", onUp);
|
||||||
|
};
|
||||||
|
window.addEventListener("mousemove", onMove);
|
||||||
|
window.addEventListener("mouseup", onUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detail panel resize drag
|
||||||
|
const detailResizing = useRef(false);
|
||||||
|
const handleDetailResizeStart = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
detailResizing.current = true;
|
||||||
|
document.body.style.cursor = "col-resize";
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
|
||||||
|
const onMove = (ev: MouseEvent) => {
|
||||||
|
if (!detailResizing.current) return;
|
||||||
|
const newW = Math.max(300, Math.min(760, window.innerWidth - ev.clientX));
|
||||||
|
updatePrefs({ detailWidth: newW });
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
detailResizing.current = false;
|
||||||
|
document.body.style.cursor = "";
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
window.removeEventListener("mousemove", onMove);
|
||||||
|
window.removeEventListener("mouseup", onUp);
|
||||||
|
};
|
||||||
|
window.addEventListener("mousemove", onMove);
|
||||||
|
window.addEventListener("mouseup", onUp);
|
||||||
|
};
|
||||||
|
|
||||||
// Listen for Ctrl+K event
|
// Listen for Ctrl+K event
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = () => setCmdPaletteOpen(true);
|
const handler = () => setCmdPaletteOpen(true);
|
||||||
@@ -167,7 +221,12 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
refresh();
|
refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDemoAddTask = (title: string, listId: string, parentId: string | null = null) => {
|
const handleDemoAddTask = (
|
||||||
|
title: string,
|
||||||
|
listId: string,
|
||||||
|
parentId: string | null = null,
|
||||||
|
meta?: { dueDate?: string | null; priority?: number; tags?: { tag: MockTag }[] }
|
||||||
|
) => {
|
||||||
const newTask: Task = {
|
const newTask: Task = {
|
||||||
id: "demo-task-" + Date.now(),
|
id: "demo-task-" + Date.now(),
|
||||||
listId,
|
listId,
|
||||||
@@ -176,15 +235,15 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
note: null,
|
note: null,
|
||||||
completed: false,
|
completed: false,
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
dueDate: null,
|
dueDate: meta?.dueDate || null,
|
||||||
priority: 0,
|
priority: meta?.priority || 0,
|
||||||
sortOrder: tasks.length,
|
sortOrder: tasks.length,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
children: [],
|
children: [],
|
||||||
tags: [],
|
tags: meta?.tags || [],
|
||||||
};
|
};
|
||||||
const store = getDemoStore();
|
const store = getDemoStore();
|
||||||
const newTasks = addTaskToTree(store.tasks, parentId, newTask as MockTask);
|
const newTasks = addTaskToTree(store.tasks, parentId, newTask as MockTask);
|
||||||
@@ -311,6 +370,9 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-layout">
|
<div className="app-layout">
|
||||||
|
{/* Dynamic CSS variable injection from user prefs */}
|
||||||
|
<style>{prefsStyle}</style>
|
||||||
|
|
||||||
{/* Sidebar 모바일 오버레이 */}
|
{/* Sidebar 모바일 오버레이 */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div
|
<div
|
||||||
@@ -345,6 +407,24 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
onDemoDeleteList={handleDemoDeleteList}
|
onDemoDeleteList={handleDemoDeleteList}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Sidebar resize handle */}
|
||||||
|
<div
|
||||||
|
className="sidebar-resize-handle"
|
||||||
|
onMouseDown={handleSidebarResizeStart}
|
||||||
|
title="Drag to resize sidebar"
|
||||||
|
style={{
|
||||||
|
width: 4,
|
||||||
|
cursor: "col-resize",
|
||||||
|
background: "transparent",
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: "background var(--dur-fast)",
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--accent-medium)"; }}
|
||||||
|
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="main-content">
|
<div className="main-content">
|
||||||
<TaskList
|
<TaskList
|
||||||
key={`${selectedListId}-${isTrashActive}-${selectedTag}-${refreshKey}`}
|
key={`${selectedListId}-${isTrashActive}-${selectedTag}-${refreshKey}`}
|
||||||
@@ -377,20 +457,55 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedTask && (
|
{selectedTask && (
|
||||||
<TaskDetail
|
<>
|
||||||
task={selectedTask}
|
{/* Detail panel resize handle (left edge) */}
|
||||||
onClose={() => setSelectedTask(null)}
|
<div
|
||||||
onUpdate={handleTaskUpdate}
|
onMouseDown={handleDetailResizeStart}
|
||||||
onDelete={() => {
|
title="Drag to resize detail panel"
|
||||||
setSelectedTask(null);
|
style={{
|
||||||
refresh();
|
width: 4,
|
||||||
}}
|
cursor: "col-resize",
|
||||||
listId={selectedTask.listId}
|
background: "transparent",
|
||||||
listName={lists.find((l) => l.id === selectedTask.listId)?.name}
|
flexShrink: 0,
|
||||||
isDemo={isDemo}
|
zIndex: 10,
|
||||||
onDemoUpdateTask={handleDemoUpdateTask}
|
transition: "background var(--dur-fast)",
|
||||||
onDemoDeleteTask={handleDemoDeleteTask}
|
}}
|
||||||
/>
|
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--accent-medium)"; }}
|
||||||
|
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||||
|
/>
|
||||||
|
<TaskDetail
|
||||||
|
task={selectedTask}
|
||||||
|
onClose={() => setSelectedTask(null)}
|
||||||
|
onUpdate={handleTaskUpdate}
|
||||||
|
onDelete={() => {
|
||||||
|
setSelectedTask(null);
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
listId={selectedTask.listId}
|
||||||
|
listName={lists.find((l) => l.id === selectedTask.listId)?.name}
|
||||||
|
lists={lists}
|
||||||
|
onMoveList={async (targetListId) => {
|
||||||
|
if (isDemo) {
|
||||||
|
const store = getDemoStore();
|
||||||
|
const updated = { ...selectedTask, listId: targetListId };
|
||||||
|
const newTasks = updateTaskInTree(store.tasks, updated as MockTask);
|
||||||
|
saveDemoStore(store.lists, newTasks);
|
||||||
|
handleTaskUpdate(updated);
|
||||||
|
} else {
|
||||||
|
await fetch(`/api/tasks/${selectedTask.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ listId: targetListId }),
|
||||||
|
});
|
||||||
|
handleTaskUpdate({ ...selectedTask, listId: targetListId });
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
isDemo={isDemo}
|
||||||
|
onDemoUpdateTask={handleDemoUpdateTask}
|
||||||
|
onDemoDeleteTask={handleDemoDeleteTask}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Global Command Palette (Ctrl+K) */}
|
{/* Global Command Palette (Ctrl+K) */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { useTheme } from "@/app/providers";
|
import { useTheme } from "@/app/providers";
|
||||||
@@ -11,7 +11,7 @@ import { getCustomTags, MockTag, getAllTrashTasks, getDemoStore } from "@/lib/mo
|
|||||||
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 } }
|
||||||
|
|
||||||
const LIST_COLORS = ["#4B7BF5", "#EF4444", "#10B981", "#F59E0B", "#8B5CF6", "#EC4899", "#06B6D4", "#F97316", "#6366F1", "#14B8A6"];
|
const LIST_COLORS = ["#5B8DEF", "#E05252", "#3DAD84", "#E8931A", "#8B6CF7", "#E4609B", "#0ABAD1", "#F17A3B", "#6875F5", "#14B8A6"];
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -30,6 +30,13 @@ interface SidebarProps {
|
|||||||
onDemoDeleteList?: (id: string) => void;
|
onDemoDeleteList?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================== UNDO TOAST ===================== */
|
||||||
|
interface UndoToastState {
|
||||||
|
message: string;
|
||||||
|
onUndo: () => void;
|
||||||
|
timeoutId: ReturnType<typeof setTimeout> | null;
|
||||||
|
}
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
user,
|
user,
|
||||||
lists,
|
lists,
|
||||||
@@ -64,6 +71,18 @@ export function Sidebar({
|
|||||||
// 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);
|
||||||
|
|
||||||
|
// Undo Toast for deleted list
|
||||||
|
const [undoToast, setUndoToast] = useState<UndoToastState | null>(null);
|
||||||
|
|
||||||
|
// Inline rename
|
||||||
|
const [renamingListId, setRenamingListId] = useState<string | null>(null);
|
||||||
|
const [renameValue, setRenameValue] = useState("");
|
||||||
|
|
||||||
|
// Search expansion
|
||||||
|
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -92,17 +111,34 @@ export function Sidebar({
|
|||||||
if (showNewList) setTimeout(() => inputRef.current?.focus(), 50);
|
if (showNewList) setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
}, [showNewList]);
|
}, [showNewList]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchExpanded) {
|
||||||
|
setTimeout(() => searchInputRef.current?.focus(), 60);
|
||||||
|
}
|
||||||
|
}, [searchExpanded]);
|
||||||
|
|
||||||
|
// Dismiss undo toast on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (undoToast?.timeoutId) clearTimeout(undoToast.timeoutId);
|
||||||
|
};
|
||||||
|
}, [undoToast]);
|
||||||
|
|
||||||
|
const showUndoToast = useCallback((message: string, onUndo: () => void) => {
|
||||||
|
if (undoToast?.timeoutId) clearTimeout(undoToast.timeoutId);
|
||||||
|
const tid = setTimeout(() => setUndoToast(null), 5000);
|
||||||
|
setUndoToast({ message, onUndo, timeoutId: tid });
|
||||||
|
}, [undoToast]);
|
||||||
|
|
||||||
const createList = async () => {
|
const createList = async () => {
|
||||||
const name = newListName.trim();
|
const name = newListName.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
|
||||||
if (isDemo) {
|
if (isDemo) {
|
||||||
if (onDemoCreateList) onDemoCreateList(name, newListColor);
|
if (onDemoCreateList) onDemoCreateList(name, newListColor);
|
||||||
setNewListName("");
|
setNewListName("");
|
||||||
setShowNewList(false);
|
setShowNewList(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/lists", {
|
const res = await fetch("/api/lists", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -122,35 +158,72 @@ export function Sidebar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteList = async (id: string) => {
|
const deleteList = async (id: string) => {
|
||||||
if (!confirm(t("deleteListConfirm"))) return;
|
// Store list for undo
|
||||||
|
const listToDelete = lists.find((l) => l.id === id);
|
||||||
|
if (!listToDelete) return;
|
||||||
|
|
||||||
|
// Optimistic: remove from UI immediately
|
||||||
|
setLists((prev) => {
|
||||||
|
const updated = prev.filter((l) => l.id !== id);
|
||||||
|
if (selectedListId === id && updated.length > 0) onListSelect(updated[0].id);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
if (isDemo) {
|
if (isDemo) {
|
||||||
if (onDemoDeleteList) onDemoDeleteList(id);
|
if (onDemoDeleteList) onDemoDeleteList(id);
|
||||||
|
showUndoToast(t("listDeleted"), () => {
|
||||||
|
// Undo: re-add the list (demo mode — restore optimistically)
|
||||||
|
setLists((prev) => {
|
||||||
|
if (prev.find((l) => l.id === id)) return prev;
|
||||||
|
return [...prev, listToDelete].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
});
|
||||||
|
onListSelect(id);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/lists/${id}`, { method: "DELETE" });
|
await fetch(`/api/lists/${id}`, { method: "DELETE" });
|
||||||
setLists((prev) => {
|
|
||||||
const updated = prev.filter((l) => l.id !== id);
|
|
||||||
if (selectedListId === id) onListSelect(updated[0]?.id || "");
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to delete list", err);
|
console.error("Failed to delete list", err);
|
||||||
|
// Rollback
|
||||||
|
setLists((prev) => [...prev, listToDelete]);
|
||||||
|
}
|
||||||
|
showUndoToast(t("listDeleted"), () => {
|
||||||
|
// For API mode, re-fetch (simplest undo simulation)
|
||||||
|
fetch("/api/lists")
|
||||||
|
.then((r) => r.ok ? r.json() : [])
|
||||||
|
.then((data) => { if (Array.isArray(data)) setLists(data); })
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renameList = async (id: string, newName: string) => {
|
||||||
|
const trimmed = newName.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
setLists((prev) => prev.map((l) => l.id === id ? { ...l, name: trimmed } : l));
|
||||||
|
setRenamingListId(null);
|
||||||
|
if (!isDemo) {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/lists/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: trimmed }),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to rename list", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImport = async () => {
|
const handleImport = async () => {
|
||||||
const file = fileRef.current?.files?.[0];
|
const file = fileRef.current?.files?.[0];
|
||||||
if (!file || !importListId) return;
|
if (!file || !importListId) return;
|
||||||
|
|
||||||
if (isDemo) {
|
if (isDemo) {
|
||||||
setImportResult("✓ Demo Mode: Import simulated successfully");
|
setImportResult("✓ Demo Mode: Import simulated successfully");
|
||||||
setTimeout(() => { setShowImport(false); setImportResult(""); }, 1500);
|
setTimeout(() => { setShowImport(false); setImportResult(""); }, 1500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setImporting(true);
|
setImporting(true);
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -180,10 +253,21 @@ export function Sidebar({
|
|||||||
|
|
||||||
const initials = user.name?.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2) || "?";
|
const initials = user.name?.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2) || "?";
|
||||||
|
|
||||||
// List context menu items
|
// Context menu items for list (right-click)
|
||||||
const getListContextMenuItems = (listId: string): MenuItem[] => [
|
const getListContextMenuItems = (listId: string): MenuItem[] => [
|
||||||
{
|
{
|
||||||
label: t("deleteListConfirm").split("?")[0],
|
label: t("renameList"),
|
||||||
|
icon: "✏️",
|
||||||
|
onClick: () => {
|
||||||
|
const list = lists.find((l) => l.id === listId);
|
||||||
|
if (list) {
|
||||||
|
setRenamingListId(listId);
|
||||||
|
setRenameValue(list.name);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("deleteList"),
|
||||||
icon: "🗑️",
|
icon: "🗑️",
|
||||||
danger: true,
|
danger: true,
|
||||||
onClick: () => deleteList(listId),
|
onClick: () => deleteList(listId),
|
||||||
@@ -193,7 +277,7 @@ export function Sidebar({
|
|||||||
return (
|
return (
|
||||||
<aside className={`sidebar${mobileOpen ? " mobile-open" : ""}`}>
|
<aside className={`sidebar${mobileOpen ? " mobile-open" : ""}`}>
|
||||||
{/* Header with Logo & Controls */}
|
{/* Header with Logo & Controls */}
|
||||||
<div className="sidebar-header" style={{ padding: "14px 14px 10px", gap: 8 }}>
|
<div className="sidebar-header" style={{ padding: "12px 12px 8px", gap: 6 }}>
|
||||||
<div className="sidebar-logo">✓</div>
|
<div className="sidebar-logo">✓</div>
|
||||||
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
|
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
|
||||||
<span className="sidebar-title" style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
<span className="sidebar-title" style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
@@ -206,19 +290,76 @@ export function Sidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Controls: Search, Language & Theme */}
|
{/* Top Controls: Hover-expand Search, Language & Theme */}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 2, flexShrink: 0 }}>
|
||||||
{/* Quick Search Button */}
|
{/* Hover-expand Search */}
|
||||||
<button
|
<div
|
||||||
className="icon-btn"
|
style={{
|
||||||
id="quick-search-btn"
|
position: "relative",
|
||||||
onClick={() => document.dispatchEvent(new CustomEvent("checkflow:openCommandPalette"))}
|
display: "flex",
|
||||||
title="Search (Ctrl+K)"
|
alignItems: "center",
|
||||||
style={{ width: 28, height: 28, flexShrink: 0 }}
|
transition: "all var(--dur-normal) var(--ease-out)",
|
||||||
type="button"
|
}}
|
||||||
|
onMouseEnter={() => setSearchExpanded(true)}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
if (!searchQuery) setSearchExpanded(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
🔍
|
{searchExpanded ? (
|
||||||
</button>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
border: "1.5px solid var(--accent)",
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
padding: "3px 8px",
|
||||||
|
animation: "expandSearch var(--dur-normal) var(--ease-out)",
|
||||||
|
width: 180,
|
||||||
|
boxShadow: "0 0 0 3px var(--accent-light)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2.5">
|
||||||
|
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === "Escape") {
|
||||||
|
document.dispatchEvent(new CustomEvent("checkflow:openCommandPalette"));
|
||||||
|
setSearchExpanded(false);
|
||||||
|
setSearchQuery("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t("searchPlaceholder")}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
id="quick-search-btn"
|
||||||
|
onClick={() => document.dispatchEvent(new CustomEvent("checkflow:openCommandPalette"))}
|
||||||
|
title={t("searchPlaceholder")}
|
||||||
|
style={{ width: 28, height: 28, flexShrink: 0 }}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
|
|
||||||
@@ -234,12 +375,14 @@ export function Sidebar({
|
|||||||
{theme === "system" ? (
|
{theme === "system" ? (
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||||
<line x1="8" y1="21" x2="16" y2="21" />
|
<line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" />
|
||||||
<line x1="12" y1="17" x2="12" y2="21" />
|
|
||||||
</svg>
|
</svg>
|
||||||
) : theme === "light" ? (
|
) : theme === "light" ? (
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<circle cx="12" cy="12" r="5" /><line x1="12" y1="1" x2="12" y2="3" /><line x1="12" y1="21" x2="12" y2="23" /><line x1="4.22" y1="4.22" x2="5.64" y2="5.64" /><line x1="18.36" y1="18.36" x2="19.78" y2="19.78" /><line x1="1" y1="12" x2="3" y2="12" /><line x1="21" y1="12" x2="23" y2="12" /><line x1="4.22" y1="19.78" x2="5.64" y2="18.36" /><line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
<circle cx="12" cy="12" r="5" /><line x1="12" y1="1" x2="12" y2="3" /><line x1="12" y1="21" x2="12" y2="23" />
|
||||||
|
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" /><line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||||
|
<line x1="1" y1="12" x2="3" y2="12" /><line x1="21" y1="12" x2="23" y2="12" />
|
||||||
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" /><line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
@@ -260,6 +403,7 @@ export function Sidebar({
|
|||||||
key={list.id}
|
key={list.id}
|
||||||
className={`sidebar-item${!isTrashActive && !selectedTag && selectedListId === list.id ? " active" : ""}`}
|
className={`sidebar-item${!isTrashActive && !selectedTag && selectedListId === list.id ? " active" : ""}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (renamingListId === list.id) return;
|
||||||
onTagSelect(null);
|
onTagSelect(null);
|
||||||
onListSelect(list.id);
|
onListSelect(list.id);
|
||||||
}}
|
}}
|
||||||
@@ -268,22 +412,43 @@ export function Sidebar({
|
|||||||
setContextMenu({ x: e.clientX, y: e.clientY, listId: list.id });
|
setContextMenu({ x: e.clientX, y: e.clientY, listId: list.id });
|
||||||
}}
|
}}
|
||||||
id={`list-item-${list.id}`}
|
id={`list-item-${list.id}`}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
>
|
>
|
||||||
<div className="list-dot" style={{ background: list.color }} />
|
<div className="list-dot" style={{ background: list.color, flexShrink: 0 }} />
|
||||||
<span className="item-label">{list.name}</span>
|
|
||||||
|
{renamingListId === list.id ? (
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") renameList(list.id, renameValue);
|
||||||
|
if (e.key === "Escape") setRenamingListId(null);
|
||||||
|
}}
|
||||||
|
onBlur={() => renameList(list.id, renameValue)}
|
||||||
|
autoFocus
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ flex: 1, fontSize: 12.5, padding: "2px 6px", height: 24 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="item-label">{list.name}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<span className="item-count">{list._count?.tasks || ""}</span>
|
<span className="item-count">{list._count?.tasks || ""}</span>
|
||||||
<button
|
<button
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
style={{ width: 22, height: 22, opacity: 0.4 }}
|
style={{ width: 20, height: 20, opacity: 0, transition: "opacity var(--dur-fast)", flexShrink: 0 }}
|
||||||
|
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.opacity = "1"; }}
|
||||||
|
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.opacity = "0"; }}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
deleteList(list.id);
|
setContextMenu({ x: e.clientX, y: e.clientY, listId: list.id });
|
||||||
}}
|
}}
|
||||||
title="Delete list"
|
title="More options"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
|
<circle cx="12" cy="5" r="1.5" /><circle cx="12" cy="12" r="1.5" /><circle cx="12" cy="19" r="1.5" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -291,19 +456,21 @@ export function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Custom Tags Section */}
|
{/* Custom Tags Section */}
|
||||||
<div className="sidebar-section">
|
{tags.length > 0 && (
|
||||||
<div className="sidebar-section-label">🏷️ {t("tags") || "Tags"}</div>
|
<div className="sidebar-section">
|
||||||
{tags.map((tag) => (
|
<div className="sidebar-section-label">🏷️ {t("tags") || "Tags"}</div>
|
||||||
<div
|
{tags.map((tag) => (
|
||||||
key={tag.id}
|
<div
|
||||||
className={`sidebar-item${selectedTag === tag.name ? " active" : ""}`}
|
key={tag.id}
|
||||||
onClick={() => onTagSelect(selectedTag === tag.name ? null : tag.name)}
|
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>
|
<span style={{ color: tag.color, fontSize: 13, fontWeight: 700 }}>#</span>
|
||||||
</div>
|
<span className="item-label">{tag.name}</span>
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Trash Smart List */}
|
{/* Trash Smart List */}
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
@@ -313,7 +480,9 @@ export function Sidebar({
|
|||||||
onClick={onTrashSelect}
|
onClick={onTrashSelect}
|
||||||
style={{ color: isTrashActive ? "var(--danger)" : "var(--text-secondary)" }}
|
style={{ color: isTrashActive ? "var(--danger)" : "var(--text-secondary)" }}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 14 }}>🗑️</span>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ opacity: 0.7 }}>
|
||||||
|
<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>
|
||||||
<span className="item-label">{t("trash") || "Trash"}</span>
|
<span className="item-label">{t("trash") || "Trash"}</span>
|
||||||
{trashCount > 0 && <span className="item-count" style={{ color: "var(--danger)" }}>{trashCount}</span>}
|
{trashCount > 0 && <span className="item-count" style={{ color: "var(--danger)" }}>{trashCount}</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -322,7 +491,7 @@ export function Sidebar({
|
|||||||
{/* New list form */}
|
{/* New list form */}
|
||||||
{showNewList ? (
|
{showNewList ? (
|
||||||
<div style={{ padding: "4px 10px" }}>
|
<div style={{ padding: "4px 10px" }}>
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, padding: "6px 4px 8px" }}>
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 5, padding: "6px 4px 8px" }}>
|
||||||
{LIST_COLORS.map((c) => (
|
{LIST_COLORS.map((c) => (
|
||||||
<div
|
<div
|
||||||
key={c}
|
key={c}
|
||||||
@@ -355,7 +524,7 @@ export function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button id="new-list-btn" className="sidebar-add-btn" onClick={() => setShowNewList(true)} type="button">
|
<button id="new-list-btn" className="sidebar-add-btn" onClick={() => setShowNewList(true)} type="button">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
</svg>
|
</svg>
|
||||||
{t("newList")}
|
{t("newList")}
|
||||||
@@ -363,22 +532,21 @@ export function Sidebar({
|
|||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User footer with Profile Menu containing Settings & Import */}
|
{/* User footer */}
|
||||||
<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: "var(--accent)" }}>{initials}</div>
|
||||||
<div className="user-info">
|
<div className="user-info">
|
||||||
<div className="user-name">{user.name || (isDemo ? "Demo User" : "User")}</div>
|
<div className="user-name">{user.name || (isDemo ? "Demo User" : "User")}</div>
|
||||||
<div className="user-email">{user.email || (isDemo ? "demo@checkflow.local" : "")}</div>
|
<div className="user-email">{user.email || (isDemo ? "demo@checkflow.local" : "")}</div>
|
||||||
</div>
|
</div>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ opacity: 0.5 }}>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ opacity: 0.4 }}>
|
||||||
<polyline points="6 9 12 15 18 9" />
|
<polyline points="6 9 12 15 18 9" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* 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 }}>
|
||||||
{/* Settings Action */}
|
|
||||||
<div
|
<div
|
||||||
className="dropdown-item"
|
className="dropdown-item"
|
||||||
id="sidebar-settings-menu-item"
|
id="sidebar-settings-menu-item"
|
||||||
@@ -388,10 +556,12 @@ export function Sidebar({
|
|||||||
setUserMenuOpen(false);
|
setUserMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
⚙️ {t("settingsModalTitle") || "Settings"}
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||||
|
</svg>
|
||||||
|
{t("settingsModalTitle") || "Settings"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Import Action */}
|
|
||||||
<div
|
<div
|
||||||
className="dropdown-item"
|
className="dropdown-item"
|
||||||
id="sidebar-import-menu-item"
|
id="sidebar-import-menu-item"
|
||||||
@@ -402,21 +572,27 @@ export function Sidebar({
|
|||||||
setUserMenuOpen(false);
|
setUserMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
📥 {t("importTasks")}
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" />
|
||||||
|
</svg>
|
||||||
|
{t("importTasks")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="context-menu-divider" />
|
<div className="dropdown-divider" />
|
||||||
|
|
||||||
{isDemo ? (
|
{isDemo ? (
|
||||||
<div
|
<div className="dropdown-item" onClick={() => { window.location.href = "/login"; }}>
|
||||||
className="dropdown-item"
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
onClick={() => { window.location.href = "/login"; }}
|
<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")}
|
{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" })}>
|
||||||
🚪 {t("signOut")}
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<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>
|
||||||
@@ -474,6 +650,71 @@ export function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Undo Toast */}
|
||||||
|
{undoToast && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
bottom: 24,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
zIndex: 300,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
background: "var(--text-primary)",
|
||||||
|
color: "var(--bg-primary)",
|
||||||
|
padding: "10px 16px",
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
animation: "toastIn var(--dur-normal) var(--ease-out)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{undoToast.message}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
undoToast.onUndo();
|
||||||
|
if (undoToast.timeoutId) clearTimeout(undoToast.timeoutId);
|
||||||
|
setUndoToast(null);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "var(--accent)",
|
||||||
|
color: "#fff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
padding: "3px 10px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("undoDelete")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (undoToast.timeoutId) clearTimeout(undoToast.timeoutId);
|
||||||
|
setUndoToast(null);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
color: "inherit",
|
||||||
|
border: "none",
|
||||||
|
opacity: 0.6,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 14,
|
||||||
|
padding: "0 2px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { useTheme } from "@/app/providers";
|
import { useTheme } from "@/app/providers";
|
||||||
import { getUserSettings, saveUserSettings, UserSettings } from "@/lib/mockData";
|
import { getUserSettings, saveUserSettings, UserSettings } from "@/lib/mockData";
|
||||||
|
import { useUserPrefs } from "@/lib/useUserPrefs";
|
||||||
|
|
||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -11,11 +12,125 @@ interface SettingsModalProps {
|
|||||||
isDemo?: boolean;
|
isDemo?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A toggle-row component used throughout Labs tab */
|
||||||
|
function LabsRow({
|
||||||
|
label,
|
||||||
|
desc,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
desc?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "10px 14px",
|
||||||
|
background: "var(--bg-secondary)",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
marginBottom: 8,
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--text-primary)" }}>{label}</div>
|
||||||
|
{desc && (
|
||||||
|
<div style={{ fontSize: 12, color: "var(--text-secondary)", marginTop: 2, lineHeight: 1.4 }}>
|
||||||
|
{desc}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flexShrink: 0 }}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Segmented control (radio-style) */
|
||||||
|
function SegmentedControl<T extends string>({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: T;
|
||||||
|
options: { label: string; value: T }[];
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
padding: 2,
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(opt.value)}
|
||||||
|
style={{
|
||||||
|
padding: "4px 10px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: value === opt.value ? 700 : 500,
|
||||||
|
borderRadius: "var(--radius-xs)",
|
||||||
|
background: value === opt.value ? "var(--accent)" : "transparent",
|
||||||
|
color: value === opt.value ? "#fff" : "var(--text-secondary)",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all var(--dur-fast)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slider with live value display */
|
||||||
|
function PrefSlider({
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
unit,
|
||||||
|
}: {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
value: number;
|
||||||
|
onChange: (v: number) => void;
|
||||||
|
unit?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||||
|
style={{ width: 120, accentColor: "var(--accent)" }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--text-secondary)", minWidth: 40 }}>
|
||||||
|
{value}{unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsModal({ isOpen, onClose, user, isDemo = false }: SettingsModalProps) {
|
export function SettingsModal({ isOpen, onClose, user, isDemo = false }: SettingsModalProps) {
|
||||||
const { t, lang, setLang } = useI18n();
|
const { t, lang, setLang } = useI18n();
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const { prefs, updatePrefs, resetToDefaults } = useUserPrefs();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<"profile" | "preferences" | "sync" | "admin">("profile");
|
const [activeTab, setActiveTab] = useState<"profile" | "preferences" | "labs" | "sync" | "admin">("profile");
|
||||||
const [displayName, setDisplayName] = useState(user.name || "Demo User");
|
const [displayName, setDisplayName] = useState(user.name || "Demo User");
|
||||||
const [email, setEmail] = useState(user.email || "demo@checkflow.local");
|
const [email, setEmail] = useState(user.email || "demo@checkflow.local");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -51,199 +166,377 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
|
|
||||||
const calDavUrl = typeof window !== "undefined" ? `${window.location.origin}/api/dav` : "https://todo.yourdomain.com/api/dav";
|
const calDavUrl = typeof window !== "undefined" ? `${window.location.origin}/api/dav` : "https://todo.yourdomain.com/api/dav";
|
||||||
|
|
||||||
|
// Accent hue preview
|
||||||
|
const hue = prefs.accentHue;
|
||||||
|
const sat = Math.round(prefs.saturation * 0.9);
|
||||||
|
const accentPreview = `hsl(${hue}, ${sat}%, 54%)`;
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: "profile", label: `👤 ${t("profile") || "Profile"}` },
|
||||||
|
{ id: "preferences", label: `⚙️ ${t("preferences") || "Preferences"}` },
|
||||||
|
{ id: "labs", label: `🧪 ${t("labs") || "Labs"}` },
|
||||||
|
{ id: "sync", label: `📱 ${t("syncIntegrations") || "Integrations"}` },
|
||||||
|
{ id: "admin", label: `👑 ${t("admin") || "Admin"}` },
|
||||||
|
] as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
<div className="modal settings-modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 640, width: "90%" }}>
|
<div
|
||||||
|
className="modal settings-modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ maxWidth: 660, width: "92%", padding: "24px 24px 20px", maxHeight: "90vh", display: "flex", flexDirection: "column" }}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
|
||||||
<h2 className="modal-title" style={{ marginBottom: 0 }}>⚙️ {t("settingsModalTitle") || "Settings"}</h2>
|
<h2 className="modal-title" style={{ marginBottom: 0, fontSize: 17 }}>⚙️ {t("settingsModalTitle") || "Settings"}</h2>
|
||||||
<button className="icon-btn" onClick={onClose} aria-label="Close" type="button">✕</button>
|
<button className="icon-btn" onClick={onClose} aria-label="Close" type="button">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Bar */}
|
{/* Tab Bar */}
|
||||||
<div className="settings-tab-bar" style={{ display: "flex", gap: 6, borderBottom: "1px solid var(--border)", marginBottom: 18, paddingBottom: 6 }}>
|
<div
|
||||||
<button
|
style={{
|
||||||
type="button"
|
display: "flex",
|
||||||
className={`btn btn-sm ${activeTab === "profile" ? "btn-primary" : "btn-ghost"}`}
|
gap: 2,
|
||||||
onClick={() => setActiveTab("profile")}
|
borderBottom: "1px solid var(--border)",
|
||||||
>
|
marginBottom: 16,
|
||||||
👤 {t("profile") || "Profile"}
|
paddingBottom: 0,
|
||||||
</button>
|
overflowX: "auto",
|
||||||
<button
|
}}
|
||||||
type="button"
|
>
|
||||||
className={`btn btn-sm ${activeTab === "preferences" ? "btn-primary" : "btn-ghost"}`}
|
{tabs.map((tab) => (
|
||||||
onClick={() => setActiveTab("preferences")}
|
<button
|
||||||
>
|
key={tab.id}
|
||||||
⚙️ {t("preferences") || "Preferences"}
|
type="button"
|
||||||
</button>
|
onClick={() => setActiveTab(tab.id)}
|
||||||
<button
|
style={{
|
||||||
type="button"
|
padding: "7px 14px",
|
||||||
className={`btn btn-sm ${activeTab === "sync" ? "btn-primary" : "btn-ghost"}`}
|
fontSize: 12.5,
|
||||||
onClick={() => setActiveTab("sync")}
|
fontWeight: activeTab === tab.id ? 700 : 500,
|
||||||
>
|
background: "transparent",
|
||||||
📱 {t("syncIntegrations") || "Integrations (DAVx⁵)"}
|
color: activeTab === tab.id ? "var(--accent)" : "var(--text-secondary)",
|
||||||
</button>
|
border: "none",
|
||||||
<button
|
borderBottom: activeTab === tab.id ? "2px solid var(--accent)" : "2px solid transparent",
|
||||||
type="button"
|
borderRadius: 0,
|
||||||
className={`btn btn-sm ${activeTab === "admin" ? "btn-primary" : "btn-ghost"}`}
|
cursor: "pointer",
|
||||||
onClick={() => setActiveTab("admin")}
|
whiteSpace: "nowrap",
|
||||||
>
|
transition: "all var(--dur-fast)",
|
||||||
👑 {t("admin") || "Admin"}
|
}}
|
||||||
</button>
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab 1: Profile */}
|
{/* Scrollable tab content */}
|
||||||
{activeTab === "profile" && (
|
<div style={{ flex: 1, overflowY: "auto", paddingRight: 2 }}>
|
||||||
<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) */}
|
{/* Tab 1: Profile */}
|
||||||
{activeTab === "preferences" && (
|
{activeTab === "profile" && (
|
||||||
<div className="settings-tab-content">
|
<div className="settings-tab-content">
|
||||||
{/* Trash Retention Days */}
|
<div className="form-group">
|
||||||
<div className="form-group">
|
<label className="form-label">{t("displayName")}</label>
|
||||||
<label className="form-label" style={{ fontWeight: 600 }}>
|
<input className="form-input" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Your Name" />
|
||||||
🗑️ {t("trashRetention") || "Trash Auto-Delete Retention Period"}
|
</div>
|
||||||
</label>
|
<div className="form-group">
|
||||||
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginBottom: 8 }}>
|
<label className="form-label">{t("email")}</label>
|
||||||
{t("trashRetentionHint") || "Deleted tasks will be permanently removed after the specified period."}
|
<input className="form-input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="your.email@example.com" />
|
||||||
</p>
|
</div>
|
||||||
<select
|
<div className="form-group">
|
||||||
className="form-input"
|
<label className="form-label">{t("password")} (Change)</label>
|
||||||
value={trashRetention}
|
<input className="form-input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="New password (leave blank to keep current)" />
|
||||||
onChange={(e) => setTrashRetention(parseInt(e.target.value, 10))}
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab 2: Preferences */}
|
||||||
|
{activeTab === "preferences" && (
|
||||||
|
<div className="settings-tab-content">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">{t("theme")}</label>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
{[
|
||||||
|
{ key: "system", icon: "💻", label: t("themeSystem") },
|
||||||
|
{ key: "light", icon: "☀️", label: t("themeLight") },
|
||||||
|
{ key: "dark", icon: "🌙", label: t("themeDark") },
|
||||||
|
].map(({ key, icon, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${theme === key ? "btn-primary" : "btn-ghost"}`}
|
||||||
|
onClick={() => { if (theme !== key) toggleTheme(); }}
|
||||||
|
>
|
||||||
|
{icon} {label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab 3: 🧪 CheckFlow Labs */}
|
||||||
|
{activeTab === "labs" && (
|
||||||
|
<div className="settings-tab-content">
|
||||||
|
{/* Section: View */}
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 8 }}>
|
||||||
|
View
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LabsRow
|
||||||
|
label="📊 Kanban Board"
|
||||||
|
desc="Switch between list view and 3-column Kanban board (To Do / In Progress / Done)."
|
||||||
>
|
>
|
||||||
<option value={7}>{t("days7") || "7 Days"}</option>
|
<SegmentedControl
|
||||||
<option value={14}>{t("days14") || "14 Days"}</option>
|
value={prefs.viewMode}
|
||||||
<option value={30}>{t("days30") || "30 Days (Recommended)"}</option>
|
options={[
|
||||||
<option value={0}>{t("neverDelete") || "Never Auto-Delete (Manual empty only)"}</option>
|
{ label: "List", value: "list" },
|
||||||
</select>
|
{ label: "Kanban", value: "kanban" },
|
||||||
</div>
|
]}
|
||||||
|
onChange={(v) => updatePrefs({ viewMode: v })}
|
||||||
|
/>
|
||||||
|
</LabsRow>
|
||||||
|
|
||||||
{/* Language Selection */}
|
{/* Section: Layout */}
|
||||||
<div className="form-group">
|
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", margin: "16px 0 8px" }}>
|
||||||
<label className="form-label">{t("language")}</label>
|
Layout
|
||||||
<select className="form-input" value={lang} onChange={(e) => setLang(e.target.value as any)}>
|
</div>
|
||||||
<option value="en">English (Default)</option>
|
|
||||||
<option value="ko">한국어 (Korean)</option>
|
|
||||||
<option value="ja">日本語 (Japanese)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Theme Toggle */}
|
<LabsRow
|
||||||
<div className="form-group">
|
label="📐 Content Density"
|
||||||
<label className="form-label">{t("theme")}</label>
|
desc="Controls the vertical spacing of task items."
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
>
|
||||||
<button
|
<SegmentedControl
|
||||||
type="button"
|
value={prefs.density}
|
||||||
className={`btn btn-sm ${theme === "system" ? "btn-primary" : "btn-ghost"}`}
|
options={[
|
||||||
onClick={() => { if (theme !== "system") toggleTheme(); }}
|
{ label: "Compact", value: "compact" },
|
||||||
|
{ label: "Default", value: "default" },
|
||||||
|
{ label: "Airy", value: "comfortable" },
|
||||||
|
]}
|
||||||
|
onChange={(v) => updatePrefs({ density: v })}
|
||||||
|
/>
|
||||||
|
</LabsRow>
|
||||||
|
|
||||||
|
<LabsRow
|
||||||
|
label="↔️ Sidebar Width"
|
||||||
|
desc={`Drag the sidebar edge or adjust here. (${prefs.sidebarWidth}px)`}
|
||||||
|
>
|
||||||
|
<PrefSlider
|
||||||
|
min={160}
|
||||||
|
max={420}
|
||||||
|
value={prefs.sidebarWidth}
|
||||||
|
onChange={(v) => updatePrefs({ sidebarWidth: v })}
|
||||||
|
unit="px"
|
||||||
|
/>
|
||||||
|
</LabsRow>
|
||||||
|
|
||||||
|
<LabsRow
|
||||||
|
label="↔️ Detail Panel Width"
|
||||||
|
desc={`Drag the panel edge or adjust here. (${prefs.detailWidth}px)`}
|
||||||
|
>
|
||||||
|
<PrefSlider
|
||||||
|
min={300}
|
||||||
|
max={760}
|
||||||
|
value={prefs.detailWidth}
|
||||||
|
onChange={(v) => updatePrefs({ detailWidth: v })}
|
||||||
|
unit="px"
|
||||||
|
/>
|
||||||
|
</LabsRow>
|
||||||
|
|
||||||
|
{/* Section: Appearance */}
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", margin: "16px 0 8px" }}>
|
||||||
|
Appearance
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LabsRow
|
||||||
|
label="🔤 Font Size"
|
||||||
|
desc="Base font size across the app."
|
||||||
|
>
|
||||||
|
<SegmentedControl
|
||||||
|
value={prefs.fontSize}
|
||||||
|
options={[
|
||||||
|
{ label: "S", value: "small" },
|
||||||
|
{ label: "M", value: "default" },
|
||||||
|
{ label: "L", value: "large" },
|
||||||
|
]}
|
||||||
|
onChange={(v) => updatePrefs({ fontSize: v })}
|
||||||
|
/>
|
||||||
|
</LabsRow>
|
||||||
|
|
||||||
|
<LabsRow
|
||||||
|
label="🎨 Accent Color"
|
||||||
|
desc="Choose the hue of your accent color. Saturation controls vibrancy."
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6, alignItems: "flex-end" }}>
|
||||||
|
{/* Hue ring preview */}
|
||||||
|
<div style={{ display: "flex", gap: 5, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||||
|
{[210, 250, 340, 10, 45, 145, 185].map((h) => (
|
||||||
|
<div
|
||||||
|
key={h}
|
||||||
|
onClick={() => updatePrefs({ accentHue: h })}
|
||||||
|
title={`Hue ${h}°`}
|
||||||
|
style={{
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: `hsl(${h}, ${sat}%, 54%)`,
|
||||||
|
cursor: "pointer",
|
||||||
|
border: prefs.accentHue === h ? "2.5px solid var(--text-primary)" : "2px solid transparent",
|
||||||
|
outline: prefs.accentHue === h ? `3px solid ${accentPreview}` : "none",
|
||||||
|
outlineOffset: 1,
|
||||||
|
transition: "transform var(--dur-fast)",
|
||||||
|
transform: prefs.accentHue === h ? "scale(1.2)" : "scale(1)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>Saturation</span>
|
||||||
|
<PrefSlider
|
||||||
|
min={20}
|
||||||
|
max={100}
|
||||||
|
value={prefs.saturation}
|
||||||
|
onChange={(v) => updatePrefs({ saturation: v })}
|
||||||
|
unit="%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Custom hue input */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>Hue °</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={360}
|
||||||
|
value={prefs.accentHue}
|
||||||
|
onChange={(e) => updatePrefs({ accentHue: parseInt(e.target.value) })}
|
||||||
|
style={{ width: 100, accentColor: accentPreview }}
|
||||||
|
/>
|
||||||
|
<div style={{ width: 18, height: 18, borderRadius: "50%", background: accentPreview, flexShrink: 0 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</LabsRow>
|
||||||
|
|
||||||
|
<LabsRow
|
||||||
|
label="⬛ Border Roundness"
|
||||||
|
desc="Controls the roundness of cards, buttons, and UI elements."
|
||||||
|
>
|
||||||
|
<SegmentedControl
|
||||||
|
value={prefs.roundness}
|
||||||
|
options={[
|
||||||
|
{ label: "Sharp", value: "sharp" },
|
||||||
|
{ label: "Default", value: "default" },
|
||||||
|
{ label: "Round", value: "round" },
|
||||||
|
]}
|
||||||
|
onChange={(v) => updatePrefs({ roundness: v })}
|
||||||
|
/>
|
||||||
|
</LabsRow>
|
||||||
|
|
||||||
|
<LabsRow
|
||||||
|
label="⚡ Animation Speed"
|
||||||
|
desc="Controls the speed of transitions and hover effects."
|
||||||
|
>
|
||||||
|
<SegmentedControl
|
||||||
|
value={prefs.animationSpeed}
|
||||||
|
options={[
|
||||||
|
{ label: "Off", value: "none" },
|
||||||
|
{ label: "Fast", value: "fast" },
|
||||||
|
{ label: "Default", value: "default" },
|
||||||
|
{ label: "Slow", value: "slow" },
|
||||||
|
]}
|
||||||
|
onChange={(v) => updatePrefs({ animationSpeed: v })}
|
||||||
|
/>
|
||||||
|
</LabsRow>
|
||||||
|
|
||||||
|
{/* Reset to Defaults */}
|
||||||
|
<div style={{ marginTop: 20, borderTop: "1px solid var(--border)", paddingTop: 16 }}>
|
||||||
|
<LabsRow
|
||||||
|
label="🔄 Reset to Defaults"
|
||||||
|
desc="Restores all layout, appearance, and view settings to their factory defaults."
|
||||||
>
|
>
|
||||||
💻 {t("themeSystem")}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button
|
className="btn btn-sm"
|
||||||
type="button"
|
style={{ color: "var(--danger)", border: "1px solid var(--danger)", background: "transparent" }}
|
||||||
className={`btn btn-sm ${theme === "light" ? "btn-primary" : "btn-ghost"}`}
|
onClick={() => {
|
||||||
onClick={() => { if (theme !== "light") toggleTheme(); }}
|
if (confirm(t("resetConfirm") || "Reset all customizations to defaults?")) {
|
||||||
>
|
resetToDefaults();
|
||||||
☀️ {t("themeLight")}
|
}
|
||||||
</button>
|
}}
|
||||||
<button
|
>
|
||||||
type="button"
|
{t("resetDefaults") || "Reset"}
|
||||||
className={`btn btn-sm ${theme === "dark" ? "btn-primary" : "btn-ghost"}`}
|
</button>
|
||||||
onClick={() => { if (theme !== "dark") toggleTheme(); }}
|
</LabsRow>
|
||||||
>
|
|
||||||
🌙 {t("themeDark")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tab 3: CalDAV / CardDAV Integrations */}
|
{/* Tab: CalDAV / CardDAV Integrations */}
|
||||||
{activeTab === "sync" && (
|
{activeTab === "sync" && (
|
||||||
<div className="settings-tab-content">
|
<div className="settings-tab-content">
|
||||||
<h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>📱 Galaxy & Mobile Sync via DAVx⁵</h3>
|
<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 }}>
|
<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.
|
CheckFlow supports native two-way synchronization with Samsung Galaxy Reminder, Apple Reminders, and Thunderbird via CalDAV/CardDAV protocols.
|
||||||
</p>
|
</p>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">CalDAV Base Endpoint</label>
|
<label className="form-label">CalDAV Base Endpoint</label>
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<input className="form-input" readOnly value={calDavUrl} style={{ fontFamily: "monospace", fontSize: 12 }} />
|
<input className="form-input" readOnly value={calDavUrl} style={{ fontFamily: "monospace", fontSize: 12 }} />
|
||||||
<button
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { navigator.clipboard.writeText(calDavUrl); }}>
|
||||||
type="button"
|
📋 Copy
|
||||||
className="btn btn-ghost btn-sm"
|
</button>
|
||||||
onClick={() => {
|
</div>
|
||||||
navigator.clipboard.writeText(calDavUrl);
|
</div>
|
||||||
alert("Copied to clipboard!");
|
<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 }}>
|
||||||
📋 Copy
|
<li>Install <strong>DAVx⁵</strong> on your Samsung Galaxy from Google Play or F-Droid.</li>
|
||||||
</button>
|
<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>
|
||||||
</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 */}
|
{/* Tab 4: Admin Quick Access */}
|
||||||
{activeTab === "admin" && (
|
{activeTab === "admin" && (
|
||||||
<div className="settings-tab-content">
|
<div className="settings-tab-content">
|
||||||
<h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>👑 Multi-User Admin Console</h3>
|
<h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>👑 Multi-User Admin Console</h3>
|
||||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: 14 }}>
|
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: 14 }}>
|
||||||
Manage registered users, user roles (User/Admin), system statistics, and storage allocations.
|
Manage registered users, user roles (User/Admin), system statistics, and storage allocations.
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a href="/admin" className="btn btn-primary" style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
||||||
href="/admin"
|
🚀 Open Admin Dashboard →
|
||||||
className="btn btn-primary"
|
</a>
|
||||||
style={{ display: "inline-flex", alignItems: "center", gap: 8 }}
|
</div>
|
||||||
>
|
)}
|
||||||
🚀 Open Admin Dashboard →
|
</div>
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="modal-footer" style={{ marginTop: 20, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
<div
|
||||||
|
className="modal-footer"
|
||||||
|
style={{ marginTop: 16, paddingTop: 12, borderTop: "1px solid var(--border)", display: "flex", justifyContent: "space-between", alignItems: "center" }}
|
||||||
|
>
|
||||||
{savedMsg && <span style={{ fontSize: 13, color: "var(--success)", fontWeight: 600 }}>{savedMsg}</span>}
|
{savedMsg && <span style={{ fontSize: 13, color: "var(--success)", fontWeight: 600 }}>{savedMsg}</span>}
|
||||||
<div style={{ display: "flex", gap: 8, marginLeft: "auto" }}>
|
<div style={{ display: "flex", gap: 8, marginLeft: "auto" }}>
|
||||||
<button className="btn btn-ghost" onClick={onClose} type="button">{t("cancel")}</button>
|
<button className="btn btn-ghost" onClick={onClose} type="button">{t("cancel")}</button>
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Task } from "./TaskList";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
|
interface KanbanViewProps {
|
||||||
|
tasks: Task[];
|
||||||
|
selectedTaskId: string | null;
|
||||||
|
onSelectTask: (task: Task) => void;
|
||||||
|
onToggleTask: (id: string, completed: boolean) => void;
|
||||||
|
onAddTask: (title: string, priority?: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KanbanView({
|
||||||
|
tasks,
|
||||||
|
selectedTaskId,
|
||||||
|
onSelectTask,
|
||||||
|
onToggleTask,
|
||||||
|
onAddTask,
|
||||||
|
}: KanbanViewProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [newTodoTitle, setNewTodoTitle] = useState("");
|
||||||
|
const [newProgTitle, setNewProgTitle] = useState("");
|
||||||
|
|
||||||
|
const PRIORITY_COLORS = ["", "var(--priority-low)", "var(--priority-medium)", "var(--priority-high)"];
|
||||||
|
|
||||||
|
// Group tasks into 3 columns
|
||||||
|
const todoTasks = tasks.filter((task) => !task.completed && task.priority < 2 && !task.parentId);
|
||||||
|
const inProgressTasks = tasks.filter((task) => !task.completed && task.priority >= 2 && !task.parentId);
|
||||||
|
const doneTasks = tasks.filter((task) => task.completed && !task.parentId);
|
||||||
|
|
||||||
|
const renderCard = (task: Task) => {
|
||||||
|
const isSelected = selectedTaskId === task.id;
|
||||||
|
const subtaskCount = task.children?.length || 0;
|
||||||
|
const completedSubtasks = task.children?.filter((s) => s.completed).length || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className={`kanban-card${isSelected ? " selected" : ""}`}
|
||||||
|
onClick={() => onSelectTask(task)}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-start", gap: 10 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`task-check-btn${task.completed ? " checked" : ""}`}
|
||||||
|
style={{ width: 17, height: 17, marginTop: 2, flexShrink: 0 }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleTask(task.id, !task.completed);
|
||||||
|
}}
|
||||||
|
aria-label="Toggle task completion"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: task.completed ? "var(--text-tertiary)" : "var(--text-primary)",
|
||||||
|
textDecoration: task.completed ? "line-through" : "none",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badges row: Priority, Due Date, Subtask progress */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 8, flexWrap: "wrap" }}>
|
||||||
|
{task.priority > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
padding: "2px 6px",
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
background: `${PRIORITY_COLORS[task.priority]}15`,
|
||||||
|
color: PRIORITY_COLORS[task.priority],
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🚩 P{task.priority}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.dueDate && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: "2px 6px",
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
background: "var(--bg-hover)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📅 {new Date(task.dueDate).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{subtaskCount > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: "2px 6px",
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
background: "var(--bg-hover)",
|
||||||
|
color: "var(--text-tertiary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
☑️ {completedSubtasks}/{subtaskCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.note && (
|
||||||
|
<span style={{ fontSize: 10, color: "var(--text-tertiary)" }} title="Has note">
|
||||||
|
📝
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kanban-container">
|
||||||
|
{/* Column 1: To Do */}
|
||||||
|
<div className="kanban-column">
|
||||||
|
<div className="kanban-column-header">
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: "50%", background: "var(--text-tertiary)" }} />
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--text-primary)" }}>{t("todoCol")}</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", background: "var(--bg-hover)", padding: "2px 8px", borderRadius: "var(--radius-full)" }}>
|
||||||
|
{todoTasks.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="kanban-column-body">
|
||||||
|
{todoTasks.map(renderCard)}
|
||||||
|
|
||||||
|
{/* Quick inline add in To Do */}
|
||||||
|
<div style={{ display: "flex", gap: 6, marginTop: 4 }}>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
placeholder={t("addTaskPlaceholder")}
|
||||||
|
style={{ fontSize: 12, padding: "6px 10px", flex: 1 }}
|
||||||
|
value={newTodoTitle}
|
||||||
|
onChange={(e) => setNewTodoTitle(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && newTodoTitle.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
onAddTask(newTodoTitle.trim(), 0);
|
||||||
|
setNewTodoTitle("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column 2: In Progress / High Priority */}
|
||||||
|
<div className="kanban-column">
|
||||||
|
<div className="kanban-column-header">
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: "50%", background: "var(--accent)" }} />
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--text-primary)" }}>{t("inProgressCol")}</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: "var(--accent)", background: "var(--accent-light)", padding: "2px 8px", borderRadius: "var(--radius-full)" }}>
|
||||||
|
{inProgressTasks.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="kanban-column-body">
|
||||||
|
{inProgressTasks.map(renderCard)}
|
||||||
|
|
||||||
|
{/* Quick inline add in In Progress */}
|
||||||
|
<div style={{ display: "flex", gap: 6, marginTop: 4 }}>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
placeholder={t("addTaskPlaceholder")}
|
||||||
|
style={{ fontSize: 12, padding: "6px 10px", flex: 1 }}
|
||||||
|
value={newProgTitle}
|
||||||
|
onChange={(e) => setNewProgTitle(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && newProgTitle.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
onAddTask(newProgTitle.trim(), 2);
|
||||||
|
setNewProgTitle("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column 3: Done */}
|
||||||
|
<div className="kanban-column">
|
||||||
|
<div className="kanban-column-header">
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: "50%", background: "var(--success)" }} />
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--text-primary)" }}>{t("doneCol")}</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: "var(--success)", background: "rgba(16, 185, 129, 0.12)", padding: "2px 8px", borderRadius: "var(--radius-full)" }}>
|
||||||
|
{doneTasks.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="kanban-column-body">
|
||||||
|
{doneTasks.map(renderCard)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
@@ -12,7 +12,7 @@ interface MarkdownNoteEditorProps {
|
|||||||
|
|
||||||
export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEditorProps) {
|
export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEditorProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [mode, setMode] = useState<"edit" | "preview">("preview");
|
const [mode, setMode] = useState<"edit" | "preview">("edit");
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const previewRef = useRef<HTMLDivElement>(null);
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -26,74 +26,32 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
|
|||||||
marked.use({ renderer, breaks: true, gfm: true });
|
marked.use({ renderer, breaks: true, gfm: true });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Insert markdown syntax at selection or cursor position
|
|
||||||
const insertSyntax = useCallback(
|
|
||||||
(before: string, after = "", placeholder = "") => {
|
|
||||||
const ta = textareaRef.current;
|
|
||||||
if (!ta) {
|
|
||||||
onChange(value + before + placeholder + after);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const start = ta.selectionStart ?? ta.value.length;
|
|
||||||
const end = ta.selectionEnd ?? ta.value.length;
|
|
||||||
const selected = ta.value.slice(start, end) || placeholder;
|
|
||||||
const newVal = ta.value.slice(0, start) + before + selected + after + ta.value.slice(end);
|
|
||||||
onChange(newVal);
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
ta.focus();
|
|
||||||
ta.selectionStart = start + before.length;
|
|
||||||
ta.selectionEnd = start + before.length + selected.length;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[onChange, value]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "b") {
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||||
e.preventDefault();
|
|
||||||
insertSyntax("**", "**", "bold");
|
|
||||||
} else if ((e.ctrlKey || e.metaKey) && e.key === "i") {
|
|
||||||
e.preventDefault();
|
|
||||||
insertSyntax("*", "*", "italic");
|
|
||||||
} else if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (onSave) onSave();
|
if (onSave) onSave();
|
||||||
} else if (e.key === "Tab") {
|
} else if (e.key === "Tab") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
insertSyntax(" ");
|
const ta = textareaRef.current;
|
||||||
|
if (!ta) return;
|
||||||
|
const start = ta.selectionStart;
|
||||||
|
const end = ta.selectionEnd;
|
||||||
|
const newVal = value.slice(0, start) + " " + value.slice(end);
|
||||||
|
onChange(newVal);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
ta.selectionStart = ta.selectionEnd = start + 2;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Toggle interactive checkboxes in markdown preview
|
// Convert markdown to sanitized HTML with safe links
|
||||||
const handlePreviewCheckboxToggle = (index: number) => {
|
|
||||||
const lines = value.split("\n");
|
|
||||||
let checkboxCount = 0;
|
|
||||||
const newLines = lines.map((line) => {
|
|
||||||
const match = line.match(/^(\s*[-*+]\s*\[)([ xX])(\])(.*)$/);
|
|
||||||
if (match) {
|
|
||||||
if (checkboxCount === index) {
|
|
||||||
const currentChecked = match[2].toLowerCase() === "x";
|
|
||||||
const newChecked = currentChecked ? " " : "x";
|
|
||||||
checkboxCount++;
|
|
||||||
return `${match[1]}${newChecked}${match[3]}${match[4]}`;
|
|
||||||
}
|
|
||||||
checkboxCount++;
|
|
||||||
}
|
|
||||||
return line;
|
|
||||||
});
|
|
||||||
onChange(newLines.join("\n"));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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; padding: 8px 0;">${t("notesPlaceholder").split("\n")[0]}</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Auto-detect plain URLs and wrap them in markdown links if not already wrapped
|
|
||||||
const textWithLinks = value.replace(
|
const textWithLinks = value.replace(
|
||||||
/(^|[^"'])(https?:\/\/[^\s<]+)/g,
|
/(^|[^"'])(https?:\/\/[^\s<]+)/g,
|
||||||
(match, prefix, url) => {
|
(match, prefix, url) => {
|
||||||
@@ -102,263 +60,121 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
let rawHtml = marked.parse(textWithLinks, { breaks: true, gfm: true }) as string;
|
const rawHtml = marked.parse(textWithLinks, { breaks: true, gfm: true }) as string;
|
||||||
|
|
||||||
// Replace task list checkboxes with interactive ones
|
return DOMPurify.sanitize(rawHtml, {
|
||||||
let cbIdx = 0;
|
ALLOWED_TAGS: [
|
||||||
rawHtml = rawHtml.replace(/<input[^>]*type="checkbox"[^>]*checked[^>]*>/gi, () => {
|
"h1", "h2", "h3", "h4", "h5", "h6", "p", "a", "span", "strong", "em", "del", "s",
|
||||||
const id = `cb-${cbIdx++}`;
|
"ul", "ol", "li", "code", "pre", "blockquote", "hr", "br", "table", "thead", "tbody",
|
||||||
return `<span class="markdown-checkbox-box checked" data-idx="${id}"></span>`;
|
"tr", "th", "td"
|
||||||
|
],
|
||||||
|
ALLOWED_ATTR: ["href", "title", "target", "rel", "class", "style"],
|
||||||
|
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
|
||||||
|
ADD_ATTR: ["target", "rel", "class"],
|
||||||
});
|
});
|
||||||
rawHtml = rawHtml.replace(/<input[^>]*type="checkbox"[^>]*>/gi, () => {
|
|
||||||
const id = `cb-${cbIdx++}`;
|
|
||||||
return `<span class="markdown-checkbox-box" data-idx="${id}"></span>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// XSS 방어: DOMPurify sanitize (링크 target=_blank, class, data-idx 허용)
|
|
||||||
const sanitized = DOMPurify.sanitize(rawHtml, {
|
|
||||||
ADD_ATTR: ["target", "rel", "data-idx", "class"],
|
|
||||||
ALLOW_DATA_ATTR: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return sanitized;
|
|
||||||
} catch {
|
} catch {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="note-editor-wrap">
|
<div
|
||||||
{/* Top Toolbar */}
|
style={{
|
||||||
<div className="note-editor-toolbar">
|
display: "flex",
|
||||||
{/* Mode Switcher Tabs */}
|
flexDirection: "column",
|
||||||
<div className="note-toolbar-group">
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
minHeight: 0,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Minimal Header with Mode Switcher */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "4px 8px 8px 8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: "0.05em", color: "var(--text-tertiary)" }}>
|
||||||
|
{t("notes").toUpperCase()}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="view-switcher-group" style={{ padding: 2 }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`note-toolbar-btn${mode === "preview" ? " active" : ""}`}
|
className={`view-switcher-btn${mode === "edit" ? " active" : ""}`}
|
||||||
onClick={() => setMode("preview")}
|
style={{ fontSize: 11, padding: "2px 8px" }}
|
||||||
title="Preview rendered Markdown"
|
|
||||||
>
|
|
||||||
👁️ Preview
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`note-toolbar-btn${mode === "edit" ? " active" : ""}`}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMode("edit");
|
setMode("edit");
|
||||||
setTimeout(() => textareaRef.current?.focus(), 50);
|
setTimeout(() => textareaRef.current?.focus(), 50);
|
||||||
}}
|
}}
|
||||||
title="Edit raw Markdown"
|
|
||||||
>
|
>
|
||||||
✏️ Edit
|
✏️ Edit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="note-toolbar-divider" />
|
|
||||||
|
|
||||||
{/* Formatting actions */}
|
|
||||||
<div className="note-toolbar-group">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="note-toolbar-btn"
|
className={`view-switcher-btn${mode === "preview" ? " active" : ""}`}
|
||||||
title="H1 Heading"
|
style={{ fontSize: 11, padding: "2px 8px" }}
|
||||||
onClick={() => {
|
onClick={() => setMode("preview")}
|
||||||
setMode("edit");
|
|
||||||
insertSyntax("# ", "", "Heading 1");
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
H1
|
👁️ Preview
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="note-toolbar-btn"
|
|
||||||
title="H2 Heading"
|
|
||||||
onClick={() => {
|
|
||||||
setMode("edit");
|
|
||||||
insertSyntax("## ", "", "Heading 2");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
H2
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="note-toolbar-btn"
|
|
||||||
title="H3 Heading"
|
|
||||||
onClick={() => {
|
|
||||||
setMode("edit");
|
|
||||||
insertSyntax("### ", "", "Heading 3");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
H3
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="note-toolbar-divider" />
|
|
||||||
|
|
||||||
<div className="note-toolbar-group">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="note-toolbar-btn"
|
|
||||||
title="Bold (Ctrl+B)"
|
|
||||||
onClick={() => {
|
|
||||||
setMode("edit");
|
|
||||||
insertSyntax("**", "**", "bold");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>B</strong>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="note-toolbar-btn"
|
|
||||||
title="Italic (Ctrl+I)"
|
|
||||||
style={{ fontStyle: "italic" }}
|
|
||||||
onClick={() => {
|
|
||||||
setMode("edit");
|
|
||||||
insertSyntax("*", "*", "italic");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<em>I</em>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="note-toolbar-btn"
|
|
||||||
title="Strikethrough"
|
|
||||||
style={{ textDecoration: "line-through" }}
|
|
||||||
onClick={() => {
|
|
||||||
setMode("edit");
|
|
||||||
insertSyntax("~~", "~~", "strikethrough");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
S
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="note-toolbar-divider" />
|
|
||||||
|
|
||||||
<div className="note-toolbar-group">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="note-toolbar-btn"
|
|
||||||
title="Checkbox item"
|
|
||||||
onClick={() => {
|
|
||||||
setMode("edit");
|
|
||||||
insertSyntax("\n- [ ] ", "", "task");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
☑️
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="note-toolbar-btn"
|
|
||||||
title="Bullet list"
|
|
||||||
onClick={() => {
|
|
||||||
setMode("edit");
|
|
||||||
insertSyntax("\n- ", "", "list item");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
•
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="note-toolbar-btn"
|
|
||||||
title="Numbered list"
|
|
||||||
onClick={() => {
|
|
||||||
setMode("edit");
|
|
||||||
insertSyntax("\n1. ", "", "list item");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
1.
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="note-toolbar-btn"
|
|
||||||
title="Quote"
|
|
||||||
onClick={() => {
|
|
||||||
setMode("edit");
|
|
||||||
insertSyntax("\n> ", "", "quote");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
❝
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="note-toolbar-btn"
|
|
||||||
title="Code Block"
|
|
||||||
style={{ fontFamily: "monospace" }}
|
|
||||||
onClick={() => {
|
|
||||||
setMode("edit");
|
|
||||||
insertSyntax("\n```\n", "\n```\n", "code");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{"< >"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="note-toolbar-btn"
|
|
||||||
title="Horizontal Divider"
|
|
||||||
onClick={() => {
|
|
||||||
setMode("edit");
|
|
||||||
insertSyntax("\n---\n");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
―
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="note-toolbar-btn"
|
|
||||||
title="Link"
|
|
||||||
onClick={() => {
|
|
||||||
setMode("edit");
|
|
||||||
insertSyntax("[", "](https://example.com)", "link title");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
🔗
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Editor / Preview Content Area */}
|
{/* Editor / Preview Area */}
|
||||||
{mode === "edit" ? (
|
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", position: "relative" }}>
|
||||||
<textarea
|
{mode === "edit" ? (
|
||||||
ref={textareaRef}
|
<textarea
|
||||||
id="note-textarea"
|
ref={textareaRef}
|
||||||
className="note-textarea"
|
className="note-editor-textarea"
|
||||||
placeholder={t("notesPlaceholder")}
|
style={{
|
||||||
value={value}
|
flex: 1,
|
||||||
onChange={(e) => onChange(e.target.value)}
|
width: "100%",
|
||||||
onKeyDown={handleKeyDown}
|
height: "100%",
|
||||||
/>
|
minHeight: 120,
|
||||||
) : (
|
padding: "8px 10px",
|
||||||
<div
|
background: "transparent",
|
||||||
ref={previewRef}
|
border: "none",
|
||||||
className="markdown-body"
|
outline: "none",
|
||||||
dangerouslySetInnerHTML={{ __html: renderMarkdownHtml() }}
|
color: "var(--text-primary)",
|
||||||
onClick={(e) => {
|
fontSize: 13.5,
|
||||||
const target = e.target as HTMLElement;
|
lineHeight: 1.6,
|
||||||
|
resize: "none",
|
||||||
// Handle link clicks cleanly
|
fontFamily: "inherit",
|
||||||
const link = target.closest("a");
|
}}
|
||||||
if (link && link.href) {
|
placeholder={t("notesPlaceholder")}
|
||||||
e.preventDefault();
|
value={value}
|
||||||
e.stopPropagation();
|
onChange={(e) => onChange(e.target.value)}
|
||||||
window.open(link.href, "_blank", "noopener,noreferrer");
|
onKeyDown={handleKeyDown}
|
||||||
return;
|
onBlur={() => {
|
||||||
}
|
if (onSave) onSave();
|
||||||
|
}}
|
||||||
// Handle interactive checkbox clicks
|
/>
|
||||||
if (target.classList.contains("markdown-checkbox-box")) {
|
) : (
|
||||||
const idxStr = target.getAttribute("data-idx");
|
<div
|
||||||
if (idxStr) {
|
ref={previewRef}
|
||||||
const idx = parseInt(idxStr.replace("cb-", ""), 10);
|
className="note-editor-preview"
|
||||||
if (!isNaN(idx)) handlePreviewCheckboxToggle(idx);
|
style={{
|
||||||
}
|
flex: 1,
|
||||||
} else if (e.detail === 2) {
|
width: "100%",
|
||||||
// Double click anywhere to quickly switch to edit mode
|
height: "100%",
|
||||||
|
padding: "8px 10px",
|
||||||
|
overflowY: "auto",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: 13.5,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
dangerouslySetInnerHTML={{ __html: renderMarkdownHtml() }}
|
||||||
|
onClick={() => {
|
||||||
setMode("edit");
|
setMode("edit");
|
||||||
}
|
setTimeout(() => textareaRef.current?.focus(), 50);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+496
-377
@@ -4,11 +4,14 @@ import { useI18n } from "@/lib/i18n";
|
|||||||
import { Task, Tag } from "./TaskList";
|
import { Task, Tag } from "./TaskList";
|
||||||
import { MarkdownNoteEditor } from "./MarkdownNoteEditor";
|
import { MarkdownNoteEditor } from "./MarkdownNoteEditor";
|
||||||
import { getCustomTags, MockTag } from "@/lib/mockData";
|
import { getCustomTags, MockTag } from "@/lib/mockData";
|
||||||
|
import { useUserPrefs } from "@/lib/useUserPrefs";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
task: Task;
|
task: Task;
|
||||||
listId: string;
|
listId: string;
|
||||||
listName?: string;
|
listName?: string;
|
||||||
|
lists?: { id: string; name: string; color: string }[];
|
||||||
|
onMoveList?: (targetListId: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onUpdate: (t: Task) => void;
|
onUpdate: (t: Task) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
@@ -21,6 +24,8 @@ export function TaskDetail({
|
|||||||
task,
|
task,
|
||||||
listId,
|
listId,
|
||||||
listName,
|
listName,
|
||||||
|
lists = [],
|
||||||
|
onMoveList,
|
||||||
onClose,
|
onClose,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -37,11 +42,29 @@ export function TaskDetail({
|
|||||||
const [tags, setTags] = useState<{ tag: Tag }[]>(task.tags || []);
|
const [tags, setTags] = useState<{ tag: Tag }[]>(task.tags || []);
|
||||||
const [allAvailableTags, setAllAvailableTags] = useState<MockTag[]>([]);
|
const [allAvailableTags, setAllAvailableTags] = useState<MockTag[]>([]);
|
||||||
const [showTagPicker, setShowTagPicker] = useState(false);
|
const [showTagPicker, setShowTagPicker] = useState(false);
|
||||||
|
const [showListPicker, setShowListPicker] = useState(false);
|
||||||
const [newTagName, setNewTagName] = useState("");
|
const [newTagName, setNewTagName] = useState("");
|
||||||
|
|
||||||
|
// Modular Blocks Customization via global prefs
|
||||||
|
const { prefs, updatePrefs } = useUserPrefs();
|
||||||
|
const blockOrder = prefs.detailBlockOrder;
|
||||||
|
const splitRatio = prefs.detailSplitRatio;
|
||||||
|
|
||||||
|
const setBlockOrder = (next: ("subtasks" | "note")[]) => {
|
||||||
|
updatePrefs({ detailBlockOrder: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSplitRatio = (r: number) => {
|
||||||
|
updatePrefs({ detailSplitRatio: r });
|
||||||
|
};
|
||||||
|
|
||||||
|
const [subtasksCollapsed, setSubtasksCollapsed] = useState(false);
|
||||||
|
const [noteCollapsed, setNoteCollapsed] = useState(false);
|
||||||
|
const [isDraggingSplit, setIsDraggingSplit] = useState(false);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
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 [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
// Mobile Bottom-Sheet: swipe down to close
|
// Mobile Bottom-Sheet: swipe down to close
|
||||||
@@ -68,6 +91,46 @@ export function TaskDetail({
|
|||||||
{ label: t("priorityHigh"), color: "var(--priority-high)", icon: "▲" },
|
{ label: t("priorityHigh"), color: "var(--priority-high)", icon: "▲" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Toggle block order — prefs store handles persistence
|
||||||
|
const toggleBlockOrder = () => {
|
||||||
|
const next = blockOrder[0] === "subtasks"
|
||||||
|
? ["note", "subtasks"] as ("subtasks" | "note")[]
|
||||||
|
: ["subtasks", "note"] as ("subtasks" | "note")[];
|
||||||
|
setBlockOrder(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSplitMouseDown = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDraggingSplit(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!isDraggingSplit || !containerRef.current) return;
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
const relativeY = e.clientY - rect.top;
|
||||||
|
let ratio = (relativeY / rect.height) * 100;
|
||||||
|
ratio = Math.max(15, Math.min(85, ratio));
|
||||||
|
setSplitRatio(ratio);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
if (isDraggingSplit) {
|
||||||
|
setIsDraggingSplit(false);
|
||||||
|
// splitRatio is already saved via updatePrefs in handleMouseMove
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDraggingSplit) {
|
||||||
|
window.addEventListener("mousemove", handleMouseMove);
|
||||||
|
window.addEventListener("mouseup", handleMouseUp);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
window.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [isDraggingSplit, splitRatio]);
|
||||||
|
|
||||||
// Sync state when switching task
|
// Sync state when switching task
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (saveTimer.current) {
|
if (saveTimer.current) {
|
||||||
@@ -245,290 +308,479 @@ export function TaskDetail({
|
|||||||
}
|
}
|
||||||
}, [newSubtitle, isDemo, listId, task, subtasks, onDemoUpdateTask, onUpdate]);
|
}, [newSubtitle, isDemo, listId, task, subtasks, onDemoUpdateTask, onUpdate]);
|
||||||
|
|
||||||
const toggleSubtask = useCallback(
|
const toggleSubtask = async (sub: Task) => {
|
||||||
async (sub: Task) => {
|
const nextCompleted = !sub.completed;
|
||||||
const newCompleted = !sub.completed;
|
const nextSubs = subtasks.map((s) => (s.id === sub.id ? { ...s, completed: nextCompleted } : s));
|
||||||
|
setSubtasks(nextSubs);
|
||||||
if (isDemo) {
|
const updatedParent = { ...task, children: nextSubs };
|
||||||
const nextSubs = subtasks.map((s) =>
|
if (isDemo && onDemoUpdateTask) {
|
||||||
s.id === sub.id
|
onDemoUpdateTask(updatedParent);
|
||||||
? { ...s, completed: newCompleted, completedAt: newCompleted ? new Date().toISOString() : null }
|
}
|
||||||
: s
|
onUpdate(updatedParent);
|
||||||
);
|
|
||||||
setSubtasks(nextSubs);
|
|
||||||
const updatedParent = { ...task, children: nextSubs };
|
|
||||||
if (onDemoUpdateTask) onDemoUpdateTask(updatedParent);
|
|
||||||
onUpdate(updatedParent);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!isDemo) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/tasks/${sub.id}`, {
|
await fetch(`/api/tasks/${sub.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ completed: newCompleted }),
|
body: JSON.stringify({ completed: nextCompleted }),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
|
||||||
const updated = await res.json();
|
|
||||||
setSubtasks((prev) => {
|
|
||||||
const next = prev.map((s) => (s.id === sub.id ? updated : s));
|
|
||||||
onUpdate({ ...task, children: next });
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[TaskDetail] toggleSubtask failed", err);
|
console.error("[TaskDetail] toggleSubtask failed", err);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
[isDemo, onDemoUpdateTask, onUpdate, subtasks, task]
|
};
|
||||||
);
|
|
||||||
|
|
||||||
const deleteSubtask = useCallback(
|
const deleteSubtask = async (subId: string) => {
|
||||||
async (subId: string) => {
|
const nextSubs = subtasks.filter((s) => s.id !== subId);
|
||||||
if (isDemo) {
|
setSubtasks(nextSubs);
|
||||||
const nextSubs = subtasks.filter((s) => s.id !== subId);
|
const updatedParent = { ...task, children: nextSubs };
|
||||||
setSubtasks(nextSubs);
|
if (isDemo && onDemoUpdateTask) {
|
||||||
const updatedParent = { ...task, children: nextSubs };
|
onDemoUpdateTask(updatedParent);
|
||||||
if (onDemoUpdateTask) onDemoUpdateTask(updatedParent);
|
}
|
||||||
onUpdate(updatedParent);
|
onUpdate(updatedParent);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!isDemo) {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/tasks/${subId}`, { method: "DELETE" });
|
await fetch(`/api/tasks/${subId}`, { method: "DELETE" });
|
||||||
setSubtasks((prev) => {
|
|
||||||
const next = prev.filter((s) => s.id !== subId);
|
|
||||||
onUpdate({ ...task, children: next });
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[TaskDetail] deleteSubtask failed", err);
|
console.error("[TaskDetail] deleteSubtask failed", err);
|
||||||
}
|
}
|
||||||
},
|
|
||||||
[isDemo, onDemoUpdateTask, onUpdate, subtasks, task]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Touch handlers: 모바일에서 아래로 스와이프 → 패널 닫기 (스크롤과 충돌 방지)
|
|
||||||
const handleTouchStart = (e: React.TouchEvent) => {
|
|
||||||
// 스와이프 핸들 또는 detail-header 영역에서만 드래그 닫기 허용
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
const isHandle = target.closest(".mobile-swipe-handle") || target.closest(".detail-header");
|
|
||||||
if (!isHandle || !isMobile) return;
|
|
||||||
touchStartY.current = e.touches[0].clientY;
|
|
||||||
touchStartX.current = e.touches[0].clientX;
|
|
||||||
gestureDirection.current = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchMove = (e: React.TouchEvent) => {
|
|
||||||
if (touchStartY.current === null || touchStartX.current === null) return;
|
|
||||||
const deltaY = e.touches[0].clientY - touchStartY.current;
|
|
||||||
const deltaX = e.touches[0].clientX - touchStartX.current;
|
|
||||||
|
|
||||||
// 초기 제스처 방향 결정 (첫 10px 이동 기준)
|
|
||||||
if (gestureDirection.current === null && (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10)) {
|
|
||||||
gestureDirection.current = Math.abs(deltaY) > Math.abs(deltaX) ? "vertical" : "horizontal";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 수직 드래그일 때만 패널 이동
|
|
||||||
if (gestureDirection.current === "vertical" && deltaY > 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
setPanelTranslateY(deltaY);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchEnd = () => {
|
|
||||||
if (panelTranslateY > 120) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
setPanelTranslateY(0);
|
|
||||||
touchStartY.current = null;
|
|
||||||
touchStartX.current = null;
|
|
||||||
gestureDirection.current = null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const completedCount = subtasks.filter((s) => s.completed).length;
|
const completedCount = subtasks.filter((s) => s.completed).length;
|
||||||
|
|
||||||
|
// Render Subtasks Block
|
||||||
|
const renderSubtasksBlock = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="detail-block-card"
|
||||||
|
style={{
|
||||||
|
flex: subtasksCollapsed ? "0 0 auto" : `0 0 ${noteCollapsed ? "100%" : `${splitRatio}%`}`,
|
||||||
|
minHeight: subtasksCollapsed ? 38 : 120,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="detail-block-header">
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}
|
||||||
|
onClick={() => setSubtasksCollapsed((p) => !p)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
style={{ transform: subtasksCollapsed ? "rotate(0deg)" : "rotate(90deg)", transition: "transform 0.15s" }}
|
||||||
|
>
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
<span style={{ fontSize: 11.5, fontWeight: 700, letterSpacing: "0.05em", color: "var(--text-primary)" }}>
|
||||||
|
☑️ {t("subtasks").toUpperCase()}
|
||||||
|
</span>
|
||||||
|
{subtasks.length > 0 && (
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", background: "var(--bg-hover)", padding: "1px 6px", borderRadius: "var(--radius-full)" }}>
|
||||||
|
{completedCount}/{subtasks.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn"
|
||||||
|
style={{ width: 22, height: 22, fontSize: 11 }}
|
||||||
|
onClick={toggleBlockOrder}
|
||||||
|
title={t("swapBlocks")}
|
||||||
|
>
|
||||||
|
⇅
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!subtasksCollapsed && (
|
||||||
|
<div style={{ padding: "8px 12px", flex: 1, display: "flex", flexDirection: "column", gap: 6, overflowY: "auto" }}>
|
||||||
|
{/* Progress Bar */}
|
||||||
|
{subtasks.length > 0 && (
|
||||||
|
<div style={{ height: 3, background: "var(--bg-hover)", borderRadius: 2, marginBottom: 4, overflow: "hidden" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
width: `${(completedCount / subtasks.length) * 100}%`,
|
||||||
|
background: "var(--accent)",
|
||||||
|
borderRadius: 2,
|
||||||
|
transition: "width var(--dur-normal) var(--ease-out)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* List of subtasks */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
{subtasks.map((sub) => (
|
||||||
|
<div
|
||||||
|
key={sub.id}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "6px 10px",
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
background: "var(--bg-secondary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className={`task-check-btn${sub.completed ? " checked" : ""}`}
|
||||||
|
style={{ width: 16, height: 16, flexShrink: 0 }}
|
||||||
|
onClick={() => toggleSubtask(sub)}
|
||||||
|
aria-label="Toggle subtask"
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 13,
|
||||||
|
textDecoration: sub.completed ? "line-through" : "none",
|
||||||
|
color: sub.completed ? "var(--text-tertiary)" : "var(--text-primary)",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sub.title}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
style={{ width: 20, height: 20, opacity: 0.4 }}
|
||||||
|
onClick={() => deleteSubtask(sub.id)}
|
||||||
|
title="Delete subtask"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Add Subtask Input */}
|
||||||
|
<div style={{ display: "flex", gap: 6, marginTop: "auto", paddingTop: 4 }}>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
placeholder={t("addSubtaskPlaceholder")}
|
||||||
|
style={{ flex: 1, fontSize: 12.5, padding: "5px 10px" }}
|
||||||
|
value={newSubtitle}
|
||||||
|
onChange={(e) => setNewSubtitle(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
addSubtask();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{newSubtitle.trim() && (
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={addSubtask} type="button">
|
||||||
|
{t("add")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render Note Block
|
||||||
|
const renderNoteBlock = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="detail-block-card"
|
||||||
|
style={{
|
||||||
|
flex: noteCollapsed ? "0 0 auto" : `0 0 ${subtasksCollapsed ? "100%" : `${100 - splitRatio}%`}`,
|
||||||
|
minHeight: noteCollapsed ? 38 : 120,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="detail-block-header">
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}
|
||||||
|
onClick={() => setNoteCollapsed((p) => !p)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
style={{ transform: noteCollapsed ? "rotate(0deg)" : "rotate(90deg)", transition: "transform 0.15s" }}
|
||||||
|
>
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
<span style={{ fontSize: 11.5, fontWeight: 700, letterSpacing: "0.05em", color: "var(--text-primary)" }}>
|
||||||
|
📝 {t("notes").toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn"
|
||||||
|
style={{ width: 22, height: 22, fontSize: 11 }}
|
||||||
|
onClick={toggleBlockOrder}
|
||||||
|
title={t("swapBlocks")}
|
||||||
|
>
|
||||||
|
⇅
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!noteCollapsed && (
|
||||||
|
<div style={{ flex: 1, padding: 8, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||||
|
<MarkdownNoteEditor
|
||||||
|
value={note}
|
||||||
|
onChange={handleNoteChange}
|
||||||
|
onSave={() => save(task.id, { note })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className="detail-panel mobile-open"
|
className="detail-panel mobile-open"
|
||||||
onTouchStart={handleTouchStart}
|
|
||||||
onTouchMove={handleTouchMove}
|
|
||||||
onTouchEnd={handleTouchEnd}
|
|
||||||
style={{
|
style={{
|
||||||
transform: isMobile && panelTranslateY > 0 ? `translateY(${panelTranslateY}px)` : undefined,
|
transform: isMobile ? `translateY(${panelTranslateY}px)` : "none",
|
||||||
transition: panelTranslateY === 0 ? "transform 0.3s cubic-bezier(0.16, 1, 0.3, 1)" : "none",
|
transition: panelTranslateY === 0 ? "transform 0.25s var(--ease-out)" : "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Mobile Swipe Handle Indicator */}
|
{/* Mobile swipe indicator */}
|
||||||
<div className="mobile-swipe-handle mobile-only" style={{ width: 36, height: 4, background: "var(--border)", borderRadius: 2, margin: "6px auto 0" }} />
|
<div
|
||||||
|
className="mobile-swipe-handle mobile-only"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px 0 4px",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
cursor: "grab",
|
||||||
|
}}
|
||||||
|
onTouchStart={(e) => {
|
||||||
|
touchStartY.current = e.touches[0].clientY;
|
||||||
|
touchStartX.current = e.touches[0].clientX;
|
||||||
|
gestureDirection.current = null;
|
||||||
|
}}
|
||||||
|
onTouchMove={(e) => {
|
||||||
|
if (touchStartY.current === null) return;
|
||||||
|
const deltaY = e.touches[0].clientY - touchStartY.current;
|
||||||
|
if (deltaY > 0) setPanelTranslateY(deltaY);
|
||||||
|
}}
|
||||||
|
onTouchEnd={() => {
|
||||||
|
if (panelTranslateY > 120) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
setPanelTranslateY(0);
|
||||||
|
touchStartY.current = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: 36, height: 4, borderRadius: 2, background: "var(--border-strong)" }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Header */}
|
||||||
|
<div className="detail-header">
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, flex: 1, minWidth: 0, position: "relative" }}>
|
||||||
|
{/* Breadcrumb / Project Move selector */}
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tick-meta-chip"
|
||||||
|
onClick={() => setShowListPicker((p) => !p)}
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--accent)",
|
||||||
|
background: "var(--accent-light)",
|
||||||
|
borderColor: "transparent",
|
||||||
|
}}
|
||||||
|
title={t("moveToList")}
|
||||||
|
>
|
||||||
|
📁 {listName || t("tasks")} ▾
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showListPicker && lists.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="dropdown"
|
||||||
|
style={{ left: 0, top: "calc(100% + 4px)", minWidth: 160, padding: 4, zIndex: 110 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{ padding: "4px 8px", fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)" }}>
|
||||||
|
{t("moveToList")}
|
||||||
|
</div>
|
||||||
|
{lists.map((l) => (
|
||||||
|
<div
|
||||||
|
key={l.id}
|
||||||
|
className="context-menu-item"
|
||||||
|
style={{
|
||||||
|
padding: "6px 10px",
|
||||||
|
fontSize: 12.5,
|
||||||
|
cursor: "pointer",
|
||||||
|
background: l.id === listId ? "var(--bg-active)" : "transparent",
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (onMoveList && l.id !== listId) {
|
||||||
|
onMoveList(l.id);
|
||||||
|
}
|
||||||
|
setShowListPicker(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📁 {l.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Top action bar */}
|
|
||||||
<div className="detail-header" style={{ padding: "10px 16px 8px" }}>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<button
|
|
||||||
className={`task-check-btn${task.completed ? " checked" : ""}`}
|
|
||||||
onClick={handleToggleComplete}
|
|
||||||
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
|
|
||||||
type="button"
|
|
||||||
/>
|
|
||||||
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>
|
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>
|
||||||
{saving ? t("saving") : t("autoSaved")}
|
{saving ? `● ${t("saving")}` : `✓ ${t("autoSaved")}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="detail-actions">
|
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||||
<button
|
<button
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
id="delete-task-btn"
|
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
title={t("deleteTaskConfirm").split("?")[0]}
|
title={t("deleteTaskConfirm").split("?")[0]}
|
||||||
|
style={{ color: "var(--danger)" }}
|
||||||
type="button"
|
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>
|
||||||
<button
|
<button className="icon-btn" onClick={onClose} title="Close" type="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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main scrollable body */}
|
{/* Main Body */}
|
||||||
<div className="detail-scroll" style={{ display: "flex", flexDirection: "column", height: "calc(100% - 48px)", padding: "8px 16px 12px", gap: 10 }}>
|
<div className="detail-scroll" style={{ display: "flex", flexDirection: "column", height: "100%", gap: 10 }}>
|
||||||
{/* Title input */}
|
{/* Title and Complete Button */}
|
||||||
<textarea
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
id="task-title-input"
|
<button
|
||||||
className="detail-title-input"
|
className={`task-check-btn${task.completed ? " checked" : ""}`}
|
||||||
value={title}
|
onClick={handleToggleComplete}
|
||||||
onChange={(e) => {
|
aria-label="Toggle completion"
|
||||||
setTitle(e.target.value);
|
type="button"
|
||||||
debounceSave({ title: e.target.value });
|
/>
|
||||||
}}
|
<input
|
||||||
placeholder={t("taskTitlePlaceholder")}
|
className="detail-title-input"
|
||||||
rows={1}
|
value={title}
|
||||||
style={{
|
onChange={(e) => {
|
||||||
textDecoration: task.completed ? "line-through" : "none",
|
setTitle(e.target.value);
|
||||||
color: task.completed ? "var(--text-tertiary)" : "var(--text-primary)",
|
debounceSave({ title: e.target.value });
|
||||||
fontSize: 20,
|
}}
|
||||||
fontWeight: 700,
|
placeholder={t("taskTitlePlaceholder")}
|
||||||
}}
|
style={{ flex: 1, fontSize: 16, fontWeight: 600 }}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Compact Metadata Strip (Priority, Due Date & Tags) */}
|
{/* Metadata Chips: Due Date, Priority, Tags */}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap", paddingBottom: 6, borderBottom: "1px solid var(--border)" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||||
{/* Priority */}
|
{/* Due date chip */}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
<input
|
||||||
<span style={{ fontSize: 11, fontWeight: 600, color: "var(--text-tertiary)", textTransform: "uppercase" }}>{t("priority")}:</span>
|
type="date"
|
||||||
<div className="priority-selector" style={{ gap: 4 }}>
|
className="tick-meta-chip"
|
||||||
{priorityMap.map((p, i) => (
|
style={{ fontSize: 11.5, padding: "3px 8px", cursor: "pointer", border: "1px solid var(--border)" }}
|
||||||
<button
|
value={dueDate}
|
||||||
key={i}
|
onChange={(e) => {
|
||||||
id={`priority-${i}`}
|
const val = e.target.value || null;
|
||||||
className={`priority-btn${priority === i ? ` active-${["none", "low", "medium", "high"][i]}` : ""}`}
|
setDueDate(e.target.value);
|
||||||
style={{ color: priority === i ? p.color : undefined, padding: "3px 8px", fontSize: 11 }}
|
save(task.id, { dueDate: val });
|
||||||
onClick={() => {
|
}}
|
||||||
setPriority(i);
|
/>
|
||||||
debounceSave({ priority: i });
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{p.icon && <span style={{ color: p.color }}>{p.icon}</span>}
|
|
||||||
{p.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Due Date */}
|
{/* Priority selector */}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
<select
|
||||||
<input
|
className="tick-meta-chip"
|
||||||
id="due-date-input"
|
style={{
|
||||||
type="date"
|
fontSize: 11.5,
|
||||||
className="form-input"
|
padding: "3px 8px",
|
||||||
value={dueDate}
|
cursor: "pointer",
|
||||||
onChange={(e) => {
|
border: "1px solid var(--border)",
|
||||||
setDueDate(e.target.value);
|
color: priority > 0 ? priorityMap[priority].color : "inherit",
|
||||||
debounceSave({ dueDate: e.target.value || null });
|
fontWeight: priority > 0 ? 700 : 500,
|
||||||
}}
|
}}
|
||||||
style={{ width: "auto", padding: "3px 8px", fontSize: 12 }}
|
value={priority}
|
||||||
/>
|
onChange={(e) => {
|
||||||
{dueDate && (
|
const val = parseInt(e.target.value, 10);
|
||||||
<button
|
setPriority(val);
|
||||||
className="btn btn-ghost btn-sm"
|
save(task.id, { priority: val });
|
||||||
style={{ padding: "2px 6px", fontSize: 11 }}
|
}}
|
||||||
onClick={() => {
|
>
|
||||||
setDueDate("");
|
{priorityMap.map((p, idx) => (
|
||||||
debounceSave({ dueDate: null });
|
<option key={idx} value={idx}>
|
||||||
}}
|
{p.icon ? `${p.icon} ` : ""}
|
||||||
type="button"
|
{p.label}
|
||||||
>
|
</option>
|
||||||
{t("clear")}
|
))}
|
||||||
</button>
|
</select>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags Trigger Chip & Popover */}
|
{/* Tag Selector */}
|
||||||
<div style={{ position: "relative", marginLeft: "auto" }}>
|
<div style={{ position: "relative" }}>
|
||||||
<button
|
<button
|
||||||
className="tick-meta-chip"
|
|
||||||
type="button"
|
type="button"
|
||||||
|
className="tick-meta-chip"
|
||||||
onClick={() => setShowTagPicker((p) => !p)}
|
onClick={() => setShowTagPicker((p) => !p)}
|
||||||
style={{ padding: "3px 8px", fontSize: 11 }}
|
style={{ fontSize: 11.5, padding: "3px 8px" }}
|
||||||
>
|
>
|
||||||
🏷️ {tags.length > 0 ? `${tags.length} tags` : "+ Tag"}
|
🏷️ {tags.length > 0 ? `${tags.length} tags` : t("selectTag")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showTagPicker && (
|
{showTagPicker && (
|
||||||
<div
|
<div
|
||||||
className="dropdown"
|
className="dropdown"
|
||||||
style={{ right: 0, top: "calc(100% + 4px)", minWidth: 200, padding: 8, zIndex: 100 }}
|
style={{ left: 0, top: "calc(100% + 4px)", minWidth: 180, padding: 8, zIndex: 110 }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", marginBottom: 6 }}>CUSTOM TAGS</div>
|
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", marginBottom: 6 }}>
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 8 }}>
|
{t("tags")}
|
||||||
{allAvailableTags.map((at) => {
|
</div>
|
||||||
const active = tags.some((tg) => tg.tag.id === at.id);
|
<div style={{ display: "flex", flexDirection: "column", gap: 4, maxHeight: 140, overflowY: "auto" }}>
|
||||||
|
{allAvailableTags.map((tag) => {
|
||||||
|
const active = tags.some((tItem) => tItem.tag.id === tag.id);
|
||||||
return (
|
return (
|
||||||
<span
|
<div
|
||||||
key={at.id}
|
key={tag.id}
|
||||||
className="badge"
|
className="context-menu-item"
|
||||||
style={{
|
style={{ padding: "4px 8px", fontSize: 12, cursor: "pointer" }}
|
||||||
background: active ? at.color : "var(--bg-primary)",
|
onClick={() => toggleTag(tag)}
|
||||||
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 style={{ width: 8, height: 8, borderRadius: "50%", background: tag.color || "var(--accent)" }} />
|
||||||
</span>
|
<span style={{ flex: 1 }}>{tag.name}</span>
|
||||||
|
{active && <span>✓</span>}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{/* Create custom tag input */}
|
<div style={{ display: "flex", gap: 4, marginTop: 6, borderTop: "1px solid var(--border)", paddingTop: 6 }}>
|
||||||
<div style={{ display: "flex", gap: 4 }}>
|
|
||||||
<input
|
<input
|
||||||
className="form-input"
|
className="form-input"
|
||||||
placeholder="New tag..."
|
placeholder="New tag..."
|
||||||
|
style={{ fontSize: 11, padding: "3px 6px", flex: 1 }}
|
||||||
value={newTagName}
|
value={newTagName}
|
||||||
onChange={(e) => setNewTagName(e.target.value)}
|
onChange={(e) => setNewTagName(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") addCustomTag(); }}
|
onKeyDown={(e) => {
|
||||||
style={{ fontSize: 11, height: 26, padding: "2px 6px" }}
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
addCustomTag();
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-primary btn-sm" type="button" onClick={addCustomTag} style={{ fontSize: 11, padding: "2px 8px" }}>
|
<button className="btn btn-primary btn-sm" style={{ fontSize: 10, padding: "2px 6px" }} onClick={addCustomTag} type="button">
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -537,170 +789,37 @@ export function TaskDetail({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selected Tags Display */}
|
{/* Modular Blocks Container (Subtasks & Notes) with Split Resizer */}
|
||||||
{tags.length > 0 && (
|
<div
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, paddingBottom: 4 }}>
|
ref={containerRef}
|
||||||
{tags.map((tg) => (
|
style={{
|
||||||
<span
|
flex: 1,
|
||||||
key={tg.tag.id}
|
display: "flex",
|
||||||
className="badge"
|
flexDirection: "column",
|
||||||
style={{
|
minHeight: 280,
|
||||||
background: tg.tag.color + "22",
|
overflow: "hidden",
|
||||||
color: tg.tag.color,
|
gap: 4,
|
||||||
fontSize: 11,
|
}}
|
||||||
padding: "2px 8px",
|
>
|
||||||
borderRadius: 4,
|
{blockOrder.map((blockType, idx) => (
|
||||||
fontWeight: 600,
|
<React.Fragment key={blockType}>
|
||||||
display: "inline-flex",
|
{blockType === "subtasks" ? renderSubtasksBlock() : renderNoteBlock()}
|
||||||
alignItems: "center",
|
|
||||||
gap: 4,
|
{/* Split Resizer bar between blocks if neither is collapsed */}
|
||||||
}}
|
{idx === 0 && !subtasksCollapsed && !noteCollapsed && (
|
||||||
>
|
<div
|
||||||
#{tg.tag.name}
|
className={`detail-split-resizer${isDraggingSplit ? " dragging" : ""}`}
|
||||||
<button
|
onMouseDown={handleSplitMouseDown}
|
||||||
type="button"
|
title="Drag to resize blocks"
|
||||||
onClick={() => toggleTag(tg.tag as MockTag)}
|
|
||||||
style={{ background: "none", border: "none", color: "inherit", cursor: "pointer", padding: 0, fontSize: 10, opacity: 0.7 }}
|
|
||||||
>
|
>
|
||||||
✕
|
<div className="detail-split-resizer-line" />
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* The Wide Adaptive Markdown Note Editor */}
|
|
||||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
|
|
||||||
<MarkdownNoteEditor
|
|
||||||
value={note}
|
|
||||||
onChange={handleNoteChange}
|
|
||||||
onSave={() => save(task.id, { note })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sub-tasks Section (Collapsible Accordion at Bottom) */}
|
|
||||||
<div style={{ borderTop: "1px solid var(--border)", paddingTop: 10 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
cursor: "pointer",
|
|
||||||
userSelect: "none",
|
|
||||||
marginBottom: showSubtasks ? 8 : 0,
|
|
||||||
}}
|
|
||||||
onClick={() => setShowSubtasks((p) => !p)}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
|
||||||
<svg
|
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2.5"
|
|
||||||
style={{ transform: showSubtasks ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.15s" }}
|
|
||||||
>
|
|
||||||
<polyline points="9 18 15 12 9 6" />
|
|
||||||
</svg>
|
|
||||||
<span className="detail-section-label" style={{ marginBottom: 0 }}>{t("subtasks")}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{subtasks.length > 0 && (
|
|
||||||
<span style={{ fontSize: 11, color: "var(--text-tertiary)", fontWeight: 600 }}>
|
|
||||||
{completedCount}/{subtasks.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showSubtasks && (
|
|
||||||
<div>
|
|
||||||
{/* Progress bar */}
|
|
||||||
{subtasks.length > 0 && (
|
|
||||||
<div style={{ height: 3, background: "var(--bg-hover)", borderRadius: 2, marginBottom: 10, overflow: "hidden" }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
width: `${(completedCount / subtasks.length) * 100}%`,
|
|
||||||
background: "var(--accent)",
|
|
||||||
borderRadius: 2,
|
|
||||||
transition: "width var(--dur-normal) var(--ease-out)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</React.Fragment>
|
||||||
{/* Sub-tasks list */}
|
))}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 4, maxHeight: 180, overflowY: "auto" }}>
|
|
||||||
{subtasks.map((sub) => (
|
|
||||||
<div
|
|
||||||
key={sub.id}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
padding: "4px 8px",
|
|
||||||
borderRadius: "var(--radius-sm)",
|
|
||||||
background: "var(--bg-secondary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className={`task-check-btn${sub.completed ? " checked" : ""}`}
|
|
||||||
style={{ width: 15, height: 15, flexShrink: 0 }}
|
|
||||||
onClick={() => toggleSubtask(sub)}
|
|
||||||
aria-label="Toggle subtask"
|
|
||||||
type="button"
|
|
||||||
/>
|
|
||||||
<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
|
|
||||||
className="icon-btn"
|
|
||||||
style={{ width: 20, height: 20, opacity: 0.4 }}
|
|
||||||
onClick={() => deleteSubtask(sub.id)}
|
|
||||||
title="Delete subtask"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Add Subtask Input */}
|
|
||||||
<div style={{ display: "flex", gap: 6, marginTop: 4 }}>
|
|
||||||
<input
|
|
||||||
className="form-input"
|
|
||||||
id="add-subtask-input"
|
|
||||||
placeholder={t("addSubtaskPlaceholder")}
|
|
||||||
style={{ flex: 1, background: "none", fontSize: 12.5, color: "var(--text-primary)" }}
|
|
||||||
value={newSubtitle}
|
|
||||||
onChange={(e) => setNewSubtitle(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
addSubtask();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{newSubtitle.trim() && (
|
|
||||||
<button className="btn btn-primary btn-sm" id="add-subtask-btn" onClick={addSubtask} type="button">
|
|
||||||
{t("add")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Info: Project Badge, Created & Edited Timestamps */}
|
{/* Footer Info */}
|
||||||
<div style={{ fontSize: 11, color: "var(--text-tertiary)", display: "flex", justifyContent: "space-between", alignItems: "center", borderTop: "1px solid var(--border)", paddingTop: 8, flexWrap: "wrap", gap: 6 }}>
|
<div style={{ fontSize: 11, color: "var(--text-tertiary)", display: "flex", justifyContent: "space-between", alignItems: "center", borderTop: "1px solid var(--border)", paddingTop: 8, flexWrap: "wrap", gap: 6 }}>
|
||||||
{listName && (
|
{listName && (
|
||||||
<span className="tick-meta-chip" style={{ fontSize: 11, padding: "2px 8px" }}>
|
<span className="tick-meta-chip" style={{ fontSize: 11, padding: "2px 8px" }}>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
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";
|
||||||
|
import { KanbanView } from "./KanbanView";
|
||||||
|
import { useUserPrefs } from "@/lib/useUserPrefs";
|
||||||
|
|
||||||
export interface Tag { id: string; name: string; color: string }
|
export interface Tag { id: string; name: string; color: string }
|
||||||
|
|
||||||
@@ -454,7 +456,12 @@ interface Props {
|
|||||||
onEmptyTrash?: () => void;
|
onEmptyTrash?: () => void;
|
||||||
onRestoreTask?: (id: string) => void;
|
onRestoreTask?: (id: string) => void;
|
||||||
onPermanentDeleteTask?: (id: string) => void;
|
onPermanentDeleteTask?: (id: string) => void;
|
||||||
onDemoAddTask?: (title: string, listId: string, parentId?: string | null) => void;
|
onDemoAddTask?: (
|
||||||
|
title: string,
|
||||||
|
listId: string,
|
||||||
|
parentId?: string | null,
|
||||||
|
meta?: { dueDate?: string | null; priority?: number; tags?: { tag: Tag }[] }
|
||||||
|
) => 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;
|
||||||
onUpdateListName?: (id: string, name: string) => void;
|
onUpdateListName?: (id: string, name: string) => void;
|
||||||
@@ -492,6 +499,23 @@ export function TaskList({
|
|||||||
const [headerTitle, setHeaderTitle] = useState("");
|
const [headerTitle, setHeaderTitle] = useState("");
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; task: Task } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; task: Task } | null>(null);
|
||||||
|
|
||||||
|
const { prefs, updatePrefs } = useUserPrefs();
|
||||||
|
// Only show kanban if the Labs flag is explicitly enabled
|
||||||
|
const kanbanEnabled = prefs.labs?.kanbanBoard ?? false;
|
||||||
|
const viewMode = kanbanEnabled ? prefs.viewMode : "list";
|
||||||
|
|
||||||
|
const handleViewModeChange = (mode: "list" | "kanban") => {
|
||||||
|
updatePrefs({ viewMode: mode });
|
||||||
|
};
|
||||||
|
|
||||||
|
// TickTick-style Quick Add Preset states
|
||||||
|
const [quickDueDate, setQuickDueDate] = useState<string | null>(null);
|
||||||
|
const [quickPriority, setQuickPriority] = useState<number>(0);
|
||||||
|
const [showQuickDue, setShowQuickDue] = useState(false);
|
||||||
|
const [showQuickPriority, setShowQuickPriority] = useState(false);
|
||||||
|
|
||||||
|
const priorityLabels = [t("priorityNone"), t("priorityLow"), t("priorityMedium"), t("priorityHigh")];
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const headerInputRef = useRef<HTMLInputElement>(null);
|
const headerInputRef = useRef<HTMLInputElement>(null);
|
||||||
const currentList = lists.find((l) => l.id === listId);
|
const currentList = lists.find((l) => l.id === listId);
|
||||||
@@ -559,9 +583,16 @@ export function TaskList({
|
|||||||
const title = newTaskTitle.trim();
|
const title = newTaskTitle.trim();
|
||||||
if (!title || !listId) return;
|
if (!title || !listId) return;
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
dueDate: quickDueDate,
|
||||||
|
priority: quickPriority,
|
||||||
|
};
|
||||||
|
|
||||||
if (isDemo) {
|
if (isDemo) {
|
||||||
if (onDemoAddTask) onDemoAddTask(title, listId, null);
|
if (onDemoAddTask) onDemoAddTask(title, listId, null, meta);
|
||||||
setNewTaskTitle("");
|
setNewTaskTitle("");
|
||||||
|
setQuickDueDate(null);
|
||||||
|
setQuickPriority(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,12 +600,14 @@ export function TaskList({
|
|||||||
const res = await fetch("/api/tasks", {
|
const res = await fetch("/api/tasks", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ title, listId }),
|
body: JSON.stringify({ title, listId, ...meta }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const task = await res.json();
|
const task = await res.json();
|
||||||
setTasks((prev) => [...prev, task]);
|
setTasks((prev) => [...prev, task]);
|
||||||
setNewTaskTitle("");
|
setNewTaskTitle("");
|
||||||
|
setQuickDueDate(null);
|
||||||
|
setQuickPriority(0);
|
||||||
onRefresh();
|
onRefresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[TaskList] handleAddTask failed", err);
|
console.error("[TaskList] handleAddTask failed", err);
|
||||||
@@ -693,7 +726,29 @@ export function TaskList({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header Actions */}
|
{/* Header Actions */}
|
||||||
<div className="main-header-actions">
|
<div className="main-header-actions" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
{/* View Switcher: List vs Kanban (Hidden in Trash/Tag mode) */}
|
||||||
|
{!isTrashActive && !selectedTag && (
|
||||||
|
<div className="view-switcher-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`view-switcher-btn${viewMode === "list" ? " active" : ""}`}
|
||||||
|
onClick={() => handleViewModeChange("list")}
|
||||||
|
title={t("listView")}
|
||||||
|
>
|
||||||
|
📋 <span className="desktop-only">{t("listView")}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`view-switcher-btn${viewMode === "kanban" ? " active" : ""}`}
|
||||||
|
onClick={() => handleViewModeChange("kanban")}
|
||||||
|
title={t("kanbanView")}
|
||||||
|
>
|
||||||
|
📊 <span className="desktop-only">{t("kanbanView")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isTrashActive ? (
|
{isTrashActive ? (
|
||||||
<button
|
<button
|
||||||
id="empty-trash-btn"
|
id="empty-trash-btn"
|
||||||
@@ -715,32 +770,55 @@ export function TaskList({
|
|||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<polyline points="20 6 9 17 4 12" />
|
<polyline points="20 6 9 17 4 12" />
|
||||||
</svg>
|
</svg>
|
||||||
{showCompleted ? t("hideDone") : t("showDone")}
|
<span className="desktop-only">{showCompleted ? t("hideDone") : t("showDone")}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task list container */}
|
{/* Main Task View (List View vs Kanban View) */}
|
||||||
<div
|
{viewMode === "kanban" && !isTrashActive && !selectedTag ? (
|
||||||
className="task-list-container"
|
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||||||
onContextMenu={(e) => {
|
<KanbanView
|
||||||
if (e.target === e.currentTarget) {
|
tasks={tasks}
|
||||||
e.preventDefault();
|
selectedTaskId={selectedTaskId}
|
||||||
}
|
onSelectTask={onTaskSelect}
|
||||||
}}
|
onToggleTask={handleToggle}
|
||||||
>
|
onAddTask={(title, priority = 0) => {
|
||||||
{loading && (
|
if (!listId) return;
|
||||||
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>{t("loading")}</div>
|
if (isDemo && onDemoAddTask) {
|
||||||
)}
|
onDemoAddTask(title, listId, null, { priority });
|
||||||
{!loading && tasks.length === 0 && (
|
} else if (!isDemo) {
|
||||||
<div className="task-list-empty">
|
fetch("/api/tasks", {
|
||||||
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
method: "POST",
|
||||||
<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" />
|
headers: { "Content-Type": "application/json" },
|
||||||
</svg>
|
body: JSON.stringify({ title, listId, priority }),
|
||||||
<p>{isTrashActive ? "Trash is empty" : selectedTag ? "No tasks with this tag" : t("noTasksYet")}</p>
|
}).then(() => onRefresh());
|
||||||
</div>
|
}
|
||||||
)}
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Task list container */
|
||||||
|
<div
|
||||||
|
className="task-list-container"
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>{t("loading")}</div>
|
||||||
|
)}
|
||||||
|
{!loading && tasks.length === 0 && (
|
||||||
|
<div className="task-list-empty">
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
<p>{isTrashActive ? "Trash is empty" : selectedTag ? "No tasks with this tag" : t("noTasksYet")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tasks Tree */}
|
{/* Tasks Tree */}
|
||||||
{incompleteTasks.map((task) => (
|
{incompleteTasks.map((task) => (
|
||||||
@@ -794,6 +872,7 @@ export function TaskList({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Full-featured Context Menu */}
|
{/* Full-featured Context Menu */}
|
||||||
{contextMenu && !isTrashActive && (
|
{contextMenu && !isTrashActive && (
|
||||||
@@ -837,11 +916,11 @@ export function TaskList({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add task bar (hidden in trash mode) */}
|
{/* Add task bar with TickTick-style Quick Presets (hidden in trash mode) */}
|
||||||
{!isTrashActive && !selectedTag && (
|
{!isTrashActive && !selectedTag && (
|
||||||
<div className="add-task-bar">
|
<div className="add-task-bar" style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
<button className="task-check-btn" style={{ opacity: 0.4, flexShrink: 0 }} aria-hidden="true" type="button" />
|
<form onSubmit={handleAddTask} style={{ display: "flex", alignItems: "center", gap: 8, width: "100%" }}>
|
||||||
<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
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
id="add-task-input"
|
id="add-task-input"
|
||||||
@@ -856,6 +935,155 @@ export function TaskList({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* TickTick-style Preset Toolbar (Due Date, Priority) */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, paddingLeft: 28, flexWrap: "wrap" }}>
|
||||||
|
{/* Due Date Preset */}
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`tick-meta-chip${quickDueDate ? " active" : ""}`}
|
||||||
|
onClick={() => setShowQuickDue((p) => !p)}
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
color: quickDueDate ? "var(--accent)" : "var(--text-tertiary)",
|
||||||
|
borderColor: quickDueDate ? "var(--accent)" : "transparent",
|
||||||
|
}}
|
||||||
|
title={t("selectDate")}
|
||||||
|
>
|
||||||
|
📅 {quickDueDate ? new Date(quickDueDate).toLocaleDateString(undefined, { month: "short", day: "numeric" }) : t("selectDate")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showQuickDue && (
|
||||||
|
<div
|
||||||
|
className="dropdown"
|
||||||
|
style={{ left: 0, bottom: "calc(100% + 6px)", minWidth: 150, padding: 6, zIndex: 120 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="context-menu-item"
|
||||||
|
style={{ padding: "4px 8px", fontSize: 12, cursor: "pointer" }}
|
||||||
|
onClick={() => {
|
||||||
|
setQuickDueDate(new Date().toISOString().split("T")[0]);
|
||||||
|
setShowQuickDue(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚡ {t("quickToday")}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="context-menu-item"
|
||||||
|
style={{ padding: "4px 8px", fontSize: 12, cursor: "pointer" }}
|
||||||
|
onClick={() => {
|
||||||
|
const tm = new Date(Date.now() + 86400000).toISOString().split("T")[0];
|
||||||
|
setQuickDueDate(tm);
|
||||||
|
setShowQuickDue(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🌅 {t("quickTomorrow")}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="context-menu-item"
|
||||||
|
style={{ padding: "4px 8px", fontSize: 12, cursor: "pointer" }}
|
||||||
|
onClick={() => {
|
||||||
|
const nw = new Date(Date.now() + 7 * 86400000).toISOString().split("T")[0];
|
||||||
|
setQuickDueDate(nw);
|
||||||
|
setShowQuickDue(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🗓️ {t("quickNextWeek")}
|
||||||
|
</div>
|
||||||
|
<div style={{ borderTop: "1px solid var(--border)", margin: "4px 0", paddingTop: 4 }}>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="form-input"
|
||||||
|
style={{ fontSize: 11, padding: "2px 6px", width: "100%" }}
|
||||||
|
value={quickDueDate || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuickDueDate(e.target.value || null);
|
||||||
|
setShowQuickDue(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{quickDueDate && (
|
||||||
|
<div
|
||||||
|
className="context-menu-item"
|
||||||
|
style={{ padding: "4px 8px", fontSize: 11, color: "var(--danger)", cursor: "pointer" }}
|
||||||
|
onClick={() => {
|
||||||
|
setQuickDueDate(null);
|
||||||
|
setShowQuickDue(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕ {t("clear")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority Preset */}
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`tick-meta-chip${quickPriority > 0 ? " active" : ""}`}
|
||||||
|
onClick={() => setShowQuickPriority((p) => !p)}
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
color: quickPriority > 0 ? PRIORITY_COLORS[quickPriority] : "var(--text-tertiary)",
|
||||||
|
borderColor: quickPriority > 0 ? PRIORITY_COLORS[quickPriority] : "transparent",
|
||||||
|
}}
|
||||||
|
title={t("selectPriority")}
|
||||||
|
>
|
||||||
|
🚩 {quickPriority > 0 ? priorityLabels[quickPriority] : t("selectPriority")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showQuickPriority && (
|
||||||
|
<div
|
||||||
|
className="dropdown"
|
||||||
|
style={{ left: 0, bottom: "calc(100% + 6px)", minWidth: 120, padding: 4, zIndex: 120 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{[0, 1, 2, 3].map((pVal) => (
|
||||||
|
<div
|
||||||
|
key={pVal}
|
||||||
|
className="context-menu-item"
|
||||||
|
style={{
|
||||||
|
padding: "4px 8px",
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: "pointer",
|
||||||
|
color: pVal > 0 ? PRIORITY_COLORS[pVal] : "inherit",
|
||||||
|
fontWeight: quickPriority === pVal ? 700 : 400,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
setQuickPriority(pVal);
|
||||||
|
setShowQuickPriority(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pVal === 0 ? `○ ${t("priorityNone")}` : `● ${priorityLabels[pVal]}`}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Helper Text */}
|
||||||
|
{(quickDueDate || quickPriority > 0) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
style={{ fontSize: 10, padding: "1px 6px", opacity: 0.6 }}
|
||||||
|
onClick={() => {
|
||||||
|
setQuickDueDate(null);
|
||||||
|
setQuickPriority(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("clear")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+16
-3
@@ -24,17 +24,30 @@ export const authOptions: NextAuthOptions = {
|
|||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
const valid = await bcrypt.compare(credentials.password, user.passwordHash);
|
const valid = await bcrypt.compare(credentials.password, user.passwordHash);
|
||||||
if (!valid) return null;
|
if (!valid) return null;
|
||||||
return { id: user.id, email: user.email, name: user.name };
|
|
||||||
|
// Admin identification logic
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL;
|
||||||
|
const isEmailAdmin = adminEmail && user.email === adminEmail;
|
||||||
|
const isAdmin = isEmailAdmin || user.email.endsWith("@checkflow.local") || user.email.startsWith("admin@");
|
||||||
|
const role = isAdmin ? "ADMIN" : "USER";
|
||||||
|
|
||||||
|
return { id: user.id, email: user.email, name: user.name, role };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
callbacks: {
|
callbacks: {
|
||||||
jwt({ token, user }) {
|
jwt({ token, user }) {
|
||||||
if (user) token.id = user.id;
|
if (user) {
|
||||||
|
token.id = user.id;
|
||||||
|
token.role = (user as { role?: string }).role;
|
||||||
|
}
|
||||||
return token;
|
return token;
|
||||||
},
|
},
|
||||||
session({ session, token }) {
|
session({ session, token }) {
|
||||||
if (session.user) session.user.id = token.id as string;
|
if (session.user) {
|
||||||
|
session.user.id = token.id as string;
|
||||||
|
session.user.role = token.role as string;
|
||||||
|
}
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+213
-79
@@ -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";
|
||||||
@@ -34,6 +34,8 @@ export const translations = {
|
|||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
save: "Save",
|
save: "Save",
|
||||||
deleteListConfirm: "Delete this list and all its tasks?",
|
deleteListConfirm: "Delete this list and all its tasks?",
|
||||||
|
undoDelete: "Undo",
|
||||||
|
listDeleted: "List deleted",
|
||||||
importTasks: "Import Tasks",
|
importTasks: "Import Tasks",
|
||||||
importModalTitle: "Import Tasks",
|
importModalTitle: "Import Tasks",
|
||||||
targetList: "Target List",
|
targetList: "Target List",
|
||||||
@@ -49,7 +51,8 @@ export const translations = {
|
|||||||
tags: "Tags",
|
tags: "Tags",
|
||||||
trash: "Trash",
|
trash: "Trash",
|
||||||
emptyTrash: "Empty Trash",
|
emptyTrash: "Empty Trash",
|
||||||
searchPlaceholder: "Search tasks, notes, tags (Ctrl+K)...",
|
searchPlaceholder: "Search tasks, notes, tags...",
|
||||||
|
searchExpandedPlaceholder: "Search anything — tasks, notes, tags (Ctrl+K)...",
|
||||||
|
|
||||||
// Settings Modal
|
// Settings Modal
|
||||||
settingsModalTitle: "Settings",
|
settingsModalTitle: "Settings",
|
||||||
@@ -104,82 +107,127 @@ export const translations = {
|
|||||||
numberedList: "Numbered list",
|
numberedList: "Numbered list",
|
||||||
checkbox: "Checkbox",
|
checkbox: "Checkbox",
|
||||||
code: "Code",
|
code: "Code",
|
||||||
|
|
||||||
|
// TickTick Mode Switcher & Quick Add
|
||||||
|
textMode: "Text Note Mode",
|
||||||
|
subtaskMode: "Checklist Mode",
|
||||||
|
switchToTextMode: "Switch to Markdown Note",
|
||||||
|
switchToSubtaskMode: "Switch to Checklist Sub-tasks",
|
||||||
|
moveToList: "Move to List",
|
||||||
|
quickAdd: "Quick Add",
|
||||||
|
quickToday: "Today",
|
||||||
|
quickTomorrow: "Tomorrow",
|
||||||
|
quickNextWeek: "Next Week",
|
||||||
|
selectDate: "Date",
|
||||||
|
selectPriority: "Priority",
|
||||||
|
selectTag: "Tag",
|
||||||
|
copied: "Copied!",
|
||||||
|
|
||||||
|
// Context Menu
|
||||||
|
editTask: "Edit",
|
||||||
|
duplicateTask: "Duplicate",
|
||||||
|
moveTask: "Move to…",
|
||||||
|
deleteTask: "Delete",
|
||||||
|
renameList: "Rename",
|
||||||
|
deleteList: "Delete list",
|
||||||
|
taskUndoHint: "Deleted. Tap to undo.",
|
||||||
|
listUndoHint: "List deleted. Tap to undo.",
|
||||||
|
|
||||||
|
// Labs, Kanban & Customization
|
||||||
|
labs: "Labs",
|
||||||
|
labsDesc: "Enable or disable experimental features individually.",
|
||||||
|
kanbanView: "Kanban Board",
|
||||||
|
listView: "List View",
|
||||||
|
enableKanban: "Kanban Board View",
|
||||||
|
enableKanbanDesc: "Switch between list and 3-column board (To Do / Doing / Done).",
|
||||||
|
swapBlocks: "Swap Position",
|
||||||
|
resetDefaults: "Reset to Defaults",
|
||||||
|
resetConfirm: "Reset all UI customizations to factory defaults?",
|
||||||
|
todoCol: "To Do",
|
||||||
|
inProgressCol: "In Progress",
|
||||||
|
doneCol: "Done",
|
||||||
|
accessRestricted: "Access Restricted",
|
||||||
|
adminRequired: "This console requires ADMIN role.",
|
||||||
},
|
},
|
||||||
|
|
||||||
ko: {
|
ko: {
|
||||||
// Auth
|
// Auth
|
||||||
appName: "CheckFlow",
|
appName: "CheckFlow",
|
||||||
tagline: "나만의 똑똑한 할 일 관리",
|
tagline: "나만의 할 일 관리, 심플하게",
|
||||||
welcomeBack: "다시 오신 것을 환영합니다",
|
welcomeBack: "다시 돌아오셨네요 👋",
|
||||||
signInSubtitle: "계정에 로그인하세요",
|
signInSubtitle: "이메일로 로그인해주세요",
|
||||||
createAccount: "계정 생성",
|
createAccount: "새 계정 만들기",
|
||||||
createAccountSubtitle: "오늘부터 할 일을 체계적으로 정리해보세요",
|
createAccountSubtitle: "오늘부터 할 일을 깔끔하게 정리해볼까요?",
|
||||||
displayName: "이름 (닉네임)",
|
displayName: "이름 (닉네임)",
|
||||||
email: "이메일",
|
email: "이메일",
|
||||||
password: "비밀번호",
|
password: "비밀번호",
|
||||||
min8Chars: "8자 이상 입력",
|
min8Chars: "8자 이상 입력해주세요",
|
||||||
signIn: "로그인",
|
signIn: "로그인",
|
||||||
signingIn: "로그인 중...",
|
signingIn: "로그인 중이에요...",
|
||||||
createBtn: "계정 생성",
|
createBtn: "계정 만들기",
|
||||||
creatingBtn: "계정 생성 중...",
|
creatingBtn: "계정 만드는 중...",
|
||||||
alreadyHaveAccount: "이미 계정이 있으신가요?",
|
alreadyHaveAccount: "이미 계정이 있으신가요?",
|
||||||
dontHaveAccount: "계정이 없으신가요?",
|
dontHaveAccount: "아직 계정이 없으신가요?",
|
||||||
tryDemoMode: "체험 모드로 둘러보기 (DB 연결 불필요)",
|
tryDemoMode: "로그인 없이 체험해보기",
|
||||||
demoBadge: "체험 모드",
|
demoBadge: "체험 중",
|
||||||
signOut: "로그아웃",
|
signOut: "로그아웃",
|
||||||
|
|
||||||
// Sidebar
|
// Sidebar
|
||||||
lists: "목록",
|
lists: "목록",
|
||||||
newList: "새 목록",
|
newList: "새 목록",
|
||||||
listNamePlaceholder: "목록 이름",
|
listNamePlaceholder: "목록 이름을 입력해주세요",
|
||||||
create: "생성",
|
create: "만들기",
|
||||||
cancel: "취소",
|
cancel: "취소",
|
||||||
save: "저장",
|
save: "저장",
|
||||||
deleteListConfirm: "이 목록과 포함된 모든 할 일을 삭제하시겠습니까?",
|
deleteListConfirm: "이 목록을 삭제할까요? 안에 있는 할 일도 모두 사라져요.",
|
||||||
importTasks: "할 일 가져오기 (Import)",
|
undoDelete: "되돌리기",
|
||||||
importModalTitle: "할 일 가져오기 (Import)",
|
listDeleted: "목록이 삭제됐어요",
|
||||||
targetList: "가져올 대상 목록",
|
importTasks: "가져오기",
|
||||||
|
importModalTitle: "할 일 가져오기",
|
||||||
|
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: "태그",
|
tags: "태그",
|
||||||
trash: "휴지통",
|
trash: "휴지통",
|
||||||
emptyTrash: "휴지통 비우기",
|
emptyTrash: "휴지통 비우기",
|
||||||
searchPlaceholder: "할 일, 메모, 태그 검색 (Ctrl+K)...",
|
searchPlaceholder: "검색...",
|
||||||
|
searchExpandedPlaceholder: "할 일, 메모, 태그 무엇이든 검색해보세요 (Ctrl+K)...",
|
||||||
|
|
||||||
// Settings Modal
|
// Settings Modal
|
||||||
settingsModalTitle: "환경설정",
|
settingsModalTitle: "설정",
|
||||||
profile: "프로필",
|
profile: "프로필",
|
||||||
preferences: "환경설정",
|
preferences: "환경설정",
|
||||||
syncIntegrations: "외부 연동 (DAVx⁵)",
|
syncIntegrations: "외부 연동",
|
||||||
admin: "관리자",
|
admin: "관리자",
|
||||||
trashRetention: "휴지통 보관 기간",
|
trashRetention: "휴지통 자동 비우기",
|
||||||
trashRetentionHint: "삭제된 항목은 지정한 기간 이후 영구적으로 삭제됩니다.",
|
trashRetentionHint: "설정한 기간이 지나면 휴지통이 자동으로 비워져요.",
|
||||||
days7: "7일",
|
days7: "7일 후 자동 삭제",
|
||||||
days14: "14일",
|
days14: "14일 후 자동 삭제",
|
||||||
days30: "30일 (권장)",
|
days30: "30일 후 자동 삭제 (권장)",
|
||||||
neverDelete: "자동 삭제 안 함 (수동 비우기만)",
|
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: "내일",
|
||||||
|
|
||||||
// Task Detail
|
// Task Detail
|
||||||
taskTitlePlaceholder: "할 일 제목",
|
taskTitlePlaceholder: "할 일 제목을 입력하세요",
|
||||||
priority: "우선순위",
|
priority: "우선순위",
|
||||||
priorityNone: "없음",
|
priorityNone: "없음",
|
||||||
priorityLow: "낮음",
|
priorityLow: "낮음",
|
||||||
@@ -187,15 +235,15 @@ export const translations = {
|
|||||||
priorityHigh: "높음",
|
priorityHigh: "높음",
|
||||||
dueDate: "마감일",
|
dueDate: "마감일",
|
||||||
clear: "지우기",
|
clear: "지우기",
|
||||||
notes: "메모장",
|
notes: "메모",
|
||||||
notesPlaceholder: "상세 내용이나 기억해야 할 메모를 자유롭게 작성하세요…\n\n마크다운 지원: **굵게**, *기울임*, # 제목, - 리스트, - [ ] 체크박스",
|
notesPlaceholder: "여기에 자유롭게 메모하세요…\n\n마크다운도 쓸 수 있어요: **굵게**, *기울임*, # 제목, - 리스트, - [ ] 체크박스",
|
||||||
subtasks: "하위 할 일 (Sub-tasks)",
|
subtasks: "하위 할 일",
|
||||||
addSubtaskPlaceholder: "하위 할 일 추가…",
|
addSubtaskPlaceholder: "하위 할 일 추가…",
|
||||||
deleteTaskConfirm: "이 할 일을 삭제하시겠습니까?",
|
deleteTaskConfirm: "이 할 일을 삭제할까요?",
|
||||||
autoSaved: "자동 저장됨",
|
autoSaved: "자동 저장됨",
|
||||||
saving: "저장 중…",
|
saving: "저장 중…",
|
||||||
created: "생성일",
|
created: "만든 날",
|
||||||
edited: "수정일",
|
edited: "수정한 날",
|
||||||
|
|
||||||
// Toolbars
|
// Toolbars
|
||||||
bold: "굵게",
|
bold: "굵게",
|
||||||
@@ -205,43 +253,87 @@ export const translations = {
|
|||||||
numberedList: "번호 매기기",
|
numberedList: "번호 매기기",
|
||||||
checkbox: "체크박스",
|
checkbox: "체크박스",
|
||||||
code: "코드 블록",
|
code: "코드 블록",
|
||||||
|
|
||||||
|
// TickTick Mode Switcher & Quick Add
|
||||||
|
textMode: "텍스트 메모 모드",
|
||||||
|
subtaskMode: "체크리스트 모드",
|
||||||
|
switchToTextMode: "마크다운 메모 모드로 전환",
|
||||||
|
switchToSubtaskMode: "체크리스트 모드로 전환",
|
||||||
|
moveToList: "다른 목록으로 이동",
|
||||||
|
quickAdd: "빠른 추가",
|
||||||
|
quickToday: "오늘",
|
||||||
|
quickTomorrow: "내일",
|
||||||
|
quickNextWeek: "다음 주",
|
||||||
|
selectDate: "날짜",
|
||||||
|
selectPriority: "우선순위",
|
||||||
|
selectTag: "태그",
|
||||||
|
copied: "복사했어요!",
|
||||||
|
|
||||||
|
// Context Menu
|
||||||
|
editTask: "수정",
|
||||||
|
duplicateTask: "복제",
|
||||||
|
moveTask: "이동…",
|
||||||
|
deleteTask: "삭제",
|
||||||
|
renameList: "이름 변경",
|
||||||
|
deleteList: "목록 삭제",
|
||||||
|
taskUndoHint: "삭제했어요. 되돌릴 수 있어요.",
|
||||||
|
listUndoHint: "목록이 삭제됐어요. 되돌릴 수 있어요.",
|
||||||
|
|
||||||
|
// Labs, Kanban & Customization
|
||||||
|
labs: "실험실 🧪",
|
||||||
|
labsDesc: "아직 정식 출시 전인 기능들이에요. 원하는 걸 골라서 켜보세요!",
|
||||||
|
kanbanView: "칸반 보드",
|
||||||
|
listView: "리스트",
|
||||||
|
enableKanban: "칸반 보드 뷰",
|
||||||
|
enableKanbanDesc: "할 일을 보드 형태로 볼 수 있어요 (할 일 / 진행 중 / 완료).",
|
||||||
|
swapBlocks: "순서 바꾸기",
|
||||||
|
resetDefaults: "기본값으로 되돌리기",
|
||||||
|
resetConfirm: "모든 커스텀 설정을 기본값으로 되돌릴까요?",
|
||||||
|
todoCol: "할 일",
|
||||||
|
inProgressCol: "진행 중",
|
||||||
|
doneCol: "완료",
|
||||||
|
accessRestricted: "접근 권한 없음",
|
||||||
|
adminRequired: "이 페이지는 관리자만 볼 수 있어요.",
|
||||||
},
|
},
|
||||||
|
|
||||||
ja: {
|
ja: {
|
||||||
// Auth
|
// Auth
|
||||||
appName: "CheckFlow",
|
appName: "CheckFlow",
|
||||||
tagline: "シンプルなタスク管理",
|
tagline: "シンプルに、毎日のタスクを",
|
||||||
welcomeBack: "おかえりなさい",
|
welcomeBack: "おかえりなさい 👋",
|
||||||
signInSubtitle: "アカウントにサインイン",
|
signInSubtitle: "メールアドレスでサインインしてください",
|
||||||
createAccount: "アカウント作成",
|
createAccount: "アカウントを作成",
|
||||||
createAccountSubtitle: "今日からタスクを整理しましょう",
|
createAccountSubtitle: "さあ、今日からタスクを整理しましょう!",
|
||||||
displayName: "お名前",
|
displayName: "お名前",
|
||||||
email: "メールアドレス",
|
email: "メールアドレス",
|
||||||
password: "パスワード",
|
password: "パスワード",
|
||||||
min8Chars: "8文字以上",
|
min8Chars: "8文字以上で入力してください",
|
||||||
signIn: "サインイン",
|
signIn: "サインイン",
|
||||||
signingIn: "サインイン中...",
|
signingIn: "サインイン中...",
|
||||||
createBtn: "アカウントを作成",
|
createBtn: "アカウントを作成する",
|
||||||
creatingBtn: "作成中...",
|
creatingBtn: "作成中...",
|
||||||
alreadyHaveAccount: "すでにアカウントをお持ちですか?",
|
alreadyHaveAccount: "すでにアカウントをお持ちですか?",
|
||||||
dontHaveAccount: "アカウントをお持ちでないですか?",
|
dontHaveAccount: "アカウントをお持ちでないですか?",
|
||||||
tryDemoMode: "デモモードで試す (DB不要)",
|
tryDemoMode: "ログインなしで試してみる",
|
||||||
demoBadge: "デモモード",
|
demoBadge: "体験中",
|
||||||
signOut: "サインアウト",
|
signOut: "サインアウト",
|
||||||
|
|
||||||
// Sidebar
|
// Sidebar
|
||||||
lists: "リスト",
|
lists: "リスト",
|
||||||
newList: "新しいリスト",
|
newList: "新しいリスト",
|
||||||
listNamePlaceholder: "リスト名",
|
listNamePlaceholder: "リスト名を入力してください",
|
||||||
create: "作成",
|
create: "作成する",
|
||||||
cancel: "キャンセル",
|
cancel: "キャンセル",
|
||||||
save: "保存",
|
save: "保存",
|
||||||
deleteListConfirm: "このリストとすべてのタスクを削除しますか?",
|
deleteListConfirm: "このリストを削除しますか?中のタスクもすべて消えます。",
|
||||||
importTasks: "タスクのインポート",
|
undoDelete: "元に戻す",
|
||||||
importModalTitle: "タスクのインポート",
|
listDeleted: "リストを削除しました",
|
||||||
targetList: "対象リスト",
|
importTasks: "インポート",
|
||||||
fileSelectLabel: "ファイル選択 (TickTick CSV または ICS)",
|
importModalTitle: "タスクをインポート",
|
||||||
|
targetList: "インポート先のリスト",
|
||||||
|
fileSelectLabel: "ファイルを選択(TickTick CSV または ICS)",
|
||||||
tickTickExportHint: "TickTick: 設定 → エクスポート → CSV または iCalendar",
|
tickTickExportHint: "TickTick: 設定 → エクスポート → CSV または iCalendar",
|
||||||
importBtn: "インポート",
|
importBtn: "インポートする",
|
||||||
importing: "インポート中...",
|
importing: "インポート中...",
|
||||||
theme: "テーマ",
|
theme: "テーマ",
|
||||||
themeSystem: "システム",
|
themeSystem: "システム",
|
||||||
@@ -251,19 +343,20 @@ export const translations = {
|
|||||||
tags: "タグ",
|
tags: "タグ",
|
||||||
trash: "ゴミ箱",
|
trash: "ゴミ箱",
|
||||||
emptyTrash: "ゴミ箱を空にする",
|
emptyTrash: "ゴミ箱を空にする",
|
||||||
searchPlaceholder: "タスク、メモ、タグを検索 (Ctrl+K)...",
|
searchPlaceholder: "検索...",
|
||||||
|
searchExpandedPlaceholder: "タスク、メモ、タグなど何でも検索できます (Ctrl+K)...",
|
||||||
|
|
||||||
// Settings Modal
|
// Settings Modal
|
||||||
settingsModalTitle: "設定",
|
settingsModalTitle: "設定",
|
||||||
profile: "プロフィール",
|
profile: "プロフィール",
|
||||||
preferences: "環境設定",
|
preferences: "環境設定",
|
||||||
syncIntegrations: "外部連携 (DAVx⁵)",
|
syncIntegrations: "外部連携",
|
||||||
admin: "管理者",
|
admin: "管理者",
|
||||||
trashRetention: "ゴミ箱の保存期間",
|
trashRetention: "ゴミ箱の自動削除",
|
||||||
trashRetentionHint: "削除されたタスクは指定した期間後に完全に削除されます。",
|
trashRetentionHint: "設定した期間が過ぎると、ゴミ箱が自動的に空になります。",
|
||||||
days7: "7日間",
|
days7: "7日後に自動削除",
|
||||||
days14: "14日間",
|
days14: "14日後に自動削除",
|
||||||
days30: "30日間 (推奨)",
|
days30: "30日後に自動削除(推奨)",
|
||||||
neverDelete: "自動削除しない",
|
neverDelete: "自動削除しない",
|
||||||
|
|
||||||
// Task List
|
// Task List
|
||||||
@@ -271,32 +364,32 @@ export const translations = {
|
|||||||
hideDone: "完了を非表示",
|
hideDone: "完了を非表示",
|
||||||
showDone: "完了を表示",
|
showDone: "完了を表示",
|
||||||
completedSection: "完了済み",
|
completedSection: "完了済み",
|
||||||
addTaskPlaceholder: "タスクを追加...",
|
addTaskPlaceholder: "タスクを追加してみましょう...",
|
||||||
add: "追加",
|
add: "追加",
|
||||||
noTasksYet: "タスクがまだありません。下から追加してください!",
|
noTasksYet: "まだタスクがありません。下から追加してみてください 😊",
|
||||||
selectListToStart: "リストを選択してください",
|
selectListToStart: "左側からリストを選んでください",
|
||||||
loading: "読み込み中...",
|
loading: "読み込み中...",
|
||||||
today: "今日",
|
today: "今日",
|
||||||
tomorrow: "明日",
|
tomorrow: "明日",
|
||||||
|
|
||||||
// Task Detail
|
// Task Detail
|
||||||
taskTitlePlaceholder: "タスク名",
|
taskTitlePlaceholder: "タスク名を入力してください",
|
||||||
priority: "優先度",
|
priority: "優先度",
|
||||||
priorityNone: "なし",
|
priorityNone: "なし",
|
||||||
priorityLow: "低",
|
priorityLow: "低め",
|
||||||
priorityMedium: "中",
|
priorityMedium: "普通",
|
||||||
priorityHigh: "高",
|
priorityHigh: "高め",
|
||||||
dueDate: "期限",
|
dueDate: "期限",
|
||||||
clear: "クリア",
|
clear: "クリア",
|
||||||
notes: "メモ",
|
notes: "メモ",
|
||||||
notesPlaceholder: "詳細やメモを自由に入力してください…\n\nMarkdown対応: **太字**, *斜体*, # 見出し, - リスト, - [ ] チェックボックス",
|
notesPlaceholder: "ここに自由にメモしてください…\n\nMarkdownも使えます: **太字**, *斜体*, # 見出し, - リスト, - [ ] チェックボックス",
|
||||||
subtasks: "サブタスク",
|
subtasks: "サブタスク",
|
||||||
addSubtaskPlaceholder: "サブタスクを追加…",
|
addSubtaskPlaceholder: "サブタスクを追加…",
|
||||||
deleteTaskConfirm: "このタスクを削除しますか?",
|
deleteTaskConfirm: "このタスクを削除しますか?",
|
||||||
autoSaved: "自動保存済み",
|
autoSaved: "自動保存しました",
|
||||||
saving: "保存中…",
|
saving: "保存中…",
|
||||||
created: "作成日時",
|
created: "作成日",
|
||||||
edited: "更新日時",
|
edited: "更新日",
|
||||||
|
|
||||||
// Toolbars
|
// Toolbars
|
||||||
bold: "太字",
|
bold: "太字",
|
||||||
@@ -306,6 +399,47 @@ export const translations = {
|
|||||||
numberedList: "番号付きリスト",
|
numberedList: "番号付きリスト",
|
||||||
checkbox: "チェックボックス",
|
checkbox: "チェックボックス",
|
||||||
code: "コードブロック",
|
code: "コードブロック",
|
||||||
|
|
||||||
|
// TickTick Mode Switcher & Quick Add
|
||||||
|
textMode: "テキストメモ",
|
||||||
|
subtaskMode: "チェックリスト",
|
||||||
|
switchToTextMode: "Markdownメモに切り替え",
|
||||||
|
switchToSubtaskMode: "チェックリストに切り替え",
|
||||||
|
moveToList: "別のリストに移動",
|
||||||
|
quickAdd: "クイック追加",
|
||||||
|
quickToday: "今日",
|
||||||
|
quickTomorrow: "明日",
|
||||||
|
quickNextWeek: "来週",
|
||||||
|
selectDate: "日付",
|
||||||
|
selectPriority: "優先度",
|
||||||
|
selectTag: "タグ",
|
||||||
|
copied: "コピーしました!",
|
||||||
|
accessRestricted: "アクセス制限",
|
||||||
|
adminRequired: "このページは管理者のみ閲覧できます。",
|
||||||
|
|
||||||
|
// Context Menu
|
||||||
|
editTask: "編集",
|
||||||
|
duplicateTask: "複製",
|
||||||
|
moveTask: "移動…",
|
||||||
|
deleteTask: "削除",
|
||||||
|
renameList: "名前を変更",
|
||||||
|
deleteList: "リストを削除",
|
||||||
|
taskUndoHint: "削除しました。元に戻せます。",
|
||||||
|
listUndoHint: "リストを削除しました。元に戻せます。",
|
||||||
|
|
||||||
|
// Labs, Kanban & Customization
|
||||||
|
labs: "実験室 🧪",
|
||||||
|
labsDesc: "まだ正式リリース前の機能です。好きなものを選んでオンにしてみてください!",
|
||||||
|
kanbanView: "カンバンボード",
|
||||||
|
listView: "リスト",
|
||||||
|
enableKanban: "カンバンボードビュー",
|
||||||
|
enableKanbanDesc: "タスクをボード形式で表示できます(未着手 / 進行中 / 完了)。",
|
||||||
|
swapBlocks: "順番を入れ替え",
|
||||||
|
resetDefaults: "デフォルトに戻す",
|
||||||
|
resetConfirm: "すべてのカスタム設定をデフォルトに戻しますか?",
|
||||||
|
todoCol: "未着手",
|
||||||
|
inProgressCol: "進行中",
|
||||||
|
doneCol: "完了",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* useUserPrefs.ts
|
||||||
|
* React hook that subscribes to the global prefs store and provides
|
||||||
|
* live updates whenever any component calls savePrefs().
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
UserPrefs,
|
||||||
|
loadPrefs,
|
||||||
|
savePrefs as persistPrefs,
|
||||||
|
resetPrefs as doReset,
|
||||||
|
prefsToCssVars,
|
||||||
|
} from "./userPrefs";
|
||||||
|
|
||||||
|
const EVENT = "checkflow:prefsChanged";
|
||||||
|
|
||||||
|
export function useUserPrefs() {
|
||||||
|
const [prefs, setPrefs] = useState<UserPrefs>(() => loadPrefs());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const next = (e as CustomEvent<UserPrefs>).detail;
|
||||||
|
setPrefs({ ...next });
|
||||||
|
};
|
||||||
|
window.addEventListener(EVENT, handler);
|
||||||
|
return () => window.removeEventListener(EVENT, handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updatePrefs = useCallback((patch: Partial<UserPrefs>) => {
|
||||||
|
const next = persistPrefs(patch);
|
||||||
|
setPrefs({ ...next });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetToDefaults = useCallback(() => {
|
||||||
|
const next = doReset();
|
||||||
|
setPrefs({ ...next });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { prefs, updatePrefs, resetToDefaults };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PrefsStyleInjector
|
||||||
|
* Renders a <style> tag that applies CSS variable overrides
|
||||||
|
* derived from the current prefs. Place once in the root layout.
|
||||||
|
*/
|
||||||
|
export function usePrefsStyle(prefs: UserPrefs): string {
|
||||||
|
const vars = prefsToCssVars(prefs);
|
||||||
|
const body = Object.entries(vars)
|
||||||
|
.map(([k, v]) => ` ${k}: ${v};`)
|
||||||
|
.join("\n");
|
||||||
|
return `:root {\n${body}\n}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* userPrefs.ts
|
||||||
|
* Global, reactive user-preference store persisted in localStorage.
|
||||||
|
* All components that call `loadPrefs()` get the same snapshot.
|
||||||
|
* Emit the `checkflow:prefsChanged` event to notify all listeners.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LabsFeatures {
|
||||||
|
/** Each flag independently enables/disables a Labs feature */
|
||||||
|
kanbanBoard: boolean; // List ↔ Kanban view switcher shown in header
|
||||||
|
// Add more feature flags here as Labs grows
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserPrefs {
|
||||||
|
/** Layout */
|
||||||
|
sidebarWidth: number; // 160–420px
|
||||||
|
detailWidth: number; // 300–760px
|
||||||
|
density: "compact" | "default" | "comfortable";
|
||||||
|
detailBlockOrder: ("subtasks" | "note")[];
|
||||||
|
detailSplitRatio: number; // 15–85 %
|
||||||
|
|
||||||
|
/** View (driven by Labs kanbanBoard flag) */
|
||||||
|
viewMode: "list" | "kanban";
|
||||||
|
|
||||||
|
/** Labs feature toggles */
|
||||||
|
labs: LabsFeatures;
|
||||||
|
|
||||||
|
/** Appearance */
|
||||||
|
fontSize: "small" | "default" | "large";
|
||||||
|
accentHue: number; // 0–360 (HSL)
|
||||||
|
saturation: number; // 20–100 (%)
|
||||||
|
roundness: "sharp" | "default" | "round";
|
||||||
|
animationSpeed: "none" | "fast" | "default" | "slow";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_PREFS: UserPrefs = {
|
||||||
|
sidebarWidth: 260,
|
||||||
|
detailWidth: 520,
|
||||||
|
density: "default",
|
||||||
|
detailBlockOrder: ["subtasks", "note"],
|
||||||
|
detailSplitRatio: 45,
|
||||||
|
viewMode: "list",
|
||||||
|
labs: {
|
||||||
|
kanbanBoard: false,
|
||||||
|
},
|
||||||
|
fontSize: "default",
|
||||||
|
accentHue: 220,
|
||||||
|
saturation: 75,
|
||||||
|
roundness: "default",
|
||||||
|
animationSpeed: "default",
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEY = "checkflow_prefs_v2";
|
||||||
|
const EVENT = "checkflow:prefsChanged";
|
||||||
|
|
||||||
|
export function loadPrefs(): UserPrefs {
|
||||||
|
if (typeof window === "undefined") return { ...DEFAULT_PREFS };
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(KEY);
|
||||||
|
if (!raw) return { ...DEFAULT_PREFS };
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
// Deep merge labs to ensure new feature flags get defaults
|
||||||
|
return {
|
||||||
|
...DEFAULT_PREFS,
|
||||||
|
...parsed,
|
||||||
|
labs: { ...DEFAULT_PREFS.labs, ...(parsed.labs || {}) },
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_PREFS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePrefs(patch: Partial<UserPrefs>): UserPrefs {
|
||||||
|
const current = loadPrefs();
|
||||||
|
const next: UserPrefs = {
|
||||||
|
...current,
|
||||||
|
...patch,
|
||||||
|
// Deep merge labs
|
||||||
|
labs: patch.labs ? { ...current.labs, ...patch.labs } : current.labs,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(next));
|
||||||
|
window.dispatchEvent(new CustomEvent(EVENT, { detail: next }));
|
||||||
|
} catch { /* storage full */ }
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetPrefs(): UserPrefs {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(KEY);
|
||||||
|
// Also remove legacy keys from v0.5
|
||||||
|
localStorage.removeItem("checkflow_block_order");
|
||||||
|
localStorage.removeItem("checkflow_split_ratio");
|
||||||
|
localStorage.removeItem("checkflow_view_mode");
|
||||||
|
window.dispatchEvent(new CustomEvent(EVENT, { detail: { ...DEFAULT_PREFS } }));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return { ...DEFAULT_PREFS };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute CSS variable overrides from a UserPrefs snapshot */
|
||||||
|
export function prefsToCssVars(p: UserPrefs): Record<string, string> {
|
||||||
|
const sat = Math.round(p.saturation * 0.9);
|
||||||
|
const accent = `hsl(${p.accentHue}, ${sat}%, 54%)`;
|
||||||
|
const accentHover = `hsl(${p.accentHue}, ${sat}%, 46%)`;
|
||||||
|
const accentLight = `hsla(${p.accentHue}, ${sat}%, 54%, 0.1)`;
|
||||||
|
const accentMedium = `hsla(${p.accentHue}, ${sat}%, 54%, 0.18)`;
|
||||||
|
|
||||||
|
const radMap = {
|
||||||
|
sharp: { xs: "2px", sm: "4px", md: "6px", lg: "8px", xl: "10px" },
|
||||||
|
default: { xs: "4px", sm: "8px", md: "12px", lg: "16px", xl: "20px" },
|
||||||
|
round: { xs: "8px", sm: "14px", md: "18px", lg: "24px", xl: "28px" },
|
||||||
|
};
|
||||||
|
const r = radMap[p.roundness];
|
||||||
|
|
||||||
|
const fontMap = { small: "12px", default: "14px", large: "15px" };
|
||||||
|
|
||||||
|
const durMap = {
|
||||||
|
none: { fast: "0ms", normal: "0ms", slow: "0ms" },
|
||||||
|
fast: { fast: "60ms", normal: "100ms", slow: "160ms" },
|
||||||
|
default: { fast: "120ms", normal: "200ms", slow: "300ms" },
|
||||||
|
slow: { fast: "220ms", normal: "360ms", slow: "500ms" },
|
||||||
|
};
|
||||||
|
const d = durMap[p.animationSpeed];
|
||||||
|
|
||||||
|
const densityMap = {
|
||||||
|
compact: { taskPad: "6px 20px", gap: "2px" },
|
||||||
|
default: { taskPad: "10px 20px", gap: "4px" },
|
||||||
|
comfortable: { taskPad: "14px 20px", gap: "8px" },
|
||||||
|
};
|
||||||
|
const dn = densityMap[p.density];
|
||||||
|
|
||||||
|
return {
|
||||||
|
"--accent": accent,
|
||||||
|
"--accent-hover": accentHover,
|
||||||
|
"--accent-light": accentLight,
|
||||||
|
"--accent-medium": accentMedium,
|
||||||
|
"--radius-xs": r.xs,
|
||||||
|
"--radius-sm": r.sm,
|
||||||
|
"--radius-md": r.md,
|
||||||
|
"--radius-lg": r.lg,
|
||||||
|
"--radius-xl": r.xl,
|
||||||
|
"--font-size-base": fontMap[p.fontSize],
|
||||||
|
"--dur-fast": d.fast,
|
||||||
|
"--dur-normal": d.normal,
|
||||||
|
"--dur-slow": d.slow,
|
||||||
|
"--task-item-padding": dn.taskPad,
|
||||||
|
"--task-item-gap": dn.gap,
|
||||||
|
"--sidebar-width": `${p.sidebarWidth}px`,
|
||||||
|
"--detail-width": `${p.detailWidth}px`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getToken } from "next-auth/jwt";
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
// Public paths:
|
||||||
|
// - /login, /register: Auth pages
|
||||||
|
// - /demo: Local preview without DB
|
||||||
|
// - /api/auth: NextAuth endpoints
|
||||||
|
// - /api/dav: DAVx⁵ uses HTTP Basic Auth
|
||||||
|
// - Static assets & metadata
|
||||||
|
const publicPrefixes = [
|
||||||
|
"/login",
|
||||||
|
"/register",
|
||||||
|
"/demo",
|
||||||
|
"/api/auth",
|
||||||
|
"/api/dav",
|
||||||
|
"/icons",
|
||||||
|
"/manifest.json",
|
||||||
|
"/sw.js",
|
||||||
|
"/favicon.ico",
|
||||||
|
];
|
||||||
|
|
||||||
|
const isPublic = publicPrefixes.some((p) => pathname.startsWith(p));
|
||||||
|
if (isPublic) return NextResponse.next();
|
||||||
|
|
||||||
|
const token = await getToken({
|
||||||
|
req: request,
|
||||||
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 비로그인 사용자의 보호된 라우트 접근 차단
|
||||||
|
if (!token) {
|
||||||
|
// API 라우트는 401 JSON 반환
|
||||||
|
if (pathname.startsWith("/api/")) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
// 페이지 라우트는 /login으로 리디렉션
|
||||||
|
const loginUrl = new URL("/login", request.url);
|
||||||
|
loginUrl.searchParams.set("callbackUrl", encodeURIComponent(pathname));
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// /admin 라우트의 경우 추가 보안 검증
|
||||||
|
if (pathname.startsWith("/admin")) {
|
||||||
|
const userRole = (token as { role?: string }).role;
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL;
|
||||||
|
const isEmailAdmin = adminEmail && token.email === adminEmail;
|
||||||
|
const isAdmin = userRole === "ADMIN" || isEmailAdmin || token.email?.endsWith("@checkflow.local");
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
// 일반 사용자가 어드민에 접근하려 할 때 403 차단 혹은 메인으로 리디렉션
|
||||||
|
const forbiddenUrl = new URL("/?error=forbidden", request.url);
|
||||||
|
return NextResponse.redirect(forbiddenUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
/*
|
||||||
|
* Match all request paths except for the ones starting with:
|
||||||
|
* - _next/static (static files)
|
||||||
|
* - _next/image (image optimization files)
|
||||||
|
* - favicon.ico (favicon file)
|
||||||
|
*/
|
||||||
|
"/((?!_next/static|_next/image|favicon.ico).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import type { NextRequest } from "next/server";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { getToken } from "next-auth/jwt";
|
|
||||||
|
|
||||||
export async function proxy(request: NextRequest) {
|
|
||||||
const { pathname } = request.nextUrl;
|
|
||||||
|
|
||||||
// Public paths
|
|
||||||
// /api/dav: DAVx⁵ uses Basic Auth (not session cookie)
|
|
||||||
// /demo: Local preview without DB
|
|
||||||
const publicPaths = ["/login", "/register", "/demo", "/api/auth", "/api/dav", "/icons", "/manifest.json", "/sw.js", "/_next", "/favicon.ico"];
|
|
||||||
const isPublic = publicPaths.some((p) => pathname.startsWith(p));
|
|
||||||
if (isPublic) return NextResponse.next();
|
|
||||||
|
|
||||||
const token = await getToken({ req: request });
|
|
||||||
if (!token) {
|
|
||||||
const loginUrl = new URL("/login", request.url);
|
|
||||||
return NextResponse.redirect(loginUrl);
|
|
||||||
}
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
||||||
};
|
|
||||||
Vendored
+3
@@ -4,12 +4,14 @@ import "next-auth/jwt";
|
|||||||
declare module "next-auth" {
|
declare module "next-auth" {
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
|
role?: string;
|
||||||
}
|
}
|
||||||
interface Session {
|
interface Session {
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
|
role?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,5 +19,6 @@ declare module "next-auth" {
|
|||||||
declare module "next-auth/jwt" {
|
declare module "next-auth/jwt" {
|
||||||
interface JWT {
|
interface JWT {
|
||||||
id: string;
|
id: string;
|
||||||
|
role?: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user