mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 11:13:25 +08:00
feat: add Unsplash search hook and API proxy routes
- Implemented `useUnsplashSearch` hook for searching Unsplash photos with debounce. - Created Unsplash API client functions for searching photos and triggering downloads. - Added proxy routes for Unsplash API to handle search requests and background image uploads. - Introduced accessibility utilities for WCAG compliance checks and motion preference management. - Developed theme sharing module for encoding and decoding theme configurations as base64url strings.
This commit is contained in:
@@ -5,11 +5,12 @@
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist, devtools } from 'zustand/middleware';
|
||||
import type { AppStore, Theme, ColorScheme, GradientLevel, Locale, ViewMode, SessionFilter, LiteTaskType, DashboardLayouts, WidgetConfig } from '../types/store';
|
||||
import type { AppStore, Theme, ColorScheme, GradientLevel, Locale, ViewMode, SessionFilter, LiteTaskType, DashboardLayouts, WidgetConfig, MotionPreference, StyleTier, ThemeSlot, ThemeSlotId, BackgroundConfig, BackgroundEffects, BackgroundMode, UnsplashAttribution } from '../types/store';
|
||||
import { DEFAULT_DASHBOARD_LAYOUT } from '../components/dashboard/defaultLayouts';
|
||||
import { getInitialLocale, updateIntl } from '../lib/i18n';
|
||||
import { getThemeId } from '../lib/theme';
|
||||
import { generateThemeFromHue } from '../lib/colorGenerator';
|
||||
import { getThemeId, DEFAULT_SLOT, THEME_SLOT_LIMIT, DEFAULT_BACKGROUND_CONFIG } from '../lib/theme';
|
||||
import { generateThemeFromHue, applyStyleTier } from '../lib/colorGenerator';
|
||||
import { resolveMotionPreference, checkThemeContrast } from '../lib/accessibility';
|
||||
|
||||
// Helper to resolve system theme
|
||||
const getSystemTheme = (): 'light' | 'dark' => {
|
||||
@@ -25,6 +26,12 @@ const resolveTheme = (theme: Theme): 'light' | 'dark' => {
|
||||
return theme;
|
||||
};
|
||||
|
||||
/** Get the style tier from the active slot */
|
||||
const getActiveStyleTier = (themeSlots: ThemeSlot[], activeSlotId: ThemeSlotId): StyleTier => {
|
||||
const slot = themeSlots.find(s => s.id === activeSlotId);
|
||||
return slot?.styleTier ?? 'standard';
|
||||
};
|
||||
|
||||
/**
|
||||
* DOM Theme Application Helper
|
||||
*
|
||||
@@ -44,7 +51,9 @@ const applyThemeToDocument = (
|
||||
customHue: number | null,
|
||||
gradientLevel: GradientLevel = 'standard',
|
||||
enableHoverGlow: boolean = true,
|
||||
enableBackgroundAnimation: boolean = false
|
||||
enableBackgroundAnimation: boolean = false,
|
||||
motionPreference: MotionPreference = 'system',
|
||||
styleTier: StyleTier = 'standard'
|
||||
): void => {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
@@ -78,11 +87,29 @@ const applyThemeToDocument = (
|
||||
|
||||
// Apply custom theme or preset theme
|
||||
if (customHue !== null) {
|
||||
const cssVars = generateThemeFromHue(customHue, resolvedTheme);
|
||||
let cssVars = generateThemeFromHue(customHue, resolvedTheme);
|
||||
// Apply style tier post-processing
|
||||
if (styleTier !== 'standard') {
|
||||
cssVars = applyStyleTier(cssVars, styleTier, resolvedTheme);
|
||||
}
|
||||
Object.entries(cssVars).forEach(([varName, varValue]) => {
|
||||
document.documentElement.style.setProperty(varName, varValue);
|
||||
});
|
||||
document.documentElement.setAttribute('data-theme', `custom-${resolvedTheme}`);
|
||||
|
||||
// Contrast validation for non-standard tiers
|
||||
if (styleTier !== 'standard') {
|
||||
const contrastResults = checkThemeContrast(cssVars);
|
||||
const failures = contrastResults.filter(r => !r.passed);
|
||||
if (failures.length > 0) {
|
||||
console.warn(
|
||||
'[Theme] Style tier "%s" caused %d WCAG AA contrast failures:',
|
||||
styleTier,
|
||||
failures.length,
|
||||
failures.map(f => `${f.fgVar}/${f.bgVar}: ${f.ratio}:1 (min ${f.required}:1)`)
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Clear custom CSS variables
|
||||
customVars.forEach(varName => {
|
||||
@@ -91,6 +118,35 @@ const applyThemeToDocument = (
|
||||
// Apply preset theme
|
||||
const themeId = getThemeId(colorScheme, resolvedTheme);
|
||||
document.documentElement.setAttribute('data-theme', themeId);
|
||||
|
||||
// Apply style tier to preset theme (if not standard)
|
||||
if (styleTier !== 'standard') {
|
||||
const computed = getComputedStyle(document.documentElement);
|
||||
const presetVars: Record<string, string> = {};
|
||||
for (const varName of customVars) {
|
||||
const value = computed.getPropertyValue(varName).trim();
|
||||
if (value) {
|
||||
presetVars[varName] = value;
|
||||
}
|
||||
}
|
||||
const tieredVars = applyStyleTier(presetVars, styleTier, resolvedTheme);
|
||||
Object.entries(tieredVars).forEach(([varName, varValue]) => {
|
||||
document.documentElement.style.setProperty(varName, varValue);
|
||||
});
|
||||
|
||||
// Contrast validation for preset themes with non-standard tiers
|
||||
const contrastResults = checkThemeContrast(tieredVars);
|
||||
const failures = contrastResults.filter(r => !r.passed);
|
||||
if (failures.length > 0) {
|
||||
console.warn(
|
||||
'[Theme] Style tier "%s" on preset "%s" caused %d WCAG AA contrast failures:',
|
||||
styleTier,
|
||||
colorScheme,
|
||||
failures.length,
|
||||
failures.map(f => `${f.fgVar}/${f.bgVar}: ${f.ratio}:1 (min ${f.required}:1)`)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set color scheme attribute
|
||||
@@ -100,10 +156,19 @@ const applyThemeToDocument = (
|
||||
document.documentElement.setAttribute('data-gradient', gradientLevel);
|
||||
document.documentElement.setAttribute('data-hover-glow', String(enableHoverGlow));
|
||||
document.documentElement.setAttribute('data-bg-animation', String(enableBackgroundAnimation));
|
||||
|
||||
// Apply reduced motion preference
|
||||
const reducedMotion = resolveMotionPreference(motionPreference);
|
||||
document.documentElement.setAttribute('data-reduced-motion', String(reducedMotion));
|
||||
|
||||
// Set style tier data attribute
|
||||
document.documentElement.setAttribute('data-style-tier', styleTier);
|
||||
};
|
||||
|
||||
// Use View Transition API for smooth transitions (progressive enhancement)
|
||||
if (typeof document !== 'undefined' && 'startViewTransition' in document) {
|
||||
// Skip view transition when reduced motion is active
|
||||
const reducedMotion = resolveMotionPreference(motionPreference);
|
||||
if (!reducedMotion && typeof document !== 'undefined' && 'startViewTransition' in document) {
|
||||
(document as unknown as { startViewTransition: (callback: () => void) => void }).startViewTransition(performThemeUpdate);
|
||||
} else {
|
||||
// Fallback: apply immediately without transition
|
||||
@@ -111,6 +176,23 @@ const applyThemeToDocument = (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply background configuration to document data attributes.
|
||||
* Sets data-bg-* attributes on <html> that CSS rules respond to.
|
||||
*/
|
||||
const applyBackgroundToDocument = (config: BackgroundConfig): void => {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const el = document.documentElement;
|
||||
el.setAttribute('data-bg-mode', config.mode);
|
||||
el.setAttribute('data-bg-blur', String(config.effects.blur));
|
||||
el.setAttribute('data-bg-darken', String(config.effects.darkenOpacity));
|
||||
el.setAttribute('data-bg-saturation', String(config.effects.saturation));
|
||||
el.setAttribute('data-bg-frosted', String(config.effects.enableFrostedGlass));
|
||||
el.setAttribute('data-bg-grain', String(config.effects.enableGrain));
|
||||
el.setAttribute('data-bg-vignette', String(config.effects.enableVignette));
|
||||
};
|
||||
|
||||
// Initial state
|
||||
const initialState = {
|
||||
// Theme
|
||||
@@ -125,6 +207,9 @@ const initialState = {
|
||||
enableHoverGlow: true,
|
||||
enableBackgroundAnimation: false,
|
||||
|
||||
// Motion preference
|
||||
motionPreference: 'system' as MotionPreference,
|
||||
|
||||
// Locale
|
||||
locale: getInitialLocale() as Locale,
|
||||
|
||||
@@ -146,6 +231,11 @@ const initialState = {
|
||||
|
||||
// Dashboard layout
|
||||
dashboardLayout: null,
|
||||
|
||||
// Theme slots
|
||||
themeSlots: [DEFAULT_SLOT] as ThemeSlot[],
|
||||
activeSlotId: 'default' as ThemeSlotId,
|
||||
deletedSlotBuffer: null as ThemeSlot | null,
|
||||
};
|
||||
|
||||
export const useAppStore = create<AppStore>()(
|
||||
@@ -161,31 +251,60 @@ export const useAppStore = create<AppStore>()(
|
||||
set({ theme, resolvedTheme: resolved }, false, 'setTheme');
|
||||
|
||||
// Apply theme using helper (encapsulates DOM manipulation)
|
||||
const { colorScheme, customHue, gradientLevel, enableHoverGlow, enableBackgroundAnimation } = get();
|
||||
applyThemeToDocument(resolved, colorScheme, customHue, gradientLevel, enableHoverGlow, enableBackgroundAnimation);
|
||||
const state = get();
|
||||
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
|
||||
applyThemeToDocument(resolved, state.colorScheme, state.customHue, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, styleTier);
|
||||
},
|
||||
|
||||
setColorScheme: (colorScheme: ColorScheme) => {
|
||||
set({ colorScheme, customHue: null, isCustomTheme: false }, false, 'setColorScheme');
|
||||
set((state) => ({
|
||||
colorScheme,
|
||||
customHue: null,
|
||||
isCustomTheme: false,
|
||||
themeSlots: state.themeSlots.map(slot =>
|
||||
slot.id === state.activeSlotId
|
||||
? { ...slot, colorScheme, customHue: null, isCustomTheme: false }
|
||||
: slot
|
||||
),
|
||||
}), false, 'setColorScheme');
|
||||
|
||||
// Apply color scheme using helper (encapsulates DOM manipulation)
|
||||
const { resolvedTheme, gradientLevel, enableHoverGlow, enableBackgroundAnimation } = get();
|
||||
applyThemeToDocument(resolvedTheme, colorScheme, null, gradientLevel, enableHoverGlow, enableBackgroundAnimation);
|
||||
const state = get();
|
||||
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
|
||||
applyThemeToDocument(state.resolvedTheme, colorScheme, null, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, styleTier);
|
||||
},
|
||||
|
||||
setCustomHue: (hue: number | null) => {
|
||||
if (hue === null) {
|
||||
// Reset to preset theme
|
||||
const { colorScheme, resolvedTheme, gradientLevel, enableHoverGlow, enableBackgroundAnimation } = get();
|
||||
set({ customHue: null, isCustomTheme: false }, false, 'setCustomHue');
|
||||
applyThemeToDocument(resolvedTheme, colorScheme, null, gradientLevel, enableHoverGlow, enableBackgroundAnimation);
|
||||
const state = get();
|
||||
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
|
||||
set((s) => ({
|
||||
customHue: null,
|
||||
isCustomTheme: false,
|
||||
themeSlots: s.themeSlots.map(slot =>
|
||||
slot.id === s.activeSlotId
|
||||
? { ...slot, customHue: null, isCustomTheme: false }
|
||||
: slot
|
||||
),
|
||||
}), false, 'setCustomHue');
|
||||
applyThemeToDocument(state.resolvedTheme, state.colorScheme, null, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, styleTier);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply custom hue
|
||||
set({ customHue: hue, isCustomTheme: true }, false, 'setCustomHue');
|
||||
const { resolvedTheme, colorScheme, gradientLevel, enableHoverGlow, enableBackgroundAnimation } = get();
|
||||
applyThemeToDocument(resolvedTheme, colorScheme, hue, gradientLevel, enableHoverGlow, enableBackgroundAnimation);
|
||||
set((state) => ({
|
||||
customHue: hue,
|
||||
isCustomTheme: true,
|
||||
themeSlots: state.themeSlots.map(slot =>
|
||||
slot.id === state.activeSlotId
|
||||
? { ...slot, customHue: hue, isCustomTheme: true }
|
||||
: slot
|
||||
),
|
||||
}), false, 'setCustomHue');
|
||||
const state = get();
|
||||
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
|
||||
applyThemeToDocument(state.resolvedTheme, state.colorScheme, hue, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, styleTier);
|
||||
},
|
||||
|
||||
toggleTheme: () => {
|
||||
@@ -197,21 +316,64 @@ export const useAppStore = create<AppStore>()(
|
||||
// ========== Gradient Settings Actions ==========
|
||||
|
||||
setGradientLevel: (level: GradientLevel) => {
|
||||
set({ gradientLevel: level }, false, 'setGradientLevel');
|
||||
const { resolvedTheme, colorScheme, customHue, enableHoverGlow, enableBackgroundAnimation } = get();
|
||||
applyThemeToDocument(resolvedTheme, colorScheme, customHue, level, enableHoverGlow, enableBackgroundAnimation);
|
||||
set((state) => ({
|
||||
gradientLevel: level,
|
||||
themeSlots: state.themeSlots.map(slot =>
|
||||
slot.id === state.activeSlotId
|
||||
? { ...slot, gradientLevel: level }
|
||||
: slot
|
||||
),
|
||||
}), false, 'setGradientLevel');
|
||||
const state = get();
|
||||
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
|
||||
applyThemeToDocument(state.resolvedTheme, state.colorScheme, state.customHue, level, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, styleTier);
|
||||
},
|
||||
|
||||
setEnableHoverGlow: (enabled: boolean) => {
|
||||
set({ enableHoverGlow: enabled }, false, 'setEnableHoverGlow');
|
||||
const { resolvedTheme, colorScheme, customHue, gradientLevel, enableBackgroundAnimation } = get();
|
||||
applyThemeToDocument(resolvedTheme, colorScheme, customHue, gradientLevel, enabled, enableBackgroundAnimation);
|
||||
set((state) => ({
|
||||
enableHoverGlow: enabled,
|
||||
themeSlots: state.themeSlots.map(slot =>
|
||||
slot.id === state.activeSlotId
|
||||
? { ...slot, enableHoverGlow: enabled }
|
||||
: slot
|
||||
),
|
||||
}), false, 'setEnableHoverGlow');
|
||||
const state = get();
|
||||
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
|
||||
applyThemeToDocument(state.resolvedTheme, state.colorScheme, state.customHue, state.gradientLevel, enabled, state.enableBackgroundAnimation, state.motionPreference, styleTier);
|
||||
},
|
||||
|
||||
setEnableBackgroundAnimation: (enabled: boolean) => {
|
||||
set({ enableBackgroundAnimation: enabled }, false, 'setEnableBackgroundAnimation');
|
||||
const { resolvedTheme, colorScheme, customHue, gradientLevel, enableHoverGlow } = get();
|
||||
applyThemeToDocument(resolvedTheme, colorScheme, customHue, gradientLevel, enableHoverGlow, enabled);
|
||||
set((state) => ({
|
||||
enableBackgroundAnimation: enabled,
|
||||
themeSlots: state.themeSlots.map(slot =>
|
||||
slot.id === state.activeSlotId
|
||||
? { ...slot, enableBackgroundAnimation: enabled }
|
||||
: slot
|
||||
),
|
||||
}), false, 'setEnableBackgroundAnimation');
|
||||
const state = get();
|
||||
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
|
||||
applyThemeToDocument(state.resolvedTheme, state.colorScheme, state.customHue, state.gradientLevel, state.enableHoverGlow, enabled, state.motionPreference, styleTier);
|
||||
},
|
||||
|
||||
setMotionPreference: (pref: MotionPreference) => {
|
||||
set({ motionPreference: pref }, false, 'setMotionPreference');
|
||||
const state = get();
|
||||
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
|
||||
applyThemeToDocument(state.resolvedTheme, state.colorScheme, state.customHue, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, pref, styleTier);
|
||||
},
|
||||
|
||||
setStyleTier: (tier: StyleTier) => {
|
||||
set((state) => ({
|
||||
themeSlots: state.themeSlots.map(slot =>
|
||||
slot.id === state.activeSlotId
|
||||
? { ...slot, styleTier: tier }
|
||||
: slot
|
||||
),
|
||||
}), false, 'setStyleTier');
|
||||
const state = get();
|
||||
applyThemeToDocument(state.resolvedTheme, state.colorScheme, state.customHue, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, tier);
|
||||
},
|
||||
|
||||
// ========== Locale Actions ==========
|
||||
@@ -302,10 +464,216 @@ export const useAppStore = create<AppStore>()(
|
||||
resetDashboardLayout: () => {
|
||||
set({ dashboardLayout: DEFAULT_DASHBOARD_LAYOUT }, false, 'resetDashboardLayout');
|
||||
},
|
||||
|
||||
// ========== Theme Slot Actions ==========
|
||||
|
||||
setActiveSlot: (slotId: ThemeSlotId) => {
|
||||
const { themeSlots, motionPreference } = get();
|
||||
const slot = themeSlots.find(s => s.id === slotId);
|
||||
if (!slot) return;
|
||||
|
||||
const resolved = resolveTheme(get().theme);
|
||||
set({
|
||||
activeSlotId: slotId,
|
||||
colorScheme: slot.colorScheme,
|
||||
customHue: slot.customHue,
|
||||
isCustomTheme: slot.isCustomTheme,
|
||||
gradientLevel: slot.gradientLevel,
|
||||
enableHoverGlow: slot.enableHoverGlow,
|
||||
enableBackgroundAnimation: slot.enableBackgroundAnimation,
|
||||
}, false, 'setActiveSlot');
|
||||
|
||||
applyThemeToDocument(
|
||||
resolved,
|
||||
slot.colorScheme,
|
||||
slot.customHue,
|
||||
slot.gradientLevel,
|
||||
slot.enableHoverGlow,
|
||||
slot.enableBackgroundAnimation,
|
||||
motionPreference,
|
||||
slot.styleTier
|
||||
);
|
||||
applyBackgroundToDocument(slot.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG);
|
||||
},
|
||||
|
||||
copySlot: () => {
|
||||
const state = get();
|
||||
if (state.themeSlots.length >= THEME_SLOT_LIMIT) return;
|
||||
|
||||
// Determine next available slot id
|
||||
const usedIds = new Set(state.themeSlots.map(s => s.id));
|
||||
const candidateIds: ThemeSlotId[] = ['custom-1', 'custom-2'];
|
||||
const nextId = candidateIds.find(id => !usedIds.has(id));
|
||||
if (!nextId) return;
|
||||
|
||||
const activeSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
|
||||
if (!activeSlot) return;
|
||||
|
||||
const newSlot: ThemeSlot = {
|
||||
id: nextId,
|
||||
name: `Copy of ${activeSlot.name}`,
|
||||
colorScheme: state.colorScheme,
|
||||
customHue: state.customHue,
|
||||
isCustomTheme: state.isCustomTheme,
|
||||
gradientLevel: state.gradientLevel,
|
||||
enableHoverGlow: state.enableHoverGlow,
|
||||
enableBackgroundAnimation: state.enableBackgroundAnimation,
|
||||
styleTier: activeSlot.styleTier,
|
||||
isDefault: false,
|
||||
backgroundConfig: activeSlot.backgroundConfig,
|
||||
};
|
||||
|
||||
set({
|
||||
themeSlots: [...state.themeSlots, newSlot],
|
||||
activeSlotId: nextId,
|
||||
}, false, 'copySlot');
|
||||
},
|
||||
|
||||
renameSlot: (slotId: ThemeSlotId, name: string) => {
|
||||
set((state) => ({
|
||||
themeSlots: state.themeSlots.map(slot =>
|
||||
slot.id === slotId ? { ...slot, name } : slot
|
||||
),
|
||||
}), false, 'renameSlot');
|
||||
},
|
||||
|
||||
deleteSlot: (slotId: ThemeSlotId) => {
|
||||
const state = get();
|
||||
const slot = state.themeSlots.find(s => s.id === slotId);
|
||||
if (!slot || slot.isDefault) return;
|
||||
|
||||
set({
|
||||
themeSlots: state.themeSlots.filter(s => s.id !== slotId),
|
||||
deletedSlotBuffer: slot,
|
||||
activeSlotId: 'default',
|
||||
}, false, 'deleteSlot');
|
||||
|
||||
// Load default slot values into active state
|
||||
const defaultSlot = state.themeSlots.find(s => s.id === 'default');
|
||||
if (defaultSlot) {
|
||||
const resolved = resolveTheme(state.theme);
|
||||
set({
|
||||
colorScheme: defaultSlot.colorScheme,
|
||||
customHue: defaultSlot.customHue,
|
||||
isCustomTheme: defaultSlot.isCustomTheme,
|
||||
gradientLevel: defaultSlot.gradientLevel,
|
||||
enableHoverGlow: defaultSlot.enableHoverGlow,
|
||||
enableBackgroundAnimation: defaultSlot.enableBackgroundAnimation,
|
||||
}, false, 'deleteSlot/applyDefault');
|
||||
|
||||
applyThemeToDocument(
|
||||
resolved,
|
||||
defaultSlot.colorScheme,
|
||||
defaultSlot.customHue,
|
||||
defaultSlot.gradientLevel,
|
||||
defaultSlot.enableHoverGlow,
|
||||
defaultSlot.enableBackgroundAnimation,
|
||||
state.motionPreference,
|
||||
defaultSlot.styleTier
|
||||
);
|
||||
applyBackgroundToDocument(defaultSlot.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG);
|
||||
}
|
||||
|
||||
// Clear buffer after 10 seconds
|
||||
setTimeout(() => {
|
||||
const current = useAppStore.getState();
|
||||
if (current.deletedSlotBuffer?.id === slotId) {
|
||||
useAppStore.setState({ deletedSlotBuffer: null }, false);
|
||||
}
|
||||
}, 10000);
|
||||
},
|
||||
|
||||
undoDeleteSlot: () => {
|
||||
const state = get();
|
||||
const restored = state.deletedSlotBuffer;
|
||||
if (!restored) return;
|
||||
if (state.themeSlots.length >= THEME_SLOT_LIMIT) return;
|
||||
|
||||
set({
|
||||
themeSlots: [...state.themeSlots, restored],
|
||||
deletedSlotBuffer: null,
|
||||
activeSlotId: restored.id,
|
||||
}, false, 'undoDeleteSlot');
|
||||
|
||||
// Apply restored slot values
|
||||
const resolved = resolveTheme(state.theme);
|
||||
set({
|
||||
colorScheme: restored.colorScheme,
|
||||
customHue: restored.customHue,
|
||||
isCustomTheme: restored.isCustomTheme,
|
||||
gradientLevel: restored.gradientLevel,
|
||||
enableHoverGlow: restored.enableHoverGlow,
|
||||
enableBackgroundAnimation: restored.enableBackgroundAnimation,
|
||||
}, false, 'undoDeleteSlot/apply');
|
||||
|
||||
applyThemeToDocument(
|
||||
resolved,
|
||||
restored.colorScheme,
|
||||
restored.customHue,
|
||||
restored.gradientLevel,
|
||||
restored.enableHoverGlow,
|
||||
restored.enableBackgroundAnimation,
|
||||
state.motionPreference,
|
||||
restored.styleTier
|
||||
);
|
||||
applyBackgroundToDocument(restored.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG);
|
||||
},
|
||||
|
||||
// ========== Background Actions ==========
|
||||
|
||||
setBackgroundConfig: (config: BackgroundConfig) => {
|
||||
set((state) => ({
|
||||
themeSlots: state.themeSlots.map(slot =>
|
||||
slot.id === state.activeSlotId
|
||||
? { ...slot, backgroundConfig: config }
|
||||
: slot
|
||||
),
|
||||
}), false, 'setBackgroundConfig');
|
||||
applyBackgroundToDocument(config);
|
||||
},
|
||||
|
||||
updateBackgroundEffect: <K extends keyof BackgroundEffects>(key: K, value: BackgroundEffects[K]) => {
|
||||
const state = get();
|
||||
const activeSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
|
||||
const current = activeSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG;
|
||||
const updated: BackgroundConfig = {
|
||||
...current,
|
||||
effects: { ...current.effects, [key]: value },
|
||||
};
|
||||
get().setBackgroundConfig(updated);
|
||||
},
|
||||
|
||||
setBackgroundMode: (mode: BackgroundMode) => {
|
||||
const state = get();
|
||||
const activeSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
|
||||
const current = activeSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG;
|
||||
const updated: BackgroundConfig = { ...current, mode };
|
||||
get().setBackgroundConfig(updated);
|
||||
},
|
||||
|
||||
setBackgroundImage: (url: string | null, attribution: UnsplashAttribution | null) => {
|
||||
const state = get();
|
||||
const activeSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
|
||||
const current = activeSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG;
|
||||
const updated: BackgroundConfig = {
|
||||
...current,
|
||||
imageUrl: url,
|
||||
attribution,
|
||||
};
|
||||
// Auto-switch mode if currently gradient-only and setting an image
|
||||
if (url && current.mode === 'gradient-only') {
|
||||
updated.mode = 'image-gradient';
|
||||
}
|
||||
// Auto-switch to gradient-only if removing image
|
||||
if (!url && current.mode !== 'gradient-only') {
|
||||
updated.mode = 'gradient-only';
|
||||
}
|
||||
get().setBackgroundConfig(updated);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'ccw-app-store',
|
||||
// Only persist theme and locale preferences
|
||||
// Only persist theme, locale, and slot preferences
|
||||
partialize: (state) => ({
|
||||
theme: state.theme,
|
||||
colorScheme: state.colorScheme,
|
||||
@@ -313,26 +681,59 @@ export const useAppStore = create<AppStore>()(
|
||||
gradientLevel: state.gradientLevel,
|
||||
enableHoverGlow: state.enableHoverGlow,
|
||||
enableBackgroundAnimation: state.enableBackgroundAnimation,
|
||||
motionPreference: state.motionPreference,
|
||||
locale: state.locale,
|
||||
sidebarCollapsed: state.sidebarCollapsed,
|
||||
expandedNavGroups: state.expandedNavGroups,
|
||||
dashboardLayout: state.dashboardLayout,
|
||||
themeSlots: state.themeSlots,
|
||||
activeSlotId: state.activeSlotId,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
// Apply theme on rehydration
|
||||
if (state) {
|
||||
// Migrate legacy schema: if no themeSlots, construct from flat fields
|
||||
if (!state.themeSlots || !Array.isArray(state.themeSlots) || state.themeSlots.length === 0) {
|
||||
const migratedSlot: ThemeSlot = {
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
colorScheme: state.colorScheme ?? 'blue',
|
||||
customHue: state.customHue ?? null,
|
||||
isCustomTheme: (state.customHue ?? null) !== null,
|
||||
gradientLevel: state.gradientLevel ?? 'standard',
|
||||
enableHoverGlow: state.enableHoverGlow ?? true,
|
||||
enableBackgroundAnimation: state.enableBackgroundAnimation ?? false,
|
||||
styleTier: 'standard',
|
||||
isDefault: true,
|
||||
};
|
||||
state.themeSlots = [migratedSlot];
|
||||
state.activeSlotId = 'default';
|
||||
}
|
||||
|
||||
// Ensure activeSlotId is valid
|
||||
if (!state.activeSlotId || !state.themeSlots.find(s => s.id === state.activeSlotId)) {
|
||||
state.activeSlotId = 'default';
|
||||
}
|
||||
|
||||
// Apply theme on rehydration
|
||||
const resolved = resolveTheme(state.theme);
|
||||
state.resolvedTheme = resolved;
|
||||
state.isCustomTheme = state.customHue !== null;
|
||||
// Apply theme using helper (encapsulates DOM manipulation)
|
||||
const rehydratedStyleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
|
||||
applyThemeToDocument(
|
||||
resolved,
|
||||
state.colorScheme,
|
||||
state.customHue,
|
||||
state.gradientLevel ?? 'standard',
|
||||
state.enableHoverGlow ?? true,
|
||||
state.enableBackgroundAnimation ?? false
|
||||
state.enableBackgroundAnimation ?? false,
|
||||
state.motionPreference ?? 'system',
|
||||
rehydratedStyleTier
|
||||
);
|
||||
|
||||
// Apply background config on rehydration
|
||||
const activeSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
|
||||
applyBackgroundToDocument(activeSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG);
|
||||
}
|
||||
// Apply locale on rehydration
|
||||
if (state) {
|
||||
@@ -354,13 +755,16 @@ if (typeof window !== 'undefined') {
|
||||
const resolved = getSystemTheme();
|
||||
useAppStore.setState({ resolvedTheme: resolved });
|
||||
// Apply theme using helper (encapsulates DOM manipulation)
|
||||
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
|
||||
applyThemeToDocument(
|
||||
resolved,
|
||||
state.colorScheme,
|
||||
state.customHue,
|
||||
state.gradientLevel,
|
||||
state.enableHoverGlow,
|
||||
state.enableBackgroundAnimation
|
||||
state.enableBackgroundAnimation,
|
||||
state.motionPreference,
|
||||
styleTier
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -368,14 +772,21 @@ if (typeof window !== 'undefined') {
|
||||
// Apply initial theme immediately (before localStorage rehydration)
|
||||
// This ensures gradient attributes are set from the start
|
||||
const state = useAppStore.getState();
|
||||
const initialStyleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
|
||||
applyThemeToDocument(
|
||||
state.resolvedTheme,
|
||||
state.colorScheme,
|
||||
state.customHue,
|
||||
state.gradientLevel,
|
||||
state.enableHoverGlow,
|
||||
state.enableBackgroundAnimation
|
||||
state.enableBackgroundAnimation,
|
||||
state.motionPreference,
|
||||
initialStyleTier
|
||||
);
|
||||
|
||||
// Apply initial background config
|
||||
const initialActiveSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
|
||||
applyBackgroundToDocument(initialActiveSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG);
|
||||
}
|
||||
|
||||
// Selectors for common access patterns
|
||||
@@ -387,8 +798,12 @@ export const selectIsCustomTheme = (state: AppStore) => state.isCustomTheme;
|
||||
export const selectGradientLevel = (state: AppStore) => state.gradientLevel;
|
||||
export const selectEnableHoverGlow = (state: AppStore) => state.enableHoverGlow;
|
||||
export const selectEnableBackgroundAnimation = (state: AppStore) => state.enableBackgroundAnimation;
|
||||
export const selectMotionPreference = (state: AppStore) => state.motionPreference;
|
||||
export const selectLocale = (state: AppStore) => state.locale;
|
||||
export const selectSidebarOpen = (state: AppStore) => state.sidebarOpen;
|
||||
export const selectCurrentView = (state: AppStore) => state.currentView;
|
||||
export const selectIsLoading = (state: AppStore) => state.isLoading;
|
||||
export const selectError = (state: AppStore) => state.error;
|
||||
export const selectThemeSlots = (state: AppStore) => state.themeSlots;
|
||||
export const selectActiveSlotId = (state: AppStore) => state.activeSlotId;
|
||||
export const selectDeletedSlotBuffer = (state: AppStore) => state.deletedSlotBuffer;
|
||||
|
||||
Reference in New Issue
Block a user