🆙 Update Rare-Value page and fix the loading of the json

This commit is contained in:
duckietm
2026-05-29 08:31:18 +02:00
parent 47e8338570
commit fbcda88cd3
4 changed files with 84 additions and 44 deletions
+74 -14
View File
@@ -1,10 +1,12 @@
import { AddLinkEventTracker, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, IRareValue, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; import { AddLinkEventTracker, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, IRareValue, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react'; import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { LocalizeFormattedNumber, LocalizeText } from '../../api'; import { LocalizeFormattedNumber, LocalizeText } from '../../api';
import { Column, Flex, LayoutCurrencyIcon, LayoutImage, Text } from '../../common'; import { Column, Flex, LayoutCurrencyIcon, LayoutImage, Text } from '../../common';
import { useRareValues } from '../../hooks'; import { useRareValues } from '../../hooks';
import { NitroCard, NitroInput } from '../../layout'; import { NitroCard, NitroInput } from '../../layout';
const PAGE_SIZE = 50;
interface RareValueRow interface RareValueRow
{ {
spriteId: number; spriteId: number;
@@ -17,7 +19,10 @@ export const RareValuesView: FC<{}> = () =>
{ {
const [ isVisible, setIsVisible ] = useState(false); const [ isVisible, setIsVisible ] = useState(false);
const [ searchValue, setSearchValue ] = useState(''); const [ searchValue, setSearchValue ] = useState('');
const [ visibleCount, setVisibleCount ] = useState(PAGE_SIZE);
const { values = null, loaded = false } = useRareValues(); const { values = null, loaded = false } = useRareValues();
const scrollRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => useEffect(() =>
{ {
@@ -79,35 +84,90 @@ export const RareValuesView: FC<{}> = () =>
return rows.filter(row => row.name.toLocaleLowerCase().includes(query)); return rows.filter(row => row.name.toLocaleLowerCase().includes(query));
}, [ rows, searchValue ]); }, [ rows, searchValue ]);
useEffect(() =>
{
setVisibleCount(PAGE_SIZE);
if(scrollRef.current) scrollRef.current.scrollTop = 0;
}, [ filtered ]);
useEffect(() =>
{
if(!isVisible) return;
if(visibleCount >= filtered.length) return;
const sentinel = sentinelRef.current;
const root = scrollRef.current;
if(!sentinel || !root) return;
const observer = new IntersectionObserver(entries =>
{
if(entries.some(entry => entry.isIntersecting))
{
setVisibleCount(prev => Math.min(prev + PAGE_SIZE, filtered.length));
}
}, { root, rootMargin: '120px 0px' });
observer.observe(sentinel);
return () => observer.disconnect();
}, [ isVisible, visibleCount, filtered.length ]);
if(!isVisible) return null; if(!isVisible) return null;
const visibleRows = filtered.slice(0, visibleCount);
const hasMore = visibleCount < filtered.length;
return ( return (
<NitroCard className="w-[420px] h-[480px]" uniqueKey="rare-values"> <NitroCard className="w-[460px] h-[520px]" uniqueKey="rare-values">
<NitroCard.Header <NitroCard.Header
headerText={ LocalizeText('rarevalues.title') } headerText={ LocalizeText('rarevalues.title') }
onCloseClick={ () => setIsVisible(false) } /> onCloseClick={ () => setIsVisible(false) } />
<NitroCard.Content> <NitroCard.Content>
<Column gap={ 2 } className="h-full p-1"> <Column gap={ 2 } className="h-full p-2">
<Flex alignItems="center" gap={ 2 } className="rounded border border-black/10 bg-white px-2 py-1 shadow-inner">
<span className="text-black/40">🔍</span>
<NitroInput <NitroInput
placeholder={ LocalizeText('generic.search') } placeholder={ LocalizeText('generic.search') }
value={ searchValue } value={ searchValue }
onChange={ event => setSearchValue(event.target.value) } /> onChange={ event => setSearchValue(event.target.value) }
<Column gap={ 0 } overflow="auto" className="grow"> className="grow !border-0 !bg-transparent !p-0 !shadow-none focus:!ring-0" />
</Flex>
{ loaded &&
<Flex alignItems="center" justifyContent="between" className="px-1 text-[11px] text-black/55">
<span>{ filtered.length } { LocalizeText('rarevalues.title').toLowerCase() }</span>
{ hasMore && <span>{ visibleRows.length } / { filtered.length }</span> }
</Flex> }
<div
ref={ scrollRef }
className="grow overflow-auto rounded border border-black/10 bg-gradient-to-b from-[#f6f8fb] to-[#eaf1f6] shadow-inner">
{ !loaded && { !loaded &&
<Text center className="mt-2 text-black/60">{ LocalizeText('rarevalues.loading') }</Text> } <div className="p-6 text-center">
<Text className="text-black/55">{ LocalizeText('rarevalues.loading') }</Text>
</div> }
{ (loaded && !filtered.length) && { (loaded && !filtered.length) &&
<Text center className="mt-2 text-black/60">{ LocalizeText('rarevalues.empty') }</Text> } <div className="p-6 text-center">
{ filtered.map(row => ( <Text className="text-black/55">{ LocalizeText('rarevalues.empty') }</Text>
<Flex key={ row.spriteId } alignItems="center" gap={ 2 } className="border-b border-black/10 py-1.5 hover:bg-black/5"> </div> }
<LayoutImage imageUrl={ row.iconUrl } className="h-10 w-10 shrink-0 bg-contain bg-center bg-no-repeat" /> { visibleRows.map((row, index) => (
<Text truncate className="grow text-[#1f2d34]">{ row.name }</Text> <Flex
<Flex alignItems="center" gap={ 1 } className="shrink-0"> key={ row.spriteId }
<Text bold textEnd className="text-[#2f6f95]">{ LocalizeFormattedNumber(row.value.points) }</Text> alignItems="center"
gap={ 2 }
className={ `border-b border-black/[0.06] px-2 py-1.5 transition-colors hover:bg-[#cfe4f1] ${ index % 2 ? 'bg-white/60' : 'bg-[#e9f1f7]' }` }>
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded border border-black/10 bg-white shadow-sm">
<LayoutImage imageUrl={ row.iconUrl } className="h-9 w-9 bg-contain bg-center bg-no-repeat" />
</div>
<Text truncate className="grow text-[13px] font-medium text-[#1f2d34]">{ row.name }</Text>
<Flex alignItems="center" gap={ 1 } className="shrink-0 rounded-full bg-white/80 px-2 py-0.5 shadow-sm">
<Text bold textEnd className="text-[13px] text-[#2f6f95]">{ LocalizeFormattedNumber(row.value.points) }</Text>
<LayoutCurrencyIcon type={ row.value.pointsType } /> <LayoutCurrencyIcon type={ row.value.pointsType } />
</Flex> </Flex>
</Flex> </Flex>
)) } )) }
</Column> { hasMore &&
<div ref={ sentinelRef } className="flex items-center justify-center py-3">
<Text small className="text-black/45">{ LocalizeText('rarevalues.loading.more') }</Text>
</div> }
</div>
</Column> </Column>
</NitroCard.Content> </NitroCard.Content>
</NitroCard> </NitroCard>
+3 -1
View File
@@ -31,7 +31,9 @@ const useRadioState = () =>
if(loadStartedRef.current) return; if(loadStartedRef.current) return;
loadStartedRef.current = true; loadStartedRef.current = true;
const url = GetConfigurationValue<string>('radio.stations.url') || 'configuration/radio-stations.json5'; const url = GetConfigurationValue<string>('radio.url')
|| GetConfigurationValue<string>('radio.stations.url')
|| 'configuration/radio-stations.json5';
(async () => (async () =>
{ {
@@ -32,18 +32,6 @@ const CLIENT_COMMANDS: { key: string; descriptionKey: string }[] = [
{ key: 'nitro', descriptionKey: 'chatcmd.client.info' }, { key: 'nitro', descriptionKey: 'chatcmd.client.info' },
]; ];
/**
* Server-pushed command cache. Lives in a Zustand store (instead of
* module-level `let` variables) so the React Compiler can analyze the
* surrounding hook cleanly, and so a future test can `setState({…})`
* a deterministic fixture without monkey-patching the module.
*
* The `isListenerRegistered` flag prevents the renderer from getting
* two AvailableCommandsEvent listeners — one from the module-level
* pre-mount registration (which captures the server's reply that lands
* during login, BEFORE any React widget mounts) and one from the
* in-hook `useMessageEvent` (which covers later rank-change refreshes).
*/
interface ChatCommandStore interface ChatCommandStore
{ {
serverCommands: CommandDefinition[]; serverCommands: CommandDefinition[];
@@ -74,15 +62,9 @@ const ensureGlobalListener = (): void =>
GetCommunication().registerMessageEvent(event); GetCommunication().registerMessageEvent(event);
useChatCommandStore.getState().markListenerRegistered(); useChatCommandStore.getState().markListenerRegistered();
} }
catch catch {}
{
// Communication not ready yet — the in-hook useMessageEvent
// below covers later mounts.
}
}; };
// Try once at module load so the server's response landing before any
// React mount still hits the cache.
ensureGlobalListener(); ensureGlobalListener();
export const useChatCommandSelector = (chatValue: string) => export const useChatCommandSelector = (chatValue: string) =>
@@ -94,13 +76,9 @@ export const useChatCommandSelector = (chatValue: string) =>
useEffect(() => useEffect(() =>
{ {
// Cover the case where the module-level registration failed
// because GetCommunication() wasn't ready at import time.
ensureGlobalListener(); ensureGlobalListener();
}, []); }, []);
// Late updates (rank change, etc.) — go through the store so all
// consumers see the same data.
useMessageEvent<AvailableCommandsEvent>(AvailableCommandsEvent, event => useMessageEvent<AvailableCommandsEvent>(AvailableCommandsEvent, event =>
{ {
const parser = event.getParser(); const parser = event.getParser();
@@ -164,13 +142,11 @@ export const useChatCommandSelector = (chatValue: string) =>
setDismissed(true); setDismissed(true);
}, []); }, []);
// Reset dismissed when chatValue changes to a new command start
useEffect(() => useEffect(() =>
{ {
if(chatValue === ':' || chatValue === '') setDismissed(false); if(chatValue === ':' || chatValue === '') setDismissed(false);
}, [ chatValue ]); }, [ chatValue ]);
// Reset selectedIndex when filtered list changes
useEffect(() => useEffect(() =>
{ {
setSelectedIndex(0); setSelectedIndex(0);
+3 -1
View File
@@ -65,7 +65,9 @@ const useSoundboardState = () =>
if(!enabled || serverSounds.length || fileLoadStartedRef.current) return; if(!enabled || serverSounds.length || fileLoadStartedRef.current) return;
fileLoadStartedRef.current = true; fileLoadStartedRef.current = true;
const url = GetConfigurationValue<string>('soundboard.sounds.url') || 'configuration/soundboard-sounds.json5'; const url = GetConfigurationValue<string>('soundboard.url')
|| GetConfigurationValue<string>('soundboard.sounds.url')
|| 'configuration/soundboard-sounds.json5';
(async () => (async () =>
{ {