From 9c2dccaad6b96ae41de18ffa332262bf3b5887dc Mon Sep 17 00:00:00 2001 From: Life Date: Sun, 22 Mar 2026 21:39:44 +0100 Subject: [PATCH] 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 --- package.json | 1 + src/App.tsx | 18 +- src/api/index.ts | 1 + src/api/ui-settings/IUiSettings.ts | 43 +++ src/api/ui-settings/UiSettingsContext.tsx | 224 ++++++++++++++ src/api/ui-settings/index.ts | 2 + src/common/Button.tsx | 39 ++- src/common/Text.tsx | 11 +- src/common/card/NitroCardHeaderView.tsx | 10 +- src/common/card/tabs/NitroCardTabsView.tsx | 12 +- src/common/layout/LayoutProgressBar.tsx | 4 +- .../backgrounds/BackgroundsView.tsx | 38 ++- .../InterfaceColorTabView.tsx | 288 ++++++++++++++++++ .../InterfaceImageTabView.tsx | 52 ++++ .../infostand/InfoStandBadgeSlotView.tsx | 3 +- .../infostand/InfoStandWidgetFurniView.tsx | 2 +- .../infostand/InfoStandWidgetUserView.tsx | 2 +- .../context-menu/ContextMenuHeaderView.tsx | 11 +- .../context-menu/ContextMenuListItemView.tsx | 11 +- .../widgets/context-menu/ContextMenuView.tsx | 2 +- src/components/toolbar/ToolbarView.tsx | 2 +- src/css/common/Buttons.css | 14 +- src/css/index.css | 38 ++- src/css/nitrocard/NitroCardView.css | 2 +- src/css/purse/PurseView.css | 4 +- src/css/room/InfoStand.css | 2 +- src/css/room/RoomWidgets.css | 6 +- src/css/slider.css | 2 +- 28 files changed, 774 insertions(+), 70 deletions(-) create mode 100644 src/api/ui-settings/IUiSettings.ts create mode 100644 src/api/ui-settings/UiSettingsContext.tsx create mode 100644 src/api/ui-settings/index.ts create mode 100644 src/components/interface-settings/InterfaceColorTabView.tsx create mode 100644 src/components/interface-settings/InterfaceImageTabView.tsx diff --git a/package.json b/package.json index 4dab2f5..716c4bf 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "framer-motion": "^11.2.12", "react": "^19.2.4", "react-bootstrap": "^2.10.10", + "react-colorful": "^5.6.1", "react-dom": "^19.2.4", "react-icons": "^5.5.0", "react-slider": "^2.0.6", diff --git a/src/App.tsx b/src/App.tsx index c97d6fa..59c8ce4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { GetAssetManager, GetAvatarRenderManager, GetCommunication, GetConfiguration, GetLocalizationManager, GetRoomEngine, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, GetStage, GetTexturePool, GetTicker, HabboWebTools, LegacyExternalInterface, LoadGameUrlEvent, NitroLogger, NitroVersion, PrepareRenderer } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; -import { GetUIVersion } from './api'; +import { GetUIVersion, UiSettingsProvider } from './api'; import { Base } from './common'; import { LoadingView } from './components/loading/LoadingView'; import { MainView } from './components/MainView'; @@ -90,12 +90,14 @@ export const App: FC<{}> = props => }, []); return ( - - { !isReady && - } - { isReady && } - - - + + + { !isReady && + } + { isReady && } + + + + ); }; \ No newline at end of file diff --git a/src/api/index.ts b/src/api/index.ts index 7089277..1bbb9e9 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -24,5 +24,6 @@ export * from './room'; export * from './room/events'; export * from './room/widgets'; export * from './user'; +export * from './ui-settings'; export * from './utils'; export * from './wired'; diff --git a/src/api/ui-settings/IUiSettings.ts b/src/api/ui-settings/IUiSettings.ts new file mode 100644 index 0000000..5c6e96d --- /dev/null +++ b/src/api/ui-settings/IUiSettings.ts @@ -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 }, +]; diff --git a/src/api/ui-settings/UiSettingsContext.tsx b/src/api/ui-settings/UiSettingsContext.tsx new file mode 100644 index 0000000..99685ce --- /dev/null +++ b/src/api/ui-settings/UiSettingsContext.tsx @@ -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) => void; + resetSettings: () => void; + getHeaderStyle: () => React.CSSProperties; + getTabsStyle: () => React.CSSProperties; + getAccentColor: () => string; +} + +const UiSettingsContext = createContext({ + 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 = ({ children }) => +{ + const [ settings, setSettings ] = useState(loadSettings); + const serverSaveTimerRef = useRef>(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) => + { + 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 ( + + { children } + + ); +}; + +export const useUiSettings = () => useContext(UiSettingsContext); diff --git a/src/api/ui-settings/index.ts b/src/api/ui-settings/index.ts new file mode 100644 index 0000000..255a5da --- /dev/null +++ b/src/api/ui-settings/index.ts @@ -0,0 +1,2 @@ +export * from './IUiSettings'; +export * from './UiSettingsContext'; diff --git a/src/common/Button.tsx b/src/common/Button.tsx index 6b7d454..c59afa0 100644 --- a/src/common/Button.tsx +++ b/src/common/Button.tsx @@ -12,20 +12,16 @@ export interface ButtonProps extends FlexProps export const Button: FC = props => { - const { variant = 'primary', size = 'sm', active = false, disabled = false, classNames = [], ...rest } = props; + const { variant = 'primary', size = 'sm', active = false, disabled = false, classNames = [], style = {}, ...rest } = props; const getClassNames = useMemo(() => { - - // fucked up method i know (i dont have a clue what im doing because im a ninja) - const newClassNames: string[] = [ 'pointer-events-auto inline-block font-normal leading-normal text-[#fff] text-center no-underline align-middle cursor-pointer select-none border border-[solid] border-transparent px-[.75rem] py-[.375rem] text-[.9rem] rounded-[.25rem] [transition:color_.15s_ease-in-out,background-color_.15s_ease-in-out,border-color_.15s_ease-in-out,box-shadow_.15s_ease-in-out]' ]; if(variant) { - if(variant == 'primary') - newClassNames.push('text-white bg-[#1e7295] border-[#1e7295] [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white hover:bg-[#1a617f] hover:border-[#185b77]'); + newClassNames.push('text-white [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white'); if(variant == 'success') newClassNames.push('text-white bg-[#00800b] border-[#00800b] [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white hover:bg-[#006d09] hover:border-[#006609]'); @@ -43,10 +39,10 @@ export const Button: FC = props => newClassNames.push('text-white bg-[#185d79] border-[#185d79] [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white hover:bg-[#144f67] hover:border-[#134a61]'); if(variant == 'dark') - newClassNames.push('text-white bg-dark [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white hover:bg-[#18181bfb] hover:border-[#161619fb]'); - + newClassNames.push('text-white [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white'); + if(variant == 'gray') - newClassNames.push('text-white bg-[#1e7295] border-[#1e7295] [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white hover:bg-[#1a617f] hover:border-[#185b77]'); + newClassNames.push('text-white [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white'); } @@ -67,5 +63,28 @@ export const Button: FC = props => return newClassNames; }, [ variant, size, active, disabled, classNames ]); - return ; + const getStyle = useMemo(() => + { + if(variant === 'primary' || variant === 'gray') + { + return { + backgroundColor: 'var(--ui-btn-primary-bg, #1e7295)', + borderColor: 'var(--ui-btn-primary-border, #1e7295)', + ...style + }; + } + + if(variant === 'dark') + { + return { + backgroundColor: 'var(--ui-dark-bg, rgba(28, 28, 32, .98))', + borderColor: 'var(--ui-dark-border, rgba(28, 28, 32, .98))', + ...style + }; + } + + return style; + }, [ variant, style ]); + + return ; }; diff --git a/src/common/Text.tsx b/src/common/Text.tsx index ffc0a85..3e797c5 100644 --- a/src/common/Text.tsx +++ b/src/common/Text.tsx @@ -1,4 +1,4 @@ -import { FC, useMemo } from 'react'; +import React, { FC, useMemo } from 'react'; import { Base, BaseProps } from './Base'; import { ColorVariantType, FontSizeType, FontWeightType, TextAlignType } from './types'; @@ -44,7 +44,7 @@ export const Text: FC = props => { const newClassNames: string[] = [truncate ? 'block' : 'inline']; if (variant) { - if (variant === 'primary') newClassNames.push('text-[#1e7295]'); + // primary color handled via inline style with CSS variable if (variant == 'secondary') newClassNames.push('text-[#185d79]'); if (variant === 'black') newClassNames.push('text-[#000000]'); if (variant == 'dark') newClassNames.push('text-[#18181b]'); @@ -73,7 +73,12 @@ export const Text: FC = props => { return newClassNames; }, [ variant, fontWeight, fontSize, fontSizeCustom, align, bold, underline, italics, truncate, center, textEnd, small, wrap, noWrap, textBreak ]); - const style = fontSizeCustom ? { '--font-size': `${fontSizeCustom}px` } as React.CSSProperties : undefined; + const style = useMemo(() => { + const s: React.CSSProperties = {}; + if (fontSizeCustom) (s as any)['--font-size'] = `${fontSizeCustom}px`; + if (variant === 'primary') s.color = 'var(--ui-accent-color, #1e7295)'; + return Object.keys(s).length ? s : undefined; + }, [ fontSizeCustom, variant ]); return ; }; \ No newline at end of file diff --git a/src/common/card/NitroCardHeaderView.tsx b/src/common/card/NitroCardHeaderView.tsx index 8bb354c..57a4aa2 100644 --- a/src/common/card/NitroCardHeaderView.tsx +++ b/src/common/card/NitroCardHeaderView.tsx @@ -1,5 +1,6 @@ import { FC, MouseEvent } from 'react'; import { FaFlag } from 'react-icons/fa'; +import { useUiSettings } from '../../api/ui-settings/UiSettingsContext'; import { Base, Column, ColumnProps, Flex } from '..'; interface NitroCardHeaderViewProps extends ColumnProps @@ -16,8 +17,7 @@ interface NitroCardHeaderViewProps extends ColumnProps export const NitroCardHeaderView: FC = props => { const { headerText = null, isGalleryPhoto = false, noCloseButton = false, isInfoToHabboPages = false, onReportPhoto = null, onClickInfoHabboPages = null, onCloseClick = null, justifyContent = 'center', alignItems = 'center', classNames = [], children = null, ...rest } = props; - - + const { isCustomActive, getHeaderStyle } = useUiSettings(); const onMouseDown = (event: MouseEvent) => { @@ -25,8 +25,12 @@ export const NitroCardHeaderView: FC = props => event.nativeEvent.stopImmediatePropagation(); }; + const headerClassName = isCustomActive + ? 'relative flex items-center justify-center flex-col drag-handler min-h-card-header max-h-card-header' + : 'relative flex items-center justify-center flex-col drag-handler min-h-card-header max-h-card-header bg-card-header'; + return ( - + { headerText } { isGalleryPhoto && diff --git a/src/common/card/tabs/NitroCardTabsView.tsx b/src/common/card/tabs/NitroCardTabsView.tsx index 5e14506..4083309 100644 --- a/src/common/card/tabs/NitroCardTabsView.tsx +++ b/src/common/card/tabs/NitroCardTabsView.tsx @@ -1,21 +1,27 @@ import { FC, useMemo } from 'react'; +import { useUiSettings } from '../../../api/ui-settings/UiSettingsContext'; import { Flex, FlexProps } from '../..'; export const NitroCardTabsView: FC = props => { const { justifyContent = 'center', gap = 1, classNames = [], children = null, ...rest } = props; + const { isCustomActive, getTabsStyle } = useUiSettings(); const getClassNames = useMemo(() => { - const newClassNames: string[] = [ 'justify-center gap-0.5 flex bg-card-tabs min-h-card-tabs max-h-card-tabs pt-1 border-b border-card-border px-2 -mt-px' ]; + const base = isCustomActive + ? 'justify-center gap-0.5 flex min-h-card-tabs max-h-card-tabs pt-1 border-b border-card-border px-2 -mt-px' + : 'justify-center gap-0.5 flex bg-card-tabs min-h-card-tabs max-h-card-tabs pt-1 border-b border-card-border px-2 -mt-px'; + + const newClassNames: string[] = [ base ]; if(classNames.length) newClassNames.push(...classNames); return newClassNames; - }, [ classNames ]); + }, [ classNames, isCustomActive ]); return ( - + { children } ); diff --git a/src/common/layout/LayoutProgressBar.tsx b/src/common/layout/LayoutProgressBar.tsx index e538657..cd96a28 100644 --- a/src/common/layout/LayoutProgressBar.tsx +++ b/src/common/layout/LayoutProgressBar.tsx @@ -14,7 +14,7 @@ export const LayoutProgressBar: FC = props => const getClassNames = useMemo(() => { - const newClassNames: string[] = [ 'border border-[solid] border-[#fff] p-[2px] h-[20px] rounded-[.25rem] overflow-hidden bg-[#1E7295] ', 'text-white' ]; + const newClassNames: string[] = [ 'border border-[solid] border-[#fff] p-[2px] h-[20px] rounded-[.25rem] overflow-hidden', 'text-white' ]; if(classNames.length) newClassNames.push(...classNames); @@ -22,7 +22,7 @@ export const LayoutProgressBar: FC = props => }, [ classNames ]); return ( - + { text && (text.length > 0) && { text } } diff --git a/src/components/backgrounds/BackgroundsView.tsx b/src/components/backgrounds/BackgroundsView.tsx index 9782dd6..eaeb07a 100644 --- a/src/components/backgrounds/BackgroundsView.tsx +++ b/src/components/backgrounds/BackgroundsView.tsx @@ -2,9 +2,11 @@ import { GetSessionDataManager, HabboClubLevelEnum} from '@nitrots/nitro-rendere import { Dispatch, FC, SetStateAction, useCallback, useMemo, useState } from 'react'; import { Base, Grid, Flex, NitroCardView, NitroCardHeaderView, NitroCardTabsView, NitroCardTabsItemView, NitroCardContentView, Text, LayoutCurrencyIcon } from '../../common'; import { useRoom } from '../../hooks'; -import { GetClubMemberLevel, GetConfigurationValue } from '../../api'; +import { GetClubMemberLevel, GetConfigurationValue, LocalizeText } from '../../api'; +import { InterfaceColorTabView } from '../interface-settings/InterfaceColorTabView'; +import { InterfaceImageTabView } from '../interface-settings/InterfaceImageTabView'; -interface ItemData { +interface ItemData { id: number; isHcOnly: boolean; minRank: number; @@ -22,7 +24,7 @@ interface BackgroundsViewProps { setSelectedOverlay: Dispatch>; } -const TABS = ['backgrounds', 'stands', 'overlays'] as const; +const TABS = ['backgrounds', 'stands', 'overlays', 'color', 'image'] as const; type TabType = typeof TABS[number]; export const BackgroundsView: FC = ({ @@ -36,7 +38,7 @@ export const BackgroundsView: FC = ({ }) => { const [activeTab, setActiveTab] = useState('backgrounds'); const { roomSession } = useRoom(); - + const userData = useMemo(() => ({ isHcMember: GetClubMemberLevel() >= HabboClubLevelEnum.CLUB, securityLevel: GetSessionDataManager().canChangeName, @@ -45,7 +47,7 @@ export const BackgroundsView: FC = ({ const processData = useCallback((configData: any[], dataType: string): ItemData[] => { if (!configData?.length) return []; - + return configData .filter(item => { const meetsRank = userData.securityLevel >= item.minRank; @@ -65,10 +67,10 @@ export const BackgroundsView: FC = ({ if (!roomSession) return; const setters = { backgrounds: setSelectedBackground, stands: setSelectedStand, overlays: setSelectedOverlay }; - + const currentValues = { backgrounds: selectedBackground, stands: selectedStand, overlays: selectedOverlay }; - setters[activeTab](id); + setters[activeTab as 'backgrounds' | 'stands' | 'overlays'](id); const newValues = { ...currentValues, [activeTab]: id }; roomSession.sendBackgroundMessage( newValues.backgrounds, newValues.stands, newValues.overlays ); }, [activeTab, roomSession, selectedBackground, selectedStand, selectedOverlay, setSelectedBackground, setSelectedStand, setSelectedOverlay]); @@ -86,9 +88,11 @@ export const BackgroundsView: FC = ({ ), [handleSelection]); + const isProfileTab = activeTab === 'backgrounds' || activeTab === 'stands' || activeTab === 'overlays'; + return ( - setIsVisible(false)} /> + setIsVisible(false)} /> {TABS.map(tab => ( = ({ isActive={activeTab === tab} onClick={() => setActiveTab(tab)} > - {tab.charAt(0).toUpperCase() + tab.slice(1)} + { LocalizeText(`interface.settings.tab.${ tab }`) } ))} - Select an Option - - {allData[activeTab].map(item => renderItem(item, activeTab.slice(0, -1)))} - + { isProfileTab && ( + <> + { LocalizeText('interface.settings.select.option') } + + {allData[activeTab as 'backgrounds' | 'stands' | 'overlays'].map(item => renderItem(item, activeTab.slice(0, -1)))} + + + ) } + { activeTab === 'color' && } + { activeTab === 'image' && } ); -}; \ No newline at end of file +}; diff --git a/src/components/interface-settings/InterfaceColorTabView.tsx b/src/components/interface-settings/InterfaceColorTabView.tsx new file mode 100644 index 0000000..70c9fb6 --- /dev/null +++ b/src/components/interface-settings/InterfaceColorTabView.tsx @@ -0,0 +1,288 @@ +import { RgbaColorPicker, RgbaColor } from 'react-colorful'; +import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { FaUndo, FaTrash, FaDownload, FaUpload } from 'react-icons/fa'; +import { LocalizeText, PRESET_COLORS, THEME_PRESETS, useUiSettings } from '../../api'; +import { Flex, Text } from '../../common'; + +const hexToRgba = (hex: string, a = 1): RgbaColor => +{ + const num = parseInt(hex.replace('#', ''), 16); + return { r: (num >> 16) & 0xFF, g: (num >> 8) & 0xFF, b: num & 0xFF, a }; +}; + +const rgbaToHex = (rgba: RgbaColor): string => +{ + return '#' + ((1 << 24) + (rgba.r << 16) + (rgba.g << 8) + rgba.b).toString(16).slice(1); +}; + +export const InterfaceColorTabView: FC<{}> = () => +{ + const { settings, updateSettings, resetSettings } = useUiSettings(); + const [ color, setColor ] = useState(() => hexToRgba(settings.headerColor, settings.headerAlpha / 100)); + const [ importValue, setImportValue ] = useState(''); + const [ showImport, setShowImport ] = useState(false); + const [ copyFeedback, setCopyFeedback ] = useState(false); + const previewTimerRef = useRef>(null); + + const hexColor = useMemo(() => rgbaToHex(color), [ color ]); + const alphaPercent = useMemo(() => Math.round((color.a ?? 1) * 100), [ color ]); + + // Live preview con debounce + useEffect(() => + { + if(previewTimerRef.current) clearTimeout(previewTimerRef.current); + + previewTimerRef.current = setTimeout(() => + { + updateSettings({ + colorMode: 'color', + headerColor: hexColor, + headerAlpha: alphaPercent + }); + }, 50); + + return () => + { + if(previewTimerRef.current) clearTimeout(previewTimerRef.current); + }; + }, [ hexColor, alphaPercent ]); + + const onHexInput = useCallback((value: string) => + { + const clean = value.replace(/[^0-9a-fA-F]/g, '').slice(0, 6); + if(clean.length === 6) + { + const rgba = hexToRgba('#' + clean, color.a); + setColor(rgba); + } + }, [ color.a ]); + + const onRgbInput = useCallback((channel: 'r' | 'g' | 'b', value: number) => + { + const clamped = Math.max(0, Math.min(255, value || 0)); + setColor(prev => ({ ...prev, [channel]: clamped })); + }, []); + + const onAlphaInput = useCallback((value: number) => + { + const clamped = Math.max(0, Math.min(100, value || 0)); + setColor(prev => ({ ...prev, a: clamped / 100 })); + }, []); + + const onPresetClick = useCallback((presetHex: string) => + { + setColor(hexToRgba(presetHex, color.a)); + }, [ color.a ]); + + const onThemeClick = useCallback((themeColor: string, themeAlpha: number) => + { + setColor(hexToRgba(themeColor, themeAlpha / 100)); + }, []); + + const onReset = useCallback(() => + { + resetSettings(); + setColor(hexToRgba('#1E7295', 1)); + }, [ resetSettings ]); + + const onDelete = useCallback(() => + { + updateSettings({ colorMode: 'default' }); + setColor(hexToRgba('#1E7295', 1)); + }, [ updateSettings ]); + + const onExport = useCallback(() => + { + const data = JSON.stringify({ + color: hexColor, + alpha: alphaPercent, + mode: settings.colorMode, + image: settings.headerImageUrl + }); + + navigator.clipboard.writeText(data); + setCopyFeedback(true); + setTimeout(() => setCopyFeedback(false), 2000); + }, [ hexColor, alphaPercent, settings ]); + + const onImport = useCallback(() => + { + try + { + const data = JSON.parse(importValue); + + if(data.color) + { + const alpha = data.alpha ?? 100; + setColor(hexToRgba(data.color, alpha / 100)); + updateSettings({ + colorMode: data.mode || 'color', + headerColor: data.color, + headerAlpha: alpha, + headerImageUrl: data.image || '' + }); + } + + setImportValue(''); + setShowImport(false); + } + catch(e) {} + }, [ importValue, updateSettings ]); + + return ( + + {/* Color picker */} +
+ +
+ + {/* Color preview swatch */} +
+ + {/* Hex/RGB/A inputs */} + + + onHexInput(e.target.value) } + maxLength={ 6 } + /> + Hex + + + onRgbInput('r', parseInt(e.target.value)) } + min={ 0 } max={ 255 } + /> + R + + + onRgbInput('g', parseInt(e.target.value)) } + min={ 0 } max={ 255 } + /> + G + + + onRgbInput('b', parseInt(e.target.value)) } + min={ 0 } max={ 255 } + /> + B + + + onAlphaInput(parseInt(e.target.value)) } + min={ 0 } max={ 100 } + /> + A + + + + {/* Preset colors */} +
+ { PRESET_COLORS.map((presetHex, i) => ( +
onPresetClick(presetHex) } + /> + )) } +
+ + {/* Theme presets */} + { LocalizeText('interface.settings.color.themes') } +
+ { THEME_PRESETS.map((theme) => ( +
onThemeClick(theme.color, theme.alpha) } + > +
+ { LocalizeText(`interface.settings.theme.${ theme.name }`) } +
+ )) } +
+ + {/* Action buttons */} + + + + + + + + {/* Import panel */} + { showImport && ( + + setImportValue(e.target.value) } + /> + + + ) } + + ); +}; diff --git a/src/components/interface-settings/InterfaceImageTabView.tsx b/src/components/interface-settings/InterfaceImageTabView.tsx new file mode 100644 index 0000000..390a390 --- /dev/null +++ b/src/components/interface-settings/InterfaceImageTabView.tsx @@ -0,0 +1,52 @@ +import { FC, useCallback, useMemo } from 'react'; +import { GetConfigurationValue, useUiSettings } from '../../api'; + +export const InterfaceImageTabView: FC<{}> = () => +{ + const { settings, updateSettings } = useUiSettings(); + + const imageCount = useMemo(() => + { + return GetConfigurationValue('ui.header.images.count', 30); + }, []); + + const baseUrl = useMemo(() => + { + return GetConfigurationValue('ui.header.images.url', 'https://image.webbo.city/image/headerImage/image{id}.gif'); + }, []); + + const images = useMemo(() => + { + const result: string[] = []; + for(let i = 1; i <= imageCount; i++) + { + result.push(baseUrl.replace('{id}', String(i))); + } + return result; + }, [ imageCount, baseUrl ]); + + const onImageSelect = useCallback((url: string) => + { + updateSettings({ + colorMode: 'image', + headerImageUrl: url + }); + }, [ updateSettings ]); + + return ( +
+ { images.map((url, i) => ( +
onImageSelect(url) } + /> + )) } +
+ ); +}; diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx index 6a504d2..dde184d 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx @@ -45,7 +45,8 @@ const BadgeMiniPicker: FC<{ return (
e.stopPropagation() }> = props return ( - +
diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx index 12aca46..8a7d2a2 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx @@ -133,7 +133,7 @@ export const InfoStandWidgetUserView: FC = props = return ( <> - +
diff --git a/src/components/room/widgets/context-menu/ContextMenuHeaderView.tsx b/src/components/room/widgets/context-menu/ContextMenuHeaderView.tsx index a0513cf..ff26177 100644 --- a/src/components/room/widgets/context-menu/ContextMenuHeaderView.tsx +++ b/src/components/room/widgets/context-menu/ContextMenuHeaderView.tsx @@ -3,16 +3,21 @@ import { Flex, FlexProps } from '../../../../common'; export const ContextMenuHeaderView: FC = props => { - const { justifyContent = 'center', alignItems = 'center', classNames = [], ...rest } = props; + const { justifyContent = 'center', alignItems = 'center', classNames = [], style = {}, ...rest } = props; const getClassNames = useMemo(() => { - const newClassNames: string[] = [ 'bg-[#3d5f6e] text-[#fff] min-w-[117px] h-[25px] max-h-[25px] text-[16px] mb-[2px]', 'p-1' ]; + const newClassNames: string[] = [ 'text-[#fff] min-w-[117px] h-[25px] max-h-[25px] text-[16px] mb-[2px]', 'p-1' ]; if(classNames.length) newClassNames.push(...classNames); return newClassNames; }, [ classNames ]); - return ; + const mergedStyle = useMemo(() => ({ + backgroundColor: 'var(--ui-ctx-header-bg, #3d5f6e)', + ...style + }), [ style ]); + + return ; }; diff --git a/src/components/room/widgets/context-menu/ContextMenuListItemView.tsx b/src/components/room/widgets/context-menu/ContextMenuListItemView.tsx index 0a1eacc..b012a93 100644 --- a/src/components/room/widgets/context-menu/ContextMenuListItemView.tsx +++ b/src/components/room/widgets/context-menu/ContextMenuListItemView.tsx @@ -8,7 +8,7 @@ interface ContextMenuListItemViewProps extends FlexProps export const ContextMenuListItemView: FC = props => { - const { disabled = false, fullWidth = true, justifyContent = 'center', alignItems = 'center', classNames = [], onClick = null, ...rest } = props; + const { disabled = false, fullWidth = true, justifyContent = 'center', alignItems = 'center', classNames = [], style = {}, onClick = null, ...rest } = props; const handleClick = (event: MouseEvent) => { @@ -19,7 +19,7 @@ export const ContextMenuListItemView: FC = props = const getClassNames = useMemo(() => { - const newClassNames: string[] = [ 'relative mb-[2px] p-[3px] overflow-hidden', 'h-[24px] max-h-[24px] p-[3px] bg-[repeating-linear-gradient(#131e25,#131e25_50%,#0d171d_50%,#0d171d_100%)] cursor-pointer' ]; + const newClassNames: string[] = [ 'relative mb-[2px] p-[3px] overflow-hidden', 'h-[24px] max-h-[24px] p-[3px] cursor-pointer' ]; if(disabled) newClassNames.push('disabled'); @@ -28,5 +28,10 @@ export const ContextMenuListItemView: FC = props = return newClassNames; }, [ disabled, classNames ]); - return ; + const mergedStyle = useMemo(() => ({ + background: 'repeating-linear-gradient(var(--ui-ctx-item-bg1, #131e25), var(--ui-ctx-item-bg1, #131e25) 50%, var(--ui-ctx-item-bg2, #0d171d) 50%, var(--ui-ctx-item-bg2, #0d171d) 100%)', + ...style + }), [ style ]); + + return ; }; diff --git a/src/components/room/widgets/context-menu/ContextMenuView.tsx b/src/components/room/widgets/context-menu/ContextMenuView.tsx index 1ca3e83..b92dc89 100644 --- a/src/components/room/widgets/context-menu/ContextMenuView.tsx +++ b/src/components/room/widgets/context-menu/ContextMenuView.tsx @@ -76,7 +76,6 @@ export const ContextMenuView: FC = ({ const getClassNames = useMemo(() => { const classes = [ 'p-[2px]!', - 'bg-[#1c323f]', 'border-2', 'border-[solid]', 'border-[rgba(255,255,255,.5)]', @@ -98,6 +97,7 @@ export const ContextMenuView: FC = ({ top: pos.y ?? 0, transition: isFading ? 'opacity 75ms linear' : undefined, opacity, + backgroundColor: 'var(--ui-ctx-bg, #1c323f)', ...style, }), [pos, opacity, isFading, style] diff --git a/src/components/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index fbb5ae8..62503bb 100644 --- a/src/components/toolbar/ToolbarView.tsx +++ b/src/components/toolbar/ToolbarView.tsx @@ -69,7 +69,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => )} - + { diff --git a/src/css/common/Buttons.css b/src/css/common/Buttons.css index 106a3cc..21d7f1f 100644 --- a/src/css/common/Buttons.css +++ b/src/css/common/Buttons.css @@ -24,8 +24,8 @@ input[type=number] { .btn-primary { color: #fff; - background-color: #3c6d82; - border: 2px solid #1a617f; + background-color: var(--ui-btn-primary-bg, #3c6d82); + border: 2px solid var(--ui-btn-primary-border, #1a617f); padding: 0.25rem 0.5rem; font-size: .7875rem; border-radius: 0.5rem; @@ -33,7 +33,7 @@ input[type=number] { } .btn-primary:hover { - border: 2px solid #1a617f; + border: 2px solid var(--ui-btn-primary-border, #1a617f); box-shadow: none!important; } @@ -81,16 +81,16 @@ input[type=number] { .btn-dark { color: #fff; - background-color: #212131; - border: 2px solid #1c1c2a; + background-color: var(--ui-dark-bg, #212131); + border: 2px solid var(--ui-dark-border, #1c1c2a); box-shadow: none!important; border-radius: 8px; padding: 4px 11px 4px 11px; } .btn-dark:hover{ - background-color: #212131; - border: 2px solid #1c1c2a; + background-color: var(--ui-dark-bg, #212131); + border: 2px solid var(--ui-dark-border, #1c1c2a); box-shadow: none!important; border-radius: 8px; padding: 4px 11px 4px 11px; diff --git a/src/css/index.css b/src/css/index.css index 5373219..00c6aef 100644 --- a/src/css/index.css +++ b/src/css/index.css @@ -121,6 +121,27 @@ body { } @layer components { + @keyframes prefix-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + + @keyframes prefix-rainbow { + 0% { filter: hue-rotate(0deg); } + 100% { filter: hue-rotate(360deg); } + } + + @keyframes prefix-shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-1px) rotate(-1deg); } + 75% { transform: translateX(1px) rotate(1deg); } + } + + @keyframes prefix-wave { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-3px); } + } + @keyframes blink { 0%, @@ -835,7 +856,7 @@ body { /* Header and Tab Colors */ .bg-card-header { - background-color: #1e7295; /* e.g., #2c3e50 */ + background-color: var(--ui-btn-primary-bg, #1e7295); /* e.g., #2c3e50 */ } .bg-card-tabs { @@ -997,4 +1018,19 @@ body { z-index: 5; width: 100%; } +} + +/* UI Theme transitions */ +.nitro-card-header, +.bg-card-header, +.bg-card-tabs, +.nitro-room-tools, +.nitro-room-history, +.nitro-room-tools-info, +.borderhccontent, +.nitro-purse-seasonal-currency, +.nitro-infostand, +.btn-primary, +.btn-dark { + transition: background-color 0.3s ease, border-color 0.3s ease; } \ No newline at end of file diff --git a/src/css/nitrocard/NitroCardView.css b/src/css/nitrocard/NitroCardView.css index 2d13b40..05309c8 100644 --- a/src/css/nitrocard/NitroCardView.css +++ b/src/css/nitrocard/NitroCardView.css @@ -12,7 +12,7 @@ .nitro-card-header { min-height: 33px; max-height: 33px; - background: #1E7295; + background: var(--ui-accent-color, #1E7295); .nitro-card-header-text { color: #FFF; diff --git a/src/css/purse/PurseView.css b/src/css/purse/PurseView.css index 7134551..69c1732 100644 --- a/src/css/purse/PurseView.css +++ b/src/css/purse/PurseView.css @@ -22,7 +22,7 @@ pointer-events: all; } .borderhccontent{ - background-color: #212131; + background-color: var(--ui-dark-bg, #212131); border-radius: 0.5rem!important; border: 2px solid #383853; height: calc(100% - 3px); @@ -46,7 +46,7 @@ } .nitro-purse-seasonal-currency { - background-color: #212131; + background-color: var(--ui-dark-bg, #212131); background: linear-gradient(to right, #5f5f8d, transparent); height: 30px; margin-bottom: 4px; diff --git a/src/css/room/InfoStand.css b/src/css/room/InfoStand.css index e44b062..7e1a050 100644 --- a/src/css/room/InfoStand.css +++ b/src/css/room/InfoStand.css @@ -27,7 +27,7 @@ width: clamp(160px, 20vw, 190px); /* Responsive width */ z-index: 30; pointer-events: auto; - background: #212131; + background: var(--ui-dark-bg, #212131); box-shadow: inset 0 5px rgba(38, 38, 57, 0.6), inset 0 -4px rgba(25, 25, 37, 0.6); border-radius: 0.5rem; padding: 10px; diff --git a/src/css/room/RoomWidgets.css b/src/css/room/RoomWidgets.css index 1509e5b..9f28d43 100644 --- a/src/css/room/RoomWidgets.css +++ b/src/css/room/RoomWidgets.css @@ -4,7 +4,7 @@ left: 15px; .nitro-room-tools { - background: #212131; + background: var(--ui-dark-bg, #212131); box-shadow: inset 0px 5px lighten(rgba(#000, .6), 2.5), inset 0 -4px darken(rgba(#000, .6), 4); border-top-right-radius: .25rem; border-bottom-right-radius: .25rem; @@ -54,7 +54,7 @@ } .nitro-room-history { - background: #212131; + background: var(--ui-dark-bg, #212131); box-shadow: inset 0px 5px lighten(rgba(#000, .6), 2.5), inset 0 -4px darken(rgba(#000, .6), 4); transition: all .2s ease; width: 150px; @@ -63,7 +63,7 @@ } .nitro-room-tools-info { - background: #212131; + background: var(--ui-dark-bg, #212131); box-shadow: inset 0px 5px lighten(rgba(#000, .6), 2.5), inset 0 -4px darken(rgba(#000, .6), 4); transition: all .2s ease; max-width: 250px; diff --git a/src/css/slider.css b/src/css/slider.css index 528dbb0..150c342 100644 --- a/src/css/slider.css +++ b/src/css/slider.css @@ -10,7 +10,7 @@ overflow: hidden; &.track-0 { - background-color: #1e7295; + background-color: var(--ui-btn-primary-bg, #1e7295); } &.track-1 {