Files
Nitro-V3/src/api/ui-settings/UiSettingsContext.tsx
T
Life 9c2dccaad6 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
2026-03-22 21:48:07 +01:00

225 lines
6.9 KiB
TypeScript

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);