feat: UI color theming system with live preview, presets and server sync

- RGBA color picker with live preview (debounce 50ms)
- 30 preset colors + 12 theme presets (Ocean, Forest, Sunset, Royal, etc.)
- Header image selection from configurable image library
- Export/Import theme as JSON via clipboard
- CSS variable theming across all UI elements: NitroCard headers/tabs,
  context menus, buttons (primary/dark/gray), InfoStand, toolbar,
  room tools, purse, progress bars, sliders
- All elements use var(--name, fallback) for zero visual change when default
- Smooth 0.3s CSS transitions on theme change
- Server-side persistence via WebSocket (packets 10047/10048)
- Integrated Color/Image tabs into BackgroundsView panel
- All strings use LocalizeText() for i18n support
- Settings persisted in localStorage + server sync with 1s debounce
- Added react-colorful dependency
This commit is contained in:
Life
2026-03-22 21:39:44 +01:00
parent b73c0841f2
commit 9c2dccaad6
28 changed files with 774 additions and 70 deletions
+43
View File
@@ -0,0 +1,43 @@
export interface IUiSettings
{
colorMode: 'color' | 'image' | 'default';
headerColor: string;
headerImageUrl: string;
headerAlpha: number;
}
export const DEFAULT_UI_SETTINGS: IUiSettings = {
colorMode: 'default',
headerColor: '#1E7295',
headerImageUrl: '',
headerAlpha: 100
};
export const PRESET_COLORS: string[] = [
'#000000', '#444444', '#888888', '#CCCCCC', '#660000', '#CC3333', '#FF6666', '#CC6600',
'#FF3333', '#FF6633', '#FF9933', '#FFCC00', '#FFFF00', '#66FF00', '#00CC00', '#009900',
'#00FFCC', '#33CCFF', '#3366FF', '#0000CC', '#6633CC', '#9933FF', '#CC33FF', '#FF66CC',
'#FF99CC', '#1E7295', '#185D79', '#2DABC2', '#2B91A7', '#283F5D'
];
export interface IThemePreset
{
name: string;
color: string;
alpha: number;
}
export const THEME_PRESETS: IThemePreset[] = [
{ name: 'default', color: '#1E7295', alpha: 100 },
{ name: 'ocean', color: '#0077B6', alpha: 100 },
{ name: 'forest', color: '#2D6A4F', alpha: 100 },
{ name: 'sunset', color: '#E76F51', alpha: 100 },
{ name: 'royal', color: '#7B2CBF', alpha: 100 },
{ name: 'midnight', color: '#1B1B2F', alpha: 100 },
{ name: 'cherry', color: '#C1121F', alpha: 100 },
{ name: 'gold', color: '#B8860B', alpha: 100 },
{ name: 'slate', color: '#475569', alpha: 100 },
{ name: 'candy', color: '#FF69B4', alpha: 100 },
{ name: 'emerald', color: '#059669', alpha: 100 },
{ name: 'volcano', color: '#DC2626', alpha: 90 },
];
+224
View File
@@ -0,0 +1,224 @@
import { GetCommunication, UiSettingsDataEvent, UiSettingsLoadComposer, UiSettingsSaveComposer } from '@nitrots/nitro-renderer';
import { createContext, FC, PropsWithChildren, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { DEFAULT_UI_SETTINGS, IUiSettings } from './IUiSettings';
const STORAGE_KEY = 'nitro.ui.settings';
interface IUiSettingsContext
{
settings: IUiSettings;
isCustomActive: boolean;
updateSettings: (partial: Partial<IUiSettings>) => void;
resetSettings: () => void;
getHeaderStyle: () => React.CSSProperties;
getTabsStyle: () => React.CSSProperties;
getAccentColor: () => string;
}
const UiSettingsContext = createContext<IUiSettingsContext>({
settings: DEFAULT_UI_SETTINGS,
isCustomActive: false,
updateSettings: () => {},
resetSettings: () => {},
getHeaderStyle: () => ({}),
getTabsStyle: () => ({}),
getAccentColor: () => DEFAULT_UI_SETTINGS.headerColor
});
const darkenColor = (hex: string, amount: number): string =>
{
const num = parseInt(hex.replace('#', ''), 16);
const r = Math.max(0, ((num >> 16) & 0xFF) - amount);
const g = Math.max(0, ((num >> 8) & 0xFF) - amount);
const b = Math.max(0, (num & 0xFF) - amount);
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
};
const loadSettings = (): IUiSettings =>
{
try
{
const stored = localStorage.getItem(STORAGE_KEY);
if(stored) return { ...DEFAULT_UI_SETTINGS, ...JSON.parse(stored) };
}
catch(e) {}
return { ...DEFAULT_UI_SETTINGS };
};
const saveSettings = (settings: IUiSettings): void =>
{
try
{
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}
catch(e) {}
};
const sendComposer = (composer: any): void =>
{
try
{
GetCommunication()?.connection?.send(composer);
}
catch(e) {}
};
export const UiSettingsProvider: FC<PropsWithChildren> = ({ children }) =>
{
const [ settings, setSettings ] = useState<IUiSettings>(loadSettings);
const serverSaveTimerRef = useRef<ReturnType<typeof setTimeout>>(null);
// Carica dal server al mount e ascolta risposta
useEffect(() =>
{
sendComposer(new UiSettingsLoadComposer());
const connection = GetCommunication()?.connection;
if(!connection) return;
const handler = (event: any) =>
{
try
{
const parser = event.getParser();
const json = parser?.settingsJson;
if(json && json !== '{}')
{
const serverSettings = { ...DEFAULT_UI_SETTINGS, ...JSON.parse(json) };
setSettings(serverSettings);
saveSettings(serverSettings);
}
}
catch(e) {}
};
connection.addMessageEvent(new UiSettingsDataEvent(handler));
}, []);
const syncToServer = useCallback((settingsToSave: IUiSettings) =>
{
if(serverSaveTimerRef.current) clearTimeout(serverSaveTimerRef.current);
serverSaveTimerRef.current = setTimeout(() =>
{
sendComposer(new UiSettingsSaveComposer(JSON.stringify(settingsToSave)));
}, 1000);
}, []);
const updateSettings = useCallback((partial: Partial<IUiSettings>) =>
{
setSettings(prev =>
{
const updated = { ...prev, ...partial };
saveSettings(updated);
syncToServer(updated);
return updated;
});
}, [ syncToServer ]);
const resetSettings = useCallback(() =>
{
setSettings({ ...DEFAULT_UI_SETTINGS });
saveSettings(DEFAULT_UI_SETTINGS);
syncToServer(DEFAULT_UI_SETTINGS);
}, [ syncToServer ]);
const getHeaderStyle = useCallback((): React.CSSProperties =>
{
if(settings.colorMode === 'color')
{
const alpha = (settings.headerAlpha ?? 100) / 100;
const num = parseInt(settings.headerColor.replace('#', ''), 16);
const r = (num >> 16) & 0xFF;
const g = (num >> 8) & 0xFF;
const b = num & 0xFF;
return { backgroundColor: `rgba(${ r }, ${ g }, ${ b }, ${ alpha })` };
}
if(settings.colorMode === 'image' && settings.headerImageUrl)
{
return {
backgroundImage: `url(${ settings.headerImageUrl })`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'repeat'
};
}
return {};
}, [ settings ]);
const getTabsStyle = useCallback((): React.CSSProperties =>
{
if(settings.colorMode === 'color')
{
return { backgroundColor: darkenColor(settings.headerColor, 30) };
}
if(settings.colorMode === 'image' && settings.headerImageUrl)
{
return {
backgroundImage: `url(${ settings.headerImageUrl })`,
backgroundSize: 'cover',
backgroundPosition: 'center bottom',
backgroundRepeat: 'repeat'
};
}
return {};
}, [ settings ]);
const getAccentColor = useCallback((): string =>
{
if(settings.colorMode === 'color') return settings.headerColor;
return DEFAULT_UI_SETTINGS.headerColor;
}, [ settings ]);
const isCustomActive = settings.colorMode !== 'default';
const ALL_CSS_VARS = [
'--ui-accent-color', '--ui-accent-dark',
'--ui-ctx-bg', '--ui-ctx-header-bg', '--ui-ctx-item-bg1', '--ui-ctx-item-bg2',
'--ui-btn-primary-bg', '--ui-btn-primary-border',
'--ui-dark-bg', '--ui-dark-border'
];
useEffect(() =>
{
const root = document.documentElement;
if(settings.colorMode === 'color')
{
const c = settings.headerColor;
root.style.setProperty('--ui-accent-color', c);
root.style.setProperty('--ui-accent-dark', darkenColor(c, 30));
root.style.setProperty('--ui-ctx-bg', darkenColor(c, 50));
root.style.setProperty('--ui-ctx-header-bg', darkenColor(c, 20));
root.style.setProperty('--ui-ctx-item-bg1', darkenColor(c, 60));
root.style.setProperty('--ui-ctx-item-bg2', darkenColor(c, 70));
root.style.setProperty('--ui-btn-primary-bg', c);
root.style.setProperty('--ui-btn-primary-border', darkenColor(c, 20));
root.style.setProperty('--ui-dark-bg', darkenColor(c, 55));
root.style.setProperty('--ui-dark-border', darkenColor(c, 60));
}
else
{
ALL_CSS_VARS.forEach(v => root.style.removeProperty(v));
}
}, [ settings ]);
return (
<UiSettingsContext.Provider value={ { settings, isCustomActive, updateSettings, resetSettings, getHeaderStyle, getTabsStyle, getAccentColor } }>
{ children }
</UiSettingsContext.Provider>
);
};
export const useUiSettings = () => useContext(UiSettingsContext);
+2
View File
@@ -0,0 +1,2 @@
export * from './IUiSettings';
export * from './UiSettingsContext';