1import { lazy, Suspense, useCallback, useSyncExternalStore } from "react";
2
3import { useTheme } from "../../hooks/useTheme";
4import {
5 getThemeDefinition,
6 subscribeToCustomThemes,
7 type ThemeAppearance,
8 type ThemeDefinition,
9} from "../../themePalette";
10import { stackedThreadToast, toastManager } from "../ui/toast";
11import { useThemeEditorStore } from "./themeEditorStore";
12
13// The host mounts above the router on every page, but the editor body only
14// renders once a session opens; lazy-loading it keeps the editor UI out of
15// the startup chunk.
16const ThemeEditorPanel = lazy(() =>
17 import("./ThemeEditorPanel").then((module) => ({ default: module.ThemeEditorPanel })),
18);
19
20function useThemeDefinition(id: string | null | undefined) {
21 return useSyncExternalStore(
22 subscribeToCustomThemes,
23 () => (id ? (getThemeDefinition(id) ?? null) : null),
24 () => null,
25 );
26}
27
28/**
29 * Renders the theme editor above the router. The editor paints its draft on
30 * the live app, so it has to outlive the settings route: the point is to walk
31 * through threads, panels, and pages while the colors are being tuned.
32 */
33export function ThemeEditorHost() {
34 const session = useThemeEditorStore((store) => store.session);
35 const closeThemeEditor = useThemeEditorStore((store) => store.closeThemeEditor);
36 const { theme, setTheme, themeHalves, refreshTheme } = useTheme();
37 // A saved definition can change without its id changing between sessions.
38 const editingTheme = useThemeDefinition(session?.editingThemeId);
39 const seedTheme = useThemeDefinition(session?.seedThemeId);
40
41 // The panel reports which path it actually took: a theme removed while its
42 // editor is open resolves to null there, so the save becomes a create even
43 // though the session still names it.
44 const handleSaved = useCallback(
45 (
46 savedTheme: ThemeDefinition,
47 { created, mergedAppearance }: { created: boolean; mergedAppearance?: ThemeAppearance },
48 ) => {
49 // A merge completed an existing theme's light/dark pair; activating the
50 // whole theme shows the new palette right away.
51 if (mergedAppearance) {
52 if (!setTheme(savedTheme.id)) {
53 toastManager.add(
54 stackedThreadToast({
55 type: "error",
56 title: "Could not save your theme",
57 description: "Browser storage is unavailable, so the change was not kept.",
58 }),
59 );
60 return false;
61 }
62 toastManager.add(
63 stackedThreadToast({
64 type: "success",
65 title: `${savedTheme.label} updated`,
66 description: `Its ${mergedAppearance} palette was added.`,
67 }),
68 );
69 return true;
70 }
71 if (!created) {
72 // The edited theme may be showing through the base preference or either
73 // half of the mix; the preference itself is untouched (a setTheme here
74 // would clear the mix), the palette just needs re-applying.
75 const wasActive =
76 getThemeDefinition(theme)?.id === savedTheme.id ||
77 themeHalves?.light === savedTheme.id ||
78 themeHalves?.dark === savedTheme.id;
79 if (wasActive) refreshTheme();
80 toastManager.add(
81 stackedThreadToast({
82 type: "success",
83 title: `${savedTheme.label} saved`,
84 description: wasActive ? "Your changes are now active." : "Your changes are saved.",
85 }),
86 );
87 return true;
88 }
89
90 if (!setTheme(savedTheme.id)) {
91 toastManager.add(
92 stackedThreadToast({
93 type: "error",
94 title: "Could not save your theme",
95 description: "Browser storage is unavailable, so the change was not kept.",
96 }),
97 );
98 return false;
99 }
100 toastManager.add(
101 stackedThreadToast({
102 type: "success",
103 title: `${savedTheme.label} created`,
104 description: "It’s now active.",
105 }),
106 );
107 return true;
108 },
109 [refreshTheme, setTheme, theme, themeHalves],
110 );
111
112 if (!session) return null;
113
114 return (
115 <Suspense fallback={null}>
116 <ThemeEditorPanel
117 editingTheme={editingTheme}
118 initialAppearance={session.initialAppearance}
119 key={session.id}
120 onOpenChange={(open) => {
121 if (!open) closeThemeEditor();
122 }}
123 onSaved={handleSaved}
124 open
125 restoreTheme={refreshTheme}
126 seedName={session.seedName ?? undefined}
127 seedTheme={seedTheme}
128 />
129 </Suspense>
130 );
131}