mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 23:16:21 +00:00
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:
@@ -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 />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>;
|
||||
}) }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
+57
-57
@@ -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>
|
||||
);
|
||||
|
||||
+51
-52
@@ -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>
|
||||
);
|
||||
};
|
||||
+43
-40
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user