mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 06:56:20 +00:00
8097344561
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)
250 lines
15 KiB
TypeScript
250 lines
15 KiB
TypeScript
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 & 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>
|
||
);
|
||
};
|