diff --git a/public/configuration/renderer-config.example b/public/configuration/renderer-config.example index c40cdee..8438c97 100644 --- a/public/configuration/renderer-config.example +++ b/public/configuration/renderer-config.example @@ -27,7 +27,7 @@ "avatar.asset.effect.url": "${asset.url}/effect/%libname%.nitro", "furni.asset.url": "${asset.url}/furniture/%libname%.nitro", "furni.asset.icon.url": "${hof.furni.url}/icons/%libname%%param%_icon.png", - "pet.asset.url": "${asset.url}/pets/%libname%.nitro", + "pet.asset.url": "${asset.url}/pet/%libname%.nitro", "generic.asset.url": "${asset.url}/generic/%libname%.nitro", "badge.asset.url": "${image.library.url}album1584/%badgename%.gif", "radio.url": "${gamedata.url}/radio-stations.json5?t=%timestamp%", diff --git a/public/configuration/ui-config.example b/public/configuration/ui-config.example index e65c501..796ac12 100644 --- a/public/configuration/ui-config.example +++ b/public/configuration/ui-config.example @@ -28,6 +28,7 @@ "housekeeping.enabled": true, "toolbar.hide.quests": true, "show.google.ads": false, + "catalog.classic.style": false, "loginview": { "images": { "background": "${asset.url}/c_images/reception/stretch_blue.png", diff --git a/src/App.tsx b/src/App.tsx index 55fce02..4b799ef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,6 +31,11 @@ const preloadUrl = async (url: string): Promise => { if(!url) return; + // Split gamedata URLs are directories (end with '/'); fetching them as a + // file just 404s and wastes a connection at startup. The real split loader + // handles those — only warm up actual file URLs here. + if(url.split('?')[0].split('#')[0].endsWith('/')) return; + try { const response = await fetch(url, { cache: 'force-cache' }); diff --git a/src/api/catalog/PageLocalization.ts b/src/api/catalog/PageLocalization.ts index f24ae87..824683d 100644 --- a/src/api/catalog/PageLocalization.ts +++ b/src/api/catalog/PageLocalization.ts @@ -27,8 +27,16 @@ export class PageLocalization implements IPageLocalization if(!imageName || !imageName.length) return null; + // Already a full URL (any extension) -> use it directly. + if(/^https?:\/\//i.test(imageName)) return imageName; + let assetUrl = GetConfigurationValue('catalog.asset.image.url'); + // The template forces ".gif" (.../%name%.gif). If the image name + // already carries its own extension (png/jpg/webp/gif), don't append + // the forced .gif so non-gif catalog images work too. + if(/\.[a-z0-9]+$/i.test(imageName)) assetUrl = assetUrl.replace(/\.gif(?=$|\?)/i, ''); + assetUrl = assetUrl.replace('%name%', imageName); return assetUrl; diff --git a/src/api/utils/LocalStorageKeys.ts b/src/api/utils/LocalStorageKeys.ts index 847f3aa..75ecfe8 100644 --- a/src/api/utils/LocalStorageKeys.ts +++ b/src/api/utils/LocalStorageKeys.ts @@ -4,4 +4,5 @@ export class LocalStorageKeys public static CATALOG_SKIP_PURCHASE_CONFIRMATION: string = 'catalogSkipPurchaseConfirmation'; public static CHAT_WINDOW_ENABLED: string = 'chatWindowEnabled'; public static CHAT_TRANSLATION_SETTINGS: string = 'chatTranslationSettings'; + public static CATALOG_CLASSIC_STYLE: string = 'catalogClassicStyle'; } diff --git a/src/components/catalog/CatalogClassicView.tsx b/src/components/catalog/CatalogClassicView.tsx index 1ee04f2..9df060e 100644 --- a/src/components/catalog/CatalogClassicView.tsx +++ b/src/components/catalog/CatalogClassicView.tsx @@ -252,7 +252,8 @@ const CatalogClassicViewInner: FC<{}> = () =>
- { !!currentPage?.localization?.getImage(0) && } + { /* 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) && }
diff --git a/src/components/catalog/CatalogModernView.tsx b/src/components/catalog/CatalogModernView.tsx new file mode 100644 index 0000000..ea1c2de --- /dev/null +++ b/src/components/catalog/CatalogModernView.tsx @@ -0,0 +1,335 @@ +import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { FC, useEffect, useState } from 'react'; +import { FaCog, FaEdit, FaEye, FaEyeSlash, FaHeart, FaPlus, FaStar, FaTrash } from 'react-icons/fa'; +import { CatalogType, LocalizeText } from '../../api'; +import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common'; +import { useCatalogActions, useCatalogData, useCatalogFavorites, useCatalogUiState, useHasPermission } from '../../hooks'; +import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext'; +import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView'; +import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView'; +import { CatalogBuildersClubStatusView } from './views/catalog-header/CatalogBuildersClubStatusView'; +import { CatalogIconView } from './views/catalog-icon/CatalogIconView'; +import { CatalogFavoritesView } from './views/favorites/CatalogFavoritesView'; +import { CatalogGiftView } from './views/gift/CatalogGiftView'; +import { CatalogNavigationView } from './views/navigation/CatalogNavigationView'; +import { CatalogSearchView } from './views/page/common/CatalogSearchView'; +import { GetCatalogLayout } from './views/page/layout/GetCatalogLayout'; +import { MarketplacePostOfferView } from './views/page/layout/marketplace/MarketplacePostOfferView'; + +const CatalogModernViewInner: FC<{}> = () => +{ + const { rootNode = null, currentPage = 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(); + const adminMode = catalogAdmin?.adminMode ?? false; + const setAdminMode = catalogAdmin?.setAdminMode ?? (() => + {}); + const hasPendingChanges = catalogAdmin?.hasPendingChanges ?? false; + const publishCatalog = catalogAdmin?.publishCatalog ?? (() => + {}); + const loading = catalogAdmin?.loading ?? false; + const { favoriteOfferIds, favoritePageIds } = useCatalogFavorites(); + const [ showFavorites, setShowFavorites ] = useState(false); + + const isMod = useHasPermission('acc_catalogfurni'); + const totalFavs = favoriteOfferIds.length + favoritePageIds.length; + const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER) + ? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' } + : undefined; + // Desktop = fixed 780x520. On mobile the window clamps below the viewport so + // it reads as a dialog (with margins) instead of filling the whole phone + // screen — applies to both the normal catalog and the Builders Club. + const catalogCardSize = 'w-[780px] h-[520px] max-w-[96vw] max-h-[72vh] sm:max-w-[100vw] sm:max-h-[92vh]'; + + useEffect(() => + { + const getCatalogTypeFromLink = (type?: string) => + { + switch((type || '').toLowerCase()) + { + case 'bc': + case 'builder': + case 'buildersclub': + case 'builders_club': + return CatalogType.BUILDER; + default: + return CatalogType.NORMAL; + } + }; + + const linkTracker: ILinkEventTracker = { + linkReceived: (url: string) => + { + const parts = url.split('/'); + + if(parts.length < 2) return; + + switch(parts[1]) + { + case 'show': + if(parts.length > 2) + { + openCatalogByType(getCatalogTypeFromLink(parts[2])); + + return; + } + + setIsVisible(true); + return; + case 'hide': + setIsVisible(false); + return; + case 'toggle': + if(parts.length > 2) + { + toggleCatalogByType(getCatalogTypeFromLink(parts[2])); + + return; + } + + setIsVisible(prevValue => !prevValue); + return; + case 'open': + if(parts.length > 2) + { + if(parts.length === 4) + { + switch(parts[2]) + { + case 'offerId': + openPageByOfferId(parseInt(parts[3])); + return; + } + } + else + { + openPageByName(parts[2]); + } + } + else + { + setIsVisible(true); + } + + return; + } + }, + eventUrlPrefix: 'catalog/' + }; + + AddLinkEventTracker(linkTracker); + + return () => RemoveLinkEventTracker(linkTracker); + }, [ setIsVisible, openPageByOfferId, openPageByName, openCatalogByType, toggleCatalogByType ]); + + return ( + <> + { isVisible && + + setIsVisible(false) } style={ buildersClubHeaderStyle } /> + + { /* Admin banner */ } + { adminMode && +
+ ⚙ Admin Mode + +
} + + +
+ { /* === LEFT SIDEBAR === */ } +
+ + { /* Favorites toggle */ } +
setShowFavorites(!showFavorites) } + > +
+ 0 ? 'text-danger' : 'text-muted' }` } /> + { totalFavs > 0 && + + { totalFavs } + } +
+ { LocalizeText('catalog.favorites') } +
+ +
+ + { /* Admin: root page actions */ } + { adminMode && rootNode && +
+ + +
} + + { /* Category icons */ } + { rootNode && rootNode.children.length > 0 && rootNode.children.map((child, index) => + { + if(!adminMode && !child.isVisible) return null; + + const isHidden = !child.isVisible; + + return ( +
+ { + if(searchResult) setSearchResult(null); + if(showFavorites) setShowFavorites(false); + activateNode(child); + } } + > +
+ + { isHidden && } +
+ + { child.localization } + + { /* Admin actions on each root category */ } + { adminMode && +
+
+ { + e.stopPropagation(); + catalogAdmin.setEditingPageNode(child); + catalogAdmin.setEditingRootPage(false); + catalogAdmin.setEditingPageData(true); + } } + > + +
+
+ { + e.stopPropagation(); + catalogAdmin.togglePageVisible(child.pageId); + } } + > + { isHidden + ? + : } +
+
+ { + e.stopPropagation(); + if(confirm(LocalizeText('catalog.admin.delete.category.confirm', [ 'name' ], [ child.localization ]))) + { + catalogAdmin.deletePage(child.pageId); + } + } } + > + +
+
} +
+ ); + }) } +
+ + { /* === MAIN AREA === */ } +
+ { /* Toolbar: search + admin */ } +
+ { /* Breadcrumb */ } +
+ + { activeNodes && activeNodes.length > 0 + ? activeNodes.map((node, i) => ( + + { i > 0 && } + activateNode(node) : undefined }> + { node.localization } + + + )) + : { LocalizeText('catalog.title') } } +
+ +
+ +
+ + { isMod && + } +
+ + { /* Content area */ } +
+ { showFavorites + ?
+ setShowFavorites(false) } /> +
+ : <> + { !navigationHidden && activeNodes && activeNodes.length > 0 && +
+ +
} +
+ { adminMode && } + { GetCatalogLayout(currentPage, () => setNavigationHidden(true)) } +
+ } +
+
+
+ + } + + + + + ); +}; + +export const CatalogModernView: FC<{}> = () => +{ + return ( + + + + ); +}; diff --git a/src/components/catalog/CatalogView.tsx b/src/components/catalog/CatalogView.tsx index fbc700e..87344fe 100644 --- a/src/components/catalog/CatalogView.tsx +++ b/src/components/catalog/CatalogView.tsx @@ -1,10 +1,23 @@ import { FC } from 'react'; -import { useCatalogData } from '../../hooks'; +import { useCatalogClassicStyle, useCatalogData } from '../../hooks'; import { CatalogClassicView } from './CatalogClassicView'; +import { CatalogModernView } from './CatalogModernView'; export const CatalogView: FC<{}> = () => { const { catalogLocalizationVersion = 0 } = useCatalogData(); + const [ catalogClassicStyle ] = useCatalogClassicStyle(); + + // Default = upstream rebuilt catalog (CatalogClassicView, latest release theme). + // The "stile classico" toggle (or global catalog.classic.style flag) switches + // to the Hippiehotel.nl catalog (CatalogModernView, self-contained tailwind). + // Both the normal catalog and the Builders Club follow this toggle. + if(catalogClassicStyle) return ( + <> +
+ + + ); return ( <> diff --git a/src/components/catalog/views/admin/CatalogAdminPageEditView.tsx b/src/components/catalog/views/admin/CatalogAdminPageEditView.tsx index b85393a..94b2326 100644 --- a/src/components/catalog/views/admin/CatalogAdminPageEditView.tsx +++ b/src/components/catalog/views/admin/CatalogAdminPageEditView.tsx @@ -67,8 +67,14 @@ export const CatalogAdminPageEditView: FC<{}> = () => { if(!editingPageData || !targetNode) return; - setCaption(targetNode.localization || ''); - setCaptionSave(targetNode.pageName || targetNode.localization || ''); + // 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*$`), ''); + + setCaption(rawCaption); + setCaptionSave(targetNode.pageName || rawCaption); setCatalogMode(currentType === CatalogType.BUILDER ? 'BUILDER' : 'NORMAL'); setPageLayout(currentPage?.layoutCode || 'default_3x3'); setIconImage(targetNode.iconId ?? 0); diff --git a/src/components/catalog/views/page/layout/CatalogLayoutBcInfoView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutBcInfoView.tsx new file mode 100644 index 0000000..793801f --- /dev/null +++ b/src/components/catalog/views/page/layout/CatalogLayoutBcInfoView.tsx @@ -0,0 +1,36 @@ +import { FC, useEffect } from 'react'; +import { SanitizeHtml } from '../../../../../api'; +import { CatalogLayoutProps } from './CatalogLayout.types'; + +// Info/landing layout: a logo box on top (image scaled to fit the available +// space, no crop) and a smaller box below with the page text in black. +// Logo = page headline image (getImage(0)), text = page text 1 (getText(0)), +// set from catalog admin (Gestione -> Modifica pagina). Hides the (empty) +// navigation sidebar so the content uses the full width. +export const CatalogLayoutBcInfoView: FC = props => +{ + const { page = null, hideNavigation = null } = props; + + const logo = page?.localization?.getImage(0) || ''; + const text = page?.localization?.getText(0) || ''; + + useEffect(() => + { + hideNavigation?.(); + }, [ page, hideNavigation ]); + + return ( +
+
+ { logo + ? + : Logo — imposta l'immagine headline da Gestione } +
+
+
+
+
+ ); +}; diff --git a/src/components/catalog/views/page/layout/GetCatalogLayout.tsx b/src/components/catalog/views/page/layout/GetCatalogLayout.tsx index 6ccf2a1..ce68f37 100644 --- a/src/components/catalog/views/page/layout/GetCatalogLayout.tsx +++ b/src/components/catalog/views/page/layout/GetCatalogLayout.tsx @@ -1,6 +1,7 @@ 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'; @@ -34,6 +35,8 @@ export const GetCatalogLayout = (page: ICatalogPage, hideNavigation: () => void) { case 'frontpage_featured': return null; + case 'info_duckets': + return ; case 'frontpage4': return ; case 'pets': diff --git a/src/components/inventory/InventoryView.tsx b/src/components/inventory/InventoryView.tsx index 4c95c35..881ddce 100644 --- a/src/components/inventory/InventoryView.tsx +++ b/src/components/inventory/InventoryView.tsx @@ -19,6 +19,13 @@ const TAB_PETS: string = 'inventory.furni.tab.pets'; const TAB_BADGES: string = 'inventory.badges'; const TAB_PREFIXES: string = 'inventory.prefixes'; const TABS = [ TAB_FURNITURE, TAB_PETS, TAB_BADGES, TAB_PREFIXES, TAB_BOTS ]; +// Maps an optional link code (inventory/show/) to a tab so other views +// (e.g. the profile "Change Badges" button) can deep-link to a specific tab. +const TAB_BY_CODE: Record = { + furni: TAB_FURNITURE, furniture: TAB_FURNITURE, + pets: TAB_PETS, badges: TAB_BADGES, + prefixes: TAB_PREFIXES, bots: TAB_BOTS +}; const UNSEEN_CATEGORIES = [ UnseenItemCategory.FURNI, UnseenItemCategory.PET, UnseenItemCategory.BADGE, UnseenItemCategory.PREFIX, UnseenItemCategory.BOT ]; const TAB_ICONS: Record = { [TAB_FURNITURE]: , @@ -94,12 +101,14 @@ export const InventoryView: FC<{}> = props => { case 'show': setIsVisible(true); + if(parts[2] && TAB_BY_CODE[parts[2]]) setCurrentTab(TAB_BY_CODE[parts[2]]); return; case 'hide': setIsVisible(false); return; case 'toggle': setIsVisible(prevValue => !prevValue); + if(parts[2] && TAB_BY_CODE[parts[2]]) setCurrentTab(TAB_BY_CODE[parts[2]]); return; } }, diff --git a/src/components/navigator/NavigatorView.tsx b/src/components/navigator/NavigatorView.tsx index b93b8d5..8daf79b 100644 --- a/src/components/navigator/NavigatorView.tsx +++ b/src/components/navigator/NavigatorView.tsx @@ -22,7 +22,7 @@ export const NavigatorView: FC<{}> = props => { const { topLevelContext, topLevelContexts, navigatorData, navigatorSearches } = useNavigatorData(); const { searchResult, isFetching } = useNavigatorSearch(); - const { isVisible, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, isOpenSavesSearches, needsInit } = useNavigatorUiState(); + const { isVisible, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, isOpenSavesSearches, needsInit, currentTabCode } = useNavigatorUiState(); const elementRef = useRef(null); useNitroEvent(RoomSessionEvent.CREATED, event => @@ -122,7 +122,7 @@ export const NavigatorView: FC<{}> = props => { topLevelContexts && topLevelContexts.length > 0 && topLevelContexts.map((context, index) => useNavigatorUiStore.getState().setTab(context.code) }> { LocalizeText('navigator.toplevelview.' + context.code) } ) } diff --git a/src/components/radio/RadioView.tsx b/src/components/radio/RadioView.tsx index 15e7c95..6cd825b 100644 --- a/src/components/radio/RadioView.tsx +++ b/src/components/radio/RadioView.tsx @@ -76,7 +76,7 @@ export const RadioView: FC<{}> = () =>
{ selectedPlaying && - Live + { LocalizeText('radio.live') } } { selected?.genre && { selected.genre } } diff --git a/src/components/room/widgets/avatar-info/infostand/ImagePositionEditorView.tsx b/src/components/room/widgets/avatar-info/infostand/ImagePositionEditorView.tsx new file mode 100644 index 0000000..1362ae0 --- /dev/null +++ b/src/components/room/widgets/avatar-info/infostand/ImagePositionEditorView.tsx @@ -0,0 +1,147 @@ +import { GetRoomEngine, RoomObjectCategory, RoomObjectVariable } from '@nitrots/nitro-renderer'; +import { FC, PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { LocalizeText } from '../../../../../api'; +import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../../common'; + +const PAD_W = 230; +const PAD_H = 150; +// How many offset units one pixel of drag represents. +const UNITS_PER_PX = 1.5; + +interface Props +{ + roomId: number; + objectId: number; + isWallItem: boolean; + initialX: number; + initialY: number; + initialZ: number; + initialScale: number; + onClose: () => void; + onSave: (x: number, y: number, z: number, scale: number) => void; +} + +export const ImagePositionEditorView: FC = props => +{ + const { roomId, objectId, isWallItem, initialX, initialY, initialZ, initialScale, onClose, onSave } = props; + const [ x, setX ] = useState(initialX); + const [ y, setY ] = useState(initialY); + const [ z, setZ ] = useState(initialZ); + const [ scale, setScale ] = useState(initialScale || 100); + const padRef = useRef(null); + const draggingRef = useRef(false); + + const category = isWallItem ? RoomObjectCategory.WALL : RoomObjectCategory.FLOOR; + + // Local-only live preview: set the branding model values directly. The model + // bumps its update counter so the visualization re-renders next frame. + // Nothing is sent to the server until Save. + const applyLive = useCallback((nx: number, ny: number, nz: number, nScale: number) => + { + const roomObject = GetRoomEngine().getRoomObject(roomId, objectId, category); + if(!roomObject?.model) return; + + roomObject.model.setValue(RoomObjectVariable.FURNITURE_BRANDING_OFFSET_X, nx); + roomObject.model.setValue(RoomObjectVariable.FURNITURE_BRANDING_OFFSET_Y, ny); + roomObject.model.setValue(RoomObjectVariable.FURNITURE_BRANDING_OFFSET_Z, nz); + roomObject.model.setValue(RoomObjectVariable.FURNITURE_BRANDING_SCALE, nScale); + }, [ roomId, objectId, category ]); + + useEffect(() => { applyLive(x, y, z, scale); }, [ x, y, z, scale, applyLive ]); + + const setFromPointer = useCallback((clientX: number, clientY: number) => + { + const rect = padRef.current?.getBoundingClientRect(); + if(!rect) return; + + const cx = rect.left + (rect.width / 2); + const cy = rect.top + (rect.height / 2); + + setX(Math.round((clientX - cx) * UNITS_PER_PX)); + setY(Math.round((clientY - cy) * UNITS_PER_PX)); + }, []); + + const onPointerDown = (event: ReactPointerEvent) => + { + draggingRef.current = true; + padRef.current?.setPointerCapture(event.pointerId); + setFromPointer(event.clientX, event.clientY); + }; + + const onPointerMove = (event: ReactPointerEvent) => + { + if(draggingRef.current) setFromPointer(event.clientX, event.clientY); + }; + + const onPointerUp = (event: ReactPointerEvent) => + { + draggingRef.current = false; + padRef.current?.releasePointerCapture?.(event.pointerId); + }; + + const cancel = () => + { + applyLive(initialX, initialY, initialZ, initialScale || 100); + onClose(); + }; + + const save = () => + { + onSave(x, y, z, scale); + onClose(); + }; + + const dotLeft = (PAD_W / 2) + (x / UNITS_PER_PX); + const dotTop = (PAD_H / 2) + (y / UNITS_PER_PX); + const clampedLeft = Math.max(0, Math.min(PAD_W, dotLeft)); + const clampedTop = Math.max(0, Math.min(PAD_H, dotTop)); + + return ( + + + +
+ { LocalizeText('image.position.editor.hint') } +
+ { /* center crosshair */ } +
+
+ { /* draggable dot */ } +
+
+ +
+ { LocalizeText('image.position.editor.scale') } + setScale(e.target.valueAsNumber || 100) } className="grow" /> + { (scale / 100).toFixed(2) }x +
+ +
+ + + +
+ +
+ + +
+
+ + + ); +}; diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx index d2a861c..40b458f 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx @@ -6,6 +6,7 @@ import { AvatarInfoFurni, GetGroupInformation, LocalizeText, SendMessageComposer import { Button, Column, Flex, LayoutBadgeImageView, LayoutCurrencyIcon, LayoutLimitedEditionCompactPlateView, LayoutRarityLevelView, LayoutRoomObjectImageView, Text, UserProfileIconView } from '../../../../../common'; import { useHasPermission, useMessageEvent, useNitroEvent, useRareValues, useRoom, useWiredTools } from '../../../../../hooks'; import { NitroInput } from '../../../../../layout'; +import { ImagePositionEditorView } from './ImagePositionEditorView'; interface InfoStandWidgetFurniViewProps { @@ -43,6 +44,7 @@ export const InfoStandWidgetFurniView: FC = props const [ isJukeBox, setIsJukeBox ] = useState(false); const [ isSongDisk, setIsSongDisk ] = useState(false); const [ isBranded, setIsBranded ] = useState(false); + const [ showPositionEditor, setShowPositionEditor ] = useState(false); const [ songId, setSongId ] = useState(-1); const [ songName, setSongName ] = useState(''); const [ songCreator, setSongCreator ] = useState(''); @@ -393,6 +395,45 @@ export const InfoStandWidgetFurniView: FC = props return data; }, [ furniKeys, furniValues ]); + const getBrandingOffset = useCallback((key: string): number => + { + const index = furniKeys.indexOf(key); + if(index < 0) return 0; + const value = parseInt(furniValues[index]); + return isNaN(value) ? 0 : value; + }, [ furniKeys, furniValues ]); + + const hasBrandingOffsets = isBranded && (furniKeys.indexOf('offsetX') >= 0); + + // Persist the position from the editor: rebuild the branding map with the + // new offsets and send it (same path as Save), then reflect it in the fields. + const savePositionEditor = useCallback((x: number, y: number, z: number, scale: number) => + { + const map = new Map(); + const clone = Array.from(furniValues); + let hasScale = false; + + for(let i = 0; i < furniKeys.length; i++) + { + const key = furniKeys[i]; + let value = furniValues[i]; + + if(key === 'offsetX') value = String(x); + else if(key === 'offsetY') value = String(y); + else if(key === 'offsetZ') value = String(z); + else if(key === 'scale') { value = String(scale); hasScale = true; } + + clone[i] = value; + map.set(key, value); + } + + // older branding furni may not carry a scale key yet — always send it + if(!hasScale) map.set('scale', String(scale)); + + GetRoomEngine().modifyRoomObjectDataWithMap(avatarInfo.id, avatarInfo.category, RoomObjectOperationType.OBJECT_SAVE_STUFF_DATA, map); + setFurniValues(clone); + }, [ avatarInfo, furniKeys, furniValues ]); + const processButtonAction = useCallback((action: string) => { if(!action || (action === '')) return; @@ -749,6 +790,10 @@ export const InfoStandWidgetFurniView: FC = props } + { hasBrandingOffsets && + } { ((furniKeys.length > 0 && furniValues.length > 0) && (furniKeys.length === furniValues.length)) && } + { showPositionEditor && + setShowPositionEditor(false) } + onSave={ savePositionEditor } /> } ); }; diff --git a/src/components/user-profile/RelationshipsContainerView.tsx b/src/components/user-profile/RelationshipsContainerView.tsx index 4de625a..204eb2b 100644 --- a/src/components/user-profile/RelationshipsContainerView.tsx +++ b/src/components/user-profile/RelationshipsContainerView.tsx @@ -1,6 +1,6 @@ import { RelationshipStatusEnum, RelationshipStatusInfoMessageParser } from '@nitrots/nitro-renderer'; import { FC } from 'react'; -import { GetUserProfile, LocalizeText } from '../../api'; +import { CreateLinkEvent, GetUserProfile, LocalizeText } from '../../api'; import { Flex, LayoutAvatarImageView } from '../../common'; interface RelationshipsContainerViewProps @@ -29,7 +29,7 @@ export const RelationshipsContainerView: FC = p
-

