Merge remote-tracking branch 'origin/Dev' into feat/messenger-groups-receipts

# Conflicts:
#	public/configuration/UITexts.example
#	src/css/friends/FriendsView.css
This commit is contained in:
simoleo89
2026-06-02 21:52:59 +02:00
225 changed files with 11432 additions and 1872 deletions
+11
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';
@@ -24,6 +25,11 @@ import { HcCenterView } from './hc-center/HcCenterView';
import { HelpView } from './help/HelpView';
import { HotelView } from './hotel-view/HotelView';
import { HousekeepingView } from './housekeeping/HousekeepingView';
import { RareValuesView } from './rare-values/RareValuesView';
import { FortuneWheelView } from './fortune-wheel/FortuneWheelView';
import { SoundboardView } from './soundboard/SoundboardView';
import { ThemeApplier } from './theme/ThemeApplier';
import { RadioView } from './radio/RadioView';
import { InventoryView } from './inventory/InventoryView';
import { ModToolsView } from './mod-tools/ModToolsView';
import { NavigatorView } from './navigator/NavigatorView';
@@ -129,6 +135,7 @@ export const MainView: FC<{}> = props =>
return (
<>
<ThemeApplier />
<div className="hidden" data-localization-version={ localizationVersion } />
<AnimatePresence>
{ landingViewVisible &&
@@ -176,6 +183,10 @@ export const MainView: FC<{}> = props =>
<GameCenterView />
<FloorplanEditorView />
<FurniEditorView />
<RareValuesView />
<FortuneWheelView />
<SoundboardView />
{ GetConfigurationValue<boolean>('radio_ui.enabled', false) && <RadioView /> }
<ExternalPluginLoader />
</>
);
+48 -8
View File
@@ -1,9 +1,9 @@
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect } from 'react';
import { FaCog, FaEdit, FaEye, FaEyeSlash, FaPlus, FaTrash } from 'react-icons/fa';
import { CatalogType, GetConfigurationValue, LocalizeText } from '../../api';
import { Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
import { useCatalogActions, useCatalogData, useCatalogUiState, useHasPermission } from '../../hooks';
import { FC, useEffect, useState } from 'react';
import { FaBars, FaCog, FaEdit, FaEye, FaEyeSlash, FaPlus, FaTrash } from 'react-icons/fa';
import { CatalogType, GetConfigurationValue, LocalizeShortNumber, LocalizeText } from '../../api';
import { Column, Grid, LayoutCurrencyIcon, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
import { useCatalogActions, useCatalogData, useCatalogUiState, useHasPermission, usePurse } from '../../hooks';
import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext';
import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView';
import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView';
@@ -31,6 +31,9 @@ const CatalogClassicViewInner: FC<{}> = () =>
const loading = catalogAdmin?.loading ?? false;
const isMod = useHasPermission('acc_catalogfurni');
const [ mobileMenuOpen, setMobileMenuOpen ] = useState(false);
const { purse = null } = usePurse();
const displayedCurrencies = GetConfigurationValue<number[]>('system.currency.types', []);
const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER)
? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' }
: undefined;
@@ -121,6 +124,42 @@ const CatalogClassicViewInner: FC<{}> = () =>
{ isVisible &&
<NitroCardView classNames={ [ 'nitro-catalog-classic-window' ] } isResizable={ false } uniqueKey="catalog">
<NitroCardHeaderView className={ currentType === CatalogType.BUILDER ? 'builders-club-card-header' : '' } headerText={ LocalizeText('catalog.title') } onCloseClick={ () => setIsVisible(false) } style={ buildersClubHeaderStyle } />
<div className="nitro-catalog-classic-mobile-header">
{ isMod &&
<div className="nitro-catalog-classic-mobile-burger">
<button className="nitro-catalog-classic-burger-btn" onClick={ () => setMobileMenuOpen(value => !value) }>
<FaBars />
</button>
{ mobileMenuOpen &&
<div className="nitro-catalog-classic-burger-menu">
<button onClick={ () =>
{
setAdminMode(!adminMode); setMobileMenuOpen(false);
} }>
{ adminMode ? 'Exit Admin' : 'Admin' }
</button>
{ adminMode &&
<button disabled={ loading } onClick={ () =>
{
publishCatalog(); setMobileMenuOpen(false);
} }>
{ loading ? '...' : 'Publish' }
</button> }
</div> }
</div> }
<div className="nitro-catalog-classic-mobile-currency">
<div className="nitro-catalog-classic-coin">
<span>{ LocalizeShortNumber(purse?.credits ?? 0) }</span>
<LayoutCurrencyIcon type={ -1 } />
</div>
{ displayedCurrencies.map(type => (
<div key={ type } className="nitro-catalog-classic-coin">
<span>{ LocalizeShortNumber(purse?.activityPoints?.get(type) ?? 0) }</span>
<LayoutCurrencyIcon type={ type } />
</div>
)) }
</div>
</div>
{ adminMode &&
<div className="nitro-catalog-classic-admin-banner flex items-center justify-between text-[10px] font-bold px-3 py-0.5 uppercase tracking-wider">
<span>Admin Mode</span>
@@ -148,7 +187,7 @@ const CatalogClassicViewInner: FC<{}> = () =>
} }>
<div className={ `flex items-center gap-1 ${ isHidden ? 'opacity-40' : '' }` }>
<CatalogIconView icon={ child.iconId } />
<span className="truncate">{ child.localization }</span>
<span className="nitro-catalog-classic-tab-label truncate">{ child.localization }</span>
{ adminMode && isHidden && <FaEyeSlash className="text-[8px] text-danger ml-1" /> }
{ adminMode &&
<div className="flex items-center gap-0.5 ml-1" onClick={ e => e.stopPropagation() }>
@@ -172,7 +211,7 @@ const CatalogClassicViewInner: FC<{}> = () =>
);
}) }
{ isMod &&
<NitroCardTabsItemView isActive={ adminMode } onClick={ () => setAdminMode(!adminMode) }>
<NitroCardTabsItemView classNames={ [ 'nitro-catalog-classic-admin-tab' ] } isActive={ adminMode } onClick={ () => setAdminMode(!adminMode) }>
<FaCog className={ `text-[10px] ${ adminMode ? 'animate-spin' : '' }` } style={ adminMode ? { animationDuration: '3s' } : {} } />
</NitroCardTabsItemView> }
</NitroCardTabsView>
@@ -213,7 +252,8 @@ const CatalogClassicViewInner: FC<{}> = () =>
<div className="nitro-catalog-classic-layout-header-shell">
<CatalogBreadcrumbView />
<div className="nitro-catalog-classic-layout-hero">
{ !!currentPage?.localization?.getImage(0) && <img src={ currentPage.localization.getImage(0) } /> }
{ /* info_duckets renders its own logo in the body (BcInfoView) — don't duplicate it in the hero */ }
{ (currentPage?.layoutCode !== 'info_duckets') && !!currentPage?.localization?.getImage(0) && <img src={ currentPage.localization.getImage(0) } /> }
</div>
</div>
<div className="nitro-catalog-classic-layout-container">
+8 -4
View File
@@ -37,6 +37,10 @@ const CatalogModernViewInner: FC<{}> = () =>
const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER)
? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' }
: undefined;
// Desktop = fixed 780x520. On mobile the window clamps below the viewport so
// it reads as a dialog (with margins) instead of filling the whole phone
// screen — applies to both the normal catalog and the Builders Club.
const catalogCardSize = 'w-[780px] h-[520px] max-w-[96vw] max-h-[72vh] sm:max-w-[100vw] sm:max-h-[92vh]';
useEffect(() =>
{
@@ -122,7 +126,7 @@ const CatalogModernViewInner: FC<{}> = () =>
return (
<>
{ isVisible &&
<NitroCardView className="nitro-catalog w-[780px] h-[520px]" uniqueKey="catalog">
<NitroCardView className={ `nitro-catalog ${ catalogCardSize }` } uniqueKey="catalog">
<NitroCardHeaderView className={ currentType === CatalogType.BUILDER ? 'builders-club-card-header' : '' } headerText={ LocalizeText('catalog.title') } onCloseClick={ () => setIsVisible(false) } style={ buildersClubHeaderStyle } />
<NitroCardContentView classNames={ [ 'p-0!', 'overflow-hidden!' ] }>
{ /* Admin banner */ }
@@ -141,7 +145,7 @@ const CatalogModernViewInner: FC<{}> = () =>
<CatalogBuildersClubStatusView />
<div className="flex min-h-0 flex-1">
{ /* === LEFT SIDEBAR === */ }
<div className="group/rail flex flex-col w-[52px] hover:w-[175px] min-w-[52px] bg-card-grid-item border-r-2 border-card-grid-item-border py-1.5 gap-px overflow-y-auto overflow-x-hidden transition-[width] duration-200 ease-in-out">
<div className="group/rail flex flex-col w-[52px] sm:hover:w-[175px] min-w-[52px] bg-card-grid-item border-r-2 border-card-grid-item-border py-1.5 gap-px overflow-y-auto overflow-x-hidden transition-[width] duration-200 ease-in-out [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{ /* Favorites toggle */ }
<div
@@ -279,7 +283,7 @@ const CatalogModernViewInner: FC<{}> = () =>
: <span className="text-muted">{ LocalizeText('catalog.title') }</span> }
</div>
<div className="w-[180px] shrink-0">
<div className="w-[110px] sm:w-[180px] shrink-0">
<CatalogSearchView />
</div>
@@ -301,7 +305,7 @@ const CatalogModernViewInner: FC<{}> = () =>
</div>
: <>
{ !navigationHidden && activeNodes && activeNodes.length > 0 &&
<div className="w-[170px] min-w-[170px] border-r-2 border-card-grid-item-border bg-card-grid-item overflow-y-auto py-1">
<div className="w-[120px] min-w-[120px] sm:w-[170px] sm:min-w-[170px] border-r-2 border-card-grid-item-border bg-card-grid-item overflow-y-auto py-1">
<CatalogNavigationView node={ activeNodes[0] } />
</div> }
<div className="flex-1 overflow-auto p-2 nitro-card-content-shell">
+7 -4
View File
@@ -1,15 +1,18 @@
import { FC } from 'react';
import { GetConfigurationValue } from '../../api';
import { useCatalogData } from '../../hooks';
import { useCatalogClassicStyle, useCatalogData } from '../../hooks';
import { CatalogClassicView } from './CatalogClassicView';
import { CatalogModernView } from './CatalogModernView';
export const CatalogView: FC<{}> = () =>
{
const { catalogLocalizationVersion = 0 } = useCatalogData();
const useNewStyle = GetConfigurationValue<boolean>('catalog.style.new', false);
const [ catalogClassicStyle ] = useCatalogClassicStyle();
if(useNewStyle) return (
// Default = upstream rebuilt catalog (CatalogClassicView, latest release theme).
// The "stile classico" toggle (or global catalog.classic.style flag) switches
// to the Hippiehotel.nl catalog (CatalogModernView, self-contained tailwind).
// Both the normal catalog and the Builders Club follow this toggle.
if(catalogClassicStyle) return (
<>
<div className="hidden" data-catalog-localization-version={ catalogLocalizationVersion } />
<CatalogModernView />
@@ -67,8 +67,14 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
{
if(!editingPageData || !targetNode) return;
setCaption(targetNode.localization || '');
setCaptionSave(targetNode.pageName || targetNode.localization || '');
// The server appends " (pageId)" to the caption for mods/admins (see
// CatalogPagesListComposer). Strip that exact suffix before seeding the
// edit field, otherwise saving folds the id back into the stored
// caption and it multiplies on every edit ("Wired (1114) (1114) ...").
const rawCaption = (targetNode.localization || '').replace(new RegExp(`\\s*\\(${ targetNode.pageId }\\)\\s*$`), '');
setCaption(rawCaption);
setCaptionSave(targetNode.pageName || rawCaption);
setCatalogMode(currentType === CatalogType.BUILDER ? 'BUILDER' : 'NORMAL');
setPageLayout(currentPage?.layoutCode || 'default_3x3');
setIconImage(targetNode.iconId ?? 0);
@@ -76,7 +76,7 @@ export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
{ iconUrl && !(offer.product.productType === ProductTypeEnum.ROBOT) &&
<div className="nitro-catalog-classic-grid-offer-icon" style={ { backgroundImage: `url(${ iconUrl })` } } /> }
{ (offer.product.productType === ProductTypeEnum.ROBOT) &&
<LayoutAvatarImageView direction={ 3 } figure={ offer.product.extraParam } headOnly={ true } /> }
<LayoutAvatarImageView direction={ 2 } figure={ offer.product.extraParam } fit /> }
<div
className={ `absolute top-0 right-0 z-10 p-0.5 cursor-pointer transition-opacity duration-100 ${ isFav ? 'opacity-100' : 'opacity-0 group-hover/tile:opacity-100' }` }
onClick={ e =>
@@ -0,0 +1,36 @@
import { FC, useEffect } from 'react';
import { SanitizeHtml } from '../../../../../api';
import { CatalogLayoutProps } from './CatalogLayout.types';
// Info/landing layout: a logo box on top (image scaled to fit the available
// space, no crop) and a smaller box below with the page text in black.
// Logo = page headline image (getImage(0)), text = page text 1 (getText(0)),
// set from catalog admin (Gestione -> Modifica pagina). Hides the (empty)
// navigation sidebar so the content uses the full width.
export const CatalogLayoutBcInfoView: FC<CatalogLayoutProps> = props =>
{
const { page = null, hideNavigation = null } = props;
const logo = page?.localization?.getImage(0) || '';
const text = page?.localization?.getText(0) || '';
useEffect(() =>
{
hideNavigation?.();
}, [ page, hideNavigation ]);
return (
<div className="flex flex-col h-full gap-2">
<div className="flex-1 min-h-0 bg-white rounded border border-card-grid-item-border overflow-hidden flex items-center justify-center">
{ logo
? <img alt="" className="max-w-full max-h-full object-contain" src={ logo } />
: <span className="text-muted text-[11px]">Logo imposta l'immagine headline da Gestione</span> }
</div>
<div className="shrink-0 max-h-[32%] bg-white rounded border border-card-grid-item-border p-3 overflow-auto">
<div
className="text-black text-[12px] leading-snug"
dangerouslySetInnerHTML={ { __html: SanitizeHtml(text) } } />
</div>
</div>
);
};
@@ -1,5 +1,5 @@
import { FC } from 'react';
import { FaEdit, FaPlus } from 'react-icons/fa';
import { FaEdit, FaPlus, FaPowerOff, FaSyncAlt } from 'react-icons/fa';
import { GetConfigurationValue, LocalizeText, ProductTypeEnum, SanitizeHtml } from '../../../../../api';
import { Text } from '../../../../../common';
import { useCatalogData } from '../../../../../hooks';
@@ -17,13 +17,12 @@ import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const { currentOffer = null, currentPage = null } = useCatalogData();
const { currentOffer = null, currentPage = null, roomPreviewer = null } = useCatalogData();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
return (
<div className="nitro-catalog-classic-default-layout flex flex-col h-full gap-2">
{ /* Admin: quick actions */ }
{ adminMode && !catalogAdmin.editingPageData &&
<div className="flex gap-2 nitro-catalog-classic-default-admin">
<button
@@ -42,23 +41,24 @@ export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
<FaPlus className="text-[10px]" /> { LocalizeText('catalog.admin.offer.new') }
</button>
</div> }
{ /* Product detail card */ }
{ currentOffer &&
<div className="nitro-catalog-classic-offer-panel flex gap-0 overflow-hidden">
{ /* Preview area */ }
<div className="nitro-catalog-classic-offer-panel flex gap-0 shrink-0">
<div className="nitro-catalog-classic-offer-preview relative flex items-center justify-center">
{ (currentOffer.product.productType !== ProductTypeEnum.BADGE) &&
<>
<button className="nitro-catalog-classic-preview-btn nitro-catalog-classic-preview-rotate" onClick={ () => roomPreviewer?.changeRoomObjectDirection() }>
<FaSyncAlt /> Rotate
</button>
<button className="nitro-catalog-classic-preview-btn nitro-catalog-classic-preview-state" onClick={ () => roomPreviewer?.changeRoomObjectState() }>
<FaPowerOff /> Toggle State
</button>
<CatalogViewProductWidgetView />
<CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-1 right-1 absolute" />
</> }
{ (currentOffer.product.productType === ProductTypeEnum.BADGE) &&
<CatalogAddOnBadgeWidgetView className="scale-2" /> }
</div>
{ /* Product info + purchase */ }
<div className="nitro-catalog-classic-offer-info flex flex-col flex-1 min-w-0 gap-2">
{ /* Title row */ }
<div>
<div className="flex items-start justify-between gap-2">
<Text className="text-[13px]! font-bold text-dark leading-tight">{ currentOffer.localizationName }</Text>
@@ -77,30 +77,25 @@ export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
</div> }
<CatalogLimitedItemWidgetView />
</div>
{ /* Price */ }
<CatalogTotalPriceWidget />
{ /* Spinner */ }
<CatalogSpinnerWidgetView />
{ /* Actions */ }
<div className="flex gap-1.5 mt-auto">
<div className="nitro-catalog-classic-offer-actions flex gap-1.5">
<CatalogPurchaseWidgetView />
</div>
</div>
</div> }
{ /* Welcome/description card */ }
{ !currentOffer &&
<div className="nitro-catalog-classic-welcome flex items-center gap-3">
<div className="nitro-catalog-classic-welcome flex items-center gap-3 shrink-0">
{ !!page.localization.getImage(1) &&
<img className="w-[70px] h-[70px] object-contain rounded shrink-0" src={ page.localization.getImage(1) } /> }
<Text className="text-[11px]! text-muted" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
</div> }
{ /* Item grid */ }
<div className="nitro-catalog-classic-grid-shell flex-1 overflow-auto min-h-0">
{ GetConfigurationValue('catalog.headers') &&
<CatalogHeaderView imageUrl={ currentPage.localization.getImage(0) } /> }
<CatalogItemGridWidgetView className="nitro-catalog-classic-grid" columnCount={ 7 } columnMinHeight={ 50 } columnMinWidth={ 50 } />
<CatalogItemGridWidgetView className="nitro-catalog-classic-grid" columnCount={ 7 } columnMinHeight={ currentPage.layoutCode === 'bots' ? 65 : 50 } columnMinWidth={ currentPage.layoutCode === 'bots' ? 65 : 50 } />
</div>
</div>
);
@@ -3,7 +3,7 @@ import { FC, useEffect, useState } from 'react';
import { LocalizeText, SendMessageComposer } from '../../../../../api';
import { useNitroQuery } from '../../../../../api/nitro-query';
import { Button, Column, Text } from '../../../../../common';
import { useCatalogUiState, useNavigator, useRoomPromote } from '../../../../../hooks';
import { useCatalogUiState, useNavigatorData, useRoomPromote } from '../../../../../hooks';
import { NitroInput } from '../../../../../layout';
import { CatalogLayoutProps } from './CatalogLayout.types';
@@ -17,7 +17,7 @@ export const CatalogLayoutRoomAdsView: FC<CatalogLayoutProps> = props =>
const [ roomId, setRoomId ] = useState<number>(-1);
const [ extended, setExtended ] = useState<boolean>(false);
const [ categoryId, setCategoryId ] = useState<number>(1);
const { categories = null } = useNavigator();
const { categories } = useNavigatorData();
const { setIsVisible = null } = useCatalogUiState();
const { promoteInformation, isExtended, setIsExtended } = useRoomPromote();
@@ -58,9 +58,11 @@ export const CatalogLayoutTrophiesView: FC<CatalogLayoutProps> = props =>
</button>
</div> }
{ /* Selected trophy card */ }
{ /* Selected trophy card. shrink-0 + no overflow-hidden so the
Buy button stays inside the panel even when the grid below
holds many trophies. */ }
{ currentOffer
? <div className="flex gap-0 bg-white rounded border-2 border-warning/40 overflow-hidden" style={ { boxShadow: '0 0 8px rgba(255,193,7,0.15)' } }>
? <div className="flex gap-0 bg-white rounded border-2 border-warning/40 shrink-0" style={ { boxShadow: '0 0 8px rgba(255,193,7,0.15)' } }>
{ /* Preview */ }
<div className="w-[120px] min-w-[120px] relative flex items-center justify-center border-r-2 border-warning/30" style={ { background: 'linear-gradient(180deg, #fff9e6 0%, #fff3cc 100%)' } }>
{ (currentOffer.product.productType !== ProductTypeEnum.BADGE)
@@ -90,7 +92,7 @@ export const CatalogLayoutTrophiesView: FC<CatalogLayoutProps> = props =>
<CatalogTotalPriceWidget />
{ !canPurchase &&
<span className="text-[9px] text-warning italic">{ LocalizeText('catalog.trophies.write.hint') }</span> }
<div className="flex gap-1.5 mt-auto">
<div className="flex gap-1.5">
<CatalogPurchaseWidgetView />
</div>
</div>
@@ -1,6 +1,7 @@
import { ICatalogPage } from '../../../../../api';
import { CatalogLayoutProps } from './CatalogLayout.types';
import { CatalogLayoutBadgeDisplayView } from './CatalogLayoutBadgeDisplayView';
import { CatalogLayoutBcInfoView } from './CatalogLayoutBcInfoView';
import { CatalogLayoutBuildersClubBuyView } from './CatalogLayoutBuildersClubBuyView';
import { CatalogLayoutColorGroupingView } from './CatalogLayoutColorGroupingView';
import { CatalogLayoutCustomPrefixView } from './CatalogLayoutCustomPrefixView';
@@ -34,6 +35,8 @@ export const GetCatalogLayout = (page: ICatalogPage, hideNavigation: () => void)
{
case 'frontpage_featured':
return null;
case 'info_duckets':
return <CatalogLayoutBcInfoView { ...layoutProps } />;
case 'frontpage4':
return <CatalogLayoutFrontpage4View { ...layoutProps } />;
case 'pets':
@@ -240,7 +240,7 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
return <Button variant="danger">{ LocalizeText('generic.failed') + ' - ' + LocalizeText('catalog.alert.limited_edition_sold_out.title') }</Button>;
case CatalogPurchaseState.NONE:
default:
return <Button disabled={ (purchaseOptions.extraParamRequired && (!purchaseOptions.extraData || !purchaseOptions.extraData.length)) } onClick={ event => setPurchaseState(CatalogPurchaseState.CONFIRM) }>{ LocalizeText('catalog.purchase_confirmation.' + (currentOffer.isRentOffer ? 'rent' : 'buy')) }</Button>;
return <Button variant="success" disabled={ (purchaseOptions.extraParamRequired && (!purchaseOptions.extraData || !purchaseOptions.extraData.length)) } onClick={ event => setPurchaseState(CatalogPurchaseState.CONFIRM) }>{ LocalizeText('catalog.purchase_confirmation.' + (currentOffer.isRentOffer ? 'rent' : 'buy')) }</Button>;
}
};
@@ -19,6 +19,8 @@ export const CatalogViewProductWidgetView: FC<{}> = props =>
if(!product) return;
roomPreviewer.reset(false);
roomPreviewer.updateObjectRoom('default', 'default', 'default');
roomPreviewer.updateRoomWallsAndFloorVisibility(true, true);
switch(product.productType)
{
@@ -68,6 +70,8 @@ export const CatalogViewProductWidgetView: FC<{}> = props =>
case ProductTypeEnum.WALL: {
if(!product.furnitureData) return;
roomPreviewer.updateRoomWallsAndFloorVisibility(true, true);
switch(product.furnitureData.specialType)
{
case FurniCategory.FLOOR:
@@ -161,7 +161,7 @@ describe('FloorplanEditorView container', () =>
expect(composer.thicknessFloor).toBe(1);
});
it('RoomOccupiedTilesMessageEvent marks blockedTilesMap entries as blocked in state', () =>
it('RoomOccupiedTilesMessageEvent marks tiles occupied without altering the saved tilemap', () =>
{
openEditor();
const fhmHandler = messageHandlers.get(FloorHeightMapEvent);
@@ -178,8 +178,9 @@ describe('FloorplanEditorView container', () =>
fireEvent.click(saveBtn!);
const composer = sendMessageComposer.mock.calls[0][0];
expect(composer).toBeInstanceOf(UpdateFloorPropertiesMessageComposer);
// Row separator is \r per serializeTilemap; row 0 was '00', col 1 blocked → '0x'
expect(composer.tilemap.split(/\r/)[0]).toBe('0x');
// Occupied is purely informational: the tile stays walkable and the
// saved tilemap is unchanged (row 0 stays '00', NOT voided to '0x').
expect(composer.tilemap.split(/\r/)[0]).toBe('00');
});
it('RoomEngineEvent.DISPOSED hides the editor', () =>
@@ -1,6 +1,6 @@
import { AddLinkEventTracker, convertNumbersForSaving, convertSettingToNumber, FloorHeightMapEvent, GetRoomEntryTileMessageComposer, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomEntryTileMessageEvent, RoomVisualizationSettingsEvent, UpdateFloorPropertiesMessageComposer } from '@nitrots/nitro-renderer';
import { AddLinkEventTracker, convertNumbersForSaving, convertSettingToNumber, FloorHeightMapEvent, GetOccupiedTilesMessageComposer, GetRoomEntryTileMessageComposer, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomEntryTileMessageEvent, RoomOccupiedTilesMessageEvent, RoomVisualizationSettingsEvent, UpdateFloorPropertiesMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { FaBolt, FaCaretLeft, FaCaretRight } from 'react-icons/fa';
import { FaBolt, FaBoxOpen, FaCaretLeft, FaCaretRight } from 'react-icons/fa';
import { LocalizeText, SendMessageComposer } from '../../api';
import { Button, ButtonGroup, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
import { useMessageEvent, useNitroEvent } from '../../hooks';
@@ -29,6 +29,7 @@ export const FloorplanEditorView: FC = () =>
const [ importExportVisible, setImportExportVisible ] = useState(false);
const [ liveSync, setLiveSync ] = useState(true);
const [ panMode, setPanMode ] = useState(false);
const [ autoPickup, setAutoPickup ] = useState(false);
const { state, dispatch, loadFromServer, undo, redo, canUndo, canRedo } = useFloorplanReducer();
const originalRef = useRef<{
tilemap: string;
@@ -41,7 +42,7 @@ export const FloorplanEditorView: FC = () =>
const area = useMemo(() => areaCount(state.tiles), [ state.tiles ]);
const { setBaseline, revert: revertLivePreview } = useFloorplanLiveSync({ enabled: liveSync && isVisible, state });
const { setBaseline, mergeBaseline, revert: revertLivePreview } = useFloorplanLiveSync({ enabled: liveSync && isVisible, state });
useNitroEvent<RoomEngineEvent>(RoomEngineEvent.DISPOSED, () => setIsVisible(false));
@@ -49,8 +50,16 @@ export const FloorplanEditorView: FC = () =>
{
if(!isVisible) return;
SendMessageComposer(new GetRoomEntryTileMessageComposer());
// Ask the server which tiles currently hold furniture so they can be
// shown (and protected from editing) in the grid.
SendMessageComposer(new GetOccupiedTilesMessageComposer());
}, [ isVisible ]);
useMessageEvent<RoomOccupiedTilesMessageEvent>(RoomOccupiedTilesMessageEvent, event =>
{
dispatch({ type: 'SET_OCCUPIED_TILES', map: event.getParser().blockedTilesMap });
});
useMessageEvent<RoomEntryTileMessageEvent>(RoomEntryTileMessageEvent, event =>
{
const parser = event.getParser();
@@ -64,6 +73,7 @@ export const FloorplanEditorView: FC = () =>
};
dispatch({ type: 'SET_DOOR', x: parser.x, y: parser.y, source: 'remote' });
dispatch({ type: 'SET_DOOR_DIR', dir: ((parser.direction | 0) & 7) as EntryDir, source: 'remote' });
mergeBaseline({ doorX: parser.x, doorY: parser.y, doorDir: (parser.direction | 0) & 7 });
});
useMessageEvent<FloorHeightMapEvent>(FloorHeightMapEvent, event =>
@@ -110,6 +120,7 @@ export const FloorplanEditorView: FC = () =>
wallHeight: originalRef.current?.wallHeight ?? -1
};
dispatch({ type: 'SET_THICKNESS', wall, floor, source: 'remote' });
mergeBaseline({ thicknessWall: wall, thicknessFloor: floor });
});
useEffect(() =>
@@ -173,7 +184,8 @@ export const FloorplanEditorView: FC = () =>
state.door.dir,
convertNumbersForSaving(state.thickness.wall),
convertNumbersForSaving(state.thickness.floor),
state.wallHeight - 1
state.wallHeight - 1,
autoPickup
));
};
@@ -224,7 +236,17 @@ export const FloorplanEditorView: FC = () =>
<Flex
alignItems="center"
gap={ 1 }
className={ `ml-auto border rounded px-2 py-1 cursor-pointer select-none ${ liveSync ? 'bg-emerald-500/15 border-emerald-500 text-emerald-700' : 'border-zinc-400 text-zinc-600' }` }
className={ `ml-auto border rounded px-2 py-1 cursor-pointer select-none ${ autoPickup ? 'bg-amber-500/15 border-amber-500 text-amber-700' : 'border-zinc-400 text-zinc-600' }` }
onClick={ () => setAutoPickup(v => !v) }
title="On save: pick up furniture blocking the new floor plan and return it to its owner's inventory"
>
<FaBoxOpen className={ autoPickup ? 'text-amber-600' : 'text-zinc-500' } />
<Text bold small>{ autoPickup ? 'Pick up blocking furni ON' : 'Pick up blocking furni OFF' }</Text>
</Flex>
<Flex
alignItems="center"
gap={ 1 }
className={ `border rounded px-2 py-1 cursor-pointer select-none ${ liveSync ? 'bg-emerald-500/15 border-emerald-500 text-emerald-700' : 'border-zinc-400 text-zinc-600' }` }
onClick={ () => setLiveSync(v => !v) }
title="Local in-room preview while drawing (does not save to server)"
>
@@ -256,7 +278,8 @@ export const FloorplanEditorView: FC = () =>
state.door.dir,
convertNumbersForSaving(state.thickness.wall),
convertNumbersForSaving(state.thickness.floor),
state.wallHeight - 1
state.wallHeight - 1,
autoPickup
));
} }
onRevertText={ () => originalRef.current?.tilemap ?? serializeTilemap(state.tiles) }
@@ -106,6 +106,36 @@ describe('reducer — ADJUST_HEIGHT', () =>
const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: 1, source: 'local' });
expect(next).toBe(start);
});
it('is a no-op on occupied tiles', () =>
{
const start = stateWith([[{ h: 5, blocked: false, occupied: true }]]);
const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: 1, source: 'local' });
expect(next).toBe(start);
});
});
describe('reducer — SET_OCCUPIED_TILES', () =>
{
it('marks tiles occupied per the map without touching h or blocked', () =>
{
const start = stateWith([[{ h: 2, blocked: false }, { h: 0, blocked: true }]]);
const next = reducer(start, { type: 'SET_OCCUPIED_TILES', map: [[true, false]] });
expect(next.tiles[0][0]).toEqual({ h: 2, blocked: false, occupied: true });
// already-unoccupied tile is left untouched (no spurious occupied key)
expect(next.tiles[0][1]).toEqual({ h: 0, blocked: true });
});
it('does not block editing of non-occupied tiles', () =>
{
const start = stateWith([[{ h: 0, blocked: false }, { h: 0, blocked: false }]]);
const occupied = reducer(start, { type: 'SET_OCCUPIED_TILES', map: [[false, true]] });
// col 0 (not occupied) can still be painted; col 1 (occupied) cannot
const painted = reducer(occupied, { type: 'PAINT_TILE', row: 0, col: 0, h: 5, source: 'local' });
expect(painted.tiles[0][0].h).toBe(5);
const blocked = reducer(occupied, { type: 'PAINT_TILE', row: 0, col: 1, h: 9, source: 'local' });
expect(blocked).toBe(occupied);
});
});
describe('reducer — SET_DOOR', () =>
@@ -52,6 +52,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl
{
const row = clamp64(action.row);
const col = clamp64(action.col);
if(state.tiles[row]?.[col]?.occupied) return state;
const tiles = ensureRect(state.tiles, row + 1, col + 1);
const target = { h: clampHeight(action.h), blocked: false };
const next = setTile(tiles, row, col, target);
@@ -64,6 +65,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl
const col = action.col | 0;
if(row < 0 || col < 0 || row >= state.tiles.length || col >= (state.tiles[0]?.length ?? 0)) return state;
const current = state.tiles[row][col];
if(current.occupied) return state;
const target = { h: current.h, blocked: true };
const next = setTile(state.tiles, row, col, target);
if(next === state.tiles) return state;
@@ -75,7 +77,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl
const col = action.col | 0;
if(row < 0 || col < 0 || row >= state.tiles.length || col >= (state.tiles[0]?.length ?? 0)) return state;
const current = state.tiles[row][col];
if(current.blocked) return state;
if(current.blocked || current.occupied) return state;
const newH = clampHeight(current.h + action.delta);
if(newH === current.h) return state;
const next = setTile(state.tiles, row, col, { h: newH, blocked: false });
@@ -106,6 +108,22 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl
if(value === state.wallHeight) return state;
return { ...state, wallHeight: value };
}
case 'SET_OCCUPIED_TILES':
{
// Mark tiles that currently hold furniture (server-reported). Leaves
// height + blocked untouched so it never alters the saved tilemap.
const map = action.map ?? [];
let changed = false;
const tiles = state.tiles.map((r, ri) => r.map((tile, ci) =>
{
const occ = !!map[ri]?.[ci];
if((tile.occupied ?? false) === occ) return tile;
changed = true;
return { ...tile, occupied: occ };
}));
if(!changed) return state;
return { ...state, tiles };
}
case 'BRUSH_SET':
{
const h = action.h ?? state.brush.h;
@@ -174,6 +192,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl
const col = parseInt(cStr, 10);
const current = tiles[row]?.[col];
if(!current) continue;
if(current.occupied) continue;
switch(state.brush.action)
{
@@ -1,4 +1,7 @@
export type Tile = { h: number; blocked: boolean };
// `blocked` = void tile (no floor, serialized as 'x'). `occupied` = a tile that
// currently has furniture on it (reported by the server); kept separate so it
// stays visible and is NOT voided on save — it just can't be edited.
export type Tile = { h: number; blocked: boolean; occupied?: boolean };
export type EntryDir = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
export type ThicknessLevel = 0 | 1 | 2 | 3;
@@ -39,6 +42,7 @@ export type FloorplanAction =
| { type: 'SET_DOOR_DIR'; dir: EntryDir; source: LocalSource }
| { type: 'SET_THICKNESS'; wall?: ThicknessLevel; floor?: ThicknessLevel; source: LocalSource }
| { type: 'SET_WALL_HEIGHT'; value: number; source: LocalSource }
| { type: 'SET_OCCUPIED_TILES'; map: boolean[][] }
| { type: 'BRUSH_SET'; h?: number; action?: FloorActionMode }
| { type: 'SELECT_RECT'; from: [number, number]; to: [number, number] }
| { type: 'SELECT_ALL' }
@@ -40,11 +40,18 @@ describe('FloorplanCanvasSVG', () =>
const dispatch = vi.fn();
const { container } = render(<FloorplanCanvasSVG state={ state } dispatch={ dispatch } />);
const svg = container.querySelector('svg') as SVGSVGElement;
svg.getBoundingClientRect = () => ({ left: 0, top: 0, right: 2048, bottom: 1024, width: 2048, height: 1024, x: 0, y: 0, toJSON: () => ({}) });
// usePointerToTile resolves the tile via document.elementFromPoint first
// (the tile polygons carry data-row/data-col). jsdom returns null and has
// no SVGSVGElement.getScreenCTM, so point the hit-test at the tile polygon.
const tilePoly = container.querySelector('polygon[data-row="0"][data-col="0"]') as Element;
// jsdom's document has no elementFromPoint at all — define it for this test.
const prevEfp = (document as { elementFromPoint?: unknown }).elementFromPoint;
(document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }).elementFromPoint = () => tilePoly;
fireEvent.pointerDown(svg, { clientX: 1024, clientY: 0, pointerId: 1 });
expect(dispatch).toHaveBeenCalled();
const call = dispatch.mock.calls[0][0];
expect(call.type).toBe('PAINT_TILE');
(document as { elementFromPoint?: unknown }).elementFromPoint = prevEfp;
});
it('zoom in/out buttons adjust the viewBox', () =>
@@ -1,4 +1,4 @@
import { Dispatch, FC, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState, WheelEvent } from 'react';
import { Dispatch, FC, PointerEvent as ReactPointerEvent, ReactElement, useCallback, useEffect, useMemo, useRef, useState, WheelEvent } from 'react';
import { FaCrosshairs, FaSearchMinus, FaSearchPlus, FaSyncAlt } from 'react-icons/fa';
import { FloorplanAction, FloorplanState } from '../state/types';
import { FloorplanTile } from './FloorplanTile';
@@ -140,7 +140,7 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
const quarter = TILE_SIZE / 4;
const tilesRows = state.tiles.length;
const tilesCols = state.tiles[0]?.length ?? 0;
const out: JSX.Element[] = [];
const out: ReactElement[] = [];
for(const key of state.selection)
{
const [ rStr, cStr ] = key.split(',');
@@ -104,6 +104,17 @@ const FloorplanTileImpl: FC<Props> = ({ row, col, tile, selected, isDoor, southH
stroke="#222"
strokeWidth={ 0.5 }
/>
{ tile.occupied && (
<polygon
data-testid="occupied-marker"
points={ points }
fill="rgba(249, 115, 22, 0.40)"
stroke="#f97316"
strokeWidth={ 1 }
strokeDasharray="2 2"
pointerEvents="none"
/>
) }
{ selected && (
<polygon
data-testid="selection-ring"
@@ -0,0 +1,175 @@
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 =>
{
// Locally-added rows carry a negative temp id; the server treats id <= 0
// as "insert a new prize", so collapse them to 0 on the wire.
const base = { id: row.id > 0 ? row.id : 0, 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));
const removeRow = (id: number) =>
setEditRows(prev => prev.filter(row => row.id !== id));
const addRow = () =>
setEditRows(prev =>
{
// New rows get a decreasing negative temp id so React keys stay
// stable and updateRow/removeRow keep matching before the save
// round-trips real ids back from the server.
const tempId = Math.min(0, ...prev.map(row => row.id)) - 1;
return [ ...prev, { id: tempId, category: 'item', num: 0, weight: 1, label: '' } ];
});
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>
<span className="w-6" />
</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]" />
<button
type="button"
title={ LocalizeText('rarevalues.editor.remove') }
onClick={ () => removeRow(row.id) }
className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded bg-[#d9534f] font-bold leading-none text-white hover:bg-[#c44440]">×</button>
</Flex>
)) }
{ !editRows.length &&
<Text small className="text-black/50">{ LocalizeText('wheel.settings.empty') }</Text> }
</Column>
<button
type="button"
disabled={ editRows.length >= 64 }
onClick={ addRow }
className="cursor-pointer rounded border border-dashed border-[#3a7bb5] px-4 py-1.5 text-sm font-bold text-[#3a7bb5] hover:bg-[#3a7bb5]/10 disabled:cursor-default disabled:opacity-40">
{ LocalizeText('rarevalues.editor.add') }
</button>
<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>
);
};
@@ -0,0 +1,299 @@
import { AddLinkEventTracker, ILinkEventTracker, IWheelPrize, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, TransitionEvent, useEffect, useMemo, useRef, useState } from 'react';
import { LocalizeText } from '../../api';
import { Column, Flex, LayoutAvatarImageView, LayoutCurrencyIcon, Text } from '../../common';
import { useFortuneWheel, useHasPermission } from '../../hooks';
import { NitroCard } from '../../layout';
import { FortuneWheelSettingsView } from './FortuneWheelSettingsView';
import { WheelWinReveal } from './WheelWinReveal';
import { renderPrizeIcon } from './wheelPrizeIcon';
// Stock UI palette (white / light-blue / grey / black). Exposed as CSS custom
// properties so a runtime theme can recolor the wheel without changing defaults
// (the fallback values keep the stock look when no theme overrides them).
const SLICE_COLORS = [ 'var(--wheel-slice-1, #eef2f5)', 'var(--wheel-slice-2, #c3dcec)' ];
const RIM = 'var(--wheel-rim, #4c606c)';
const DIVIDER = 'var(--wheel-divider, rgba(76,96,108,0.3))';
const HUB = 'var(--wheel-hub, #eef2f5)';
const WHEEL_SIZE = 420;
const ICON_RADIUS = 150;
const FULL_TURNS = 5;
// Spin motion (wind-back → fast spin past target → settle back).
const WINDBACK_DEG = 14;
const OVERSHOOT_DEG = 16;
const WINDBACK_MS = 250;
const SPIN_MS = 4000;
const SETTLE_MS = 550;
type SpinPhase = 'idle' | 'windback' | 'spin' | 'settle';
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 [ phase, setPhase ] = useState<SpinPhase>('idle');
const [ revealPrize, setRevealPrize ] = useState<IWheelPrize | null>(null);
const [ wheelScale, setWheelScale ] = useState(1);
const rotationRef = useRef(0);
const targetRef = useRef(0);
const phaseRef = useRef<SpinPhase>('idle');
const wonPrizeRef = useRef<IWheelPrize | null>(null);
const prizesRef = useRef<IWheelPrize[]>([]);
const wheelHostRef = useRef<HTMLDivElement>(null);
prizesRef.current = prizes;
const reducedMotion = useMemo(() => (typeof window !== 'undefined') && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches, []);
const setSpinPhase = (next: SpinPhase) =>
{
phaseRef.current = next;
setPhase(next);
};
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(prev => !prev); return;
}
},
eventUrlPrefix: 'fortune-wheel/',
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
useEffect(() =>
{
if(isVisible) open();
}, [ isVisible, open ]);
// Keep the wheel fitting its container on narrow viewports without
// rewriting the px-based slice/icon math: measure the available width
// and scale the whole wheel down to fit.
useEffect(() =>
{
const host = wheelHostRef.current;
if(!host || (typeof ResizeObserver === 'undefined')) return;
const observer = new ResizeObserver(entries =>
{
const width = entries[0]?.contentRect.width ?? WHEEL_SIZE;
setWheelScale(Math.min(1, width / WHEEL_SIZE));
});
observer.observe(host);
return () => observer.disconnect();
}, [ isVisible ]);
// Drive the spin animation when the server reports the winning slice.
useEffect(() =>
{
if(pendingPrizeId < 0) return;
const list = prizesRef.current;
const idx = list.findIndex(prize => prize.id === pendingPrizeId);
if(!list.length || (idx < 0))
{
finishSpin();
return;
}
wonPrizeRef.current = list[idx];
const sliceAngle = 360 / list.length;
const centerAngle = ((idx + 0.5) * sliceAngle);
const current = rotationRef.current;
const target = (current - (current % 360)) + (FULL_TURNS * 360) + (360 - centerAngle);
targetRef.current = target;
if(reducedMotion)
{
// Single straightforward move to the target, no flourish.
setSpinPhase('spin');
rotationRef.current = target;
setRotation(target);
return;
}
// Phase 1: tiny anticipation wind-back before the spin.
setSpinPhase('windback');
const back = current - WINDBACK_DEG;
rotationRef.current = back;
setRotation(back);
}, [ pendingPrizeId, finishSpin, reducedMotion ]);
const finishReveal = () =>
{
setSpinPhase('idle');
setRevealPrize(wonPrizeRef.current);
finishSpin();
};
const handleTransitionEnd = (event: TransitionEvent<HTMLDivElement>) =>
{
// Only react to the wheel's own transform transition finishing. Child
// elements (prize icons, badges) can emit their own bubbling
// transitionend events; without this guard they'd advance the spin
// phase machine early and reveal the prize before the wheel stops.
if((event.target !== event.currentTarget) || (event.propertyName !== 'transform')) return;
switch(phaseRef.current)
{
case 'windback':
// Phase 2: spin fast, overshooting the target slightly.
setSpinPhase('spin');
rotationRef.current = targetRef.current + OVERSHOOT_DEG;
setRotation(rotationRef.current);
return;
case 'spin':
if(reducedMotion)
{
finishReveal();
return;
}
// Phase 3: settle back from the overshoot onto the target.
setSpinPhase('settle');
rotationRef.current = targetRef.current;
setRotation(rotationRef.current);
return;
case 'settle':
finishReveal();
return;
}
};
const sliceAngle = prizes.length ? (360 / prizes.length) : 0;
const background = useMemo(() =>
{
if(!prizes.length) return SLICE_COLORS[0];
const stops = prizes.map((_, i) => `${ SLICE_COLORS[i % 2] } ${ i * sliceAngle }deg ${ (i + 1) * sliceAngle }deg`).join(', ');
return `conic-gradient(${ stops })`;
}, [ prizes, sliceAngle ]);
const wheelTransition = useMemo(() =>
{
switch(phase)
{
case 'windback': return `transform ${ WINDBACK_MS }ms ease-in`;
case 'spin': return `transform ${ SPIN_MS }ms cubic-bezier(0.12,0.78,0.2,1)`;
case 'settle': return `transform ${ SETTLE_MS }ms ease-out`;
default: return 'none';
}
}, [ phase ]);
if(!isVisible) return null;
const canSpin = ((freeSpins + extraSpins) > 0) && !isSpinning && (prizes.length > 0);
return (
<NitroCard className="wheel-card w-[780px] max-w-[96vw]" uniqueKey="fortune-wheel">
<NitroCard.Header headerText={ LocalizeText('wheel.title') } onCloseClick={ () => setIsVisible(false) } />
<NitroCard.Content>
<div className="relative">
<Flex gap={ 3 } className="flex-col sm:flex-row">
<Column alignItems="center" gap={ 2 } className="w-full shrink-0 sm:w-[420px]">
<div ref={ wheelHostRef } className="relative w-full" style={ { height: WHEEL_SIZE * wheelScale } }>
<div
className="absolute left-1/2 top-0"
style={ { width: WHEEL_SIZE, height: WHEEL_SIZE, transform: `translateX(-50%) scale(${ wheelScale })`, transformOrigin: 'top center' } }>
<div
className="absolute left-1/2 -top-2 z-20 -translate-x-1/2 drop-shadow-[0_2px_2px_rgba(0,0,0,0.25)]"
style={ { width: 0, height: 0, borderLeft: '14px solid transparent', borderRight: '14px solid transparent', borderTop: `28px solid ${ RIM }` } } />
<div
className="absolute inset-0 rounded-full shadow-[0_0_0_4px_rgba(0,0,0,0.12),inset_0_0_18px_rgba(0,0,0,0.1)]"
style={ { background, border: `8px solid ${ RIM }`, transform: `rotate(${ rotation }deg)`, transition: wheelTransition } }
onTransitionEnd={ handleTransitionEnd }>
{ prizes.map((_, i) => (
<div
key={ `divider-${ i }` }
className="absolute bottom-1/2 left-1/2 origin-bottom"
style={ { width: '2px', height: `${ WHEEL_SIZE / 2 }px`, transform: `translateX(-1px) rotate(${ i * sliceAngle }deg)`, background: DIVIDER } } />
)) }
{ prizes.map((prize, i) =>
{
const centerAngle = ((i + 0.5) * sliceAngle);
return (
<div
key={ prize.id }
className="absolute left-1/2 top-1/2"
style={ { transform: `rotate(${ centerAngle }deg) translateY(-${ ICON_RADIUS }px) rotate(-${ centerAngle }deg)` } }>
<div className="wheel-slice-icon -translate-x-1/2 -translate-y-1/2">
{ renderPrizeIcon(prize) }
</div>
</div>);
}) }
</div>
<div className="absolute left-1/2 top-1/2 z-10 h-14 w-14 -translate-x-1/2 -translate-y-1/2 rounded-full shadow-[0_0_8px_rgba(0,0,0,0.25)]" style={ { border: `4px solid ${ RIM }`, background: HUB } } />
</div>
</div>
<Text bold className="text-[#2f6f95]">{ LocalizeText('wheel.free.today', [ 'count' ], [ freeSpins.toString() ]) }</Text>
<Text small className="text-[#33424c]">{ LocalizeText('wheel.extra', [ 'count' ], [ extraSpins.toString() ]) }</Text>
<Flex gap={ 2 } alignItems="center" className="flex-wrap justify-center">
<button
disabled={ !canSpin }
onClick={ () => spin() }
className="cursor-pointer rounded bg-[#3a7bb5] px-4 py-2 font-bold text-white hover:bg-[#336ea3] disabled:cursor-default disabled:opacity-40">
{ LocalizeText('wheel.spin') }
</button>
<button
onClick={ () => buySpin() }
className="flex cursor-pointer items-center gap-1 rounded bg-[#6b7884] px-3 py-2 text-white hover:bg-[#5e6a75]">
{ 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">
<Text bold className="text-base text-[#33424c]">{ LocalizeText('wheel.winners') }</Text>
<Column gap={ 1 } overflow="auto" className="h-[440px] max-h-[60vh]">
{ recentWins.map((win, i) => (
<Flex key={ i } alignItems="center" gap={ 2 } className="rounded border-b border-black/10 py-1.5 hover:bg-black/5">
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded bg-black/5">
<LayoutAvatarImageView figure={ win.look } headOnly direction={ 2 } style={ { backgroundSize: 'auto', backgroundPosition: '-22px -32px' } } />
</div>
<Column gap={ 0 } className="min-w-0">
<Text bold truncate className="text-[#1f2d34]">{ win.username }</Text>
<Text small truncate className="text-[#2f6f95]">{ win.prizeLabel }</Text>
</Column>
</Flex>
)) }
{ !recentWins.length &&
<Text small className="text-black/50">{ LocalizeText('wheel.winners.empty') }</Text> }
</Column>
</Column>
</Flex>
{ revealPrize &&
<WheelWinReveal prize={ revealPrize } onDismiss={ () => setRevealPrize(null) } /> }
</div>
</NitroCard.Content>
{ canManage && isSettingsOpen &&
<FortuneWheelSettingsView onClose={ () => setIsSettingsOpen(false) } /> }
</NitroCard>
);
};
@@ -0,0 +1,101 @@
import { IWheelPrize } from '@nitrots/nitro-renderer';
import { FC, useEffect } from 'react';
import { LocalizeText } from '../../api';
import { renderPrizeIcon } from './wheelPrizeIcon';
import { getPrizeTier } from './wheelPrizeTier';
interface WheelWinRevealProps
{
prize: IWheelPrize;
onDismiss: () => void;
}
const CONFETTI_COLORS = [ '#ffd34d', '#4fc3f7', '#ff7b7b', '#7bff9e', '#c08bff', '#ffa94d' ];
const CONFETTI_COUNT = 40;
// Precomputed once at module load (not during render) so the React Compiler
// purity rules stay happy and there's no per-mount cost. A fixed spread of
// 40 pieces with staggered delays reads as a lively burst either way.
const CONFETTI = Array.from({ length: CONFETTI_COUNT }, (_, i) => ({
left: Math.random() * 100,
delay: Math.random() * 0.5,
duration: 1.8 + (Math.random() * 1.4),
drift: (Math.random() - 0.5) * 160,
color: CONFETTI_COLORS[i % CONFETTI_COLORS.length],
width: 6 + Math.round(Math.random() * 4)
}));
// How long each tier lingers before auto-dismissing (ms).
const AUTO_DISMISS = { none: 2000, common: 2400, rare: 4000 } as const;
export const WheelWinReveal: FC<WheelWinRevealProps> = ({ prize, onDismiss }) =>
{
const tier = getPrizeTier(prize);
useEffect(() =>
{
const timer = window.setTimeout(onDismiss, AUTO_DISMISS[tier]);
return () => window.clearTimeout(timer);
}, [ tier, onDismiss ]);
// The "nothing" slice gets a quiet, non-celebratory message.
if(tier === 'none')
{
return (
<div className="absolute inset-0 z-40 flex items-center justify-center bg-black/35" onClick={ onDismiss }>
<div className="rounded-xl bg-white px-6 py-4 text-center shadow-2xl" style={ { animation: 'wheelPop .45s cubic-bezier(.18,.89,.32,1.28)' } }>
<div className="text-3xl">🍀</div>
<div className="mt-1 font-bold text-[#33424c]">{ LocalizeText('wheel.win.nothing') }</div>
</div>
</div>
);
}
const isRare = tier === 'rare';
return (
<div
className={ `absolute inset-0 z-40 flex flex-col items-center justify-center gap-2 ${ isRare ? 'bg-black/70' : 'bg-black/40' }` }
onClick={ onDismiss }>
<style>{ `
@keyframes wheelPop { from { transform: scale(.35); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@keyframes wheelConfettiFall {
0% { transform: translate(0, -10%) rotate(0deg); opacity: 1; }
100% { transform: translate(var(--drift), 320px) rotate(720deg); opacity: 0; }
}
@keyframes wheelGlow {
0%,100% { box-shadow: 0 0 18px 4px rgba(255,211,77,.55); }
50% { box-shadow: 0 0 30px 10px rgba(255,211,77,.9); }
}
` }</style>
{ isRare && CONFETTI.map((piece, i) => (
<span
key={ i }
className="pointer-events-none absolute top-0"
style={ {
left: `${ piece.left }%`,
width: `${ piece.width }px`,
height: `${ piece.width + 4 }px`,
background: piece.color,
borderRadius: '2px',
['--drift' as any]: `${ piece.drift }px`,
animation: `wheelConfettiFall ${ piece.duration }s ${ piece.delay }s linear forwards`
} } />
)) }
{ isRare &&
<div className="text-sm font-black uppercase tracking-[0.2em] text-[#ffd34d] drop-shadow">{ LocalizeText('wheel.win.jackpot') }</div> }
<div
className="flex h-32 w-32 items-center justify-center rounded-full bg-white shadow-2xl"
style={ { animation: `wheelPop .5s cubic-bezier(.18,.89,.32,1.28)${ isRare ? ', wheelGlow 1.4s ease-in-out .5s infinite' : '' }` } }>
{ renderPrizeIcon(prize, true) }
</div>
<div className="mt-1 text-lg font-black text-white drop-shadow">{ LocalizeText('wheel.win.title') }</div>
{ !!prize.label &&
<div className="rounded-full bg-white/95 px-4 py-1 text-sm font-bold text-[#20313a] shadow">{ prize.label }</div> }
</div>
);
};
@@ -0,0 +1,36 @@
import { GetRoomEngine, IWheelPrize } from '@nitrots/nitro-renderer';
import { ReactNode } from 'react';
import { Column, LayoutBadgeImageView, LayoutCurrencyIcon, LayoutImage } from '../../common';
// Shared prize-icon renderer used both on the wheel slices (small) and in the
// win-reveal overlay (large). Keeping it in one place means the two stay
// visually consistent when prize types change.
export const renderPrizeIcon = (prize: IWheelPrize, large = false): ReactNode =>
{
const imageClass = large ? 'h-20 w-20' : 'h-9 w-9';
const amountClass = large ? 'text-xl font-bold text-[#2a3a42]' : 'text-[10px] font-bold text-[#2a3a42]';
switch(prize.type)
{
case 'item':
return <LayoutImage imageUrl={ GetRoomEngine().getFurnitureFloorIconUrl(prize.spriteId) } className={ `${ imageClass } bg-contain bg-center bg-no-repeat` } />;
case 'badge':
return <div className={ large ? 'scale-[1.8]' : '' }><LayoutBadgeImageView badgeCode={ prize.badgeCode } /></div>;
case 'credits':
return (
<Column alignItems="center" gap={ 0 }>
<div className={ large ? 'scale-150' : '' }><LayoutCurrencyIcon type={ -1 } /></div>
<span className={ amountClass }>{ prize.amount }</span>
</Column>);
case 'points':
return (
<Column alignItems="center" gap={ 0 }>
<div className={ large ? 'scale-150' : '' }><LayoutCurrencyIcon type={ prize.pointsType } /></div>
<span className={ amountClass }>{ prize.amount }</span>
</Column>);
case 'spin':
return <span className={ large ? 'text-2xl font-bold text-[#2a3a42]' : 'text-xs font-bold text-[#2a3a42]' }>+{ prize.amount }</span>;
default:
return <span className={ large ? 'text-2xl font-bold text-[#2a3a42]/60' : 'text-xs font-bold text-[#2a3a42]/60' }></span>;
}
};
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { CREDITS_RARE_THRESHOLD, getPrizeTier, POINTS_RARE_THRESHOLD } from './wheelPrizeTier';
const makePrize = (overrides: Partial<{ type: string; amount: number }>) => ({
id: 1,
type: 'credits',
spriteId: 0,
badgeCode: '',
amount: 0,
pointsType: -1,
label: '',
...overrides
}) as any;
describe('getPrizeTier', () =>
{
it('returns "common" for a null prize (defensive default)', () =>
{
expect(getPrizeTier(null)).toBe('common');
});
it('classifies the "nothing" slice as "none"', () =>
{
expect(getPrizeTier(makePrize({ type: 'nothing' }))).toBe('none');
});
it('classifies items and badges as "rare"', () =>
{
expect(getPrizeTier(makePrize({ type: 'item' }))).toBe('rare');
expect(getPrizeTier(makePrize({ type: 'badge' }))).toBe('rare');
});
it('classifies a free spin as "common"', () =>
{
expect(getPrizeTier(makePrize({ type: 'spin', amount: 1 }))).toBe('common');
});
it('tiers credits by the threshold', () =>
{
expect(getPrizeTier(makePrize({ type: 'credits', amount: CREDITS_RARE_THRESHOLD - 1 }))).toBe('common');
expect(getPrizeTier(makePrize({ type: 'credits', amount: CREDITS_RARE_THRESHOLD }))).toBe('rare');
expect(getPrizeTier(makePrize({ type: 'credits', amount: CREDITS_RARE_THRESHOLD + 1000 }))).toBe('rare');
});
it('tiers points by the threshold', () =>
{
expect(getPrizeTier(makePrize({ type: 'points', amount: POINTS_RARE_THRESHOLD - 1 }))).toBe('common');
expect(getPrizeTier(makePrize({ type: 'points', amount: POINTS_RARE_THRESHOLD }))).toBe('rare');
});
it('falls back to "common" for unknown prize types', () =>
{
expect(getPrizeTier(makePrize({ type: 'mystery-future-type' }))).toBe('common');
});
});
@@ -0,0 +1,41 @@
import { IWheelPrize } from '@nitrots/nitro-renderer';
// Group A is client-only: the player-facing prize payload (WheelDataEvent /
// IWheelPrize) carries no `weight`, so we can't read the server's real spin
// odds here. We approximate rarity from the data the client already has —
// the prize `type` and `amount`. A future cross-component change (Group B)
// can pass the true weight through and replace this heuristic.
//
// none → the "lose" slice. Quiet message, no celebration.
// common → low-value wins (a free spin, a small credit/point payout).
// Light reveal: prize card pops in, no confetti.
// rare → items, badges, or large currency payouts. Full celebration
// overlay with confetti.
export type WheelPrizeTier = 'none' | 'common' | 'rare';
// Currency payouts at or above these amounts are treated as "rare". These are
// deliberately conservative defaults; tune per hotel if needed.
export const CREDITS_RARE_THRESHOLD = 500;
export const POINTS_RARE_THRESHOLD = 100;
export const getPrizeTier = (prize: IWheelPrize | null): WheelPrizeTier =>
{
if(!prize) return 'common';
switch(prize.type)
{
case 'nothing':
return 'none';
case 'item':
case 'badge':
return 'rare';
case 'spin':
return 'common';
case 'credits':
return prize.amount >= CREDITS_RARE_THRESHOLD ? 'rare' : 'common';
case 'points':
return prize.amount >= POINTS_RARE_THRESHOLD ? 'rare' : 'common';
default:
return 'common';
}
};
@@ -45,7 +45,7 @@ export const GroupBadgeCreatorView: FC<GroupBadgeCreatorViewProps> = props =>
const getAvailableSymbols = () =>
{
const symbols = groupCustomize.badgeSymbols || [];
const symbols = groupCustomize?.badgeSymbols || [];
if(selectedIndex < 0) return symbols;
@@ -69,7 +69,7 @@ export const GroupBadgeCreatorView: FC<GroupBadgeCreatorViewProps> = props =>
}
};
if(!badgeParts || !badgeParts.length) return null;
if(!groupCustomize || !badgeParts || !badgeParts.length) return null;
return (
<>
@@ -1,4 +1,4 @@
import { GroupBuyComposer, GroupBuyDataComposer, GroupBuyDataEvent } from '@nitrots/nitro-renderer';
import { GroupBadgePartsComposer, GroupBuyComposer, GroupBuyDataComposer, GroupBuyDataEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { HasHabboClub, IGroupData, LocalizeText, SendMessageComposer } from '../../../api';
import { Button, Column, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../common';
@@ -119,6 +119,7 @@ export const GroupCreatorView: FC<GroupCreatorViewProps> = props =>
});
SendMessageComposer(new GroupBuyDataComposer());
SendMessageComposer(new GroupBadgePartsComposer());
}, [ setGroupData ]);
if(!groupData) return null;
@@ -131,7 +132,7 @@ export const GroupCreatorView: FC<GroupCreatorViewProps> = props =>
{ TABS.map((tab, index) =>
{
return (
<Flex key={ index } center className={ `relative -ml-[6px] bg-[url('@/assets/images/groups/creator_tabs.png')] bg-no-repeat ${ ((tab === 1) ? 'w-[84px] h-[24px] bg-position-[0px_0px]' : (tab === 4) ? 'w-[133px] h-[28px] bg-position-[0px_-104px]' : 'w-[83px] h-[24px] bg-position-[0px_-52px]') } ${ (currentTab === tab) ? 'active' : '' }` }>
<Flex key={ index } center className={ `relative -ml-[6px] bg-[url('@/assets/images/groups/creator_tabs.png')] bg-no-repeat transition-[transform,filter,opacity] duration-150 ${ ((tab === 1) ? 'w-[84px] h-[24px] bg-position-[0px_0px]' : (tab === 4) ? 'w-[133px] h-[28px] bg-position-[0px_-104px]' : 'w-[83px] h-[24px] bg-position-[0px_-52px]') } ${ (currentTab === tab) ? 'active z-[1] scale-[1.05] brightness-110 saturate-150 drop-shadow-[0_1px_3px_rgba(0,0,0,0.4)]' : 'opacity-60 saturate-50' }` }>
<Text variant="white">{ LocalizeText(`group.create.steplabel.${ tab }`) }</Text>
</Flex>
);
@@ -68,7 +68,9 @@ export const GroupTabBadgeView: FC<GroupTabBadgeViewProps> = props =>
useEffect(() =>
{
if(groupData.groupBadgeParts) return;
if(groupData.groupBadgeParts && groupData.groupBadgeParts.length) return;
if(!groupCustomize?.badgeBases?.length || !groupCustomize?.badgePartColors?.length) return;
const badgeParts = [
new GroupBadgePart(GroupBadgePart.BASE, groupCustomize.badgeBases[0].id, groupCustomize.badgePartColors[0].id),
@@ -20,9 +20,12 @@ export const GroupTabColorsView: FC<GroupTabColorsViewProps> = props =>
const getGroupColor = (colorIndex: number) =>
{
if(colorIndex === 0) return groupCustomize.groupColorsA.find(color => (color.id === colors[colorIndex])).color;
if(!groupCustomize || !colors) return '000000';
return groupCustomize.groupColorsB.find(color => (color.id === colors[colorIndex])).color;
const list = (colorIndex === 0) ? groupCustomize.groupColorsA : groupCustomize.groupColorsB;
const found = list?.find(color => (color.id === colors[colorIndex]));
return found ? found.color : '000000';
};
const selectColor = (colorIndex: number, colorId: number) =>
@@ -64,7 +67,7 @@ export const GroupTabColorsView: FC<GroupTabColorsViewProps> = props =>
useEffect(() =>
{
if(!groupCustomize.groupColorsA || !groupCustomize.groupColorsB || groupData.groupColors) return;
if(!groupCustomize?.groupColorsA?.length || !groupCustomize?.groupColorsB?.length || (groupData.groupColors && groupData.groupColors.length)) return;
const groupColors = [ groupCustomize.groupColorsA[0].id, groupCustomize.groupColorsB[0].id ];
@@ -108,7 +111,7 @@ export const GroupTabColorsView: FC<GroupTabColorsViewProps> = props =>
<Column gap={ 1 } overflow="hidden" size={ 5 }>
<Text bold>{ LocalizeText('group.edit.color.primary.color') }</Text>
<AutoGrid columnCount={ 7 } columnMinHeight={ 16 } columnMinWidth={ 16 } gap={ 1 }>
{ groupData.groupColors && groupCustomize.groupColorsA && groupCustomize.groupColorsA.map((item, index) =>
{ groupData.groupColors && groupCustomize?.groupColorsA && groupCustomize.groupColorsA.map((item, index) =>
{
return <div key={ index } className={ classNames('relative rounded-[.25rem] w-[16px] h-[16px] bg-[#fff] border-2 border-[solid] border-[#fff] [box-shadow:inset_3px_3px_#0000001a] [box-shadow:inset_2px_2px_#0003] cursor-pointer', ((groupData.groupColors[0] === item.id) && 'bg-primary [box-shadow:none]')) } style={ { backgroundColor: '#' + item.color } } onClick={ () => selectColor(0, item.id) }></div>;
}) }
@@ -117,7 +120,7 @@ export const GroupTabColorsView: FC<GroupTabColorsViewProps> = props =>
<Column gap={ 1 } overflow="hidden" size={ 5 }>
<Text bold>{ LocalizeText('group.edit.color.secondary.color') }</Text>
<AutoGrid columnCount={ 7 } columnMinHeight={ 16 } columnMinWidth={ 16 } gap={ 1 }>
{ groupData.groupColors && groupCustomize.groupColorsB && groupCustomize.groupColorsB.map((item, index) =>
{ groupData.groupColors && groupCustomize?.groupColorsB && groupCustomize.groupColorsB.map((item, index) =>
{
return <div key={ index } className={ classNames('relative rounded-[.25rem] w-[16px] h-[16px] bg-[#fff] border-2 border-[solid] border-[#fff] [box-shadow:inset_3px_3px_#0000001a] [box-shadow:inset_2px_2px_#0003] cursor-pointer', ((groupData.groupColors[1] === item.id) && 'bg-primary [box-shadow:none]')) } style={ { backgroundColor: '#' + item.color } } onClick={ () => selectColor(1, item.id) }></div>;
}) }
+24 -6
View File
@@ -1,5 +1,6 @@
import { AddLinkEventTracker, BadgePointLimitsEvent, GetLocalizationManager, GetRoomEngine, ILinkEventTracker, IRoomSession, RemoveLinkEventTracker, RoomEngineObjectEvent, RoomEngineObjectPlacedEvent, RoomPreviewer, RoomSessionEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FC, ReactNode, useEffect, useState } from 'react';
import { FaAward, FaCouch, FaPaw, FaRobot, FaTag } from 'react-icons/fa';
import { GroupItem, LocalizeText, UnseenItemCategory, isObjectMoverRequested, setObjectMoverRequested } from '../../api';
import { NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
import { useInventoryBadges, useInventoryFurni, useInventoryPrefixes, useInventoryTrade, useInventoryUnseenTracker, useMessageEvent, useNitroEvent } from '../../hooks';
@@ -18,7 +19,21 @@ const TAB_PETS: string = 'inventory.furni.tab.pets';
const TAB_BADGES: string = 'inventory.badges';
const TAB_PREFIXES: string = 'inventory.prefixes';
const TABS = [ TAB_FURNITURE, TAB_PETS, TAB_BADGES, TAB_PREFIXES, TAB_BOTS ];
// Maps an optional link code (inventory/show/<code>) to a tab so other views
// (e.g. the profile "Change Badges" button) can deep-link to a specific tab.
const TAB_BY_CODE: Record<string, string> = {
furni: TAB_FURNITURE, furniture: TAB_FURNITURE,
pets: TAB_PETS, badges: TAB_BADGES,
prefixes: TAB_PREFIXES, bots: TAB_BOTS
};
const UNSEEN_CATEGORIES = [ UnseenItemCategory.FURNI, UnseenItemCategory.PET, UnseenItemCategory.BADGE, UnseenItemCategory.PREFIX, UnseenItemCategory.BOT ];
const TAB_ICONS: Record<string, ReactNode> = {
[TAB_FURNITURE]: <FaCouch />,
[TAB_PETS]: <FaPaw />,
[TAB_BADGES]: <FaAward />,
[TAB_PREFIXES]: <FaTag />,
[TAB_BOTS]: <FaRobot />
};
export const InventoryView: FC<{}> = props =>
{
@@ -86,12 +101,14 @@ export const InventoryView: FC<{}> = props =>
{
case 'show':
setIsVisible(true);
if(parts[2] && TAB_BY_CODE[parts[2]]) setCurrentTab(TAB_BY_CODE[parts[2]]);
return;
case 'hide':
setIsVisible(false);
return;
case 'toggle':
setIsVisible(prevValue => !prevValue);
if(parts[2] && TAB_BY_CODE[parts[2]]) setCurrentTab(TAB_BY_CODE[parts[2]]);
return;
}
},
@@ -129,13 +146,13 @@ export const InventoryView: FC<{}> = props =>
return (
<>
<NitroCardView className="w-[528px] h-[420px] min-w-[528px] min-h-[420px]" uniqueKey="inventory">
<NitroCardView className="nitro-inventory-window w-[528px] h-[420px] min-w-[528px] min-h-[420px]" uniqueKey="inventory">
<NitroCardHeaderView
headerText={ LocalizeText('inventory.title') }
onCloseClick={ onClose } />
{ !isTrading &&
<>
<NitroCardTabsView>
<NitroCardTabsView classNames={ [ 'nitro-inventory-tabs-shell' ] }>
{ TABS.map((name, index) =>
{
return (
@@ -144,12 +161,13 @@ export const InventoryView: FC<{}> = props =>
count={ getCount(UNSEEN_CATEGORIES[index]) }
isActive={ (currentTab === name) }
onClick={ event => setCurrentTab(name) }>
{ LocalizeText(name) }
<span className="nitro-inventory-tab-icon" title={ LocalizeText(name) }>{ TAB_ICONS[name] }</span>
<span className="nitro-inventory-tab-label">{ LocalizeText(name) }</span>
</NitroCardTabsItemView>
);
}) }
</NitroCardTabsView>
<div className="flex flex-col overflow-hidden bg-[#DFDFDF] p-2 h-full gap-2">
<div className="nitro-inventory-body flex flex-col overflow-hidden p-2 h-full gap-2">
{ showFilter &&
<InventoryCategoryFilterView
badgeCodes={ badgeCodes }
@@ -175,7 +193,7 @@ export const InventoryView: FC<{}> = props =>
</div>
</> }
{ isTrading &&
<div className="flex flex-col overflow-hidden bg-[#DFDFDF] p-2 h-full">
<div className="nitro-inventory-body flex flex-col overflow-hidden p-2 h-full">
<InventoryTradeView cancelTrade={ onClose } />
</div> }
</NitroCardView>
@@ -87,7 +87,7 @@ export const InventoryCategoryFilterView: FC<InventoryCategoryFilterViewProps> =
return (
<div
className="flex gap-1 rounded p-1 bg-[#C9C9C9] shrink-0"
className="nitro-inventory-filter-bar flex gap-1 rounded p-1 shrink-0"
style={ { width: currentTab === TAB_BADGES ? '320px' : '100%' } }>
<div className="relative flex flex-1 items-center">
<NitroInput
@@ -38,8 +38,8 @@ export const InventoryBotItemView: FC<PropsWithChildren<{
};
return (
<InfiniteGrid.Item itemActive={ (selectedBot === botItem) } itemUnseen={ unseen } onDoubleClick={ onMouseEvent } onMouseDown={ onMouseEvent } onMouseOut={ onMouseEvent } onMouseUp={ onMouseEvent } { ...rest } className="*:[background-position-y:-32px]">
<LayoutAvatarImageView direction={ 3 } figure={ botItem.botData.figure } headOnly={ true } />
<InfiniteGrid.Item itemActive={ (selectedBot === botItem) } itemUnseen={ unseen } onDoubleClick={ onMouseEvent } onMouseDown={ onMouseEvent } onMouseOut={ onMouseEvent } onMouseUp={ onMouseEvent } { ...rest } className="aspect-[2/3]">
<LayoutAvatarImageView direction={ 2 } figure={ botItem.botData.figure } fit />
{ children }
</InfiniteGrid.Item>
);
@@ -68,9 +68,11 @@ export const InventoryBotView: FC<{
<div className="grid h-full grid-cols-12 gap-2">
<div className="flex flex-col col-span-7 gap-1 overflow-hidden">
<InfiniteGrid<IBotItem>
columnCount={ 6 }
columnCount={ 4 }
estimateSize={ 110 }
itemRender={ item => <InventoryBotItemView botItem={ item } /> }
items={ botItems } />
items={ botItems }
rowGap={ 4 } />
</div>
<div className="flex flex-col col-span-5">
<div className="relative flex flex-col">
@@ -80,7 +82,7 @@ export const InventoryBotView: FC<{
<div className="flex flex-col justify-between gap-2 grow">
<span className="truncate grow">{ selectedBot.botData.name }</span>
{ !!roomSession &&
<NitroButton onClick={ event => attemptBotPlacement(selectedBot) }>
<NitroButton className="nitro-inventory-btn-place" onClick={ event => attemptBotPlacement(selectedBot) }>
{ LocalizeText('inventory.furni.placetoroom') }
</NitroButton> }
</div> }
@@ -1,7 +1,7 @@
import { InfiniteGrid } from '@layout/InfiniteGrid';
import { GetRoomEngine, GetSessionDataManager, IRoomSession, RoomObjectVariable, RoomPreviewer, Vector3d } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FaTrashAlt } from 'react-icons/fa';
import { FaPowerOff, FaSyncAlt, FaTrashAlt } from 'react-icons/fa';
import { DispatchUiEvent, FurniCategory, GroupItem, LocalizeText, UnseenItemCategory, attemptItemPlacement } from '../../../../api';
import { LayoutLimitedEditionCompactPlateView, LayoutRarityLevelView, LayoutRoomPreviewerView } from '../../../../common';
import { CatalogPostMarketplaceOfferEvent, DeleteItemConfirmEvent } from '../../../../events';
@@ -49,22 +49,24 @@ export const InventoryFurnitureView: FC<{
if(!furnitureItem) return;
const roomEngine = GetRoomEngine();
let wallType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_WALL_TYPE);
let floorType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_FLOOR_TYPE);
let landscapeType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_LANDSCAPE_TYPE);
wallType = (wallType && wallType.length) ? wallType : '101';
floorType = (floorType && floorType.length) ? floorType : '101';
landscapeType = (landscapeType && landscapeType.length) ? landscapeType : '1.1';
roomPreviewer.reset(false);
roomPreviewer.updateObjectRoom(floorType, wallType, landscapeType);
roomPreviewer.updateRoomWallsAndFloorVisibility(true, true);
if((furnitureItem.category === FurniCategory.WALL_PAPER) || (furnitureItem.category === FurniCategory.FLOOR) || (furnitureItem.category === FurniCategory.LANDSCAPE))
const isRoomDecoration = (furnitureItem.category === FurniCategory.WALL_PAPER) || (furnitureItem.category === FurniCategory.FLOOR) || (furnitureItem.category === FurniCategory.LANDSCAPE);
if(isRoomDecoration)
{
const roomEngine = GetRoomEngine();
let wallType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_WALL_TYPE);
let floorType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_FLOOR_TYPE);
let landscapeType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_LANDSCAPE_TYPE);
wallType = (wallType && wallType.length) ? wallType : '101';
floorType = (floorType && floorType.length) ? floorType : '101';
landscapeType = (landscapeType && landscapeType.length) ? landscapeType : '1.1';
roomPreviewer.updateRoomWallsAndFloorVisibility(true, true);
floorType = ((furnitureItem.category === FurniCategory.FLOOR) ? selectedItem.stuffData.getLegacyString() : floorType);
wallType = ((furnitureItem.category === FurniCategory.WALL_PAPER) ? selectedItem.stuffData.getLegacyString() : wallType);
landscapeType = ((furnitureItem.category === FurniCategory.LANDSCAPE) ? selectedItem.stuffData.getLegacyString() : landscapeType);
@@ -77,17 +79,20 @@ export const InventoryFurnitureView: FC<{
if(data) roomPreviewer.addWallItemIntoRoom(data.id, new Vector3d(90, 0, 0), data.customParams);
}
return;
}
roomPreviewer.updateObjectRoom('default', 'default', 'default');
roomPreviewer.updateRoomWallsAndFloorVisibility(true, true);
if(selectedItem.isWallItem)
{
roomPreviewer.addWallItemIntoRoom(selectedItem.type, new Vector3d(90), furnitureItem.stuffData.getLegacyString());
}
else
{
if(selectedItem.isWallItem)
{
roomPreviewer.addWallItemIntoRoom(selectedItem.type, new Vector3d(90), furnitureItem.stuffData.getLegacyString());
}
else
{
roomPreviewer.addFurnitureIntoRoom(selectedItem.type, new Vector3d(90), selectedItem.stuffData, (furnitureItem.extra.toString()));
}
roomPreviewer.addFurnitureIntoRoom(selectedItem.type, new Vector3d(90), selectedItem.stuffData, (furnitureItem.extra.toString()));
}
}, [ roomPreviewer, selectedItem ]);
@@ -129,6 +134,15 @@ export const InventoryFurnitureView: FC<{
<div className="flex flex-col col-span-5">
<div className="relative flex flex-col">
<LayoutRoomPreviewerView height={ 140 } roomPreviewer={ roomPreviewer } />
{ selectedItem &&
<>
<button className="nitro-inventory-preview-btn nitro-inventory-preview-rotate" onClick={ () => roomPreviewer?.changeRoomObjectDirection() }>
<FaSyncAlt /> Rotate
</button>
<button className="nitro-inventory-preview-btn nitro-inventory-preview-state" onClick={ () => roomPreviewer?.changeRoomObjectState() }>
<FaPowerOff /> Toggle State
</button>
</> }
{ selectedItem &&
<NitroButton
className="bg-danger! hover:bg-danger/80! absolute bottom-2 inset-e-2 p-1"
@@ -147,11 +161,11 @@ export const InventoryFurnitureView: FC<{
<span className="text-xs truncate">{ selectedItem.description }</span> }
<div className="flex flex-col gap-1">
{ !!roomSession &&
<NitroButton onClick={ event => attemptItemPlacement(selectedItem) }>
<NitroButton className="nitro-inventory-btn-place" onClick={ event => attemptItemPlacement(selectedItem) }>
{ LocalizeText('inventory.furni.placetoroom') }
</NitroButton> }
{ selectedItem.isSellable &&
<NitroButton onClick={ event => attemptPlaceMarketplaceOffer(selectedItem) }>
<NitroButton className="nitro-inventory-btn-sell" onClick={ event => attemptPlaceMarketplaceOffer(selectedItem) }>
{ LocalizeText('inventory.marketplace.sell') }
</NitroButton> }
</div>
@@ -84,6 +84,8 @@ export const InventoryPetView: FC<{
<div className="flex flex-col col-span-7 gap-1 overflow-hidden">
<InfiniteGrid<IPetItem>
columnCount={ 6 }
estimateSize={ 46 }
itemMinWidth={ 46 }
itemRender={ item => <InventoryPetItemView petItem={ item } /> }
items={ petItems } />
</div>
@@ -101,7 +103,7 @@ export const InventoryPetView: FC<{
<div className="flex flex-col justify-between gap-2 grow">
<span className="text-sm truncate grow">{ selectedPet.petData.name }</span>
{ !!roomSession &&
<NitroButton onClick={ event => attemptPetPlacement(selectedPet) }>
<NitroButton className="nitro-inventory-btn-place" onClick={ event => attemptPetPlacement(selectedPet) }>
{ LocalizeText('inventory.furni.placetoroom') }
</NitroButton> }
</div> }
@@ -1,8 +0,0 @@
.button-search-saves {
padding: 4px;
height: 17px;
margin-top: -1px;
font-size: 10px;
border-radius: 4px;
background-color: #FAA700;
}
+81 -179
View File
@@ -1,289 +1,178 @@
import { NitroCard } from '@layout/NitroCard';
import { AddLinkEventTracker, ConvertGlobalRoomIdMessageComposer, FindNewFriendsMessageComposer, HabboWebTools, ILinkEventTracker, LegacyExternalInterface, NavigatorInitComposer, NavigatorSearchComposer, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useRef, useState } from 'react';
import { AddLinkEventTracker, ConvertGlobalRoomIdMessageComposer, FindNewFriendsMessageComposer, HabboWebTools, ILinkEventTracker, LegacyExternalInterface, NavigatorInitComposer, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useRef } from 'react';
import { FaPlus } from 'react-icons/fa';
import savesSearchIcon from '../../assets/images/navigator/saves-search/search_save.png';
import createRoomImg from '../../assets/images/navigator/create_room.png';
import randomRoomImg from '../../assets/images/navigator/random_room.png';
import promoteRoomImg from '../../assets/images/navigator/promote_room.png';
import { CreateLinkEvent, LocalizeText, SendMessageComposer, TryVisitRoom } from '../../api';
import { Flex, Text } from '../../common';
import { useNavigator, useNitroEvent } from '../../hooks';
import { Flex, Text, WidgetErrorBoundary } from '../../common';
import { useNavigatorData, useNavigatorSearch, useNavigatorUiState, useNavigatorUiStore, useNitroEvent } from '../../hooks';
import { NavigatorDoorStateView } from './views/NavigatorDoorStateView';
import { NavigatorRoomCreatorView } from './views/NavigatorRoomCreatorView';
import { NavigatorRoomInfoView } from './views/NavigatorRoomInfoView';
import { NavigatorRoomLinkView } from './views/NavigatorRoomLinkView';
import { NavigatorRoomSettingsView } from './views/room-settings/NavigatorRoomSettingsView';
import { NavigatorEmptyStateView } from './views/search/NavigatorEmptyStateView';
import { NavigatorSearchResultView } from './views/search/NavigatorSearchResultView';
import { NavigatorSearchSavesResultView } from './views/search/NavigatorSearchSavesResultView';
import { NavigatorSearchSkeletonView } from './views/search/NavigatorSearchSkeletonView';
import { NavigatorSearchView } from './views/search/NavigatorSearchView';
export const NavigatorView: FC<{}> = props =>
{
const [ isVisible, setIsVisible ] = useState(false);
const [ isReady, setIsReady ] = useState(false);
const [ isCreatorOpen, setCreatorOpen ] = useState(false);
const [ isRoomInfoOpen, setRoomInfoOpen ] = useState(false);
const [ isRoomLinkOpen, setRoomLinkOpen ] = useState(false);
const [ isOpenSavesSearches, setIsOpenSavesSearches ] = useState(false);
const [ isLoading, setIsLoading ] = useState(false);
const [ needsInit, setNeedsInit ] = useState(true);
const [ needsSearch, setNeedsSearch ] = useState(false);
const { searchResult = null, topLevelContext = null, topLevelContexts = null, navigatorData = null, navigatorSearches = null } = useNavigator();
const pendingSearch = useRef<{ value: string, code: string }>(null);
const { topLevelContext, topLevelContexts, navigatorData, navigatorSearches } = useNavigatorData();
const { searchResult, isFetching } = useNavigatorSearch();
const { isVisible, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, isOpenSavesSearches, needsInit, currentTabCode } = useNavigatorUiState();
const elementRef = useRef<HTMLDivElement>(null);
useNitroEvent<RoomSessionEvent>(RoomSessionEvent.CREATED, event =>
{
setIsVisible(false);
setCreatorOpen(false);
useNavigatorUiStore.getState().hide();
useNavigatorUiStore.getState().closeCreator();
});
const sendSearch = useCallback((searchValue: string, contextCode: string) =>
{
setCreatorOpen(false);
SendMessageComposer(new NavigatorSearchComposer(contextCode, searchValue));
setIsLoading(true);
}, []);
const reloadCurrentSearch = useCallback(() =>
{
if(!isReady)
{
setNeedsSearch(true);
return;
}
if(pendingSearch.current)
{
sendSearch(pendingSearch.current.value, pendingSearch.current.code);
pendingSearch.current = null;
return;
}
if(searchResult)
{
sendSearch(searchResult.data, searchResult.code);
return;
}
if(!topLevelContext) return;
sendSearch('', topLevelContext.code);
}, [ isReady, searchResult, topLevelContext, sendSearch ]);
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return;
const store = useNavigatorUiStore.getState();
switch(parts[1])
{
case 'show': {
setIsVisible(true);
setNeedsSearch(true);
case 'show':
store.show();
return;
}
case 'hide':
setIsVisible(false);
store.hide();
return;
case 'toggle': {
if(isVisible)
{
setIsVisible(false);
return;
}
setIsVisible(true);
setNeedsSearch(true);
case 'toggle':
store.toggle();
return;
}
case 'toggle-room-info':
setRoomInfoOpen(value => !value);
store.toggleRoomInfo();
return;
case 'toggle-room-link':
setRoomLinkOpen(value => !value);
store.toggleRoomLink();
return;
case 'goto':
if(parts.length <= 2) return;
switch(parts[2])
if(parts[2] === 'home')
{
case 'home':
if(navigatorData.homeRoomId <= 0) return;
TryVisitRoom(navigatorData.homeRoomId);
break;
default: {
const roomId = parseInt(parts[2]);
TryVisitRoom(roomId);
}
if(navigatorData.homeRoomId <= 0) return;
TryVisitRoom(navigatorData.homeRoomId);
return;
}
TryVisitRoom(parseInt(parts[2]));
return;
case 'create':
setIsVisible(true);
setCreatorOpen(true);
store.openCreator();
return;
case 'search':
if(parts.length > 2)
{
const topLevelContextCode = parts[2];
let searchValue = '';
if(parts.length > 3) searchValue = parts[3];
pendingSearch.current = { value: searchValue, code: topLevelContextCode };
setIsVisible(true);
setNeedsSearch(true);
}
if(parts.length <= 2) return;
const code = parts[2];
const value = parts.length > 3 ? parts[3] : '';
store.setTab(code);
if(value) store.setFilter(value);
store.show();
return;
}
},
eventUrlPrefix: 'navigator/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, [ isVisible, navigatorData ]);
}, [ navigatorData ]);
useEffect(() =>
{
if(!searchResult) return;
setIsLoading(false);
if(elementRef && elementRef.current) elementRef.current.scrollTop = 0;
if(elementRef.current) elementRef.current.scrollTop = 0;
}, [ searchResult ]);
useEffect(() =>
{
if(!isVisible || !isReady || !needsSearch) return;
reloadCurrentSearch();
setNeedsSearch(false);
}, [ isVisible, isReady, needsSearch, reloadCurrentSearch ]);
useEffect(() =>
{
if(isReady || !topLevelContext) return;
setIsReady(true);
}, [ isReady, topLevelContext ]);
useEffect(() =>
{
if(!isVisible || !needsInit) return;
SendMessageComposer(new NavigatorInitComposer());
setNeedsInit(false);
useNavigatorUiStore.getState().markInitDone();
}, [ isVisible, needsInit ]);
useEffect(() =>
{
LegacyExternalInterface.addCallback(HabboWebTools.OPENROOM, (k: string, _arg_2: boolean = false, _arg_3: string = null) => SendMessageComposer(new ConvertGlobalRoomIdMessageComposer(k)));
LegacyExternalInterface.addCallback(HabboWebTools.OPENROOM, (k: string) => SendMessageComposer(new ConvertGlobalRoomIdMessageComposer(k)));
}, []);
return (
<>
{ isVisible &&
<NitroCard
className={ `${ isOpenSavesSearches ? 'w-[600px] min-w-[600px]' : 'w-navigator-w min-w-navigator-w' } h-navigator-h min-h-navigator-h` }
className={ `${ isOpenSavesSearches ? 'w-[600px] sm:min-w-[600px]' : 'w-navigator-w sm:min-w-navigator-w' } max-w-[calc(100vw-1rem)] h-navigator-h min-h-navigator-h` }
uniqueKey="navigator">
<NitroCard.Header
headerText={ LocalizeText(isCreatorOpen ? 'navigator.createroom.title' : 'navigator.title') }
onCloseClick={ event => setIsVisible(false) } />
onCloseClick={ () => useNavigatorUiStore.getState().hide() } />
<NitroCard.Tabs>
<NitroCard.TabItem
isActive={ isOpenSavesSearches }
title={ LocalizeText('navigator.tooltip.left.show.hide') }
onClick={ () => setIsOpenSavesSearches(prev => !prev) }>
onClick={ () => useNavigatorUiStore.getState().toggleSavesSearches() }>
<img src={ savesSearchIcon } alt="" style={{ width: 18, height: 18 }} />
</NitroCard.TabItem>
{ topLevelContexts && (topLevelContexts.length > 0) && topLevelContexts.map((context, index) =>
{
return (
<NitroCard.TabItem
key={ index }
isActive={ ((topLevelContext === context) && !isCreatorOpen) }
onClick={ event => sendSearch('', context.code) }>
{ LocalizeText(('navigator.toplevelview.' + context.code)) }
</NitroCard.TabItem>
);
}) }
{ topLevelContexts && topLevelContexts.length > 0 && topLevelContexts.map((context, index) =>
<NitroCard.TabItem
key={ index }
isActive={ (currentTabCode ? currentTabCode === context.code : topLevelContext === context) && !isCreatorOpen }
onClick={ () => useNavigatorUiStore.getState().setTab(context.code) }>
{ LocalizeText('navigator.toplevelview.' + context.code) }
</NitroCard.TabItem>) }
<NitroCard.TabItem
isActive={ isCreatorOpen }
onClick={ event => setCreatorOpen(true) }>
onClick={ () => useNavigatorUiStore.getState().openCreator() }>
<FaPlus className="fa-icon" />
</NitroCard.TabItem>
</NitroCard.Tabs>
<NitroCard.Content isLoading={ isLoading }>
<NitroCard.Content>
{ !isCreatorOpen &&
<div className="flex h-full overflow-hidden gap-2">
<div className="flex flex-col sm:flex-row h-full overflow-hidden gap-2">
{ isOpenSavesSearches &&
<div className="overflow-hidden pr-1 shrink-0">
<div className="overflow-hidden pr-1 shrink-0 w-full sm:w-auto max-h-40 sm:max-h-none">
<NavigatorSearchSavesResultView searches={ navigatorSearches || [] } />
</div> }
<div className="flex flex-col w-full overflow-hidden gap-2">
<NavigatorSearchView sendSearch={ sendSearch } />
<div className="flex flex-col w-full min-h-0 overflow-hidden gap-2">
<NavigatorSearchView searchResult={ searchResult } />
<div ref={ elementRef } className="flex flex-col flex-1 min-h-0 overflow-auto gap-2">
{ (searchResult && searchResult.results.map((result, index) => <NavigatorSearchResultView key={ index } searchResult={ result } />)) }
{ (searchResult && (!searchResult.results || (searchResult.results.length === 0))) &&
<div className="nitro-card-panel px-3 py-2 text-sm text-muted">
{ LocalizeText(searchResult.code === 'myworld_view' ? 'navigator.roomsettings.moderation.none' : 'navigator.search.returned.no.results') }
</div> }
{ (isFetching && !searchResult) &&
<NavigatorSearchSkeletonView /> }
{ searchResult && searchResult.results.map((result, index) => <NavigatorSearchResultView key={ index } searchResult={ result } />) }
{ searchResult && (!searchResult.results || searchResult.results.length === 0) &&
<NavigatorEmptyStateView code={ searchResult.code } onCreateRoom={ () => useNavigatorUiStore.getState().openCreator() } /> }
</div>
<Flex className="nitro-card-divider pt-2 border-t gap-2">
<Flex
pointer
alignItems="center"
justifyContent="center"
<Flex pointer alignItems="center" justifyContent="center"
className="flex-1 h-[60px] cursor-pointer bg-no-repeat pl-16"
style={ { backgroundImage: `url(${ createRoomImg })`, backgroundSize: '100% 100%' } }
onClick={ () => setCreatorOpen(true) }
>
onClick={ () => useNavigatorUiStore.getState().openCreator() }>
<Text variant="white" bold className="text-xs drop-shadow">
{ LocalizeText('navigator.createroom.create') }
</Text>
</Flex>
{ (searchResult?.code !== 'myworld_view' && searchResult?.code !== 'roomads_view') &&
<Flex
pointer
alignItems="center"
justifyContent="center"
{ searchResult?.code !== 'myworld_view' && searchResult?.code !== 'roomads_view' &&
<Flex pointer alignItems="center" justifyContent="center"
className="flex-1 h-[60px] cursor-pointer bg-no-repeat pl-16"
style={ { backgroundImage: `url(${ randomRoomImg })`, backgroundSize: '100% 100%' } }
onClick={ () => SendMessageComposer(new FindNewFriendsMessageComposer()) }
>
onClick={ () => SendMessageComposer(new FindNewFriendsMessageComposer()) }>
<Text variant="white" bold className="text-xs drop-shadow">
{ LocalizeText('navigator.random.room') }
</Text>
</Flex> }
{ (searchResult?.code === 'myworld_view' || searchResult?.code === 'roomads_view') &&
<Flex
pointer
alignItems="center"
justifyContent="center"
<Flex pointer alignItems="center" justifyContent="center"
className="flex-1 h-[60px] cursor-pointer bg-no-repeat pl-16"
style={ { backgroundImage: `url(${ promoteRoomImg })`, backgroundSize: '100% 100%' } }
onClick={ () => CreateLinkEvent('catalog/open/room_event') }
>
onClick={ () => CreateLinkEvent('catalog/open/room_event') }>
<Text variant="white" bold className="text-xs drop-shadow">
{ LocalizeText('navigator.promote.room') }
</Text>
@@ -291,13 +180,26 @@ export const NavigatorView: FC<{}> = props =>
</Flex>
</div>
</div> }
{ isCreatorOpen && <NavigatorRoomCreatorView /> }
{ isCreatorOpen &&
<WidgetErrorBoundary name="NavigatorRoomCreator">
<NavigatorRoomCreatorView />
</WidgetErrorBoundary> }
</NitroCard.Content>
</NitroCard> }
<NavigatorDoorStateView />
{ isRoomInfoOpen && <NavigatorRoomInfoView onCloseClick={ () => setRoomInfoOpen(false) } /> }
{ isRoomLinkOpen && <NavigatorRoomLinkView onCloseClick={ () => setRoomLinkOpen(false) } /> }
<NavigatorRoomSettingsView />
<WidgetErrorBoundary name="NavigatorDoorState">
<NavigatorDoorStateView />
</WidgetErrorBoundary>
{ isRoomInfoOpen &&
<WidgetErrorBoundary name="NavigatorRoomInfo">
<NavigatorRoomInfoView onCloseClick={ () => useNavigatorUiStore.getState().setRoomInfoOpen(false) } />
</WidgetErrorBoundary> }
{ isRoomLinkOpen &&
<WidgetErrorBoundary name="NavigatorRoomLink">
<NavigatorRoomLinkView onCloseClick={ () => useNavigatorUiStore.getState().setRoomLinkOpen(false) } />
</WidgetErrorBoundary> }
<WidgetErrorBoundary name="NavigatorRoomSettings">
<NavigatorRoomSettingsView />
</WidgetErrorBoundary>
</>
);
};
@@ -1,88 +1,68 @@
import { FC, useEffect, useState } from 'react';
import { CreateRoomSession, DoorStateType, GoToDesktop, LocalizeText } from '../../../api';
import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../common';
import { useNavigator } from '../../../hooks';
import { useDoorState } from '../../../hooks';
import { NitroInput } from '../../../layout';
const VISIBLE_STATES = [ DoorStateType.START_DOORBELL, DoorStateType.STATE_WAITING, DoorStateType.STATE_NO_ANSWER, DoorStateType.START_PASSWORD, DoorStateType.STATE_WRONG_PASSWORD ];
const DOORBELL_STATES = [ DoorStateType.START_DOORBELL, DoorStateType.STATE_WAITING, DoorStateType.STATE_NO_ANSWER ];
const PASSWORD_STATES = [ DoorStateType.START_PASSWORD, DoorStateType.STATE_WRONG_PASSWORD ];
export const NavigatorDoorStateView: FC<{}> = props =>
{
const [ password, setPassword ] = useState('');
const { doorData = null, setDoorData = null } = useNavigator();
const { snapshot, setSnapshot, reset } = useDoorState();
const onClose = () =>
{
if(doorData && (doorData.state === DoorStateType.STATE_WAITING)) GoToDesktop();
setDoorData(null);
if(snapshot.state === DoorStateType.STATE_WAITING) GoToDesktop();
reset();
};
const ring = () =>
{
if(!doorData || !doorData.roomInfo) return;
CreateRoomSession(doorData.roomInfo.roomId);
setDoorData(prevValue =>
{
const newValue = { ...prevValue };
newValue.state = DoorStateType.STATE_PENDING_SERVER;
return newValue;
});
if(!snapshot.roomInfo) return;
CreateRoomSession(snapshot.roomInfo.roomId);
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_PENDING_SERVER }));
};
const tryEntering = () =>
{
if(!doorData || !doorData.roomInfo) return;
CreateRoomSession(doorData.roomInfo.roomId, password);
setDoorData(prevValue =>
{
const newValue = { ...prevValue };
newValue.state = DoorStateType.STATE_PENDING_SERVER;
return newValue;
});
if(!snapshot.roomInfo) return;
CreateRoomSession(snapshot.roomInfo.roomId, password);
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_PENDING_SERVER }));
};
useEffect(() =>
{
if(!doorData || (doorData.state !== DoorStateType.STATE_NO_ANSWER)) return;
if(snapshot.state !== DoorStateType.STATE_NO_ANSWER) return;
GoToDesktop();
}, [ doorData ]);
}, [ snapshot.state ]);
if(!doorData || (doorData.state === DoorStateType.NONE) || (VISIBLE_STATES.indexOf(doorData.state) === -1)) return null;
if(snapshot.state === DoorStateType.NONE) return null;
if(VISIBLE_STATES.indexOf(snapshot.state) === -1) return null;
const isDoorbell = (DOORBELL_STATES.indexOf(doorData.state) >= 0);
const isDoorbell = DOORBELL_STATES.indexOf(snapshot.state) >= 0;
return (
<NitroCardView className="nitro-navigator-doorbell" theme="primary-slim">
<NitroCardHeaderView headerText={ LocalizeText(isDoorbell ? 'navigator.doorbell.title' : 'navigator.password.title') } onCloseClick={ onClose } />
<NitroCardContentView>
<div className="flex flex-col gap-1">
<Text bold>{ doorData && doorData.roomInfo && doorData.roomInfo.roomName }</Text>
{ (doorData.state === DoorStateType.START_DOORBELL) &&
<Text bold>{ snapshot.roomInfo && snapshot.roomInfo.roomName }</Text>
{ snapshot.state === DoorStateType.START_DOORBELL &&
<Text>{ LocalizeText('navigator.doorbell.info') }</Text> }
{ (doorData.state === DoorStateType.STATE_WAITING) &&
{ snapshot.state === DoorStateType.STATE_WAITING &&
<Text>{ LocalizeText('navigator.doorbell.waiting') }</Text> }
{ (doorData.state === DoorStateType.STATE_NO_ANSWER) &&
{ snapshot.state === DoorStateType.STATE_NO_ANSWER &&
<Text>{ LocalizeText('navigator.doorbell.no.answer') }</Text> }
{ (doorData.state === DoorStateType.START_PASSWORD) &&
{ snapshot.state === DoorStateType.START_PASSWORD &&
<Text>{ LocalizeText('navigator.password.info') }</Text> }
{ (doorData.state === DoorStateType.STATE_WRONG_PASSWORD) &&
{ snapshot.state === DoorStateType.STATE_WRONG_PASSWORD &&
<Text>{ LocalizeText('navigator.password.retryinfo') }</Text> }
</div>
{ isDoorbell &&
<div className="flex flex-col gap-1">
{ (doorData.state === DoorStateType.START_DOORBELL) &&
{ snapshot.state === DoorStateType.START_DOORBELL &&
<Button variant="success" onClick={ ring }>
{ LocalizeText('navigator.doorbell.button.ring') }
</Button> }
@@ -3,7 +3,7 @@ import { CreateFlatMessageComposer, HabboClubLevelEnum } from '@nitrots/nitro-re
import { FC, useEffect, useState } from 'react';
import { GetClubMemberLevel, GetConfigurationValue, IRoomModel, LocalizeText, SendMessageComposer } from '../../../api';
import { Button, Flex, Grid, LayoutCurrencyIcon, LayoutGridItem, Text } from '../../../common';
import { useNavigator } from '../../../hooks';
import { useNavigatorData } from '../../../hooks';
import { NitroInput } from '../../../layout';
import { useRoomCreatorStore } from './navigatorRoomCreatorStore';
@@ -25,7 +25,7 @@ export const NavigatorRoomCreatorView: FC = () =>
});
const isCreating = useRoomCreatorStore(s => s.isCreating);
const beginCreate = useRoomCreatorStore(s => s.beginCreate);
const { categories = null } = useNavigator();
const { categories } = useNavigatorData();
const hcDisabled = GetConfigurationValue<boolean>('hc.disabled', false);
@@ -1,10 +1,10 @@
import { CreateLinkEvent, GetCustomRoomFilterMessageComposer, GetGuestRoomMessageComposer, GetSessionDataManager, NavigatorSearchComposer, RemoveOwnRoomRightsRoomMessageComposer, RoomControllerLevel, RoomMuteComposer, RoomSettingsComposer, ToggleStaffPickMessageComposer, UpdateHomeRoomMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react';
import { FC, useEffect, useState } from 'react';
import { FaLink, FaSignOutAlt } from 'react-icons/fa';
import { DispatchUiEvent, GetGroupInformation, LocalizeText, ReportType, SendMessageComposer, ToggleFavoriteRoom } from '../../../api';
import { DispatchUiEvent, GetGroupInformation, LocalizeText, ReportType, SendMessageComposer } from '../../../api';
import { Button, Column, Flex, LayoutBadgeImageView, LayoutRoomThumbnailView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text, UserProfileIconView } from '../../../common';
import { RoomWidgetThumbnailEvent } from '../../../events';
import { useHasPermission, useHelp, useNavigator, useRoom } from '../../../hooks';
import { useHasPermission, useHelp, useNavigatorData, useNavigatorFavourite, useRoom } from '../../../hooks';
import { classNames } from '../../../layout';
export interface NavigatorRoomInfoViewProps {
@@ -17,12 +17,13 @@ export const NavigatorRoomInfoView: FC<NavigatorRoomInfoViewProps> = props =>
const [ isRoomPicked, setIsRoomPicked ] = useState(false);
const [ isRoomMuted, setIsRoomMuted ] = useState(false);
const { report = null } = useHelp();
const { navigatorData = null, favouriteRoomIds = [] } = useNavigator();
const { navigatorData } = useNavigatorData();
const { roomSession = null } = useRoom();
const canManageAnyRoom = useHasPermission('acc_anyroomowner');
const canStaffPick = useHasPermission('acc_staff_pick');
const enteredRoomId = navigatorData?.enteredGuestRoom?.roomId ?? 0;
const { isFavourite: isRoomInFavouritesList, toggle: toggleFavourite } = useNavigatorFavourite(enteredRoomId);
useEffect(() =>
{
@@ -30,22 +31,6 @@ export const NavigatorRoomInfoView: FC<NavigatorRoomInfoViewProps> = props =>
SendMessageComposer(new GetGuestRoomMessageComposer(enteredRoomId, false, false));
}, [ enteredRoomId ]);
const isRoomInFavouritesList = useMemo(() =>
{
if(!enteredRoomId) return false;
return favouriteRoomIds.some((id: any) =>
{
if(id && typeof id === 'object')
{
if('roomId' in id) return Number(id.roomId) === enteredRoomId;
if('id' in id) return Number(id.id) === enteredRoomId;
}
return String(id) === String(enteredRoomId);
});
}, [ favouriteRoomIds, enteredRoomId ]);
const hasPermission = (permission: string) =>
{
if(!navigatorData?.enteredGuestRoom) return false;
@@ -115,7 +100,7 @@ export const NavigatorRoomInfoView: FC<NavigatorRoomInfoViewProps> = props =>
report(ReportType.ROOM, { roomId, roomName: navigatorData.enteredGuestRoom.roomName });
return;
case 'room_favourite':
ToggleFavoriteRoom(roomId, isRoomInFavouritesList);
toggleFavourite();
SendMessageComposer(new GetGuestRoomMessageComposer(roomId, false, false));
return;
case 'remove_rights':
@@ -1,7 +1,7 @@
import { FC } from 'react';
import { GetConfigurationValue, LocalizeText } from '../../../api';
import { LayoutRoomThumbnailView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../common';
import { useNavigator } from '../../../hooks';
import { useNavigatorData } from '../../../hooks';
export class NavigatorRoomLinkViewProps
{
@@ -11,7 +11,7 @@ export class NavigatorRoomLinkViewProps
export const NavigatorRoomLinkView: FC<NavigatorRoomLinkViewProps> = props =>
{
const { onCloseClick = null } = props;
const { navigatorData = null } = useNavigator();
const { navigatorData } = useNavigatorData();
if(!navigatorData.enteredGuestRoom) return null;
@@ -2,6 +2,7 @@ import { RoomDataParser } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { IRoomData, LocalizeText } from '../../../../api';
import { Column, Flex, Text } from '../../../../common';
import { NavigatorRoomSettingsSectionView } from './NavigatorRoomSettingsSectionView';
interface NavigatorRoomSettingsTabViewProps
{
@@ -36,9 +37,8 @@ export const NavigatorRoomSettingsAccessTabView: FC<NavigatorRoomSettingsTabView
<Text bold>{ LocalizeText('navigator.roomsettings.roomaccess.caption') }</Text>
<Text>{ LocalizeText('navigator.roomsettings.roomaccess.info') }</Text>
</Column>
<Column overflow="auto">
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.roomsettings.doormode') }</Text>
<Column overflow="auto" gap={ 2 }>
<NavigatorRoomSettingsSectionView title={ LocalizeText('navigator.roomsettings.doormode') } gap={ 1 }>
<Flex alignItems="center" gap={ 1 }>
<input className="form-check-input" type="radio" name="lockState" checked={ (roomData.lockState === RoomDataParser.OPEN_STATE) && !isTryingPassword } onChange={ event => handleChange('lock_state', RoomDataParser.OPEN_STATE) } />
<Text>{ LocalizeText('navigator.roomsettings.doormode.open') }</Text>
@@ -58,21 +58,20 @@ export const NavigatorRoomSettingsAccessTabView: FC<NavigatorRoomSettingsTabView
{ (isTryingPassword || (roomData.lockState === RoomDataParser.PASSWORD_STATE)) &&
<Column gap={ 1 }>
<Text>{ LocalizeText('navigator.roomsettings.doormode.password') }</Text>
<input type="password" className="form-control form-control-sm col-4" value={ password } onChange={ event => setPassword(event.target.value) } placeholder={ LocalizeText('navigator.roomsettings.password') } onFocus={ event => setIsTryingPassword(true) } />
<input type="password" className="form-control form-control-sm" value={ password } onChange={ event => setPassword(event.target.value) } placeholder={ LocalizeText('navigator.roomsettings.password') } onFocus={ event => setIsTryingPassword(true) } />
{ isTryingPassword && (password.length <= 0) &&
<Text bold small variant="danger">
{ LocalizeText('navigator.roomsettings.passwordismandatory') }
</Text> }
<input type="password" className="form-control form-control-sm col-4" value={ confirmPassword } onChange={ event => setConfirmPassword(event.target.value) } onBlur={ saveRoomPassword } placeholder={ LocalizeText('navigator.roomsettings.passwordconfirm') } />
<input type="password" className="form-control form-control-sm" value={ confirmPassword } onChange={ event => setConfirmPassword(event.target.value) } onBlur={ saveRoomPassword } placeholder={ LocalizeText('navigator.roomsettings.passwordconfirm') } />
{ isTryingPassword && ((password.length > 0) && (password !== confirmPassword)) &&
<Text bold small variant="danger">
{ LocalizeText('navigator.roomsettings.invalidconfirm') }
</Text> }
</Column> }
</Flex>
</Column>
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.roomsettings.pets') }</Text>
</NavigatorRoomSettingsSectionView>
<NavigatorRoomSettingsSectionView title={ LocalizeText('navigator.roomsettings.pets') } gap={ 1 }>
<Flex alignItems="center" gap={ 1 }>
<input className="form-check-input" type="checkbox" checked={ roomData.allowPets } onChange={ event => handleChange('allow_pets', event.target.checked) } />
<Text>{ LocalizeText('navigator.roomsettings.allowpets') }</Text>
@@ -81,7 +80,7 @@ export const NavigatorRoomSettingsAccessTabView: FC<NavigatorRoomSettingsTabView
<input className="form-check-input" type="checkbox" checked={ roomData.allowPetsEat } onChange={ event => handleChange('allow_pets_eat', event.target.checked) } />
<Text>{ LocalizeText('navigator.roomsettings.allowfoodconsume') }</Text>
</Flex>
</Column>
</NavigatorRoomSettingsSectionView>
</Column>
</>
);
@@ -2,8 +2,8 @@ import { RoomDeleteComposer, RoomSettingsSaveErrorEvent, RoomSettingsSaveErrorPa
import { FC, useEffect, useState } from 'react';
import { FaTimes } from 'react-icons/fa';
import { CreateLinkEvent, GetMaxVisitorsList, IRoomData, LocalizeText, SendMessageComposer } from '../../../../api';
import { Base, Column, Flex, Text } from '../../../../common';
import { useMessageEvent, useNavigator, useNotification } from '../../../../hooks';
import { Column, Flex, Text } from '../../../../common';
import { useMessageEvent, useNavigatorData, useNotification } from '../../../../hooks';
const ROOM_NAME_MIN_LENGTH = 3;
const ROOM_NAME_MAX_LENGTH = 60;
@@ -27,7 +27,7 @@ export const NavigatorRoomSettingsBasicTabView: FC<NavigatorRoomSettingsTabViewP
const [ tagIndex, setTagIndex ] = useState(0);
const [ typeError, setTypeError ] = useState<string>('');
const { showConfirm = null } = useNotification();
const { categories = null } = useNavigator();
const { categories } = useNavigatorData();
useMessageEvent<RoomSettingsSaveErrorEvent>(RoomSettingsSaveErrorEvent, event =>
{
@@ -39,6 +39,7 @@ export const NavigatorRoomSettingsBasicTabView: FC<NavigatorRoomSettingsTabViewP
{
case RoomSettingsSaveErrorParser.ERROR_INVALID_TAG:
setTypeError('navigator.roomsettings.unacceptablewords');
break;
case RoomSettingsSaveErrorParser.ERROR_NON_USER_CHOOSABLE_TAG:
setTypeError('navigator.roomsettings.nonuserchoosabletag');
break;
@@ -77,9 +78,9 @@ export const NavigatorRoomSettingsBasicTabView: FC<NavigatorRoomSettingsTabViewP
const saveTags = (index: number) =>
{
if(index === 0 && (roomTag1 === roomData.tags[0]) || (roomTag1.length > TAGS_MAX_LENGTH)) return;
if(index === 0 && ((roomTag1 === roomData.tags[0]) || (roomTag1.length > TAGS_MAX_LENGTH))) return;
if(index === 1 && (roomTag2 === roomData.tags[1]) || (roomTag2.length > TAGS_MAX_LENGTH)) return;
if(index === 1 && ((roomTag2 === roomData.tags[1]) || (roomTag2.length > TAGS_MAX_LENGTH))) return;
if(roomTag1 === '' && roomTag2 !== '') setRoomTag2('');
@@ -98,78 +99,77 @@ export const NavigatorRoomSettingsBasicTabView: FC<NavigatorRoomSettingsTabViewP
return (
<>
<Flex alignItems="center" gap={ 1 }>
<Text className="col-3">{ LocalizeText('navigator.roomname') }</Text>
<Column fullWidth gap={ 0 }>
<input className="form-control form-control-sm" value={ roomName } maxLength={ ROOM_NAME_MAX_LENGTH } onChange={ event => setRoomName(event.target.value) } onBlur={ saveRoomName } />
{ (roomName.length < ROOM_NAME_MIN_LENGTH) &&
<Text bold small variant="danger">
{ LocalizeText('navigator.roomsettings.roomnameismandatory') }
</Text> }
</Column>
</Flex>
<Flex alignItems="center" gap={ 1 }>
<Text className="col-3">{ LocalizeText('navigator.roomsettings.desc') }</Text>
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.roomname') }</Text>
<input className="form-control form-control-sm" value={ roomName } maxLength={ ROOM_NAME_MAX_LENGTH } onChange={ event => setRoomName(event.target.value) } onBlur={ saveRoomName } />
{ (roomName.length < ROOM_NAME_MIN_LENGTH) &&
<Text bold small variant="danger">
{ LocalizeText('navigator.roomsettings.roomnameismandatory') }
</Text> }
</Column>
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.roomsettings.desc') }</Text>
<textarea className="form-control form-control-sm" value={ roomDescription } maxLength={ DESC_MAX_LENGTH } onChange={ event => setRoomDescription(event.target.value) } onBlur={ saveRoomDescription } />
</Flex>
<Flex alignItems="center" gap={ 1 }>
<Text className="col-3">{ LocalizeText('navigator.category') }</Text>
</Column>
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.category') }</Text>
<select className="form-select form-select-sm" value={ roomData.categoryId } onChange={ event => handleChange('category', event.target.value) }>
{ categories && categories.map(category => <option key={ category.id } value={ category.id }>{ LocalizeText(category.name) }</option>) }
</select>
</Flex>
<Flex alignItems="center" gap={ 1 }>
<Text className="col-3">{ LocalizeText('navigator.maxvisitors') }</Text>
</Column>
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.maxvisitors') }</Text>
<select className="form-select form-select-sm" value={ roomData.userCount } onChange={ event => handleChange('max_visitors', event.target.value) }>
{ GetMaxVisitorsList && GetMaxVisitorsList.map(value => <option key={ value } value={ value }>{ value }</option>) }
</select>
</Flex>
<Flex alignItems="center" gap={ 1 }>
<Text className="col-3">{ LocalizeText('navigator.tradesettings') }</Text>
</Column>
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.tradesettings') }</Text>
<select className="form-select form-select-sm" value={ roomData.tradeState } onChange={ event => handleChange('trade_state', event.target.value) }>
<option value="0">{ LocalizeText('navigator.roomsettings.trade_not_allowed') }</option>
<option value="1">{ LocalizeText('navigator.roomsettings.trade_not_with_Controller') }</option>
<option value="2">{ LocalizeText('navigator.roomsettings.trade_allowed') }</option>
</select>
</Flex>
</Column>
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.tags') }</Text>
<Flex gap={ 1 }>
<Column fullWidth gap={ 0 }>
<input className="form-control form-control-sm" value={ roomTag1 } onChange={ event => setRoomTag1(event.target.value) } onBlur={ () => saveTags(0) } />
{ (roomTag1.length > TAGS_MAX_LENGTH) &&
<Text bold small variant="danger">
{ LocalizeText('navigator.roomsettings.toomanycharacters') }
</Text> }
{ (tagIndex === 0 && typeError != '') &&
<Text bold small variant="danger">
{ LocalizeText(typeError) }
</Text> }
</Column>
<Column fullWidth gap={ 0 }>
<input className="form-control form-control-sm" value={ roomTag2 } onChange={ event => setRoomTag2(event.target.value) } onBlur={ () => saveTags(1) } />
{ (roomTag2.length > TAGS_MAX_LENGTH) &&
<Text bold small variant="danger">
{ LocalizeText('navigator.roomsettings.toomanycharacters') }
</Text> }
{ (tagIndex === 1 && typeError != '') &&
<Text bold small variant="danger">
{ LocalizeText(typeError) }
</Text> }
</Column>
</Flex>
</Column>
<Flex alignItems="center" gap={ 1 }>
<Text className="col-3">{ LocalizeText('navigator.tags') }</Text>
<Column fullWidth gap={ 0 }>
<input className="form-control form-control-sm" value={ roomTag1 } onChange={ event => setRoomTag1(event.target.value) } onBlur={ () => saveTags(0) } />
{ (roomTag1.length > TAGS_MAX_LENGTH) &&
<Text bold small variant="danger">
{ LocalizeText('navigator.roomsettings.toomanycharacters') }
</Text> }
{ (tagIndex === 0 && typeError != '') &&
<Text bold small variant="danger">
{ LocalizeText(typeError) }
</Text> }
</Column>
<Column fullWidth gap={ 0 }>
<input className="form-control form-control-sm" value={ roomTag2 } onChange={ event => setRoomTag2(event.target.value) } onBlur={ () => saveTags(1) } />
{ (roomTag2.length > TAGS_MAX_LENGTH) &&
<Text bold small variant="danger">
{ LocalizeText('navigator.roomsettings.toomanycharacters') }
</Text> }
{ (tagIndex === 1 && typeError != '') &&
<Text bold small variant="danger">
{ LocalizeText(typeError) }
</Text> }
</Column>
</Flex>
<Flex alignItems="center" gap={ 1 }>
<Base className="col-3" />
<input className="form-check-input" type="checkbox" checked={ roomData.allowWalkthrough } onChange={ event => handleChange('allow_walkthrough', event.target.checked) } />
<Text>{ LocalizeText('navigator.roomsettings.allow_walk_through') }</Text>
</Flex>
<Flex alignItems="center" gap={ 1 }>
<Base className="col-3" />
<input className="form-check-input" type="checkbox" checked={ roomData.allowUnderpass } onChange={ event => handleChange('allow_underpass', event.target.checked) } />
<Text>{ LocalizeText('navigator.roomsettings.allow_underpass') }</Text>
</Flex>
<Text variant="danger" underline bold pointer className="d-flex justify-content-center align-items-center gap-1" onClick={ deleteRoom }>
<FaTimes className="fa-icon" /> { LocalizeText('navigator.roomsettings.delete') }
</Text>
<Flex pointer alignItems="center" justifyContent="center" gap={ 1 } onClick={ deleteRoom }>
<FaTimes className="fa-icon shrink-0 text-[#a81a12]" />
<Text variant="danger" underline bold className="whitespace-nowrap">{ LocalizeText('navigator.roomsettings.delete') }</Text>
</Flex>
</>
);
};
@@ -1,7 +1,7 @@
import { YouTubeRoomSettingsComposer, YouTubeRoomSettingsEvent } from '@nitrots/nitro-renderer';
import { FC, useState } from 'react';
import { getYoutubeRoomEnabled, IRoomData, LocalizeText, SendMessageComposer, setYoutubeRoomEnabled } from '../../../../api';
import { useMessageEvent } from '../../../../hooks';
import { useMessageEvent, useSoundboard } from '../../../../hooks';
interface NavigatorRoomSettingsMiscTabViewProps
{
@@ -13,6 +13,7 @@ export const NavigatorRoomSettingsMiscTabView: FC<NavigatorRoomSettingsMiscTabVi
const { roomData = null } = props;
const [ youtubeEnabled, setYoutubeEnabled ] = useState(getYoutubeRoomEnabled());
const [ cooldown, setCooldown ] = useState(false);
const { enabled: soundboardEnabled, setRoomEnabled: setSoundboardEnabled } = useSoundboard();
useMessageEvent<YouTubeRoomSettingsEvent>(YouTubeRoomSettingsEvent, event =>
{
@@ -29,6 +30,14 @@ export const NavigatorRoomSettingsMiscTabView: FC<NavigatorRoomSettingsMiscTabVi
setTimeout(() => setCooldown(false), 300);
};
const toggleSoundboard = (enabled: boolean) =>
{
if (cooldown) return;
setSoundboardEnabled(enabled);
setCooldown(true);
setTimeout(() => setCooldown(false), 300);
};
return (
<>
<div className="mb-3">
@@ -52,6 +61,23 @@ export const NavigatorRoomSettingsMiscTabView: FC<NavigatorRoomSettingsMiscTabVi
</label>
</div>
</div>
<div className={`p-3 rounded transition-colors ${cooldown ? 'bg-gray-200 opacity-60' : 'bg-gray-100'}`}>
<div className="flex items-center justify-between">
<div>
<div className="font-bold text-sm">🔊 { LocalizeText('soundboard.title') }</div>
<div className="text-xs text-gray-500">{ LocalizeText('soundboard.room.setting.desc') }</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={ soundboardEnabled }
disabled={ cooldown }
onChange={ e => toggleSoundboard(e.target.checked) }
className="w-5 h-5"
/>
</label>
</div>
</div>
</div>
</>
);
@@ -3,6 +3,7 @@ import { FC, useEffect, useState } from 'react';
import { IRoomData, LocalizeText, SendMessageComposer } from '../../../../api';
import { Button, Column, Flex, Grid, Text, UserProfileIconView } from '../../../../common';
import { useMessageEvent } from '../../../../hooks';
import { NavigatorRoomSettingsSectionView } from './NavigatorRoomSettingsSectionView';
interface NavigatorRoomSettingsTabViewProps
{
@@ -51,28 +52,29 @@ export const NavigatorRoomSettingsModTabView: FC<NavigatorRoomSettingsTabViewPro
return (
<Grid overflow="auto">
<Column size={ 6 }>
<Text bold>{ LocalizeText('navigator.roomsettings.moderation.banned.users') } ({ bannedUsers.length })</Text>
<Flex overflow="hidden" className="nitro-card-panel list-container p-2">
<Column fullWidth overflow="auto" gap={ 1 }>
{ bannedUsers && (bannedUsers.length > 0) && bannedUsers.map((user, index) =>
{
return (
<Flex key={ index } shrink alignItems="center" gap={ 1 } overflow="hidden">
<UserProfileIconView userId={ user.userId } />
<Text pointer grow onClick={ event => setSelectedUserId(user.userId) }> { user.userName }</Text>
</Flex>
);
}) }
</Column>
</Flex>
<Button disabled={ (selectedUserId <= 0) } onClick={ event => unBanUser(selectedUserId) }>
{ LocalizeText('navigator.roomsettings.moderation.unban') } { selectedUserId > 0 && bannedUsers.find(user => (user.userId === selectedUserId))?.userName }
</Button>
<NavigatorRoomSettingsSectionView title={ `${ LocalizeText('navigator.roomsettings.moderation.banned.users') } (${ bannedUsers.length })` } gap={ 1 } className="h-full">
<Flex overflow="hidden" className="nitro-card-panel list-container p-2">
<Column fullWidth overflow="auto" gap={ 1 }>
{ bannedUsers && (bannedUsers.length > 0) && bannedUsers.map((user, index) =>
{
return (
<Flex key={ index } shrink alignItems="center" gap={ 1 } overflow="hidden">
<UserProfileIconView userId={ user.userId } />
<Text pointer grow onClick={ event => setSelectedUserId(user.userId) }> { user.userName }</Text>
</Flex>
);
}) }
</Column>
</Flex>
<Button disabled={ (selectedUserId <= 0) } onClick={ event => unBanUser(selectedUserId) }>
{ LocalizeText('navigator.roomsettings.moderation.unban') } { selectedUserId > 0 && bannedUsers.find(user => (user.userId === selectedUserId))?.userName }
</Button>
</NavigatorRoomSettingsSectionView>
</Column>
<Column size={ 6 }>
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.roomsettings.moderation.mute.header') }</Text>
<Flex alignItems="center" gap={ 1 }>
<NavigatorRoomSettingsSectionView title={ LocalizeText('navigator.roomsettings.moderation') } gap={ 2 } className="h-full">
<Column gap={ 1 }>
<Text bold small>{ LocalizeText('navigator.roomsettings.moderation.mute.header') }</Text>
<select className="form-select form-select-sm" value={ roomData.moderationSettings.allowMute } onChange={ event => handleChange('moderation_mute', event.target.value) }>
<option value={ RoomModerationSettings.MODERATION_LEVEL_NONE }>
{ LocalizeText('navigator.roomsettings.moderation.none') }
@@ -81,11 +83,9 @@ export const NavigatorRoomSettingsModTabView: FC<NavigatorRoomSettingsTabViewPro
{ LocalizeText('navigator.roomsettings.moderation.rights') }
</option>
</select>
</Flex>
</Column>
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.roomsettings.moderation.kick.header') }</Text>
<Flex alignItems="center" gap={ 1 }>
</Column>
<Column gap={ 1 }>
<Text bold small>{ LocalizeText('navigator.roomsettings.moderation.kick.header') }</Text>
<select className="form-select form-select-sm" value={ roomData.moderationSettings.allowKick } onChange={ event => handleChange('moderation_kick', event.target.value) }>
<option value={ RoomModerationSettings.MODERATION_LEVEL_NONE }>
{ LocalizeText('navigator.roomsettings.moderation.none') }
@@ -97,11 +97,9 @@ export const NavigatorRoomSettingsModTabView: FC<NavigatorRoomSettingsTabViewPro
{ LocalizeText('navigator.roomsettings.moderation.all') }
</option>
</select>
</Flex>
</Column>
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.roomsettings.moderation.ban.header') }</Text>
<Flex alignItems="center" gap={ 1 }>
</Column>
<Column gap={ 1 }>
<Text bold small>{ LocalizeText('navigator.roomsettings.moderation.ban.header') }</Text>
<select className="form-select form-select-sm" value={ roomData.moderationSettings.allowBan } onChange={ event => handleChange('moderation_ban', event.target.value) }>
<option value={ RoomModerationSettings.MODERATION_LEVEL_NONE }>
{ LocalizeText('navigator.roomsettings.moderation.none') }
@@ -110,8 +108,8 @@ export const NavigatorRoomSettingsModTabView: FC<NavigatorRoomSettingsTabViewPro
{ LocalizeText('navigator.roomsettings.moderation.rights') }
</option>
</select>
</Flex>
</Column>
</Column>
</NavigatorRoomSettingsSectionView>
</Column>
</Grid>
);
@@ -3,6 +3,7 @@ import { FC, useEffect, useRef, useState } from 'react';
import { IRoomData, LocalizeText, SendMessageComposer } from '../../../../api';
import { Button, Column, Flex, Grid, Text, UserProfileIconView } from '../../../../common';
import { useFriends, useMessageEvent } from '../../../../hooks';
import { NavigatorRoomSettingsSectionView } from './NavigatorRoomSettingsSectionView';
interface NavigatorRoomSettingsTabViewProps
{
@@ -105,74 +106,72 @@ export const NavigatorRoomSettingsRightsTabView: FC<NavigatorRoomSettingsTabView
return (
<Grid>
<Column size={ 6 }>
<Text bold>
{ LocalizeText(
<NavigatorRoomSettingsSectionView gap={ 1 } className="h-full"
title={ LocalizeText(
'navigator.flatctrls.userswithrights',
[ 'displayed', 'total' ],
[
filteredUsersWithRights.size.toString(),
filteredUsersWithRights.size.toString()
]
) }
</Text>
) }>
<Flex overflow="hidden" className="nitro-card-panel p-2 list-container">
<Column fullWidth overflow="auto" gap={ 1 }>
{ Array.from(filteredUsersWithRights.entries()).map(([ id, name ], index) =>
{
return (
<Flex key={ `${id}-${index}` } shrink alignItems="center" gap={ 1 } overflow="hidden">
<UserProfileIconView userId={ id } />
<Text
pointer
grow
onClick={ () => guardedSend(`take_${id}`, new RoomTakeRightsComposer(id)) }>
{ name }
</Text>
</Flex>
);
}) }
</Column>
</Flex>
<Flex overflow="hidden" className="nitro-card-panel p-2 list-container">
<Column fullWidth overflow="auto" gap={ 1 }>
{ Array.from(filteredUsersWithRights.entries()).map(([ id, name ], index) =>
{
return (
<Flex key={ `${id}-${index}` } shrink alignItems="center" gap={ 1 } overflow="hidden">
<UserProfileIconView userId={ id } />
<Text
pointer
grow
onClick={ () => guardedSend(`take_${id}`, new RoomTakeRightsComposer(id)) }>
{ name }
</Text>
</Flex>
);
}) }
</Column>
</Flex>
<Button
variant="danger"
disabled={ !filteredUsersWithRights.size }
onClick={ () => roomData && guardedSend('removeAll', new RemoveAllRightsMessageComposer(roomData.roomId)) }>
{ LocalizeText('navigator.flatctrls.clear') }
</Button>
<Button
variant="danger"
disabled={ !filteredUsersWithRights.size }
onClick={ () => roomData && guardedSend('removeAll', new RemoveAllRightsMessageComposer(roomData.roomId)) }>
{ LocalizeText('navigator.flatctrls.clear') }
</Button>
</NavigatorRoomSettingsSectionView>
</Column>
<Column size={ 6 }>
<Text bold>
{ LocalizeText(
<NavigatorRoomSettingsSectionView gap={ 1 } className="h-full"
title={ LocalizeText(
'navigator.flatctrls.friends',
[ 'displayed', 'total' ],
[
friendsWithoutRights.length.toString(),
allFriends.length.toString()
]
) }
</Text>
<Flex overflow="hidden" className="nitro-card-panel p-2 list-container">
<Column fullWidth overflow="auto" gap={ 1 }>
{ friendsWithoutRights.map((friend, index) =>
{
return (
<Flex key={ `${friend.id}-${index}` } shrink alignItems="center" gap={ 1 } overflow="hidden">
<UserProfileIconView userId={ friend.id } />
<Text
pointer
grow
onClick={ () => guardedSend(`give_${friend.id}`, new RoomGiveRightsComposer(friend.id)) }>
{ friend.name }
</Text>
</Flex>
);
}) }
</Column>
</Flex>
) }>
<Flex overflow="hidden" className="nitro-card-panel p-2 list-container">
<Column fullWidth overflow="auto" gap={ 1 }>
{ friendsWithoutRights.map((friend, index) =>
{
return (
<Flex key={ `${friend.id}-${index}` } shrink alignItems="center" gap={ 1 } overflow="hidden">
<UserProfileIconView userId={ friend.id } />
<Text
pointer
grow
onClick={ () => guardedSend(`give_${friend.id}`, new RoomGiveRightsComposer(friend.id)) }>
{ friend.name }
</Text>
</Flex>
);
}) }
</Column>
</Flex>
</NavigatorRoomSettingsSectionView>
</Column>
</Grid>
);
@@ -0,0 +1,22 @@
import { FC, ReactNode } from 'react';
import { Column, Text } from '../../../../common';
interface NavigatorRoomSettingsSectionViewProps
{
title?: string;
gap?: 1 | 2 | 3;
className?: string;
children: ReactNode;
}
export const NavigatorRoomSettingsSectionView: FC<NavigatorRoomSettingsSectionViewProps> = props =>
{
const { title = null, gap = 2, className = '', children = null } = props;
return (
<Column gap={ gap } className={ `rounded bg-gray-100 p-3 ${ className }`.trim() }>
{ title && <Text bold small>{ title }</Text> }
{ children }
</Column>
);
};
@@ -3,6 +3,7 @@ import { FC, useEffect, useState } from 'react';
import { GetClubMemberLevel, IRoomData, LocalizeText } from '../../../../api';
import { Column, Grid, Text } from '../../../../common';
import { NitroInput } from '../../../../layout';
import { NavigatorRoomSettingsSectionView } from './NavigatorRoomSettingsSectionView';
interface NavigatorRoomSettingsTabViewProps
{
@@ -29,48 +30,50 @@ export const NavigatorRoomSettingsVipChatTabView: FC<NavigatorRoomSettingsTabVie
</div>
<Grid className={ !isHC ? 'opacity-50 pointer-events-none' : '' } overflow="auto">
<Column gap={ 1 } size={ 6 }>
<Text small bold>{ LocalizeText('navigator.roomsettings.chat_settings') }</Text>
<Text small>{ LocalizeText('navigator.roomsettings.chat_settings.info') }</Text>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.chatSettings.mode } onChange={ event => handleChange('bubble_mode', event.target.value) }>
<option value={ RoomChatSettings.CHAT_MODE_FREE_FLOW }>{ LocalizeText('navigator.roomsettings.chat.mode.free.flow') }</option>
<option value={ RoomChatSettings.CHAT_MODE_LINE_BY_LINE }>{ LocalizeText('navigator.roomsettings.chat.mode.line.by.line') }</option>
</select>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.chatSettings.weight } onChange={ event => handleChange('chat_weight', event.target.value) }>
<option value={ RoomChatSettings.CHAT_BUBBLE_WIDTH_NORMAL }>{ LocalizeText('navigator.roomsettings.chat.bubbles.width.normal') }</option>
<option value={ RoomChatSettings.CHAT_BUBBLE_WIDTH_THIN }>{ LocalizeText('navigator.roomsettings.chat.bubbles.width.thin') }</option>
<option value={ RoomChatSettings.CHAT_BUBBLE_WIDTH_WIDE }>{ LocalizeText('navigator.roomsettings.chat.bubbles.width.wide') }</option>
</select>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.chatSettings.speed } onChange={ event => handleChange('bubble_speed', event.target.value) }>
<option value={ RoomChatSettings.CHAT_SCROLL_SPEED_FAST }>{ LocalizeText('navigator.roomsettings.chat.speed.fast') }</option>
<option value={ RoomChatSettings.CHAT_SCROLL_SPEED_NORMAL }>{ LocalizeText('navigator.roomsettings.chat.speed.normal') }</option>
<option value={ RoomChatSettings.CHAT_SCROLL_SPEED_SLOW }>{ LocalizeText('navigator.roomsettings.chat.speed.slow') }</option>
</select>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.chatSettings.protection } onChange={ event => handleChange('flood_protection', event.target.value) }>
<option value={ RoomChatSettings.FLOOD_FILTER_LOOSE }>{ LocalizeText('navigator.roomsettings.chat.flood.loose') }</option>
<option value={ RoomChatSettings.FLOOD_FILTER_NORMAL }>{ LocalizeText('navigator.roomsettings.chat.flood.normal') }</option>
<option value={ RoomChatSettings.FLOOD_FILTER_STRICT }>{ LocalizeText('navigator.roomsettings.chat.flood.strict') }</option>
</select>
<Text small>{ LocalizeText('navigator.roomsettings.chat_settings.hearing.distance') }</Text>
<NitroInput className="form-control-sm" disabled={ !isHC } min="0" type="number" value={ chatDistance } onBlur={ event => handleChange('chat_distance', chatDistance) } onChange={ event => setChatDistance(event.target.valueAsNumber) } />
<NavigatorRoomSettingsSectionView title={ LocalizeText('navigator.roomsettings.chat_settings') } gap={ 1 } className="h-full">
<Text small>{ LocalizeText('navigator.roomsettings.chat_settings.info') }</Text>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.chatSettings.mode } onChange={ event => handleChange('bubble_mode', event.target.value) }>
<option value={ RoomChatSettings.CHAT_MODE_FREE_FLOW }>{ LocalizeText('navigator.roomsettings.chat.mode.free.flow') }</option>
<option value={ RoomChatSettings.CHAT_MODE_LINE_BY_LINE }>{ LocalizeText('navigator.roomsettings.chat.mode.line.by.line') }</option>
</select>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.chatSettings.weight } onChange={ event => handleChange('chat_weight', event.target.value) }>
<option value={ RoomChatSettings.CHAT_BUBBLE_WIDTH_NORMAL }>{ LocalizeText('navigator.roomsettings.chat.bubbles.width.normal') }</option>
<option value={ RoomChatSettings.CHAT_BUBBLE_WIDTH_THIN }>{ LocalizeText('navigator.roomsettings.chat.bubbles.width.thin') }</option>
<option value={ RoomChatSettings.CHAT_BUBBLE_WIDTH_WIDE }>{ LocalizeText('navigator.roomsettings.chat.bubbles.width.wide') }</option>
</select>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.chatSettings.speed } onChange={ event => handleChange('bubble_speed', event.target.value) }>
<option value={ RoomChatSettings.CHAT_SCROLL_SPEED_FAST }>{ LocalizeText('navigator.roomsettings.chat.speed.fast') }</option>
<option value={ RoomChatSettings.CHAT_SCROLL_SPEED_NORMAL }>{ LocalizeText('navigator.roomsettings.chat.speed.normal') }</option>
<option value={ RoomChatSettings.CHAT_SCROLL_SPEED_SLOW }>{ LocalizeText('navigator.roomsettings.chat.speed.slow') }</option>
</select>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.chatSettings.protection } onChange={ event => handleChange('flood_protection', event.target.value) }>
<option value={ RoomChatSettings.FLOOD_FILTER_LOOSE }>{ LocalizeText('navigator.roomsettings.chat.flood.loose') }</option>
<option value={ RoomChatSettings.FLOOD_FILTER_NORMAL }>{ LocalizeText('navigator.roomsettings.chat.flood.normal') }</option>
<option value={ RoomChatSettings.FLOOD_FILTER_STRICT }>{ LocalizeText('navigator.roomsettings.chat.flood.strict') }</option>
</select>
<Text small>{ LocalizeText('navigator.roomsettings.chat_settings.hearing.distance') }</Text>
<NitroInput className="form-control-sm" disabled={ !isHC } min="0" type="number" value={ chatDistance } onBlur={ event => handleChange('chat_distance', chatDistance) } onChange={ event => setChatDistance(event.target.valueAsNumber) } />
</NavigatorRoomSettingsSectionView>
</Column>
<Column gap={ 1 } size={ 6 }>
<Text small bold>{ LocalizeText('navigator.roomsettings.vip_settings') }</Text>
<div className="flex items-center gap-1">
<input checked={ roomData.hideWalls } className="form-check-input" disabled={ !isHC } type="checkbox" onChange={ event => handleChange('hide_walls', event.target.checked) } />
<Text small>{ LocalizeText('navigator.roomsettings.hide_walls') }</Text>
</div>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.wallThickness } onChange={ event => handleChange('wall_thickness', event.target.value) }>
<option value="0">{ LocalizeText('navigator.roomsettings.wall_thickness.normal') }</option>
<option value="1">{ LocalizeText('navigator.roomsettings.wall_thickness.thick') }</option>
<option value="-1">{ LocalizeText('navigator.roomsettings.wall_thickness.thin') }</option>
<option value="-2">{ LocalizeText('navigator.roomsettings.wall_thickness.thinnest') }</option>
</select>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.floorThickness } onChange={ event => handleChange('floor_thickness', event.target.value) }>
<option value="0">{ LocalizeText('navigator.roomsettings.floor_thickness.normal') }</option>
<option value="1">{ LocalizeText('navigator.roomsettings.floor_thickness.thick') }</option>
<option value="-1">{ LocalizeText('navigator.roomsettings.floor_thickness.thin') }</option>
<option value="-2">{ LocalizeText('navigator.roomsettings.floor_thickness.thinnest') }</option>
</select>
<NavigatorRoomSettingsSectionView title={ LocalizeText('navigator.roomsettings.vip_settings') } gap={ 1 } className="h-full">
<div className="flex items-center gap-1">
<input checked={ roomData.hideWalls } className="form-check-input" disabled={ !isHC } type="checkbox" onChange={ event => handleChange('hide_walls', event.target.checked) } />
<Text small>{ LocalizeText('navigator.roomsettings.hide_walls') }</Text>
</div>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.wallThickness } onChange={ event => handleChange('wall_thickness', event.target.value) }>
<option value="0">{ LocalizeText('navigator.roomsettings.wall_thickness.normal') }</option>
<option value="1">{ LocalizeText('navigator.roomsettings.wall_thickness.thick') }</option>
<option value="-1">{ LocalizeText('navigator.roomsettings.wall_thickness.thin') }</option>
<option value="-2">{ LocalizeText('navigator.roomsettings.wall_thickness.thinnest') }</option>
</select>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.floorThickness } onChange={ event => handleChange('floor_thickness', event.target.value) }>
<option value="0">{ LocalizeText('navigator.roomsettings.floor_thickness.normal') }</option>
<option value="1">{ LocalizeText('navigator.roomsettings.floor_thickness.thick') }</option>
<option value="-1">{ LocalizeText('navigator.roomsettings.floor_thickness.thin') }</option>
<option value="-2">{ LocalizeText('navigator.roomsettings.floor_thickness.thinnest') }</option>
</select>
</NavigatorRoomSettingsSectionView>
</Column>
</Grid>
</>
@@ -0,0 +1,33 @@
import { FC } from 'react';
import { FaPlus, FaSearch } from 'react-icons/fa';
import { LocalizeText } from '../../../../api';
import { Button } from '../../../../common';
interface NavigatorEmptyStateViewProps
{
code: string;
onCreateRoom: () => void;
}
export const NavigatorEmptyStateView: FC<NavigatorEmptyStateViewProps> = props =>
{
const { code, onCreateRoom } = props;
const isMyWorld = (code === 'myworld_view');
const messageKey = isMyWorld ? 'navigator.roomsettings.moderation.none' : 'navigator.search.returned.no.results';
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-8 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-black/5 text-muted">
<FaSearch size={ 26 } className="opacity-40" />
</div>
<div className="text-sm text-muted max-w-[240px]">
{ LocalizeText(messageKey) }
</div>
<Button variant="primary" onClick={ onCreateRoom }>
<FaPlus className="fa-icon me-1" />
{ LocalizeText('navigator.createroom.create') }
</Button>
</div>
);
};
@@ -0,0 +1,32 @@
import { FC } from 'react';
import { LocalizeText, SearchFilterOptions } from '../../../../api';
interface NavigatorFilterChipsViewProps
{
value: number;
onChange: (index: number) => void;
}
export const NavigatorFilterChipsView: FC<NavigatorFilterChipsViewProps> = props =>
{
const { value, onChange } = props;
return (
<div className="flex flex-wrap gap-1">
{ SearchFilterOptions.map((filter, index) =>
{
const isActive = (value === index);
return (
<button
key={ index }
type="button"
onClick={ () => onChange(index) }
className={ `px-2 py-0.5 rounded-full text-[11px] border cursor-pointer transition-colors ${ isActive ? 'bg-primary text-white border-primary' : 'bg-card-grid-item text-gray-600 border-card-grid-item-border hover:bg-primary hover:text-white hover:border-primary' }` }>
{ LocalizeText('navigator.filter.' + filter.name) }
</button>
);
}) }
</div>
);
};
@@ -2,9 +2,9 @@ import { RoomDataParser, RoomSettingsComposer, UpdateHomeRoomMessageComposer } f
import * as Popover from '@radix-ui/react-popover';
import React, { FC, useRef, useState } from 'react';
import { FaUser } from 'react-icons/fa';
import { GetGroupInformation, GetSessionDataManager, GetUserProfile, LocalizeText, ReportType, SendMessageComposer, ToggleFavoriteRoom } from '../../../../api';
import { GetGroupInformation, GetSessionDataManager, GetUserProfile, LocalizeText, ReportType, SendMessageComposer } from '../../../../api';
import { Column, Flex, LayoutBadgeImageView, LayoutRoomThumbnailView, NitroCardContentView, Text, UserProfileIconView } from '../../../../common';
import { useHelp, useNavigator } from '../../../../hooks';
import { useHelp, useNavigatorData, useNavigatorFavourite } from '../../../../hooks';
import { classNames } from '../../../../layout';
interface NavigatorSearchResultItemInfoViewProps
@@ -20,7 +20,8 @@ export const NavigatorSearchResultItemInfoView: FC<NavigatorSearchResultItemInfo
const { roomData = null, isVisible = undefined, onToggle, setIsPopoverActive } = props;
const elementRef = useRef<HTMLDivElement>(null);
const [ internalVisible, setInternalVisible ] = useState(false);
const { navigatorData = null, favouriteRoomIds = [] } = useNavigator();
const { navigatorData } = useNavigatorData();
const { isFavourite, toggle: toggleFavourite } = useNavigatorFavourite(roomData?.roomId);
const { report = null } = useHelp();
const isControlled = isVisible !== undefined;
@@ -63,7 +64,7 @@ export const NavigatorSearchResultItemInfoView: FC<NavigatorSearchResultItemInfo
report(ReportType.ROOM, { roomId: roomData.roomId, roomName: roomData.roomName });
return;
case 'room_favourite':
ToggleFavoriteRoom(roomData.roomId, favouriteRoomIds.includes(roomData.roomId));
toggleFavourite();
return;
}
};
@@ -163,7 +164,7 @@ export const NavigatorSearchResultItemInfoView: FC<NavigatorSearchResultItemInfo
</Column>
<Column alignItems="start" gap={ 2 } className="w-2/5">
<Flex pointer alignItems="center" gap={ 2 } onClick={ () => processAction('room_favourite') }>
<i className={ classNames('icon icon-navigator-favorite-room', favouriteRoomIds.includes(roomData.roomId) ? 'active' : '') } />
<i className={ classNames('icon icon-navigator-favorite-room', isFavourite ? 'active' : '') } />
<Text className="text-xs">{ LocalizeText('navigator.room.popup.room.info.favorite') }</Text>
</Flex>
<Flex pointer alignItems="center" gap={ 2 } onClick={ () => processAction('set_home_room') }>
@@ -3,7 +3,7 @@ import React, { FC, MouseEvent, useEffect } from 'react';
import { FaUser } from 'react-icons/fa';
import { CreateRoomSession, DoorStateType, TryVisitRoom } from '../../../../api';
import { Column, Flex, LayoutBadgeImageView, LayoutGridItemProps, LayoutRoomThumbnailView, Text } from '../../../../common';
import { useNavigator } from '../../../../hooks';
import { useDoorState } from '../../../../hooks';
import { NavigatorSearchResultItemInfoView } from './NavigatorSearchResultItemInfoView';
export interface NavigatorSearchResultItemViewProps extends LayoutGridItemProps
@@ -19,7 +19,7 @@ export interface NavigatorSearchResultItemViewProps extends LayoutGridItemProps
export const NavigatorSearchResultItemView: FC<NavigatorSearchResultItemViewProps> = props =>
{
const { roomData = null, children = null, thumbnail = false, selectedRoomId, setSelectedRoomId, isPopoverActive, setIsPopoverActive, ...rest } = props;
const { setDoorData = null } = useNavigator();
const { setSnapshot: setDoorData } = useDoorState();
const handleMouseEnter = () =>
{
@@ -3,7 +3,7 @@ import { FC, useEffect, useState } from 'react';
import { FaBars, FaMinus, FaPlus, FaTh, FaWindowMaximize, FaWindowRestore } from 'react-icons/fa';
import { LocalizeText, NavigatorSearchResultViewDisplayMode, SendMessageComposer } from '../../../../api';
import { AutoGrid, AutoGridProps, Column, Flex, Grid, LayoutSearchSavesView, Text } from '../../../../common';
import { useNavigator } from '../../../../hooks';
import { useNavigatorData } from '../../../../hooks';
import { NavigatorSearchResultItemView } from './NavigatorSearchResultItemView';
export interface NavigatorSearchResultViewProps extends AutoGridProps
@@ -19,7 +19,7 @@ export const NavigatorSearchResultView: FC<NavigatorSearchResultViewProps> = pro
const [ selectedRoomId, setSelectedRoomId ] = useState<number | null>(null);
const [ isPopoverActive, setIsPopoverActive ] = useState<boolean>(false);
const { topLevelContext = null } = useNavigator();
const { topLevelContext } = useNavigatorData();
const getResultTitle = () =>
{
@@ -1,5 +1,6 @@
import { NavigatorDeleteSavedSearchComposer, NavigatorSavedSearch, NavigatorSearchComposer } from '@nitrots/nitro-renderer';
import { FC, useState } from 'react';
import { FC, MouseEvent } from 'react';
import { FaBolt } from 'react-icons/fa';
import { LocalizeText, SendMessageComposer } from '../../../../api';
import { Flex, Text } from '../../../../common';
@@ -11,7 +12,6 @@ export interface NavigatorSearchSavesResultItemViewProps
export const NavigatorSearchSavesResultItemView: FC<NavigatorSearchSavesResultItemViewProps> = props =>
{
const { search = null } = props;
const [ isHovered, setIsHovered ] = useState(false);
const getResultTitle = () =>
{
@@ -24,23 +24,33 @@ export const NavigatorSearchSavesResultItemView: FC<NavigatorSearchSavesResultIt
return ('navigator.searchcode.title.' + name);
};
const openSearch = () => SendMessageComposer(new NavigatorSearchComposer(search.code.split('.').reverse()[0], search.filter));
const deleteSearch = (event: MouseEvent) =>
{
event.stopPropagation();
SendMessageComposer(new NavigatorDeleteSavedSearchComposer(search.id));
};
return (
<Flex grow pointer alignItems="center" gap={ 1 } onMouseEnter={ () => setIsHovered(true) } onMouseLeave={ () => setIsHovered(false) }>
{ isHovered &&
<i
className="nitro-icon icon-navigator-search-delete cursor-pointer flex-shrink-0"
title={ LocalizeText('navigator.tooltip.remove.saved.search') }
onClick={ () => SendMessageComposer(new NavigatorDeleteSavedSearchComposer(search.id)) }
/> }
<Text
small
pointer
variant="black"
title={ LocalizeText('navigator.tooltip.open.saved.search') }
onClick={ () => SendMessageComposer(new NavigatorSearchComposer(search.code.split('.').reverse()[0], search.filter)) }
>
<Flex
grow
pointer
alignItems="center"
gap={ 1 }
className="saved-search-row group px-1 py-0.5"
title={ LocalizeText('navigator.tooltip.open.saved.search') }
onClick={ openSearch }
>
<FaBolt className="text-orange-500 shrink-0 text-[10px]" />
<Text small pointer truncate variant="black" className="grow! min-w-0">
{ LocalizeText(getResultTitle()) }
</Text>
<i
className="nitro-icon icon-navigator-search-delete cursor-pointer flex-shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
title={ LocalizeText('navigator.tooltip.remove.saved.search') }
onClick={ deleteSearch }
/>
</Flex>
);
};
@@ -15,16 +15,19 @@ export const NavigatorSearchSavesResultView: FC<NavigatorSearchSavesResultViewPr
const { searches = [] } = props;
return (
<Column className="nitro-navigator-search-saves-result min-w-[100px]">
<Flex className="rounded px-2 py-1 bg-orange-500" gap={ 1 } alignItems="center">
<Column className="nitro-navigator-search-saves-result h-full min-w-[100px] sm:w-[150px]" gap={ 1 }>
<Flex className="rounded px-2 py-1 bg-orange-500 shrink-0" gap={ 1 } alignItems="center">
<FaBolt color="white" />
<Text variant="white">{ LocalizeText('navigator.quick.links.title') }</Text>
<Text variant="white" truncate>{ LocalizeText('navigator.quick.links.title') }</Text>
</Flex>
<Column className="p-1 overflow-x-hidden overflow-y-auto">
{ (searches && searches.length > 0) &&
searches.map((search: NavigatorSavedSearch) => (
<Column className="flex-1 min-h-0 p-1 overflow-x-hidden overflow-y-auto" gap={ 0 }>
{ (searches && searches.length > 0)
? searches.map((search: NavigatorSavedSearch) => (
<NavigatorSearchSavesResultItemView key={ search.id } search={ search } />
)) }
))
: <Flex center className="py-4 opacity-30">
<FaBolt className="text-orange-500" size={ 22 } />
</Flex> }
</Column>
</Column>
);
@@ -0,0 +1,25 @@
import { FC } from 'react';
interface NavigatorSearchSkeletonViewProps
{
rows?: number;
}
export const NavigatorSearchSkeletonView: FC<NavigatorSearchSkeletonViewProps> = props =>
{
const { rows = 5 } = props;
return (
<div className="flex flex-col gap-2" aria-hidden="true">
{ Array.from({ length: rows }).map((_, index) =>
<div key={ index } className="nitro-card-panel flex items-center gap-2 px-2 py-2">
<div className="h-10 w-10 shrink-0 rounded bg-black/10 animate-pulse" />
<div className="flex flex-1 flex-col gap-1">
<div className="h-3 w-1/2 rounded bg-black/10 animate-pulse" />
<div className="h-2.5 w-1/3 rounded bg-black/10 animate-pulse" />
</div>
<div className="h-4 w-8 shrink-0 rounded bg-black/10 animate-pulse" />
</div>) }
</div>
);
};
@@ -1,38 +1,26 @@
import { FC, KeyboardEvent, useEffect, useState } from 'react';
import { NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
import { FC, useEffect, useRef, useState } from 'react';
import { FaSearch } from 'react-icons/fa';
import { INavigatorSearchFilter, LocalizeText, SearchFilterOptions } from '../../../../api';
import { Button } from '../../../../common';
import { useNavigator } from '../../../../hooks';
import { useNavigatorData, useNavigatorUiStore } from '../../../../hooks';
import { NavigatorFilterChipsView } from './NavigatorFilterChipsView';
export const NavigatorSearchView: FC<{
sendSearch: (searchValue: string, contextCode: string) => void;
}> = props =>
interface NavigatorSearchViewProps
{
const { sendSearch = null } = props;
searchResult: NavigatorSearchResultSet | null;
}
export const NavigatorSearchView: FC<NavigatorSearchViewProps> = props =>
{
const { searchResult } = props;
const [ searchFilterIndex, setSearchFilterIndex ] = useState(0);
const [ searchValue, setSearchValue ] = useState('');
const { topLevelContext = null, searchResult = null } = useNavigator();
const processSearch = () =>
{
if(!topLevelContext) return;
let searchFilter = SearchFilterOptions[searchFilterIndex];
if(!searchFilter) searchFilter = SearchFilterOptions[0];
const searchQuery = ((searchFilter.query ? (searchFilter.query + ':') : '') + searchValue);
sendSearch((searchQuery || ''), topLevelContext.code);
};
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) =>
{
if(event.key !== 'Enter') return;
processSearch();
};
const [ inputText, setInputText ] = useState('');
const formRef = useRef<HTMLFormElement>(null);
const { topLevelContext } = useNavigatorData();
// Sync the input text display when a server result arrives (e.g. on tab switch
// or deep-link navigation that sets the filter through the store directly).
useEffect(() =>
{
if(!searchResult) return;
@@ -57,25 +45,44 @@ export const NavigatorSearchView: FC<{
if(!filter) filter = SearchFilterOptions[0];
setSearchFilterIndex(SearchFilterOptions.findIndex(option => (option === filter)));
setSearchValue(value);
setInputText(value);
}, [ searchResult ]);
// Debounced filter — 300ms after the user stops typing, push to the store
// which updates the query key and triggers a refetch.
useEffect(() =>
{
const timer = setTimeout(() =>
{
const searchFilter = SearchFilterOptions[searchFilterIndex] ?? SearchFilterOptions[0];
const searchQuery = (searchFilter.query ? (searchFilter.query + ':') : '') + inputText;
useNavigatorUiStore.getState().setFilter(searchQuery);
}, 300);
return () => clearTimeout(timer);
}, [ inputText, searchFilterIndex ]);
// React 19 form action — fires on Enter or the submit button, skipping the
// debounce timer for an immediate search.
const submitSearch = (formData: FormData) =>
{
if(!topLevelContext) return;
const raw = formData.get('q');
const value = (typeof raw === 'string') ? raw : inputText;
const searchFilter = SearchFilterOptions[searchFilterIndex] ?? SearchFilterOptions[0];
const searchQuery = (searchFilter.query ? (searchFilter.query + ':') : '') + value;
useNavigatorUiStore.getState().setFilter(searchQuery);
};
return (
<div className="flex w-full gap-1">
<div className="flex shrink-0">
<select className="form-select" value={ searchFilterIndex } onChange={ event => setSearchFilterIndex(parseInt(event.target.value)) }>
{ SearchFilterOptions.map((filter, index) =>
{
return <option key={ index } value={ index }>{ LocalizeText('navigator.filter.' + filter.name) }</option>;
}) }
</select>
</div>
<div className="flex w-full gap-1">
<input className="w-full form-control" placeholder={ LocalizeText('navigator.filter.input.placeholder') } type="text" value={ searchValue } onChange={ event => setSearchValue(event.target.value) } onKeyDown={ event => handleKeyDown(event) } />
<Button variant="primary" onClick={ processSearch }>
<div className="flex w-full flex-col gap-1">
<NavigatorFilterChipsView value={ searchFilterIndex } onChange={ setSearchFilterIndex } />
<form ref={ formRef } action={ submitSearch } className="flex w-full gap-1">
<input className="w-full form-control" name="q" placeholder={ LocalizeText('navigator.filter.input.placeholder') } type="text" value={ inputText } onChange={ event => setInputText(event.target.value) } />
<Button variant="primary" onClick={ () => formRef.current?.requestSubmit() }>
<FaSearch className="fa-icon" />
</Button>
</div>
</form>
</div>
);
};
+146
View File
@@ -0,0 +1,146 @@
import { FC, useEffect, useState } from 'react';
import { FaBroadcastTower, FaChevronDown, FaPlay, FaStop } from 'react-icons/fa';
import { LocalizeText } from '../../api';
import { LayoutImage } from '../../common';
import { RadioStation, useRadio } from '../../hooks';
const RADIO_STYLES = `
.radio-widget { font-feature-settings: "tnum"; }
.radio-eq { display: flex; align-items: flex-end; gap: 2px; height: 12px; }
.radio-eq span { width: 3px; height: 30%; border-radius: 2px; background: #38bdf8; opacity: .55; }
.radio-eq.is-live span { opacity: 1; animation: radioEq .9s ease-in-out infinite; }
.radio-eq span:nth-child(2) { animation-delay: .18s; }
.radio-eq span:nth-child(3) { animation-delay: .36s; }
.radio-eq span:nth-child(4) { animation-delay: .12s; }
@keyframes radioEq { 0%, 100% { height: 22%; } 50% { height: 100%; } }
.radio-scroll::-webkit-scrollbar { width: 6px; }
.radio-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,.18); border-radius: 3px; }
.radio-scroll::-webkit-scrollbar-track { background: transparent; }
.radio-vol { accent-color: #38bdf8; }
`;
// Compact, polished top-left radio widget. Shows the selected station with a
// dropdown (3 visible, scrolls if more) to switch. Nudged down so it clears the
// CMS top bar most hotels render there.
export const RadioView: FC<{}> = () =>
{
const { stations, currentId, isPlaying, volume, loadError, play, stop, setVolume } = useRadio();
const [ open, setOpen ] = useState(false);
const [ selectedId, setSelectedId ] = useState<string | null>(null);
useEffect(() =>
{
if(!selectedId && stations.length) setSelectedId(stations[0].id);
}, [ stations, selectedId ]);
const selected: RadioStation | null = stations.find(s => s.id === selectedId) ?? stations[0] ?? null;
const selectedPlaying = !!selected && (currentId === selected.id) && isPlaying;
const onPlayToggle = () =>
{
if(!selected) return;
if(selectedPlaying) stop();
else play(selected);
};
const onPick = (station: RadioStation) =>
{
setSelectedId(station.id);
setOpen(false);
play(station);
};
return (
<div className="radio-widget fixed left-2 top-12 z-40 w-[244px] max-w-[64vw] select-none overflow-hidden rounded-xl border border-white/10 bg-gradient-to-b from-[rgba(22,24,30,0.94)] to-[rgba(10,11,14,0.94)] text-white shadow-[0_8px_24px_rgba(0,0,0,0.4)] backdrop-blur-sm">
<style>{ RADIO_STYLES }</style>
<div className="flex items-center gap-2 border-b border-white/10 px-3 py-1.5">
<FaBroadcastTower className={ `text-[11px] ${ isPlaying ? 'text-sky-400' : 'text-white/45' }` } />
<span className="grow text-[10px] font-bold uppercase tracking-[0.14em] text-white/55">{ LocalizeText('radio.title') }</span>
<div className={ `radio-eq ${ isPlaying ? 'is-live' : '' }` }>
<span /><span /><span /><span />
</div>
</div>
<div className="flex items-center gap-2.5 px-3 py-2.5">
<button
type="button"
onClick={ onPlayToggle }
disabled={ !selected }
title={ selectedPlaying ? LocalizeText('radio.stop') : LocalizeText('radio.title') }
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-500 text-xs text-white shadow-inner transition-all hover:bg-emerald-400 disabled:opacity-40">
{ selectedPlaying ? <FaStop /> : <FaPlay className="translate-x-px" /> }
</button>
<div className="min-w-0 grow">
<div className="truncate text-sm font-bold leading-tight">{ selected ? selected.name : LocalizeText('radio.title') }</div>
<div className="mt-0.5 flex items-center gap-1.5">
{ selectedPlaying &&
<span className="flex items-center gap-1 text-[9px] font-bold uppercase tracking-wide text-sky-400">
<span className="h-1.5 w-1.5 rounded-full bg-sky-400" /> { LocalizeText('radio.live') }
</span> }
{ selected?.genre &&
<span className="truncate text-[10px] text-white/45">{ selected.genre }</span> }
</div>
</div>
<button
type="button"
onClick={ () => setOpen(value => !value) }
title={ LocalizeText('radio.title') }
className={ `flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition-colors ${ open ? 'bg-white/20' : 'bg-white/8 hover:bg-white/15' }` }>
<FaChevronDown className={ `text-[10px] transition-transform ${ open ? 'rotate-180' : '' }` } />
</button>
</div>
{ selectedPlaying &&
<div className="flex items-center gap-2 px-3 pb-2.5">
<span className="text-xs text-white/55">🔊</span>
<input
type="range"
min={ 0 }
max={ 1 }
step={ 0.01 }
value={ volume }
onChange={ e => setVolume(e.target.valueAsNumber) }
className="radio-vol h-1 grow cursor-pointer"
/>
</div> }
{ open &&
<div className="border-t border-white/10 bg-black/20 p-1.5">
{ loadError &&
<div className="px-2 py-2 text-[11px] text-red-400">{ LocalizeText('radio.error') }</div> }
{ !loadError && !stations.length &&
<div className="px-2 py-2 text-[11px] text-white/50">{ LocalizeText('radio.empty') }</div> }
{ /* ~3 rows tall, scrolls when there are more */ }
<div className="radio-scroll flex max-h-[156px] flex-col gap-1 overflow-y-auto pr-0.5">
{ stations.map(station =>
{
const isActive = station.id === selectedId;
const playingThis = (currentId === station.id) && isPlaying;
return (
<div
key={ station.id }
onClick={ () => onPick(station) }
className={ `flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors ${ isActive ? 'bg-sky-500/15 ring-1 ring-sky-400/40' : 'hover:bg-white/8' }` }>
{ station.logo
? <LayoutImage imageUrl={ station.logo } className="h-7 w-7 shrink-0 rounded bg-contain bg-center bg-no-repeat" />
: <div className={ `flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-[11px] ${ playingThis ? 'bg-sky-500/80' : 'bg-white/10' }` }>
{ playingThis ? <FaStop /> : <FaPlay className="translate-x-px" /> }
</div> }
<div className="min-w-0 grow">
<div className="truncate text-xs font-bold leading-tight">{ station.name }</div>
{ station.genre &&
<div className="truncate text-[10px] text-white/45">{ station.genre }</div> }
</div>
{ playingThis &&
<div className="radio-eq is-live shrink-0">
<span /><span /><span /><span />
</div> }
</div>
);
}) }
</div>
</div> }
</div>
);
};
@@ -0,0 +1,179 @@
import { AddLinkEventTracker, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, IRareValue, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { LocalizeFormattedNumber, LocalizeText } from '../../api';
import { Column, Flex, LayoutCurrencyIcon, LayoutImage, Text } from '../../common';
import { useRareValues } from '../../hooks';
import { NitroCard, NitroInput } from '../../layout';
const PAGE_SIZE = 50;
interface RareValueRow
{
spriteId: number;
name: string;
iconUrl: string;
value: IRareValue;
}
export const RareValuesView: FC<{}> = () =>
{
const [ isVisible, setIsVisible ] = useState(false);
const [ searchValue, setSearchValue ] = useState('');
const [ visibleCount, setVisibleCount ] = useState(PAGE_SIZE);
const { values = null, loaded = false } = useRareValues();
const scrollRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
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(prev => !prev); return;
}
},
eventUrlPrefix: 'rare-values/',
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
const rows = useMemo<RareValueRow[]>(() =>
{
if(!values) return [];
const list: RareValueRow[] = [];
values.forEach((value, spriteId) =>
{
if(value.points <= 0) return;
const floorData = GetSessionDataManager().getFloorItemData(spriteId);
const wallData = floorData ? null : GetSessionDataManager().getWallItemData(spriteId);
const data = (floorData ?? wallData);
if(!data) return;
const iconUrl = (floorData
? GetRoomEngine().getFurnitureFloorIconUrl(spriteId)
: GetRoomEngine().getFurnitureWallIconUrl(spriteId));
list.push({ spriteId, name: (data.name || data.className || `#${ spriteId }`), iconUrl, value });
});
list.sort((a, b) => (b.value.points - a.value.points));
return list;
}, [ values ]);
const filtered = useMemo<RareValueRow[]>(() =>
{
const query = searchValue.trim().toLocaleLowerCase();
if(!query) return rows;
return rows.filter(row => row.name.toLocaleLowerCase().includes(query));
}, [ rows, searchValue ]);
// Reset paging when the underlying list changes (typed in search, data loaded).
useEffect(() =>
{
setVisibleCount(PAGE_SIZE);
if(scrollRef.current) scrollRef.current.scrollTop = 0;
}, [ filtered ]);
// Infinite scroll: grow visibleCount by PAGE_SIZE whenever the sentinel
// enters the viewport. The root is the scroll container so the trigger
// fires reliably inside an in-app modal (no document scroll).
useEffect(() =>
{
if(!isVisible) return;
if(visibleCount >= filtered.length) return;
const sentinel = sentinelRef.current;
const root = scrollRef.current;
if(!sentinel || !root) return;
const observer = new IntersectionObserver(entries =>
{
if(entries.some(entry => entry.isIntersecting))
{
setVisibleCount(prev => Math.min(prev + PAGE_SIZE, filtered.length));
}
}, { root, rootMargin: '120px 0px' });
observer.observe(sentinel);
return () => observer.disconnect();
}, [ isVisible, visibleCount, filtered.length ]);
if(!isVisible) return null;
const visibleRows = filtered.slice(0, visibleCount);
const hasMore = visibleCount < filtered.length;
return (
<NitroCard className="w-[460px] h-[520px]" uniqueKey="rare-values">
<NitroCard.Header
headerText={ LocalizeText('rarevalues.title') }
onCloseClick={ () => setIsVisible(false) } />
<NitroCard.Content>
<Column gap={ 2 } className="h-full p-2">
<Flex alignItems="center" gap={ 2 } className="rounded border border-black/10 bg-white px-2 py-1 shadow-inner">
<span className="text-black/40">🔍</span>
<NitroInput
placeholder={ LocalizeText('generic.search') }
value={ searchValue }
onChange={ event => setSearchValue(event.target.value) }
className="grow !border-0 !bg-transparent !p-0 !shadow-none focus:!ring-0" />
</Flex>
{ loaded &&
<Flex alignItems="center" justifyContent="between" className="px-1 text-[11px] text-black/55">
<span>{ filtered.length } { LocalizeText('rarevalues.title').toLowerCase() }</span>
{ hasMore && <span>{ visibleRows.length } / { filtered.length }</span> }
</Flex> }
<div
ref={ scrollRef }
className="grow overflow-auto rounded border border-black/10 bg-gradient-to-b from-[#f6f8fb] to-[#eaf1f6] shadow-inner">
{ !loaded &&
<div className="p-6 text-center">
<Text className="text-black/55">{ LocalizeText('rarevalues.loading') }</Text>
</div> }
{ (loaded && !filtered.length) &&
<div className="p-6 text-center">
<Text className="text-black/55">{ LocalizeText('rarevalues.empty') }</Text>
</div> }
{ visibleRows.map((row, index) => (
<Flex
key={ row.spriteId }
alignItems="center"
gap={ 2 }
className={ `border-b border-black/[0.06] px-2 py-1.5 transition-colors hover:bg-[#cfe4f1] ${ index % 2 ? 'bg-white/60' : 'bg-[#e9f1f7]' }` }>
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded border border-black/10 bg-white shadow-sm">
<LayoutImage imageUrl={ row.iconUrl } className="h-9 w-9 bg-contain bg-center bg-no-repeat" />
</div>
<Text truncate className="grow text-[13px] font-medium text-[#1f2d34]">{ row.name }</Text>
<Flex alignItems="center" gap={ 1 } className="shrink-0 rounded-full bg-white/80 px-2 py-0.5 shadow-sm">
<Text bold textEnd className="text-[13px] text-[#2f6f95]">{ LocalizeFormattedNumber(row.value.points) }</Text>
<LayoutCurrencyIcon type={ row.value.pointsType } />
</Flex>
</Flex>
)) }
{ hasMore &&
<div ref={ sentinelRef } className="flex items-center justify-center py-3">
<Text small className="text-black/45">{ LocalizeText('rarevalues.loading.more') }</Text>
</div> }
</div>
</Column>
</NitroCard.Content>
</NitroCard>
);
};
@@ -0,0 +1,147 @@
import { GetRoomEngine, RoomObjectCategory, RoomObjectVariable } from '@nitrots/nitro-renderer';
import { FC, PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState } from 'react';
import { LocalizeText } from '../../../../../api';
import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../../common';
const PAD_W = 230;
const PAD_H = 150;
// How many offset units one pixel of drag represents.
const UNITS_PER_PX = 1.5;
interface Props
{
roomId: number;
objectId: number;
isWallItem: boolean;
initialX: number;
initialY: number;
initialZ: number;
initialScale: number;
onClose: () => void;
onSave: (x: number, y: number, z: number, scale: number) => void;
}
export const ImagePositionEditorView: FC<Props> = props =>
{
const { roomId, objectId, isWallItem, initialX, initialY, initialZ, initialScale, onClose, onSave } = props;
const [ x, setX ] = useState(initialX);
const [ y, setY ] = useState(initialY);
const [ z, setZ ] = useState(initialZ);
const [ scale, setScale ] = useState(initialScale || 100);
const padRef = useRef<HTMLDivElement>(null);
const draggingRef = useRef(false);
const category = isWallItem ? RoomObjectCategory.WALL : RoomObjectCategory.FLOOR;
// Local-only live preview: set the branding model values directly. The model
// bumps its update counter so the visualization re-renders next frame.
// Nothing is sent to the server until Save.
const applyLive = useCallback((nx: number, ny: number, nz: number, nScale: number) =>
{
const roomObject = GetRoomEngine().getRoomObject(roomId, objectId, category);
if(!roomObject?.model) return;
roomObject.model.setValue(RoomObjectVariable.FURNITURE_BRANDING_OFFSET_X, nx);
roomObject.model.setValue(RoomObjectVariable.FURNITURE_BRANDING_OFFSET_Y, ny);
roomObject.model.setValue(RoomObjectVariable.FURNITURE_BRANDING_OFFSET_Z, nz);
roomObject.model.setValue(RoomObjectVariable.FURNITURE_BRANDING_SCALE, nScale);
}, [ roomId, objectId, category ]);
useEffect(() => { applyLive(x, y, z, scale); }, [ x, y, z, scale, applyLive ]);
const setFromPointer = useCallback((clientX: number, clientY: number) =>
{
const rect = padRef.current?.getBoundingClientRect();
if(!rect) return;
const cx = rect.left + (rect.width / 2);
const cy = rect.top + (rect.height / 2);
setX(Math.round((clientX - cx) * UNITS_PER_PX));
setY(Math.round((clientY - cy) * UNITS_PER_PX));
}, []);
const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) =>
{
draggingRef.current = true;
padRef.current?.setPointerCapture(event.pointerId);
setFromPointer(event.clientX, event.clientY);
};
const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) =>
{
if(draggingRef.current) setFromPointer(event.clientX, event.clientY);
};
const onPointerUp = (event: ReactPointerEvent<HTMLDivElement>) =>
{
draggingRef.current = false;
padRef.current?.releasePointerCapture?.(event.pointerId);
};
const cancel = () =>
{
applyLive(initialX, initialY, initialZ, initialScale || 100);
onClose();
};
const save = () =>
{
onSave(x, y, z, scale);
onClose();
};
const dotLeft = (PAD_W / 2) + (x / UNITS_PER_PX);
const dotTop = (PAD_H / 2) + (y / UNITS_PER_PX);
const clampedLeft = Math.max(0, Math.min(PAD_W, dotLeft));
const clampedTop = Math.max(0, Math.min(PAD_H, dotTop));
return (
<NitroCardView className="no-resize" uniqueKey="image-position-editor" theme="primary-slim">
<NitroCardHeaderView headerText={ LocalizeText('image.position.editor.title') } onCloseClick={ cancel } />
<NitroCardContentView>
<div className="flex flex-col gap-2">
<span className="text-[11px] text-black/60">{ LocalizeText('image.position.editor.hint') }</span>
<div
ref={ padRef }
onPointerDown={ onPointerDown }
onPointerMove={ onPointerMove }
onPointerUp={ onPointerUp }
className="relative cursor-crosshair self-center rounded border border-black/30 bg-[#1b2733]"
style={ { width: PAD_W, height: PAD_H } }>
{ /* center crosshair */ }
<div className="pointer-events-none absolute left-1/2 top-0 h-full w-px -translate-x-1/2 bg-white/10" />
<div className="pointer-events-none absolute left-0 top-1/2 h-px w-full -translate-y-1/2 bg-white/10" />
{ /* draggable dot */ }
<div
className="pointer-events-none absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-sky-400 shadow-[0_0_6px_rgba(56,189,248,0.8)] ring-2 ring-white/70"
style={ { left: clampedLeft, top: clampedTop } } />
</div>
<div className="flex items-center gap-2">
<span className="w-12 text-[11px] text-black/70">{ LocalizeText('image.position.editor.scale') }</span>
<input type="range" min={ 10 } max={ 500 } step={ 1 } value={ scale } onChange={ e => setScale(e.target.valueAsNumber || 100) } className="grow" />
<span className="w-12 text-right text-[11px] tabular-nums text-black/70">{ (scale / 100).toFixed(2) }x</span>
</div>
<div className="grid grid-cols-3 gap-2">
<label className="flex flex-col gap-0.5 text-[11px] text-black/70">{ LocalizeText('image.position.editor.offsetx') }
<input type="number" value={ x } onChange={ e => setX(e.target.valueAsNumber || 0) } className="form-control form-control-sm" />
</label>
<label className="flex flex-col gap-0.5 text-[11px] text-black/70">{ LocalizeText('image.position.editor.offsety') }
<input type="number" value={ y } onChange={ e => setY(e.target.valueAsNumber || 0) } className="form-control form-control-sm" />
</label>
<label className="flex flex-col gap-0.5 text-[11px] text-black/70">{ LocalizeText('image.position.editor.offsetz') }
<input type="number" value={ z } onChange={ e => setZ(e.target.valueAsNumber || 0) } className="form-control form-control-sm" />
</label>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={ cancel }>{ LocalizeText('image.position.editor.cancel') }</Button>
<Button variant="success" onClick={ save }>{ LocalizeText('save') }</Button>
</div>
</div>
</NitroCardContentView>
</NitroCardView>
);
};
@@ -3,9 +3,10 @@ import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FaCrosshairs, FaTimes } from 'react-icons/fa';
import { GrFormNextLink, GrRotateLeft, GrRotateRight } from 'react-icons/gr';
import { AvatarInfoFurni, GetGroupInformation, LocalizeText, SendMessageComposer } from '../../../../../api';
import { Button, Column, Flex, LayoutBadgeImageView, LayoutLimitedEditionCompactPlateView, LayoutRarityLevelView, LayoutRoomObjectImageView, Text, UserProfileIconView } from '../../../../../common';
import { useHasPermission, useMessageEvent, useNitroEvent, useRoom, useWiredTools } from '../../../../../hooks';
import { Button, Column, Flex, LayoutBadgeImageView, LayoutCurrencyIcon, LayoutLimitedEditionCompactPlateView, LayoutRarityLevelView, LayoutRoomObjectImageView, Text, UserProfileIconView } from '../../../../../common';
import { useHasPermission, useMessageEvent, useNitroEvent, useRareValues, useRoom, useWiredTools } from '../../../../../hooks';
import { NitroInput } from '../../../../../layout';
import { ImagePositionEditorView } from './ImagePositionEditorView';
interface InfoStandWidgetFurniViewProps
{
@@ -23,6 +24,8 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
const { roomSession = null } = useRoom();
const { openInspectionForFurni, showInspectButton } = useWiredTools();
const isModerator = useHasPermission('acc_anyroomowner');
const { getValue: getRareValue } = useRareValues();
const rareValue = useMemo(() => (avatarInfo ? getRareValue(avatarInfo.spriteId) : null), [ avatarInfo, getRareValue ]);
const [ pickupMode, setPickupMode ] = useState(0);
const [ canMove, setCanMove ] = useState(false);
@@ -41,6 +44,7 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
const [ isJukeBox, setIsJukeBox ] = useState<boolean>(false);
const [ isSongDisk, setIsSongDisk ] = useState<boolean>(false);
const [ isBranded, setIsBranded ] = useState<boolean>(false);
const [ showPositionEditor, setShowPositionEditor ] = useState<boolean>(false);
const [ songId, setSongId ] = useState<number>(-1);
const [ songName, setSongName ] = useState<string>('');
const [ songCreator, setSongCreator ] = useState<string>('');
@@ -391,6 +395,45 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
return data;
}, [ furniKeys, furniValues ]);
const getBrandingOffset = useCallback((key: string): number =>
{
const index = furniKeys.indexOf(key);
if(index < 0) return 0;
const value = parseInt(furniValues[index]);
return isNaN(value) ? 0 : value;
}, [ furniKeys, furniValues ]);
const hasBrandingOffsets = isBranded && (furniKeys.indexOf('offsetX') >= 0);
// Persist the position from the editor: rebuild the branding map with the
// new offsets and send it (same path as Save), then reflect it in the fields.
const savePositionEditor = useCallback((x: number, y: number, z: number, scale: number) =>
{
const map = new Map<string, string>();
const clone = Array.from(furniValues);
let hasScale = false;
for(let i = 0; i < furniKeys.length; i++)
{
const key = furniKeys[i];
let value = furniValues[i];
if(key === 'offsetX') value = String(x);
else if(key === 'offsetY') value = String(y);
else if(key === 'offsetZ') value = String(z);
else if(key === 'scale') { value = String(scale); hasScale = true; }
clone[i] = value;
map.set(key, value);
}
// older branding furni may not carry a scale key yet — always send it
if(!hasScale) map.set('scale', String(scale));
GetRoomEngine().modifyRoomObjectDataWithMap(avatarInfo.id, avatarInfo.category, RoomObjectOperationType.OBJECT_SAVE_STUFF_DATA, map);
setFurniValues(clone);
}, [ avatarInfo, furniKeys, furniValues ]);
const processButtonAction = useCallback((action: string) =>
{
if(!action || (action === '')) return;
@@ -563,6 +606,17 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
<Text small textBreak variant="white">X: { itemLocation.x } · Y: { itemLocation.y } · H: { itemLocation.z < 0.01 ? 0 : itemLocation.z }</Text>
</div>
</> }
{ (rareValue && rareValue.points > 0) &&
<>
<hr className="m-0 bg-[#0003] border-0 opacity-[.5] h-px" />
<Flex alignItems="center" gap={ 2 }>
<Text small variant="white">{ LocalizeText('rarevalues.infostand.label') }</Text>
<Flex alignItems="center" gap={ 1 }>
<Text small variant="white">{ rareValue.points }</Text>
<LayoutCurrencyIcon type={ rareValue.pointsType } />
</Flex>
</Flex>
</> }
{ godMode &&
<>
<hr className="m-0 bg-[#0003] border-0 opacity-[.5] h-px" />
@@ -736,6 +790,10 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
<Button variant="dark" onClick={ event => processButtonAction('use') }>
{ LocalizeText('infostand.button.use') }
</Button> }
{ hasBrandingOffsets &&
<Button variant="dark" onClick={ () => setShowPositionEditor(true) }>
{ LocalizeText('image.position.editor.button') }
</Button> }
{ ((furniKeys.length > 0 && furniValues.length > 0) && (furniKeys.length === furniValues.length)) &&
<Button variant="dark" onClick={ () => processButtonAction('save_branding_configuration') }>
{ LocalizeText('save') }
@@ -745,6 +803,17 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
{ LocalizeText('save') }
</Button> }
</Flex>
{ showPositionEditor &&
<ImagePositionEditorView
roomId={ roomSession.roomId }
objectId={ avatarInfo.id }
isWallItem={ avatarInfo.isWallItem }
initialX={ getBrandingOffset('offsetX') }
initialY={ getBrandingOffset('offsetY') }
initialZ={ getBrandingOffset('offsetZ') }
initialScale={ getBrandingOffset('scale') || 100 }
onClose={ () => setShowPositionEditor(false) }
onSave={ savePositionEditor } /> }
</Column>
);
};
@@ -109,6 +109,10 @@ export const AvatarInfoWidgetRentableBotView: FC<AvatarInfoWidgetRentableBotView
case 'dance':
SendMessageComposer(new BotSkillSaveComposer(avatarInfo.webID, BotSkillsEnum.DANCE, ''));
break;
case 'rotate':
SendMessageComposer(new BotSkillSaveComposer(avatarInfo.webID, BotSkillsEnum.ROTATE, ''));
hideMenu = false;
break;
case 'nux_take_tour':
CreateLinkEvent('help/tour');
SendMessageComposer(new BotSkillSaveComposer(avatarInfo.webID, BotSkillsEnum.NUX_TAKE_TOUR, ''));
@@ -170,6 +174,10 @@ export const AvatarInfoWidgetRentableBotView: FC<AvatarInfoWidgetRentableBotView
<ContextMenuListItemView onClick={ event => processAction('dance') }>
{ LocalizeText('avatar.widget.dance') }
</ContextMenuListItemView> }
{ (avatarInfo.botSkills.indexOf(BotSkillsEnum.ROTATE) >= 0) &&
<ContextMenuListItemView onClick={ event => processAction('rotate') }>
{ LocalizeText('tooltip.roombuilding.rotate') }
</ContextMenuListItemView> }
{ (avatarInfo.botSkills.indexOf(BotSkillsEnum.NO_PICK_UP) === -1) &&
<ContextMenuListItemView onClick={ event => processAction('pick') }>
{ LocalizeText('avatar.widget.pick_up') }
@@ -2,7 +2,7 @@ import { UpdateRoomFilterMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useState } from 'react';
import { LocalizeText, SendMessageComposer } from '../../../../api';
import { Button, Column, Flex, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
import { useFilterWordsWidget, useNavigator } from '../../../../hooks';
import { useFilterWordsWidget, useNavigatorData } from '../../../../hooks';
import { NitroInput, classNames } from '../../../../layout';
export const RoomFilterWordsWidgetView: FC<{}> = props =>
@@ -11,7 +11,7 @@ export const RoomFilterWordsWidgetView: FC<{}> = props =>
const [ selectedWord, setSelectedWord ] = useState<string>('');
const [ isSelectingWord, setIsSelectingWord ] = useState<boolean>(false);
const { wordsFilter = [], isVisible = null, setWordsFilter, onClose = null } = useFilterWordsWidget();
const { navigatorData = null } = useNavigator();
const { navigatorData } = useNavigatorData();
const processAction = (isAddingWord: boolean) =>
{
@@ -4,7 +4,7 @@ import { classNames } from '../../../../layout';
import { FC, useEffect, useState } from 'react';
import { GetConfigurationValue, LocalizeText, SendMessageComposer, SetLocalStorage, TryVisitRoom } from '../../../../api';
import { Text } from '../../../../common';
import { useMessageEvent, useNavigator, useRoom } from '../../../../hooks';
import { useMessageEvent, useNavigatorData, useRoom } from '../../../../hooks';
import { getRegisteredPlugins, INitroPlugin, subscribePlugins } from '../../../plugins/NitroPluginApi';
export const RoomToolsWidgetView: FC<{}> = props =>
@@ -18,7 +18,7 @@ export const RoomToolsWidgetView: FC<{}> = props =>
const [isOpenHistory, setIsOpenHistory] = useState<boolean>(false);
const [roomHistory, setRoomHistory] = useState<{ roomId: number, roomName: string }[]>([]);
const [plugins, setPlugins] = useState<INitroPlugin[]>([]);
const { navigatorData = null } = useNavigator();
const { navigatorData } = useNavigatorData();
const { roomSession = null } = useRoom();
// Subscribe to external plugin changes
@@ -0,0 +1,100 @@
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { LocalizeText } from '../../api';
import { Column, Flex, Text } from '../../common';
import { useSoundboard } from '../../hooks';
import { NitroCard } from '../../layout';
export const SoundboardView: FC<{}> = () =>
{
const [ isVisible, setIsVisible ] = useState(false);
const { enabled, sounds, lastPlayed, play } = useSoundboard();
const PAGE_SIZE = 9;
const [ page, setPage ] = useState(0);
const totalPages = Math.max(1, Math.ceil(sounds.length / PAGE_SIZE));
// Clamp the page if the sound list shrinks (or on first load).
useEffect(() =>
{
if(page > (totalPages - 1)) setPage(0);
}, [ totalPages, page ]);
const pageSounds = sounds.slice(page * PAGE_SIZE, (page * PAGE_SIZE) + PAGE_SIZE);
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(prev => !prev); return;
}
},
eventUrlPrefix: 'soundboard/',
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
// The soundboard belongs to the room — close it when the room turns it off.
useEffect(() =>
{
if(!enabled) setIsVisible(false);
}, [ enabled ]);
if(!isVisible || !enabled) return null;
return (
<NitroCard className="w-[420px] max-w-[96vw]" uniqueKey="soundboard">
<NitroCard.Header headerText={ LocalizeText('soundboard.title') } onCloseClick={ () => setIsVisible(false) } />
<NitroCard.Content>
<Column gap={ 2 }>
{ !sounds.length &&
<Text small className="text-black/50">{ LocalizeText('soundboard.empty') }</Text> }
{ !!sounds.length &&
<>
<div className="grid grid-cols-3 gap-2">
{ pageSounds.map(sound => (
<button
key={ sound.id }
onClick={ () => play(sound) }
title={ sound.name }
className="flex h-20 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg bg-[#3a7bb5] px-2 text-white shadow transition-transform hover:bg-[#336ea3] active:scale-95">
<span className="text-2xl leading-none">🔊</span>
<span className="line-clamp-2 text-center text-[11px] font-bold leading-tight">{ sound.name }</span>
</button>
)) }
</div>
{ totalPages > 1 &&
<Flex alignItems="center" justifyContent="center" gap={ 2 } className="select-none pt-1">
<button
disabled={ page === 0 }
onClick={ () => setPage(p => Math.max(0, p - 1)) }
className="cursor-pointer rounded bg-[#3a7bb5] px-3 py-1 text-sm font-bold text-white hover:bg-[#336ea3] disabled:cursor-default disabled:opacity-40"></button>
<Text small bold className="min-w-[44px] text-center text-[#2f6f95]">{ page + 1 } / { totalPages }</Text>
<button
disabled={ page >= (totalPages - 1) }
onClick={ () => setPage(p => Math.min(totalPages - 1, p + 1)) }
className="cursor-pointer rounded bg-[#3a7bb5] px-3 py-1 text-sm font-bold text-white hover:bg-[#336ea3] disabled:cursor-default disabled:opacity-40"></button>
</Flex> }
</> }
{ lastPlayed &&
<Flex alignItems="center" justifyContent="center" className="pt-1">
<Text small className="text-[#2f6f95]">
{ LocalizeText('soundboard.lastplayed', [ 'user' ], [ lastPlayed.username ]) }
</Text>
</Flex> }
</Column>
</NitroCard.Content>
</NitroCard>
);
};
+12
View File
@@ -0,0 +1,12 @@
import { FC } from 'react';
import { useThemes } from '../../hooks';
// Mounted once at app level: subscribing to the shared theme store triggers the
// load + apply effects, so the saved/default custom theme is applied on boot
// and kept in sync when the user changes it from Settings. Renders nothing.
export const ThemeApplier: FC<{}> = () =>
{
useThemes();
return null;
};
+13 -4
View File
@@ -1,4 +1,4 @@
import { CreateLinkEvent, GetRoomEngine, GetSessionDataManager, MouseEventType, RoomObjectCategory } from '@nitrots/nitro-renderer';
import { CreateLinkEvent, GetRoomEngine, GetSessionDataManager, RoomObjectCategory } from '@nitrots/nitro-renderer';
import { Dispatch, FC, PropsWithChildren, SetStateAction, useEffect, useRef } from 'react';
import { DispatchUiEvent, GetConfigurationValue, GetRoomSession, GetUserProfile, LocalizeText } from '../../api';
import { Flex, LayoutItemCountView } from '../../common';
@@ -24,11 +24,20 @@ export const ToolbarMeView: FC<PropsWithChildren<{
useEffect(() =>
{
const onClick = (event: MouseEvent) => setMeExpanded(false);
const onClick = (event: MouseEvent) =>
{
if(elementRef.current && elementRef.current.contains(event.target as Node)) return;
document.addEventListener('click', onClick);
setMeExpanded(false);
};
return () => document.removeEventListener(MouseEventType.MOUSE_CLICK, onClick);
const timeout = window.setTimeout(() => document.addEventListener('click', onClick), 0);
return () =>
{
window.clearTimeout(timeout);
document.removeEventListener('click', onClick);
};
}, [ setMeExpanded ]);
return (
+133 -63
View File
@@ -3,7 +3,7 @@ import { AnimatePresence, motion, Variants } from 'framer-motion';
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { GetConfigurationValue, isHousekeepingEnabled, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, VisitDesktop } from '../../api';
import { Flex, LayoutAvatarImageView, LayoutItemCountView } from '../../common';
import { useAchievements, useFriends, useHasPermission, useInventoryUnseenTracker, useMessageEvent, useMessenger, useModTools, useNitroEvent, useSessionInfo, useWiredTools } from '../../hooks';
import { useAchievements, useFriends, useHasPermission, useInventoryUnseenTracker, useMessageEvent, useMessenger, useModTools, useNitroEvent, useSessionInfo, useSoundboard, useWiredTools } from '../../hooks';
import { ToolbarItemView } from './ToolbarItemView';
import { ToolbarMeView } from './ToolbarMeView';
import { YouTubePlayerView } from './YouTubePlayerView';
@@ -34,6 +34,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
const [ isMeExpanded, setMeExpanded ] = useState(false);
const [ isToolbarOpen, setIsToolbarOpen ] = useState(false);
const [ isTouchLayout, setIsTouchLayout ] = useState(false);
const [ staffStackBottom, setStaffStackBottom ] = useState<number | null>(null);
const [ useGuideTool, setUseGuideTool ] = useState(false);
const [ youtubeEnabled, setYoutubeEnabled ] = useState(false);
const { userFigure = null } = useSessionInfo();
@@ -42,6 +43,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
const { requests = [] } = useFriends();
const { iconState = MessengerIconState.HIDDEN } = useMessenger();
const { openMonitor, showToolbarButton } = useWiredTools();
const { enabled: soundboardEnabled, reset: resetSoundboard } = useSoundboard();
const isMod = useHasPermission('acc_supporttool');
const isHk = useHasPermission('acc_housekeeping');
const hkEnabled = useMemo(() => isHousekeepingEnabled(), []);
@@ -69,10 +71,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
toggleTimeoutRef.current = setTimeout(() => { toggleLockRef.current = false; }, TOGGLE_LOCK_MS);
}, []);
const compactFramePosition = (isToolbarOpen && isInRoom) ? 'bottom-[90px] min-[1540px]:bottom-0' : 'bottom-0';
const mobileOnlyClasses = isTouchLayout ? '' : 'min-[1540px]:hidden';
const desktopBlockClasses = isTouchLayout ? 'hidden' : 'hidden min-[1540px]:block';
const desktopFlexClasses = isTouchLayout ? 'hidden' : 'hidden min-[1540px]:flex';
const compactFramePosition = (isToolbarOpen && isInRoom) ? 'bottom-[90px] min-[1700px]:bottom-0' : 'bottom-0';
const mobileOnlyClasses = isTouchLayout ? '' : 'min-[1700px]:hidden';
const desktopBlockClasses = isTouchLayout ? 'hidden' : 'hidden min-[1700px]:block';
const desktopFlexClasses = isTouchLayout ? 'hidden' : 'hidden min-[1700px]:flex';
const leftNavVariants = useMemo<Variants>(() => ({
hidden: { opacity: 0, x: isInRoom ? -10 : 0, y: isInRoom ? 0 : 8, pointerEvents: 'none' },
visible: { opacity: 1, x: 0, y: 0, pointerEvents: 'auto' }
@@ -99,8 +101,9 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
{
setYoutubeEnabled(false);
setYoutubeRoomEnabled(false);
resetSoundboard();
}
}, [ isInRoom ]);
}, [ isInRoom, resetSoundboard ]);
useEffect(() =>
{
@@ -113,6 +116,33 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
return () => query.removeEventListener('change', updateTouchLayout);
}, []);
// Keep the left staff-tools stack pinned 15px above the room tools rail
// (its height is dynamic, so measure it). Falls back to null (CSS
// default) when the room tools aren't present, e.g. outside a room.
useEffect(() =>
{
const measure = () =>
{
const roomTools = document.querySelector('.nitro-room-tools-container') as HTMLElement | null;
const next = roomTools
? Math.max(8, Math.round(window.innerHeight - roomTools.getBoundingClientRect().top + 15))
: null;
setStaffStackBottom(prevValue => (prevValue === next ? prevValue : next));
};
measure();
const interval = window.setInterval(measure, 400);
window.addEventListener('resize', measure);
return () =>
{
window.clearInterval(interval);
window.removeEventListener('resize', measure);
};
}, [ isInRoom ]);
const openYouTubePlayer = () => window.dispatchEvent(new CustomEvent('youtube:toggle'));
useMessageEvent<PerkAllowancesMessageEvent>(PerkAllowancesMessageEvent, event =>
@@ -216,14 +246,6 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="catalog" onClick={ () => CreateLinkEvent('catalog/toggle/normal') } className="tb-icon" />
</motion.div>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="buildersclub" onClick={ () => CreateLinkEvent('catalog/toggle/builder') } className="tb-icon" />
</motion.div>
<motion.div variants={ itemVariants } className="relative">
<ToolbarItemView icon="inventory" onClick={ () => CreateLinkEvent('inventory/toggle') } className="tb-icon" />
{ (getFullCount > 0) &&
<LayoutItemCountView count={ getFullCount } className="absolute -right-1 top-0" /> }
</motion.div>
<motion.div variants={ itemVariants } className="relative">
<AnimatePresence>
{ isMeExpanded &&
@@ -237,7 +259,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
</motion.div> }
</AnimatePresence>
<motion.div
className="cursor-pointer"
className="cursor-pointer relative h-[40px] w-[40px] overflow-hidden"
whileHover={ { scale: 1.08 } }
whileTap={ { scale: 0.95 } }
onClick={ event =>
@@ -245,11 +267,25 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
setMeExpanded(value => !value);
event.stopPropagation();
} }>
<LayoutAvatarImageView headOnly={ true } direction={ 2 } figure={ userFigure } className="tb-icon !h-[64px] !w-[32px] !bg-center !bg-no-repeat" style={ { marginTop: "8px" } } />
<LayoutAvatarImageView headOnly={ true } direction={ 2 } figure={ userFigure } className="tb-icon" style={ { backgroundSize: 'auto', backgroundPosition: '-25px -38px' } } />
</motion.div>
{ (getTotalUnseen > 0) &&
<LayoutItemCountView count={ getTotalUnseen } className="pointer-events-none absolute -right-1 -top-1 z-10" /> }
</motion.div>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="buildersclub" onClick={ () => CreateLinkEvent('catalog/toggle/builder') } className="tb-icon" />
</motion.div>
<motion.div variants={ itemVariants } className="relative">
<ToolbarItemView icon="inventory" onClick={ () => CreateLinkEvent('inventory/toggle') } className="tb-icon" />
{ (getFullCount > 0) &&
<LayoutItemCountView count={ getFullCount } className="absolute -right-1 top-0" /> }
</motion.div>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="rare-values" onClick={ () => CreateLinkEvent('rare-values/toggle') } className="tb-icon" />
</motion.div>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="fortune-wheel" onClick={ () => CreateLinkEvent('fortune-wheel/toggle') } className="tb-icon" />
</motion.div>
{ (isInRoom && showToolbarButton) &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="wired-tools" onClick={ openMonitor } className="tb-icon" />
@@ -262,20 +298,24 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="youtube" onClick={ openYouTubePlayer } className="tb-icon" />
</motion.div> }
{ (isInRoom && soundboardEnabled) &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="soundboard" onClick={ () => CreateLinkEvent('soundboard/toggle') } className="tb-icon" />
</motion.div> }
{ isMod &&
<motion.div variants={ itemVariants } className="relative">
<ToolbarItemView icon="modtools" onClick={ () => CreateLinkEvent('mod-tools/toggle') } className="tb-icon" />
{ (openTicketsCount > 0) &&
<LayoutItemCountView count={ openTicketsCount } className="pointer-events-none absolute -right-1 -top-1 z-10" /> }
</motion.div> }
{ isMod &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="furnieditor" onClick={ () => CreateLinkEvent('furni-editor/toggle') } className="tb-icon" />
</motion.div> }
{ (isHk && hkEnabled) &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="housekeeping" onClick={ () => CreateLinkEvent('housekeeping/toggle') } className="tb-icon" />
</motion.div> }
{ isMod &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="furnieditor" onClick={ () => CreateLinkEvent('furni-editor/toggle') } className="tb-icon" />
</motion.div> }
</motion.div>
</motion.div>
<motion.div
@@ -324,40 +364,43 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="catalog" onClick={ () => CreateLinkEvent('catalog/toggle/normal') } className="tb-icon" />
</motion.div>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="buildersclub" onClick={ () => CreateLinkEvent('catalog/toggle/builder') } className="tb-icon" />
<motion.div variants={ itemVariants } className="relative shrink-0">
<AnimatePresence>
{ isMeExpanded &&
<motion.div
initial={ { opacity: 0, y: 6, scale: 0.97 } }
animate={ { opacity: 1, y: 0, scale: 1 } }
exit={ { opacity: 0, y: 6, scale: 0.97 } }
transition={ ME_POPOVER_TRANSITION }
className="pointer-events-auto fixed bottom-[calc(100%+10px)] left-1/2 z-[70] -translate-x-1/2">
<ToolbarMeView setMeExpanded={ setMeExpanded } unseenAchievementCount={ getTotalUnseen } useGuideTool={ useGuideTool } />
</motion.div> }
</AnimatePresence>
<motion.div
className="cursor-pointer relative h-[40px] w-[40px] overflow-hidden"
whileHover={ { scale: 1.08 } }
whileTap={ { scale: 0.95 } }
onClick={ event =>
{
setMeExpanded(value => !value);
event.stopPropagation();
} }>
<LayoutAvatarImageView headOnly={ true } direction={ 2 } figure={ userFigure } className="tb-icon" style={ { backgroundSize: 'auto', backgroundPosition: '-25px -38px' } } />
</motion.div>
{ (getTotalUnseen > 0) &&
<LayoutItemCountView count={ getTotalUnseen } className="pointer-events-none absolute -right-1 -top-1 z-10" /> }
</motion.div>
<motion.div variants={ itemVariants } className="relative">
<ToolbarItemView icon="inventory" onClick={ () => CreateLinkEvent('inventory/toggle') } className="tb-icon" />
{ (getFullCount > 0) &&
<LayoutItemCountView count={ getFullCount } className="absolute -right-1 top-0" /> }
</motion.div>
</motion.div>
<motion.div variants={ itemVariants } className="relative mx-[2px] shrink-0">
<AnimatePresence>
{ isMeExpanded &&
<motion.div
initial={ { opacity: 0, y: 6, scale: 0.97 } }
animate={ { opacity: 1, y: 0, scale: 1 } }
exit={ { opacity: 0, y: 6, scale: 0.97 } }
transition={ ME_POPOVER_TRANSITION }
className="pointer-events-auto absolute bottom-[calc(100%+10px)] left-1/2 z-[70] -translate-x-1/2">
<ToolbarMeView setMeExpanded={ setMeExpanded } unseenAchievementCount={ getTotalUnseen } useGuideTool={ useGuideTool } />
</motion.div> }
</AnimatePresence>
<motion.div
className="cursor-pointer"
whileHover={ { scale: 1.08 } }
whileTap={ { scale: 0.95 } }
onClick={ event =>
{
setMeExpanded(value => !value);
event.stopPropagation();
} }>
<LayoutAvatarImageView headOnly={ true } direction={ 2 } figure={ userFigure } className="tb-icon !h-[64px] !w-[32px] !bg-center !bg-no-repeat" style={ { marginTop: "8px" } } />
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="rare-values" onClick={ () => CreateLinkEvent('rare-values/toggle') } className="tb-icon" />
</motion.div>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="fortune-wheel" onClick={ () => CreateLinkEvent('fortune-wheel/toggle') } className="tb-icon" />
</motion.div>
{ (getTotalUnseen > 0) &&
<LayoutItemCountView count={ getTotalUnseen } className="pointer-events-none absolute -right-1 -top-1 z-10" /> }
</motion.div>
<motion.div
variants={ containerVariants }
@@ -366,27 +409,13 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="wired-tools" onClick={ openMonitor } className="tb-icon" />
</motion.div> }
{ isInRoom &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="camera" onClick={ () => CreateLinkEvent('camera/toggle') } className="tb-icon" />
</motion.div> }
{ (isInRoom && youtubeEnabled) &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="youtube" onClick={ openYouTubePlayer } className="tb-icon" />
</motion.div> }
{ isMod &&
<motion.div variants={ itemVariants } className="relative">
<ToolbarItemView icon="modtools" onClick={ () => CreateLinkEvent('mod-tools/toggle') } className="tb-icon" />
{ (openTicketsCount > 0) &&
<LayoutItemCountView count={ openTicketsCount } className="pointer-events-none absolute -right-1 -top-1 z-10" /> }
</motion.div> }
{ isMod &&
{ (isInRoom && soundboardEnabled) &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="furnieditor" onClick={ () => CreateLinkEvent('furni-editor/toggle') } className="tb-icon" />
</motion.div> }
{ (isHk && hkEnabled) &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="housekeeping" onClick={ () => CreateLinkEvent('housekeeping/toggle') } className="tb-icon" />
<ToolbarItemView icon="soundboard" onClick={ () => CreateLinkEvent('soundboard/toggle') } className="tb-icon" />
</motion.div> }
<motion.div variants={ itemVariants } className="relative">
<ToolbarItemView icon="friendall" onClick={ () => CreateLinkEvent('friends/toggle') } className="tb-icon" />
@@ -395,6 +424,39 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
</motion.div>
</motion.div>
</motion.div>
{ /* Mobile side tools moved out of the bottom bar into a
vertical pill stack on the left edge so the bottom bar has
room. Always present (Builders Club), plus camera in-room
and the staff-only tools when permitted. */ }
<motion.div
initial="hidden"
animate={ visibilityVariant }
variants={ mobileNavVariants }
transition={ NAV_TRANSITION }
style={ staffStackBottom != null ? { top: 'auto', bottom: `${ staffStackBottom }px` } : undefined }
className={ `fixed left-1 z-40 flex flex-col items-center gap-2 rounded-[12px] border border-white/8 bg-[rgba(10,10,12,0.58)] px-[4px] py-[6px] shadow-[0_6px_18px_rgba(0,0,0,0.18)] ${ staffStackBottom == null ? 'top-1/2 -translate-y-1/2' : '' } ${ mobileOnlyClasses }` }>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="buildersclub" onClick={ () => CreateLinkEvent('catalog/toggle/builder') } className="tb-icon" />
</motion.div>
{ isInRoom &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="camera" onClick={ () => CreateLinkEvent('camera/toggle') } className="tb-icon" />
</motion.div> }
{ isMod &&
<motion.div variants={ itemVariants } className="relative">
<ToolbarItemView icon="modtools" onClick={ () => CreateLinkEvent('mod-tools/toggle') } className="tb-icon" />
{ (openTicketsCount > 0) &&
<LayoutItemCountView count={ openTicketsCount } className="pointer-events-none absolute -right-1 -top-1 z-10" /> }
</motion.div> }
{ (isHk && hkEnabled) &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="housekeeping" onClick={ () => CreateLinkEvent('housekeeping/toggle') } className="tb-icon" />
</motion.div> }
{ isMod &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="furnieditor" onClick={ () => CreateLinkEvent('furni-editor/toggle') } className="tb-icon" />
</motion.div> }
</motion.div>
</>
);
};
@@ -472,6 +534,14 @@ const TOOLBAR_STYLES = `
flex-wrap: nowrap;
}
/* Keep each icon at its natural size so the mobile bar scrolls
horizontally instead of squashing the items into each other.
(Default flex-shrink:1 let the fixed-size icon backgrounds overlap
once enough icons were present to exceed the bar width.) */
.tb-bar-scroll > * {
flex-shrink: 0;
}
.tb-bar-scroll::-webkit-scrollbar {
display: none;
}
@@ -1,6 +1,6 @@
import { GroupInformationComposer, GroupInformationEvent, GroupInformationParser, HabboGroupEntryData } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { LocalizeText, SendMessageComposer, ToggleFavoriteGroup } from '../../api';
import { LocalizeText, SanitizeHtml, SendMessageComposer, ToggleFavoriteGroup } from '../../api';
import { Column, GridProps, LayoutBadgeImageView, LayoutGridItem } from '../../common';
import { useMessageEvent } from '../../hooks';
import { GroupInformationView } from '../groups/views/GroupInformationView';
@@ -68,9 +68,7 @@ export const GroupsContainerView: FC<GroupsContainerViewProps> = props =>
return (
<div className="nitro-extended-profile-groups">
<div className="nitro-extended-profile-groups__sidebar">
<div className="nitro-extended-profile-groups__count">
{ LocalizeText('extendedprofile.groups.count', [ 'count' ], [ groups.length.toString() ]) }
</div>
<div className="nitro-extended-profile-groups__count" dangerouslySetInnerHTML={ { __html: SanitizeHtml(LocalizeText('extendedprofile.groups.count', [ 'count' ], [ groups.length.toString() ])) } } />
<div className="nitro-extended-profile-groups__list">
{ groups.map((group, index) =>
{
@@ -1,6 +1,6 @@
import { RelationshipStatusEnum, RelationshipStatusInfoMessageParser } from '@nitrots/nitro-renderer';
import { FC } from 'react';
import { GetUserProfile, LocalizeText } from '../../api';
import { CreateLinkEvent, GetUserProfile, LocalizeText } from '../../api';
import { Flex, LayoutAvatarImageView } from '../../common';
interface RelationshipsContainerViewProps
@@ -29,7 +29,7 @@ export const RelationshipsContainerView: FC<RelationshipsContainerViewProps> = p
</Flex>
<div className="nitro-extended-profile__relationship-copy">
<div className="nitro-extended-profile__relationship-box">
<p className="nitro-extended-profile__relationship-name" onClick={ event => (relationshipInfo && (relationshipInfo.randomFriendId >= 1) && GetUserProfile(relationshipInfo.randomFriendId)) }>
<p className="nitro-extended-profile__relationship-name" onClick={ event => ((relationshipInfo && (relationshipInfo.randomFriendId >= 1)) ? GetUserProfile(relationshipInfo.randomFriendId) : CreateLinkEvent('friends/toggle')) }>
{ (!relationshipInfo || (relationshipInfo.friendCount === 0)) &&
LocalizeText('extendedprofile.add.friends') }
{ (relationshipInfo && (relationshipInfo.friendCount >= 1)) &&
@@ -37,7 +37,7 @@ export const RelationshipsContainerView: FC<RelationshipsContainerViewProps> = p
</p>
{ (relationshipInfo && (relationshipInfo.friendCount >= 1)) &&
<div className="nitro-extended-profile__relationship-head">
<LayoutAvatarImageView direction={ 4 } figure={ relationshipInfo.randomFriendFigure } headOnly={ true } classNames={ [ '!w-auto', '!h-auto', '!left-0' ] } />
<LayoutAvatarImageView direction={ 2 } figure={ relationshipInfo.randomFriendFigure } headOnly={ true } />
</div> }
</div>
<p className="nitro-extended-profile__relationship-subcopy">
@@ -1,6 +1,6 @@
import { CreateLinkEvent, GetSessionDataManager, RelationshipStatusInfoMessageParser, RequestFriendComposer, UserProfileParser } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react';
import { FriendlyTime, LocalizeText, SendMessageComposer } from '../../api';
import { FriendlyTime, LocalizeText, SanitizeHtml, SendMessageComposer } from '../../api';
import { LayoutAvatarImageView, LayoutBadgeImageView, Text, UserIdentityView } from '../../common';
import { badgeEmblemDefault } from '../../assets/images/leaderboard_badge';
import { level as profileLevelIcon, rooms as profileRoomsIcon } from '../../assets/images/user-profile';
@@ -60,18 +60,12 @@ export const UserContainerView: FC<UserContainerViewProps> = props =>
prefixText={ userProfile.prefixText }
username={ userProfile.username } />
<p className="nitro-extended-profile__motto">{ userProfile.motto || '\u00A0' }</p>
<p
className="nitro-extended-profile__meta"
dangerouslySetInnerHTML={ {
__html: LocalizeText('extendedprofile.created', [ 'created' ], [ userProfile.registration ])
} }
/>
<p
className="nitro-extended-profile__meta"
dangerouslySetInnerHTML={ {
__html: LocalizeText('extendedprofile.last.login', [ 'lastlogin' ], [ FriendlyTime.format(userProfile.secondsSinceLastVisit, '.ago', 2) ])
} }
/>
<p className="nitro-extended-profile__meta">
<span dangerouslySetInnerHTML={ { __html: SanitizeHtml(LocalizeText('extendedprofile.created').replace(/%\w+%/g, '').trim()) } } /> { userProfile.registration }
</p>
<p className="nitro-extended-profile__meta">
<span dangerouslySetInnerHTML={ { __html: SanitizeHtml(LocalizeText('extendedprofile.last.login').replace(/%\w+%/g, '').trim()) } } /> { FriendlyTime.format(userProfile.secondsSinceLastVisit, '.ago', 2) }
</p>
<p className="nitro-extended-profile__meta nitro-extended-profile__meta--strong">
<b>{ LocalizeText('extendedprofile.achievementscore') }</b> { userProfile.achievementPoints }
</p>
@@ -100,10 +94,10 @@ export const UserContainerView: FC<UserContainerViewProps> = props =>
{ isOwnProfile &&
<div className="nitro-extended-profile__actions">
<button className="nitro-extended-profile__link" type="button">
<button className="nitro-extended-profile__link" type="button" onClick={ () => CreateLinkEvent('avatar-editor/show') }>
{ LocalizeText('extended.profile.change.looks') }
</button>
<button className="nitro-extended-profile__link" type="button" onClick={ () => CreateLinkEvent('inventory/open/badges') }>
<button className="nitro-extended-profile__link" type="button" onClick={ () => CreateLinkEvent('inventory/show/badges') }>
{ LocalizeText('extended.profile.change.badges') }
</button>
</div> }
@@ -123,11 +117,11 @@ export const UserContainerView: FC<UserContainerViewProps> = props =>
<p
className="text-sm leading-none"
dangerouslySetInnerHTML={{
__html: LocalizeText(
__html: SanitizeHtml(LocalizeText(
'extendedprofile.friends.count',
['count'],
[userProfile.friendsCount.toString()]
)
))
}}
/>
<p className="nitro-extended-profile__relationships-label">{ LocalizeText('extendedprofile.relstatus') }</p>
@@ -148,11 +142,11 @@ export const UserContainerView: FC<UserContainerViewProps> = props =>
<span className="nitro-extended-profile__summary-label">{ LocalizeText('inventory.badges') }</span>
<span className="nitro-extended-profile__summary-value">{ totalBadges }</span>
</button>
<div className="nitro-extended-profile__summary-button">
<button className="nitro-extended-profile__summary-button nitro-extended-profile__summary-button--center" type="button" onClick={ () => CreateLinkEvent('achievements/toggle') }>
<img className="nitro-extended-profile__summary-icon" src={ profileLevelIcon } alt="" />
<span className="nitro-extended-profile__summary-label">{ LocalizeText('extendedprofile.achievementscore') }</span>
<span className="nitro-extended-profile__summary-value">{ userProfile.achievementPoints }</span>
</div>
</button>
</div>
</div>
);
@@ -1,6 +1,6 @@
import { ExtendedProfileChangedMessageEvent, GetSessionDataManager, NavigatorSearchComposer, RelationshipStatusInfoEvent, RelationshipStatusInfoMessageParser, RoomEngineObjectEvent, RoomObjectCategory, RoomObjectType, UserCurrentBadgesComposer, UserCurrentBadgesEvent, UserProfileEvent, UserProfileParser, UserRelationshipsComposer } from '@nitrots/nitro-renderer';
import { ExtendedProfileChangedMessageEvent, GetSessionDataManager, RelationshipStatusInfoEvent, RelationshipStatusInfoMessageParser, RoomEngineObjectEvent, RoomObjectCategory, RoomObjectType, UserCurrentBadgesComposer, UserCurrentBadgesEvent, UserProfileEvent, UserProfileParser, UserRelationshipsComposer } from '@nitrots/nitro-renderer';
import { FC, useState } from 'react';
import { GetRoomSession, GetUserProfile, LocalizeText, SendMessageComposer } from '../../api';
import { CreateLinkEvent, GetRoomSession, GetUserProfile, LocalizeText, SendMessageComposer } from '../../api';
import { useMessageEvent, useNitroEvent } from '../../hooks';
import { NitroCard } from '../../layout';
import { GroupsContainerView } from './GroupsContainerView';
@@ -28,10 +28,9 @@ export const UserProfileView: FC<{}> = () =>
const onOpenRooms = () =>
{
if(userProfile)
{
SendMessageComposer(new NavigatorSearchComposer('hotel_view', `owner:${ userProfile.username }`));
}
if(!userProfile) return;
CreateLinkEvent(`navigator/search/hotel_view/owner:${ userProfile.username }`);
};
useMessageEvent<UserCurrentBadgesEvent>(UserCurrentBadgesEvent, event =>
@@ -99,12 +98,15 @@ export const UserProfileView: FC<{}> = () =>
if(!userProfile) return null;
const cardBackgroundId = userProfile.cardBackgroundId ?? 0;
const cardBackgroundClass = cardBackgroundId ? `profile-card-background card-background-${ cardBackgroundId }` : '';
return (
<NitroCard className="nitro-extended-profile-window w-[521px] h-[537px]" uniqueKey="nitro-user-profile">
<NitroCard className="nitro-extended-profile-window w-[640px] h-[720px] max-w-[96vw] max-h-[92vh]" uniqueKey="nitro-user-profile">
<NitroCard.Header
headerText={ LocalizeText('extendedprofile.caption') }
onCloseClick={ onClose } />
<NitroCard.Content className="nitro-extended-profile-window__content overflow-hidden !p-0 flex flex-col">
<NitroCard.Content className={ `nitro-extended-profile-window__content overflow-hidden !p-0 flex flex-col ${ cardBackgroundClass }` }>
<div className="px-[10px] pt-[8px]">
<UserContainerView
userBadges={ userBadges }
@@ -1,7 +1,7 @@
import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, KeyboardEvent, useEffect, useMemo, useState } from 'react';
import { FaArrowLeft, FaCheckCircle, FaChevronRight, FaEnvelope, FaExclamationTriangle, FaEye, FaEyeSlash, FaIdBadge, FaInfoCircle, FaKey, FaShieldAlt, FaUserCog } from 'react-icons/fa';
import { GetConfigurationValue, getAccessToken } from '../../api';
import { GetConfigurationValue, LocalizeText, getAccessToken } from '../../api';
import { Button, LayoutAvatarImageView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
const MIN_PASSWORD_LENGTH = 8;
@@ -17,9 +17,9 @@ const MAX_USERNAME_LENGTH = 25;
type FeedbackKind = 'error' | 'success';
type Section = 'menu' | 'password' | 'email' | 'username';
const passwordStrength = (value: string): { score: number; label: string; color: string } =>
const passwordStrength = (value: string): { score: number; labelKey: string; color: string } =>
{
if(!value) return { score: 0, label: '', color: 'bg-black/10' };
if(!value) return { score: 0, labelKey: '', color: 'bg-black/10' };
let score = 0;
if(value.length >= MIN_PASSWORD_LENGTH) score++;
@@ -28,10 +28,10 @@ const passwordStrength = (value: string): { score: number; label: string; color:
if(/\d/.test(value)) score++;
if(/[^A-Za-z0-9]/.test(value)) score++;
if(score <= 1) return { score: 1, label: 'Weak', color: 'bg-[#a81a12]' };
if(score === 2) return { score: 2, label: 'Fair', color: 'bg-[#ffc107]' };
if(score === 3) return { score: 3, label: 'Good', color: 'bg-[#1e7295]' };
return { score: 4, label: 'Strong', color: 'bg-[#00800b]' };
if(score <= 1) return { score: 1, labelKey: 'usersettings.strength.weak', color: 'bg-[#a81a12]' };
if(score === 2) return { score: 2, labelKey: 'usersettings.strength.fair', color: 'bg-[#ffc107]' };
if(score === 3) return { score: 3, labelKey: 'usersettings.strength.good', color: 'bg-[#1e7295]' };
return { score: 4, labelKey: 'usersettings.strength.strong', color: 'bg-[#00800b]' };
};
export const UserAccountSettingsView: FC<{}> = () =>
@@ -131,38 +131,38 @@ export const UserAccountSettingsView: FC<{}> = () =>
if(!currentPassword || !newPassword || !confirmPassword)
{
setFeedback({ kind: 'error', message: 'All fields are required.' });
setFeedback({ kind: 'error', message: LocalizeText('usersettings.error.fields_required') });
return;
}
if(newPassword.length < MIN_PASSWORD_LENGTH)
{
setFeedback({ kind: 'error', message: `Password must be at least ${ MIN_PASSWORD_LENGTH } characters.` });
setFeedback({ kind: 'error', message: LocalizeText('usersettings.error.password_min', [ 'count' ], [ MIN_PASSWORD_LENGTH.toString() ]) });
return;
}
if(newPassword.length > MAX_PASSWORD_LENGTH)
{
setFeedback({ kind: 'error', message: 'Password is too long.' });
setFeedback({ kind: 'error', message: LocalizeText('usersettings.error.password_long') });
return;
}
if(newPassword !== confirmPassword)
{
setFeedback({ kind: 'error', message: 'New passwords do not match.' });
setFeedback({ kind: 'error', message: LocalizeText('usersettings.error.password_mismatch') });
return;
}
if(newPassword === currentPassword)
{
setFeedback({ kind: 'error', message: 'New password must be different from the current password.' });
setFeedback({ kind: 'error', message: LocalizeText('usersettings.error.password_same') });
return;
}
const token = getAccessToken();
if(!token)
{
setFeedback({ kind: 'error', message: 'You are not authenticated. Please log in again.' });
setFeedback({ kind: 'error', message: LocalizeText('usersettings.error.not_authenticated') });
return;
}
@@ -191,14 +191,14 @@ export const UserAccountSettingsView: FC<{}> = () =>
{
const message = typeof payload.error === 'string' && payload.error
? payload.error
: `Request failed (${ response.status }).`;
: LocalizeText('usersettings.error.request_failed', [ 'status' ], [ response.status.toString() ]);
setFeedback({ kind: 'error', message });
return;
}
const message = typeof payload.message === 'string' && payload.message
? payload.message
: 'Password updated successfully.';
: LocalizeText('usersettings.success.password');
setFeedback({ kind: 'success', message });
setCurrentPassword('');
setNewPassword('');
@@ -208,7 +208,7 @@ export const UserAccountSettingsView: FC<{}> = () =>
}
catch
{
setFeedback({ kind: 'error', message: 'Could not reach the server. Please try again.' });
setFeedback({ kind: 'error', message: LocalizeText('usersettings.error.network') });
}
finally
{
@@ -224,26 +224,26 @@ export const UserAccountSettingsView: FC<{}> = () =>
if(!emailCurrentPassword || !newEmail)
{
setFeedback({ kind: 'error', message: 'All fields are required.' });
setFeedback({ kind: 'error', message: LocalizeText('usersettings.error.fields_required') });
return;
}
if(newEmail.length > MAX_EMAIL_LENGTH)
{
setFeedback({ kind: 'error', message: 'Email address is too long.' });
setFeedback({ kind: 'error', message: LocalizeText('usersettings.error.email_long') });
return;
}
if(!EMAIL_RE.test(newEmail))
{
setFeedback({ kind: 'error', message: 'Please enter a valid email address.' });
setFeedback({ kind: 'error', message: LocalizeText('usersettings.error.email_invalid') });
return;
}
const token = getAccessToken();
if(!token)
{
setFeedback({ kind: 'error', message: 'You are not authenticated. Please log in again.' });
setFeedback({ kind: 'error', message: LocalizeText('usersettings.error.not_authenticated') });
return;
}
@@ -272,14 +272,14 @@ export const UserAccountSettingsView: FC<{}> = () =>
{
const message = typeof payload.error === 'string' && payload.error
? payload.error
: `Request failed (${ response.status }).`;
: LocalizeText('usersettings.error.request_failed', [ 'status' ], [ response.status.toString() ]);
setFeedback({ kind: 'error', message });
return;
}
const message = typeof payload.message === 'string' && payload.message
? payload.message
: 'Email updated successfully.';
: LocalizeText('usersettings.success.email');
setFeedback({ kind: 'success', message });
setEmailCurrentPassword('');
setNewEmail('');
@@ -287,7 +287,7 @@ export const UserAccountSettingsView: FC<{}> = () =>
}
catch
{
setFeedback({ kind: 'error', message: 'Could not reach the server. Please try again.' });
setFeedback({ kind: 'error', message: LocalizeText('usersettings.error.network') });
}
finally
{
@@ -303,32 +303,32 @@ export const UserAccountSettingsView: FC<{}> = () =>
if(!usernameCurrentPassword || !newUsername)
{
setFeedback({ kind: 'error', message: 'All fields are required.' });
setFeedback({ kind: 'error', message: LocalizeText('usersettings.error.fields_required') });
return;
}
if(newUsername.length < MIN_USERNAME_LENGTH || newUsername.length > MAX_USERNAME_LENGTH)
{
setFeedback({ kind: 'error', message: `Username must be between ${ MIN_USERNAME_LENGTH } and ${ MAX_USERNAME_LENGTH } characters.` });
setFeedback({ kind: 'error', message: LocalizeText('usersettings.error.username_length', [ 'min', 'max' ], [ MIN_USERNAME_LENGTH.toString(), MAX_USERNAME_LENGTH.toString() ]) });
return;
}
if(!USERNAME_RE.test(newUsername))
{
setFeedback({ kind: 'error', message: 'Username may only contain letters, numbers, dot, underscore and dash.' });
setFeedback({ kind: 'error', message: LocalizeText('usersettings.error.username_invalid') });
return;
}
if(newUsername === session.username)
{
setFeedback({ kind: 'error', message: 'New username must be different from the current one.' });
setFeedback({ kind: 'error', message: LocalizeText('usersettings.error.username_same') });
return;
}
const token = getAccessToken();
if(!token)
{
setFeedback({ kind: 'error', message: 'You are not authenticated. Please log in again.' });
setFeedback({ kind: 'error', message: LocalizeText('usersettings.error.not_authenticated') });
return;
}
@@ -357,14 +357,14 @@ export const UserAccountSettingsView: FC<{}> = () =>
{
const message = typeof payload.error === 'string' && payload.error
? payload.error
: `Request failed (${ response.status }).`;
: LocalizeText('usersettings.error.request_failed', [ 'status' ], [ response.status.toString() ]);
setFeedback({ kind: 'error', message });
return;
}
const message = typeof payload.message === 'string' && payload.message
? payload.message
: 'Username updated. Please log in again with your new name.';
: LocalizeText('usersettings.success.username');
setFeedback({ kind: 'success', message });
setUsernameCurrentPassword('');
setNewUsername('');
@@ -382,7 +382,7 @@ export const UserAccountSettingsView: FC<{}> = () =>
}
catch
{
setFeedback({ kind: 'error', message: 'Could not reach the server. Please try again.' });
setFeedback({ kind: 'error', message: LocalizeText('usersettings.error.network') });
}
finally
{
@@ -394,7 +394,7 @@ export const UserAccountSettingsView: FC<{}> = () =>
return (
<NitroCardView className="user-account-settings-window w-[360px]" theme="primary-slim" uniqueKey="user-account-settings">
<NitroCardHeaderView headerText="User Settings" onCloseClick={ close } />
<NitroCardHeaderView headerText={ LocalizeText('usersettings.title') } onCloseClick={ close } />
<div className="relative flex items-center gap-3 px-3 py-2 bg-[linear-gradient(180deg,#2e8fb8_0%,#1e7295_100%)] text-white">
<div className="absolute inset-0 opacity-20 pointer-events-none [background-image:radial-gradient(rgba(255,255,255,0.5)_1px,transparent_1px)] [background-size:6px_6px]" />
@@ -405,21 +405,21 @@ export const UserAccountSettingsView: FC<{}> = () =>
direction={ 2 }
headOnly={ true }
scale={ 1 }
classNames={ [ '!absolute !left-1/2 !top-[56%] !w-[64px] !h-[64px] !-translate-x-1/2 !-translate-y-1/2 !bg-center !bg-no-repeat' ] }
style={ { backgroundSize: '112px auto', backgroundPosition: '-24px -38px' } }
/>
</div>
) }
<div className="relative flex flex-col leading-tight">
<Text small className="text-white/80 uppercase tracking-wider">My account</Text>
<Text bold className="text-white text-[15px]">{ session.username || 'Guest' }</Text>
<Text small className="text-white/80">Manage your account and security</Text>
<Text small className="text-white/80 uppercase tracking-wider">{ LocalizeText('usersettings.account.label') }</Text>
<Text bold className="text-white text-[15px]">{ session.username || LocalizeText('usersettings.guest') }</Text>
<Text small className="text-white/80">{ LocalizeText('usersettings.subtitle') }</Text>
</div>
</div>
<NitroCardContentView className="flex flex-col gap-2 text-black">
{ section === 'menu' && (
<div className="flex flex-col gap-2">
<Text small className="text-black/60 uppercase tracking-wider px-1">Account</Text>
<Text small className="text-black/60 uppercase tracking-wider px-1">{ LocalizeText('usersettings.menu.section') }</Text>
<button
type="button"
className="group flex items-center gap-3 rounded-md border border-black/10 bg-white px-3 py-2 hover:bg-[#f5fbfd] hover:border-[#1e7295] transition-colors cursor-pointer text-left"
@@ -428,8 +428,8 @@ export const UserAccountSettingsView: FC<{}> = () =>
<FaKey />
</div>
<div className="flex flex-col flex-1 leading-tight">
<Text bold>Reset password</Text>
<Text small className="text-black/60">Change the password used to log in.</Text>
<Text bold>{ LocalizeText('usersettings.menu.password.title') }</Text>
<Text small className="text-black/60">{ LocalizeText('usersettings.menu.password.desc') }</Text>
</div>
<FaChevronRight className="text-black/40 group-hover:text-[#1e7295]" />
</button>
@@ -442,8 +442,8 @@ export const UserAccountSettingsView: FC<{}> = () =>
<FaEnvelope />
</div>
<div className="flex flex-col flex-1 leading-tight">
<Text bold>Change email</Text>
<Text small className="text-black/60">Update the email address on your account.</Text>
<Text bold>{ LocalizeText('usersettings.menu.email.title') }</Text>
<Text small className="text-black/60">{ LocalizeText('usersettings.menu.email.desc') }</Text>
</div>
<FaChevronRight className="text-black/40 group-hover:text-[#1e7295]" />
</button>
@@ -456,8 +456,8 @@ export const UserAccountSettingsView: FC<{}> = () =>
<FaIdBadge />
</div>
<div className="flex flex-col flex-1 leading-tight">
<Text bold>Change username</Text>
<Text small className="text-black/60">Pick a new name. You'll need to log in again.</Text>
<Text bold>{ LocalizeText('usersettings.menu.username.title') }</Text>
<Text small className="text-black/60">{ LocalizeText('usersettings.menu.username.desc') }</Text>
</div>
<FaChevronRight className="text-black/40 group-hover:text-[#a37800]" />
</button>
@@ -467,8 +467,8 @@ export const UserAccountSettingsView: FC<{}> = () =>
<FaShieldAlt />
</div>
<div className="flex flex-col flex-1 leading-tight">
<Text bold className="text-black/60">More coming soon</Text>
<Text small className="text-black/50">Two-factor authentication and more.</Text>
<Text bold className="text-black/60">{ LocalizeText('usersettings.menu.soon.title') }</Text>
<Text small className="text-black/50">{ LocalizeText('usersettings.menu.soon.desc') }</Text>
</div>
</div>
</div>
@@ -485,16 +485,16 @@ export const UserAccountSettingsView: FC<{}> = () =>
<FaArrowLeft size={ 11 } />
</button>
<FaUserCog className="text-[#1e7295]" />
<Text bold>Reset password</Text>
<Text bold>{ LocalizeText('usersettings.menu.password.title') }</Text>
</div>
<div className="flex items-start gap-2 rounded-md border border-[#1e7295]/30 bg-[#1e7295]/10 px-2 py-2 text-[11px] leading-4 text-[#0d3d52]">
<FaInfoCircle className="mt-[2px] shrink-0 text-[#1e7295]" />
<span>Use at least <strong>{ MIN_PASSWORD_LENGTH } characters</strong>. Mix upper &amp; lowercase, numbers and symbols for a stronger password.</span>
<span>{ LocalizeText('usersettings.password.hint', [ 'count' ], [ MIN_PASSWORD_LENGTH.toString() ]) }</span>
</div>
<label className="flex flex-col gap-1 text-[12px]">
<span className="font-bold">Current password</span>
<span className="font-bold">{ LocalizeText('usersettings.field.current_password') }</span>
<div className="relative flex items-center">
<FaKey className="absolute left-2 text-black/40" size={ 12 } />
<input
@@ -508,7 +508,7 @@ export const UserAccountSettingsView: FC<{}> = () =>
/>
<button
type="button"
aria-label={ showCurrent ? 'Hide password' : 'Show password' }
aria-label={ showCurrent ? LocalizeText('usersettings.aria.hide_password') : LocalizeText('usersettings.aria.show_password') }
onClick={ () => setShowCurrent(prev => !prev) }
className="absolute right-2 text-black/40 hover:text-black/70">
{ showCurrent ? <FaEyeSlash size={ 12 } /> : <FaEye size={ 12 } /> }
@@ -517,7 +517,7 @@ export const UserAccountSettingsView: FC<{}> = () =>
</label>
<label className="flex flex-col gap-1 text-[12px]">
<span className="font-bold">New password</span>
<span className="font-bold">{ LocalizeText('usersettings.field.new_password') }</span>
<div className="relative flex items-center">
<FaKey className="absolute left-2 text-black/40" size={ 12 } />
<input
@@ -531,7 +531,7 @@ export const UserAccountSettingsView: FC<{}> = () =>
/>
<button
type="button"
aria-label={ showNew ? 'Hide password' : 'Show password' }
aria-label={ showNew ? LocalizeText('usersettings.aria.hide_password') : LocalizeText('usersettings.aria.show_password') }
onClick={ () => setShowNew(prev => !prev) }
className="absolute right-2 text-black/40 hover:text-black/70">
{ showNew ? <FaEyeSlash size={ 12 } /> : <FaEye size={ 12 } /> }
@@ -542,13 +542,13 @@ export const UserAccountSettingsView: FC<{}> = () =>
<div className="flex-1 h-1.5 rounded-full bg-black/10 overflow-hidden">
<div className={ `h-full ${ strength.color } transition-all` } style={ { width: `${ (strength.score / 4) * 100 }%` } } />
</div>
<span className="text-[10px] text-black/60 w-12 text-right">{ strength.label }</span>
<span className="text-[10px] text-black/60 w-12 text-right">{ strength.labelKey ? LocalizeText(strength.labelKey) : '' }</span>
</div>
) }
</label>
<label className="flex flex-col gap-1 text-[12px]">
<span className="font-bold">Retype new password</span>
<span className="font-bold">{ LocalizeText('usersettings.field.retype_password') }</span>
<div className="relative flex items-center">
<FaKey className="absolute left-2 text-black/40" size={ 12 } />
<input
@@ -577,10 +577,10 @@ export const UserAccountSettingsView: FC<{}> = () =>
<div className="flex justify-end gap-2 pt-1">
<Button variant="secondary" disabled={ submitting } onClick={ () => { resetForm(); setSection('menu'); } }>
Cancel
{ LocalizeText('usersettings.btn.cancel') }
</Button>
<Button variant="success" disabled={ submitting } onClick={ () => submitPasswordChange() }>
{ submitting ? 'Saving' : 'Save password' }
{ submitting ? LocalizeText('usersettings.btn.saving') : LocalizeText('usersettings.btn.save_password') }
</Button>
</div>
</div>
@@ -597,16 +597,16 @@ export const UserAccountSettingsView: FC<{}> = () =>
<FaArrowLeft size={ 11 } />
</button>
<FaEnvelope className="text-[#185d79]" />
<Text bold>Change email</Text>
<Text bold>{ LocalizeText('usersettings.menu.email.title') }</Text>
</div>
<div className="flex items-start gap-2 rounded-md border border-[#1e7295]/30 bg-[#1e7295]/10 px-2 py-2 text-[11px] leading-4 text-[#0d3d52]">
<FaInfoCircle className="mt-[2px] shrink-0 text-[#1e7295]" />
<span>For security we ask you to confirm your <strong>current password</strong> before changing the email on your account.</span>
<span>{ LocalizeText('usersettings.email.hint') }</span>
</div>
<label className="flex flex-col gap-1 text-[12px]">
<span className="font-bold">Current password</span>
<span className="font-bold">{ LocalizeText('usersettings.field.current_password') }</span>
<div className="relative flex items-center">
<FaKey className="absolute left-2 text-black/40" size={ 12 } />
<input
@@ -620,7 +620,7 @@ export const UserAccountSettingsView: FC<{}> = () =>
/>
<button
type="button"
aria-label={ showEmailPassword ? 'Hide password' : 'Show password' }
aria-label={ showEmailPassword ? LocalizeText('usersettings.aria.hide_password') : LocalizeText('usersettings.aria.show_password') }
onClick={ () => setShowEmailPassword(prev => !prev) }
className="absolute right-2 text-black/40 hover:text-black/70">
{ showEmailPassword ? <FaEyeSlash size={ 12 } /> : <FaEye size={ 12 } /> }
@@ -629,7 +629,7 @@ export const UserAccountSettingsView: FC<{}> = () =>
</label>
<label className="flex flex-col gap-1 text-[12px]">
<span className="font-bold">New email address</span>
<span className="font-bold">{ LocalizeText('usersettings.field.new_email') }</span>
<div className="relative flex items-center">
<FaEnvelope className="absolute left-2 text-black/40" size={ 12 } />
<input
@@ -660,10 +660,10 @@ export const UserAccountSettingsView: FC<{}> = () =>
<div className="flex justify-end gap-2 pt-1">
<Button variant="secondary" disabled={ submitting } onClick={ () => { resetForm(); setSection('menu'); } }>
Cancel
{ LocalizeText('usersettings.btn.cancel') }
</Button>
<Button variant="success" disabled={ submitting } onClick={ () => submitEmailChange() }>
{ submitting ? 'Saving' : 'Save email' }
{ submitting ? LocalizeText('usersettings.btn.saving') : LocalizeText('usersettings.btn.save_email') }
</Button>
</div>
</div>
@@ -680,16 +680,16 @@ export const UserAccountSettingsView: FC<{}> = () =>
<FaArrowLeft size={ 11 } />
</button>
<FaIdBadge className="text-[#a37800]" />
<Text bold>Change username</Text>
<Text bold>{ LocalizeText('usersettings.menu.username.title') }</Text>
</div>
<div className="flex items-start gap-2 rounded-md border border-[#ffc107]/50 bg-[#fff8e1] px-2 py-2 text-[11px] leading-4 text-[#5c4400]">
<FaExclamationTriangle className="mt-[2px] shrink-0 text-[#a37800]" />
<span>Renaming will <strong>log you out</strong> and you can only rename again after 30 days. Make sure your friends know your new name!</span>
<span>{ LocalizeText('usersettings.username.hint') }</span>
</div>
<label className="flex flex-col gap-1 text-[12px]">
<span className="font-bold">Current password</span>
<span className="font-bold">{ LocalizeText('usersettings.field.current_password') }</span>
<div className="relative flex items-center">
<FaKey className="absolute left-2 text-black/40" size={ 12 } />
<input
@@ -703,7 +703,7 @@ export const UserAccountSettingsView: FC<{}> = () =>
/>
<button
type="button"
aria-label={ showUsernamePassword ? 'Hide password' : 'Show password' }
aria-label={ showUsernamePassword ? LocalizeText('usersettings.aria.hide_password') : LocalizeText('usersettings.aria.show_password') }
onClick={ () => setShowUsernamePassword(prev => !prev) }
className="absolute right-2 text-black/40 hover:text-black/70">
{ showUsernamePassword ? <FaEyeSlash size={ 12 } /> : <FaEye size={ 12 } /> }
@@ -712,7 +712,7 @@ export const UserAccountSettingsView: FC<{}> = () =>
</label>
<label className="flex flex-col gap-1 text-[12px]">
<span className="font-bold">New username</span>
<span className="font-bold">{ LocalizeText('usersettings.field.new_username') }</span>
<div className="relative flex items-center">
<FaIdBadge className="absolute left-2 text-black/40" size={ 12 } />
<input
@@ -730,7 +730,7 @@ export const UserAccountSettingsView: FC<{}> = () =>
<FaCheckCircle className="absolute right-2 text-[#00800b]" size={ 12 } />
) }
</div>
<span className="text-[10px] text-black/50">{ MIN_USERNAME_LENGTH }-{ MAX_USERNAME_LENGTH } characters. Letters, numbers, dot, underscore and dash only.</span>
<span className="text-[10px] text-black/50">{ LocalizeText('usersettings.username.rules', [ 'min', 'max' ], [ MIN_USERNAME_LENGTH.toString(), MAX_USERNAME_LENGTH.toString() ]) }</span>
</label>
{ feedback && (
@@ -744,10 +744,10 @@ export const UserAccountSettingsView: FC<{}> = () =>
<div className="flex justify-end gap-2 pt-1">
<Button variant="secondary" disabled={ submitting } onClick={ () => { resetForm(); setSection('menu'); } }>
Cancel
{ LocalizeText('usersettings.btn.cancel') }
</Button>
<Button variant="warning" disabled={ submitting } onClick={ () => submitUsernameChange() }>
{ submitting ? 'Renaming' : 'Rename me' }
{ submitting ? LocalizeText('usersettings.btn.renaming') : LocalizeText('usersettings.btn.rename') }
</Button>
</div>
</div>
@@ -3,16 +3,19 @@ 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 { useCatalogPlaceMultipleItems, useCatalogSkipPurchaseConfirmation, useChatWindow, useMessageEvent } from '../../hooks';
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) =>
{
@@ -131,6 +134,11 @@ export const UserSettingsView: FC<{}> = props =>
<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') }>{ LocalizeText('usersettings.tab.general') }</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') }>{ LocalizeText('usersettings.tab.themes') }</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) } />
@@ -154,7 +162,11 @@ export const UserSettingsView: FC<{}> = props =>
</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>
<Text>{ LocalizeText('memenu.settings.other.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>{ LocalizeText('memenu.settings.other.catalog.classic.style') }</Text>
</div>
</div>
<div className="flex flex-col">
@@ -196,12 +208,41 @@ export const UserSettingsView: FC<{}> = props =>
<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>
<Text bold>{ LocalizeText('usersettings.open.title') }</Text>
<Text small className="text-black/60">{ LocalizeText('usersettings.open.subtitle') }</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>{ LocalizeText('usersettings.themes.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="">{ LocalizeText('usersettings.themes.default_option') }</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>{ LocalizeText('usersettings.themes.active_pieces') }</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">{ LocalizeText('usersettings.themes.invalid') }</Text> }
{ !themes.length &&
<Text small className="text-black/60">{ LocalizeText('usersettings.themes.none') }</Text> }
</div> }
</NitroCardContentView>
</NitroCardView>
);