feat(ux): enhance n-depth tree DND reordering, full i18n localization, admin live stats & sidebar redesign
Build and Push Docker Image / build-and-push (push) Successful in 9m22s
Build and Push Docker Image / build-and-push (push) Successful in 9m22s
This commit is contained in:
@@ -15,6 +15,10 @@
|
|||||||
- `src/middleware.ts`: Route-level auth and admin role verification guard.
|
- `src/middleware.ts`: Route-level auth and admin role verification guard.
|
||||||
|
|
||||||
- **Completed So Far:**
|
- **Completed So Far:**
|
||||||
|
- Standardized complete localization across all 3 supported languages (EN, KO, JA) without partial fallback leaks; translated all Settings Modal tabs (Profile, Preferences, Labs, Sync Integrations, Admin) and interactive guides (v0.7.1).
|
||||||
|
- Resolved list header rename layout shift bug where editing list title pushed the completed items toggle button (v0.7.1).
|
||||||
|
- Enhanced Markdown Note Editor preview/editor canvas alignment and unified description typography heights (v0.7.1).
|
||||||
|
- Standardized Task Detail priority picker into a sleek custom popover chip matching the central quick-add toolbar (v0.7.1).
|
||||||
- Rewrote entire Git commit history with active local Git identity across all branches and tags, and force-pushed to self-hosted Gitea remote origin.
|
- Rewrote entire Git commit history with active local Git identity across all branches and tags, and force-pushed to self-hosted Gitea remote origin.
|
||||||
- Conducted full audit of codebase against all specification items in `AGENTS.md`.
|
- Conducted full audit of codebase against all specification items in `AGENTS.md`.
|
||||||
- Verified 100% implementation of core features: n-depth recursive checklist, real-time subtask synchronization, modular blocks & split resizer, 3-way theme (VSCode dark / pastel light / system), global `UserPrefs` customization, Labs Kanban view, and mobile bottom sheet.
|
- Verified 100% implementation of core features: n-depth recursive checklist, real-time subtask synchronization, modular blocks & split resizer, 3-way theme (VSCode dark / pastel light / system), global `UserPrefs` customization, Labs Kanban view, and mobile bottom sheet.
|
||||||
@@ -36,6 +40,8 @@
|
|||||||
- Real-time Optimistic UI updates for subtask inline addition & checklist checkbox clicks without requiring page refreshes (v0.7.0).
|
- Real-time Optimistic UI updates for subtask inline addition & checklist checkbox clicks without requiring page refreshes (v0.7.0).
|
||||||
- Auto-promotion of the first-ever registered user to ADMIN role on self-hosted instances (v0.7.0).
|
- Auto-promotion of the first-ever registered user to ADMIN role on self-hosted instances (v0.7.0).
|
||||||
- Full N-depth tree drag-and-drop (DND) reordering for main tasks and subtasks with LocalStorage persistence (v0.7.0).
|
- Full N-depth tree drag-and-drop (DND) reordering for main tasks and subtasks with LocalStorage persistence (v0.7.0).
|
||||||
|
- Recursive soft-delete cascading (deleting a parent task automatically soft-deletes all descendant subtasks) in both Prisma database and LocalStorage mock store (v0.7.0).
|
||||||
|
- Real-time Admin console API endpoints (`/api/admin/stats`, `/api/admin/users`) with Live/Demo duality (v0.7.0).
|
||||||
- Validated that `npm run lint` and `npm run build` execute with 0 errors and 0 warnings.
|
- Validated that `npm run lint` and `npm run build` execute with 0 errors and 0 warnings.
|
||||||
|
|
||||||
- **Next Steps (Todo):**
|
- **Next Steps (Todo):**
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ src/
|
|||||||
| `checkpoint-v0.6.2` | 표준 태스크 내보내기(Export) 기능 구현: TickTick/RFC 4180 호환 CSV (UTF-8 BOM 포함) 및 RFC 5545 iCalendar (`.ics` VTODO), 사이드바 프로필 메뉴 연동, 데모 모드 로컬 Blob 내보내기 지원, 다국어 사전 동기화 | 2026-08-21 |
|
| `checkpoint-v0.6.2` | 표준 태스크 내보내기(Export) 기능 구현: TickTick/RFC 4180 호환 CSV (UTF-8 BOM 포함) 및 RFC 5545 iCalendar (`.ics` VTODO), 사이드바 프로필 메뉴 연동, 데모 모드 로컬 Blob 내보내기 지원, 다국어 사전 동기화 | 2026-08-21 |
|
||||||
| `checkpoint-v0.6.3` | 노션 스타일 6도트 드래그 핸들(`⋮⋮`) & DND 태스크 재정렬, F2 단축키 인라인 제목 수정, 우측 호버 서브태스크 추가 버튼, 6초 플로팅 인터랙티브 실행 취소(Undo) 토스트 배너 탑재, 휴지통 및 로컬 스토리지 트리 중복 버그 수정 | 2026-08-21 |
|
| `checkpoint-v0.6.3` | 노션 스타일 6도트 드래그 핸들(`⋮⋮`) & DND 태스크 재정렬, F2 단축키 인라인 제목 수정, 우측 호버 서브태스크 추가 버튼, 6초 플로팅 인터랙티브 실행 취소(Undo) 토스트 배너 탑재, 휴지통 및 로컬 스토리지 트리 중복 버그 수정 | 2026-08-21 |
|
||||||
| `checkpoint-v0.6.4` | i18n 모듈러 사전 아키텍처(`types.ts`, `locales/en.ts`, `locales/ko.ts`, `locales/ja.ts`) 분리 및 번역 완성도 100%, 어드민 콘솔(`/admin`) 데모 프리뷰 및 멀티유저 게이트 연동, 설정 모달 Admin 탭 정보 고도화, 사이드바 프로필 메뉴 내 Admin Console 진입점 추가 | 2026-08-21 |
|
| `checkpoint-v0.6.4` | i18n 모듈러 사전 아키텍처(`types.ts`, `locales/en.ts`, `locales/ko.ts`, `locales/ja.ts`) 분리 및 번역 완성도 100%, 어드민 콘솔(`/admin`) 데모 프리뷰 및 멀티유저 게이트 연동, 설정 모달 Admin 탭 정보 고도화, 사이드바 프로필 메뉴 내 Admin Console 진입점 추가 | 2026-08-21 |
|
||||||
| `checkpoint-v0.7.0` | **(현재 최신)** 좌측 사이드바 UI/UX 전면 리디자인(브랜드 로고 SVG, 중앙정렬 매트 검색창, 바닥 밀착 프로필), 뷰 전환(리스트/칸반) 미니멀 원터치 토글 및 실험실 연동 버그 수정, 휴지통 카운트 실시간 동기화 & 기본화면 튕김 버그 수정, 첫 가입 계정 자동 ADMIN 승격, 서브태스크 인라인 추가 및 체크박스 Optimistic UI 실시간 렌더링, 메모장 기본 Preview 모드 & 원터치 토글, N-depth 계층형 DND 드래그 앤 드롭 재정렬 지원 | 2026-08-21 |
|
| `checkpoint-v0.7.0` | 좌측 사이드바 UI/UX 전면 리디자인(브랜드 로고 SVG, 중앙정렬 매트 검색창, 바닥 밀착 프로필), 뷰 전환(리스트/칸반) 미니멀 원터치 토글 및 실험실 연동 버그 수정, 휴지통 카운트 실시간 동기화 & 기본화면 튕김 버그 수정, 첫 가입 계정 자동 ADMIN 승격 및 실시간 Admin API(/api/admin/users, /api/admin/stats) 구축, 서브태스크 인라인 추가 및 체크박스 Optimistic UI 실시간 렌더링, 메모장 기본 Preview 모드 & 원터치 토글, N-depth 계층형 DND 드래그 앤 드롭 재정렬 및 재귀 하위 태스크 캐스케이드 삭제 지원 | 2026-08-21 |
|
||||||
|
| `checkpoint-v0.7.1` | **(현재 최신)** 설정 모달(5개 탭 및 가이드/프리뷰 전면) i18n 3개국어(EN/KO/JA) 100% 완전 번역 및 언어 로컬스토리지 영구 기억/자동 감지 적용, 태스크 목록 제목 인라인 수정 시 완료항목 토글 버튼 밀림 flexbox 레이아웃 버그 수정, 메모장 프리뷰/에디터 높이 및 플레이스홀더 타이포그래피 일관성 보정, 우측 상세 패널 우선순위 브라우저 기본 드롭다운을 중앙 퀵애드와 동일한 세련된 컬러 팝오버 칩 UI로 통일 | 2026-08-22 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+198
-115
@@ -1,5 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { getDemoStore } from "@/lib/mockData";
|
import { getDemoStore } from "@/lib/mockData";
|
||||||
@@ -15,7 +15,7 @@ interface AdminUser {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const INITIAL_ADMIN_USERS: AdminUser[] = [
|
const INITIAL_DEMO_USERS: AdminUser[] = [
|
||||||
{
|
{
|
||||||
id: "user-1",
|
id: "user-1",
|
||||||
name: "Admin Master",
|
name: "Admin Master",
|
||||||
@@ -49,27 +49,18 @@ export default function AdminPage() {
|
|||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const [users, setUsers] = useState<AdminUser[]>(INITIAL_ADMIN_USERS);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [totalTasks] = useState(() => {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const store = getDemoStore();
|
|
||||||
return store.tasks ? store.tasks.length : 67;
|
|
||||||
}
|
|
||||||
return 67;
|
|
||||||
});
|
|
||||||
const [totalLists] = useState(() => {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const store = getDemoStore();
|
|
||||||
return store.lists ? store.lists.length : 9;
|
|
||||||
}
|
|
||||||
return 9;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Is this running in Demo mode (no session or unauthenticated)
|
|
||||||
const isDemo = status === "unauthenticated" || !session;
|
const isDemo = status === "unauthenticated" || !session;
|
||||||
|
|
||||||
// 어드민 권한 판별
|
const [users, setUsers] = useState<AdminUser[]>(INITIAL_DEMO_USERS);
|
||||||
|
const [stats, setStats] = useState({
|
||||||
|
totalUsers: 3,
|
||||||
|
totalLists: 9,
|
||||||
|
totalTasks: 67,
|
||||||
|
caldavStatus: "Active",
|
||||||
|
});
|
||||||
|
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
const userRole = session?.user?.role;
|
const userRole = session?.user?.role;
|
||||||
const userEmail = session?.user?.email;
|
const userEmail = session?.user?.email;
|
||||||
const isAuthorized =
|
const isAuthorized =
|
||||||
@@ -79,7 +70,49 @@ export default function AdminPage() {
|
|||||||
userEmail?.startsWith("admin@") ||
|
userEmail?.startsWith("admin@") ||
|
||||||
userEmail === "admin@checkflow.local";
|
userEmail === "admin@checkflow.local";
|
||||||
|
|
||||||
// 로딩 중
|
const fetchAdminData = useCallback(async () => {
|
||||||
|
if (isDemo) {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const store = getDemoStore();
|
||||||
|
setStats({
|
||||||
|
totalUsers: INITIAL_DEMO_USERS.length,
|
||||||
|
totalLists: store.lists.length,
|
||||||
|
totalTasks: store.tasks.length,
|
||||||
|
caldavStatus: "Active",
|
||||||
|
});
|
||||||
|
setUsers(INITIAL_DEMO_USERS);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingUsers(true);
|
||||||
|
try {
|
||||||
|
const [usersRes, statsRes] = await Promise.all([
|
||||||
|
fetch("/api/admin/users"),
|
||||||
|
fetch("/api/admin/stats"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (usersRes.ok) {
|
||||||
|
const uData = await usersRes.json();
|
||||||
|
setUsers(uData);
|
||||||
|
}
|
||||||
|
if (statsRes.ok) {
|
||||||
|
const sData = await statsRes.json();
|
||||||
|
setStats(sData);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load admin data:", err);
|
||||||
|
} finally {
|
||||||
|
setLoadingUsers(false);
|
||||||
|
}
|
||||||
|
}, [isDemo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== "loading") {
|
||||||
|
fetchAdminData();
|
||||||
|
}
|
||||||
|
}, [status, fetchAdminData]);
|
||||||
|
|
||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return (
|
return (
|
||||||
<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)" }}>
|
||||||
@@ -114,10 +147,27 @@ export default function AdminPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleRole = (id: string) => {
|
const toggleRole = async (user: AdminUser) => {
|
||||||
setUsers((prev) =>
|
const newRole = user.role === "ADMIN" ? "USER" : "ADMIN";
|
||||||
prev.map((u) => (u.id === id ? { ...u, role: u.role === "ADMIN" ? "USER" : "ADMIN" } : u))
|
if (isDemo) {
|
||||||
);
|
setUsers((prev) =>
|
||||||
|
prev.map((u) => (u.id === user.id ? { ...u, role: newRole } : u))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/users", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ userId: user.id, role: newRole }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
fetchAdminData();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to toggle role:", err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleStatus = (id: string) => {
|
const toggleStatus = (id: string) => {
|
||||||
@@ -126,9 +176,26 @@ export default function AdminPage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteUser = (id: string) => {
|
const deleteUser = async (user: AdminUser) => {
|
||||||
if (confirm("Are you sure you want to delete this user and all their tasks?")) {
|
if (!confirm(`Are you sure you want to delete ${user.name} (${user.email}) and all their tasks?`)) {
|
||||||
setUsers((prev) => prev.filter((u) => u.id !== id));
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDemo) {
|
||||||
|
setUsers((prev) => prev.filter((u) => u.id !== user.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/users?userId=${user.id}`, { method: "DELETE" });
|
||||||
|
if (res.ok) {
|
||||||
|
fetchAdminData();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
alert(data.error || "Failed to delete user");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete user:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,10 +216,14 @@ export default function AdminPage() {
|
|||||||
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0, letterSpacing: -0.5 }}>
|
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0, letterSpacing: -0.5 }}>
|
||||||
CheckFlow Admin Console
|
CheckFlow Admin Console
|
||||||
</h1>
|
</h1>
|
||||||
{isDemo && (
|
{isDemo ? (
|
||||||
<span className="demo-badge" style={{ fontSize: 11, padding: "2px 8px" }}>
|
<span className="demo-badge" style={{ fontSize: 11, padding: "2px 8px" }}>
|
||||||
Demo Preview
|
Demo Preview
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-primary" style={{ fontSize: 11, padding: "2px 8px" }}>
|
||||||
|
Live Instance
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: 12, color: "var(--text-tertiary)", margin: "2px 0 0" }}>
|
<p style={{ fontSize: 12, color: "var(--text-tertiary)", margin: "2px 0 0" }}>
|
||||||
@@ -162,9 +233,15 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<Link href="/demo" className="btn btn-ghost btn-sm">
|
{isDemo ? (
|
||||||
← Back to Demo
|
<Link href="/demo" className="btn btn-ghost btn-sm">
|
||||||
</Link>
|
← Back to Demo
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={fetchAdminData}>
|
||||||
|
🔄 Refresh Data
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<Link href="/" className="btn btn-primary btn-sm">
|
<Link href="/" className="btn btn-primary btn-sm">
|
||||||
🚀 Open Dashboard
|
🚀 Open Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
@@ -175,25 +252,25 @@ export default function AdminPage() {
|
|||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: 16, marginBottom: 28 }}>
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: 16, marginBottom: 28 }}>
|
||||||
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
|
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
|
||||||
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>Total Users</div>
|
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>Total Users</div>
|
||||||
<div style={{ fontSize: 28, fontWeight: 800, color: "var(--accent)", marginTop: 4 }}>{users.length}</div>
|
<div style={{ fontSize: 28, fontWeight: 800, color: "var(--accent)", marginTop: 4 }}>{stats.totalUsers}</div>
|
||||||
<div style={{ fontSize: 11, color: "var(--success)", marginTop: 2 }}>● 100% active instances</div>
|
<div style={{ fontSize: 11, color: "var(--success)", marginTop: 2 }}>● 100% active instances</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
|
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
|
||||||
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>Total Projects / Lists</div>
|
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>Total Projects / Lists</div>
|
||||||
<div style={{ fontSize: 28, fontWeight: 800, color: "#10B981", marginTop: 4 }}>{totalLists}</div>
|
<div style={{ fontSize: 28, fontWeight: 800, color: "#10B981", marginTop: 4 }}>{stats.totalLists}</div>
|
||||||
<div style={{ fontSize: 11, color: "var(--text-tertiary)", marginTop: 2 }}>Across all users</div>
|
<div style={{ fontSize: 11, color: "var(--text-tertiary)", marginTop: 2 }}>Across all users</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
|
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
|
||||||
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>Total Tasks & Notes</div>
|
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>Total Tasks & Notes</div>
|
||||||
<div style={{ fontSize: 28, fontWeight: 800, color: "#8B5CF6", marginTop: 4 }}>{totalTasks}</div>
|
<div style={{ fontSize: 28, fontWeight: 800, color: "#8B5CF6", marginTop: 4 }}>{stats.totalTasks}</div>
|
||||||
<div style={{ fontSize: 11, color: "var(--text-tertiary)", marginTop: 2 }}>Recursive subtasks included</div>
|
<div style={{ fontSize: 11, color: "var(--text-tertiary)", marginTop: 2 }}>Recursive subtasks included</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
|
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
|
||||||
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>CalDAV / DAVx⁵ Status</div>
|
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>CalDAV / DAVx⁵ Status</div>
|
||||||
<div style={{ fontSize: 28, fontWeight: 800, color: "#F59E0B", marginTop: 4 }}>Active</div>
|
<div style={{ fontSize: 28, fontWeight: 800, color: "#F59E0B", marginTop: 4 }}>{stats.caldavStatus}</div>
|
||||||
<div style={{ fontSize: 11, color: "var(--success)", marginTop: 2 }}>● Basic Auth Bypass Verified</div>
|
<div style={{ fontSize: 11, color: "var(--success)", marginTop: 2 }}>● Basic Auth Bypass Verified</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,85 +295,91 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
{/* Users Table */}
|
{/* Users Table */}
|
||||||
<div style={{ overflowX: "auto" }}>
|
<div style={{ overflowX: "auto" }}>
|
||||||
<table style={{ width: "100%", borderCollapse: "collapse", textAlign: "left", fontSize: 13 }}>
|
{loadingUsers ? (
|
||||||
<thead>
|
<div style={{ padding: "30px", textAlign: "center", color: "var(--text-tertiary)" }}>
|
||||||
<tr style={{ borderBottom: "1px solid var(--border)", color: "var(--text-tertiary)" }}>
|
{t("loading")}
|
||||||
<th style={{ padding: "10px 12px" }}>User</th>
|
</div>
|
||||||
<th style={{ padding: "10px 12px" }}>Role</th>
|
) : (
|
||||||
<th style={{ padding: "10px 12px" }}>Joined Date</th>
|
<table style={{ width: "100%", borderCollapse: "collapse", textAlign: "left", fontSize: 13 }}>
|
||||||
<th style={{ padding: "10px 12px" }}>Tasks</th>
|
<thead>
|
||||||
<th style={{ padding: "10px 12px" }}>Status</th>
|
<tr style={{ borderBottom: "1px solid var(--border)", color: "var(--text-tertiary)" }}>
|
||||||
<th style={{ padding: "10px 12px", textAlign: "right" }}>Actions</th>
|
<th style={{ padding: "10px 12px" }}>User</th>
|
||||||
</tr>
|
<th style={{ padding: "10px 12px" }}>Role</th>
|
||||||
</thead>
|
<th style={{ padding: "10px 12px" }}>Joined Date</th>
|
||||||
<tbody>
|
<th style={{ padding: "10px 12px" }}>Tasks</th>
|
||||||
{filteredUsers.map((u) => (
|
<th style={{ padding: "10px 12px" }}>Status</th>
|
||||||
<tr key={u.id} style={{ borderBottom: "1px solid var(--border)" }}>
|
<th style={{ padding: "10px 12px", textAlign: "right" }}>Actions</th>
|
||||||
<td style={{ padding: "12px" }}>
|
|
||||||
<div style={{ fontWeight: 600 }}>{u.name}</div>
|
|
||||||
<div style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{u.email}</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: "12px" }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
padding: "3px 8px",
|
|
||||||
borderRadius: "var(--radius-sm)",
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 700,
|
|
||||||
background: u.role === "ADMIN" ? "rgba(75, 123, 245, 0.15)" : "var(--bg-primary)",
|
|
||||||
color: u.role === "ADMIN" ? "var(--accent)" : "var(--text-secondary)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{u.role}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: "12px", color: "var(--text-secondary)" }}>
|
|
||||||
{new Date(u.createdAt).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: "12px", color: "var(--text-secondary)" }}>
|
|
||||||
{u.taskCount} tasks
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: "12px" }}>
|
|
||||||
<span style={{ color: u.active ? "var(--success)" : "var(--danger)", fontWeight: 600, fontSize: 12 }}>
|
|
||||||
{u.active ? "● Active" : "○ Inactive"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: "12px", textAlign: "right" }}>
|
|
||||||
<div style={{ display: "inline-flex", gap: 6 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn-sm"
|
|
||||||
onClick={() => toggleRole(u.id)}
|
|
||||||
title="Change role"
|
|
||||||
style={{ fontSize: 11, padding: "3px 8px" }}
|
|
||||||
>
|
|
||||||
{u.role === "ADMIN" ? "Demote" : "Promote Admin"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn-sm"
|
|
||||||
onClick={() => toggleStatus(u.id)}
|
|
||||||
title="Toggle active status"
|
|
||||||
style={{ fontSize: 11, padding: "3px 8px" }}
|
|
||||||
>
|
|
||||||
{u.active ? "Deactivate" : "Activate"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn-sm"
|
|
||||||
onClick={() => deleteUser(u.id)}
|
|
||||||
title="Delete user"
|
|
||||||
style={{ fontSize: 11, padding: "3px 8px", color: "var(--danger)" }}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{filteredUsers.map((u) => (
|
||||||
|
<tr key={u.id} style={{ borderBottom: "1px solid var(--border)" }}>
|
||||||
|
<td style={{ padding: "12px" }}>
|
||||||
|
<div style={{ fontWeight: 600 }}>{u.name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{u.email}</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "12px" }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: "3px 8px",
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
background: u.role === "ADMIN" ? "rgba(75, 123, 245, 0.15)" : "var(--bg-primary)",
|
||||||
|
color: u.role === "ADMIN" ? "var(--accent)" : "var(--text-secondary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{u.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "12px", color: "var(--text-secondary)" }}>
|
||||||
|
{new Date(u.createdAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "12px", color: "var(--text-secondary)" }}>
|
||||||
|
{u.taskCount} tasks
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "12px" }}>
|
||||||
|
<span style={{ color: u.active ? "var(--success)" : "var(--danger)", fontWeight: 600, fontSize: 12 }}>
|
||||||
|
{u.active ? "● Active" : "○ Inactive"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "12px", textAlign: "right" }}>
|
||||||
|
<div style={{ display: "inline-flex", gap: 6 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => toggleRole(u)}
|
||||||
|
title="Change role"
|
||||||
|
style={{ fontSize: 11, padding: "3px 8px" }}
|
||||||
|
>
|
||||||
|
{u.role === "ADMIN" ? "Demote" : "Promote Admin"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => toggleStatus(u.id)}
|
||||||
|
title="Toggle active status"
|
||||||
|
style={{ fontSize: 11, padding: "3px 8px" }}
|
||||||
|
>
|
||||||
|
{u.active ? "Deactivate" : "Activate"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => deleteUser(u)}
|
||||||
|
title="Delete user"
|
||||||
|
style={{ fontSize: 11, padding: "3px 8px", color: "var(--danger)" }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRole = session.user.role;
|
||||||
|
const userEmail = session.user.email;
|
||||||
|
const isAuthorized =
|
||||||
|
userRole === "ADMIN" ||
|
||||||
|
userEmail?.startsWith("admin@") ||
|
||||||
|
userEmail?.endsWith("@checkflow.local") ||
|
||||||
|
userEmail === "admin@checkflow.local";
|
||||||
|
|
||||||
|
if (!isAuthorized) {
|
||||||
|
return NextResponse.json({ error: "Forbidden: Admin required" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [totalUsers, totalLists, totalTasks] = await Promise.all([
|
||||||
|
prisma.user.count(),
|
||||||
|
prisma.list.count(),
|
||||||
|
prisma.task.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
totalUsers,
|
||||||
|
totalLists,
|
||||||
|
totalTasks,
|
||||||
|
caldavStatus: "Active",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[API/Admin/Stats GET] Error:", error);
|
||||||
|
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
function checkAdminAuth(session: any): boolean {
|
||||||
|
if (!session?.user) return false;
|
||||||
|
const userRole = session.user.role;
|
||||||
|
const userEmail = session.user.email;
|
||||||
|
return (
|
||||||
|
userRole === "ADMIN" ||
|
||||||
|
userEmail?.startsWith("admin@") ||
|
||||||
|
userEmail?.endsWith("@checkflow.local") ||
|
||||||
|
userEmail === "admin@checkflow.local"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/admin/users - List all registered users with task count
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!checkAdminAuth(session)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden: Admin required" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
createdAt: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
tasks: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedUsers = users.map((u, idx) => {
|
||||||
|
const isAdmin =
|
||||||
|
idx === 0 ||
|
||||||
|
u.email.startsWith("admin@") ||
|
||||||
|
u.email.endsWith("@checkflow.local") ||
|
||||||
|
u.email === "admin@checkflow.local";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: u.id,
|
||||||
|
name: u.name || "User",
|
||||||
|
email: u.email,
|
||||||
|
role: (isAdmin ? "ADMIN" : "USER") as "USER" | "ADMIN",
|
||||||
|
createdAt: u.createdAt.toISOString(),
|
||||||
|
taskCount: u._count.tasks,
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(formattedUsers);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[API/Admin/Users GET] Error:", error);
|
||||||
|
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/admin/users - Delete a user
|
||||||
|
export async function DELETE(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!checkAdminAuth(session)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden: Admin required" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const userId = searchParams.get("userId");
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "userId is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent admin from deleting themselves
|
||||||
|
if (userId === session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Cannot delete own admin account" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.delete({
|
||||||
|
where: { id: userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[API/Admin/Users DELETE] Error:", error);
|
||||||
|
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,26 +116,27 @@ export async function DELETE(req: NextRequest, { params }: Ctx) {
|
|||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Soft delete task and its children
|
// Soft delete task and all its descendants recursively
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.task.update({
|
const collectDescendantIds = async (parentIds: string[]): Promise<string[]> => {
|
||||||
where: { id },
|
if (parentIds.length === 0) return [];
|
||||||
|
const children = await prisma.task.findMany({
|
||||||
|
where: { parentId: { in: parentIds }, userId: session.user.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const childIds = children.map((c) => c.id);
|
||||||
|
const grandChildIds = await collectDescendantIds(childIds);
|
||||||
|
return [...childIds, ...grandChildIds];
|
||||||
|
};
|
||||||
|
|
||||||
|
const allDescendants = await collectDescendantIds([id]);
|
||||||
|
const allIdsToDelete = [id, ...allDescendants];
|
||||||
|
|
||||||
|
await prisma.task.updateMany({
|
||||||
|
where: { id: { in: allIdsToDelete }, userId: session.user.id },
|
||||||
data: { isDeleted: true, deletedAt: now } as any,
|
data: { isDeleted: true, deletedAt: now } as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Recursively soft-delete children
|
|
||||||
const softDeleteChildren = async (parentId: string) => {
|
|
||||||
const children = await prisma.task.findMany({ where: { parentId } });
|
|
||||||
for (const child of children) {
|
|
||||||
await prisma.task.update({
|
|
||||||
where: { id: child.id },
|
|
||||||
data: { isDeleted: true, deletedAt: now } as any,
|
|
||||||
});
|
|
||||||
await softDeleteChildren(child.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
await softDeleteChildren(id);
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, softDeleted: true });
|
return NextResponse.json({ ok: true, softDeleted: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[tasks/id:DELETE]", err);
|
console.error("[tasks/id:DELETE]", err);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
getAllTrashTasks,
|
getAllTrashTasks,
|
||||||
filterTasksByTag,
|
filterTasksByTag,
|
||||||
filterActiveTree,
|
filterActiveTree,
|
||||||
|
removeTaskFromTree,
|
||||||
} from "@/lib/mockData";
|
} from "@/lib/mockData";
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
@@ -342,7 +343,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
const [undoToast, setUndoToast] = useState<{ task: Task; title: string } | null>(null);
|
const [undoToast, setUndoToast] = useState<{ task: Task; title: string } | null>(null);
|
||||||
const undoTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const undoTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// Unified task delete handler with immediate Undo Toast
|
// Unified task delete handler with immediate Undo Toast (recursively removes task & all descendants)
|
||||||
const handleDeleteTaskWithUndo = useCallback((task: Task) => {
|
const handleDeleteTaskWithUndo = useCallback((task: Task) => {
|
||||||
if (undoTimerRef.current) clearTimeout(undoTimerRef.current);
|
if (undoTimerRef.current) clearTimeout(undoTimerRef.current);
|
||||||
|
|
||||||
@@ -361,15 +362,26 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
}
|
}
|
||||||
if (selectedTask?.id === task.id) setSelectedTask(null);
|
if (selectedTask?.id === task.id) setSelectedTask(null);
|
||||||
} else {
|
} else {
|
||||||
setTasks((prev) => prev.filter((t) => t.id !== task.id));
|
// API Mode: Optimistically strip target task and all its subtasks from active tree immediately
|
||||||
|
setTasks((prev) => removeTaskFromTree(prev as MockTask[], task.id) as Task[]);
|
||||||
|
setApiTrashCount((c) => c + 1);
|
||||||
if (selectedTask?.id === task.id) setSelectedTask(null);
|
if (selectedTask?.id === task.id) setSelectedTask(null);
|
||||||
fetch(`/api/tasks/${task.id}`, { method: "DELETE" }).catch((e) => console.error(e));
|
|
||||||
|
fetch(`/api/tasks/${task.id}`, { method: "DELETE" })
|
||||||
|
.then(() => {
|
||||||
|
// Re-sync trash count in background
|
||||||
|
fetch("/api/tasks?countTrash=true")
|
||||||
|
.then((r) => (r.ok ? r.json() : { count: 0 }))
|
||||||
|
.then((data) => setApiTrashCount(data.count || 0))
|
||||||
|
.catch(() => {});
|
||||||
|
})
|
||||||
|
.catch((e) => console.error(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
undoTimerRef.current = setTimeout(() => {
|
undoTimerRef.current = setTimeout(() => {
|
||||||
setUndoToast(null);
|
setUndoToast(null);
|
||||||
}, 6000);
|
}, 6000);
|
||||||
}, [isDemo, selectedTask]);
|
}, [isDemo, selectedTask, isTrashActive, selectedListId, selectedTag, showCompleted]);
|
||||||
|
|
||||||
// Undo delete (Restores exact soft-deleted task hierarchy)
|
// Undo delete (Restores exact soft-deleted task hierarchy)
|
||||||
const handleUndoDelete = useCallback(async () => {
|
const handleUndoDelete = useCallback(async () => {
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function Sidebar({
|
|||||||
const [exportListId, setExportListId] = useState<string>("all");
|
const [exportListId, setExportListId] = useState<string>("all");
|
||||||
const [exportIncludeCompleted, setExportIncludeCompleted] = useState<boolean>(true);
|
const [exportIncludeCompleted, setExportIncludeCompleted] = useState<boolean>(true);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [tags, setTags] = useState<Array<MockTag | APITag>>(() => (typeof window !== "undefined" ? getCustomTags() : []));
|
const [tags, setTags] = useState<Array<MockTag | APITag>>(() => (isDemo && typeof window !== "undefined" ? getCustomTags() : []));
|
||||||
const [internalTrashCount, setInternalTrashCount] = useState(0);
|
const [internalTrashCount, setInternalTrashCount] = useState(0);
|
||||||
|
|
||||||
const trashCount = externalTrashCount !== undefined ? externalTrashCount : internalTrashCount;
|
const trashCount = externalTrashCount !== undefined ? externalTrashCount : internalTrashCount;
|
||||||
@@ -121,8 +121,12 @@ export function Sidebar({
|
|||||||
.then((r) => (r.ok ? r.json() : []))
|
.then((r) => (r.ok ? r.json() : []))
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (Array.isArray(data)) setTags(data);
|
if (Array.isArray(data)) setTags(data);
|
||||||
|
else setTags([]);
|
||||||
})
|
})
|
||||||
.catch((err) => console.error("Failed to load tags", err));
|
.catch((err) => {
|
||||||
|
console.error("Failed to load tags", err);
|
||||||
|
setTags([]);
|
||||||
|
});
|
||||||
|
|
||||||
// Load trash count
|
// Load trash count
|
||||||
fetch("/api/tasks?countTrash=true")
|
fetch("/api/tasks?countTrash=true")
|
||||||
|
|||||||
@@ -131,8 +131,8 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
const { prefs, updatePrefs, resetToDefaults } = useUserPrefs();
|
const { prefs, updatePrefs, resetToDefaults } = useUserPrefs();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<"profile" | "preferences" | "labs" | "sync" | "admin">("profile");
|
const [activeTab, setActiveTab] = useState<"profile" | "preferences" | "labs" | "sync" | "admin">("profile");
|
||||||
const [displayName, setDisplayName] = useState(() => user.name || (typeof window !== "undefined" ? getUserSettings().displayName : "Demo User"));
|
const [displayName, setDisplayName] = useState(() => user.name || (typeof window !== "undefined" ? getUserSettings().displayName : "User"));
|
||||||
const [email, setEmail] = useState(() => user.email || (typeof window !== "undefined" ? getUserSettings().email : "demo@checkflow.local"));
|
const [email, setEmail] = useState(() => user.email || (typeof window !== "undefined" ? getUserSettings().email : "user@checkflow.local"));
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [trashRetention, setTrashRetention] = useState(() => (typeof window !== "undefined" ? (getUserSettings().trashRetentionDays ?? 30) : 30));
|
const [trashRetention, setTrashRetention] = useState(() => (typeof window !== "undefined" ? (getUserSettings().trashRetentionDays ?? 30) : 30));
|
||||||
const [savedMsg, setSavedMsg] = useState("");
|
const [savedMsg, setSavedMsg] = useState("");
|
||||||
@@ -143,8 +143,8 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
const s = getUserSettings();
|
const s = getUserSettings();
|
||||||
setDisplayName(user.name || s.displayName);
|
setDisplayName(user.name || s.displayName || "User");
|
||||||
setEmail(user.email || s.email);
|
setEmail(user.email || s.email || "user@checkflow.local");
|
||||||
setTrashRetention(s.trashRetentionDays ?? 30);
|
setTrashRetention(s.trashRetentionDays ?? 30);
|
||||||
}
|
}
|
||||||
}, [isOpen, user]);
|
}, [isOpen, user]);
|
||||||
@@ -177,7 +177,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
language: lang,
|
language: lang,
|
||||||
};
|
};
|
||||||
saveUserSettings(newSettings);
|
saveUserSettings(newSettings);
|
||||||
setSavedMsg("✓ " + (lang === "ko" ? "설정이 저장되었습니다" : lang === "ja" ? "設定が保存されました" : "Settings saved"));
|
setSavedMsg("✓ " + t("settingsSaved"));
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setSavedMsg("");
|
setSavedMsg("");
|
||||||
onClose();
|
onClose();
|
||||||
@@ -192,11 +192,11 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
const accentPreview = `hsl(${hue}, ${sat}%, 54%)`;
|
const accentPreview = `hsl(${hue}, ${sat}%, 54%)`;
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: "profile", label: `👤 ${t("profile") || "Profile"}` },
|
{ id: "profile", label: `👤 ${t("profile")}` },
|
||||||
{ id: "preferences", label: `⚙️ ${t("preferences") || "Preferences"}` },
|
{ id: "preferences", label: `⚙️ ${t("preferences")}` },
|
||||||
{ id: "labs", label: `🧪 ${t("labs") || "Labs"}` },
|
{ id: "labs", label: `🧪 ${t("labs")}` },
|
||||||
{ id: "sync", label: `📱 ${t("syncIntegrations") || "Integrations"}` },
|
{ id: "sync", label: `📱 ${t("syncIntegrations")}` },
|
||||||
{ id: "admin", label: `👑 ${t("admin") || "Admin"}` },
|
{ id: "admin", label: `👑 ${t("admin")}` },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -208,7 +208,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
|
||||||
<h2 className="modal-title" style={{ marginBottom: 0, fontSize: 17 }}>⚙️ {t("settingsModalTitle") || "Settings"}</h2>
|
<h2 className="modal-title" style={{ marginBottom: 0, fontSize: 17 }}>⚙️ {t("settingsModalTitle")}</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>
|
||||||
|
|
||||||
@@ -255,15 +255,32 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
<div className="settings-tab-content">
|
<div className="settings-tab-content">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">{t("displayName")}</label>
|
<label className="form-label">{t("displayName")}</label>
|
||||||
<input className="form-input" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Your Name" />
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder={t("yourNamePlaceholder")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">{t("email")}</label>
|
<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" />
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="your.email@example.com"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">{t("password")} (Change)</label>
|
<label className="form-label">{t("password")}</label>
|
||||||
<input className="form-input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="New password (leave blank to keep current)" />
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={t("passwordChangePlaceholder")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -273,16 +290,16 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
<div className="settings-tab-content">
|
<div className="settings-tab-content">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label" style={{ fontWeight: 600 }}>
|
<label className="form-label" style={{ fontWeight: 600 }}>
|
||||||
🗑️ {t("trashRetention") || "Trash Auto-Delete Retention Period"}
|
🗑️ {t("trashRetention")}
|
||||||
</label>
|
</label>
|
||||||
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginBottom: 8 }}>
|
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginBottom: 8 }}>
|
||||||
{t("trashRetentionHint") || "Deleted tasks will be permanently removed after the specified period."}
|
{t("trashRetentionHint")}
|
||||||
</p>
|
</p>
|
||||||
<select className="form-input" value={trashRetention} onChange={(e) => setTrashRetention(parseInt(e.target.value, 10))}>
|
<select className="form-input" value={trashRetention} onChange={(e) => setTrashRetention(parseInt(e.target.value, 10))}>
|
||||||
<option value={7}>{t("days7") || "7 Days"}</option>
|
<option value={7}>{t("days7")}</option>
|
||||||
<option value={14}>{t("days14") || "14 Days"}</option>
|
<option value={14}>{t("days14")}</option>
|
||||||
<option value={30}>{t("days30") || "30 Days (Recommended)"}</option>
|
<option value={30}>{t("days30")}</option>
|
||||||
<option value={0}>{t("neverDelete") || "Never Auto-Delete (Manual empty only)"}</option>
|
<option value={0}>{t("neverDelete")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -320,46 +337,46 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
<div className="settings-tab-content">
|
<div className="settings-tab-content">
|
||||||
{/* Section: View */}
|
{/* Section: View */}
|
||||||
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 8 }}>
|
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 8 }}>
|
||||||
View
|
{t("labsSectionView")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LabsRow
|
<LabsRow
|
||||||
label="📊 Kanban Board"
|
label={`📊 ${t("labsKanbanTitle")}`}
|
||||||
desc="Switch between list view and 3-column Kanban board (To Do / In Progress / Done)."
|
desc={t("labsKanbanDesc")}
|
||||||
>
|
>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={prefs.viewMode}
|
value={prefs.labs?.kanbanBoard ? "enabled" : "disabled"}
|
||||||
options={[
|
options={[
|
||||||
{ label: "List", value: "list" },
|
{ label: t("animationOff"), value: "disabled" },
|
||||||
{ label: "Kanban", value: "kanban" },
|
{ label: "ON", value: "enabled" },
|
||||||
]}
|
]}
|
||||||
onChange={(v) => updatePrefs({ viewMode: v })}
|
onChange={(v) => updatePrefs({ labs: { kanbanBoard: v === "enabled" } })}
|
||||||
/>
|
/>
|
||||||
</LabsRow>
|
</LabsRow>
|
||||||
|
|
||||||
{/* Section: Layout */}
|
{/* Section: Layout */}
|
||||||
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", margin: "16px 0 8px" }}>
|
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", margin: "16px 0 8px" }}>
|
||||||
Layout
|
{t("labsSectionLayout")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LabsRow
|
<LabsRow
|
||||||
label="📐 Content Density"
|
label={`📐 ${t("labsDensityTitle")}`}
|
||||||
desc="Controls the vertical spacing of task items."
|
desc={t("labsDensityDesc")}
|
||||||
>
|
>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={prefs.density}
|
value={prefs.density}
|
||||||
options={[
|
options={[
|
||||||
{ label: "Compact", value: "compact" },
|
{ label: t("densityCompact"), value: "compact" },
|
||||||
{ label: "Default", value: "default" },
|
{ label: t("densityDefault"), value: "default" },
|
||||||
{ label: "Airy", value: "comfortable" },
|
{ label: t("densityComfortable"), value: "comfortable" },
|
||||||
]}
|
]}
|
||||||
onChange={(v) => updatePrefs({ density: v })}
|
onChange={(v) => updatePrefs({ density: v })}
|
||||||
/>
|
/>
|
||||||
</LabsRow>
|
</LabsRow>
|
||||||
|
|
||||||
<LabsRow
|
<LabsRow
|
||||||
label="↔️ Sidebar Width"
|
label={`↔️ ${t("sidebarWidthTitle")}`}
|
||||||
desc={`Drag the sidebar edge or adjust here. (${prefs.sidebarWidth}px)`}
|
desc={`${t("sidebarWidthDesc")} (${prefs.sidebarWidth}px)`}
|
||||||
>
|
>
|
||||||
<PrefSlider
|
<PrefSlider
|
||||||
min={160}
|
min={160}
|
||||||
@@ -371,8 +388,8 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
</LabsRow>
|
</LabsRow>
|
||||||
|
|
||||||
<LabsRow
|
<LabsRow
|
||||||
label="↔️ Detail Panel Width"
|
label={`↔️ ${t("detailWidthTitle")}`}
|
||||||
desc={`Drag the panel edge or adjust here. (${prefs.detailWidth}px)`}
|
desc={`${t("detailWidthDesc")} (${prefs.detailWidth}px)`}
|
||||||
>
|
>
|
||||||
<PrefSlider
|
<PrefSlider
|
||||||
min={300}
|
min={300}
|
||||||
@@ -385,27 +402,27 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
|
|
||||||
{/* Section: Appearance */}
|
{/* Section: Appearance */}
|
||||||
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", margin: "16px 0 8px" }}>
|
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", margin: "16px 0 8px" }}>
|
||||||
Appearance
|
{t("labsSectionAppearance")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LabsRow
|
<LabsRow
|
||||||
label="🔤 Font Size"
|
label={`🔤 ${t("fontSizeTitle")}`}
|
||||||
desc="Base font size across the app."
|
desc={t("fontSizeDesc")}
|
||||||
>
|
>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={prefs.fontSize}
|
value={prefs.fontSize}
|
||||||
options={[
|
options={[
|
||||||
{ label: "S", value: "small" },
|
{ label: t("fontSizeSmall"), value: "small" },
|
||||||
{ label: "M", value: "default" },
|
{ label: t("fontSizeMedium"), value: "default" },
|
||||||
{ label: "L", value: "large" },
|
{ label: t("fontSizeLarge"), value: "large" },
|
||||||
]}
|
]}
|
||||||
onChange={(v) => updatePrefs({ fontSize: v })}
|
onChange={(v) => updatePrefs({ fontSize: v })}
|
||||||
/>
|
/>
|
||||||
</LabsRow>
|
</LabsRow>
|
||||||
|
|
||||||
<LabsRow
|
<LabsRow
|
||||||
label="🎨 Accent Color"
|
label={`🎨 ${t("accentColorTitle")}`}
|
||||||
desc="Choose the hue of your accent color. Saturation controls vibrancy."
|
desc={t("accentColorDesc")}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 6, alignItems: "flex-end" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 6, alignItems: "flex-end" }}>
|
||||||
{/* Hue ring preview */}
|
{/* Hue ring preview */}
|
||||||
@@ -431,7 +448,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>Saturation</span>
|
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{t("saturationLabel")}</span>
|
||||||
<PrefSlider
|
<PrefSlider
|
||||||
min={20}
|
min={20}
|
||||||
max={100}
|
max={100}
|
||||||
@@ -442,7 +459,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
</div>
|
</div>
|
||||||
{/* Custom hue input */}
|
{/* Custom hue input */}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>Hue °</span>
|
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{t("hueLabel")}</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -457,31 +474,31 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
</LabsRow>
|
</LabsRow>
|
||||||
|
|
||||||
<LabsRow
|
<LabsRow
|
||||||
label="⬛ Border Roundness"
|
label={`⬛ ${t("roundnessTitle")}`}
|
||||||
desc="Controls the roundness of cards, buttons, and UI elements."
|
desc={t("roundnessDesc")}
|
||||||
>
|
>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={prefs.roundness}
|
value={prefs.roundness}
|
||||||
options={[
|
options={[
|
||||||
{ label: "Sharp", value: "sharp" },
|
{ label: t("roundnessSharp"), value: "sharp" },
|
||||||
{ label: "Default", value: "default" },
|
{ label: t("roundnessDefault"), value: "default" },
|
||||||
{ label: "Round", value: "round" },
|
{ label: t("roundnessRound"), value: "round" },
|
||||||
]}
|
]}
|
||||||
onChange={(v) => updatePrefs({ roundness: v })}
|
onChange={(v) => updatePrefs({ roundness: v })}
|
||||||
/>
|
/>
|
||||||
</LabsRow>
|
</LabsRow>
|
||||||
|
|
||||||
<LabsRow
|
<LabsRow
|
||||||
label="⚡ Animation Speed"
|
label={`⚡ ${t("animationSpeedTitle")}`}
|
||||||
desc="Controls the speed of transitions and hover effects."
|
desc={t("animationSpeedDesc")}
|
||||||
>
|
>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={prefs.animationSpeed}
|
value={prefs.animationSpeed}
|
||||||
options={[
|
options={[
|
||||||
{ label: "Off", value: "none" },
|
{ label: t("animationOff"), value: "none" },
|
||||||
{ label: "Fast", value: "fast" },
|
{ label: t("animationFast"), value: "fast" },
|
||||||
{ label: "Default", value: "default" },
|
{ label: t("animationDefault"), value: "default" },
|
||||||
{ label: "Slow", value: "slow" },
|
{ label: t("animationSlow"), value: "slow" },
|
||||||
]}
|
]}
|
||||||
onChange={(v) => updatePrefs({ animationSpeed: v })}
|
onChange={(v) => updatePrefs({ animationSpeed: v })}
|
||||||
/>
|
/>
|
||||||
@@ -490,20 +507,20 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
{/* Reset to Defaults */}
|
{/* Reset to Defaults */}
|
||||||
<div style={{ marginTop: 20, borderTop: "1px solid var(--border)", paddingTop: 16 }}>
|
<div style={{ marginTop: 20, borderTop: "1px solid var(--border)", paddingTop: 16 }}>
|
||||||
<LabsRow
|
<LabsRow
|
||||||
label="🔄 Reset to Defaults"
|
label={`🔄 ${t("resetDefaultsTitle")}`}
|
||||||
desc="Restores all layout, appearance, and view settings to their factory defaults."
|
desc={t("resetDefaultsDesc")}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-sm"
|
className="btn btn-sm"
|
||||||
style={{ color: "var(--danger)", border: "1px solid var(--danger)", background: "transparent" }}
|
style={{ color: "var(--danger)", border: "1px solid var(--danger)", background: "transparent" }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm(t("resetConfirm") || "Reset all customizations to defaults?")) {
|
if (confirm(t("resetConfirm"))) {
|
||||||
resetToDefaults();
|
resetToDefaults();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("resetDefaults") || "Reset"}
|
{t("resetDefaults")}
|
||||||
</button>
|
</button>
|
||||||
</LabsRow>
|
</LabsRow>
|
||||||
</div>
|
</div>
|
||||||
@@ -514,15 +531,15 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
{activeTab === "sync" && (
|
{activeTab === "sync" && (
|
||||||
<div className="settings-tab-content">
|
<div className="settings-tab-content">
|
||||||
<h3 style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}>
|
<h3 style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}>
|
||||||
📱 {t("syncTitle") || "CalDAV & Mobile Sync (Android / Apple)"}
|
📱 {t("syncTitle")}
|
||||||
</h3>
|
</h3>
|
||||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.5, marginBottom: 14 }}>
|
<p style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.5, marginBottom: 14 }}>
|
||||||
{t("syncDesc") || "CheckFlow supports native two-way synchronization with Android, Apple Reminders, and Thunderbird via standard CalDAV."}
|
{t("syncDesc")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Endpoint bar & Action buttons */}
|
{/* Endpoint bar & Action buttons */}
|
||||||
<div className="form-group" style={{ marginBottom: 14 }}>
|
<div className="form-group" style={{ marginBottom: 14 }}>
|
||||||
<label className="form-label" style={{ fontWeight: 600 }}>{t("syncBaseUrl") || "CalDAV Server Base URL"}</label>
|
<label className="form-label" style={{ fontWeight: 600 }}>{t("syncBaseUrl")}</label>
|
||||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
<input
|
<input
|
||||||
className="form-input"
|
className="form-input"
|
||||||
@@ -541,7 +558,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
}}
|
}}
|
||||||
style={{ minWidth: 80, fontWeight: 600 }}
|
style={{ minWidth: 80, fontWeight: 600 }}
|
||||||
>
|
>
|
||||||
{copiedCalDav ? `✓ ${t("syncCopied") || "Copied!"}` : `📋 ${t("syncCopyUrl") || "Copy"}`}
|
{copiedCalDav ? `✓ ${t("syncCopied")}` : `📋 ${t("syncCopyUrl")}`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -555,7 +572,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
disabled={testSyncStatus === "testing"}
|
disabled={testSyncStatus === "testing"}
|
||||||
style={{ display: "inline-flex", alignItems: "center", gap: 6 }}
|
style={{ display: "inline-flex", alignItems: "center", gap: 6 }}
|
||||||
>
|
>
|
||||||
{testSyncStatus === "testing" ? `⏳ ${t("syncTesting") || "Testing..."}` : `🔍 ${t("syncTestConnection") || "Test Endpoint"}`}
|
{testSyncStatus === "testing" ? `⏳ ${t("syncTesting")}` : `🔍 ${t("syncTestConnection")}`}
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
href="/api/dav"
|
href="/api/dav"
|
||||||
@@ -564,16 +581,16 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
className="btn btn-sm btn-ghost"
|
className="btn btn-sm btn-ghost"
|
||||||
style={{ display: "inline-flex", alignItems: "center", gap: 6, textDecoration: "none" }}
|
style={{ display: "inline-flex", alignItems: "center", gap: 6, textDecoration: "none" }}
|
||||||
>
|
>
|
||||||
📥 {t("syncDownloadIcs") || "Download .ICS Feed"}
|
📥 {t("syncDownloadIcs")}
|
||||||
</a>
|
</a>
|
||||||
{testSyncStatus === "success" && (
|
{testSyncStatus === "success" && (
|
||||||
<span style={{ fontSize: 12, color: "var(--success)", fontWeight: 600 }}>
|
<span style={{ fontSize: 12, color: "var(--success)", fontWeight: 600 }}>
|
||||||
{t("syncTestSuccess") || "✓ CalDAV endpoint responded successfully"}
|
✓ {t("syncTestSuccess")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{testSyncStatus === "error" && (
|
{testSyncStatus === "error" && (
|
||||||
<span style={{ fontSize: 12, color: "var(--danger)", fontWeight: 600 }}>
|
<span style={{ fontSize: 12, color: "var(--danger)", fontWeight: 600 }}>
|
||||||
✕ Endpoint test failed
|
✕ {t("syncTestError")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -583,9 +600,9 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={syncPlatform}
|
value={syncPlatform}
|
||||||
options={[
|
options={[
|
||||||
{ label: `🤖 ${t("syncTabAndroid") || "Android (DAVx⁵)"}`, value: "android" },
|
{ label: `🤖 ${t("syncTabAndroid")}`, value: "android" },
|
||||||
{ label: `🍎 ${t("syncTabApple") || "Apple Reminders"}`, value: "apple" },
|
{ label: `🍎 ${t("syncTabApple")}`, value: "apple" },
|
||||||
{ label: `🦅 ${t("syncTabThunderbird") || "Thunderbird"}`, value: "thunderbird" },
|
{ label: `🦅 ${t("syncTabThunderbird")}`, value: "thunderbird" },
|
||||||
]}
|
]}
|
||||||
onChange={(v) => setSyncPlatform(v)}
|
onChange={(v) => setSyncPlatform(v)}
|
||||||
/>
|
/>
|
||||||
@@ -615,7 +632,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
<li>{t("syncAndroidStep4")}</li>
|
<li>{t("syncAndroidStep4")}</li>
|
||||||
</ol>
|
</ol>
|
||||||
<div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}>
|
<div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}>
|
||||||
💡 <strong>Tip:</strong> In DAVx⁵ account settings, set Sync Interval to <strong>15 minutes</strong> for battery efficiency and near real-time sync.
|
💡 <strong>Tip:</strong> {t("syncAndroidTip")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -632,7 +649,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
<li>{t("syncAppleStep4")}</li>
|
<li>{t("syncAppleStep4")}</li>
|
||||||
</ol>
|
</ol>
|
||||||
<div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}>
|
<div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}>
|
||||||
💡 <strong>Tip:</strong> If using HTTPS behind a reverse proxy (Nginx/Caddy), ensure valid SSL certificates are trusted by Apple devices.
|
💡 <strong>Tip:</strong> {t("syncAppleTip")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -649,7 +666,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
<li>{t("syncThunderbirdStep4")}</li>
|
<li>{t("syncThunderbirdStep4")}</li>
|
||||||
</ol>
|
</ol>
|
||||||
<div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}>
|
<div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}>
|
||||||
💡 <strong>Tip:</strong> Thunderbird Tasks view will display CheckFlow priority tags, due dates, and completion status.
|
💡 <strong>Tip:</strong> {t("syncThunderbirdTip")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -664,7 +681,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
<span style={{ fontSize: 24 }}>👑</span>
|
<span style={{ fontSize: 24 }}>👑</span>
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ fontSize: 15, fontWeight: 700, margin: 0 }}>
|
<h3 style={{ fontSize: 15, fontWeight: 700, margin: 0 }}>
|
||||||
{t("admin") || "Admin Console"}
|
{t("admin")}
|
||||||
</h3>
|
</h3>
|
||||||
<p style={{ fontSize: 12, color: "var(--text-tertiary)", margin: "2px 0 0" }}>
|
<p style={{ fontSize: 12, color: "var(--text-tertiary)", margin: "2px 0 0" }}>
|
||||||
Multi-user administration, account control, and system telemetry
|
Multi-user administration, account control, and system telemetry
|
||||||
@@ -685,7 +702,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
>
|
>
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 10, flexWrap: "wrap", gap: 8 }}>
|
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 10, flexWrap: "wrap", gap: 8 }}>
|
||||||
<div>
|
<div>
|
||||||
<strong style={{ color: "var(--text-primary)" }}>Current Account:</strong>{" "}
|
<strong style={{ color: "var(--text-primary)" }}>{t("adminCurrentAccount")}:</strong>{" "}
|
||||||
<code style={{ background: "var(--bg-primary)", padding: "2px 6px", borderRadius: 4, border: "1px solid var(--border)" }}>
|
<code style={{ background: "var(--bg-primary)", padding: "2px 6px", borderRadius: 4, border: "1px solid var(--border)" }}>
|
||||||
{user.email || "demo@checkflow.local"}
|
{user.email || "demo@checkflow.local"}
|
||||||
</code>
|
</code>
|
||||||
@@ -709,12 +726,12 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginTop: 12 }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginTop: 12 }}>
|
||||||
<div style={{ background: "var(--bg-primary)", padding: "10px 12px", borderRadius: "var(--radius-sm)", border: "1px solid var(--border)" }}>
|
<div style={{ background: "var(--bg-primary)", padding: "10px 12px", borderRadius: "var(--radius-sm)", border: "1px solid var(--border)" }}>
|
||||||
<div style={{ fontSize: 11, color: "var(--text-tertiary)" }}>Isolation Mode</div>
|
<div style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{t("adminIsolationMode")}</div>
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, marginTop: 2, color: "var(--success)" }}>● Multi-User Privacy</div>
|
<div style={{ fontSize: 13, fontWeight: 600, marginTop: 2, color: "var(--success)" }}>● {t("adminMultiUserPrivacy")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ background: "var(--bg-primary)", padding: "10px 12px", borderRadius: "var(--radius-sm)", border: "1px solid var(--border)" }}>
|
<div style={{ background: "var(--bg-primary)", padding: "10px 12px", borderRadius: "var(--radius-sm)", border: "1px solid var(--border)" }}>
|
||||||
<div style={{ fontSize: 11, color: "var(--text-tertiary)" }}>CalDAV Endpoint</div>
|
<div style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{t("adminCalDavEndpoint")}</div>
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, marginTop: 2, color: "var(--accent)" }}>● Active / Enabled</div>
|
<div style={{ fontSize: 13, fontWeight: 600, marginTop: 2, color: "var(--accent)" }}>● {t("adminActiveEnabled")}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -725,7 +742,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
style={{ display: "inline-flex", alignItems: "center", gap: 8, textDecoration: "none" }}
|
style={{ display: "inline-flex", alignItems: "center", gap: 8, textDecoration: "none" }}
|
||||||
>
|
>
|
||||||
🚀 Open Admin Dashboard →
|
🚀 {t("adminOpenDashboard")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -741,11 +758,11 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
|||||||
<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>
|
||||||
<button className="btn btn-primary" onClick={handleSave} type="button">
|
<button className="btn btn-primary" onClick={handleSave} type="button">
|
||||||
{t("save") || "Save Changes"}
|
{t("save")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
|
|||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const previewRef = useRef<HTMLDivElement>(null);
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Reset to preview whenever value or active task changes
|
||||||
|
useEffect(() => {
|
||||||
|
// Keep preview mode as primary
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Configure marked to open links in new tabs safely
|
// Configure marked to open links in new tabs safely
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const renderer = new marked.Renderer();
|
const renderer = new marked.Renderer();
|
||||||
@@ -49,7 +54,7 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
|
|||||||
// Convert markdown to sanitized HTML with safe links
|
// Convert markdown to sanitized HTML with safe links
|
||||||
const renderMarkdownHtml = () => {
|
const renderMarkdownHtml = () => {
|
||||||
if (!value || !value.trim()) {
|
if (!value || !value.trim()) {
|
||||||
return `<p style="color: var(--text-tertiary); font-style: italic; padding: 8px 0;">${t("notesPlaceholder").split("\n")[0]}</p>`;
|
return `<div style="color: var(--text-tertiary); font-style: italic; user-select: none; line-height: 1.6;">${t("notesPlaceholder")}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -96,6 +101,7 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
padding: "4px 8px 8px 8px",
|
padding: "4px 8px 8px 8px",
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: "0.05em", color: "var(--text-tertiary)" }}>
|
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: "0.05em", color: "var(--text-tertiary)" }}>
|
||||||
@@ -133,7 +139,19 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Editor / Preview Area */}
|
{/* Editor / Preview Area */}
|
||||||
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", position: "relative" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
position: "relative",
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{mode === "edit" ? (
|
{mode === "edit" ? (
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
@@ -142,8 +160,7 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
minHeight: 120,
|
padding: "10px 12px",
|
||||||
padding: "8px 10px",
|
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
border: "none",
|
border: "none",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
@@ -152,6 +169,7 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
|
|||||||
lineHeight: 1.6,
|
lineHeight: 1.6,
|
||||||
resize: "none",
|
resize: "none",
|
||||||
fontFamily: "inherit",
|
fontFamily: "inherit",
|
||||||
|
boxSizing: "border-box",
|
||||||
}}
|
}}
|
||||||
placeholder={t("notesPlaceholder")}
|
placeholder={t("notesPlaceholder")}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -169,11 +187,13 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
padding: "8px 10px",
|
padding: "10px 12px",
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
color: "var(--text-primary)",
|
color: "var(--text-primary)",
|
||||||
fontSize: 13.5,
|
fontSize: 13.5,
|
||||||
lineHeight: 1.6,
|
lineHeight: 1.6,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
cursor: "text",
|
||||||
}}
|
}}
|
||||||
dangerouslySetInnerHTML={{ __html: renderMarkdownHtml() }}
|
dangerouslySetInnerHTML={{ __html: renderMarkdownHtml() }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -185,4 +205,4 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function TaskDetail({
|
|||||||
const [allAvailableTags, setAllAvailableTags] = useState<MockTag[]>(() => (typeof window !== "undefined" ? getCustomTags() : []));
|
const [allAvailableTags, setAllAvailableTags] = useState<MockTag[]>(() => (typeof window !== "undefined" ? getCustomTags() : []));
|
||||||
const [showTagPicker, setShowTagPicker] = useState(false);
|
const [showTagPicker, setShowTagPicker] = useState(false);
|
||||||
const [showListPicker, setShowListPicker] = useState(false);
|
const [showListPicker, setShowListPicker] = useState(false);
|
||||||
|
const [showPriorityPicker, setShowPriorityPicker] = useState(false);
|
||||||
const [newTagName, setNewTagName] = useState("");
|
const [newTagName, setNewTagName] = useState("");
|
||||||
|
|
||||||
// Modular Blocks Customization via global prefs
|
// Modular Blocks Customization via global prefs
|
||||||
@@ -706,31 +707,63 @@ export function TaskDetail({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Priority selector */}
|
{/* Priority custom popover chip */}
|
||||||
<select
|
<div style={{ position: "relative" }}>
|
||||||
className="tick-meta-chip"
|
<button
|
||||||
style={{
|
type="button"
|
||||||
fontSize: 11.5,
|
className={`tick-meta-chip${priority > 0 ? " active" : ""}`}
|
||||||
padding: "3px 8px",
|
onClick={() => setShowPriorityPicker((p) => !p)}
|
||||||
cursor: "pointer",
|
style={{
|
||||||
border: "1px solid var(--border)",
|
fontSize: 11.5,
|
||||||
color: priority > 0 ? priorityMap[priority].color : "inherit",
|
padding: "3px 8px",
|
||||||
fontWeight: priority > 0 ? 700 : 500,
|
borderRadius: "var(--radius-sm)",
|
||||||
}}
|
border: "1px solid var(--border)",
|
||||||
value={priority}
|
color: priority > 0 ? priorityMap[priority].color : "var(--text-tertiary)",
|
||||||
onChange={(e) => {
|
borderColor: priority > 0 ? priorityMap[priority].color : "var(--border)",
|
||||||
const val = parseInt(e.target.value, 10);
|
display: "inline-flex",
|
||||||
setPriority(val);
|
alignItems: "center",
|
||||||
save(task.id, { priority: val });
|
gap: 5,
|
||||||
}}
|
cursor: "pointer",
|
||||||
>
|
}}
|
||||||
{priorityMap.map((p, idx) => (
|
title={t("selectPriority")}
|
||||||
<option key={idx} value={idx}>
|
>
|
||||||
{p.icon ? `${p.icon} ` : ""}
|
<span>🚩</span>
|
||||||
{p.label}
|
<span>{priorityMap[priority].label}</span>
|
||||||
</option>
|
</button>
|
||||||
))}
|
|
||||||
</select>
|
{showPriorityPicker && (
|
||||||
|
<div
|
||||||
|
className="dropdown"
|
||||||
|
style={{ left: 0, top: "calc(100% + 4px)", minWidth: 130, padding: 4, zIndex: 130 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{priorityMap.map((p, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="context-menu-item"
|
||||||
|
style={{
|
||||||
|
padding: "5px 8px",
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
color: idx > 0 ? p.color : "inherit",
|
||||||
|
fontWeight: priority === idx ? 700 : 400,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
setPriority(idx);
|
||||||
|
save(task.id, { priority: idx });
|
||||||
|
setShowPriorityPicker(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{idx === 0 ? "○" : "●"}</span>
|
||||||
|
<span>{p.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tag Selector */}
|
{/* Tag Selector */}
|
||||||
<div style={{ position: "relative" }}>
|
<div style={{ position: "relative" }}>
|
||||||
|
|||||||
+196
-100
@@ -44,7 +44,7 @@ interface TaskItemProps {
|
|||||||
isTrashMode?: boolean;
|
isTrashMode?: boolean;
|
||||||
onRestore?: (id: string) => void;
|
onRestore?: (id: string) => void;
|
||||||
onPermanentDelete?: (id: string) => void;
|
onPermanentDelete?: (id: string) => void;
|
||||||
onDragTask?: (draggedId: string, targetId: string, position: "before" | "after") => void;
|
onDragTask?: (draggedId: string, targetId: string, position: "before" | "after" | "inside") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recursive Task Tree Item (Supports 1st, 2nd, 3rd, N-level sub-tasks seamlessly)
|
// Recursive Task Tree Item (Supports 1st, 2nd, 3rd, N-level sub-tasks seamlessly)
|
||||||
@@ -157,7 +157,7 @@ function RecursiveTaskItem({
|
|||||||
touchStartX.current = null;
|
touchStartX.current = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const [dragOverPos, setDragOverPos] = useState<"top" | "bottom" | null>(null);
|
const [dragOverPos, setDragOverPos] = useState<"top" | "bottom" | "inside" | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ paddingLeft: depth > 0 ? 24 : 0, position: "relative" }}>
|
<div style={{ paddingLeft: depth > 0 ? 24 : 0, position: "relative" }}>
|
||||||
@@ -185,8 +185,15 @@ function RecursiveTaskItem({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = "move";
|
e.dataTransfer.dropEffect = "move";
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
const mid = rect.top + rect.height / 2;
|
const relY = e.clientY - rect.top;
|
||||||
setDragOverPos(e.clientY < mid ? "top" : "bottom");
|
const h = rect.height;
|
||||||
|
if (relY < h * 0.25) {
|
||||||
|
setDragOverPos("top");
|
||||||
|
} else if (relY > h * 0.75) {
|
||||||
|
setDragOverPos("bottom");
|
||||||
|
} else {
|
||||||
|
setDragOverPos("inside");
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onDragLeave={() => setDragOverPos(null)}
|
onDragLeave={() => setDragOverPos(null)}
|
||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
@@ -194,7 +201,9 @@ function RecursiveTaskItem({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const draggedId = e.dataTransfer.getData("text/plain");
|
const draggedId = e.dataTransfer.getData("text/plain");
|
||||||
if (draggedId && draggedId !== task.id && onDragTask) {
|
if (draggedId && draggedId !== task.id && onDragTask) {
|
||||||
onDragTask(draggedId, task.id, dragOverPos === "top" ? "before" : "after");
|
const pos: "before" | "after" | "inside" =
|
||||||
|
dragOverPos === "top" ? "before" : dragOverPos === "bottom" ? "after" : "inside";
|
||||||
|
onDragTask(draggedId, task.id, pos);
|
||||||
}
|
}
|
||||||
setDragOverPos(null);
|
setDragOverPos(null);
|
||||||
}}
|
}}
|
||||||
@@ -204,6 +213,9 @@ function RecursiveTaskItem({
|
|||||||
marginLeft: depth > 0 ? 8 : 0,
|
marginLeft: depth > 0 ? 8 : 0,
|
||||||
transform: `translateX(${swipeOffset}px)`,
|
transform: `translateX(${swipeOffset}px)`,
|
||||||
transition: swipeOffset === 0 ? "transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)" : "none",
|
transition: swipeOffset === 0 ? "transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)" : "none",
|
||||||
|
outline: dragOverPos === "inside" ? "2px dashed var(--accent)" : undefined,
|
||||||
|
outlineOffset: -2,
|
||||||
|
background: dragOverPos === "inside" ? "var(--accent-light, rgba(75, 123, 245, 0.12))" : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Notion-style 6-dot Drag Handle */}
|
{/* Notion-style 6-dot Drag Handle */}
|
||||||
@@ -771,17 +783,43 @@ export function TaskList({
|
|||||||
setEditingHeader(false);
|
setEditingHeader(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tree-aware reordering function that supports reordering both top-level tasks and nested subtasks
|
// Helper: check if targetId is inside the subtree of ancestorId (prevents circular nesting / node disappearing)
|
||||||
const handleReorderTasks = useCallback((draggedId: string, targetId: string, position: "before" | "after") => {
|
const isDescendantNode = (nodes: Task[], ancestorId: string, targetId: string): boolean => {
|
||||||
setTasks((prev) => {
|
for (const node of nodes) {
|
||||||
// Helper to find and remove a node from the tree
|
if (node.id === ancestorId) {
|
||||||
let extractedNode: Task | null = null;
|
const checkChildren = (children: Task[]): boolean => {
|
||||||
|
for (const c of children) {
|
||||||
|
if (c.id === targetId) return true;
|
||||||
|
if (c.children && c.children.length > 0 && checkChildren(c.children)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
return checkChildren(node.children || []);
|
||||||
|
}
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
if (isDescendantNode(node.children, ancestorId, targetId)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Robust Tree-aware reordering function (1st, 2nd, 3rd depth task reordering, promoting to top-level, and demoting into subtask)
|
||||||
|
const handleReorderTasks = useCallback((draggedId: string, targetId: string, position: "before" | "after" | "inside") => {
|
||||||
|
// Prevent dropping onto itself or into its own subtree (which causes loops/disappearing tasks)
|
||||||
|
if (draggedId === targetId) return;
|
||||||
|
|
||||||
|
setTasks((prev) => {
|
||||||
|
if (isDescendantNode(prev, draggedId, targetId)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Extract and remove the dragged node from its current position
|
||||||
|
let extractedNode: Task | null = null;
|
||||||
const removeNode = (nodes: Task[]): Task[] => {
|
const removeNode = (nodes: Task[]): Task[] => {
|
||||||
const result: Task[] = [];
|
const result: Task[] = [];
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.id === draggedId) {
|
if (node.id === draggedId) {
|
||||||
extractedNode = node;
|
extractedNode = { ...node };
|
||||||
} else {
|
} else {
|
||||||
const updatedNode = { ...node };
|
const updatedNode = { ...node };
|
||||||
if (updatedNode.children && updatedNode.children.length > 0) {
|
if (updatedNode.children && updatedNode.children.length > 0) {
|
||||||
@@ -795,43 +833,93 @@ export function TaskList({
|
|||||||
|
|
||||||
const treeWithoutDragged = removeNode(prev);
|
const treeWithoutDragged = removeNode(prev);
|
||||||
if (!extractedNode) return prev;
|
if (!extractedNode) return prev;
|
||||||
|
const safeExtractedNode: Task = extractedNode;
|
||||||
|
|
||||||
// Helper to insert the extracted node adjacent to targetId in the same list/level
|
let determinedParentId: string | null = null;
|
||||||
const insertNode = (nodes: Task[]): { list: Task[]; inserted: boolean } => {
|
|
||||||
const idx = nodes.findIndex((n) => n.id === targetId);
|
|
||||||
if (idx !== -1) {
|
|
||||||
const newNodes = [...nodes];
|
|
||||||
const insertIdx = position === "before" ? idx : idx + 1;
|
|
||||||
// Maintain target's parentId for the dragged node
|
|
||||||
const adjustedNode = { ...extractedNode!, parentId: nodes[idx].parentId };
|
|
||||||
newNodes.splice(insertIdx, 0, adjustedNode);
|
|
||||||
return { list: newNodes, inserted: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasInserted = false;
|
// 2. Insert into the target position
|
||||||
const newNodes = nodes.map((node) => {
|
if (position === "inside") {
|
||||||
if (!hasInserted && node.children && node.children.length > 0) {
|
determinedParentId = targetId;
|
||||||
const res = insertNode(node.children);
|
const insertInside = (nodes: Task[]): { list: Task[]; inserted: boolean } => {
|
||||||
if (res.inserted) {
|
let hasInserted = false;
|
||||||
|
const newNodes = nodes.map((node) => {
|
||||||
|
if (node.id === targetId) {
|
||||||
hasInserted = true;
|
hasInserted = true;
|
||||||
return { ...node, children: res.list };
|
const nodeWithNewParent: Task = { ...safeExtractedNode, parentId: targetId };
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
children: [...(node.children || []), nodeWithNewParent],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
const res = insertInside(node.children);
|
||||||
|
if (res.inserted) {
|
||||||
|
hasInserted = true;
|
||||||
|
return { ...node, children: res.list };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
return { list: newNodes, inserted: hasInserted };
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = insertInside(treeWithoutDragged);
|
||||||
|
const finalTree = result.inserted ? result.list : [...treeWithoutDragged, { ...safeExtractedNode, parentId: null }];
|
||||||
|
|
||||||
|
if (isDemo && typeof window !== "undefined") {
|
||||||
|
const store = getDemoStore();
|
||||||
|
saveDemoStore(store.lists, finalTree as unknown as MockTask[]);
|
||||||
|
} else {
|
||||||
|
fetch(`/api/tasks/${draggedId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ parentId: determinedParentId }),
|
||||||
|
}).catch((err) => console.error("Failed to update parentId", err));
|
||||||
|
}
|
||||||
|
return finalTree;
|
||||||
|
} else {
|
||||||
|
// Drop before/after as sibling (if target is at root level, parentId becomes null -> promoted to 1st level)
|
||||||
|
const insertSibling = (nodes: Task[], currentParentId: string | null): { list: Task[]; inserted: boolean } => {
|
||||||
|
const idx = nodes.findIndex((n) => n.id === targetId);
|
||||||
|
if (idx !== -1) {
|
||||||
|
determinedParentId = currentParentId;
|
||||||
|
const insertIdx = position === "before" ? idx : idx + 1;
|
||||||
|
const nodeWithNewParent: Task = { ...safeExtractedNode, parentId: currentParentId };
|
||||||
|
const newNodes = [...nodes];
|
||||||
|
newNodes.splice(insertIdx, 0, nodeWithNewParent);
|
||||||
|
return { list: newNodes, inserted: true };
|
||||||
}
|
}
|
||||||
return node;
|
|
||||||
});
|
|
||||||
|
|
||||||
return { list: newNodes, inserted: hasInserted };
|
let hasInserted = false;
|
||||||
};
|
const newNodes = nodes.map((node) => {
|
||||||
|
if (!hasInserted && node.children && node.children.length > 0) {
|
||||||
|
const res = insertSibling(node.children, node.id);
|
||||||
|
if (res.inserted) {
|
||||||
|
hasInserted = true;
|
||||||
|
return { ...node, children: res.list };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
|
||||||
const finalTree = insertNode(treeWithoutDragged).list;
|
return { list: newNodes, inserted: hasInserted };
|
||||||
|
};
|
||||||
|
|
||||||
// Update demo store or backend sort orders
|
const result = insertSibling(treeWithoutDragged, null);
|
||||||
if (isDemo && typeof window !== "undefined") {
|
const finalTree = result.inserted ? result.list : [...treeWithoutDragged, { ...safeExtractedNode, parentId: null }];
|
||||||
const store = getDemoStore();
|
|
||||||
saveDemoStore(store.lists, finalTree as unknown as MockTask[]);
|
if (isDemo && typeof window !== "undefined") {
|
||||||
|
const store = getDemoStore();
|
||||||
|
saveDemoStore(store.lists, finalTree as unknown as MockTask[]);
|
||||||
|
} else {
|
||||||
|
fetch(`/api/tasks/${draggedId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ parentId: determinedParentId }),
|
||||||
|
}).catch((err) => console.error("Failed to update sibling parentId", err));
|
||||||
|
}
|
||||||
|
return finalTree;
|
||||||
}
|
}
|
||||||
|
|
||||||
return finalTree;
|
|
||||||
});
|
});
|
||||||
}, [isDemo, setTasks]);
|
}, [isDemo, setTasks]);
|
||||||
|
|
||||||
@@ -861,70 +949,78 @@ export function TaskList({
|
|||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="main-header">
|
<div className="main-header" style={{ justifyContent: "space-between" }}>
|
||||||
<button className="icon-btn mobile-only" id="menu-btn" onClick={onMenuOpen} aria-label="Menu" type="button">
|
<div style={{ display: "flex", alignItems: "center", gap: 10, flex: 1, minWidth: 0, marginRight: 12 }}>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<button className="icon-btn mobile-only" id="menu-btn" onClick={onMenuOpen} aria-label="Menu" type="button">
|
||||||
<line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" />
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
</svg>
|
<line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" />
|
||||||
</button>
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Header Title / Trash Header / Tag Header */}
|
{/* Header Title / Trash Header / Tag Header */}
|
||||||
{isTrashActive ? (
|
{isTrashActive ? (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||||||
<span style={{ fontSize: 18, fontWeight: 700, color: "var(--danger)" }}>🗑️ {t("trash") || "Trash"}</span>
|
<span style={{ fontSize: 18, fontWeight: 700, color: "var(--danger)" }}>🗑️ {t("trash") || "Trash"}</span>
|
||||||
<span style={{ fontSize: 12, color: "var(--text-tertiary)" }}>({tasks.length})</span>
|
<span style={{ fontSize: 12, color: "var(--text-tertiary)" }}>({tasks.length})</span>
|
||||||
</div>
|
</div>
|
||||||
) : selectedTag ? (
|
) : selectedTag ? (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||||||
<span style={{ fontSize: 18, fontWeight: 700, color: "var(--accent)" }}>🏷️ #{selectedTag}</span>
|
<span style={{ fontSize: 18, fontWeight: 700, color: "var(--accent)" }}>🏷️ #{selectedTag}</span>
|
||||||
<span style={{ fontSize: 12, color: "var(--text-tertiary)" }}>({tasks.length})</span>
|
<span style={{ fontSize: 12, color: "var(--text-tertiary)" }}>({tasks.length})</span>
|
||||||
</div>
|
</div>
|
||||||
) : editingHeader ? (
|
) : editingHeader ? (
|
||||||
<input
|
<input
|
||||||
ref={headerInputRef}
|
ref={headerInputRef}
|
||||||
id="header-rename-input"
|
id="header-rename-input"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
value={headerTitle}
|
value={headerTitle}
|
||||||
onChange={(e) => setHeaderTitle(e.target.value)}
|
onChange={(e) => setHeaderTitle(e.target.value)}
|
||||||
onBlur={handleSaveHeaderTitle}
|
onBlur={handleSaveHeaderTitle}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") handleSaveHeaderTitle();
|
if (e.key === "Enter") handleSaveHeaderTitle();
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
if (currentList) setHeaderTitle(currentList.name);
|
if (currentList) setHeaderTitle(currentList.name);
|
||||||
setEditingHeader(false);
|
setEditingHeader(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
autoFocus
|
autoFocus
|
||||||
style={{ fontSize: 18, fontWeight: 700, height: 36, padding: "2px 10px", maxWidth: 360 }}
|
style={{ fontSize: 18, fontWeight: 700, height: 36, padding: "2px 10px", width: "100%", maxWidth: 320 }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="main-header-title"
|
className="main-header-title"
|
||||||
id="main-header-title"
|
id="main-header-title"
|
||||||
style={{
|
style={{
|
||||||
color: currentList?.color,
|
color: currentList?.color,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 8,
|
gap: 8,
|
||||||
padding: "4px 8px",
|
padding: "4px 8px",
|
||||||
borderRadius: "var(--radius-sm)",
|
borderRadius: "var(--radius-sm)",
|
||||||
transition: "background var(--dur-fast)",
|
transition: "background var(--dur-fast)",
|
||||||
}}
|
minWidth: 0,
|
||||||
onClick={(e) => {
|
overflow: "hidden",
|
||||||
e.stopPropagation();
|
textOverflow: "ellipsis",
|
||||||
setEditingHeader(true);
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
title="Click to rename list"
|
onClick={(e) => {
|
||||||
>
|
e.stopPropagation();
|
||||||
<span>{currentList?.name || t("tasks")}</span>
|
setEditingHeader(true);
|
||||||
<span style={{ fontSize: 13, opacity: 0.5 }}>✏️</span>
|
}}
|
||||||
</div>
|
title="Click to rename list"
|
||||||
)}
|
>
|
||||||
|
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{currentList?.name || t("tasks")}</span>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 400, flexShrink: 0 }}>
|
||||||
|
({incompleteTasks.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Header Actions */}
|
{/* Header Actions */}
|
||||||
<div className="main-header-actions" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<div className="main-header-actions" style={{ flexShrink: 0, marginLeft: "auto", display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
{/* View Switcher: Minimal & Matte Single Toggle (Only shown when Kanban is enabled in Labs) */}
|
{/* View Switcher: Minimal & Matte Single Toggle (Only shown when Kanban is enabled in Labs) */}
|
||||||
{!isTrashActive && !selectedTag && kanbanEnabled && (
|
{!isTrashActive && !selectedTag && kanbanEnabled && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
+28
-5
@@ -2,6 +2,7 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||||
import { Language, TranslationKey, TranslationDict } from "./types";
|
import { Language, TranslationKey, TranslationDict } from "./types";
|
||||||
import { translations, en, ko, ja } from "./locales";
|
import { translations, en, ko, ja } from "./locales";
|
||||||
|
import { getUserSettings, saveUserSettings } from "@/lib/mockData";
|
||||||
|
|
||||||
export type { Language, TranslationKey, TranslationDict };
|
export type { Language, TranslationKey, TranslationDict };
|
||||||
export { translations, en, ko, ja };
|
export { translations, en, ko, ja };
|
||||||
@@ -20,15 +21,35 @@ const I18nContext = createContext<I18nContextType>({
|
|||||||
|
|
||||||
const LANG_STORAGE_KEY = "checkflow_lang";
|
const LANG_STORAGE_KEY = "checkflow_lang";
|
||||||
|
|
||||||
|
function getInitialLanguage(): Language {
|
||||||
|
if (typeof window === "undefined") return "en";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(LANG_STORAGE_KEY) as Language | null;
|
||||||
|
if (saved && (saved === "en" || saved === "ko" || saved === "ja")) {
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
const userSettings = getUserSettings();
|
||||||
|
if (userSettings?.language && (userSettings.language === "en" || userSettings.language === "ko" || userSettings.language === "ja")) {
|
||||||
|
return userSettings.language;
|
||||||
|
}
|
||||||
|
const navLang = navigator.language?.toLowerCase() || "";
|
||||||
|
if (navLang.startsWith("ko")) return "ko";
|
||||||
|
if (navLang.startsWith("ja")) return "ja";
|
||||||
|
} catch {
|
||||||
|
// fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
|
||||||
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [lang, setLangState] = useState<Language>("en");
|
const [lang, setLangState] = useState<Language>(getInitialLanguage);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(LANG_STORAGE_KEY) as Language | null;
|
const initLang = getInitialLanguage();
|
||||||
if (saved && (saved === "en" || saved === "ko" || saved === "ja")) {
|
setLangState(initLang);
|
||||||
setLangState(saved);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -38,6 +59,8 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setLangState(l);
|
setLangState(l);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(LANG_STORAGE_KEY, l);
|
localStorage.setItem(LANG_STORAGE_KEY, l);
|
||||||
|
const cur = getUserSettings();
|
||||||
|
saveUserSettings({ ...cur, language: l });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|||||||
+129
-74
@@ -3,50 +3,50 @@ import { TranslationDict } from "../types";
|
|||||||
export const en: TranslationDict = {
|
export const en: TranslationDict = {
|
||||||
// Auth
|
// Auth
|
||||||
appName: "CheckFlow",
|
appName: "CheckFlow",
|
||||||
tagline: "Your Personal Todo & Markdown Workspace",
|
tagline: "TickTick-style independent task manager",
|
||||||
welcomeBack: "Welcome back",
|
welcomeBack: "Welcome back",
|
||||||
signInSubtitle: "Sign in to your account",
|
signInSubtitle: "Sign in to manage your tasks securely",
|
||||||
createAccount: "Create account",
|
createAccount: "Create account",
|
||||||
createAccountSubtitle: "Start organizing your tasks today",
|
createAccountSubtitle: "Get started with your self-hosted tasks",
|
||||||
displayName: "Display Name",
|
displayName: "Display Name",
|
||||||
email: "Email",
|
email: "Email",
|
||||||
password: "Password",
|
password: "Password",
|
||||||
min8Chars: "Min. 8 characters",
|
min8Chars: "At least 8 characters",
|
||||||
signIn: "Sign in",
|
signIn: "Sign In",
|
||||||
signingIn: "Signing in...",
|
signingIn: "Signing in...",
|
||||||
createBtn: "Create account",
|
createBtn: "Create Account",
|
||||||
creatingBtn: "Creating account...",
|
creatingBtn: "Creating account...",
|
||||||
alreadyHaveAccount: "Already have an account?",
|
alreadyHaveAccount: "Already have an account? Sign in",
|
||||||
dontHaveAccount: "Don't have an account?",
|
dontHaveAccount: "Don't have an account? Create one",
|
||||||
tryDemoMode: "Try Demo Mode (No DB Required)",
|
tryDemoMode: "Try Demo Mode (No DB)",
|
||||||
demoBadge: "Demo Mode",
|
demoBadge: "Demo Mode",
|
||||||
signOut: "Sign out",
|
signOut: "Sign Out",
|
||||||
|
|
||||||
// Sidebar
|
// Sidebar
|
||||||
lists: "Lists",
|
lists: "Lists",
|
||||||
newList: "New List",
|
newList: "New List",
|
||||||
listNamePlaceholder: "List name",
|
listNamePlaceholder: "List name...",
|
||||||
create: "Create",
|
create: "Create",
|
||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
save: "Save",
|
save: "Save",
|
||||||
deleteListConfirm: "Delete this list and all its tasks?",
|
deleteListConfirm: "Are you sure you want to delete this list?",
|
||||||
undoDelete: "Undo",
|
undoDelete: "Undo",
|
||||||
listDeleted: "List deleted",
|
listDeleted: "List deleted",
|
||||||
importTasks: "Import Tasks",
|
importTasks: "Import Tasks",
|
||||||
importModalTitle: "Import Tasks",
|
importModalTitle: "Import Tasks (CSV / ICS)",
|
||||||
targetList: "Target List",
|
targetList: "Target List",
|
||||||
fileSelectLabel: "File (CSV or ICS from TickTick)",
|
fileSelectLabel: "Select .csv or .ics file (TickTick export supported)",
|
||||||
tickTickExportHint: "TickTick: Settings → Export → Export as CSV or iCalendar",
|
tickTickExportHint: "TickTick export: Settings → Backup → Export CSV",
|
||||||
importBtn: "Import",
|
importBtn: "Import",
|
||||||
importing: "Importing...",
|
importing: "Importing...",
|
||||||
exportTasks: "Export Tasks",
|
exportTasks: "Export Tasks",
|
||||||
exportModalTitle: "Export Tasks",
|
exportModalTitle: "Export Tasks (CSV / ICS)",
|
||||||
exportFormat: "Format",
|
exportFormat: "Format",
|
||||||
exportScope: "Target List",
|
exportScope: "Scope",
|
||||||
allLists: "All Lists (Entire Tasks)",
|
allLists: "All Lists",
|
||||||
includeCompleted: "Include Completed Tasks",
|
includeCompleted: "Include completed tasks",
|
||||||
exportBtn: "Export & Download",
|
exportBtn: "Export",
|
||||||
exportSuccess: "Tasks exported successfully!",
|
exportSuccess: "Tasks exported successfully",
|
||||||
theme: "Theme",
|
theme: "Theme",
|
||||||
themeSystem: "System",
|
themeSystem: "System",
|
||||||
themeLight: "Light",
|
themeLight: "Light",
|
||||||
@@ -55,21 +55,70 @@ export const en: TranslationDict = {
|
|||||||
tags: "Tags",
|
tags: "Tags",
|
||||||
trash: "Trash",
|
trash: "Trash",
|
||||||
emptyTrash: "Empty Trash",
|
emptyTrash: "Empty Trash",
|
||||||
searchPlaceholder: "Search tasks, notes, tags...",
|
searchPlaceholder: "Search tasks...",
|
||||||
searchExpandedPlaceholder: "Search anything — tasks, notes, tags (Ctrl+K)...",
|
searchExpandedPlaceholder: "Type to search tasks, tags, notes...",
|
||||||
|
|
||||||
// Settings Modal
|
// Settings Modal - General & Tabs
|
||||||
settingsModalTitle: "Settings",
|
settingsModalTitle: "Settings",
|
||||||
profile: "Profile",
|
profile: "Profile",
|
||||||
preferences: "Preferences",
|
preferences: "Preferences",
|
||||||
syncIntegrations: "Integrations (CalDAV)",
|
syncIntegrations: "Integrations",
|
||||||
admin: "Admin",
|
admin: "Admin",
|
||||||
trashRetention: "Trash Retention Period",
|
trashRetention: "Trash Auto-Delete Retention Period",
|
||||||
trashRetentionHint: "Deleted tasks will be permanently removed after the specified period.",
|
trashRetentionHint: "Deleted tasks will be permanently removed after the specified period.",
|
||||||
days7: "7 Days",
|
days7: "7 Days",
|
||||||
days14: "14 Days",
|
days14: "14 Days",
|
||||||
days30: "30 Days (Recommended)",
|
days30: "30 Days (Recommended)",
|
||||||
neverDelete: "Never Auto-Delete",
|
neverDelete: "Never Auto-Delete (Manual empty only)",
|
||||||
|
settingsSaved: "Settings saved",
|
||||||
|
passwordChangePlaceholder: "New password (leave blank to keep current)",
|
||||||
|
yourNamePlaceholder: "Your Name",
|
||||||
|
|
||||||
|
// Settings Modal - Labs & Customization
|
||||||
|
labsSectionView: "View",
|
||||||
|
labsSectionLayout: "Layout",
|
||||||
|
labsSectionAppearance: "Appearance",
|
||||||
|
labsKanbanTitle: "Kanban Board",
|
||||||
|
labsKanbanDesc: "Switch between list view and 3-column Kanban board (To Do / In Progress / Done).",
|
||||||
|
labsDensityTitle: "Content Density",
|
||||||
|
labsDensityDesc: "Controls the vertical spacing of task items.",
|
||||||
|
densityCompact: "Compact",
|
||||||
|
densityDefault: "Default",
|
||||||
|
densityComfortable: "Airy",
|
||||||
|
sidebarWidthTitle: "Sidebar Width",
|
||||||
|
sidebarWidthDesc: "Drag the sidebar edge or adjust here.",
|
||||||
|
detailWidthTitle: "Detail Panel Width",
|
||||||
|
detailWidthDesc: "Drag the panel edge or adjust here.",
|
||||||
|
fontSizeTitle: "Font Size",
|
||||||
|
fontSizeDesc: "Base font size across the app.",
|
||||||
|
fontSizeSmall: "S",
|
||||||
|
fontSizeMedium: "M",
|
||||||
|
fontSizeLarge: "L",
|
||||||
|
accentColorTitle: "Accent Color",
|
||||||
|
accentColorDesc: "Choose the hue of your accent color. Saturation controls vibrancy.",
|
||||||
|
saturationLabel: "Saturation",
|
||||||
|
hueLabel: "Hue °",
|
||||||
|
roundnessTitle: "Border Roundness",
|
||||||
|
roundnessDesc: "Controls the roundness of cards, buttons, and UI elements.",
|
||||||
|
roundnessSharp: "Sharp",
|
||||||
|
roundnessDefault: "Default",
|
||||||
|
roundnessRound: "Round",
|
||||||
|
animationSpeedTitle: "Animation Speed",
|
||||||
|
animationSpeedDesc: "Controls the speed of transitions and hover effects.",
|
||||||
|
animationOff: "Off",
|
||||||
|
animationFast: "Fast",
|
||||||
|
animationDefault: "Default",
|
||||||
|
animationSlow: "Slow",
|
||||||
|
resetDefaultsTitle: "Reset to Defaults",
|
||||||
|
resetDefaultsDesc: "Restores all layout, appearance, and view settings to their factory defaults.",
|
||||||
|
|
||||||
|
// Settings Modal - Admin tab
|
||||||
|
adminCurrentAccount: "Current Account",
|
||||||
|
adminIsolationMode: "Isolation Mode",
|
||||||
|
adminMultiUserPrivacy: "Multi-User Privacy",
|
||||||
|
adminCalDavEndpoint: "CalDAV Endpoint",
|
||||||
|
adminActiveEnabled: "Active / Enabled",
|
||||||
|
adminOpenDashboard: "Open Admin Dashboard →",
|
||||||
|
|
||||||
// CalDAV & Sync
|
// CalDAV & Sync
|
||||||
syncTitle: "CalDAV & Mobile Sync (Android / Apple)",
|
syncTitle: "CalDAV & Mobile Sync (Android / Apple)",
|
||||||
@@ -80,69 +129,75 @@ export const en: TranslationDict = {
|
|||||||
syncDownloadIcs: "Download .ICS Feed",
|
syncDownloadIcs: "Download .ICS Feed",
|
||||||
syncTestConnection: "Test Endpoint",
|
syncTestConnection: "Test Endpoint",
|
||||||
syncTesting: "Testing...",
|
syncTesting: "Testing...",
|
||||||
syncTestSuccess: "✓ CalDAV endpoint responded successfully (200 OK)",
|
syncTestSuccess: "CalDAV endpoint responded successfully",
|
||||||
|
syncTestError: "Endpoint test failed",
|
||||||
syncTabAndroid: "Android (DAVx⁵)",
|
syncTabAndroid: "Android (DAVx⁵)",
|
||||||
syncTabApple: "Apple Reminders (iOS / macOS)",
|
syncTabApple: "Apple Reminders",
|
||||||
syncTabThunderbird: "Thunderbird (Desktop)",
|
syncTabThunderbird: "Thunderbird",
|
||||||
syncAndroidStep1: "Install DAVx⁵ from Google Play Store or F-Droid.",
|
syncAndroidStep1: "Install DAVx⁵ from Google Play Store or F-Droid.",
|
||||||
syncAndroidStep2: "Open DAVx⁵ → Add Account → Select 'Login with URL and user name'.",
|
syncAndroidStep2: "Choose 'Login with URL and user name' and enter the CalDAV URL above.",
|
||||||
syncAndroidStep3: "Paste the Base URL above, then enter your CheckFlow email & password.",
|
syncAndroidStep3: "Enter your CheckFlow email and password.",
|
||||||
syncAndroidStep4: "Enable 'VTODO (Tasks)' collection to sync with Android Tasks or OpenTasks.",
|
syncAndroidStep4: "Open your favorite To-Do app (e.g. Tasks.org, OpenTasks) to manage your synchronized tasks.",
|
||||||
syncAppleStep1: "Open Settings → Reminders → Accounts → Add Account.",
|
syncAppleStep1: "Open System Settings (macOS) or Settings (iOS/iPadOS) → Internet Accounts.",
|
||||||
syncAppleStep2: "Select 'Other' → Add CalDAV Account.",
|
syncAppleStep2: "Tap 'Add Account' → 'Add CalDAV Account'.",
|
||||||
syncAppleStep3: "Server: paste base URL without protocol, User/Pass: CheckFlow credentials.",
|
syncAppleStep3: "Set Account Type to 'Manual', Server Address to the CalDAV URL, with your email & password.",
|
||||||
syncAppleStep4: "Toggle 'Reminders' ON and tap Save.",
|
syncAppleStep4: "Tasks will automatically sync inside the Apple Reminders app.",
|
||||||
syncThunderbirdStep1: "Open Thunderbird → Switch to Calendar view → New Calendar.",
|
syncThunderbirdStep1: "Open Thunderbird → Calendar / Tasks tab.",
|
||||||
syncThunderbirdStep2: "Select 'On the Network' → Format: CalDAV.",
|
syncThunderbirdStep2: "Right-click Calendar list → 'New Calendar' → 'On the Network'.",
|
||||||
syncThunderbirdStep3: "Location: paste the Base URL, Username: CheckFlow email.",
|
syncThunderbirdStep3: "Select 'CalDAV', paste the CalDAV URL, and enter your credentials.",
|
||||||
syncThunderbirdStep4: "Enter your password when prompted to finalize sync.",
|
syncThunderbirdStep4: "Your CheckFlow task lists will appear under Tasks.",
|
||||||
|
syncAndroidTip: "In DAVx⁵ account settings, set Sync Interval to 15 minutes for battery efficiency and near real-time sync.",
|
||||||
|
syncAppleTip: "If using HTTPS behind a reverse proxy (Nginx/Caddy), ensure valid SSL certificates are trusted by Apple devices.",
|
||||||
|
syncThunderbirdTip: "Thunderbird Tasks view will display CheckFlow priority tags, due dates, and completion status.",
|
||||||
|
|
||||||
// Task List
|
// Task List
|
||||||
tasks: "Tasks",
|
tasks: "Tasks",
|
||||||
hideDone: "Hide done",
|
hideDone: "Hide Done",
|
||||||
showDone: "Show done",
|
showDone: "Show Done",
|
||||||
completedSection: "Completed",
|
completedSection: "Completed",
|
||||||
addTaskPlaceholder: "Add a task...",
|
addTaskPlaceholder: "Add a task, press Enter...",
|
||||||
add: "Add",
|
add: "Add",
|
||||||
noTasksYet: "No tasks yet. Add one below!",
|
noTasksYet: "No tasks in this list yet",
|
||||||
selectListToStart: "Select a list to get started",
|
selectListToStart: "Select a list to get started",
|
||||||
loading: "Loading...",
|
loading: "Loading...",
|
||||||
today: "Today",
|
today: "Today",
|
||||||
tomorrow: "Tomorrow",
|
tomorrow: "Tomorrow",
|
||||||
|
|
||||||
// Task Detail
|
// Task Detail
|
||||||
taskTitlePlaceholder: "Task title",
|
taskTitlePlaceholder: "Task title...",
|
||||||
priority: "Priority",
|
priority: "Priority",
|
||||||
priorityNone: "None",
|
priorityNone: "None",
|
||||||
priorityLow: "Low",
|
priorityLow: "Low",
|
||||||
priorityMedium: "Medium",
|
priorityMedium: "Medium",
|
||||||
priorityHigh: "High",
|
priorityHigh: "High",
|
||||||
dueDate: "Due Date",
|
dueDate: "Due date",
|
||||||
clear: "Clear",
|
clear: "Clear",
|
||||||
notes: "Notes",
|
notes: "Notes",
|
||||||
notesPlaceholder: "Add notes, details, or anything you need to remember…\n\nMarkdown supported: **bold**, *italic*, # heading, - list, - [ ] checkbox",
|
notesPlaceholder: "Add detailed notes, markdown supported...",
|
||||||
subtasks: "Sub-tasks",
|
subtasks: "Subtasks",
|
||||||
addSubtaskPlaceholder: "Add sub-task…",
|
addSubtaskPlaceholder: "Add a subtask, press Enter...",
|
||||||
deleteTaskConfirm: "Delete this task?",
|
deleteTaskConfirm: "Are you sure you want to delete this task?",
|
||||||
autoSaved: "Auto-saved",
|
autoSaved: "Saved",
|
||||||
saving: "Saving…",
|
saving: "Saving...",
|
||||||
created: "Created",
|
created: "Created",
|
||||||
edited: "Edited",
|
edited: "Edited",
|
||||||
|
previewTab: "Preview",
|
||||||
|
editTab: "Edit",
|
||||||
|
|
||||||
// Toolbars
|
// Toolbars
|
||||||
bold: "Bold",
|
bold: "Bold",
|
||||||
italic: "Italic",
|
italic: "Italic",
|
||||||
heading: "Heading",
|
heading: "Heading",
|
||||||
bulletList: "Bullet list",
|
bulletList: "Bullet List",
|
||||||
numberedList: "Numbered list",
|
numberedList: "Numbered List",
|
||||||
checkbox: "Checkbox",
|
checkbox: "Checkbox",
|
||||||
code: "Code",
|
code: "Code",
|
||||||
|
|
||||||
// TickTick Mode Switcher & Quick Add
|
// TickTick Mode Switcher & Quick Add
|
||||||
textMode: "Text Note Mode",
|
textMode: "Text Mode",
|
||||||
subtaskMode: "Checklist Mode",
|
subtaskMode: "Subtask Mode",
|
||||||
switchToTextMode: "Switch to Markdown Note",
|
switchToTextMode: "Switch to Text Mode",
|
||||||
switchToSubtaskMode: "Switch to Checklist Sub-tasks",
|
switchToSubtaskMode: "Switch to Subtask Mode",
|
||||||
moveToList: "Move to List",
|
moveToList: "Move to List",
|
||||||
quickAdd: "Quick Add",
|
quickAdd: "Quick Add",
|
||||||
quickToday: "Today",
|
quickToday: "Today",
|
||||||
@@ -151,31 +206,31 @@ export const en: TranslationDict = {
|
|||||||
selectDate: "Date",
|
selectDate: "Date",
|
||||||
selectPriority: "Priority",
|
selectPriority: "Priority",
|
||||||
selectTag: "Tag",
|
selectTag: "Tag",
|
||||||
copied: "Copied!",
|
copied: "Copied",
|
||||||
|
|
||||||
// Context Menu
|
// Context Menu
|
||||||
editTask: "Edit",
|
editTask: "Edit Task",
|
||||||
duplicateTask: "Duplicate",
|
duplicateTask: "Duplicate",
|
||||||
moveTask: "Move to…",
|
moveTask: "Move To...",
|
||||||
deleteTask: "Delete",
|
deleteTask: "Delete Task",
|
||||||
renameList: "Rename",
|
renameList: "Rename",
|
||||||
deleteList: "Delete list",
|
deleteList: "Delete List",
|
||||||
taskUndoHint: "Deleted. Tap to undo.",
|
taskUndoHint: "Task deleted",
|
||||||
listUndoHint: "List deleted. Tap to undo.",
|
listUndoHint: "List deleted",
|
||||||
|
|
||||||
// Labs, Kanban & Customization
|
// Labs, Kanban & Customization
|
||||||
labs: "Labs",
|
labs: "Labs",
|
||||||
labsDesc: "Enable or disable experimental features individually.",
|
labsDesc: "Experimental features and UI personalization.",
|
||||||
kanbanView: "Kanban Board",
|
kanbanView: "Kanban",
|
||||||
listView: "List View",
|
listView: "List",
|
||||||
enableKanban: "Kanban Board View",
|
enableKanban: "Kanban Board View",
|
||||||
enableKanbanDesc: "Switch between list and 3-column board (To Do / Doing / Done).",
|
enableKanbanDesc: "Enable 3-column Kanban board view (To Do, In Progress, Done) switchable in task lists.",
|
||||||
swapBlocks: "Swap Position",
|
swapBlocks: "Swap Blocks",
|
||||||
resetDefaults: "Reset to Defaults",
|
resetDefaults: "Reset",
|
||||||
resetConfirm: "Reset all UI customizations to factory defaults?",
|
resetConfirm: "Reset all customizations to defaults?",
|
||||||
todoCol: "To Do",
|
todoCol: "To Do",
|
||||||
inProgressCol: "In Progress",
|
inProgressCol: "In Progress",
|
||||||
doneCol: "Done",
|
doneCol: "Done",
|
||||||
accessRestricted: "Access Restricted",
|
accessRestricted: "Access Restricted",
|
||||||
adminRequired: "This console requires ADMIN role.",
|
adminRequired: "This console requires ADMIN privileges.",
|
||||||
};
|
};
|
||||||
|
|||||||
+162
-107
@@ -3,179 +3,234 @@ import { TranslationDict } from "../types";
|
|||||||
export const ja: TranslationDict = {
|
export const ja: TranslationDict = {
|
||||||
// Auth
|
// Auth
|
||||||
appName: "CheckFlow",
|
appName: "CheckFlow",
|
||||||
tagline: "シンプルで洗練されたパーソナルタスク管理",
|
tagline: "TickTickスタイルのセルフホスト型タスク管理",
|
||||||
welcomeBack: "お帰りなさい 👋",
|
welcomeBack: "おかえりなさい",
|
||||||
signInSubtitle: "メールアドレスでサインインしてください",
|
signInSubtitle: "安全にログインしてタスクを管理します",
|
||||||
createAccount: "アカウント新規登録",
|
createAccount: "アカウント作成",
|
||||||
createAccountSubtitle: "CheckFlowでタスクの整理を始めましょう",
|
createAccountSubtitle: "セルフホストToDoプラットフォームを始めましょう",
|
||||||
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: "デモモードを試す(DB不要)",
|
||||||
demoBadge: "体験モード",
|
demoBadge: "デモモード",
|
||||||
signOut: "サインアウト",
|
signOut: "ログアウト",
|
||||||
|
|
||||||
// Sidebar
|
// Sidebar
|
||||||
lists: "リスト",
|
lists: "リスト",
|
||||||
newList: "新規リスト",
|
newList: "新しいリスト",
|
||||||
listNamePlaceholder: "リスト名を入力",
|
listNamePlaceholder: "リスト名を入力...",
|
||||||
create: "作成",
|
create: "作成",
|
||||||
cancel: "キャンセル",
|
cancel: "キャンセル",
|
||||||
save: "保存",
|
save: "保存",
|
||||||
deleteListConfirm: "このリストを削除しますか?含まれるすべてのタスクも削除されます。",
|
deleteListConfirm: "このリストと含まれるすべてのタスクを削除しますか?",
|
||||||
undoDelete: "元に戻す",
|
undoDelete: "元に戻す",
|
||||||
listDeleted: "リストを削除しました",
|
listDeleted: "リストが削除されました",
|
||||||
importTasks: "インポート",
|
importTasks: "タスクをインポート",
|
||||||
importModalTitle: "タスクのインポート",
|
importModalTitle: "タスクのインポート (CSV / ICS)",
|
||||||
targetList: "インポート先リスト",
|
targetList: "インポート先リスト",
|
||||||
fileSelectLabel: "ファイル選択 (TickTick CSV / ICS)",
|
fileSelectLabel: ".csv または .ics ファイルを選択 (TickTickエクスポート対応)",
|
||||||
tickTickExportHint: "TickTick: 設定 → データのエクスポート → CSVまたはiCalendar",
|
tickTickExportHint: "TickTickエクスポート手順: 設定 → バックアップ → CSVエクスポート",
|
||||||
importBtn: "インポート",
|
importBtn: "インポート",
|
||||||
importing: "インポート中...",
|
importing: "インポート中...",
|
||||||
exportTasks: "エクスポート",
|
exportTasks: "タスクをエクスポート",
|
||||||
exportModalTitle: "タスクのエクスポート",
|
exportModalTitle: "タスクのエクスポート (CSV / ICS)",
|
||||||
exportFormat: "エクスポート形式",
|
exportFormat: "ファイル形式",
|
||||||
exportScope: "対象リスト",
|
exportScope: "対象範囲",
|
||||||
allLists: "すべてのリスト (全体)",
|
allLists: "すべてのリスト",
|
||||||
includeCompleted: "完了したタスクを含める",
|
includeCompleted: "完了したタスクを含める",
|
||||||
exportBtn: "エクスポートしてダウンロード",
|
exportBtn: "エクスポート",
|
||||||
exportSuccess: "タスクを正常にエクスポートしました!",
|
exportSuccess: "タスクのエクスポートが完了しました",
|
||||||
theme: "テーマ",
|
theme: "テーマ",
|
||||||
themeSystem: "システム設定に同期",
|
themeSystem: "システム",
|
||||||
themeLight: "ライトモード",
|
themeLight: "ライト",
|
||||||
themeDark: "ダークモード",
|
themeDark: "ダーク",
|
||||||
language: "言語 (Language)",
|
language: "言語",
|
||||||
tags: "タグ",
|
tags: "タグ",
|
||||||
trash: "ゴミ箱",
|
trash: "ゴミ箱",
|
||||||
emptyTrash: "ゴミ箱を空にする",
|
emptyTrash: "ゴミ箱を空にする",
|
||||||
searchPlaceholder: "タスク、メモ、タグを検索...",
|
searchPlaceholder: "タスクを検索...",
|
||||||
searchExpandedPlaceholder: "タスク、メモ、タグをすべて検索 (Ctrl+K)...",
|
searchExpandedPlaceholder: "タスク名、タグ、メモを検索...",
|
||||||
|
|
||||||
// Settings Modal
|
// Settings Modal - General & Tabs
|
||||||
settingsModalTitle: "環境設定",
|
settingsModalTitle: "設定",
|
||||||
profile: "プロフィール",
|
profile: "プロフィール",
|
||||||
preferences: "カスタマイズ設定",
|
preferences: "環境設定",
|
||||||
syncIntegrations: "外部連携 (CalDAV)",
|
syncIntegrations: "外部連携",
|
||||||
admin: "管理者設定",
|
admin: "管理者",
|
||||||
trashRetention: "ゴミ箱の自動保持期間",
|
trashRetention: "ゴミ箱自動削除の保持期間",
|
||||||
trashRetentionHint: "指定した期間が経過した削除済みタスクは自動的に完全に削除されます。",
|
trashRetentionHint: "削除されたタスクは指定期間を過ぎると自動的に完全に削除されます。",
|
||||||
days7: "7日後に完全削除",
|
days7: "7日間",
|
||||||
days14: "14日後に完全削除",
|
days14: "14日間",
|
||||||
days30: "30日後に完全削除 (推奨)",
|
days30: "30日間 (推奨)",
|
||||||
neverDelete: "自動削除しない (手動削除のみ)",
|
neverDelete: "自動削除しない (手動で空にするのみ)",
|
||||||
|
settingsSaved: "設定が正常に保存されました",
|
||||||
|
passwordChangePlaceholder: "新しいパスワード (変更しない場合は空白)",
|
||||||
|
yourNamePlaceholder: "お名前",
|
||||||
|
|
||||||
|
// Settings Modal - Labs & Customization
|
||||||
|
labsSectionView: "ビュー設定",
|
||||||
|
labsSectionLayout: "レイアウト",
|
||||||
|
labsSectionAppearance: "外観とデザイン",
|
||||||
|
labsKanbanTitle: "カンバンボード",
|
||||||
|
labsKanbanDesc: "リストビューと3カラムのカンバンボード (To Do / 進行中 / 完了) を切り替えます。",
|
||||||
|
labsDensityTitle: "表示密度",
|
||||||
|
labsDensityDesc: "タスク項目の行間および縦の余白を調整します。",
|
||||||
|
densityCompact: "コンパクト",
|
||||||
|
densityDefault: "標準",
|
||||||
|
densityComfortable: "ゆったり",
|
||||||
|
sidebarWidthTitle: "サイドバーの幅",
|
||||||
|
sidebarWidthDesc: "サイドバーの境界線をドラッグするかスライダーで調整します。",
|
||||||
|
detailWidthTitle: "詳細パネルの幅",
|
||||||
|
detailWidthDesc: "右側詳細タブの境界線をドラッグするかスライダーで調整します。",
|
||||||
|
fontSizeTitle: "フォントサイズ",
|
||||||
|
fontSizeDesc: "アプリ全体の基準フォントサイズを設定します。",
|
||||||
|
fontSizeSmall: "小",
|
||||||
|
fontSizeMedium: "中",
|
||||||
|
fontSizeLarge: "大",
|
||||||
|
accentColorTitle: "アクセントカラー",
|
||||||
|
accentColorDesc: "アクセントカラーの色相(Hue)と彩度(Saturation)をカスタマイズします。",
|
||||||
|
saturationLabel: "彩度",
|
||||||
|
hueLabel: "色相 °",
|
||||||
|
roundnessTitle: "角の丸み",
|
||||||
|
roundnessDesc: "カードやボタン、UI要素の角丸スタイルを設定します。",
|
||||||
|
roundnessSharp: "シャープ",
|
||||||
|
roundnessDefault: "標準",
|
||||||
|
roundnessRound: "ラウンド",
|
||||||
|
animationSpeedTitle: "アニメーション速度",
|
||||||
|
animationSpeedDesc: "画面切り替えやホバー効果の速度を調整します。",
|
||||||
|
animationOff: "オフ",
|
||||||
|
animationFast: "高速",
|
||||||
|
animationDefault: "標準",
|
||||||
|
animationSlow: "ゆっくり",
|
||||||
|
resetDefaultsTitle: "設定を初期状態に戻す",
|
||||||
|
resetDefaultsDesc: "すべてのレイアウト、デザイン、ビューのカスタマイズ設定を初期値にリセットします。",
|
||||||
|
|
||||||
|
// Settings Modal - Admin tab
|
||||||
|
adminCurrentAccount: "現在のアカウント",
|
||||||
|
adminIsolationMode: "分離モード",
|
||||||
|
adminMultiUserPrivacy: "マルチユーザー・プライバシー保護",
|
||||||
|
adminCalDavEndpoint: "CalDAV エンドポイント",
|
||||||
|
adminActiveEnabled: "正常に稼働中",
|
||||||
|
adminOpenDashboard: "管理者ダッシュボードを開く →",
|
||||||
|
|
||||||
// CalDAV & Sync
|
// CalDAV & Sync
|
||||||
syncTitle: "CalDAV モバイルおよび外部アプリ同期",
|
syncTitle: "CalDAV モバイル同期 (Android / Apple)",
|
||||||
syncDesc: "Android (DAVx⁵, Tasks.org)、Apple リマインダー、Thunderbird など標準 CalDAV をサポートする各種端末やアプリと双方向同期できます。",
|
syncDesc: "CheckFlowは標準CalDAVプロトコルにより、Android、Appleリマインダー、Thunderbirdとの双方向同期に対応しています。",
|
||||||
syncBaseUrl: "CalDAV サーバー基本 URL",
|
syncBaseUrl: "CalDAV サーバーアドレス (Base URL)",
|
||||||
syncCopyUrl: "URLをコピー",
|
syncCopyUrl: "URLをコピー",
|
||||||
syncCopied: "コピー完了!",
|
syncCopied: "コピーしました!",
|
||||||
syncDownloadIcs: ".ICS フィードをダウンロード",
|
syncDownloadIcs: ".ICS フィードをダウンロード",
|
||||||
syncTestConnection: "接続テスト",
|
syncTestConnection: "接続テスト",
|
||||||
syncTesting: "テスト中...",
|
syncTesting: "確認中...",
|
||||||
syncTestSuccess: "✓ CalDAV エンドポイントは正常に応答しています (200 OK)",
|
syncTestSuccess: "CalDAVサーバーが正常に応答しました",
|
||||||
|
syncTestError: "エンドポイントテスト失敗",
|
||||||
syncTabAndroid: "Android (DAVx⁵)",
|
syncTabAndroid: "Android (DAVx⁵)",
|
||||||
syncTabApple: "Apple リマインダー (iOS / macOS)",
|
syncTabApple: "Apple リマインダー",
|
||||||
syncTabThunderbird: "Thunderbird (Desktop)",
|
syncTabThunderbird: "Thunderbird",
|
||||||
syncAndroidStep1: "Google Play または F-Droid から「DAVx⁵」アプリをインストールします。",
|
syncAndroidStep1: "Google Play または F-Droid から DAVx⁵ をインストールします。",
|
||||||
syncAndroidStep2: "DAVx⁵ を起動 → アカウント追加(+) →「URL とユーザー名でログイン」を選択します。",
|
syncAndroidStep2: "「URLとユーザー名でログイン」を選択し、上記のCalDAV URLを入力します。",
|
||||||
syncAndroidStep3: "上記の基本 URL を入力し、CheckFlow のログインメールアドレスとパスワードを入力します。",
|
syncAndroidStep3: "CheckFlow のメールアドレスとパスワードを入力します。",
|
||||||
syncAndroidStep4: "「VTODO (タスク)」コレクションを有効にすると、Tasks.org や OpenTasks と双方向同期されます。",
|
syncAndroidStep4: "Tasks.org や OpenTasks などのアプリを開いてタスクを管理します。",
|
||||||
syncAppleStep1: "端末の設定 → リマインダー → アカウント →「アカウントを追加」を開きます。",
|
syncAppleStep1: "システム設定 (macOS) または 設定 (iOS/iPadOS) → インターネットアカウント を開きます。",
|
||||||
syncAppleStep2: "「その他」を選択 →「CalDAV アカウントを追加」を選択します。",
|
syncAppleStep2: "「アカウントを追加」→「CalDAVアカウントを追加」を選択します。",
|
||||||
syncAppleStep3: "サーバーに上記の URL、ユーザー名とパスワードに CheckFlow のアカウント情報を入力します。",
|
syncAppleStep3: "アカウントの種類を「手動」にし、上記のCalDAV URLとログイン情報を入力します。",
|
||||||
syncAppleStep4: "「リマインダー」をオンにして保存します。",
|
syncAppleStep4: "Apple リマインダー (Reminders) アプリにタスクが自動同期されます。",
|
||||||
syncThunderbirdStep1: "Thunderbird を起動 → カレンダータブ →「新しいカレンダー」を作成します。",
|
syncThunderbirdStep1: "Mozilla Thunderbird を開き、カレンダー/タスク タブを開きます。",
|
||||||
syncThunderbirdStep2: "「ネットワーク上」を選択 → 形式:「CalDAV」を選択します。",
|
syncThunderbirdStep2: "カレンダー一覧を右クリック →「新しいカレンダー」→「ネットワーク上」を選択します。",
|
||||||
syncThunderbirdStep3: "場所に上記の基本 URL、ユーザー名に CheckFlow のメールアドレスを入力します。",
|
syncThunderbirdStep3: "「CalDAV」を選択し、上記のCalDAV URLとログイン情報を入力します。",
|
||||||
syncThunderbirdStep4: "認証ダイアログでパスワードを入力して設定を完了します。",
|
syncThunderbirdStep4: "CheckFlow のタスク一覧が Thunderbird Tasks に表示されます。",
|
||||||
|
syncAndroidTip: "DAVx⁵ の設定で同期周期を 15 分に設定すると、バッテリーを節約しながら準リアルタイム同期が可能です。",
|
||||||
|
syncAppleTip: "Nginx/Caddy などのリバースプロキシで HTTPS を使用する場合は、有効な SSL 証明書が設定されているか確認してください。",
|
||||||
|
syncThunderbirdTip: "Thunderbird のタスクビューで CheckFlow の優先度、期日、完了状態がそのまま表示されます。",
|
||||||
|
|
||||||
// Task List
|
// Task List
|
||||||
tasks: "タスク",
|
tasks: "タスク",
|
||||||
hideDone: "完了済みを非表示",
|
hideDone: "完了項目を非表示",
|
||||||
showDone: "完了済みを表示",
|
showDone: "完了項目を表示",
|
||||||
completedSection: "完了したタスク",
|
completedSection: "完了済み",
|
||||||
addTaskPlaceholder: "新しいタスクを入力...",
|
addTaskPlaceholder: "新しいタスクを入力してEnter...",
|
||||||
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: "詳細なメモを入力 (Markdown対応)...",
|
||||||
subtasks: "サブタスク",
|
subtasks: "サブタスク",
|
||||||
addSubtaskPlaceholder: "サブタスクを追加…",
|
addSubtaskPlaceholder: "サブタスクを入力してEnter...",
|
||||||
deleteTaskConfirm: "このタスクを削除しますか?",
|
deleteTaskConfirm: "このタスクを削除してもよろしいですか?",
|
||||||
autoSaved: "自動保存済み",
|
autoSaved: "保存済み",
|
||||||
saving: "保存中…",
|
saving: "保存中...",
|
||||||
created: "作成日時",
|
created: "作成日時",
|
||||||
edited: "更新日時",
|
edited: "更新日時",
|
||||||
|
previewTab: "プレビュー",
|
||||||
|
editTab: "編集",
|
||||||
|
|
||||||
// Toolbars
|
// Toolbars
|
||||||
bold: "太字",
|
bold: "太字",
|
||||||
italic: "斜体",
|
italic: "斜体",
|
||||||
heading: "見出し",
|
heading: "見出し",
|
||||||
bulletList: "箇条書きリスト",
|
bulletList: "箇条書き",
|
||||||
numberedList: "番号付きリスト",
|
numberedList: "番号付きリスト",
|
||||||
checkbox: "チェックボックス",
|
checkbox: "チェックボックス",
|
||||||
code: "コードブロック",
|
code: "コード",
|
||||||
|
|
||||||
// TickTick Mode Switcher & Quick Add
|
// TickTick Mode Switcher & Quick Add
|
||||||
textMode: "Markdownメモモード",
|
textMode: "テキストモード",
|
||||||
subtaskMode: "チェックリストモード",
|
subtaskMode: "サブタスクモード",
|
||||||
switchToTextMode: "Markdownメモモードに切り替え",
|
switchToTextMode: "テキストモードに切り替え",
|
||||||
switchToSubtaskMode: "チェックリストモードに切り替え",
|
switchToSubtaskMode: "サブタスクモードに切り替え",
|
||||||
moveToList: "別のリストに移動",
|
moveToList: "リストへ移動",
|
||||||
quickAdd: "クイック追加",
|
quickAdd: "クイック追加",
|
||||||
quickToday: "今日",
|
quickToday: "今日",
|
||||||
quickTomorrow: "明日",
|
quickTomorrow: "明日",
|
||||||
quickNextWeek: "来週",
|
quickNextWeek: "来週",
|
||||||
selectDate: "期日設定",
|
selectDate: "日付を選択",
|
||||||
selectPriority: "優先度設定",
|
selectPriority: "優先度",
|
||||||
selectTag: "タグ設定",
|
selectTag: "タグを選択",
|
||||||
copied: "コピー完了!",
|
copied: "コピー完了",
|
||||||
|
|
||||||
// Context Menu
|
// Context Menu
|
||||||
editTask: "編集",
|
editTask: "タスクを編集",
|
||||||
duplicateTask: "複製",
|
duplicateTask: "複製",
|
||||||
moveTask: "移動…",
|
moveTask: "移動...",
|
||||||
deleteTask: "削除",
|
deleteTask: "タスクを削除",
|
||||||
renameList: "名前を変更",
|
renameList: "リスト名を変更",
|
||||||
deleteList: "リストを削除",
|
deleteList: "リストを削除",
|
||||||
taskUndoHint: "削除しました。クリックして元に戻せます。",
|
taskUndoHint: "タスクが削除されました",
|
||||||
listUndoHint: "リストを削除しました。クリックして元に戻せます。",
|
listUndoHint: "リストが削除されました",
|
||||||
|
|
||||||
// Labs, Kanban & Customization
|
// Labs, Kanban & Customization
|
||||||
labs: "Labs (実験室)",
|
labs: "実験室",
|
||||||
labsDesc: "開発中の実験的機能を個別に有効化・無効化できます。",
|
labsDesc: "実験的な機能とUIのカスタマイズ設定です。",
|
||||||
kanbanView: "カンバンボード",
|
kanbanView: "カンバン",
|
||||||
listView: "リストビュー",
|
listView: "リスト",
|
||||||
enableKanban: "カンバンボードビュー",
|
enableKanban: "カンバンボードビュー",
|
||||||
enableKanbanDesc: "タスクを3列ボード(未着手 / 進行中 / 完了)形式で管理します。",
|
enableKanbanDesc: "タスクリストでリストビューと3カラムカンバンビューを切り替えられるようにします。",
|
||||||
swapBlocks: "ブロック配置の入れ替え",
|
swapBlocks: "ブロック順序の入れ替え",
|
||||||
resetDefaults: "初期設定にリセット",
|
resetDefaults: "初期状態に戻す",
|
||||||
resetConfirm: "すべてのUIカスタマイズを初期デフォルト設定に戻しますか?",
|
resetConfirm: "すべてのカスタマイズ設定を初期状態に戻しますか?",
|
||||||
todoCol: "未着手",
|
todoCol: "To Do",
|
||||||
inProgressCol: "進行中",
|
inProgressCol: "進行中",
|
||||||
doneCol: "完了",
|
doneCol: "完了",
|
||||||
accessRestricted: "アクセス権限制限",
|
accessRestricted: "アクセス制限",
|
||||||
adminRequired: "この管理コンソールにはADMIN権限が必要です。",
|
adminRequired: "このコンソールには ADMIN 権限が必要です。",
|
||||||
};
|
};
|
||||||
|
|||||||
+161
-106
@@ -3,179 +3,234 @@ import { TranslationDict } from "../types";
|
|||||||
export const ko: TranslationDict = {
|
export const ko: TranslationDict = {
|
||||||
// Auth
|
// Auth
|
||||||
appName: "CheckFlow",
|
appName: "CheckFlow",
|
||||||
tagline: "나만의 스마트 할 일 & 마크다운 워크스페이스",
|
tagline: "TickTick 스타일의 독립형 To-Do & 노트 웹앱",
|
||||||
welcomeBack: "다시 오신 것을 환영합니다 👋",
|
welcomeBack: "다시 오신 것을 환영합니다",
|
||||||
signInSubtitle: "이메일 계정으로 로그인하세요",
|
signInSubtitle: "안전하게 로그인하여 할 일을 관리하세요",
|
||||||
createAccount: "새 계정 생성",
|
createAccount: "계정 만들기",
|
||||||
createAccountSubtitle: "CheckFlow에서 효율적인 할 일 관리를 시작하세요",
|
createAccountSubtitle: "셀프호스팅 To-Do 플랫폼을 시작하세요",
|
||||||
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: "데모 모드로 체험하기 (DB 미연결)",
|
||||||
demoBadge: "체험 모드",
|
demoBadge: "데모 모드",
|
||||||
signOut: "로그아웃",
|
signOut: "로그아웃",
|
||||||
|
|
||||||
// Sidebar
|
// Sidebar
|
||||||
lists: "목록",
|
lists: "목록",
|
||||||
newList: "새 목록",
|
newList: "새 목록",
|
||||||
listNamePlaceholder: "목록 이름 입력",
|
listNamePlaceholder: "목록 이름 입력...",
|
||||||
create: "생성",
|
create: "생성",
|
||||||
cancel: "취소",
|
cancel: "취소",
|
||||||
save: "저장",
|
save: "저장",
|
||||||
deleteListConfirm: "이 목록을 삭제하시겠습니까? 포함된 모든 할 일도 함께 삭제됩니다.",
|
deleteListConfirm: "이 목록과 포함된 모든 할 일을 삭제하시겠습니까?",
|
||||||
undoDelete: "실행 취소",
|
undoDelete: "실행 취소",
|
||||||
listDeleted: "목록이 삭제되었습니다",
|
listDeleted: "목록이 삭제되었습니다",
|
||||||
importTasks: "가져오기",
|
importTasks: "할 일 가져오기",
|
||||||
importModalTitle: "할 일 가져오기",
|
importModalTitle: "할 일 가져오기 (CSV / ICS)",
|
||||||
targetList: "가져올 대상 목록",
|
targetList: "가져올 대상 목록",
|
||||||
fileSelectLabel: "파일 선택 (TickTick CSV 또는 ICS)",
|
fileSelectLabel: ".csv 또는 .ics 파일 선택 (TickTick 백업 지원)",
|
||||||
tickTickExportHint: "TickTick: 설정 → 데이터 내보내기 → CSV 또는 iCalendar 선택",
|
tickTickExportHint: "TickTick 내보내기 방법: 설정 → 백업 → CSV 내보내기",
|
||||||
importBtn: "가져오기",
|
importBtn: "가져오기",
|
||||||
importing: "가져오는 중...",
|
importing: "가져오는 중...",
|
||||||
exportTasks: "내보내기",
|
exportTasks: "할 일 내보내기",
|
||||||
exportModalTitle: "할 일 내보내기",
|
exportModalTitle: "할 일 내보내기 (CSV / ICS)",
|
||||||
exportFormat: "내보내기 형식",
|
exportFormat: "파일 포맷",
|
||||||
exportScope: "내보낼 대상 목록",
|
exportScope: "내보낼 대상",
|
||||||
allLists: "모든 목록 (전체 할 일)",
|
allLists: "전체 목록",
|
||||||
includeCompleted: "완료된 항목 포함",
|
includeCompleted: "완료된 작업 포함",
|
||||||
exportBtn: "내보내기 및 다운로드",
|
exportBtn: "내보내기",
|
||||||
exportSuccess: "할 일을 성공적으로 내보냈습니다!",
|
exportSuccess: "할 일이 성공적으로 내보내졌습니다",
|
||||||
theme: "테마",
|
theme: "테마",
|
||||||
themeSystem: "시스템 설정 동기화",
|
themeSystem: "시스템",
|
||||||
themeLight: "라이트 모드",
|
themeLight: "라이트",
|
||||||
themeDark: "다크 모드",
|
themeDark: "다크",
|
||||||
language: "언어 (Language)",
|
language: "언어",
|
||||||
tags: "태그",
|
tags: "태그",
|
||||||
trash: "휴지통",
|
trash: "휴지통",
|
||||||
emptyTrash: "휴지통 비우기",
|
emptyTrash: "휴지통 비우기",
|
||||||
searchPlaceholder: "할 일, 메모, 태그 검색...",
|
searchPlaceholder: "할 일 검색...",
|
||||||
searchExpandedPlaceholder: "할 일, 메모, 태그 전체 검색 (Ctrl+K)...",
|
searchExpandedPlaceholder: "작업 제목, 태그, 메모 검색...",
|
||||||
|
|
||||||
// Settings Modal
|
// Settings Modal - General & Tabs
|
||||||
settingsModalTitle: "환경설정",
|
settingsModalTitle: "설정",
|
||||||
profile: "프로필",
|
profile: "프로필",
|
||||||
preferences: "개인화 설정",
|
preferences: "환경설정",
|
||||||
syncIntegrations: "외부 연동 (CalDAV)",
|
syncIntegrations: "외부 연동",
|
||||||
admin: "관리자 설정",
|
admin: "관리자",
|
||||||
trashRetention: "휴지통 자동 보관 기간",
|
trashRetention: "휴지통 자동 삭제 보관 기간",
|
||||||
trashRetentionHint: "지정한 보관 기간이 경과한 삭제 항목은 자동으로 영구 삭제됩니다.",
|
trashRetentionHint: "삭제된 작업은 지정된 기간이 지나면 영구적으로 자동 삭제됩니다.",
|
||||||
days7: "7일 후 영구 삭제",
|
days7: "7일",
|
||||||
days14: "14일 후 영구 삭제",
|
days14: "14일",
|
||||||
days30: "30일 후 영구 삭제 (권장)",
|
days30: "30일 (권장)",
|
||||||
neverDelete: "자동 삭제 안 함 (수동 삭제)",
|
neverDelete: "자동 삭제 안 함 (수동 비우기만)",
|
||||||
|
settingsSaved: "설정이 성공적으로 저장되었습니다",
|
||||||
|
passwordChangePlaceholder: "새 비밀번호 (현재 비밀번호 유지 시 비워두기)",
|
||||||
|
yourNamePlaceholder: "사용자 이름",
|
||||||
|
|
||||||
|
// Settings Modal - Labs & Customization
|
||||||
|
labsSectionView: "뷰 설정",
|
||||||
|
labsSectionLayout: "레이아웃",
|
||||||
|
labsSectionAppearance: "외형 및 디자인",
|
||||||
|
labsKanbanTitle: "칸반 보드",
|
||||||
|
labsKanbanDesc: "목록 뷰와 3개 컬럼의 칸반 보드(To Do / 진행 중 / 완료) 간 전환을 지원합니다.",
|
||||||
|
labsDensityTitle: "화면 표시 밀도",
|
||||||
|
labsDensityDesc: "태스크 항목의 세로 여백 및 행 높이를 조절합니다.",
|
||||||
|
densityCompact: "컴팩트",
|
||||||
|
densityDefault: "기본값",
|
||||||
|
densityComfortable: "여유롭게",
|
||||||
|
sidebarWidthTitle: "사이드바 기본 너비",
|
||||||
|
sidebarWidthDesc: "사이드바 경계선을 드래그하거나 슬라이더로 조절할 수 있습니다.",
|
||||||
|
detailWidthTitle: "상세 패널 기본 너비",
|
||||||
|
detailWidthDesc: "우측 상세 탭 경계선을 드래그하거나 슬라이더로 조절할 수 있습니다.",
|
||||||
|
fontSizeTitle: "기본 폰트 크기",
|
||||||
|
fontSizeDesc: "앱 전체에 적용되는 텍스트 기준 크기를 설정합니다.",
|
||||||
|
fontSizeSmall: "작게",
|
||||||
|
fontSizeMedium: "보통",
|
||||||
|
fontSizeLarge: "크게",
|
||||||
|
accentColorTitle: "포인트(액센트) 색상",
|
||||||
|
accentColorDesc: "강조 색상의 색조(Hue)와 채도(Saturation)를 사용자에 맞게 조절합니다.",
|
||||||
|
saturationLabel: "채도",
|
||||||
|
hueLabel: "색조 °",
|
||||||
|
roundnessTitle: "모서리 둥글기",
|
||||||
|
roundnessDesc: "카드, 버튼 및 UI 요소의 모서리 라운드 스타일을 설정합니다.",
|
||||||
|
roundnessSharp: "각지게",
|
||||||
|
roundnessDefault: "기본값",
|
||||||
|
roundnessRound: "둥글게",
|
||||||
|
animationSpeedTitle: "애니메이션 속도",
|
||||||
|
animationSpeedDesc: "화면 전환 및 호버 인터랙션 효과의 속도를 조절합니다.",
|
||||||
|
animationOff: "끄기",
|
||||||
|
animationFast: "빠르게",
|
||||||
|
animationDefault: "기본값",
|
||||||
|
animationSlow: "부드럽게",
|
||||||
|
resetDefaultsTitle: "설정 기본값 복원",
|
||||||
|
resetDefaultsDesc: "모든 레이아웃, 디자인 및 뷰 커스터마이징 설정을 초기 순정 상태로 되돌립니다.",
|
||||||
|
|
||||||
|
// Settings Modal - Admin tab
|
||||||
|
adminCurrentAccount: "현재 계정",
|
||||||
|
adminIsolationMode: "격리 모드",
|
||||||
|
adminMultiUserPrivacy: "완전한 멀티유저 프라이버시",
|
||||||
|
adminCalDavEndpoint: "CalDAV 엔드포인트",
|
||||||
|
adminActiveEnabled: "정상 동작 중",
|
||||||
|
adminOpenDashboard: "관리자 콘솔 대시보드 열기 →",
|
||||||
|
|
||||||
// CalDAV & Sync
|
// CalDAV & Sync
|
||||||
syncTitle: "CalDAV 모바일 및 외부 앱 동기화",
|
syncTitle: "CalDAV 모바일 동기화 (Android / Apple)",
|
||||||
syncDesc: "Android(DAVx⁵, Tasks.org), Apple 미리알림, Thunderbird 등 표준 CalDAV를 지원하는 모든 기기 및 앱과 실시간 양방향으로 동기화됩니다.",
|
syncDesc: "CheckFlow는 표준 CalDAV 프로토콜을 통해 Android, Apple 미리알림, Thunderbird와의 완전한 양방향 동기화를 지원합니다.",
|
||||||
syncBaseUrl: "CalDAV 기본 엔드포인트 URL",
|
syncBaseUrl: "CalDAV 서버 주소 (Base URL)",
|
||||||
syncCopyUrl: "URL 복사",
|
syncCopyUrl: "URL 복사",
|
||||||
syncCopied: "복사 완료!",
|
syncCopied: "복사됨!",
|
||||||
syncDownloadIcs: ".ICS 피드 다운로드",
|
syncDownloadIcs: ".ICS 피드 다운로드",
|
||||||
syncTestConnection: "연결 테스트",
|
syncTestConnection: "엔드포인트 연결 테스트",
|
||||||
syncTesting: "연결 확인 중...",
|
syncTesting: "연결 확인 중...",
|
||||||
syncTestSuccess: "✓ CalDAV 엔드포인트가 정상적으로 응답합니다 (200 OK)",
|
syncTestSuccess: "CalDAV 서버가 정상적으로 응답했습니다",
|
||||||
|
syncTestError: "엔드포인트 테스트 실패",
|
||||||
syncTabAndroid: "Android (DAVx⁵)",
|
syncTabAndroid: "Android (DAVx⁵)",
|
||||||
syncTabApple: "Apple 미리알림 (iOS / macOS)",
|
syncTabApple: "Apple 미리알림",
|
||||||
syncTabThunderbird: "Thunderbird (Desktop)",
|
syncTabThunderbird: "Thunderbird",
|
||||||
syncAndroidStep1: "Google Play Store 또는 F-Droid에서 'DAVx⁵' 앱을 설치합니다.",
|
syncAndroidStep1: "Google Play 스토어 또는 F-Droid에서 DAVx⁵ 앱을 설치합니다.",
|
||||||
syncAndroidStep2: "DAVx⁵ 실행 → 계정 추가(+) → 'URL 및 사용자 이름으로 로그인'을 선택합니다.",
|
syncAndroidStep2: "'URL 및 사용자 이름으로 로그인'을 선택하고 위의 CalDAV URL을 입력합니다.",
|
||||||
syncAndroidStep3: "위 기본 URL을 입력하고, CheckFlow 계정 이메일과 비밀번호를 입력합니다.",
|
syncAndroidStep3: "CheckFlow 이메일 계정과 비밀번호를 입력합니다.",
|
||||||
syncAndroidStep4: "'VTODO (할 일)' 컬렉션을 활성화하면 Tasks.org 또는 OpenTasks 앱과 실시간 동기화됩니다.",
|
syncAndroidStep4: "Tasks.org 또는 OpenTasks 등의 앱을 열어 동기화된 할 일을 관리하세요.",
|
||||||
syncAppleStep1: "기기 설정 → 미리알림 → 계정 → '계정 추가'를 선택합니다.",
|
syncAppleStep1: "시스템 설정(macOS) 또는 설정(iOS/iPadOS) → 인터넷 계정으로 이동합니다.",
|
||||||
syncAppleStep2: "'기타' 선택 → 'CalDAV 계정 추가'를 선택합니다.",
|
syncAppleStep2: "'계정 추가' → 'CalDAV 계정 추가'를 선택합니다.",
|
||||||
syncAppleStep3: "서버 주소에 위 URL을 입력하고, 사용자 이름/비밀번호에 CheckFlow 계정 정보를 입력합니다.",
|
syncAppleStep3: "계정 유형을 '수동'으로 설정하고 위의 CalDAV 서버 주소와 로그인 정보를 입력합니다.",
|
||||||
syncAppleStep4: "'미리알림' 항목을 활성화하고 저장을 누릅니다.",
|
syncAppleStep4: "Apple 미리알림(Reminders) 앱에 작업 목록이 자동으로 동기화됩니다.",
|
||||||
syncThunderbirdStep1: "Thunderbird 실행 → 캘린더 탭으로 이동 → '새 캘린더'를 선택합니다.",
|
syncThunderbirdStep1: "Mozilla Thunderbird를 열고 캘린더 / 작업 탭으로 이동합니다.",
|
||||||
syncThunderbirdStep2: "'네트워크에 저장' 선택 → 형식: 'CalDAV'를 선택합니다.",
|
syncThunderbirdStep2: "캘린더 목록 우클릭 → '새 캘린더' → '네트워크에서'를 선택합니다.",
|
||||||
syncThunderbirdStep3: "위치에 위 기본 URL을 입력하고, 사용자 이름에 CheckFlow 계정 이메일을 입력합니다.",
|
syncThunderbirdStep3: "'CalDAV'를 선택하고 위의 CalDAV 주소와 로그인 정보를 입력합니다.",
|
||||||
syncThunderbirdStep4: "인증 대화상자가 표시되면 비밀번호를 입력하여 완료합니다.",
|
syncThunderbirdStep4: "CheckFlow의 작업 목록이 Thunderbird Tasks에 표시됩니다.",
|
||||||
|
syncAndroidTip: "DAVx⁵ 계정 설정에서 동기화 주기를 15분으로 설정하면 배터리 소모를 줄이면서 실시간 동기화를 유지할 수 있습니다.",
|
||||||
|
syncAppleTip: "Nginx/Caddy 등 리버스 프록시 뒤에서 HTTPS를 사용할 경우 올바른 SSL 인증서가 구성되어 있는지 확인하세요.",
|
||||||
|
syncThunderbirdTip: "Thunderbird 작업 뷰에서 CheckFlow의 우선순위, 마감일, 완료 상태가 그대로 표시됩니다.",
|
||||||
|
|
||||||
// Task List
|
// Task List
|
||||||
tasks: "할 일",
|
tasks: "할 일",
|
||||||
hideDone: "완료 항목 숨기기",
|
hideDone: "완료항목 숨기기",
|
||||||
showDone: "완료 항목 표시",
|
showDone: "완료항목 표시",
|
||||||
completedSection: "완료된 항목",
|
completedSection: "완료됨",
|
||||||
addTaskPlaceholder: "새 할 일 추가...",
|
addTaskPlaceholder: "새 할 일 입력 후 Enter...",
|
||||||
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\n마크다운 문법 지원: **굵게**, *기울임*, # 제목, - 목록, - [ ] 체크박스 등",
|
notesPlaceholder: "상세 내용을 입력하세요 (마크다운 지원)...",
|
||||||
subtasks: "하위 작업",
|
subtasks: "하위작업",
|
||||||
addSubtaskPlaceholder: "하위 작업 추가…",
|
addSubtaskPlaceholder: "하위작업 추가 후 Enter...",
|
||||||
deleteTaskConfirm: "이 할 일을 삭제하시겠습니까?",
|
deleteTaskConfirm: "이 작업을 정말 삭제하시겠습니까?",
|
||||||
autoSaved: "자동 저장 완료",
|
autoSaved: "저장됨",
|
||||||
saving: "저장 중…",
|
saving: "저장 중...",
|
||||||
created: "생성 일시",
|
created: "생성일",
|
||||||
edited: "수정 일시",
|
edited: "수정일",
|
||||||
|
previewTab: "미리보기",
|
||||||
|
editTab: "편집",
|
||||||
|
|
||||||
// Toolbars
|
// Toolbars
|
||||||
bold: "굵게",
|
bold: "굵게",
|
||||||
italic: "기울임",
|
italic: "기울임",
|
||||||
heading: "제목",
|
heading: "제목",
|
||||||
bulletList: "글머리 기호 목록",
|
bulletList: "글머리 기호",
|
||||||
numberedList: "번호 매기기 목록",
|
numberedList: "번호 목록",
|
||||||
checkbox: "체크박스",
|
checkbox: "체크박스",
|
||||||
code: "코드 블록",
|
code: "코드",
|
||||||
|
|
||||||
// TickTick Mode Switcher & Quick Add
|
// TickTick Mode Switcher & Quick Add
|
||||||
textMode: "마크다운 메모 모드",
|
textMode: "텍스트 모드",
|
||||||
subtaskMode: "체크리스트 모드",
|
subtaskMode: "하위작업 모드",
|
||||||
switchToTextMode: "마크다운 메모 모드로 전환",
|
switchToTextMode: "텍스트 모드로 전환",
|
||||||
switchToSubtaskMode: "하위 체크리스트 모드로 전환",
|
switchToSubtaskMode: "하위작업 모드로 전환",
|
||||||
moveToList: "다른 목록으로 이동",
|
moveToList: "목록 이동",
|
||||||
quickAdd: "빠른 추가",
|
quickAdd: "빠른 추가",
|
||||||
quickToday: "오늘",
|
quickToday: "오늘",
|
||||||
quickTomorrow: "내일",
|
quickTomorrow: "내일",
|
||||||
quickNextWeek: "다음 주",
|
quickNextWeek: "다음 주",
|
||||||
selectDate: "마감일 지정",
|
selectDate: "날짜 선택",
|
||||||
selectPriority: "우선순위 지정",
|
selectPriority: "우선순위",
|
||||||
selectTag: "태그 지정",
|
selectTag: "태그 선택",
|
||||||
copied: "복사 완료!",
|
copied: "복사됨",
|
||||||
|
|
||||||
// Context Menu
|
// Context Menu
|
||||||
editTask: "수정",
|
editTask: "작업 수정",
|
||||||
duplicateTask: "복제",
|
duplicateTask: "복제",
|
||||||
moveTask: "이동…",
|
moveTask: "이동...",
|
||||||
deleteTask: "삭제",
|
deleteTask: "삭제",
|
||||||
renameList: "이름 변경",
|
renameList: "목록 이름 변경",
|
||||||
deleteList: "목록 삭제",
|
deleteList: "목록 삭제",
|
||||||
taskUndoHint: "삭제되었습니다. 클릭하여 되돌리기",
|
taskUndoHint: "작업이 삭제되었습니다",
|
||||||
listUndoHint: "목록이 삭제되었습니다. 클릭하여 되돌리기",
|
listUndoHint: "목록이 삭제되었습니다",
|
||||||
|
|
||||||
// Labs, Kanban & Customization
|
// Labs, Kanban & Customization
|
||||||
labs: "실험실 (Labs)",
|
labs: "실험실",
|
||||||
labsDesc: "실험적인 기능들을 개별적으로 활성화하거나 비활성화할 수 있습니다.",
|
labsDesc: "실험적 기능 및 UI 개인화 설정입니다.",
|
||||||
kanbanView: "칸반 보드",
|
kanbanView: "칸반 보드",
|
||||||
listView: "리스트 뷰",
|
listView: "목록 뷰",
|
||||||
enableKanban: "칸반 보드 뷰",
|
enableKanban: "칸반 보드 뷰",
|
||||||
enableKanbanDesc: "할 일을 3컬럼 보드(할 일 / 진행 중 / 완료) 형태로 전환하여 관리합니다.",
|
enableKanbanDesc: "목록에서 리스트 뷰와 3컬럼 칸반 보드 뷰 간을 자유롭게 전환합니다.",
|
||||||
swapBlocks: "상하 블록 위치 변경",
|
swapBlocks: "블록 순서 변경",
|
||||||
resetDefaults: "기본값으로 복원",
|
resetDefaults: "기본값 복원",
|
||||||
resetConfirm: "모든 UI 커스텀 설정을 초기 기본값으로 복원하시겠습니까?",
|
resetConfirm: "모든 커스터마이징 설정을 초기 상태로 되돌리시겠습니까?",
|
||||||
todoCol: "할 일",
|
todoCol: "할 일",
|
||||||
inProgressCol: "진행 중",
|
inProgressCol: "진행 중",
|
||||||
doneCol: "완료",
|
doneCol: "완료",
|
||||||
accessRestricted: "접근 권한 제한",
|
accessRestricted: "접근 제한",
|
||||||
adminRequired: "이 관리 콘솔은 ADMIN 권한이 필요합니다.",
|
adminRequired: "이 콘솔은 ADMIN 권한이 필요합니다.",
|
||||||
};
|
};
|
||||||
|
|||||||
+56
-1
@@ -58,7 +58,7 @@ export interface TranslationDict {
|
|||||||
searchPlaceholder: string;
|
searchPlaceholder: string;
|
||||||
searchExpandedPlaceholder: string;
|
searchExpandedPlaceholder: string;
|
||||||
|
|
||||||
// Settings Modal
|
// Settings Modal - General & Tabs
|
||||||
settingsModalTitle: string;
|
settingsModalTitle: string;
|
||||||
profile: string;
|
profile: string;
|
||||||
preferences: string;
|
preferences: string;
|
||||||
@@ -70,6 +70,55 @@ export interface TranslationDict {
|
|||||||
days14: string;
|
days14: string;
|
||||||
days30: string;
|
days30: string;
|
||||||
neverDelete: string;
|
neverDelete: string;
|
||||||
|
settingsSaved: string;
|
||||||
|
passwordChangePlaceholder: string;
|
||||||
|
yourNamePlaceholder: string;
|
||||||
|
|
||||||
|
// Settings Modal - Labs & Customization
|
||||||
|
labsSectionView: string;
|
||||||
|
labsSectionLayout: string;
|
||||||
|
labsSectionAppearance: string;
|
||||||
|
labsKanbanTitle: string;
|
||||||
|
labsKanbanDesc: string;
|
||||||
|
labsDensityTitle: string;
|
||||||
|
labsDensityDesc: string;
|
||||||
|
densityCompact: string;
|
||||||
|
densityDefault: string;
|
||||||
|
densityComfortable: string;
|
||||||
|
sidebarWidthTitle: string;
|
||||||
|
sidebarWidthDesc: string;
|
||||||
|
detailWidthTitle: string;
|
||||||
|
detailWidthDesc: string;
|
||||||
|
fontSizeTitle: string;
|
||||||
|
fontSizeDesc: string;
|
||||||
|
fontSizeSmall: string;
|
||||||
|
fontSizeMedium: string;
|
||||||
|
fontSizeLarge: string;
|
||||||
|
accentColorTitle: string;
|
||||||
|
accentColorDesc: string;
|
||||||
|
saturationLabel: string;
|
||||||
|
hueLabel: string;
|
||||||
|
roundnessTitle: string;
|
||||||
|
roundnessDesc: string;
|
||||||
|
roundnessSharp: string;
|
||||||
|
roundnessDefault: string;
|
||||||
|
roundnessRound: string;
|
||||||
|
animationSpeedTitle: string;
|
||||||
|
animationSpeedDesc: string;
|
||||||
|
animationOff: string;
|
||||||
|
animationFast: string;
|
||||||
|
animationDefault: string;
|
||||||
|
animationSlow: string;
|
||||||
|
resetDefaultsTitle: string;
|
||||||
|
resetDefaultsDesc: string;
|
||||||
|
|
||||||
|
// Settings Modal - Admin tab
|
||||||
|
adminCurrentAccount: string;
|
||||||
|
adminIsolationMode: string;
|
||||||
|
adminMultiUserPrivacy: string;
|
||||||
|
adminCalDavEndpoint: string;
|
||||||
|
adminActiveEnabled: string;
|
||||||
|
adminOpenDashboard: string;
|
||||||
|
|
||||||
// CalDAV & Sync
|
// CalDAV & Sync
|
||||||
syncTitle: string;
|
syncTitle: string;
|
||||||
@@ -81,6 +130,7 @@ export interface TranslationDict {
|
|||||||
syncTestConnection: string;
|
syncTestConnection: string;
|
||||||
syncTesting: string;
|
syncTesting: string;
|
||||||
syncTestSuccess: string;
|
syncTestSuccess: string;
|
||||||
|
syncTestError: string;
|
||||||
syncTabAndroid: string;
|
syncTabAndroid: string;
|
||||||
syncTabApple: string;
|
syncTabApple: string;
|
||||||
syncTabThunderbird: string;
|
syncTabThunderbird: string;
|
||||||
@@ -96,6 +146,9 @@ export interface TranslationDict {
|
|||||||
syncThunderbirdStep2: string;
|
syncThunderbirdStep2: string;
|
||||||
syncThunderbirdStep3: string;
|
syncThunderbirdStep3: string;
|
||||||
syncThunderbirdStep4: string;
|
syncThunderbirdStep4: string;
|
||||||
|
syncAndroidTip: string;
|
||||||
|
syncAppleTip: string;
|
||||||
|
syncThunderbirdTip: string;
|
||||||
|
|
||||||
// Task List
|
// Task List
|
||||||
tasks: string;
|
tasks: string;
|
||||||
@@ -128,6 +181,8 @@ export interface TranslationDict {
|
|||||||
saving: string;
|
saving: string;
|
||||||
created: string;
|
created: string;
|
||||||
edited: string;
|
edited: string;
|
||||||
|
previewTab: string;
|
||||||
|
editTab: string;
|
||||||
|
|
||||||
// Toolbars
|
// Toolbars
|
||||||
bold: string;
|
bold: string;
|
||||||
|
|||||||
+8
-3
@@ -330,18 +330,23 @@ export function filterActiveTree(nodes: MockTask[], showCompleted = true): MockT
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Permanent delete
|
// Completely remove task and all its descendants from active tree state
|
||||||
export function deleteTaskInTree(tree: MockTask[], idToDelete: string): MockTask[] {
|
export function removeTaskFromTree(tree: MockTask[], idToDelete: string): MockTask[] {
|
||||||
return tree
|
return tree
|
||||||
.filter((node) => node.id !== idToDelete)
|
.filter((node) => node.id !== idToDelete)
|
||||||
.map((node) => {
|
.map((node) => {
|
||||||
if (node.children && node.children.length > 0) {
|
if (node.children && node.children.length > 0) {
|
||||||
return { ...node, children: deleteTaskInTree(node.children, idToDelete) };
|
return { ...node, children: removeTaskFromTree(node.children, idToDelete) };
|
||||||
}
|
}
|
||||||
return node;
|
return node;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Permanent delete
|
||||||
|
export function deleteTaskInTree(tree: MockTask[], idToDelete: string): MockTask[] {
|
||||||
|
return removeTaskFromTree(tree, idToDelete);
|
||||||
|
}
|
||||||
|
|
||||||
export function emptyTrashInTree(tree: MockTask[]): MockTask[] {
|
export function emptyTrashInTree(tree: MockTask[]): MockTask[] {
|
||||||
return tree
|
return tree
|
||||||
.filter((node) => !node.isDeleted)
|
.filter((node) => !node.isDeleted)
|
||||||
|
|||||||
Reference in New Issue
Block a user