(relationshipInfo && (relationshipInfo.randomFriendId >= 1) && GetUserProfile(relationshipInfo.randomFriendId)) }> +

((relationshipInfo && (relationshipInfo.randomFriendId >= 1)) ? GetUserProfile(relationshipInfo.randomFriendId) : CreateLinkEvent('friends/toggle')) }> { (!relationshipInfo || (relationshipInfo.friendCount === 0)) && LocalizeText('extendedprofile.add.friends') } { (relationshipInfo && (relationshipInfo.friendCount >= 1)) && @@ -37,7 +37,7 @@ export const RelationshipsContainerView: FC = p

{ (relationshipInfo && (relationshipInfo.friendCount >= 1)) &&
- +
}

diff --git a/src/components/user-profile/UserContainerView.tsx b/src/components/user-profile/UserContainerView.tsx index 73b3ca2..e4e6b43 100644 --- a/src/components/user-profile/UserContainerView.tsx +++ b/src/components/user-profile/UserContainerView.tsx @@ -60,18 +60,12 @@ export const UserContainerView: FC = props => prefixText={ userProfile.prefixText } username={ userProfile.username } />

{ userProfile.motto || '\u00A0' }

-

-

+

+ { LocalizeText('extendedprofile.created').replace(/%\w+%/g, '').trim() } { userProfile.registration } +

+

+ { LocalizeText('extendedprofile.last.login').replace(/%\w+%/g, '').trim() } { FriendlyTime.format(userProfile.secondsSinceLastVisit, '.ago', 2) } +

{ LocalizeText('extendedprofile.achievementscore') } { userProfile.achievementPoints }

@@ -100,10 +94,10 @@ export const UserContainerView: FC = props => { isOwnProfile &&
- -
} @@ -148,11 +142,11 @@ export const UserContainerView: FC = props => { LocalizeText('inventory.badges') } { totalBadges } -
+
+
); diff --git a/src/components/user-profile/UserProfileView.tsx b/src/components/user-profile/UserProfileView.tsx index fd5a3b5..801fd4d 100644 --- a/src/components/user-profile/UserProfileView.tsx +++ b/src/components/user-profile/UserProfileView.tsx @@ -1,6 +1,6 @@ -import { ExtendedProfileChangedMessageEvent, GetSessionDataManager, NavigatorSearchComposer, RelationshipStatusInfoEvent, RelationshipStatusInfoMessageParser, RoomEngineObjectEvent, RoomObjectCategory, RoomObjectType, UserCurrentBadgesComposer, UserCurrentBadgesEvent, UserProfileEvent, UserProfileParser, UserRelationshipsComposer } from '@nitrots/nitro-renderer'; +import { ExtendedProfileChangedMessageEvent, GetSessionDataManager, RelationshipStatusInfoEvent, RelationshipStatusInfoMessageParser, RoomEngineObjectEvent, RoomObjectCategory, RoomObjectType, UserCurrentBadgesComposer, UserCurrentBadgesEvent, UserProfileEvent, UserProfileParser, UserRelationshipsComposer } from '@nitrots/nitro-renderer'; import { FC, useState } from 'react'; -import { GetRoomSession, GetUserProfile, LocalizeText, SendMessageComposer } from '../../api'; +import { CreateLinkEvent, GetRoomSession, GetUserProfile, LocalizeText, SendMessageComposer } from '../../api'; import { useMessageEvent, useNitroEvent } from '../../hooks'; import { NitroCard } from '../../layout'; import { GroupsContainerView } from './GroupsContainerView'; @@ -28,10 +28,11 @@ export const UserProfileView: FC<{}> = () => const onOpenRooms = () => { - if(userProfile) - { - SendMessageComposer(new NavigatorSearchComposer('hotel_view', `owner:${ userProfile.username }`)); - } + if(!userProfile) return; + + // Open the navigator AND run the owner search (the composer alone never + // showed the navigator window, so the button looked dead). + CreateLinkEvent(`navigator/search/hotel_view/owner:${ userProfile.username }`); }; useMessageEvent(UserCurrentBadgesEvent, event => @@ -100,7 +101,7 @@ export const UserProfileView: FC<{}> = () => if(!userProfile) return null; return ( - + diff --git a/src/components/user-settings/UserSettingsView.tsx b/src/components/user-settings/UserSettingsView.tsx index 8857e1a..999ac3b 100644 --- a/src/components/user-settings/UserSettingsView.tsx +++ b/src/components/user-settings/UserSettingsView.tsx @@ -3,7 +3,7 @@ import { FC, useEffect, useState } from 'react'; import { FaUserCog, FaVolumeDown, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'; import { DispatchMainEvent, DispatchUiEvent, LocalizeText, SendMessageComposer } from '../../api'; import { NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common'; -import { useCatalogPlaceMultipleItems, useCatalogSkipPurchaseConfirmation, useChatWindow, useMessageEvent } from '../../hooks'; +import { useCatalogClassicStyle, useCatalogPlaceMultipleItems, useCatalogSkipPurchaseConfirmation, useChatWindow, useMessageEvent } from '../../hooks'; import { classNames } from '../../layout'; export const UserSettingsView: FC<{}> = props => @@ -13,6 +13,7 @@ export const UserSettingsView: FC<{}> = props => const [ catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects ] = useCatalogPlaceMultipleItems(); const [ catalogSkipPurchaseConfirmation, setCatalogSkipPurchaseConfirmation ] = useCatalogSkipPurchaseConfirmation(); const [ chatWindowEnabled, setChatWindowEnabled ] = useChatWindow(); + const [ catalogClassicStyle, setCatalogClassicStyle ] = useCatalogClassicStyle(); const processAction = (type: string, value?: boolean | number | string) => { @@ -156,6 +157,10 @@ export const UserSettingsView: FC<{}> = props => setChatWindowEnabled(event.target.checked) } /> Enable chat window
+
+ setCatalogClassicStyle(event.target.checked) } /> + Catalogo: stile classico +
{ LocalizeText('widget.memenu.settings.volume') } diff --git a/src/components/user-settings/fortune-wheel/FortuneWheelSettingsView.tsx b/src/components/user-settings/fortune-wheel/FortuneWheelSettingsView.tsx deleted file mode 100644 index 91cc44c..0000000 --- a/src/components/user-settings/fortune-wheel/FortuneWheelSettingsView.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { IWheelAdminPrize, IWheelAdminPrizeEdit } from '@nitrots/nitro-renderer'; -import { FC, useEffect, useState } from 'react'; -import { LocalizeText } from '../../api'; -import { Column, Flex, Text } from '../../common'; -import { useFortuneWheel } from '../../hooks'; -import { NitroCard } from '../../layout'; - -interface EditRow -{ - id: number; - category: string; - num: number; - weight: number; - label: string; -} - -interface CategoryDef -{ - key: string; - labelKey: string; -} - -const CATEGORIES: CategoryDef[] = [ - { key: 'item', labelKey: 'rarevalues.editor.cat.item' }, - { key: 'diamanti', labelKey: 'achievements.activitypoint.5' }, - { key: 'duckets', labelKey: 'achievements.activitypoint.0' }, - { key: 'crediti', labelKey: 'credits' }, - { key: 'giri', labelKey: 'rarevalues.editor.cat.spin' }, - { key: 'nulla', labelKey: 'rarevalues.editor.cat.nothing' } -]; - -const prizeToCategory = (prize: IWheelAdminPrize): string => -{ - switch(prize.type) - { - case 'item': return 'item'; - case 'points': return (prize.pointsType === 5) ? 'diamanti' : 'duckets'; - case 'credits': return 'crediti'; - case 'spin': return 'giri'; - default: return 'nulla'; - } -}; - -const prizeToNum = (prize: IWheelAdminPrize): number => - (prize.type === 'item') ? (parseInt(prize.value) || 0) : prize.amount; - -const rowToEdit = (row: EditRow): IWheelAdminPrizeEdit => -{ - const base = { id: row.id, weight: row.weight, label: row.label }; - - switch(row.category) - { - case 'item': return { ...base, type: 'item', value: String(row.num), amount: 1, pointsType: 0 }; - case 'diamanti': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 5 }; - case 'duckets': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 0 }; - case 'crediti': return { ...base, type: 'credits', value: '', amount: row.num, pointsType: 0 }; - case 'giri': return { ...base, type: 'spin', value: '', amount: row.num, pointsType: 0 }; - default: return { ...base, type: 'nothing', value: '', amount: 0, pointsType: 0 }; - } -}; - -interface FortuneWheelSettingsViewProps -{ - onClose: () => void; -} - -export const FortuneWheelSettingsView: FC = ({ onClose }) => -{ - const { adminPrizes = [], loadAdminPrizes = null, saveAdminPrizes = null } = useFortuneWheel(); - const [ editRows, setEditRows ] = useState([]); - - useEffect(() => - { - if(loadAdminPrizes) loadAdminPrizes(); - }, [ loadAdminPrizes ]); - - useEffect(() => - { - setEditRows(adminPrizes.map(prize => ({ - id: prize.id, - category: prizeToCategory(prize), - num: prizeToNum(prize), - weight: prize.weight, - label: prize.label - }))); - }, [ adminPrizes ]); - - const updateRow = (id: number, patch: Partial) => - setEditRows(prev => prev.map(row => (row.id === id) ? { ...row, ...patch } : row)); - - return ( - - - - - - { LocalizeText('rarevalues.editor.type') } - { LocalizeText('rarevalues.editor.value') } - { LocalizeText('rarevalues.editor.weight') } - { LocalizeText('rarevalues.editor.label') } - - - { editRows.map(row => ( - - - updateRow(row.id, { num: parseInt(event.target.value) || 0 }) } - className="w-16 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34] disabled:opacity-40" /> - updateRow(row.id, { weight: parseInt(event.target.value) || 0 }) } - className="w-12 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" /> - updateRow(row.id, { label: event.target.value }) } - className="min-w-0 grow rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" /> - - )) } - { !editRows.length && - { LocalizeText('wheel.settings.empty') } } - - - - - - ); -}; diff --git a/src/components/user-settings/fortune-wheel/FortuneWheelView.tsx b/src/components/user-settings/fortune-wheel/FortuneWheelView.tsx deleted file mode 100644 index 37aa94b..0000000 --- a/src/components/user-settings/fortune-wheel/FortuneWheelView.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { AddLinkEventTracker, GetRoomEngine, ILinkEventTracker, IWheelPrize, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; -import { FC, useEffect, useMemo, useRef, useState } from 'react'; -import { LocalizeText } from '../../api'; -import { Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, LayoutCurrencyIcon, LayoutImage, Text } from '../../common'; -import { useFortuneWheel, useHasPermission } from '../../hooks'; -import { NitroCard } from '../../layout'; -import { FortuneWheelSettingsView } from './FortuneWheelSettingsView'; - -// Stock UI palette (white / light-blue / grey / black). -const SLICE_COLORS = [ '#eef2f5', '#c3dcec' ]; -const RIM = '#4c606c'; -const WHEEL_SIZE = 420; -const ICON_RADIUS = 150; -const FULL_TURNS = 5; - -const renderPrizeIcon = (prize: IWheelPrize) => -{ - switch(prize.type) - { - case 'item': - return ; - case 'badge': - return ; - case 'credits': - return ( - - - { prize.amount } - ); - case 'points': - return ( - - - { prize.amount } - ); - case 'spin': - return +{ prize.amount }; - default: - return ; - } -}; - -export const FortuneWheelView: FC<{}> = () => -{ - const [ isVisible, setIsVisible ] = useState(false); - const [ isSettingsOpen, setIsSettingsOpen ] = useState(false); - const { freeSpins, extraSpins, spinCost, spinCostType, prizes, recentWins, pendingPrizeId, isSpinning, open, spin, buySpin, finishSpin } = useFortuneWheel(); - const canManage = useHasPermission('acc_wheeladmin'); - const [ rotation, setRotation ] = useState(0); - const rotationRef = useRef(0); - const prizesRef = useRef([]); - prizesRef.current = prizes; - - useEffect(() => - { - const linkTracker: ILinkEventTracker = { - linkReceived: (url: string) => - { - const parts = url.split('/'); - if(parts.length < 2) return; - - switch(parts[1]) - { - case 'show': setIsVisible(true); return; - case 'hide': setIsVisible(false); return; - case 'toggle': setIsVisible(prev => !prev); return; - } - }, - eventUrlPrefix: 'fortune-wheel/', - }; - - AddLinkEventTracker(linkTracker); - - return () => RemoveLinkEventTracker(linkTracker); - }, []); - - useEffect(() => - { - if(isVisible) open(); - }, [ isVisible, open ]); - - // Drive the spin animation when the server reports the winning slice. - useEffect(() => - { - if(pendingPrizeId < 0) return; - - const list = prizesRef.current; - const idx = list.findIndex(prize => prize.id === pendingPrizeId); - - if(!list.length || (idx < 0)) - { - finishSpin(); - return; - } - - const sliceAngle = 360 / list.length; - const centerAngle = ((idx + 0.5) * sliceAngle); - const current = rotationRef.current; - const target = (current - (current % 360)) + (FULL_TURNS * 360) + (360 - centerAngle); - - rotationRef.current = target; - setRotation(target); - }, [ pendingPrizeId, finishSpin ]); - - const sliceAngle = prizes.length ? (360 / prizes.length) : 0; - - const background = useMemo(() => - { - if(!prizes.length) return SLICE_COLORS[0]; - - const stops = prizes.map((_, i) => `${ SLICE_COLORS[i % 2] } ${ i * sliceAngle }deg ${ (i + 1) * sliceAngle }deg`).join(', '); - return `conic-gradient(${ stops })`; - }, [ prizes, sliceAngle ]); - - if(!isVisible) return null; - - const canSpin = ((freeSpins + extraSpins) > 0) && !isSpinning && (prizes.length > 0); - - return ( - - setIsVisible(false) } /> - - - -
-
-
{ if(isSpinning) finishSpin(); } }> - { prizes.map((_, i) => ( -
- )) } - { prizes.map((prize, i) => - { - const centerAngle = ((i + 0.5) * sliceAngle); - return ( -
-
- { renderPrizeIcon(prize) } -
-
); - }) } -
-
-
- { LocalizeText('wheel.free.today', [ 'count' ], [ freeSpins.toString() ]) } - { LocalizeText('wheel.extra', [ 'count' ], [ extraSpins.toString() ]) } - - - - { canManage && - } - - - - { LocalizeText('wheel.winners') } - - { recentWins.map((win, i) => ( - -
- -
- - { win.username } - { win.prizeLabel } - -
- )) } - { !recentWins.length && - { LocalizeText('wheel.winners.empty') } } -
-
- - - { canManage && isSettingsOpen && - setIsSettingsOpen(false) } /> } - - ); -}; diff --git a/src/components/user-settings/rare-values/RareValuesView.tsx b/src/components/user-settings/rare-values/RareValuesView.tsx deleted file mode 100644 index c03f787..0000000 --- a/src/components/user-settings/rare-values/RareValuesView.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { AddLinkEventTracker, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, IRareValue, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; -import { FC, useEffect, useMemo, useState } from 'react'; -import { LocalizeFormattedNumber, LocalizeText } from '../../api'; -import { Column, Flex, LayoutCurrencyIcon, LayoutImage, Text } from '../../common'; -import { useRareValues } from '../../hooks'; -import { NitroCard, NitroInput } from '../../layout'; - -interface RareValueRow -{ - spriteId: number; - name: string; - iconUrl: string; - value: IRareValue; -} - -export const RareValuesView: FC<{}> = () => -{ - const [ isVisible, setIsVisible ] = useState(false); - const [ searchValue, setSearchValue ] = useState(''); - const { values = null, loaded = false } = useRareValues(); - - useEffect(() => - { - const linkTracker: ILinkEventTracker = { - linkReceived: (url: string) => - { - const parts = url.split('/'); - if(parts.length < 2) return; - - switch(parts[1]) - { - case 'show': setIsVisible(true); return; - case 'hide': setIsVisible(false); return; - case 'toggle': setIsVisible(prev => !prev); return; - } - }, - eventUrlPrefix: 'rare-values/', - }; - - AddLinkEventTracker(linkTracker); - - return () => RemoveLinkEventTracker(linkTracker); - }, []); - - const rows = useMemo(() => - { - if(!values) return []; - - const list: RareValueRow[] = []; - - values.forEach((value, spriteId) => - { - if(value.points <= 0) return; - - const floorData = GetSessionDataManager().getFloorItemData(spriteId); - const wallData = floorData ? null : GetSessionDataManager().getWallItemData(spriteId); - const data = (floorData ?? wallData); - - if(!data) return; - - const iconUrl = (floorData - ? GetRoomEngine().getFurnitureFloorIconUrl(spriteId) - : GetRoomEngine().getFurnitureWallIconUrl(spriteId)); - - list.push({ spriteId, name: (data.name || data.className || `#${ spriteId }`), iconUrl, value }); - }); - - list.sort((a, b) => (b.value.points - a.value.points)); - - return list; - }, [ values ]); - - const filtered = useMemo(() => - { - const query = searchValue.trim().toLocaleLowerCase(); - - if(!query) return rows; - - return rows.filter(row => row.name.toLocaleLowerCase().includes(query)); - }, [ rows, searchValue ]); - - if(!isVisible) return null; - - return ( - - setIsVisible(false) } /> - - - setSearchValue(event.target.value) } /> - - { !loaded && - { LocalizeText('rarevalues.loading') } } - { (loaded && !filtered.length) && - { LocalizeText('rarevalues.empty') } } - { filtered.map(row => ( - - - { row.name } - - { LocalizeFormattedNumber(row.value.points) } - - - - )) } - - - - - ); -}; diff --git a/src/css/friends/FriendsView.css b/src/css/friends/FriendsView.css index 2b2ea17..3786920 100644 --- a/src/css/friends/FriendsView.css +++ b/src/css/friends/FriendsView.css @@ -176,9 +176,15 @@ border: 0 !important; } - & .nitro-card-accordion-set-header span { - font-size: 12px; + /* The header title is rendered by the shared component, which is a +
(not a ) — so target the div too, otherwise it keeps the app + default size and shows as oversized "testoni". */ + & .nitro-card-accordion-set-header span, + & .nitro-card-accordion-set-header > div { + font-size: 12px !important; + font-weight: 700; color: #111 !important; + line-height: 1.1; } & .nitro-card-accordion-set-header .fa-icon { @@ -800,3 +806,43 @@ } } } + +/* ------------------------------------------------------------------ * + * Flat (non-nested) overrides. The rules above live inside a nested + * `.nitro-friends { & ... }` block; these are written flat so they + * apply reliably and win by source order. They fix two things the + * nested rules didn't: the oversized/overflowing friend-list heads and + * the oversized accordion section titles ("testoni"). + * ------------------------------------------------------------------ */ + +/* Friend-list avatar: clip a small box and centre the head, same proven + recipe as the messenger head. `inset: auto` cancels the component's + `inset-0`, otherwise the 130px head fills the row and overflows. */ +.nitro-friends .friends-list-avatar { + position: relative !important; + width: 34px; + height: 36px; + flex-shrink: 0; + overflow: hidden; +} + +.nitro-friends .friends-list-avatar .avatar-image { + position: absolute !important; + inset: auto !important; + left: 50% !important; + top: 56% !important; + width: 54px !important; + height: 54px !important; + margin: 0 !important; + background-position: center center !important; + transform: translate(-50%, -50%) scale(.95) !important; +} + +/* Accordion section titles are rendered by (a
, not a ), + so size the div too — otherwise they show oversized. */ +.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; +} diff --git a/src/css/user-profile/UserProfileView.css b/src/css/user-profile/UserProfileView.css index 3f3e47b..66bbec6 100644 --- a/src/css/user-profile/UserProfileView.css +++ b/src/css/user-profile/UserProfileView.css @@ -1,7 +1,3 @@ -.nitro-extended-profile-window { - border-radius: 0 !important; -} - .nitro-extended-profile-window .nitro-card-header-shell { min-height: 34px; max-height: 34px; @@ -46,32 +42,31 @@ .nitro-extended-profile__identity { display: grid; - grid-template-columns: 56px minmax(0, 1fr); - gap: 8px; + grid-template-columns: 68px minmax(0, 1fr); + gap: 10px; } +/* Mirror the room infostand exactly: a 68x135 flex column (= profile-background) + that centres the avatar horizontally and clips it; the stand/overlay sit on + top as absolute layers. The avatar keeps its component default classes + (relative w-[90px] h-[130px] left-[-2px]) so it lines up with bg + stand and + isn't crooked. Do NOT absolutely position or force width/height on it. */ .nitro-extended-profile__avatar-shell { - width: 56px; - height: 113px; + width: 68px; + height: 135px; position: relative; overflow: hidden; - background-size: cover; - background-position: center; + border-radius: 3px; + display: flex; + flex-direction: column; + align-items: center; } .nitro-extended-profile__avatar-stand, .nitro-extended-profile__avatar-overlay { position: absolute; - inset: 0; -} - -.nitro-extended-profile__avatar-image { - position: absolute !important; - left: 50% !important; - bottom: -4px; - transform: translateX(-50%); - width: auto !important; - height: auto !important; + top: 0; + left: 0; } .nitro-extended-profile__identity-copy { @@ -253,14 +248,27 @@ .nitro-extended-profile__relationship-head { position: absolute; - right: -2px; + right: 3px; top: 50%; - width: 34px; - height: 34px; + width: 30px; + height: 32px; transform: translateY(-50%); - display: flex; - align-items: center; - justify-content: center; + overflow: hidden; +} + +/* Same proven recipe as the messenger head: clip a small box and centre a + 54x54 avatar in it. `inset: auto` cancels the component's `inset-0` so the + width/position take effect (otherwise the head overflows huge). */ +.nitro-extended-profile__relationship-head .avatar-image { + position: absolute !important; + inset: auto !important; + left: 50% !important; + top: 54% !important; + width: 50px !important; + height: 50px !important; + margin: 0 !important; + background-position: center center !important; + transform: translate(-50%, -50%) scale(.95) !important; } .nitro-extended-profile__relationship-subcopy { diff --git a/src/hooks/catalog/index.ts b/src/hooks/catalog/index.ts index 2a817fa..1b4b9bc 100644 --- a/src/hooks/catalog/index.ts +++ b/src/hooks/catalog/index.ts @@ -1,4 +1,5 @@ export * from './useCatalog'; +export * from './useCatalogClassicStyle'; export * from './useCatalogFavorites'; export * from './useCatalogPlaceMultipleItems'; export * from './useCatalogSkipPurchaseConfirmation'; diff --git a/src/hooks/catalog/useCatalogClassicStyle.ts b/src/hooks/catalog/useCatalogClassicStyle.ts new file mode 100644 index 0000000..a239a60 --- /dev/null +++ b/src/hooks/catalog/useCatalogClassicStyle.ts @@ -0,0 +1,14 @@ +import { useBetween } from 'use-between'; +import { GetConfigurationValue, LocalStorageKeys } from '../../api'; +import { useLocalStorage } from '../useLocalStorage'; + +// Per-user toggle for the catalog visual style. +// - true => classic (old) catalog look +// - false => modern (rebuilt) catalog look +// The default for users who never touched the toggle comes from the global +// `catalog.classic.style` flag in ui-config.json, so an admin can flip the +// default for everyone (true = classic for all, false = modern for all) +// while still letting each user override it from the settings panel. +const useCatalogClassicStyleState = () => useLocalStorage(LocalStorageKeys.CATALOG_CLASSIC_STYLE, GetConfigurationValue('catalog.classic.style', false)); + +export const useCatalogClassicStyle = () => useBetween(useCatalogClassicStyleState); diff --git a/src/hooks/navigator/useNavigatorSearch.ts b/src/hooks/navigator/useNavigatorSearch.ts index 6ee7db0..fa5e021 100644 --- a/src/hooks/navigator/useNavigatorSearch.ts +++ b/src/hooks/navigator/useNavigatorSearch.ts @@ -1,5 +1,5 @@ -import { NavigatorSearchComposer, NavigatorSearchEvent, - NavigatorSearchResultSet } from '@nitrots/nitro-renderer'; +import { FlatCreatedEvent, NavigatorSearchComposer, NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer'; +import { useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { SendMessageComposer } from '../../api'; import { useMessageEvent } from '../events'; @@ -23,6 +23,7 @@ export const useNavigatorSearch = () => { const tabCode = useNavigatorUiStore(s => s.currentTabCode); const filter = useNavigatorUiStore(s => s.currentFilter); + const queryClient = useQueryClient(); const [ searchResult, setSearchResult ] = useState(null); const [ isFetching, setIsFetching ] = useState(false); @@ -40,15 +41,25 @@ export const useNavigatorSearch = () => const result = event.getParser()?.result; if(!result) return; - // Accept any incoming result for the currently active tab. Server - // can push extra results unprompted (e.g. after a room is - // created); accepting them keeps the panel in sync. - if(tabCode && result.code !== tabCode) return; + // No active tab → the search query is disabled, ignore any event. + // Otherwise only accept the event whose code matches the active tab. + if(!tabCode || (result.code !== tabCode)) return; setSearchResult(result); setIsFetching(false); }); + // A newly created room invalidates the current search so it refetches. + useMessageEvent(FlatCreatedEvent, () => + { + queryClient.invalidateQueries({ queryKey: [ 'navigator', 'search' ] }); + + if(!tabCode) return; + + setIsFetching(true); + SendMessageComposer(new NavigatorSearchComposer(tabCode, filter)); + }); + return { searchResult, isFetching,