From 9c2dccaad6b96ae41de18ffa332262bf3b5887dc Mon Sep 17 00:00:00 2001 From: Life Date: Sun, 22 Mar 2026 21:39:44 +0100 Subject: [PATCH 1/3] 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 { From 19fd0e08093bcf9c9677723e3a015ba4b2f3c220 Mon Sep 17 00:00:00 2001 From: duckietm Date: Mon, 23 Mar 2026 13:31:15 +0100 Subject: [PATCH 2/3] Revert "Merge pull request #45 from simoleo89/interface-color-pr" This reverts commit d911196ccb812cc6ae2acaa34adee1f68a6baa66, reversing changes made to 8dccc509c40af3a81b014e0abfcf3c7c54a56891. --- 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, 70 insertions(+), 774 deletions(-) delete mode 100644 src/api/ui-settings/IUiSettings.ts delete mode 100644 src/api/ui-settings/UiSettingsContext.tsx delete mode 100644 src/api/ui-settings/index.ts delete mode 100644 src/components/interface-settings/InterfaceColorTabView.tsx delete mode 100644 src/components/interface-settings/InterfaceImageTabView.tsx diff --git a/package.json b/package.json index 716c4bf..4dab2f5 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ "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 59c8ce4..c97d6fa 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, UiSettingsProvider } from './api'; +import { GetUIVersion } from './api'; import { Base } from './common'; import { LoadingView } from './components/loading/LoadingView'; import { MainView } from './components/MainView'; @@ -90,14 +90,12 @@ 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 1bbb9e9..7089277 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -24,6 +24,5 @@ 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 deleted file mode 100644 index 5c6e96d..0000000 --- a/src/api/ui-settings/IUiSettings.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 99685ce..0000000 --- a/src/api/ui-settings/UiSettingsContext.tsx +++ /dev/null @@ -1,224 +0,0 @@ -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 deleted file mode 100644 index 255a5da..0000000 --- a/src/api/ui-settings/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './IUiSettings'; -export * from './UiSettingsContext'; diff --git a/src/common/Button.tsx b/src/common/Button.tsx index c59afa0..6b7d454 100644 --- a/src/common/Button.tsx +++ b/src/common/Button.tsx @@ -12,16 +12,20 @@ export interface ButtonProps extends FlexProps export const Button: FC = props => { - const { variant = 'primary', size = 'sm', active = false, disabled = false, classNames = [], style = {}, ...rest } = props; + const { variant = 'primary', size = 'sm', active = false, disabled = false, classNames = [], ...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 [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white'); + 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]'); 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]'); @@ -39,10 +43,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 [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white'); - + 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]'); + if(variant == 'gray') - newClassNames.push('text-white [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white'); + 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]'); } @@ -63,28 +67,5 @@ export const Button: FC = props => return newClassNames; }, [ variant, size, active, disabled, classNames ]); - 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 ; + return ; }; diff --git a/src/common/Text.tsx b/src/common/Text.tsx index 3e797c5..ffc0a85 100644 --- a/src/common/Text.tsx +++ b/src/common/Text.tsx @@ -1,4 +1,4 @@ -import React, { FC, useMemo } from 'react'; +import { 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) { - // primary color handled via inline style with CSS variable + if (variant === 'primary') newClassNames.push('text-[#1e7295]'); if (variant == 'secondary') newClassNames.push('text-[#185d79]'); if (variant === 'black') newClassNames.push('text-[#000000]'); if (variant == 'dark') newClassNames.push('text-[#18181b]'); @@ -73,12 +73,7 @@ export const Text: FC = props => { return newClassNames; }, [ variant, fontWeight, fontSize, fontSizeCustom, align, bold, underline, italics, truncate, center, textEnd, small, wrap, noWrap, textBreak ]); - 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 ]); + const style = fontSizeCustom ? { '--font-size': `${fontSizeCustom}px` } as React.CSSProperties : undefined; return ; }; \ No newline at end of file diff --git a/src/common/card/NitroCardHeaderView.tsx b/src/common/card/NitroCardHeaderView.tsx index 57a4aa2..8bb354c 100644 --- a/src/common/card/NitroCardHeaderView.tsx +++ b/src/common/card/NitroCardHeaderView.tsx @@ -1,6 +1,5 @@ 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 @@ -17,7 +16,8 @@ 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,12 +25,8 @@ 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 4083309..5e14506 100644 --- a/src/common/card/tabs/NitroCardTabsView.tsx +++ b/src/common/card/tabs/NitroCardTabsView.tsx @@ -1,27 +1,21 @@ 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 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 ]; + 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' ]; if(classNames.length) newClassNames.push(...classNames); return newClassNames; - }, [ classNames, isCustomActive ]); + }, [ classNames ]); return ( - + { children } ); diff --git a/src/common/layout/LayoutProgressBar.tsx b/src/common/layout/LayoutProgressBar.tsx index cd96a28..e538657 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', 'text-white' ]; + const newClassNames: string[] = [ 'border border-[solid] border-[#fff] p-[2px] h-[20px] rounded-[.25rem] overflow-hidden bg-[#1E7295] ', '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 eaeb07a..9782dd6 100644 --- a/src/components/backgrounds/BackgroundsView.tsx +++ b/src/components/backgrounds/BackgroundsView.tsx @@ -2,11 +2,9 @@ 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, LocalizeText } from '../../api'; -import { InterfaceColorTabView } from '../interface-settings/InterfaceColorTabView'; -import { InterfaceImageTabView } from '../interface-settings/InterfaceImageTabView'; +import { GetClubMemberLevel, GetConfigurationValue } from '../../api'; -interface ItemData { +interface ItemData { id: number; isHcOnly: boolean; minRank: number; @@ -24,7 +22,7 @@ interface BackgroundsViewProps { setSelectedOverlay: Dispatch>; } -const TABS = ['backgrounds', 'stands', 'overlays', 'color', 'image'] as const; +const TABS = ['backgrounds', 'stands', 'overlays'] as const; type TabType = typeof TABS[number]; export const BackgroundsView: FC = ({ @@ -38,7 +36,7 @@ export const BackgroundsView: FC = ({ }) => { const [activeTab, setActiveTab] = useState('backgrounds'); const { roomSession } = useRoom(); - + const userData = useMemo(() => ({ isHcMember: GetClubMemberLevel() >= HabboClubLevelEnum.CLUB, securityLevel: GetSessionDataManager().canChangeName, @@ -47,7 +45,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; @@ -67,10 +65,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 as 'backgrounds' | 'stands' | 'overlays'](id); + setters[activeTab](id); const newValues = { ...currentValues, [activeTab]: id }; roomSession.sendBackgroundMessage( newValues.backgrounds, newValues.stands, newValues.overlays ); }, [activeTab, roomSession, selectedBackground, selectedStand, selectedOverlay, setSelectedBackground, setSelectedStand, setSelectedOverlay]); @@ -88,11 +86,9 @@ 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)} > - { LocalizeText(`interface.settings.tab.${ tab }`) } + {tab.charAt(0).toUpperCase() + tab.slice(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' && } + Select an Option + + {allData[activeTab].map(item => renderItem(item, activeTab.slice(0, -1)))} + ); -}; +}; \ No newline at end of file diff --git a/src/components/interface-settings/InterfaceColorTabView.tsx b/src/components/interface-settings/InterfaceColorTabView.tsx deleted file mode 100644 index 70c9fb6..0000000 --- a/src/components/interface-settings/InterfaceColorTabView.tsx +++ /dev/null @@ -1,288 +0,0 @@ -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 deleted file mode 100644 index 390a390..0000000 --- a/src/components/interface-settings/InterfaceImageTabView.tsx +++ /dev/null @@ -1,52 +0,0 @@ -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 dde184d..6a504d2 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx @@ -45,8 +45,7 @@ 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 8a7d2a2..12aca46 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 ff26177..a0513cf 100644 --- a/src/components/room/widgets/context-menu/ContextMenuHeaderView.tsx +++ b/src/components/room/widgets/context-menu/ContextMenuHeaderView.tsx @@ -3,21 +3,16 @@ import { Flex, FlexProps } from '../../../../common'; export const ContextMenuHeaderView: FC = props => { - const { justifyContent = 'center', alignItems = 'center', classNames = [], style = {}, ...rest } = props; + const { justifyContent = 'center', alignItems = 'center', classNames = [], ...rest } = props; const getClassNames = useMemo(() => { - const newClassNames: string[] = [ 'text-[#fff] min-w-[117px] h-[25px] max-h-[25px] text-[16px] mb-[2px]', 'p-1' ]; + const newClassNames: string[] = [ 'bg-[#3d5f6e] 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 ]); - const mergedStyle = useMemo(() => ({ - backgroundColor: 'var(--ui-ctx-header-bg, #3d5f6e)', - ...style - }), [ style ]); - - return ; + return ; }; diff --git a/src/components/room/widgets/context-menu/ContextMenuListItemView.tsx b/src/components/room/widgets/context-menu/ContextMenuListItemView.tsx index b012a93..0a1eacc 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 = [], style = {}, onClick = null, ...rest } = props; + const { disabled = false, fullWidth = true, justifyContent = 'center', alignItems = 'center', classNames = [], 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] cursor-pointer' ]; + 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' ]; if(disabled) newClassNames.push('disabled'); @@ -28,10 +28,5 @@ export const ContextMenuListItemView: FC = props = return newClassNames; }, [ disabled, classNames ]); - 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 ; + return ; }; diff --git a/src/components/room/widgets/context-menu/ContextMenuView.tsx b/src/components/room/widgets/context-menu/ContextMenuView.tsx index b92dc89..1ca3e83 100644 --- a/src/components/room/widgets/context-menu/ContextMenuView.tsx +++ b/src/components/room/widgets/context-menu/ContextMenuView.tsx @@ -76,6 +76,7 @@ export const ContextMenuView: FC = ({ const getClassNames = useMemo(() => { const classes = [ 'p-[2px]!', + 'bg-[#1c323f]', 'border-2', 'border-[solid]', 'border-[rgba(255,255,255,.5)]', @@ -97,7 +98,6 @@ 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 62503bb..fbb5ae8 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 21d7f1f..106a3cc 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: var(--ui-btn-primary-bg, #3c6d82); - border: 2px solid var(--ui-btn-primary-border, #1a617f); + background-color: #3c6d82; + border: 2px solid #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 var(--ui-btn-primary-border, #1a617f); + border: 2px solid #1a617f; box-shadow: none!important; } @@ -81,16 +81,16 @@ input[type=number] { .btn-dark { color: #fff; - background-color: var(--ui-dark-bg, #212131); - border: 2px solid var(--ui-dark-border, #1c1c2a); + background-color: #212131; + border: 2px solid #1c1c2a; box-shadow: none!important; border-radius: 8px; padding: 4px 11px 4px 11px; } .btn-dark:hover{ - background-color: var(--ui-dark-bg, #212131); - border: 2px solid var(--ui-dark-border, #1c1c2a); + background-color: #212131; + border: 2px solid #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 cdf4dab..806e0e5 100644 --- a/src/css/index.css +++ b/src/css/index.css @@ -78,27 +78,6 @@ 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%, @@ -813,7 +792,7 @@ body { /* Header and Tab Colors */ .bg-card-header { - background-color: var(--ui-btn-primary-bg, #1e7295); /* e.g., #2c3e50 */ + background-color: #1e7295; /* e.g., #2c3e50 */ } .bg-card-tabs { @@ -975,19 +954,4 @@ 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 05309c8..2d13b40 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: var(--ui-accent-color, #1E7295); + background: #1E7295; .nitro-card-header-text { color: #FFF; diff --git a/src/css/purse/PurseView.css b/src/css/purse/PurseView.css index 69c1732..7134551 100644 --- a/src/css/purse/PurseView.css +++ b/src/css/purse/PurseView.css @@ -22,7 +22,7 @@ pointer-events: all; } .borderhccontent{ - background-color: var(--ui-dark-bg, #212131); + background-color: #212131; border-radius: 0.5rem!important; border: 2px solid #383853; height: calc(100% - 3px); @@ -46,7 +46,7 @@ } .nitro-purse-seasonal-currency { - background-color: var(--ui-dark-bg, #212131); + background-color: #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 7e1a050..e44b062 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: var(--ui-dark-bg, #212131); + background: #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 9f28d43..1509e5b 100644 --- a/src/css/room/RoomWidgets.css +++ b/src/css/room/RoomWidgets.css @@ -4,7 +4,7 @@ left: 15px; .nitro-room-tools { - background: var(--ui-dark-bg, #212131); + background: #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: var(--ui-dark-bg, #212131); + background: #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: var(--ui-dark-bg, #212131); + background: #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 150c342..528dbb0 100644 --- a/src/css/slider.css +++ b/src/css/slider.css @@ -10,7 +10,7 @@ overflow: hidden; &.track-0 { - background-color: var(--ui-btn-primary-bg, #1e7295); + background-color: #1e7295; } &.track-1 { From 33c31fe07de4795c3ae7d0ab3bb3c65640b07da1 Mon Sep 17 00:00:00 2001 From: duckietm Date: Mon, 23 Mar 2026 15:02:20 +0100 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=86=95=20=20Added=20New=20catalogue?= =?UTF-8?q?=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/renderer-config.json | 110 ------- src/api/ui-settings/IUiSettings.ts | 43 +++ src/api/ui-settings/UiSettingsContext.tsx | 224 +++++++++++++ src/api/ui-settings/index.ts | 2 + .../catalog/CatalogAdminContext.tsx | 296 ++++++++++++++++++ src/components/catalog/CatalogClassicView.tsx | 183 +++++++++++ src/components/catalog/CatalogModernView.tsx | 291 +++++++++++++++++ src/components/catalog/CatalogView.tsx | 114 +------ .../views/admin/CatalogAdminOfferEditView.tsx | 225 +++++++++++++ .../views/admin/CatalogAdminPageEditView.tsx | 153 +++++++++ .../catalog-rail/CatalogRailItemView.tsx | 30 ++ .../views/favorites/CatalogFavoritesView.tsx | 154 +++++++++ .../navigation/CatalogBreadcrumbView.tsx | 39 +++ .../navigation/CatalogNavigationItemView.tsx | 132 +++++++- .../navigation/CatalogNavigationView.tsx | 23 +- .../page/common/CatalogGridOfferView.tsx | 26 +- .../views/page/common/CatalogSearchView.tsx | 43 +-- .../layout/CatalogLayoutCustomPrefixView.tsx | 85 ++--- .../page/layout/CatalogLayoutDefaultView.tsx | 125 +++++--- .../page/layout/CatalogLayoutPets3View.tsx | 33 +- .../page/layout/CatalogLayoutTrophiesView.tsx | 128 ++++++-- .../page/layout/pets/CatalogLayoutPetView.tsx | 177 ++++++++--- .../widgets/CatalogItemGridWidgetView.tsx | 66 +++- .../widgets/CatalogPriceDisplayWidgetView.tsx | 14 +- .../page/widgets/CatalogSpinnerWidgetView.tsx | 34 +- .../InterfaceColorTabView.tsx | 288 +++++++++++++++++ .../InterfaceImageTabView.tsx | 52 +++ src/hooks/catalog/index.ts | 1 + src/hooks/catalog/useCatalogFavorites.ts | 129 ++++++++ 29 files changed, 2746 insertions(+), 474 deletions(-) delete mode 100644 public/renderer-config.json 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/catalog/CatalogAdminContext.tsx create mode 100644 src/components/catalog/CatalogClassicView.tsx create mode 100644 src/components/catalog/CatalogModernView.tsx create mode 100644 src/components/catalog/views/admin/CatalogAdminOfferEditView.tsx create mode 100644 src/components/catalog/views/admin/CatalogAdminPageEditView.tsx create mode 100644 src/components/catalog/views/catalog-rail/CatalogRailItemView.tsx create mode 100644 src/components/catalog/views/favorites/CatalogFavoritesView.tsx create mode 100644 src/components/catalog/views/navigation/CatalogBreadcrumbView.tsx create mode 100644 src/components/interface-settings/InterfaceColorTabView.tsx create mode 100644 src/components/interface-settings/InterfaceImageTabView.tsx create mode 100644 src/hooks/catalog/useCatalogFavorites.ts diff --git a/public/renderer-config.json b/public/renderer-config.json deleted file mode 100644 index 1c2b22a..0000000 --- a/public/renderer-config.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "socket.url": "ws://localhost:2097", - "asset.url": "http://localhost:3000/nitro-assets", - "image.library.url": "http://localhost:3000/swf/c_images/", - "hof.furni.url": "http://localhost:3000/swf/dcr/hof_furni", - "images.url": "${asset.url}/images", - "gamedata.url": "${asset.url}/gamedata", - "sounds.url": "${asset.url}/sounds/%sample%.mp3", - "external.texts.url": [ "${gamedata.url}/ExternalTexts.json", "${gamedata.url}/UITexts.json" ], - "external.samples.url": "${hof.furni.url}/mp3/sound_machine_sample_%sample%.mp3", - "furnidata.url": "${gamedata.url}/FurnitureData.json", - "productdata.url": "${gamedata.url}/ProductData.json", - "avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json", - "avatar.figuredata.url": "${gamedata.url}/FigureData.json", - "avatar.figuremap.url": "${gamedata.url}/FigureMap.json", - "avatar.effectmap.url": "${gamedata.url}/EffectMap.json", - "avatar.asset.url": "${asset.url}/bundled/figure/%libname%.nitro", - "avatar.asset.effect.url": "${asset.url}/bundled/effect/%libname%.nitro", - "furni.asset.url": "${asset.url}/bundled/furniture/%libname%.nitro", - "furni.asset.icon.url": "${hof.furni.url}/icons/%libname%%param%_icon.png", - "pet.asset.url": "${asset.url}/bundled/pet/%libname%.nitro", - "generic.asset.url": "${asset.url}/bundled/generic/%libname%.nitro", - "badge.asset.url": "${image.library.url}album1584/%badgename%.gif", - "furni.rotation.bounce.steps": 20, - "furni.rotation.bounce.height": 0.0625, - "enable.avatar.arrow": false, - "system.log.debug": true, - "system.log.warn": true, - "system.log.error": true, - "system.log.events": true, - "system.log.packets": true, - "system.fps.animation": 24, - "system.fps.max": 60, - "system.pong.manually": true, - "system.pong.interval.ms": 20000, - "room.color.skip.transition": true, - "room.landscapes.enabled": true, - "avatar.mandatory.libraries": [ - "bd:1", - "li:0" - ], - "avatar.mandatory.effect.libraries": [ - "dance.1", - "dance.2", - "dance.3", - "dance.4" - ], - "avatar.default.figuredata": {"palettes":[{"id":1,"colors":[{"id":99999,"index":1001,"club":0,"selectable":false,"hexCode":"DDDDDD"},{"id":99998,"index":1001,"club":0,"selectable":false,"hexCode":"FAFAFA"}]},{"id":3,"colors":[{"id":10001,"index":1001,"club":0,"selectable":false,"hexCode":"EEEEEE"},{"id":10002,"index":1002,"club":0,"selectable":false,"hexCode":"FA3831"},{"id":10003,"index":1003,"club":0,"selectable":false,"hexCode":"FD92A0"},{"id":10004,"index":1004,"club":0,"selectable":false,"hexCode":"2AC7D2"},{"id":10005,"index":1005,"club":0,"selectable":false,"hexCode":"35332C"},{"id":10006,"index":1006,"club":0,"selectable":false,"hexCode":"EFFF92"},{"id":10007,"index":1007,"club":0,"selectable":false,"hexCode":"C6FF98"},{"id":10008,"index":1008,"club":0,"selectable":false,"hexCode":"FF925A"},{"id":10009,"index":1009,"club":0,"selectable":false,"hexCode":"9D597E"},{"id":10010,"index":1010,"club":0,"selectable":false,"hexCode":"B6F3FF"},{"id":10011,"index":1011,"club":0,"selectable":false,"hexCode":"6DFF33"},{"id":10012,"index":1012,"club":0,"selectable":false,"hexCode":"3378C9"},{"id":10013,"index":1013,"club":0,"selectable":false,"hexCode":"FFB631"},{"id":10014,"index":1014,"club":0,"selectable":false,"hexCode":"DFA1E9"},{"id":10015,"index":1015,"club":0,"selectable":false,"hexCode":"F9FB32"},{"id":10016,"index":1016,"club":0,"selectable":false,"hexCode":"CAAF8F"},{"id":10017,"index":1017,"club":0,"selectable":false,"hexCode":"C5C6C5"},{"id":10018,"index":1018,"club":0,"selectable":false,"hexCode":"47623D"},{"id":10019,"index":1019,"club":0,"selectable":false,"hexCode":"8A8361"},{"id":10020,"index":1020,"club":0,"selectable":false,"hexCode":"FF8C33"},{"id":10021,"index":1021,"club":0,"selectable":false,"hexCode":"54C627"},{"id":10022,"index":1022,"club":0,"selectable":false,"hexCode":"1E6C99"},{"id":10023,"index":1023,"club":0,"selectable":false,"hexCode":"984F88"},{"id":10024,"index":1024,"club":0,"selectable":false,"hexCode":"77C8FF"},{"id":10025,"index":1025,"club":0,"selectable":false,"hexCode":"FFC08E"},{"id":10026,"index":1026,"club":0,"selectable":false,"hexCode":"3C4B87"},{"id":10027,"index":1027,"club":0,"selectable":false,"hexCode":"7C2C47"},{"id":10028,"index":1028,"club":0,"selectable":false,"hexCode":"D7FFE3"},{"id":10029,"index":1029,"club":0,"selectable":false,"hexCode":"8F3F1C"},{"id":10030,"index":1030,"club":0,"selectable":false,"hexCode":"FF6393"},{"id":10031,"index":1031,"club":0,"selectable":false,"hexCode":"1F9B79"},{"id":10032,"index":1032,"club":0,"selectable":false,"hexCode":"FDFF33"}]}],"setTypes":[{"type":"hd","paletteId":1,"mandatory_f_0":true,"mandatory_f_1":true,"mandatory_m_0":true,"mandatory_m_1":true,"sets":[{"id":99999,"gender":"U","club":0,"colorable":true,"selectable":false,"preselectable":false,"sellable":false,"parts":[{"id":1,"type":"bd","colorable":true,"index":0,"colorindex":1},{"id":1,"type":"hd","colorable":true,"index":0,"colorindex":1},{"id":1,"type":"lh","colorable":true,"index":0,"colorindex":1},{"id":1,"type":"rh","colorable":true,"index":0,"colorindex":1}]}]},{"type":"bds","paletteId":1,"mandatory_f_0":false,"mandatory_f_1":false,"mandatory_m_0":false,"mandatory_m_1":false,"sets":[{"id":10001,"gender":"U","club":0,"colorable":true,"selectable":false,"preselectable":false,"sellable":false,"parts":[{"id":10001,"type":"bds","colorable":true,"index":0,"colorindex":1},{"id":10001,"type":"lhs","colorable":true,"index":0,"colorindex":1},{"id":10001,"type":"rhs","colorable":true,"index":0,"colorindex":1}],"hiddenLayers":[{"partType":"bd"},{"partType":"rh"},{"partType":"lh"}]}]},{"type":"ss","paletteId":3,"mandatory_f_0":false,"mandatory_f_1":false,"mandatory_m_0":false,"mandatory_m_1":false,"sets":[{"id":10010,"gender":"F","club":0,"colorable":true,"selectable":false,"preselectable":false,"sellable":false,"parts":[{"id":10001,"type":"ss","colorable":true,"index":0,"colorindex":1}],"hiddenLayers":[{"partType":"ch"},{"partType":"lg"},{"partType":"ca"},{"partType":"wa"},{"partType":"sh"},{"partType":"ls"},{"partType":"rs"},{"partType":"lc"},{"partType":"rc"},{"partType":"cc"},{"partType":"cp"}]},{"id":10011,"gender":"M","club":0,"colorable":true,"selectable":false,"preselectable":false,"sellable":false,"parts":[{"id":10002,"type":"ss","colorable":true,"index":0,"colorindex":1}],"hiddenLayers":[{"partType":"ch"},{"partType":"lg"},{"partType":"ca"},{"partType":"wa"},{"partType":"sh"},{"partType":"ls"},{"partType":"rs"},{"partType":"lc"},{"partType":"rc"},{"partType":"cc"},{"partType":"cp"}]}]}]}, - "avatar.default.actions": { - "actions": [ - { - "id": "Default", - "state": "std", - "precedence": 1000, - "main": true, - "isDefault": true, - "geometryType": "vertical", - "activePartSet": "figure", - "assetPartDefinition": "std" - } - ] - }, - "pet.types": [ - "dog", - "cat", - "croco", - "terrier", - "bear", - "pig", - "lion", - "rhino", - "spider", - "turtle", - "chicken", - "frog", - "dragon", - "monster", - "monkey", - "horse", - "monsterplant", - "bunnyeaster", - "bunnyevil", - "bunnydepressed", - "bunnylove", - "pigeongood", - "pigeonevil", - "demonmonkey", - "bearbaby", - "terrierbaby", - "gnome", - "leprechaun", - "kittenbaby", - "puppybaby", - "pigletbaby", - "haloompa", - "fools", - "pterosaur", - "velociraptor", - "cow", - "dragondog" - ], - "preload.assets.urls": [ - "${asset.url}/bundled/generic/avatar_additions.nitro", - "${asset.url}/bundled/generic/group_badge.nitro", - "${asset.url}/bundled/generic/floor_editor.nitro", - "${images.url}/loading_icon.png", - "${images.url}/clear_icon.png", - "${images.url}/big_arrow.png" - ] -} 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/components/catalog/CatalogAdminContext.tsx b/src/components/catalog/CatalogAdminContext.tsx new file mode 100644 index 0000000..5a5fe18 --- /dev/null +++ b/src/components/catalog/CatalogAdminContext.tsx @@ -0,0 +1,296 @@ +import { CatalogAdminCreateOfferComposer, CatalogAdminCreatePageComposer, CatalogAdminDeleteOfferComposer, CatalogAdminDeletePageComposer, CatalogAdminMoveOfferComposer, CatalogAdminMovePageComposer, CatalogAdminPublishComposer, CatalogAdminResultEvent, CatalogAdminSaveOfferComposer, CatalogAdminSavePageComposer } from '@nitrots/nitro-renderer'; +import { createContext, FC, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { ICatalogNode, IPurchasableOffer, NotificationAlertType, SendMessageComposer } from '../../api'; +import { useMessageEvent, useNotification } from '../../hooks'; + +export interface IPageEditData +{ + pageId?: number; + caption: string; + parentId: number; + pageLayout: string; + enabled: string; + visible: string; + minRank: number; + clubOnly?: string; + orderNum: number; + pageHeadline?: string; + pageTeaser?: string; + pageSpecial?: string; + pageText1?: string; + pageText2?: string; + pageTextDetails?: string; + pageTextTeaser?: string; +} + +export interface IOfferEditData +{ + offerId?: number; + pageId: number; + itemIds: string; + catalogName: string; + costCredits: number; + costPoints: number; + pointsType: number; + amount: number; + clubOnly: string; + extradata: string; + haveOffer: string; + offerId_group: number; + limitedStack: number; + orderNumber: number; +} + +interface ICatalogAdminContext +{ + adminMode: boolean; + setAdminMode: (value: boolean) => void; + editingOffer: IPurchasableOffer | null; + setEditingOffer: (offer: IPurchasableOffer | null) => void; + editingPageData: boolean; + setEditingPageData: (value: boolean) => void; + editingRootPage: boolean; + setEditingRootPage: (value: boolean) => void; + editingPageNode: ICatalogNode | null; + setEditingPageNode: (node: ICatalogNode | null) => void; + loading: boolean; + lastError: string | null; + savePage: (data: IPageEditData) => void; + createPage: (data: IPageEditData) => void; + deletePage: (pageId: number) => void; + saveOffer: (data: IOfferEditData) => void; + createOffer: (data: IOfferEditData) => void; + deleteOffer: (offerId: number) => void; + reorderOffers: (orders: { id: number; orderNumber: number }[]) => void; + reorderPage: (pageId: number, newParentId: number, newIndex: number) => void; + togglePageEnabled: (pageId: number) => void; + togglePageVisible: (pageId: number) => void; + publishCatalog: () => void; + hasPendingChanges: boolean; +} + +const CatalogAdminContext = createContext(null); + +export const useCatalogAdmin = () => useContext(CatalogAdminContext); + +export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children }) => +{ + const [ adminMode, setAdminMode ] = useState(false); + const [ editingOffer, setEditingOffer ] = useState(null); + const [ editingPageData, setEditingPageData ] = useState(false); + const [ editingRootPage, setEditingRootPage ] = useState(false); + const [ editingPageNode, setEditingPageNode ] = useState(null); + const [ loading, setLoading ] = useState(false); + const [ lastError, setLastError ] = useState(null); + const [ hasPendingChanges, setHasPendingChanges ] = useState(false); + const pendingActionRef = useRef(null); + const { simpleAlert = null } = useNotification(); + + // Keyboard shortcuts: Esc to close edit panels + useEffect(() => + { + if(!adminMode) return; + + const handleKeyDown = (e: KeyboardEvent) => + { + if(e.key === 'Escape') + { + if(editingOffer) { setEditingOffer(null); e.preventDefault(); return; } + if(editingPageData || editingRootPage || editingPageNode) + { + setEditingPageData(false); + setEditingRootPage(false); + setEditingPageNode(null); + e.preventDefault(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => window.removeEventListener('keydown', handleKeyDown); + }, [ adminMode, editingOffer, editingPageData, editingRootPage, editingPageNode ]); + + useMessageEvent(CatalogAdminResultEvent, (event: CatalogAdminResultEvent) => + { + const parser = event.getParser(); + const action = pendingActionRef.current; + + pendingActionRef.current = null; + setLoading(false); + + if(!parser.success) + { + setLastError(parser.message || 'Operation failed'); + + if(simpleAlert) + { + simpleAlert(parser.message || 'Operation failed', NotificationAlertType.ALERT, null, null, 'Admin Error'); + } + } + else + { + setLastError(null); + setEditingOffer(null); + setEditingPageData(false); + setEditingRootPage(false); + setEditingPageNode(null); + + if(action === 'publish') + { + setHasPendingChanges(false); + } + else + { + setHasPendingChanges(true); + } + + if(simpleAlert && action) + { + const messages: Record = { + 'savePage': 'Page saved (publish to apply)', + 'createPage': 'Page created (publish to apply)', + 'deletePage': 'Page deleted (publish to apply)', + 'saveOffer': 'Offer saved (publish to apply)', + 'createOffer': 'Offer created (publish to apply)', + 'deleteOffer': 'Offer deleted (publish to apply)', + 'reorder': 'Order updated (publish to apply)', + 'toggleEnabled': 'Page toggled (publish to apply)', + 'toggleVisible': 'Visibility toggled (publish to apply)', + 'movePage': 'Page moved (publish to apply)', + 'publish': 'Catalog published! All users updated.', + }; + + simpleAlert(messages[action] || 'Operation completed', NotificationAlertType.DEFAULT, null, null, 'Catalog Admin'); + } + } + }); + + const savePage = useCallback((data: IPageEditData) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'savePage'; + SendMessageComposer(new CatalogAdminSavePageComposer( + data.pageId || 0, data.caption, data.caption, data.pageLayout, 0, + data.minRank, data.visible === '1', data.enabled === '1', + data.orderNum, data.parentId, + data.pageHeadline || '', data.pageTeaser || '', data.pageTextDetails || '' + )); + }, []); + + const createPage = useCallback((data: IPageEditData) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'createPage'; + SendMessageComposer(new CatalogAdminCreatePageComposer( + data.caption, data.caption, data.pageLayout, 0, + data.minRank, data.visible === '1', data.enabled === '1', + data.orderNum, data.parentId + )); + }, []); + + const deletePage = useCallback((pageId: number) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'deletePage'; + SendMessageComposer(new CatalogAdminDeletePageComposer(pageId)); + }, []); + + const saveOffer = useCallback((data: IOfferEditData) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'saveOffer'; + SendMessageComposer(new CatalogAdminSaveOfferComposer( + data.offerId || 0, data.pageId, parseInt(data.itemIds) || 0, + data.catalogName, data.costCredits, data.costPoints, data.pointsType, + data.amount, data.clubOnly === '1' ? 1 : 0, data.extradata, + data.haveOffer === '1', data.offerId_group, data.limitedStack, data.orderNumber + )); + }, []); + + const createOffer = useCallback((data: IOfferEditData) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'createOffer'; + SendMessageComposer(new CatalogAdminCreateOfferComposer( + data.pageId, parseInt(data.itemIds) || 0, + data.catalogName, data.costCredits, data.costPoints, data.pointsType, + data.amount, data.clubOnly === '1' ? 1 : 0, data.extradata, + data.haveOffer === '1', data.offerId_group, data.limitedStack, data.orderNumber + )); + }, []); + + const deleteOffer = useCallback((offerId: number) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'deleteOffer'; + SendMessageComposer(new CatalogAdminDeleteOfferComposer(offerId)); + }, []); + + const reorderOffers = useCallback((orders: { id: number; orderNumber: number }[]) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'reorder'; + + for(const order of orders) + { + SendMessageComposer(new CatalogAdminMoveOfferComposer(order.id, order.orderNumber)); + } + }, []); + + const reorderPage = useCallback((pageId: number, newParentId: number, newIndex: number) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'movePage'; + SendMessageComposer(new CatalogAdminMovePageComposer(pageId, newParentId, newIndex)); + }, []); + + const togglePageEnabled = useCallback((pageId: number) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'toggleEnabled'; + SendMessageComposer(new CatalogAdminMovePageComposer(pageId, -1, -1)); + }, []); + + const togglePageVisible = useCallback((pageId: number) => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'toggleVisible'; + SendMessageComposer(new CatalogAdminMovePageComposer(pageId, -2, -1)); + }, []); + + const publishCatalog = useCallback(() => + { + setLoading(true); + setLastError(null); + pendingActionRef.current = 'publish'; + SendMessageComposer(new CatalogAdminPublishComposer()); + }, []); + + return ( + + { children } + + ); +}; diff --git a/src/components/catalog/CatalogClassicView.tsx b/src/components/catalog/CatalogClassicView.tsx new file mode 100644 index 0000000..a5fabd4 --- /dev/null +++ b/src/components/catalog/CatalogClassicView.tsx @@ -0,0 +1,183 @@ +import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { FC, useEffect } from 'react'; +import { FaCog, FaEdit, FaEye, FaEyeSlash, FaPlus, FaTrash } from 'react-icons/fa'; +import { GetConfigurationValue, LocalizeText } from '../../api'; +import { Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common'; +import { useCatalog } from '../../hooks'; +import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext'; +import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView'; +import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView'; +import { CatalogIconView } from './views/catalog-icon/CatalogIconView'; +import { CatalogGiftView } from './views/gift/CatalogGiftView'; +import { CatalogNavigationView } from './views/navigation/CatalogNavigationView'; +import { GetCatalogLayout } from './views/page/layout/GetCatalogLayout'; +import { MarketplacePostOfferView } from './views/page/layout/marketplace/MarketplacePostOfferView'; + +const CatalogClassicViewInner: FC<{}> = () => +{ + const { isVisible = false, setIsVisible = null, rootNode = null, currentPage = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], searchResult = null, setSearchResult = null, openPageByName = null, openPageByOfferId = null, activateNode = null } = useCatalog(); + const catalogAdmin = useCatalogAdmin(); + const adminMode = catalogAdmin?.adminMode ?? false; + const setAdminMode = catalogAdmin?.setAdminMode ?? (() => {}); + const hasPendingChanges = catalogAdmin?.hasPendingChanges ?? false; + const publishCatalog = catalogAdmin?.publishCatalog ?? (() => {}); + const loading = catalogAdmin?.loading ?? false; + + const isMod = GetSessionDataManager().isModerator; + + useEffect(() => + { + const linkTracker: ILinkEventTracker = { + linkReceived: (url: string) => + { + const parts = url.split('/'); + + if(parts.length < 2) return; + + switch(parts[1]) + { + case 'show': + setIsVisible(true); + return; + case 'hide': + setIsVisible(false); + return; + case 'toggle': + setIsVisible(prevValue => !prevValue); + return; + case 'open': + if(parts.length > 2) + { + if(parts.length === 4) + { + switch(parts[2]) + { + case 'offerId': + openPageByOfferId(parseInt(parts[3])); + return; + } + } + else + { + openPageByName(parts[2]); + } + } + else + { + setIsVisible(true); + } + + return; + } + }, + eventUrlPrefix: 'catalog/' + }; + + AddLinkEventTracker(linkTracker); + + return () => RemoveLinkEventTracker(linkTracker); + }, [ setIsVisible, openPageByOfferId, openPageByName ]); + + return ( + <> + { isVisible && + + setIsVisible(false) } /> + { /* Admin banner */ } + { adminMode && +
+ ⚙ Admin Mode + +
} + + { rootNode && (rootNode.children.length > 0) && rootNode.children.map((child, index) => + { + if(!adminMode && !child.isVisible) return null; + + const isHidden = !child.isVisible; + + return ( + + { + if(searchResult) setSearchResult(null); + + activateNode(child); + } } > +
+ { GetConfigurationValue('catalog.tab.icons') && } + { child.localization } + { adminMode && isHidden && } + { adminMode && +
e.stopPropagation() }> + { catalogAdmin.setEditingPageNode(child); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true); } } /> + catalogAdmin.togglePageVisible(child.pageId) }> + { isHidden ? : } + + { if(confirm(LocalizeText('catalog.admin.delete.category.confirm', [ 'name' ], [ child.localization ]))) catalogAdmin.deletePage(child.pageId); } } /> +
} +
+
+ ); + }) } + { /* Admin toggle button in tabs bar */ } + { isMod && + setAdminMode(!adminMode) }> + + } +
+ + { /* Admin: add new root category */ } + { adminMode && rootNode && +
+ + +
} + + { !navigationHidden && + + { activeNodes && (activeNodes.length > 0) && + } + } + + { adminMode && } + { GetCatalogLayout(currentPage, () => setNavigationHidden(true)) } + + +
+
} + + + + + ); +}; + +export const CatalogClassicView: FC<{}> = () => +{ + return ( + + + + ); +}; diff --git a/src/components/catalog/CatalogModernView.tsx b/src/components/catalog/CatalogModernView.tsx new file mode 100644 index 0000000..958e3ea --- /dev/null +++ b/src/components/catalog/CatalogModernView.tsx @@ -0,0 +1,291 @@ +import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { FC, useEffect, useState } from 'react'; +import { FaCog, FaEdit, FaEye, FaEyeSlash, FaHeart, FaPlus, FaStar, FaTrash } from 'react-icons/fa'; +import { LocalizeText } from '../../api'; +import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common'; +import { useCatalog, useCatalogFavorites } from '../../hooks'; +import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext'; +import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView'; +import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView'; +import { CatalogIconView } from './views/catalog-icon/CatalogIconView'; +import { CatalogFavoritesView } from './views/favorites/CatalogFavoritesView'; +import { CatalogGiftView } from './views/gift/CatalogGiftView'; +import { CatalogNavigationView } from './views/navigation/CatalogNavigationView'; +import { CatalogSearchView } from './views/page/common/CatalogSearchView'; +import { GetCatalogLayout } from './views/page/layout/GetCatalogLayout'; +import { MarketplacePostOfferView } from './views/page/layout/marketplace/MarketplacePostOfferView'; + +const CatalogModernViewInner: FC<{}> = () => +{ + const { isVisible = false, setIsVisible = null, rootNode = null, currentPage = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], searchResult = null, setSearchResult = null, openPageByName = null, openPageByOfferId = null, activateNode = null } = useCatalog(); + const catalogAdmin = useCatalogAdmin(); + const adminMode = catalogAdmin?.adminMode ?? false; + const setAdminMode = catalogAdmin?.setAdminMode ?? (() => {}); + const hasPendingChanges = catalogAdmin?.hasPendingChanges ?? false; + const publishCatalog = catalogAdmin?.publishCatalog ?? (() => {}); + const loading = catalogAdmin?.loading ?? false; + const { favoriteOfferIds, favoritePageIds } = useCatalogFavorites(); + const [ showFavorites, setShowFavorites ] = useState(false); + + const isMod = GetSessionDataManager().isModerator; + const totalFavs = favoriteOfferIds.length + favoritePageIds.length; + + useEffect(() => + { + const linkTracker: ILinkEventTracker = { + linkReceived: (url: string) => + { + const parts = url.split('/'); + + if(parts.length < 2) return; + + switch(parts[1]) + { + case 'show': + setIsVisible(true); + return; + case 'hide': + setIsVisible(false); + return; + case 'toggle': + setIsVisible(prevValue => !prevValue); + return; + case 'open': + if(parts.length > 2) + { + if(parts.length === 4) + { + switch(parts[2]) + { + case 'offerId': + openPageByOfferId(parseInt(parts[3])); + return; + } + } + else + { + openPageByName(parts[2]); + } + } + else + { + setIsVisible(true); + } + + return; + } + }, + eventUrlPrefix: 'catalog/' + }; + + AddLinkEventTracker(linkTracker); + + return () => RemoveLinkEventTracker(linkTracker); + }, [ setIsVisible, openPageByOfferId, openPageByName ]); + + return ( + <> + { isVisible && + + setIsVisible(false) } /> + + { /* Admin banner */ } + { adminMode && +
+ ⚙ Admin Mode + +
} + +
+ { /* === LEFT SIDEBAR === */ } +
+ + { /* Favorites toggle */ } +
setShowFavorites(!showFavorites) } + > +
+ 0 ? 'text-danger' : 'text-muted' }` } /> + { totalFavs > 0 && + + { totalFavs } + } +
+ { LocalizeText('catalog.favorites') } +
+ +
+ + { /* Admin: root page actions */ } + { adminMode && rootNode && +
+ + +
} + + { /* Category icons */ } + { rootNode && rootNode.children.length > 0 && rootNode.children.map((child, index) => + { + if(!adminMode && !child.isVisible) return null; + + const isHidden = !child.isVisible; + + return ( +
+ { + if(searchResult) setSearchResult(null); + if(showFavorites) setShowFavorites(false); + activateNode(child); + } } + > +
+ + { isHidden && } +
+ + { child.localization } + + { /* Admin actions on each root category */ } + { adminMode && +
+
+ { + e.stopPropagation(); + catalogAdmin.setEditingPageNode(child); + catalogAdmin.setEditingRootPage(false); + catalogAdmin.setEditingPageData(true); + } } + > + +
+
+ { + e.stopPropagation(); + catalogAdmin.togglePageVisible(child.pageId); + } } + > + { isHidden + ? + : } +
+
+ { + e.stopPropagation(); + if(confirm(LocalizeText('catalog.admin.delete.category.confirm', [ 'name' ], [ child.localization ]))) + { + catalogAdmin.deletePage(child.pageId); + } + } } + > + +
+
} +
+ ); + }) } +
+ + { /* === MAIN AREA === */ } +
+ { /* Toolbar: search + admin */ } +
+ { /* Breadcrumb */ } +
+ + { activeNodes && activeNodes.length > 0 + ? activeNodes.map((node, i) => ( + + { i > 0 && } + activateNode(node) : undefined }> + { node.localization } + + + )) + : { LocalizeText('catalog.title') } } +
+ +
+ +
+ + { isMod && + } +
+ + { /* Content area */ } +
+ { showFavorites + ?
+ setShowFavorites(false) } /> +
+ : <> + { !navigationHidden && activeNodes && activeNodes.length > 0 && +
+ +
} +
+ { adminMode && } + { GetCatalogLayout(currentPage, () => setNavigationHidden(true)) } +
+ } +
+
+
+ + } + + + + + ); +}; + +export const CatalogModernView: FC<{}> = () => +{ + return ( + + + + ); +}; diff --git a/src/components/catalog/CatalogView.tsx b/src/components/catalog/CatalogView.tsx index fc5f633..e685131 100644 --- a/src/components/catalog/CatalogView.tsx +++ b/src/components/catalog/CatalogView.tsx @@ -1,111 +1,13 @@ -import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; -import { FC, useEffect } from 'react'; -import { GetConfigurationValue, LocalizeText } from '../../api'; -import { Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common'; -import { useCatalog } from '../../hooks'; -import { CatalogIconView } from './views/catalog-icon/CatalogIconView'; -import { CatalogGiftView } from './views/gift/CatalogGiftView'; -import { CatalogNavigationView } from './views/navigation/CatalogNavigationView'; -import { GetCatalogLayout } from './views/page/layout/GetCatalogLayout'; -import { MarketplacePostOfferView } from './views/page/layout/marketplace/MarketplacePostOfferView'; +import { FC } from 'react'; +import { GetConfigurationValue } from '../../api'; +import { CatalogClassicView } from './CatalogClassicView'; +import { CatalogModernView } from './CatalogModernView'; -export const CatalogView: FC<{}> = props => +export const CatalogView: FC<{}> = () => { - const { isVisible = false, setIsVisible = null, rootNode = null, currentPage = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], searchResult = null, setSearchResult = null, openPageByName = null, openPageByOfferId = null, activateNode = null, getNodeById } = useCatalog(); + const style = GetConfigurationValue('catalog.style', 'classic'); - useEffect(() => - { - const linkTracker: ILinkEventTracker = { - linkReceived: (url: string) => - { - const parts = url.split('/'); + if(style === 'new') return ; - if(parts.length < 2) return; - - switch(parts[1]) - { - case 'show': - setIsVisible(true); - return; - case 'hide': - setIsVisible(false); - return; - case 'toggle': - setIsVisible(prevValue => !prevValue); - return; - case 'open': - if(parts.length > 2) - { - if(parts.length === 4) - { - switch(parts[2]) - { - case 'offerId': - openPageByOfferId(parseInt(parts[3])); - return; - } - } - else - { - openPageByName(parts[2]); - } - } - else - { - setIsVisible(true); - } - - return; - } - }, - eventUrlPrefix: 'catalog/' - }; - - AddLinkEventTracker(linkTracker); - - return () => RemoveLinkEventTracker(linkTracker); - }, [ setIsVisible, openPageByOfferId, openPageByName ]); - - return ( - <> - { isVisible && - - setIsVisible(false) } /> - - { rootNode && (rootNode.children.length > 0) && rootNode.children.map((child, index) => - { - if(!child.isVisible) return null; - - return ( - - { - if(searchResult) setSearchResult(null); - - activateNode(child); - } } > -
- { GetConfigurationValue('catalog.tab.icons') && } - { child.localization } -
-
- ); - }) } -
- - - { !navigationHidden && - - { activeNodes && (activeNodes.length > 0) && - } - } - - { GetCatalogLayout(currentPage, () => setNavigationHidden(true)) } - - - -
} - - - - ); + return ; }; diff --git a/src/components/catalog/views/admin/CatalogAdminOfferEditView.tsx b/src/components/catalog/views/admin/CatalogAdminOfferEditView.tsx new file mode 100644 index 0000000..f6da55a --- /dev/null +++ b/src/components/catalog/views/admin/CatalogAdminOfferEditView.tsx @@ -0,0 +1,225 @@ +import { FC, useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { FaSave, FaSpinner, FaTimes, FaTrash } from 'react-icons/fa'; +import { LocalizeText } from '../../../../api'; +import { useCatalog } from '../../../../hooks'; +import { IOfferEditData, useCatalogAdmin } from '../../CatalogAdminContext'; + +export const CatalogAdminOfferEditView: FC<{}> = () => +{ + const { currentPage = null } = useCatalog(); + const catalogAdmin = useCatalogAdmin(); + const editingOffer = catalogAdmin?.editingOffer ?? null; + const setEditingOffer = catalogAdmin?.setEditingOffer; + const saveOffer = catalogAdmin?.saveOffer; + const deleteOffer = catalogAdmin?.deleteOffer; + const createOffer = catalogAdmin?.createOffer; + const loading = catalogAdmin?.loading ?? false; + + const [ itemIds, setItemIds ] = useState('0'); + const [ catalogName, setCatalogName ] = useState(''); + const [ costCredits, setCostCredits ] = useState(0); + const [ costPoints, setCostPoints ] = useState(0); + const [ pointsType, setPointsType ] = useState(0); + const [ amount, setAmount ] = useState(1); + const [ clubOnly, setClubOnly ] = useState('0'); + const [ extradata, setExtradata ] = useState(''); + const [ haveOffer, setHaveOffer ] = useState('1'); + const [ offerId, setOfferIdGroup ] = useState(-1); + const [ limitedStack, setLimitedStack ] = useState(0); + const [ orderNumber, setOrderNumber ] = useState(0); + const [ isNew, setIsNew ] = useState(false); + + useEffect(() => + { + if(!editingOffer) return; + + if(editingOffer.offerId === -1) + { + setIsNew(true); + setItemIds('0'); + setCatalogName(''); + setCostCredits(0); + setCostPoints(0); + setPointsType(0); + setAmount(1); + setClubOnly('0'); + setExtradata(''); + setHaveOffer('1'); + setOfferIdGroup(-1); + setLimitedStack(0); + setOrderNumber(0); + } + else + { + setIsNew(false); + setItemIds(String(editingOffer.product?.productClassId || 0)); + setCatalogName(editingOffer.localizationName || ''); + setCostCredits(editingOffer.priceInCredits); + setCostPoints(editingOffer.priceInActivityPoints); + setPointsType(editingOffer.activityPointType); + setAmount(editingOffer.product?.productCount || 1); + setClubOnly(editingOffer.clubLevel > 0 ? '1' : '0'); + setExtradata(editingOffer.product?.extraParam || ''); + setHaveOffer('1'); + setOfferIdGroup(editingOffer.offerId || -1); + setLimitedStack(0); + setOrderNumber(0); + } + }, [ editingOffer ]); + + if(!editingOffer) return null; + + const handleSave = async () => + { + if(!saveOffer || !createOffer) return; + + const data: IOfferEditData = { + offerId: isNew ? undefined : editingOffer.offerId, + pageId: currentPage?.pageId || 0, + itemIds, + catalogName, + costCredits, + costPoints, + pointsType, + amount, + clubOnly, + extradata, + haveOffer, + offerId_group: offerId, + limitedStack, + orderNumber + }; + + const success = isNew ? await createOffer(data) : await saveOffer(data); + + if(success && setEditingOffer) setEditingOffer(null); + }; + + const handleDelete = () => + { + if(isNew || !deleteOffer || !confirm(LocalizeText('catalog.admin.delete.offer.confirm'))) return; + + deleteOffer(editingOffer.offerId); + if(setEditingOffer) setEditingOffer(null); + }; + + const inputClass = 'text-[11px] border-2 border-card-grid-item-border rounded px-2 py-1 bg-white focus:outline-none focus:border-primary transition-colors'; + + return createPortal( +
setEditingOffer(null) }> +
+ +
e.stopPropagation() }> + { /* Header */ } +
+ + { isNew ? LocalizeText('catalog.admin.offer.new') : `${ LocalizeText('catalog.admin.offer.edit') } #${ editingOffer.offerId }` } + +
setEditingOffer(null) }> + +
+
+ +
+ { /* Current name */ } + { !isNew && +
+ { editingOffer.localizationName } +
} + + { /* Catalog Name */ } +
+ + setCatalogName(e.target.value) } /> +
+ + { /* Generale */ } +
+
{ LocalizeText('catalog.admin.offer.general') }
+
+
+ + setItemIds(e.target.value) } /> +
+
+ + setAmount(parseInt(e.target.value) || 1) } /> +
+
+ + setOrderNumber(parseInt(e.target.value) || 0) } /> +
+
+
+ + { /* Prezzi */ } +
+
{ LocalizeText('catalog.admin.offer.prices') }
+
+
+ + setCostCredits(parseInt(e.target.value) || 0) } /> +
+
+ + setCostPoints(parseInt(e.target.value) || 0) } /> +
+
+ + +
+
+
+ + { /* Opzioni */ } +
+
{ LocalizeText('catalog.admin.offer.options') }
+
+
+ + +
+
+ + setLimitedStack(parseInt(e.target.value) || 0) } /> +
+
+ + setOfferIdGroup(parseInt(e.target.value) || -1) } /> +
+
+
+ + setExtradata(e.target.value) } /> +
+
+ setHaveOffer(e.target.checked ? '1' : '0') } /> + +
+
+ + { /* Actions */ } +
+ { !isNew + ? + :
} + +
+
+
+
, + document.body + ); +}; diff --git a/src/components/catalog/views/admin/CatalogAdminPageEditView.tsx b/src/components/catalog/views/admin/CatalogAdminPageEditView.tsx new file mode 100644 index 0000000..6fd4a07 --- /dev/null +++ b/src/components/catalog/views/admin/CatalogAdminPageEditView.tsx @@ -0,0 +1,153 @@ +import { FC, useEffect, useState } from 'react'; +import { FaSave, FaSpinner, FaTimes, FaTrash } from 'react-icons/fa'; +import { LocalizeText } from '../../../../api'; +import { useCatalog } from '../../../../hooks'; +import { IPageEditData, useCatalogAdmin } from '../../CatalogAdminContext'; + +const LAYOUT_OPTIONS = [ + 'default_3x3', 'frontpage4', 'pets', 'pets2', 'pets3', + 'spaces_new', 'soundmachine', 'trophies', 'roomads', + 'guild_frontpage', 'guild_forum', 'guild_custom_furni', + 'vip_buy', 'marketplace', 'marketplace_own_items', + 'recycler', 'recycler_info', 'recycler_prizes', + 'info_loyalty', 'badge_display', 'bots', 'single_bundle', + 'color_grouping', 'recent_purchases', 'custom_prefix' +]; + +export const CatalogAdminPageEditView: FC<{}> = () => +{ + const { currentPage = null, activeNodes = [], rootNode = null } = useCatalog(); + const catalogAdmin = useCatalogAdmin(); + const editingPageData = catalogAdmin?.editingPageData ?? false; + const editingRootPage = catalogAdmin?.editingRootPage ?? false; + const editingPageNode = catalogAdmin?.editingPageNode ?? null; + const loading = catalogAdmin?.loading ?? false; + + const [ caption, setCaption ] = useState(''); + const [ pageLayout, setPageLayout ] = useState('default_3x3'); + const [ minRank, setMinRank ] = useState(1); + const [ visible, setVisible ] = useState('1'); + const [ enabled, setEnabled ] = useState('1'); + const [ orderNum, setOrderNum ] = useState(0); + + // Resolve what we're editing: + // 1. editingPageNode (explicit node from sidebar click) + // 2. editingRootPage (root button) + // 3. current active page (from "Modifica Pagina" in layout) + const targetNode = editingPageNode + ? editingPageNode + : editingRootPage + ? rootNode + : (activeNodes.length > 0 ? activeNodes[activeNodes.length - 1] : null); + + const targetPageId = targetNode?.pageId ?? currentPage?.pageId; + const isRoot = editingRootPage; + + const closeForm = () => + { + catalogAdmin?.setEditingPageData(false); + catalogAdmin?.setEditingRootPage(false); + catalogAdmin?.setEditingPageNode(null); + }; + + useEffect(() => + { + if(!editingPageData || !targetNode) return; + + setCaption(targetNode.localization || ''); + setPageLayout(currentPage?.layoutCode || 'default_3x3'); + setVisible(targetNode.isVisible ? '1' : '0'); + setEnabled('1'); + setMinRank(1); + setOrderNum(0); + }, [ editingPageData, targetNode, currentPage ]); + + if(!editingPageData || !targetNode) return null; + + const inputClass = 'text-[11px] border-2 border-card-grid-item-border rounded px-2 py-1 bg-white focus:outline-none focus:border-primary transition-colors'; + + const handleSave = async () => + { + if(!catalogAdmin?.savePage) return; + + const parentNode = targetNode.parent; + + const data: IPageEditData = { + pageId: targetPageId, + caption, + pageLayout, + minRank, + visible, + enabled, + orderNum, + parentId: parentNode ? parentNode.pageId : -1, + }; + + const success = await catalogAdmin.savePage(data); + + if(success) closeForm(); + }; + + const handleDelete = async () => + { + if(!catalogAdmin?.deletePage || isRoot) return; + if(!confirm(LocalizeText('catalog.admin.delete.page.confirm', [ 'name' ], [ targetNode.localization ]))) return; + + const success = await catalogAdmin.deletePage(targetPageId); + + if(success) closeForm(); + }; + + return ( +
+
+ + { isRoot ? LocalizeText('catalog.admin.edit.root') : `${ LocalizeText('catalog.admin.edit') } ${ targetNode.localization } (#${ targetPageId })` } + + +
+ +
+
+ + setCaption(e.target.value) } /> +
+
+ + setMinRank(parseInt(e.target.value) || 1) } /> +
+
+ + +
+
+ + setOrderNum(parseInt(e.target.value) || 0) } /> +
+
+ + +
+
+ +
+ { !isRoot + ? + :
} + +
+
+ ); +}; diff --git a/src/components/catalog/views/catalog-rail/CatalogRailItemView.tsx b/src/components/catalog/views/catalog-rail/CatalogRailItemView.tsx new file mode 100644 index 0000000..3b1cd21 --- /dev/null +++ b/src/components/catalog/views/catalog-rail/CatalogRailItemView.tsx @@ -0,0 +1,30 @@ +import { FC } from 'react'; +import { ICatalogNode } from '../../../../api'; +import { CatalogIconView } from '../catalog-icon/CatalogIconView'; + +interface CatalogRailItemViewProps +{ + node: ICatalogNode; + isActive: boolean; + onClick: () => void; +} + +export const CatalogRailItemView: FC = props => +{ + const { node, isActive, onClick } = props; + + return ( +
+
+ +
+ + { node.localization } + +
+ ); +}; diff --git a/src/components/catalog/views/favorites/CatalogFavoritesView.tsx b/src/components/catalog/views/favorites/CatalogFavoritesView.tsx new file mode 100644 index 0000000..676f771 --- /dev/null +++ b/src/components/catalog/views/favorites/CatalogFavoritesView.tsx @@ -0,0 +1,154 @@ +import { FC, useMemo } from 'react'; +import { FaHeart, FaStar, FaTimes } from 'react-icons/fa'; +import { ICatalogNode, LocalizeText } from '../../../../api'; +import { useCatalog, useCatalogFavorites } from '../../../../hooks'; +import { CatalogIconView } from '../catalog-icon/CatalogIconView'; + +interface CatalogFavoritesViewProps +{ + onClose: () => void; +} + +export const CatalogFavoritesView: FC = props => +{ + const { onClose } = props; + const { favoriteOffers, favoritePageIds, toggleFavoritePage, toggleFavoriteOffer } = useCatalogFavorites(); + const { offersToNodes, activateNode, openPageByOfferId, rootNode } = useCatalog(); + + const favoritePages = useMemo(() => + { + if(!rootNode || favoritePageIds.length === 0) return []; + + const pages: Array<{ pageId: number; name: string; iconId: number; node: ICatalogNode }> = []; + + const findNode = (node: ICatalogNode) => + { + if(favoritePageIds.includes(node.pageId)) + { + pages.push({ pageId: node.pageId, name: node.localization, iconId: node.iconId, node }); + } + + if(node.children) + { + for(const child of node.children) findNode(child); + } + }; + + findNode(rootNode); + + return pages; + }, [ favoritePageIds, rootNode ]); + + // Enrich offers with node data if available + const enrichedOffers = useMemo(() => + { + return favoriteOffers.map(fav => + { + let nodeName: string | null = null; + let nodeIconId: number | null = null; + + if(offersToNodes) + { + const nodes = offersToNodes.get(fav.offerId); + + if(nodes && nodes.length > 0) + { + nodeName = nodes[0].localization; + nodeIconId = nodes[0].iconId; + } + } + + return { + ...fav, + displayName: fav.name || nodeName || `Offer #${ fav.offerId }`, + nodeIconId + }; + }); + }, [ favoriteOffers, offersToNodes ]); + + return ( +
+ { /* Header */ } +
+
+ + { LocalizeText('catalog.favorites') } + ({ enrichedOffers.length + favoritePages.length }) +
+ +
+ +
+ { /* Favorite Pages */ } + { favoritePages.length > 0 && +
+
+ + { LocalizeText('catalog.favorites.pages') } +
+
+ { favoritePages.map(page => ( +
{ activateNode(page.node); onClose(); } } + > + + { page.name } + { e.stopPropagation(); toggleFavoritePage(page.pageId); } } + /> +
+ )) } +
+
} + + { /* Favorite Offers */ } + { enrichedOffers.length > 0 && +
+
+ + { LocalizeText('catalog.favorites.furni') } +
+
+ { enrichedOffers.map(fav => ( +
{ openPageByOfferId(fav.offerId); onClose(); } } + > + { /* Furni icon */ } +
+ { fav.iconUrl + ? + : fav.nodeIconId !== null + ? + : + } +
+ { fav.displayName } + { e.stopPropagation(); toggleFavoriteOffer(fav.offerId); } } + /> +
+ )) } +
+
} + + { /* Empty state */ } + { favoritePages.length === 0 && enrichedOffers.length === 0 && +
+
+ +

{ LocalizeText('catalog.favorites.empty') }

+

{ LocalizeText('catalog.favorites.empty.hint') }

+
+
} +
+
+ ); +}; diff --git a/src/components/catalog/views/navigation/CatalogBreadcrumbView.tsx b/src/components/catalog/views/navigation/CatalogBreadcrumbView.tsx new file mode 100644 index 0000000..60ed73a --- /dev/null +++ b/src/components/catalog/views/navigation/CatalogBreadcrumbView.tsx @@ -0,0 +1,39 @@ +import { FC } from 'react'; +import { FaChevronRight, FaHome } from 'react-icons/fa'; +import { LocalizeText } from '../../../../api'; +import { useCatalog } from '../../../../hooks'; + +export const CatalogBreadcrumbView: FC<{}> = () => +{ + const { activeNodes = [], activateNode } = useCatalog(); + + if(!activeNodes || activeNodes.length === 0) + { + return ( +
+ + { LocalizeText('catalog.title') } +
+ ); + } + + return ( +
+ activateNode(activeNodes[0]) } + /> + { activeNodes.map((node, i) => ( + + + activateNode(node) : undefined } + > + { node.localization } + + + )) } +
+ ); +}; diff --git a/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx b/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx index 48ea04e..d31d801 100644 --- a/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx +++ b/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx @@ -1,8 +1,8 @@ -import { FC } from 'react'; -import { FaCaretDown, FaCaretUp } from 'react-icons/fa'; -import { ICatalogNode } from '../../../../api'; -import { LayoutGridItem, Text } from '../../../../common'; -import { useCatalog } from '../../../../hooks'; +import { FC, useCallback, useRef, useState } from 'react'; +import { FaArrowsAlt, FaCaretDown, FaCaretUp, FaPlus, FaStar, FaTrash } from 'react-icons/fa'; +import { ICatalogNode, LocalizeText } from '../../../../api'; +import { useCatalog, useCatalogFavorites } from '../../../../hooks'; +import { useCatalogAdmin } from '../../CatalogAdminContext'; import { CatalogIconView } from '../catalog-icon/CatalogIconView'; import { CatalogNavigationSetView } from './CatalogNavigationSetView'; @@ -16,18 +16,122 @@ export const CatalogNavigationItemView: FC = pro { const { node = null, child = false } = props; const { activateNode = null } = useCatalog(); + const catalogAdmin = useCatalogAdmin(); + const adminMode = catalogAdmin?.adminMode ?? false; + const { isFavoritePage, toggleFavoritePage } = useCatalogFavorites(); + const isFav = node ? isFavoritePage(node.pageId) : false; + const [ isDragOver, setIsDragOver ] = useState(false); + const dragRef = useRef(null); + + const handleDragStart = useCallback((e: React.DragEvent) => + { + if(!adminMode) return; + + e.dataTransfer.setData('text/plain', JSON.stringify({ pageId: node.pageId, parentId: node.parent?.pageId ?? -1 })); + e.dataTransfer.effectAllowed = 'move'; + }, [ adminMode, node ]); + + const handleDragOver = useCallback((e: React.DragEvent) => + { + if(!adminMode) return; + + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setIsDragOver(true); + }, [ adminMode ]); + + const handleDragLeave = useCallback(() => + { + setIsDragOver(false); + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => + { + if(!adminMode) return; + + e.preventDefault(); + setIsDragOver(false); + + try + { + const data = JSON.parse(e.dataTransfer.getData('text/plain')); + + if(data.pageId && data.pageId !== node.pageId) + { + // Drop onto a branch = reparent under this node + // Drop onto a leaf = reorder as sibling + const targetParentId = node.isBranch ? node.pageId : (node.parent?.pageId ?? -1); + const targetIndex = node.isBranch ? 0 : (node.parent?.children?.indexOf(node) ?? 0); + + catalogAdmin?.reorderPage(data.pageId, targetParentId, targetIndex); + } + } + catch(err) + { + // Invalid drag data + } + }, [ adminMode, node, catalogAdmin ]); return ( -
- activateNode(node) }> - - { node.localization } +
+
activateNode(node) } + onDragLeave={ adminMode ? handleDragLeave : undefined } + onDragOver={ adminMode ? handleDragOver : undefined } + onDragStart={ adminMode ? handleDragStart : undefined } + onDrop={ adminMode ? handleDrop : undefined } + > + { adminMode && + } +
+ +
+ { node.localization } + { adminMode && +
+ + { + e.stopPropagation(); + catalogAdmin.createPage({ + caption: 'New Page', + pageLayout: 'default_3x3', + minRank: 1, + visible: '1', + enabled: '1', + orderNum: 0, + parentId: node.pageId, + }); + } } + /> + + { + e.stopPropagation(); + if(confirm(LocalizeText('catalog.admin.delete.page.confirm', [ 'name' ], [ node.localization ]))) + { + catalogAdmin.deletePage(node.pageId); + } + } } + /> +
} + { !adminMode && node.pageId > 0 && + { e.stopPropagation(); toggleFavoritePage(node.pageId); } } + /> } { node.isBranch && - <> - { node.isOpen && } - { !node.isOpen && } - } - + + { node.isOpen ? : } + } +
{ node.isOpen && node.isBranch && }
diff --git a/src/components/catalog/views/navigation/CatalogNavigationView.tsx b/src/components/catalog/views/navigation/CatalogNavigationView.tsx index 10e2a2f..777c5fd 100644 --- a/src/components/catalog/views/navigation/CatalogNavigationView.tsx +++ b/src/components/catalog/views/navigation/CatalogNavigationView.tsx @@ -1,8 +1,6 @@ import { FC } from 'react'; import { ICatalogNode } from '../../../../api'; -import { AutoGrid, Column } from '../../../../common'; import { useCatalog } from '../../../../hooks'; -import { CatalogSearchView } from '../page/common/CatalogSearchView'; import { CatalogNavigationItemView } from './CatalogNavigationItemView'; import { CatalogNavigationSetView } from './CatalogNavigationSetView'; @@ -17,18 +15,13 @@ export const CatalogNavigationView: FC = props => const { searchResult = null } = useCatalog(); return ( - <> - - - - { searchResult && (searchResult.filteredNodes.length > 0) && searchResult.filteredNodes.map((n, index) => - { - return ; - }) } - { !searchResult && - } - - - +
+ { searchResult && (searchResult.filteredNodes.length > 0) && searchResult.filteredNodes.map((n, index) => + { + return ; + }) } + { !searchResult && + } +
); }; diff --git a/src/components/catalog/views/page/common/CatalogGridOfferView.tsx b/src/components/catalog/views/page/common/CatalogGridOfferView.tsx index f99bbad..a3fcedd 100644 --- a/src/components/catalog/views/page/common/CatalogGridOfferView.tsx +++ b/src/components/catalog/views/page/common/CatalogGridOfferView.tsx @@ -1,8 +1,9 @@ import { MouseEventType } from '@nitrots/nitro-renderer'; import { FC, MouseEvent, useMemo, useState } from 'react'; +import { FaHeart } from 'react-icons/fa'; import { IPurchasableOffer, Offer, ProductTypeEnum } from '../../../../../api'; import { LayoutAvatarImageView, LayoutGridItem, LayoutGridItemProps } from '../../../../../common'; -import { useCatalog, useInventoryFurni } from '../../../../../hooks'; +import { useCatalog, useCatalogFavorites, useInventoryFurni } from '../../../../../hooks'; interface CatalogGridOfferViewProps extends LayoutGridItemProps { @@ -16,6 +17,8 @@ export const CatalogGridOfferView: FC = props => const [ isMouseDown, setMouseDown ] = useState(false); const { requestOfferToMover = null } = useCatalog(); const { isVisible = false } = useInventoryFurni(); + const { isFavoriteOffer, toggleFavoriteOffer } = useCatalogFavorites(); + const isFav = isFavoriteOffer(offer.offerId); const iconUrl = useMemo(() => { @@ -51,9 +54,28 @@ export const CatalogGridOfferView: FC = props => if(!product) return null; return ( - + { (offer.product.productType === ProductTypeEnum.ROBOT) && } +
{ e.stopPropagation(); e.preventDefault(); toggleFavoriteOffer(offer.offerId, offer.localizationName, iconUrl); } } + onMouseDown={ e => e.stopPropagation() } + > + +
); }; diff --git a/src/components/catalog/views/page/common/CatalogSearchView.tsx b/src/components/catalog/views/page/common/CatalogSearchView.tsx index dc3f34b..7cc30c7 100644 --- a/src/components/catalog/views/page/common/CatalogSearchView.tsx +++ b/src/components/catalog/views/page/common/CatalogSearchView.tsx @@ -2,11 +2,9 @@ import { GetSessionDataManager, IFurnitureData } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; import { FaSearch, FaTimes } from 'react-icons/fa'; import { CatalogPage, CatalogType, FilterCatalogNode, FurnitureOffer, GetOfferNodes, ICatalogNode, ICatalogPage, IPurchasableOffer, LocalizeText, PageLocalization, SearchResult } from '../../../../../api'; -import { Button, Flex } from '../../../../../common'; import { useCatalog } from '../../../../../hooks'; -import { NitroInput } from '../../../../../layout'; -export const CatalogSearchView: FC<{}> = props => +export const CatalogSearchView: FC<{}> = () => { const [ searchValue, setSearchValue ] = useState(''); const { currentType = null, rootNode = null, offersToNodes = null, searchResult = null, setSearchResult = null, setCurrentPage = null } = useCatalog(); @@ -78,29 +76,22 @@ export const CatalogSearchView: FC<{}> = props => }, [ offersToNodes, currentType, rootNode, searchValue, setCurrentPage, setSearchResult ]); return ( -
- - - - - - - - setSearchValue(event.target.value) } /> - - - - { (!searchValue || !searchValue.length) && - } - { searchValue && !!searchValue.length && - } +
+ + setSearchValue(e.target.value) } + /> + { searchValue && searchValue.length > 0 && + }
); }; diff --git a/src/components/catalog/views/page/layout/CatalogLayoutCustomPrefixView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutCustomPrefixView.tsx index 0b7f904..65f9a21 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutCustomPrefixView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutCustomPrefixView.tsx @@ -1,7 +1,6 @@ import { PurchasePrefixComposer } from '@nitrots/nitro-renderer'; -import { createPortal } from 'react-dom'; import { FC, useEffect, useMemo, useRef, useState } from 'react'; -import { SendMessageComposer, PRESET_PREFIX_EFFECTS, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../../api'; +import { LocalizeText, SendMessageComposer, PRESET_PREFIX_EFFECTS, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../../api'; import { CatalogLayoutProps } from './CatalogLayout.types'; import data from '@emoji-mart/data'; import Picker from '@emoji-mart/react'; @@ -32,32 +31,6 @@ export const CatalogLayoutCustomPrefixView: FC = props => const [ showIconPicker, setShowIconPicker ] = useState(false); const [ selectedEffect, setSelectedEffect ] = useState(''); const [ purchased, setPurchased ] = useState(false); - const pickerContainerRef = useRef(null); - - // Inject style into emoji-mart Shadow DOM to remove backdrop-filter blur - useEffect(() => - { - if(!showIconPicker) return; - - const timer = setTimeout(() => - { - const container = pickerContainerRef.current; - if(!container) return; - - const emPicker = container.querySelector('em-emoji-picker'); - if(!emPicker?.shadowRoot) return; - - const existing = emPicker.shadowRoot.querySelector('#no-blur-fix'); - if(existing) return; - - const style = document.createElement('style'); - style.id = 'no-blur-fix'; - style.textContent = `.sticky { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; background-color: rgb(var(--em-rgb-background)) !important; } .menu { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; background-color: rgb(var(--em-rgb-background)) !important; }`; - emPicker.shadowRoot.appendChild(style); - }, 50); - - return () => clearTimeout(timer); - }, [ showIconPicker ]); const colorString = useMemo(() => { @@ -104,7 +77,7 @@ export const CatalogLayoutCustomPrefixView: FC = props => setLetterColors(prev => ({ ...prev, [selectedLetterIndex]: color })); setCustomColorInput(color); - // Auto-advance to next letter + // Auto-avanza alla lettera successiva if(selectedLetterIndex < prefixText.length - 1) { const nextIdx = selectedLetterIndex + 1; @@ -194,12 +167,12 @@ export const CatalogLayoutCustomPrefixView: FC = props => { /* Text + Icon Row */ }
- +
= props =>
- +
@@ -241,14 +214,14 @@ export const CatalogLayoutCustomPrefixView: FC = props =>
- { /* Emoji Picker (emoji-mart) - portaled to body, no backdrop */ } - { showIconPicker && createPortal( + { /* Emoji Picker (emoji-mart) - fixed overlay */ } + { showIconPicker && ( <> -
setShowIconPicker(false) } /> -
+
setShowIconPicker(false) } /> +
{ setSelectedIcon(emoji.native); setShowIconPicker(false); } } theme="dark" previewPosition="none" @@ -261,13 +234,12 @@ export const CatalogLayoutCustomPrefixView: FC = props => set="native" />
- , - document.body + ) } { /* Effect Selector */ }
- +
{ PRESET_PREFIX_EFFECTS.map(fx => (
@@ -316,7 +288,7 @@ export const CatalogLayoutCustomPrefixView: FC = props =>
- Select a letter, then choose a color. Auto-advances. + { LocalizeText('catalog.prefix.color.hint') }
= props =>
{ colorMode === 'perLetter' && selectedLetterIndex !== null && - Selected letter: "{ prefixText[selectedLetterIndex] || '' }" + { LocalizeText('catalog.prefix.color.selected') } "{ prefixText[selectedLetterIndex] || '' }" } -
+
{ PRESET_COLORS.map((color, idx) => { const isActive = currentActiveColor === color; return (
handleColorSelect(color) } /> @@ -443,8 +410,8 @@ export const CatalogLayoutCustomPrefixView: FC = props =>
- Price: - 5 Credits + { LocalizeText('catalog.prefix.price') } + { LocalizeText('catalog.prefix.price.amount') }
diff --git a/src/components/catalog/views/page/layout/CatalogLayoutDefaultView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutDefaultView.tsx index 50b2955..107b820 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutDefaultView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutDefaultView.tsx @@ -1,7 +1,9 @@ import { FC } from 'react'; -import { GetConfigurationValue, ProductTypeEnum } from '../../../../../api'; -import { Column, Flex, Grid, LayoutImage, Text } from '../../../../../common'; +import { FaEdit, FaPlus } from 'react-icons/fa'; +import { GetConfigurationValue, LocalizeText, ProductTypeEnum } from '../../../../../api'; +import { Text } from '../../../../../common'; import { useCatalog } from '../../../../../hooks'; +import { useCatalogAdmin } from '../../../CatalogAdminContext'; import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView'; import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView'; import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView'; @@ -16,46 +18,87 @@ export const CatalogLayoutDefaultView: FC = props => { const { page = null } = props; const { currentOffer = null, currentPage = null } = useCatalog(); + const catalogAdmin = useCatalogAdmin(); + const adminMode = catalogAdmin?.adminMode ?? false; return ( - <> - - - { GetConfigurationValue('catalog.headers') && - } - - - - { !currentOffer && - <> - { !!page.localization.getImage(1) && - } - - } - { currentOffer && - <> - - { (currentOffer.product.productType !== ProductTypeEnum.BADGE) && - <> - - - } - { (currentOffer.product.productType === ProductTypeEnum.BADGE) && } - - - - { currentOffer.localizationName } -
-
- -
- -
- -
- } -
-
- +
+ { /* Admin: quick actions */ } + { adminMode && !catalogAdmin.editingPageData && +
+ + +
} + + { /* Product detail card */ } + { currentOffer && +
+ { /* Preview area */ } +
+ { (currentOffer.product.productType !== ProductTypeEnum.BADGE) && + <> + + + } + { (currentOffer.product.productType === ProductTypeEnum.BADGE) && + } +
+ { /* Product info + purchase */ } +
+ { /* Title row */ } +
+
+ { currentOffer.localizationName } + { adminMode && + catalogAdmin.setEditingOffer(currentOffer) } + /> } +
+ { adminMode && +
+ ID: { currentOffer.product.productClassId } + Offer: { currentOffer.offerId } + { currentOffer.product.productType.toUpperCase() } +
} + +
+ { /* Price */ } + + { /* Spinner */ } + + { /* Actions */ } +
+ +
+
+
} + + { /* Welcome/description card */ } + { !currentOffer && +
+ { !!page.localization.getImage(1) && + } + +
} + + { /* Item grid */ } +
+ { GetConfigurationValue('catalog.headers') && + } + +
+
); }; diff --git a/src/components/catalog/views/page/layout/CatalogLayoutPets3View.tsx b/src/components/catalog/views/page/layout/CatalogLayoutPets3View.tsx index 8c2e085..caba81a 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutPets3View.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutPets3View.tsx @@ -1,5 +1,5 @@ import { FC } from 'react'; -import { Column } from '../../../../../common'; +import { FaPaw } from 'react-icons/fa'; import { CatalogLayoutProps } from './CatalogLayout.types'; export const CatalogLayoutPets3View: FC = props => @@ -9,17 +9,28 @@ export const CatalogLayoutPets3View: FC = props => const imageUrl = page.localization.getImage(1); return ( - -
- { imageUrl && } -
+
+ { /* Header card */ } +
+ { imageUrl && } +
+
+ + +
+
- -
- -
-
+ + { /* Content */ } +
+
- + + { /* Footer */ } + { !!page.localization.getText(3) && +
+ +
} +
); }; diff --git a/src/components/catalog/views/page/layout/CatalogLayoutTrophiesView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutTrophiesView.tsx index bcea3d6..d192b81 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutTrophiesView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutTrophiesView.tsx @@ -1,6 +1,10 @@ import { FC, useEffect, useState } from 'react'; -import { Column, Grid, Text } from '../../../../../common'; +import { FaEdit, FaPen, FaPlus, FaTrophy } from 'react-icons/fa'; +import { LocalizeText, ProductTypeEnum } from '../../../../../api'; +import { Text } from '../../../../../common'; import { useCatalog } from '../../../../../hooks'; +import { useCatalogAdmin } from '../../../CatalogAdminContext'; +import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView'; import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView'; import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView'; import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget'; @@ -12,6 +16,8 @@ export const CatalogLayoutTrophiesView: FC = props => const { page = null } = props; const [ trophyText, setTrophyText ] = useState(''); const { currentOffer = null, setPurchaseOptions = null } = useCatalog(); + const catalogAdmin = useCatalogAdmin(); + const adminMode = catalogAdmin?.adminMode ?? false; useEffect(() => { @@ -27,30 +33,104 @@ export const CatalogLayoutTrophiesView: FC = props => }); }, [ currentOffer, trophyText, setPurchaseOptions ]); + const canPurchase = currentOffer && trophyText.trim().length > 0; + return ( - - - -