Files
Nitro-V3/src/components/user-settings/UserSettingsView.tsx
T
medievalshell 8097344561 feat(theme): runtime custom theme ecosystem (graphics-only)
Runtime-loaded visual re-skin system (no client rebuild, real themes never
hit git). A theme = a folder on the server (theme.base.url) with a manifest +
CSS "pieces"; each piece is toggled from Settings > Themes (checkboxes). A
broken/404 piece auto-falls back to the default (per piece). Hotel-wide default
via ui-config theme.default (+ theme.default.pieces), per-user override in
localStorage (same pattern as the catalog style toggle).

- api/theme/ThemeManager: fetch index/manifest + inject/remove <link> + fallback
- hooks/theme/useThemes: state + persist + default-from-config + live apply
- components/theme/ThemeApplier: applies on boot (mounted in MainView)
- UserSettings: General/Themes tabs with theme selector + per-piece checkboxes
- custom-themes/: reference template (demo theme "Neon Viola" + README)
- .gitignore: public/custom-themes/ (real themes are never committed)
2026-05-31 14:39:59 +02:00

250 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { AddLinkEventTracker, CreateLinkEvent, ILinkEventTracker, NitroSettingsEvent, RemoveLinkEventTracker, UserSettingsCameraFollowComposer, UserSettingsEvent, UserSettingsOldChatComposer, UserSettingsRoomInvitesComposer, UserSettingsSoundComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FaUserCog, FaVolumeDown, FaVolumeMute, FaVolumeUp } from 'react-icons/fa';
import { DispatchMainEvent, DispatchUiEvent, LocalizeText, SendMessageComposer } from '../../api';
import { NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
import { useCatalogClassicStyle, useCatalogPlaceMultipleItems, useCatalogSkipPurchaseConfirmation, useChatWindow, useMessageEvent, useThemes } from '../../hooks';
import { classNames } from '../../layout';
export const UserSettingsView: FC<{}> = props =>
{
const [ isVisible, setIsVisible ] = useState(false);
const [ activeTab, setActiveTab ] = useState<'general' | 'themes'>('general');
const [ userSettings, setUserSettings ] = useState<NitroSettingsEvent>(null);
const { themes, activeThemeId, manifest, activeEnabled, selectTheme, togglePiece } = useThemes();
const [ catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects ] = useCatalogPlaceMultipleItems();
const [ catalogSkipPurchaseConfirmation, setCatalogSkipPurchaseConfirmation ] = useCatalogSkipPurchaseConfirmation();
const [ chatWindowEnabled, setChatWindowEnabled ] = useChatWindow();
const [ catalogClassicStyle, setCatalogClassicStyle ] = useCatalogClassicStyle();
const processAction = (type: string, value?: boolean | number | string) =>
{
let doUpdate = true;
const clone = userSettings.clone();
switch(type)
{
case 'close_view':
setIsVisible(false);
doUpdate = false;
return;
case 'oldchat':
clone.oldChat = value as boolean;
SendMessageComposer(new UserSettingsOldChatComposer(clone.oldChat));
break;
case 'room_invites':
clone.roomInvites = value as boolean;
SendMessageComposer(new UserSettingsRoomInvitesComposer(clone.roomInvites));
break;
case 'camera_follow':
clone.cameraFollow = value as boolean;
SendMessageComposer(new UserSettingsCameraFollowComposer(clone.cameraFollow));
break;
case 'system_volume':
clone.volumeSystem = value as number;
clone.volumeSystem = Math.max(0, clone.volumeSystem);
clone.volumeSystem = Math.min(100, clone.volumeSystem);
break;
case 'furni_volume':
clone.volumeFurni = value as number;
clone.volumeFurni = Math.max(0, clone.volumeFurni);
clone.volumeFurni = Math.min(100, clone.volumeFurni);
break;
case 'trax_volume':
clone.volumeTrax = value as number;
clone.volumeTrax = Math.max(0, clone.volumeTrax);
clone.volumeTrax = Math.min(100, clone.volumeTrax);
break;
}
if(doUpdate) setUserSettings(clone);
DispatchMainEvent(clone);
};
const saveRangeSlider = (type: string) =>
{
switch(type)
{
case 'volume':
SendMessageComposer(new UserSettingsSoundComposer(Math.round(userSettings.volumeSystem), Math.round(userSettings.volumeFurni), Math.round(userSettings.volumeTrax)));
break;
}
};
useMessageEvent<UserSettingsEvent>(UserSettingsEvent, event =>
{
const parser = event.getParser();
const settingsEvent = new NitroSettingsEvent();
settingsEvent.volumeSystem = parser.volumeSystem;
settingsEvent.volumeFurni = parser.volumeFurni;
settingsEvent.volumeTrax = parser.volumeTrax;
settingsEvent.oldChat = parser.oldChat;
settingsEvent.roomInvites = parser.roomInvites;
settingsEvent.cameraFollow = parser.cameraFollow;
settingsEvent.flags = parser.flags;
settingsEvent.chatType = parser.chatType;
setUserSettings(settingsEvent);
DispatchMainEvent(settingsEvent);
});
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;
}
},
eventUrlPrefix: 'user-settings/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
useEffect(() =>
{
if(!userSettings) return;
DispatchUiEvent(userSettings);
}, [ userSettings ]);
if(!isVisible || !userSettings) return null;
return (
<NitroCardView className="user-settings-window" theme="primary-slim" uniqueKey="user-settings">
<NitroCardHeaderView headerText={ LocalizeText('widget.memenu.settings.title') } onCloseClick={ event => processAction('close_view') } />
<NitroCardContentView className="text-black">
<div className="flex items-center gap-1 mb-2 border-b border-black/10 pb-1">
<button type="button" onClick={ () => setActiveTab('general') } className={ classNames('px-3 py-1 rounded text-xs font-bold cursor-pointer transition-colors', activeTab === 'general' ? 'bg-[#1e7295] text-white' : 'bg-black/5 hover:bg-black/10') }>Generale</button>
<button type="button" onClick={ () => setActiveTab('themes') } className={ classNames('px-3 py-1 rounded text-xs font-bold cursor-pointer transition-colors', activeTab === 'themes' ? 'bg-[#1e7295] text-white' : 'bg-black/5 hover:bg-black/10') }>Temi</button>
</div>
{ activeTab === 'general' && <>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<input checked={ userSettings.oldChat } className="form-check-input" type="checkbox" onChange={ event => processAction('oldchat', event.target.checked) } />
<Text>{ LocalizeText('memenu.settings.chat.prefer.old.chat') }</Text>
</div>
<div className="flex items-center gap-1">
<input checked={ userSettings.roomInvites } className="form-check-input" type="checkbox" onChange={ event => processAction('room_invites', event.target.checked) } />
<Text>{ LocalizeText('memenu.settings.other.ignore.room.invites') }</Text>
</div>
<div className="flex items-center gap-1">
<input checked={ userSettings.cameraFollow } className="form-check-input" type="checkbox" onChange={ event => processAction('camera_follow', event.target.checked) } />
<Text>{ LocalizeText('memenu.settings.other.disable.room.camera.follow') }</Text>
</div>
<div className="flex items-center gap-1">
<input checked={ catalogPlaceMultipleObjects } className="form-check-input" type="checkbox" onChange={ event => setCatalogPlaceMultipleObjects(event.target.checked) } />
<Text>{ LocalizeText('memenu.settings.other.place.multiple.objects') }</Text>
</div>
<div className="flex items-center gap-1">
<input checked={ catalogSkipPurchaseConfirmation } className="form-check-input" type="checkbox" onChange={ event => setCatalogSkipPurchaseConfirmation(event.target.checked) } />
<Text>{ LocalizeText('memenu.settings.other.skip.purchase.confirmation') }</Text>
</div>
<div className="flex items-center gap-1">
<input checked={ chatWindowEnabled } className="form-check-input" type="checkbox" onChange={ event => setChatWindowEnabled(event.target.checked) } />
<Text>Enable chat window</Text>
</div>
<div className="flex items-center gap-1">
<input checked={ catalogClassicStyle } className="form-check-input" type="checkbox" onChange={ event => setCatalogClassicStyle(event.target.checked) } />
<Text>Catalogo: stile classico</Text>
</div>
</div>
<div className="flex flex-col">
<Text bold>{ LocalizeText('widget.memenu.settings.volume') }</Text>
<div className="flex flex-col gap-1">
<Text>{ LocalizeText('widget.memenu.settings.volume.ui') }</Text>
<div className="flex items-center gap-1">
{ (userSettings.volumeSystem === 0) && <FaVolumeMute className={ classNames((userSettings.volumeSystem >= 50) && 'text-muted', 'fa-icon') } /> }
{ (userSettings.volumeSystem > 0) && <FaVolumeDown className={ classNames((userSettings.volumeSystem >= 50) && 'text-muted', 'fa-icon') } /> }
<input className="custom-range w-full" id="volumeSystem" max="100" min="0" step="1" type="range" value={ userSettings.volumeSystem } onChange={ event => processAction('system_volume', event.target.value) } onMouseUp={ () => saveRangeSlider('volume') } />
<FaVolumeUp className={ classNames((userSettings.volumeSystem < 50) && 'text-muted', 'fa-icon') } />
</div>
</div>
<div className="flex flex-col gap-1">
<Text>{ LocalizeText('widget.memenu.settings.volume.furni') }</Text>
<div className="flex items-center gap-1">
{ (userSettings.volumeFurni === 0) && <FaVolumeMute className={ classNames((userSettings.volumeFurni >= 50) && 'text-muted', 'fa-icon') } /> }
{ (userSettings.volumeFurni > 0) && <FaVolumeDown className={ classNames((userSettings.volumeFurni >= 50) && 'text-muted', 'fa-icon') } /> }
<input className="custom-range w-full" id="volumeFurni" max="100" min="0" step="1" type="range" value={ userSettings.volumeFurni } onChange={ event => processAction('furni_volume', event.target.value) } onMouseUp={ () => saveRangeSlider('volume') } />
<FaVolumeUp className={ classNames((userSettings.volumeFurni < 50) && 'text-muted', 'fa-icon') } />
</div>
</div>
<div className="flex flex-col gap-1">
<Text>{ LocalizeText('widget.memenu.settings.volume.trax') }</Text>
<div className="flex items-center gap-1">
{ (userSettings.volumeTrax === 0) && <FaVolumeMute className={ classNames((userSettings.volumeTrax >= 50) && 'text-muted', 'fa-icon') } /> }
{ (userSettings.volumeTrax > 0) && <FaVolumeDown className={ classNames((userSettings.volumeTrax >= 50) && 'text-muted', 'fa-icon') } /> }
<input className="custom-range w-full" id="volumeTrax" max="100" min="0" step="1" type="range" value={ userSettings.volumeTrax } onChange={ event => processAction('trax_volume', event.target.value) } onMouseUp={ () => saveRangeSlider('volume') } />
<FaVolumeUp className={ classNames((userSettings.volumeTrax < 50) && 'text-muted', 'fa-icon') } />
</div>
</div>
</div>
<div className="flex flex-col pt-2 mt-1 border-t border-black/10">
<button
type="button"
onClick={ () => CreateLinkEvent('user-account-settings/show') }
className="group flex items-center gap-2 rounded-md border border-black/10 bg-white px-2 py-1.5 hover:bg-[#f5fbfd] hover:border-[#1e7295] transition-colors cursor-pointer text-left">
<div className="flex items-center justify-center w-7 h-7 rounded-full bg-[#1e7295] text-white shadow-[inset_0_2px_#ffffff26,inset_0_-2px_#0000001a]">
<FaUserCog size={ 12 } />
</div>
<div className="flex flex-col flex-1 leading-tight">
<Text bold>User settings</Text>
<Text small className="text-black/60">Password &amp; account</Text>
</div>
<span className="text-black/30 group-hover:text-[#1e7295] text-[10px]"></span>
</button>
</div>
</> }
{ activeTab === 'themes' && <div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<Text bold>Tema custom</Text>
<select
value={ activeThemeId }
onChange={ event => selectTheme(event.target.value) }
className="form-select rounded border border-black/15 px-2 py-1 text-sm">
<option value="">Default (nessun tema)</option>
{ themes.map(theme => (
<option key={ theme.id } value={ theme.id }>{ theme.name }{ theme.author ? `${ theme.author }` : '' }</option>
)) }
</select>
</div>
{ activeThemeId && manifest && manifest.pieces.length > 0 &&
<div className="flex flex-col gap-1 pt-1 border-t border-black/10">
<Text bold>Pezzi attivi</Text>
{ manifest.pieces.map(piece => (
<div key={ piece.id } className="flex items-center gap-1">
<input className="form-check-input" type="checkbox" checked={ activeEnabled.includes(piece.id) } onChange={ () => togglePiece(piece.id) } />
<Text>{ piece.name }</Text>
</div>
)) }
</div> }
{ activeThemeId && !manifest &&
<Text small className="text-black/60">Tema non valido o non raggiungibile uso il default.</Text> }
{ !themes.length &&
<Text small className="text-black/60">Nessun tema disponibile. Aggiungi una cartella in custom-themes/ sul server.</Text> }
</div> }
</NitroCardContentView>
</NitroCardView>
);
};