mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 23:16:21 +00:00
Merge remote-tracking branch 'duckie/main' into merge-duckie-main-2026-05-06
# Conflicts: # index.html # public/UITexts.example # public/renderer-config.example # src/App.tsx # src/components/login/LoginView.tsx # src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx # src/components/toolbar/ToolbarView.tsx # src/components/user-profile/UserContainerView.tsx
This commit is contained in:
@@ -4,6 +4,8 @@ import { FC, useEffect, useState } from 'react';
|
||||
import { useNitroEvent } from '../hooks';
|
||||
import { AchievementsView } from './achievements/AchievementsView';
|
||||
import { AvatarEditorView } from './avatar-editor';
|
||||
import { BadgeCreatorView } from './badge-creator';
|
||||
import { AvatarEffectsView } from './avatar-effects';
|
||||
import { CameraWidgetView } from './camera/CameraWidgetView';
|
||||
import { CampaignView } from './campaign/CampaignView';
|
||||
import { CatalogView } from './catalog/CatalogView';
|
||||
@@ -121,6 +123,8 @@ export const MainView: FC<{}> = props =>
|
||||
<CustomizeNickIconView />
|
||||
<WiredView />
|
||||
<AvatarEditorView />
|
||||
<BadgeCreatorView />
|
||||
<AvatarEffectsView />
|
||||
<AchievementsView />
|
||||
<NavigatorView />
|
||||
<NitrobubbleHiddenView />
|
||||
|
||||
@@ -89,13 +89,13 @@ export const AvatarEditorModelView: FC<{
|
||||
<div className="flex-1 min-w-0 overflow-hidden avatar-editor-palette-set-view">
|
||||
{ advancedColorMode
|
||||
? <AvatarEditorAdvancedColorView category={ activeCategory } paletteIndex={ 0 } />
|
||||
: <AvatarEditorPaletteSetView category={ activeCategory } columnCount={ 14 } paletteIndex={ 0 } /> }
|
||||
: <AvatarEditorPaletteSetView category={ activeCategory } columnCount={ maxPaletteCount === 2 ? 5 : 14 } paletteIndex={ 0 } /> }
|
||||
</div> }
|
||||
{ (maxPaletteCount === 2) &&
|
||||
<div className="flex-1 min-w-0 overflow-hidden avatar-editor-palette-set-view">
|
||||
{ advancedColorMode
|
||||
? <AvatarEditorAdvancedColorView category={ activeCategory } paletteIndex={ 1 } />
|
||||
: <AvatarEditorPaletteSetView category={ activeCategory } columnCount={ 14 } paletteIndex={ 1 } /> }
|
||||
: <AvatarEditorPaletteSetView category={ activeCategory } columnCount={ 5 } paletteIndex={ 1 } /> }
|
||||
</div> }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -78,7 +78,8 @@ export const AvatarEditorView: FC<{}> = props =>
|
||||
|
||||
return (
|
||||
<NitroCardView
|
||||
className={ `nitro-avatar-editor ${ isWardrobeOpen ? 'w-[880px]' : 'w-[620px]' } h-[460px]` }
|
||||
className={ `nitro-avatar-editor ${ isWardrobeOpen ? 'w-[880px]' : 'w-[600px]' } h-[460px]` }
|
||||
isResizable={ false }
|
||||
uniqueKey="avatar-editor">
|
||||
<NitroCardHeaderView headerText={ LocalizeText('avatareditor.title') } onCloseClick={ event => setIsVisible(false) } />
|
||||
<NitroCardTabsView classNames={ ['avatar-editor-tabs'] }>
|
||||
|
||||
@@ -77,7 +77,7 @@ export const AvatarEditorFigureSetItemView: FC<{
|
||||
itemActive={ isSelected }
|
||||
itemImage={ (!partItem.isClear && isHead) ? assetUrl : undefined }
|
||||
className={ `avatar-parts mx-auto${ isSelected ? ' part-selected' : '' }${ !partItem.isClear && isSellableNotOwned ? ' pet-sellable-locked' : '' }` }
|
||||
style={ isHead ? { backgroundSize: '200%', backgroundPosition: 'center -32px' } : undefined }
|
||||
style={ isHead ? { backgroundSize: 'auto 80%', backgroundPosition: 'center', imageRendering: 'pixelated' } : undefined }
|
||||
{ ...rest }
|
||||
>
|
||||
{ !partItem.isClear && assetUrl && !isHead &&
|
||||
|
||||
@@ -30,12 +30,12 @@ export const AvatarEditorFigureSetView: FC<{
|
||||
};
|
||||
|
||||
return (
|
||||
<InfiniteGrid<IAvatarEditorCategoryPartItem> columnCount={ columnCount } estimateSize={ estimateSize } itemRender={ (item: IAvatarEditorCategoryPartItem) =>
|
||||
<InfiniteGrid<IAvatarEditorCategoryPartItem> columnCount={ columnCount } itemMinWidth={ 42 } rowGap={ 8 } estimateSize={ estimateSize } itemRender={ (item: IAvatarEditorCategoryPartItem) =>
|
||||
{
|
||||
if(!item) return null;
|
||||
|
||||
return (
|
||||
<AvatarEditorFigureSetItemView isSelected={ isPartItemSelected(item) } partItem={ item } setType={ category.setType } width={ `calc(100% / ${ columnCount })` } onClick={ event => selectEditorPart(category.setType, item.partSet?.id ?? -1) } />
|
||||
<AvatarEditorFigureSetItemView isSelected={ isPartItemSelected(item) } partItem={ item } setType={ category.setType } onClick={ event => selectEditorPart(category.setType, item.partSet?.id ?? -1) } />
|
||||
);
|
||||
} } items={ category.partItems } overscan={ columnCount } />
|
||||
);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { GetRoomEngine, RoomPreviewer } from '@nitrots/nitro-renderer';
|
||||
import { CSSProperties, FC, useEffect, useState } from 'react';
|
||||
import { LayoutRoomPreviewerView } from '../../common';
|
||||
|
||||
interface AvatarEffectPreviewViewProps
|
||||
{
|
||||
figure: string;
|
||||
gender: string;
|
||||
direction: number;
|
||||
effect: number;
|
||||
height?: number;
|
||||
zoom?: number;
|
||||
}
|
||||
|
||||
export const AvatarEffectPreviewView: FC<AvatarEffectPreviewViewProps> = props =>
|
||||
{
|
||||
const { figure = '', gender = 'M', direction = 4, effect = 0, height = 280, zoom = 1 } = props;
|
||||
const [ roomPreviewer, setRoomPreviewer ] = useState<RoomPreviewer>(null);
|
||||
|
||||
const renderHeight = Math.floor(height / zoom);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const previewer = new RoomPreviewer(GetRoomEngine(), ++RoomPreviewer.PREVIEW_COUNTER);
|
||||
setRoomPreviewer(previewer);
|
||||
|
||||
return () =>
|
||||
{
|
||||
previewer.dispose();
|
||||
setRoomPreviewer(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!roomPreviewer || !figure) return;
|
||||
|
||||
roomPreviewer.addAvatarIntoRoom(figure, effect);
|
||||
roomPreviewer.updateObjectUserFigure(figure, gender);
|
||||
}, [ roomPreviewer, figure, gender, effect ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!roomPreviewer) return;
|
||||
roomPreviewer.updateAvatarDirection(direction, direction);
|
||||
}, [ roomPreviewer, direction ]);
|
||||
|
||||
if(!roomPreviewer) return null;
|
||||
|
||||
if(zoom === 1)
|
||||
{
|
||||
return <LayoutRoomPreviewerView roomPreviewer={ roomPreviewer } height={ height } />;
|
||||
}
|
||||
|
||||
const outerStyle: CSSProperties = {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden'
|
||||
};
|
||||
|
||||
const innerStyle: CSSProperties = {
|
||||
width: `${ 100 / zoom }%`,
|
||||
height: `${ 100 / zoom }%`,
|
||||
transform: `scale(${ zoom })`,
|
||||
transformOrigin: 'top left',
|
||||
imageRendering: 'pixelated'
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={ outerStyle }>
|
||||
<div style={ innerStyle }>
|
||||
<LayoutRoomPreviewerView roomPreviewer={ roomPreviewer } height={ renderHeight } />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,271 @@
|
||||
import { AddLinkEventTracker, AvatarDirectionAngle, AvatarEffectActivatedComposer, GetConfiguration, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FaChevronLeft, FaChevronRight, FaSearch } from 'react-icons/fa';
|
||||
import { LocalizeText, SendMessageComposer } from '../../api';
|
||||
import { Button, Column, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
|
||||
import { AvatarEffectPreviewView } from './AvatarEffectPreviewView';
|
||||
|
||||
interface EffectMapEntry
|
||||
{
|
||||
id: string;
|
||||
lib: string;
|
||||
type: string;
|
||||
revision?: string | number;
|
||||
}
|
||||
|
||||
const DEFAULT_DIRECTION = 4;
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export const AvatarEffectsView: FC<{}> = () =>
|
||||
{
|
||||
const [ isVisible, setIsVisible ] = useState(false);
|
||||
const [ effects, setEffects ] = useState<EffectMapEntry[]>([]);
|
||||
const [ loadError, setLoadError ] = useState<string>(null);
|
||||
const [ selectedId, setSelectedId ] = useState<number>(0);
|
||||
const [ direction, setDirection ] = useState<number>(DEFAULT_DIRECTION);
|
||||
const [ query, setQuery ] = useState<string>('');
|
||||
const [ visibleCount, setVisibleCount ] = useState<number>(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: 'avatar-effects/'
|
||||
};
|
||||
|
||||
AddLinkEventTracker(linkTracker);
|
||||
|
||||
return () => RemoveLinkEventTracker(linkTracker);
|
||||
}, []);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!isVisible || effects.length || loadError) return;
|
||||
|
||||
const url = GetConfiguration().getValue<string>('avatar.effectmap.url');
|
||||
if(!url)
|
||||
{
|
||||
setLoadError('Effect map URL is not configured.');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const response = await fetch(url);
|
||||
if(!response.ok) throw new Error(`HTTP ${ response.status }`);
|
||||
const json = await response.json();
|
||||
if(cancelled) return;
|
||||
|
||||
const list: EffectMapEntry[] = Array.isArray(json?.effects)
|
||||
? json.effects.filter((e: EffectMapEntry) => e?.type === 'fx' && /^\d+$/.test(String(e.id)))
|
||||
: [];
|
||||
|
||||
list.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
|
||||
setEffects(list);
|
||||
}
|
||||
catch(error)
|
||||
{
|
||||
if(!cancelled) setLoadError(String((error as Error).message ?? error));
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [ isVisible, effects.length, loadError ]);
|
||||
|
||||
const session = GetSessionDataManager();
|
||||
const figure = session?.figure ?? '';
|
||||
const gender = session?.gender ?? 'M';
|
||||
|
||||
const rotateFigure = useCallback((delta: number) =>
|
||||
{
|
||||
setDirection(prev =>
|
||||
{
|
||||
let next = prev + delta;
|
||||
if(next < AvatarDirectionAngle.MIN_DIRECTION) next = AvatarDirectionAngle.MAX_DIRECTION;
|
||||
if(next > AvatarDirectionAngle.MAX_DIRECTION) next = AvatarDirectionAngle.MIN_DIRECTION;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applySelectedEffect = useCallback(() =>
|
||||
{
|
||||
if(!selectedId) return;
|
||||
SendMessageComposer(new AvatarEffectActivatedComposer(selectedId));
|
||||
setIsVisible(false);
|
||||
}, [ selectedId ]);
|
||||
|
||||
const onClose = useCallback(() => setIsVisible(false), []);
|
||||
|
||||
const filteredEffects = useMemo(() =>
|
||||
{
|
||||
const trimmed = query.trim().toLowerCase();
|
||||
if(!trimmed) return effects;
|
||||
return effects.filter(e =>
|
||||
e.id.toLowerCase().includes(trimmed) ||
|
||||
e.lib.toLowerCase().includes(trimmed));
|
||||
}, [ effects, query ]);
|
||||
|
||||
const onQueryChange = useCallback((event: ChangeEvent<HTMLInputElement>) =>
|
||||
{
|
||||
setQuery(event.target.value);
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
}, []);
|
||||
|
||||
const visibleEffects = filteredEffects.slice(0, visibleCount);
|
||||
const hasMore = filteredEffects.length > visibleEffects.length;
|
||||
const selectedEffect = selectedId ? effects.find(e => parseInt(e.id, 10) === selectedId) : null;
|
||||
|
||||
const selectedRowRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const jumpToSelected = useCallback(() =>
|
||||
{
|
||||
if(!selectedId) return;
|
||||
|
||||
const indexInFiltered = filteredEffects.findIndex(e => parseInt(e.id, 10) === selectedId);
|
||||
const indexInAll = effects.findIndex(e => parseInt(e.id, 10) === selectedId);
|
||||
|
||||
if(indexInFiltered === -1)
|
||||
{
|
||||
setQuery('');
|
||||
if(indexInAll >= 0 && indexInAll >= visibleCount)
|
||||
{
|
||||
setVisibleCount(Math.ceil((indexInAll + 1) / PAGE_SIZE) * PAGE_SIZE);
|
||||
}
|
||||
}
|
||||
else if(indexInFiltered >= visibleCount)
|
||||
{
|
||||
setVisibleCount(Math.ceil((indexInFiltered + 1) / PAGE_SIZE) * PAGE_SIZE);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() =>
|
||||
{
|
||||
selectedRowRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
}, [ selectedId, filteredEffects, effects, visibleCount ]);
|
||||
|
||||
if(!isVisible) return null;
|
||||
|
||||
return (
|
||||
<NitroCardView className="nitro-avatar-effects w-[640px] h-[480px]" isResizable={ false } uniqueKey="avatar-effects" theme="primary-slim">
|
||||
<NitroCardHeaderView headerText={ LocalizeText('product.type.effect') || 'Avatar effect' } onCloseClick={ onClose } />
|
||||
<NitroCardContentView className="flex flex-row gap-3 text-black">
|
||||
<Column overflow="hidden" className="w-[220px] items-center justify-between">
|
||||
<div className="figure-preview-container overflow-hidden relative w-full h-[280px] bg-gradient-to-b from-[#1a1a1a] to-black rounded-md shadow-inner">
|
||||
<AvatarEffectPreviewView figure={ figure } gender={ gender } direction={ direction } effect={ selectedId } height={ 280 } zoom={ 2 } />
|
||||
<div className="arrow-container absolute inset-y-0 left-0 right-0 flex items-center justify-between px-1 z-10 pointer-events-none">
|
||||
<button
|
||||
type="button"
|
||||
className="pointer-events-auto flex items-center justify-center w-7 h-7 rounded-full bg-black/45 hover:bg-black/70 border border-white/15 text-white shadow-md backdrop-blur-sm transition-all hover:scale-110 active:scale-95"
|
||||
onClick={ () => rotateFigure(1) }
|
||||
aria-label="Rotate left"
|
||||
>
|
||||
<FaChevronLeft className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="pointer-events-auto flex items-center justify-center w-7 h-7 rounded-full bg-black/45 hover:bg-black/70 border border-white/15 text-white shadow-md backdrop-blur-sm transition-all hover:scale-110 active:scale-95"
|
||||
onClick={ () => rotateFigure(-1) }
|
||||
aria-label="Rotate right"
|
||||
>
|
||||
<FaChevronRight className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{ selectedEffect &&
|
||||
<div className="absolute top-2 left-2 right-2 bg-black/55 backdrop-blur-sm rounded px-2 py-1 text-white text-xs leading-tight">
|
||||
<div className="font-mono opacity-70 text-[10px]">#{ parseInt(selectedEffect.id, 10) }</div>
|
||||
<div className="font-semibold truncate">{ selectedEffect.lib }</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<Button variant="success" disabled={ !selectedId } onClick={ applySelectedEffect } className="w-full mt-2">
|
||||
{ LocalizeText('inventory.effects.activate') || 'Use' }
|
||||
</Button>
|
||||
</Column>
|
||||
<Column overflow="hidden" className="flex-1 min-h-0">
|
||||
<div className="relative">
|
||||
<FaSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#888] text-sm pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={ query }
|
||||
onChange={ onQueryChange }
|
||||
placeholder={ LocalizeText('generic.search') || 'Search by name or #number' }
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm border border-[#2a2a2a]/20 rounded-md bg-white outline-none transition-colors focus:border-[#3a78c4] focus:shadow-[0_0_0_2px_rgba(58,120,196,0.15)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-1 py-1 text-[11px] uppercase tracking-wide text-[#666] border-b border-[#2a2a2a]/10">
|
||||
<span>{ filteredEffects.length === effects.length ? `${ effects.length } effects` : `${ filteredEffects.length } of ${ effects.length }` }</span>
|
||||
{ selectedId > 0 &&
|
||||
<button
|
||||
type="button"
|
||||
onClick={ jumpToSelected }
|
||||
className="text-[#3a78c4] hover:text-[#2a5d9e] hover:underline normal-case font-semibold cursor-pointer"
|
||||
title="Jump to selected effect"
|
||||
>
|
||||
#{ selectedId } selected
|
||||
</button> }
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{ loadError && <div className="text-red-600 text-sm px-2 py-3">{ loadError }</div> }
|
||||
{ !loadError && !effects.length && <div className="text-sm px-2 py-3 opacity-70">{ LocalizeText('generic.loading') || 'Loading…' }</div> }
|
||||
{ !!effects.length && !filteredEffects.length &&
|
||||
<div className="text-sm px-2 py-3 opacity-70 italic">{ LocalizeText('generic.search.noresults') || 'No effects match your search.' }</div>
|
||||
}
|
||||
{ !!visibleEffects.length &&
|
||||
<ul className="flex flex-col">
|
||||
{ visibleEffects.map((effect, index) =>
|
||||
{
|
||||
const id = parseInt(effect.id, 10);
|
||||
const isSelected = (id === selectedId);
|
||||
return (
|
||||
<li key={ effect.id }>
|
||||
<button
|
||||
ref={ isSelected ? selectedRowRef : undefined }
|
||||
type="button"
|
||||
onClick={ () => setSelectedId(id) }
|
||||
className={ `flex w-full items-center gap-3 px-3 py-1.5 text-sm border-l-[3px] transition-colors ${
|
||||
isSelected
|
||||
? 'border-[#3a78c4] bg-[#cfe1f5] text-[#1d3a5e]'
|
||||
: `border-transparent hover:bg-[#eef3f9] ${ index % 2 === 0 ? 'bg-white' : 'bg-[#fafafa]' }`
|
||||
}` }
|
||||
title={ effect.lib }
|
||||
>
|
||||
<span className={ `font-mono text-xs w-12 text-right shrink-0 ${ isSelected ? 'opacity-80' : 'opacity-50' }` }>#{ id }</span>
|
||||
<span className="truncate font-semibold">{ effect.lib }</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}) }
|
||||
{ hasMore &&
|
||||
<li className="px-3 py-2 border-t border-[#2a2a2a]/10 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={ () => setVisibleCount(prev => prev + PAGE_SIZE) }
|
||||
className="w-full text-sm font-semibold text-[#3a78c4] hover:text-[#2a5d9e] hover:bg-[#eef3f9] cursor-pointer py-1.5 rounded-md transition-colors"
|
||||
>
|
||||
{ LocalizeText('navigator.show.more') || 'See More' }
|
||||
<span className="opacity-60 ml-1 font-normal">({ filteredEffects.length - visibleEffects.length } more)</span>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</Column>
|
||||
</NitroCardContentView>
|
||||
</NitroCardView>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './AvatarEffectPreviewView';
|
||||
export * from './AvatarEffectsView';
|
||||
@@ -1,15 +1,10 @@
|
||||
import { GetSessionDataManager, HabboClubLevelEnum} from '@nitrots/nitro-renderer';
|
||||
import { Dispatch, FC, SetStateAction, useCallback, useMemo, useState } from 'react';
|
||||
import { Base, Grid, Flex, NitroCardView, NitroCardHeaderView, NitroCardTabsView, NitroCardTabsItemView, NitroCardContentView, Text, LayoutCurrencyIcon } from '../../common';
|
||||
import { Base, Grid, Flex, NitroCardView, NitroCardHeaderView, NitroCardTabsView, NitroCardTabsItemView, NitroCardContentView, Text } from '../../common';
|
||||
import { useRoom } from '../../hooks';
|
||||
import { GetClubMemberLevel, GetConfigurationValue } from '../../api';
|
||||
import { GetConfigurationValue } from '../../api';
|
||||
|
||||
interface ItemData {
|
||||
interface ItemData {
|
||||
id: number;
|
||||
isHcOnly: boolean;
|
||||
minRank: number;
|
||||
isAmbassadorOnly: boolean;
|
||||
selectable: boolean;
|
||||
}
|
||||
|
||||
interface BackgroundsViewProps {
|
||||
@@ -20,9 +15,11 @@ interface BackgroundsViewProps {
|
||||
setSelectedStand: Dispatch<SetStateAction<number>>;
|
||||
selectedOverlay: number;
|
||||
setSelectedOverlay: Dispatch<SetStateAction<number>>;
|
||||
selectedCardBackground: number;
|
||||
setSelectedCardBackground: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
|
||||
const TABS = ['backgrounds', 'stands', 'overlays'] as const;
|
||||
const TABS = ['backgrounds', 'stands', 'overlays', 'cards'] as const;
|
||||
type TabType = typeof TABS[number];
|
||||
|
||||
export const BackgroundsView: FC<BackgroundsViewProps> = ({
|
||||
@@ -32,57 +29,49 @@ export const BackgroundsView: FC<BackgroundsViewProps> = ({
|
||||
selectedStand,
|
||||
setSelectedStand,
|
||||
selectedOverlay,
|
||||
setSelectedOverlay
|
||||
setSelectedOverlay,
|
||||
selectedCardBackground,
|
||||
setSelectedCardBackground
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('backgrounds');
|
||||
const { roomSession } = useRoom();
|
||||
|
||||
const userData = useMemo(() => ({
|
||||
isHcMember: GetClubMemberLevel() >= HabboClubLevelEnum.CLUB,
|
||||
securityLevel: GetSessionDataManager().canChangeName,
|
||||
isAmbassador: GetSessionDataManager().isAmbassador
|
||||
}), []);
|
||||
|
||||
const processData = useCallback((configData: any[], dataType: string): ItemData[] => {
|
||||
const processData = useCallback((configData: any[], idField: string): ItemData[] => {
|
||||
if (!configData?.length) return [];
|
||||
|
||||
return configData
|
||||
.filter(item => {
|
||||
const meetsRank = userData.securityLevel >= item.minRank;
|
||||
const ambassadorEligible = !item.isAmbassadorOnly || userData.isAmbassador;
|
||||
return item.isHcOnly || (meetsRank && ambassadorEligible);
|
||||
})
|
||||
.map(item => ({ id: item[`${dataType}Id`], ...item, selectable: !item.isHcOnly || userData.isHcMember }));
|
||||
}, [userData]);
|
||||
|
||||
return configData.map(item => ({ id: item[idField] }));
|
||||
}, []);
|
||||
|
||||
const allData = useMemo(() => ({
|
||||
backgrounds: processData(GetConfigurationValue('backgrounds.data'), 'background'),
|
||||
stands: processData(GetConfigurationValue('stands.data'), 'stand'),
|
||||
overlays: processData(GetConfigurationValue('overlays.data'), 'overlay')
|
||||
backgrounds: processData(GetConfigurationValue('backgrounds.data'), 'backgroundId'),
|
||||
stands: processData(GetConfigurationValue('stands.data'), 'standId'),
|
||||
overlays: processData(GetConfigurationValue('overlays.data'), 'overlayId'),
|
||||
cards: processData(GetConfigurationValue('cards.data') || GetConfigurationValue('backgrounds.data'), 'backgroundId')
|
||||
}), [processData]);
|
||||
|
||||
const handleSelection = useCallback((id: number) => {
|
||||
if (!roomSession) return;
|
||||
|
||||
const setters = { backgrounds: setSelectedBackground, stands: setSelectedStand, overlays: setSelectedOverlay };
|
||||
|
||||
const currentValues = { backgrounds: selectedBackground, stands: selectedStand, overlays: selectedOverlay };
|
||||
const setters = { backgrounds: setSelectedBackground, stands: setSelectedStand, overlays: setSelectedOverlay, cards: setSelectedCardBackground };
|
||||
|
||||
const currentValues = { backgrounds: selectedBackground, stands: selectedStand, overlays: selectedOverlay, cards: selectedCardBackground };
|
||||
|
||||
setters[activeTab](id);
|
||||
const newValues = { ...currentValues, [activeTab]: id };
|
||||
roomSession.sendBackgroundMessage( newValues.backgrounds, newValues.stands, newValues.overlays );
|
||||
}, [activeTab, roomSession, selectedBackground, selectedStand, selectedOverlay, setSelectedBackground, setSelectedStand, setSelectedOverlay]);
|
||||
roomSession.sendBackgroundMessage( newValues.backgrounds, newValues.stands, newValues.overlays, newValues.cards );
|
||||
}, [activeTab, roomSession, selectedBackground, selectedStand, selectedOverlay, selectedCardBackground, setSelectedBackground, setSelectedStand, setSelectedOverlay, setSelectedCardBackground]);
|
||||
|
||||
const renderItem = useCallback((item: ItemData, type: string) => (
|
||||
<Flex
|
||||
pointer
|
||||
position="relative"
|
||||
key={item.id}
|
||||
onClick={() => item.selectable && handleSelection(item.id)}
|
||||
className={item.selectable ? '' : 'non-selectable'}
|
||||
onClick={() => handleSelection(item.id)}
|
||||
>
|
||||
<Base className={`profile-${type} ${type}-${item.id}`} />
|
||||
{item.isHcOnly && <LayoutCurrencyIcon position="absolute" className="top-1 inset-e-1" type="hc" />}
|
||||
<Base
|
||||
className={`profile-${type} ${type}-${item.id}`}
|
||||
style={type === 'card-background' ? { width: 60, height: 80, borderRadius: 4 } : undefined}
|
||||
/>
|
||||
</Flex>
|
||||
), [handleSelection]);
|
||||
|
||||
@@ -103,7 +92,7 @@ export const BackgroundsView: FC<BackgroundsViewProps> = ({
|
||||
<NitroCardContentView gap={1}>
|
||||
<Text bold center>Select an Option</Text>
|
||||
<Grid gap={1} columnCount={7} overflow="auto">
|
||||
{allData[activeTab].map(item => renderItem(item, activeTab.slice(0, -1)))}
|
||||
{allData[activeTab].map(item => renderItem(item, activeTab === 'cards' ? 'card-background' : activeTab.slice(0, -1)))}
|
||||
</Grid>
|
||||
</NitroCardContentView>
|
||||
</NitroCardView>
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { FC, MouseEvent as ReactMouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { LocalizeText } from '../../api';
|
||||
import { createCustomBadge, CustomBadgeRecord, deleteCustomBadge, ensureCustomBadgeTexts, fetchCustomBadges, refreshCustomBadgeTexts, setCustomBadgeText, updateCustomBadge } from '../../api/badges';
|
||||
import { Button, Column, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
|
||||
import { useNotification } from '../../hooks';
|
||||
|
||||
const t = (key: string, fallback: string, params?: string[], replacements?: string[]): string =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const value = LocalizeText(key, params ?? null, replacements ?? null);
|
||||
if(value && value !== key) return value;
|
||||
}
|
||||
catch {}
|
||||
|
||||
if(!params || !replacements) return fallback;
|
||||
let out = fallback;
|
||||
for(let i = 0; i < params.length; i++)
|
||||
{
|
||||
if(replacements[i] !== undefined) out = out.replace('%' + params[i] + '%', replacements[i]);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const GRID_WIDTH = 40;
|
||||
const GRID_HEIGHT = 40;
|
||||
const PIXEL_DISPLAY_SIZE = 12;
|
||||
const TRANSPARENT = 0;
|
||||
|
||||
const PALETTE: number[] = [
|
||||
0xFF000000, 0xFF4F4F4F, 0xFF808080, 0xFFB0B0B0, 0xFFD8D8D8, 0xFFFFFFFF, TRANSPARENT, 0xFF7B0000,
|
||||
0xFFBF0000, 0xFFFF0000, 0xFFFF7777, 0xFFFF7700, 0xFFFFAA00, 0xFFFFD700, 0xFFFFEB3B, 0xFF003E1F,
|
||||
0xFF006837, 0xFF00A653, 0xFF2BC93C, 0xFF00C8A0, 0xFF00BCFF, 0xFF2962FF, 0xFF1A237E, 0xFF4A0072,
|
||||
0xFF9C00B5, 0xFFE91E63, 0xFFFF80AB, 0xFF5D2E1A, 0xFF8B5A2B, 0xFFC28E5E, 0xFFF1D7B6, 0xFFE8C3A0
|
||||
];
|
||||
|
||||
const currencyName = (type: number): string =>
|
||||
{
|
||||
if(type === -1) return 'credits';
|
||||
if(type === 0) return 'duckets';
|
||||
if(type === 5) return 'diamonds';
|
||||
return `currency #${ type }`;
|
||||
};
|
||||
|
||||
type Tool = 'paint' | 'erase' | 'picker' | 'fill';
|
||||
|
||||
const floodFill = (grid: Uint32Array, w: number, h: number, startX: number, startY: number, replacement: number): Uint32Array =>
|
||||
{
|
||||
if(startX < 0 || startY < 0 || startX >= w || startY >= h) return grid;
|
||||
const startIdx = startY * w + startX;
|
||||
const target = grid[startIdx];
|
||||
if(target === replacement) return grid;
|
||||
|
||||
const next = new Uint32Array(grid.length);
|
||||
next.set(grid);
|
||||
|
||||
const stack: number[] = [ startIdx ];
|
||||
while(stack.length)
|
||||
{
|
||||
const idx = stack.pop() as number;
|
||||
if(next[idx] !== target) continue;
|
||||
next[idx] = replacement;
|
||||
const x = idx % w;
|
||||
const y = (idx - x) / w;
|
||||
if(x > 0) stack.push(idx - 1);
|
||||
if(x < w - 1) stack.push(idx + 1);
|
||||
if(y > 0) stack.push(idx - w);
|
||||
if(y < h - 1) stack.push(idx + w);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const argbToCss = (argb: number): string =>
|
||||
{
|
||||
if(argb === TRANSPARENT) return 'transparent';
|
||||
const a = ((argb >>> 24) & 0xff) / 255;
|
||||
const r = (argb >>> 16) & 0xff;
|
||||
const g = (argb >>> 8) & 0xff;
|
||||
const b = argb & 0xff;
|
||||
return `rgba(${ r }, ${ g }, ${ b }, ${ a })`;
|
||||
};
|
||||
|
||||
const argbToHex = (argb: number): string =>
|
||||
{
|
||||
if(argb === TRANSPARENT) return '#000000';
|
||||
const r = (argb >>> 16) & 0xff;
|
||||
const g = (argb >>> 8) & 0xff;
|
||||
const b = argb & 0xff;
|
||||
return '#' + [ r, g, b ].map(c => c.toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
|
||||
const hexToArgb = (hex: string): number =>
|
||||
{
|
||||
const match = /^#?([0-9a-f]{6})$/i.exec(hex || '');
|
||||
if(!match) return 0xFF000000;
|
||||
return (0xFF000000 | parseInt(match[1], 16)) >>> 0;
|
||||
};
|
||||
|
||||
const emptyGrid = (): Uint32Array => new Uint32Array(GRID_WIDTH * GRID_HEIGHT);
|
||||
|
||||
const cloneGrid = (src: Uint32Array): Uint32Array =>
|
||||
{
|
||||
const copy = new Uint32Array(src.length);
|
||||
copy.set(src);
|
||||
return copy;
|
||||
};
|
||||
|
||||
const gridToPngBase64 = async (grid: Uint32Array): Promise<{ b64: string; bytes: number }> =>
|
||||
{
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = GRID_WIDTH;
|
||||
canvas.height = GRID_HEIGHT;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if(!ctx) throw new Error('Canvas not supported.');
|
||||
|
||||
const image = ctx.createImageData(GRID_WIDTH, GRID_HEIGHT);
|
||||
for(let i = 0; i < grid.length; i++)
|
||||
{
|
||||
const argb = grid[i];
|
||||
const o = i * 4;
|
||||
image.data[o] = (argb >>> 16) & 0xff;
|
||||
image.data[o + 1] = (argb >>> 8) & 0xff;
|
||||
image.data[o + 2] = argb & 0xff;
|
||||
image.data[o + 3] = (argb >>> 24) & 0xff;
|
||||
}
|
||||
ctx.putImageData(image, 0, 0);
|
||||
|
||||
const blob: Blob = await new Promise((resolve, reject) => canvas.toBlob(b => b ? resolve(b) : reject(new Error('PNG encode failed.')), 'image/png'));
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const bytes = arrayBuffer.byteLength;
|
||||
let binary = '';
|
||||
const u8 = new Uint8Array(arrayBuffer);
|
||||
for(let i = 0; i < u8.length; i++) binary += String.fromCharCode(u8[i]);
|
||||
return { b64: window.btoa(binary), bytes };
|
||||
};
|
||||
|
||||
const loadGridFromUrl = (url: string): Promise<Uint32Array> =>
|
||||
new Promise((resolve, reject) =>
|
||||
{
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = GRID_WIDTH;
|
||||
canvas.height = GRID_HEIGHT;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if(!ctx) return reject(new Error('Canvas not supported.'));
|
||||
ctx.clearRect(0, 0, GRID_WIDTH, GRID_HEIGHT);
|
||||
ctx.drawImage(image, 0, 0, GRID_WIDTH, GRID_HEIGHT);
|
||||
const data = ctx.getImageData(0, 0, GRID_WIDTH, GRID_HEIGHT).data;
|
||||
const grid = emptyGrid();
|
||||
for(let i = 0; i < grid.length; i++)
|
||||
{
|
||||
const o = i * 4;
|
||||
const a = data[o + 3];
|
||||
if(a === 0) { grid[i] = 0; continue; }
|
||||
grid[i] = ((a & 0xff) << 24) | ((data[o] & 0xff) << 16) | ((data[o + 1] & 0xff) << 8) | (data[o + 2] & 0xff);
|
||||
}
|
||||
resolve(grid);
|
||||
}
|
||||
catch(err) { reject(err); }
|
||||
};
|
||||
image.onerror = () => reject(new Error('Could not load badge image (CORS?).'));
|
||||
image.src = url + (url.includes('?') ? '&' : '?') + 't=' + Date.now();
|
||||
});
|
||||
|
||||
export const BadgeCreatorView: FC<{}> = () =>
|
||||
{
|
||||
const [ isVisible, setIsVisible ] = useState(false);
|
||||
const [ grid, setGrid ] = useState<Uint32Array>(() => emptyGrid());
|
||||
const [ selectedColor, setSelectedColor ] = useState<number>(PALETTE[0]);
|
||||
const [ tool, setTool ] = useState<Tool>('paint');
|
||||
const [ showGrid, setShowGrid ] = useState(true);
|
||||
const [ name, setName ] = useState('');
|
||||
const [ description, setDescription ] = useState('');
|
||||
const [ editingBadgeId, setEditingBadgeId ] = useState<string | null>(null);
|
||||
const [ badges, setBadges ] = useState<CustomBadgeRecord[] | null>(null);
|
||||
const [ pendingEditBadgeId, setPendingEditBadgeId ] = useState<string | null>(null);
|
||||
const [ maxBadges, setMaxBadges ] = useState(5);
|
||||
const [ maxBytes, setMaxBytes ] = useState(40960);
|
||||
const [ priceBadge, setPriceBadge ] = useState(0);
|
||||
const [ currencyType, setCurrencyType ] = useState(-1);
|
||||
const [ submitting, setSubmitting ] = useState(false);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
|
||||
const { showConfirm } = useNotification();
|
||||
|
||||
const refresh = useCallback(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const data = await fetchCustomBadges();
|
||||
setBadges(data.badges ?? []);
|
||||
if(typeof data.max === 'number') setMaxBadges(data.max);
|
||||
if(typeof data.maxBadgeSizeBytes === 'number') setMaxBytes(data.maxBadgeSizeBytes);
|
||||
if(typeof data.priceBadge === 'number') setPriceBadge(data.priceBadge);
|
||||
if(typeof data.currencyType === 'number') setCurrencyType(data.currencyType);
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
setBadges([]);
|
||||
setError((err as Error)?.message || 'Could not load badges.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const tracker: 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(v => !v); return;
|
||||
case 'edit':
|
||||
if(!parts[2]) return;
|
||||
setPendingEditBadgeId(parts[2]);
|
||||
setIsVisible(true);
|
||||
return;
|
||||
}
|
||||
},
|
||||
eventUrlPrefix: 'badge-creator/'
|
||||
};
|
||||
AddLinkEventTracker(tracker);
|
||||
return () => RemoveLinkEventTracker(tracker);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { if(isVisible) { refresh(); ensureCustomBadgeTexts(); } }, [ isVisible, refresh ]);
|
||||
|
||||
const resetEditor = useCallback(() =>
|
||||
{
|
||||
setGrid(emptyGrid());
|
||||
setName('');
|
||||
setDescription('');
|
||||
setEditingBadgeId(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const startEdit = useCallback(async (badge: CustomBadgeRecord) =>
|
||||
{
|
||||
setError(null);
|
||||
setEditingBadgeId(badge.badgeId);
|
||||
setName(badge.name || '');
|
||||
setDescription(badge.description || '');
|
||||
try
|
||||
{
|
||||
const loaded = await loadGridFromUrl(badge.url);
|
||||
setGrid(loaded);
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
setError((err as Error)?.message || 'Could not load that badge.');
|
||||
setGrid(emptyGrid());
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!pendingEditBadgeId || !badges) return;
|
||||
const target = badges.find(b => b.badgeId === pendingEditBadgeId);
|
||||
if(!target) return;
|
||||
setPendingEditBadgeId(null);
|
||||
startEdit(target);
|
||||
}, [ pendingEditBadgeId, badges, startEdit ]);
|
||||
|
||||
const paintAt = useCallback((x: number, y: number, isClick: boolean) =>
|
||||
{
|
||||
if(x < 0 || y < 0 || x >= GRID_WIDTH || y >= GRID_HEIGHT) return;
|
||||
const idx = y * GRID_WIDTH + x;
|
||||
|
||||
if(tool === 'picker')
|
||||
{
|
||||
const cell = grid[idx];
|
||||
if(cell !== TRANSPARENT) setSelectedColor(cell);
|
||||
setTool('paint');
|
||||
return;
|
||||
}
|
||||
|
||||
if(tool === 'fill')
|
||||
{
|
||||
if(!isClick) return;
|
||||
setGrid(floodFill(grid, GRID_WIDTH, GRID_HEIGHT, x, y, selectedColor));
|
||||
return;
|
||||
}
|
||||
|
||||
const value = (tool === 'erase') ? TRANSPARENT : selectedColor;
|
||||
if(grid[idx] === value) return;
|
||||
const next = cloneGrid(grid);
|
||||
next[idx] = value;
|
||||
setGrid(next);
|
||||
}, [ grid, selectedColor, tool ]);
|
||||
|
||||
const isDraggingRef = useRef(false);
|
||||
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||
const mainCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const targets = [ mainCanvasRef.current, previewCanvasRef.current ];
|
||||
for(const canvas of targets)
|
||||
{
|
||||
if(!canvas) continue;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if(!ctx) continue;
|
||||
const image = ctx.createImageData(GRID_WIDTH, GRID_HEIGHT);
|
||||
const buffer = image.data;
|
||||
for(let i = 0; i < grid.length; i++)
|
||||
{
|
||||
const v = grid[i];
|
||||
const o = i * 4;
|
||||
buffer[o] = (v >>> 16) & 0xff;
|
||||
buffer[o + 1] = (v >>> 8) & 0xff;
|
||||
buffer[o + 2] = v & 0xff;
|
||||
buffer[o + 3] = (v >>> 24) & 0xff;
|
||||
}
|
||||
ctx.putImageData(image, 0, 0);
|
||||
}
|
||||
}, [ grid, isVisible ]);
|
||||
|
||||
const openColorPicker = useCallback(() =>
|
||||
{
|
||||
const input = colorInputRef.current;
|
||||
if(!input) return;
|
||||
input.value = argbToHex(selectedColor);
|
||||
input.click();
|
||||
}, [ selectedColor ]);
|
||||
|
||||
const handleColorPicked = useCallback((event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
{
|
||||
setSelectedColor(hexToArgb(event.target.value));
|
||||
setTool('paint');
|
||||
}, []);
|
||||
|
||||
const cellFromEvent = useCallback((event: ReactMouseEvent<HTMLDivElement>): { x: number; y: number } =>
|
||||
{
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const x = Math.floor(((event.clientX - rect.left) / rect.width) * GRID_WIDTH);
|
||||
const y = Math.floor(((event.clientY - rect.top) / rect.height) * GRID_HEIGHT);
|
||||
return { x, y };
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback((event: ReactMouseEvent<HTMLDivElement>) =>
|
||||
{
|
||||
if(event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
isDraggingRef.current = true;
|
||||
const { x, y } = cellFromEvent(event);
|
||||
paintAt(x, y, true);
|
||||
}, [ cellFromEvent, paintAt ]);
|
||||
|
||||
const handleMouseMove = useCallback((event: ReactMouseEvent<HTMLDivElement>) =>
|
||||
{
|
||||
if(!isDraggingRef.current) return;
|
||||
const { x, y } = cellFromEvent(event);
|
||||
paintAt(x, y, false);
|
||||
}, [ cellFromEvent, paintAt ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const stopDrag = () => { isDraggingRef.current = false; };
|
||||
window.addEventListener('mouseup', stopDrag);
|
||||
return () => window.removeEventListener('mouseup', stopDrag);
|
||||
}, []);
|
||||
|
||||
const clearCanvas = useCallback(() => setGrid(emptyGrid()), []);
|
||||
|
||||
const copyColor = useCallback(() => setTool('picker'), []);
|
||||
|
||||
const isEmpty = useMemo(() =>
|
||||
{
|
||||
for(let i = 0; i < grid.length; i++) if(grid[i] !== 0) return false;
|
||||
return true;
|
||||
}, [ grid ]);
|
||||
|
||||
const canCreateMore = (badges?.length ?? 0) < maxBadges;
|
||||
|
||||
const handleSave = useCallback(async () =>
|
||||
{
|
||||
if(submitting) return;
|
||||
if(isEmpty) { setError(t('badgecreator.error.empty', 'Draw something first.')); return; }
|
||||
if(!editingBadgeId && !canCreateMore)
|
||||
{
|
||||
setError(t('badgecreator.error.limit', 'You already have %max% custom badges.', [ 'max' ], [ String(maxBadges) ]));
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try
|
||||
{
|
||||
const { b64, bytes } = await gridToPngBase64(grid);
|
||||
if(bytes > maxBytes)
|
||||
{
|
||||
setError(t('badgecreator.error.too_large', `Image is too large (${ bytes } / %max% bytes).`, [ 'max' ], [ String(maxBytes) ]));
|
||||
return;
|
||||
}
|
||||
const body = { name: name.trim(), description: description.trim(), image: b64 };
|
||||
const saved = editingBadgeId
|
||||
? await updateCustomBadge(editingBadgeId, body)
|
||||
: await createCustomBadge(body);
|
||||
if(saved && saved.badgeId) setCustomBadgeText(saved.badgeId, saved.name, saved.description);
|
||||
await refresh();
|
||||
refreshCustomBadgeTexts();
|
||||
resetEditor();
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
setError((err as Error)?.message || 'Could not save the badge.');
|
||||
}
|
||||
finally
|
||||
{
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [ submitting, isEmpty, editingBadgeId, canCreateMore, maxBadges, grid, maxBytes, name, description, refresh, resetEditor ]);
|
||||
|
||||
const handleDelete = useCallback((badge: CustomBadgeRecord) =>
|
||||
{
|
||||
showConfirm(
|
||||
t('badgecreator.delete.confirm', 'Delete "%name%"?', [ 'name' ], [ badge.name || badge.badgeId ]),
|
||||
async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await deleteCustomBadge(badge.badgeId);
|
||||
if(editingBadgeId === badge.badgeId) resetEditor();
|
||||
await refresh();
|
||||
refreshCustomBadgeTexts();
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
setError((err as Error)?.message || 'Could not delete the badge.');
|
||||
}
|
||||
},
|
||||
null, null, null,
|
||||
t('badgecreator.delete.title', 'Delete badge')
|
||||
);
|
||||
}, [ showConfirm, editingBadgeId, refresh, resetEditor ]);
|
||||
|
||||
if(!isVisible) return null;
|
||||
|
||||
return (
|
||||
<NitroCardView className="nitro-badge-creator w-[760px] h-[680px]" isResizable={ false } theme="primary-slim" uniqueKey="badge-creator">
|
||||
<NitroCardHeaderView headerText={ t('badgecreator.title', 'Badge Creator') } onCloseClick={ () => setIsVisible(false) } />
|
||||
<NitroCardContentView className="text-black">
|
||||
<Flex gap={ 2 } className="badge-creator-main">
|
||||
<Column gap={ 2 }>
|
||||
<div
|
||||
className="badge-creator-canvas"
|
||||
style={ {
|
||||
width: GRID_WIDTH * PIXEL_DISPLAY_SIZE,
|
||||
height: GRID_HEIGHT * PIXEL_DISPLAY_SIZE,
|
||||
backgroundColor: '#ffffff',
|
||||
backgroundImage: showGrid
|
||||
? 'linear-gradient(to right, rgba(0,0,0,0.18) 1px, transparent 1px), linear-gradient(to bottom, rgba(0,0,0,0.18) 1px, transparent 1px)'
|
||||
: 'none',
|
||||
backgroundSize: `${ PIXEL_DISPLAY_SIZE }px ${ PIXEL_DISPLAY_SIZE }px`,
|
||||
backgroundPosition: '0 0',
|
||||
border: '1px solid #888',
|
||||
boxSizing: 'content-box',
|
||||
imageRendering: 'pixelated',
|
||||
cursor: tool === 'picker' ? 'crosshair' : (tool === 'erase' ? 'cell' : 'crosshair'),
|
||||
position: 'relative',
|
||||
userSelect: 'none'
|
||||
} }
|
||||
onMouseDown={ handleMouseDown }
|
||||
onMouseMove={ handleMouseMove }>
|
||||
<canvas
|
||||
ref={ mainCanvasRef }
|
||||
width={ GRID_WIDTH }
|
||||
height={ GRID_HEIGHT }
|
||||
style={ {
|
||||
display: 'block',
|
||||
width: GRID_WIDTH * PIXEL_DISPLAY_SIZE,
|
||||
height: GRID_HEIGHT * PIXEL_DISPLAY_SIZE,
|
||||
imageRendering: 'pixelated',
|
||||
pointerEvents: 'none'
|
||||
} }
|
||||
/>
|
||||
</div>
|
||||
<Flex gap={ 1 } className="badge-creator-tools">
|
||||
<Button onClick={ () => setTool('paint') } variant={ tool === 'paint' ? 'success' : 'primary' }>{ t('badgecreator.tool.paint', 'Paint') }</Button>
|
||||
<Button onClick={ () => setTool('fill') } variant={ tool === 'fill' ? 'success' : 'primary' }>{ t('badgecreator.tool.fill', 'Fill') }</Button>
|
||||
<Button onClick={ () => setTool('erase') } variant={ tool === 'erase' ? 'success' : 'primary' }>{ t('badgecreator.tool.erase', 'Erase') }</Button>
|
||||
<Button onClick={ copyColor } variant={ tool === 'picker' ? 'success' : 'primary' }>{ t('badgecreator.tool.picker', 'Pick') }</Button>
|
||||
<Button onClick={ clearCanvas } variant="danger">{ t('badgecreator.tool.clear', 'Clear') }</Button>
|
||||
<Button onClick={ () => setShowGrid(g => !g) } variant="primary">{ showGrid ? (t('badgecreator.tool.gridoff', 'Grid off')) : (t('badgecreator.tool.gridon', 'Grid on')) }</Button>
|
||||
</Flex>
|
||||
</Column>
|
||||
<Column gap={ 2 } className="badge-creator-side" style={ { minWidth: 220 } }>
|
||||
<div>
|
||||
<Text bold variant="black">{ t('badgecreator.palette', 'Palette') }</Text>
|
||||
<div className="badge-creator-palette" style={ { display: 'grid', gridTemplateColumns: 'repeat(8, 22px)', gap: 4, marginTop: 4 } }>
|
||||
{ PALETTE.map((color, idx) =>
|
||||
{
|
||||
const isTransparent = color === TRANSPARENT;
|
||||
const isSelected = color === selectedColor;
|
||||
return (
|
||||
<button
|
||||
key={ idx }
|
||||
type="button"
|
||||
onClick={ () => { setSelectedColor(color); setTool('paint'); } }
|
||||
title={ isTransparent ? 'Transparent' : argbToCss(color) }
|
||||
style={ {
|
||||
width: 22,
|
||||
height: 22,
|
||||
border: isSelected ? '2px solid #000' : '1px solid #888',
|
||||
background: isTransparent
|
||||
? 'repeating-conic-gradient(#ddd 0% 25%, #fff 0% 50%) 50% / 8px 8px'
|
||||
: argbToCss(color),
|
||||
cursor: 'pointer',
|
||||
padding: 0
|
||||
} }
|
||||
/>
|
||||
);
|
||||
}) }
|
||||
</div>
|
||||
<div style={ { display: 'flex', alignItems: 'center', gap: 6, marginTop: 6 } }>
|
||||
<button
|
||||
type="button"
|
||||
onClick={ openColorPicker }
|
||||
title={ t('badgecreator.color.custom', 'Pick a custom colour') }
|
||||
style={ {
|
||||
width: 28,
|
||||
height: 26,
|
||||
padding: 2,
|
||||
border: '1px solid #888',
|
||||
background: '#f3f3f3',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
} }>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4.5 14.5 12 7l5 5-7.5 7.5a2 2 0 0 1-2.83 0l-2.17-2.17a2 2 0 0 1 0-2.83Z" fill="#d3d3d3" stroke="#222" strokeWidth="1.4" strokeLinejoin="round" />
|
||||
<path d="m12 7-2-2 2-2 2 2" stroke="#222" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M18 14c0 1.5 1.4 2.5 1.4 4a1.4 1.4 0 1 1-2.8 0c0-1.5 1.4-2.5 1.4-4Z" fill="#ffb74d" stroke="#222" strokeWidth="1.2" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
onClick={ openColorPicker }
|
||||
title={ argbToHex(selectedColor) }
|
||||
style={ {
|
||||
width: 26,
|
||||
height: 26,
|
||||
border: '1px solid #888',
|
||||
background: selectedColor === TRANSPARENT
|
||||
? 'repeating-conic-gradient(#ddd 0% 25%, #fff 0% 50%) 50% / 8px 8px'
|
||||
: argbToCss(selectedColor),
|
||||
cursor: 'pointer'
|
||||
} }
|
||||
/>
|
||||
<Text small variant="muted">{ argbToHex(selectedColor).toUpperCase() }</Text>
|
||||
<input
|
||||
ref={ colorInputRef }
|
||||
type="color"
|
||||
onChange={ handleColorPicked }
|
||||
style={ { position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' } }
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text bold variant="black">{ t('badgecreator.preview', 'Preview') }</Text>
|
||||
<div style={ { width: GRID_WIDTH, height: GRID_HEIGHT, marginTop: 4, border: '1px solid #888', imageRendering: 'pixelated', position: 'relative', overflow: 'hidden' } }>
|
||||
<canvas
|
||||
ref={ previewCanvasRef }
|
||||
width={ GRID_WIDTH }
|
||||
height={ GRID_HEIGHT }
|
||||
style={ { display: 'block', width: GRID_WIDTH, height: GRID_HEIGHT, imageRendering: 'pixelated' } }
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text variant="black">{ t('badgecreator.name', 'Name') }</Text>
|
||||
<input className="form-control form-control-sm" maxLength={ 64 } value={ name } onChange={ e => setName(e.target.value) } />
|
||||
</div>
|
||||
<div>
|
||||
<Text variant="black">{ t('badgecreator.description', 'Description') }</Text>
|
||||
<input className="form-control form-control-sm" maxLength={ 255 } value={ description } onChange={ e => setDescription(e.target.value) } />
|
||||
</div>
|
||||
{ error && <Text variant="danger">{ error }</Text> }
|
||||
{ !editingBadgeId && priceBadge > 0 &&
|
||||
<Text small variant="muted">
|
||||
{ t('badgecreator.price', 'Cost: %price% %currency%', [ 'price', 'currency' ], [ String(priceBadge), currencyName(currencyType) ]) }
|
||||
</Text> }
|
||||
<Flex gap={ 1 }>
|
||||
<Button onClick={ handleSave } disabled={ submitting } variant="success">
|
||||
{ submitting
|
||||
? (t('badgecreator.saving', 'Saving…'))
|
||||
: (editingBadgeId
|
||||
? (t('badgecreator.save.edit', 'Save changes'))
|
||||
: (priceBadge > 0
|
||||
? t('badgecreator.save.create.priced', 'Create badge (%price% %currency%)', [ 'price', 'currency' ], [ String(priceBadge), currencyName(currencyType) ])
|
||||
: t('badgecreator.save.create', 'Create badge'))) }
|
||||
</Button>
|
||||
{ editingBadgeId &&
|
||||
<Button onClick={ resetEditor } variant="primary">{ t('generic.cancel', 'Cancel') }</Button> }
|
||||
</Flex>
|
||||
</Column>
|
||||
</Flex>
|
||||
<Column gap={ 1 } className="badge-creator-list" style={ { marginTop: 8 } }>
|
||||
<Text bold variant="black">
|
||||
{ t('badgecreator.list.title', 'Your custom badges (%count%/%max%)', [ 'count', 'max' ], [ String(badges?.length ?? 0), String(maxBadges) ]) }
|
||||
</Text>
|
||||
{ badges === null && <Text variant="black">{ t('badgecreator.list.loading', 'Loading…') }</Text> }
|
||||
{ badges !== null && !badges.length && <Text variant="black">{ t('badgecreator.list.empty', 'You haven\'t made any badges yet.') }</Text> }
|
||||
{ badges !== null && badges.map(badge => (
|
||||
<Flex key={ badge.badgeId } alignItems="center" gap={ 2 } style={ { padding: 4, borderTop: '1px solid #ccc' } }>
|
||||
<img src={ badge.url } alt={ badge.name || badge.badgeId } width={ GRID_WIDTH } height={ GRID_HEIGHT } style={ { imageRendering: 'pixelated', border: '1px solid #888' } } />
|
||||
<Column gap={ 0 } style={ { flex: 1, minWidth: 0 } }>
|
||||
<Text bold variant="black" truncate>{ badge.name || badge.badgeId }</Text>
|
||||
{ badge.description && <Text small variant="muted" truncate>{ badge.description }</Text> }
|
||||
</Column>
|
||||
<Button onClick={ () => startEdit(badge) } variant="primary">{ t('generic.edit', 'Edit') }</Button>
|
||||
<Button onClick={ () => handleDelete(badge) } variant="danger">{ t('generic.delete', 'Delete') }</Button>
|
||||
</Flex>
|
||||
)) }
|
||||
</Column>
|
||||
</NitroCardContentView>
|
||||
</NitroCardView>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './BadgeCreatorView';
|
||||
@@ -33,7 +33,7 @@ export const FriendBarItemView: FC<{ friend: MessengerFriend }> = props => {
|
||||
onClick={() => setVisible(prev => !prev)}
|
||||
>
|
||||
<div className="absolute left-[6px] top-1/2 h-[24px] w-[24px] -translate-y-1/2 bg-[url('@/assets/images/toolbar/friend-search.png')] bg-contain bg-center bg-no-repeat pointer-events-none" />
|
||||
<div className="truncate text-[0.8rem] font-bold">{LocalizeText('friend.bar.find.title')}</div>
|
||||
<div className="truncate text-[0.83rem]">{LocalizeText('friend.bar.find.title')}</div>
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence>
|
||||
@@ -45,10 +45,10 @@ export const FriendBarItemView: FC<{ friend: MessengerFriend }> = props => {
|
||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||
className="absolute bottom-[calc(100%+12px)] left-1/2 -translate-x-1/2 tbme-panel whitespace-nowrap z-[80] flex flex-col items-center gap-2 pointer-events-auto min-w-[170px]"
|
||||
>
|
||||
<div className="text-white text-[13px] font-bold drop-shadow-[1px_1px_0_#000]">{LocalizeText('friend.bar.find.title')}</div>
|
||||
<div className="text-white text-[13px] drop-shadow-[1px_1px_0_#000]">{LocalizeText('friend.bar.find.title')}</div>
|
||||
<div className="text-white/80 text-xs px-2">{LocalizeText('friend.bar.find.text')}</div>
|
||||
<button
|
||||
className="px-3 py-1 bg-black/40 hover:bg-black/60 border border-white/10 rounded-lg text-white text-[11px] font-bold transition-colors cursor-pointer mt-1"
|
||||
className="px-3 py-1 bg-black/40 hover:bg-black/60 border border-white/10 rounded-lg text-white text-[11px] transition-colors cursor-pointer mt-1"
|
||||
onClick={event => { event.stopPropagation(); SendMessageComposer(new FindNewFriendsMessageComposer()); setVisible(false); }}
|
||||
>
|
||||
{LocalizeText('friend.bar.find.button')}
|
||||
@@ -62,8 +62,8 @@ export const FriendBarItemView: FC<{ friend: MessengerFriend }> = props => {
|
||||
|
||||
return (
|
||||
<div ref={elementRef} className="relative">
|
||||
<div className="absolute left-[-4px] bottom-[-2px] z-10 h-[66px] w-[34px] overflow-hidden pointer-events-none">
|
||||
{(friend.id > 0) ? (
|
||||
{(friend.id > 0) ? (
|
||||
<div className="absolute left-[-4px] bottom-[-2px] z-10 h-[66px] w-[34px] overflow-hidden pointer-events-none">
|
||||
<LayoutAvatarImageView
|
||||
direction={2}
|
||||
figure={friend.figure}
|
||||
@@ -71,17 +71,19 @@ export const FriendBarItemView: FC<{ friend: MessengerFriend }> = props => {
|
||||
className="block pointer-events-none drop-shadow-[1px_1px_0_rgba(0,0,0,0.6)]"
|
||||
style={ { marginLeft: '-28px', marginTop: '-10px' } }
|
||||
/>
|
||||
) : (
|
||||
<LayoutBadgeImageView badgeCode="ADM" isGroup={false} className="scale-75 block pointer-events-none drop-shadow-[1px_1px_0_rgba(0,0,0,0.6)]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute left-[6px] top-1/2 -translate-y-1/2 z-10 flex h-[28px] w-[28px] items-center justify-center pointer-events-none">
|
||||
<LayoutBadgeImageView badgeCode="ADM" isGroup={false} className="block pointer-events-none drop-shadow-[1px_1px_0_rgba(0,0,0,0.6)]" />
|
||||
</div>
|
||||
)}
|
||||
<motion.button
|
||||
type="button"
|
||||
whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}
|
||||
className="relative flex h-[34px] w-[132px] items-center rounded-[7px] border border-[#9fc56f] bg-[#6f8f39] pl-[44px] pr-[10px] text-left text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.18),0_2px_0_rgba(0,0,0,0.25)] overflow-visible"
|
||||
onClick={() => setVisible(prev => !prev)}
|
||||
>
|
||||
<div className="truncate text-[0.82rem] font-bold">{friend.name}</div>
|
||||
<div className="truncate text-[0.83rem]">{friend.name}</div>
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence>
|
||||
|
||||
@@ -37,7 +37,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)]">
|
||||
<div className="flex h-[34px] items-center rounded-[7px] border border-[#9fc56f] bg-[#5f7d2f] px-[10px] text-[0.83rem] 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 } { LocalizeText('friendbar.requests.title') }
|
||||
</div>
|
||||
</motion.div> }
|
||||
@@ -82,7 +82,7 @@ export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
>
|
||||
<div className="flex h-[34px] items-center rounded-[7px] border border-[#9fc56f] bg-[#5f7d2f] px-[10px] text-[0.74rem] font-medium whitespace-nowrap text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.18),0_2px_0_rgba(0,0,0,0.25)]">
|
||||
<div className="flex h-[34px] items-center rounded-[7px] border border-[#9fc56f] bg-[#5f7d2f] px-[10px] text-[0.83rem] font-medium whitespace-nowrap text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.18),0_2px_0_rgba(0,0,0,0.25)]">
|
||||
Nessun amico online
|
||||
</div>
|
||||
</motion.div> }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DeleteBadgeMessageComposer } from '@nitrots/nitro-renderer';
|
||||
import { CreateLinkEvent, DeleteBadgeMessageComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FaTrashAlt } from 'react-icons/fa';
|
||||
import { GetConfigurationValue, LocalizeBadgeName, LocalizeText, SendMessageComposer, UnseenItemCategory } from '../../../../api';
|
||||
import { FaPaintBrush, FaPencilAlt, FaTrashAlt } from 'react-icons/fa';
|
||||
import { deleteCustomBadge, ensureCustomBadgeTexts, fetchCustomBadges, GetConfigurationValue, isCustomBadgeCode, LocalizeBadgeName, LocalizeText, refreshCustomBadgeTexts, SendMessageComposer, UnseenItemCategory } from '../../../../api';
|
||||
import { LayoutBadgeImageView } from '../../../../common';
|
||||
import { useInventoryBadges, useInventoryUnseenTracker, useNotification } from '../../../../hooks';
|
||||
import { InfiniteGrid, NitroButton } from '../../../../layout';
|
||||
@@ -90,7 +90,60 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
|
||||
const [ isDraggingFromActive, setIsDraggingFromActive ] = useState(false);
|
||||
|
||||
const maxSlots = useMemo(() => GetConfigurationValue<number>('user.badges.max.slots', 5), []);
|
||||
const displayCodes = (filteredBadgeCodes !== null ? filteredBadgeCodes : badgeCodes);
|
||||
|
||||
const [ ownCustomBadgeIds, setOwnCustomBadgeIds ] = useState<Set<string>>(() => new Set());
|
||||
const [ filter, setFilter ] = useState<'all' | 'custom'>('all');
|
||||
|
||||
const refreshOwnCustomBadges = useCallback(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const data = await fetchCustomBadges();
|
||||
setOwnCustomBadgeIds(new Set((data.badges ?? []).map(b => b.badgeId)));
|
||||
}
|
||||
catch
|
||||
{
|
||||
setOwnCustomBadgeIds(new Set());
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { refreshOwnCustomBadges(); }, [ refreshOwnCustomBadges ]);
|
||||
useEffect(() => { ensureCustomBadgeTexts(); }, []);
|
||||
|
||||
const baseCodes = (filteredBadgeCodes !== null ? filteredBadgeCodes : badgeCodes);
|
||||
const customCount = useMemo(() => baseCodes.filter(c => isCustomBadgeCode(c)).length, [ baseCodes ]);
|
||||
const displayCodes = useMemo(() =>
|
||||
filter === 'custom' ? baseCodes.filter(c => isCustomBadgeCode(c)) : baseCodes,
|
||||
[ baseCodes, filter ]);
|
||||
|
||||
const isOwnCustomBadge = (code: string | null) => !!code && isCustomBadgeCode(code) && ownCustomBadgeIds.has(code);
|
||||
|
||||
const handleEditCustom = useCallback(() =>
|
||||
{
|
||||
if(!selectedBadgeCode) return;
|
||||
CreateLinkEvent(`badge-creator/edit/${ selectedBadgeCode }`);
|
||||
}, [ selectedBadgeCode ]);
|
||||
|
||||
const handleDeleteCustom = useCallback(() =>
|
||||
{
|
||||
if(!selectedBadgeCode) return;
|
||||
const target = selectedBadgeCode;
|
||||
showConfirm(
|
||||
LocalizeText('inventory.delete.confirm_delete.info', [ 'furniname', 'amount' ], [ LocalizeBadgeName(target), '1' ]),
|
||||
async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await deleteCustomBadge(target);
|
||||
await refreshOwnCustomBadges();
|
||||
refreshCustomBadgeTexts();
|
||||
}
|
||||
catch { /* error already surfaced server-side */ }
|
||||
},
|
||||
null, null, null,
|
||||
LocalizeText('inventory.delete.confirm_delete.title')
|
||||
);
|
||||
}, [ selectedBadgeCode, showConfirm, refreshOwnCustomBadges ]);
|
||||
|
||||
const attemptDeleteBadge = () =>
|
||||
{
|
||||
@@ -205,6 +258,28 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
|
||||
<span className="text-red-400/60 text-xs font-medium">{ LocalizeText('inventory.badges.clearbadge') }</span>
|
||||
</div>
|
||||
) }
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className={ `px-2 py-0.5 rounded ${ filter === 'all' ? 'bg-card-grid-item-active text-white' : 'bg-card-grid-item' }` }
|
||||
onClick={ () => setFilter('all') }>
|
||||
{ LocalizeText('inventory.badges.tab.all') !== 'inventory.badges.tab.all' ? LocalizeText('inventory.badges.tab.all') : 'All' } ({ baseCodes.length })
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={ `px-2 py-0.5 rounded ${ filter === 'custom' ? 'bg-card-grid-item-active text-white' : 'bg-card-grid-item' }` }
|
||||
onClick={ () => setFilter('custom') }>
|
||||
{ LocalizeText('inventory.badges.tab.custom') !== 'inventory.badges.tab.custom' ? LocalizeText('inventory.badges.tab.custom') : 'Custom' } ({ customCount })
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto px-2 py-0.5 rounded bg-card-grid-item flex items-center gap-1"
|
||||
onClick={ () => CreateLinkEvent('badge-creator/show') }
|
||||
title={ LocalizeText('inventory.badges.create') !== 'inventory.badges.create' ? LocalizeText('inventory.badges.create') : 'Open badge creator' }>
|
||||
<FaPaintBrush className="fa-icon text-[10px]" />
|
||||
<span>{ LocalizeText('inventory.badges.create') !== 'inventory.badges.create' ? LocalizeText('inventory.badges.create') : 'Create' }</span>
|
||||
</button>
|
||||
</div>
|
||||
<InfiniteGrid<string>
|
||||
columnCount={ 5 }
|
||||
estimateSize={ 50 }
|
||||
@@ -242,8 +317,12 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
|
||||
onClick={ event => toggleBadge(selectedBadgeCode) }>
|
||||
{ LocalizeText(isWearingBadge(selectedBadgeCode) ? 'inventory.badges.clearbadge' : 'inventory.badges.wearbadge') }
|
||||
</NitroButton>
|
||||
{ isOwnCustomBadge(selectedBadgeCode) &&
|
||||
<NitroButton className="p-1" title={ LocalizeText('inventory.badges.edit') !== 'inventory.badges.edit' ? LocalizeText('inventory.badges.edit') : 'Edit' } onClick={ handleEditCustom }>
|
||||
<FaPencilAlt className="fa-icon" />
|
||||
</NitroButton> }
|
||||
{ !isWearingBadge(selectedBadgeCode) &&
|
||||
<NitroButton className="bg-danger! hover:bg-danger/80! p-1" onClick={ attemptDeleteBadge }>
|
||||
<NitroButton className="bg-danger! hover:bg-danger/80! p-1" onClick={ isOwnCustomBadge(selectedBadgeCode) ? handleDeleteCustom : attemptDeleteBadge }>
|
||||
<FaTrashAlt className="fa-icon" />
|
||||
</NitroButton> }
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { FC, FormEvent, useCallback, useState } from 'react';
|
||||
import { TurnstileWidget } from '../TurnstileWidget';
|
||||
import { t } from '../utils/i18n';
|
||||
import { DialogSharedProps } from './shared';
|
||||
|
||||
export interface ForgotDialogProps extends DialogSharedProps
|
||||
{
|
||||
onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => void;
|
||||
}
|
||||
|
||||
export 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(t('nitro.login.error.missing_email', '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>{ t('nitro.login.forgot.title', 'Reset password') }</span>
|
||||
<span className="nitro-card-close-button" role="button" aria-label={ t('generic.close', 'Close') } onClick={ onCancel } />
|
||||
</div>
|
||||
<form className="card-body" onSubmit={ handle } autoComplete="on">
|
||||
<div className="field">
|
||||
<label htmlFor="forgot-email">{ t('nitro.login.forgot.email.label', '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 }>{ t('nitro.login.forgot.send', 'Send email') }</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { t } from '../utils/i18n';
|
||||
import { resolveNewsImage, resolveNewsLink } from '../utils/news';
|
||||
|
||||
interface NewsItem
|
||||
{
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
image: string | null;
|
||||
linkText: string;
|
||||
linkUrl: string;
|
||||
}
|
||||
|
||||
interface NewsWindowProps { newsUrl: string; }
|
||||
|
||||
const NEWS_AUTO_ADVANCE_MS = 10000;
|
||||
|
||||
export const NewsWindow: FC<NewsWindowProps> = ({ newsUrl }) =>
|
||||
{
|
||||
const [ items, setItems ] = useState<NewsItem[] | null>(null);
|
||||
const [ failed, setFailed ] = useState(false);
|
||||
const [ index, setIndex ] = useState(0);
|
||||
const [ autoTick, setAutoTick ] = useState(0);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!newsUrl) { setFailed(true); return; }
|
||||
let cancelled = false;
|
||||
fetch(newsUrl, { credentials: 'omit' })
|
||||
.then(async r =>
|
||||
{
|
||||
if(!r.ok) throw new Error('status ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then((json: unknown) =>
|
||||
{
|
||||
if(cancelled) return;
|
||||
const list = Array.isArray((json as { news?: unknown })?.news)
|
||||
? (json as { news: NewsItem[] }).news
|
||||
: [];
|
||||
setItems(list);
|
||||
})
|
||||
.catch(() => { if(!cancelled) setFailed(true); });
|
||||
return () => { cancelled = true; };
|
||||
}, [ newsUrl ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!items || items.length < 2) return;
|
||||
const id = window.setTimeout(() =>
|
||||
{
|
||||
setIndex(i => (i + 1) % items.length);
|
||||
}, NEWS_AUTO_ADVANCE_MS);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [ items, index, autoTick ]);
|
||||
|
||||
if(failed) return null;
|
||||
if(!items || !items.length) return null;
|
||||
|
||||
const current = items[Math.min(index, items.length - 1)];
|
||||
const hasMany = items.length > 1;
|
||||
const bumpAuto = () => setAutoTick(t => t + 1);
|
||||
const prev = () => { setIndex(i => (i - 1 + items.length) % items.length); bumpAuto(); };
|
||||
const next = () => { setIndex(i => (i + 1) % items.length); bumpAuto(); };
|
||||
|
||||
const safeLinkUrl = resolveNewsLink(current.linkUrl);
|
||||
const safeImageSrc = resolveNewsImage(current.image);
|
||||
const openLink = () =>
|
||||
{
|
||||
if(!safeLinkUrl) return;
|
||||
window.open(safeLinkUrl, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-news-stack">
|
||||
<div className="news-card-wrapper" key={ current.id }>
|
||||
<span className="news-sparkle news-sparkle-1" aria-hidden="true">★</span>
|
||||
<span className="news-sparkle news-sparkle-2" aria-hidden="true">✦</span>
|
||||
<span className="news-sparkle news-sparkle-3" aria-hidden="true">✧</span>
|
||||
|
||||
<div className="news-new-badge" aria-hidden="true">
|
||||
<span>{ t('nitro.login.news.new', 'NEW!') }</span>
|
||||
</div>
|
||||
|
||||
<div className="nitro-login-card nitro-news-card">
|
||||
<div className="card-title news-ribbon">
|
||||
<span className="news-ribbon-text">{ t('nitro.login.news.title', 'Hotel News') }</span>
|
||||
</div>
|
||||
<div className="card-body news-body">
|
||||
{ safeImageSrc &&
|
||||
<div className="news-image">
|
||||
<img
|
||||
src={ safeImageSrc }
|
||||
alt={ current.title || 'news' }
|
||||
onError={ e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; } }
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<div className="news-headline">{ current.title }</div>
|
||||
{ current.body &&
|
||||
<div className="news-text">{ current.body }</div> }
|
||||
|
||||
<div className="news-footer">
|
||||
{ current.linkText && safeLinkUrl
|
||||
? <button type="button" className="ok-button news-link-button" onClick={ openLink }>{ current.linkText }</button>
|
||||
: <span /> }
|
||||
|
||||
{ hasMany &&
|
||||
<div className="news-pager">
|
||||
<button type="button" className="arrow-btn" aria-label="Previous news" onClick={ prev }>‹</button>
|
||||
<span className="news-counter">{ index + 1 }/{ items.length }</span>
|
||||
<button type="button" className="arrow-btn" aria-label="Next news" onClick={ next }>›</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,633 @@
|
||||
import { FC, FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { GetConfiguration } from '@nitrots/nitro-renderer';
|
||||
import { GetConfigurationValue } from '../../../api';
|
||||
import { TurnstileWidget } from '../TurnstileWidget';
|
||||
import { t } from '../utils/i18n';
|
||||
import {
|
||||
buildFigureString,
|
||||
buildImagingUrl,
|
||||
buildPartPreviewUrl,
|
||||
EMAIL_REGEX,
|
||||
FALLBACK_DEFAULTS,
|
||||
FALLBACK_HEX,
|
||||
FigureData,
|
||||
FigureSelection,
|
||||
GenderKey,
|
||||
PART_ROWS
|
||||
} from '../utils/figure';
|
||||
import { DialogSharedProps } from './shared';
|
||||
|
||||
export interface RegisterDialogProps extends DialogSharedProps
|
||||
{
|
||||
onSubmit: (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; templateId: number | null; }, onDialogReset: () => void) => void;
|
||||
onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>;
|
||||
onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>;
|
||||
onCheckServer: () => Promise<boolean>;
|
||||
imagingUrl: string;
|
||||
roomTemplatesUrl: string;
|
||||
}
|
||||
|
||||
type RegisterStep = 'credentials' | 'avatar' | 'room';
|
||||
|
||||
interface RoomTemplate { templateId: number; title: string; description: string; thumbnail: string; }
|
||||
|
||||
export const RegisterDialog: FC<RegisterDialogProps> = props =>
|
||||
{
|
||||
const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, imagingUrl, roomTemplatesUrl, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props;
|
||||
|
||||
const [ step, setStep ] = useState<RegisterStep>('credentials');
|
||||
const [ email, setEmail ] = useState('');
|
||||
const [ password, setPassword ] = useState('');
|
||||
const [ confirm, setConfirm ] = useState('');
|
||||
const [ username, setUsername ] = useState('');
|
||||
const [ gender, setGender ] = useState<GenderKey>('F');
|
||||
const [ selection, setSelection ] = useState<FigureSelection>(() => ({ ...FALLBACK_DEFAULTS.F }));
|
||||
const [ localError, setLocalError ] = useState<string | null>(null);
|
||||
const [ checking, setChecking ] = useState(false);
|
||||
const [ turnstileToken, setTurnstileToken ] = useState('');
|
||||
const [ resetSignal, setResetSignal ] = useState(0);
|
||||
const [ serverReachable, setServerReachable ] = useState<boolean | null>(null);
|
||||
const [ pingingServer, setPingingServer ] = useState(false);
|
||||
|
||||
const pingServer = useCallback(async () =>
|
||||
{
|
||||
setPingingServer(true);
|
||||
try
|
||||
{
|
||||
const ok = await onCheckServer();
|
||||
setServerReachable(ok);
|
||||
return ok;
|
||||
}
|
||||
finally
|
||||
{
|
||||
setPingingServer(false);
|
||||
}
|
||||
}, [ onCheckServer ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
let cancelled = false;
|
||||
(async () =>
|
||||
{
|
||||
const ok = await onCheckServer();
|
||||
if(!cancelled) setServerReachable(ok);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [ onCheckServer ]);
|
||||
|
||||
const resetWidget = useCallback(() =>
|
||||
{
|
||||
setTurnstileToken('');
|
||||
setResetSignal(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { setLocalError(null); }, [ step ]);
|
||||
|
||||
const [ roomTemplates, setRoomTemplates ] = useState<RoomTemplate[] | null>(null);
|
||||
const [ roomTemplatesError, setRoomTemplatesError ] = useState<string | null>(null);
|
||||
const [ selectedTemplateId, setSelectedTemplateId ] = useState<number | null>(null);
|
||||
|
||||
const [ figureData, setFigureData ] = useState<FigureData | null>(null);
|
||||
const figureDataUrlRaw = GetConfigurationValue<string>('avatar.figuredata.url', '');
|
||||
const figureDataUrl = useMemo(() =>
|
||||
{
|
||||
if(!figureDataUrlRaw) return '';
|
||||
try { return GetConfiguration().interpolate(figureDataUrlRaw); }
|
||||
catch { return figureDataUrlRaw; }
|
||||
}, [ figureDataUrlRaw ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(step !== 'avatar' || figureData || !figureDataUrl) return;
|
||||
let cancelled = false;
|
||||
fetch(figureDataUrl, { credentials: 'omit' })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(json => { if(!cancelled && json) setFigureData(json as FigureData); })
|
||||
.catch(() => { });
|
||||
return () => { cancelled = true; };
|
||||
}, [ step, figureData, figureDataUrl ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(step !== 'room' || roomTemplates !== null || !roomTemplatesUrl) return;
|
||||
let cancelled = false;
|
||||
setRoomTemplatesError(null);
|
||||
fetch(roomTemplatesUrl, { credentials: 'include' })
|
||||
.then(async r => {
|
||||
if(!r.ok) throw new Error(`status ${ r.status }`);
|
||||
return r.json();
|
||||
})
|
||||
.then(json => {
|
||||
if(cancelled) return;
|
||||
const list = Array.isArray((json as { templates?: unknown })?.templates)
|
||||
? (json as { templates: RoomTemplate[] }).templates
|
||||
: [];
|
||||
setRoomTemplates(list);
|
||||
})
|
||||
.catch(() => {
|
||||
if(cancelled) return;
|
||||
setRoomTemplates([]);
|
||||
setRoomTemplatesError(t('nitro.login.register.room.error', 'Could not load room options. You can still skip this step.'));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [ step, roomTemplates, roomTemplatesUrl ]);
|
||||
|
||||
const partOptions = useMemo(() =>
|
||||
{
|
||||
const result: Record<string, Record<GenderKey, number[]>> = {};
|
||||
if(!figureData) return result;
|
||||
for(const st of figureData.setTypes)
|
||||
{
|
||||
if(!PART_ROWS.includes(st.type)) continue;
|
||||
const forGender = (g: GenderKey) => st.sets
|
||||
.filter(s => s.selectable && s.club === 0 && (s.gender === g || s.gender === 'U'))
|
||||
.map(s => s.id);
|
||||
result[st.type] = { M: forGender('M'), F: forGender('F') };
|
||||
}
|
||||
return result;
|
||||
}, [ figureData ]);
|
||||
|
||||
const paletteOptions = useMemo(() =>
|
||||
{
|
||||
const result: Record<string, { id: number; hex: string }[]> = {};
|
||||
if(!figureData) return result;
|
||||
for(const st of figureData.setTypes)
|
||||
{
|
||||
if(!PART_ROWS.includes(st.type)) continue;
|
||||
const palette = figureData.palettes.find(p => p.id === st.paletteId);
|
||||
if(!palette) { result[st.type] = []; continue; }
|
||||
result[st.type] = palette.colors
|
||||
.filter(c => c.selectable && c.club === 0)
|
||||
.map(c => ({ id: c.id, hex: '#' + c.hexCode.toUpperCase() }));
|
||||
}
|
||||
return result;
|
||||
}, [ figureData ]);
|
||||
|
||||
const hexFor = useCallback((setType: string, colorId: number): string =>
|
||||
{
|
||||
const list = paletteOptions[setType];
|
||||
if(list)
|
||||
{
|
||||
const found = list.find(c => c.id === colorId);
|
||||
if(found) return found.hex;
|
||||
}
|
||||
return FALLBACK_HEX[colorId] || '#c9c9c9';
|
||||
}, [ paletteOptions ]);
|
||||
|
||||
const [ hotLooks, setHotLooks ] = useState<{ gender: GenderKey; figure: string }[]>([]);
|
||||
const [ hotLookIndex, setHotLookIndex ] = useState(-1);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(step !== 'avatar' || hotLooks.length) return;
|
||||
let cancelled = false;
|
||||
fetch('hotlooks.json', { credentials: 'omit' })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then((json: unknown) =>
|
||||
{
|
||||
if(cancelled || !Array.isArray(json)) return;
|
||||
const parsed: { gender: GenderKey; figure: string }[] = [];
|
||||
for(const entry of json as Record<string, unknown>[])
|
||||
{
|
||||
const rawGender = typeof entry._gender === 'string' ? entry._gender.toUpperCase() : '';
|
||||
const figure = typeof entry._figure === 'string' ? entry._figure : '';
|
||||
if((rawGender !== 'M' && rawGender !== 'F') || !figure) continue;
|
||||
parsed.push({ gender: rawGender as GenderKey, figure });
|
||||
}
|
||||
if(parsed.length) setHotLooks(parsed);
|
||||
})
|
||||
.catch(() => { });
|
||||
return () => { cancelled = true; };
|
||||
}, [ step, hotLooks.length ]);
|
||||
|
||||
const applyLook = useCallback((figure: string, lookGender: GenderKey) =>
|
||||
{
|
||||
const next: FigureSelection = {};
|
||||
for(const setPart of figure.split('.'))
|
||||
{
|
||||
const bits = setPart.split('-');
|
||||
if(bits.length < 2) continue;
|
||||
const setType = bits[0];
|
||||
const partId = parseInt(bits[1], 10);
|
||||
if(!setType || Number.isNaN(partId)) continue;
|
||||
const colors: number[] = [];
|
||||
for(let i = 2; i < bits.length; i++)
|
||||
{
|
||||
const c = parseInt(bits[i], 10);
|
||||
if(!Number.isNaN(c)) colors.push(c);
|
||||
}
|
||||
next[setType] = { partId, colors };
|
||||
}
|
||||
|
||||
for(const setType of PART_ROWS)
|
||||
{
|
||||
if(!next[setType]) next[setType] = { ...FALLBACK_DEFAULTS[lookGender][setType] };
|
||||
}
|
||||
setGender(lookGender);
|
||||
setSelection(next);
|
||||
}, []);
|
||||
|
||||
const cycleHotLook = useCallback(() =>
|
||||
{
|
||||
if(!hotLooks.length) return;
|
||||
const nextIdx = (hotLookIndex + 1) % hotLooks.length;
|
||||
setHotLookIndex(nextIdx);
|
||||
const look = hotLooks[nextIdx];
|
||||
applyLook(look.figure, look.gender);
|
||||
}, [ hotLooks, hotLookIndex, applyLook ]);
|
||||
|
||||
const credentialsValid =
|
||||
EMAIL_REGEX.test(email.trim()) &&
|
||||
password.length >= 8 &&
|
||||
password === confirm;
|
||||
|
||||
const handleCredentialsNext = async (event: FormEvent<HTMLFormElement>) =>
|
||||
{
|
||||
event.preventDefault();
|
||||
setLocalError(null);
|
||||
|
||||
if(!email.trim() || !password || !confirm)
|
||||
{
|
||||
setLocalError(t('nitro.login.error.missing_fields', 'Please fill in every field.'));
|
||||
return;
|
||||
}
|
||||
if(!EMAIL_REGEX.test(email.trim()))
|
||||
{
|
||||
setLocalError(t('nitro.login.error.invalid_email', 'Please enter a valid email address.'));
|
||||
return;
|
||||
}
|
||||
if(password.length < 8)
|
||||
{
|
||||
setLocalError(t('nitro.login.error.password_too_short', 'Your password must be at least 8 characters.'));
|
||||
return;
|
||||
}
|
||||
if(password !== confirm)
|
||||
{
|
||||
setLocalError(t('nitro.login.error.password_mismatch', 'Passwords do not match.'));
|
||||
return;
|
||||
}
|
||||
|
||||
setChecking(true);
|
||||
try
|
||||
{
|
||||
const serverOk = await pingServer();
|
||||
if(!serverOk)
|
||||
{
|
||||
setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.'));
|
||||
return;
|
||||
}
|
||||
const result = await onCheckEmail(email.trim());
|
||||
if(!result.available)
|
||||
{
|
||||
setLocalError(result.error || t('nitro.login.error.email_taken', 'This email is already in use.'));
|
||||
return;
|
||||
}
|
||||
setStep('avatar');
|
||||
}
|
||||
finally
|
||||
{
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyGender = (newGender: GenderKey) =>
|
||||
{
|
||||
setGender(newGender);
|
||||
setSelection({ ...FALLBACK_DEFAULTS[newGender] });
|
||||
setHotLookIndex(-1);
|
||||
};
|
||||
|
||||
const getPartList = useCallback((setType: string): number[] =>
|
||||
{
|
||||
const loaded = partOptions[setType]?.[gender];
|
||||
if(loaded && loaded.length) return loaded;
|
||||
const fallback = FALLBACK_DEFAULTS[gender][setType]?.partId;
|
||||
return fallback !== undefined ? [ fallback ] : [];
|
||||
}, [ partOptions, gender ]);
|
||||
|
||||
const getColorList = useCallback((setType: string): number[] =>
|
||||
{
|
||||
const loaded = paletteOptions[setType];
|
||||
if(loaded && loaded.length) return loaded.map(c => c.id);
|
||||
const fallback = FALLBACK_DEFAULTS[gender][setType]?.colors?.[0];
|
||||
return fallback !== undefined ? [ fallback ] : [];
|
||||
}, [ paletteOptions, gender ]);
|
||||
|
||||
const cyclePart = (setType: string, direction: 1 | -1) =>
|
||||
{
|
||||
const options = getPartList(setType);
|
||||
if(!options.length) return;
|
||||
const current = selection[setType]?.partId ?? options[0];
|
||||
const idx = options.indexOf(current);
|
||||
const nextIdx = ((idx === -1 ? 0 : idx) + direction + options.length) % options.length;
|
||||
const colors = getColorList(setType);
|
||||
setSelection(prev => ({
|
||||
...prev,
|
||||
[setType]: {
|
||||
partId: options[nextIdx],
|
||||
colors: prev[setType]?.colors ?? [ colors[0] ?? 0 ]
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const cycleColor = (setType: string, direction: 1 | -1) =>
|
||||
{
|
||||
const colors = getColorList(setType);
|
||||
if(!colors.length) return;
|
||||
const currentColor = selection[setType]?.colors?.[0] ?? colors[0];
|
||||
const idx = colors.indexOf(currentColor);
|
||||
const nextIdx = ((idx === -1 ? 0 : idx) + direction + colors.length) % colors.length;
|
||||
const parts = getPartList(setType);
|
||||
setSelection(prev => ({
|
||||
...prev,
|
||||
[setType]: {
|
||||
partId: prev[setType]?.partId ?? parts[0],
|
||||
colors: [ colors[nextIdx] ]
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const figure = buildFigureString(selection);
|
||||
const previewSrc = buildImagingUrl(imagingUrl, figure, gender);
|
||||
|
||||
const handleAvatarSubmit = async (event: FormEvent<HTMLFormElement>) =>
|
||||
{
|
||||
event.preventDefault();
|
||||
setLocalError(null);
|
||||
|
||||
const trimmed = username.trim();
|
||||
if(!trimmed)
|
||||
{
|
||||
setLocalError(t('nitro.login.error.missing_username', 'Please choose a Habbo name.'));
|
||||
return;
|
||||
}
|
||||
if(trimmed.length < 3 || trimmed.length > 16)
|
||||
{
|
||||
setLocalError(t('nitro.login.error.username_length', 'Habbo name must be 3–16 characters.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if(turnstileEnabled && !turnstileToken)
|
||||
{
|
||||
setLocalError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
|
||||
return;
|
||||
}
|
||||
|
||||
setChecking(true);
|
||||
try
|
||||
{
|
||||
const serverOk = await pingServer();
|
||||
if(!serverOk)
|
||||
{
|
||||
setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.'));
|
||||
return;
|
||||
}
|
||||
const result = await onCheckUsername(trimmed);
|
||||
if(!result.available)
|
||||
{
|
||||
setLocalError(result.error || t('nitro.login.error.username_taken', 'This Habbo name is already taken.'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
setChecking(false);
|
||||
}
|
||||
|
||||
setStep('room');
|
||||
};
|
||||
|
||||
const submitRegistration = (templateId: number | null) =>
|
||||
{
|
||||
onSubmit({
|
||||
username: username.trim(),
|
||||
email: email.trim(),
|
||||
password,
|
||||
figure,
|
||||
gender,
|
||||
turnstileToken,
|
||||
templateId
|
||||
}, resetWidget);
|
||||
};
|
||||
|
||||
const handleRoomSubmit = (event: FormEvent<HTMLFormElement>) =>
|
||||
{
|
||||
event.preventDefault();
|
||||
setLocalError(null);
|
||||
submitRegistration(selectedTemplateId);
|
||||
};
|
||||
|
||||
const busy = submitting || checking || pingingServer;
|
||||
const serverOffline = serverReachable === false;
|
||||
|
||||
return (
|
||||
<div className="nitro-login-modal">
|
||||
<div className={ `dialog ${ step === 'avatar' ? 'dialog-avatar' : '' } ${ step === 'room' ? 'dialog-room' : '' }` }>
|
||||
<div className="nitro-login-card">
|
||||
<div className="card-title">
|
||||
<span>{ t('nitro.login.register.title', 'Habbo Details') }</span>
|
||||
<span className="nitro-card-close-button" role="button" aria-label={ t('generic.close', 'Close') } onClick={ onCancel } />
|
||||
</div>
|
||||
|
||||
{ step === 'credentials' &&
|
||||
<form className="card-body" onSubmit={ handleCredentialsNext } autoComplete="on">
|
||||
<div className="register-intro">
|
||||
{ t('nitro.login.register.intro.credentials', 'Let\'s create your account. Enter your email and pick a password — we\'ll check that email isn\'t already in use.') }
|
||||
</div>
|
||||
{ serverOffline &&
|
||||
<div className="error-line server-offline">
|
||||
{ t('nitro.login.server.offline.long', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') }
|
||||
<button type="button" className="retry-link" onClick={ pingServer } disabled={ pingingServer }>
|
||||
{ pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry') }
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div className="field">
|
||||
<label htmlFor="register-email">{ t('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">{ t('generic.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">{ t('nitro.login.register.confirm.label', 'Confirm password') }</label>
|
||||
<input id="register-confirm" type="password" maxLength={ 128 } autoComplete="new-password"
|
||||
value={ confirm } onChange={ e => setConfirm(e.target.value) } />
|
||||
</div>
|
||||
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
|
||||
{ info && <div className="info-line">{ info }</div> }
|
||||
<div className="step-footer">
|
||||
<span className="step-indicator">1/3</span>
|
||||
<button type="submit" className="ok-button" disabled={ !credentialsValid || busy || serverOffline }>
|
||||
{ checking || pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
{ step === 'avatar' &&
|
||||
<form className="card-body" onSubmit={ handleAvatarSubmit } autoComplete="on">
|
||||
<div className="register-intro">
|
||||
{ t('nitro.login.register.intro.avatar', 'Now it\'s time to make your own Habbo character! To make your own Habbo, please start by choosing your Habbo Name.') }
|
||||
</div>
|
||||
{ serverOffline &&
|
||||
<div className="error-line server-offline">
|
||||
{ t('nitro.login.server.offline.long', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') }
|
||||
<button type="button" className="retry-link" onClick={ pingServer } disabled={ pingingServer }>
|
||||
{ pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry') }
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div className="field">
|
||||
<input id="register-username" type="text" maxLength={ 16 } autoComplete="username" placeholder={ t('nitro.login.register.username.placeholder', 'HabboName') }
|
||||
value={ username } onChange={ e => setUsername(e.target.value) } />
|
||||
</div>
|
||||
|
||||
<div className="gender-row">
|
||||
<label>
|
||||
<input type="radio" name="register-gender" checked={ gender === 'F' } onChange={ () => applyGender('F') } />
|
||||
<span>{ t('avatareditor.generic.girl', 'Girl') }</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="register-gender" checked={ gender === 'M' } onChange={ () => applyGender('M') } />
|
||||
<span>{ t('avatareditor.generic.boy', 'Boy') }</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="avatar-builder">
|
||||
<div className="avatar-part-col">
|
||||
{ PART_ROWS.map(setType => {
|
||||
const partPreviewSrc = buildPartPreviewUrl(imagingUrl, setType, selection, gender);
|
||||
return (
|
||||
<div className="avatar-part-row" key={ `part-${ setType }` }>
|
||||
<button type="button" className="arrow-btn" aria-label={ `Previous ${ setType }` }
|
||||
onClick={ () => cyclePart(setType, -1) }>‹</button>
|
||||
<div className={ `part-preview part-preview-${ setType }` }>
|
||||
<img src={ partPreviewSrc } alt={ `${ setType } preview` } onError={ e => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } />
|
||||
</div>
|
||||
<button type="button" className="arrow-btn" aria-label={ `Next ${ setType }` }
|
||||
onClick={ () => cyclePart(setType, 1) }>›</button>
|
||||
</div>
|
||||
);
|
||||
}) }
|
||||
</div>
|
||||
|
||||
<div className="avatar-preview">
|
||||
<img src={ previewSrc } alt="Habbo preview" onError={ e => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } />
|
||||
</div>
|
||||
|
||||
<div className="avatar-color-col">
|
||||
{ PART_ROWS.map(setType => {
|
||||
const fallbackColor = FALLBACK_DEFAULTS[gender][setType]?.colors?.[0] ?? 0;
|
||||
const currentColor = selection[setType]?.colors?.[0] ?? fallbackColor;
|
||||
const swatchHex = hexFor(setType, currentColor);
|
||||
return (
|
||||
<div className="avatar-color-row" key={ `color-${ setType }` }>
|
||||
<button type="button" className="arrow-btn" aria-label={ `Previous color ${ setType }` }
|
||||
onClick={ () => cycleColor(setType, -1) }>‹</button>
|
||||
<div className="color-swatch" style={ { background: swatchHex } } />
|
||||
<button type="button" className="arrow-btn" aria-label={ `Next color ${ setType }` }
|
||||
onClick={ () => cycleColor(setType, 1) }>›</button>
|
||||
</div>
|
||||
);
|
||||
}) }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hot-looks-row">
|
||||
<button type="button" className="ok-button hot-looks-button"
|
||||
onClick={ cycleHotLook }
|
||||
disabled={ !hotLooks.length || busy }
|
||||
title={ hotLooks.length
|
||||
? t('nitro.login.register.hotlooks.count', '%count% looks available', [ 'count' ], [ String(hotLooks.length) ])
|
||||
: t('nitro.login.register.hotlooks.none', 'No hot looks loaded') }>
|
||||
{ t('avatareditor.category.hotlooks', 'Hot Looks') }{ hotLookIndex >= 0 && hotLooks.length ? ` (${ hotLookIndex + 1 }/${ hotLooks.length })` : '' }
|
||||
</button>
|
||||
</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="step-footer step-footer-split">
|
||||
<button type="button" className="ok-button back-button" onClick={ () => setStep('credentials') } disabled={ busy }>{ t('generic.back', 'Back') }</button>
|
||||
<span className="step-indicator">2/3</span>
|
||||
<button type="submit" className="ok-button" disabled={ !username.trim() || busy || serverOffline }>
|
||||
{ (checking || pingingServer) ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
{ step === 'room' &&
|
||||
<form className="card-body" onSubmit={ handleRoomSubmit } autoComplete="off">
|
||||
<div className="register-intro">
|
||||
{ t('nitro.login.register.intro.room', 'Last step — pick a starter room, or skip and create your own later.') }
|
||||
</div>
|
||||
{ serverOffline &&
|
||||
<div className="error-line server-offline">
|
||||
{ t('nitro.login.server.offline.long', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') }
|
||||
<button type="button" className="retry-link" onClick={ pingServer } disabled={ pingingServer }>
|
||||
{ pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry') }
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="room-templates-list">
|
||||
<label className={ `room-template-option room-template-skip ${ selectedTemplateId === null ? 'selected' : '' }` }>
|
||||
<input type="radio" name="register-room-template" checked={ selectedTemplateId === null }
|
||||
onChange={ () => setSelectedTemplateId(null) } />
|
||||
<div className="room-template-body">
|
||||
<div className="room-template-title">{ t('nitro.login.register.room.skip.title', 'I\'m okay — I\'ll create my own rooms') }</div>
|
||||
<div className="room-template-description">{ t('nitro.login.register.room.skip.description', 'Skip for now and start with an empty hotel inventory.') }</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{ roomTemplates === null && <div className="info-line">{ t('nitro.login.register.room.loading', 'Loading rooms…') }</div> }
|
||||
|
||||
{ roomTemplates !== null && roomTemplates.map(template => (
|
||||
<label key={ template.templateId }
|
||||
className={ `room-template-option ${ selectedTemplateId === template.templateId ? 'selected' : '' }` }>
|
||||
<input type="radio" name="register-room-template" checked={ selectedTemplateId === template.templateId }
|
||||
onChange={ () => setSelectedTemplateId(template.templateId) } />
|
||||
{ template.thumbnail &&
|
||||
<img className="room-template-thumb" src={ template.thumbnail } alt={ template.title }
|
||||
onError={ e => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> }
|
||||
<div className="room-template-body">
|
||||
<div className="room-template-title">{ template.title }</div>
|
||||
{ template.description &&
|
||||
<div className="room-template-description">{ template.description }</div> }
|
||||
</div>
|
||||
</label>
|
||||
)) }
|
||||
</div>
|
||||
|
||||
{ roomTemplatesError && <div className="error-line">{ roomTemplatesError }</div> }
|
||||
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
|
||||
{ info && <div className="info-line">{ info }</div> }
|
||||
|
||||
<div className="step-footer step-footer-split">
|
||||
<button type="button" className="ok-button back-button" onClick={ () => setStep('avatar') } disabled={ busy }>{ t('generic.back', 'Back') }</button>
|
||||
<span className="step-indicator">3/3</span>
|
||||
<button type="submit" className="ok-button" disabled={ busy || serverOffline }>
|
||||
{ submitting ? t('nitro.login.register.creating', 'Creating…') : t('nitro.login.register.finish', 'Finish') }
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface DialogSharedProps
|
||||
{
|
||||
onCancel: () => void;
|
||||
submitting: boolean;
|
||||
error: string | null;
|
||||
info: string | null;
|
||||
turnstileEnabled: boolean;
|
||||
turnstileSiteKey: string;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export interface BanInfo
|
||||
{
|
||||
type: 'account' | 'ip' | 'machine' | 'super' | string;
|
||||
reason: string;
|
||||
permanent: boolean;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
export const parseBan = (payload: Record<string, unknown>): BanInfo | null =>
|
||||
{
|
||||
const raw = payload?.ban;
|
||||
if(!raw || typeof raw !== 'object') return null;
|
||||
const ban = raw as Record<string, unknown>;
|
||||
const type = typeof ban.type === 'string' ? ban.type : 'account';
|
||||
const reason = typeof ban.reason === 'string' ? ban.reason : '';
|
||||
const permanent = ban.permanent === true || ban.permanent === 'true';
|
||||
const expiresAt = typeof ban.expiresAt === 'number' ? ban.expiresAt : undefined;
|
||||
return { type, reason, permanent, expiresAt };
|
||||
};
|
||||
|
||||
export const formatRemaining = (epochSeconds: number): string =>
|
||||
{
|
||||
const totalSeconds = Math.max(0, epochSeconds - Math.floor(Date.now() / 1000));
|
||||
const days = Math.floor(totalSeconds / 86400);
|
||||
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if(days > 0) return `${ days }d ${ hours }h ${ minutes }m`;
|
||||
if(hours > 0) return `${ hours }h ${ minutes }m`;
|
||||
if(minutes > 0) return `${ minutes }m ${ seconds }s`;
|
||||
return `${ seconds }s`;
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
export type GenderKey = 'M' | 'F';
|
||||
|
||||
export const PART_ROWS: string[] = [ 'hr', 'hd', 'ch', 'lg', 'sh' ];
|
||||
|
||||
export const FALLBACK_DEFAULTS: Record<GenderKey, Record<string, { partId: number; colors: number[] }>> = {
|
||||
M: {
|
||||
hr: { partId: 180, colors: [ 45 ] },
|
||||
hd: { partId: 180, colors: [ 1 ] },
|
||||
ch: { partId: 215, colors: [ 66 ] },
|
||||
lg: { partId: 270, colors: [ 82 ] },
|
||||
sh: { partId: 290, colors: [ 80 ] }
|
||||
},
|
||||
F: {
|
||||
hr: { partId: 515, colors: [ 45 ] },
|
||||
hd: { partId: 600, colors: [ 1 ] },
|
||||
ch: { partId: 660, colors: [ 100 ] },
|
||||
lg: { partId: 716, colors: [ 82 ] },
|
||||
sh: { partId: 725, colors: [ 61 ] }
|
||||
}
|
||||
};
|
||||
|
||||
export const FALLBACK_HEX: Record<number, string> = {
|
||||
1: '#ffcb98', 8: '#f4ac54', 14: '#f5da88', 19: '#b87560', 20: '#9c543f',
|
||||
45: '#e8c498', 61: '#f1ece3', 66: '#96743d', 80: '#4f4d4d', 82: '#7f4f30',
|
||||
92: '#ececec', 100: '#c7ddff', 106: '#c6e6bd', 110: '#91a7c8', 143: '#ffffff'
|
||||
};
|
||||
|
||||
export interface FigureColor { id: number; hexCode: string; club: number; selectable: boolean; }
|
||||
export interface FigurePalette { id: number; colors: FigureColor[]; }
|
||||
export interface FigureSet { id: number; gender: 'M' | 'F' | 'U'; club: number; selectable: boolean; }
|
||||
export interface FigureSetType { type: string; paletteId: number; sets: FigureSet[]; }
|
||||
export interface FigureData { palettes: FigurePalette[]; setTypes: FigureSetType[]; }
|
||||
|
||||
export interface PartSelection { partId: number; colors: number[]; }
|
||||
export type FigureSelection = Record<string, PartSelection>;
|
||||
|
||||
export const buildFigureString = (selection: FigureSelection): string =>
|
||||
{
|
||||
const seen = new Set<string>();
|
||||
const parts: string[] = [];
|
||||
const push = (setType: string) =>
|
||||
{
|
||||
if(seen.has(setType)) return;
|
||||
seen.add(setType);
|
||||
const sel = selection[setType];
|
||||
if(!sel || sel.partId < 0) return;
|
||||
const tail = (sel.colors && sel.colors.length) ? `-${ sel.colors.join('-') }` : '';
|
||||
parts.push(`${ setType }-${ sel.partId }${ tail }`);
|
||||
};
|
||||
for(const setType of PART_ROWS) push(setType);
|
||||
for(const setType of Object.keys(selection)) push(setType);
|
||||
return parts.join('.');
|
||||
};
|
||||
|
||||
export const buildImagingUrl = (template: string, figure: string, gender: GenderKey): string =>
|
||||
template
|
||||
.replace(/\{figure\}/g, encodeURIComponent(figure))
|
||||
.replace(/\{gender\}/g, gender)
|
||||
.replace(/\{direction\}/g, '2');
|
||||
|
||||
const HEAD_ONLY_PARTS = new Set([ 'hr', 'hd' ]);
|
||||
|
||||
export const buildPartPreviewUrl = (
|
||||
template: string,
|
||||
setType: string,
|
||||
selection: FigureSelection,
|
||||
gender: GenderKey
|
||||
): string =>
|
||||
{
|
||||
const defaults = FALLBACK_DEFAULTS[gender];
|
||||
const partSel = selection[setType] ?? defaults[setType];
|
||||
const tail = (partSel.colors && partSel.colors.length) ? `-${ partSel.colors.join('-') }` : '';
|
||||
const isHeadOnly = HEAD_ONLY_PARTS.has(setType);
|
||||
|
||||
let parts: string[];
|
||||
if(isHeadOnly)
|
||||
{
|
||||
const hd = defaults.hd;
|
||||
const pieces = new Map<string, string>();
|
||||
pieces.set('hd', `hd-${ hd.partId }-${ hd.colors.join('-') }`);
|
||||
pieces.set(setType, `${ setType }-${ partSel.partId }${ tail }`);
|
||||
parts = Array.from(pieces.values());
|
||||
}
|
||||
else
|
||||
{
|
||||
const hd = defaults.hd;
|
||||
parts = [
|
||||
`hd-${ hd.partId }-${ hd.colors.join('-') }`,
|
||||
`${ setType }-${ partSel.partId }${ tail }`
|
||||
];
|
||||
}
|
||||
|
||||
const figure = parts.join('.');
|
||||
let url = template
|
||||
.replace(/\{figure\}/g, encodeURIComponent(figure))
|
||||
.replace(/\{gender\}/g, gender)
|
||||
.replace(/\{direction\}/g, '2');
|
||||
|
||||
url = url.replace(/size=l/, 'size=s').replace(/size=m/, 'size=s');
|
||||
if(!/size=/.test(url)) url += (url.includes('?') ? '&' : '?') + 'size=s';
|
||||
if(isHeadOnly && !/headonly=/.test(url)) url += '&headonly=1';
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { GetConfiguration } from '@nitrots/nitro-renderer';
|
||||
import { LocalizeText } from '../../../api';
|
||||
|
||||
export const t = (key: string, fallback: string, params?: string[], replacements?: string[]): string =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const value = LocalizeText(key, params ?? null, replacements ?? null);
|
||||
if(value && value !== key) return value;
|
||||
}
|
||||
catch {}
|
||||
|
||||
if(!params || !replacements) return fallback;
|
||||
let out = fallback;
|
||||
for(let i = 0; i < params.length; i++)
|
||||
{
|
||||
if(replacements[i] !== undefined) out = out.replace('%' + params[i] + '%', replacements[i]);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export const interpolate = (value: string | null | undefined): string =>
|
||||
{
|
||||
if(!value) return '';
|
||||
try { return GetConfiguration().interpolate(value); }
|
||||
catch { return value; }
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
export const LOCK_KEY = 'nitro.login.lock';
|
||||
export const MAX_ATTEMPTS = 5;
|
||||
export const LOCK_WINDOW_MS = 60_000;
|
||||
export const LOCK_DURATION_MS = 2 * 60_000;
|
||||
|
||||
export type AttemptState = { attempts: number; firstAt: number; lockedUntil: number };
|
||||
|
||||
export 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 }; }
|
||||
};
|
||||
|
||||
export const writeLock = (state: AttemptState) =>
|
||||
{
|
||||
try { sessionStorage.setItem(LOCK_KEY, JSON.stringify(state)); }
|
||||
catch { }
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Accepts a URL (http/https, protocol-relative, or site-relative),
|
||||
* a data URL with an image mime type, or a raw base64 image payload.
|
||||
* Anything else (including data:text/html, javascript:, etc.) is rejected
|
||||
* to keep an admin-set DB value from becoming an XSS / phishing vector.
|
||||
*/
|
||||
export const resolveNewsImage = (raw: string | null | undefined): string =>
|
||||
{
|
||||
const value = (raw ?? '').trim();
|
||||
if(!value) return '';
|
||||
if(/^https?:\/\//i.test(value)) return value;
|
||||
if(value.startsWith('//')) return window.location.protocol + value;
|
||||
if(value.startsWith('/'))
|
||||
{
|
||||
try { return new URL(value, window.location.origin).href; }
|
||||
catch { return window.location.origin + value; }
|
||||
}
|
||||
if(value.startsWith('data:'))
|
||||
{
|
||||
return /^data:image\/[a-z0-9.+-]+[,;]/i.test(value) ? value : '';
|
||||
}
|
||||
|
||||
const stripped = value.replace(/\s+/g, '');
|
||||
if(!/^[A-Za-z0-9+/=]+$/.test(stripped)) return '';
|
||||
let mime = 'image/png';
|
||||
if(stripped.startsWith('/9j/')) mime = 'image/jpeg';
|
||||
else if(stripped.startsWith('R0lGOD')) mime = 'image/gif';
|
||||
else if(stripped.startsWith('UklGR')) mime = 'image/webp';
|
||||
else if(stripped.startsWith('PHN2Zy') || stripped.startsWith('PD94bWw')) mime = 'image/svg+xml';
|
||||
else if(stripped.startsWith('iVBORw0KGgo')) mime = 'image/png';
|
||||
return `data:${ mime };base64,${ stripped }`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Rejects anything that isn't an http(s) URL or a same-origin path so a
|
||||
* malicious DB value can't be a `javascript:` / `data:` / `file:` link.
|
||||
*/
|
||||
export const resolveNewsLink = (raw: string | null | undefined): string =>
|
||||
{
|
||||
const value = (raw ?? '').trim();
|
||||
if(!value) return '';
|
||||
try
|
||||
{
|
||||
const url = new URL(value, window.location.href);
|
||||
const proto = url.protocol.toLowerCase();
|
||||
if(proto !== 'http:' && proto !== 'https:') return '';
|
||||
return url.href;
|
||||
}
|
||||
catch { return ''; }
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { RoomDataParser, RoomSettingsComposer, UpdateHomeRoomMessageComposer } from '@nitrots/nitro-renderer';
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
import React, { FC, useRef, useState } from 'react';
|
||||
import { FaUser } from 'react-icons/fa';
|
||||
import { ArrowContainer, Popover } from 'react-tiny-popover';
|
||||
import { GetGroupInformation, GetSessionDataManager, GetUserProfile, LocalizeText, ReportType, SendMessageComposer, ToggleFavoriteRoom } from '../../../../api';
|
||||
import { Column, Flex, LayoutBadgeImageView, LayoutRoomThumbnailView, NitroCardContentView, Text, UserProfileIconView } from '../../../../common';
|
||||
import { useHelp, useNavigator } from '../../../../hooks';
|
||||
@@ -26,6 +26,12 @@ export const NavigatorSearchResultItemInfoView: FC<NavigatorSearchResultItemInfo
|
||||
const isControlled = isVisible !== undefined;
|
||||
const popoverOpen = isControlled ? isVisible : internalVisible;
|
||||
|
||||
const handleOpenChange = (open: boolean) =>
|
||||
{
|
||||
if(!isControlled) setInternalVisible(open);
|
||||
if(!open && setIsPopoverActive) setIsPopoverActive(false);
|
||||
};
|
||||
|
||||
const getUserCounterColor = () =>
|
||||
{
|
||||
const num: number = (100 * (roomData.userCount / roomData.maxUserCount));
|
||||
@@ -88,17 +94,22 @@ export const NavigatorSearchResultItemInfoView: FC<NavigatorSearchResultItemInfo
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
containerClassName="max-w-[276px] not-italic font-normal leading-normal text-left no-underline text-shadow-none normal-case tracking-[normal] [word-break:normal] [word-spacing:normal] whitespace-normal text-[.7875rem] [word-wrap:break-word] bg-[#f2f2eb] border border-[#000] rounded-[8px] shadow-none z-[1070]"
|
||||
content={ ({ position, childRect, popoverRect }) => (
|
||||
<ArrowContainer
|
||||
arrowColor="black"
|
||||
arrowSize={ 7 }
|
||||
arrowStyle={ { left: 'calc(-.5rem - 0px)' } }
|
||||
childRect={ childRect }
|
||||
popoverRect={ popoverRect }
|
||||
position={ position }
|
||||
>
|
||||
<Popover.Root open={ popoverOpen } onOpenChange={ handleOpenChange }>
|
||||
<Popover.Trigger asChild>
|
||||
<div
|
||||
ref={ elementRef }
|
||||
className="cursor-pointer nitro-icon icon-navigator-info"
|
||||
onClick={ handleIconClick }
|
||||
onMouseOver={ () => { if(!isControlled) setInternalVisible(true); } }
|
||||
onMouseLeave={ () => { if(!isControlled) setInternalVisible(false); } }
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
side="right"
|
||||
sideOffset={ 10 }
|
||||
collisionPadding={ 8 }
|
||||
className="max-w-[276px] not-italic font-normal leading-normal text-left no-underline normal-case tracking-normal whitespace-normal text-[.7875rem] [word-wrap:break-word] bg-[#f2f2eb] border border-black rounded-[8px] shadow-none z-[1070]">
|
||||
<NitroCardContentView className="bg-transparent room-info image-rendering-pixelated !p-0" overflow="hidden" onClick={ e => e.stopPropagation() }>
|
||||
<Flex gap={ 1 } overflow="hidden" className="p-2">
|
||||
<LayoutRoomThumbnailView className="flex flex-col items-center justify-end mb-1" customUrl={ roomData.officialRoomPicRef } roomId={ roomData.roomId }>
|
||||
@@ -173,24 +184,9 @@ export const NavigatorSearchResultItemInfoView: FC<NavigatorSearchResultItemInfo
|
||||
</Flex> }
|
||||
</Column>
|
||||
</NitroCardContentView>
|
||||
</ArrowContainer>
|
||||
) }
|
||||
isOpen={ popoverOpen }
|
||||
onClickOutside={ () =>
|
||||
{
|
||||
if(!isControlled) setInternalVisible(false);
|
||||
if(setIsPopoverActive) setIsPopoverActive(false);
|
||||
} }
|
||||
padding={ 10 }
|
||||
positions={ [ 'right', 'left', 'top', 'bottom' ] }
|
||||
>
|
||||
<div
|
||||
ref={ elementRef }
|
||||
className="cursor-pointer nitro-icon icon-navigator-info"
|
||||
onClick={ handleIconClick }
|
||||
onMouseOver={ () => { if(!isControlled) setInternalVisible(true); } }
|
||||
onMouseLeave={ () => { if(!isControlled) setInternalVisible(false); } }
|
||||
/>
|
||||
</Popover>
|
||||
<Popover.Arrow className="fill-black" width={ 14 } height={ 7 } />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { FC, useMemo } from 'react';
|
||||
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
|
||||
import { LocalizeFormattedNumber, LocalizeShortNumber } from '../../../api';
|
||||
import { Flex, LayoutCurrencyIcon, Text } from '../../../common';
|
||||
|
||||
@@ -17,23 +16,22 @@ export const CurrencyView: FC<CurrencyViewProps> = props =>
|
||||
const element = useMemo(() =>
|
||||
{
|
||||
return (
|
||||
<Flex justifyContent="end" pointer gap={ 1 } className={`nitro-purse-button rounded allcurrencypurse nitro-purse-button currency-${type}`}>
|
||||
<Flex justifyContent="end" pointer gap={ 1 } className={ `nitro-purse-button rounded allcurrencypurse nitro-purse-button currency-${ type }` }>
|
||||
<Text truncate textEnd variant="white" grow>{ short ? LocalizeShortNumber(amount) : LocalizeFormattedNumber(amount) }</Text>
|
||||
<LayoutCurrencyIcon type={ type } />
|
||||
</Flex>);
|
||||
}, [ amount, short, type ]);
|
||||
|
||||
if(!short) return element;
|
||||
|
||||
|
||||
return (
|
||||
<OverlayTrigger
|
||||
placement="left"
|
||||
overlay={
|
||||
<Tooltip id={ `tooltip-${ type }` }>
|
||||
{ LocalizeFormattedNumber(amount) }
|
||||
</Tooltip>
|
||||
}>
|
||||
<div className="group relative">
|
||||
{ element }
|
||||
</OverlayTrigger>
|
||||
<div
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute right-full top-1/2 z-50 mr-2 -translate-y-1/2 whitespace-nowrap rounded bg-black/80 px-2 py-1 text-xs text-white opacity-0 shadow transition-opacity duration-150 group-hover:opacity-100">
|
||||
{ LocalizeFormattedNumber(amount) }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { FaPlus } from 'react-icons/fa';
|
||||
import { GetConfigurationValue, LocalizeText } from '../../../../../api';
|
||||
import { LayoutBadgeImageView } from '../../../../../common';
|
||||
@@ -15,7 +16,8 @@ const BadgeMiniPicker: FC<{
|
||||
onSelect: (badgeCode: string) => void;
|
||||
onClose: () => void;
|
||||
activeBadgeCodes: (string | null)[];
|
||||
}> = ({ onSelect, onClose, activeBadgeCodes }) =>
|
||||
position: { top: number; left: number };
|
||||
}> = ({ onSelect, onClose, activeBadgeCodes, position }) =>
|
||||
{
|
||||
const { badgeCodes = [], requestBadges = null } = useInventoryBadges();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -43,10 +45,11 @@ const BadgeMiniPicker: FC<{
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [ onClose ]);
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ ref }
|
||||
className="absolute right-[calc(100%+8px)] top-0 z-50 bg-[rgba(28,28,32,0.97)] border border-white/20 rounded-md p-2 shadow-lg min-w-[160px]"
|
||||
className="fixed z-[9999] bg-[rgba(28,28,32,0.97)] border border-white/20 rounded-md p-2 shadow-lg min-w-[160px]"
|
||||
style={ { top: position.top, left: position.left } }
|
||||
onClick={ e => e.stopPropagation() }>
|
||||
<input
|
||||
autoFocus
|
||||
@@ -73,7 +76,8 @@ const BadgeMiniPicker: FC<{
|
||||
) }
|
||||
</div>
|
||||
) }
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
@@ -83,7 +87,9 @@ export const InfoStandBadgeSlotView: FC<InfoStandBadgeSlotProps> = ({ slotIndex,
|
||||
const [ isDragOver, setIsDragOver ] = useState(false);
|
||||
const [ isDragging, setIsDragging ] = useState(false);
|
||||
const [ justDropped, setJustDropped ] = useState(false);
|
||||
const [ showPicker, setShowPicker ] = useState(false);
|
||||
const [ pickerPosition, setPickerPosition ] = useState<{ top: number; left: number } | null>(null);
|
||||
const slotRef = useRef<HTMLDivElement>(null);
|
||||
const showPicker = pickerPosition !== null;
|
||||
|
||||
const hookInitialized = activeBadgeCodes.length > 0;
|
||||
|
||||
@@ -152,9 +158,17 @@ export const InfoStandBadgeSlotView: FC<InfoStandBadgeSlotProps> = ({ slotIndex,
|
||||
|
||||
const handleSlotClick = useCallback(() =>
|
||||
{
|
||||
if(!isOwnUser || badgeCode) return;
|
||||
if(!isOwnUser || badgeCode || !slotRef.current) return;
|
||||
|
||||
setShowPicker(true);
|
||||
const rect = slotRef.current.getBoundingClientRect();
|
||||
const pickerWidth = 180;
|
||||
const gap = 8;
|
||||
let left = rect.right + gap;
|
||||
|
||||
if((left + pickerWidth) > (window.innerWidth - gap)) left = rect.left - pickerWidth - gap;
|
||||
if(left < gap) left = gap;
|
||||
|
||||
setPickerPosition({ top: rect.top, left });
|
||||
}, [ isOwnUser, badgeCode ]);
|
||||
|
||||
const handleDoubleClick = useCallback(() =>
|
||||
@@ -167,12 +181,13 @@ export const InfoStandBadgeSlotView: FC<InfoStandBadgeSlotProps> = ({ slotIndex,
|
||||
const handlePickerSelect = useCallback((code: string) =>
|
||||
{
|
||||
setBadgeAtSlot(code, slotIndex);
|
||||
setShowPicker(false);
|
||||
setPickerPosition(null);
|
||||
}, [ setBadgeAtSlot, slotIndex ]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={ slotRef }
|
||||
className={ `flex items-center justify-center relative w-[40px] h-[40px] bg-no-repeat bg-center transition-all duration-150
|
||||
${ isOwnUser && badgeCode ? 'cursor-grab active:cursor-grabbing' : '' }
|
||||
${ isOwnUser && !badgeCode ? 'cursor-pointer' : '' }
|
||||
@@ -196,8 +211,9 @@ export const InfoStandBadgeSlotView: FC<InfoStandBadgeSlotProps> = ({ slotIndex,
|
||||
{ showPicker && (
|
||||
<BadgeMiniPicker
|
||||
activeBadgeCodes={ activeBadgeCodes }
|
||||
onClose={ () => setShowPicker(false) }
|
||||
onClose={ () => setPickerPosition(null) }
|
||||
onSelect={ handlePickerSelect }
|
||||
position={ pickerPosition }
|
||||
/>
|
||||
) }
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CrackableDataType, CreateLinkEvent, FurnitureFloorUpdateEvent, GetRoomEngine, GetSoundManager, GroupInformationComposer, GroupInformationEvent, NowPlayingEvent, RoomControllerLevel, RoomObjectCategory, RoomObjectOperationType, RoomObjectVariable, RoomWidgetEnumItemExtradataParameter, RoomWidgetFurniInfoUsagePolicyEnum, SetObjectDataMessageComposer, SongInfoReceivedEvent, StringDataType, UpdateFurniturePositionComposer } from '@nitrots/nitro-renderer';
|
||||
import { CrackableDataType, CreateLinkEvent, FurnitureFloorUpdateEvent, GetRoomEngine, GetSessionDataManager, GetSoundManager, GroupInformationComposer, GroupInformationEvent, NowPlayingEvent, RoomControllerLevel, RoomObjectCategory, RoomObjectOperationType, RoomObjectVariable, RoomWidgetEnumItemExtradataParameter, RoomWidgetFurniInfoUsagePolicyEnum, SetObjectDataMessageComposer, SongInfoReceivedEvent, StringDataType, UpdateFurniturePositionComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FaCrosshairs, FaRulerVertical, FaTimes } from 'react-icons/fa';
|
||||
import { GrFormNextLink, GrRotateLeft, GrRotateRight } from 'react-icons/gr';
|
||||
@@ -585,19 +585,20 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
|
||||
onClick={ () => setDropdownOpen(!dropdownOpen) }>
|
||||
{ dropdownOpen ? `${LocalizeText('widget.furni.present.close')} Buildtools` : `${LocalizeText('navigator.roomsettings.doormode.open')} Buildtools` }
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-white text-xs bg-[#1e7295] hover:bg-[#1a617f] border border-[#ffffff33] rounded px-2 py-1 cursor-pointer transition-colors"
|
||||
onClick={ () =>
|
||||
{
|
||||
const roomObject = GetRoomEngine().getRoomObject(roomSession.roomId, avatarInfo.id, avatarInfo.isWallItem ? RoomObjectCategory.WALL : RoomObjectCategory.FLOOR);
|
||||
const typeId = roomObject?.model?.getValue(RoomObjectVariable.FURNITURE_TYPE_ID);
|
||||
{ GetSessionDataManager().isModerator &&
|
||||
<button
|
||||
className="w-full text-white text-xs bg-[#1e7295] hover:bg-[#1a617f] border border-[#ffffff33] rounded px-2 py-1 cursor-pointer transition-colors"
|
||||
onClick={ () =>
|
||||
{
|
||||
const roomObject = GetRoomEngine().getRoomObject(roomSession.roomId, avatarInfo.id, avatarInfo.isWallItem ? RoomObjectCategory.WALL : RoomObjectCategory.FLOOR);
|
||||
const typeId = roomObject?.model?.getValue(RoomObjectVariable.FURNITURE_TYPE_ID);
|
||||
|
||||
CreateLinkEvent('furni-editor/show');
|
||||
CreateLinkEvent('furni-editor/show');
|
||||
|
||||
if(typeId) window.dispatchEvent(new CustomEvent('furni-editor:open', { detail: { spriteId: typeId } }));
|
||||
} }>
|
||||
Edit Furni
|
||||
</button>
|
||||
if(typeId) window.dispatchEvent(new CustomEvent('furni-editor:open', { detail: { spriteId: typeId } }));
|
||||
} }>
|
||||
Edit Furni
|
||||
</button> }
|
||||
{ dropdownOpen &&
|
||||
<div className="flex gap-[4px] w-full">
|
||||
{ /* Left panel: position + rotation */ }
|
||||
|
||||
@@ -24,12 +24,14 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
|
||||
const [backgroundId, setBackgroundId] = useState<number>(null);
|
||||
const [standId, setStandId] = useState<number>(null);
|
||||
const [overlayId, setOverlayId] = useState<number>(null);
|
||||
const [cardBackgroundId, setCardBackgroundId] = useState<number>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const { roomSession = null } = useRoom();
|
||||
|
||||
const infostandBackgroundClass = `background-${backgroundId ?? 'default'}`;
|
||||
const infostandStandClass = `stand-${standId ?? 'default'}`;
|
||||
const infostandOverlayClass = `overlay-${overlayId ?? 'default'}`;
|
||||
const infostandCardBackgroundClass = cardBackgroundId ? `card-background-${cardBackgroundId}` : '';
|
||||
const handleProfileClick = useCallback(() => { GetUserProfile(avatarInfo.webID); }, [avatarInfo.webID]);
|
||||
|
||||
const handleEditClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); setIsVisible(prev => !prev); }, []);
|
||||
@@ -96,6 +98,7 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
|
||||
newValue.backgroundId = event.backgroundId;
|
||||
newValue.standId = event.standId;
|
||||
newValue.overlayId = event.overlayId;
|
||||
newValue.cardBackgroundId = event.cardBackgroundId ?? 0;
|
||||
return newValue;
|
||||
});
|
||||
});
|
||||
@@ -130,16 +133,12 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
|
||||
setBackgroundId(avatarInfo.backgroundId);
|
||||
setStandId(avatarInfo.standId);
|
||||
setOverlayId(avatarInfo.overlayId);
|
||||
setCardBackgroundId(avatarInfo.cardBackgroundId ?? 0);
|
||||
|
||||
SendMessageComposer(new UserRelationshipsComposer(avatarInfo.webID));
|
||||
|
||||
return () => {
|
||||
setIsEditingMotto(false);
|
||||
setMotto(null);
|
||||
setRelationships(null);
|
||||
setBackgroundId(null);
|
||||
setStandId(null);
|
||||
setOverlayId(null);
|
||||
};
|
||||
}, [avatarInfo]);
|
||||
|
||||
@@ -147,7 +146,7 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
|
||||
|
||||
return (
|
||||
<>
|
||||
<Column className="relative min-w-[190px] max-w-[190px] z-30 pointer-events-auto bg-[rgba(28,28,32,0.95)] [box-shadow:inset_0_5px_#22222799,inset_0_-4px_#12121599] rounded">
|
||||
<Column className={`relative min-w-[190px] max-w-[190px] z-30 pointer-events-auto ${cardBackgroundId ? '' : 'bg-[rgba(28,28,32,0.95)]'} [box-shadow:inset_0_5px_#22222799,inset_0_-4px_#12121599] rounded overflow-hidden profile-card-background ${infostandCardBackgroundClass}`}>
|
||||
<Column className="h-full p-[8px] overflow-auto" gap={1} overflow="visible">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -292,6 +291,8 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
|
||||
setSelectedStand={setStandId}
|
||||
selectedOverlay={overlayId}
|
||||
setSelectedOverlay={setOverlayId}
|
||||
selectedCardBackground={cardBackgroundId}
|
||||
setSelectedCardBackground={setCardBackgroundId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -55,6 +55,9 @@ export const AvatarInfoWidgetOwnAvatarView: FC<AvatarInfoWidgetOwnAvatarViewProp
|
||||
case 'change_looks':
|
||||
CreateLinkEvent('avatar-editor/show');
|
||||
break;
|
||||
case 'avatar_effect':
|
||||
CreateLinkEvent('avatar-effects/show');
|
||||
break;
|
||||
case 'expressions':
|
||||
hideMenu = false;
|
||||
setMode(MODE_EXPRESSIONS);
|
||||
@@ -137,6 +140,9 @@ export const AvatarInfoWidgetOwnAvatarView: FC<AvatarInfoWidgetOwnAvatarViewProp
|
||||
<ContextMenuListItemView onClick={ event => processAction('change_looks') }>
|
||||
{ LocalizeText('widget.memenu.myclothes') }
|
||||
</ContextMenuListItemView>
|
||||
<ContextMenuListItemView onClick={ event => processAction('avatar_effect') }>
|
||||
{ LocalizeText('product.type.effect') }
|
||||
</ContextMenuListItemView>
|
||||
{ (HasHabboClub() && !isRidingHorse) &&
|
||||
<ContextMenuListItemView onClick={ event => processAction('dance_menu') }>
|
||||
<FaChevronRight className="right fa-icon" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import data from '@emoji-mart/data';
|
||||
import Picker from '@emoji-mart/react';
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
import { FC, useState } from 'react';
|
||||
import { Popover } from 'react-tiny-popover';
|
||||
|
||||
interface ChatInputEmojiSelectorViewProps
|
||||
{
|
||||
@@ -19,19 +19,16 @@ export const ChatInputEmojiSelectorView: FC<ChatInputEmojiSelectorViewProps> = p
|
||||
setSelectorVisible(false);
|
||||
};
|
||||
|
||||
const toggleSelector = () => setSelectorVisible(prev => !prev);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Popover
|
||||
containerClassName="z-[1070]"
|
||||
content={ <Picker data={ data } onEmojiSelect={ handleEmojiSelect } /> }
|
||||
isOpen={ selectorVisible }
|
||||
positions={ [ 'top' ] }
|
||||
onClickOutside={ () => setSelectorVisible(false) }
|
||||
>
|
||||
<div className="cursor-pointer text-lg select-none px-1" onClick={ toggleSelector }>🙂</div>
|
||||
</Popover>
|
||||
</div>
|
||||
<Popover.Root open={ selectorVisible } onOpenChange={ setSelectorVisible }>
|
||||
<Popover.Trigger asChild>
|
||||
<div className="cursor-pointer text-lg select-none px-1">🙂</div>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content className="z-[1070]" side="top" sideOffset={ 8 }>
|
||||
<Picker data={ data } onEmojiSelect={ handleEmojiSelect } />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
import { FC, useState } from 'react';
|
||||
import { ArrowContainer, Popover } from 'react-tiny-popover';
|
||||
import { Flex, Grid, NitroCardContentView } from '../../../../common';
|
||||
|
||||
interface ChatInputStyleSelectorViewProps
|
||||
@@ -21,20 +21,17 @@ export const ChatInputStyleSelectorView: FC<ChatInputStyleSelectorViewProps> = p
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
padding={12}
|
||||
isOpen={selectorVisible}
|
||||
positions={['top']}
|
||||
reposition={false}
|
||||
containerClassName="max-w-[276px] not-italic font-normal leading-normal text-left no-underline text-shadow-none normal-case tracking-[normal] [word-break:normal] [word-spacing:normal] whitespace-normal text-[.7875rem] [word-wrap:break-word] bg-[#dfdfdf] bg-clip-padding border border-[solid] border-[#283F5D] rounded-[.25rem] [box-shadow:0_2px_#00000073] z-1070"
|
||||
content={({ position, childRect, popoverRect }) => (
|
||||
<ArrowContainer
|
||||
arrowColor={'black'}
|
||||
arrowSize={7}
|
||||
arrowStyle={{ bottom: 'calc(-.5rem - 1px)' }}
|
||||
childRect={childRect}
|
||||
popoverRect={popoverRect}
|
||||
position={position}
|
||||
<Popover.Root open={selectorVisible} onOpenChange={setSelectorVisible}>
|
||||
<Popover.Trigger asChild>
|
||||
<div className="chatstyles-anchor">
|
||||
<div className="nitro-icon chatstyles-icon" />
|
||||
</div>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
side="top"
|
||||
sideOffset={12}
|
||||
className="max-w-[276px] not-italic font-normal leading-normal text-left no-underline normal-case tracking-normal whitespace-normal text-[.7875rem] [word-wrap:break-word] bg-[#dfdfdf] bg-clip-padding border border-solid border-[#283F5D] rounded-[.25rem] [box-shadow:0_2px_#00000073] z-[1070]"
|
||||
>
|
||||
<NitroCardContentView className="bg-transparent max-h-[210px]!" overflow="hidden">
|
||||
<Grid columnCount={3} overflow="auto">
|
||||
@@ -47,15 +44,9 @@ export const ChatInputStyleSelectorView: FC<ChatInputStyleSelectorViewProps> = p
|
||||
))}
|
||||
</Grid>
|
||||
</NitroCardContentView>
|
||||
</ArrowContainer>
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="chatstyles-anchor"
|
||||
onClick={() => setSelectorVisible(v => !v)}
|
||||
>
|
||||
<div className="nitro-icon chatstyles-icon" />
|
||||
</div>
|
||||
</Popover>
|
||||
<Popover.Arrow className="fill-black" width={14} height={7} />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import YouTube, { Options } from 'react-youtube';
|
||||
import { YouTubePlayer } from 'youtube-player/dist/types';
|
||||
import { FC, useRef } from 'react';
|
||||
import ReactPlayer from 'react-player/youtube';
|
||||
import { LocalizeText, YoutubeVideoPlaybackStateEnum } from '../../../../api';
|
||||
import { AutoGrid, AutoGridProps, LayoutGridItem, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
|
||||
import { useFurnitureYoutubeWidget } from '../../../../hooks';
|
||||
@@ -12,71 +11,24 @@ interface FurnitureYoutubeDisplayViewProps extends AutoGridProps
|
||||
|
||||
export const FurnitureYoutubeDisplayView: FC<{}> = FurnitureYoutubeDisplayViewProps =>
|
||||
{
|
||||
const [ player, setPlayer ] = useState<any>(null);
|
||||
const { objectId = -1, videoId = null, videoStart = 0, videoEnd = 0, currentVideoState = null, selectedVideo = null, playlists = [], onClose = null, previous = null, next = null, pause = null, play = null, selectVideo = null } = useFurnitureYoutubeWidget();
|
||||
const playerRef = useRef<ReactPlayer>(null);
|
||||
|
||||
const onStateChange = (event: { target: YouTubePlayer; data: number }) =>
|
||||
const handlePlay = () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
setPlayer(event.target);
|
||||
|
||||
if(objectId === -1) return;
|
||||
|
||||
switch(event.target.getPlayerState())
|
||||
{
|
||||
case -1:
|
||||
case 1:
|
||||
if(currentVideoState !== 1) play();
|
||||
return;
|
||||
case 2:
|
||||
if(currentVideoState !== 2) pause();
|
||||
}
|
||||
}
|
||||
catch(err) {}
|
||||
if(objectId === -1) return;
|
||||
if(currentVideoState !== YoutubeVideoPlaybackStateEnum.PLAYING) play();
|
||||
};
|
||||
|
||||
useEffect(() =>
|
||||
const handlePause = () =>
|
||||
{
|
||||
if((currentVideoState === null) || !player) return;
|
||||
|
||||
try
|
||||
{
|
||||
if((currentVideoState === YoutubeVideoPlaybackStateEnum.PLAYING) && (player.getPlayerState() !== YoutubeVideoPlaybackStateEnum.PLAYING))
|
||||
{
|
||||
player.playVideo();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if((currentVideoState === YoutubeVideoPlaybackStateEnum.PAUSED) && (player.getPlayerState() !== YoutubeVideoPlaybackStateEnum.PAUSED))
|
||||
{
|
||||
player.pauseVideo();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
setPlayer(null);
|
||||
}
|
||||
}, [ currentVideoState, player ]);
|
||||
if(objectId === -1) return;
|
||||
if(currentVideoState !== YoutubeVideoPlaybackStateEnum.PAUSED) pause();
|
||||
};
|
||||
|
||||
if(objectId === -1) return null;
|
||||
|
||||
const youtubeOptions: Options = {
|
||||
height: '375',
|
||||
width: '500',
|
||||
playerVars: {
|
||||
autoplay: 1,
|
||||
disablekb: 1,
|
||||
controls: 0,
|
||||
origin: window.origin,
|
||||
modestbranding: 1,
|
||||
start: videoStart,
|
||||
end: videoEnd
|
||||
}
|
||||
};
|
||||
const playing = (currentVideoState === null) ? true : (currentVideoState === YoutubeVideoPlaybackStateEnum.PLAYING);
|
||||
|
||||
return (
|
||||
<NitroCardView className="youtube-tv-widget">
|
||||
@@ -85,7 +37,26 @@ export const FurnitureYoutubeDisplayView: FC<{}> = FurnitureYoutubeDisplayViewPr
|
||||
<div className="row size-full">
|
||||
<div className="youtube-video-container col-span-9 overflow-hidden">
|
||||
{ (videoId && videoId.length > 0) &&
|
||||
<YouTube containerClassName={ 'youtubeContainer' } opts={ youtubeOptions } videoId={ videoId } onReady={ event => setPlayer(event.target) } onStateChange={ onStateChange } />
|
||||
<ReactPlayer
|
||||
ref={ playerRef }
|
||||
url={ `https://www.youtube.com/watch?v=${ videoId }` }
|
||||
width={ 500 }
|
||||
height={ 375 }
|
||||
playing={ playing }
|
||||
controls={ false }
|
||||
onPlay={ handlePlay }
|
||||
onPause={ handlePause }
|
||||
config={ {
|
||||
playerVars: {
|
||||
autoplay: 1,
|
||||
disablekb: 1,
|
||||
controls: 0,
|
||||
origin: window.origin,
|
||||
modestbranding: 1,
|
||||
start: videoStart,
|
||||
end: videoEnd
|
||||
}
|
||||
} } />
|
||||
}
|
||||
{ (!videoId || videoId.length === 0) &&
|
||||
<div className="empty-video size-full justify-center items-center flex">{ LocalizeText('widget.furni.video_viewer.no_videos') }</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ export const ToolbarMeView: FC<PropsWithChildren<{
|
||||
}, [ setMeExpanded ]);
|
||||
|
||||
return (
|
||||
<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 }>
|
||||
<Flex alignItems="center" className="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') }>
|
||||
@@ -42,6 +42,7 @@ export const ToolbarMeView: FC<PropsWithChildren<{
|
||||
<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-badge-creator cursor-pointer" onClick={ event => CreateLinkEvent('badge-creator/toggle') } title={ LocalizeText('toolbar.icon.label.badge_creator') } />
|
||||
<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 }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ControlYoutubeDisplayPlaybackMessageComposer, YouTubeRoomBroadcastEvent, YouTubeRoomPlayComposer, YouTubeRoomSettingsEvent, YouTubeRoomWatchersEvent, YouTubeRoomWatchingComposer } from "@nitrots/nitro-renderer";
|
||||
import { FC, useEffect, useRef, useState } from "react";
|
||||
import YouTube from "react-youtube";
|
||||
import ReactPlayer from "react-player/youtube";
|
||||
import { GetRoomSession, getYoutubeRoomEnabled, GetSessionDataManager, LocalizeText, SendMessageComposer, YoutubeVideoPlaybackStateEnum } from "../../api";
|
||||
import { NitroCardContentView, NitroCardHeaderView, NitroCardView, LayoutAvatarImageView } from "../../common";
|
||||
import { useFurnitureYoutubeWidget, useMessageEvent } from "../../hooks";
|
||||
@@ -35,7 +35,7 @@ export const YouTubePlayerView: FC<{}> = () => {
|
||||
const [playlist, setPlaylist] = useState<string[]>([]);
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [showVolumeSlider, setShowVolumeSlider] = useState(true);
|
||||
const playerRef = useRef<any>(null);
|
||||
const playerRef = useRef<ReactPlayer | null>(null);
|
||||
const { objectId: youtubeObjectId, videoId: roomVideoId, currentVideoState, hasControl } = useFurnitureYoutubeWidget();
|
||||
const [spectators, setSpectators] = useState< { id: number; name: string; look: string }[] >([]);
|
||||
const [broadcastVideo, setBroadcastVideo] = useState("");
|
||||
@@ -310,22 +310,22 @@ export const YouTubePlayerView: FC<{}> = () => {
|
||||
)}
|
||||
|
||||
{videoId ? (
|
||||
<YouTube
|
||||
videoId={videoId}
|
||||
opts={{
|
||||
width: "100%",
|
||||
height: isFullscreen ? "100%" : "280",
|
||||
<ReactPlayer
|
||||
ref={ref => { playerRef.current = ref; }}
|
||||
url={`https://www.youtube.com/watch?v=${videoId}`}
|
||||
width="100%"
|
||||
height={isFullscreen ? "100%" : 280}
|
||||
playing
|
||||
muted={isMuted}
|
||||
loop={isLooping}
|
||||
volume={Math.max(0, Math.min(1, volume / 100))}
|
||||
config={{
|
||||
playerVars: {
|
||||
autoplay: 1,
|
||||
volume: volume,
|
||||
muted: isMuted ? 1 : 0,
|
||||
loop: isLooping ? 1 : 0,
|
||||
},
|
||||
}}
|
||||
onReady={(e) => {
|
||||
playerRef.current = e.target;
|
||||
addToHistory(videoId);
|
||||
}}
|
||||
onReady={() => addToHistory(videoId)}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-[280px] flex items-center justify-center bg-gray-800 text-gray-500">
|
||||
|
||||
@@ -18,6 +18,7 @@ export const UserContainerView: FC<{
|
||||
const infostandBackgroundClass = `background-${userProfile.backgroundId ?? 'default'}`;
|
||||
const infostandStandClass = `stand-${userProfile.standId ?? 'default'}`;
|
||||
const infostandOverlayClass = `overlay-${userProfile.overlayId ?? 'default'}`;
|
||||
const profileCardBgClass = userProfile.cardBackgroundId ? `card-background-${userProfile.cardBackgroundId}` : '';
|
||||
const addFriend = () =>
|
||||
{
|
||||
setRequestSent(true);
|
||||
@@ -31,7 +32,7 @@ export const UserContainerView: FC<{
|
||||
}, [ userProfile ]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className={`flex gap-2 p-2 rounded profile-card-background ${profileCardBgClass}`}>
|
||||
<div className={`flex flex-col justify-center items-center w-[75px] h-[120px] rounded-sm relative overflow-hidden profile-background ${infostandBackgroundClass}`}>
|
||||
<div className={`absolute inset-0 profile-stand ${infostandStandClass}`} />
|
||||
<LayoutAvatarImageView direction={ 2 } figure={ userProfile.figure } />
|
||||
|
||||
Reference in New Issue
Block a user