Merge branch 'Dev' into feat/react19-modernization

This commit is contained in:
DuckieTM
2026-05-20 10:42:34 +02:00
committed by GitHub
48 changed files with 973 additions and 12 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# v2.2.0 -Nitro V3 !! Use at Own Risk as it is still in Beta !! # Nitro V3
## Prerequisites ## Prerequisites
+3 -2
View File
@@ -11,8 +11,8 @@
"gamedata.url": "https://nitro.example.com:2096/nitro-sec/file?kind=gamedata&file=", "gamedata.url": "https://nitro.example.com:2096/nitro-sec/file?kind=gamedata&file=",
"sounds.url": "${asset.url}/sounds/%sample%.mp3", "sounds.url": "${asset.url}/sounds/%sample%.mp3",
"external.texts.url": [ "external.texts.url": [
"${gamedata.url}/ExternalTexts.json", "${gamedata.url}/ExternalTexts.json?t=%timestamp%",
"${gamedata.url}/UITexts.json" "${gamedata.url}/UITexts.json?t=%timestamp%"
], ],
"external.texts.translation.url": "${gamedata.url}/text_translate/ExternalTexts_%locale%.json?t=%timestamp%", "external.texts.translation.url": "${gamedata.url}/text_translate/ExternalTexts_%locale%.json?t=%timestamp%",
"external.samples.url": "${hof.furni.url}/mp3/sound_machine_sample_%sample%.mp3", "external.samples.url": "${hof.furni.url}/mp3/sound_machine_sample_%sample%.mp3",
@@ -68,6 +68,7 @@
"badges.custom.update.endpoint": "${api.url}/api/badges/custom/%badgeId%", "badges.custom.update.endpoint": "${api.url}/api/badges/custom/%badgeId%",
"badges.custom.delete.endpoint": "${api.url}/api/badges/custom/%badgeId%", "badges.custom.delete.endpoint": "${api.url}/api/badges/custom/%badgeId%",
"badges.custom.texts.endpoint": "${api.url}/api/badges/custom/texts", "badges.custom.texts.endpoint": "${api.url}/api/badges/custom/texts",
"badges.leaderboard.endpoint": "${api.url}/api/badges/leaderboard",
"login.turnstile.enabled": true, "login.turnstile.enabled": true,
"login.turnstile.sitekey": "1x00000000000000000000AA", "login.turnstile.sitekey": "1x00000000000000000000AA",
"avatar.mandatory.libraries": [ "avatar.mandatory.libraries": [
+170
View File
@@ -0,0 +1,170 @@
import { GetConfiguration } from '@nitrots/nitro-renderer';
import { getAccessToken } from '../auth';
export type BadgeRarityKey = 'common' | 'rare' | 'epic' | 'legendary' | 'mythical' | 'unique';
export interface BadgeLeaderboardStat
{
badgeCode: string;
ownerCount: number;
rarity: BadgeRarityKey;
}
export interface BadgeLeaderboardEntry
{
userId: number;
username: string;
figure: string;
score: number;
rank: number;
}
export interface BadgeLeaderboardBoard
{
entries: BadgeLeaderboardEntry[];
totalPlayers: number;
viewerEntry?: Partial<BadgeLeaderboardEntry>;
}
export interface BadgeLeaderboardResponse
{
viewerUserId: number;
badgeStats: BadgeLeaderboardStat[];
thresholds: {
commonMinOwners: number;
rareMinOwners: number;
epicMinOwners: number;
legendaryMinOwners: number;
mythicalMinOwners: number;
uniqueOwners: number;
};
leaderboards: {
totalBadges: BadgeLeaderboardBoard;
achievementLevel: BadgeLeaderboardBoard;
rarity: Record<BadgeRarityKey, BadgeLeaderboardBoard>;
};
}
const interpolate = (value: string): string =>
{
try { return GetConfiguration().interpolate(value); }
catch { return value; }
};
const getUrl = (): string =>
{
const configured = GetConfiguration().getValue<string>('badges.leaderboard.endpoint', '/api/badges/leaderboard');
return interpolate(configured);
};
const authHeaders = (): Record<string, string> =>
{
const headers: Record<string, string> = {
'Accept': 'application/json',
'X-Requested-With': 'NitroBadgeLeaderboard'
};
const token = getAccessToken();
if(token) headers.Authorization = `Bearer ${ token }`;
return headers;
};
const parseJson = async <T>(response: Response): Promise<T> =>
{
const text = await response.text();
if(!text) return {} as T;
try { return JSON.parse(text) as T; }
catch { throw new Error('Invalid response from badge leaderboard endpoint.'); }
};
const throwOnError = async (response: Response): Promise<void> =>
{
if(response.ok) return;
const payload = await parseJson<{ error?: string }>(response);
const message = payload?.error || `Request failed (${ response.status }).`;
const error = new Error(message) as Error & { status?: number };
error.status = response.status;
throw error;
};
let cachePromise: Promise<BadgeLeaderboardResponse> = null;
let cacheValue: BadgeLeaderboardResponse = null;
const buildStatsMap = (response: BadgeLeaderboardResponse | null): Map<string, BadgeLeaderboardStat> =>
{
const map = new Map<string, BadgeLeaderboardStat>();
if(!response?.badgeStats?.length) return map;
for(const stat of response.badgeStats)
{
if(!stat?.badgeCode) continue;
map.set(stat.badgeCode, stat);
}
return map;
};
let cacheStatsMap: Map<string, BadgeLeaderboardStat> = new Map();
export const fetchBadgeLeaderboard = async (force = false): Promise<BadgeLeaderboardResponse> =>
{
if(!force)
{
if(cacheValue) return cacheValue;
if(cachePromise) return cachePromise;
}
cachePromise = (async () =>
{
const response = await fetch(getUrl(), {
method: 'GET',
credentials: 'include',
headers: authHeaders()
});
await throwOnError(response);
const payload = await parseJson<BadgeLeaderboardResponse>(response);
cacheValue = payload;
cacheStatsMap = buildStatsMap(payload);
return payload;
})();
try
{
return await cachePromise;
}
finally
{
cachePromise = null;
}
};
export const getCachedBadgeLeaderboard = (): BadgeLeaderboardResponse =>
{
return cacheValue;
};
export const getCachedBadgeRarityStat = (badgeCode: string): BadgeLeaderboardStat =>
{
if(!badgeCode) return null;
return cacheStatsMap.get(badgeCode) || null;
};
export const ensureBadgeLeaderboardLoaded = async (): Promise<BadgeLeaderboardResponse> =>
{
return fetchBadgeLeaderboard(false);
};
+1
View File
@@ -1 +1,2 @@
export * from './CustomBadgeApi'; export * from './CustomBadgeApi';
export * from './BadgeLeaderboardApi';
Binary file not shown.

After

Width:  |  Height:  |  Size: 654 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 682 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 871 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 966 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 847 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1016 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,32 @@
export { default as badgeEmblemAchievement } from './badge_emblem_achievement.png';
export { default as badgeEmblemAchievementExtended } from './badge_emblem_achievement_extended.png';
export { default as badgeEmblemCommon } from './badge_emblem_common.png';
export { default as badgeEmblemCommonExtended } from './badge_emblem_common_extended.png';
export { default as badgeEmblemDefault } from './badge_emblem_default.png';
export { default as badgeEmblemEpic } from './badge_emblem_epic.png';
export { default as badgeEmblemEpicExtended } from './badge_emblem_epic_extended.png';
export { default as badgeEmblemLegendary } from './badge_emblem_legendary.png';
export { default as badgeEmblemLegendaryExtended } from './badge_emblem_legendary_extended.png';
export { default as badgeEmblemMythical } from './badge_emblem_mythical.png';
export { default as badgeEmblemMythicalExtended } from './badge_emblem_mythical_extended.png';
export { default as badgeEmblemRare } from './badge_emblem_rare.png';
export { default as badgeEmblemRareExtended } from './badge_emblem_rare_extended.png';
export { default as badgeEmblemUnique } from './badge_emblem_unique.png';
export { default as badgeEmblemUniqueExtended } from './badge_emblem_unique_extended.png';
export { default as frameLeaderboardAchievement } from './frame_leaderboard_achievement.png';
export { default as frameLeaderboardRarityCommon } from './frame_leaderboard_rarity_common.png';
export { default as frameLeaderboardRarityEpic } from './frame_leaderboard_rarity_epic.png';
export { default as frameLeaderboardRarityLegendary } from './frame_leaderboard_rarity_legendary.png';
export { default as frameLeaderboardRarityMythical } from './frame_leaderboard_rarity_mythical.png';
export { default as frameLeaderboardRarityRare } from './frame_leaderboard_rarity_rare.png';
export { default as frameLeaderboardRarityUnique } from './frame_leaderboard_rarity_unique.png';
export { default as frameLeaderboardTotal } from './frame_leaderboard_total.png';
export { default as leaderboardBadgesWhite } from './leaderboard_badges_white.png';
export { default as leaderboardButtonCloseSwf } from './leaderboard_button_close_swf.png';
export { default as leaderboardDropdownOpener } from './leaderboard_dropdown_opener.png';
export { default as leaderboardDivider } from './leaderboard_divider.png';
export { default as leaderboardEntryEven } from './leaderboard_entry_even.png';
export { default as leaderboardEntrySelf } from './leaderboard_entry_self.png';
export { default as leaderboardEntryUneven } from './leaderboard_entry_uneven.png';
export { default as leaderboardHighlighter } from './leaderboard_highlighter.png';
export { default as leaderboardIconProgress } from './leaderboard_icon_progress.png';
Binary file not shown.

After

Width:  |  Height:  |  Size: 283 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 B

@@ -0,0 +1,4 @@
raro > 10
epico > 6
leggendario > 3
unico > 0
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

@@ -30,10 +30,8 @@ export const DraggableWindow: FC<DraggableWindowProps> = props =>
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isPositioned, setIsPositioned] = useState(false); const [isPositioned, setIsPositioned] = useState(false);
const [dragHandler, setDragHandler] = useState<HTMLElement>(null); const [dragHandler, setDragHandler] = useState<HTMLElement>(null);
const elementRef = useRef<HTMLDivElement>(null); const elementRef = useRef<HTMLDivElement>();
const bringToTop = useCallback(() => {
const bringToTop = useCallback(() =>
{
let zIndex = 400; let zIndex = 400;
for (const existingWindow of CURRENT_WINDOWS) for (const existingWindow of CURRENT_WINDOWS)
{ {
+79 -3
View File
@@ -1,7 +1,7 @@
import { BadgeImageReadyEvent, GetEventDispatcher, GetSessionDataManager, NitroSprite, TextureUtils } from '@nitrots/nitro-renderer'; import { BadgeImageReadyEvent, GetEventDispatcher, GetSessionDataManager, NitroSprite, TextureUtils } from '@nitrots/nitro-renderer';
import { CSSProperties, FC, useEffect, useMemo, useRef, useState } from 'react'; import { CSSProperties, FC, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { GetConfigurationValue, LocalizeBadgeDescription, LocalizeBadgeName, LocalizeText } from '../../api'; import { BadgeLeaderboardStat, ensureBadgeLeaderboardLoaded, getCachedBadgeRarityStat, GetConfigurationValue, LocalizeBadgeDescription, LocalizeBadgeName, LocalizeText } from '../../api';
import { Base, BaseProps } from '../Base'; import { Base, BaseProps } from '../Base';
export interface LayoutBadgeImageViewProps extends BaseProps<HTMLDivElement> export interface LayoutBadgeImageViewProps extends BaseProps<HTMLDivElement>
@@ -12,13 +12,25 @@ export interface LayoutBadgeImageViewProps extends BaseProps<HTMLDivElement>
customTitle?: string; customTitle?: string;
isGrayscale?: boolean; isGrayscale?: boolean;
scale?: number; scale?: number;
showRarityInfo?: boolean;
highlightRarity?: boolean;
} }
const BADGE_RARITY_COLORS: Record<string, { glow: string; pillBackground: string; pillBorder: string; pillText: string }> = {
common: { glow: 'rgba(148, 163, 184, 0.55)', pillBackground: 'rgba(71, 85, 105, 0.16)', pillBorder: 'rgba(100, 116, 139, 0.45)', pillText: '#475569' },
rare: { glow: 'rgba(59, 130, 246, 0.7)', pillBackground: 'rgba(59, 130, 246, 0.12)', pillBorder: 'rgba(37, 99, 235, 0.38)', pillText: '#1d4ed8' },
epic: { glow: 'rgba(168, 85, 247, 0.72)', pillBackground: 'rgba(168, 85, 247, 0.14)', pillBorder: 'rgba(147, 51, 234, 0.4)', pillText: '#7e22ce' },
legendary: { glow: 'rgba(249, 115, 22, 0.76)', pillBackground: 'rgba(249, 115, 22, 0.16)', pillBorder: 'rgba(234, 88, 12, 0.4)', pillText: '#c2410c' },
mythical: { glow: 'rgba(236, 72, 153, 0.76)', pillBackground: 'rgba(236, 72, 153, 0.15)', pillBorder: 'rgba(219, 39, 119, 0.4)', pillText: '#be185d' },
unique: { glow: 'rgba(34, 197, 94, 0.76)', pillBackground: 'rgba(34, 197, 94, 0.14)', pillBorder: 'rgba(22, 163, 74, 0.4)', pillText: '#15803d' }
};
export const LayoutBadgeImageView: FC<LayoutBadgeImageViewProps> = props => export const LayoutBadgeImageView: FC<LayoutBadgeImageViewProps> = props =>
{ {
const { badgeCode = null, isGroup = false, showInfo = false, customTitle = null, isGrayscale = false, scale = 1, classNames = [], style = {}, children = null, ...rest } = props; const { badgeCode = null, isGroup = false, showInfo = false, customTitle = null, isGrayscale = false, scale = 1, showRarityInfo = false, highlightRarity = false, classNames = [], style = {}, children = null, ...rest } = props;
const [ imageElement, setImageElement ] = useState<HTMLImageElement>(null); const [ imageElement, setImageElement ] = useState<HTMLImageElement>(null);
const [ tooltipPosition, setTooltipPosition ] = useState<{ top: number; left: number } | null>(null); const [ tooltipPosition, setTooltipPosition ] = useState<{ top: number; left: number } | null>(null);
const [ badgeRarityStat, setBadgeRarityStat ] = useState<BadgeLeaderboardStat>(null);
const badgeRef = useRef<HTMLDivElement>(null); const badgeRef = useRef<HTMLDivElement>(null);
const tooltipsEnabled = showInfo && GetConfigurationValue<boolean>('badge.descriptions.enabled', true); const tooltipsEnabled = showInfo && GetConfigurationValue<boolean>('badge.descriptions.enabled', true);
@@ -73,10 +85,21 @@ export const LayoutBadgeImageView: FC<LayoutBadgeImageViewProps> = props =>
} }
} }
if(highlightRarity && badgeRarityStat)
{
const colors = BADGE_RARITY_COLORS[badgeRarityStat.rarity];
if(colors)
{
newStyle.borderRadius = 8;
newStyle.boxShadow = `0 0 0 1px ${ colors.glow }, 0 0 14px ${ colors.glow }`;
}
}
if(Object.keys(style).length) newStyle = { ...newStyle, ...style }; if(Object.keys(style).length) newStyle = { ...newStyle, ...style };
return newStyle; return newStyle;
}, [ badgeCode, isGroup, imageElement, scale, style ]); }, [ badgeCode, badgeRarityStat, highlightRarity, isGroup, imageElement, scale, style ]);
useEffect(() => useEffect(() =>
{ {
@@ -136,6 +159,46 @@ export const LayoutBadgeImageView: FC<LayoutBadgeImageViewProps> = props =>
return () => GetEventDispatcher().removeEventListener(BadgeImageReadyEvent.IMAGE_READY, onBadgeImageReadyEvent); return () => GetEventDispatcher().removeEventListener(BadgeImageReadyEvent.IMAGE_READY, onBadgeImageReadyEvent);
}, [ badgeCode, isGroup ]); }, [ badgeCode, isGroup ]);
useEffect(() =>
{
if(isGroup || !badgeCode || (!showRarityInfo && !highlightRarity))
{
setBadgeRarityStat(null);
return;
}
const cached = getCachedBadgeRarityStat(badgeCode);
if(cached)
{
setBadgeRarityStat(cached);
return;
}
let cancelled = false;
ensureBadgeLeaderboardLoaded()
.then(() =>
{
if(cancelled) return;
setBadgeRarityStat(getCachedBadgeRarityStat(badgeCode));
})
.catch(() =>
{
if(cancelled) return;
setBadgeRarityStat(null);
});
return () => { cancelled = true; };
}, [ badgeCode, highlightRarity, isGroup, showRarityInfo ]);
const rarityColors = badgeRarityStat ? BADGE_RARITY_COLORS[badgeRarityStat.rarity] : null;
const rarityLabel = badgeRarityStat ? LocalizeText(`badge.rarity.${ badgeRarityStat.rarity }`) : '';
const rarityText = badgeRarityStat ? LocalizeText('badge.rarity.badge', [ 'rarity' ], [ rarityLabel ]) : '';
const ownersText = badgeRarityStat ? LocalizeText('badge.owner_count', [ 'count' ], [ badgeRarityStat.ownerCount.toString() ]) : '';
return ( return (
<Base <Base
innerRef={ badgeRef } innerRef={ badgeRef }
@@ -149,6 +212,19 @@ export const LayoutBadgeImageView: FC<LayoutBadgeImageViewProps> = props =>
className="fixed z-[9999] pointer-events-none select-none w-[210px] rounded-[.25rem] bg-[#fff] text-black py-1 px-2 small" className="fixed z-[9999] pointer-events-none select-none w-[210px] rounded-[.25rem] bg-[#fff] text-black py-1 px-2 small"
style={ { top: tooltipPosition.top, left: tooltipPosition.left } }> style={ { top: tooltipPosition.top, left: tooltipPosition.left } }>
<div className="font-bold mb-1">{ isGroup ? customTitle : LocalizeBadgeName(badgeCode) }</div> <div className="font-bold mb-1">{ isGroup ? customTitle : LocalizeBadgeName(badgeCode) }</div>
{ showRarityInfo && badgeRarityStat &&
<div className="flex flex-col gap-1 mb-1">
<div
className="inline-flex items-center self-start rounded-full px-2 py-[2px] text-[10px] font-bold uppercase tracking-[0.04em]"
style={ {
background: rarityColors.pillBackground,
border: `1px solid ${ rarityColors.pillBorder }`,
color: rarityColors.pillText
} }>
{ rarityText }
</div>
<div className="text-[10px] text-[#5f5f5f]">{ ownersText }</div>
</div> }
<div>{ isGroup ? LocalizeText('group.badgepopup.body') : LocalizeBadgeDescription(badgeCode) }</div> <div>{ isGroup ? LocalizeText('group.badgepopup.body') : LocalizeBadgeDescription(badgeCode) }</div>
</div>, </div>,
document.body document.body
+2
View File
@@ -5,6 +5,7 @@ import { useNitroEventReducer } from '../hooks';
import { AchievementsView } from './achievements/AchievementsView'; import { AchievementsView } from './achievements/AchievementsView';
import { AvatarEditorView } from './avatar-editor'; import { AvatarEditorView } from './avatar-editor';
import { BadgeCreatorView } from './badge-creator'; import { BadgeCreatorView } from './badge-creator';
import { BadgeLeaderboardView } from './badge-leaderboard/BadgeLeaderboardView';
import { AvatarEffectsView } from './avatar-effects'; import { AvatarEffectsView } from './avatar-effects';
import { CameraWidgetView } from './camera/CameraWidgetView'; import { CameraWidgetView } from './camera/CameraWidgetView';
import { CampaignView } from './campaign/CampaignView'; import { CampaignView } from './campaign/CampaignView';
@@ -147,6 +148,7 @@ export const MainView: FC<{}> = props =>
<WiredView /> <WiredView />
<AvatarEditorView /> <AvatarEditorView />
<BadgeCreatorView /> <BadgeCreatorView />
<BadgeLeaderboardView />
<AvatarEffectsView /> <AvatarEffectsView />
<AchievementsView /> <AchievementsView />
<NavigatorView /> <NavigatorView />
@@ -0,0 +1,320 @@
import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { CSSProperties, FC, useEffect, useMemo, useState } from 'react';
import { BadgeLeaderboardBoard, BadgeLeaderboardEntry, BadgeRarityKey, fetchBadgeLeaderboard, getCachedBadgeLeaderboard, LocalizeText } from '../../api';
import { Column, DraggableWindow, DraggableWindowPosition, Flex, Text } from '../../common';
import {
badgeEmblemAchievement,
badgeEmblemCommon,
badgeEmblemDefault,
badgeEmblemEpic,
badgeEmblemLegendary,
badgeEmblemMythical,
badgeEmblemRare,
badgeEmblemUnique,
frameLeaderboardAchievement,
frameLeaderboardRarityCommon,
frameLeaderboardRarityEpic,
frameLeaderboardRarityLegendary,
frameLeaderboardRarityMythical,
frameLeaderboardRarityRare,
frameLeaderboardRarityUnique,
frameLeaderboardTotal,
leaderboardButtonCloseSwf,
leaderboardDivider,
leaderboardDropdownOpener
} from '../../assets/images/leaderboard_badge';
type LeaderboardPage =
| { key: 'totalBadges'; board: BadgeLeaderboardBoard; frame: string; emblem: string; title: () => string; info: () => string; option: () => string; }
| { key: 'achievementLevel'; board: BadgeLeaderboardBoard; frame: string; emblem: string; title: () => string; info: () => string; option: () => string; }
| { key: `rarity-${ BadgeRarityKey }`; rarity: BadgeRarityKey; board: BadgeLeaderboardBoard; frame: string; emblem: string; title: () => string; info: () => string; option: () => string; };
const RARITY_ASSETS: Record<BadgeRarityKey, { frame: string; emblem: string }> = {
common: { frame: frameLeaderboardRarityCommon, emblem: badgeEmblemCommon },
rare: { frame: frameLeaderboardRarityRare, emblem: badgeEmblemRare },
epic: { frame: frameLeaderboardRarityEpic, emblem: badgeEmblemEpic },
legendary: { frame: frameLeaderboardRarityLegendary, emblem: badgeEmblemLegendary },
mythical: { frame: frameLeaderboardRarityMythical, emblem: badgeEmblemMythical },
unique: { frame: frameLeaderboardRarityUnique, emblem: badgeEmblemUnique }
};
const RARITY_ORDER: BadgeRarityKey[] = [ 'common', 'rare', 'epic', 'legendary', 'mythical', 'unique' ];
const PAGE_SIZE = 10;
const getAvatarHeadUrl = (figure: string): string =>
{
if(!figure) return '';
return `https://www.habbo.com/habbo-imaging/avatarimage?figure=${ encodeURIComponent(figure) }&direction=2&head_direction=2&gesture=sml&size=m&headonly=1`;
};
export const BadgeLeaderboardView: FC<{}> = props =>
{
const [ isVisible, setIsVisible ] = useState(false);
const [ isLoading, setIsLoading ] = useState(false);
const [ loadError, setLoadError ] = useState<string>(null);
const [ version, setVersion ] = useState(0);
const [ categoryIndex, setCategoryIndex ] = useState(0);
const [ entryPageIndex, setEntryPageIndex ] = useState(0);
const [ isCategoryMenuVisible, setIsCategoryMenuVisible ] = useState(false);
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(value => !value);
return;
case 'refresh':
setVersion(value => value + 1);
return;
}
},
eventUrlPrefix: 'badge-leaderboard/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
useEffect(() =>
{
if(!isVisible) return;
let cancelled = false;
setIsLoading(true);
setLoadError(null);
fetchBadgeLeaderboard(version > 0)
.then(() =>
{
if(cancelled) return;
setIsLoading(false);
})
.catch(error =>
{
if(cancelled) return;
setLoadError(String((error as Error)?.message || error));
setIsLoading(false);
});
return () => { cancelled = true; };
}, [ isVisible, version ]);
const leaderboard = getCachedBadgeLeaderboard();
const pages = useMemo<LeaderboardPage[]>(() =>
{
if(!leaderboard) return [];
const built: LeaderboardPage[] = [
{
key: 'totalBadges',
board: leaderboard.leaderboards.totalBadges,
frame: frameLeaderboardTotal,
emblem: badgeEmblemDefault,
title: () => LocalizeText('badge_leaderboard.title.total_badges'),
info: () => LocalizeText('badge_leaderboard.info.total_badges'),
option: () => LocalizeText('badge_leaderboard.option.total_badges')
},
{
key: 'achievementLevel',
board: leaderboard.leaderboards.achievementLevel,
frame: frameLeaderboardAchievement,
emblem: badgeEmblemAchievement,
title: () => LocalizeText('badge_leaderboard.title.achievement_level'),
info: () => LocalizeText('badge_leaderboard.info.achievement_level'),
option: () => LocalizeText('badge_leaderboard.option.achievement_level')
}
];
for(const rarity of RARITY_ORDER)
{
const board = leaderboard.leaderboards.rarity?.[rarity];
if(!board?.totalPlayers) continue;
const assets = RARITY_ASSETS[rarity];
const rarityText = LocalizeText(`badge.rarity.${ rarity }`);
built.push({
key: `rarity-${ rarity }`,
rarity,
board,
frame: assets.frame,
emblem: assets.emblem,
title: () => LocalizeText('badge_leaderboard.title.rarity', [ 'rarity' ], [ rarityText ]),
info: () => LocalizeText(`badge_leaderboard.info.rarity.${ rarity }`),
option: () => LocalizeText('badge_leaderboard.option.rarity', [ 'rarity' ], [ rarityText ])
});
}
return built;
}, [ leaderboard ]);
useEffect(() =>
{
if(!pages.length) return;
if(categoryIndex < pages.length) return;
setCategoryIndex(0);
}, [ categoryIndex, pages.length ]);
useEffect(() => setEntryPageIndex(0), [ categoryIndex ]);
useEffect(() =>
{
if(!isCategoryMenuVisible) return;
const onWindowPointerDown = () => setIsCategoryMenuVisible(false);
window.addEventListener('pointerdown', onWindowPointerDown);
return () => window.removeEventListener('pointerdown', onWindowPointerDown);
}, [ isCategoryMenuVisible ]);
const currentPage = pages[categoryIndex] || null;
const allEntries = currentPage?.board?.entries || [];
const viewerEntry = useMemo(() =>
{
const fromBoard = currentPage?.board?.viewerEntry as BadgeLeaderboardEntry;
if(fromBoard?.userId) return fromBoard;
if(!leaderboard?.viewerUserId) return null;
const session = GetSessionDataManager();
if(!session || session.userId !== leaderboard.viewerUserId) return null;
return {
userId: session.userId,
username: session.userName || '',
figure: session.figure || '',
score: 0,
rank: 0
} as BadgeLeaderboardEntry;
}, [ currentPage?.board?.viewerEntry, leaderboard?.viewerUserId ]);
const rankedEntries = useMemo(() =>
{
if(!allEntries.length) return [];
if(!viewerEntry?.userId) return allEntries;
return allEntries.filter(entry => entry.userId !== viewerEntry.userId);
}, [ allEntries, viewerEntry?.userId ]);
const viewerHasRankedScore = ((viewerEntry?.rank || 0) > 0);
const rankedTotalPlayers = Math.max((currentPage?.board?.totalPlayers || 0) - (viewerHasRankedScore ? 1 : 0), rankedEntries.length);
const totalEntryPages = Math.max(1, Math.ceil(rankedTotalPlayers / PAGE_SIZE));
const clampedEntryPageIndex = Math.min(entryPageIndex, totalEntryPages - 1);
const pageStart = clampedEntryPageIndex * PAGE_SIZE;
const pageEntries = rankedEntries.slice(pageStart, pageStart + PAGE_SIZE);
const showViewerEntry = !!viewerEntry?.userId;
if(!isVisible) return null;
return (
<div className="nitro-badge-leaderboard fixed inset-0 z-[100] flex items-center justify-center pointer-events-none">
<DraggableWindow uniqueKey="badge-leaderboard" handleSelector=".nitro-badge-leaderboard__drag-handle" windowPosition={ DraggableWindowPosition.CENTER }>
<div className="nitro-badge-leaderboard__window pointer-events-auto" style={ { '--badge-leaderboard-frame': `url(${ currentPage?.frame || frameLeaderboardTotal })` } as CSSProperties }>
<div className="nitro-badge-leaderboard__frame" aria-hidden="true" />
<div className="nitro-badge-leaderboard__drag-handle" />
<button className="nitro-badge-leaderboard__close" type="button" onPointerDown={ event => event.stopPropagation() } onClick={ () => setIsVisible(false) } aria-label="Close">
<span className="nitro-badge-leaderboard__close-icon" style={ { backgroundImage: `url(${ leaderboardButtonCloseSwf })` } } />
</button>
<div className="nitro-badge-leaderboard__header">
<button className="nitro-badge-leaderboard__category-button" type="button" onPointerDown={ event => event.stopPropagation() } onClick={ () => setIsCategoryMenuVisible(value => !value) }>
<Text className="nitro-badge-leaderboard__header-title">{ currentPage?.title() || LocalizeText('badge_leaderboard.title.total_badges') }</Text>
<img className="nitro-badge-leaderboard__header-arrow" src={ leaderboardDropdownOpener } alt="" />
</button>
{ isCategoryMenuVisible &&
<div className="nitro-badge-leaderboard__category-menu" onPointerDown={ event => event.stopPropagation() }>
{ pages.map((page, index) => (
<button key={ page.key } className={ `nitro-badge-leaderboard__category-option ${ index === categoryIndex ? 'is-active' : '' }` } type="button" onClick={ () => { setCategoryIndex(index); setIsCategoryMenuVisible(false); } }>
{ page.option() }
</button>
)) }
</div> }
</div>
<div className="nitro-badge-leaderboard__content">
{ isLoading && !leaderboard &&
<div className="nitro-badge-leaderboard__state">
{ LocalizeText('generic.loading') }
</div> }
{ loadError && !leaderboard &&
<div className="nitro-badge-leaderboard__state nitro-badge-leaderboard__state--error">
{ loadError }
</div> }
{ currentPage &&
<>
<div className="nitro-badge-leaderboard__info-card">
<img className="nitro-badge-leaderboard__info-icon" src={ currentPage.emblem } alt="" />
<Text className="nitro-badge-leaderboard__info-text" small wrap>{ currentPage.info() }</Text>
</div>
<div className="nitro-badge-leaderboard__list">
{ pageEntries.map((entry, index) => <LeaderboardRow key={ `${ currentPage.key }-${ entry.userId }` } entry={ entry } emblem={ currentPage.emblem } rowIndex={ pageStart + index } isCurrentUser={ false } />) }
{ showViewerEntry && <LeaderboardRow entry={ viewerEntry } emblem={ currentPage.emblem } rowIndex={ pageEntries.length } isCurrentUser={ true } />}
</div>
<img className="nitro-badge-leaderboard__divider" src={ leaderboardDivider } alt="" />
<Flex className="nitro-badge-leaderboard__footer" justifyContent="between" alignItems="center">
<button className="nitro-badge-leaderboard__nav-button is-previous" disabled={ clampedEntryPageIndex <= 0 } onClick={ () => setEntryPageIndex(value => Math.max(0, value - 1)) }>
{ LocalizeText('badge_leaderboard.previous') }
</button>
<Column gap={ 0 } alignItems="center">
<Text small bold>{ currentPage.option() }</Text>
<Text className="opacity-70" small>{ `${ clampedEntryPageIndex + 1 } / ${ totalEntryPages }` }</Text>
</Column>
<button className="nitro-badge-leaderboard__nav-button is-next" disabled={ clampedEntryPageIndex >= (totalEntryPages - 1) } onClick={ () => setEntryPageIndex(value => Math.min(totalEntryPages - 1, value + 1)) }>
{ LocalizeText('badge_leaderboard.next') }
</button>
</Flex>
</> }
</div>
</div>
</DraggableWindow>
</div>
);
};
interface LeaderboardRowProps
{
entry: BadgeLeaderboardEntry;
emblem: string;
rowIndex?: number;
isCurrentUser?: boolean;
}
const LeaderboardRow: FC<LeaderboardRowProps> = props =>
{
const { entry = null, emblem = null, rowIndex = 0, isCurrentUser = false } = props;
if(!entry) return null;
const rankClassName = ((entry.rank === 1) ? 'is-rank-1' : ((entry.rank === 2) ? 'is-rank-2' : ((entry.rank === 3) ? 'is-rank-3' : '')));
return (
<div className={ `nitro-badge-leaderboard__row ${ isCurrentUser ? 'is-current-user' : '' } ${ ((rowIndex % 2) === 0) ? 'is-even' : 'is-odd' }` }>
<div className={ `nitro-badge-leaderboard__rank ${ rankClassName }` }>{ entry.rank }</div>
<div className="nitro-badge-leaderboard__avatar">
<img className="nitro-badge-leaderboard__avatar-image" src={ getAvatarHeadUrl(entry.figure) } alt="" loading="lazy" />
</div>
<Text className="nitro-badge-leaderboard__username" bold>{ entry.username }</Text>
<Text className="nitro-badge-leaderboard__score" bold>{ entry.score }</Text>
<img className="nitro-badge-leaderboard__row-emblem" src={ emblem } alt="" />
</div>
);
};
@@ -205,7 +205,7 @@ export const InfoStandBadgeSlotView: FC<InfoStandBadgeSlotProps> = ({ slotIndex,
onClick={ handleSlotClick } onClick={ handleSlotClick }
onDoubleClick={ handleDoubleClick }> onDoubleClick={ handleDoubleClick }>
{ badgeCode { badgeCode
? <LayoutBadgeImageView badgeCode={ badgeCode } showInfo={ true } /> ? <LayoutBadgeImageView badgeCode={ badgeCode } showInfo={ true } showRarityInfo={ true } highlightRarity={ true } />
: isOwnUser && <FaPlus className="text-white/30 text-[10px]" /> } : isOwnUser && <FaPlus className="text-white/30 text-[10px]" /> }
</div> </div>
{ showPicker && ( { showPicker && (
@@ -61,6 +61,9 @@ export const AvatarInfoWidgetOwnAvatarView: FC<AvatarInfoWidgetOwnAvatarViewProp
case 'customize_nick': case 'customize_nick':
CreateLinkEvent('customize/show'); CreateLinkEvent('customize/show');
break; break;
case 'badge_leaderboard':
CreateLinkEvent('badge-leaderboard/show');
break;
case 'expressions': case 'expressions':
hideMenu = false; hideMenu = false;
setMode(MODE_EXPRESSIONS); setMode(MODE_EXPRESSIONS);
@@ -149,6 +152,9 @@ export const AvatarInfoWidgetOwnAvatarView: FC<AvatarInfoWidgetOwnAvatarViewProp
<ContextMenuListItemView onClick={ event => processAction('customize_nick') }> <ContextMenuListItemView onClick={ event => processAction('customize_nick') }>
Nick Custom Nick Custom
</ContextMenuListItemView> </ContextMenuListItemView>
<ContextMenuListItemView onClick={ event => processAction('badge_leaderboard') }>
{ LocalizeText('badge_leaderboard.title.total_badges') }
</ContextMenuListItemView>
{ (HasHabboClub() && !isRidingHorse) && { (HasHabboClub() && !isRidingHorse) &&
<ContextMenuListItemView onClick={ event => processAction('dance_menu') }> <ContextMenuListItemView onClick={ event => processAction('dance_menu') }>
<FaChevronRight className="right fa-icon" /> <FaChevronRight className="right fa-icon" />
+350
View File
@@ -0,0 +1,350 @@
.nitro-badge-leaderboard__window {
position: relative;
width: 460px;
height: 679px;
overflow: hidden;
image-rendering: pixelated;
filter: drop-shadow(0 18px 32px rgba(0, 0, 0, 0.45));
}
.nitro-badge-leaderboard__drag-handle {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 90px;
z-index: 1;
cursor: move;
}
.nitro-badge-leaderboard__frame {
position: absolute;
inset: 0;
border-style: solid;
border-width: 87px 96px 42px 96px;
border-image-source: var(--badge-leaderboard-frame);
border-image-slice: 87 96 42 96;
border-image-width: 87px 96px 42px 96px;
border-image-repeat: round;
background: #ece8dc;
background-clip: padding-box;
box-sizing: border-box;
pointer-events: none;
}
.nitro-badge-leaderboard__frame::after {
content: '';
position: absolute;
inset: 86px 95px 41px 95px;
background: #ece8dc;
}
.nitro-badge-leaderboard__close {
position: absolute;
top: 12px;
right: 108px;
width: 16px;
height: 16px;
border: 0;
padding: 0;
background: transparent;
cursor: pointer;
z-index: 5;
}
.nitro-badge-leaderboard__close-icon {
display: block;
width: 16px;
height: 16px;
background-repeat: no-repeat;
background-position: 0 0;
image-rendering: pixelated;
}
.nitro-badge-leaderboard__header {
position: absolute;
top: 7px;
left: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
z-index: 4;
}
.nitro-badge-leaderboard__category-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-width: 120px;
max-width: 220px;
border: 0;
padding: 0;
background: transparent;
cursor: pointer;
}
.nitro-badge-leaderboard__header-title {
color: #ffffff;
font-size: 28px;
font-weight: 700;
line-height: 1;
text-shadow:
0 1px 0 #4a4a4a,
1px 0 0 #4a4a4a,
-1px 0 0 #4a4a4a,
0 -1px 0 #4a4a4a;
}
.nitro-badge-leaderboard__header-arrow {
width: auto;
height: auto;
margin-top: 3px;
image-rendering: pixelated;
}
.nitro-badge-leaderboard__category-menu {
position: absolute;
top: calc(100% - 2px);
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
width: 154px;
padding: 2px;
border: 1px solid #8f8f8f;
border-radius: 6px;
background: #ffffff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
z-index: 6;
}
.nitro-badge-leaderboard__category-option {
border: 0;
padding: 5px 9px;
background: transparent;
color: #232323;
font-size: 14px;
line-height: 1.15;
text-align: left;
cursor: pointer;
border-radius: 4px;
}
.nitro-badge-leaderboard__category-option:hover,
.nitro-badge-leaderboard__category-option.is-active {
background: rgba(117, 170, 207, 0.28);
}
.nitro-badge-leaderboard__content {
position: absolute;
inset: 80px 38px 30px 38px;
display: flex;
flex-direction: column;
gap: 8px;
color: #1f1f1f;
z-index: 2;
}
.nitro-badge-leaderboard__state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 18px;
color: #3d3d3d;
}
.nitro-badge-leaderboard__state--error {
color: #9f2b2b;
}
.nitro-badge-leaderboard__info-card {
display: flex;
align-items: center;
gap: 10px;
min-height: 52px;
padding: 8px 12px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(231, 229, 221, 0.96));
border: 1px solid #a4a094;
border-radius: 10px;
box-shadow: inset 0 1px 0 #ffffff;
}
.nitro-badge-leaderboard__info-icon {
width: auto;
height: auto;
max-width: 25px;
max-height: 25px;
image-rendering: pixelated;
flex: none;
}
.nitro-badge-leaderboard__info-text {
color: #3b3b3b;
font-size: 15px;
line-height: 1.2;
}
.nitro-badge-leaderboard__list {
display: flex;
flex-direction: column;
gap: 3px;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.nitro-badge-leaderboard__row {
display: grid;
grid-template-columns: 34px 54px minmax(0, 1fr) 56px 28px;
align-items: center;
gap: 6px;
min-height: 36px;
padding: 0 10px 0 8px;
background-repeat: no-repeat;
background-position: center;
background-size: 100% 100%;
}
.nitro-badge-leaderboard__row.is-even {
background-image: url('../../assets/images/leaderboard_badge/leaderboard_entry_even.png');
}
.nitro-badge-leaderboard__row.is-odd {
background-image: url('../../assets/images/leaderboard_badge/leaderboard_entry_uneven.png');
}
.nitro-badge-leaderboard__row.is-current-user {
min-height: 40px;
background-image: url('../../assets/images/leaderboard_badge/leaderboard_entry_self.png');
}
.nitro-badge-leaderboard__rank {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
background: linear-gradient(180deg, #6a8db7 0%, #4d6788 100%);
border: 1px solid #546b87;
color: #ffffff;
font-size: 16px;
font-weight: 700;
text-shadow: 0 1px 0 #31455d;
}
.nitro-badge-leaderboard__rank.is-rank-1 {
background: linear-gradient(180deg, #efcf58 0%, #c28e1f 100%);
border-color: #af8325;
text-shadow: 0 1px 0 #7f6020;
}
.nitro-badge-leaderboard__rank.is-rank-2 {
background: linear-gradient(180deg, #d4d4d4 0%, #9d9d9d 100%);
border-color: #8e8e8e;
text-shadow: 0 1px 0 #666666;
}
.nitro-badge-leaderboard__rank.is-rank-3 {
background: linear-gradient(180deg, #db9c4f 0%, #a56521 100%);
border-color: #9a5c1c;
text-shadow: 0 1px 0 #784312;
}
.nitro-badge-leaderboard__avatar {
width: 54px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
overflow: visible;
margin: 0 auto;
align-self: center;
}
.nitro-badge-leaderboard__avatar .avatar-image,
.nitro-badge-leaderboard__avatar-image {
display: block;
width: auto !important;
height: auto !important;
max-width: 54px;
max-height: 62px;
left: auto !important;
right: auto !important;
top: auto !important;
bottom: auto !important;
margin: 0 auto;
image-rendering: pixelated;
object-fit: contain;
}
.nitro-badge-leaderboard__username {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #202020;
font-size: 15px;
line-height: 1;
}
.nitro-badge-leaderboard__score {
width: 56px;
color: #1f1f1f;
font-size: 15px;
line-height: 1;
text-align: right;
padding-inline-start: 0;
}
.nitro-badge-leaderboard__row-emblem {
display: block;
width: auto !important;
height: auto !important;
max-width: 25px;
max-height: 25px;
margin: 0 auto;
image-rendering: pixelated;
object-fit: contain;
}
.nitro-badge-leaderboard__divider {
width: auto;
height: auto;
max-width: 100%;
max-height: 2px;
image-rendering: pixelated;
}
.nitro-badge-leaderboard__footer {
gap: 10px;
margin-top: auto;
}
.nitro-badge-leaderboard__nav-button {
min-width: 100px;
height: 26px;
border: 1px solid #8a8a8a;
border-radius: 4px;
background: linear-gradient(180deg, #fefefe 0%, #e8e8e6 100%);
color: #1f1f1f;
font-size: 15px;
cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.95);
}
.nitro-badge-leaderboard__nav-button:hover:not(:disabled) {
background: linear-gradient(180deg, #ffffff 0%, #ece8dc 100%);
}
.nitro-badge-leaderboard__nav-button:disabled {
color: #8d8d8d;
background: linear-gradient(180deg, #ececec 0%, #d9d9d9 100%);
cursor: default;
}
+1
View File
@@ -18,6 +18,7 @@ const queryClient = new QueryClient({
import './css/index.css'; import './css/index.css';
import './css/backgrounds/BackgroundsView.css'; import './css/backgrounds/BackgroundsView.css';
import './css/badges/BadgeLeaderboardView.css';
import './css/chat/Chats.css'; import './css/chat/Chats.css';