Add emulator stats dashboard and refresh classic UI views
@@ -69,6 +69,7 @@
|
||||
"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",
|
||||
"emustats.endpoint": "${api.url}/api/emustats",
|
||||
"login.turnstile.enabled": true,
|
||||
"login.turnstile.sitekey": "1x00000000000000000000AA",
|
||||
"avatar.mandatory.libraries": [
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { GetConfiguration } from '@nitrots/nitro-renderer';
|
||||
import { getAccessToken } from '../auth';
|
||||
|
||||
export interface EmuStatsOverview
|
||||
{
|
||||
uptimeSeconds: number;
|
||||
lastRefreshEpochMs: number;
|
||||
guiStatus: string;
|
||||
memoryUsedMb: number;
|
||||
memoryMaxMb: number;
|
||||
memoryAllocatedMb: number;
|
||||
memoryUsagePercent: number;
|
||||
cpuLoadPercent: number;
|
||||
activeOsThreads: number;
|
||||
connectedPlayers: number;
|
||||
loadedRooms: number;
|
||||
wiredTickables: number;
|
||||
peakPlayers: number;
|
||||
activeWebSocketSessions: number;
|
||||
peakWebSocketSessions: number;
|
||||
averageRoomCycleMs: number;
|
||||
worstRoomCycleMs: number;
|
||||
worstRoomCycleRoomId: number;
|
||||
worstRoomCycleRoomName: string;
|
||||
delayedEventsPending: number;
|
||||
overloadedWiredRooms: number;
|
||||
heavyWiredRooms: number;
|
||||
wiredActivityPerSecond: number;
|
||||
}
|
||||
|
||||
export interface EmuStatsMemoryPoint
|
||||
{
|
||||
timestamp: number;
|
||||
usedMb: number;
|
||||
maxMb: number;
|
||||
usagePercent: number;
|
||||
}
|
||||
|
||||
export interface EmuStatsUserRow
|
||||
{
|
||||
id: number;
|
||||
username: string;
|
||||
rank: string;
|
||||
credits: number;
|
||||
roomId: number;
|
||||
}
|
||||
|
||||
export interface EmuStatsRoomRow
|
||||
{
|
||||
roomId: number;
|
||||
name: string;
|
||||
players: number;
|
||||
items: number;
|
||||
tickables: number;
|
||||
cpuMs: number;
|
||||
estimatedRamKb: number;
|
||||
thread: string;
|
||||
}
|
||||
|
||||
export interface EmuStatsWiredRow
|
||||
{
|
||||
roomId: number;
|
||||
averageTickMs: number;
|
||||
peakTickMs: number;
|
||||
usagePercent: number;
|
||||
delayedEventsPending: number;
|
||||
overloaded: boolean;
|
||||
heavy: boolean;
|
||||
}
|
||||
|
||||
export interface EmuStatsWiredTopRoomRow
|
||||
{
|
||||
roomId: number;
|
||||
name: string;
|
||||
usagePercent: number;
|
||||
averageTickMs: number;
|
||||
peakTickMs: number;
|
||||
delayedEventsPending: number;
|
||||
activityPerSecond: number;
|
||||
heavy: boolean;
|
||||
}
|
||||
|
||||
export interface EmuStatsDatabasePool
|
||||
{
|
||||
activeConnections: number;
|
||||
idleConnections: number;
|
||||
totalConnections: number;
|
||||
waitingThreads: number;
|
||||
maxConnections: number;
|
||||
}
|
||||
|
||||
export interface EmuStatsScheduler
|
||||
{
|
||||
queuedTasks: number;
|
||||
activeThreads: number;
|
||||
poolSize: number;
|
||||
completedTasks: number;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
export interface EmuStatsNetwork
|
||||
{
|
||||
incomingPacketsPerSecond: number;
|
||||
outgoingPacketsPerSecond: number;
|
||||
incomingKilobytesPerSecond: number;
|
||||
outgoingKilobytesPerSecond: number;
|
||||
totalIncomingPackets: number;
|
||||
totalOutgoingPackets: number;
|
||||
}
|
||||
|
||||
export interface EmuStatsGarbageCollector
|
||||
{
|
||||
totalCollections: number;
|
||||
totalCollectionTimeMs: number;
|
||||
collectionsSinceLastSample: number;
|
||||
lastObservedPauseMs: number;
|
||||
sampledAtEpochMs: number;
|
||||
}
|
||||
|
||||
export interface EmuStatsSnapshot
|
||||
{
|
||||
overview: EmuStatsOverview;
|
||||
memoryHistory: EmuStatsMemoryPoint[];
|
||||
users: EmuStatsUserRow[];
|
||||
rooms: EmuStatsRoomRow[];
|
||||
wired: EmuStatsWiredRow[];
|
||||
wiredTopRooms: EmuStatsWiredTopRoomRow[];
|
||||
databasePool: EmuStatsDatabasePool;
|
||||
scheduler: EmuStatsScheduler;
|
||||
network: EmuStatsNetwork;
|
||||
garbageCollector: EmuStatsGarbageCollector;
|
||||
}
|
||||
|
||||
const interpolate = (value: string): string =>
|
||||
{
|
||||
try { return GetConfiguration().interpolate(value); }
|
||||
catch { return value; }
|
||||
};
|
||||
|
||||
const getUrl = (): string =>
|
||||
{
|
||||
const configured = GetConfiguration().getValue<string>('emustats.endpoint', '${api.url}/api/emustats');
|
||||
|
||||
return interpolate(configured);
|
||||
};
|
||||
|
||||
const buildHeaders = (): Record<string, string> =>
|
||||
{
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'NitroEmuStats'
|
||||
};
|
||||
|
||||
const token = getAccessToken();
|
||||
|
||||
if(token) headers.Authorization = `Bearer ${ token }`;
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
let cacheValue: EmuStatsSnapshot = null;
|
||||
|
||||
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 emulator stats response.'); }
|
||||
};
|
||||
|
||||
export const fetchEmuStats = async (force = false): Promise<EmuStatsSnapshot> =>
|
||||
{
|
||||
if(!force && cacheValue) return cacheValue;
|
||||
|
||||
const response = await fetch(getUrl(), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: buildHeaders()
|
||||
});
|
||||
|
||||
const payload = await parseJson<EmuStatsSnapshot & { error?: string }>(response);
|
||||
|
||||
if(!response.ok)
|
||||
{
|
||||
throw new Error(payload?.error || `Request failed (${ response.status }).`);
|
||||
}
|
||||
|
||||
cacheValue = payload;
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const getCachedEmuStats = (): EmuStatsSnapshot =>
|
||||
{
|
||||
return cacheValue;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './EmuStatsApi';
|
||||
@@ -8,6 +8,7 @@ export * from './camera';
|
||||
export * from './campaign';
|
||||
export * from './catalog';
|
||||
export * from './chat-history';
|
||||
export * from './emustats';
|
||||
export * from './events';
|
||||
export * from './friends';
|
||||
export * from './groups';
|
||||
|
||||
|
After Width: | Height: | Size: 590 B |
|
After Width: | Height: | Size: 561 B |
|
After Width: | Height: | Size: 961 B |
|
After Width: | Height: | Size: 88 B |
|
After Width: | Height: | Size: 206 B |
|
After Width: | Height: | Size: 101 B |
|
After Width: | Height: | Size: 205 B |
|
After Width: | Height: | Size: 118 B |
|
After Width: | Height: | Size: 107 B |
|
After Width: | Height: | Size: 118 B |
|
After Width: | Height: | Size: 228 B |
@@ -0,0 +1,5 @@
|
||||
export { default as block } from './block.png';
|
||||
export { default as level } from './level.png';
|
||||
export { default as me } from './me.png';
|
||||
export { default as profile } from './profile.png';
|
||||
export { default as rooms } from './rooms.png';
|
||||
|
After Width: | Height: | Size: 381 B |
|
After Width: | Height: | Size: 394 B |
|
After Width: | Height: | Size: 202 B |
|
After Width: | Height: | Size: 668 B |
@@ -12,6 +12,7 @@ import { CampaignView } from './campaign/CampaignView';
|
||||
import { CatalogView } from './catalog/CatalogView';
|
||||
import { ChatHistoryView } from './chat-history/ChatHistoryView';
|
||||
import { CustomizeNickIconView } from './customize/CustomizeNickIconView';
|
||||
import { EmuStatsView } from './emustats/EmuStatsView';
|
||||
import { FloorplanEditorView } from './floorplan-editor/FloorplanEditorView';
|
||||
import { FurniEditorView } from './furni-editor/FurniEditorView';
|
||||
import { FriendsView } from './friends/FriendsView';
|
||||
@@ -127,6 +128,7 @@ export const MainView: FC<{}> = props =>
|
||||
<AvatarEditorView />
|
||||
<BadgeCreatorView />
|
||||
<BadgeLeaderboardView />
|
||||
<EmuStatsView />
|
||||
<AvatarEffectsView />
|
||||
<AchievementsView />
|
||||
<NavigatorView />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect } from 'react';
|
||||
import { FaCog, FaEdit, FaEye, FaEyeSlash, FaPlus, FaTrash } from 'react-icons/fa';
|
||||
import { CatalogType, GetConfigurationValue, LocalizeText } from '../../api';
|
||||
import { Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
|
||||
import { CatalogType, LocalizeText } from '../../api';
|
||||
import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
|
||||
import { useCatalog } from '../../hooks';
|
||||
import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext';
|
||||
import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView';
|
||||
@@ -10,7 +10,9 @@ import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView
|
||||
import { CatalogBuildersClubStatusView } from './views/catalog-header/CatalogBuildersClubStatusView';
|
||||
import { CatalogIconView } from './views/catalog-icon/CatalogIconView';
|
||||
import { CatalogGiftView } from './views/gift/CatalogGiftView';
|
||||
import { CatalogBreadcrumbView } from './views/navigation/CatalogBreadcrumbView';
|
||||
import { CatalogNavigationView } from './views/navigation/CatalogNavigationView';
|
||||
import { CatalogSearchView } from './views/page/common/CatalogSearchView';
|
||||
import { GetCatalogLayout } from './views/page/layout/GetCatalogLayout';
|
||||
import { MarketplacePostOfferView } from './views/page/layout/marketplace/MarketplacePostOfferView';
|
||||
|
||||
@@ -23,7 +25,6 @@ const CatalogClassicViewInner: FC<{}> = () =>
|
||||
const hasPendingChanges = catalogAdmin?.hasPendingChanges ?? false;
|
||||
const publishCatalog = catalogAdmin?.publishCatalog ?? (() => {});
|
||||
const loading = catalogAdmin?.loading ?? false;
|
||||
|
||||
const isMod = GetSessionDataManager().isModerator;
|
||||
const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER)
|
||||
? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' }
|
||||
@@ -113,21 +114,20 @@ const CatalogClassicViewInner: FC<{}> = () =>
|
||||
return (
|
||||
<>
|
||||
{ isVisible &&
|
||||
<NitroCardView className="w-[630px] h-[400px]" style={ GetConfigurationValue('catalog.headers') ? { width: 710 } : {} } uniqueKey="catalog">
|
||||
<NitroCardView classNames={ [ 'nitro-catalog-classic-window' ] } isResizable={ false } uniqueKey="catalog">
|
||||
<NitroCardHeaderView className={ currentType === CatalogType.BUILDER ? 'builders-club-card-header' : '' } headerText={ LocalizeText('catalog.title') } onCloseClick={ () => setIsVisible(false) } style={ buildersClubHeaderStyle } />
|
||||
{ /* Admin banner */ }
|
||||
{ adminMode &&
|
||||
<div className="flex items-center justify-between bg-warning text-dark text-[10px] font-bold px-3 py-0.5 uppercase tracking-wider" style={ { textShadow: '0 1px 0 rgba(255,255,255,0.3)' } }>
|
||||
<span>⚙ Admin Mode</span>
|
||||
<div className="nitro-catalog-classic-admin-banner flex items-center justify-between text-[10px] font-bold px-3 py-0.5 uppercase tracking-wider">
|
||||
<span>Admin Mode</span>
|
||||
<button
|
||||
className={ `px-3 py-0.5 rounded text-[10px] font-bold uppercase cursor-pointer transition-all ${ hasPendingChanges ? 'bg-success text-white animate-pulse shadow-md' : 'bg-white/50 text-dark hover:bg-success hover:text-white' }` }
|
||||
disabled={ loading }
|
||||
onClick={ () => publishCatalog() }
|
||||
>
|
||||
{ loading ? '...' : '⬆ Publish' }
|
||||
{ loading ? '...' : 'Publish' }
|
||||
</button>
|
||||
</div> }
|
||||
<NitroCardTabsView>
|
||||
<NitroCardTabsView classNames={ [ 'nitro-catalog-classic-tabs-shell' ] } justifyContent="start">
|
||||
{ rootNode && (rootNode.children.length > 0) && rootNode.children.map((child, index) =>
|
||||
{
|
||||
if(!adminMode && !child.isVisible) return null;
|
||||
@@ -140,10 +140,10 @@ const CatalogClassicViewInner: FC<{}> = () =>
|
||||
if(searchResult) setSearchResult(null);
|
||||
|
||||
activateNode(child);
|
||||
} } >
|
||||
<div className={ `flex items-center gap-${ GetConfigurationValue('catalog.tab.icons') ? 1 : 0 } ${ isHidden ? 'opacity-40' : '' }` }>
|
||||
{ GetConfigurationValue('catalog.tab.icons') && <CatalogIconView icon={ child.iconId } /> }
|
||||
{ child.localization }
|
||||
} }>
|
||||
<div className={ `flex items-center gap-1 ${ isHidden ? 'opacity-40' : '' }` }>
|
||||
<CatalogIconView icon={ child.iconId } />
|
||||
<span className="truncate">{ child.localization }</span>
|
||||
{ adminMode && isHidden && <FaEyeSlash className="text-[8px] text-danger ml-1" /> }
|
||||
{ adminMode &&
|
||||
<div className="flex items-center gap-0.5 ml-1" onClick={ e => e.stopPropagation() }>
|
||||
@@ -160,17 +160,15 @@ const CatalogClassicViewInner: FC<{}> = () =>
|
||||
</NitroCardTabsItemView>
|
||||
);
|
||||
}) }
|
||||
{ /* Admin toggle button in tabs bar */ }
|
||||
{ isMod &&
|
||||
<NitroCardTabsItemView isActive={ adminMode } onClick={ () => setAdminMode(!adminMode) }>
|
||||
<FaCog className={ `text-[10px] ${ adminMode ? 'animate-spin' : '' }` } style={ adminMode ? { animationDuration: '3s' } : {} } />
|
||||
</NitroCardTabsItemView> }
|
||||
</NitroCardTabsView>
|
||||
<CatalogBuildersClubStatusView />
|
||||
<NitroCardContentView>
|
||||
{ /* Admin: add new root category */ }
|
||||
<NitroCardContentView classNames={ [ 'nitro-catalog-classic-content-shell' ] }>
|
||||
<CatalogBuildersClubStatusView />
|
||||
{ adminMode && rootNode &&
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 mb-1 nitro-catalog-classic-admin-actions">
|
||||
<button
|
||||
className="flex items-center gap-1 text-[9px] text-success hover:text-green-800 cursor-pointer transition-colors"
|
||||
onClick={ () => catalogAdmin.createPage({ caption: 'New Category', catalogMode: currentType, pageLayout: 'default_3x3', minRank: 1, visible: '1', enabled: '1', orderNum: 99, parentId: rootNode.pageId }) }
|
||||
@@ -186,17 +184,30 @@ const CatalogClassicViewInner: FC<{}> = () =>
|
||||
<span>{ LocalizeText('catalog.admin.root') }</span>
|
||||
</button>
|
||||
</div> }
|
||||
<Grid>
|
||||
<div className={ `nitro-catalog-classic-stage ${ navigationHidden ? 'is-navigation-hidden' : '' }` }>
|
||||
{ !navigationHidden &&
|
||||
<Column overflow="auto" size={ 3 }>
|
||||
{ activeNodes && (activeNodes.length > 0) &&
|
||||
<CatalogNavigationView node={ activeNodes[0] } /> }
|
||||
</Column> }
|
||||
<Column overflow="hidden" size={ !navigationHidden ? 9 : 12 }>
|
||||
{ adminMode && <CatalogAdminPageEditView /> }
|
||||
{ GetCatalogLayout(currentPage, () => setNavigationHidden(true)) }
|
||||
</Column>
|
||||
</Grid>
|
||||
<div className="nitro-catalog-classic-sidebar">
|
||||
<div className="nitro-catalog-classic-search-shell">
|
||||
<CatalogSearchView />
|
||||
</div>
|
||||
<div className="nitro-catalog-classic-navigation-shell">
|
||||
{ activeNodes && (activeNodes.length > 0) &&
|
||||
<CatalogNavigationView node={ activeNodes[0] } /> }
|
||||
</div>
|
||||
</div> }
|
||||
<div className="nitro-catalog-classic-layout-shell">
|
||||
<div className="nitro-catalog-classic-layout-header-shell">
|
||||
<CatalogBreadcrumbView />
|
||||
<div className="nitro-catalog-classic-layout-hero">
|
||||
{ !!currentPage?.localization?.getImage(0) && <img src={ currentPage.localization.getImage(0) } /> }
|
||||
</div>
|
||||
</div>
|
||||
<div className="nitro-catalog-classic-layout-container">
|
||||
{ adminMode && <CatalogAdminPageEditView /> }
|
||||
{ GetCatalogLayout(currentPage, () => setNavigationHidden(true)) }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NitroCardContentView>
|
||||
</NitroCardView> }
|
||||
<CatalogAdminOfferEditView />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { FC } from 'react';
|
||||
import { FaChevronRight, FaHome } from 'react-icons/fa';
|
||||
import { LocalizeText } from '../../../../api';
|
||||
import { useCatalog } from '../../../../hooks';
|
||||
|
||||
@@ -10,25 +9,20 @@ export const CatalogBreadcrumbView: FC<{}> = () =>
|
||||
if(!activeNodes || activeNodes.length === 0)
|
||||
{
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-catalog-text-muted">
|
||||
<FaHome className="text-[10px]" />
|
||||
<div className="nitro-catalog-classic-breadcrumb">
|
||||
<span>{ LocalizeText('catalog.title') }</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-[11px] text-catalog-text-muted overflow-hidden min-w-0">
|
||||
<FaHome
|
||||
className="text-[10px] cursor-pointer hover:text-catalog-accent transition-colors shrink-0"
|
||||
onClick={ () => activateNode(activeNodes[0]) }
|
||||
/>
|
||||
{ activeNodes.map((node, i) => (
|
||||
<span key={ node.pageId } className="flex items-center gap-1 min-w-0">
|
||||
<FaChevronRight className="text-[7px] opacity-30 shrink-0" />
|
||||
<div className="nitro-catalog-classic-breadcrumb">
|
||||
{ activeNodes.map((node, index) => (
|
||||
<span key={ node.pageId } className="nitro-catalog-classic-breadcrumb-segment">
|
||||
<span className="nitro-catalog-classic-breadcrumb-separator">›</span>
|
||||
<span
|
||||
className={ `truncate ${ i === activeNodes.length - 1 ? 'text-catalog-text font-semibold' : 'cursor-pointer hover:text-catalog-accent transition-colors' }` }
|
||||
onClick={ i < activeNodes.length - 1 ? () => activateNode(node) : undefined }
|
||||
className={ `truncate ${ index === activeNodes.length - 1 ? 'font-semibold' : 'cursor-pointer hover:underline' }` }
|
||||
onClick={ index < activeNodes.length - 1 ? () => activateNode(node) : undefined }
|
||||
>
|
||||
{ node.localization }
|
||||
</span>
|
||||
|
||||
@@ -73,10 +73,10 @@ export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = pro
|
||||
}, [ adminMode, node, catalogAdmin ]);
|
||||
|
||||
return (
|
||||
<div className={ child ? 'pl-1.5 ml-1.5 border-l-2 border-card-grid-item-border' : '' }>
|
||||
<div className={ `nitro-catalog-classic-navigation-node ${ child ? 'is-child' : '' }` }>
|
||||
<div
|
||||
ref={ dragRef }
|
||||
className={ `group/nav flex items-center gap-1.5 px-1.5 py-[3px] mx-0.5 rounded cursor-pointer transition-all duration-100 text-[11px] ${ node.isActive ? 'bg-card-grid-item-active border border-card-grid-item-border-active shadow-inner1px font-bold' : 'border border-transparent hover:bg-card-grid-item-active' } ${ isDragOver ? 'ring-2 ring-primary ring-offset-1 bg-primary/10' : '' }` }
|
||||
className={ `nitro-catalog-classic-navigation-item group/nav ${ node.isActive ? 'is-active' : '' } ${ node.isBranch ? 'is-branch' : 'is-leaf' } ${ node.isOpen ? 'is-open' : '' } ${ isDragOver ? 'is-drag-over' : '' }` }
|
||||
draggable={ adminMode }
|
||||
onClick={ () => activateNode(node) }
|
||||
onDragLeave={ adminMode ? handleDragLeave : undefined }
|
||||
@@ -85,13 +85,13 @@ export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = pro
|
||||
onDrop={ adminMode ? handleDrop : undefined }
|
||||
>
|
||||
{ adminMode &&
|
||||
<FaArrowsAlt className="text-[7px] text-muted cursor-grab shrink-0 opacity-0 group-hover/nav:opacity-60" /> }
|
||||
<div className="w-5 h-5 flex items-center justify-center shrink-0">
|
||||
<FaArrowsAlt className="nitro-catalog-classic-navigation-drag text-[7px] text-muted cursor-grab shrink-0 opacity-0 group-hover/nav:opacity-60" /> }
|
||||
<div className="nitro-catalog-classic-navigation-icon">
|
||||
<CatalogIconView icon={ node.iconId } />
|
||||
</div>
|
||||
<span className="flex-1 truncate" title={ adminMode ? `Page ID: ${ node.pageId }` : undefined }>{ node.localization }</span>
|
||||
<span className="nitro-catalog-classic-navigation-label" title={ adminMode ? `Page ID: ${ node.pageId }` : undefined }>{ node.localization }</span>
|
||||
{ adminMode &&
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover/nav:opacity-100 transition-opacity">
|
||||
<div className="nitro-catalog-classic-navigation-admin flex items-center gap-1 opacity-0 group-hover/nav:opacity-100 transition-opacity">
|
||||
<FaPlus
|
||||
className="text-[8px] text-success hover:text-green-800"
|
||||
title={ LocalizeText('catalog.admin.create.subpage') }
|
||||
@@ -125,11 +125,11 @@ export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = pro
|
||||
</div> }
|
||||
{ !adminMode && node.pageId > 0 &&
|
||||
<FaStar
|
||||
className={ `text-[8px] transition-all duration-100 cursor-pointer shrink-0 ${ isFav ? 'text-warning opacity-100' : 'text-muted opacity-0 group-hover/nav:opacity-100 hover:text-warning' }` }
|
||||
className={ `nitro-catalog-classic-navigation-favorite text-[8px] transition-all duration-100 cursor-pointer shrink-0 ${ isFav ? 'text-warning opacity-100' : 'text-muted opacity-0 group-hover/nav:opacity-100 hover:text-warning' }` }
|
||||
onClick={ e => { e.stopPropagation(); toggleFavoritePage(node.pageId); } }
|
||||
/> }
|
||||
{ node.isBranch &&
|
||||
<span className="text-[9px] text-muted shrink-0">
|
||||
<span className="nitro-catalog-classic-navigation-caret text-[9px] text-muted shrink-0">
|
||||
{ node.isOpen ? <FaCaretUp /> : <FaCaretDown /> }
|
||||
</span> }
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ export const CatalogNavigationView: FC<CatalogNavigationViewProps> = props =>
|
||||
const { searchResult = null } = useCatalog();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-px px-0.5 py-0.5">
|
||||
<div className="nitro-catalog-classic-navigation-list">
|
||||
{ searchResult && (searchResult.filteredNodes.length > 0) && searchResult.filteredNodes.map((n, index) =>
|
||||
{
|
||||
return <CatalogNavigationItemView key={ index } node={ n } />;
|
||||
|
||||
@@ -61,10 +61,9 @@ export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
|
||||
|
||||
return (
|
||||
<LayoutGridItem
|
||||
className="group/tile relative"
|
||||
className={ `group/tile relative ${ itemActive ? 'is-active' : '' }` }
|
||||
itemActive={ itemActive }
|
||||
itemCount={ ((offer.pricingModel === Offer.PRICING_MODEL_MULTI) ? product.productCount : 1) }
|
||||
itemImage={ iconUrl }
|
||||
itemUniqueNumber={ product.uniqueLimitedItemSeriesSize }
|
||||
itemUniqueSoldout={ (product.uniqueLimitedItemSeriesSize && !product.uniqueLimitedItemsLeft) }
|
||||
title={ `ID: ${ product.productClassId } | Offer: ${ offer.offerId }` }
|
||||
@@ -73,6 +72,8 @@ export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
|
||||
onMouseUp={ onMouseEvent }
|
||||
{ ...rest }
|
||||
>
|
||||
{ iconUrl && !(offer.product.productType === ProductTypeEnum.ROBOT) &&
|
||||
<div className="nitro-catalog-classic-grid-offer-icon" style={ { backgroundImage: `url(${ iconUrl })` } } /> }
|
||||
{ (offer.product.productType === ProductTypeEnum.ROBOT) &&
|
||||
<LayoutAvatarImageView direction={ 3 } figure={ offer.product.extraParam } headOnly={ true } /> }
|
||||
<div
|
||||
|
||||
@@ -22,10 +22,10 @@ export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
|
||||
const adminMode = catalogAdmin?.adminMode ?? false;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-2">
|
||||
<div className="nitro-catalog-classic-default-layout flex flex-col h-full gap-2">
|
||||
{ /* Admin: quick actions */ }
|
||||
{ adminMode && !catalogAdmin.editingPageData &&
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 nitro-catalog-classic-default-admin">
|
||||
<button
|
||||
className="flex items-center gap-1 text-[10px] text-primary hover:text-dark transition-colors cursor-pointer"
|
||||
onClick={ () => { catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true); } }
|
||||
@@ -42,9 +42,9 @@ export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
|
||||
|
||||
{ /* Product detail card */ }
|
||||
{ currentOffer &&
|
||||
<div className="flex gap-0 bg-white rounded border-2 border-card-grid-item-border overflow-hidden">
|
||||
<div className="nitro-catalog-classic-offer-panel flex gap-0 overflow-hidden">
|
||||
{ /* Preview area */ }
|
||||
<div className="w-[140px] min-w-[140px] bg-card-grid-item relative flex items-center justify-center border-r-2 border-card-grid-item-border">
|
||||
<div className="nitro-catalog-classic-offer-preview relative flex items-center justify-center">
|
||||
{ (currentOffer.product.productType !== ProductTypeEnum.BADGE) &&
|
||||
<>
|
||||
<CatalogViewProductWidgetView />
|
||||
@@ -54,7 +54,7 @@ export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
|
||||
<CatalogAddOnBadgeWidgetView className="scale-2" /> }
|
||||
</div>
|
||||
{ /* Product info + purchase */ }
|
||||
<div className="flex flex-col flex-1 min-w-0 p-2.5 gap-2">
|
||||
<div className="nitro-catalog-classic-offer-info flex flex-col flex-1 min-w-0 gap-2">
|
||||
{ /* Title row */ }
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -87,17 +87,17 @@ export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
|
||||
|
||||
{ /* Welcome/description card */ }
|
||||
{ !currentOffer &&
|
||||
<div className="flex items-center gap-3 p-2.5 bg-white rounded border-2 border-card-grid-item-border">
|
||||
<div className="nitro-catalog-classic-welcome flex items-center gap-3">
|
||||
{ !!page.localization.getImage(1) &&
|
||||
<img className="w-[70px] h-[70px] object-contain rounded shrink-0" src={ page.localization.getImage(1) } /> }
|
||||
<Text className="text-[11px]! text-muted" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
|
||||
</div> }
|
||||
|
||||
{ /* Item grid */ }
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
<div className="nitro-catalog-classic-grid-shell flex-1 overflow-auto min-h-0">
|
||||
{ GetConfigurationValue('catalog.headers') &&
|
||||
<CatalogHeaderView imageUrl={ currentPage.localization.getImage(0) } /> }
|
||||
<CatalogItemGridWidgetView columnCount={ 7 } columnMinHeight={ 50 } columnMinWidth={ 50 } />
|
||||
<CatalogItemGridWidgetView className="nitro-catalog-classic-grid" columnCount={ 7 } columnMinHeight={ 50 } columnMinWidth={ 50 } />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { CSSProperties, FC, MouseEvent as ReactMouseEvent, ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { EmuStatsMemoryPoint, EmuStatsRoomRow, EmuStatsSnapshot, EmuStatsUserRow, EmuStatsWiredRow, EmuStatsWiredTopRoomRow, fetchEmuStats, getCachedEmuStats } from '../../api';
|
||||
import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
|
||||
|
||||
type EmuStatsSection = 'overview' | 'system' | 'wiredInsights' | 'users' | 'rooms' | 'wired';
|
||||
|
||||
const REFRESH_INTERVAL_MS = 2_500;
|
||||
|
||||
const formatDateTime = (value: number): string =>
|
||||
{
|
||||
if(!value) return '-';
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}).format(new Date(value));
|
||||
};
|
||||
|
||||
const formatUptime = (totalSeconds: number): string =>
|
||||
{
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = Math.floor(totalSeconds % 60);
|
||||
|
||||
return `${ hours.toString().padStart(2, '0') }h ${ minutes.toString().padStart(2, '0') }m ${ seconds.toString().padStart(2, '0') }s`;
|
||||
};
|
||||
|
||||
const formatCpu = (value: number): string => `${ value.toFixed(1) }%`;
|
||||
const formatMemory = (used: number, max: number): string => `${ used } MB / ${ max } MB`;
|
||||
const formatCompactNumber = (value: number): string => new Intl.NumberFormat().format(value);
|
||||
const formatThroughput = (value: number, suffix: string): string => `${ value.toFixed(1) } ${ suffix }`;
|
||||
const formatMs = (value: number): string => `${ value.toFixed(2) } ms`;
|
||||
const formatBoolean = (value: boolean): string => value ? 'Yes' : 'No';
|
||||
const formatRoomLabel = (roomId: number, roomName: string): string => roomId ? (roomName?.length ? `${ roomName } (#${ roomId })` : `#${ roomId }`) : '-';
|
||||
|
||||
const MemoryChart: FC<{ history: EmuStatsMemoryPoint[] }> = ({ history }) =>
|
||||
{
|
||||
const [ hoveredIndex, setHoveredIndex ] = useState<number>(-1);
|
||||
|
||||
const chart = useMemo(() =>
|
||||
{
|
||||
if(!history?.length)
|
||||
{
|
||||
return {
|
||||
linePoints: '',
|
||||
areaPoints: '',
|
||||
peak: 0,
|
||||
latest: 0,
|
||||
plotPoints: [],
|
||||
gridValues: [ 0, 0, 0, 0 ]
|
||||
};
|
||||
}
|
||||
|
||||
const width = 100;
|
||||
const height = 100;
|
||||
const maxMb = Math.max(...history.map(point => point.maxMb || 1), 1);
|
||||
const lastIndex = Math.max(history.length - 1, 1);
|
||||
|
||||
const plotPoints = history.map((point, index) =>
|
||||
{
|
||||
const x = (index / lastIndex) * width;
|
||||
const y = height - ((point.usedMb / maxMb) * (height - 8)) - 4;
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
usedMb: point.usedMb,
|
||||
usagePercent: point.usagePercent,
|
||||
timestamp: point.timestamp
|
||||
};
|
||||
});
|
||||
|
||||
const points = plotPoints.map(point => `${ point.x.toFixed(2) },${ point.y.toFixed(2) }`);
|
||||
const latest = history[history.length - 1]?.usedMb || 0;
|
||||
const peak = Math.max(...history.map(point => point.usedMb), 0);
|
||||
const gridValues = [ 1, 0.75, 0.5, 0.25 ].map(value => Math.round(maxMb * value));
|
||||
|
||||
return {
|
||||
linePoints: points.join(' '),
|
||||
areaPoints: `0,100 ${ points.join(' ') } 100,100`,
|
||||
peak,
|
||||
latest,
|
||||
plotPoints,
|
||||
gridValues
|
||||
};
|
||||
}, [ history ]);
|
||||
|
||||
const hoveredPoint = (hoveredIndex >= 0) ? chart.plotPoints[hoveredIndex] : null;
|
||||
|
||||
const onMouseMove = (event: ReactMouseEvent<SVGSVGElement>) =>
|
||||
{
|
||||
if(!chart.plotPoints.length) return;
|
||||
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const relativeX = Math.min(Math.max(event.clientX - bounds.left, 0), bounds.width);
|
||||
const ratio = bounds.width > 0 ? (relativeX / bounds.width) : 0;
|
||||
const nextIndex = Math.min(chart.plotPoints.length - 1, Math.max(0, Math.round(ratio * (chart.plotPoints.length - 1))));
|
||||
|
||||
setHoveredIndex(nextIndex);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="nitro-emustats__chart-card">
|
||||
<div className="nitro-emustats__section-header">
|
||||
<div>
|
||||
<h3>Realtime Memory Usage</h3>
|
||||
<p>Rolling history from the emulator process.</p>
|
||||
</div>
|
||||
<div className="nitro-emustats__chart-meta">
|
||||
<span>Peak { chart.peak } MB</span>
|
||||
<strong>{ chart.latest } MB</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="nitro-emustats__chart-shell">
|
||||
<div className="nitro-emustats__chart-axis">
|
||||
{ chart.gridValues.map((value, index) => <span key={ `${ value }-${ index }` }>{ value } MB</span>) }
|
||||
<span>0 MB</span>
|
||||
</div>
|
||||
<div className="nitro-emustats__chart-canvas">
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
className="nitro-emustats__chart"
|
||||
onMouseLeave={ () => setHoveredIndex(-1) }
|
||||
onMouseMove={ onMouseMove }
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="emuStatsArea" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="rgba(99,102,241,0.45)" />
|
||||
<stop offset="100%" stopColor="rgba(99,102,241,0.02)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<line x1="0" y1="20" x2="100" y2="20" className="nitro-emustats__chart-grid" />
|
||||
<line x1="0" y1="40" x2="100" y2="40" className="nitro-emustats__chart-grid" />
|
||||
<line x1="0" y1="60" x2="100" y2="60" className="nitro-emustats__chart-grid" />
|
||||
<line x1="0" y1="80" x2="100" y2="80" className="nitro-emustats__chart-grid" />
|
||||
{ !!chart.areaPoints.length && <polygon points={ chart.areaPoints } fill="url(#emuStatsArea)" /> }
|
||||
{ !!chart.linePoints.length && <polyline points={ chart.linePoints } className="nitro-emustats__chart-line" /> }
|
||||
{ hoveredPoint &&
|
||||
<>
|
||||
<line x1={ hoveredPoint.x } y1="0" x2={ hoveredPoint.x } y2="100" className="nitro-emustats__chart-hover-line" />
|
||||
<circle cx={ hoveredPoint.x } cy={ hoveredPoint.y } r="1.8" className="nitro-emustats__chart-hover-point" />
|
||||
</> }
|
||||
</svg>
|
||||
{ hoveredPoint &&
|
||||
<div
|
||||
className="nitro-emustats__chart-tooltip"
|
||||
style={ {
|
||||
left: `${ Math.min(88, Math.max(6, hoveredPoint.x)) }%`,
|
||||
top: `${ Math.min(72, Math.max(6, hoveredPoint.y - 10)) }%`
|
||||
} }
|
||||
>
|
||||
<strong>{ hoveredPoint.usedMb } MB</strong>
|
||||
<span>{ hoveredPoint.usagePercent.toFixed(1) }%</span>
|
||||
<small>{ formatDateTime(hoveredPoint.timestamp) }</small>
|
||||
</div> }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StatsTable = <TRow extends object>({ columns, rows, rowKey }: {
|
||||
columns: { key: keyof TRow; label: string; className?: string; render?: (row: TRow) => ReactNode; }[];
|
||||
rows: TRow[];
|
||||
rowKey: (row: TRow, index: number) => string;
|
||||
}) =>
|
||||
{
|
||||
return (
|
||||
<div className="nitro-emustats__table-shell">
|
||||
<table className="nitro-emustats__table">
|
||||
<thead>
|
||||
<tr>
|
||||
{ columns.map(column => <th key={ String(column.key) } className={ column.className }>{ column.label }</th>) }
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ rows.length === 0 &&
|
||||
<tr>
|
||||
<td colSpan={ columns.length } className="nitro-emustats__table-empty">Nothing to show right now.</td>
|
||||
</tr> }
|
||||
{ rows.map((row, index) => (
|
||||
<tr key={ rowKey(row, index) }>
|
||||
{ columns.map(column => (
|
||||
<td key={ String(column.key) } className={ column.className }>
|
||||
{ column.render ? column.render(row) : (row[column.key] as ReactNode) }
|
||||
</td>
|
||||
)) }
|
||||
</tr>
|
||||
)) }
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DetailPanel: FC<{ title: string; description?: string; children: ReactNode; }> = ({ title, description = '', children }) =>
|
||||
{
|
||||
return (
|
||||
<div className="nitro-emustats__detail-panel">
|
||||
<div className="nitro-emustats__detail-panel-header">
|
||||
<h3>{ title }</h3>
|
||||
{ !!description.length && <p>{ description }</p> }
|
||||
</div>
|
||||
{ children }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const KeyValueGrid: FC<{ items: { label: string; value: string; tone?: 'default' | 'good' | 'warn'; }[]; columns?: 1 | 2; }> = ({ items, columns = 2 }) =>
|
||||
{
|
||||
return (
|
||||
<div className={ `nitro-emustats__kv-grid is-${ columns }col` }>
|
||||
{ items.map(item => (
|
||||
<div key={ item.label } className="nitro-emustats__kv-item">
|
||||
<span>{ item.label }</span>
|
||||
<strong data-tone={ item.tone || 'default' }>{ item.value }</strong>
|
||||
</div>
|
||||
)) }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SystemSection: FC<{ snapshot: EmuStatsSnapshot }> = ({ snapshot }) =>
|
||||
{
|
||||
const { databasePool, garbageCollector, network, overview, scheduler } = snapshot;
|
||||
|
||||
return (
|
||||
<div className="nitro-emustats__detail-layout">
|
||||
<DetailPanel title="Database Pool" description="Current Hikari pool pressure and connection availability.">
|
||||
<KeyValueGrid
|
||||
items={ [
|
||||
{ label: 'Active', value: formatCompactNumber(databasePool.activeConnections) },
|
||||
{ label: 'Idle', value: formatCompactNumber(databasePool.idleConnections) },
|
||||
{ label: 'Total', value: formatCompactNumber(databasePool.totalConnections) },
|
||||
{ label: 'Max', value: formatCompactNumber(databasePool.maxConnections) },
|
||||
{ label: 'Waiting Threads', value: formatCompactNumber(databasePool.waitingThreads), tone: databasePool.waitingThreads > 0 ? 'warn' : 'good' }
|
||||
] }
|
||||
/>
|
||||
</DetailPanel>
|
||||
<DetailPanel title="Network Throughput" description="Realtime rates sampled from incoming and outgoing packet pipelines.">
|
||||
<KeyValueGrid
|
||||
items={ [
|
||||
{ label: 'Incoming Packets/s', value: formatThroughput(network.incomingPacketsPerSecond, 'pkt') },
|
||||
{ label: 'Outgoing Packets/s', value: formatThroughput(network.outgoingPacketsPerSecond, 'pkt') },
|
||||
{ label: 'Incoming KB/s', value: formatThroughput(network.incomingKilobytesPerSecond, 'KB') },
|
||||
{ label: 'Outgoing KB/s', value: formatThroughput(network.outgoingKilobytesPerSecond, 'KB') },
|
||||
{ label: 'Total Incoming', value: formatCompactNumber(network.totalIncomingPackets) },
|
||||
{ label: 'Total Outgoing', value: formatCompactNumber(network.totalOutgoingPackets) }
|
||||
] }
|
||||
/>
|
||||
</DetailPanel>
|
||||
<DetailPanel title="Scheduler" description="Executor load behind delayed tasks and internal service work.">
|
||||
<KeyValueGrid
|
||||
items={ [
|
||||
{ label: 'Queued Tasks', value: formatCompactNumber(scheduler.queuedTasks), tone: scheduler.queuedTasks > 0 ? 'warn' : 'good' },
|
||||
{ label: 'Active Threads', value: formatCompactNumber(scheduler.activeThreads) },
|
||||
{ label: 'Pool Size', value: formatCompactNumber(scheduler.poolSize) },
|
||||
{ label: 'Completed Tasks', value: formatCompactNumber(scheduler.completedTasks) },
|
||||
{ label: 'Running', value: formatBoolean(scheduler.running), tone: scheduler.running ? 'good' : 'warn' }
|
||||
] }
|
||||
/>
|
||||
</DetailPanel>
|
||||
<DetailPanel title="Garbage Collection" description="Observed JVM collection activity since emulator startup.">
|
||||
<KeyValueGrid
|
||||
items={ [
|
||||
{ label: 'Total Collections', value: formatCompactNumber(garbageCollector.totalCollections) },
|
||||
{ label: 'Collections This Sample', value: formatCompactNumber(garbageCollector.collectionsSinceLastSample) },
|
||||
{ label: 'Total Pause Time', value: `${ formatCompactNumber(garbageCollector.totalCollectionTimeMs) } ms` },
|
||||
{ label: 'Last Observed Pause', value: `${ formatCompactNumber(garbageCollector.lastObservedPauseMs) } ms`, tone: garbageCollector.lastObservedPauseMs > 250 ? 'warn' : 'default' },
|
||||
{ label: 'Sampled At', value: formatDateTime(garbageCollector.sampledAtEpochMs) }
|
||||
] }
|
||||
/>
|
||||
</DetailPanel>
|
||||
<DetailPanel title="Cycle Health" description="Aggregated timing from the active room update loop.">
|
||||
<KeyValueGrid
|
||||
items={ [
|
||||
{ label: 'Average Room Cycle', value: formatMs(overview.averageRoomCycleMs) },
|
||||
{ label: 'Worst Room Cycle', value: formatMs(overview.worstRoomCycleMs), tone: overview.worstRoomCycleMs > 20 ? 'warn' : 'default' },
|
||||
{ label: 'Worst Room', value: formatRoomLabel(overview.worstRoomCycleRoomId, overview.worstRoomCycleRoomName) },
|
||||
{ label: 'WebSocket Sessions', value: `${ formatCompactNumber(overview.activeWebSocketSessions) } / ${ formatCompactNumber(overview.peakWebSocketSessions) } peak` }
|
||||
] }
|
||||
columns={ 1 }
|
||||
/>
|
||||
</DetailPanel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const WiredInsightsSection: FC<{ snapshot: EmuStatsSnapshot }> = ({ snapshot }) =>
|
||||
{
|
||||
const { overview } = snapshot;
|
||||
|
||||
return (
|
||||
<div className="nitro-emustats__detail-layout">
|
||||
<DetailPanel title="Wired Pressure" description="Live summary of tick load, delayed actions and heavy rooms.">
|
||||
<KeyValueGrid
|
||||
items={ [
|
||||
{ label: 'Tickables', value: formatCompactNumber(overview.wiredTickables) },
|
||||
{ label: 'Delayed Events', value: formatCompactNumber(overview.delayedEventsPending), tone: overview.delayedEventsPending > 0 ? 'warn' : 'good' },
|
||||
{ label: 'Heavy Rooms', value: formatCompactNumber(overview.heavyWiredRooms), tone: overview.heavyWiredRooms > 0 ? 'warn' : 'good' },
|
||||
{ label: 'Overloaded Rooms', value: formatCompactNumber(overview.overloadedWiredRooms), tone: overview.overloadedWiredRooms > 0 ? 'warn' : 'good' },
|
||||
{ label: 'Activity / Second', value: formatThroughput(overview.wiredActivityPerSecond, 'ops') }
|
||||
] }
|
||||
/>
|
||||
</DetailPanel>
|
||||
<DetailPanel title="Top Wired Rooms" description="Highest usage rooms in the current diagnostics window.">
|
||||
<StatsTable<EmuStatsWiredTopRoomRow>
|
||||
columns={ [
|
||||
{ key: 'roomId', label: 'Room', className: 'is-xs' },
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'usagePercent', label: 'Usage', className: 'is-xs', render: row => `${ row.usagePercent }%` },
|
||||
{ key: 'averageTickMs', label: 'Avg Tick', className: 'is-sm', render: row => `${ row.averageTickMs } ms` },
|
||||
{ key: 'peakTickMs', label: 'Peak Tick', className: 'is-sm', render: row => `${ row.peakTickMs } ms` },
|
||||
{ key: 'delayedEventsPending', label: 'Delayed', className: 'is-xs' },
|
||||
{ key: 'activityPerSecond', label: 'Ops/s', className: 'is-sm', render: row => row.activityPerSecond.toFixed(1) },
|
||||
{ key: 'heavy', label: 'Heavy', className: 'is-sm', render: row => formatBoolean(row.heavy) }
|
||||
] }
|
||||
rowKey={ row => `wired-top-${ row.roomId }` }
|
||||
rows={ snapshot.wiredTopRooms }
|
||||
/>
|
||||
</DetailPanel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MetricCard: FC<{ title: string; value: string; subtitle?: string; accent?: string; }> = ({ title, value, subtitle = '', accent }) =>
|
||||
{
|
||||
return (
|
||||
<div className="nitro-emustats__metric-card" style={ accent ? { '--emustats-accent': accent } as CSSProperties : undefined }>
|
||||
<span>{ title }</span>
|
||||
<strong>{ value }</strong>
|
||||
{ !!subtitle.length && <small>{ subtitle }</small> }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const EmuStatsView: FC<{}> = () =>
|
||||
{
|
||||
const [ isVisible, setIsVisible ] = useState(false);
|
||||
const [ isLoading, setIsLoading ] = useState(false);
|
||||
const [ error, setError ] = useState<string>(null);
|
||||
const [ section, setSection ] = useState<EmuStatsSection>('overview');
|
||||
const [ version, setVersion ] = useState(0);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const linkTracker: ILinkEventTracker = {
|
||||
eventUrlPrefix: 'emustats/',
|
||||
linkReceived: url =>
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
AddLinkEventTracker(linkTracker);
|
||||
|
||||
return () => RemoveLinkEventTracker(linkTracker);
|
||||
}, []);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!isVisible) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const load = async (force = false) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
setIsLoading(true);
|
||||
const payload = await fetchEmuStats(force);
|
||||
|
||||
if(cancelled) return;
|
||||
|
||||
if(payload) setError(null);
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
if(cancelled) return;
|
||||
|
||||
setError(String((err as Error)?.message || err));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(!cancelled) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void load(version > 0);
|
||||
|
||||
const interval = window.setInterval(() => void load(true), REFRESH_INTERVAL_MS);
|
||||
|
||||
return () =>
|
||||
{
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [ isVisible, version ]);
|
||||
|
||||
const snapshot = getCachedEmuStats();
|
||||
const overview = snapshot?.overview;
|
||||
const session = GetSessionDataManager();
|
||||
|
||||
const navItems: { key: EmuStatsSection; label: string; count?: number; }[] = [
|
||||
{ key: 'overview', label: 'Overview' },
|
||||
{ key: 'system', label: 'System Health' },
|
||||
{ key: 'wiredInsights', label: 'Wired Insights', count: snapshot?.wiredTopRooms?.length || 0 },
|
||||
{ key: 'wired', label: 'Wired Diagnostics', count: snapshot?.wired?.length || 0 },
|
||||
{ key: 'users', label: 'Online Users', count: snapshot?.users?.length || 0 },
|
||||
{ key: 'rooms', label: 'Active Rooms', count: snapshot?.rooms?.length || 0 }
|
||||
];
|
||||
|
||||
const content = useMemo(() =>
|
||||
{
|
||||
if(!snapshot || !overview) return null;
|
||||
|
||||
switch(section)
|
||||
{
|
||||
case 'system':
|
||||
return <SystemSection snapshot={ snapshot } />;
|
||||
case 'wiredInsights':
|
||||
return <WiredInsightsSection snapshot={ snapshot } />;
|
||||
case 'users':
|
||||
return (
|
||||
<StatsTable<EmuStatsUserRow>
|
||||
columns={ [
|
||||
{ key: 'id', label: 'ID', className: 'is-xs' },
|
||||
{ key: 'username', label: 'Username' },
|
||||
{ key: 'rank', label: 'Rank' },
|
||||
{ key: 'credits', label: 'Credits', className: 'is-sm' },
|
||||
{ key: 'roomId', label: 'Room ID', className: 'is-sm' }
|
||||
] }
|
||||
rowKey={ row => `user-${ row.id }` }
|
||||
rows={ snapshot.users }
|
||||
/>
|
||||
);
|
||||
case 'rooms':
|
||||
return (
|
||||
<StatsTable<EmuStatsRoomRow>
|
||||
columns={ [
|
||||
{ key: 'roomId', label: 'Room', className: 'is-xs' },
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'players', label: 'Players', className: 'is-xs' },
|
||||
{ key: 'items', label: 'Items', className: 'is-xs' },
|
||||
{ key: 'tickables', label: 'Tickables', className: 'is-xs' },
|
||||
{ key: 'cpuMs', label: 'CPU (ms)', className: 'is-sm', render: row => row.cpuMs.toFixed(2) },
|
||||
{ key: 'estimatedRamKb', label: 'RAM (KB)', className: 'is-sm' },
|
||||
{ key: 'thread', label: 'Thread', className: 'is-md' }
|
||||
] }
|
||||
rowKey={ row => `room-${ row.roomId }` }
|
||||
rows={ snapshot.rooms }
|
||||
/>
|
||||
);
|
||||
case 'wired':
|
||||
return (
|
||||
<StatsTable<EmuStatsWiredRow>
|
||||
columns={ [
|
||||
{ key: 'roomId', label: 'Room', className: 'is-xs' },
|
||||
{ key: 'averageTickMs', label: 'Avg Tick', className: 'is-sm', render: row => `${ row.averageTickMs } ms` },
|
||||
{ key: 'peakTickMs', label: 'Peak Tick', className: 'is-sm', render: row => `${ row.peakTickMs } ms` },
|
||||
{ key: 'usagePercent', label: 'Usage', className: 'is-xs', render: row => `${ row.usagePercent }%` },
|
||||
{ key: 'delayedEventsPending', label: 'Delayed', className: 'is-xs' },
|
||||
{ key: 'overloaded', label: 'Overloaded', className: 'is-sm', render: row => formatBoolean(row.overloaded) },
|
||||
{ key: 'heavy', label: 'Heavy', className: 'is-sm', render: row => formatBoolean(row.heavy) }
|
||||
] }
|
||||
rowKey={ row => `wired-${ row.roomId }` }
|
||||
rows={ snapshot.wired }
|
||||
/>
|
||||
);
|
||||
case 'overview':
|
||||
default:
|
||||
return (
|
||||
<div className="nitro-emustats__overview">
|
||||
<div className="nitro-emustats__overview-cards">
|
||||
<MetricCard title="Uptime" accent="#6366f1" value={ formatUptime(overview.uptimeSeconds) } />
|
||||
<MetricCard title="Last Refresh" accent="#22c55e" value={ formatDateTime(overview.lastRefreshEpochMs) } />
|
||||
<MetricCard title="GUI Status" accent="#f59e0b" value={ overview.guiStatus } />
|
||||
<MetricCard title="Memory Allocation" value={ formatMemory(overview.memoryUsedMb, overview.memoryMaxMb) } subtitle={ `${ overview.memoryAllocatedMb } MB allocated` } />
|
||||
<MetricCard title="CPU Load" value={ formatCpu(overview.cpuLoadPercent) } />
|
||||
<MetricCard title="Active OS Threads" value={ String(overview.activeOsThreads) } />
|
||||
<MetricCard title="Connected Players" value={ String(overview.connectedPlayers) } />
|
||||
<MetricCard title="Peak Players" value={ String(overview.peakPlayers) } />
|
||||
<MetricCard title="Loaded Rooms" value={ String(overview.loadedRooms) } />
|
||||
<MetricCard title="Wired Tickables" value={ String(overview.wiredTickables) } />
|
||||
<MetricCard title="WS Sessions" value={ `${ overview.activeWebSocketSessions } / ${ overview.peakWebSocketSessions }` } subtitle="current / peak" />
|
||||
<MetricCard title="Avg Room Cycle" value={ formatMs(overview.averageRoomCycleMs) } />
|
||||
</div>
|
||||
<MemoryChart history={ snapshot.memoryHistory } />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [ overview, section, snapshot ]);
|
||||
|
||||
if(!isVisible) return null;
|
||||
|
||||
return (
|
||||
<NitroCardView className="nitro-emustats-window w-[980px] h-[620px]" isResizable={ false } theme="primary-slim" uniqueKey="emu-stats">
|
||||
<NitroCardHeaderView headerText="Emulator Stats" onCloseClick={ () => setIsVisible(false) } />
|
||||
<NitroCardContentView classNames={ [ 'nitro-emustats-window__content' ] }>
|
||||
<div className="nitro-emustats">
|
||||
<aside className="nitro-emustats__sidebar">
|
||||
<div className="nitro-emustats__sidebar-brand">
|
||||
<h2>Arcturus</h2>
|
||||
<p>{ session?.userName || 'Operator' }</p>
|
||||
</div>
|
||||
<nav className="nitro-emustats__nav">
|
||||
{ navItems.map(item => (
|
||||
<button
|
||||
key={ item.key }
|
||||
className={ `nitro-emustats__nav-button ${ section === item.key ? 'is-active' : '' }` }
|
||||
onClick={ () => setSection(item.key) }
|
||||
type="button"
|
||||
>
|
||||
<span>{ item.label }</span>
|
||||
{ typeof item.count === 'number' && <strong>{ item.count }</strong> }
|
||||
</button>
|
||||
)) }
|
||||
</nav>
|
||||
<div className="nitro-emustats__sidebar-footer">
|
||||
<button className="nitro-emustats__refresh-button" onClick={ () => setVersion(value => value + 1) } type="button">
|
||||
Refresh now
|
||||
</button>
|
||||
<p>Auto refresh every { REFRESH_INTERVAL_MS / 1000 }s</p>
|
||||
</div>
|
||||
</aside>
|
||||
<section className="nitro-emustats__main">
|
||||
<div className="nitro-emustats__header">
|
||||
<div>
|
||||
<h1>{ navItems.find(item => item.key === section)?.label || 'Overview' }</h1>
|
||||
<p>Live operational view of emulator health, activity and wired performance.</p>
|
||||
</div>
|
||||
{ overview &&
|
||||
<div className="nitro-emustats__status-pill" data-status={ overview.guiStatus.toLowerCase().replace(/\s+/g, '-') }>
|
||||
{ overview.guiStatus }
|
||||
</div> }
|
||||
</div>
|
||||
{ error &&
|
||||
<div className="nitro-emustats__error">
|
||||
{ error }
|
||||
</div> }
|
||||
{ isLoading && !snapshot &&
|
||||
<div className="nitro-emustats__empty">
|
||||
Loading emulator stats...
|
||||
</div> }
|
||||
{ !isLoading && !snapshot && !error &&
|
||||
<div className="nitro-emustats__empty">
|
||||
No emulator stats available yet.
|
||||
</div> }
|
||||
{ snapshot &&
|
||||
<div className="nitro-emustats__body" style={ { '--emustats-section': `"${ section }"` } as CSSProperties }>
|
||||
{ content }
|
||||
</div> }
|
||||
</section>
|
||||
</div>
|
||||
</NitroCardContentView>
|
||||
</NitroCardView>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +1,20 @@
|
||||
import { CreateLinkEvent, GetSessionDataManager, GroupInformationParser, GroupRemoveMemberComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC } from 'react';
|
||||
import { CatalogPageName, GetGroupManager, GetGroupMembers, GroupMembershipType, GroupType, LocalizeText, SendMessageComposer, TryJoinGroup, TryVisitRoom } from '../../../api';
|
||||
import { Button, Column, Grid, GridProps, LayoutBadgeImageView, Text } from '../../../common';
|
||||
import { Button, LayoutBadgeImageView, Text } from '../../../common';
|
||||
import { useNotification } from '../../../hooks';
|
||||
|
||||
const STATES: string[] = [ 'regular', 'exclusive', 'private' ];
|
||||
|
||||
interface GroupInformationViewProps extends GridProps
|
||||
interface GroupInformationViewProps
|
||||
{
|
||||
groupInformation: GroupInformationParser;
|
||||
onJoin?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const GroupInformationView: FC<GroupInformationViewProps> = props =>
|
||||
{
|
||||
const { groupInformation = null, onClose = null, overflow = 'hidden', ...rest } = props;
|
||||
const { groupInformation = null, onClose = null } = props;
|
||||
const { showConfirm = null } = useNotification();
|
||||
|
||||
const isRealOwner = (groupInformation && (groupInformation.ownerName === GetSessionDataManager().userName));
|
||||
@@ -103,49 +102,47 @@ export const GroupInformationView: FC<GroupInformationViewProps> = props =>
|
||||
if(!groupInformation) return null;
|
||||
|
||||
return (
|
||||
<Grid overflow={ overflow } { ...rest }>
|
||||
<Column center overflow="hidden" size={ 3 }>
|
||||
<div className="flex items-center overflow-hidden group-badge">
|
||||
<LayoutBadgeImageView badgeCode={ groupInformation.badge } isGroup={ true } scale={ 2 } />
|
||||
<div className="nitro-extended-profile-group-info">
|
||||
<div className="nitro-extended-profile-group-info__badge-column">
|
||||
<div className="nitro-extended-profile-group-info__badge-wrap group-badge">
|
||||
<LayoutBadgeImageView badgeCode={ groupInformation.badge } isGroup={ true } scale={ 2.1 } />
|
||||
</div>
|
||||
<Column alignItems="center" gap={ 1 }>
|
||||
<Text pointer small underline onClick={ () => handleAction('members') }>{ LocalizeText('group.membercount', [ 'totalMembers' ], [ groupInformation.membersCount.toString() ]) }</Text>
|
||||
<div className="nitro-extended-profile-group-info__meta">
|
||||
<Text pointer small bold underline onClick={ () => handleAction('members') }>{ LocalizeText('group.membercount', [ 'totalMembers' ], [ groupInformation.membersCount.toString() ]) }</Text>
|
||||
{ (groupInformation.pendingRequestsCount > 0) &&
|
||||
<Text pointer small underline onClick={ () => handleAction('members_pending') }>{ LocalizeText('group.pendingmembercount', [ 'amount' ], [ groupInformation.pendingRequestsCount.toString() ]) }</Text> }
|
||||
{ groupInformation.isOwner &&
|
||||
<Text pointer small underline onClick={ () => handleAction('manage') }>{ LocalizeText('group.manage') }</Text> }
|
||||
</Column>
|
||||
{ getRoleIcon() }
|
||||
</Column>
|
||||
<div className="flex flex-col justify-between overflow-auto col-span-9">
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Text bold>{ groupInformation.title }</Text>
|
||||
<div className="flex gap-1">
|
||||
<i className={ 'nitro-icon icon-group-type-' + groupInformation.type } title={ LocalizeText(`group.edit.settings.type.${ STATES[groupInformation.type] }.help`) } />
|
||||
{ groupInformation.canMembersDecorate &&
|
||||
<i className="nitro-icon icon-group-decorate" title={ LocalizeText('group.memberscandecorate') } /> }
|
||||
</div>
|
||||
</div>
|
||||
<Text small>{ LocalizeText('group.created', [ 'date', 'owner' ], [ groupInformation.createdAt, groupInformation.ownerName ]) }</Text>
|
||||
</div>
|
||||
<Text small className="group-description" overflow="auto">{ groupInformation.description }</Text>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Text pointer small underline onClick={ () => handleAction('homeroom') }>{ LocalizeText('group.linktobase') }</Text>
|
||||
<Text pointer small underline onClick={ () => handleAction('furniture') }>{ LocalizeText('group.buyfurni') }</Text>
|
||||
<Text pointer small underline onClick={ () => handleAction('popular_groups') }>{ LocalizeText('group.showgroups') }</Text>
|
||||
{ groupInformation.hasForum &&
|
||||
<Text pointer small underline onClick={ () => handleAction('forum') }>{ LocalizeText('group.showforum') }</Text> }
|
||||
</div>
|
||||
{ (groupInformation.type !== GroupType.PRIVATE || groupInformation.type === GroupType.PRIVATE && groupInformation.membershipType === GroupMembershipType.MEMBER) &&
|
||||
<Button disabled={ (groupInformation.membershipType === GroupMembershipType.REQUEST_PENDING) || isRealOwner } onClick={ handleButtonClick }>
|
||||
{ LocalizeText(getButtonText()) }
|
||||
</Button> }
|
||||
<div className="nitro-extended-profile-group-info__role">
|
||||
{ getRoleIcon() }
|
||||
</div>
|
||||
</div>
|
||||
</Grid>
|
||||
<div className="nitro-extended-profile-group-info__content">
|
||||
<div className="nitro-extended-profile-group-info__header-copy">
|
||||
<div className="flex items-center gap-2">
|
||||
<Text bold>{ groupInformation.title }</Text>
|
||||
<div className="flex gap-1">
|
||||
<i className={ 'nitro-icon icon-group-type-' + groupInformation.type } title={ LocalizeText(`group.edit.settings.type.${ STATES[groupInformation.type] }.help`) } />
|
||||
{ groupInformation.canMembersDecorate &&
|
||||
<i className="nitro-icon icon-group-decorate" title={ LocalizeText('group.memberscandecorate') } /> }
|
||||
</div>
|
||||
</div>
|
||||
<Text small>{ LocalizeText('group.created', [ 'date', 'owner' ], [ groupInformation.createdAt, groupInformation.ownerName ]) }</Text>
|
||||
</div>
|
||||
<Text small className="nitro-extended-profile-group-info__description" overflow="auto">{ groupInformation.description }</Text>
|
||||
<div className="nitro-extended-profile-group-info__links">
|
||||
<Text pointer small underline onClick={ () => handleAction('homeroom') }>{ LocalizeText('group.linktobase') }</Text>
|
||||
<Text pointer small underline onClick={ () => handleAction('furniture') }>{ LocalizeText('group.buyfurni') }</Text>
|
||||
<Text pointer small underline onClick={ () => handleAction('popular_groups') }>{ LocalizeText('group.showgroups') }</Text>
|
||||
{ groupInformation.hasForum &&
|
||||
<Text pointer small underline onClick={ () => handleAction('forum') }>{ LocalizeText('group.showforum') }</Text> }
|
||||
{ groupInformation.isOwner &&
|
||||
<Text pointer small underline onClick={ () => handleAction('manage') }>{ LocalizeText('group.manage') }</Text> }
|
||||
</div>
|
||||
{ (groupInformation.type !== GroupType.PRIVATE || groupInformation.type === GroupType.PRIVATE && groupInformation.membershipType === GroupMembershipType.MEMBER) &&
|
||||
<Button className="nitro-extended-profile-group-info__button" disabled={ (groupInformation.membershipType === GroupMembershipType.REQUEST_PENDING) || isRealOwner } onClick={ handleButtonClick }>
|
||||
{ LocalizeText(getButtonText()) }
|
||||
</Button> }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { GroupInformationComposer, GroupInformationEvent, GroupInformationParser, HabboGroupEntryData } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { SendMessageComposer, ToggleFavoriteGroup } from '../../api';
|
||||
import { AutoGrid, Column, Grid, GridProps, LayoutBadgeImageView, LayoutGridItem } from '../../common';
|
||||
import { LocalizeText, SendMessageComposer, ToggleFavoriteGroup } from '../../api';
|
||||
import { Column, GridProps, LayoutBadgeImageView, LayoutGridItem } from '../../common';
|
||||
import { useMessageEvent } from '../../hooks';
|
||||
import { GroupInformationView } from '../groups/views/GroupInformationView';
|
||||
|
||||
@@ -14,7 +14,7 @@ interface GroupsContainerViewProps extends GridProps
|
||||
|
||||
export const GroupsContainerView: FC<GroupsContainerViewProps> = props =>
|
||||
{
|
||||
const { itsMe = null, groups = null, onLeaveGroup = null, overflow = 'hidden', gap = 2, ...rest } = props;
|
||||
const { itsMe = null, groups = null, onLeaveGroup = null } = props;
|
||||
const [ selectedGroupId, setSelectedGroupId ] = useState<number>(null);
|
||||
const [ groupInformation, setGroupInformation ] = useState<GroupInformationParser>(null);
|
||||
|
||||
@@ -55,7 +55,7 @@ export const GroupsContainerView: FC<GroupsContainerViewProps> = props =>
|
||||
if(!groups || !groups.length)
|
||||
{
|
||||
return (
|
||||
<Column center fullHeight>
|
||||
<Column center fullHeight className="nitro-extended-profile-groups">
|
||||
<div className="flex justify-center gap-2">
|
||||
<div className="no-group-spritesheet image-1" />
|
||||
<div className="no-group-spritesheet image-2" />
|
||||
@@ -66,25 +66,28 @@ export const GroupsContainerView: FC<GroupsContainerViewProps> = props =>
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid gap={ 2 } overflow={ overflow } { ...rest }>
|
||||
<Column alignItems="center" overflow="auto" size={ 2 }>
|
||||
<AutoGrid className="w-[50px]" columnCount={ 1 } columnMinHeight={ 50 } overflow={ null }>
|
||||
<div className="nitro-extended-profile-groups">
|
||||
<div className="nitro-extended-profile-groups__sidebar">
|
||||
<div className="nitro-extended-profile-groups__count">
|
||||
{ LocalizeText('extendedprofile.groups.count', [ 'count' ], [ groups.length.toString() ]) }
|
||||
</div>
|
||||
<div className="nitro-extended-profile-groups__list">
|
||||
{ groups.map((group, index) =>
|
||||
{
|
||||
return (
|
||||
<LayoutGridItem key={ index } className="p-1" itemActive={ (selectedGroupId === group.groupId) } overflow="unset" onClick={ () => setSelectedGroupId(group.groupId) }>
|
||||
<LayoutGridItem key={ index } className="nitro-extended-profile-groups__item p-1" itemActive={ (selectedGroupId === group.groupId) } overflow="unset" onClick={ () => setSelectedGroupId(group.groupId) }>
|
||||
{ itsMe &&
|
||||
<i className={ 'absolute inset-e-0 top-0 z-20 nitro-icon icon-group-' + (group.favourite ? 'favorite' : 'not-favorite') } onClick={ () => ToggleFavoriteGroup(group) } /> }
|
||||
<i className={ 'absolute inset-e-0 top-0 z-20 nitro-icon icon-group-' + (group.favourite ? 'favorite' : 'not-favorite') } onClick={ event => { event.stopPropagation(); ToggleFavoriteGroup(group); } } /> }
|
||||
<LayoutBadgeImageView badgeCode={ group.badgeCode } isGroup={ true } />
|
||||
</LayoutGridItem>
|
||||
);
|
||||
}) }
|
||||
</AutoGrid>
|
||||
</Column>
|
||||
<Column overflow="hidden" size={ 10 }>
|
||||
</div>
|
||||
</div>
|
||||
<div className="nitro-extended-profile-groups__details">
|
||||
{ groupInformation &&
|
||||
<GroupInformationView groupInformation={ groupInformation } onClose={ onLeaveGroup } /> }
|
||||
</Column>
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,24 +23,24 @@ export const RelationshipsContainerView: FC<RelationshipsContainerViewProps> = p
|
||||
const relationshipName = RelationshipStatusEnum.RELATIONSHIP_NAMES[type].toLocaleLowerCase();
|
||||
|
||||
return (
|
||||
<div className="flex w-full gap-1">
|
||||
<Flex center className="h-[25px]">
|
||||
<div className="nitro-extended-profile__relationship">
|
||||
<Flex center className="nitro-extended-profile__relationship-icon">
|
||||
<i className={ `nitro-friends-spritesheet icon-${ relationshipName }` } />
|
||||
</Flex>
|
||||
<div className="flex flex-col grow gap-0">
|
||||
<div className="nitro-card-row flex items-center justify-between px-2 py-1 h-[25px]">
|
||||
<p className="text-sm underline pointer" onClick={ event => (relationshipInfo && (relationshipInfo.randomFriendId >= 1) && GetUserProfile(relationshipInfo.randomFriendId)) }>
|
||||
<div className="nitro-extended-profile__relationship-copy">
|
||||
<div className="nitro-extended-profile__relationship-box">
|
||||
<p className="nitro-extended-profile__relationship-name" onClick={ event => (relationshipInfo && (relationshipInfo.randomFriendId >= 1) && GetUserProfile(relationshipInfo.randomFriendId)) }>
|
||||
{ (!relationshipInfo || (relationshipInfo.friendCount === 0)) &&
|
||||
LocalizeText('extendedprofile.add.friends') }
|
||||
{ (relationshipInfo && (relationshipInfo.friendCount >= 1)) &&
|
||||
relationshipInfo.randomFriendName }
|
||||
</p>
|
||||
{ (relationshipInfo && (relationshipInfo.friendCount >= 1)) &&
|
||||
<div className="flex items-center justify-center w-[50px] h-[50px] top-[20px] -right-[8px] relative">
|
||||
<LayoutAvatarImageView direction={ 4 } figure={ relationshipInfo.randomFriendFigure } headOnly={ true } />
|
||||
<div className="nitro-extended-profile__relationship-head">
|
||||
<LayoutAvatarImageView direction={ 4 } figure={ relationshipInfo.randomFriendFigure } headOnly={ true } classNames={ [ '!w-auto', '!h-auto', '!left-0' ] } />
|
||||
</div> }
|
||||
</div>
|
||||
<p className="italics text-sm mt-[2px] ml-[5px] text-[#939392]!">
|
||||
<p className="nitro-extended-profile__relationship-subcopy">
|
||||
{ (!relationshipInfo || (relationshipInfo.friendCount === 0)) &&
|
||||
LocalizeText('extendedprofile.no.friends.in.this.category') }
|
||||
{ (relationshipInfo && (relationshipInfo.friendCount > 1)) &&
|
||||
|
||||
@@ -1,28 +1,35 @@
|
||||
import { GetSessionDataManager, RequestFriendComposer, UserProfileParser } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { CreateLinkEvent, GetSessionDataManager, RelationshipStatusInfoMessageParser, RequestFriendComposer, UserProfileParser } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useMemo, useState } from 'react';
|
||||
import { FriendlyTime, LocalizeText, SendMessageComposer } from '../../api';
|
||||
import { LayoutAvatarImageView, Text, UserIdentityView } from '../../common';
|
||||
import { LayoutAvatarImageView, LayoutBadgeImageView, Text, UserIdentityView } from '../../common';
|
||||
import { badgeEmblemDefault } from '../../assets/images/leaderboard_badge';
|
||||
import { level as profileLevelIcon, rooms as profileRoomsIcon } from '../../assets/images/user-profile';
|
||||
import { RelationshipsContainerView } from './RelationshipsContainerView';
|
||||
|
||||
export const UserContainerView: FC<{
|
||||
userProfile: UserProfileParser;
|
||||
}> = props =>
|
||||
interface UserContainerViewProps
|
||||
{
|
||||
const { userProfile = null } = props;
|
||||
userProfile: UserProfileParser;
|
||||
userBadges?: string[];
|
||||
userRelationships?: RelationshipStatusInfoMessageParser;
|
||||
onOpenRooms?: () => void;
|
||||
}
|
||||
|
||||
export const UserContainerView: FC<UserContainerViewProps> = props =>
|
||||
{
|
||||
const { userProfile = null, userBadges = [], userRelationships = null, onOpenRooms = null } = props;
|
||||
|
||||
const [ requestSent, setRequestSent ] = useState(userProfile.requestSent);
|
||||
|
||||
const isOwnProfile = (userProfile.id === GetSessionDataManager().userId);
|
||||
|
||||
const canSendFriendRequest = !requestSent && (!isOwnProfile && !userProfile.isMyFriend && !userProfile.requestSent);
|
||||
const infostandBackgroundClass = `background-${ userProfile.backgroundId ?? 'default' }`;
|
||||
const infostandStandClass = `stand-${ userProfile.standId ?? 'default' }`;
|
||||
const infostandOverlayClass = `overlay-${ userProfile.overlayId ?? 'default' }`;
|
||||
const selectedBadges = useMemo(() => [ ...userBadges ].slice(0, 5), [ userBadges ]);
|
||||
const totalBadges = ((userProfile as any).totalBadges ?? userBadges.length ?? 0);
|
||||
|
||||
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);
|
||||
|
||||
SendMessageComposer(new RequestFriendComposer(userProfile.username));
|
||||
};
|
||||
|
||||
@@ -32,94 +39,115 @@ export const UserContainerView: FC<{
|
||||
}, [ userProfile ]);
|
||||
|
||||
return (
|
||||
<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 } />
|
||||
<div className={`absolute inset-0 profile-overlay ${infostandOverlayClass}`} />
|
||||
<div className="nitro-extended-profile">
|
||||
<div className="nitro-extended-profile__top">
|
||||
<div className="nitro-extended-profile__left">
|
||||
<div className="nitro-extended-profile__identity">
|
||||
<div className={ `nitro-extended-profile__avatar-shell profile-background ${ infostandBackgroundClass }` }>
|
||||
<div className={ `nitro-extended-profile__avatar-stand profile-stand ${ infostandStandClass }` } />
|
||||
<LayoutAvatarImageView figure={ userProfile.figure } direction={ 2 } classNames={ [ 'nitro-extended-profile__avatar-image' ] } />
|
||||
<div className={ `nitro-extended-profile__avatar-overlay profile-overlay ${ infostandOverlayClass }` } />
|
||||
</div>
|
||||
<div className="nitro-extended-profile__identity-copy">
|
||||
<UserIdentityView
|
||||
className="nitro-extended-profile__username"
|
||||
displayOrder={ userProfile.displayOrder }
|
||||
nickIcon={ userProfile.nickIcon }
|
||||
prefixColor={ userProfile.prefixColor }
|
||||
prefixEffect={ userProfile.prefixEffect }
|
||||
prefixFont={ userProfile.prefixFont }
|
||||
prefixIcon={ userProfile.prefixIcon }
|
||||
prefixText={ userProfile.prefixText }
|
||||
username={ userProfile.username } />
|
||||
<p className="nitro-extended-profile__motto">{ userProfile.motto || '\u00A0' }</p>
|
||||
<p
|
||||
className="nitro-extended-profile__meta"
|
||||
dangerouslySetInnerHTML={ {
|
||||
__html: LocalizeText('extendedprofile.created', [ 'created' ], [ userProfile.registration ])
|
||||
} }
|
||||
/>
|
||||
<p
|
||||
className="nitro-extended-profile__meta"
|
||||
dangerouslySetInnerHTML={ {
|
||||
__html: LocalizeText('extendedprofile.last.login', [ 'lastlogin' ], [ FriendlyTime.format(userProfile.secondsSinceLastVisit, '.ago', 2) ])
|
||||
} }
|
||||
/>
|
||||
<p className="nitro-extended-profile__meta nitro-extended-profile__meta--strong">
|
||||
<b>{ LocalizeText('extendedprofile.achievementscore') }</b> { userProfile.achievementPoints }
|
||||
</p>
|
||||
<div className="nitro-extended-profile__status">
|
||||
<div className="nitro-extended-profile__presence">
|
||||
<i className={ `nitro-icon ${ userProfile.isOnline ? 'icon-pf-online' : 'icon-pf-offline' }` } />
|
||||
</div>
|
||||
<div className="nitro-extended-profile__status-copy">
|
||||
{ canSendFriendRequest &&
|
||||
<button className="nitro-extended-profile__friend-button" type="button" onClick={ addFriend }>
|
||||
{ LocalizeText('extendedprofile.addasafriend') }
|
||||
</button> }
|
||||
{ !canSendFriendRequest &&
|
||||
<>
|
||||
<i className="nitro-icon icon-pf-tick" />
|
||||
<span className="nitro-extended-profile__status-text">
|
||||
{ isOwnProfile && LocalizeText('extendedprofile.me') }
|
||||
{ userProfile.isMyFriend && LocalizeText('extendedprofile.friend') }
|
||||
{ (requestSent || userProfile.requestSent) && LocalizeText('extendedprofile.friendrequestsent') }
|
||||
</span>
|
||||
</> }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ isOwnProfile &&
|
||||
<div className="nitro-extended-profile__actions">
|
||||
<button className="nitro-extended-profile__link" type="button">
|
||||
{ LocalizeText('extended.profile.change.looks') }
|
||||
</button>
|
||||
<button className="nitro-extended-profile__link" type="button" onClick={ () => CreateLinkEvent('inventory/open/badges') }>
|
||||
{ LocalizeText('extended.profile.change.badges') }
|
||||
</button>
|
||||
</div> }
|
||||
|
||||
<div className="nitro-extended-profile__badges">
|
||||
{ [ 0, 1, 2, 3, 4 ].map(index => (
|
||||
<button key={ index } className="nitro-extended-profile__badge-slot" type="button">
|
||||
{ selectedBadges[index] && <LayoutBadgeImageView badgeCode={ selectedBadges[index] } highlightRarity showInfo showRarityInfo /> }
|
||||
</button>
|
||||
)) }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="nitro-extended-profile__separator" />
|
||||
|
||||
<div className="nitro-extended-profile__right">
|
||||
<p
|
||||
className="nitro-extended-profile__meta"
|
||||
dangerouslySetInnerHTML={ {
|
||||
__html: LocalizeText('extendedprofile.friends.count', [ 'count' ], [ userProfile.friendsCount ])
|
||||
} }
|
||||
/>
|
||||
<p className="nitro-extended-profile__relationships-label">{ LocalizeText('extendedprofile.relstatus') }</p>
|
||||
{ userRelationships &&
|
||||
<RelationshipsContainerView relationships={ userRelationships } /> }
|
||||
{ !userRelationships &&
|
||||
<Text small variant="muted">{ LocalizeText('generic.loading') }</Text> }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-0">
|
||||
<UserIdentityView
|
||||
className="leading-tight"
|
||||
displayOrder={ userProfile.displayOrder }
|
||||
nickIcon={ userProfile.nickIcon }
|
||||
prefixColor={ userProfile.prefixColor }
|
||||
prefixEffect={ userProfile.prefixEffect }
|
||||
prefixFont={ userProfile.prefixFont }
|
||||
prefixIcon={ userProfile.prefixIcon }
|
||||
prefixText={ userProfile.prefixText }
|
||||
username={ userProfile.username } />
|
||||
<p className="text-sm italic leading-tight">{ userProfile.motto }</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p
|
||||
className="text-sm leading-none"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: LocalizeText(
|
||||
'extendedprofile.created',
|
||||
['created'],
|
||||
[userProfile.registration]
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<p
|
||||
className="text-sm leading-none"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: LocalizeText(
|
||||
'extendedprofile.last.login',
|
||||
['lastlogin'],
|
||||
[FriendlyTime.format(userProfile.secondsSinceLastVisit, '.ago', 2)]
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<p
|
||||
className="text-sm leading-none"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: LocalizeText(
|
||||
'extendedprofile.friends.count',
|
||||
['count'],
|
||||
[userProfile.friendsCount]
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="text-sm leading-none">
|
||||
<b>{ LocalizeText('extendedprofile.achievementscore') }</b> { userProfile.achievementPoints }
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{ userProfile.isOnline &&
|
||||
<i className="nitro-icon icon-pf-online" /> }
|
||||
|
||||
{ !userProfile.isOnline &&
|
||||
<i className="nitro-icon icon-pf-offline" /> }
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{ canSendFriendRequest &&
|
||||
<Text pointer small underline onClick={ addFriend }>
|
||||
{ LocalizeText('extendedprofile.addasafriend') }
|
||||
</Text> }
|
||||
|
||||
{ !canSendFriendRequest &&
|
||||
<>
|
||||
<i className="nitro-icon icon-pf-tick" />
|
||||
|
||||
{ isOwnProfile &&
|
||||
<p>{ LocalizeText('extendedprofile.me') }</p> }
|
||||
|
||||
{ userProfile.isMyFriend &&
|
||||
<p>{ LocalizeText('extendedprofile.friend') }</p> }
|
||||
|
||||
{ (requestSent || userProfile.requestSent) &&
|
||||
<p>{ LocalizeText('extendedprofile.friendrequestsent') }</p> }
|
||||
</> }
|
||||
</div>
|
||||
<div className="nitro-extended-profile__summary-bar">
|
||||
<button className="nitro-extended-profile__summary-button" type="button" onClick={ onOpenRooms }>
|
||||
<img className="nitro-extended-profile__summary-icon" src={ profileRoomsIcon } alt="" />
|
||||
<span className="nitro-extended-profile__summary-label">{ LocalizeText('extendedprofile.rooms') }</span>
|
||||
</button>
|
||||
<button className="nitro-extended-profile__summary-button nitro-extended-profile__summary-button--center" type="button" onClick={ () => CreateLinkEvent('badge-leaderboard/show') }>
|
||||
<img className="nitro-extended-profile__summary-icon nitro-extended-profile__summary-icon--badge" src={ badgeEmblemDefault } alt="" />
|
||||
<span className="nitro-extended-profile__summary-label">{ LocalizeText('inventory.badges') }</span>
|
||||
<span className="nitro-extended-profile__summary-value">{ totalBadges }</span>
|
||||
</button>
|
||||
<div className="nitro-extended-profile__summary-button">
|
||||
<img className="nitro-extended-profile__summary-icon" src={ profileLevelIcon } alt="" />
|
||||
<span className="nitro-extended-profile__summary-label">{ LocalizeText('extendedprofile.achievementscore') }</span>
|
||||
<span className="nitro-extended-profile__summary-value">{ userProfile.achievementPoints }</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
import { ExtendedProfileChangedMessageEvent, GetSessionDataManager, NavigatorSearchComposer, NavigatorSearchEvent, RelationshipStatusInfoEvent, RelationshipStatusInfoMessageParser, RoomDataParser, RoomEngineObjectEvent, RoomObjectCategory, RoomObjectType, UserCurrentBadgesComposer, UserCurrentBadgesEvent, UserProfileEvent, UserProfileParser, UserRelationshipsComposer } from '@nitrots/nitro-renderer';
|
||||
import { ExtendedProfileChangedMessageEvent, GetSessionDataManager, NavigatorSearchComposer, RelationshipStatusInfoEvent, RelationshipStatusInfoMessageParser, RoomEngineObjectEvent, RoomObjectCategory, RoomObjectType, UserCurrentBadgesComposer, UserCurrentBadgesEvent, UserProfileEvent, UserProfileParser, UserRelationshipsComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useState } from 'react';
|
||||
import { CreateRoomSession, GetRoomSession, GetUserProfile, LocalizeText, SendMessageComposer } from '../../api';
|
||||
import { Flex, Text } from '../../common';
|
||||
import { BadgeInfoView } from './BadgeInfoView';
|
||||
import { GetRoomSession, GetUserProfile, LocalizeText, SendMessageComposer } from '../../api';
|
||||
import { useMessageEvent, useNitroEvent } from '../../hooks';
|
||||
import { NitroCard } from '../../layout';
|
||||
import { FriendsContainerView } from './FriendsContainerView';
|
||||
import { GroupsContainerView } from './GroupsContainerView';
|
||||
import { UserContainerView } from './UserContainerView';
|
||||
|
||||
type ProfileTab = 'badge' | 'friends' | 'rooms' | 'groups';
|
||||
|
||||
export const UserProfileView: FC<{}> = props =>
|
||||
export const UserProfileView: FC<{}> = () =>
|
||||
{
|
||||
const [ userProfile, setUserProfile ] = useState<UserProfileParser>(null);
|
||||
const [ userBadges, setUserBadges ] = useState<string[]>([]);
|
||||
const [ userRelationships, setUserRelationships ] = useState<RelationshipStatusInfoMessageParser>(null);
|
||||
const [ activeTab, setActiveTab ] = useState<ProfileTab>('badge');
|
||||
const [ userRooms, setUserRooms ] = useState<RoomDataParser[]>(null);
|
||||
|
||||
const onClose = () =>
|
||||
{
|
||||
setUserProfile(null);
|
||||
setUserBadges([]);
|
||||
setUserRelationships(null);
|
||||
setActiveTab('badge');
|
||||
setUserRooms(null);
|
||||
};
|
||||
|
||||
const onLeaveGroup = () =>
|
||||
@@ -35,11 +26,9 @@ export const UserProfileView: FC<{}> = props =>
|
||||
GetUserProfile(userProfile.id);
|
||||
};
|
||||
|
||||
const onTabClick = (tab: ProfileTab) =>
|
||||
const onOpenRooms = () =>
|
||||
{
|
||||
setActiveTab(tab);
|
||||
|
||||
if(tab === 'rooms' && !userRooms && userProfile)
|
||||
if(userProfile)
|
||||
{
|
||||
SendMessageComposer(new NavigatorSearchComposer('hotel_view', `owner:${ userProfile.username }`));
|
||||
}
|
||||
@@ -80,8 +69,6 @@ export const UserProfileView: FC<{}> = props =>
|
||||
{
|
||||
setUserBadges([]);
|
||||
setUserRelationships(null);
|
||||
setActiveTab('badge');
|
||||
setUserRooms(null);
|
||||
}
|
||||
|
||||
SendMessageComposer(new UserCurrentBadgesComposer(parser.id));
|
||||
@@ -97,28 +84,6 @@ export const UserProfileView: FC<{}> = props =>
|
||||
GetUserProfile(parser.userId);
|
||||
});
|
||||
|
||||
useMessageEvent<NavigatorSearchEvent>(NavigatorSearchEvent, event =>
|
||||
{
|
||||
if(!userProfile || activeTab !== 'rooms') return;
|
||||
|
||||
const parser = event.getParser();
|
||||
const result = parser.result;
|
||||
|
||||
if(!result) return;
|
||||
|
||||
const rooms: RoomDataParser[] = [];
|
||||
|
||||
for(const resultList of result.results)
|
||||
{
|
||||
if(resultList.rooms && resultList.rooms.length)
|
||||
{
|
||||
for(const room of resultList.rooms) rooms.push(room);
|
||||
}
|
||||
}
|
||||
|
||||
setUserRooms(rooms);
|
||||
});
|
||||
|
||||
useNitroEvent<RoomEngineObjectEvent>(RoomEngineObjectEvent.SELECTED, event =>
|
||||
{
|
||||
if(!userProfile) return;
|
||||
@@ -135,82 +100,22 @@ export const UserProfileView: FC<{}> = props =>
|
||||
if(!userProfile) return null;
|
||||
|
||||
return (
|
||||
<NitroCard className="w-[470px] h-[460px]" uniqueKey="nitro-user-profile">
|
||||
<NitroCard className="nitro-extended-profile-window w-[521px] h-[537px]" uniqueKey="nitro-user-profile">
|
||||
<NitroCard.Header
|
||||
headerText={ LocalizeText('extendedprofile.caption') }
|
||||
onCloseClick={ onClose } />
|
||||
<NitroCard.Content className="overflow-hidden !p-0 flex flex-col">
|
||||
<div className="p-2">
|
||||
<UserContainerView userProfile={ userProfile } />
|
||||
<NitroCard.Content className="nitro-extended-profile-window__content overflow-hidden !p-0 flex flex-col">
|
||||
<div className="px-[10px] pt-[8px]">
|
||||
<UserContainerView
|
||||
userBadges={ userBadges }
|
||||
userProfile={ userProfile }
|
||||
userRelationships={ userRelationships }
|
||||
onOpenRooms={ onOpenRooms } />
|
||||
</div>
|
||||
<NitroCard.Tabs>
|
||||
<NitroCard.TabItem isActive={ activeTab === 'badge' } count={ userBadges.length } onClick={ () => onTabClick('badge') }>
|
||||
{ LocalizeText('levelinfo.category.badge') }
|
||||
</NitroCard.TabItem>
|
||||
<NitroCard.TabItem isActive={ activeTab === 'friends' } count={ userProfile.friendsCount } onClick={ () => onTabClick('friends') }>
|
||||
{ LocalizeText('navigator.tab.3') }
|
||||
</NitroCard.TabItem>
|
||||
<NitroCard.TabItem isActive={ activeTab === 'rooms' } onClick={ () => onTabClick('rooms') }>
|
||||
{ LocalizeText('navigator.tab.2') }
|
||||
</NitroCard.TabItem>
|
||||
<NitroCard.TabItem isActive={ activeTab === 'groups' } count={ userProfile.groups?.length } onClick={ () => onTabClick('groups') }>
|
||||
{ LocalizeText('navigator.searchcode.title.groups') }
|
||||
</NitroCard.TabItem>
|
||||
</NitroCard.Tabs>
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
{ activeTab === 'badge' && (
|
||||
<div className="nitro-card-panel flex flex-wrap content-start gap-2 p-2 h-full">
|
||||
{ userBadges && (userBadges.length > 0)
|
||||
? userBadges.map((badge, index) => (
|
||||
<BadgeInfoView key={ badge + index } badgeCode={ badge } />
|
||||
))
|
||||
: (
|
||||
<Flex center fullWidth className="h-full">
|
||||
<Text small variant="muted">{ LocalizeText('extendedprofile.badge.empty') }</Text>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
) }
|
||||
{ activeTab === 'friends' && (
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
{ userRelationships ? (
|
||||
<FriendsContainerView friendsCount={ userProfile.friendsCount } relationships={ userRelationships } />
|
||||
) : (
|
||||
<Flex center className="h-full">
|
||||
<Text small variant="muted">{ LocalizeText('generic.loading') }</Text>
|
||||
</Flex>
|
||||
) }
|
||||
</div>
|
||||
) }
|
||||
{ activeTab === 'rooms' && (
|
||||
<div className="flex flex-col gap-1 h-full">
|
||||
{ !userRooms && (
|
||||
<Flex center className="h-full">
|
||||
<Text small variant="muted">{ LocalizeText('extendedprofile.rooms.loading') }</Text>
|
||||
</Flex>
|
||||
) }
|
||||
{ userRooms && userRooms.length === 0 && (
|
||||
<Flex center className="h-full">
|
||||
<Text small variant="muted">{ LocalizeText('extendedprofile.rooms.empty') }</Text>
|
||||
</Flex>
|
||||
) }
|
||||
{ userRooms && userRooms.length > 0 && userRooms.map(room => (
|
||||
<Flex key={ room.roomId } alignItems="center" gap={ 2 } className="nitro-card-row px-2 py-1.5 cursor-pointer" onClick={ () => CreateRoomSession(room.roomId) }>
|
||||
<div className="flex flex-col min-w-0 grow">
|
||||
<Text bold small truncate>{ room.roomName }</Text>
|
||||
{ room.description && <Text small truncate variant="muted">{ room.description }</Text> }
|
||||
</div>
|
||||
<Text small variant="muted" className="shrink-0">{ room.userCount }/{ room.maxUserCount }</Text>
|
||||
</Flex>
|
||||
)) }
|
||||
</div>
|
||||
) }
|
||||
{ activeTab === 'groups' && (
|
||||
<div className="h-full">
|
||||
<GroupsContainerView fullWidth groups={ userProfile.groups } itsMe={ userProfile.id === GetSessionDataManager().userId } onLeaveGroup={ onLeaveGroup } />
|
||||
</div>
|
||||
) }
|
||||
<div className="nitro-extended-profile-window__body nitro-extended-profile-window__body--groups flex-1 overflow-hidden px-[10px] pb-[10px] pt-[6px]">
|
||||
<div className="nitro-extended-profile-window__panel h-full p-2">
|
||||
<GroupsContainerView fullWidth groups={ userProfile.groups } itsMe={ userProfile.id === GetSessionDataManager().userId } onLeaveGroup={ onLeaveGroup } />
|
||||
</div>
|
||||
</div>
|
||||
</NitroCard.Content>
|
||||
</NitroCard>
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
.nitro-catalog-classic-window {
|
||||
width: 606px !important;
|
||||
height: 565px !important;
|
||||
max-width: 606px !important;
|
||||
min-width: 606px !important;
|
||||
min-height: 565px !important;
|
||||
max-height: 565px !important;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-window .nitro-card-title {
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-window .nitro-card-header-shell {
|
||||
min-height: 38px;
|
||||
max-height: 38px;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-admin-banner {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.18);
|
||||
background: linear-gradient(180deg, #f4d45d 0%, #d8b43e 100%);
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-tabs-shell {
|
||||
flex-wrap: nowrap;
|
||||
gap: 1px;
|
||||
min-height: 30px;
|
||||
max-height: 30px;
|
||||
padding: 0 6px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
align-items: end;
|
||||
background: #e7e8df;
|
||||
border-bottom: 1px solid #b8beb4;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-tabs-shell .nitro-card-tab-item {
|
||||
min-height: 28px;
|
||||
padding: 5px 10px 4px;
|
||||
border: 1px solid #8f8f8b;
|
||||
border-bottom: 0;
|
||||
border-radius: 5px 5px 0 0;
|
||||
background: linear-gradient(180deg, #fafaf7 0%, #dde2d9 100%);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-tabs-shell .nitro-card-tab-item:hover {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #e7ece4 100%);
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-tabs-shell .nitro-card-tab-item-active {
|
||||
background: #f2f2eb;
|
||||
transform: translateY(0);
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-content-shell {
|
||||
padding: 6px 8px 8px !important;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-stage {
|
||||
display: grid;
|
||||
grid-template-columns: 196px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-stage.is-navigation-hidden {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-search-shell {
|
||||
padding: 3px;
|
||||
border: 1px solid #a7aba1;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(180deg, #f9f8f2 0%, #eaede5 100%);
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-search-shell input {
|
||||
height: 18px;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
border-width: 1px !important;
|
||||
border-color: #8f9588 !important;
|
||||
border-radius: 3px !important;
|
||||
background: #fff !important;
|
||||
box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-search-shell svg {
|
||||
color: #61645b !important;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-navigation-shell {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 3px 2px 3px 3px;
|
||||
border: 1px solid #a7aba1;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(180deg, #f1f2ec 0%, #d8ddd3 100%);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-navigation-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-navigation-node.is-child {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-navigation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 21px;
|
||||
padding: 1px 6px 1px 5px;
|
||||
border: 1px solid #bdc2ba;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(180deg, #f6f7f2 0%, #e6e9e1 100%);
|
||||
color: #2e2e2e;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-navigation-item:hover {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #ebeee6 100%);
|
||||
border-color: #9ea79b;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-navigation-item.is-active {
|
||||
background: linear-gradient(180deg, #dae7f0 0%, #c4d2de 100%);
|
||||
border-color: #8e9ba5;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-navigation-item.is-drag-over {
|
||||
outline: 2px solid rgba(48, 114, 140, 0.35);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-navigation-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-navigation-icon img,
|
||||
.nitro-catalog-classic-navigation-icon canvas {
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 18px;
|
||||
max-height: 18px;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-navigation-label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-navigation-caret,
|
||||
.nitro-catalog-classic-navigation-favorite,
|
||||
.nitro-catalog-classic-navigation-admin,
|
||||
.nitro-catalog-classic-navigation-drag {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-navigation-caret {
|
||||
color: #676d66 !important;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-layout-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
border: 1px solid #a7aba1;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(180deg, #eceee7 0%, #dfe4da 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-layout-header-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-height: 66px;
|
||||
padding: 5px 7px;
|
||||
border-bottom: 1px solid #c8cdc3;
|
||||
background: linear-gradient(180deg, #f6f6f2 0%, #e9ece4 100%);
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-layout-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1 1 auto;
|
||||
min-height: 32px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-layout-hero img {
|
||||
max-width: 100%;
|
||||
max-height: 32px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-layout-container {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 6px;
|
||||
background: #f2f2eb;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-default-layout {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-offer-panel,
|
||||
.nitro-catalog-classic-welcome {
|
||||
border: 1px solid #bfc4bc;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f3f3ed 100%);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-offer-preview {
|
||||
width: 136px;
|
||||
min-width: 136px;
|
||||
padding: 8px;
|
||||
border-right: 1px solid #c9cec5;
|
||||
background: linear-gradient(180deg, #eef2ea 0%, #dde3d8 100%);
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-offer-info {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-welcome {
|
||||
min-height: 128px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-grid-shell {
|
||||
min-height: 150px;
|
||||
padding: 4px;
|
||||
border: 1px solid #bcc2b8;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(180deg, #f5f5f0 0%, #e4e7de 100%);
|
||||
height: auto;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-grid {
|
||||
gap: 4px !important;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-window .layout-grid-item {
|
||||
height: 54px;
|
||||
border: 1px solid #b8beb6 !important;
|
||||
border-radius: 6px !important;
|
||||
background-color: #d7dde2;
|
||||
background-image: none;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-window .layout-grid-item.is-active {
|
||||
background-color: #e5ebef !important;
|
||||
border-color: #8f978b !important;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-grid-offer-icon {
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-window .nitro-catalog-header {
|
||||
justify-content: flex-start;
|
||||
min-height: 56px;
|
||||
margin-bottom: 6px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid #bec3ba;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f2f2ec 100%);
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-window .nitro-catalog-header img {
|
||||
max-width: 100%;
|
||||
max-height: 48px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-height: 16px;
|
||||
overflow: hidden;
|
||||
color: #666a63;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-breadcrumb-segment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-breadcrumb-separator {
|
||||
color: #9ea395;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-navigation-shell::-webkit-scrollbar,
|
||||
.nitro-catalog-classic-layout-container :is(.overflow-auto, .nitro-card-content-shell, .nitro-catalog-classic-grid-shell)::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-navigation-shell::-webkit-scrollbar-track,
|
||||
.nitro-catalog-classic-layout-container :is(.overflow-auto, .nitro-card-content-shell, .nitro-catalog-classic-grid-shell)::-webkit-scrollbar-track {
|
||||
border-left: 1px solid #c2c6be;
|
||||
background: #dde2d8;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-navigation-shell::-webkit-scrollbar-thumb,
|
||||
.nitro-catalog-classic-layout-container :is(.overflow-auto, .nitro-card-content-shell, .nitro-catalog-classic-grid-shell)::-webkit-scrollbar-thumb {
|
||||
border: 1px solid #7d8680;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(180deg, #a8b3ae 0%, #89948f 100%);
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-navigation-shell::-webkit-scrollbar-button:single-button:vertical:decrement,
|
||||
.nitro-catalog-classic-layout-container :is(.overflow-auto, .nitro-card-content-shell, .nitro-catalog-classic-grid-shell)::-webkit-scrollbar-button:single-button:vertical:decrement {
|
||||
height: 12px;
|
||||
background: #dde2d8;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-navigation-shell::-webkit-scrollbar-button:single-button:vertical:increment,
|
||||
.nitro-catalog-classic-layout-container :is(.overflow-auto, .nitro-card-content-shell, .nitro-catalog-classic-grid-shell)::-webkit-scrollbar-button:single-button:vertical:increment {
|
||||
height: 12px;
|
||||
background: #dde2d8;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.nitro-catalog-classic-window {
|
||||
width: min(calc(100vw - 16px), 570px) !important;
|
||||
min-width: 0 !important;
|
||||
height: min(calc(100vh - 16px), 635px) !important;
|
||||
min-height: 0 !important;
|
||||
max-width: calc(100vw - 16px) !important;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-stage {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-sidebar {
|
||||
max-height: 180px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
.nitro-emustats-window {
|
||||
min-width: 1024px;
|
||||
max-width: 1024px;
|
||||
min-height: 700px;
|
||||
max-height: 700px;
|
||||
}
|
||||
|
||||
.nitro-emustats-window__content {
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.nitro-emustats {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
height: 100%;
|
||||
background: #f3f4f8;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.nitro-emustats__sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 18px 14px 14px;
|
||||
background: linear-gradient(180deg, #f8f9fd 0%, #eef1f8 100%);
|
||||
border-right: 1px solid #d8deea;
|
||||
}
|
||||
|
||||
.nitro-emustats__sidebar-brand h2 {
|
||||
margin: 0;
|
||||
font-size: 31px;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
color: #1e2a44;
|
||||
}
|
||||
|
||||
.nitro-emustats__sidebar-brand p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.nitro-emustats__nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.nitro-emustats__nav-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid #d8deea;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.18s ease, border-color 0.18s ease, transform 0.18s ease;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.nitro-emustats__nav-button:hover {
|
||||
background: #f8fbff;
|
||||
border-color: #a9b8db;
|
||||
}
|
||||
|
||||
.nitro-emustats__nav-button.is-active {
|
||||
background: linear-gradient(180deg, #e9efff 0%, #dbe7ff 100%);
|
||||
border-color: #7b93dd;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nitro-emustats__nav-button span,
|
||||
.nitro-emustats__nav-button strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.nitro-emustats__nav-button strong {
|
||||
color: #5b6f99;
|
||||
}
|
||||
|
||||
.nitro-emustats__sidebar-footer {
|
||||
margin-top: auto;
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.nitro-emustats__refresh-button {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
border: 1px solid #7b93dd;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, #edf2ff 0%, #dce7ff 100%);
|
||||
color: #26406d;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.nitro-emustats__sidebar-footer p {
|
||||
margin: 10px 0 0;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
color: #7b879b;
|
||||
}
|
||||
|
||||
.nitro-emustats__main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 22px 24px 20px;
|
||||
gap: 16px;
|
||||
background: linear-gradient(180deg, #fbfcff 0%, #f2f5fb 100%);
|
||||
}
|
||||
|
||||
.nitro-emustats__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.nitro-emustats__header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 1.05;
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.nitro-emustats__header p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.nitro-emustats__status-pill {
|
||||
padding: 7px 12px;
|
||||
border-radius: 999px;
|
||||
background: #def7e8;
|
||||
color: #166534;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
border: 1px solid #a7e0be;
|
||||
}
|
||||
|
||||
.nitro-emustats__status-pill[data-status="attention-needed"] {
|
||||
background: #fff1d6;
|
||||
color: #92400e;
|
||||
border-color: #f3ce88;
|
||||
}
|
||||
|
||||
.nitro-emustats__body,
|
||||
.nitro-emustats__overview {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nitro-emustats__body {
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.nitro-emustats__overview {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.nitro-emustats__overview-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.nitro-emustats__metric-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 92px;
|
||||
padding: 12px 13px;
|
||||
border: 1px solid #dbe2ef;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8faff 100%);
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.nitro-emustats__metric-card::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 3px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--emustats-accent, #6366f1);
|
||||
}
|
||||
|
||||
.nitro-emustats__metric-card span {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.nitro-emustats__metric-card strong {
|
||||
margin-top: auto;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.nitro-emustats__metric-card small {
|
||||
margin-top: 6px;
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.nitro-emustats__chart-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 290px;
|
||||
padding: 16px;
|
||||
border: 1px solid #dbe2ef;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8faff 100%);
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.nitro-emustats__section-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.nitro-emustats__section-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.nitro-emustats__section-header p {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.nitro-emustats__chart-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.nitro-emustats__chart-meta span {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.nitro-emustats__chart-meta strong {
|
||||
font-size: 22px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.nitro-emustats__chart-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
min-height: 220px;
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.nitro-emustats__chart-axis {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0 10px 2px;
|
||||
}
|
||||
|
||||
.nitro-emustats__chart-axis span {
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: #7b879b;
|
||||
}
|
||||
|
||||
.nitro-emustats__chart-canvas {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dde5f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nitro-emustats__chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nitro-emustats__chart-grid {
|
||||
stroke: #dde5f0;
|
||||
stroke-width: 0.6;
|
||||
}
|
||||
|
||||
.nitro-emustats__chart-line {
|
||||
fill: none;
|
||||
stroke: #5969d8;
|
||||
stroke-width: 1.4;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.nitro-emustats__chart-hover-line {
|
||||
stroke: rgba(89, 105, 216, 0.38);
|
||||
stroke-width: 0.55;
|
||||
stroke-dasharray: 2 2;
|
||||
}
|
||||
|
||||
.nitro-emustats__chart-hover-point {
|
||||
fill: #ffffff;
|
||||
stroke: #5969d8;
|
||||
stroke-width: 0.9;
|
||||
}
|
||||
|
||||
.nitro-emustats__chart-tooltip {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 92px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d9e1ef;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nitro-emustats__chart-tooltip strong {
|
||||
font-size: 12px;
|
||||
line-height: 1.1;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.nitro-emustats__chart-tooltip span,
|
||||
.nitro-emustats__chart-tooltip small {
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.nitro-emustats__table-shell {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
border: 1px solid #dbe2ef;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.nitro-emustats__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.nitro-emustats__table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: 12px 12px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
background: #eef3fb;
|
||||
color: #4b5d7a;
|
||||
border-bottom: 1px solid #dbe2ef;
|
||||
}
|
||||
|
||||
.nitro-emustats__table tbody td {
|
||||
padding: 11px 12px;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid #edf2f8;
|
||||
color: #1f2937;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nitro-emustats__table tbody tr:nth-child(even) td {
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.nitro-emustats__table-empty {
|
||||
padding: 24px 12px !important;
|
||||
text-align: center;
|
||||
color: #64748b !important;
|
||||
}
|
||||
|
||||
.nitro-emustats__table .is-xs {
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
.nitro-emustats__table .is-sm {
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
.nitro-emustats__table .is-md {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.nitro-emustats__error,
|
||||
.nitro-emustats__empty {
|
||||
padding: 16px 18px;
|
||||
border: 1px solid #dbe2ef;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.nitro-emustats__error {
|
||||
border-color: #fecaca;
|
||||
color: #991b1b;
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.nitro-emustats__detail-layout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.nitro-emustats__detail-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid #dbe2ef;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8faff 100%);
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.nitro-emustats__detail-panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.nitro-emustats__detail-panel-header p {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.nitro-emustats__kv-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nitro-emustats__kv-grid.is-2col {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.nitro-emustats__kv-grid.is-1col {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.nitro-emustats__kv-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e5eaf4;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.nitro-emustats__kv-item span {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.nitro-emustats__kv-item strong {
|
||||
font-size: 14px;
|
||||
line-height: 1.25;
|
||||
color: #1e293b;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.nitro-emustats__detail-panel .nitro-emustats__table-shell {
|
||||
min-height: 220px;
|
||||
max-height: 220px;
|
||||
}
|
||||
|
||||
.nitro-emustats__kv-item strong[data-tone="good"] {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.nitro-emustats__kv-item strong[data-tone="warn"] {
|
||||
color: #b45309;
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
.nitro-extended-profile-window {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-window .nitro-card-header-shell {
|
||||
min-height: 34px;
|
||||
max-height: 34px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-window .nitro-card-title {
|
||||
font-size: 25px;
|
||||
line-height: 1;
|
||||
text-shadow: 1px 1px 0 #4f4f4f;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-window__content {
|
||||
background: #ece8dc;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-window .nitro-card-close-button {
|
||||
right: 6px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__top {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.06fr) 1px minmax(0, 0.94fr);
|
||||
gap: 10px;
|
||||
min-height: 188px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__separator {
|
||||
background: #afafaf;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__left,
|
||||
.nitro-extended-profile__right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__identity {
|
||||
display: grid;
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__avatar-shell {
|
||||
width: 56px;
|
||||
height: 113px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__avatar-stand,
|
||||
.nitro-extended-profile__avatar-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__avatar-image {
|
||||
position: absolute !important;
|
||||
left: 50% !important;
|
||||
bottom: -4px;
|
||||
transform: translateX(-50%);
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__identity-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__username {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__motto {
|
||||
margin: 0;
|
||||
min-height: 24px;
|
||||
color: #242424;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
line-height: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__meta {
|
||||
margin: 0;
|
||||
color: #111;
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__meta--strong {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-height: 23px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__presence {
|
||||
width: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__status-copy {
|
||||
min-height: 23px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__status-text {
|
||||
color: #111;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__friend-button,
|
||||
.nitro-extended-profile__link {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: #0655b7;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__friend-button {
|
||||
min-height: 24px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #9b9b9b;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #e2e2e2 100%);
|
||||
color: #222;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 2px;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__badges {
|
||||
margin-top: 5px;
|
||||
min-height: 55px;
|
||||
border: 1px solid #afafaf;
|
||||
background: linear-gradient(180deg, #f6f3e7 0%, #ede8d8 100%);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 0;
|
||||
padding: 6px 7px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__badge-slot {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__badge-slot .badge-image {
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__right {
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__relationships-label {
|
||||
margin: 3px 0 4px;
|
||||
color: #111;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__relationship {
|
||||
display: grid;
|
||||
grid-template-columns: 19px minmax(0, 1fr);
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__relationship-icon {
|
||||
height: 33px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__relationship-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__relationship-box {
|
||||
min-height: 22px;
|
||||
border: 1px solid #b4b4b4;
|
||||
background: linear-gradient(180deg, #fdfdfb 0%, #ecebe3 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 7px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__relationship-name {
|
||||
margin: 0;
|
||||
color: #111;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
max-width: calc(100% - 38px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__relationship-head {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
top: 50%;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__relationship-subcopy {
|
||||
margin: 1px 0 0 8px;
|
||||
color: #7f7f7f;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__summary-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
align-items: center;
|
||||
min-height: 38px;
|
||||
background: #ece8dc;
|
||||
border-top: 1px solid #afafaf;
|
||||
border-bottom: 1px solid #afafaf;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__summary-button {
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-right: 1px solid #afafaf;
|
||||
color: #111;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__summary-button:first-child {
|
||||
border-left: 1px solid #afafaf;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__summary-button--center {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__summary-icon {
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 32px;
|
||||
max-height: 28px;
|
||||
image-rendering: pixelated;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__summary-icon--badge {
|
||||
max-width: 25px;
|
||||
max-height: 25px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__summary-label {
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.nitro-extended-profile__summary-value {
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-window__body {
|
||||
background: #ece8dc;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-window__body--groups {
|
||||
min-height: 249px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-window__panel {
|
||||
border: 1px solid #afafaf;
|
||||
background: #d6d3cb;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-groups {
|
||||
display: grid;
|
||||
grid-template-columns: 82px minmax(0, 1fr);
|
||||
gap: 7px;
|
||||
min-height: 236px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-groups__sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-groups__count {
|
||||
margin-bottom: 5px;
|
||||
color: #111;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-groups__list {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-groups__item {
|
||||
width: 54px;
|
||||
min-height: 54px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-groups__details {
|
||||
min-width: 0;
|
||||
min-height: 236px;
|
||||
border: 1px solid #9e9e9e;
|
||||
border-radius: 14px;
|
||||
background: #bdbbbb;
|
||||
padding: 11px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-group-info {
|
||||
display: grid;
|
||||
grid-template-columns: 110px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
min-height: 100%;
|
||||
border: 1px solid #9f9f9f;
|
||||
border-radius: 10px;
|
||||
background: #efede4;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-group-info__badge-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-group-info__badge-wrap {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-group-info__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-group-info__role {
|
||||
min-height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-group-info__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-group-info__header-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-group-info__description {
|
||||
margin-top: 10px;
|
||||
min-height: 52px;
|
||||
color: #222;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-group-info__links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-group-info__button {
|
||||
align-self: center;
|
||||
min-width: 162px;
|
||||
min-height: 24px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-group-info .group-badge .badge-image {
|
||||
transform: scale(2.1);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-window .layout-grid-item.active {
|
||||
background: linear-gradient(180deg, #ffe89b 0%, #ffc74b 100%);
|
||||
box-shadow: inset 0 0 0 2px #fff4c4;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-window .layout-grid-item {
|
||||
border-radius: 4px;
|
||||
border: 1px solid #8d8d8d;
|
||||
background: #efede4;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-groups__item .layout-grid-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nitro-extended-profile-window .layout-grid-item .badge-image.group-badge {
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
}
|
||||
@@ -188,6 +188,13 @@ const useChatInputWidgetState = () =>
|
||||
return null;
|
||||
case ':customize':
|
||||
CreateLinkEvent('customize/show');
|
||||
return null;
|
||||
case ':emustats':
|
||||
if(GetSessionDataManager().isModerator)
|
||||
{
|
||||
CreateLinkEvent('emustats/toggle');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import './css/index.css';
|
||||
|
||||
import './css/backgrounds/BackgroundsView.css';
|
||||
import './css/badges/BadgeLeaderboardView.css';
|
||||
import './css/catalog/CatalogClassicView.css';
|
||||
import './css/emustats/EmuStatsView.css';
|
||||
|
||||
import './css/chat/Chats.css';
|
||||
|
||||
@@ -38,6 +40,7 @@ import './css/room/RoomWidgets.css';
|
||||
import './css/slider.css';
|
||||
|
||||
import './css/toolbar/ToolBar.css';
|
||||
import './css/user-profile/UserProfileView.css';
|
||||
|
||||
import './css/widgets/FurnitureWidgets.css';
|
||||
|
||||
|
||||