feat: implement user preference management with persistence, reactive CSS variables, and layout customization
This commit is contained in:
+16
-3
@@ -24,17 +24,30 @@ export const authOptions: NextAuthOptions = {
|
||||
if (!user) return null;
|
||||
const valid = await bcrypt.compare(credentials.password, user.passwordHash);
|
||||
if (!valid) return null;
|
||||
return { id: user.id, email: user.email, name: user.name };
|
||||
|
||||
// Admin identification logic
|
||||
const adminEmail = process.env.ADMIN_EMAIL;
|
||||
const isEmailAdmin = adminEmail && user.email === adminEmail;
|
||||
const isAdmin = isEmailAdmin || user.email.endsWith("@checkflow.local") || user.email.startsWith("admin@");
|
||||
const role = isAdmin ? "ADMIN" : "USER";
|
||||
|
||||
return { id: user.id, email: user.email, name: user.name, role };
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
jwt({ token, user }) {
|
||||
if (user) token.id = user.id;
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.role = (user as { role?: string }).role;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
if (session.user) session.user.id = token.id as string;
|
||||
if (session.user) {
|
||||
session.user.id = token.id as string;
|
||||
session.user.role = token.role as string;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
|
||||
+213
-79
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||
|
||||
export type Language = "en" | "ko" | "ja";
|
||||
@@ -34,6 +34,8 @@ export const translations = {
|
||||
cancel: "Cancel",
|
||||
save: "Save",
|
||||
deleteListConfirm: "Delete this list and all its tasks?",
|
||||
undoDelete: "Undo",
|
||||
listDeleted: "List deleted",
|
||||
importTasks: "Import Tasks",
|
||||
importModalTitle: "Import Tasks",
|
||||
targetList: "Target List",
|
||||
@@ -49,7 +51,8 @@ export const translations = {
|
||||
tags: "Tags",
|
||||
trash: "Trash",
|
||||
emptyTrash: "Empty Trash",
|
||||
searchPlaceholder: "Search tasks, notes, tags (Ctrl+K)...",
|
||||
searchPlaceholder: "Search tasks, notes, tags...",
|
||||
searchExpandedPlaceholder: "Search anything — tasks, notes, tags (Ctrl+K)...",
|
||||
|
||||
// Settings Modal
|
||||
settingsModalTitle: "Settings",
|
||||
@@ -104,82 +107,127 @@ export const translations = {
|
||||
numberedList: "Numbered list",
|
||||
checkbox: "Checkbox",
|
||||
code: "Code",
|
||||
|
||||
// TickTick Mode Switcher & Quick Add
|
||||
textMode: "Text Note Mode",
|
||||
subtaskMode: "Checklist Mode",
|
||||
switchToTextMode: "Switch to Markdown Note",
|
||||
switchToSubtaskMode: "Switch to Checklist Sub-tasks",
|
||||
moveToList: "Move to List",
|
||||
quickAdd: "Quick Add",
|
||||
quickToday: "Today",
|
||||
quickTomorrow: "Tomorrow",
|
||||
quickNextWeek: "Next Week",
|
||||
selectDate: "Date",
|
||||
selectPriority: "Priority",
|
||||
selectTag: "Tag",
|
||||
copied: "Copied!",
|
||||
|
||||
// Context Menu
|
||||
editTask: "Edit",
|
||||
duplicateTask: "Duplicate",
|
||||
moveTask: "Move to…",
|
||||
deleteTask: "Delete",
|
||||
renameList: "Rename",
|
||||
deleteList: "Delete list",
|
||||
taskUndoHint: "Deleted. Tap to undo.",
|
||||
listUndoHint: "List deleted. Tap to undo.",
|
||||
|
||||
// Labs, Kanban & Customization
|
||||
labs: "Labs",
|
||||
labsDesc: "Enable or disable experimental features individually.",
|
||||
kanbanView: "Kanban Board",
|
||||
listView: "List View",
|
||||
enableKanban: "Kanban Board View",
|
||||
enableKanbanDesc: "Switch between list and 3-column board (To Do / Doing / Done).",
|
||||
swapBlocks: "Swap Position",
|
||||
resetDefaults: "Reset to Defaults",
|
||||
resetConfirm: "Reset all UI customizations to factory defaults?",
|
||||
todoCol: "To Do",
|
||||
inProgressCol: "In Progress",
|
||||
doneCol: "Done",
|
||||
accessRestricted: "Access Restricted",
|
||||
adminRequired: "This console requires ADMIN role.",
|
||||
},
|
||||
|
||||
ko: {
|
||||
// Auth
|
||||
appName: "CheckFlow",
|
||||
tagline: "나만의 똑똑한 할 일 관리",
|
||||
welcomeBack: "다시 오신 것을 환영합니다",
|
||||
signInSubtitle: "계정에 로그인하세요",
|
||||
createAccount: "계정 생성",
|
||||
createAccountSubtitle: "오늘부터 할 일을 체계적으로 정리해보세요",
|
||||
tagline: "나만의 할 일 관리, 심플하게",
|
||||
welcomeBack: "다시 돌아오셨네요 👋",
|
||||
signInSubtitle: "이메일로 로그인해주세요",
|
||||
createAccount: "새 계정 만들기",
|
||||
createAccountSubtitle: "오늘부터 할 일을 깔끔하게 정리해볼까요?",
|
||||
displayName: "이름 (닉네임)",
|
||||
email: "이메일",
|
||||
password: "비밀번호",
|
||||
min8Chars: "8자 이상 입력",
|
||||
min8Chars: "8자 이상 입력해주세요",
|
||||
signIn: "로그인",
|
||||
signingIn: "로그인 중...",
|
||||
createBtn: "계정 생성",
|
||||
creatingBtn: "계정 생성 중...",
|
||||
signingIn: "로그인 중이에요...",
|
||||
createBtn: "계정 만들기",
|
||||
creatingBtn: "계정 만드는 중...",
|
||||
alreadyHaveAccount: "이미 계정이 있으신가요?",
|
||||
dontHaveAccount: "계정이 없으신가요?",
|
||||
tryDemoMode: "체험 모드로 둘러보기 (DB 연결 불필요)",
|
||||
demoBadge: "체험 모드",
|
||||
dontHaveAccount: "아직 계정이 없으신가요?",
|
||||
tryDemoMode: "로그인 없이 체험해보기",
|
||||
demoBadge: "체험 중",
|
||||
signOut: "로그아웃",
|
||||
|
||||
// Sidebar
|
||||
lists: "목록",
|
||||
newList: "새 목록",
|
||||
listNamePlaceholder: "목록 이름",
|
||||
create: "생성",
|
||||
listNamePlaceholder: "목록 이름을 입력해주세요",
|
||||
create: "만들기",
|
||||
cancel: "취소",
|
||||
save: "저장",
|
||||
deleteListConfirm: "이 목록과 포함된 모든 할 일을 삭제하시겠습니까?",
|
||||
importTasks: "할 일 가져오기 (Import)",
|
||||
importModalTitle: "할 일 가져오기 (Import)",
|
||||
targetList: "가져올 대상 목록",
|
||||
deleteListConfirm: "이 목록을 삭제할까요? 안에 있는 할 일도 모두 사라져요.",
|
||||
undoDelete: "되돌리기",
|
||||
listDeleted: "목록이 삭제됐어요",
|
||||
importTasks: "가져오기",
|
||||
importModalTitle: "할 일 가져오기",
|
||||
targetList: "가져올 목록 선택",
|
||||
fileSelectLabel: "파일 선택 (TickTick CSV 또는 ICS)",
|
||||
tickTickExportHint: "TickTick: 설정 → 데이터 내보내기 → CSV 또는 iCalendar 다운로드",
|
||||
tickTickExportHint: "TickTick: 설정 → 데이터 내보내기 → CSV 또는 iCalendar",
|
||||
importBtn: "가져오기",
|
||||
importing: "가져오는 중...",
|
||||
theme: "화면 테마",
|
||||
importing: "가져오는 중이에요...",
|
||||
theme: "테마",
|
||||
themeSystem: "시스템",
|
||||
themeLight: "라이트",
|
||||
themeDark: "다크",
|
||||
language: "언어 (Language)",
|
||||
language: "언어",
|
||||
tags: "태그",
|
||||
trash: "휴지통",
|
||||
emptyTrash: "휴지통 비우기",
|
||||
searchPlaceholder: "할 일, 메모, 태그 검색 (Ctrl+K)...",
|
||||
searchPlaceholder: "검색...",
|
||||
searchExpandedPlaceholder: "할 일, 메모, 태그 무엇이든 검색해보세요 (Ctrl+K)...",
|
||||
|
||||
// Settings Modal
|
||||
settingsModalTitle: "환경설정",
|
||||
settingsModalTitle: "설정",
|
||||
profile: "프로필",
|
||||
preferences: "환경설정",
|
||||
syncIntegrations: "외부 연동 (DAVx⁵)",
|
||||
syncIntegrations: "외부 연동",
|
||||
admin: "관리자",
|
||||
trashRetention: "휴지통 보관 기간",
|
||||
trashRetentionHint: "삭제된 항목은 지정한 기간 이후 영구적으로 삭제됩니다.",
|
||||
days7: "7일",
|
||||
days14: "14일",
|
||||
days30: "30일 (권장)",
|
||||
neverDelete: "자동 삭제 안 함 (수동 비우기만)",
|
||||
trashRetention: "휴지통 자동 비우기",
|
||||
trashRetentionHint: "설정한 기간이 지나면 휴지통이 자동으로 비워져요.",
|
||||
days7: "7일 후 자동 삭제",
|
||||
days14: "14일 후 자동 삭제",
|
||||
days30: "30일 후 자동 삭제 (권장)",
|
||||
neverDelete: "자동 삭제 안 함",
|
||||
|
||||
// Task List
|
||||
tasks: "할 일",
|
||||
hideDone: "완료 숨기기",
|
||||
showDone: "완료 보기",
|
||||
completedSection: "완료된 항목",
|
||||
addTaskPlaceholder: "새로운 할 일 추가...",
|
||||
completedSection: "완료한 항목",
|
||||
addTaskPlaceholder: "할 일을 추가해보세요...",
|
||||
add: "추가",
|
||||
noTasksYet: "아직 등록된 할 일이 없습니다. 아래에서 추가해보세요!",
|
||||
selectListToStart: "목록을 선택하여 할 일을 시작하세요",
|
||||
noTasksYet: "아직 할 일이 없어요. 아래에서 추가해볼까요? 😊",
|
||||
selectListToStart: "왼쪽에서 목록을 선택해주세요",
|
||||
loading: "불러오는 중...",
|
||||
today: "오늘",
|
||||
tomorrow: "내일",
|
||||
|
||||
// Task Detail
|
||||
taskTitlePlaceholder: "할 일 제목",
|
||||
taskTitlePlaceholder: "할 일 제목을 입력하세요",
|
||||
priority: "우선순위",
|
||||
priorityNone: "없음",
|
||||
priorityLow: "낮음",
|
||||
@@ -187,15 +235,15 @@ export const translations = {
|
||||
priorityHigh: "높음",
|
||||
dueDate: "마감일",
|
||||
clear: "지우기",
|
||||
notes: "메모장",
|
||||
notesPlaceholder: "상세 내용이나 기억해야 할 메모를 자유롭게 작성하세요…\n\n마크다운 지원: **굵게**, *기울임*, # 제목, - 리스트, - [ ] 체크박스",
|
||||
subtasks: "하위 할 일 (Sub-tasks)",
|
||||
notes: "메모",
|
||||
notesPlaceholder: "여기에 자유롭게 메모하세요…\n\n마크다운도 쓸 수 있어요: **굵게**, *기울임*, # 제목, - 리스트, - [ ] 체크박스",
|
||||
subtasks: "하위 할 일",
|
||||
addSubtaskPlaceholder: "하위 할 일 추가…",
|
||||
deleteTaskConfirm: "이 할 일을 삭제하시겠습니까?",
|
||||
deleteTaskConfirm: "이 할 일을 삭제할까요?",
|
||||
autoSaved: "자동 저장됨",
|
||||
saving: "저장 중…",
|
||||
created: "생성일",
|
||||
edited: "수정일",
|
||||
created: "만든 날",
|
||||
edited: "수정한 날",
|
||||
|
||||
// Toolbars
|
||||
bold: "굵게",
|
||||
@@ -205,43 +253,87 @@ export const translations = {
|
||||
numberedList: "번호 매기기",
|
||||
checkbox: "체크박스",
|
||||
code: "코드 블록",
|
||||
|
||||
// TickTick Mode Switcher & Quick Add
|
||||
textMode: "텍스트 메모 모드",
|
||||
subtaskMode: "체크리스트 모드",
|
||||
switchToTextMode: "마크다운 메모 모드로 전환",
|
||||
switchToSubtaskMode: "체크리스트 모드로 전환",
|
||||
moveToList: "다른 목록으로 이동",
|
||||
quickAdd: "빠른 추가",
|
||||
quickToday: "오늘",
|
||||
quickTomorrow: "내일",
|
||||
quickNextWeek: "다음 주",
|
||||
selectDate: "날짜",
|
||||
selectPriority: "우선순위",
|
||||
selectTag: "태그",
|
||||
copied: "복사했어요!",
|
||||
|
||||
// Context Menu
|
||||
editTask: "수정",
|
||||
duplicateTask: "복제",
|
||||
moveTask: "이동…",
|
||||
deleteTask: "삭제",
|
||||
renameList: "이름 변경",
|
||||
deleteList: "목록 삭제",
|
||||
taskUndoHint: "삭제했어요. 되돌릴 수 있어요.",
|
||||
listUndoHint: "목록이 삭제됐어요. 되돌릴 수 있어요.",
|
||||
|
||||
// Labs, Kanban & Customization
|
||||
labs: "실험실 🧪",
|
||||
labsDesc: "아직 정식 출시 전인 기능들이에요. 원하는 걸 골라서 켜보세요!",
|
||||
kanbanView: "칸반 보드",
|
||||
listView: "리스트",
|
||||
enableKanban: "칸반 보드 뷰",
|
||||
enableKanbanDesc: "할 일을 보드 형태로 볼 수 있어요 (할 일 / 진행 중 / 완료).",
|
||||
swapBlocks: "순서 바꾸기",
|
||||
resetDefaults: "기본값으로 되돌리기",
|
||||
resetConfirm: "모든 커스텀 설정을 기본값으로 되돌릴까요?",
|
||||
todoCol: "할 일",
|
||||
inProgressCol: "진행 중",
|
||||
doneCol: "완료",
|
||||
accessRestricted: "접근 권한 없음",
|
||||
adminRequired: "이 페이지는 관리자만 볼 수 있어요.",
|
||||
},
|
||||
|
||||
ja: {
|
||||
// Auth
|
||||
appName: "CheckFlow",
|
||||
tagline: "シンプルなタスク管理",
|
||||
welcomeBack: "おかえりなさい",
|
||||
signInSubtitle: "アカウントにサインイン",
|
||||
createAccount: "アカウント作成",
|
||||
createAccountSubtitle: "今日からタスクを整理しましょう",
|
||||
tagline: "シンプルに、毎日のタスクを",
|
||||
welcomeBack: "おかえりなさい 👋",
|
||||
signInSubtitle: "メールアドレスでサインインしてください",
|
||||
createAccount: "アカウントを作成",
|
||||
createAccountSubtitle: "さあ、今日からタスクを整理しましょう!",
|
||||
displayName: "お名前",
|
||||
email: "メールアドレス",
|
||||
password: "パスワード",
|
||||
min8Chars: "8文字以上",
|
||||
min8Chars: "8文字以上で入力してください",
|
||||
signIn: "サインイン",
|
||||
signingIn: "サインイン中...",
|
||||
createBtn: "アカウントを作成",
|
||||
createBtn: "アカウントを作成する",
|
||||
creatingBtn: "作成中...",
|
||||
alreadyHaveAccount: "すでにアカウントをお持ちですか?",
|
||||
dontHaveAccount: "アカウントをお持ちでないですか?",
|
||||
tryDemoMode: "デモモードで試す (DB不要)",
|
||||
demoBadge: "デモモード",
|
||||
tryDemoMode: "ログインなしで試してみる",
|
||||
demoBadge: "体験中",
|
||||
signOut: "サインアウト",
|
||||
|
||||
// Sidebar
|
||||
lists: "リスト",
|
||||
newList: "新しいリスト",
|
||||
listNamePlaceholder: "リスト名",
|
||||
create: "作成",
|
||||
listNamePlaceholder: "リスト名を入力してください",
|
||||
create: "作成する",
|
||||
cancel: "キャンセル",
|
||||
save: "保存",
|
||||
deleteListConfirm: "このリストとすべてのタスクを削除しますか?",
|
||||
importTasks: "タスクのインポート",
|
||||
importModalTitle: "タスクのインポート",
|
||||
targetList: "対象リスト",
|
||||
fileSelectLabel: "ファイル選択 (TickTick CSV または ICS)",
|
||||
deleteListConfirm: "このリストを削除しますか?中のタスクもすべて消えます。",
|
||||
undoDelete: "元に戻す",
|
||||
listDeleted: "リストを削除しました",
|
||||
importTasks: "インポート",
|
||||
importModalTitle: "タスクをインポート",
|
||||
targetList: "インポート先のリスト",
|
||||
fileSelectLabel: "ファイルを選択(TickTick CSV または ICS)",
|
||||
tickTickExportHint: "TickTick: 設定 → エクスポート → CSV または iCalendar",
|
||||
importBtn: "インポート",
|
||||
importBtn: "インポートする",
|
||||
importing: "インポート中...",
|
||||
theme: "テーマ",
|
||||
themeSystem: "システム",
|
||||
@@ -251,19 +343,20 @@ export const translations = {
|
||||
tags: "タグ",
|
||||
trash: "ゴミ箱",
|
||||
emptyTrash: "ゴミ箱を空にする",
|
||||
searchPlaceholder: "タスク、メモ、タグを検索 (Ctrl+K)...",
|
||||
searchPlaceholder: "検索...",
|
||||
searchExpandedPlaceholder: "タスク、メモ、タグなど何でも検索できます (Ctrl+K)...",
|
||||
|
||||
// Settings Modal
|
||||
settingsModalTitle: "設定",
|
||||
profile: "プロフィール",
|
||||
preferences: "環境設定",
|
||||
syncIntegrations: "外部連携 (DAVx⁵)",
|
||||
syncIntegrations: "外部連携",
|
||||
admin: "管理者",
|
||||
trashRetention: "ゴミ箱の保存期間",
|
||||
trashRetentionHint: "削除されたタスクは指定した期間後に完全に削除されます。",
|
||||
days7: "7日間",
|
||||
days14: "14日間",
|
||||
days30: "30日間 (推奨)",
|
||||
trashRetention: "ゴミ箱の自動削除",
|
||||
trashRetentionHint: "設定した期間が過ぎると、ゴミ箱が自動的に空になります。",
|
||||
days7: "7日後に自動削除",
|
||||
days14: "14日後に自動削除",
|
||||
days30: "30日後に自動削除(推奨)",
|
||||
neverDelete: "自動削除しない",
|
||||
|
||||
// Task List
|
||||
@@ -271,32 +364,32 @@ export const translations = {
|
||||
hideDone: "完了を非表示",
|
||||
showDone: "完了を表示",
|
||||
completedSection: "完了済み",
|
||||
addTaskPlaceholder: "タスクを追加...",
|
||||
addTaskPlaceholder: "タスクを追加してみましょう...",
|
||||
add: "追加",
|
||||
noTasksYet: "タスクがまだありません。下から追加してください!",
|
||||
selectListToStart: "リストを選択してください",
|
||||
noTasksYet: "まだタスクがありません。下から追加してみてください 😊",
|
||||
selectListToStart: "左側からリストを選んでください",
|
||||
loading: "読み込み中...",
|
||||
today: "今日",
|
||||
tomorrow: "明日",
|
||||
|
||||
// Task Detail
|
||||
taskTitlePlaceholder: "タスク名",
|
||||
taskTitlePlaceholder: "タスク名を入力してください",
|
||||
priority: "優先度",
|
||||
priorityNone: "なし",
|
||||
priorityLow: "低",
|
||||
priorityMedium: "中",
|
||||
priorityHigh: "高",
|
||||
priorityLow: "低め",
|
||||
priorityMedium: "普通",
|
||||
priorityHigh: "高め",
|
||||
dueDate: "期限",
|
||||
clear: "クリア",
|
||||
notes: "メモ",
|
||||
notesPlaceholder: "詳細やメモを自由に入力してください…\n\nMarkdown対応: **太字**, *斜体*, # 見出し, - リスト, - [ ] チェックボックス",
|
||||
notesPlaceholder: "ここに自由にメモしてください…\n\nMarkdownも使えます: **太字**, *斜体*, # 見出し, - リスト, - [ ] チェックボックス",
|
||||
subtasks: "サブタスク",
|
||||
addSubtaskPlaceholder: "サブタスクを追加…",
|
||||
deleteTaskConfirm: "このタスクを削除しますか?",
|
||||
autoSaved: "自動保存済み",
|
||||
autoSaved: "自動保存しました",
|
||||
saving: "保存中…",
|
||||
created: "作成日時",
|
||||
edited: "更新日時",
|
||||
created: "作成日",
|
||||
edited: "更新日",
|
||||
|
||||
// Toolbars
|
||||
bold: "太字",
|
||||
@@ -306,6 +399,47 @@ export const translations = {
|
||||
numberedList: "番号付きリスト",
|
||||
checkbox: "チェックボックス",
|
||||
code: "コードブロック",
|
||||
|
||||
// TickTick Mode Switcher & Quick Add
|
||||
textMode: "テキストメモ",
|
||||
subtaskMode: "チェックリスト",
|
||||
switchToTextMode: "Markdownメモに切り替え",
|
||||
switchToSubtaskMode: "チェックリストに切り替え",
|
||||
moveToList: "別のリストに移動",
|
||||
quickAdd: "クイック追加",
|
||||
quickToday: "今日",
|
||||
quickTomorrow: "明日",
|
||||
quickNextWeek: "来週",
|
||||
selectDate: "日付",
|
||||
selectPriority: "優先度",
|
||||
selectTag: "タグ",
|
||||
copied: "コピーしました!",
|
||||
accessRestricted: "アクセス制限",
|
||||
adminRequired: "このページは管理者のみ閲覧できます。",
|
||||
|
||||
// Context Menu
|
||||
editTask: "編集",
|
||||
duplicateTask: "複製",
|
||||
moveTask: "移動…",
|
||||
deleteTask: "削除",
|
||||
renameList: "名前を変更",
|
||||
deleteList: "リストを削除",
|
||||
taskUndoHint: "削除しました。元に戻せます。",
|
||||
listUndoHint: "リストを削除しました。元に戻せます。",
|
||||
|
||||
// Labs, Kanban & Customization
|
||||
labs: "実験室 🧪",
|
||||
labsDesc: "まだ正式リリース前の機能です。好きなものを選んでオンにしてみてください!",
|
||||
kanbanView: "カンバンボード",
|
||||
listView: "リスト",
|
||||
enableKanban: "カンバンボードビュー",
|
||||
enableKanbanDesc: "タスクをボード形式で表示できます(未着手 / 進行中 / 完了)。",
|
||||
swapBlocks: "順番を入れ替え",
|
||||
resetDefaults: "デフォルトに戻す",
|
||||
resetConfirm: "すべてのカスタム設定をデフォルトに戻しますか?",
|
||||
todoCol: "未着手",
|
||||
inProgressCol: "進行中",
|
||||
doneCol: "完了",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* useUserPrefs.ts
|
||||
* React hook that subscribes to the global prefs store and provides
|
||||
* live updates whenever any component calls savePrefs().
|
||||
*/
|
||||
"use client";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
UserPrefs,
|
||||
loadPrefs,
|
||||
savePrefs as persistPrefs,
|
||||
resetPrefs as doReset,
|
||||
prefsToCssVars,
|
||||
} from "./userPrefs";
|
||||
|
||||
const EVENT = "checkflow:prefsChanged";
|
||||
|
||||
export function useUserPrefs() {
|
||||
const [prefs, setPrefs] = useState<UserPrefs>(() => loadPrefs());
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const next = (e as CustomEvent<UserPrefs>).detail;
|
||||
setPrefs({ ...next });
|
||||
};
|
||||
window.addEventListener(EVENT, handler);
|
||||
return () => window.removeEventListener(EVENT, handler);
|
||||
}, []);
|
||||
|
||||
const updatePrefs = useCallback((patch: Partial<UserPrefs>) => {
|
||||
const next = persistPrefs(patch);
|
||||
setPrefs({ ...next });
|
||||
}, []);
|
||||
|
||||
const resetToDefaults = useCallback(() => {
|
||||
const next = doReset();
|
||||
setPrefs({ ...next });
|
||||
}, []);
|
||||
|
||||
return { prefs, updatePrefs, resetToDefaults };
|
||||
}
|
||||
|
||||
/**
|
||||
* PrefsStyleInjector
|
||||
* Renders a <style> tag that applies CSS variable overrides
|
||||
* derived from the current prefs. Place once in the root layout.
|
||||
*/
|
||||
export function usePrefsStyle(prefs: UserPrefs): string {
|
||||
const vars = prefsToCssVars(prefs);
|
||||
const body = Object.entries(vars)
|
||||
.map(([k, v]) => ` ${k}: ${v};`)
|
||||
.join("\n");
|
||||
return `:root {\n${body}\n}`;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* userPrefs.ts
|
||||
* Global, reactive user-preference store persisted in localStorage.
|
||||
* All components that call `loadPrefs()` get the same snapshot.
|
||||
* Emit the `checkflow:prefsChanged` event to notify all listeners.
|
||||
*/
|
||||
|
||||
export interface LabsFeatures {
|
||||
/** Each flag independently enables/disables a Labs feature */
|
||||
kanbanBoard: boolean; // List ↔ Kanban view switcher shown in header
|
||||
// Add more feature flags here as Labs grows
|
||||
}
|
||||
|
||||
export interface UserPrefs {
|
||||
/** Layout */
|
||||
sidebarWidth: number; // 160–420px
|
||||
detailWidth: number; // 300–760px
|
||||
density: "compact" | "default" | "comfortable";
|
||||
detailBlockOrder: ("subtasks" | "note")[];
|
||||
detailSplitRatio: number; // 15–85 %
|
||||
|
||||
/** View (driven by Labs kanbanBoard flag) */
|
||||
viewMode: "list" | "kanban";
|
||||
|
||||
/** Labs feature toggles */
|
||||
labs: LabsFeatures;
|
||||
|
||||
/** Appearance */
|
||||
fontSize: "small" | "default" | "large";
|
||||
accentHue: number; // 0–360 (HSL)
|
||||
saturation: number; // 20–100 (%)
|
||||
roundness: "sharp" | "default" | "round";
|
||||
animationSpeed: "none" | "fast" | "default" | "slow";
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFS: UserPrefs = {
|
||||
sidebarWidth: 260,
|
||||
detailWidth: 520,
|
||||
density: "default",
|
||||
detailBlockOrder: ["subtasks", "note"],
|
||||
detailSplitRatio: 45,
|
||||
viewMode: "list",
|
||||
labs: {
|
||||
kanbanBoard: false,
|
||||
},
|
||||
fontSize: "default",
|
||||
accentHue: 220,
|
||||
saturation: 75,
|
||||
roundness: "default",
|
||||
animationSpeed: "default",
|
||||
};
|
||||
|
||||
const KEY = "checkflow_prefs_v2";
|
||||
const EVENT = "checkflow:prefsChanged";
|
||||
|
||||
export function loadPrefs(): UserPrefs {
|
||||
if (typeof window === "undefined") return { ...DEFAULT_PREFS };
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (!raw) return { ...DEFAULT_PREFS };
|
||||
const parsed = JSON.parse(raw);
|
||||
// Deep merge labs to ensure new feature flags get defaults
|
||||
return {
|
||||
...DEFAULT_PREFS,
|
||||
...parsed,
|
||||
labs: { ...DEFAULT_PREFS.labs, ...(parsed.labs || {}) },
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_PREFS };
|
||||
}
|
||||
}
|
||||
|
||||
export function savePrefs(patch: Partial<UserPrefs>): UserPrefs {
|
||||
const current = loadPrefs();
|
||||
const next: UserPrefs = {
|
||||
...current,
|
||||
...patch,
|
||||
// Deep merge labs
|
||||
labs: patch.labs ? { ...current.labs, ...patch.labs } : current.labs,
|
||||
};
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(next));
|
||||
window.dispatchEvent(new CustomEvent(EVENT, { detail: next }));
|
||||
} catch { /* storage full */ }
|
||||
return next;
|
||||
}
|
||||
|
||||
export function resetPrefs(): UserPrefs {
|
||||
try {
|
||||
localStorage.removeItem(KEY);
|
||||
// Also remove legacy keys from v0.5
|
||||
localStorage.removeItem("checkflow_block_order");
|
||||
localStorage.removeItem("checkflow_split_ratio");
|
||||
localStorage.removeItem("checkflow_view_mode");
|
||||
window.dispatchEvent(new CustomEvent(EVENT, { detail: { ...DEFAULT_PREFS } }));
|
||||
} catch { /* ignore */ }
|
||||
return { ...DEFAULT_PREFS };
|
||||
}
|
||||
|
||||
/** Compute CSS variable overrides from a UserPrefs snapshot */
|
||||
export function prefsToCssVars(p: UserPrefs): Record<string, string> {
|
||||
const sat = Math.round(p.saturation * 0.9);
|
||||
const accent = `hsl(${p.accentHue}, ${sat}%, 54%)`;
|
||||
const accentHover = `hsl(${p.accentHue}, ${sat}%, 46%)`;
|
||||
const accentLight = `hsla(${p.accentHue}, ${sat}%, 54%, 0.1)`;
|
||||
const accentMedium = `hsla(${p.accentHue}, ${sat}%, 54%, 0.18)`;
|
||||
|
||||
const radMap = {
|
||||
sharp: { xs: "2px", sm: "4px", md: "6px", lg: "8px", xl: "10px" },
|
||||
default: { xs: "4px", sm: "8px", md: "12px", lg: "16px", xl: "20px" },
|
||||
round: { xs: "8px", sm: "14px", md: "18px", lg: "24px", xl: "28px" },
|
||||
};
|
||||
const r = radMap[p.roundness];
|
||||
|
||||
const fontMap = { small: "12px", default: "14px", large: "15px" };
|
||||
|
||||
const durMap = {
|
||||
none: { fast: "0ms", normal: "0ms", slow: "0ms" },
|
||||
fast: { fast: "60ms", normal: "100ms", slow: "160ms" },
|
||||
default: { fast: "120ms", normal: "200ms", slow: "300ms" },
|
||||
slow: { fast: "220ms", normal: "360ms", slow: "500ms" },
|
||||
};
|
||||
const d = durMap[p.animationSpeed];
|
||||
|
||||
const densityMap = {
|
||||
compact: { taskPad: "6px 20px", gap: "2px" },
|
||||
default: { taskPad: "10px 20px", gap: "4px" },
|
||||
comfortable: { taskPad: "14px 20px", gap: "8px" },
|
||||
};
|
||||
const dn = densityMap[p.density];
|
||||
|
||||
return {
|
||||
"--accent": accent,
|
||||
"--accent-hover": accentHover,
|
||||
"--accent-light": accentLight,
|
||||
"--accent-medium": accentMedium,
|
||||
"--radius-xs": r.xs,
|
||||
"--radius-sm": r.sm,
|
||||
"--radius-md": r.md,
|
||||
"--radius-lg": r.lg,
|
||||
"--radius-xl": r.xl,
|
||||
"--font-size-base": fontMap[p.fontSize],
|
||||
"--dur-fast": d.fast,
|
||||
"--dur-normal": d.normal,
|
||||
"--dur-slow": d.slow,
|
||||
"--task-item-padding": dn.taskPad,
|
||||
"--task-item-gap": dn.gap,
|
||||
"--sidebar-width": `${p.sidebarWidth}px`,
|
||||
"--detail-width": `${p.detailWidth}px`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user