| 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | import { Check, Copy, Download, Pencil, Plus, RotateCcw, Trash2, Upload } from "lucide-react"; |
| 3 | import { app } from "../lib/bridge"; |
| 4 | import { useT } from "../lib/i18n"; |
| 5 | import { THEME_STYLES, type ThemeStyle, isThemeStyle } from "../lib/theme"; |
| 6 | import { |
| 7 | type ThemePackBackground, |
| 8 | type ThemePackRecipes, |
| 9 | type ThemePackTokens, |
| 10 | type ThemePackView, |
| 11 | type ThemeSaveInput, |
| 12 | applyThemePack, |
| 13 | beginThemePreview, |
| 14 | cancelThemePreview, |
| 15 | clearThemePack, |
| 16 | commitThemePreview, |
| 17 | defaultBackground, |
| 18 | draftPackView, |
| 19 | emptyThemeTokens, |
| 20 | isSafeHex, |
| 21 | themePackKind, |
| 22 | themeTokenKeys, |
| 23 | } from "../lib/themePack"; |
| 24 | import { useToast } from "../lib/toast"; |
| 25 | import { useConfirmDialog } from "./ConfirmDialog"; |
| 26 | |
| 27 | type EditorState = { |
| 28 | mode: "create" | "edit"; |
| 29 | id: string; |
| 30 | name: string; |
| 31 | author: string; |
| 32 | description: string; |
| 33 | license: string; |
| 34 | baseStyle: ThemeStyle; |
| 35 | tokens: ThemePackTokens; |
| 36 | recipes: ThemePackRecipes; |
| 37 | background: ThemePackBackground | null; |
| 38 | backgroundDataUrl: string; |
| 39 | existingBackgroundUrl: string; |
| 40 | tokenMode: "light" | "dark"; |
| 41 | originalId: string; |
| 42 | }; |
| 43 | |
| 44 | const TOKEN_GROUPS: { labelKey: string; keys: string[] }[] = [ |
| 45 | { labelKey: "settings.themeTokens.surfaces", keys: ["bg", "bgSoft", "bgElev", "panel", "sidebar", "chat", "workspace", "workspaceFiles"] }, |
| 46 | { labelKey: "settings.themeTokens.borderText", keys: ["border", "borderSoft", "fg", "fgDim", "fgFaint"] }, |
| 47 | { labelKey: "settings.themeTokens.accentStatus", keys: ["accent", "accentFg", "ok", "warn", "err"] }, |
| 48 | ]; |
| 49 | |
| 50 | function slugifyId(name: string): string { |
| 51 | const s = name |
| 52 | .toLowerCase() |
| 53 | .replace(/[^a-z0-9]+/g, "-") |
| 54 | .replace(/^-+|-+$/g, "") |
| 55 | .slice(0, 48); |
| 56 | if (!s) return "my-theme"; |
| 57 | if (/^[a-z][a-z0-9-]*[a-z0-9]$|^[a-z]$/.test(s)) return s; |
| 58 | return `t-${s}`.slice(0, 48); |
| 59 | } |
| 60 | |
| 61 | function packToEditor(pack: ThemePackView, mode: "create" | "edit"): EditorState { |
| 62 | return { |
| 63 | mode, |
| 64 | id: mode === "create" ? slugifyId(`${pack.id}-copy`) : pack.id, |
| 65 | name: mode === "create" ? `${pack.name} Copy` : pack.name, |
| 66 | author: pack.author || "", |
| 67 | description: pack.description || "", |
| 68 | license: pack.license || "", |
| 69 | baseStyle: isThemeStyle(pack.baseStyle) ? pack.baseStyle : "graphite", |
| 70 | tokens: { |
| 71 | light: { ...(pack.tokens?.light || {}) }, |
| 72 | dark: { ...(pack.tokens?.dark || {}) }, |
| 73 | }, |
| 74 | recipes: { |
| 75 | density: pack.recipes?.density === "compact" ? "compact" : "comfortable", |
| 76 | corners: pack.recipes?.corners === "square" || pack.recipes?.corners === "round" ? pack.recipes.corners : "soft", |
| 77 | }, |
| 78 | background: pack.background ? { ...pack.background } : null, |
| 79 | backgroundDataUrl: "", |
| 80 | existingBackgroundUrl: pack.backgroundUrl || "", |
| 81 | tokenMode: "dark", |
| 82 | originalId: pack.id, |
| 83 | }; |
| 84 | } |
| 85 | |
| 86 | function emptyEditor(): EditorState { |
| 87 | return { |
| 88 | mode: "create", |
| 89 | id: "my-theme", |
| 90 | name: "My Theme", |
| 91 | author: "", |
| 92 | description: "", |
| 93 | license: "", |
| 94 | baseStyle: "graphite", |
| 95 | tokens: emptyThemeTokens(), |
| 96 | recipes: { density: "comfortable", corners: "soft" }, |
| 97 | background: null, |
| 98 | backgroundDataUrl: "", |
| 99 | existingBackgroundUrl: "", |
| 100 | tokenMode: "dark", |
| 101 | originalId: "", |
| 102 | }; |
| 103 | } |
| 104 | |
| 105 | export function ThemeLibrarySection() { |
| 106 | const t = useT(); |
| 107 | const { showToast } = useToast(); |
| 108 | const { confirm, dialog: confirmDialog } = useConfirmDialog(); |
| 109 | const [packs, setPacks] = useState<ThemePackView[]>([]); |
| 110 | const [loading, setLoading] = useState(true); |
| 111 | const [editor, setEditor] = useState<EditorState | null>(null); |
| 112 | const [busy, setBusy] = useState(false); |
| 113 | const previewTimer = useRef<number | null>(null); |
| 114 | |
| 115 | const reload = useCallback(async () => { |
| 116 | setLoading(true); |
| 117 | try { |
| 118 | const list = await app.ListThemePacks(); |
| 119 | setPacks(list || []); |
| 120 | const active = await app.GetActiveThemePack(); |
| 121 | if (active?.pack) { |
| 122 | commitThemePreview(active.pack); |
| 123 | } else { |
| 124 | const still = (list || []).find((p) => p.active); |
| 125 | if (!still) clearThemePack(); |
| 126 | } |
| 127 | } catch (err) { |
| 128 | showToast(err instanceof Error ? err.message : String(err), "error"); |
| 129 | } finally { |
| 130 | setLoading(false); |
| 131 | } |
| 132 | }, []); |
| 133 | |
| 134 | useEffect(() => { |
| 135 | void reload(); |
| 136 | return () => { |
| 137 | if (previewTimer.current) window.clearTimeout(previewTimer.current); |
| 138 | // Closing settings / leaving the appearance tab must not leave a draft preview applied. |
| 139 | cancelThemePreview(); |
| 140 | }; |
| 141 | }, [reload]); |
| 142 | |
| 143 | const schedulePreview = useCallback((state: EditorState) => { |
| 144 | if (previewTimer.current) window.clearTimeout(previewTimer.current); |
| 145 | previewTimer.current = window.setTimeout(() => { |
| 146 | const bgUrl = state.backgroundDataUrl || state.existingBackgroundUrl || ""; |
| 147 | const draft = draftPackView({ |
| 148 | id: state.id || "preview", |
| 149 | name: state.name, |
| 150 | baseStyle: state.baseStyle, |
| 151 | tokens: state.tokens, |
| 152 | recipes: state.recipes, |
| 153 | background: state.background, |
| 154 | backgroundUrl: bgUrl, |
| 155 | }); |
| 156 | beginThemePreview(draft); |
| 157 | }, 80); |
| 158 | }, []); |
| 159 | |
| 160 | const openCreate = () => { |
| 161 | const state = emptyEditor(); |
| 162 | setEditor(state); |
| 163 | schedulePreview(state); |
| 164 | }; |
| 165 | |
| 166 | const openEdit = (pack: ThemePackView) => { |
| 167 | if (themePackKind(pack) !== "user") { |
| 168 | // Editing a base style means copying into a user theme. |
| 169 | const state = packToEditor(pack, "create"); |
| 170 | setEditor(state); |
| 171 | schedulePreview(state); |
| 172 | return; |
| 173 | } |
| 174 | const state = packToEditor(pack, "edit"); |
| 175 | setEditor(state); |
| 176 | schedulePreview(state); |
| 177 | }; |
| 178 | |
| 179 | const openCopy = async (pack: ThemePackView) => { |
| 180 | setBusy(true); |
| 181 | try { |
| 182 | const newId = slugifyId(`${pack.id}-copy`); |
| 183 | const created = await app.CopyThemePack(pack.id, newId, `${pack.name} Copy`); |
| 184 | showToast(t("settings.themeLibrary.copied", { name: created.name }), "info"); |
| 185 | await reload(); |
| 186 | } catch (err) { |
| 187 | showToast(err instanceof Error ? err.message : String(err), "error"); |
| 188 | } finally { |
| 189 | setBusy(false); |
| 190 | } |
| 191 | }; |
| 192 | |
| 193 | const activate = async (pack: ThemePackView) => { |
| 194 | setBusy(true); |
| 195 | try { |
| 196 | await app.ActivateThemePack(pack.id); |
| 197 | const active = await app.GetActiveThemePack(); |
| 198 | commitThemePreview(active.pack ?? null); |
| 199 | // Sync base style via appearance when activating a pack. |
| 200 | if (active.pack && isThemeStyle(active.pack.baseStyle)) { |
| 201 | // Appearance style stays independent in config; pack overlay supplies baseStyle live. |
| 202 | } |
| 203 | await reload(); |
| 204 | showToast(t("settings.themeLibrary.activated", { name: pack.name }), "info"); |
| 205 | } catch (err) { |
| 206 | showToast(err instanceof Error ? err.message : String(err), "error"); |
| 207 | } finally { |
| 208 | setBusy(false); |
| 209 | } |
| 210 | }; |
| 211 | |
| 212 | const resetDefault = async () => { |
| 213 | setBusy(true); |
| 214 | try { |
| 215 | await app.ResetThemePack(); |
| 216 | cancelThemePreview(); |
| 217 | applyThemePack(null); |
| 218 | await reload(); |
| 219 | showToast(t("settings.themeLibrary.resetDone"), "info"); |
| 220 | } catch (err) { |
| 221 | showToast(err instanceof Error ? err.message : String(err), "error"); |
| 222 | } finally { |
| 223 | setBusy(false); |
| 224 | } |
| 225 | }; |
| 226 | |
| 227 | const remove = async (pack: ThemePackView) => { |
| 228 | if (themePackKind(pack) !== "user") return; |
| 229 | const ok = await confirm({ |
| 230 | title: t("settings.themeLibrary.confirmDeleteTitle"), |
| 231 | message: t("settings.themeLibrary.confirmDelete", { name: packDisplayName(pack, t) }), |
| 232 | confirmLabel: t("common.delete"), |
| 233 | cancelLabel: t("common.cancel"), |
| 234 | tone: "danger", |
| 235 | }); |
| 236 | if (!ok) return; |
| 237 | setBusy(true); |
| 238 | try { |
| 239 | await app.DeleteThemePack(pack.id); |
| 240 | await reload(); |
| 241 | const active = await app.GetActiveThemePack(); |
| 242 | commitThemePreview(active.pack ?? null); |
| 243 | } catch (err) { |
| 244 | showToast(err instanceof Error ? err.message : String(err), "error"); |
| 245 | } finally { |
| 246 | setBusy(false); |
| 247 | } |
| 248 | }; |
| 249 | |
| 250 | const doImport = async (replace = false) => { |
| 251 | setBusy(true); |
| 252 | try { |
| 253 | // First call may open a file dialog. On ID conflict the backend stages the |
| 254 | // extract and returns needsReplace — confirm then call again with replace=true |
| 255 | // (empty path) so the staged import is published without re-picking a file. |
| 256 | const result = await app.ImportThemePack("", replace); |
| 257 | if (!result) return; |
| 258 | if (result.needsReplace) { |
| 259 | const ok = await confirm({ |
| 260 | title: t("settings.themeLibrary.confirmReplaceImportTitle"), |
| 261 | message: t("settings.themeLibrary.confirmReplaceImport"), |
| 262 | confirmLabel: t("settings.themeLibrary.replaceConfirm"), |
| 263 | cancelLabel: t("common.cancel"), |
| 264 | }); |
| 265 | if (!ok) return; |
| 266 | const confirmed = await app.ImportThemePack("", true); |
| 267 | if (!confirmed?.pack?.id) return; |
| 268 | showToast(t("settings.themeLibrary.imported", { name: confirmed.pack.name }), "info"); |
| 269 | await reload(); |
| 270 | return; |
| 271 | } |
| 272 | if (!result.pack?.id) { |
| 273 | // Cancelled |
| 274 | return; |
| 275 | } |
| 276 | showToast(t("settings.themeLibrary.imported", { name: result.pack.name }), "info"); |
| 277 | await reload(); |
| 278 | } catch (err) { |
| 279 | const msg = err instanceof Error ? err.message : String(err); |
| 280 | showToast(msg, "error"); |
| 281 | } finally { |
| 282 | setBusy(false); |
| 283 | } |
| 284 | }; |
| 285 | |
| 286 | const doExport = async (pack: ThemePackView) => { |
| 287 | if (pack.hasBackground) { |
| 288 | const ok = await confirm({ |
| 289 | title: t("settings.themeLibrary.exportRightsTitle"), |
| 290 | message: t("settings.themeLibrary.exportRights"), |
| 291 | confirmLabel: t("settings.themeLibrary.exportConfirm"), |
| 292 | cancelLabel: t("common.cancel"), |
| 293 | }); |
| 294 | if (!ok) return; |
| 295 | } |
| 296 | setBusy(true); |
| 297 | try { |
| 298 | const path = await app.ExportThemePack(pack.id, ""); |
| 299 | if (path) showToast(t("settings.themeLibrary.exported"), "info"); |
| 300 | } catch (err) { |
| 301 | showToast(err instanceof Error ? err.message : String(err), "error"); |
| 302 | } finally { |
| 303 | setBusy(false); |
| 304 | } |
| 305 | }; |
| 306 | |
| 307 | const cancelEditor = () => { |
| 308 | cancelThemePreview(); |
| 309 | setEditor(null); |
| 310 | }; |
| 311 | |
| 312 | const saveEditor = async (activateAfter: boolean) => { |
| 313 | if (!editor) return; |
| 314 | setBusy(true); |
| 315 | try { |
| 316 | const input: ThemeSaveInput = { |
| 317 | id: editor.id.trim(), |
| 318 | name: editor.name.trim(), |
| 319 | author: editor.author, |
| 320 | description: editor.description, |
| 321 | license: editor.license, |
| 322 | baseStyle: editor.baseStyle, |
| 323 | tokens: editor.tokens, |
| 324 | recipes: editor.recipes, |
| 325 | background: editor.background, |
| 326 | backgroundDataUrl: editor.backgroundDataUrl || undefined, |
| 327 | clearBackground: editor.background === null && editor.mode === "edit", |
| 328 | replace: editor.mode === "edit", |
| 329 | activate: activateAfter, |
| 330 | }; |
| 331 | const saved = await app.SaveThemePack(input); |
| 332 | commitThemePreview(activateAfter ? saved : (await app.GetActiveThemePack()).pack ?? null); |
| 333 | setEditor(null); |
| 334 | await reload(); |
| 335 | showToast(t("settings.themeLibrary.saved", { name: saved.name }), "info"); |
| 336 | } catch (err) { |
| 337 | showToast(err instanceof Error ? err.message : String(err), "error"); |
| 338 | } finally { |
| 339 | setBusy(false); |
| 340 | } |
| 341 | }; |
| 342 | |
| 343 | const updateEditor = (patch: Partial<EditorState>) => { |
| 344 | setEditor((prev) => { |
| 345 | if (!prev) return prev; |
| 346 | const next = { ...prev, ...patch }; |
| 347 | schedulePreview(next); |
| 348 | return next; |
| 349 | }); |
| 350 | }; |
| 351 | |
| 352 | const activeId = useMemo(() => packs.find((p) => p.active)?.id ?? "", [packs]); |
| 353 | const groups = useMemo(() => { |
| 354 | const official: ThemePackView[] = []; |
| 355 | const base: ThemePackView[] = []; |
| 356 | const user: ThemePackView[] = []; |
| 357 | const plugin: ThemePackView[] = []; |
| 358 | for (const p of packs) { |
| 359 | const kind = themePackKind(p); |
| 360 | if (kind === "official") official.push(p); |
| 361 | else if (kind === "base") base.push(p); |
| 362 | else if (kind === "plugin") plugin.push(p); |
| 363 | else user.push(p); |
| 364 | } |
| 365 | return { official, base, user, plugin }; |
| 366 | }, [packs]); |
| 367 | |
| 368 | return ( |
| 369 | <div className="theme-library"> |
| 370 | <div className="theme-library__toolbar"> |
| 371 | <button type="button" className="btn btn--small" disabled={busy} onClick={openCreate}> |
| 372 | <Plus size={13} /> {t("settings.themeLibrary.new")} |
| 373 | </button> |
| 374 | <button type="button" className="btn btn--small" disabled={busy} onClick={() => void doImport(false)}> |
| 375 | <Upload size={13} /> {t("settings.themeLibrary.import")} |
| 376 | </button> |
| 377 | <button type="button" className="btn btn--small theme-reset-btn" disabled={busy} onClick={() => void resetDefault()}> |
| 378 | <RotateCcw size={13} /> {t("settings.themeLibrary.reset")} |
| 379 | </button> |
| 380 | </div> |
| 381 | |
| 382 | {loading ? ( |
| 383 | <div className="theme-lib-card__sub">{t("settings.themeLibrary.loading")}</div> |
| 384 | ) : ( |
| 385 | <> |
| 386 | {groups.official.length > 0 && ( |
| 387 | <section className="theme-library__group" data-group="official"> |
| 388 | <h4 className="theme-library__heading">{t("settings.themeLibrary.groupOfficial")}</h4> |
| 389 | <div className="theme-library__grid theme-library__grid--official"> |
| 390 | {groups.official.map((pack) => ( |
| 391 | <OfficialThemeCard |
| 392 | key={pack.id} |
| 393 | pack={pack} |
| 394 | active={pack.id === activeId} |
| 395 | busy={busy} |
| 396 | onActivate={() => void activate(pack)} |
| 397 | onCopy={() => void openCopy(pack)} |
| 398 | /> |
| 399 | ))} |
| 400 | </div> |
| 401 | </section> |
| 402 | )} |
| 403 | |
| 404 | {groups.base.length > 0 && ( |
| 405 | <section className="theme-library__group" data-group="base"> |
| 406 | <h4 className="theme-library__heading">{t("settings.themeLibrary.groupBase")}</h4> |
| 407 | <div className="theme-library__grid theme-library__grid--base"> |
| 408 | {groups.base.map((pack) => ( |
| 409 | <ThemeLibCard |
| 410 | key={pack.id} |
| 411 | pack={pack} |
| 412 | active={pack.id === activeId} |
| 413 | busy={busy} |
| 414 | onActivate={() => void activate(pack)} |
| 415 | onEdit={() => openEdit(pack)} |
| 416 | onCopy={() => void openCopy(pack)} |
| 417 | onExport={() => void doExport(pack)} |
| 418 | onDelete={() => void remove(pack)} |
| 419 | /> |
| 420 | ))} |
| 421 | </div> |
| 422 | </section> |
| 423 | )} |
| 424 | |
| 425 | <section className="theme-library__group" data-group="user"> |
| 426 | <h4 className="theme-library__heading">{t("settings.themeLibrary.groupUser")}</h4> |
| 427 | {groups.user.length === 0 ? ( |
| 428 | <div className="theme-lib-card__sub">{t("settings.themeLibrary.emptyUser")}</div> |
| 429 | ) : ( |
| 430 | <div className="theme-library__grid"> |
| 431 | {groups.user.map((pack) => ( |
| 432 | <ThemeLibCard |
| 433 | key={pack.id} |
| 434 | pack={pack} |
| 435 | active={pack.id === activeId} |
| 436 | busy={busy} |
| 437 | onActivate={() => void activate(pack)} |
| 438 | onEdit={() => openEdit(pack)} |
| 439 | onCopy={() => void openCopy(pack)} |
| 440 | onExport={() => void doExport(pack)} |
| 441 | onDelete={() => void remove(pack)} |
| 442 | /> |
| 443 | ))} |
| 444 | </div> |
| 445 | )} |
| 446 | </section> |
| 447 | |
| 448 | {groups.plugin.length > 0 && ( |
| 449 | <section className="theme-library__group" data-group="plugin"> |
| 450 | <h4 className="theme-library__heading">{t("settings.themeLibrary.groupPlugin")}</h4> |
| 451 | <div className="theme-library__grid"> |
| 452 | {groups.plugin.map((pack) => ( |
| 453 | <ThemeLibCard |
| 454 | key={pack.id} |
| 455 | pack={pack} |
| 456 | active={pack.id === activeId} |
| 457 | busy={busy} |
| 458 | onActivate={() => void activate(pack)} |
| 459 | onEdit={() => openEdit(pack)} |
| 460 | onCopy={() => void openCopy(pack)} |
| 461 | onExport={() => void doExport(pack)} |
| 462 | onDelete={() => void remove(pack)} |
| 463 | /> |
| 464 | ))} |
| 465 | </div> |
| 466 | </section> |
| 467 | )} |
| 468 | </> |
| 469 | )} |
| 470 | |
| 471 | {editor && ( |
| 472 | <ThemeEditor |
| 473 | state={editor} |
| 474 | busy={busy} |
| 475 | onChange={updateEditor} |
| 476 | onCancel={cancelEditor} |
| 477 | onSave={(activateAfter) => void saveEditor(activateAfter)} |
| 478 | /> |
| 479 | )} |
| 480 | {confirmDialog} |
| 481 | </div> |
| 482 | ); |
| 483 | } |
| 484 | |
| 485 | function packDisplayName(pack: ThemePackView, t: (key: never, vars?: Record<string, string | number>) => string): string { |
| 486 | return pack.nameKey ? t(pack.nameKey as never) : pack.name; |
| 487 | } |
| 488 | |
| 489 | function packDescription(pack: ThemePackView, t: (key: never, vars?: Record<string, string | number>) => string): string { |
| 490 | if (pack.descriptionKey) return t(pack.descriptionKey as never); |
| 491 | return pack.description || ""; |
| 492 | } |
| 493 | |
| 494 | function OfficialThemeCard({ |
| 495 | pack, |
| 496 | active, |
| 497 | busy, |
| 498 | onActivate, |
| 499 | onCopy, |
| 500 | }: { |
| 501 | pack: ThemePackView; |
| 502 | active: boolean; |
| 503 | busy: boolean; |
| 504 | onActivate: () => void; |
| 505 | onCopy: () => void; |
| 506 | }) { |
| 507 | const t = useT(); |
| 508 | const name = packDisplayName(pack, t); |
| 509 | const desc = packDescription(pack, t); |
| 510 | const lightBg = pack.tokens?.light?.bg || "#f4f3ef"; |
| 511 | const darkBg = pack.tokens?.dark?.bg || "#0c0d10"; |
| 512 | const accent = pack.tokens?.dark?.accent || pack.tokens?.light?.accent || "#ff6a3d"; |
| 513 | |
| 514 | return ( |
| 515 | <div className={`theme-lib-card theme-lib-card--official${active ? " theme-lib-card--on" : ""}`}> |
| 516 | <div className="theme-lib-card__thumb theme-lib-card__thumb--img"> |
| 517 | {pack.previewUrl ? ( |
| 518 | <img src={pack.previewUrl} alt={name} loading="lazy" decoding="async" /> |
| 519 | ) : ( |
| 520 | <div className="theme-lib-card__thumb-fallback" style={{ background: `linear-gradient(120deg, ${lightBg} 0%, ${lightBg} 55%, ${accent} 140%)` }} /> |
| 521 | )} |
| 522 | </div> |
| 523 | <div className="theme-lib-card__meta"> |
| 524 | <div className="theme-lib-card__name"> |
| 525 | {name} {active ? <Check size={12} style={{ display: "inline", verticalAlign: "middle" }} /> : null} |
| 526 | </div> |
| 527 | {desc ? <div className="theme-lib-card__desc">{desc}</div> : null} |
| 528 | <div className="theme-lib-card__sub"> |
| 529 | {pack.license || "MIT"} · {pack.author || "Reasonix Contributors"} |
| 530 | </div> |
| 531 | </div> |
| 532 | <div className="theme-lib-card__swatches" aria-hidden="true"> |
| 533 | <span className="theme-lib-card__swatch" style={{ background: lightBg }} /> |
| 534 | <span className="theme-lib-card__swatch" style={{ background: darkBg }} /> |
| 535 | <span className="theme-lib-card__swatch" style={{ background: accent }} /> |
| 536 | </div> |
| 537 | <div className="theme-lib-card__actions"> |
| 538 | <button type="button" className="btn btn--small btn--primary" disabled={busy || active} onClick={onActivate}> |
| 539 | {active ? t("settings.themeLibrary.active") : t("settings.themeLibrary.enable")} |
| 540 | </button> |
| 541 | <button type="button" className="btn btn--small" disabled={busy} onClick={onCopy}> |
| 542 | <Copy size={12} /> {t("settings.themeLibrary.copyFrom")} |
| 543 | </button> |
| 544 | </div> |
| 545 | </div> |
| 546 | ); |
| 547 | } |
| 548 | |
| 549 | function ThemeLibCard({ |
| 550 | pack, |
| 551 | active, |
| 552 | busy, |
| 553 | onActivate, |
| 554 | onEdit, |
| 555 | onCopy, |
| 556 | onExport, |
| 557 | onDelete, |
| 558 | }: { |
| 559 | pack: ThemePackView; |
| 560 | active: boolean; |
| 561 | busy: boolean; |
| 562 | onActivate: () => void; |
| 563 | onEdit: () => void; |
| 564 | onCopy: () => void; |
| 565 | onExport: () => void; |
| 566 | onDelete: () => void; |
| 567 | }) { |
| 568 | const t = useT(); |
| 569 | const kind = themePackKind(pack); |
| 570 | const lightBg = pack.tokens?.light?.bg || "#f4f3ef"; |
| 571 | const darkBg = pack.tokens?.dark?.bg || "#0c0d10"; |
| 572 | const accent = pack.tokens?.dark?.accent || pack.tokens?.light?.accent || "#ff6a3d"; |
| 573 | const thumbStyle: Record<string, string> = pack.backgroundUrl |
| 574 | ? { backgroundImage: `url("${pack.backgroundUrl}")`, backgroundSize: "cover" } |
| 575 | : { ["--thumb-light"]: lightBg, ["--thumb-dark"]: darkBg }; |
| 576 | |
| 577 | return ( |
| 578 | <div className={`theme-lib-card${active ? " theme-lib-card--on" : ""}`}> |
| 579 | <div className="theme-lib-card__thumb" style={thumbStyle} /> |
| 580 | <div className="theme-lib-card__meta"> |
| 581 | <div className="theme-lib-card__name"> |
| 582 | {pack.name} {active ? <Check size={12} style={{ display: "inline", verticalAlign: "middle" }} /> : null} |
| 583 | </div> |
| 584 | <div className="theme-lib-card__sub"> |
| 585 | {kind === "base" |
| 586 | ? t("settings.themeLibrary.builtin") |
| 587 | : kind === "plugin" |
| 588 | ? pack.pluginName |
| 589 | ? t("settings.themeGallery.kindPlugin", { name: pack.pluginName }) |
| 590 | : t("settings.themeGallery.kindPluginUnknown") |
| 591 | : pack.author || t("settings.themeLibrary.userTheme")} |
| 592 | {" · "} |
| 593 | {pack.baseStyle} |
| 594 | </div> |
| 595 | </div> |
| 596 | <div className="theme-lib-card__swatches" aria-hidden="true"> |
| 597 | <span className="theme-lib-card__swatch" style={{ background: lightBg }} /> |
| 598 | <span className="theme-lib-card__swatch" style={{ background: darkBg }} /> |
| 599 | <span className="theme-lib-card__swatch" style={{ background: accent }} /> |
| 600 | </div> |
| 601 | <div className="theme-lib-card__actions"> |
| 602 | <button type="button" className="btn btn--small btn--primary" disabled={busy || active} onClick={onActivate}> |
| 603 | {active ? t("settings.themeLibrary.active") : t("settings.themeLibrary.enable")} |
| 604 | </button> |
| 605 | {kind === "user" && ( |
| 606 | <button type="button" className="btn btn--small" disabled={busy} onClick={onEdit} title={t("settings.themeLibrary.edit")}> |
| 607 | <Pencil size={12} /> |
| 608 | </button> |
| 609 | )} |
| 610 | {kind !== "plugin" && ( |
| 611 | <button type="button" className="btn btn--small" disabled={busy} onClick={onCopy} title={t("settings.themeLibrary.copy")}> |
| 612 | <Copy size={12} /> |
| 613 | </button> |
| 614 | )} |
| 615 | {kind === "user" && ( |
| 616 | <> |
| 617 | <button type="button" className="btn btn--small" disabled={busy} onClick={onExport} title={t("settings.themeLibrary.export")}> |
| 618 | <Download size={12} /> |
| 619 | </button> |
| 620 | <button type="button" className="btn btn--small" disabled={busy} onClick={onDelete} title={t("settings.themeLibrary.delete")}> |
| 621 | <Trash2 size={12} /> |
| 622 | </button> |
| 623 | </> |
| 624 | )} |
| 625 | </div> |
| 626 | </div> |
| 627 | ); |
| 628 | } |
| 629 | |
| 630 | function ThemeEditor({ |
| 631 | state, |
| 632 | busy, |
| 633 | onChange, |
| 634 | onCancel, |
| 635 | onSave, |
| 636 | }: { |
| 637 | state: EditorState; |
| 638 | busy: boolean; |
| 639 | onChange: (patch: Partial<EditorState>) => void; |
| 640 | onCancel: () => void; |
| 641 | onSave: (activate: boolean) => void; |
| 642 | }) { |
| 643 | const t = useT(); |
| 644 | const { showToast } = useToast(); |
| 645 | const previewRef = useRef<HTMLDivElement>(null); |
| 646 | const dragging = useRef(false); |
| 647 | |
| 648 | const setToken = (key: string, value: string) => { |
| 649 | const mode = state.tokenMode; |
| 650 | const nextTokens = { |
| 651 | ...state.tokens, |
| 652 | [mode]: { ...(state.tokens[mode] || {}), [key]: value }, |
| 653 | }; |
| 654 | // Allow empty to clear override |
| 655 | if (!value) { |
| 656 | const map = { ...(nextTokens[mode] || {}) }; |
| 657 | delete map[key]; |
| 658 | nextTokens[mode] = map; |
| 659 | } else if (!isSafeHex(value) && value.length >= 7) { |
| 660 | // Keep typing intermediate values without applying invalid hex to preview tokens fully |
| 661 | } |
| 662 | onChange({ tokens: nextTokens }); |
| 663 | }; |
| 664 | |
| 665 | const pickBackground = async () => { |
| 666 | try { |
| 667 | const dataUrl = await app.PickThemeBackground(); |
| 668 | if (!dataUrl) return; |
| 669 | const bg = state.background ? { ...state.background } : defaultBackground(); |
| 670 | onChange({ |
| 671 | background: bg, |
| 672 | backgroundDataUrl: dataUrl, |
| 673 | existingBackgroundUrl: "", |
| 674 | }); |
| 675 | } catch (err) { |
| 676 | showToast(err instanceof Error ? err.message : String(err), "error"); |
| 677 | } |
| 678 | }; |
| 679 | |
| 680 | const onFocusPointer = (clientX: number, clientY: number) => { |
| 681 | const el = previewRef.current; |
| 682 | if (!el || !state.background) return; |
| 683 | const rect = el.getBoundingClientRect(); |
| 684 | const x = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); |
| 685 | const y = Math.min(1, Math.max(0, (clientY - rect.top) / rect.height)); |
| 686 | onChange({ background: { ...state.background, focusX: x, focusY: y } }); |
| 687 | }; |
| 688 | |
| 689 | const bgUrl = state.backgroundDataUrl || state.existingBackgroundUrl; |
| 690 | const warnings = useMemo(() => { |
| 691 | // Client-side soft check mirroring backend pairs. |
| 692 | const out: string[] = []; |
| 693 | for (const mode of ["light", "dark"] as const) { |
| 694 | const fg = state.tokens[mode]?.fg; |
| 695 | const bg = state.tokens[mode]?.bg; |
| 696 | if (fg && bg && isSafeHex(fg) && isSafeHex(bg)) { |
| 697 | const ratio = contrastRatio(fg, bg); |
| 698 | if (ratio < 4.5) out.push(`${mode} fg/bg ${ratio.toFixed(2)} < 4.5`); |
| 699 | } |
| 700 | } |
| 701 | return out; |
| 702 | }, [state.tokens]); |
| 703 | |
| 704 | return ( |
| 705 | <div className="theme-editor"> |
| 706 | <strong>{state.mode === "create" ? t("settings.themeLibrary.editorCreate") : t("settings.themeLibrary.editorEdit")}</strong> |
| 707 | |
| 708 | <div className="theme-editor__row"> |
| 709 | <div className="theme-editor__label">{t("settings.themeLibrary.fieldId")}</div> |
| 710 | <div className="theme-editor__fields"> |
| 711 | <input |
| 712 | value={state.id} |
| 713 | disabled={state.mode === "edit" || busy} |
| 714 | onChange={(e) => onChange({ id: e.target.value })} |
| 715 | /> |
| 716 | <input |
| 717 | value={state.name} |
| 718 | disabled={busy} |
| 719 | placeholder={t("settings.themeLibrary.fieldName")} |
| 720 | onChange={(e) => onChange({ name: e.target.value })} |
| 721 | /> |
| 722 | <input |
| 723 | value={state.author} |
| 724 | disabled={busy} |
| 725 | placeholder={t("settings.themeLibrary.fieldAuthor")} |
| 726 | onChange={(e) => onChange({ author: e.target.value })} |
| 727 | /> |
| 728 | </div> |
| 729 | </div> |
| 730 | |
| 731 | <div className="theme-editor__row"> |
| 732 | <div className="theme-editor__label">{t("settings.themeLibrary.fieldBase")}</div> |
| 733 | <div className="set-seg"> |
| 734 | {THEME_STYLES.map((s) => ( |
| 735 | <button |
| 736 | key={s} |
| 737 | type="button" |
| 738 | className={`set-seg__btn${state.baseStyle === s ? " set-seg__btn--on" : ""}`} |
| 739 | disabled={busy} |
| 740 | onClick={() => onChange({ baseStyle: s })} |
| 741 | > |
| 742 | {s} |
| 743 | </button> |
| 744 | ))} |
| 745 | </div> |
| 746 | </div> |
| 747 | |
| 748 | <div className="theme-editor__row"> |
| 749 | <div className="theme-editor__label">{t("settings.themeLibrary.fieldRecipes")}</div> |
| 750 | <div className="theme-editor__fields"> |
| 751 | <div className="set-seg"> |
| 752 | {(["comfortable", "compact"] as const).map((d) => ( |
| 753 | <button |
| 754 | key={d} |
| 755 | type="button" |
| 756 | className={`set-seg__btn${state.recipes.density === d ? " set-seg__btn--on" : ""}`} |
| 757 | disabled={busy} |
| 758 | onClick={() => onChange({ recipes: { ...state.recipes, density: d } })} |
| 759 | > |
| 760 | {d} |
| 761 | </button> |
| 762 | ))} |
| 763 | </div> |
| 764 | <div className="set-seg"> |
| 765 | {(["square", "soft", "round"] as const).map((c) => ( |
| 766 | <button |
| 767 | key={c} |
| 768 | type="button" |
| 769 | className={`set-seg__btn${state.recipes.corners === c ? " set-seg__btn--on" : ""}`} |
| 770 | disabled={busy} |
| 771 | onClick={() => onChange({ recipes: { ...state.recipes, corners: c } })} |
| 772 | > |
| 773 | {c} |
| 774 | </button> |
| 775 | ))} |
| 776 | </div> |
| 777 | </div> |
| 778 | </div> |
| 779 | |
| 780 | <div className="theme-editor__row"> |
| 781 | <div className="theme-editor__label">{t("settings.themeLibrary.fieldTokens")}</div> |
| 782 | <div className="theme-editor__fields"> |
| 783 | <div className="set-seg"> |
| 784 | {(["dark", "light"] as const).map((m) => ( |
| 785 | <button |
| 786 | key={m} |
| 787 | type="button" |
| 788 | className={`set-seg__btn${state.tokenMode === m ? " set-seg__btn--on" : ""}`} |
| 789 | onClick={() => onChange({ tokenMode: m })} |
| 790 | > |
| 791 | {m} |
| 792 | </button> |
| 793 | ))} |
| 794 | </div> |
| 795 | {TOKEN_GROUPS.map((group) => ( |
| 796 | <div key={group.labelKey}> |
| 797 | <div className="theme-lib-card__sub" style={{ marginBottom: 6 }}>{t(group.labelKey as never)}</div> |
| 798 | <div className="theme-editor__color-grid"> |
| 799 | {group.keys.filter((k) => themeTokenKeys().includes(k)).map((key) => { |
| 800 | const val = state.tokens[state.tokenMode]?.[key] || ""; |
| 801 | const colorVal = isSafeHex(val) ? val.slice(0, 7) : "#888888"; |
| 802 | return ( |
| 803 | <label key={key} className="theme-editor__color"> |
| 804 | <span>{key}</span> |
| 805 | <input |
| 806 | type="color" |
| 807 | value={colorVal} |
| 808 | disabled={busy} |
| 809 | onChange={(e) => setToken(key, e.target.value)} |
| 810 | /> |
| 811 | <input |
| 812 | type="text" |
| 813 | value={val} |
| 814 | placeholder="#RRGGBB" |
| 815 | disabled={busy} |
| 816 | onChange={(e) => setToken(key, e.target.value.trim())} |
| 817 | /> |
| 818 | </label> |
| 819 | ); |
| 820 | })} |
| 821 | </div> |
| 822 | </div> |
| 823 | ))} |
| 824 | </div> |
| 825 | </div> |
| 826 | |
| 827 | <div className="theme-editor__row"> |
| 828 | <div className="theme-editor__label">{t("settings.themeLibrary.fieldBackground")}</div> |
| 829 | <div className="theme-editor__fields"> |
| 830 | <div className="theme-library__toolbar"> |
| 831 | <button type="button" className="btn btn--small" disabled={busy} onClick={() => void pickBackground()}> |
| 832 | {t("settings.themeLibrary.pickImage")} |
| 833 | </button> |
| 834 | <button |
| 835 | type="button" |
| 836 | className="btn btn--small" |
| 837 | disabled={busy || (!state.background && !bgUrl)} |
| 838 | onClick={() => onChange({ background: null, backgroundDataUrl: "", existingBackgroundUrl: "" })} |
| 839 | > |
| 840 | {t("settings.themeLibrary.clearImage")} |
| 841 | </button> |
| 842 | </div> |
| 843 | {state.background && ( |
| 844 | <> |
| 845 | <div |
| 846 | ref={previewRef} |
| 847 | className="theme-editor__bg-preview" |
| 848 | style={bgUrl ? { backgroundImage: `url("${bgUrl}")` } : undefined} |
| 849 | onPointerDown={(e) => { |
| 850 | dragging.current = true; |
| 851 | (e.target as HTMLElement).setPointerCapture?.(e.pointerId); |
| 852 | onFocusPointer(e.clientX, e.clientY); |
| 853 | }} |
| 854 | onPointerMove={(e) => { |
| 855 | if (!dragging.current) return; |
| 856 | onFocusPointer(e.clientX, e.clientY); |
| 857 | }} |
| 858 | onPointerUp={() => { |
| 859 | dragging.current = false; |
| 860 | }} |
| 861 | > |
| 862 | <span |
| 863 | className="theme-editor__focus" |
| 864 | style={{ left: `${(state.background.focusX ?? 0.5) * 100}%`, top: `${(state.background.focusY ?? 0.5) * 100}%` }} |
| 865 | /> |
| 866 | </div> |
| 867 | <div className="set-seg"> |
| 868 | {(["left", "center", "right"] as const).map((s) => ( |
| 869 | <button |
| 870 | key={s} |
| 871 | type="button" |
| 872 | className={`set-seg__btn${state.background?.safeArea === s ? " set-seg__btn--on" : ""}`} |
| 873 | onClick={() => onChange({ background: { ...state.background!, safeArea: s } })} |
| 874 | > |
| 875 | {s} |
| 876 | </button> |
| 877 | ))} |
| 878 | </div> |
| 879 | <label className="theme-editor__color"> |
| 880 | {t("settings.themeLibrary.homeOpacity")} |
| 881 | <input |
| 882 | type="range" |
| 883 | min={0} |
| 884 | max={1} |
| 885 | step={0.01} |
| 886 | value={state.background.homeOpacity} |
| 887 | onChange={(e) => onChange({ background: { ...state.background!, homeOpacity: Number(e.target.value) } })} |
| 888 | /> |
| 889 | </label> |
| 890 | <label className="theme-editor__color"> |
| 891 | {t("settings.themeLibrary.taskOpacity")} |
| 892 | <input |
| 893 | type="range" |
| 894 | min={0} |
| 895 | max={0.45} |
| 896 | step={0.01} |
| 897 | value={state.background.taskOpacity} |
| 898 | onChange={(e) => onChange({ background: { ...state.background!, taskOpacity: Number(e.target.value) } })} |
| 899 | /> |
| 900 | </label> |
| 901 | <label className="theme-editor__color"> |
| 902 | {t("settings.themeLibrary.overlayStrength")} |
| 903 | <input |
| 904 | type="range" |
| 905 | min={0} |
| 906 | max={1} |
| 907 | step={0.01} |
| 908 | value={state.background.overlayStrength} |
| 909 | onChange={(e) => onChange({ background: { ...state.background!, overlayStrength: Number(e.target.value) } })} |
| 910 | /> |
| 911 | </label> |
| 912 | </> |
| 913 | )} |
| 914 | </div> |
| 915 | </div> |
| 916 | |
| 917 | {warnings.length > 0 && ( |
| 918 | <div className="theme-editor__warn"> |
| 919 | {t("settings.themeLibrary.contrastWarn")} |
| 920 | <ul style={{ margin: "6px 0 0", paddingLeft: 18 }}> |
| 921 | {warnings.map((w) => ( |
| 922 | <li key={w}>{w}</li> |
| 923 | ))} |
| 924 | </ul> |
| 925 | </div> |
| 926 | )} |
| 927 | |
| 928 | <div className="theme-editor__actions"> |
| 929 | <button type="button" className="btn btn--small" disabled={busy} onClick={onCancel}> |
| 930 | {t("settings.themeLibrary.cancel")} |
| 931 | </button> |
| 932 | <button type="button" className="btn btn--small" disabled={busy} onClick={() => onSave(false)}> |
| 933 | {t("settings.themeLibrary.save")} |
| 934 | </button> |
| 935 | <button type="button" className="btn btn--small btn--primary" disabled={busy} onClick={() => onSave(true)}> |
| 936 | {t("settings.themeLibrary.saveEnable")} |
| 937 | </button> |
| 938 | </div> |
| 939 | </div> |
| 940 | ); |
| 941 | } |
| 942 | |
| 943 | function contrastRatio(a: string, b: string): number { |
| 944 | const la = relativeLuminance(a); |
| 945 | const lb = relativeLuminance(b); |
| 946 | const lighter = Math.max(la, lb); |
| 947 | const darker = Math.min(la, lb); |
| 948 | return (lighter + 0.05) / (darker + 0.05); |
| 949 | } |
| 950 | |
| 951 | function relativeLuminance(hex: string): number { |
| 952 | const n = hex.replace("#", ""); |
| 953 | const r = parseInt(n.slice(0, 2), 16) / 255; |
| 954 | const g = parseInt(n.slice(2, 4), 16) / 255; |
| 955 | const b = parseInt(n.slice(4, 6), 16) / 255; |
| 956 | const lin = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); |
| 957 | return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); |
| 958 | } |
| 959 |