@@ -18,55 +18,55 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@tanstack/react-query": "5",
|
||||
"@tanstack/react-query-devtools": "5",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"dompurify": "^3.4.2",
|
||||
"@tanstack/react-virtual": "^3.14.2",
|
||||
"dompurify": "^3.4.8",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"emoji-toolkit": "10.0.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"framer-motion": "^12.40.0",
|
||||
"json5": "^2.2.3",
|
||||
"react": "^19.2.5",
|
||||
"react": "^19.2.7",
|
||||
"react-colorful": "^5.7.0",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-error-boundary": "^6.1.1",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-error-boundary": "^6.1.2",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-player": "^2.16.0",
|
||||
"react-player": "^3.4.0",
|
||||
"use-between": "^1.4.0",
|
||||
"zustand": "^5.0.13"
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/postcss": "^4.2.4",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^19.2.16",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.1",
|
||||
"@typescript-eslint/parser": "^8.59.1",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260509.2",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260604.1",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.5.12",
|
||||
"postcss": "^8.5.15",
|
||||
"postcss-nested": "^7.0.2",
|
||||
"sass": "^1.99.0",
|
||||
"sass": "^1.100.0",
|
||||
"sirv": "^3.0.2",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.1",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^3"
|
||||
"typescript-eslint": "^8.60.1",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^3.2.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,6 +640,8 @@
|
||||
'wheel.extra': 'Extra spins: %count%',
|
||||
'wheel.spin': 'SPIN',
|
||||
'wheel.buy': 'Buy spin',
|
||||
'wheel.settings': 'Settings',
|
||||
'wheel.settings.title': 'Wheel of Fortune Settings',
|
||||
'wheel.winners': 'Latest winners',
|
||||
'wheel.winners.empty': 'No winners yet',
|
||||
'wheel.win.title': 'You won!',
|
||||
@@ -707,24 +709,6 @@
|
||||
'chatcmd.client.info': 'Client info',
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Mentions
|
||||
// ------------------------------------------------------------------------
|
||||
'mentions.window.title': 'Mentions',
|
||||
'mentions.window.empty': 'No mentions',
|
||||
'mentions.window.markall': 'Mark all as read',
|
||||
'mentions.tab.title': 'Mentions',
|
||||
'mentions.notification': '%sender% mentioned you in %room%',
|
||||
'mentions.filter.all': 'All',
|
||||
'mentions.filter.unread': 'Unread',
|
||||
'mentions.filter.direct': 'Direct',
|
||||
'mentions.filter.room': 'Room',
|
||||
'mentions.group.today': 'Today',
|
||||
'mentions.group.yesterday': 'Yesterday',
|
||||
'mentions.group.older': 'Earlier',
|
||||
'mentions.type.direct': 'Direct mention',
|
||||
'mentions.type.room': 'Room mention',
|
||||
'mentions.action.goto': 'Go to room',
|
||||
'mentions.action.remove': 'Remove',
|
||||
// Me-menu settings + User account settings window
|
||||
// ------------------------------------------------------------------------
|
||||
'usersettings.tab.general': "General",
|
||||
@@ -790,4 +774,27 @@
|
||||
'usersettings.success.password': "Password updated successfully.",
|
||||
'usersettings.success.email': "Email updated successfully.",
|
||||
'usersettings.success.username': "Username updated. Please log in again with your new name.",
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// @-mention autocomplete (chat input)
|
||||
// ------------------------------------------------------------------------
|
||||
'mentions.window.title': "Mentions",
|
||||
'mentions.window.empty': "No mentions",
|
||||
'mentions.window.markall': "Mark all as read",
|
||||
'mentions.tab.title': "Mentions",
|
||||
'mentions.notification': "%sender% mentioned you in %room%",
|
||||
'mentions.filter.all': "All",
|
||||
'mentions.filter.unread': "Unread",
|
||||
'mentions.filter.direct': "Direct",
|
||||
'mentions.filter.room': "Room",
|
||||
'mentions.group.today': "Today",
|
||||
'mentions.group.yesterday': "Yesterday",
|
||||
'mentions.group.older': "Earlier",
|
||||
'mentions.type.direct': "Direct mention",
|
||||
'mentions.type.room': "Room mention",
|
||||
'mentions.action.goto': "Go to room",
|
||||
'mentions.action.remove': "Remove",
|
||||
'mentions.alias.description.everyone': "Everyone in the hotel",
|
||||
'mentions.alias.description.friends': "Your online friends",
|
||||
'mentions.alias.description.room': "Everyone in this room",
|
||||
}
|
||||
|
||||
@@ -640,6 +640,8 @@
|
||||
'wheel.extra': 'Giri extra: %count%',
|
||||
'wheel.spin': 'GIRA',
|
||||
'wheel.buy': 'Acquista giro',
|
||||
'wheel.settings': 'Configurações',
|
||||
'wheel.settings.title': 'Configuração de Sistema da Roleta',
|
||||
'wheel.winners': 'Ultimi vincitori',
|
||||
'wheel.winners.empty': 'Ancora nessun vincitore',
|
||||
'wheel.win.title': 'Hai vinto!',
|
||||
@@ -707,24 +709,6 @@
|
||||
'chatcmd.client.info': 'Info client',
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Mentions
|
||||
// ------------------------------------------------------------------------
|
||||
'mentions.window.title': 'Menzioni',
|
||||
'mentions.window.empty': 'Nessuna menzione',
|
||||
'mentions.window.markall': 'Segna tutte come lette',
|
||||
'mentions.tab.title': 'Menzioni',
|
||||
'mentions.notification': '%sender% ti ha menzionato in %room%',
|
||||
'mentions.filter.all': 'Tutte',
|
||||
'mentions.filter.unread': 'Non lette',
|
||||
'mentions.filter.direct': 'Dirette',
|
||||
'mentions.filter.room': 'Stanza',
|
||||
'mentions.group.today': 'Oggi',
|
||||
'mentions.group.yesterday': 'Ieri',
|
||||
'mentions.group.older': 'Precedenti',
|
||||
'mentions.type.direct': 'Menzione diretta',
|
||||
'mentions.type.room': 'Menzione di stanza',
|
||||
'mentions.action.goto': 'Vai alla stanza',
|
||||
'mentions.action.remove': 'Rimuovi',
|
||||
// Me-menu settings + User account settings window
|
||||
// ------------------------------------------------------------------------
|
||||
'usersettings.tab.general': "Generale",
|
||||
@@ -790,4 +774,27 @@
|
||||
'usersettings.success.password': "Password aggiornata con successo.",
|
||||
'usersettings.success.email': "Email aggiornata con successo.",
|
||||
'usersettings.success.username': "Nome utente aggiornato. Accedi di nuovo con il tuo nuovo nome.",
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// @-mention autocomplete (chat input)
|
||||
// ------------------------------------------------------------------------
|
||||
'mentions.window.title': "Menzioni",
|
||||
'mentions.window.empty': "Nessuna menzione",
|
||||
'mentions.window.markall': "Segna tutte come lette",
|
||||
'mentions.tab.title': "Menzioni",
|
||||
'mentions.notification': "%sender% ti ha menzionato in %room%",
|
||||
'mentions.filter.all': "Tutte",
|
||||
'mentions.filter.unread': "Non lette",
|
||||
'mentions.filter.direct': "Dirette",
|
||||
'mentions.filter.room': "Stanza",
|
||||
'mentions.group.today': "Oggi",
|
||||
'mentions.group.yesterday': "Ieri",
|
||||
'mentions.group.older': "Precedenti",
|
||||
'mentions.type.direct': "Menzione diretta",
|
||||
'mentions.type.room': "Menzione di stanza",
|
||||
'mentions.action.goto': "Vai alla stanza",
|
||||
'mentions.action.remove': "Rimuovi",
|
||||
'mentions.alias.description.everyone': "Tutti nell'hotel",
|
||||
'mentions.alias.description.friends': "I tuoi amici online",
|
||||
'mentions.alias.description.room': "Tutti in questa stanza",
|
||||
}
|
||||
|
||||
@@ -709,24 +709,6 @@
|
||||
'chatcmd.client.info': 'Client info',
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Mentions
|
||||
// ------------------------------------------------------------------------
|
||||
'mentions.window.title': 'Vermeldingen',
|
||||
'mentions.window.empty': 'Geen vermeldingen',
|
||||
'mentions.window.markall': 'Alles als gelezen markeren',
|
||||
'mentions.tab.title': 'Vermeldingen',
|
||||
'mentions.notification': '%sender% heeft je genoemd in %room%',
|
||||
'mentions.filter.all': 'Alle',
|
||||
'mentions.filter.unread': 'Ongelezen',
|
||||
'mentions.filter.direct': 'Direct',
|
||||
'mentions.filter.room': 'Kamer',
|
||||
'mentions.group.today': 'Vandaag',
|
||||
'mentions.group.yesterday': 'Gisteren',
|
||||
'mentions.group.older': 'Eerder',
|
||||
'mentions.type.direct': 'Directe vermelding',
|
||||
'mentions.type.room': 'Kamervermelding',
|
||||
'mentions.action.goto': 'Ga naar kamer',
|
||||
'mentions.action.remove': 'Verwijderen',
|
||||
// Me-menu settings + User account settings window
|
||||
// ------------------------------------------------------------------------
|
||||
'usersettings.tab.general': "Algemeen",
|
||||
@@ -792,4 +774,27 @@
|
||||
'usersettings.success.password': "Wachtwoord succesvol bijgewerkt.",
|
||||
'usersettings.success.email': "E-mail succesvol bijgewerkt.",
|
||||
'usersettings.success.username': "Gebruikersnaam bijgewerkt. Log opnieuw in met je nieuwe naam.",
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// @-mention autocomplete (chat input)
|
||||
// ------------------------------------------------------------------------
|
||||
'mentions.window.title': "Vermeldingen",
|
||||
'mentions.window.empty': "Geen vermeldingen",
|
||||
'mentions.window.markall': "Markeer alles als gelezen",
|
||||
'mentions.tab.title': "Vermeldingen",
|
||||
'mentions.notification': "%sender% noemde je in %room%",
|
||||
'mentions.filter.all': "Alles",
|
||||
'mentions.filter.unread': "Ongelezen",
|
||||
'mentions.filter.direct': "Direct",
|
||||
'mentions.filter.room': "Kamer",
|
||||
'mentions.group.today': "Vandaag",
|
||||
'mentions.group.yesterday': "Gisteren",
|
||||
'mentions.group.older': "Eerder",
|
||||
'mentions.type.direct': "Directe vermelding",
|
||||
'mentions.type.room': "Kamervermelding",
|
||||
'mentions.action.goto': "Ga naar kamer",
|
||||
'mentions.action.remove': "Verwijderen",
|
||||
'mentions.alias.description.everyone': "Iedereen in het hotel",
|
||||
'mentions.alias.description.friends': "Je vrienden online",
|
||||
'mentions.alias.description.room': "Iedereen in deze kamer",
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { LoginView } from './components/login/LoginView';
|
||||
import { MainView } from './components/MainView';
|
||||
import { ReconnectView } from './components/reconnect/ReconnectView';
|
||||
import { useMessageEvent, useNitroEvent } from './hooks';
|
||||
import { ensureChatCommandListener } from './hooks/rooms/widgets/useChatCommandSelector';
|
||||
|
||||
NitroVersion.UI_VERSION = GetUIVersion();
|
||||
|
||||
@@ -563,9 +562,7 @@ export const App: FC<{}> = props =>
|
||||
bumpProgress(85, taskLabel('loading.task.rooms', 'Loading rooms...'));
|
||||
await GetRoomEngine().init();
|
||||
bumpProgress(92, taskLabel('loading.task.engine', 'Loading graphics engine...'));
|
||||
ensureChatCommandListener();
|
||||
await GetCommunication().init();
|
||||
ensureChatCommandListener();
|
||||
bumpProgress(98, taskLabel('generic.reconnecting', 'Connecting to server...'));
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ export class RoomWidgetUpdateChatInputContentEvent extends RoomWidgetUpdateEvent
|
||||
public static CHAT_INPUT_CONTENT: string = 'RWUCICE_CHAT_INPUT_CONTENT';
|
||||
public static WHISPER: string = 'whisper';
|
||||
public static SHOUT: string = 'shout';
|
||||
public static TEXT: string = 'text';
|
||||
|
||||
private _chatMode: string = '';
|
||||
private _userName: string = '';
|
||||
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 182 B |
|
After Width: | Height: | Size: 179 B |
|
After Width: | Height: | Size: 202 B |
|
After Width: | Height: | Size: 197 B |
|
After Width: | Height: | Size: 317 B |
|
After Width: | Height: | Size: 552 B |
|
After Width: | Height: | Size: 317 B |
|
After Width: | Height: | Size: 317 B |
|
After Width: | Height: | Size: 269 B |
|
After Width: | Height: | Size: 263 B |
|
After Width: | Height: | Size: 250 B |
|
After Width: | Height: | Size: 236 B |
|
After Width: | Height: | Size: 228 B |
|
After Width: | Height: | Size: 231 B |
|
After Width: | Height: | Size: 195 B |
|
After Width: | Height: | Size: 187 B |
|
After Width: | Height: | Size: 202 B |
|
After Width: | Height: | Size: 199 B |
|
After Width: | Height: | Size: 237 B |
|
After Width: | Height: | Size: 221 B |
|
After Width: | Height: | Size: 248 B |
|
After Width: | Height: | Size: 232 B |
|
After Width: | Height: | Size: 178 B |
|
After Width: | Height: | Size: 181 B |
|
After Width: | Height: | Size: 172 B |
|
After Width: | Height: | Size: 188 B |
|
After Width: | Height: | Size: 193 B |
|
After Width: | Height: | Size: 194 B |
|
After Width: | Height: | Size: 183 B |
|
After Width: | Height: | Size: 183 B |
|
After Width: | Height: | Size: 192 B |
|
After Width: | Height: | Size: 175 B |
|
After Width: | Height: | Size: 177 B |
|
After Width: | Height: | Size: 196 B |
|
After Width: | Height: | Size: 188 B |
|
After Width: | Height: | Size: 188 B |
|
After Width: | Height: | Size: 183 B |
|
After Width: | Height: | Size: 175 B |
|
After Width: | Height: | Size: 177 B |
|
After Width: | Height: | Size: 181 B |
@@ -1,3 +1,5 @@
|
||||
import './pixiPatch';
|
||||
|
||||
import { GetConfiguration } from '@nitrots/nitro-renderer';
|
||||
import JSON5 from 'json5';
|
||||
import { configFileUrl, getClientMode, installSecureFetch } from './secure-assets';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GetRenderer, GetTicker, NitroTicker, RoomPreviewer, TextureUtils } from '@nitrots/nitro-renderer';
|
||||
import { GetRenderer, GetTicker, NitroLogger, NitroTicker, RoomPreviewer, TextureUtils } from '@nitrots/nitro-renderer';
|
||||
import { FC, MouseEvent, useEffect, useRef } from 'react';
|
||||
|
||||
export const LayoutRoomPreviewerView: FC<{
|
||||
@@ -8,6 +8,8 @@ export const LayoutRoomPreviewerView: FC<{
|
||||
{
|
||||
const { roomPreviewer = null, height = 0 } = props;
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
const renderFailuresRef = useRef(0);
|
||||
const MAX_RENDER_FAILURES = 6;
|
||||
|
||||
const onClick = (event: MouseEvent<HTMLDivElement>) =>
|
||||
{
|
||||
@@ -21,17 +23,32 @@ export const LayoutRoomPreviewerView: FC<{
|
||||
{
|
||||
if(!elementRef) return;
|
||||
|
||||
renderFailuresRef.current = 0;
|
||||
|
||||
const width = elementRef.current.parentElement.clientWidth;
|
||||
const texture = TextureUtils.createRenderTexture(width, height);
|
||||
|
||||
const noteFailure = (label: string, error: unknown) =>
|
||||
{
|
||||
renderFailuresRef.current += 1;
|
||||
|
||||
if(renderFailuresRef.current >= MAX_RENDER_FAILURES)
|
||||
{
|
||||
NitroLogger.error(`LayoutRoomPreviewerView ${ label } failed ${ renderFailuresRef.current } times; disabling further renders for this preview`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const paintToDOM = () =>
|
||||
{
|
||||
if(renderFailuresRef.current >= MAX_RENDER_FAILURES) return;
|
||||
if(!roomPreviewer || !elementRef.current) return;
|
||||
|
||||
const renderingCanvas = roomPreviewer.getRenderingCanvas();
|
||||
|
||||
if(!renderingCanvas) return;
|
||||
|
||||
try
|
||||
{
|
||||
GetRenderer().render({
|
||||
target: texture,
|
||||
container: renderingCanvas.master,
|
||||
@@ -45,13 +62,28 @@ export const LayoutRoomPreviewerView: FC<{
|
||||
canvas.height = 0;
|
||||
|
||||
elementRef.current.style.backgroundImage = `url(${ base64 })`;
|
||||
renderFailuresRef.current = 0;
|
||||
}
|
||||
catch(error)
|
||||
{
|
||||
noteFailure('paint', error);
|
||||
}
|
||||
};
|
||||
|
||||
const update = (ticker: NitroTicker) =>
|
||||
{
|
||||
if(renderFailuresRef.current >= MAX_RENDER_FAILURES) return;
|
||||
if(!roomPreviewer || !elementRef.current) return;
|
||||
|
||||
try
|
||||
{
|
||||
roomPreviewer.updatePreviewRoomView();
|
||||
}
|
||||
catch(error)
|
||||
{
|
||||
noteFailure('update', error);
|
||||
return;
|
||||
}
|
||||
|
||||
const renderingCanvas = roomPreviewer.getRenderingCanvas();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CatalogAdminCreateOfferComposer, CatalogAdminCreatePageComposer, CatalogAdminDeleteOfferComposer, CatalogAdminDeletePageComposer, CatalogAdminMoveOfferComposer, CatalogAdminMovePageComposer, CatalogAdminPublishComposer, CatalogAdminResultEvent, CatalogAdminSaveOfferComposer, CatalogAdminSavePageComposer } from '@nitrots/nitro-renderer';
|
||||
import { CatalogAdminCreateOfferComposer, CatalogAdminCreatePageComposer, CatalogAdminDeleteOfferComposer, CatalogAdminDeletePageComposer, CatalogAdminLoadOfferComposer, CatalogAdminLoadPageComposer, CatalogAdminMoveOfferComposer, CatalogAdminMovePageComposer, CatalogAdminOfferDetailsEvent, CatalogAdminPageDetailsEvent, CatalogAdminPublishComposer, CatalogAdminResultEvent, CatalogAdminSaveOfferComposer, CatalogAdminSavePageComposer } from '@nitrots/nitro-renderer';
|
||||
import { createContext, FC, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { ICatalogNode, IPurchasableOffer, NotificationAlertType, SendMessageComposer } from '../../api';
|
||||
import { useCatalogUiState, useMessageEvent, useNotification } from '../../hooks';
|
||||
@@ -44,12 +44,34 @@ export interface IOfferEditData
|
||||
orderNumber: number;
|
||||
}
|
||||
|
||||
export interface IEditingOfferDetails
|
||||
{
|
||||
offerId: number;
|
||||
offerIdGroup: number;
|
||||
limitedStack: number;
|
||||
orderNumber: number;
|
||||
}
|
||||
|
||||
export interface IEditingPageDetails
|
||||
{
|
||||
pageId: number;
|
||||
caption: string;
|
||||
captionSave: string;
|
||||
minRank: number;
|
||||
orderNum: number;
|
||||
visible: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface ICatalogAdminContext
|
||||
{
|
||||
adminMode: boolean;
|
||||
setAdminMode: (value: boolean) => void;
|
||||
editingOffer: IPurchasableOffer | null;
|
||||
setEditingOffer: (offer: IPurchasableOffer | null) => void;
|
||||
editingOfferDetails: IEditingOfferDetails | null;
|
||||
editingPageDetails: IEditingPageDetails | null;
|
||||
requestPageDetails: (pageId: number) => void;
|
||||
editingPageData: boolean;
|
||||
setEditingPageData: (value: boolean) => void;
|
||||
editingRootPage: boolean;
|
||||
@@ -80,7 +102,9 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
|
||||
{
|
||||
const { currentType } = useCatalogUiState();
|
||||
const [ adminMode, setAdminMode ] = useState(false);
|
||||
const [ editingOffer, setEditingOffer ] = useState<IPurchasableOffer | null>(null);
|
||||
const [ editingOffer, setEditingOfferState ] = useState<IPurchasableOffer | null>(null);
|
||||
const [ editingOfferDetails, setEditingOfferDetails ] = useState<IEditingOfferDetails | null>(null);
|
||||
const [ editingPageDetails, setEditingPageDetails ] = useState<IEditingPageDetails | null>(null);
|
||||
const [ editingPageData, setEditingPageData ] = useState(false);
|
||||
const [ editingRootPage, setEditingRootPage ] = useState(false);
|
||||
const [ editingPageNode, setEditingPageNode ] = useState<ICatalogNode | null>(null);
|
||||
@@ -90,6 +114,51 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
|
||||
const pendingActionRef = useRef<string | null>(null);
|
||||
const { simpleAlert = null } = useNotification();
|
||||
|
||||
const setEditingOffer = useCallback((offer: IPurchasableOffer | null) =>
|
||||
{
|
||||
setEditingOfferState(offer);
|
||||
setEditingOfferDetails(null);
|
||||
|
||||
if(offer && offer.offerId !== -1)
|
||||
{
|
||||
SendMessageComposer(new CatalogAdminLoadOfferComposer(offer.offerId, currentType));
|
||||
}
|
||||
}, [ currentType ]);
|
||||
|
||||
useMessageEvent(CatalogAdminOfferDetailsEvent, (event: CatalogAdminOfferDetailsEvent) =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
setEditingOfferDetails({
|
||||
offerId: parser.offerId,
|
||||
offerIdGroup: parser.offerIdGroup,
|
||||
limitedStack: parser.limitedStack,
|
||||
orderNumber: parser.orderNumber
|
||||
});
|
||||
});
|
||||
|
||||
useMessageEvent(CatalogAdminPageDetailsEvent, (event: CatalogAdminPageDetailsEvent) =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
setEditingPageDetails({
|
||||
pageId: parser.pageId,
|
||||
caption: parser.caption,
|
||||
captionSave: parser.captionSave,
|
||||
minRank: parser.minRank,
|
||||
orderNum: parser.orderNum,
|
||||
visible: parser.visible,
|
||||
enabled: parser.enabled
|
||||
});
|
||||
});
|
||||
|
||||
const requestPageDetails = useCallback((pageId: number) =>
|
||||
{
|
||||
setEditingPageDetails(null);
|
||||
if(pageId == null || pageId < 0) return;
|
||||
SendMessageComposer(new CatalogAdminLoadPageComposer(pageId, currentType));
|
||||
}, [ currentType ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!adminMode) return;
|
||||
@@ -288,7 +357,8 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
|
||||
return (
|
||||
<CatalogAdminContext value={ {
|
||||
adminMode, setAdminMode,
|
||||
editingOffer, setEditingOffer,
|
||||
editingOffer, setEditingOffer, editingOfferDetails,
|
||||
editingPageDetails, requestPageDetails,
|
||||
editingPageData, setEditingPageData,
|
||||
editingRootPage, setEditingRootPage,
|
||||
editingPageNode, setEditingPageNode,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { FaBars, FaCog, FaEdit, FaEye, FaEyeSlash, FaPlus, FaTrash } from 'react-icons/fa';
|
||||
import { CatalogType, GetConfigurationValue, LocalizeShortNumber, LocalizeText } from '../../api';
|
||||
import { CatalogType, GetConfigurationValue, LocalizeShortNumber, LocalizeText, SanitizeHtml } from '../../api';
|
||||
import { Column, Grid, LayoutCurrencyIcon, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
|
||||
import { useCatalogActions, useCatalogData, useCatalogUiState, useHasPermission, usePurse } from '../../hooks';
|
||||
import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext';
|
||||
@@ -18,7 +18,7 @@ import { MarketplacePostOfferView } from './views/page/layout/marketplace/Market
|
||||
|
||||
const CatalogClassicViewInner: FC<{}> = () =>
|
||||
{
|
||||
const { rootNode = null, currentPage = null, searchResult = null } = useCatalogData();
|
||||
const { rootNode = null, currentPage = null, currentOffer = null, searchResult = null } = useCatalogData();
|
||||
const { isVisible = false, setIsVisible = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], setSearchResult = null, currentType = CatalogType.NORMAL } = useCatalogUiState();
|
||||
const { openPageByName = null, openPageByOfferId = null, activateNode = null, openCatalogByType = null, toggleCatalogByType = null } = useCatalogActions();
|
||||
const catalogAdmin = useCatalogAdmin();
|
||||
@@ -34,6 +34,11 @@ const CatalogClassicViewInner: FC<{}> = () =>
|
||||
const [ mobileMenuOpen, setMobileMenuOpen ] = useState(false);
|
||||
const { purse = null } = usePurse();
|
||||
const displayedCurrencies = GetConfigurationValue<number[]>('system.currency.types', []);
|
||||
const activeCatalogNode = activeNodes?.[activeNodes.length - 1] ?? null;
|
||||
// Strip SWF-style suffixes like "(BC)" or "(Hot)" but keep the
|
||||
// pageId hint the gameserver appends when the viewer has
|
||||
// ACC_CATALOG_IDS - that's a pure-numeric "(6)" trailer.
|
||||
const getSwfTabLabel = (label: string) => (label || '').replace(/\s*\(\D[^)]*\)\s*$/g, '').trim();
|
||||
const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER)
|
||||
? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' }
|
||||
: undefined;
|
||||
@@ -122,7 +127,7 @@ const CatalogClassicViewInner: FC<{}> = () =>
|
||||
return (
|
||||
<>
|
||||
{ isVisible &&
|
||||
<NitroCardView classNames={ [ 'nitro-catalog-classic-window' ] } isResizable={ false } uniqueKey="catalog">
|
||||
<NitroCardView classNames={ [ 'habbo-swf-window', 'habbo-swf-catalog-window', '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 } />
|
||||
<div className="nitro-catalog-classic-mobile-header">
|
||||
{ isMod &&
|
||||
@@ -161,20 +166,19 @@ const CatalogClassicViewInner: FC<{}> = () =>
|
||||
</div>
|
||||
</div>
|
||||
{ adminMode &&
|
||||
<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' }` }
|
||||
className={ `nitro-catalog-classic-header-publish nitro-catalog-swf-button nitro-catalog-swf-buy-button ${ hasPendingChanges ? 'has-pending' : '' }` }
|
||||
disabled={ loading }
|
||||
onClick={ () => publishCatalog() }
|
||||
title={ hasPendingChanges ? 'You have unsaved changes - click to publish' : 'Publish catalog' }
|
||||
>
|
||||
{ loading ? '...' : 'Publish' }
|
||||
</button>
|
||||
</div> }
|
||||
{ loading ? '...' : 'PUBLISH' }
|
||||
</button> }
|
||||
<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;
|
||||
if(!adminMode && (index === 0) && getSwfTabLabel(child.localization).toLowerCase().includes('rari')) return null;
|
||||
|
||||
const isHidden = !child.isVisible;
|
||||
|
||||
@@ -186,8 +190,9 @@ const CatalogClassicViewInner: FC<{}> = () =>
|
||||
activateNode(child);
|
||||
} }>
|
||||
<div className={ `flex items-center gap-1 ${ isHidden ? 'opacity-40' : '' }` }>
|
||||
<CatalogIconView icon={ child.iconId } />
|
||||
<span className="nitro-catalog-classic-tab-label truncate">{ child.localization }</span>
|
||||
{ (child.iconId > 0) &&
|
||||
<CatalogIconView icon={ child.iconId } className="nitro-catalog-classic-tab-icon" /> }
|
||||
<span className="nitro-catalog-classic-tab-label truncate">{ getSwfTabLabel(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() }>
|
||||
@@ -215,6 +220,20 @@ const CatalogClassicViewInner: FC<{}> = () =>
|
||||
<FaCog className={ `text-[10px] ${ adminMode ? 'animate-spin' : '' }` } style={ adminMode ? { animationDuration: '3s' } : {} } />
|
||||
</NitroCardTabsItemView> }
|
||||
</NitroCardTabsView>
|
||||
<div className="nitro-catalog-classic-swf-header">
|
||||
<div className="nitro-catalog-classic-swf-header-bg" style={ currentPage?.localization?.getImage(0) ? { backgroundImage: `url(${ currentPage.localization.getImage(0) })` } : undefined } />
|
||||
<div className="nitro-catalog-classic-swf-header-icon">
|
||||
<CatalogIconView icon={ activeCatalogNode?.iconId ?? rootNode?.iconId ?? 1 } />
|
||||
</div>
|
||||
<div className="nitro-catalog-classic-swf-header-copy">
|
||||
<div className="nitro-catalog-classic-swf-header-title">
|
||||
{ currentType === CatalogType.BUILDER ? LocalizeText('builder.header.title') : getSwfTabLabel(activeCatalogNode?.localization ?? LocalizeText('catalog.title')) }
|
||||
</div>
|
||||
{ currentType === CatalogType.BUILDER
|
||||
? <div className="nitro-catalog-classic-swf-header-description">{ LocalizeText('builder.header.status.membership') }</div>
|
||||
: <div className="nitro-catalog-classic-swf-header-description" dangerouslySetInnerHTML={ { __html: SanitizeHtml(currentPage?.localization?.getText(0) || '') } } /> }
|
||||
</div>
|
||||
</div>
|
||||
<NitroCardContentView classNames={ [ 'nitro-catalog-classic-content-shell' ] }>
|
||||
<CatalogBuildersClubStatusView />
|
||||
{ adminMode && rootNode &&
|
||||
@@ -252,8 +271,7 @@ const CatalogClassicViewInner: FC<{}> = () =>
|
||||
<div className="nitro-catalog-classic-layout-header-shell">
|
||||
<CatalogBreadcrumbView />
|
||||
<div className="nitro-catalog-classic-layout-hero">
|
||||
{ /* info_duckets renders its own logo in the body (BcInfoView) — don't duplicate it in the hero */ }
|
||||
{ (currentPage?.layoutCode !== 'info_duckets') && !!currentPage?.localization?.getImage(0) && <img src={ currentPage.localization.getImage(0) } /> }
|
||||
{ !!currentPage?.localization?.getImage(0) && <img src={ currentPage.localization.getImage(0) } /> }
|
||||
</div>
|
||||
</div>
|
||||
<div className="nitro-catalog-classic-layout-container">
|
||||
|
||||
@@ -10,6 +10,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
|
||||
const { currentPage = null } = useCatalogData();
|
||||
const catalogAdmin = useCatalogAdmin();
|
||||
const editingOffer = catalogAdmin?.editingOffer ?? null;
|
||||
const editingOfferDetails = catalogAdmin?.editingOfferDetails ?? null;
|
||||
const setEditingOffer = catalogAdmin?.setEditingOffer;
|
||||
const saveOffer = catalogAdmin?.saveOffer;
|
||||
const deleteOffer = catalogAdmin?.deleteOffer;
|
||||
@@ -62,12 +63,21 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
|
||||
setClubOnly(editingOffer.clubLevel > 0 ? '1' : '0');
|
||||
setExtradata(editingOffer.product?.extraParam || '');
|
||||
setHaveOffer(editingOffer.haveOffer ? '1' : '0');
|
||||
setOfferIdGroup(editingOffer.offerId || -1);
|
||||
setOfferIdGroup(0);
|
||||
setLimitedStack(0);
|
||||
setOrderNumber(0);
|
||||
}
|
||||
}, [ editingOffer ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!editingOfferDetails) return;
|
||||
|
||||
setOfferIdGroup(editingOfferDetails.offerIdGroup);
|
||||
setLimitedStack(editingOfferDetails.limitedStack);
|
||||
setOrderNumber(editingOfferDetails.orderNumber);
|
||||
}, [ editingOfferDetails ]);
|
||||
|
||||
if(!editingOffer) return null;
|
||||
|
||||
const handleSave = async () =>
|
||||
|
||||
@@ -28,6 +28,8 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
|
||||
const editingPageData = catalogAdmin?.editingPageData ?? false;
|
||||
const editingRootPage = catalogAdmin?.editingRootPage ?? false;
|
||||
const editingPageNode = catalogAdmin?.editingPageNode ?? null;
|
||||
const editingPageDetails = catalogAdmin?.editingPageDetails ?? null;
|
||||
const requestPageDetails = catalogAdmin?.requestPageDetails;
|
||||
const loading = catalogAdmin?.loading ?? false;
|
||||
|
||||
const [ caption, setCaption ] = useState('');
|
||||
@@ -67,21 +69,22 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
|
||||
{
|
||||
if(!editingPageData || !targetNode) return;
|
||||
|
||||
// The server appends " (pageId)" to the caption for mods/admins (see
|
||||
// CatalogPagesListComposer). Strip that exact suffix before seeding the
|
||||
// edit field, otherwise saving folds the id back into the stored
|
||||
// caption and it multiplies on every edit ("Wired (1114) (1114) ...").
|
||||
const rawCaption = (targetNode.localization || '').replace(new RegExp(`\\s*\\(${ targetNode.pageId }\\)\\s*$`), '');
|
||||
// Don't read the decorated caption out of the catalog index -
|
||||
// the gameserver appends " (id)" when ACC_CATALOG_IDS is on and
|
||||
// we don't want that round-tripping back into the DB. Wait for
|
||||
// the admin page-details event to land instead; it carries the
|
||||
// raw caption / caption_save / min_rank / order_num / enabled.
|
||||
setCaption('');
|
||||
setCaptionSave('');
|
||||
setMinRank(1);
|
||||
setOrderNum(0);
|
||||
setEnabled('1');
|
||||
|
||||
setCaption(rawCaption);
|
||||
setCaptionSave(targetNode.pageName || rawCaption);
|
||||
setCatalogMode(currentType === CatalogType.BUILDER ? 'BUILDER' : 'NORMAL');
|
||||
setPageLayout(currentPage?.layoutCode || 'default_3x3');
|
||||
setIconImage(targetNode.iconId ?? 0);
|
||||
setVisible(targetNode.isVisible ? '1' : '0');
|
||||
setEnabled('1');
|
||||
setMinRank(1);
|
||||
setOrderNum(0);
|
||||
|
||||
const matchesLoadedPage = currentPage && targetPageId === currentPage.pageId;
|
||||
const existingText1 = matchesLoadedPage && currentPage.localization
|
||||
? currentPage.localization.getText(0)
|
||||
@@ -94,7 +97,22 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
|
||||
setParentId(typeof wireParentId === 'number' && wireParentId !== -1
|
||||
? wireParentId
|
||||
: (targetNode.parent ? targetNode.parent.pageId : -1));
|
||||
}, [ editingPageData, targetNode, currentPage, currentType ]);
|
||||
|
||||
if(targetPageId != null && targetPageId >= 0) requestPageDetails?.(targetPageId);
|
||||
}, [ editingPageData, targetNode, currentPage, currentType, targetPageId, requestPageDetails ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!editingPageDetails) return;
|
||||
if(targetPageId != null && editingPageDetails.pageId !== targetPageId) return;
|
||||
|
||||
setCaption(editingPageDetails.caption);
|
||||
setCaptionSave(editingPageDetails.captionSave);
|
||||
setMinRank(editingPageDetails.minRank);
|
||||
setOrderNum(editingPageDetails.orderNum);
|
||||
setVisible(editingPageDetails.visible ? '1' : '0');
|
||||
setEnabled(editingPageDetails.enabled ? '1' : '0');
|
||||
}, [ editingPageDetails, targetPageId ]);
|
||||
|
||||
if(!editingPageData || !targetNode) return null;
|
||||
|
||||
@@ -168,7 +186,7 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
|
||||
const handleDelete = async () =>
|
||||
{
|
||||
if(!catalogAdmin?.deletePage || isRoot) return;
|
||||
if(!confirm(LocalizeText('catalog.admin.delete.page.confirm', [ 'name' ], [ targetNode.localization ]))) return;
|
||||
if(!confirm(LocalizeText('catalog.admin.delete.page.confirm', [ 'name' ], [ editingPageDetails?.caption ?? '' ]))) return;
|
||||
|
||||
catalogAdmin.deletePage(targetPageId);
|
||||
|
||||
@@ -179,7 +197,7 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
|
||||
<div className="bg-white rounded border-2 border-card-grid-item-border p-2.5 mb-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] font-bold text-primary uppercase tracking-wide">
|
||||
{ isRoot ? LocalizeText('catalog.admin.edit.root') : `${ LocalizeText('catalog.admin.edit') } ${ targetNode.localization }` }
|
||||
{ isRoot ? LocalizeText('catalog.admin.edit.root') : `${ LocalizeText('catalog.admin.edit') } ${ editingPageDetails?.caption ?? '' }` }
|
||||
</span>
|
||||
<FaTimes className="text-muted cursor-pointer hover:text-danger text-[10px]" onClick={ closeForm } />
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,10 @@ export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = pro
|
||||
const isFav = node ? isFavoritePage(node.pageId) : false;
|
||||
const [ isDragOver, setIsDragOver ] = useState(false);
|
||||
const dragRef = useRef<HTMLDivElement>(null);
|
||||
// Strip SWF-style suffixes like "(BC)" or "(Hot)" but keep the
|
||||
// pageId hint the gameserver appends when the viewer has
|
||||
// ACC_CATALOG_IDS - that's a pure-numeric "(6)" trailer.
|
||||
const swfLabel = (node?.localization || '').replace(/\s*\(\D[^)]*\)\s*$/g, '').trim();
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent) =>
|
||||
{
|
||||
@@ -90,7 +94,7 @@ export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = pro
|
||||
<div className="nitro-catalog-classic-navigation-icon">
|
||||
<CatalogIconView icon={ node.iconId } />
|
||||
</div>
|
||||
<span className="nitro-catalog-classic-navigation-label" title={ adminMode ? `Page ID: ${ node.pageId }` : undefined }>{ node.localization }</span>
|
||||
<span className="nitro-catalog-classic-navigation-label" title={ adminMode ? `Page ID: ${ node.pageId }` : undefined }>{ swfLabel }</span>
|
||||
{ adminMode &&
|
||||
<div className="nitro-catalog-classic-navigation-admin flex items-center gap-1 opacity-0 group-hover/nav:opacity-100 transition-opacity">
|
||||
<FaPlus
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MouseEventType } from '@nitrots/nitro-renderer';
|
||||
import { FC, MouseEvent, useMemo, useState } from 'react';
|
||||
import { FaHeart } from 'react-icons/fa';
|
||||
import { CatalogType, IPurchasableOffer, Offer, ProductTypeEnum } from '../../../../../api';
|
||||
import { CatalogType, GetConfigurationValue, IPurchasableOffer, Offer, ProductTypeEnum } from '../../../../../api';
|
||||
import { LayoutAvatarImageView, LayoutGridItem, LayoutGridItemProps } from '../../../../../common';
|
||||
import { useCatalogActions, useCatalogFavorites, useCatalogUiState, useInventoryFurni } from '../../../../../hooks';
|
||||
|
||||
@@ -30,9 +30,50 @@ export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
|
||||
return null;
|
||||
}
|
||||
|
||||
return offer.product?.getIconUrl(offer) ?? null;
|
||||
const product = offer.product;
|
||||
|
||||
if(!product) return null;
|
||||
|
||||
if((product.productType === ProductTypeEnum.FLOOR) || (product.productType === ProductTypeEnum.WALL))
|
||||
{
|
||||
const className = product.furnitureData?.className;
|
||||
|
||||
if(className?.length)
|
||||
{
|
||||
const param = (product.productType === ProductTypeEnum.WALL && product.extraParam?.length) ? `_${ product.extraParam }` : '';
|
||||
const configuredIconUrl = GetConfigurationValue<string>('furni.asset.icon.url', '');
|
||||
|
||||
if(configuredIconUrl?.length)
|
||||
{
|
||||
return configuredIconUrl
|
||||
.replace('%libname%', className)
|
||||
.replace('%param%', param);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return product.getIconUrl(offer) ?? null;
|
||||
}, [ offer ]);
|
||||
|
||||
const prices = useMemo(() =>
|
||||
{
|
||||
if(!offer) return [];
|
||||
|
||||
const values: { amount: number; type: number }[] = [];
|
||||
|
||||
if(offer.priceInCredits > 0) values.push({ amount: offer.priceInCredits, type: -1 });
|
||||
if(offer.priceInActivityPoints > 0) values.push({ amount: offer.priceInActivityPoints, type: offer.activityPointType });
|
||||
|
||||
return values;
|
||||
}, [ offer ]);
|
||||
|
||||
const getCurrencyIconUrl = (type: number) =>
|
||||
{
|
||||
const configuredCurrencyUrl = GetConfigurationValue<string>('currency.asset.icon.url', '');
|
||||
|
||||
return configuredCurrencyUrl.replace('%type%', type.toString());
|
||||
};
|
||||
|
||||
const onMouseEvent = (event: MouseEvent) =>
|
||||
{
|
||||
switch(event.type)
|
||||
@@ -74,9 +115,30 @@ export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
|
||||
{ ...rest }
|
||||
>
|
||||
{ iconUrl && !(offer.product.productType === ProductTypeEnum.ROBOT) &&
|
||||
<div className="nitro-catalog-classic-grid-offer-icon" style={ { backgroundImage: `url(${ iconUrl })` } } /> }
|
||||
<img
|
||||
className="nitro-catalog-classic-grid-offer-icon"
|
||||
src={ iconUrl }
|
||||
draggable={ false }
|
||||
onError={ event =>
|
||||
{
|
||||
const fallbackIconUrl = product.getIconUrl(offer);
|
||||
|
||||
if(fallbackIconUrl && (event.currentTarget.src !== fallbackIconUrl)) event.currentTarget.src = fallbackIconUrl;
|
||||
} } /> }
|
||||
{ (offer.product.productType === ProductTypeEnum.ROBOT) &&
|
||||
<LayoutAvatarImageView direction={ 2 } figure={ offer.product.extraParam } fit /> }
|
||||
{ (prices.length > 0) &&
|
||||
<span className={ `nitro-catalog-classic-grid-price ${ prices.length > 1 ? 'is-multi-price' : 'is-single-price' }` }>
|
||||
{ prices.map((price, index) =>
|
||||
<span key={ `${ price.type }-${ index }` } className="nitro-catalog-classic-grid-price-entry">
|
||||
{ index > 0 && <span className="nitro-catalog-classic-grid-price-plus">+</span> }
|
||||
<span className="nitro-catalog-classic-grid-price-amount">{ price.amount }</span>
|
||||
<img
|
||||
className="nitro-catalog-classic-grid-price-currency"
|
||||
src={ getCurrencyIconUrl(price.type) }
|
||||
draggable={ false } />
|
||||
</span>) }
|
||||
</span> }
|
||||
<div
|
||||
className={ `absolute top-0 right-0 z-10 p-0.5 cursor-pointer transition-opacity duration-100 ${ isFav ? 'opacity-100' : 'opacity-0 group-hover/tile:opacity-100' }` }
|
||||
onClick={ e =>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ColorConverter } from '@nitrots/nitro-renderer';
|
||||
import { FC, useMemo, useState } from 'react';
|
||||
import { FaFillDrip } from 'react-icons/fa';
|
||||
import { IPurchasableOffer, SanitizeHtml } from '../../../../../api';
|
||||
import { AutoGrid, Button, Column, Grid, LayoutGridItem, Text } from '../../../../../common';
|
||||
import { FaExchangeAlt, FaFillDrip, FaSyncAlt } from 'react-icons/fa';
|
||||
import { IPurchasableOffer, ProductTypeEnum, SanitizeHtml } from '../../../../../api';
|
||||
import { AutoGrid, Button, Column, LayoutGridItem, Text } from '../../../../../common';
|
||||
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
|
||||
import { CatalogGridOfferView } from '../common/CatalogGridOfferView';
|
||||
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
|
||||
@@ -22,7 +22,7 @@ export const CatalogLayoutColorGroupingView: FC<CatalogLayoutColorGroupViewProps
|
||||
{
|
||||
const { page = null } = props;
|
||||
const [ colorableItems, setColorableItems ] = useState<Map<string, number[]>>(new Map<string, number[]>());
|
||||
const { currentOffer = null } = useCatalogData();
|
||||
const { currentOffer = null, roomPreviewer = null } = useCatalogData();
|
||||
const { setCurrentOffer = null } = useCatalogUiState();
|
||||
const [ colorsShowing, setColorsShowing ] = useState<boolean>(false);
|
||||
|
||||
@@ -132,26 +132,42 @@ export const CatalogLayoutColorGroupingView: FC<CatalogLayoutColorGroupViewProps
|
||||
}, [ page.offers ]);
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
<Column overflow="hidden" size={ 7 }>
|
||||
<AutoGrid columnCount={ 5 }>
|
||||
<Column overflow="hidden">
|
||||
{ /* Top: two visible rows of furni tiles. Tile is 70px tall
|
||||
and the AutoGrid handles its own overflow if there are
|
||||
more than two rows worth of offers. */ }
|
||||
<div className="shrink-0" style={ { maxHeight: 154 } }>
|
||||
{ (!colorsShowing || !currentOffer || !colorableItems.has(currentOffer.product.furnitureData.className)) &&
|
||||
offers.map((offer, index) => <CatalogGridOfferView key={ index } itemActive={ (currentOffer && (currentOffer.product.furnitureData.hasIndexedColor ? currentOffer.product.furnitureData.className === offer.product.furnitureData.className : currentOffer.offerId === offer.offerId)) } offer={ offer } selectOffer={ selectOffer } />)
|
||||
}
|
||||
<AutoGrid columnCount={ 7 } columnMinHeight={ 70 } columnMinWidth={ 45 }>
|
||||
{ offers.map((offer, index) => <CatalogGridOfferView key={ index } itemActive={ (currentOffer && (currentOffer.product.furnitureData.hasIndexedColor ? currentOffer.product.furnitureData.className === offer.product.furnitureData.className : currentOffer.offerId === offer.offerId)) } offer={ offer } selectOffer={ selectOffer } />) }
|
||||
</AutoGrid> }
|
||||
{ (colorsShowing && currentOffer && colorableItems.has(currentOffer.product.furnitureData.className)) &&
|
||||
colorableItems.get(currentOffer.product.furnitureData.className).map((color, index) => <LayoutGridItem key={ index } itemHighlight className="clear-bg" itemActive={ (currentOffer.product.furnitureData.colorIndex === index) } itemColor={ ColorConverter.int2rgb(color) } onClick={ event => selectColor(index, currentOffer.product.furnitureData.className) } />)
|
||||
}
|
||||
</AutoGrid>
|
||||
</Column>
|
||||
<Column center={ !currentOffer } overflow="hidden" size={ 5 }>
|
||||
<div className="nitro-catalog-classic-color-swatches flex flex-wrap gap-1 p-2 overflow-auto">
|
||||
{ colorableItems.get(currentOffer.product.furnitureData.className).map((color, index) => <LayoutGridItem key={ index } itemHighlight className="clear-bg" itemActive={ (currentOffer.product.furnitureData.colorIndex === index) } itemColor={ ColorConverter.int2rgb(color) } onClick={ event => selectColor(index, currentOffer.product.furnitureData.className) } />) }
|
||||
</div> }
|
||||
</div>
|
||||
|
||||
{ /* Bottom: preview pane stacked under the grid. Mirrors the
|
||||
default-3x3 split (preview on the left, offer info on the
|
||||
right) so the rotate/state buttons and Buy/Gift actions
|
||||
sit where the user expects. */ }
|
||||
{ !currentOffer &&
|
||||
<>
|
||||
<Column center grow overflow="hidden">
|
||||
{ !!page.localization.getImage(1) && <img alt="" src={ page.localization.getImage(1) } /> }
|
||||
<Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
|
||||
</> }
|
||||
</Column> }
|
||||
{ currentOffer &&
|
||||
<div className="nitro-catalog-classic-offer-panel flex flex-col items-center grow overflow-hidden gap-2">
|
||||
<div className="nitro-catalog-classic-offer-preview relative flex items-center justify-center overflow-hidden">
|
||||
{ (currentOffer.product.productType !== ProductTypeEnum.BADGE) &&
|
||||
<>
|
||||
<div className="relative overflow-hidden">
|
||||
<button className="nitro-catalog-classic-preview-btn nitro-catalog-classic-preview-rotate" onClick={ () => roomPreviewer?.changeRoomObjectDirection() }>
|
||||
<FaSyncAlt />
|
||||
</button>
|
||||
<button className="nitro-catalog-classic-preview-btn nitro-catalog-classic-preview-state" onClick={ () => roomPreviewer?.changeRoomObjectState() }>
|
||||
<FaExchangeAlt />
|
||||
</button>
|
||||
</> }
|
||||
<CatalogViewProductWidgetView />
|
||||
<CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-1 inset-e-1" position="absolute" />
|
||||
{ currentOffer.product.furnitureData.hasIndexedColor &&
|
||||
@@ -159,19 +175,16 @@ export const CatalogLayoutColorGroupingView: FC<CatalogLayoutColorGroupViewProps
|
||||
<FaFillDrip className="fa-icon" />
|
||||
</Button> }
|
||||
</div>
|
||||
<Column className="grow!" gap={ 1 }>
|
||||
<div className="w-full max-w-[360px] flex flex-col gap-2 px-1">
|
||||
<CatalogLimitedItemWidgetView />
|
||||
<Text truncate className="grow!">{ currentOffer.localizationName }</Text>
|
||||
<div className="flex justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Text truncate>{ currentOffer.localizationName }</Text>
|
||||
<div className="flex justify-between items-center">
|
||||
<CatalogSpinnerWidgetView />
|
||||
</div>
|
||||
<CatalogTotalPriceWidget alignItems="end" justifyContent="end" />
|
||||
</div>
|
||||
<CatalogPurchaseWidgetView />
|
||||
</div>
|
||||
</div> }
|
||||
</Column>
|
||||
</> }
|
||||
</Column>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FC } from 'react';
|
||||
import { FaEdit, FaPlus, FaPowerOff, FaSyncAlt } from 'react-icons/fa';
|
||||
import { FaEdit, FaExchangeAlt, FaPlus, FaSyncAlt } from 'react-icons/fa';
|
||||
import { GetConfigurationValue, LocalizeText, ProductTypeEnum, SanitizeHtml } from '../../../../../api';
|
||||
import { Text } from '../../../../../common';
|
||||
import { useCatalogData } from '../../../../../hooks';
|
||||
@@ -20,6 +20,7 @@ export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
|
||||
const { currentOffer = null, currentPage = null, roomPreviewer = null } = useCatalogData();
|
||||
const catalogAdmin = useCatalogAdmin();
|
||||
const adminMode = catalogAdmin?.adminMode ?? false;
|
||||
const offerName = currentOffer?.localizationName?.replace(/\s*\([^)]*\)\s*$/g, '');
|
||||
|
||||
return (
|
||||
<div className="nitro-catalog-classic-default-layout flex flex-col h-full gap-2">
|
||||
@@ -40,17 +41,28 @@ export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
|
||||
>
|
||||
<FaPlus className="text-[10px]" /> { LocalizeText('catalog.admin.offer.new') }
|
||||
</button>
|
||||
</div> }
|
||||
{ currentOffer &&
|
||||
<div className="nitro-catalog-classic-offer-panel flex gap-0 shrink-0">
|
||||
<button
|
||||
className="flex items-center gap-1 text-[10px] text-primary hover:text-dark transition-colors cursor-pointer"
|
||||
title={ `${ LocalizeText('catalog.admin.offer.edit') } - Class ${ currentOffer.product.productClassId } / Offer ${ currentOffer.offerId }` }
|
||||
onClick={ () => catalogAdmin.setEditingOffer(currentOffer) }
|
||||
>
|
||||
<FaEdit className="text-[10px]" /> { LocalizeText('catalog.admin.offer.edit') }
|
||||
<span className="font-mono text-[9px] text-dark font-semibold">#{ currentOffer.product.productClassId }/{ currentOffer.offerId }</span>
|
||||
</button> }
|
||||
</div> }
|
||||
<div className="nitro-catalog-classic-product-view">
|
||||
{ currentOffer &&
|
||||
<div className="nitro-catalog-classic-offer-panel flex gap-0">
|
||||
<div className="nitro-catalog-classic-offer-preview relative flex items-center justify-center">
|
||||
<Text className="nitro-catalog-classic-preview-title">{ offerName }</Text>
|
||||
{ (currentOffer.product.productType !== ProductTypeEnum.BADGE) &&
|
||||
<>
|
||||
<button className="nitro-catalog-classic-preview-btn nitro-catalog-classic-preview-rotate" onClick={ () => roomPreviewer?.changeRoomObjectDirection() }>
|
||||
<FaSyncAlt /> Rotate
|
||||
<FaSyncAlt />
|
||||
</button>
|
||||
<button className="nitro-catalog-classic-preview-btn nitro-catalog-classic-preview-state" onClick={ () => roomPreviewer?.changeRoomObjectState() }>
|
||||
<FaPowerOff /> Toggle State
|
||||
<FaExchangeAlt />
|
||||
</button>
|
||||
<CatalogViewProductWidgetView />
|
||||
<CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-1 right-1 absolute" />
|
||||
@@ -61,7 +73,7 @@ export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
|
||||
<div className="nitro-catalog-classic-offer-info flex flex-col flex-1 min-w-0 gap-2">
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<Text className="text-[13px]! font-bold text-dark leading-tight">{ currentOffer.localizationName }</Text>
|
||||
<Text className="text-[13px]! font-bold text-dark leading-tight">{ offerName }</Text>
|
||||
{ adminMode &&
|
||||
<FaEdit
|
||||
className="text-primary text-[11px] cursor-pointer hover:text-dark transition-colors shrink-0 mt-0.5"
|
||||
@@ -77,26 +89,39 @@ export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
|
||||
</div> }
|
||||
<CatalogLimitedItemWidgetView />
|
||||
</div>
|
||||
<CatalogTotalPriceWidget />
|
||||
<CatalogSpinnerWidgetView />
|
||||
<div className="nitro-catalog-classic-offer-actions flex gap-1.5">
|
||||
<CatalogPurchaseWidgetView />
|
||||
</div>
|
||||
</div>
|
||||
</div> }
|
||||
|
||||
{ !currentOffer &&
|
||||
<div className="nitro-catalog-classic-welcome flex items-center gap-3 shrink-0">
|
||||
<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> }
|
||||
</div>
|
||||
|
||||
<div className="nitro-catalog-classic-grid-shell flex-1 overflow-auto min-h-0">
|
||||
{ GetConfigurationValue('catalog.headers') &&
|
||||
<CatalogHeaderView imageUrl={ currentPage.localization.getImage(0) } /> }
|
||||
<CatalogItemGridWidgetView className="nitro-catalog-classic-grid" columnCount={ 7 } columnMinHeight={ currentPage.layoutCode === 'bots' ? 65 : 50 } columnMinWidth={ currentPage.layoutCode === 'bots' ? 65 : 50 } />
|
||||
<CatalogItemGridWidgetView className="nitro-catalog-classic-grid" columnCount={ 7 } columnMinHeight={ 70 } columnMinWidth={ 45 } />
|
||||
</div>
|
||||
|
||||
{ currentOffer &&
|
||||
<div className="nitro-catalog-classic-price-row flex items-center justify-between gap-2">
|
||||
<div className="nitro-catalog-classic-spinner-slot">
|
||||
<CatalogSpinnerWidgetView />
|
||||
</div>
|
||||
<div className="nitro-catalog-classic-total-price-slot">
|
||||
<CatalogTotalPriceWidget />
|
||||
</div>
|
||||
</div> }
|
||||
|
||||
{ currentOffer &&
|
||||
<div className="nitro-catalog-classic-purchase-row flex items-start justify-end">
|
||||
<div className="nitro-catalog-classic-offer-actions flex gap-1.5">
|
||||
<CatalogPurchaseWidgetView />
|
||||
</div>
|
||||
</div> }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,14 +17,14 @@ export const CatalogLayouGuildCustomFurniView: FC<CatalogLayoutProps> = props =>
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
<Column overflow="hidden" size={ 7 }>
|
||||
<CatalogItemGridWidgetView />
|
||||
<Column overflow="hidden" size={ 8 }>
|
||||
<CatalogItemGridWidgetView columnMinWidth={ 36 } />
|
||||
</Column>
|
||||
<Column center={ !currentOffer } overflow="hidden" size={ 5 }>
|
||||
<Column center={ !currentOffer } overflow="hidden" size={ 4 }>
|
||||
{ !currentOffer &&
|
||||
<>
|
||||
{ !!page.localization.getImage(1) && <img alt="" src={ page.localization.getImage(1) } /> }
|
||||
<Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
|
||||
{ !!page.localization.getImage(1) && <img alt="" className="max-w-full object-contain" src={ page.localization.getImage(1) } /> }
|
||||
<Text center bold dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
|
||||
</> }
|
||||
{ currentOffer &&
|
||||
<>
|
||||
@@ -33,7 +33,7 @@ export const CatalogLayouGuildCustomFurniView: FC<CatalogLayoutProps> = props =>
|
||||
<CatalogGuildBadgeWidgetView className="bottom-1 inset-e-1" position="absolute" />
|
||||
</div>
|
||||
<Column grow gap={ 1 }>
|
||||
<Text truncate>{ currentOffer.localizationName }</Text>
|
||||
<Text bold className="leading-tight">{ currentOffer.localizationName }</Text>
|
||||
<div className="grow!">
|
||||
<CatalogGuildSelectorWidgetView />
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ export const CatalogLayoutRoomAdsView: FC<CatalogLayoutProps> = props =>
|
||||
const { categories } = useNavigatorData();
|
||||
const { setIsVisible = null } = useCatalogUiState();
|
||||
const { promoteInformation, isExtended, setIsExtended } = useRoomPromote();
|
||||
const promoteData = promoteInformation?.data ?? null;
|
||||
|
||||
const { data: availableRooms = [] } = useNitroQuery<RoomAdPurchaseInfoEvent, RoomEntryData[]>({
|
||||
key: [ 'nitro', 'catalog', 'room-ad-purchase-info' ],
|
||||
@@ -31,17 +32,17 @@ export const CatalogLayoutRoomAdsView: FC<CatalogLayoutProps> = props =>
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(isExtended)
|
||||
if(isExtended && promoteData)
|
||||
{
|
||||
setRoomId(promoteInformation.data.flatId);
|
||||
setEventName(promoteInformation.data.eventName);
|
||||
setEventDesc(promoteInformation.data.eventDescription);
|
||||
setCategoryId(promoteInformation.data.categoryId);
|
||||
setRoomId(promoteData.flatId);
|
||||
setEventName(promoteData.eventName);
|
||||
setEventDesc(promoteData.eventDescription);
|
||||
setCategoryId(promoteData.categoryId);
|
||||
setExtended(isExtended); // This is for sending to packet
|
||||
setIsExtended(false); // This is from hook useRoomPromotte
|
||||
}
|
||||
|
||||
}, [ isExtended, eventName, eventDesc, categoryId, promoteInformation.data, setIsExtended ]);
|
||||
}, [ isExtended, promoteData, setIsExtended ]);
|
||||
|
||||
const resetData = () =>
|
||||
{
|
||||
|
||||
@@ -28,7 +28,7 @@ export const CatalogLayoutSingleBundleView: FC<CatalogLayoutProps> = props =>
|
||||
<Text center small overflow="auto">{ page.localization.getText(1) }</Text> }
|
||||
<Column grow gap={ 0 } overflow="hidden" position="relative">
|
||||
{ !!page.localization.getImage(1) &&
|
||||
<img alt="" className="grow!" src={ page.localization.getImage(1) } /> }
|
||||
<img alt="" className="grow! min-h-0 w-full h-full object-contain object-center" src={ page.localization.getImage(1) } /> }
|
||||
<CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-0 inset-s-0" position="absolute" />
|
||||
<CatalogSimplePriceWidgetView />
|
||||
</Column>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ICatalogPage } from '../../../../../api';
|
||||
import { CatalogLayoutProps } from './CatalogLayout.types';
|
||||
import { CatalogLayoutBadgeDisplayView } from './CatalogLayoutBadgeDisplayView';
|
||||
import { CatalogLayoutBcInfoView } from './CatalogLayoutBcInfoView';
|
||||
import { CatalogLayoutBuildersClubBuyView } from './CatalogLayoutBuildersClubBuyView';
|
||||
import { CatalogLayoutColorGroupingView } from './CatalogLayoutColorGroupingView';
|
||||
import { CatalogLayoutCustomPrefixView } from './CatalogLayoutCustomPrefixView';
|
||||
@@ -35,8 +34,6 @@ export const GetCatalogLayout = (page: ICatalogPage, hideNavigation: () => void)
|
||||
{
|
||||
case 'frontpage_featured':
|
||||
return null;
|
||||
case 'info_duckets':
|
||||
return <CatalogLayoutBcInfoView { ...layoutProps } />;
|
||||
case 'frontpage4':
|
||||
return <CatalogLayoutFrontpage4View { ...layoutProps } />;
|
||||
case 'pets':
|
||||
|
||||
@@ -206,7 +206,7 @@ export const CatalogLayoutPetView: FC<CatalogLayoutProps> = props =>
|
||||
</div> }
|
||||
|
||||
{ /* Top card: preview + name + purchase */ }
|
||||
<div className="flex gap-3 p-2.5 bg-white rounded border-2 border-card-grid-item-border">
|
||||
<div className="nitro-catalog-classic-pet-card flex gap-3 p-2.5 bg-white rounded border-2 border-card-grid-item-border">
|
||||
{ /* Pet preview */ }
|
||||
<div className="w-[160px] min-w-[160px] h-[140px] rounded overflow-hidden bg-card-grid-item relative flex items-center justify-center border border-card-grid-item-border">
|
||||
<CatalogViewProductWidgetView />
|
||||
@@ -240,12 +240,12 @@ export const CatalogLayoutPetView: FC<CatalogLayoutProps> = props =>
|
||||
<span className="text-[8px] font-mono text-white bg-primary px-1 py-px rounded">Offer: { currentOffer.offerId }</span>
|
||||
</div> }
|
||||
{ !!page.localization.getText(0) &&
|
||||
<p className="text-[10px] text-muted mt-0.5" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } /> }
|
||||
<p className="text-[10px] text-dark mt-0.5" dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } /> }
|
||||
</div>
|
||||
|
||||
{ /* Name input */ }
|
||||
<div className="flex flex-col gap-1 mt-2">
|
||||
<label className="text-[9px] text-muted uppercase font-bold">{ LocalizeText('widgets.petpackage.name.title') }</label>
|
||||
<label className="text-[9px] text-dark uppercase font-bold">{ LocalizeText('widgets.petpackage.name.title') }</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
className={ `w-full text-[11px] border-2 rounded px-2 py-1.5 focus:outline-none transition-colors ${ approvalResult > 0 ? 'border-danger bg-danger/5' : approvalResult === 0 ? 'border-success bg-success/5' : 'border-card-grid-item-border focus:border-primary bg-white' }` }
|
||||
@@ -267,7 +267,7 @@ export const CatalogLayoutPetView: FC<CatalogLayoutProps> = props =>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<CatalogTotalPriceWidget />
|
||||
<button
|
||||
className="px-3 py-1 rounded text-[11px] font-bold bg-primary text-white hover:bg-secondary transition-colors cursor-pointer disabled:opacity-50"
|
||||
className="nitro-catalog-swf-button nitro-catalog-swf-buy-button"
|
||||
disabled={ !petName.length || (approvalResult > 0) }
|
||||
onClick={ purchasePet }
|
||||
>
|
||||
@@ -280,7 +280,7 @@ export const CatalogLayoutPetView: FC<CatalogLayoutProps> = props =>
|
||||
{ /* Breed/Color grid */ }
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<span className="text-[10px] font-bold text-muted uppercase tracking-wide">
|
||||
<span className="text-[10px] font-bold text-dark uppercase tracking-wide">
|
||||
{ colorsShowing ? LocalizeText('catalog.pets.choose.color') : LocalizeText('catalog.pets.choose.breed') }
|
||||
</span>
|
||||
{ colorsShowing &&
|
||||
@@ -291,7 +291,7 @@ export const CatalogLayoutPetView: FC<CatalogLayoutProps> = props =>
|
||||
{ LocalizeText('catalog.pets.back.breeds') }
|
||||
</button> }
|
||||
</div>
|
||||
<div className="grid grid-cols-6 gap-1">
|
||||
<div className={ colorsShowing ? 'nitro-catalog-classic-color-swatches flex flex-wrap gap-1 p-2 overflow-auto' : 'nitro-catalog-classic-pet-breeds flex flex-wrap gap-1 p-1 overflow-auto' }>
|
||||
{ !colorsShowing && (sellablePalettes.length > 0) && sellablePalettes.map((palette, index) => (
|
||||
<LayoutGridItem
|
||||
key={ index }
|
||||
@@ -303,10 +303,12 @@ export const CatalogLayoutPetView: FC<CatalogLayoutProps> = props =>
|
||||
</LayoutGridItem>
|
||||
)) }
|
||||
{ colorsShowing && (sellableColors.length > 0) && sellableColors.map((colorSet, index) => (
|
||||
<div
|
||||
<LayoutGridItem
|
||||
key={ index }
|
||||
className={ `w-full aspect-square rounded border-2 cursor-pointer transition-all ${ selectedColorIndex === index ? 'border-primary scale-110 shadow-md' : 'border-card-grid-item-border hover:border-primary/50' }` }
|
||||
style={ { backgroundColor: `#${ ColorConverter.int2rgb(colorSet[0]) }` } }
|
||||
itemHighlight
|
||||
className="clear-bg"
|
||||
itemActive={ (selectedColorIndex === index) }
|
||||
itemColor={ ColorConverter.int2rgb(colorSet[0]) }
|
||||
onClick={ () => setSelectedColorIndex(index) }
|
||||
/>
|
||||
)) }
|
||||
|
||||
@@ -47,7 +47,7 @@ export const CatalogGuildSelectorWidgetView: FC<{}> = props =>
|
||||
return (
|
||||
<div className="bg-muted rounded p-1 text-black text-center">
|
||||
{ LocalizeText('catalog.guild_selector.members_only') }
|
||||
<Button className="mt-1">
|
||||
<Button fullWidth classNames={ [ 'mt-1', 'nitro-catalog-swf-button', 'nitro-catalog-swf-buy-button', 'whitespace-normal!', 'text-[10px]!', 'leading-tight!', 'py-1!' ] }>
|
||||
{ LocalizeText('catalog.guild_selector.find_groups') }
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -19,17 +19,17 @@ export const CatalogPriceDisplayWidgetView: FC<CatalogPriceDisplayWidgetViewProp
|
||||
if(!offer) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="nitro-catalog-swf-price-display">
|
||||
{ (offer.priceInCredits > 0) &&
|
||||
<div className="flex items-center gap-1 bg-warning/15 border border-warning/40 rounded-full px-2 py-0.5">
|
||||
<Text className="text-[11px]! font-bold text-dark">{ (offer.priceInCredits * quantity) }</Text>
|
||||
<div className="nitro-catalog-swf-price-pill">
|
||||
<Text className="nitro-catalog-swf-price-text">{ (offer.priceInCredits * quantity) }</Text>
|
||||
<LayoutCurrencyIcon type={ -1 } />
|
||||
</div> }
|
||||
{ separator && (offer.priceInCredits > 0) && (offer.priceInActivityPoints > 0) &&
|
||||
<FaPlus className="text-[7px] text-muted" /> }
|
||||
<FaPlus className="nitro-catalog-swf-price-plus" /> }
|
||||
{ (offer.priceInActivityPoints > 0) &&
|
||||
<div className="flex items-center gap-1 bg-purple/15 border border-purple/40 rounded-full px-2 py-0.5">
|
||||
<Text className="text-[11px]! font-bold text-dark">{ (offer.priceInActivityPoints * quantity) }</Text>
|
||||
<div className="nitro-catalog-swf-price-pill">
|
||||
<Text className="nitro-catalog-swf-price-text">{ (offer.priceInActivityPoints * quantity) }</Text>
|
||||
<LayoutCurrencyIcon type={ offer.activityPointType } />
|
||||
</div> }
|
||||
</div>
|
||||
|
||||
@@ -171,6 +171,8 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
|
||||
|
||||
const PurchaseButton = () =>
|
||||
{
|
||||
const swfButtonClassNames = [ 'nitro-catalog-swf-button' ];
|
||||
|
||||
if(isBuildersClubPlaceable)
|
||||
{
|
||||
const hasMissingExtraParam = (purchaseOptions.extraParamRequired && (!purchaseOptions.extraData || !purchaseOptions.extraData.length));
|
||||
@@ -198,10 +200,10 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 items-start">
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<Button disabled={ isDisabled } onClick={ () => startBuilderPlacement(true) }>
|
||||
<Button classNames={ swfButtonClassNames } disabled={ isDisabled } onClick={ () => startBuilderPlacement(true) }>
|
||||
{ LocalizeText('builder.placement_widget.place_many') }
|
||||
</Button>
|
||||
<Button disabled={ isDisabled } onClick={ () => startBuilderPlacement(false) } style={ buildersClubPlaceOneButtonStyle }>
|
||||
<Button classNames={ swfButtonClassNames } disabled={ isDisabled } onClick={ () => startBuilderPlacement(false) } style={ buildersClubPlaceOneButtonStyle }>
|
||||
{ LocalizeText('builder.placement_widget.place_one') }
|
||||
</Button>
|
||||
</div>
|
||||
@@ -220,37 +222,37 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
|
||||
const priceCredits = (currentOffer.priceInCredits * purchaseOptions.quantity);
|
||||
const pricePoints = (currentOffer.priceInActivityPoints * purchaseOptions.quantity);
|
||||
|
||||
if(GetClubMemberLevel() < currentOffer.clubLevel) return <Button disabled variant="danger">{ LocalizeText('catalog.alert.hc.required') }</Button>;
|
||||
if(GetClubMemberLevel() < currentOffer.clubLevel) return <Button classNames={ swfButtonClassNames } disabled variant="danger">{ LocalizeText('catalog.alert.hc.required') }</Button>;
|
||||
|
||||
if(isLimitedSoldOut) return <Button disabled variant="danger">{ LocalizeText('catalog.alert.limited_edition_sold_out.title') }</Button>;
|
||||
if(isLimitedSoldOut) return <Button classNames={ swfButtonClassNames } disabled variant="danger">{ LocalizeText('catalog.alert.limited_edition_sold_out.title') }</Button>;
|
||||
|
||||
if(priceCredits > getCurrencyAmount(-1)) return <Button disabled variant="danger">{ LocalizeText('catalog.alert.notenough.title') }</Button>;
|
||||
if(priceCredits > getCurrencyAmount(-1)) return <Button classNames={ swfButtonClassNames } disabled variant="danger">{ LocalizeText('catalog.alert.notenough.title') }</Button>;
|
||||
|
||||
if(pricePoints > getCurrencyAmount(currentOffer.activityPointType)) return <Button disabled variant="danger">{ LocalizeText('catalog.alert.notenough.activitypoints.title.' + currentOffer.activityPointType) }</Button>;
|
||||
if(pricePoints > getCurrencyAmount(currentOffer.activityPointType)) return <Button classNames={ swfButtonClassNames } disabled variant="danger">{ LocalizeText('catalog.alert.notenough.activitypoints.title.' + currentOffer.activityPointType) }</Button>;
|
||||
|
||||
switch(purchaseState)
|
||||
{
|
||||
case CatalogPurchaseState.CONFIRM:
|
||||
return <Button variant="warning" onClick={ event => purchase() }>{ LocalizeText('catalog.marketplace.confirm_title') }</Button>;
|
||||
return <Button classNames={ swfButtonClassNames } variant="warning" onClick={ event => purchase() }>{ LocalizeText('catalog.marketplace.confirm_title') }</Button>;
|
||||
case CatalogPurchaseState.PURCHASE:
|
||||
return <Button disabled><LayoutLoadingSpinnerView /></Button>;
|
||||
return <Button classNames={ swfButtonClassNames } disabled><LayoutLoadingSpinnerView /></Button>;
|
||||
case CatalogPurchaseState.FAILED:
|
||||
return <Button variant="danger">{ LocalizeText('generic.failed') }</Button>;
|
||||
return <Button classNames={ swfButtonClassNames } variant="danger">{ LocalizeText('generic.failed') }</Button>;
|
||||
case CatalogPurchaseState.SOLD_OUT:
|
||||
return <Button variant="danger">{ LocalizeText('generic.failed') + ' - ' + LocalizeText('catalog.alert.limited_edition_sold_out.title') }</Button>;
|
||||
return <Button classNames={ swfButtonClassNames } variant="danger">{ LocalizeText('generic.failed') + ' - ' + LocalizeText('catalog.alert.limited_edition_sold_out.title') }</Button>;
|
||||
case CatalogPurchaseState.NONE:
|
||||
default:
|
||||
return <Button variant="success" disabled={ (purchaseOptions.extraParamRequired && (!purchaseOptions.extraData || !purchaseOptions.extraData.length)) } onClick={ event => setPurchaseState(CatalogPurchaseState.CONFIRM) }>{ LocalizeText('catalog.purchase_confirmation.' + (currentOffer.isRentOffer ? 'rent' : 'buy')) }</Button>;
|
||||
return <Button classNames={ [ ...swfButtonClassNames, 'nitro-catalog-swf-buy-button' ] } variant="success" disabled={ (purchaseOptions.extraParamRequired && (!purchaseOptions.extraData || !purchaseOptions.extraData.length)) } onClick={ event => setPurchaseState(CatalogPurchaseState.CONFIRM) }>{ LocalizeText('catalog.purchase_confirmation.' + (currentOffer.isRentOffer ? 'rent' : 'buy')) }</Button>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PurchaseButton />
|
||||
{ (!isBuildersClubOffer && !noGiftOption && !currentOffer.isRentOffer) &&
|
||||
<Button disabled={ ((purchaseOptions.quantity > 1) || !currentOffer.giftable || isLimitedSoldOut || (purchaseOptions.extraParamRequired && (!purchaseOptions.extraData || !purchaseOptions.extraData.length))) } onClick={ event => purchase(true) }>
|
||||
<Button classNames={ [ 'nitro-catalog-swf-button', 'nitro-catalog-swf-gift-button' ] } disabled={ ((purchaseOptions.quantity > 1) || !currentOffer.giftable || isLimitedSoldOut || (purchaseOptions.extraParamRequired && (!purchaseOptions.extraData || !purchaseOptions.extraData.length))) } onClick={ event => purchase(true) }>
|
||||
{ LocalizeText('catalog.purchase_confirmation.gift') }
|
||||
</Button> }
|
||||
<PurchaseButton />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -34,26 +34,26 @@ export const CatalogSpinnerWidgetView: FC<{}> = props =>
|
||||
if(!currentOffer || !currentOffer.bundlePurchaseAllowed) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted whitespace-nowrap">{ LocalizeText('catalog.bundlewidget.spinner.select.amount') }</span>
|
||||
<div className="flex items-center rounded overflow-hidden border-2 border-card-grid-item-border">
|
||||
<div className="nitro-catalog-swf-spinner">
|
||||
<span className="nitro-catalog-swf-spinner-label">{ LocalizeText('catalog.bundlewidget.quantity') }</span>
|
||||
<div className="nitro-catalog-swf-spinner-control">
|
||||
<button
|
||||
className="w-[24px] h-[24px] flex items-center justify-center bg-card-grid-item hover:bg-card-grid-item-active transition-colors cursor-pointer border-r border-card-grid-item-border"
|
||||
className="nitro-catalog-swf-spinner-button nitro-catalog-swf-spinner-button-less"
|
||||
onClick={ event => updateQuantity(quantity - 1) }
|
||||
>
|
||||
<FaMinus className="text-[7px] text-dark" />
|
||||
<FaMinus />
|
||||
</button>
|
||||
<input
|
||||
className="w-[40px] h-[24px] text-center text-[11px] font-bold bg-white border-x border-card-grid-item-border [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none focus:outline-none"
|
||||
className="nitro-catalog-swf-spinner-input"
|
||||
type="number"
|
||||
value={ quantity }
|
||||
onChange={ event => updateQuantity(event.target.valueAsNumber) }
|
||||
/>
|
||||
<button
|
||||
className="w-[24px] h-[24px] flex items-center justify-center bg-card-grid-item hover:bg-card-grid-item-active transition-colors cursor-pointer border-l border-card-grid-item-border"
|
||||
className="nitro-catalog-swf-spinner-button nitro-catalog-swf-spinner-button-more"
|
||||
onClick={ event => updateQuantity(quantity + 1) }
|
||||
>
|
||||
<FaPlus className="text-[7px] text-dark" />
|
||||
<FaPlus />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,8 @@ export const CatalogViewProductWidgetView: FC<{}> = props =>
|
||||
roomPreviewer.updateObjectRoom('default', 'default', 'default');
|
||||
roomPreviewer.updateRoomWallsAndFloorVisibility(true, true);
|
||||
|
||||
const populate = () =>
|
||||
{
|
||||
switch(product.productType)
|
||||
{
|
||||
case ProductTypeEnum.FLOOR: {
|
||||
@@ -101,6 +103,17 @@ export const CatalogViewProductWidgetView: FC<{}> = props =>
|
||||
roomPreviewer.addAvatarIntoRoom(GetSessionDataManager().figure, product.productClassId);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
populate();
|
||||
|
||||
// RoomPreviewer.addFurnitureIntoRoom / addAvatarIntoRoom flip
|
||||
// _automaticStateChange to true, which makes the ticker advance
|
||||
// the room object's state every AUTOMATIC_STATE_CHANGE_INTERVAL.
|
||||
// In the catalog we want the preview to sit still until the
|
||||
// user clicks the state button explicitly - turn it back off
|
||||
// after populate() runs.
|
||||
roomPreviewer.setAutomaticStateChange(false);
|
||||
}, [ currentOffer, previewStuffData, roomPreviewer ]);
|
||||
|
||||
if(!currentOffer) return null;
|
||||
@@ -119,5 +132,11 @@ export const CatalogViewProductWidgetView: FC<{}> = props =>
|
||||
);
|
||||
}
|
||||
|
||||
return <LayoutRoomPreviewerView height={ 140 } roomPreviewer={ roomPreviewer } />;
|
||||
// Re-mount the previewer whenever the offer changes so the render
|
||||
// latch / texture handle in LayoutRoomPreviewerView resets cleanly.
|
||||
// Without this a single broken offer (e.g. blackhole's Pixi filter
|
||||
// crash) latches the previewer permanently and every following
|
||||
// offer paints nothing - the singleton roomPreviewer + 240px height
|
||||
// keep the same component mounted otherwise.
|
||||
return <LayoutRoomPreviewerView key={ currentOffer?.offerId } height={ 240 } roomPreviewer={ roomPreviewer } />;
|
||||
};
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
import { FC, useEffect, useRef } from 'react';
|
||||
import type { CommandDefinition } from '../../../../api';
|
||||
import type { RankedCommandDefinition } from '../../../../hooks/rooms/widgets/useChatCommandSelector.helpers';
|
||||
import { CommandDefinition } from '../../../../api';
|
||||
|
||||
interface ChatInputCommandSelectorViewProps
|
||||
{
|
||||
commands: RankedCommandDefinition[];
|
||||
commands: CommandDefinition[];
|
||||
selectedIndex: number;
|
||||
onSelect: (command: CommandDefinition) => void;
|
||||
onHover: (index: number) => void;
|
||||
/**
|
||||
* When true, render the flat minimalist look (gray list, dark-blue
|
||||
* selection). When false / undefined (default) the picker wears the
|
||||
* Habbo NitroCard chrome with the green :command header strip.
|
||||
*/
|
||||
newStyle?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* :command autocomplete popover. Two visual modes, both driven by the
|
||||
* "New style" toggle in user settings (memenu.settings.other.catalog.classic.style):
|
||||
*
|
||||
* - newStyle = false (default): cream cardstock, habbo-green header,
|
||||
* UbuntuCondensed names, green ":" tile, custom Habbo scrollbar.
|
||||
* - newStyle = true: flat gray list, dark-blue selection, plain text rows.
|
||||
*/
|
||||
export const ChatInputCommandSelectorView: FC<ChatInputCommandSelectorViewProps> = props =>
|
||||
{
|
||||
const { commands = [], selectedIndex = 0, onSelect = null, onHover = null } = props;
|
||||
const { commands = [], selectedIndex = 0, onSelect = null, onHover = null, newStyle = false } = props;
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() =>
|
||||
@@ -24,20 +37,57 @@ export const ChatInputCommandSelectorView: FC<ChatInputCommandSelectorViewProps>
|
||||
if(selected) selected.scrollIntoView({ block: 'nearest' });
|
||||
}, [ selectedIndex ]);
|
||||
|
||||
if(newStyle)
|
||||
{
|
||||
return (
|
||||
<div ref={ listRef } className="absolute bottom-full left-0 z-[1070] max-h-[238px] w-full overflow-y-auto rounded-t-[8px] border-2 border-b-0 border-black bg-[#f2f2eb] shadow-[0_-4px_14px_rgba(0,0,0,0.22)]">
|
||||
<div ref={ listRef } className="absolute bottom-full left-0 w-full bg-[#e8e8e8] border-2 border-black border-b-0 rounded-t-lg max-h-[240px] overflow-y-auto z-[1070]">
|
||||
{ commands.map((cmd, index) => (
|
||||
<button
|
||||
<div
|
||||
key={ cmd.key }
|
||||
className={ `flex min-h-[34px] w-full cursor-pointer items-center gap-2 border-b border-[#c6c6bd] px-3 py-1.5 text-left last:border-b-0 ${ index === selectedIndex ? 'bg-[#255d72] text-white' : 'text-black hover:bg-[#dceaf0]' }` }
|
||||
type="button"
|
||||
className={ `px-3 py-1.5 cursor-pointer text-sm flex items-center gap-2 ${ index === selectedIndex ? 'bg-[#283F5D] text-white' : 'hover:bg-gray-300' }` }
|
||||
onClick={ () => onSelect(cmd) }
|
||||
onMouseEnter={ () => onHover(index) }
|
||||
>
|
||||
<span className={ `shrink-0 rounded-[4px] border px-1.5 py-[1px] font-bold ${ index === selectedIndex ? 'border-white/60 bg-white/15' : 'border-[#8ca6b1] bg-white text-[#123b4c]' }` }>:{ cmd.key }</span>
|
||||
<span className={ `min-w-0 flex-1 truncate text-[12px] ${ index === selectedIndex ? 'text-white/85' : 'text-[#525252]' }` }>{ cmd.description }</span>
|
||||
</button>
|
||||
<span className="font-bold">:{ cmd.key }</span>
|
||||
<span className={ `text-xs ${ index === selectedIndex ? 'text-gray-300' : 'text-gray-500' }` }>{ cmd.description }</span>
|
||||
</div>
|
||||
)) }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-input-command-popover">
|
||||
<div className="chat-input-command-popover-header">
|
||||
<span className="chat-input-command-popover-header-dot" aria-hidden />
|
||||
<span>: Command</span>
|
||||
</div>
|
||||
<div ref={ listRef } className="chat-input-command-popover-list">
|
||||
{ commands.map((cmd, index) =>
|
||||
{
|
||||
const isSelected = (index === selectedIndex);
|
||||
const rowClass = [
|
||||
'chat-input-command-row',
|
||||
isSelected ? 'is-selected' : ''
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ cmd.key }
|
||||
className={ rowClass }
|
||||
onClick={ () => onSelect(cmd) }
|
||||
onMouseEnter={ () => onHover(index) }
|
||||
>
|
||||
<div className="chat-input-command-row-tile">:</div>
|
||||
<div className="chat-input-command-row-body">
|
||||
<span className="chat-input-command-row-name">:{ cmd.key }</span>
|
||||
{ cmd.description &&
|
||||
<span className="chat-input-command-row-desc">{ cmd.description }</span> }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,11 +19,28 @@ interface ChatInputMentionSelectorViewProps
|
||||
selectedIndex: number;
|
||||
onSelect: (suggestion: MentionSuggestion) => void;
|
||||
onHover: (index: number) => void;
|
||||
/**
|
||||
* When true, render the flat minimalist look (gray list, dark-blue
|
||||
* selection, no header / no kind chip). When false / undefined (default)
|
||||
* the picker wears the Habbo NitroCard chrome.
|
||||
*/
|
||||
newStyle?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @-mention autocomplete popover. Two visual modes, both driven by the
|
||||
* "New style" toggle in user settings (memenu.settings.other.catalog.classic.style):
|
||||
*
|
||||
* - newStyle = false (default): cream cardstock, habbo-blue header,
|
||||
* UbuntuCondensed names, kind chips, custom Habbo scrollbar.
|
||||
* - newStyle = true: flat gray list, dark-blue selection, plain text rows.
|
||||
*
|
||||
* Both modes share the same suggestion structure and keyboard contract -
|
||||
* the difference is purely cosmetic.
|
||||
*/
|
||||
export const ChatInputMentionSelectorView: FC<ChatInputMentionSelectorViewProps> = props =>
|
||||
{
|
||||
const { suggestions = [], selectedIndex = 0, onSelect = null, onHover = null } = props;
|
||||
const { suggestions = [], selectedIndex = 0, onSelect = null, onHover = null, newStyle = false } = props;
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() =>
|
||||
@@ -37,6 +54,8 @@ export const ChatInputMentionSelectorView: FC<ChatInputMentionSelectorViewProps>
|
||||
|
||||
if(suggestions.length === 0) return null;
|
||||
|
||||
if(newStyle)
|
||||
{
|
||||
return (
|
||||
<div ref={ listRef } className="absolute bottom-full left-0 w-full bg-[#e8e8e8] border-2 border-black border-b-0 rounded-t-lg max-h-[240px] overflow-y-auto z-[1070]">
|
||||
{ suggestions.map((suggestion, index) =>
|
||||
@@ -72,4 +91,55 @@ export const ChatInputMentionSelectorView: FC<ChatInputMentionSelectorViewProps>
|
||||
}) }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-input-mention-popover">
|
||||
<div className="chat-input-mention-popover-header">
|
||||
<span className="chat-input-mention-popover-header-dot" aria-hidden />
|
||||
<span>@ Mention</span>
|
||||
</div>
|
||||
<div ref={ listRef } className="chat-input-mention-popover-list">
|
||||
{ suggestions.map((suggestion, index) =>
|
||||
{
|
||||
const isSelected = (index === selectedIndex);
|
||||
const rowClass = [
|
||||
'chat-input-mention-row',
|
||||
isSelected ? 'is-selected' : ''
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ suggestion.key }
|
||||
className={ rowClass }
|
||||
onClick={ () => onSelect(suggestion) }
|
||||
onMouseEnter={ () => onHover(index) }
|
||||
>
|
||||
{ suggestion.kind === 'user' && suggestion.figure
|
||||
? (
|
||||
<div className="chat-input-mention-row-tile">
|
||||
<LayoutAvatarImageView
|
||||
figure={ suggestion.figure }
|
||||
direction={ 2 }
|
||||
headOnly
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="chat-input-mention-row-tile is-alias">@</div>
|
||||
) }
|
||||
<div className="chat-input-mention-row-body">
|
||||
<span className="chat-input-mention-row-name">@{ suggestion.name }</span>
|
||||
{ suggestion.description &&
|
||||
<span className="chat-input-mention-row-desc">{ suggestion.description }</span> }
|
||||
</div>
|
||||
<span className={ `chat-input-mention-row-kind ${ suggestion.kind === 'alias' ? 'is-alias' : '' }` }>
|
||||
{ suggestion.kind === 'alias' ? 'Broadcast' : 'User' }
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}) }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChatMessageTypeEnum, GetClubMemberLevel, GetConfigurationValue, LocalizeText, RoomWidgetUpdateChatInputContentEvent } from '../../../../api';
|
||||
import { Text } from '../../../../common';
|
||||
import { useChatCommandSelector, useChatInputWidget, useRoom, useSessionInfo, useUiEvent } from '../../../../hooks';
|
||||
import { useCatalogClassicStyle, useChatCommandSelector, useChatInputWidget, useRoom, useSessionInfo, useUiEvent } from '../../../../hooks';
|
||||
import { useRoomUserListSnapshot } from '../../../../hooks/session/useSessionSnapshots';
|
||||
import { ChatInputCommandSelectorView } from './ChatInputCommandSelectorView';
|
||||
import { ChatInputEmojiSelectorView } from './ChatInputEmojiSelectorView';
|
||||
@@ -12,15 +12,40 @@ import { ChatInputStyleSelectorView } from './ChatInputStyleSelectorView';
|
||||
|
||||
const USER_TYPE_REAL_USER = 1;
|
||||
const MAX_MENTION_SUGGESTIONS = 8;
|
||||
const MENTION_ALIASES: ReadonlyArray<{ key: string; label: string; description?: string }> = [
|
||||
{ key: 'all', label: 'all', description: 'Everyone in the hotel' },
|
||||
{ key: 'everyone', label: 'everyone', description: 'Everyone in the hotel' },
|
||||
{ key: 'tutti', label: 'tutti', description: 'Everyone in the hotel' },
|
||||
{ key: 'friends', label: 'friends', description: 'Your online friends' },
|
||||
{ key: 'amici', label: 'amici', description: 'Your online friends' },
|
||||
{ key: 'room', label: 'room', description: 'Everyone in this room' },
|
||||
{ key: 'stanza', label: 'stanza', description: 'Everyone in this room' }
|
||||
];
|
||||
|
||||
type MentionAliasScope = 'everyone' | 'friends' | 'room';
|
||||
|
||||
const MENTION_ALIAS_CONFIG_KEY: Record<MentionAliasScope, string> = {
|
||||
everyone: 'mentions_ui.aliases.everyone',
|
||||
friends: 'mentions_ui.aliases.friends',
|
||||
room: 'mentions_ui.aliases.room'
|
||||
};
|
||||
|
||||
const MENTION_ALIAS_DEFAULTS: Record<MentionAliasScope, string[]> = {
|
||||
everyone: [ 'all', 'everyone', 'tutti' ],
|
||||
friends: [ 'friends', 'amici' ],
|
||||
room: [ 'room', 'stanza' ]
|
||||
};
|
||||
|
||||
const MENTION_ALIAS_DESCRIPTION_KEY: Record<MentionAliasScope, string> = {
|
||||
everyone: 'mentions.alias.description.everyone',
|
||||
friends: 'mentions.alias.description.friends',
|
||||
room: 'mentions.alias.description.room'
|
||||
};
|
||||
|
||||
const sanitizeAliasList = (raw: unknown, fallback: string[]): string[] =>
|
||||
{
|
||||
if(!Array.isArray(raw)) return fallback;
|
||||
const out: string[] = [];
|
||||
for(const entry of raw)
|
||||
{
|
||||
if(typeof entry !== 'string') continue;
|
||||
const trimmed = entry.trim();
|
||||
if(!trimmed) continue;
|
||||
out.push(trimmed);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export const ChatInputView: FC<{}> = props =>
|
||||
{
|
||||
@@ -30,8 +55,14 @@ export const ChatInputView: FC<{}> = props =>
|
||||
const { roomSession = null } = useRoom();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { isVisible: commandSelectorVisible, filteredCommands, selectedIndex, setSelectedIndex, moveUp, moveDown, selectCurrent, close: closeCommandSelector } = useChatCommandSelector(chatValue);
|
||||
|
||||
const roomUserList = useRoomUserListSnapshot();
|
||||
const [ mentionSelectedIndex, setMentionSelectedIndex ] = useState<number>(0);
|
||||
// The "New style" user-setting (memenu.settings.other.catalog.classic.style)
|
||||
// drives BOTH the catalog layout and the mention-picker chrome:
|
||||
// false (default) = Habbo old-school NitroCard cardstock look
|
||||
// true = flat minimalist gray look
|
||||
const [ newStyle ] = useCatalogClassicStyle();
|
||||
|
||||
const mentionContext = useMemo(() =>
|
||||
{
|
||||
@@ -46,12 +77,38 @@ export const ChatInputView: FC<{}> = props =>
|
||||
if(at > 0 && !/\s/.test(upToCaret.charAt(at - 1))) return null;
|
||||
|
||||
const query = upToCaret.slice(at + 1);
|
||||
|
||||
if(/\s/.test(query)) return null;
|
||||
|
||||
return { atIndex: at, replaceFrom: at, replaceTo: caret, query };
|
||||
}, [ chatValue, commandSelectorVisible ]);
|
||||
|
||||
const mentionAliases = useMemo<ReadonlyArray<{ key: string; scope: MentionAliasScope; description: string }>>(() =>
|
||||
{
|
||||
const out: { key: string; scope: MentionAliasScope; description: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const scopes: MentionAliasScope[] = [ 'everyone', 'friends', 'room' ];
|
||||
for(const scope of scopes)
|
||||
{
|
||||
const list = sanitizeAliasList(
|
||||
GetConfigurationValue<unknown>(MENTION_ALIAS_CONFIG_KEY[scope], MENTION_ALIAS_DEFAULTS[scope]),
|
||||
MENTION_ALIAS_DEFAULTS[scope]
|
||||
);
|
||||
|
||||
for(const key of list)
|
||||
{
|
||||
const lower = key.toLowerCase();
|
||||
|
||||
if(seen.has(lower)) continue;
|
||||
seen.add(lower);
|
||||
|
||||
out.push({ key, scope, description: LocalizeText(MENTION_ALIAS_DESCRIPTION_KEY[scope]) });
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}, []);
|
||||
|
||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(() =>
|
||||
{
|
||||
if(!mentionContext) return [];
|
||||
@@ -76,14 +133,14 @@ export const ChatInputView: FC<{}> = props =>
|
||||
if(out.length >= MAX_MENTION_SUGGESTIONS) break;
|
||||
}
|
||||
|
||||
for(const alias of MENTION_ALIASES)
|
||||
for(const alias of mentionAliases)
|
||||
{
|
||||
if(query.length > 0 && !alias.key.toLowerCase().startsWith(query)) continue;
|
||||
|
||||
out.push({
|
||||
key: `alias:${ alias.key }`,
|
||||
kind: 'alias',
|
||||
name: alias.label,
|
||||
name: alias.key,
|
||||
insertToken: alias.key,
|
||||
description: alias.description
|
||||
});
|
||||
@@ -92,7 +149,7 @@ export const ChatInputView: FC<{}> = props =>
|
||||
}
|
||||
|
||||
return out;
|
||||
}, [ mentionContext, roomUserList ]);
|
||||
}, [ mentionContext, roomUserList, mentionAliases ]);
|
||||
|
||||
const mentionSelectorVisible = mentionSuggestions.length > 0;
|
||||
|
||||
@@ -148,23 +205,6 @@ export const ChatInputView: FC<{}> = props =>
|
||||
inputRef.current.setSelectionRange((inputRef.current.value.length * 2), (inputRef.current.value.length * 2));
|
||||
}, [ inputRef ]);
|
||||
|
||||
const setChatInputValue = useCallback((value: string, markTyping: boolean = true) =>
|
||||
{
|
||||
setChatValue(value);
|
||||
|
||||
if(markTyping)
|
||||
{
|
||||
setIsTyping(!!value.length);
|
||||
setIsIdle(!!value.length);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() =>
|
||||
{
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.setSelectionRange(value.length, value.length);
|
||||
});
|
||||
}, [ setIsTyping, setIsIdle ]);
|
||||
|
||||
const checkSpecialKeywordForInput = useCallback(() =>
|
||||
{
|
||||
setChatValue(prevValue =>
|
||||
@@ -279,7 +319,7 @@ export const ChatInputView: FC<{}> = props =>
|
||||
if(selected)
|
||||
{
|
||||
event.preventDefault();
|
||||
setChatInputValue(':' + selected.key + ' ');
|
||||
setChatValue(':' + selected.key + ' ');
|
||||
return;
|
||||
}
|
||||
break;
|
||||
@@ -361,9 +401,6 @@ export const ChatInputView: FC<{}> = props =>
|
||||
{
|
||||
switch(event.chatMode)
|
||||
{
|
||||
case RoomWidgetUpdateChatInputContentEvent.TEXT:
|
||||
setChatInputValue(event.userName);
|
||||
return;
|
||||
case RoomWidgetUpdateChatInputContentEvent.WHISPER: {
|
||||
setChatValue(`${ chatModeIdWhisper } ${ event.userName } `);
|
||||
return;
|
||||
@@ -450,9 +487,10 @@ export const ChatInputView: FC<{}> = props =>
|
||||
selectedIndex={ selectedIndex }
|
||||
onSelect={ (cmd) =>
|
||||
{
|
||||
setChatInputValue(':' + cmd.key + ' ');
|
||||
setChatValue(':' + cmd.key + ' '); inputRef.current?.focus();
|
||||
} }
|
||||
onHover={ setSelectedIndex }
|
||||
newStyle={ newStyle }
|
||||
/> }
|
||||
{ mentionSelectorVisible && !commandSelectorVisible &&
|
||||
<ChatInputMentionSelectorView
|
||||
@@ -460,6 +498,7 @@ export const ChatInputView: FC<{}> = props =>
|
||||
selectedIndex={ mentionSelectedIndex }
|
||||
onSelect={ applyMentionSuggestion }
|
||||
onHover={ setMentionSelectedIndex }
|
||||
newStyle={ newStyle }
|
||||
/> }
|
||||
<div className="flex-1 items-center input-sizer">
|
||||
{ !floodBlocked &&
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { FC, useRef } from 'react';
|
||||
import ReactPlayer from 'react-player/youtube';
|
||||
import { LocalizeText, YoutubeVideoPlaybackStateEnum } from '../../../../api';
|
||||
import { AutoGrid, AutoGridProps, LayoutGridItem, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
|
||||
import { useFurnitureYoutubeWidget } from '../../../../hooks';
|
||||
import ReactPlayer from '../../../youtube/YoutubeReactPlayer';
|
||||
|
||||
interface FurnitureYoutubeDisplayViewProps extends AutoGridProps
|
||||
{
|
||||
@@ -12,7 +12,7 @@ interface FurnitureYoutubeDisplayViewProps extends AutoGridProps
|
||||
export const FurnitureYoutubeDisplayView: FC<{}> = FurnitureYoutubeDisplayViewProps =>
|
||||
{
|
||||
const { objectId = -1, videoId = null, videoStart = 0, videoEnd = 0, currentVideoState = null, selectedVideo = null, playlists = [], onClose = null, previous = null, next = null, pause = null, play = null, selectVideo = null } = useFurnitureYoutubeWidget();
|
||||
const playerRef = useRef<ReactPlayer>(null);
|
||||
const playerRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
const handlePlay = () =>
|
||||
{
|
||||
@@ -39,7 +39,7 @@ export const FurnitureYoutubeDisplayView: FC<{}> = FurnitureYoutubeDisplayViewPr
|
||||
{ (videoId && videoId.length > 0) &&
|
||||
<ReactPlayer
|
||||
ref={ playerRef }
|
||||
url={ `https://www.youtube.com/watch?v=${ videoId }` }
|
||||
src={ `https://www.youtube.com/watch?v=${ videoId }` }
|
||||
width={ 500 }
|
||||
height={ 375 }
|
||||
playing={ playing }
|
||||
@@ -47,12 +47,9 @@ export const FurnitureYoutubeDisplayView: FC<{}> = FurnitureYoutubeDisplayViewPr
|
||||
onPlay={ handlePlay }
|
||||
onPause={ handlePause }
|
||||
config={ {
|
||||
playerVars: {
|
||||
autoplay: 1,
|
||||
youtube: {
|
||||
disablekb: 1,
|
||||
controls: 0,
|
||||
origin: window.origin,
|
||||
modestbranding: 1,
|
||||
start: videoStart,
|
||||
end: videoEnd
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ControlYoutubeDisplayPlaybackMessageComposer, YouTubeRoomBroadcastEvent, YouTubeRoomPlayComposer, YouTubeRoomSettingsEvent, YouTubeRoomWatchersEvent, YouTubeRoomWatchingComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useRef, useState } from 'react';
|
||||
import ReactPlayer from 'react-player/youtube';
|
||||
import { GetRoomSession, getYoutubeRoomEnabled, LocalizeText, SendMessageComposer, YoutubeVideoPlaybackStateEnum } from '../../api';
|
||||
import { NitroCardContentView, NitroCardHeaderView, NitroCardView, LayoutAvatarImageView } from '../../common';
|
||||
import { useFurnitureYoutubeWidget, useHasPermission, useMessageEvent } from '../../hooks';
|
||||
import ReactPlayer from '../youtube/YoutubeReactPlayer';
|
||||
|
||||
const CONTROL_COMMAND_PREVIOUS_VIDEO = 0;
|
||||
const CONTROL_COMMAND_NEXT_VIDEO = 1;
|
||||
@@ -38,7 +38,7 @@ export const YouTubePlayerView: FC<{}> = () =>
|
||||
const [playlist, setPlaylist] = useState<string[]>([]);
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [showVolumeSlider, setShowVolumeSlider] = useState(true);
|
||||
const playerRef = useRef<ReactPlayer | null>(null);
|
||||
const playerRef = useRef<HTMLVideoElement | null>(null);
|
||||
const { objectId: youtubeObjectId, videoId: roomVideoId, currentVideoState, hasControl } = useFurnitureYoutubeWidget();
|
||||
const [spectators, setSpectators] = useState< { id: number; name: string; look: string }[] >([]);
|
||||
const [broadcastVideo, setBroadcastVideo] = useState('');
|
||||
@@ -380,7 +380,7 @@ export const YouTubePlayerView: FC<{}> = () =>
|
||||
{
|
||||
playerRef.current = ref;
|
||||
}}
|
||||
url={`https://www.youtube.com/watch?v=${videoId}`}
|
||||
src={`https://www.youtube.com/watch?v=${videoId}`}
|
||||
width="100%"
|
||||
height={isFullscreen ? '100%' : 280}
|
||||
playing
|
||||
@@ -388,10 +388,7 @@ export const YouTubePlayerView: FC<{}> = () =>
|
||||
loop={isLooping}
|
||||
volume={Math.max(0, Math.min(1, volume / 100))}
|
||||
config={{
|
||||
playerVars: {
|
||||
autoplay: 1,
|
||||
loop: isLooping ? 1 : 0,
|
||||
},
|
||||
youtube: {},
|
||||
}}
|
||||
onReady={() => addToHistory(videoId)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ComponentType, LazyExoticComponent, lazy } from 'react';
|
||||
import HtmlPlayer from 'react-player/HtmlPlayer';
|
||||
import { canPlay } from 'react-player/patterns';
|
||||
import { PlayerEntry } from 'react-player/players';
|
||||
import { createReactPlayer } from 'react-player/ReactPlayer';
|
||||
import { VideoElementProps } from 'react-player/types';
|
||||
|
||||
const YoutubeElement = lazy(() => import('youtube-video-element/react')) as LazyExoticComponent<ComponentType<VideoElementProps>>;
|
||||
|
||||
const YoutubeReactPlayer = createReactPlayer(
|
||||
[
|
||||
{
|
||||
key: 'youtube',
|
||||
name: 'YouTube',
|
||||
canPlay: canPlay.youtube,
|
||||
player: YoutubeElement
|
||||
}
|
||||
] satisfies PlayerEntry[],
|
||||
{
|
||||
key: 'html',
|
||||
name: 'html',
|
||||
canPlay: canPlay.html,
|
||||
canEnablePIP: () => true,
|
||||
player: HtmlPlayer
|
||||
}
|
||||
);
|
||||
|
||||
export default YoutubeReactPlayer;
|
||||
@@ -1629,6 +1629,9 @@
|
||||
.infostand-border.border-15 { border-color: #cbd5e1; } /* Silver */
|
||||
.infostand-border.border-16 { border-color: #1f2937; } /* Black */
|
||||
|
||||
/* Image-based borders (17-25). These override the colour-border insets and
|
||||
strip the CSS border so the artwork sits ~22px outside the card and
|
||||
stretches to fill the frame area. */
|
||||
.infostand-border.border-17,
|
||||
.infostand-border.border-18,
|
||||
.infostand-border.border-19,
|
||||
@@ -1659,6 +1662,8 @@
|
||||
.infostand-border.border-24 { background-image: url('@/assets/images/backgrounds/borders/border_24.webp'); }
|
||||
.infostand-border.border-25 { background-image: url('@/assets/images/backgrounds/borders/border_25.webp'); }
|
||||
|
||||
/* Picker thumbnails inside the BackgroundsView "Borders" tab.
|
||||
Each thumbnail is a small rounded box outlined in its border colour. */
|
||||
.profile-border {
|
||||
width: 60px;
|
||||
height: 76px;
|
||||
@@ -1669,7 +1674,9 @@
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* border-0 = no border (default) — show as a dashed translucent outline */
|
||||
.profile-border.border-0 { border: 2px dashed rgba(255, 255, 255, 0.25); }
|
||||
|
||||
.profile-border.border-1 { border-color: #ef4444; }
|
||||
.profile-border.border-2 { border-color: #f97316; }
|
||||
.profile-border.border-3 { border-color: #eab308; }
|
||||
@@ -1686,6 +1693,8 @@
|
||||
.profile-border.border-14 { border-color: #d4a020; }
|
||||
.profile-border.border-15 { border-color: #cbd5e1; }
|
||||
.profile-border.border-16 { border-color: #1f2937; }
|
||||
|
||||
/* Image-border picker thumbnails — drop the CSS frame and show the artwork. */
|
||||
.profile-border.border-17,
|
||||
.profile-border.border-18,
|
||||
.profile-border.border-19,
|
||||
@@ -1711,60 +1720,3 @@
|
||||
.profile-border.border-23 { background-image: url('@/assets/images/backgrounds/borders/border_23.webp'); }
|
||||
.profile-border.border-24 { background-image: url('@/assets/images/backgrounds/borders/border_24.webp'); }
|
||||
.profile-border.border-25 { background-image: url('@/assets/images/backgrounds/borders/border_25.webp'); }
|
||||
|
||||
.card-background-2,
|
||||
.card-background-3,
|
||||
.card-background-4,
|
||||
.card-background-5,
|
||||
.card-background-6,
|
||||
.card-background-7,
|
||||
.card-background-12,
|
||||
.card-background-13,
|
||||
.card-background-28,
|
||||
.card-background-30,
|
||||
.card-background-35,
|
||||
.card-background-52,
|
||||
.card-background-55,
|
||||
.card-background-56,
|
||||
.card-background-58,
|
||||
.card-background-60,
|
||||
.card-background-95,
|
||||
.card-background-116,
|
||||
.card-background-122,
|
||||
.card-background-127,
|
||||
.card-background-129,
|
||||
.card-background-131,
|
||||
.card-background-144,
|
||||
.card-background-149,
|
||||
.card-background-150,
|
||||
.card-background-161,
|
||||
.card-background-185,
|
||||
.card-background-187 {
|
||||
--profile-card-text: #1a1a1a;
|
||||
--profile-card-shadow: 0 1px 1px rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
[class*="card-background-"] .nitro-extended-profile__username .username,
|
||||
[class*="card-background-"] .nitro-extended-profile__motto,
|
||||
[class*="card-background-"] .nitro-extended-profile__meta,
|
||||
[class*="card-background-"] .nitro-extended-profile__meta b,
|
||||
[class*="card-background-"] .nitro-extended-profile__meta span,
|
||||
[class*="card-background-"] .nitro-extended-profile__status-text,
|
||||
[class*="card-background-"] .nitro-extended-profile__relationships-label,
|
||||
[class*="card-background-"] .nitro-extended-profile__relationship-subcopy,
|
||||
[class*="card-background-"] .nitro-extended-profile__link,
|
||||
[class*="card-background-"] .nitro-extended-profile__right > p,
|
||||
[class*="card-background-"] .nitro-extended-profile__right > p b {
|
||||
color: var(--profile-card-text, #fff) !important;
|
||||
text-shadow: var(--profile-card-shadow, 0 1px 2px rgba(0, 0, 0, 0.55));
|
||||
}
|
||||
|
||||
.profile-card-background[class*="card-background-"]:not(.nitro-extended-profile-window__content) {
|
||||
color: var(--profile-card-text, #fff);
|
||||
text-shadow: var(--profile-card-shadow, 0 1px 2px rgba(0, 0, 0, 0.55));
|
||||
}
|
||||
|
||||
.profile-card-background[class*="card-background-"]:not(.nitro-extended-profile-window__content) .text-white {
|
||||
color: var(--profile-card-text, #fff) !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
/* ============================================================================
|
||||
Chat-bar @-mention autocomplete - Habbo style
|
||||
----------------------------------------------------------------------------
|
||||
Mirrors the NitroCard look (cream cardstock, habbo-blue header, black 2px
|
||||
border, drop shadow) and the in-room infostand row chrome. The popover
|
||||
appears above the chat input, anchored to its bottom-left and the same
|
||||
width as the input, with the bottom corners flush so it visually merges
|
||||
with the input edge.
|
||||
============================================================================ */
|
||||
|
||||
.chat-input-mention-popover {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
margin-bottom: 4px;
|
||||
background: #f2f2eb;
|
||||
border: 2px solid #000;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.28);
|
||||
overflow: hidden;
|
||||
z-index: 1070;
|
||||
font-family: Volter, Volter_Goldfish, "Ubuntu", sans-serif;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.chat-input-mention-popover-header {
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
background: #30728c;
|
||||
border-bottom: 2px solid #000;
|
||||
color: #fff;
|
||||
text-shadow: 1px 1px 1px #000;
|
||||
font-family: UbuntuCondensed, Ubuntu, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
letter-spacing: 0.3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-input-mention-popover-header-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #ffdc4c;
|
||||
border: 1px solid #000;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 1px 1px 0 rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.chat-input-mention-popover-list {
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.chat-input-mention-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
transition: background 0.08s linear, border-color 0.08s linear;
|
||||
}
|
||||
|
||||
.chat-input-mention-row:hover {
|
||||
background: rgba(48, 114, 140, 0.12);
|
||||
}
|
||||
|
||||
.chat-input-mention-row.is-selected {
|
||||
background: #30728c;
|
||||
border-color: #000;
|
||||
color: #fff;
|
||||
text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.45);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.chat-input-mention-row-tile {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #000;
|
||||
background: #cfcfc4;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), inset 0 -1px 0 rgba(0, 0, 0, 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-input-mention-row.is-selected .chat-input-mention-row-tile {
|
||||
background: #1f5a72;
|
||||
}
|
||||
|
||||
.chat-input-mention-row-tile.is-alias {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(180deg, #ffdc4c 0%, #f0a91c 100%);
|
||||
color: #4a2b00;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
text-shadow: 1px 1px 0 rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.chat-input-mention-row.is-selected .chat-input-mention-row-tile.is-alias {
|
||||
background: linear-gradient(180deg, #ffe97a 0%, #f9bd44 100%);
|
||||
}
|
||||
|
||||
.chat-input-mention-row-tile .avatar-image {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
left: 0 !important;
|
||||
background-repeat: no-repeat;
|
||||
background-position: -22px -32px;
|
||||
background-size: auto;
|
||||
}
|
||||
|
||||
.chat-input-mention-row-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.chat-input-mention-row-name {
|
||||
font-family: UbuntuCondensed, Ubuntu, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.1;
|
||||
color: #2c2c2c;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-input-mention-row.is-selected .chat-input-mention-row-name {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chat-input-mention-row-desc {
|
||||
font-size: 10px;
|
||||
line-height: 1.1;
|
||||
color: #6b6b6b;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-input-mention-row.is-selected .chat-input-mention-row-desc {
|
||||
color: #d6e6ee;
|
||||
}
|
||||
|
||||
.chat-input-mention-row-kind {
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
padding: 2px 5px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #000;
|
||||
background: #cfe6ef;
|
||||
color: #1c3d4c;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.chat-input-mention-row-kind.is-alias {
|
||||
background: #ffe97a;
|
||||
color: #5a3a00;
|
||||
}
|
||||
|
||||
.chat-input-mention-row.is-selected .chat-input-mention-row-kind {
|
||||
background: #ffdc4c;
|
||||
color: #4a2b00;
|
||||
}
|
||||
|
||||
/* Habbo-style scrollbar, matching NitroCardContentView scroll chrome. */
|
||||
.chat-input-mention-popover-list::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.chat-input-mention-popover-list::-webkit-scrollbar-track {
|
||||
background: #d8d8cf;
|
||||
border-left: 1px solid #000;
|
||||
}
|
||||
|
||||
.chat-input-mention-popover-list::-webkit-scrollbar-thumb {
|
||||
background: #30728c;
|
||||
border: 1px solid #000;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chat-input-mention-popover-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #3c88a6;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
:command popover - same Habbo NitroCard chrome as the @-mention picker.
|
||||
Header is green to distinguish it visually from the mention popover,
|
||||
which uses the standard habbo-blue.
|
||||
============================================================================ */
|
||||
|
||||
.chat-input-command-popover {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
margin-bottom: 4px;
|
||||
background: #f2f2eb;
|
||||
border: 2px solid #000;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.28);
|
||||
overflow: hidden;
|
||||
z-index: 1070;
|
||||
font-family: Volter, Volter_Goldfish, "Ubuntu", sans-serif;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.chat-input-command-popover-header {
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
background: #2f8d4a;
|
||||
border-bottom: 2px solid #000;
|
||||
color: #fff;
|
||||
text-shadow: 1px 1px 1px #000;
|
||||
font-family: UbuntuCondensed, Ubuntu, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
letter-spacing: 0.3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-input-command-popover-header-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #ffdc4c;
|
||||
border: 1px solid #000;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 1px 1px 0 rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.chat-input-command-popover-list {
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.chat-input-command-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
transition: background 0.08s linear, border-color 0.08s linear;
|
||||
}
|
||||
|
||||
.chat-input-command-row:hover {
|
||||
background: rgba(47, 141, 74, 0.12);
|
||||
}
|
||||
|
||||
.chat-input-command-row.is-selected {
|
||||
background: #2f8d4a;
|
||||
border-color: #000;
|
||||
color: #fff;
|
||||
text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.45);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.chat-input-command-row-tile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #000;
|
||||
background: linear-gradient(180deg, #b6e6c4 0%, #5ec07d 100%);
|
||||
color: #1a4a28;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
font-family: UbuntuCondensed, Ubuntu, sans-serif;
|
||||
text-shadow: 1px 1px 0 rgba(255, 255, 255, 0.45);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), inset 0 -1px 0 rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.chat-input-command-row.is-selected .chat-input-command-row-tile {
|
||||
background: linear-gradient(180deg, #c8f0d5 0%, #7ed79a 100%);
|
||||
}
|
||||
|
||||
.chat-input-command-row-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.chat-input-command-row-name {
|
||||
font-family: UbuntuCondensed, Ubuntu, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.1;
|
||||
color: #2c2c2c;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-input-command-row.is-selected .chat-input-command-row-name {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chat-input-command-row-desc {
|
||||
font-size: 10px;
|
||||
line-height: 1.1;
|
||||
color: #6b6b6b;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-input-command-row.is-selected .chat-input-command-row-desc {
|
||||
color: #d9efde;
|
||||
}
|
||||
|
||||
.chat-input-command-popover-list::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.chat-input-command-popover-list::-webkit-scrollbar-track {
|
||||
background: #d8d8cf;
|
||||
border-left: 1px solid #000;
|
||||
}
|
||||
|
||||
.chat-input-command-popover-list::-webkit-scrollbar-thumb {
|
||||
background: #2f8d4a;
|
||||
border: 1px solid #000;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chat-input-command-popover-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #3aa55b;
|
||||
}
|
||||
@@ -807,435 +807,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-253 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_253.png');
|
||||
border-image-slice: 16 22 15 27 fill;
|
||||
border-image-width: 16px 22px 15px 27px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_253_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-254 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_254.png');
|
||||
border-image-slice: 7 28 15 25 fill;
|
||||
border-image-width: 7px 28px 15px 25px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_254_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-255 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_255.png');
|
||||
border-image-slice: 12 19 22 30 fill;
|
||||
border-image-width: 12px 19px 22px 30px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_255_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-256 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_256.png');
|
||||
border-image-slice: 24 18 10 31 fill;
|
||||
border-image-width: 24px 18px 10px 31px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_256_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-257 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_257.png');
|
||||
border-image-slice: 6 17 19 36 fill;
|
||||
border-image-width: 6px 17px 19px 36px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_257_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-258 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_258.png');
|
||||
border-image-slice: 22 27 10 27 fill;
|
||||
border-image-width: 22px 27px 10px 27px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_258_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-259 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_259.png');
|
||||
border-image-slice: 21 27 18 37 fill;
|
||||
border-image-width: 21px 27px 18px 37px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_259_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-260 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_260.png');
|
||||
border-image-slice: 6 22 16 27 fill;
|
||||
border-image-width: 6px 22px 16px 27px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_260_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-261 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_261.png');
|
||||
border-image-slice: 18 27 5 22 fill;
|
||||
border-image-width: 18px 27px 5px 22px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_261_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-262 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_262.png');
|
||||
border-image-slice: 33 31 11 34 fill;
|
||||
border-image-width: 33px 31px 11px 34px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_262_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-263 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_263.png');
|
||||
border-image-slice: 15 19 10 32 fill;
|
||||
border-image-width: 15px 19px 10px 32px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_263_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-264 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_264.png');
|
||||
border-image-slice: 18 24 16 25 fill;
|
||||
border-image-width: 18px 24px 16px 25px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_264_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-265 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_265.png');
|
||||
border-image-slice: 41 40 17 18 fill;
|
||||
border-image-width: 41px 40px 17px 18px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_265_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-266 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_266.png');
|
||||
border-image-slice: 13 34 22 27 fill;
|
||||
border-image-width: 13px 34px 22px 27px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_266_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-267 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_267.png');
|
||||
border-image-slice: 17 30 22 25 fill;
|
||||
border-image-width: 17px 30px 22px 25px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_267_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-268 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_268.png');
|
||||
border-image-slice: 7 30 21 24 fill;
|
||||
border-image-width: 7px 30px 21px 24px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_268_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-269 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_269.png');
|
||||
border-image-slice: 10 23 25 35 fill;
|
||||
border-image-width: 10px 23px 25px 35px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_269_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-270 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_270.png');
|
||||
border-image-slice: 13 30 14 26 fill;
|
||||
border-image-width: 13px 30px 14px 26px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_270_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-271 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_271.png');
|
||||
border-image-slice: 23 23 9 35 fill;
|
||||
border-image-width: 23px 23px 9px 35px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_271_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-272 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_272.png');
|
||||
border-image-slice: 9 31 24 25 fill;
|
||||
border-image-width: 9px 31px 24px 25px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_272_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-273 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_273.png');
|
||||
border-image-slice: 11 16 25 37 fill;
|
||||
border-image-width: 11px 16px 25px 37px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_273_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-274 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_274.png');
|
||||
border-image-slice: 7 22 19 27 fill;
|
||||
border-image-width: 7px 22px 19px 27px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_274_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-275 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_275.png');
|
||||
border-image-slice: 8 23 14 26 fill;
|
||||
border-image-width: 8px 23px 14px 26px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_275_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-276 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_276.png');
|
||||
border-image-slice: 12 40 17 17 fill;
|
||||
border-image-width: 12px 40px 17px 17px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_276_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-277 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_277.png');
|
||||
border-image-slice: 6 39 18 17 fill;
|
||||
border-image-width: 6px 39px 18px 17px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_277_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-278 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_278.png');
|
||||
border-image-slice: 16 38 6 19 fill;
|
||||
border-image-width: 16px 38px 6px 19px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_278_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-279 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_279.png');
|
||||
border-image-slice: 6 26 16 23 fill;
|
||||
border-image-width: 6px 26px 16px 23px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_279_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-280 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_280.png');
|
||||
border-image-slice: 23 29 6 15 fill;
|
||||
border-image-width: 23px 29px 6px 15px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_280_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-281 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_281.png');
|
||||
border-image-slice: 18 42 9 18 fill;
|
||||
border-image-width: 18px 42px 9px 18px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_281_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-282 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_282.png');
|
||||
border-image-slice: 18 42 9 18 fill;
|
||||
border-image-width: 18px 42px 9px 18px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_282_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-283 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_283.png');
|
||||
border-image-slice: 17 26 13 31 fill;
|
||||
border-image-width: 17px 26px 13px 31px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_283_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-284 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_284.png');
|
||||
border-image-slice: 9 26 23 26 fill;
|
||||
border-image-width: 9px 26px 23px 26px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_284_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-285 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_285.png');
|
||||
border-image-slice: 16 35 15 15 fill;
|
||||
border-image-width: 16px 35px 15px 15px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_285_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-286 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_286.png');
|
||||
border-image-slice: 18 22 4 23 fill;
|
||||
border-image-width: 18px 22px 4px 23px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_286_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-287 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_287.png');
|
||||
border-image-slice: 6 22 18 26 fill;
|
||||
border-image-width: 6px 22px 18px 26px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_287_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-288 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_288.png');
|
||||
border-image-slice: 18 31 11 24 fill;
|
||||
border-image-width: 18px 31px 11px 24px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_288_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-289 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_289.png');
|
||||
border-image-slice: 7 54 17 24 fill;
|
||||
border-image-width: 7px 54px 17px 24px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_289_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-290 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_290.png');
|
||||
border-image-slice: 18 24 14 29 fill;
|
||||
border-image-width: 18px 24px 14px 29px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_290_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-291 {
|
||||
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_291.png');
|
||||
border-image-slice: 9 26 11 35 fill;
|
||||
border-image-width: 9px 26px 11px 35px;
|
||||
border-image-repeat: stretch stretch;
|
||||
|
||||
.pointer {
|
||||
background: url('@/assets/images/chat/chatbubbles/bubble_291_pointer.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.bubble-200,
|
||||
&.bubble-201,
|
||||
&.bubble-202,
|
||||
@@ -2239,169 +1810,4 @@
|
||||
background: center / contain no-repeat url('@/assets/images/chat/chatbubbles/bubble_252_extra.png');
|
||||
}
|
||||
}
|
||||
&.bubble-253 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_253.png');
|
||||
}
|
||||
|
||||
&.bubble-254 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_254.png');
|
||||
}
|
||||
|
||||
&.bubble-255 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_255.png');
|
||||
}
|
||||
|
||||
&.bubble-256 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_256.png');
|
||||
}
|
||||
|
||||
&.bubble-257 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_257.png');
|
||||
}
|
||||
|
||||
&.bubble-258 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_258.png');
|
||||
}
|
||||
|
||||
&.bubble-259 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_259.png');
|
||||
}
|
||||
|
||||
&.bubble-260 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_260.png');
|
||||
}
|
||||
|
||||
&.bubble-261 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_261.png');
|
||||
}
|
||||
|
||||
&.bubble-262 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_262.png');
|
||||
}
|
||||
|
||||
&.bubble-263 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_263.png');
|
||||
}
|
||||
|
||||
&.bubble-264 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_264.png');
|
||||
}
|
||||
|
||||
&.bubble-265 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_265.png');
|
||||
}
|
||||
|
||||
&.bubble-266 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_266.png');
|
||||
}
|
||||
|
||||
&.bubble-267 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_267.png');
|
||||
}
|
||||
|
||||
&.bubble-268 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_268.png');
|
||||
}
|
||||
|
||||
&.bubble-269 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_269.png');
|
||||
}
|
||||
|
||||
&.bubble-270 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_270.png');
|
||||
}
|
||||
|
||||
&.bubble-271 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_271.png');
|
||||
}
|
||||
|
||||
&.bubble-272 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_272.png');
|
||||
}
|
||||
|
||||
&.bubble-273 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_273.png');
|
||||
}
|
||||
|
||||
&.bubble-274 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_274.png');
|
||||
}
|
||||
|
||||
&.bubble-275 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_275.png');
|
||||
}
|
||||
|
||||
&.bubble-276 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_276.png');
|
||||
}
|
||||
|
||||
&.bubble-277 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_277.png');
|
||||
}
|
||||
|
||||
&.bubble-278 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_278.png');
|
||||
}
|
||||
|
||||
&.bubble-279 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_279.png');
|
||||
}
|
||||
|
||||
&.bubble-280 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_280.png');
|
||||
}
|
||||
|
||||
&.bubble-281 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_281.png');
|
||||
}
|
||||
|
||||
&.bubble-282 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_282.png');
|
||||
}
|
||||
|
||||
&.bubble-283 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_283.png');
|
||||
}
|
||||
|
||||
&.bubble-284 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_284.png');
|
||||
}
|
||||
|
||||
&.bubble-285 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_285.png');
|
||||
}
|
||||
|
||||
&.bubble-286 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_286.png');
|
||||
}
|
||||
|
||||
&.bubble-287 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_287.png');
|
||||
}
|
||||
|
||||
&.bubble-288 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_288.png');
|
||||
}
|
||||
|
||||
&.bubble-289 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_289.png');
|
||||
}
|
||||
|
||||
&.bubble-290 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_290.png');
|
||||
}
|
||||
|
||||
&.bubble-291 {
|
||||
background-image: url('@/assets/images/chat/chatbubbles/bubble_291.png');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* Mention highlight inside chat bubbles (cosmetic) */
|
||||
.mention-highlight {
|
||||
font-weight: 700;
|
||||
color: #1e7295;
|
||||
background-color: rgba(30, 114, 149, 0.16);
|
||||
border-radius: 3px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* ── Friends spritesheet icons ── */
|
||||
.nitro-friends-spritesheet {
|
||||
background: url('@/assets/images/friends/friends-spritesheet.png') transparent no-repeat;
|
||||
|
||||
@@ -138,8 +139,8 @@
|
||||
|
||||
& .nitro-card-accordion-set-content,
|
||||
& .nitro-card-content-shell {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #6d7b84 #cdd4d8;
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: auto;
|
||||
}
|
||||
|
||||
& .nitro-card-accordion-set-content::-webkit-scrollbar,
|
||||
@@ -175,12 +176,9 @@
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
& .nitro-card-accordion-set-header span,
|
||||
& .nitro-card-accordion-set-header > div {
|
||||
font-size: 12px !important;
|
||||
font-weight: 700;
|
||||
& .nitro-card-accordion-set-header span {
|
||||
font-size: 12px;
|
||||
color: #111 !important;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
& .nitro-card-accordion-set-header .fa-icon {
|
||||
@@ -463,8 +461,8 @@
|
||||
|
||||
& .nitro-card-content-shell,
|
||||
& .chat-messages {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #6d7b84 #cdd4d8;
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: auto;
|
||||
}
|
||||
|
||||
& .nitro-card-content-shell::-webkit-scrollbar,
|
||||
@@ -802,29 +800,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nitro-friends .friends-list-avatar {
|
||||
position: relative !important;
|
||||
width: 32px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nitro-friends .friends-list-avatar .avatar-image {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
margin: 0 !important;
|
||||
background-size: 66px auto !important;
|
||||
background-position: -16px -21px !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.nitro-friends .nitro-card-accordion-set-header > div,
|
||||
.nitro-friends .nitro-card-accordion-set-header span {
|
||||
font-size: 12px !important;
|
||||
font-weight: 700 !important;
|
||||
line-height: 1.15 !important;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
.habbo-swf-window {
|
||||
--habbo-swf-bg: #ecece4;
|
||||
--habbo-swf-panel: #f7f7f2;
|
||||
--habbo-swf-border: #9d9d96;
|
||||
--habbo-swf-header: #2f8097;
|
||||
--habbo-swf-header-dark: #1a5364;
|
||||
--habbo-swf-text: #111;
|
||||
--habbo-swf-close: url("../../assets/images/catalog/buttons/close.png");
|
||||
--habbo-swf-close-hover: url("../../assets/images/catalog/buttons/close_hover.png");
|
||||
--habbo-swf-close-pressed: url("../../assets/images/catalog/buttons/close_pressed.png");
|
||||
--habbo-swf-button: url("../../assets/images/catalog/buttons/btn_secondary.png");
|
||||
--habbo-swf-button-hover: url("../../assets/images/catalog/buttons/btn_secondary_hover.png");
|
||||
--habbo-swf-button-pressed: url("../../assets/images/catalog/buttons/btn_secondary_pressed.png");
|
||||
--habbo-swf-button-disabled: url("../../assets/images/catalog/buttons/btn_secondary_disabled.png");
|
||||
--habbo-swf-button-green: url("../../assets/images/catalog/buttons/buy.png");
|
||||
color: var(--habbo-swf-text) !important;
|
||||
background: var(--habbo-swf-bg) !important;
|
||||
border: 1px solid #000 !important;
|
||||
border-radius: 7px 7px 0 0 !important;
|
||||
font-family: Ubuntu, Arial, sans-serif !important;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.habbo-swf-window,
|
||||
.habbo-swf-window * {
|
||||
box-sizing: border-box;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.habbo-swf-window .nitro-card-header-shell,
|
||||
.habbo-swf-window .nitro-card-header {
|
||||
min-height: 35px !important;
|
||||
max-height: 35px !important;
|
||||
height: 35px !important;
|
||||
background: var(--habbo-swf-header) !important;
|
||||
border: 0 !important;
|
||||
border-bottom: 1px solid #000 !important;
|
||||
border-radius: 6px 6px 0 0 !important;
|
||||
}
|
||||
|
||||
.habbo-swf-window .nitro-card-title {
|
||||
color: #fff !important;
|
||||
font-family: Ubuntu, Arial, sans-serif !important;
|
||||
font-size: 16px !important;
|
||||
font-weight: 700 !important;
|
||||
line-height: 35px !important;
|
||||
text-align: center !important;
|
||||
text-shadow: 0 1px 0 #000 !important;
|
||||
}
|
||||
|
||||
.habbo-swf-window .nitro-card-close-button {
|
||||
top: 7px !important;
|
||||
right: 7px !important;
|
||||
width: 19px !important;
|
||||
min-width: 19px !important;
|
||||
height: 20px !important;
|
||||
min-height: 20px !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
background-color: transparent !important;
|
||||
/* Direct URL instead of var(--habbo-swf-close) - some browser /
|
||||
bundler combinations don't resolve relative url()s inside CSS
|
||||
custom properties consistently (they're spec'd to resolve from
|
||||
the document, not the stylesheet). Inlining the path makes
|
||||
this immune. */
|
||||
background-image: url("../../assets/images/catalog/buttons/close.png") !important;
|
||||
background-repeat: no-repeat !important;
|
||||
background-position: center !important;
|
||||
background-size: 19px 20px !important;
|
||||
box-shadow: none !important;
|
||||
image-rendering: pixelated !important;
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.habbo-swf-window .nitro-card-close-button:hover {
|
||||
background-image: url("../../assets/images/catalog/buttons/close_hover.png") !important;
|
||||
}
|
||||
|
||||
.habbo-swf-window .nitro-card-close-button:active {
|
||||
background-image: url("../../assets/images/catalog/buttons/close_pressed.png") !important;
|
||||
}
|
||||
|
||||
.habbo-swf-window .nitro-card-close-button::before,
|
||||
.habbo-swf-window .nitro-card-close-button::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.habbo-swf-window .nitro-card-content-shell,
|
||||
.habbo-swf-window .nitro-card-content {
|
||||
background: var(--habbo-swf-bg) !important;
|
||||
color: var(--habbo-swf-text) !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.habbo-swf-window button,
|
||||
.habbo-swf-window .btn,
|
||||
.habbo-swf-window .nitro-swf-button {
|
||||
min-height: 22px !important;
|
||||
border: 4px solid transparent !important;
|
||||
border-radius: 0 !important;
|
||||
border-image-source: var(--habbo-swf-button) !important;
|
||||
border-image-slice: 4 4 4 4 fill !important;
|
||||
border-image-width: 4px !important;
|
||||
border-image-repeat: stretch !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
color: #111 !important;
|
||||
font-family: Ubuntu, Arial, sans-serif !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 700 !important;
|
||||
line-height: 14px !important;
|
||||
}
|
||||
|
||||
.habbo-swf-window button:hover,
|
||||
.habbo-swf-window .btn:hover,
|
||||
.habbo-swf-window .nitro-swf-button:hover {
|
||||
border-image-source: var(--habbo-swf-button-hover) !important;
|
||||
}
|
||||
|
||||
.habbo-swf-window button:active,
|
||||
.habbo-swf-window .btn:active,
|
||||
.habbo-swf-window .nitro-swf-button:active {
|
||||
border-image-source: var(--habbo-swf-button-pressed) !important;
|
||||
}
|
||||
|
||||
.habbo-swf-window button:disabled,
|
||||
.habbo-swf-window .btn:disabled,
|
||||
.habbo-swf-window .nitro-swf-button:disabled {
|
||||
border-image-source: var(--habbo-swf-button-disabled) !important;
|
||||
color: #8d8d87 !important;
|
||||
}
|
||||
|
||||
.habbo-swf-window .btn-success,
|
||||
.habbo-swf-window button[class*="success"],
|
||||
.habbo-swf-window .nitro-swf-button-success {
|
||||
border-image-source: var(--habbo-swf-button-green) !important;
|
||||
color: #fff !important;
|
||||
text-shadow: 0 1px 0 #004b00 !important;
|
||||
}
|
||||
|
||||
.habbo-swf-window input,
|
||||
.habbo-swf-window textarea,
|
||||
.habbo-swf-window select {
|
||||
min-height: 22px !important;
|
||||
border: 1px solid #b7b7ae !important;
|
||||
border-radius: 3px !important;
|
||||
background: #fff !important;
|
||||
color: #333 !important;
|
||||
box-shadow: inset 1px 1px 0 rgba(0, 0, 0, 0.08) !important;
|
||||
font-family: Ubuntu, Arial, sans-serif !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.habbo-swf-window .nitro-card-tabs-shell,
|
||||
.habbo-swf-window .nitro-catalog-classic-tabs-shell {
|
||||
background: var(--habbo-swf-bg) !important;
|
||||
border-bottom: 1px solid #000 !important;
|
||||
}
|
||||
|
||||
.habbo-swf-window .nitro-card-tab-item {
|
||||
color: #111 !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
@@ -229,13 +229,6 @@
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.nitro-icon.icon-mentions {
|
||||
background-image: url("@/assets/images/toolbar/icons/mentions.png");
|
||||
background-size: contain;
|
||||
width: 36px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.nitro-icon.icon-wired-tools {
|
||||
background-image: url("@/assets/images/wiredtools/wired_menu.png");
|
||||
background-size: contain;
|
||||
|
||||
@@ -51,8 +51,8 @@ body {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
overscroll-behavior: none;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #6d7b84 #c8d0d4;
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: auto;
|
||||
}
|
||||
|
||||
#root {
|
||||
@@ -115,77 +115,98 @@ body {
|
||||
@apply outline-0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: .875rem;
|
||||
*::-webkit-scrollbar {
|
||||
width: 17px !important;
|
||||
height: 17px !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar:horizontal {
|
||||
height: .875rem;
|
||||
*::-webkit-scrollbar:horizontal {
|
||||
height: 17px !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar:not(:horizontal) {
|
||||
width: .875rem;
|
||||
*::-webkit-scrollbar:not(:horizontal) {
|
||||
width: 17px !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: linear-gradient(180deg, #dfe5e8 0%, #c9d1d5 100%);
|
||||
border-left: 1px solid #7a858b;
|
||||
border-right: 1px solid #eef3f5;
|
||||
border-radius: 0;
|
||||
/* App-wide Habbo scrollbar (sprites cropped from catalog_skin1.png).
|
||||
Thumb sprite is 17x34 with caps + grip baked in; stretched full
|
||||
height via background-size: 17px 100%. Arrow buttons are natural
|
||||
17x16 sprites. */
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background-color: #e7e5d8 !important;
|
||||
background-image: none !important;
|
||||
box-shadow: inset 1px 0 0 #b9b6a5, inset -1px 0 0 #ffffff !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, #8fb5c7 0%, #5d8ea5 100%);
|
||||
border: 1px solid #446879;
|
||||
border-radius: 2px;
|
||||
box-shadow: inset 1px 1px 0 rgba(255, 255, 255, 0.28);
|
||||
*::-webkit-scrollbar-thumb {
|
||||
min-height: 24px !important;
|
||||
background-color: transparent !important;
|
||||
background-image: url("../assets/images/catalog/scrollbar/scroll_v_thumb.png") !important;
|
||||
background-repeat: no-repeat !important;
|
||||
background-position: center center !important;
|
||||
background-size: 17px 100% !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
image-rendering: pixelated !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, #99c2d5 0%, #689ab0 100%);
|
||||
*::-webkit-scrollbar-thumb:hover,
|
||||
*::-webkit-scrollbar-thumb:active {
|
||||
background-image: url("../assets/images/catalog/scrollbar/scroll_v_thumb_pressed.png") !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background: linear-gradient(180deg, #5c889d 0%, #436977 100%);
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: #c9d1d5;
|
||||
*::-webkit-scrollbar-button:single-button {
|
||||
display: block !important;
|
||||
width: 17px !important;
|
||||
height: 16px !important;
|
||||
/* Cream fill so the arrow sprite's transparent rounded corners
|
||||
paint over the track colour, not whatever is behind the
|
||||
scrollbar (which can render black). */
|
||||
background-color: #e7e5d8 !important;
|
||||
background-repeat: no-repeat !important;
|
||||
background-position: center !important;
|
||||
border: 0 !important;
|
||||
image-rendering: pixelated !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:single-button {
|
||||
display: block;
|
||||
width: .875rem;
|
||||
height: .875rem;
|
||||
background-color: #d8dfe3;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
border-left: 1px solid #7a858b;
|
||||
border-right: 1px solid #eef3f5;
|
||||
*::-webkit-scrollbar-button:single-button:vertical:decrement {
|
||||
background-image: url("../assets/images/catalog/scrollbar/scroll_v_up.png") !important;
|
||||
}
|
||||
*::-webkit-scrollbar-button:single-button:vertical:decrement:active {
|
||||
background-image: url("../assets/images/catalog/scrollbar/scroll_v_up_pressed.png") !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:single-button:vertical:decrement {
|
||||
background-image: linear-gradient(135deg, transparent 50%, #35586a 50%), linear-gradient(225deg, transparent 50%, #35586a 50%);
|
||||
background-size: 6px 6px;
|
||||
background-position: calc(50% - 3px) 55%, calc(50% + 3px) 55%;
|
||||
*::-webkit-scrollbar-button:single-button:vertical:increment {
|
||||
background-image: url("../assets/images/catalog/scrollbar/scroll_v_down.png") !important;
|
||||
}
|
||||
*::-webkit-scrollbar-button:single-button:vertical:increment:active {
|
||||
background-image: url("../assets/images/catalog/scrollbar/scroll_v_down_pressed.png") !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:single-button:vertical:increment {
|
||||
background-image: linear-gradient(315deg, transparent 50%, #35586a 50%), linear-gradient(45deg, transparent 50%, #35586a 50%);
|
||||
background-size: 6px 6px;
|
||||
background-position: calc(50% - 3px) 45%, calc(50% + 3px) 45%;
|
||||
*::-webkit-scrollbar-button:single-button:horizontal:decrement {
|
||||
width: 16px !important;
|
||||
height: 17px !important;
|
||||
background-image: url("../assets/images/catalog/scrollbar/scroll_h_left.png") !important;
|
||||
}
|
||||
*::-webkit-scrollbar-button:single-button:horizontal:decrement:active {
|
||||
background-image: url("../assets/images/catalog/scrollbar/scroll_h_left_pressed.png") !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:single-button:horizontal:decrement {
|
||||
background-image: linear-gradient(45deg, transparent 50%, #35586a 50%), linear-gradient(135deg, transparent 50%, #35586a 50%);
|
||||
background-size: 6px 6px;
|
||||
background-position: 58% calc(50% - 3px), 58% calc(50% + 3px);
|
||||
*::-webkit-scrollbar-button:single-button:horizontal:increment {
|
||||
width: 16px !important;
|
||||
height: 17px !important;
|
||||
background-image: url("../assets/images/catalog/scrollbar/scroll_h_right.png") !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button:single-button:horizontal:increment {
|
||||
background-image: linear-gradient(225deg, transparent 50%, #35586a 50%), linear-gradient(315deg, transparent 50%, #35586a 50%);
|
||||
background-size: 6px 6px;
|
||||
background-position: 42% calc(50% - 3px), 42% calc(50% + 3px);
|
||||
*::-webkit-scrollbar-button:single-button:horizontal:increment:active {
|
||||
background-image: url("../assets/images/catalog/scrollbar/scroll_h_right_pressed.png") !important;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@@ -306,7 +327,7 @@ body {
|
||||
|
||||
.nitro-card-shell:not(.nitro-wired) .nitro-card-header-shell {
|
||||
border: 2px solid #3c88a6;
|
||||
border-bottom-color: #30728c;
|
||||
border-bottom-color: #000;
|
||||
border-radius: 8px 8px 0 0;
|
||||
background: #30728c;
|
||||
padding: 5px;
|
||||
@@ -314,7 +335,7 @@ body {
|
||||
|
||||
.nitro-card-shell:not(.nitro-wired) .nitro-card-header-shell.builders-club-card-header {
|
||||
border-color: #d79d2e;
|
||||
border-bottom-color: #c68515;
|
||||
border-bottom-color: #000;
|
||||
background: linear-gradient(180deg, #d89f2d 0%, #c68515 100%);
|
||||
}
|
||||
|
||||
|
||||
@@ -1190,7 +1190,7 @@
|
||||
overflow-x: hidden;
|
||||
padding-right: 4px;
|
||||
margin-top: 2px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-width: auto;
|
||||
}
|
||||
|
||||
.nitro-login-card .room-template-option {
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
.habbo-navigator-desktop {
|
||||
border: 1px solid #000;
|
||||
border-radius: 7px;
|
||||
background: #e9e9e1;
|
||||
box-shadow: 0 4px 4px rgba(0, 0, 0, 0.35);
|
||||
color: #111;
|
||||
font-family: Ubuntu, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-header-shell {
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
background: #418db0;
|
||||
border-bottom: 1px solid #000;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-title {
|
||||
color: #fff;
|
||||
font-family: UbuntuCondensed, Ubuntu, Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
text-shadow: 1px 1px 0 #000;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-close-button {
|
||||
width: 19px;
|
||||
height: 20px;
|
||||
right: 6px;
|
||||
border: 2px solid #000;
|
||||
border-radius: 4px;
|
||||
background: #c73a32;
|
||||
box-shadow: inset 1px 1px 0 rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-close-button::before,
|
||||
.habbo-navigator-desktop .nitro-card-close-button::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 2px;
|
||||
background: #fff;
|
||||
box-shadow: 1px 1px 0 #64120f;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-close-button::before {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-close-button::after {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-tabs-shell {
|
||||
justify-content: flex-start;
|
||||
gap: 0;
|
||||
min-height: 27px;
|
||||
max-height: 27px;
|
||||
padding: 4px 8px 0;
|
||||
background: #e9e9e1;
|
||||
border-bottom: 1px solid #b8b8ad;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-tab-item {
|
||||
min-height: 23px;
|
||||
margin-right: -1px;
|
||||
padding: 4px 12px 3px;
|
||||
border: 1px solid #555;
|
||||
border-bottom: 0;
|
||||
border-radius: 6px 6px 0 0;
|
||||
background-color: #d5d8cf;
|
||||
background-image: url("../../assets/images/navigator/swf/tab_bg_unsel.png");
|
||||
background-repeat: repeat-x;
|
||||
background-size: auto 100%;
|
||||
color: #111;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
box-shadow: inset 1px 1px 0 #fff;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-tab-item:hover {
|
||||
background-color: #e7e8df;
|
||||
background-image: url("../../assets/images/navigator/swf/tab_bg_hilite.png");
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-tab-item-active {
|
||||
z-index: 2;
|
||||
margin-bottom: -1px;
|
||||
background-color: #f4f4ed;
|
||||
background-image: url("../../assets/images/navigator/swf/tab_bg_sel.png");
|
||||
border-bottom: 1px solid #f4f4ed;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .habbo-navigator-desktop-content {
|
||||
padding: 8px 9px 9px;
|
||||
overflow: hidden;
|
||||
background: #f4f4ed;
|
||||
border-radius: 0 0 6px 6px;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .habbo-navigator-desktop-content input,
|
||||
.habbo-navigator-desktop .habbo-navigator-desktop-content select,
|
||||
.habbo-navigator-desktop .habbo-navigator-desktop-content textarea {
|
||||
height: 22px;
|
||||
border: 1px solid #a0a49c;
|
||||
border-radius: 3px;
|
||||
background-color: #fff;
|
||||
background-image: url("../../assets/images/navigator/swf/hdr_search.png");
|
||||
background-repeat: repeat-x;
|
||||
color: #333;
|
||||
font-size: 12px;
|
||||
box-shadow: inset 1px 1px 0 rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .habbo-navigator-desktop-content button,
|
||||
.habbo-navigator-desktop .habbo-navigator-desktop-content .btn {
|
||||
min-height: 22px;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
background-color: #d6d6d1;
|
||||
background-image: url("../../assets/images/navigator/swf/button.png");
|
||||
background-repeat: repeat-x;
|
||||
background-size: auto 100%;
|
||||
color: #111;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
box-shadow: inset 1px 1px 0 #fff;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-panel {
|
||||
border: 1px solid #babdb4;
|
||||
border-radius: 6px;
|
||||
background: #efefe8;
|
||||
box-shadow: inset 1px 1px 0 #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-panel > .flex:first-child {
|
||||
min-height: 28px;
|
||||
padding: 5px 8px;
|
||||
border-bottom: 1px solid #d3d5cd;
|
||||
background: #efefe8;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .navigator-grid {
|
||||
padding: 0 4px 5px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .navigator-item {
|
||||
min-height: 28px;
|
||||
margin: 2px 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
background: #f5f5ef;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .navigator-item:nth-child(even) {
|
||||
background: #e7e8e0;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .navigator-item:hover {
|
||||
border-color: #777;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-navigator-search-saves-result {
|
||||
width: 155px;
|
||||
min-width: 155px;
|
||||
height: 100%;
|
||||
border: 1px solid #babdb4;
|
||||
border-radius: 6px;
|
||||
background: #efefe8;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-navigator-search-saves-result > .flex:first-child {
|
||||
min-height: 24px;
|
||||
border: 1px solid #d58e00;
|
||||
border-radius: 4px;
|
||||
background: #f8a900;
|
||||
box-shadow: inset 1px 1px 0 rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-navigator-search-saves-result span {
|
||||
color: #111;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-icon.icon-navigator-info {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop ::-webkit-scrollbar {
|
||||
width: 17px !important;
|
||||
height: 17px !important;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop ::-webkit-scrollbar-track {
|
||||
border: 0 !important;
|
||||
background-color: transparent !important;
|
||||
background-image: url("../../assets/images/catalog/swf/ubuntu_scrollbar_track_v_17x2.png") !important;
|
||||
background-repeat: repeat-y !important;
|
||||
background-position: center top !important;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop ::-webkit-scrollbar-thumb {
|
||||
min-height: 24px !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
background-color: transparent !important;
|
||||
background-image:
|
||||
url("../../assets/images/catalog/swf/ubuntu_scrollbar_thumb_v_grip_7x10.png"),
|
||||
url("../../assets/images/catalog/swf/ubuntu_scrollbar_thumb_v_top_17x2.png"),
|
||||
url("../../assets/images/catalog/swf/ubuntu_scrollbar_thumb_v_bottom_17x2.png"),
|
||||
url("../../assets/images/catalog/swf/ubuntu_scrollbar_thumb_v_mid_17x1.png") !important;
|
||||
background-repeat: repeat-y, no-repeat, no-repeat, repeat-y !important;
|
||||
background-position: center center, center top, center bottom, center top !important;
|
||||
background-size: 7px 10px, 17px 2px, 17px 2px, 17px 1px !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop ::-webkit-scrollbar-button {
|
||||
width: 17px !important;
|
||||
height: 16px !important;
|
||||
background-color: transparent !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop ::-webkit-scrollbar-button:single-button:vertical:decrement {
|
||||
background-image: url("../../assets/images/catalog/swf/ubuntu_scrollbar_up_17x16.png") !important;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop ::-webkit-scrollbar-button:single-button:vertical:increment {
|
||||
background-image: url("../../assets/images/catalog/swf/ubuntu_scrollbar_down_17x16.png") !important;
|
||||
}
|
||||
@@ -20,77 +20,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.nitro-alert-command-list {
|
||||
width: min(430px, calc(100vw - 18px));
|
||||
min-height: 210px;
|
||||
max-height: min(520px, calc(100vh - 24px));
|
||||
|
||||
.content-area {
|
||||
padding: 9px 10px 8px;
|
||||
}
|
||||
|
||||
.notification-text {
|
||||
min-width: 0;
|
||||
padding-right: 3px;
|
||||
font-family: Ubuntu, sans-serif;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.notification-command-template {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.notification-command-heading {
|
||||
font-weight: 700;
|
||||
color: #101010;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.notification-command-copy {
|
||||
color: #262626;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.notification-command-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
min-height: 34px;
|
||||
padding: 5px 8px;
|
||||
color: #123b4c;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #dceaf0 100%);
|
||||
border: 1px solid #8ca6b1;
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85);
|
||||
text-align: left;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.notification-command-row:hover {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #cfe2eb 100%);
|
||||
border-color: #4f879b;
|
||||
}
|
||||
|
||||
.notification-command-name {
|
||||
font-weight: 700;
|
||||
color: #123b4c;
|
||||
}
|
||||
|
||||
.notification-command-description {
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
color: #3d4a50;
|
||||
}
|
||||
|
||||
.notification-command-spacer {
|
||||
height: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
&.nitro-alert-credits {
|
||||
width: 370px;
|
||||
.notification-text {
|
||||
|
||||
@@ -12,11 +12,27 @@ const MESSENGER_HISTORY_MAX = 1000;
|
||||
let CHAT_HISTORY_COUNTER: number = 0;
|
||||
let MESSENGER_HISTORY_COUNTER: number = 0;
|
||||
|
||||
/**
|
||||
* Project a list of chat entries to the slim shape we want to persist in
|
||||
* localStorage. `imageUrl` is a base64 data URL of the avatar / pet head
|
||||
* (10-50 KB each) - keeping it in storage blows past the browser quota
|
||||
* inside minutes in a pet-heavy room. The avatar can always be re-rendered
|
||||
* from `look` via ChatBubbleUtilities.getUserImage(), and pet images are
|
||||
* regenerated from the bubble flow when needed; we just don't restore
|
||||
* head thumbnails for entries loaded from a previous session.
|
||||
*
|
||||
* `style` / `chatType` / `color` are kept because they're tiny but
|
||||
* meaningful for re-rendering the bubble. Translation fields are kept
|
||||
* because they're already text.
|
||||
*/
|
||||
const slimChatEntriesForStorage = (entries: IChatEntry[]): IChatEntry[] =>
|
||||
entries.map(entry => entry.imageUrl ? { ...entry, imageUrl: undefined } : entry);
|
||||
|
||||
const useChatHistoryState = () =>
|
||||
{
|
||||
const [ chatHistory, setChatHistory ] = useLocalStorage<IChatEntry[]>('chatHistory', []);
|
||||
const [ chatHistory, setChatHistory ] = useLocalStorage<IChatEntry[]>('chatHistory', [], { toStorage: slimChatEntriesForStorage });
|
||||
const [ roomHistory, setRoomHistory ] = useLocalStorage<IRoomHistoryEntry[]>('roomHistory', []);
|
||||
const [ messengerHistory, setMessengerHistory ] = useLocalStorage<IChatEntry[]>('messengerHistory', []);
|
||||
const [ messengerHistory, setMessengerHistory ] = useLocalStorage<IChatEntry[]>('messengerHistory', [], { toStorage: slimChatEntriesForStorage });
|
||||
const [ needsRoomInsert, setNeedsRoomInsert ] = useLocalStorage('needsRoomInsert', false);
|
||||
|
||||
const addChatEntry = (entry: IChatEntry) =>
|
||||
|
||||
@@ -3,9 +3,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { CommandDefinition, LocalizeText } from '../../../api';
|
||||
import { createNitroStore } from '../../../state/createNitroStore';
|
||||
import { useMessageEvent } from '../../events';
|
||||
import { getChatCommandQuery, getRankedCommandSuggestions } from './useChatCommandSelector.helpers';
|
||||
|
||||
const MAX_VISIBLE_COMMANDS = 8;
|
||||
|
||||
// Client-only commands are static; safe to keep at module scope. The
|
||||
// `descriptionKey` is a LocalizeText slot resolved at merge time so
|
||||
@@ -65,7 +62,7 @@ const useChatCommandStore = createNitroStore<ChatCommandStore>()((set) => ({
|
||||
markListenerRegistered: () => set({ isListenerRegistered: true })
|
||||
}));
|
||||
|
||||
export const ensureChatCommandListener = (): void =>
|
||||
const ensureGlobalListener = (): void =>
|
||||
{
|
||||
if(useChatCommandStore.getState().isListenerRegistered) return;
|
||||
|
||||
@@ -89,20 +86,20 @@ export const ensureChatCommandListener = (): void =>
|
||||
|
||||
// Try once at module load so the server's response landing before any
|
||||
// React mount still hits the cache.
|
||||
ensureChatCommandListener();
|
||||
ensureGlobalListener();
|
||||
|
||||
export const useChatCommandSelector = (chatValue: string) =>
|
||||
{
|
||||
const serverCommands = useChatCommandStore(s => s.serverCommands);
|
||||
const setServerCommands = useChatCommandStore(s => s.setServerCommands);
|
||||
const [ selectedIndex, setSelectedIndex ] = useState(0);
|
||||
const [ dismissedQuery, setDismissedQuery ] = useState<string | null>(null);
|
||||
const [ dismissed, setDismissed ] = useState(false);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
// Cover the case where the module-level registration failed
|
||||
// because GetCommunication() wasn't ready at import time.
|
||||
ensureChatCommandListener();
|
||||
ensureGlobalListener();
|
||||
}, []);
|
||||
|
||||
// Late updates (rank change, etc.) — go through the store so all
|
||||
@@ -126,55 +123,61 @@ export const useChatCommandSelector = (chatValue: string) =>
|
||||
return merged.sort((a, b) => a.key.localeCompare(b.key));
|
||||
}, [ serverCommands ]);
|
||||
|
||||
const filterText = useMemo(() => getChatCommandQuery(chatValue), [ chatValue ]);
|
||||
const filterText = useMemo(() =>
|
||||
{
|
||||
if(!chatValue.startsWith(':') || chatValue.includes(' ')) return '';
|
||||
|
||||
return chatValue.slice(1).toLowerCase();
|
||||
}, [ chatValue ]);
|
||||
|
||||
const filteredCommands = useMemo(() =>
|
||||
{
|
||||
if(filterText === null) return [];
|
||||
if(!filterText && !chatValue.startsWith(':')) return [];
|
||||
|
||||
return getRankedCommandSuggestions(allCommands, filterText, MAX_VISIBLE_COMMANDS);
|
||||
}, [ allCommands, filterText ]);
|
||||
return allCommands.filter(cmd => cmd.key.toLowerCase().startsWith(filterText));
|
||||
}, [ allCommands, filterText, chatValue ]);
|
||||
|
||||
const isVisible = useMemo(() =>
|
||||
{
|
||||
return filterText !== null && filteredCommands.length > 0 && dismissedQuery !== filterText;
|
||||
}, [ filterText, filteredCommands, dismissedQuery ]);
|
||||
|
||||
const boundedSelectedIndex = useMemo(() =>
|
||||
{
|
||||
if(!filteredCommands.length) return 0;
|
||||
|
||||
return Math.min(selectedIndex, filteredCommands.length - 1);
|
||||
}, [ filteredCommands.length, selectedIndex ]);
|
||||
return chatValue.startsWith(':') && !chatValue.includes(' ') && filteredCommands.length > 0 && !dismissed;
|
||||
}, [ chatValue, filteredCommands, dismissed ]);
|
||||
|
||||
const moveUp = useCallback(() =>
|
||||
{
|
||||
if(!filteredCommands.length) return;
|
||||
|
||||
setSelectedIndex(prev => ((prev <= 0 || prev >= filteredCommands.length) ? filteredCommands.length - 1 : prev - 1));
|
||||
setSelectedIndex(prev => (prev <= 0 ? filteredCommands.length - 1 : prev - 1));
|
||||
}, [ filteredCommands.length ]);
|
||||
|
||||
const moveDown = useCallback(() =>
|
||||
{
|
||||
if(!filteredCommands.length) return;
|
||||
|
||||
setSelectedIndex(prev => (prev >= filteredCommands.length - 1 ? 0 : prev + 1));
|
||||
}, [ filteredCommands.length ]);
|
||||
|
||||
const selectCurrent = useCallback((): CommandDefinition | null =>
|
||||
{
|
||||
if(boundedSelectedIndex >= 0 && boundedSelectedIndex < filteredCommands.length)
|
||||
if(selectedIndex >= 0 && selectedIndex < filteredCommands.length)
|
||||
{
|
||||
return filteredCommands[boundedSelectedIndex];
|
||||
return filteredCommands[selectedIndex];
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [ boundedSelectedIndex, filteredCommands ]);
|
||||
}, [ selectedIndex, filteredCommands ]);
|
||||
|
||||
const close = useCallback(() =>
|
||||
{
|
||||
setDismissedQuery(filterText);
|
||||
setDismissed(true);
|
||||
}, []);
|
||||
|
||||
// Reset dismissed when chatValue changes to a new command start
|
||||
useEffect(() =>
|
||||
{
|
||||
if(chatValue === ':' || chatValue === '') setDismissed(false);
|
||||
}, [ chatValue ]);
|
||||
|
||||
// Reset selectedIndex when filtered list changes
|
||||
useEffect(() =>
|
||||
{
|
||||
setSelectedIndex(0);
|
||||
}, [ filterText ]);
|
||||
|
||||
return { isVisible, filteredCommands, selectedIndex: boundedSelectedIndex, setSelectedIndex, moveUp, moveDown, selectCurrent, close };
|
||||
return { isVisible, filteredCommands, selectedIndex, setSelectedIndex, moveUp, moveDown, selectCurrent, close };
|
||||
};
|
||||
|
||||
@@ -230,7 +230,15 @@ const useChatWidgetState = () =>
|
||||
|
||||
return newValue;
|
||||
});
|
||||
const chatEntryId = addChatEntry({
|
||||
|
||||
// Pet, Bot and Rentable Bot chat is fire-and-forget ("UDP-style"):
|
||||
// the live bubble already rendered above, but we deliberately skip
|
||||
// addChatEntry so the entry never lands in localStorage. A pet-heavy
|
||||
// room used to push 30+ KB per message (base64 head data URL) into
|
||||
// the chat history, exhausting the localStorage quota in minutes.
|
||||
// Real users still go through the full persisted path.
|
||||
const chatEntryId = (userType === RoomObjectType.USER)
|
||||
? addChatEntry({
|
||||
id: -1,
|
||||
webId: userData.webID,
|
||||
entityId: userData.roomIndex,
|
||||
@@ -245,7 +253,8 @@ const useChatWidgetState = () =>
|
||||
roomId: roomSession.roomId,
|
||||
color,
|
||||
...(outgoingTranslation ? buildTranslatedEntryPatch(outgoingTranslation.originalText, outgoingTranslation.translatedText, outgoingTranslation.detectedLanguage, outgoingTranslation.targetLanguage) : {})
|
||||
});
|
||||
})
|
||||
: -1;
|
||||
|
||||
if(!settings.enabled || outgoingTranslation || !isTranslatableChatType || !text.trim().length) return;
|
||||
|
||||
|
||||
@@ -1,10 +1,43 @@
|
||||
import { NitroLogger } from '@nitrots/nitro-renderer';
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';
|
||||
import { GetLocalStorage, SetLocalStorage } from '../api';
|
||||
|
||||
const userId = new URLSearchParams(window.location.search).get('userid') || 0;
|
||||
|
||||
const useLocalStorageState = <T>(key: string, initialValue: T): [ T, Dispatch<SetStateAction<T>>] =>
|
||||
const STORAGE_WRITE_DEBOUNCE_MS = 250;
|
||||
const QUOTA_TRIM_FACTOR = 0.5; // on quota error, keep the newest 50%.
|
||||
const MIN_RETAINED_ENTRIES = 50;
|
||||
|
||||
const isQuotaError = (error: unknown): boolean =>
|
||||
{
|
||||
if(!error || typeof error !== 'object') return false;
|
||||
const name = (error as { name?: string }).name;
|
||||
if(name === 'QuotaExceededError') return true;
|
||||
// Firefox legacy:
|
||||
if(name === 'NS_ERROR_DOM_QUOTA_REACHED') return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const trimArrayForQuota = <T>(value: T): T =>
|
||||
{
|
||||
if(!Array.isArray(value)) return value;
|
||||
if(value.length <= MIN_RETAINED_ENTRIES) return [] as unknown as T;
|
||||
const keep = Math.max(MIN_RETAINED_ENTRIES, Math.floor(value.length * QUOTA_TRIM_FACTOR));
|
||||
return value.slice(value.length - keep) as unknown as T;
|
||||
};
|
||||
|
||||
interface UseLocalStorageOptions<T>
|
||||
{
|
||||
/**
|
||||
* Optional projection applied right before the value is written to
|
||||
* localStorage. The in-memory React state is unaffected. Use this to
|
||||
* strip heavy ephemeral fields (e.g. base64 image URLs) that would
|
||||
* otherwise blow past the storage quota.
|
||||
*/
|
||||
toStorage?: (value: T) => unknown;
|
||||
}
|
||||
|
||||
const useLocalStorageState = <T>(key: string, initialValue: T, options: UseLocalStorageOptions<T> = {}): [ T, Dispatch<SetStateAction<T>>] =>
|
||||
{
|
||||
key = userId ? `${ key }.${ userId }` : key;
|
||||
|
||||
@@ -22,6 +55,91 @@ const useLocalStorageState = <T>(key: string, initialValue: T): [ T, Dispatch<Se
|
||||
}
|
||||
});
|
||||
|
||||
const pendingWriteRef = useRef<T | null>(null);
|
||||
const writeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const optionsRef = useRef(options);
|
||||
|
||||
// Keep the latest toStorage projection without re-running effects.
|
||||
optionsRef.current = options;
|
||||
|
||||
const flushWrite = (value: T) =>
|
||||
{
|
||||
if(typeof window === 'undefined') return;
|
||||
|
||||
const project = optionsRef.current.toStorage;
|
||||
const projected = project ? project(value) : value;
|
||||
|
||||
try
|
||||
{
|
||||
SetLocalStorage(key, projected);
|
||||
return;
|
||||
}
|
||||
catch(error)
|
||||
{
|
||||
if(!isQuotaError(error))
|
||||
{
|
||||
NitroLogger.error(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Quota exceeded - trim and retry once. Anything that isn't an
|
||||
// array gets cleared, since we have no generic trimming rule.
|
||||
try
|
||||
{
|
||||
const trimmed = trimArrayForQuota(projected as T);
|
||||
SetLocalStorage(key, trimmed);
|
||||
NitroLogger.warn(`[useLocalStorage] quota exceeded for ${ key }, trimmed payload`);
|
||||
}
|
||||
catch(retryError)
|
||||
{
|
||||
NitroLogger.error(retryError);
|
||||
// Last resort: drop the key entirely so future writes have room.
|
||||
try { window.localStorage.removeItem(key); } catch(_) { /* ignore */ }
|
||||
}
|
||||
};
|
||||
|
||||
// Debounce: high-frequency chat would otherwise trigger one full
|
||||
// JSON.stringify + setItem per message. We coalesce bursts into one
|
||||
// write per STORAGE_WRITE_DEBOUNCE_MS window with the latest value.
|
||||
const scheduleWrite = (value: T) =>
|
||||
{
|
||||
pendingWriteRef.current = value;
|
||||
if(writeTimerRef.current) clearTimeout(writeTimerRef.current);
|
||||
writeTimerRef.current = setTimeout(() =>
|
||||
{
|
||||
writeTimerRef.current = null;
|
||||
if(pendingWriteRef.current !== null)
|
||||
{
|
||||
flushWrite(pendingWriteRef.current);
|
||||
pendingWriteRef.current = null;
|
||||
}
|
||||
}, STORAGE_WRITE_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
// Flush a pending write on tab close / hide so we don't lose the last
|
||||
// burst of activity.
|
||||
useEffect(() =>
|
||||
{
|
||||
const flushOnLeave = () =>
|
||||
{
|
||||
if(pendingWriteRef.current === null) return;
|
||||
if(writeTimerRef.current) clearTimeout(writeTimerRef.current);
|
||||
writeTimerRef.current = null;
|
||||
flushWrite(pendingWriteRef.current);
|
||||
pendingWriteRef.current = null;
|
||||
};
|
||||
|
||||
window.addEventListener('pagehide', flushOnLeave);
|
||||
window.addEventListener('beforeunload', flushOnLeave);
|
||||
|
||||
return () =>
|
||||
{
|
||||
window.removeEventListener('pagehide', flushOnLeave);
|
||||
window.removeEventListener('beforeunload', flushOnLeave);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setValue = (value: T) =>
|
||||
{
|
||||
try
|
||||
@@ -30,7 +148,7 @@ const useLocalStorageState = <T>(key: string, initialValue: T): [ T, Dispatch<Se
|
||||
|
||||
setStoredValue(valueToStore);
|
||||
|
||||
if(typeof window !== 'undefined') SetLocalStorage(key, valueToStore);
|
||||
scheduleWrite(valueToStore);
|
||||
}
|
||||
|
||||
catch(error)
|
||||
|
||||
@@ -23,6 +23,7 @@ import './css/catalog/CatalogClassicView.css';
|
||||
import './css/emustats/EmuStatsView.css';
|
||||
|
||||
import './css/chat/Chats.css';
|
||||
import './css/chat/ChatInputMentionSelectorView.css';
|
||||
import './css/mentions/MentionToasts.css';
|
||||
|
||||
import './css/common/Buttons.css';
|
||||
@@ -32,6 +33,8 @@ import './css/forms/form_select.css';
|
||||
|
||||
import './css/friends/FriendsView.css';
|
||||
|
||||
import './css/habbo/HabboSwfSkin.css';
|
||||
|
||||
import './css/hotelview/HotelView.css';
|
||||
|
||||
import './css/login/LoginView.css';
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
actly once, idempotent across HMR reloads.
|
||||
*/
|
||||
import * as PIXI from 'pixi.js';
|
||||
|
||||
type AnyFn = (...args: unknown[]) => unknown;
|
||||
|
||||
interface MethodHost {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__nitroPixiBatcherPatched__?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const NULL_TEXTURE_MARKERS = /alphaMode|reading 'uid'|reading 'destroyed'|reading 'source'/;
|
||||
|
||||
const isNullTextureCrash = (err: unknown): boolean =>
|
||||
{
|
||||
if(!(err instanceof TypeError)) return false;
|
||||
return NULL_TEXTURE_MARKERS.test(err.message ?? '');
|
||||
};
|
||||
|
||||
const guardMethod = (proto: MethodHost, methodName: string, label: string): boolean =>
|
||||
{
|
||||
const original = proto[methodName];
|
||||
if(typeof original !== 'function') return false;
|
||||
if((original as { __nitroGuarded__?: boolean }).__nitroGuarded__) return false;
|
||||
|
||||
const guarded = function(this: unknown, ...args: unknown[])
|
||||
{
|
||||
try
|
||||
{
|
||||
return (original as AnyFn).apply(this, args);
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
if(isNullTextureCrash(err)) return undefined;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
(guarded as { __nitroGuarded__?: boolean }).__nitroGuarded__ = true;
|
||||
proto[methodName] = guarded;
|
||||
|
||||
|
||||
console.info(`[NitroPixiPatch] guarded ${ label }.prototype.${ methodName } against null textureSource`);
|
||||
return true;
|
||||
};
|
||||
|
||||
const installPatch = (): void =>
|
||||
{
|
||||
if(typeof window === 'undefined') return;
|
||||
if(window.__nitroPixiBatcherPatched__) return;
|
||||
|
||||
const candidates: Array<[string, unknown]> = [
|
||||
[ 'DefaultBatcher', (PIXI as Record<string, unknown>).DefaultBatcher ],
|
||||
[ 'Batcher', (PIXI as Record<string, unknown>).Batcher ]
|
||||
];
|
||||
|
||||
let patched = false;
|
||||
|
||||
for(const [ name, ctor ] of candidates)
|
||||
{
|
||||
const proto = (ctor as { prototype?: MethodHost } | undefined)?.prototype;
|
||||
if(!proto) continue;
|
||||
|
||||
if(guardMethod(proto, 'break', name)) patched = true;
|
||||
|
||||
if(guardMethod(proto, 'checkAndUpdateTexture', name)) patched = true;
|
||||
}
|
||||
|
||||
window.__nitroPixiBatcherPatched__ = patched;
|
||||
|
||||
if(!patched)
|
||||
{
|
||||
|
||||
console.warn('[NitroPixiPatch] could not locate Batcher.prototype methods - is pixi.js export shape unchanged?');
|
||||
}
|
||||
};
|
||||
|
||||
installPatch();
|
||||
@@ -162,6 +162,9 @@ export default defineConfig({
|
||||
chunkSizeWarningLimit: 200000,
|
||||
manifest: true,
|
||||
rollupOptions: {
|
||||
checks: {
|
||||
pluginTimings: false
|
||||
},
|
||||
output: {
|
||||
assetFileNames: 'src/assets/[name]-[hash].[ext]',
|
||||
// Granular chunking: split the monolithic vendor / nitro-renderer
|
||||
|
||||