Merge upstream/Dev: fortune wheel settings popup + radio toggle

Adopt upstream wheel redo (Settings popup gated by acc_wheeladmin,
RareValues becomes view-only) and the radio enable/disable config gate.
Drop the broken orphaned duplicates under user-settings/fortune-wheel
and user-settings/rare-values (wrong relative import depth, unused,
failed typecheck). Soundboard / radio / background editor untouched.
This commit is contained in:
medievalshell
2026-05-28 22:28:00 +02:00
7 changed files with 218 additions and 173 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
import { AddLinkEventTracker, GetCommunication, GetRoomSessionManager, HabboWebTools, ILinkEventTracker, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer';
import { AnimatePresence, motion } from 'framer-motion';
import { FC, useEffect, useState } from 'react';
import { GetConfigurationValue } from '../api';
import { useNitroEventReducer } from '../hooks';
import { AchievementsView } from './achievements/AchievementsView';
import { AvatarEditorView } from './avatar-editor';
@@ -183,7 +184,7 @@ export const MainView: FC<{}> = props =>
<RareValuesView />
<FortuneWheelView />
<SoundboardView />
<RadioView />
{ GetConfigurationValue<boolean>('radio_ui', true) && <RadioView /> }
<ExternalPluginLoader />
</>
);
@@ -0,0 +1,147 @@
import { IWheelAdminPrize, IWheelAdminPrizeEdit } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { LocalizeText } from '../../api';
import { Column, Flex, Text } from '../../common';
import { useFortuneWheel } from '../../hooks';
import { NitroCard } from '../../layout';
interface EditRow
{
id: number;
category: string;
num: number;
weight: number;
label: string;
}
interface CategoryDef
{
key: string;
labelKey: string;
}
const CATEGORIES: CategoryDef[] = [
{ key: 'item', labelKey: 'rarevalues.editor.cat.item' },
{ key: 'diamonds', labelKey: 'achievements.activitypoint.5' },
{ key: 'duckets', labelKey: 'achievements.activitypoint.0' },
{ key: 'credits', labelKey: 'credits' },
{ key: 'spins', labelKey: 'rarevalues.editor.cat.spin' },
{ key: 'nothing', labelKey: 'rarevalues.editor.cat.nothing' }
];
const prizeToCategory = (prize: IWheelAdminPrize): string =>
{
switch(prize.type)
{
case 'item': return 'item';
case 'points': return (prize.pointsType === 5) ? 'diamonds' : 'duckets';
case 'credits': return 'credits';
case 'spin': return 'spins';
default: return 'nothing';
}
};
const prizeToNum = (prize: IWheelAdminPrize): number =>
(prize.type === 'item') ? (parseInt(prize.value) || 0) : prize.amount;
const rowToEdit = (row: EditRow): IWheelAdminPrizeEdit =>
{
const base = { id: row.id, weight: row.weight, label: row.label };
switch(row.category)
{
case 'item': return { ...base, type: 'item', value: String(row.num), amount: 1, pointsType: 0 };
case 'diamonds': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 5 };
case 'duckets': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 0 };
case 'credits': return { ...base, type: 'credits', value: '', amount: row.num, pointsType: 0 };
case 'spins': return { ...base, type: 'spin', value: '', amount: row.num, pointsType: 0 };
default: return { ...base, type: 'nothing', value: '', amount: 0, pointsType: 0 };
}
};
interface FortuneWheelSettingsViewProps
{
onClose: () => void;
}
export const FortuneWheelSettingsView: FC<FortuneWheelSettingsViewProps> = ({ onClose }) =>
{
const { adminPrizes = [], loadAdminPrizes = null, saveAdminPrizes = null } = useFortuneWheel();
const [ editRows, setEditRows ] = useState<EditRow[]>([]);
useEffect(() =>
{
if(loadAdminPrizes) loadAdminPrizes();
}, [ loadAdminPrizes ]);
useEffect(() =>
{
setEditRows(adminPrizes.map(prize => ({
id: prize.id,
category: prizeToCategory(prize),
num: prizeToNum(prize),
weight: prize.weight,
label: prize.label
})));
}, [ adminPrizes ]);
const updateRow = (id: number, patch: Partial<EditRow>) =>
setEditRows(prev => prev.map(row => (row.id === id) ? { ...row, ...patch } : row));
return (
<NitroCard className="w-[480px] h-[520px]" uniqueKey="fortune-wheel-settings">
<NitroCard.Header
headerText={ LocalizeText('wheel.settings.title') }
onCloseClick={ onClose } />
<NitroCard.Content>
<Column gap={ 1 } className="h-full p-1">
<Flex gap={ 1 } className="px-1 text-[11px] font-bold text-black/60">
<span className="w-28">{ LocalizeText('rarevalues.editor.type') }</span>
<span className="w-16">{ LocalizeText('rarevalues.editor.value') }</span>
<span className="w-12">{ LocalizeText('rarevalues.editor.weight') }</span>
<span className="grow">{ LocalizeText('rarevalues.editor.label') }</span>
</Flex>
<Column gap={ 1 } overflow="auto" className="grow">
{ editRows.map(row => (
<Flex key={ row.id } alignItems="center" gap={ 1 } className="border-b border-black/10 pb-1">
<select
value={ row.category }
onChange={ event => updateRow(row.id, { category: event.target.value }) }
className="w-28 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]">
{ CATEGORIES.map(cat => (
<option key={ cat.key } value={ cat.key }>{ LocalizeText(cat.labelKey) }</option>
)) }
</select>
<input
type="number"
value={ row.num }
disabled={ row.category === 'nothing' }
onChange={ event => updateRow(row.id, { num: parseInt(event.target.value) || 0 }) }
className="w-16 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34] disabled:opacity-40" />
<input
type="number"
value={ row.weight }
onChange={ event => updateRow(row.id, { weight: parseInt(event.target.value) || 0 }) }
className="w-12 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" />
<input
type="text"
value={ row.label }
onChange={ event => updateRow(row.id, { label: event.target.value }) }
className="min-w-0 grow rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" />
</Flex>
)) }
{ !editRows.length &&
<Text small className="text-black/50">{ LocalizeText('wheel.settings.empty') }</Text> }
</Column>
<button
type="button"
disabled={ !editRows.length }
onClick={ () => saveAdminPrizes?.(editRows.map(rowToEdit)) }
className="cursor-pointer rounded bg-[#3a7bb5] px-4 py-2 font-bold text-white hover:bg-[#336ea3] disabled:cursor-default disabled:opacity-40">
{ LocalizeText('rarevalues.editor.save') }
</button>
</Column>
</NitroCard.Content>
</NitroCard>
);
};
@@ -2,8 +2,9 @@ import { AddLinkEventTracker, GetRoomEngine, ILinkEventTracker, IWheelPrize, Rem
import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { LocalizeText } from '../../api';
import { Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, LayoutCurrencyIcon, LayoutImage, Text } from '../../common';
import { useFortuneWheel } from '../../hooks';
import { useFortuneWheel, useHasPermission } from '../../hooks';
import { NitroCard } from '../../layout';
import { FortuneWheelSettingsView } from './FortuneWheelSettingsView';
// Stock UI palette (white / light-blue / grey / black).
const SLICE_COLORS = [ '#eef2f5', '#c3dcec' ];
@@ -42,7 +43,9 @@ const renderPrizeIcon = (prize: IWheelPrize) =>
export const FortuneWheelView: FC<{}> = () =>
{
const [ isVisible, setIsVisible ] = useState(false);
const [ isSettingsOpen, setIsSettingsOpen ] = useState(false);
const { freeSpins, extraSpins, spinCost, spinCostType, prizes, recentWins, pendingPrizeId, isSpinning, open, spin, buySpin, finishSpin } = useFortuneWheel();
const canManage = useHasPermission('acc_wheeladmin');
const [ rotation, setRotation ] = useState(0);
const rotationRef = useRef(0);
const prizesRef = useRef<IWheelPrize[]>([]);
@@ -164,6 +167,12 @@ export const FortuneWheelView: FC<{}> = () =>
{ LocalizeText('wheel.buy') } { spinCost }
<LayoutCurrencyIcon type={ spinCostType } />
</button>
{ canManage &&
<button
onClick={ () => setIsSettingsOpen(true) }
className="cursor-pointer rounded bg-[#8a6b3a] px-3 py-2 font-bold text-white hover:bg-[#735730]">
{ LocalizeText('wheel.settings') }
</button> }
</Flex>
</Column>
<Column gap={ 2 } className="min-w-[300px] grow rounded-lg border border-black/10 bg-black/5 p-3">
@@ -186,6 +195,8 @@ export const FortuneWheelView: FC<{}> = () =>
</Column>
</Flex>
</NitroCard.Content>
{ canManage && isSettingsOpen &&
<FortuneWheelSettingsView onClose={ () => setIsSettingsOpen(false) } /> }
</NitroCard>
);
};
+23 -142
View File
@@ -1,8 +1,8 @@
import { AddLinkEventTracker, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, IRareValue, IWheelAdminPrize, IWheelAdminPrizeEdit, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { AddLinkEventTracker, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, IRareValue, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react';
import { LocalizeFormattedNumber, LocalizeText } from '../../api';
import { Column, Flex, LayoutCurrencyIcon, LayoutImage, Text } from '../../common';
import { useFortuneWheel, useHasPermission, useRareValues } from '../../hooks';
import { useRareValues } from '../../hooks';
import { NitroCard, NitroInput } from '../../layout';
interface RareValueRow
@@ -13,63 +13,11 @@ interface RareValueRow
value: IRareValue;
}
interface EditRow
{
id: number;
category: string;
num: number;
weight: number;
label: string;
}
const CATEGORIES: { key: string; label: string }[] = [
{ key: 'item', label: 'Raro (ID)' },
{ key: 'diamanti', label: 'Diamanti' },
{ key: 'duckets', label: 'Duckets' },
{ key: 'crediti', label: 'Crediti' },
{ key: 'giri', label: 'Giri extra' },
{ key: 'nulla', label: 'Nulla' }
];
const prizeToCategory = (prize: IWheelAdminPrize): string =>
{
switch(prize.type)
{
case 'item': return 'item';
case 'points': return (prize.pointsType === 5) ? 'diamanti' : 'duckets';
case 'credits': return 'crediti';
case 'spin': return 'giri';
default: return 'nulla';
}
};
const prizeToNum = (prize: IWheelAdminPrize): number =>
(prize.type === 'item') ? (parseInt(prize.value) || 0) : prize.amount;
const rowToEdit = (row: EditRow): IWheelAdminPrizeEdit =>
{
const base = { id: row.id, weight: row.weight, label: row.label };
switch(row.category)
{
case 'item': return { ...base, type: 'item', value: String(row.num), amount: 1, pointsType: 0 };
case 'diamanti': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 5 };
case 'duckets': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 0 };
case 'crediti': return { ...base, type: 'credits', value: '', amount: row.num, pointsType: 0 };
case 'giri': return { ...base, type: 'spin', value: '', amount: row.num, pointsType: 0 };
default: return { ...base, type: 'nothing', value: '', amount: 0, pointsType: 0 };
}
};
export const RareValuesView: FC<{}> = () =>
{
const [ isVisible, setIsVisible ] = useState(false);
const [ tab, setTab ] = useState<'values' | 'editor'>('values');
const [ searchValue, setSearchValue ] = useState('');
const { values = null, loaded = false } = useRareValues();
const { adminPrizes = [], loadAdminPrizes = null, saveAdminPrizes = null } = useFortuneWheel();
const canEdit = useHasPermission('acc_supporttool');
const [ editRows, setEditRows ] = useState<EditRow[]>([]);
useEffect(() =>
{
@@ -94,16 +42,6 @@ export const RareValuesView: FC<{}> = () =>
return () => RemoveLinkEventTracker(linkTracker);
}, []);
useEffect(() =>
{
if(isVisible && (tab === 'editor') && canEdit && loadAdminPrizes) loadAdminPrizes();
}, [ isVisible, tab, canEdit, loadAdminPrizes ]);
useEffect(() =>
{
setEditRows(adminPrizes.map(prize => ({ id: prize.id, category: prizeToCategory(prize), num: prizeToNum(prize), weight: prize.weight, label: prize.label })));
}, [ adminPrizes ]);
const rows = useMemo<RareValueRow[]>(() =>
{
if(!values) return [];
@@ -143,91 +81,34 @@ export const RareValuesView: FC<{}> = () =>
if(!isVisible) return null;
const updateRow = (id: number, patch: Partial<EditRow>) =>
setEditRows(prev => prev.map(row => (row.id === id) ? { ...row, ...patch } : row));
return (
<NitroCard className="w-[420px] h-[480px]" uniqueKey="rare-values">
<NitroCard.Header
headerText={ LocalizeText('rarevalues.title') }
onCloseClick={ () => setIsVisible(false) } />
{ canEdit &&
<NitroCard.Tabs>
<NitroCard.TabItem isActive={ tab === 'values' } onClick={ () => setTab('values') }>
<Text>{ LocalizeText('rarevalues.title') }</Text>
</NitroCard.TabItem>
<NitroCard.TabItem isActive={ tab === 'editor' } onClick={ () => setTab('editor') }>
<Text>{ LocalizeText('rarevalues.editor.tab') }</Text>
</NitroCard.TabItem>
</NitroCard.Tabs> }
<NitroCard.Content>
{ (tab === 'values' || !canEdit) &&
<Column gap={ 2 } className="h-full p-1">
<NitroInput
placeholder={ LocalizeText('generic.search') }
value={ searchValue }
onChange={ event => setSearchValue(event.target.value) } />
<Column gap={ 0 } overflow="auto" className="grow">
{ !loaded &&
<Text center className="mt-2 text-black/60">{ LocalizeText('rarevalues.loading') }</Text> }
{ (loaded && !filtered.length) &&
<Text center className="mt-2 text-black/60">{ LocalizeText('rarevalues.empty') }</Text> }
{ filtered.map(row => (
<Flex key={ row.spriteId } alignItems="center" gap={ 2 } className="border-b border-black/10 py-1.5 hover:bg-black/5">
<LayoutImage imageUrl={ row.iconUrl } className="h-10 w-10 shrink-0 bg-contain bg-center bg-no-repeat" />
<Text truncate className="grow text-[#1f2d34]">{ row.name }</Text>
<Flex alignItems="center" gap={ 1 } className="shrink-0">
<Text bold textEnd className="text-[#2f6f95]">{ LocalizeFormattedNumber(row.value.points) }</Text>
<LayoutCurrencyIcon type={ row.value.pointsType } />
</Flex>
<Column gap={ 2 } className="h-full p-1">
<NitroInput
placeholder={ LocalizeText('generic.search') }
value={ searchValue }
onChange={ event => setSearchValue(event.target.value) } />
<Column gap={ 0 } overflow="auto" className="grow">
{ !loaded &&
<Text center className="mt-2 text-black/60">{ LocalizeText('rarevalues.loading') }</Text> }
{ (loaded && !filtered.length) &&
<Text center className="mt-2 text-black/60">{ LocalizeText('rarevalues.empty') }</Text> }
{ filtered.map(row => (
<Flex key={ row.spriteId } alignItems="center" gap={ 2 } className="border-b border-black/10 py-1.5 hover:bg-black/5">
<LayoutImage imageUrl={ row.iconUrl } className="h-10 w-10 shrink-0 bg-contain bg-center bg-no-repeat" />
<Text truncate className="grow text-[#1f2d34]">{ row.name }</Text>
<Flex alignItems="center" gap={ 1 } className="shrink-0">
<Text bold textEnd className="text-[#2f6f95]">{ LocalizeFormattedNumber(row.value.points) }</Text>
<LayoutCurrencyIcon type={ row.value.pointsType } />
</Flex>
)) }
</Column>
</Column> }
{ (tab === 'editor' && canEdit) &&
<Column gap={ 1 } className="h-full p-1">
<Flex gap={ 1 } className="px-1 text-[11px] font-bold text-black/60">
<span className="w-28">{ LocalizeText('rarevalues.editor.type') }</span>
<span className="w-16">{ LocalizeText('rarevalues.editor.value') }</span>
<span className="w-12">{ LocalizeText('rarevalues.editor.weight') }</span>
<span className="grow">{ LocalizeText('rarevalues.editor.label') }</span>
</Flex>
<Column gap={ 1 } overflow="auto" className="grow">
{ editRows.map(row => (
<Flex key={ row.id } alignItems="center" gap={ 1 } className="border-b border-black/10 pb-1">
<select
value={ row.category }
onChange={ event => updateRow(row.id, { category: event.target.value }) }
className="w-28 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]">
{ CATEGORIES.map(cat => <option key={ cat.key } value={ cat.key }>{ cat.label }</option>) }
</select>
<input
type="number"
value={ row.num }
disabled={ row.category === 'nulla' }
onChange={ event => updateRow(row.id, { num: parseInt(event.target.value) || 0 }) }
className="w-16 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34] disabled:opacity-40" />
<input
type="number"
value={ row.weight }
onChange={ event => updateRow(row.id, { weight: parseInt(event.target.value) || 0 }) }
className="w-12 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" />
<input
type="text"
value={ row.label }
onChange={ event => updateRow(row.id, { label: event.target.value }) }
className="min-w-0 grow rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" />
</Flex>
)) }
</Column>
<button
type="button"
onClick={ () => saveAdminPrizes?.(editRows.map(rowToEdit)) }
className="cursor-pointer rounded bg-[#3a7bb5] px-4 py-2 font-bold text-white hover:bg-[#336ea3]">
{ LocalizeText('rarevalues.editor.save') }
</button>
</Column> }
</Flex>
)) }
</Column>
</Column>
</NitroCard.Content>
</NitroCard>
);
@@ -1,36 +1,35 @@
import { AvailableCommandsEvent, GetCommunication } from '@nitrots/nitro-renderer';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { CommandDefinition } from '../../../api';
import { CommandDefinition, LocalizeText } from '../../../api';
import { createNitroStore } from '../../../state/createNitroStore';
import { useMessageEvent } from '../../events';
// Client-only commands are static; safe to keep at module scope.
const CLIENT_COMMANDS: CommandDefinition[] = [
// Effetti stanza
{ key: 'shake', description: 'Scuoti la stanza' },
{ key: 'rotate', description: 'Ruota la stanza' },
{ key: 'zoom', description: 'Zoom stanza' },
{ key: 'flip', description: 'Reset zoom' },
{ key: 'iddqd', description: 'Reset zoom' },
{ key: 'screenshot', description: 'Screenshot stanza' },
{ key: 'togglefps', description: 'Toggle FPS' },
// Espressioni
{ key: 'd', description: 'Ridi (VIP)' },
{ key: 'kiss', description: 'Manda un bacio (VIP)' },
{ key: 'jump', description: 'Salta (VIP)' },
{ key: 'idle', description: 'Vai in idle' },
{ key: 'sign', description: 'Mostra cartello' },
// Gestione stanza
{ key: 'furni', description: 'Furni chooser' },
{ key: 'chooser', description: 'User chooser' },
{ key: 'floor', description: 'Floor editor' },
{ key: 'bcfloor', description: 'Floor editor' },
{ key: 'pickall', description: 'Raccogli tutti i furni' },
{ key: 'ejectall', description: 'Espelli tutti i furni' },
{ key: 'settings', description: 'Impostazioni stanza' },
const CLIENT_COMMANDS: { key: string; descriptionKey: string }[] = [
// Room effects
{ key: 'shake', descriptionKey: 'chatcmd.client.shake' },
{ key: 'rotate', descriptionKey: 'chatcmd.client.rotate' },
{ key: 'zoom', descriptionKey: 'chatcmd.client.zoom' },
{ key: 'flip', descriptionKey: 'chatcmd.client.flip' },
{ key: 'iddqd', descriptionKey: 'chatcmd.client.iddqd' },
{ key: 'screenshot', descriptionKey: 'chatcmd.client.screenshot' },
{ key: 'togglefps', descriptionKey: 'chatcmd.client.togglefps' },
// Expressions
{ key: 'd', descriptionKey: 'chatcmd.client.laugh' },
{ key: 'kiss', descriptionKey: 'chatcmd.client.kiss' },
{ key: 'jump', descriptionKey: 'chatcmd.client.jump' },
{ key: 'idle', descriptionKey: 'chatcmd.client.idle' },
{ key: 'sign', descriptionKey: 'chatcmd.client.sign' },
// Room management
{ key: 'furni', descriptionKey: 'chatcmd.client.furni' },
{ key: 'chooser', descriptionKey: 'chatcmd.client.chooser' },
{ key: 'floor', descriptionKey: 'chatcmd.client.floor' },
{ key: 'bcfloor', descriptionKey: 'chatcmd.client.floor' },
{ key: 'pickall', descriptionKey: 'chatcmd.client.pickall' },
{ key: 'ejectall', descriptionKey: 'chatcmd.client.ejectall' },
{ key: 'settings', descriptionKey: 'chatcmd.client.settings' },
// Info
{ key: 'client', description: 'Info client' },
{ key: 'nitro', description: 'Info client' },
{ key: 'client', descriptionKey: 'chatcmd.client.info' },
{ key: 'nitro', descriptionKey: 'chatcmd.client.info' },
];
/**
@@ -110,11 +109,12 @@ export const useChatCommandSelector = (chatValue: string) =>
const allCommands = useMemo(() =>
{
const merged = [ ...serverCommands ];
const merged: CommandDefinition[] = [ ...serverCommands ];
for(const clientCmd of CLIENT_COMMANDS)
{
if(!merged.some(cmd => cmd.key === clientCmd.key)) merged.push(clientCmd);
if(merged.some(cmd => cmd.key === clientCmd.key)) continue;
merged.push({ key: clientCmd.key, description: LocalizeText(clientCmd.descriptionKey) });
}
return merged.sort((a, b) => a.key.localeCompare(b.key));