mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 23:16:21 +00:00
Merge latest duckie main with login UI
This commit is contained in:
@@ -25,6 +25,7 @@ import { NavigatorView } from './navigator/NavigatorView';
|
||||
import { NitrobubbleHiddenView } from './nitrobubblehidden/NitrobubbleHiddenView';
|
||||
import { NitropediaView } from './nitropedia/NitropediaView';
|
||||
import { ExternalPluginLoader } from './plugins/ExternalPluginLoader';
|
||||
import { GoogleAdsView } from './ads/GoogleAdsView';
|
||||
import { RightSideView } from './right-side/RightSideView';
|
||||
import { RoomView } from './room/RoomView';
|
||||
import { ToolbarView } from './toolbar/ToolbarView';
|
||||
@@ -112,6 +113,7 @@ export const MainView: FC<{}> = props =>
|
||||
</AnimatePresence>
|
||||
<ToolbarView isInRoom={ !landingViewVisible } />
|
||||
<TranslationBootstrap />
|
||||
<GoogleAdsView />
|
||||
<ModToolsView />
|
||||
<WiredCreatorToolsView />
|
||||
<RoomView />
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { FC, useEffect, useRef, useState } from 'react';
|
||||
import { GetConfigurationValue } from '../../api';
|
||||
import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
|
||||
|
||||
interface AdsenseConfig {
|
||||
slot: string;
|
||||
format?: string;
|
||||
fullWidthResponsive?: boolean;
|
||||
}
|
||||
|
||||
const ADSENSE_SCRIPT_ID = 'google-adsense-script';
|
||||
|
||||
const parsePublisherIdFromAdsTxt = (text: string): string | null => {
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.split('#')[0].trim();
|
||||
if (!line) continue;
|
||||
const parts = line.split(',').map(part => part.trim());
|
||||
if (parts.length < 2) continue;
|
||||
if (parts[0].toLowerCase() !== 'google.com') continue;
|
||||
const pub = parts[1];
|
||||
if (/^pub-\d+$/.test(pub)) return pub;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const ensureAdsenseScript = (publisherId: string): void => {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (document.getElementById(ADSENSE_SCRIPT_ID)) return;
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.id = ADSENSE_SCRIPT_ID;
|
||||
script.async = true;
|
||||
script.crossOrigin = 'anonymous';
|
||||
script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-${ publisherId }`;
|
||||
document.head.appendChild(script);
|
||||
};
|
||||
|
||||
export const GoogleAdsView: FC<{}> = () => {
|
||||
const adsEnabled = GetConfigurationValue<boolean>('show.google.ads', false);
|
||||
const [ isOpen, setIsOpen ] = useState(false);
|
||||
const [ publisherId, setPublisherId ] = useState<string | null>(null);
|
||||
const [ config, setConfig ] = useState<AdsenseConfig | null>(null);
|
||||
const [ loadError, setLoadError ] = useState<string | null>(null);
|
||||
const insRef = useRef<HTMLModElement>(null);
|
||||
const pushedRef = useRef(false);
|
||||
const autoOpenedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!adsEnabled) return;
|
||||
const handler = () => setIsOpen(prev => !prev);
|
||||
window.addEventListener('ads:toggle', handler);
|
||||
return () => window.removeEventListener('ads:toggle', handler);
|
||||
}, [ adsEnabled ]);
|
||||
|
||||
// Auto-open once on initial mount (the login / landing stage).
|
||||
// Subsequent toggles are driven by the "ads:toggle" window event
|
||||
// (e.g. the Show Ad button in NitroSystemAlertView).
|
||||
useEffect(() => {
|
||||
if (!adsEnabled) return;
|
||||
if (autoOpenedRef.current) return;
|
||||
autoOpenedRef.current = true;
|
||||
const t = setTimeout(() => setIsOpen(true), 500);
|
||||
return () => clearTimeout(t);
|
||||
}, [ adsEnabled ]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const [ adsTxtRes, configRes ] = await Promise.all([
|
||||
fetch('/ads.txt', { cache: 'no-cache' }),
|
||||
fetch('/adsense.json', { cache: 'no-cache' })
|
||||
]);
|
||||
|
||||
if (!adsTxtRes.ok) throw new Error(`ads.txt ${ adsTxtRes.status }`);
|
||||
|
||||
const adsTxt = await adsTxtRes.text();
|
||||
const pubId = parsePublisherIdFromAdsTxt(adsTxt);
|
||||
|
||||
if (!pubId) throw new Error('No google.com publisher id in ads.txt');
|
||||
|
||||
let cfg: AdsenseConfig = { slot: '', format: 'auto', fullWidthResponsive: true };
|
||||
if (configRes.ok) cfg = { ...cfg, ...(await configRes.json()) };
|
||||
|
||||
if (cancelled) return;
|
||||
setPublisherId(pubId);
|
||||
setConfig(cfg);
|
||||
} catch (err) {
|
||||
if (!cancelled) setLoadError((err as Error).message);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !publisherId || !config) return;
|
||||
ensureAdsenseScript(publisherId);
|
||||
}, [ isOpen, publisherId, config ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
pushedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!insRef.current || pushedRef.current) return;
|
||||
if (!publisherId || !config?.slot) return;
|
||||
|
||||
const tryPush = () => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const w = window as any;
|
||||
w.adsbygoogle = w.adsbygoogle || [];
|
||||
w.adsbygoogle.push({});
|
||||
pushedRef.current = true;
|
||||
} catch {
|
||||
// AdSense script may not be ready yet; retry once
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const w = window as any;
|
||||
w.adsbygoogle = w.adsbygoogle || [];
|
||||
w.adsbygoogle.push({});
|
||||
pushedRef.current = true;
|
||||
} catch { /* give up */ }
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
const t = setTimeout(tryPush, 50);
|
||||
return () => clearTimeout(t);
|
||||
}, [ isOpen, publisherId, config ]);
|
||||
|
||||
if (!adsEnabled) return null;
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<NitroCardView className="nitro-google-ads" uniqueKey="google-ads" theme="primary">
|
||||
<NitroCardHeaderView headerText="Sponsored" onCloseClick={ () => setIsOpen(false) } />
|
||||
<NitroCardContentView>
|
||||
<div className="flex items-center justify-center w-[300px] h-[250px] bg-white">
|
||||
{ loadError &&
|
||||
<div className="text-xs text-red-600 text-center px-2">Ads unavailable: { loadError }</div> }
|
||||
{ !loadError && (!publisherId || !config) &&
|
||||
<div className="text-xs text-gray-500">Loading…</div> }
|
||||
{ !loadError && publisherId && config?.slot &&
|
||||
<ins
|
||||
ref={ insRef }
|
||||
key={ `${ publisherId }-${ config.slot }` }
|
||||
className="adsbygoogle"
|
||||
style={ { display: 'block', width: '100%', height: '100%' } }
|
||||
data-ad-client={ `ca-${ publisherId }` }
|
||||
data-ad-slot={ config.slot }
|
||||
data-ad-format={ config.format ?? 'auto' }
|
||||
data-full-width-responsive={ (config.fullWidthResponsive ?? true) ? 'true' : 'false' }
|
||||
/> }
|
||||
{ !loadError && publisherId && config && !config.slot &&
|
||||
<div className="text-xs text-gray-500 text-center px-2">Ad slot not configured in adsense.json</div> }
|
||||
</div>
|
||||
</NitroCardContentView>
|
||||
</NitroCardView>
|
||||
);
|
||||
};
|
||||
@@ -88,11 +88,13 @@ export const AvatarEditorView: FC<{}> = props =>
|
||||
const isWardrobe = (modelKey === AvatarEditorFigureCategory.WARDROBE);
|
||||
const isPets = (modelKey === AvatarEditorFigureCategory.PETS);
|
||||
const isNft = (modelKey === AvatarEditorFigureCategory.NFT);
|
||||
const isMisc = (modelKey === AvatarEditorFigureCategory.MISC);
|
||||
|
||||
let tabClass = `tab ${ modelKey }`;
|
||||
if(isWardrobe) tabClass = 'tab-wardrobe';
|
||||
else if(isPets) tabClass = 'tab-pets';
|
||||
else if(isNft) tabClass = 'tab-nft';
|
||||
else if(isMisc) tabClass = 'tab-misc';
|
||||
|
||||
return (
|
||||
<NitroCardTabsItemView key={ modelKey } isActive={ isActive } onClick={ event => setActiveModelKey(modelKey) }>
|
||||
|
||||
@@ -84,7 +84,7 @@ export const AvatarEditorFigureSetItemView: FC<{
|
||||
<img
|
||||
src={ assetUrl }
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-contain pointer-events-none image-rendering-pixelated"
|
||||
className="max-w-full max-h-full pointer-events-none image-rendering-pixelated"
|
||||
draggable={ false }
|
||||
/> }
|
||||
{ !partItem.isClear && isHC && <LayoutCurrencyIcon className="absolute inset-e-1 bottom-1" type="hc" /> }
|
||||
|
||||
@@ -207,7 +207,7 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
|
||||
setLastError(null);
|
||||
pendingActionRef.current = 'saveOffer';
|
||||
SendMessageComposer(new CatalogAdminSaveOfferComposer(
|
||||
data.offerId || 0, data.pageId, parseInt(data.itemIds) || 0,
|
||||
data.offerId || 0, data.pageId, data.itemIds || '',
|
||||
data.catalogName, data.costCredits, data.costPoints, data.pointsType,
|
||||
data.amount, data.clubOnly === '1' ? 1 : 0, data.extradata,
|
||||
data.haveOffer === '1', data.offerId_group, data.limitedStack, data.orderNumber, currentType
|
||||
@@ -220,7 +220,7 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
|
||||
setLastError(null);
|
||||
pendingActionRef.current = 'createOffer';
|
||||
SendMessageComposer(new CatalogAdminCreateOfferComposer(
|
||||
data.pageId, parseInt(data.itemIds) || 0,
|
||||
data.pageId, data.itemIds || '',
|
||||
data.catalogName, data.costCredits, data.costPoints, data.pointsType,
|
||||
data.amount, data.clubOnly === '1' ? 1 : 0, data.extradata,
|
||||
data.haveOffer === '1', data.offerId_group, data.limitedStack, data.orderNumber, currentType
|
||||
|
||||
@@ -16,7 +16,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
|
||||
const createOffer = catalogAdmin?.createOffer;
|
||||
const loading = catalogAdmin?.loading ?? false;
|
||||
|
||||
const [ itemIds, setItemIds ] = useState('0');
|
||||
const [ itemIds, setItemIds ] = useState('');
|
||||
const [ catalogName, setCatalogName ] = useState('');
|
||||
const [ costCredits, setCostCredits ] = useState(0);
|
||||
const [ costPoints, setCostPoints ] = useState(0);
|
||||
@@ -37,7 +37,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
|
||||
if(editingOffer.offerId === -1)
|
||||
{
|
||||
setIsNew(true);
|
||||
setItemIds('0');
|
||||
setItemIds('');
|
||||
setCatalogName('');
|
||||
setCostCredits(0);
|
||||
setCostPoints(0);
|
||||
@@ -53,7 +53,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
|
||||
else
|
||||
{
|
||||
setIsNew(false);
|
||||
setItemIds(String(editingOffer.product?.productClassId || 0));
|
||||
setItemIds(editingOffer.itemIds || '');
|
||||
setCatalogName(editingOffer.localizationName || '');
|
||||
setCostCredits(editingOffer.priceInCredits);
|
||||
setCostPoints(editingOffer.priceInActivityPoints);
|
||||
@@ -61,7 +61,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
|
||||
setAmount(editingOffer.product?.productCount || 1);
|
||||
setClubOnly(editingOffer.clubLevel > 0 ? '1' : '0');
|
||||
setExtradata(editingOffer.product?.extraParam || '');
|
||||
setHaveOffer('1');
|
||||
setHaveOffer(editingOffer.haveOffer ? '1' : '0');
|
||||
setOfferIdGroup(editingOffer.offerId || -1);
|
||||
setLimitedStack(0);
|
||||
setOrderNumber(0);
|
||||
@@ -104,7 +104,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
|
||||
if(setEditingOffer) setEditingOffer(null);
|
||||
};
|
||||
|
||||
const inputClass = 'text-[11px] border-2 border-card-grid-item-border rounded px-2 py-1 bg-white focus:outline-none focus:border-primary transition-colors';
|
||||
const inputClass = 'text-[11px] border-2 border-card-grid-item-border rounded px-2 py-1 bg-white placeholder:text-[#4b5563] focus:outline-none focus:border-primary transition-colors';
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 flex items-center justify-center" style={ { zIndex: 1000 } } onClick={ () => setEditingOffer(null) }>
|
||||
@@ -140,7 +140,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label className="text-[9px] text-muted">Item IDs</label>
|
||||
<input className={ inputClass } placeholder="1234" type="text" value={ itemIds } onChange={ e => setItemIds(e.target.value) } />
|
||||
<input className={ inputClass } placeholder="1234 or 100;200" type="text" value={ itemIds } onChange={ e => setItemIds(e.target.value) } />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label className="text-[9px] text-muted">{ LocalizeText('catalog.admin.offer.quantity') }</label>
|
||||
@@ -198,7 +198,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label className="text-[9px] text-muted">{ LocalizeText('catalog.admin.offer.extradata') }</label>
|
||||
<input className={ inputClass } placeholder="dati extra (opzionale)" type="text" value={ extradata } onChange={ e => setExtradata(e.target.value) } />
|
||||
<input className={ inputClass } placeholder={ LocalizeText('catalog.admin.offer.extradata') } type="text" value={ extradata } onChange={ e => setExtradata(e.target.value) } />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1.5">
|
||||
<input className="accent-primary" checked={ haveOffer === '1' } id="haveOffer" type="checkbox" onChange={ e => setHaveOffer(e.target.checked ? '1' : '0') } />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { GetAvatarRenderManager, GetSessionDataManager, Vector3d } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect } from 'react';
|
||||
import { FurniCategory, Offer, ProductTypeEnum } from '../../../../../api';
|
||||
import { BuildPurchasableClothingFigure, FurniCategory, Offer, ProductTypeEnum } from '../../../../../api';
|
||||
import { AutoGrid, Column, LayoutGridItem, LayoutRoomPreviewerView } from '../../../../../common';
|
||||
import { useCatalog } from '../../../../../hooks';
|
||||
|
||||
@@ -24,18 +24,37 @@ export const CatalogViewProductWidgetView: FC<{}> = props =>
|
||||
case ProductTypeEnum.FLOOR: {
|
||||
if(!product.furnitureData) return;
|
||||
|
||||
if(product.furnitureData.specialType === FurniCategory.FIGURE_PURCHASABLE_SET)
|
||||
const furniData = GetSessionDataManager().getFloorItemData(product.furnitureData.id);
|
||||
const isPurchasableClothing = (product.furnitureData.specialType === FurniCategory.FIGURE_PURCHASABLE_SET);
|
||||
const hasResolvableFigureSets = (() =>
|
||||
{
|
||||
if(!furniData || !furniData.customParams || !furniData.customParams.length) return false;
|
||||
|
||||
const parts = furniData.customParams.split(',').map(value => parseInt(value));
|
||||
|
||||
for(const part of parts)
|
||||
{
|
||||
if(isNaN(part)) continue;
|
||||
|
||||
if(GetAvatarRenderManager().structureData?.getFigurePartSet(part)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
})();
|
||||
|
||||
if(isPurchasableClothing || hasResolvableFigureSets)
|
||||
{
|
||||
const furniData = GetSessionDataManager().getFloorItemData(product.furnitureData.id);
|
||||
const customParts = furniData.customParams.split(',').map(value => parseInt(value));
|
||||
const figureSets: number[] = [];
|
||||
|
||||
for(const part of customParts)
|
||||
{
|
||||
if(isNaN(part)) continue;
|
||||
|
||||
if(GetAvatarRenderManager().isValidFigureSetForGender(part, GetSessionDataManager().gender)) figureSets.push(part);
|
||||
}
|
||||
|
||||
const figureString = GetAvatarRenderManager().getFigureStringWithFigureIds(GetSessionDataManager().figure, GetSessionDataManager().gender, figureSets);
|
||||
const figureString = BuildPurchasableClothingFigure(GetSessionDataManager().figure, figureSets);
|
||||
|
||||
roomPreviewer.addAvatarIntoRoom(figureString, product.productClassId);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount
|
||||
{ (requestsCount > 0) &&
|
||||
<motion.div variants={ itemVariants }>
|
||||
<div className="flex h-[34px] items-center rounded-[7px] border border-[#9fc56f] bg-[#5f7d2f] px-[10px] text-[0.74rem] font-bold whitespace-nowrap text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.18),0_2px_0_rgba(0,0,0,0.25)]">
|
||||
{ requestsCount } richieste
|
||||
{ requestsCount } { LocalizeText('friendbar.requests.title') }
|
||||
</div>
|
||||
</motion.div> }
|
||||
<motion.div variants={itemVariants}>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { AddLinkEventTracker, ClubGiftInfoEvent, CreateLinkEvent, GetClubGiftInfo, ILinkEventTracker, RemoveLinkEventTracker, ScrGetKickbackInfoMessageComposer, ScrKickbackData, ScrSendKickbackInfoMessageEvent } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { ClubStatus, FriendlyTime, GetClubBadge, GetConfigurationValue, LocalizeText, SendMessageComposer } from '../../api';
|
||||
import hcLogo from '../../assets/images/hc-center/hc_logo.gif';
|
||||
import paydayBg from '../../assets/images/hc-center/payday.png';
|
||||
import clockIcon from '../../assets/images/hc-center/clock.png';
|
||||
import benefitsBg from '../../assets/images/hc-center/benefits.png';
|
||||
import { Button, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
|
||||
import { useInventoryBadges, useMessageEvent, usePurse, useSessionInfo } from '../../hooks';
|
||||
|
||||
@@ -126,73 +130,72 @@ export const HcCenterView: FC<{}> = props =>
|
||||
);
|
||||
|
||||
return (
|
||||
<NitroCardView className="nitro-hc-center" theme="primary-slim">
|
||||
<NitroCardView className="w-[430px] resize-none" theme="primary-slim">
|
||||
<NitroCardHeaderView headerText={ LocalizeText('generic.hccenter') } onCloseClick={ () => setIsVisible(false) } />
|
||||
<Flex className="bg-muted p-2" position="relative">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="hc-logo" />
|
||||
<Flex>
|
||||
<Button variant="success" onClick={ event => CreateLinkEvent('catalog/open/' + GetConfigurationValue('catalog.links')['hc.buy_hc']) }>
|
||||
{ LocalizeText((clubStatus === ClubStatus.ACTIVE) ? 'hccenter.btn.extend' : 'hccenter.btn.buy') }
|
||||
</Button>
|
||||
</Flex>
|
||||
</div>
|
||||
<div className="inset-e-0 p-4 top-0 habbo-avatar absolute">
|
||||
<Flex className="bg-muted/50 p-3" position="relative">
|
||||
<Column gap={ 2 }>
|
||||
<div className="w-[213px] h-[37px] bg-contain bg-no-repeat" style={ { backgroundImage: `url(${ hcLogo })` } } />
|
||||
<Button variant="success" onClick={ event => CreateLinkEvent('catalog/open/' + GetConfigurationValue('catalog.links')['hc.buy_hc']) }>
|
||||
{ LocalizeText((clubStatus === ClubStatus.ACTIVE) ? 'hccenter.btn.extend' : 'hccenter.btn.buy') }
|
||||
</Button>
|
||||
</Column>
|
||||
<div className="absolute right-0 top-0 p-2 z-[4]">
|
||||
<LayoutAvatarImageView direction={ 4 } figure={ userFigure } scale={ 2 } />
|
||||
</div>
|
||||
</Flex>
|
||||
<NitroCardContentView>
|
||||
<div className="flex gap-2">
|
||||
<LayoutBadgeImageView badgeCode={ badgeCode } className="align-self-center shrink-0 me-1" />
|
||||
<Column className="streak-info" gap={ 0 } size={ 5 }>
|
||||
<Text>{ LocalizeText('hccenter.status.' + clubStatus) }</Text>
|
||||
<Text dangerouslySetInnerHTML={ { __html: getInfoText() } } />
|
||||
<Flex gap={ 2 } alignItems="center" className="p-2 rounded bg-card-grid-item/30">
|
||||
<LayoutBadgeImageView badgeCode={ badgeCode } className="shrink-0" />
|
||||
<Column gap={ 0 } className="min-h-[48px] leading-4">
|
||||
<Text bold>{ LocalizeText('hccenter.status.' + clubStatus) }</Text>
|
||||
<Text small className="text-gray-700" dangerouslySetInnerHTML={ { __html: getInfoText() } } />
|
||||
</Column>
|
||||
</div>
|
||||
</Flex>
|
||||
{ GetConfigurationValue('hc.center')['payday.info'] &&
|
||||
<Flex alignItems="center">
|
||||
|
||||
<Column className="rounded-start bg-primary p-2 payday-special mb-1">
|
||||
<h4 className="mb-1">{ LocalizeText('hccenter.special.title') }</h4>
|
||||
<div>{ LocalizeText('hccenter.special.info') }</div>
|
||||
<div className="btn btn-link text-white p-0 mt-auto align-self-baseline" onClick={ () => CreateLinkEvent('habbopages/' + GetConfigurationValue('hc.center')['payday.habbopage']) }>{ LocalizeText('hccenter.special.infolink') }</div>
|
||||
</Column>
|
||||
<div className="payday shrink-0 p-2">
|
||||
<h5 className="mb-2 ms-2">{ LocalizeText('hccenter.special.time.title') }</h5>
|
||||
<div className="flex flex-row mb-2">
|
||||
<div className="clock me-2" />
|
||||
<h6 className="mb-0 align-self-center">{ getHcPaydayTime() }</h6>
|
||||
<Flex className="rounded overflow-hidden border border-card-grid-item-border">
|
||||
<Column className="bg-primary p-3 flex-1 text-white" gap={ 1 }>
|
||||
<Text bold className="text-white">{ LocalizeText('hccenter.special.title') }</Text>
|
||||
<Text small className="text-white/80">{ LocalizeText('hccenter.special.info') }</Text>
|
||||
<div className="mt-auto">
|
||||
<span className="text-white/90 text-sm cursor-pointer hover:underline" onClick={ () => CreateLinkEvent('habbopages/' + GetConfigurationValue('hc.center')['payday.habbopage']) }>
|
||||
{ LocalizeText('hccenter.special.infolink') }
|
||||
</span>
|
||||
</div>
|
||||
</Column>
|
||||
<Column className="w-[200px] shrink-0 p-3 bg-contain bg-no-repeat bg-center text-[#6b3502]" gap={ 1 } style={ { backgroundImage: `url(${ paydayBg })` } }>
|
||||
<Text bold small>{ LocalizeText('hccenter.special.time.title') }</Text>
|
||||
<Flex gap={ 1 } alignItems="center">
|
||||
<div className="w-5 h-5 shrink-0 bg-contain bg-no-repeat bg-center" style={ { backgroundImage: `url(${ clockIcon })` } } />
|
||||
<Text bold>{ getHcPaydayTime() }</Text>
|
||||
</Flex>
|
||||
{ clubStatus === ClubStatus.ACTIVE &&
|
||||
<div className="pe-3">
|
||||
<h5 className="ms-2 mb-1 bolder">{ LocalizeText('hccenter.special.amount.title') }</h5>
|
||||
<div className="flex flex-col">
|
||||
<div className="w-full text-center ms-4n">{ getHcPaydayAmount() }</div>
|
||||
<div className="btn btn-link align-self-end text-primary">
|
||||
{ LocalizeText('hccenter.breakdown.infolink') }
|
||||
</div>
|
||||
</div>
|
||||
</div> }
|
||||
</div>
|
||||
<Column gap={ 0 } className="mt-1">
|
||||
<Text bold small>{ LocalizeText('hccenter.special.amount.title') }</Text>
|
||||
<Text bold className="text-center">{ getHcPaydayAmount() }</Text>
|
||||
<span className="text-primary text-sm cursor-pointer hover:underline self-end" onClick={ () => CreateLinkEvent('habbopages/' + GetConfigurationValue('hc.center')['payday.habbopage']) }>
|
||||
{ LocalizeText('hccenter.breakdown.infolink') }
|
||||
</span>
|
||||
</Column> }
|
||||
</Column>
|
||||
</Flex> }
|
||||
{ GetConfigurationValue('hc.center')['gift.info'] &&
|
||||
<div className="rounded bg-success p-2 flex flex-row mb-0">
|
||||
<div>
|
||||
<h4 className="mb-1">{ LocalizeText('hccenter.gift.title') }</h4>
|
||||
<div dangerouslySetInnerHTML={ { __html: unclaimedGifts > 0 ? LocalizeText('hccenter.unclaimedgifts', [ 'unclaimedgifts' ], [ unclaimedGifts.toString() ]) : LocalizeText('hccenter.gift.info') } }></div>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-lg align-self-center ms-auto" onClick={ () => CreateLinkEvent('catalog/open/' + GetConfigurationValue('catalog.links')['hc.hc_gifts']) }>
|
||||
<Flex className="rounded bg-success/90 p-3" alignItems="center" gap={ 2 }>
|
||||
<Column gap={ 0 } className="flex-1">
|
||||
<Text bold className="text-white">{ LocalizeText('hccenter.gift.title') }</Text>
|
||||
<Text small className="text-white/80" dangerouslySetInnerHTML={ { __html: unclaimedGifts > 0 ? LocalizeText('hccenter.unclaimedgifts', [ 'unclaimedgifts' ], [ unclaimedGifts.toString() ]) : LocalizeText('hccenter.gift.info') } } />
|
||||
</Column>
|
||||
<Button variant="primary" className="shrink-0" onClick={ () => CreateLinkEvent('catalog/open/' + GetConfigurationValue('catalog.links')['hc.hc_gifts']) }>
|
||||
{ LocalizeText(clubStatus === ClubStatus.ACTIVE ? 'hccenter.btn.gifts.redeem' : 'hccenter.btn.gifts.view') }
|
||||
</button>
|
||||
</div> }
|
||||
</Button>
|
||||
</Flex> }
|
||||
{ GetConfigurationValue('hc.center')['benefits.info'] &&
|
||||
<div className="benefits text-black py-2">
|
||||
<h5 className="mb-1 text-primary">{ LocalizeText('hccenter.general.title') }</h5>
|
||||
<div className="mb-2" dangerouslySetInnerHTML={ { __html: LocalizeText('hccenter.general.info') } } />
|
||||
<button className="btn btn-link p-0 text-primary" onClick={ () => CreateLinkEvent('habbopages/' + GetConfigurationValue('hc.center')['benefits.habbopage']) }>
|
||||
<Column className="rounded p-3 bg-no-repeat bg-right-top border border-card-grid-item-border" gap={ 1 } style={ { backgroundImage: `url(${ benefitsBg })` } }>
|
||||
<Text bold variant="primary">{ LocalizeText('hccenter.general.title') }</Text>
|
||||
<Text small className="text-gray-700" dangerouslySetInnerHTML={ { __html: LocalizeText('hccenter.general.info') } } />
|
||||
<span className="text-primary text-sm cursor-pointer hover:underline mt-1" onClick={ () => CreateLinkEvent('habbopages/' + GetConfigurationValue('hc.center')['benefits.habbopage']) }>
|
||||
{ LocalizeText('hccenter.general.infolink') }
|
||||
</button>
|
||||
</div> }
|
||||
</span>
|
||||
</Column> }
|
||||
</NitroCardContentView>
|
||||
</NitroCardView>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
@import './friends/FriendsView';
|
||||
@import './groups/GroupView';
|
||||
@import './guide-tool/GuideToolView';
|
||||
@import './hc-center/HcCenterView';
|
||||
@import './help/HelpView';
|
||||
@import './hotel-view/HotelView';
|
||||
@import './loading/LoadingView';
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
import { GetConfiguration } from '@nitrots/nitro-renderer';
|
||||
import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { GetConfigurationValue } from '../../api';
|
||||
import { TurnstileWidget } from './TurnstileWidget';
|
||||
|
||||
type DialogMode = 'login' | 'register' | 'forgot';
|
||||
|
||||
const interpolate = (value: string | null | undefined): string =>
|
||||
{
|
||||
if(!value) return '';
|
||||
try { return GetConfiguration().interpolate(value); }
|
||||
catch { return value; }
|
||||
};
|
||||
|
||||
const LOCK_KEY = 'nitro.login.lock';
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const LOCK_WINDOW_MS = 60_000; // rolling 60s window
|
||||
const LOCK_DURATION_MS = 2 * 60_000; // 2 minute lockout
|
||||
|
||||
type AttemptState = { attempts: number; firstAt: number; lockedUntil: number };
|
||||
|
||||
const readLock = (): AttemptState =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const raw = sessionStorage.getItem(LOCK_KEY);
|
||||
if(!raw) return { attempts: 0, firstAt: 0, lockedUntil: 0 };
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
catch { return { attempts: 0, firstAt: 0, lockedUntil: 0 }; }
|
||||
};
|
||||
|
||||
const writeLock = (state: AttemptState) =>
|
||||
{
|
||||
try { sessionStorage.setItem(LOCK_KEY, JSON.stringify(state)); }
|
||||
catch { /* ignore */ }
|
||||
};
|
||||
|
||||
export interface LoginViewProps
|
||||
{
|
||||
onAuthenticated: (ssoTicket: string) => void;
|
||||
}
|
||||
|
||||
export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
|
||||
{
|
||||
const [ mode, setMode ] = useState<DialogMode>('login');
|
||||
const [ username, setUsername ] = useState('');
|
||||
const [ password, setPassword ] = useState('');
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
const [ info, setInfo ] = useState<string | null>(null);
|
||||
const [ submitting, setSubmitting ] = useState(false);
|
||||
const [ loginTurnstileToken, setLoginTurnstileToken ] = useState('');
|
||||
const [ loginTurnstileResetSignal, setLoginTurnstileResetSignal ] = useState(0);
|
||||
const submitTimeRef = useRef(0);
|
||||
|
||||
const loginImages: Record<string, string> = ((GetConfigurationValue<Record<string, unknown>>('loginview', {})?.['images']) as Record<string, string>) ?? {};
|
||||
|
||||
const backgroundColor = (loginImages['background.colour'] || GetConfigurationValue<string>('login_background.colour', '#6eadc8'));
|
||||
const background = interpolate(loginImages['background'] || GetConfigurationValue<string>('login_background', ''));
|
||||
const sun = interpolate(loginImages['sun'] || GetConfigurationValue<string>('login_sun', ''));
|
||||
const drape = interpolate(loginImages['drape'] || GetConfigurationValue<string>('login_drape', ''));
|
||||
const left = interpolate(loginImages['left'] || GetConfigurationValue<string>('login_left', ''));
|
||||
const rightRepeat = interpolate(loginImages['right.repeat'] || GetConfigurationValue<string>('login_right.repeat', ''));
|
||||
const right = interpolate(loginImages['right'] || GetConfigurationValue<string>('login_right', ''));
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
// eslint-disable-next-line no-console
|
||||
console.info('[LoginView] resolved background assets', {
|
||||
'asset.url': GetConfigurationValue<string>('asset.url', ''),
|
||||
login_background: background,
|
||||
'login_background.colour': backgroundColor,
|
||||
login_sun: sun,
|
||||
login_drape: drape,
|
||||
login_left: left,
|
||||
login_right: right,
|
||||
'login_right.repeat': rightRepeat
|
||||
});
|
||||
}, [ background, backgroundColor, sun, drape, left, right, rightRepeat ]);
|
||||
|
||||
const loginUrl = GetConfigurationValue<string>('login.endpoint', '/api/auth/login');
|
||||
const registerUrl = GetConfigurationValue<string>('login.register.endpoint', '/api/auth/register');
|
||||
const forgotUrl = GetConfigurationValue<string>('login.forgot.endpoint', '/api/auth/forgot-password');
|
||||
const turnstileSiteKey = GetConfigurationValue<string>('login.turnstile.sitekey', '');
|
||||
const rawTurnstileEnabled = GetConfigurationValue<unknown>('login.turnstile.enabled', false);
|
||||
const turnstileEnabled = (rawTurnstileEnabled === true
|
||||
|| rawTurnstileEnabled === 'true'
|
||||
|| rawTurnstileEnabled === 1
|
||||
|| rawTurnstileEnabled === '1') && !!turnstileSiteKey;
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
// eslint-disable-next-line no-console
|
||||
console.info('[LoginView] turnstile config', {
|
||||
rawTurnstileEnabled,
|
||||
turnstileEnabled,
|
||||
turnstileSiteKey: turnstileSiteKey ? (turnstileSiteKey.slice(0, 6) + '…') : '(empty)'
|
||||
});
|
||||
}, [ rawTurnstileEnabled, turnstileEnabled, turnstileSiteKey ]);
|
||||
|
||||
const resetLoginTurnstile = useCallback(() =>
|
||||
{
|
||||
setLoginTurnstileToken('');
|
||||
setLoginTurnstileResetSignal(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
// Clear error on mode change but keep the success notification so users
|
||||
// returning to the login form can read it (e.g. "Account created").
|
||||
// Reset the login captcha only when we're actually on the login form.
|
||||
useEffect(() =>
|
||||
{
|
||||
setError(null);
|
||||
if(mode === 'login') resetLoginTurnstile();
|
||||
}, [ mode, resetLoginTurnstile ]);
|
||||
|
||||
// Auto-dismiss the info notification after a few seconds so it doesn't
|
||||
// hang around forever once the user has seen it.
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!info) return;
|
||||
const timeout = window.setTimeout(() => setInfo(null), 8000);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [ info ]);
|
||||
|
||||
const lockState = useMemo(() => readLock(), [ submitting ]);
|
||||
const now = Date.now();
|
||||
const isLocked = lockState.lockedUntil > now;
|
||||
|
||||
const recordFailure = useCallback(() =>
|
||||
{
|
||||
const state = readLock();
|
||||
const currentNow = Date.now();
|
||||
|
||||
if(currentNow - state.firstAt > LOCK_WINDOW_MS)
|
||||
{
|
||||
writeLock({ attempts: 1, firstAt: currentNow, lockedUntil: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const attempts = state.attempts + 1;
|
||||
const lockedUntil = attempts >= MAX_ATTEMPTS ? currentNow + LOCK_DURATION_MS : 0;
|
||||
writeLock({ attempts, firstAt: state.firstAt || currentNow, lockedUntil });
|
||||
}, []);
|
||||
|
||||
const clearLock = useCallback(() =>
|
||||
{
|
||||
writeLock({ attempts: 0, firstAt: 0, lockedUntil: 0 });
|
||||
}, []);
|
||||
|
||||
const postJson = useCallback(async (url: string, body: Record<string, unknown>) =>
|
||||
{
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'NitroLoginView'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
let payload: Record<string, unknown> = {};
|
||||
try { payload = await response.json(); }
|
||||
catch { /* ignore non-json responses */ }
|
||||
|
||||
return { ok: response.ok, status: response.status, payload };
|
||||
}, []);
|
||||
|
||||
const handleLoginSubmit = useCallback(async (event: FormEvent<HTMLFormElement>) =>
|
||||
{
|
||||
event.preventDefault();
|
||||
|
||||
if(submitting) return;
|
||||
|
||||
const nowTs = Date.now();
|
||||
if(nowTs - submitTimeRef.current < 1000) return;
|
||||
submitTimeRef.current = nowTs;
|
||||
|
||||
const state = readLock();
|
||||
if(state.lockedUntil > nowTs)
|
||||
{
|
||||
const remaining = Math.ceil((state.lockedUntil - nowTs) / 1000);
|
||||
setError(`Too many attempts. Try again in ${ remaining }s.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if(!username.trim() || !password)
|
||||
{
|
||||
setError('Please enter both your Habbo name and password.');
|
||||
return;
|
||||
}
|
||||
|
||||
if(turnstileEnabled && !loginTurnstileToken)
|
||||
{
|
||||
setError('Please complete the security check.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
|
||||
try
|
||||
{
|
||||
const { ok, payload } = await postJson(loginUrl, {
|
||||
username: username.trim(),
|
||||
password,
|
||||
turnstileToken: turnstileEnabled ? loginTurnstileToken : undefined
|
||||
});
|
||||
|
||||
const ssoTicket = typeof payload.ssoTicket === 'string' ? payload.ssoTicket : (typeof payload.sso === 'string' ? payload.sso : '');
|
||||
|
||||
if(ok && ssoTicket)
|
||||
{
|
||||
clearLock();
|
||||
onAuthenticated(ssoTicket);
|
||||
return;
|
||||
}
|
||||
|
||||
recordFailure();
|
||||
const message = typeof payload.error === 'string' ? payload.error : 'Invalid Habbo name or password.';
|
||||
setError(message);
|
||||
resetLoginTurnstile();
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
recordFailure();
|
||||
setError('Unable to reach the login service. Please try again.');
|
||||
resetLoginTurnstile();
|
||||
}
|
||||
finally
|
||||
{
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [ submitting, username, password, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile ]);
|
||||
|
||||
// Register + forgot-password submit handlers receive the Turnstile token
|
||||
// from the dialog (the dialog owns its own widget lifecycle), so the
|
||||
// login widget underneath can't reset or overwrite it while the user
|
||||
// is working on the modal.
|
||||
|
||||
const handleRegisterSubmit = useCallback(async (body: { username: string; email: string; password: string; turnstileToken: string; }, onDialogReset: () => void) =>
|
||||
{
|
||||
if(turnstileEnabled && !body.turnstileToken)
|
||||
{
|
||||
setError('Please complete the security check.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setSubmitting(true);
|
||||
|
||||
try
|
||||
{
|
||||
const { ok, payload } = await postJson(registerUrl, {
|
||||
username: body.username,
|
||||
email: body.email,
|
||||
password: body.password,
|
||||
turnstileToken: turnstileEnabled ? body.turnstileToken : undefined
|
||||
});
|
||||
|
||||
if(ok)
|
||||
{
|
||||
const friendly = `Welcome aboard, ${ body.username }! Your account is ready — log in below with the password you just chose.`;
|
||||
setInfo(typeof payload.message === 'string' ? payload.message : friendly);
|
||||
setMode('login');
|
||||
setUsername(body.username);
|
||||
setPassword('');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(typeof payload.error === 'string' ? payload.error : 'Unable to create your account.');
|
||||
onDialogReset();
|
||||
}
|
||||
catch
|
||||
{
|
||||
setError('Unable to reach the registration service.');
|
||||
onDialogReset();
|
||||
}
|
||||
finally
|
||||
{
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [ turnstileEnabled, registerUrl, postJson ]);
|
||||
|
||||
const handleForgotSubmit = useCallback(async (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) =>
|
||||
{
|
||||
if(turnstileEnabled && !body.turnstileToken)
|
||||
{
|
||||
setError('Please complete the security check.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setSubmitting(true);
|
||||
|
||||
try
|
||||
{
|
||||
const { ok, payload } = await postJson(forgotUrl, {
|
||||
email: body.email,
|
||||
turnstileToken: turnstileEnabled ? body.turnstileToken : undefined
|
||||
});
|
||||
|
||||
if(ok)
|
||||
{
|
||||
const friendly = 'Email sent! If an account matches that address you\'ll find a reset link in your inbox shortly (check spam if it doesn\'t show up within a minute).';
|
||||
setInfo(typeof payload.message === 'string' ? payload.message : friendly);
|
||||
setMode('login');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(typeof payload.error === 'string' ? payload.error : 'Unable to send a reset email right now.');
|
||||
onDialogReset();
|
||||
}
|
||||
catch
|
||||
{
|
||||
setError('Unable to reach the password reset service.');
|
||||
onDialogReset();
|
||||
}
|
||||
finally
|
||||
{
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [ turnstileEnabled, forgotUrl, postJson ]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="nitro-login-view"
|
||||
style={ backgroundColor ? { background: backgroundColor } : undefined }
|
||||
>
|
||||
{ background ? <div className="login-background login-layer" style={ { backgroundImage: `url(${ background })` } } /> : null }
|
||||
{ sun ? <div className="login-sun login-layer" style={ { backgroundImage: `url(${ sun })` } } /> : null }
|
||||
{ drape ? <div className="login-drape login-layer" style={ { backgroundImage: `url(${ drape })` } } /> : null }
|
||||
{ left ? <div className="login-left login-layer" style={ { backgroundImage: `url(${ left })` } } /> : null }
|
||||
{ rightRepeat ? <div className="login-right-repeat login-layer" style={ { backgroundImage: `url(${ rightRepeat })` } } /> : null }
|
||||
{ right ? <div className="login-right login-layer" style={ { backgroundImage: `url(${ right })` } } /> : null }
|
||||
|
||||
<div className="login-stack">
|
||||
<div className="nitro-login-card">
|
||||
<div className="card-title">First time here?</div>
|
||||
<div className="card-body register-card-body">
|
||||
<span>Don't have a Habbo yet?</span>
|
||||
<a onClick={ () => setMode('register') }>You can create one here</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="nitro-login-card">
|
||||
<div className="card-title">What's your Habbo called?</div>
|
||||
<form className="card-body" onSubmit={ handleLoginSubmit } autoComplete="on">
|
||||
<div className="field">
|
||||
<label htmlFor="login-username">Name of your Habbo</label>
|
||||
<input
|
||||
id="login-username"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
type="text"
|
||||
maxLength={ 32 }
|
||||
value={ username }
|
||||
onChange={ e => setUsername(e.target.value) }
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="login-password">Password</label>
|
||||
<input
|
||||
id="login-password"
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
type="password"
|
||||
maxLength={ 128 }
|
||||
value={ password }
|
||||
onChange={ e => setPassword(e.target.value) }
|
||||
/>
|
||||
</div>
|
||||
{ turnstileEnabled && mode === 'login' &&
|
||||
<TurnstileWidget
|
||||
siteKey={ turnstileSiteKey }
|
||||
size="compact"
|
||||
onToken={ setLoginTurnstileToken }
|
||||
onExpire={ () => setLoginTurnstileToken('') }
|
||||
onError={ () => setLoginTurnstileToken('') }
|
||||
resetSignal={ loginTurnstileResetSignal }
|
||||
/> }
|
||||
{ error && <div className="error-line">{ error }</div> }
|
||||
{ info && <div className="info-line">{ info }</div> }
|
||||
<div className="submit-row">
|
||||
<button
|
||||
type="submit"
|
||||
className="ok-button"
|
||||
disabled={ submitting || isLocked }
|
||||
>OK</button>
|
||||
</div>
|
||||
<a className="forgot" onClick={ () => setMode('forgot') }>Forgotten your password?</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ mode === 'register' &&
|
||||
<RegisterDialog
|
||||
onCancel={ () => setMode('login') }
|
||||
onSubmit={ handleRegisterSubmit }
|
||||
submitting={ submitting }
|
||||
error={ error }
|
||||
info={ info }
|
||||
turnstileEnabled={ turnstileEnabled }
|
||||
turnstileSiteKey={ turnstileSiteKey }
|
||||
/> }
|
||||
|
||||
{ mode === 'forgot' &&
|
||||
<ForgotDialog
|
||||
onCancel={ () => setMode('login') }
|
||||
onSubmit={ handleForgotSubmit }
|
||||
submitting={ submitting }
|
||||
error={ error }
|
||||
info={ info }
|
||||
turnstileEnabled={ turnstileEnabled }
|
||||
turnstileSiteKey={ turnstileSiteKey }
|
||||
/> }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DialogSharedProps
|
||||
{
|
||||
onCancel: () => void;
|
||||
submitting: boolean;
|
||||
error: string | null;
|
||||
info: string | null;
|
||||
turnstileEnabled: boolean;
|
||||
turnstileSiteKey: string;
|
||||
}
|
||||
|
||||
interface RegisterDialogProps extends DialogSharedProps
|
||||
{
|
||||
onSubmit: (body: { username: string; email: string; password: string; turnstileToken: string; }, onDialogReset: () => void) => void;
|
||||
}
|
||||
|
||||
const RegisterDialog: FC<RegisterDialogProps> = props =>
|
||||
{
|
||||
const { onCancel, onSubmit, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props;
|
||||
const [ username, setUsername ] = useState('');
|
||||
const [ email, setEmail ] = useState('');
|
||||
const [ password, setPassword ] = useState('');
|
||||
const [ confirm, setConfirm ] = useState('');
|
||||
const [ localError, setLocalError ] = useState<string | null>(null);
|
||||
const [ turnstileToken, setTurnstileToken ] = useState('');
|
||||
const [ resetSignal, setResetSignal ] = useState(0);
|
||||
|
||||
const resetWidget = useCallback(() =>
|
||||
{
|
||||
setTurnstileToken('');
|
||||
setResetSignal(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
const handle = (event: FormEvent<HTMLFormElement>) =>
|
||||
{
|
||||
event.preventDefault();
|
||||
setLocalError(null);
|
||||
|
||||
if(!username.trim() || !email.trim() || !password)
|
||||
{
|
||||
setLocalError('Please fill in every field.');
|
||||
return;
|
||||
}
|
||||
|
||||
if(password.length < 8)
|
||||
{
|
||||
setLocalError('Your password must be at least 8 characters.');
|
||||
return;
|
||||
}
|
||||
|
||||
if(password !== confirm)
|
||||
{
|
||||
setLocalError('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit({ username: username.trim(), email: email.trim(), password, turnstileToken }, resetWidget);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="nitro-login-modal">
|
||||
<div className="dialog">
|
||||
<div className="nitro-login-card">
|
||||
<div className="card-title">
|
||||
<span>Create a Habbo</span>
|
||||
<span className="nitro-card-close-button" role="button" aria-label="Close" onClick={ onCancel } />
|
||||
</div>
|
||||
<form className="card-body" onSubmit={ handle } autoComplete="on">
|
||||
<div className="field">
|
||||
<label htmlFor="register-username">Habbo name</label>
|
||||
<input id="register-username" type="text" maxLength={ 32 } autoComplete="username"
|
||||
value={ username } onChange={ e => setUsername(e.target.value) } />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="register-email">Email</label>
|
||||
<input id="register-email" type="email" maxLength={ 120 } autoComplete="email"
|
||||
value={ email } onChange={ e => setEmail(e.target.value) } />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="register-password">Password</label>
|
||||
<input id="register-password" type="password" maxLength={ 128 } autoComplete="new-password"
|
||||
value={ password } onChange={ e => setPassword(e.target.value) } />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="register-confirm">Confirm password</label>
|
||||
<input id="register-confirm" type="password" maxLength={ 128 } autoComplete="new-password"
|
||||
value={ confirm } onChange={ e => setConfirm(e.target.value) } />
|
||||
</div>
|
||||
{ turnstileEnabled &&
|
||||
<TurnstileWidget
|
||||
siteKey={ turnstileSiteKey }
|
||||
size="compact"
|
||||
onToken={ setTurnstileToken }
|
||||
onExpire={ () => setTurnstileToken('') }
|
||||
onError={ () => setTurnstileToken('') }
|
||||
resetSignal={ resetSignal }
|
||||
/> }
|
||||
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
|
||||
{ info && <div className="info-line">{ info }</div> }
|
||||
<div className="submit-row">
|
||||
<button type="submit" className="ok-button" disabled={ submitting }>Create account</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ForgotDialogProps extends DialogSharedProps
|
||||
{
|
||||
onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => void;
|
||||
}
|
||||
|
||||
const ForgotDialog: FC<ForgotDialogProps> = props =>
|
||||
{
|
||||
const { onCancel, onSubmit, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props;
|
||||
const [ email, setEmail ] = useState('');
|
||||
const [ localError, setLocalError ] = useState<string | null>(null);
|
||||
const [ turnstileToken, setTurnstileToken ] = useState('');
|
||||
const [ resetSignal, setResetSignal ] = useState(0);
|
||||
|
||||
const resetWidget = useCallback(() =>
|
||||
{
|
||||
setTurnstileToken('');
|
||||
setResetSignal(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
const handle = (event: FormEvent<HTMLFormElement>) =>
|
||||
{
|
||||
event.preventDefault();
|
||||
setLocalError(null);
|
||||
|
||||
if(!email.trim())
|
||||
{
|
||||
setLocalError('Please enter your email address.');
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit({ email: email.trim(), turnstileToken }, resetWidget);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="nitro-login-modal">
|
||||
<div className="dialog">
|
||||
<div className="nitro-login-card">
|
||||
<div className="card-title">
|
||||
<span>Reset password</span>
|
||||
<span className="nitro-card-close-button" role="button" aria-label="Close" onClick={ onCancel } />
|
||||
</div>
|
||||
<form className="card-body" onSubmit={ handle } autoComplete="on">
|
||||
<div className="field">
|
||||
<label htmlFor="forgot-email">Email address</label>
|
||||
<input id="forgot-email" type="email" maxLength={ 120 } autoComplete="email"
|
||||
value={ email } onChange={ e => setEmail(e.target.value) } />
|
||||
</div>
|
||||
{ turnstileEnabled &&
|
||||
<TurnstileWidget
|
||||
siteKey={ turnstileSiteKey }
|
||||
size="compact"
|
||||
onToken={ setTurnstileToken }
|
||||
onExpire={ () => setTurnstileToken('') }
|
||||
onError={ () => setTurnstileToken('') }
|
||||
resetSignal={ resetSignal }
|
||||
/> }
|
||||
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
|
||||
{ info && <div className="info-line">{ info }</div> }
|
||||
<div className="submit-row">
|
||||
<button type="submit" className="ok-button" disabled={ submitting }>Send email</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import { FC, useEffect, useRef } from 'react';
|
||||
|
||||
declare global
|
||||
{
|
||||
interface Window
|
||||
{
|
||||
turnstile?: {
|
||||
render: (container: string | HTMLElement, options: Record<string, unknown>) => string;
|
||||
reset: (widgetId?: string) => void;
|
||||
remove: (widgetId?: string) => void;
|
||||
};
|
||||
onTurnstileLoad?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPT_ID = 'cf-turnstile-script';
|
||||
const SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
||||
|
||||
let scriptPromise: Promise<void> | null = null;
|
||||
|
||||
const loadTurnstileScript = (): Promise<void> =>
|
||||
{
|
||||
if(typeof window === 'undefined') return Promise.resolve();
|
||||
if(window.turnstile) return Promise.resolve();
|
||||
if(scriptPromise) return scriptPromise;
|
||||
|
||||
scriptPromise = new Promise<void>((resolve, reject) =>
|
||||
{
|
||||
const existing = document.getElementById(SCRIPT_ID) as HTMLScriptElement | null;
|
||||
|
||||
if(existing)
|
||||
{
|
||||
existing.addEventListener('load', () => resolve());
|
||||
existing.addEventListener('error', () => reject(new Error('Turnstile failed to load')));
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.id = SCRIPT_ID;
|
||||
script.src = SCRIPT_SRC;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('Turnstile failed to load'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return scriptPromise;
|
||||
};
|
||||
|
||||
export interface TurnstileWidgetProps
|
||||
{
|
||||
siteKey: string;
|
||||
theme?: 'light' | 'dark' | 'auto';
|
||||
size?: 'normal' | 'compact';
|
||||
onToken: (token: string) => void;
|
||||
onExpire?: () => void;
|
||||
onError?: () => void;
|
||||
resetSignal?: number;
|
||||
}
|
||||
|
||||
export const TurnstileWidget: FC<TurnstileWidgetProps> = props =>
|
||||
{
|
||||
const { siteKey, theme = 'light', size = 'normal', onToken, onExpire, onError, resetSignal = 0 } = props;
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const widgetIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!siteKey || !containerRef.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
loadTurnstileScript()
|
||||
.then(() =>
|
||||
{
|
||||
if(cancelled || !window.turnstile || !containerRef.current) return;
|
||||
|
||||
widgetIdRef.current = window.turnstile.render(containerRef.current, {
|
||||
sitekey: siteKey,
|
||||
theme,
|
||||
size,
|
||||
callback: (token: string) => onToken(token),
|
||||
'expired-callback': () => onExpire?.(),
|
||||
'error-callback': () => onError?.()
|
||||
});
|
||||
})
|
||||
.catch(err =>
|
||||
{
|
||||
console.error('[Turnstile] script load failed', err);
|
||||
onError?.();
|
||||
});
|
||||
|
||||
return () =>
|
||||
{
|
||||
cancelled = true;
|
||||
|
||||
if(widgetIdRef.current && window.turnstile)
|
||||
{
|
||||
try { window.turnstile.remove(widgetIdRef.current); } catch { }
|
||||
widgetIdRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [ siteKey, theme, size ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(resetSignal <= 0) return;
|
||||
if(widgetIdRef.current && window.turnstile)
|
||||
{
|
||||
try { window.turnstile.reset(widgetIdRef.current); } catch { }
|
||||
}
|
||||
}, [ resetSignal ]);
|
||||
|
||||
if(!siteKey) return null;
|
||||
|
||||
return <div ref={ containerRef } className="turnstile-slot" />;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FC } from 'react';
|
||||
import { GetRendererVersion, GetUIVersion, NotificationAlertItem } from '../../../../api';
|
||||
import { GetConfigurationValue, GetRendererVersion, GetUIVersion, NotificationAlertItem } from '../../../../api';
|
||||
import { Button, Column, Grid, LayoutNotificationAlertView, LayoutNotificationAlertViewProps, Text } from '../../../../common';
|
||||
|
||||
interface NotificationDefaultAlertViewProps extends LayoutNotificationAlertViewProps
|
||||
@@ -9,10 +9,11 @@ interface NotificationDefaultAlertViewProps extends LayoutNotificationAlertViewP
|
||||
|
||||
export const NitroSystemAlertView: FC<NotificationDefaultAlertViewProps> = props =>
|
||||
{
|
||||
const { title = 'Nitro', onClose = null, ...rest } = props;
|
||||
const { title = 'Nitro', onClose = null, classNames = [], ...rest } = props;
|
||||
const adsEnabled = GetConfigurationValue<boolean>('show.google.ads', false);
|
||||
|
||||
return (
|
||||
<LayoutNotificationAlertView title={ title } onClose={ onClose } { ...rest }>
|
||||
<LayoutNotificationAlertView title={ title } onClose={ onClose } classNames={ [ 'nitro-alert-system', ...classNames ] } { ...rest }>
|
||||
<Grid>
|
||||
<Column size={ 12 }>
|
||||
<Column alignItems="center" gap={ 0 }>
|
||||
@@ -23,6 +24,8 @@ export const NitroSystemAlertView: FC<NotificationDefaultAlertViewProps> = props
|
||||
<Text><b>Renderer:</b> v{ GetRendererVersion() }</Text>
|
||||
<Column fullWidth gap={ 1 }>
|
||||
<Button fullWidth variant="success" onClick={ event => window.open('https://discord.nitrodev.co') }>Discord</Button>
|
||||
{ adsEnabled &&
|
||||
<Button fullWidth onClick={ () => window.dispatchEvent(new CustomEvent('ads:toggle')) }>Show Ad</Button> }
|
||||
</Column>
|
||||
</Column>
|
||||
<div className="alertView_nitro-coolui-logo"></div>
|
||||
@@ -35,7 +38,7 @@ export const NitroSystemAlertView: FC<NotificationDefaultAlertViewProps> = props
|
||||
</Column>
|
||||
</Column>
|
||||
</Column>
|
||||
|
||||
|
||||
</Grid>
|
||||
</LayoutNotificationAlertView>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CreateLinkEvent, HabboClubLevelEnum } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useMemo, useState } from 'react';
|
||||
import { FaChevronDown, FaLanguage, FaQuestionCircle } from 'react-icons/fa';
|
||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FaChevronDown, FaLanguage, FaQuestionCircle, FaSignOutAlt } from 'react-icons/fa';
|
||||
import { FriendlyTime, GetConfigurationValue, LocalizeText } from '../../api';
|
||||
import { Column, Flex, LayoutCurrencyIcon, Text } from '../../common';
|
||||
import { usePurse } from '../../hooks';
|
||||
@@ -58,6 +58,33 @@ export const PurseView: FC<{}> = props => {
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [ isOpen ]);
|
||||
|
||||
const handleLogout = useCallback(async (event: React.MouseEvent) =>
|
||||
{
|
||||
event.stopPropagation();
|
||||
|
||||
const logoutUrl = GetConfigurationValue<string>('login.logout.endpoint', '/api/auth/logout');
|
||||
const ssoTicket = (window.NitroConfig?.['sso.ticket'] as string) ?? '';
|
||||
|
||||
try
|
||||
{
|
||||
await fetch(logoutUrl, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
keepalive: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'NitroPurseLogout'
|
||||
},
|
||||
body: JSON.stringify({ ssoTicket })
|
||||
});
|
||||
}
|
||||
catch { /* best-effort — proceed with local logout regardless */ }
|
||||
|
||||
if(window.NitroConfig) window.NitroConfig['sso.ticket'] = '';
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
if (!purse) return null;
|
||||
|
||||
return (
|
||||
@@ -100,6 +127,9 @@ export const PurseView: FC<{}> = props => {
|
||||
<button type="button" className="nitro-purse__action-button nitro-purse__action-button--settings" onClick={ event => { event.stopPropagation(); CreateLinkEvent('user-settings/toggle'); } } title={ LocalizeText('widget.memenu.settings.title') }>
|
||||
<i className="nitro-icon icon-cog" />
|
||||
</button>
|
||||
<button type="button" className="nitro-purse__action-button nitro-purse__action-button--logout" onClick={ handleLogout } title="Log out">
|
||||
<FaSignOutAlt />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{ seasonalCurrencies.length > 0 &&
|
||||
|
||||
+11
-15
@@ -1,6 +1,6 @@
|
||||
import { AvatarFigurePartType, GetAvatarRenderManager, GetSessionDataManager, RedeemItemClothingComposer, RoomObjectCategory, UserFigureComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { FurniCategory, GetFurnitureDataForRoomObject, LocalizeText, SendMessageComposer } from '../../../../../api';
|
||||
import { BuildPurchasableClothingFigure, GetFurnitureDataForRoomObject, LocalizeText, SendMessageComposer } from '../../../../../api';
|
||||
import { Button, Column, LayoutAvatarImageView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../../common';
|
||||
import { useRoom } from '../../../../../hooks';
|
||||
|
||||
@@ -41,22 +41,18 @@ export const PurchasableClothingConfirmView: FC<PurchasableClothingConfirmViewPr
|
||||
{
|
||||
const furniData = GetFurnitureDataForRoomObject(roomSession.roomId, objectId, RoomObjectCategory.FLOOR);
|
||||
|
||||
if(furniData)
|
||||
if(furniData && furniData.customParams && furniData.customParams.length)
|
||||
{
|
||||
switch(furniData.specialType)
|
||||
const setIds = furniData.customParams.split(',')
|
||||
.map(part => parseInt(part))
|
||||
.filter(id => !isNaN(id));
|
||||
|
||||
for(const setId of setIds)
|
||||
{
|
||||
case FurniCategory.FIGURE_PURCHASABLE_SET:
|
||||
mode = MODE_PURCHASABLE_CLOTHING;
|
||||
|
||||
const setIds = furniData.customParams.split(',').map(part => parseInt(part));
|
||||
|
||||
for(const setId of setIds)
|
||||
{
|
||||
if(GetAvatarRenderManager().isValidFigureSetForGender(setId, gender)) validSets.push(setId);
|
||||
}
|
||||
|
||||
break;
|
||||
if(GetAvatarRenderManager().isValidFigureSetForGender(setId, gender)) validSets.push(setId);
|
||||
}
|
||||
|
||||
if(validSets.length) mode = MODE_PURCHASABLE_CLOTHING;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +64,7 @@ export const PurchasableClothingConfirmView: FC<PurchasableClothingConfirmViewPr
|
||||
}
|
||||
|
||||
setGender(gender);
|
||||
setNewFigure(GetAvatarRenderManager().getFigureStringWithFigureIds(figure, gender, validSets));
|
||||
setNewFigure(BuildPurchasableClothingFigure(figure, validSets));
|
||||
|
||||
// if owns clothing, change to it
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { GetConfigurationValue, LocalizeText, SendMessageComposer, SetLocalStora
|
||||
import { Text } from '../../../../common';
|
||||
import { useMessageEvent, useNavigator, useRoom } from '../../../../hooks';
|
||||
import { getRegisteredPlugins, INitroPlugin, subscribePlugins } from '../../../plugins/NitroPluginApi';
|
||||
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
|
||||
|
||||
export const RoomToolsWidgetView: FC<{}> = props => {
|
||||
const [areBubblesMuted, setAreBubblesMuted] = useState(false);
|
||||
@@ -16,7 +15,6 @@ export const RoomToolsWidgetView: FC<{}> = props => {
|
||||
const [roomTags, setRoomTags] = useState<string[]>(null);
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [isOpenHistory, setIsOpenHistory] = useState<boolean>(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(false);
|
||||
const [roomHistory, setRoomHistory] = useState<{ roomId: number, roomName: string }[]>([]);
|
||||
const [plugins, setPlugins] = useState<INitroPlugin[]>([]);
|
||||
const { navigatorData = null } = useNavigator();
|
||||
@@ -81,11 +79,10 @@ export const RoomToolsWidgetView: FC<{}> = props => {
|
||||
|
||||
const onChangeRoomHistory = (roomId: number, roomName: string) => {
|
||||
let newStorage = JSON.parse(window.localStorage.getItem('nitro.room.history') || '[]');
|
||||
if (newStorage.some((room: { roomId: number }) => room.roomId === roomId)) return;
|
||||
|
||||
newStorage = newStorage.filter((room: { roomId: number }) => room.roomId !== roomId);
|
||||
newStorage = [ ...newStorage, { roomId, roomName } ];
|
||||
|
||||
if (newStorage.length > 10) newStorage = newStorage.slice(-10);
|
||||
if (newStorage.length >= 10) newStorage.shift();
|
||||
newStorage = [...newStorage, { roomId, roomName }];
|
||||
|
||||
setRoomHistory(newStorage);
|
||||
SetLocalStorage('nitro.room.history', newStorage);
|
||||
@@ -102,55 +99,48 @@ export const RoomToolsWidgetView: FC<{}> = props => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if(roomName || roomOwner || (roomTags && roomTags.length)) setIsOpen(true);
|
||||
setIsOpen(true);
|
||||
const timeout = setTimeout(() => setIsOpen(false), 5000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [roomName, roomOwner, roomTags]);
|
||||
|
||||
useEffect(() => {
|
||||
if(!isCollapsed && (roomName || roomOwner || (roomTags && roomTags.length))) setIsOpen(true);
|
||||
}, [ isCollapsed, roomName, roomOwner, roomTags ]);
|
||||
|
||||
useEffect(() => {
|
||||
setRoomHistory(JSON.parse(window.localStorage.getItem('nitro.room.history') || '[]'));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="nitro-room-tools-container">
|
||||
<div className="nitro-room-tools-rail">
|
||||
<button type="button" className={ `nitro-room-tools-toggle ${ isCollapsed ? 'is-collapsed' : 'is-open' }` } onClick={ () => setIsCollapsed(value => !value) } title={ isCollapsed ? 'Apri strumenti stanza' : 'Chiudi strumenti stanza' }>
|
||||
{ isCollapsed ? <FaChevronRight className="text-[11px]" /> : <FaChevronLeft className="text-[11px]" /> }
|
||||
</button>
|
||||
<AnimatePresence initial={ false }>
|
||||
{ !isCollapsed &&
|
||||
<motion.div
|
||||
initial={ { opacity: 0, x: -10, scale: 0.96 } }
|
||||
animate={ { opacity: 1, x: 0, scale: 1 } }
|
||||
exit={ { opacity: 0, x: -10, scale: 0.96 } }
|
||||
transition={ { duration: 0.26, ease: [ 0.16, 1, 0.3, 1 ] } }
|
||||
className="flex flex-col items-center justify-center p-2 nitro-room-tools">
|
||||
<div className="cursor-pointer nitro-icon icon-cog" title={LocalizeText('room.settings.button.text')} onClick={() => handleToolClick('settings')} />
|
||||
<div className={classNames('cursor-pointer', 'nitro-icon', (!isZoomedIn && 'icon-zoom-less'), (isZoomedIn && 'icon-zoom-more'))} title={LocalizeText('room.zoom.button.text')} onClick={() => handleToolClick('zoom')} />
|
||||
<div className="cursor-pointer nitro-icon icon-chat-history" title={LocalizeText('room.chathistory.button.text')} onClick={() => handleToolClick('chat_history')} />
|
||||
<div className={classNames('cursor-pointer', 'nitro-icon', (areBubblesMuted ? 'icon-chat-disablebubble' : 'icon-chat-enablebubble'))} title={areBubblesMuted ? LocalizeText('room.unmute.button.text') : LocalizeText('room.mute.button.text')} onClick={() => handleToolClick('hiddenbubbles')} />
|
||||
useEffect(() => {
|
||||
const handleTabClose = () => {
|
||||
window.localStorage.removeItem('nitro.room.history');
|
||||
};
|
||||
window.addEventListener('beforeunload', handleTabClose);
|
||||
return () => window.removeEventListener('beforeunload', handleTabClose);
|
||||
}, []);
|
||||
|
||||
{navigatorData.canRate && (
|
||||
<div className="cursor-pointer nitro-icon icon-like-room" title={LocalizeText('room.like.button.text')} onClick={() => handleToolClick('like_room')} />
|
||||
)}
|
||||
<div className="cursor-pointer nitro-icon icon-room-link" title={LocalizeText('navigator.embed.caption')} onClick={() => handleToolClick('toggle_room_link')} />
|
||||
<div className="cursor-pointer nitro-icon icon-room-history-enabled" title={LocalizeText('room.history.button.tooltip')} onClick={() => handleToolClick('room_history')} />
|
||||
{plugins.map(plugin => (
|
||||
<div
|
||||
key={plugin.name}
|
||||
className={`cursor-pointer nitro-icon ${plugin.icon || 'icon-cog'}`}
|
||||
title={plugin.label}
|
||||
onClick={() => plugin.onOpen()}
|
||||
/>
|
||||
))}
|
||||
</motion.div> }
|
||||
</AnimatePresence>
|
||||
return (
|
||||
<div className="flex space-x-2 nitro-room-tools-container">
|
||||
<div className="flex flex-col items-center justify-center p-2 nitro-room-tools">
|
||||
<div className="cursor-pointer nitro-icon icon-cog" title={LocalizeText('room.settings.button.text')} onClick={() => handleToolClick('settings')} />
|
||||
<div className={classNames('cursor-pointer', 'nitro-icon', (!isZoomedIn && 'icon-zoom-less'), (isZoomedIn && 'icon-zoom-more'))} title={LocalizeText('room.zoom.button.text')} onClick={() => handleToolClick('zoom')} />
|
||||
<div className="cursor-pointer nitro-icon icon-chat-history" title={LocalizeText('room.chathistory.button.text')} onClick={() => handleToolClick('chat_history')} />
|
||||
<div className={classNames('cursor-pointer', 'nitro-icon', (areBubblesMuted ? 'icon-chat-disablebubble' : 'icon-chat-enablebubble'))} title={areBubblesMuted ? LocalizeText('room.unmute.button.text') : LocalizeText('room.mute.button.text')} onClick={() => handleToolClick('hiddenbubbles')} />
|
||||
|
||||
{navigatorData.canRate && (
|
||||
<div className="cursor-pointer nitro-icon icon-like-room" title={LocalizeText('room.like.button.text')} onClick={() => handleToolClick('like_room')} />
|
||||
)}
|
||||
<div className="cursor-pointer nitro-icon icon-room-link" title={LocalizeText('navigator.embed.caption')} onClick={() => handleToolClick('toggle_room_link')} />
|
||||
<div className="cursor-pointer nitro-icon icon-room-history-enabled" title={LocalizeText('room.history.button.tooltip')} onClick={() => handleToolClick('room_history')} />
|
||||
{plugins.map(plugin => (
|
||||
<div
|
||||
key={plugin.name}
|
||||
className={`cursor-pointer nitro-icon ${plugin.icon || 'icon-cog'}`}
|
||||
title={plugin.label}
|
||||
onClick={() => plugin.onOpen()}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col justify-center nitro-room-tools-side-container">
|
||||
<div className="flex flex-col justify-center">
|
||||
<AnimatePresence>
|
||||
{(!isCollapsed && (isOpen || !!roomName || !!roomOwner || !!(roomTags && roomTags.length))) && (
|
||||
{isOpen && (
|
||||
<motion.div initial={{ x: -100 }} animate={{ x: 0 }} exit={{ x: -100 }} transition={{ duration: 0.3 }}>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="flex flex-col px-3 py-2 rounded nitro-room-tools-info">
|
||||
@@ -171,7 +161,7 @@ export const RoomToolsWidgetView: FC<{}> = props => {
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
{(!isCollapsed && isOpenHistory) && (
|
||||
{isOpenHistory && (
|
||||
<motion.div initial={{ x: -100 }} animate={{ x: 0 }} exit={{ x: -100 }} transition={{ duration: 0.3 }} className="nitro-room-tools-history">
|
||||
<div className="flex flex-col px-3 py-2 rounded nitro-room-history">
|
||||
{roomHistory.map(history => (
|
||||
|
||||
@@ -11,7 +11,7 @@ export const ToolbarItemView = forwardRef<HTMLDivElement, PropsWithChildren<{
|
||||
<div
|
||||
ref={ ref }
|
||||
className={ classNames(
|
||||
'relative h-[32px] w-[32px] shrink-0 cursor-pointer bg-center bg-no-repeat transition-transform duration-200 ease-out hover:-translate-y-[1px] active:translate-y-0',
|
||||
'cursor-pointer relative',
|
||||
`nitro-icon icon-${ icon }`,
|
||||
className
|
||||
) }
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { CreateLinkEvent, GetRoomEngine, GetSessionDataManager, RoomObjectCategory } from '@nitrots/nitro-renderer';
|
||||
import { CreateLinkEvent, GetRoomEngine, GetSessionDataManager, MouseEventType, RoomObjectCategory } from '@nitrots/nitro-renderer';
|
||||
import { Dispatch, FC, PropsWithChildren, SetStateAction, useEffect, useRef } from 'react';
|
||||
import { DispatchUiEvent, GetConfigurationValue, GetRoomSession, GetUserProfile, LocalizeText } from '../../api';
|
||||
import { LayoutItemCountView } from '../../common';
|
||||
import { Flex, LayoutItemCountView } from '../../common';
|
||||
import { GuideToolEvent } from '../../events';
|
||||
import { ToolbarItemView } from './ToolbarItemView';
|
||||
|
||||
export const ToolbarMeView: FC<PropsWithChildren<{
|
||||
useGuideTool: boolean;
|
||||
@@ -11,8 +10,8 @@ export const ToolbarMeView: FC<PropsWithChildren<{
|
||||
setMeExpanded: Dispatch<SetStateAction<boolean>>;
|
||||
}>> = props =>
|
||||
{
|
||||
const { useGuideTool = false, unseenAchievementCount = 0, children = null } = props;
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
const { useGuideTool = false, unseenAchievementCount = 0, setMeExpanded = null, children = null, ...rest } = props;
|
||||
const elementRef = useRef<HTMLDivElement>();
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
@@ -23,22 +22,29 @@ export const ToolbarMeView: FC<PropsWithChildren<{
|
||||
GetRoomEngine().selectRoomObject(roomSession.roomId, roomSession.ownRoomIndex, RoomObjectCategory.UNIT);
|
||||
}, []);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const onClick = (event: MouseEvent) => setMeExpanded(false);
|
||||
|
||||
document.addEventListener('click', onClick);
|
||||
|
||||
return () => document.removeEventListener(MouseEventType.MOUSE_CLICK, onClick);
|
||||
}, [ setMeExpanded ]);
|
||||
|
||||
return (
|
||||
<div className="w-fit max-w-[min(calc(100vw-16px),520px)] rounded-[12px] border border-white/8 bg-[rgba(10,10,12,0.58)] px-[10px] py-[7px] shadow-[0_10px_24px_rgba(0,0,0,0.2)]" ref={ elementRef }>
|
||||
<div className="flex items-center gap-[8px] overflow-x-auto overflow-y-visible whitespace-nowrap">
|
||||
{ (GetConfigurationValue('guides.enabled') && useGuideTool) &&
|
||||
<ToolbarItemView icon="me-helper-tool" onClick={ event => DispatchUiEvent(new GuideToolEvent(GuideToolEvent.TOGGLE_GUIDE_TOOL)) } title={ LocalizeText('guide.help.button.label') } /> }
|
||||
<ToolbarItemView icon="me-achievements" onClick={ event => CreateLinkEvent('achievements/toggle') } title={ LocalizeText('toolbar.icon.label.achievements') }>
|
||||
{ (unseenAchievementCount > 0) &&
|
||||
<LayoutItemCountView count={ unseenAchievementCount } /> }
|
||||
</ToolbarItemView>
|
||||
<ToolbarItemView icon="me-profile" onClick={ event => GetUserProfile(GetSessionDataManager().userId) } title={ LocalizeText('toolbar.icon.label.memenu') } />
|
||||
<ToolbarItemView icon="me-rooms" onClick={ event => CreateLinkEvent('navigator/search/myworld_view') } title={ LocalizeText('navigator.myworlds') } />
|
||||
<ToolbarItemView icon="me-clothing" onClick={ event => CreateLinkEvent('avatar-editor/toggle') } title={ LocalizeText('widget.memenu.settings.avatar') } />
|
||||
<ToolbarItemView icon="me-settings" onClick={ event => CreateLinkEvent('user-settings/toggle') } title={ LocalizeText('widget.memenu.settings.title') } />
|
||||
<ToolbarItemView icon="me-forums" onClick={ event => CreateLinkEvent('groupforum/toggle') } title={ LocalizeText('toolbar.icon.label.forums') } />
|
||||
{ children }
|
||||
<Flex alignItems="center" className="absolute bottom-[60px] left-[33px] bg-[rgba(20,20,20,.95)] border border-[solid] border-[#101010] [box-shadow:inset_2px_2px_rgba(255,255,255,.1),inset_-2px_-2px_rgba(255,255,255,.1)] rounded-[$border-radius] p-2" gap={ 2 } innerRef={ elementRef }>
|
||||
{ (GetConfigurationValue('guides.enabled') && useGuideTool) &&
|
||||
<div className="navigation-item relative nitro-icon icon-me-helper-tool cursor-pointer" onClick={ event => DispatchUiEvent(new GuideToolEvent(GuideToolEvent.TOGGLE_GUIDE_TOOL)) } /> }
|
||||
<div className="navigation-item relative nitro-icon icon-me-achievements cursor-pointer" onClick={ event => CreateLinkEvent('achievements/toggle') }>
|
||||
{ (unseenAchievementCount > 0) &&
|
||||
<LayoutItemCountView count={ unseenAchievementCount } /> }
|
||||
</div>
|
||||
</div>
|
||||
<div className="navigation-item relative nitro-icon icon-me-profile cursor-pointer" onClick={ event => GetUserProfile(GetSessionDataManager().userId) } />
|
||||
<div className="navigation-item relative nitro-icon icon-me-rooms cursor-pointer" onClick={ event => CreateLinkEvent('navigator/search/myworld_view') } />
|
||||
<div className="navigation-item relative nitro-icon icon-me-clothing cursor-pointer" onClick={ event => CreateLinkEvent('avatar-editor/toggle') } />
|
||||
<div className="navigation-item relative nitro-icon icon-me-settings cursor-pointer" onClick={ event => CreateLinkEvent('user-settings/toggle') } />
|
||||
<div className="navigation-item relative nitro-icon icon-me-forums cursor-pointer" onClick={ event => CreateLinkEvent('groupforum/toggle') } title={ LocalizeText('toolbar.icon.label.forums') } />
|
||||
{ children }
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user