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