import { lazy, Suspense, useCallback, useSyncExternalStore } from "react"; import { useTheme } from "../../hooks/useTheme";import { getThemeDefinition, subscribeToCustomThemes, type ThemeAppearance, type ThemeDefinition,} from "../../themePalette";import { stackedThreadToast, toastManager } from "../ui/toast";import { useThemeEditorStore } from "./themeEditorStore"; // The host mounts above the router on every page, but the editor body only// renders once a session opens; lazy-loading it keeps the editor UI out of// the startup chunk.const ThemeEditorPanel = lazy(() => import("./ThemeEditorPanel").then((module) => ({ default: module.ThemeEditorPanel })),); function useThemeDefinition(id: string | null | undefined) { return useSyncExternalStore( subscribeToCustomThemes, () => (id ? (getThemeDefinition(id) ?? null) : null), () => null, );} /** * Renders the theme editor above the router. The editor paints its draft on * the live app, so it has to outlive the settings route: the point is to walk * through threads, panels, and pages while the colors are being tuned. */export function ThemeEditorHost() { const session = useThemeEditorStore((store) => store.session); const closeThemeEditor = useThemeEditorStore((store) => store.closeThemeEditor); const { theme, setTheme, themeHalves, refreshTheme } = useTheme(); // A saved definition can change without its id changing between sessions. const editingTheme = useThemeDefinition(session?.editingThemeId); const seedTheme = useThemeDefinition(session?.seedThemeId); // The panel reports which path it actually took: a theme removed while its // editor is open resolves to null there, so the save becomes a create even // though the session still names it. const handleSaved = useCallback( ( savedTheme: ThemeDefinition, { created, mergedAppearance }: { created: boolean; mergedAppearance?: ThemeAppearance }, ) => { // A merge completed an existing theme's light/dark pair; activating the // whole theme shows the new palette right away. if (mergedAppearance) { if (!setTheme(savedTheme.id)) { toastManager.add( stackedThreadToast({ type: "error", title: "Could not save your theme", description: "Browser storage is unavailable, so the change was not kept.", }), ); return false; } toastManager.add( stackedThreadToast({ type: "success", title: `${savedTheme.label} updated`, description: `Its ${mergedAppearance} palette was added.`, }), ); return true; } if (!created) { // The edited theme may be showing through the base preference or either // half of the mix; the preference itself is untouched (a setTheme here // would clear the mix), the palette just needs re-applying. const wasActive = getThemeDefinition(theme)?.id === savedTheme.id || themeHalves?.light === savedTheme.id || themeHalves?.dark === savedTheme.id; if (wasActive) refreshTheme(); toastManager.add( stackedThreadToast({ type: "success", title: `${savedTheme.label} saved`, description: wasActive ? "Your changes are now active." : "Your changes are saved.", }), ); return true; } if (!setTheme(savedTheme.id)) { toastManager.add( stackedThreadToast({ type: "error", title: "Could not save your theme", description: "Browser storage is unavailable, so the change was not kept.", }), ); return false; } toastManager.add( stackedThreadToast({ type: "success", title: `${savedTheme.label} created`, description: "It’s now active.", }), ); return true; }, [refreshTheme, setTheme, theme, themeHalves], ); if (!session) return null; return ( <Suspense fallback={null}> <ThemeEditorPanel editingTheme={editingTheme} initialAppearance={session.initialAppearance} key={session.id} onOpenChange={(open) => { if (!open) closeThemeEditor(); }} onSaved={handleSaved} open restoreTheme={refreshTheme} seedName={session.seedName ?? undefined} seedTheme={seedTheme} /> </Suspense> );}
import { lazy, Suspense, useCallback, useSyncExternalStore } from "react";
import { useTheme } from "../../hooks/useTheme";
import {
getThemeDefinition,
subscribeToCustomThemes,
type ThemeAppearance,
type ThemeDefinition,
} from "../../themePalette";
import { stackedThreadToast, toastManager } from "../ui/toast";
import { useThemeEditorStore } from "./themeEditorStore";
// The host mounts above the router on every page, but the editor body only
// renders once a session opens; lazy-loading it keeps the editor UI out of
// the startup chunk.
const ThemeEditorPanel = lazy(() =>
import("./ThemeEditorPanel").then((module) => ({ default: module.ThemeEditorPanel })),
);
function useThemeDefinition(id: string | null | undefined) {
return useSyncExternalStore(
subscribeToCustomThemes,
() => (id ? (getThemeDefinition(id) ?? null) : null),
() => null,
);
}
/**
* Renders the theme editor above the router. The editor paints its draft on
* the live app, so it has to outlive the settings route: the point is to walk
* through threads, panels, and pages while the colors are being tuned.
*/
export function ThemeEditorHost() {
const session = useThemeEditorStore((store) => store.session);
const closeThemeEditor = useThemeEditorStore((store) => store.closeThemeEditor);
const { theme, setTheme, themeHalves, refreshTheme } = useTheme();
// A saved definition can change without its id changing between sessions.
const editingTheme = useThemeDefinition(session?.editingThemeId);
const seedTheme = useThemeDefinition(session?.seedThemeId);
// The panel reports which path it actually took: a theme removed while its
// editor is open resolves to null there, so the save becomes a create even
// though the session still names it.
const handleSaved = useCallback(
(
savedTheme: ThemeDefinition,
{ created, mergedAppearance }: { created: boolean; mergedAppearance?: ThemeAppearance },
) => {
// A merge completed an existing theme's light/dark pair; activating the
// whole theme shows the new palette right away.
if (mergedAppearance) {
if (!setTheme(savedTheme.id)) {
toastManager.add(
stackedThreadToast({
type: "error",
title: "Could not save your theme",
description: "Browser storage is unavailable, so the change was not kept.",
}),
);
return false;
}
toastManager.add(
stackedThreadToast({
type: "success",
title: `${savedTheme.label} updated`,
description: `Its ${mergedAppearance} palette was added.`,
}),
);
return true;
}
if (!created) {
// The edited theme may be showing through the base preference or either
// half of the mix; the preference itself is untouched (a setTheme here
// would clear the mix), the palette just needs re-applying.
const wasActive =
getThemeDefinition(theme)?.id === savedTheme.id ||
themeHalves?.light === savedTheme.id ||
themeHalves?.dark === savedTheme.id;
if (wasActive) refreshTheme();
toastManager.add(
stackedThreadToast({
type: "success",
title: `${savedTheme.label} saved`,
description: wasActive ? "Your changes are now active." : "Your changes are saved.",
}),
);
return true;
}
if (!setTheme(savedTheme.id)) {
toastManager.add(
stackedThreadToast({
type: "error",
title: "Could not save your theme",
description: "Browser storage is unavailable, so the change was not kept.",
}),
);
return false;
}
toastManager.add(
stackedThreadToast({
type: "success",
title: `${savedTheme.label} created`,
description: "It’s now active.",
}),
);
return true;
},
[refreshTheme, setTheme, theme, themeHalves],
);
if (!session) return null;
return (
<Suspense fallback={null}>
<ThemeEditorPanel
editingTheme={editingTheme}
initialAppearance={session.initialAppearance}
key={session.id}
onOpenChange={(open) => {
if (!open) closeThemeEditor();
}}
onSaved={handleSaved}
open
restoreTheme={refreshTheme}
seedName={session.seedName ?? undefined}
seedTheme={seedTheme}
/>
</Suspense>
);
}