Files
picpeak/frontend/src/utils/themeMigration.ts
T

170 lines
5.6 KiB
TypeScript

import type { ThemeConfig, HeaderStyleType, HeroDividerStyle, GalleryLayoutType } from '../types/theme.types';
/**
* Surface defaults for the two color modes — the same values applyTheme()
* falls back to when a theme has no explicit surface/elevated/border/text
* tokens. Exposed here so the force-color-mode helper can swap them
* wholesale when an admin locks the instance to a mode that the active
* theme doesn't natively support.
*/
const DARK_SURFACE_DEFAULTS = {
backgroundColor: '#0f0f0f',
surfaceColor: '#1a1a1a',
elevatedColor: '#242424',
surfaceBorderColor: '#2e2e2e',
textColor: '#e5e5e5',
mutedTextColor: '#a3a3a3',
};
const LIGHT_SURFACE_DEFAULTS = {
backgroundColor: '#fafafa',
surfaceColor: '#ffffff',
elevatedColor: '#f5f5f5',
surfaceBorderColor: '#e5e5e5',
textColor: '#171717',
mutedTextColor: '#737373',
};
/**
* Apply an instance-wide force color mode lock to a theme config.
*
* If the theme already matches the locked mode (or no lock is set), only
* the colorMode flag is pinned. If the theme is locked to a mode it
* doesn't natively support (e.g. an admin set Force Dark but is opening
* a light gallery preset), the surface/text tokens are replaced with the
* matching mode's defaults — the user's accent/accentDark colours are
* preserved so brand identity survives the flip.
*
* Centralised here so GlobalThemeProvider, GalleryPage and GalleryView
* stay in sync (#397 follow-up: galleries did not visibly flip when
* Force Dark/Light was toggled because only colorMode was overridden,
* leaving the original light/dark surface colours in place).
*/
export function applyForceColorMode(
theme: ThemeConfig,
forced: 'dark' | 'light' | null | undefined
): ThemeConfig {
if (!forced) return theme;
const themeMode = theme.colorMode === 'auto'
? (typeof window !== 'undefined'
&& window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light')
: (theme.colorMode || 'light');
if (themeMode === forced) {
return { ...theme, colorMode: forced };
}
const surfaces = forced === 'dark' ? DARK_SURFACE_DEFAULTS : LIGHT_SURFACE_DEFAULTS;
return {
...theme,
...surfaces,
colorMode: forced,
};
}
/**
* Fills in any missing 8-token CI palette fields on legacy themes that were
* saved before the palette expanded from 4 → 8 explicit tokens.
*
* The visible look of an existing instance must not change just because the
* type system grew (per project memory: migrations preserve visual state).
* For each missing token we fall back to the value the renderer was already
* deriving implicitly:
* - accentDarkColor ← primaryColor (legacy primary was used as CTA fill)
* - elevatedColor ← surfaceColor (or a slight shift for light themes)
* - surfaceColor ← '#ffffff' / '#1a1a1a' depending on colorMode
* - surfaceBorderColor← '#e5e5e5' / '#2e2e2e'
* - mutedTextColor ← '#737373' / '#a3a3a3'
*/
function fillMissingPaletteTokens(theme: ThemeConfig): ThemeConfig {
const isDark = theme.colorMode === 'dark';
const filled: ThemeConfig = { ...theme };
if (!filled.surfaceColor) {
filled.surfaceColor = isDark ? '#1a1a1a' : '#ffffff';
}
if (!filled.elevatedColor) {
// For dark themes raise slightly above surface; for light, drop slightly below.
filled.elevatedColor = isDark ? '#242424' : '#f5f5f5';
}
if (!filled.surfaceBorderColor) {
filled.surfaceBorderColor = isDark ? '#2e2e2e' : '#e5e5e5';
}
if (!filled.mutedTextColor) {
filled.mutedTextColor = isDark ? '#a3a3a3' : '#737373';
}
if (!filled.accentDarkColor) {
// Legacy themes used primaryColor as the CTA fill — preserve that.
filled.accentDarkColor = filled.primaryColor;
}
return filled;
}
/**
* Migrates legacy theme configurations:
* - 'hero' galleryLayout → decoupled headerStyle + galleryLayout
* - missing 8-token CI palette fields → derived from legacy 4-color set
*
* This ensures backward compatibility with existing events.
*/
export function migrateThemeConfig(theme: ThemeConfig): ThemeConfig {
if (!theme) return theme;
let migrated = theme;
// Check if this theme uses the legacy 'hero' layout
if ((migrated.galleryLayout as string) === 'hero') {
migrated = {
...migrated,
headerStyle: 'hero' as HeaderStyleType,
galleryLayout: 'grid' as GalleryLayoutType,
heroDividerStyle: (migrated.heroDividerStyle || 'wave') as HeroDividerStyle,
};
}
// If headerStyle is not set but galleryLayout is valid, default to 'standard'
if (!migrated.headerStyle && migrated.galleryLayout) {
migrated = {
...migrated,
headerStyle: 'standard' as HeaderStyleType,
};
}
// Fill any missing 8-token palette fields so the renderer never has to
// fall back to hard-coded defaults that diverge from the original look.
return fillMissingPaletteTokens(migrated);
}
/**
* Parses and migrates a color_theme JSON string from the database.
* Handles both JSON strings and legacy preset names.
*/
export function parseAndMigrateTheme(colorTheme: string | null | undefined): ThemeConfig | null {
if (!colorTheme) return null;
try {
// Check if it's a JSON string
if (colorTheme.startsWith('{')) {
const parsed = JSON.parse(colorTheme);
return migrateThemeConfig(parsed);
}
// Legacy preset name - return null to let the caller handle preset lookup
return null;
} catch {
// Invalid JSON
return null;
}
}
/**
* Checks if a theme configuration needs migration from legacy hero layout.
*/
export function needsMigration(theme: ThemeConfig): boolean {
return (theme.galleryLayout as string) === 'hero';
}