diff --git a/README.md b/README.md index cbfe365..dbcd724 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# v2.2.0 -Nitro V3 !! Use at Own Risk as it is still in Beta !! +# Nitro V3 ## Prerequisites diff --git a/public/configuration/renderer-config.example b/public/configuration/renderer-config.example index 6c928da..fff74ab 100644 --- a/public/configuration/renderer-config.example +++ b/public/configuration/renderer-config.example @@ -11,8 +11,8 @@ "gamedata.url": "https://nitro.example.com:2096/nitro-sec/file?kind=gamedata&file=", "sounds.url": "${asset.url}/sounds/%sample%.mp3", "external.texts.url": [ - "${gamedata.url}/ExternalTexts.json", - "${gamedata.url}/UITexts.json" + "${gamedata.url}/ExternalTexts.json?t=%timestamp%", + "${gamedata.url}/UITexts.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", @@ -68,6 +68,7 @@ "badges.custom.update.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.leaderboard.endpoint": "${api.url}/api/badges/leaderboard", "login.turnstile.enabled": true, "login.turnstile.sitekey": "1x00000000000000000000AA", "avatar.mandatory.libraries": [ @@ -612,4 +613,4 @@ "${images.url}/clear_icon.png", "${images.url}/big_arrow.png" ] -} \ No newline at end of file +} diff --git a/src/api/badges/BadgeLeaderboardApi.ts b/src/api/badges/BadgeLeaderboardApi.ts new file mode 100644 index 0000000..e0fb5bb --- /dev/null +++ b/src/api/badges/BadgeLeaderboardApi.ts @@ -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; +} + +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; + }; +} + +const interpolate = (value: string): string => +{ + try { return GetConfiguration().interpolate(value); } + catch { return value; } +}; + +const getUrl = (): string => +{ + const configured = GetConfiguration().getValue('badges.leaderboard.endpoint', '/api/badges/leaderboard'); + + return interpolate(configured); +}; + +const authHeaders = (): Record => +{ + const headers: Record = { + 'Accept': 'application/json', + 'X-Requested-With': 'NitroBadgeLeaderboard' + }; + + const token = getAccessToken(); + + if(token) headers.Authorization = `Bearer ${ token }`; + + return headers; +}; + +const parseJson = async (response: Response): Promise => +{ + 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 => +{ + 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 = null; +let cacheValue: BadgeLeaderboardResponse = null; + +const buildStatsMap = (response: BadgeLeaderboardResponse | null): Map => +{ + const map = new Map(); + + 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 = new Map(); + +export const fetchBadgeLeaderboard = async (force = false): Promise => +{ + 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(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 => +{ + return fetchBadgeLeaderboard(false); +}; diff --git a/src/api/badges/index.ts b/src/api/badges/index.ts index 75e2cd1..7144507 100644 --- a/src/api/badges/index.ts +++ b/src/api/badges/index.ts @@ -1 +1,2 @@ export * from './CustomBadgeApi'; +export * from './BadgeLeaderboardApi'; diff --git a/src/assets/images/leaderboard_badge/badge_emblem_achievement.png b/src/assets/images/leaderboard_badge/badge_emblem_achievement.png new file mode 100644 index 0000000..0733dd3 Binary files /dev/null and b/src/assets/images/leaderboard_badge/badge_emblem_achievement.png differ diff --git a/src/assets/images/leaderboard_badge/badge_emblem_achievement_extended.png b/src/assets/images/leaderboard_badge/badge_emblem_achievement_extended.png new file mode 100644 index 0000000..6ba7860 Binary files /dev/null and b/src/assets/images/leaderboard_badge/badge_emblem_achievement_extended.png differ diff --git a/src/assets/images/leaderboard_badge/badge_emblem_common.png b/src/assets/images/leaderboard_badge/badge_emblem_common.png new file mode 100644 index 0000000..acb7504 Binary files /dev/null and b/src/assets/images/leaderboard_badge/badge_emblem_common.png differ diff --git a/src/assets/images/leaderboard_badge/badge_emblem_common_extended.png b/src/assets/images/leaderboard_badge/badge_emblem_common_extended.png new file mode 100644 index 0000000..40425aa Binary files /dev/null and b/src/assets/images/leaderboard_badge/badge_emblem_common_extended.png differ diff --git a/src/assets/images/leaderboard_badge/badge_emblem_default.png b/src/assets/images/leaderboard_badge/badge_emblem_default.png new file mode 100644 index 0000000..bfe87a5 Binary files /dev/null and b/src/assets/images/leaderboard_badge/badge_emblem_default.png differ diff --git a/src/assets/images/leaderboard_badge/badge_emblem_epic.png b/src/assets/images/leaderboard_badge/badge_emblem_epic.png new file mode 100644 index 0000000..8c1e5eb Binary files /dev/null and b/src/assets/images/leaderboard_badge/badge_emblem_epic.png differ diff --git a/src/assets/images/leaderboard_badge/badge_emblem_epic_extended.png b/src/assets/images/leaderboard_badge/badge_emblem_epic_extended.png new file mode 100644 index 0000000..a2bde6f Binary files /dev/null and b/src/assets/images/leaderboard_badge/badge_emblem_epic_extended.png differ diff --git a/src/assets/images/leaderboard_badge/badge_emblem_legendary.png b/src/assets/images/leaderboard_badge/badge_emblem_legendary.png new file mode 100644 index 0000000..eb71305 Binary files /dev/null and b/src/assets/images/leaderboard_badge/badge_emblem_legendary.png differ diff --git a/src/assets/images/leaderboard_badge/badge_emblem_legendary_extended.png b/src/assets/images/leaderboard_badge/badge_emblem_legendary_extended.png new file mode 100644 index 0000000..451f693 Binary files /dev/null and b/src/assets/images/leaderboard_badge/badge_emblem_legendary_extended.png differ diff --git a/src/assets/images/leaderboard_badge/badge_emblem_mythical.png b/src/assets/images/leaderboard_badge/badge_emblem_mythical.png new file mode 100644 index 0000000..fe7cae0 Binary files /dev/null and b/src/assets/images/leaderboard_badge/badge_emblem_mythical.png differ diff --git a/src/assets/images/leaderboard_badge/badge_emblem_mythical_extended.png b/src/assets/images/leaderboard_badge/badge_emblem_mythical_extended.png new file mode 100644 index 0000000..632f961 Binary files /dev/null and b/src/assets/images/leaderboard_badge/badge_emblem_mythical_extended.png differ diff --git a/src/assets/images/leaderboard_badge/badge_emblem_rare.png b/src/assets/images/leaderboard_badge/badge_emblem_rare.png new file mode 100644 index 0000000..3b16ec3 Binary files /dev/null and b/src/assets/images/leaderboard_badge/badge_emblem_rare.png differ diff --git a/src/assets/images/leaderboard_badge/badge_emblem_rare_extended.png b/src/assets/images/leaderboard_badge/badge_emblem_rare_extended.png new file mode 100644 index 0000000..2b85c96 Binary files /dev/null and b/src/assets/images/leaderboard_badge/badge_emblem_rare_extended.png differ diff --git a/src/assets/images/leaderboard_badge/badge_emblem_unique.png b/src/assets/images/leaderboard_badge/badge_emblem_unique.png new file mode 100644 index 0000000..b6cf7e8 Binary files /dev/null and b/src/assets/images/leaderboard_badge/badge_emblem_unique.png differ diff --git a/src/assets/images/leaderboard_badge/badge_emblem_unique_extended.png b/src/assets/images/leaderboard_badge/badge_emblem_unique_extended.png new file mode 100644 index 0000000..c87660b Binary files /dev/null and b/src/assets/images/leaderboard_badge/badge_emblem_unique_extended.png differ diff --git a/src/assets/images/leaderboard_badge/frame_leaderboard_achievement.png b/src/assets/images/leaderboard_badge/frame_leaderboard_achievement.png new file mode 100644 index 0000000..2dfff9a Binary files /dev/null and b/src/assets/images/leaderboard_badge/frame_leaderboard_achievement.png differ diff --git a/src/assets/images/leaderboard_badge/frame_leaderboard_rarity_common.png b/src/assets/images/leaderboard_badge/frame_leaderboard_rarity_common.png new file mode 100644 index 0000000..ece61b8 Binary files /dev/null and b/src/assets/images/leaderboard_badge/frame_leaderboard_rarity_common.png differ diff --git a/src/assets/images/leaderboard_badge/frame_leaderboard_rarity_epic.png b/src/assets/images/leaderboard_badge/frame_leaderboard_rarity_epic.png new file mode 100644 index 0000000..11fbc46 Binary files /dev/null and b/src/assets/images/leaderboard_badge/frame_leaderboard_rarity_epic.png differ diff --git a/src/assets/images/leaderboard_badge/frame_leaderboard_rarity_legendary.png b/src/assets/images/leaderboard_badge/frame_leaderboard_rarity_legendary.png new file mode 100644 index 0000000..59c8120 Binary files /dev/null and b/src/assets/images/leaderboard_badge/frame_leaderboard_rarity_legendary.png differ diff --git a/src/assets/images/leaderboard_badge/frame_leaderboard_rarity_mythical.png b/src/assets/images/leaderboard_badge/frame_leaderboard_rarity_mythical.png new file mode 100644 index 0000000..b016468 Binary files /dev/null and b/src/assets/images/leaderboard_badge/frame_leaderboard_rarity_mythical.png differ diff --git a/src/assets/images/leaderboard_badge/frame_leaderboard_rarity_rare.png b/src/assets/images/leaderboard_badge/frame_leaderboard_rarity_rare.png new file mode 100644 index 0000000..d45a5b0 Binary files /dev/null and b/src/assets/images/leaderboard_badge/frame_leaderboard_rarity_rare.png differ diff --git a/src/assets/images/leaderboard_badge/frame_leaderboard_rarity_unique.png b/src/assets/images/leaderboard_badge/frame_leaderboard_rarity_unique.png new file mode 100644 index 0000000..909afe1 Binary files /dev/null and b/src/assets/images/leaderboard_badge/frame_leaderboard_rarity_unique.png differ diff --git a/src/assets/images/leaderboard_badge/frame_leaderboard_total.png b/src/assets/images/leaderboard_badge/frame_leaderboard_total.png new file mode 100644 index 0000000..2dcec1d Binary files /dev/null and b/src/assets/images/leaderboard_badge/frame_leaderboard_total.png differ diff --git a/src/assets/images/leaderboard_badge/index.ts b/src/assets/images/leaderboard_badge/index.ts new file mode 100644 index 0000000..a5dddb1 --- /dev/null +++ b/src/assets/images/leaderboard_badge/index.ts @@ -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'; diff --git a/src/assets/images/leaderboard_badge/leaderboard_badges_white.png b/src/assets/images/leaderboard_badge/leaderboard_badges_white.png new file mode 100644 index 0000000..fd41604 Binary files /dev/null and b/src/assets/images/leaderboard_badge/leaderboard_badges_white.png differ diff --git a/src/assets/images/leaderboard_badge/leaderboard_button_close.png b/src/assets/images/leaderboard_badge/leaderboard_button_close.png new file mode 100644 index 0000000..984a14b Binary files /dev/null and b/src/assets/images/leaderboard_badge/leaderboard_button_close.png differ diff --git a/src/assets/images/leaderboard_badge/leaderboard_button_close_swf.png b/src/assets/images/leaderboard_badge/leaderboard_button_close_swf.png new file mode 100644 index 0000000..984a14b Binary files /dev/null and b/src/assets/images/leaderboard_badge/leaderboard_button_close_swf.png differ diff --git a/src/assets/images/leaderboard_badge/leaderboard_divider.png b/src/assets/images/leaderboard_badge/leaderboard_divider.png new file mode 100644 index 0000000..beda1be Binary files /dev/null and b/src/assets/images/leaderboard_badge/leaderboard_divider.png differ diff --git a/src/assets/images/leaderboard_badge/leaderboard_dropdown_opener.png b/src/assets/images/leaderboard_badge/leaderboard_dropdown_opener.png new file mode 100644 index 0000000..d3b55e1 Binary files /dev/null and b/src/assets/images/leaderboard_badge/leaderboard_dropdown_opener.png differ diff --git a/src/assets/images/leaderboard_badge/leaderboard_entry_even.png b/src/assets/images/leaderboard_badge/leaderboard_entry_even.png new file mode 100644 index 0000000..e68fb7f Binary files /dev/null and b/src/assets/images/leaderboard_badge/leaderboard_entry_even.png differ diff --git a/src/assets/images/leaderboard_badge/leaderboard_entry_self.png b/src/assets/images/leaderboard_badge/leaderboard_entry_self.png new file mode 100644 index 0000000..9dd1a4f Binary files /dev/null and b/src/assets/images/leaderboard_badge/leaderboard_entry_self.png differ diff --git a/src/assets/images/leaderboard_badge/leaderboard_entry_uneven.png b/src/assets/images/leaderboard_badge/leaderboard_entry_uneven.png new file mode 100644 index 0000000..91e8b02 Binary files /dev/null and b/src/assets/images/leaderboard_badge/leaderboard_entry_uneven.png differ diff --git a/src/assets/images/leaderboard_badge/leaderboard_highlighter.png b/src/assets/images/leaderboard_badge/leaderboard_highlighter.png new file mode 100644 index 0000000..5663e4b Binary files /dev/null and b/src/assets/images/leaderboard_badge/leaderboard_highlighter.png differ diff --git a/src/assets/images/leaderboard_badge/leaderboard_icon_progress.png b/src/assets/images/leaderboard_badge/leaderboard_icon_progress.png new file mode 100644 index 0000000..bfe87a5 Binary files /dev/null and b/src/assets/images/leaderboard_badge/leaderboard_icon_progress.png differ diff --git a/src/assets/images/leaderboard_badge/rarity-thresholds.txt b/src/assets/images/leaderboard_badge/rarity-thresholds.txt new file mode 100644 index 0000000..f47c312 --- /dev/null +++ b/src/assets/images/leaderboard_badge/rarity-thresholds.txt @@ -0,0 +1,4 @@ +raro > 10 +epico > 6 +leggendario > 3 +unico > 0 \ No newline at end of file diff --git a/src/assets/images/leaderboard_badge/reference-final.png b/src/assets/images/leaderboard_badge/reference-final.png new file mode 100644 index 0000000..8afe74e Binary files /dev/null and b/src/assets/images/leaderboard_badge/reference-final.png differ diff --git a/src/common/draggable-window/DraggableWindow.tsx b/src/common/draggable-window/DraggableWindow.tsx index f4af261..e3b8695 100644 --- a/src/common/draggable-window/DraggableWindow.tsx +++ b/src/common/draggable-window/DraggableWindow.tsx @@ -30,10 +30,8 @@ export const DraggableWindow: FC = props => const [isDragging, setIsDragging] = useState(false); const [isPositioned, setIsPositioned] = useState(false); const [dragHandler, setDragHandler] = useState(null); - const elementRef = useRef(null); - - const bringToTop = useCallback(() => - { + const elementRef = useRef(); + const bringToTop = useCallback(() => { let zIndex = 400; for (const existingWindow of CURRENT_WINDOWS) { diff --git a/src/common/layout/LayoutBadgeImageView.tsx b/src/common/layout/LayoutBadgeImageView.tsx index 7162627..2486584 100644 --- a/src/common/layout/LayoutBadgeImageView.tsx +++ b/src/common/layout/LayoutBadgeImageView.tsx @@ -1,7 +1,7 @@ import { BadgeImageReadyEvent, GetEventDispatcher, GetSessionDataManager, NitroSprite, TextureUtils } from '@nitrots/nitro-renderer'; import { CSSProperties, FC, useEffect, useMemo, useRef, useState } from 'react'; 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'; export interface LayoutBadgeImageViewProps extends BaseProps @@ -12,13 +12,25 @@ export interface LayoutBadgeImageViewProps extends BaseProps customTitle?: string; isGrayscale?: boolean; scale?: number; + showRarityInfo?: boolean; + highlightRarity?: boolean; } +const BADGE_RARITY_COLORS: Record = { + 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 = 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(null); const [ tooltipPosition, setTooltipPosition ] = useState<{ top: number; left: number } | null>(null); + const [ badgeRarityStat, setBadgeRarityStat ] = useState(null); const badgeRef = useRef(null); const tooltipsEnabled = showInfo && GetConfigurationValue('badge.descriptions.enabled', true); @@ -73,10 +85,21 @@ export const LayoutBadgeImageView: FC = 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 }; return newStyle; - }, [ badgeCode, isGroup, imageElement, scale, style ]); + }, [ badgeCode, badgeRarityStat, highlightRarity, isGroup, imageElement, scale, style ]); useEffect(() => { @@ -136,6 +159,46 @@ export const LayoutBadgeImageView: FC = props => return () => GetEventDispatcher().removeEventListener(BadgeImageReadyEvent.IMAGE_READY, onBadgeImageReadyEvent); }, [ 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 ( = props => 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 } }>
{ isGroup ? customTitle : LocalizeBadgeName(badgeCode) }
+ { showRarityInfo && badgeRarityStat && +
+
+ { rarityText } +
+
{ ownersText }
+
}
{ isGroup ? LocalizeText('group.badgepopup.body') : LocalizeBadgeDescription(badgeCode) }
, document.body diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index c620ad7..1d2c5d0 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -5,6 +5,7 @@ import { useNitroEventReducer } from '../hooks'; import { AchievementsView } from './achievements/AchievementsView'; import { AvatarEditorView } from './avatar-editor'; import { BadgeCreatorView } from './badge-creator'; +import { BadgeLeaderboardView } from './badge-leaderboard/BadgeLeaderboardView'; import { AvatarEffectsView } from './avatar-effects'; import { CameraWidgetView } from './camera/CameraWidgetView'; import { CampaignView } from './campaign/CampaignView'; @@ -147,6 +148,7 @@ export const MainView: FC<{}> = props => + diff --git a/src/components/badge-leaderboard/BadgeLeaderboardView.tsx b/src/components/badge-leaderboard/BadgeLeaderboardView.tsx new file mode 100644 index 0000000..7af23d0 --- /dev/null +++ b/src/components/badge-leaderboard/BadgeLeaderboardView.tsx @@ -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 = { + 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(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(() => + { + 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 ( +
+ +
+ + ); +}; + +interface LeaderboardRowProps +{ + entry: BadgeLeaderboardEntry; + emblem: string; + rowIndex?: number; + isCurrentUser?: boolean; +} + +const LeaderboardRow: FC = 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 ( +
+
{ entry.rank }
+
+ +
+ { entry.username } + { entry.score } + +
+ ); +}; diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx index c66609d..c179eec 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx @@ -205,7 +205,7 @@ export const InfoStandBadgeSlotView: FC = ({ slotIndex, onClick={ handleSlotClick } onDoubleClick={ handleDoubleClick }> { badgeCode - ? + ? : isOwnUser && }
{ showPicker && ( diff --git a/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetOwnAvatarView.tsx b/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetOwnAvatarView.tsx index b3a8ab3..e6e02aa 100644 --- a/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetOwnAvatarView.tsx +++ b/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetOwnAvatarView.tsx @@ -61,6 +61,9 @@ export const AvatarInfoWidgetOwnAvatarView: FC processAction('customize_nick') }> Nick Custom + processAction('badge_leaderboard') }> + { LocalizeText('badge_leaderboard.title.total_badges') } + { (HasHabboClub() && !isRidingHorse) && processAction('dance_menu') }> diff --git a/src/css/badges/BadgeLeaderboardView.css b/src/css/badges/BadgeLeaderboardView.css new file mode 100644 index 0000000..0d87c0e --- /dev/null +++ b/src/css/badges/BadgeLeaderboardView.css @@ -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; +} diff --git a/src/index.tsx b/src/index.tsx index 8621453..7a8b018 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -18,6 +18,7 @@ const queryClient = new QueryClient({ import './css/index.css'; import './css/backgrounds/BackgroundsView.css'; +import './css/badges/BadgeLeaderboardView.css'; import './css/chat/Chats.css';