mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 23:16:21 +00:00
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:
@@ -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 },
|
||||
];
|
||||
@@ -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);
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './IUiSettings';
|
||||
export * from './UiSettingsContext';
|
||||
Reference in New Issue
Block a user