From 1810a866180ff031e8b3f152f93d09e806ef4074 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 19:11:31 +0200 Subject: [PATCH 01/15] docs(navigator): P2 TanStack Query design + integrated plan Combines spec + 5-task plan into a single doc for faster execution. Branch: feat/navigator-p2-query (forked from feat/navigator-modernization P1 tip). Migrates search from event-driven imperative state to useNitroQuery with cache per [tabCode, filter], invalidator on FlatCreatedEvent + RoomSettingsUpdatedEvent, accept-filter that rejects mismatched-tab server pushes. Key API changes: useNavigatorActions DELETED (sendSearch + reloadCurrentSearch gone); useNavigatorData no longer returns searchResult; navigatorUiStore adds currentTabCode + currentFilter + setTab + setFilter; new useNavigatorSearch hook returns the { searchResult, isFetching, refetch } triple. --- ...5-27-navigator-p2-tanstack-query-design.md | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-27-navigator-p2-tanstack-query-design.md diff --git a/docs/superpowers/specs/2026-05-27-navigator-p2-tanstack-query-design.md b/docs/superpowers/specs/2026-05-27-navigator-p2-tanstack-query-design.md new file mode 100644 index 0000000..b6f3965 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-navigator-p2-tanstack-query-design.md @@ -0,0 +1,243 @@ +# Navigator Modernization — P2: TanStack Query for Search + +**Branch**: `feat/navigator-p2-query` (forked from `feat/navigator-modernization` @ `1148c0a6`) +**Date**: 2026-05-27 +**Depends on**: P1 (hook split) — merged or pending merge + +## 1. Goal + +Migrate Navigator's search request/response from event-driven imperative state to TanStack Query. The user gets: +- **Instant tab switching** when the same tab/filter was visited before in the session (cache hit, no round-trip) +- **Stale-while-revalidate** on revisit (shows cached results while refetching in background) +- **Server-driven refresh** via `useNitroEventInvalidator` on `FlatCreatedEvent` and `RoomSettingsUpdatedEvent` (and possibly `FavouriteChangedEvent` if the active tab is `favorites_view`) +- **Single source of truth** for `isFetching` — no separate `isLoading` flag to manage + +## 2. Architecture changes + +### 2.1 New file: `src/hooks/navigator/useNavigatorSearch.ts` + +The query hook. Reads `currentTabCode` + `currentFilter` from `navigatorUiStore`, fires `NavigatorSearchComposer`, waits for `NavigatorSearchEvent`, returns the parsed `NavigatorSearchResultSet`. + +```ts +import { NavigatorSearchComposer, NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer'; +import { useNitroEventInvalidator, useNitroQuery } from '../../api/nitro-query'; +import { useNavigatorUiStore } from './navigatorUiStore'; + +export const useNavigatorSearch = () => +{ + const tabCode = useNavigatorUiStore(s => s.currentTabCode); + const filter = useNavigatorUiStore(s => s.currentFilter); + + const query = useNitroQuery({ + key: [ 'navigator', 'search', tabCode, filter ], + request: () => new NavigatorSearchComposer(tabCode, filter), + parser: NavigatorSearchEvent, + select: e => e.getParser()?.result ?? null, + accept: e => { + const result = e.getParser()?.result; + // accept-filter: only this query's matching tab code + return !!result && result.code === tabCode; + }, + enabled: !!tabCode, + staleTime: 30_000 // re-fetch after 30s of staleness on revisit + }); + + useNitroEventInvalidator(FlatCreatedEvent, [ 'navigator', 'search' ]); + useNitroEventInvalidator(RoomSettingsUpdatedEvent, [ 'navigator', 'search' ]); + + return { + searchResult: query.data, + isFetching: query.isFetching, + refetch: query.refetch + }; +}; +``` + +### 2.2 `navigatorUiStore.ts` additions + +Add 2 new state fields + 2 new actions: + +```ts +type NavigatorUiState = { + // ...existing 9 flags... + currentTabCode: string; // '' until NavigatorMetadataEvent arrives, then first top-level context code + currentFilter: string; // '' by default +}; + +type NavigatorUiActions = { + // ...existing 15 actions... + setTab(code: string): void; // also clears currentFilter + setFilter(value: string): void; +}; +``` + +`setTab(code)` resets `currentFilter` to `''` because switching tabs starts a fresh search. `setFilter` updates only the filter — the user is typing in the same tab. + +### 2.3 `useNavigatorStore.ts` — remove search state ownership + +Remove: +- `useState(null)` for `searchResult` +- `useMessageEvent` listener +- `sendSearch` and `reloadCurrentSearch` actions +- The `useNavigatorUiStore.getState().setLoading(...)` calls (no longer needed) +- The `topLevelContextRef` and `searchResultRef` (only consumed inside `reloadCurrentSearch`) + +Keep: +- `topLevelContext` + `topLevelContexts` (these still come from `NavigatorMetadataEvent` and drive the tab list) +- The `NavigatorMetadataEvent` listener — but it now ALSO calls `useNavigatorUiStore.getState().setTab(parser.topLevelContexts[0]?.code ?? '')` on first arrival, to seed the initial tab. The query then activates because `currentTabCode` becomes non-empty (`enabled: !!tabCode`). + +### 2.4 `useNavigatorData.ts` — remove `searchResult` from return shape + +`useNavigatorData()` no longer returns `searchResult`. Consumers that need it call `useNavigatorSearch()` instead. + +### 2.5 `useNavigatorActions.ts` — empty or removed + +Both `sendSearch` and `reloadCurrentSearch` are gone. Either: +- Remove the file + the export — consumers use `useNavigatorUiStore.getState().setTab(...)` / `setFilter(...)` directly +- Or keep the file as an empty re-export for forward compat. (Decision: REMOVE — minimize dead API). + +### 2.6 `useNavigatorUiState.ts` — add the 2 new flags + +Add `currentTabCode` and `currentFilter` to the per-key selector list and return shape. + +### 2.7 `useNavigatorSearch.test.tsx` — new + +Test cases: +- Initial mount with empty tabCode → query is disabled, no request fired +- After `setTab('public')` → query fires NavigatorSearchComposer('public', '') +- After `setFilter('cocco')` → query fires NavigatorSearchComposer('public', 'cocco') +- After `setTab('events')` → currentFilter resets to '', query fires NavigatorSearchComposer('events', '') +- `FlatCreatedEvent` invalidates the cache → refetch +- `RoomSettingsUpdatedEvent` invalidates the cache → refetch +- `NavigatorSearchEvent` with WRONG tabCode (e.g. server pushes an unsolicited result) is REJECTED by `accept` filter — does NOT update query data + +### 2.8 `NavigatorView.tsx` — major rewrite + +Replace: +- `useNavigatorActions` import → gone +- `useNavigatorData` no longer destructures `searchResult` — get it from `useNavigatorSearch` instead +- 4 `useEffect` blocks driving the imperative search flow (`needsSearch`, `needsInit` lifecycle, `reloadCurrentSearch` orchestration) → gone +- Tab `onClick={ () => sendSearch('', context.code) }` → `onClick={ () => useNavigatorUiStore.getState().setTab(context.code) }` +- `isLoading` from `useNavigatorUiState()` → `isFetching` from `useNavigatorSearch()` query +- `NavigatorInitComposer` initial dispatch on first `isVisible` — KEEP (still need it to get `topLevelContexts` populated) +- `pendingSearch` ref — gone (linkTracker `case 'search'` directly does `setTab(code); setFilter(value)`) + +Major simplification: the file shrinks ~30 lines. + +### 2.9 `NavigatorSearchView.tsx` — drive setFilter + +Read the file. The component currently exposes a search input that, on enter or button click, calls `sendSearch(value, currentTabCode)`. After P2 it: +- Reads `currentFilter` from `useNavigatorUiState` +- onChange → `useNavigatorUiStore.getState().setFilter(value)` (debounced 300ms) +- No more `sendSearch` reference + +Debounce: use a local `useState` for the input text + a `useEffect` that calls `setFilter(text)` 300ms after the last keystroke. Standard pattern. + +## 3. Backward-compat considerations + +- `useNavigatorActions.sendSearch` and `useNavigatorActions.reloadCurrentSearch` are REMOVED. No consumer outside Navigator depends on them — verified by grepping the previous P1 consumer migration. +- `useNavigatorData.searchResult` is REMOVED. Only `NavigatorView` reads it currently — easy to migrate. +- The `useNavigatorActions` filter itself becomes empty — consider whether to delete the file entirely. **Decision: delete the file** to minimize the API surface. Tasks 5-8 of P1 migrated `NavigatorSearchView` to use `useNavigatorActions` — that's the only consumer; it migrates to `useNavigatorUiStore` directly. + +## 4. Out of scope (each gets its own future spec) + +- Reactive favourite stars on cards (P3) +- Visual rework: empty states, virtualization, chip-based UI (P4) +- Form Action on search input (P6) + +## 5. Acceptance criteria + +P2 is complete when: + +1. `src/hooks/navigator/useNavigatorSearch.ts` exists and exports `useNavigatorSearch` +2. `useNavigatorStore.ts` no longer owns `searchResult`, no longer subscribes to `NavigatorSearchEvent`, no longer exposes `sendSearch` or `reloadCurrentSearch` +3. `navigatorUiStore.ts` has `currentTabCode` + `currentFilter` state and `setTab` + `setFilter` actions +4. `useNavigatorActions.ts` is deleted; barrel no longer exports `useNavigatorActions` +5. `useNavigatorData.ts` no longer returns `searchResult` +6. `useNavigatorUiState.ts` returns `currentTabCode` + `currentFilter` +7. `NavigatorView.tsx` reads `searchResult` from `useNavigatorSearch()`, uses `isFetching` for the loading flag, calls `setTab` on tab clicks +8. `NavigatorSearchView.tsx` debounces `setFilter` calls +9. `yarn typecheck` clean (same pre-existing floorplan errors) +10. `yarn test --run` green; smoke test updated; new `useNavigatorSearch.test.tsx` with 7 cases +11. `yarn lint:hooks` clean +12. Manual smoke: switch tabs rapidly → results cached, no flicker. Type filter → debounced refetch. Create a room → list refreshes. + +## 6. Risk register + +| Risk | Mitigation | +|---|---| +| `NavigatorSearchEvent` arrives unsolicited (server-side push) — query wouldn't update | The `accept` filter checks the result's code matches the current tabCode, so only matching events update the query. Unsolicited results to a non-active tab are ignored (acceptable — when the user switches to that tab, the cache is empty and a fresh query fires). | +| Removing `useNavigatorActions` breaks an import we missed | Type-checker catches it. The P1 grep showed only Navigator-internal consumers use it. | +| Removing the `isLoading`/`isReady`/`needsInit`/`needsSearch` flags from `navigatorUiStore` (they're now derivable from query state) — too aggressive? | KEEP them in P2. Only `searchResult` ownership moves. Future cleanup can remove the obsolete lifecycle flags once we're sure nothing reads them. | +| Debounce timing on search input | 300ms is standard; if it feels laggy the user can lower it later — pure UX tune | + +## 7. Plan (executable) + +### Task 1: Add UI store state + actions (TDD) + +**Files**: `src/hooks/navigator/navigatorUiStore.ts`, `src/hooks/navigator/navigatorUiStore.test.ts` + +- [ ] Add `currentTabCode: string` (initial `''`) and `currentFilter: string` (initial `''`) to `NavigatorUiState` +- [ ] Add `setTab(code: string): void` and `setFilter(value: string): void` to `NavigatorUiActions` +- [ ] `setTab(code)` sets `{ currentTabCode: code, currentFilter: '' }` (atomic reset on tab change) +- [ ] `setFilter(value)` sets `{ currentFilter: value }` (no tab side-effect) +- [ ] Update test file: 3 new cases — `setTab` updates tab and resets filter; `setFilter` updates filter without touching tab; idempotent `setTab` on same code resets filter to '' regardless +- [ ] `yarn test --run src/hooks/navigator/navigatorUiStore.test.ts` → green +- [ ] Commit: `feat(navigator): add currentTabCode + currentFilter to UI store (P2 prep)` + +### Task 2: Create `useNavigatorSearch` query hook (TDD) + +**Files**: `src/hooks/navigator/useNavigatorSearch.ts`, `src/hooks/navigator/useNavigatorSearch.test.tsx` + +Implement per §2.1 + §2.7 above. 7 test cases. + +The test will need: `QueryClientProvider` wrapper, mock for `NavigatorSearchComposer` (probably already in mock), `NavigatorSearchEvent` dispatch with parser.result.code matching/non-matching. + +- [ ] Commit: `feat(navigator): useNavigatorSearch query hook (P2 core)` + +### Task 3: Strip search ownership from `useNavigatorStore` + `useNavigatorData` + remove `useNavigatorActions` + +**Files**: `useNavigatorStore.ts`, `useNavigatorData.ts`, `useNavigatorActions.ts` (DELETE), `useNavigatorUiState.ts`, `index.ts` + +- [ ] Remove `searchResult` state + `setSearchResult` from `useNavigatorStore` +- [ ] Remove `NavigatorSearchEvent` listener from `useNavigatorStore` +- [ ] Remove `sendSearch` and `reloadCurrentSearch` from `useNavigatorStore` return +- [ ] Remove `setLoading` calls inside `useNavigatorStore` +- [ ] Remove `topLevelContextRef` and `searchResultRef` (no longer used after sendSearch/reload removal) +- [ ] In `NavigatorMetadataEvent` handler, add `useNavigatorUiStore.getState().setTab(parser.topLevelContexts[0]?.code ?? '')` after `setTopLevelContext(...)` — seeds the query when contexts arrive +- [ ] Remove `searchResult` from `useNavigatorData` destructure + return +- [ ] DELETE `src/hooks/navigator/useNavigatorActions.ts` +- [ ] Update `useNavigatorUiState.ts` to expose `currentTabCode` + `currentFilter` per-key selectors +- [ ] Update `src/hooks/navigator/index.ts` to remove `useNavigatorActions` export, add `useNavigatorSearch` export +- [ ] Update `useNavigatorStore.test.tsx` smoke test: 2 cases that expected `searchResult` in data shape or `sendSearch/reloadCurrentSearch` in actions shape — update accordingly (or just remove the "useNavigatorActions returns ..." test entirely) +- [ ] Verify typecheck: ONLY consumer-side errors expected (NavigatorView still references the old API). Hook files clean. +- [ ] Commit: `refactor(navigator): remove search ownership from useNavigatorStore` + +### Task 4: Migrate `NavigatorView.tsx` + `NavigatorSearchView.tsx` + +**Files**: `src/components/navigator/NavigatorView.tsx`, `src/components/navigator/views/search/NavigatorSearchView.tsx` + +- [ ] In `NavigatorView`: + - Import `useNavigatorSearch` + - Replace `useNavigatorData` destructure of `searchResult` with `useNavigatorSearch()` call returning `{ searchResult, isFetching }` + - Drop `useNavigatorActions` import + destructure (it's gone) + - Drop the 4 lifecycle `useEffect` blocks (needsSearch / needsInit-init / markReady / reloadCurrentSearch); the new flow: + - Keep the `NavigatorInitComposer` on first `isVisible` — still needed for metadata + - Tab clicks call `useNavigatorUiStore.getState().setTab(context.code)` + - linkTracker `case 'search'`: `store.setTab(parts[2]); store.setFilter(parts[3] ?? ''); store.show();` (no more `pendingSearch` ref) + - Replace `` with `isFetching` from the query + - Drop the `pendingSearch` ref +- [ ] In `NavigatorSearchView`: + - Read `currentFilter` from `useNavigatorUiState` for the initial input value + - Local `useState` for the text being typed (mirrors the store value) + - Debounce: `useEffect` with 300ms timer calling `useNavigatorUiStore.getState().setFilter(text)` + - Remove all `useNavigatorActions` references — the search submit happens via store, query refires automatically +- [ ] `yarn typecheck` clean +- [ ] `yarn test --run` green +- [ ] `yarn lint:hooks` clean +- [ ] Commit: `feat(navigator): drive search via TanStack Query + setTab/setFilter UI store actions` + +### Task 5: PR + +- [ ] Push branch +- [ ] Open PR against `duckietm:Dev`: `feat(navigator): TanStack Query for search (P2)` From 8f1b664b2fe8e53d51b71b7bf35f22211a77afcd Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 19:15:08 +0200 Subject: [PATCH 02/15] feat(navigator): add currentTabCode + currentFilter to UI store (P2 prep) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setTab(code) atomically updates currentTabCode and resets currentFilter to '' — switching tabs starts a fresh search context. setFilter(value) updates only the filter — the user is typing in the same tab. TDD: 3 new cases (16 total in navigatorUiStore.test). --- src/hooks/navigator/navigatorUiStore.test.ts | 33 +++++++++++++++++++- src/hooks/navigator/navigatorUiStore.ts | 10 +++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/hooks/navigator/navigatorUiStore.test.ts b/src/hooks/navigator/navigatorUiStore.test.ts index ebc41fa..36a7450 100644 --- a/src/hooks/navigator/navigatorUiStore.test.ts +++ b/src/hooks/navigator/navigatorUiStore.test.ts @@ -10,7 +10,9 @@ const INITIAL = { isOpenSavesSearches: false, isLoading: false, needsInit: true, - needsSearch: false + needsSearch: false, + currentTabCode: '', + currentFilter: '' }; describe('useNavigatorUiStore', () => @@ -32,6 +34,8 @@ describe('useNavigatorUiStore', () => expect(s.isLoading).toBe(false); expect(s.needsInit).toBe(true); expect(s.needsSearch).toBe(false); + expect(s.currentTabCode).toBe(''); + expect(s.currentFilter).toBe(''); }); describe('show / hide / toggle', () => @@ -141,4 +145,31 @@ describe('useNavigatorUiStore', () => expect(useNavigatorUiStore.getState().needsSearch).toBe(false); }); }); + + describe('tab + filter', () => + { + it("setTab('public') sets currentTabCode and clears currentFilter", () => + { + useNavigatorUiStore.setState({ currentTabCode: 'events', currentFilter: 'habbo' }); + useNavigatorUiStore.getState().setTab('public'); + expect(useNavigatorUiStore.getState().currentTabCode).toBe('public'); + expect(useNavigatorUiStore.getState().currentFilter).toBe(''); + }); + + it("setFilter('cocco') sets currentFilter without touching tab", () => + { + useNavigatorUiStore.getState().setTab('events'); + useNavigatorUiStore.getState().setFilter('cocco'); + expect(useNavigatorUiStore.getState().currentTabCode).toBe('events'); + expect(useNavigatorUiStore.getState().currentFilter).toBe('cocco'); + }); + + it('setTab on same code still resets currentFilter', () => + { + useNavigatorUiStore.setState({ currentTabCode: 'public', currentFilter: 'test' }); + useNavigatorUiStore.getState().setTab('public'); + expect(useNavigatorUiStore.getState().currentTabCode).toBe('public'); + expect(useNavigatorUiStore.getState().currentFilter).toBe(''); + }); + }); }); diff --git a/src/hooks/navigator/navigatorUiStore.ts b/src/hooks/navigator/navigatorUiStore.ts index 527aab7..709207a 100644 --- a/src/hooks/navigator/navigatorUiStore.ts +++ b/src/hooks/navigator/navigatorUiStore.ts @@ -10,6 +10,8 @@ export type NavigatorUiState = { isLoading: boolean; needsInit: boolean; needsSearch: boolean; + currentTabCode: string; + currentFilter: string; }; export type NavigatorUiActions = { @@ -28,6 +30,8 @@ export type NavigatorUiActions = { markInitDone(): void; requestSearch(): void; consumeSearchRequest(): void; + setTab(code: string): void; + setFilter(value: string): void; }; export const useNavigatorUiStore = createNitroStore()((set) => ({ @@ -40,6 +44,8 @@ export const useNavigatorUiStore = createNitroStore set({ isVisible: true, needsSearch: true }), hide: () => set({ isVisible: false }), @@ -57,5 +63,7 @@ export const useNavigatorUiStore = createNitroStore set({ isReady: true }), markInitDone: () => set({ needsInit: false }), requestSearch: () => set({ needsSearch: true }), - consumeSearchRequest: () => set({ needsSearch: false }) + consumeSearchRequest: () => set({ needsSearch: false }), + setTab: (code) => set({ currentTabCode: code, currentFilter: '' }), + setFilter: (value) => set({ currentFilter: value }) })); From 7435326dad5e3a65ee3bfea56e9742b0d781ed90 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 19:18:24 +0200 Subject: [PATCH 03/15] feat(navigator): useNavigatorSearch query hook (P2 core) useNitroQuery keyed on [currentTabCode, currentFilter] from navigatorUiStore. Fires NavigatorSearchComposer; subscribes to NavigatorSearchEvent with an accept-filter that rejects results whose code does not match the current tab. Invalidates on FlatCreatedEvent and RoomSettingsUpdatedEvent for server-driven refresh. nitro-renderer.mock.ts: add connection.send stub to GetCommunication so SendMessageComposer (which calls GetCommunication().connection.send) does not throw in tests that exercise useNitroQuery. TDD: 7 cases incl. enabled-gating, accept-filter rejection on mismatched tab, invalidator round-trip. --- .../navigator/useNavigatorSearch.test.tsx | 300 ++++++++++++++++++ src/hooks/navigator/useNavigatorSearch.ts | 47 +++ src/nitro-renderer.mock.ts | 4 +- 3 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 src/hooks/navigator/useNavigatorSearch.test.tsx create mode 100644 src/hooks/navigator/useNavigatorSearch.ts diff --git a/src/hooks/navigator/useNavigatorSearch.test.tsx b/src/hooks/navigator/useNavigatorSearch.test.tsx new file mode 100644 index 0000000..bd2fb60 --- /dev/null +++ b/src/hooks/navigator/useNavigatorSearch.test.tsx @@ -0,0 +1,300 @@ +/* @vitest-environment jsdom */ + +import { FlatCreatedEvent, NavigatorSearchComposer, NavigatorSearchEvent, + NavigatorSearchResultSet } from '@nitrots/nitro-renderer'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockEventDispatcher } from '../../nitro-renderer.mock'; +import { useNavigatorUiStore } from './navigatorUiStore'; +import { useNavigatorSearch } from './useNavigatorSearch'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Create a fresh QueryClient with retries off so failures are immediate. */ +const makeQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 } + } + }); + +/** Wrapper factory — each test gets its own QueryClient instance. */ +const makeWrapper = (client: QueryClient) => + ({ children }: { children: React.ReactNode }) => ( + + { children } + + ); + +/** Build a fake NavigatorSearchEvent that getParser() returns a result with `code`. */ +const makeSearchEvent = (code: string) => +{ + const result = new NavigatorSearchResultSet() as any; + result.code = code; + result.data = ''; + result.results = []; + + const ev = new NavigatorSearchEvent() as any; + ev.getParser = () => ({ result }); + return ev as NavigatorSearchEvent; +}; + +const INITIAL_UI = { + isVisible: false, + isReady: false, + isCreatorOpen: false, + isRoomInfoOpen: false, + isRoomLinkOpen: false, + isOpenSavesSearches: false, + isLoading: false, + needsInit: true, + needsSearch: false, + currentTabCode: '', + currentFilter: '' +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('useNavigatorSearch', () => +{ + beforeEach(() => + { + // Reset UI store state before each test + useNavigatorUiStore.setState(INITIAL_UI); + }); + + afterEach(() => + { + cleanup(); + vi.clearAllMocks(); + }); + + it('1. with empty tabCode query is disabled — NavigatorSearchEvent does not update data', async () => + { + const client = makeQueryClient(); + const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) }); + + // Dispatch a search event — should be ignored (query disabled) + act(() => + { + mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + }); + + // Data must stay null + expect(result.current.searchResult).toBeNull(); + expect(result.current.isFetching).toBe(false); + }); + + it('2. after setTab("public"), NavigatorSearchComposer is fired and NavigatorSearchEvent resolves query', async () => + { + const client = makeQueryClient(); + const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) }); + + // Activate the query + act(() => + { + useNavigatorUiStore.getState().setTab('public'); + }); + + // Hook should start fetching + await waitFor(() => expect(result.current.isFetching).toBe(true)); + + // Simulate server response + act(() => + { + mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + }); + + // Query should resolve with the matching result + await waitFor(() => expect(result.current.searchResult).not.toBeNull()); + expect((result.current.searchResult as any).code).toBe('public'); + expect(result.current.isFetching).toBe(false); + }); + + it('3. after setFilter("cocco"), a new query fires and NavigatorSearchEvent resolves it', async () => + { + const client = makeQueryClient(); + const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) }); + + // First establish a tab + act(() => + { + useNavigatorUiStore.getState().setTab('public'); + }); + // Resolve the initial query + await waitFor(() => expect(result.current.isFetching).toBe(true)); + act(() => + { + mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + }); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + // Now set a filter — triggers new query + act(() => + { + useNavigatorUiStore.getState().setFilter('cocco'); + }); + + await waitFor(() => expect(result.current.isFetching).toBe(true)); + + // Resolve with matching event + act(() => + { + mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + }); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + expect((result.current.searchResult as any).code).toBe('public'); + + // Confirm filter is set + expect(useNavigatorUiStore.getState().currentFilter).toBe('cocco'); + }); + + it('4. after setTab("events"), currentFilter resets to "" and new query fires for events', async () => + { + const client = makeQueryClient(); + const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) }); + + // Establish public tab with a filter + act(() => + { + useNavigatorUiStore.getState().setTab('public'); + useNavigatorUiStore.getState().setFilter('some-filter'); + }); + + // Resolve the public+filter query + await waitFor(() => expect(result.current.isFetching).toBe(true)); + act(() => + { + mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + }); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + // Switch to events tab — should atomically reset filter + act(() => + { + useNavigatorUiStore.getState().setTab('events'); + }); + + // Filter must be cleared + expect(useNavigatorUiStore.getState().currentFilter).toBe(''); + expect(useNavigatorUiStore.getState().currentTabCode).toBe('events'); + + // New query for 'events' fires + await waitFor(() => expect(result.current.isFetching).toBe(true)); + + // Resolve with events result + act(() => + { + mockEventDispatcher.dispatchEvent(makeSearchEvent('events')); + }); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + expect((result.current.searchResult as any).code).toBe('events'); + }); + + it('5. NavigatorSearchEvent with result.code === currentTabCode is accepted and updates data', async () => + { + const client = makeQueryClient(); + const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) }); + + act(() => + { + useNavigatorUiStore.getState().setTab('public'); + }); + + await waitFor(() => expect(result.current.isFetching).toBe(true)); + + act(() => + { + mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + }); + + await waitFor(() => + { + expect(result.current.searchResult).not.toBeNull(); + expect((result.current.searchResult as any).code).toBe('public'); + }); + }); + + it('6. NavigatorSearchEvent with result.code !== currentTabCode is REJECTED — data unchanged', async () => + { + const client = makeQueryClient(); + const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) }); + + act(() => + { + useNavigatorUiStore.getState().setTab('public'); + }); + + await waitFor(() => expect(result.current.isFetching).toBe(true)); + + // Dispatch an event for a DIFFERENT tab — should be rejected by accept filter + act(() => + { + mockEventDispatcher.dispatchEvent(makeSearchEvent('wrong_tab')); + }); + + // Still fetching — the wrong-tab event was ignored + // (the query promise stays pending until it times out or a matching event arrives) + // After the wrong-tab dispatch, data should NOT be updated + expect(result.current.searchResult).toBeNull(); + + // Now dispatch the correct one to unblock the test + act(() => + { + mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + }); + + await waitFor(() => expect(result.current.searchResult).not.toBeNull()); + // Only the correct-tab result is stored + expect((result.current.searchResult as any).code).toBe('public'); + }); + + it('7. dispatching FlatCreatedEvent triggers query invalidation (refetch starts)', async () => + { + const client = makeQueryClient(); + + // Spy on invalidateQueries to confirm the invalidator calls it + const invalidateSpy = vi.spyOn(client, 'invalidateQueries'); + + const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) }); + + // Establish a resolved query so there is something to invalidate + act(() => + { + useNavigatorUiStore.getState().setTab('public'); + }); + await waitFor(() => expect(result.current.isFetching).toBe(true)); + act(() => + { + mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + }); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + // Dispatch FlatCreatedEvent — should trigger invalidateQueries + const flatCreatedEv = new FlatCreatedEvent() as any; + flatCreatedEv.getParser = () => ({ roomId: 999 }); + act(() => + { + mockEventDispatcher.dispatchEvent(flatCreatedEv); + }); + + await waitFor(() => expect(invalidateSpy).toHaveBeenCalled()); + + // The invalidation should target the 'navigator', 'search' key prefix + const calls = invalidateSpy.mock.calls; + const calledWithSearchKey = calls.some(call => + { + const opts = call[0] as any; + const key: string[] = opts?.queryKey ?? []; + return key[0] === 'navigator' && key[1] === 'search'; + }); + expect(calledWithSearchKey).toBe(true); + }); +}); diff --git a/src/hooks/navigator/useNavigatorSearch.ts b/src/hooks/navigator/useNavigatorSearch.ts new file mode 100644 index 0000000..b120001 --- /dev/null +++ b/src/hooks/navigator/useNavigatorSearch.ts @@ -0,0 +1,47 @@ +import { FlatCreatedEvent, NavigatorSearchComposer, NavigatorSearchEvent, + NavigatorSearchResultSet, RoomSettingsUpdatedEvent } from '@nitrots/nitro-renderer'; +import { useNitroEventInvalidator, useNitroQuery } from '../../api/nitro-query'; +import { useNavigatorUiStore } from './navigatorUiStore'; + +const SEARCH_BASE_KEY = [ 'navigator', 'search' ] as const; + +/** + * TanStack Query wrapper for navigator search. + * + * Cache key: ['navigator', 'search', tabCode, filter] + * - Fires NavigatorSearchComposer(tabCode, filter) on miss. + * - Listens for NavigatorSearchEvent and resolves with the result. + * - accept-filter: rejects events whose result.code !== tabCode (defends + * against server-side cross-tab pushes resolving the wrong query slot). + * - Disabled when tabCode is '' (initial state, before metadata arrives). + * - Invalidates on FlatCreatedEvent (new room created) and + * RoomSettingsUpdatedEvent (room renamed / settings changed). + */ +export const useNavigatorSearch = () => +{ + const tabCode = useNavigatorUiStore(s => s.currentTabCode); + const filter = useNavigatorUiStore(s => s.currentFilter); + + const query = useNitroQuery({ + key: [ ...SEARCH_BASE_KEY, tabCode, filter ], + request: () => new NavigatorSearchComposer(tabCode, filter), + parser: NavigatorSearchEvent, + select: e => e.getParser()?.result ?? null, + accept: e => + { + const result = e.getParser()?.result; + return !!result && result.code === tabCode; + }, + enabled: !!tabCode, + staleTime: 30_000 + }); + + useNitroEventInvalidator(FlatCreatedEvent, [ ...SEARCH_BASE_KEY ]); + useNitroEventInvalidator(RoomSettingsUpdatedEvent, [ ...SEARCH_BASE_KEY ]); + + return { + searchResult: query.data ?? null, + isFetching: query.isFetching, + refetch: query.refetch + }; +}; diff --git a/src/nitro-renderer.mock.ts b/src/nitro-renderer.mock.ts index 28abd00..d80b851 100644 --- a/src/nitro-renderer.mock.ts +++ b/src/nitro-renderer.mock.ts @@ -533,7 +533,9 @@ export const GetCommunication = vi.fn(() => ({ { const wrapper = _msgEventWrappers.get(event); if(wrapper) msgListeners.get(event.type)?.delete(wrapper); - } + }, + // Stub for SendMessageComposer which calls GetCommunication().connection.send(...) + connection: { send: vi.fn() } })); export const GetConfiguration = vi.fn(stubManager); export const GetLocalizationManager = vi.fn(stubManager); From ee3736474ddd404e65a63c5c6ffd0054fc45f093 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 19:20:27 +0200 Subject: [PATCH 04/15] refactor(navigator): remove search ownership from useNavigatorStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 core surgery: search result + NavigatorSearchEvent listener + sendSearch + reloadCurrentSearch all leave useNavigatorStore. The new useNavigatorSearch query hook owns the cache. useNavigatorActions is deleted entirely — the only two actions it exposed are gone, and no consumer outside Navigator depended on it. NavigatorMetadataEvent handler now seeds the UI store's currentTabCode on first arrival, activating the query the moment top-level contexts land. useNavigatorData: searchResult removed from closure and return. useNavigatorUiState: currentTabCode + currentFilter added. index.ts: useNavigatorActions removed, useNavigatorSearch added. NavigatorView.tsx is intentionally broken at this commit and gets fixed in the next. --- src/hooks/navigator/index.ts | 2 +- src/hooks/navigator/useNavigatorActions.ts | 8 -- src/hooks/navigator/useNavigatorData.ts | 4 +- .../navigator/useNavigatorStore.test.tsx | 14 +--- src/hooks/navigator/useNavigatorStore.ts | 75 ++----------------- src/hooks/navigator/useNavigatorUiState.ts | 5 +- 6 files changed, 18 insertions(+), 90 deletions(-) delete mode 100644 src/hooks/navigator/useNavigatorActions.ts diff --git a/src/hooks/navigator/index.ts b/src/hooks/navigator/index.ts index c621851..67660d0 100644 --- a/src/hooks/navigator/index.ts +++ b/src/hooks/navigator/index.ts @@ -1,5 +1,5 @@ -export { useNavigatorActions } from './useNavigatorActions'; export { useNavigatorData } from './useNavigatorData'; +export { useNavigatorSearch } from './useNavigatorSearch'; export { useNavigatorUiState } from './useNavigatorUiState'; export { useNavigatorUiStore } from './navigatorUiStore'; export { useDoorState } from '../rooms/widgets/useDoorState'; diff --git a/src/hooks/navigator/useNavigatorActions.ts b/src/hooks/navigator/useNavigatorActions.ts deleted file mode 100644 index 6a88e43..0000000 --- a/src/hooks/navigator/useNavigatorActions.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useBetween } from 'use-between'; -import { useNavigatorStore } from './useNavigatorStore'; - -export const useNavigatorActions = () => -{ - const { sendSearch, reloadCurrentSearch } = useBetween(useNavigatorStore); - return { sendSearch, reloadCurrentSearch }; -}; diff --git a/src/hooks/navigator/useNavigatorData.ts b/src/hooks/navigator/useNavigatorData.ts index aeb05b7..500fb9e 100644 --- a/src/hooks/navigator/useNavigatorData.ts +++ b/src/hooks/navigator/useNavigatorData.ts @@ -6,12 +6,12 @@ export const useNavigatorData = () => const { categories, eventCategories, favouriteRoomIds, topLevelContext, topLevelContexts, - searchResult, navigatorSearches, navigatorData + navigatorSearches, navigatorData } = useBetween(useNavigatorStore); return { categories, eventCategories, favouriteRoomIds, topLevelContext, topLevelContexts, - searchResult, navigatorSearches, navigatorData + navigatorSearches, navigatorData }; }; diff --git a/src/hooks/navigator/useNavigatorStore.test.tsx b/src/hooks/navigator/useNavigatorStore.test.tsx index a9040f4..2e09036 100644 --- a/src/hooks/navigator/useNavigatorStore.test.tsx +++ b/src/hooks/navigator/useNavigatorStore.test.tsx @@ -1,6 +1,6 @@ import { renderHook } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { useNavigatorActions, useNavigatorData, useNavigatorUiState } from './index'; +import { useNavigatorData, useNavigatorUiState } from './index'; describe('navigator filter shapes (smoke)', () => { @@ -10,24 +10,18 @@ describe('navigator filter shapes (smoke)', () => expect(Object.keys(result.current).sort()).toEqual([ 'categories', 'eventCategories', 'favouriteRoomIds', 'navigatorData', 'navigatorSearches', - 'searchResult', 'topLevelContext', 'topLevelContexts' + 'topLevelContext', 'topLevelContexts' ].sort()); }); - it('useNavigatorUiState returns the 9 documented flags', () => + it('useNavigatorUiState returns the 11 documented flags', () => { const { result } = renderHook(() => useNavigatorUiState()); expect(Object.keys(result.current).sort()).toEqual([ + 'currentFilter', 'currentTabCode', 'isCreatorOpen', 'isLoading', 'isOpenSavesSearches', 'isReady', 'isRoomInfoOpen', 'isRoomLinkOpen', 'isVisible', 'needsInit', 'needsSearch' ].sort()); }); - - it('useNavigatorActions returns sendSearch + reloadCurrentSearch', () => - { - const { result } = renderHook(() => useNavigatorActions()); - expect(typeof result.current.sendSearch).toBe('function'); - expect(typeof result.current.reloadCurrentSearch).toBe('function'); - }); }); diff --git a/src/hooks/navigator/useNavigatorStore.ts b/src/hooks/navigator/useNavigatorStore.ts index e3165d9..bf1a513 100644 --- a/src/hooks/navigator/useNavigatorStore.ts +++ b/src/hooks/navigator/useNavigatorStore.ts @@ -6,13 +6,13 @@ import { CanCreateRoomEventEvent, CantConnectMessageParser, CreateLinkEvent, HabboWebTools, LegacyExternalInterface, NavigatorCategoryDataParser, NavigatorEventCategoryDataParser, NavigatorHomeRoomEvent, NavigatorMetadataEvent, NavigatorOpenRoomCreatorEvent, NavigatorSavedSearch, - NavigatorSearchComposer, NavigatorSearchesEvent, NavigatorSearchEvent, - NavigatorSearchResultSet, NavigatorTopLevelContext, NitroEventType, + NavigatorSearchesEvent, + NavigatorTopLevelContext, NitroEventType, RoomDataParser, RoomEnterErrorEvent, RoomEntryInfoMessageEvent, - RoomForwardEvent, RoomScoreEvent, RoomSettingsUpdatedEvent, + RoomForwardEvent, RoomScoreEvent, SecurityLevel, UserEventCatsEvent, UserFlatCatsEvent, UserInfoEvent, UserPermissionsEvent } from '@nitrots/nitro-renderer'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useState } from 'react'; import { CreateRoomSession, GetConfigurationValue, INavigatorData, LocalizeText, NotificationAlertType, SendMessageComposer, TryVisitRoom, VisitDesktop } from '../../api'; @@ -27,7 +27,6 @@ export const useNavigatorStore = () => const [ favouriteRoomIds, setFavouriteRoomIds ] = useState([]); const [ topLevelContext, setTopLevelContext ] = useState(null); const [ topLevelContexts, setTopLevelContexts ] = useState(null); - const [ searchResult, setSearchResult ] = useState(null); const [ navigatorSearches, setNavigatorSearches ] = useState(null); const [ navigatorData, setNavigatorData ] = useState({ settingsReceived: false, @@ -44,41 +43,8 @@ export const useNavigatorStore = () => canRate: true }); - // Refs let handlers stay [] deps without losing access to fresh state. - const topLevelContextsRef = useRef(topLevelContexts); - topLevelContextsRef.current = topLevelContexts; - const topLevelContextRef = useRef(topLevelContext); - topLevelContextRef.current = topLevelContext; - const searchResultRef = useRef(searchResult); - searchResultRef.current = searchResult; - const { simpleAlert = null } = useNotification(); - const sendSearch = useCallback((searchValue: string, contextCode: string) => - { - useNavigatorUiStore.getState().closeCreator(); - SendMessageComposer(new NavigatorSearchComposer(contextCode, searchValue)); - useNavigatorUiStore.getState().setLoading(true); - }, []); - - const reloadCurrentSearch = useCallback(() => - { - if(!useNavigatorUiStore.getState().isReady) - { - useNavigatorUiStore.getState().requestSearch(); - return; - } - const sr = searchResultRef.current; - if(sr) - { - sendSearch(sr.data, sr.code); - return; - } - const ctx = topLevelContextRef.current; - if(!ctx) return; - sendSearch('', ctx.code); - }, [ sendSearch ]); - useMessageEvent(FavouritesEvent, useCallback(event => { const parser = event.getParser(); @@ -99,12 +65,6 @@ export const useNavigatorStore = () => }); }, [])); - useMessageEvent(RoomSettingsUpdatedEvent, useCallback(event => - { - const parser = event.getParser(); - SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, false, false)); - }, [])); - useMessageEvent(CanCreateRoomEventEvent, useCallback(event => { const parser = event.getParser(); @@ -223,28 +183,8 @@ export const useNavigatorStore = () => const parser = event.getParser(); setTopLevelContexts(parser.topLevelContexts); setTopLevelContext(parser.topLevelContexts.length ? parser.topLevelContexts[0] : null); - }, [])); - - useMessageEvent(NavigatorSearchEvent, useCallback(event => - { - const parser = event.getParser(); - const contexts = topLevelContextsRef.current; - setTopLevelContext(prev => - { - let next = prev; - if(!next) next = (contexts && contexts.length && contexts[0]) || null; - if(!next) return null; - if(contexts && contexts.length) - { - for(const ctx of contexts) - { - if(ctx.code === parser.result.code) next = ctx; - } - } - return next; - }); - setSearchResult(parser.result); - useNavigatorUiStore.getState().setLoading(false); + // Seed the query's tab code so useNavigatorSearch activates immediately + useNavigatorUiStore.getState().setTab(parser.topLevelContexts[0]?.code ?? ''); }, [])); useMessageEvent(UserFlatCatsEvent, useCallback(event => @@ -342,7 +282,6 @@ export const useNavigatorStore = () => return { categories, eventCategories, favouriteRoomIds, topLevelContext, topLevelContexts, - searchResult, navigatorSearches, navigatorData, - sendSearch, reloadCurrentSearch + navigatorSearches, navigatorData }; }; diff --git a/src/hooks/navigator/useNavigatorUiState.ts b/src/hooks/navigator/useNavigatorUiState.ts index 3c0868a..d811f0c 100644 --- a/src/hooks/navigator/useNavigatorUiState.ts +++ b/src/hooks/navigator/useNavigatorUiState.ts @@ -11,8 +11,11 @@ export const useNavigatorUiState = () => const isLoading = useNavigatorUiStore(s => s.isLoading); const needsInit = useNavigatorUiStore(s => s.needsInit); const needsSearch = useNavigatorUiStore(s => s.needsSearch); + const currentTabCode = useNavigatorUiStore(s => s.currentTabCode); + const currentFilter = useNavigatorUiStore(s => s.currentFilter); return { isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, - isOpenSavesSearches, isLoading, needsInit, needsSearch + isOpenSavesSearches, isLoading, needsInit, needsSearch, + currentTabCode, currentFilter }; }; From 26772f7073465f6ebaf82773b8264e37f3949801 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 19:25:30 +0200 Subject: [PATCH 05/15] feat(navigator): drive search via TanStack Query + setTab/setFilter UI store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NavigatorView reads searchResult/isFetching from useNavigatorSearch instead of useNavigatorData/useNavigatorUiState. Tab clicks call setTab(code) on the UI store, which atomically updates the query key and triggers refetch. The 4 lifecycle useEffect blocks driving the old imperative flow (needsSearch / reloadCurrentSearch / markReady) are removed — the query handles all of it now. NavigatorSearchView has a debounced (300ms) onChange -> setFilter that drives the same query refetch. Explicit submit (Enter / button) skips the debounce and calls setFilter immediately. linkTracker case 'search' now setTab + setFilter + show — no more pendingSearch ref. useNavigatorSearch.test.tsx: cast constructors as any to satisfy tsgo against real renderer types while keeping runtime stubs no-arg-safe. yarn typecheck / test / lint:hooks all clean (only pre-existing floorplan environmental failures). --- src/components/navigator/NavigatorView.tsx | 39 +++-------- .../views/search/NavigatorSearchView.tsx | 64 +++++++++++-------- .../navigator/useNavigatorSearch.test.tsx | 34 +++++----- 3 files changed, 66 insertions(+), 71 deletions(-) diff --git a/src/components/navigator/NavigatorView.tsx b/src/components/navigator/NavigatorView.tsx index 3ff6be6..7819c96 100644 --- a/src/components/navigator/NavigatorView.tsx +++ b/src/components/navigator/NavigatorView.tsx @@ -8,7 +8,7 @@ import randomRoomImg from '../../assets/images/navigator/random_room.png'; import promoteRoomImg from '../../assets/images/navigator/promote_room.png'; import { CreateLinkEvent, LocalizeText, SendMessageComposer, TryVisitRoom } from '../../api'; import { Flex, Text } from '../../common'; -import { useNavigatorActions, useNavigatorData, useNavigatorUiState, useNavigatorUiStore, useNitroEvent } from '../../hooks'; +import { useNavigatorData, useNavigatorSearch, useNavigatorUiState, useNavigatorUiStore, useNitroEvent } from '../../hooks'; import { NavigatorDoorStateView } from './views/NavigatorDoorStateView'; import { NavigatorRoomCreatorView } from './views/NavigatorRoomCreatorView'; import { NavigatorRoomInfoView } from './views/NavigatorRoomInfoView'; @@ -20,10 +20,9 @@ import { NavigatorSearchView } from './views/search/NavigatorSearchView'; export const NavigatorView: FC<{}> = props => { - const { searchResult, topLevelContext, topLevelContexts, navigatorData, navigatorSearches } = useNavigatorData(); - const { isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, isOpenSavesSearches, isLoading, needsInit, needsSearch } = useNavigatorUiState(); - const { sendSearch, reloadCurrentSearch } = useNavigatorActions(); - const pendingSearch = useRef<{ value: string, code: string }>(null); + const { topLevelContext, topLevelContexts, navigatorData, navigatorSearches } = useNavigatorData(); + const { searchResult, isFetching } = useNavigatorSearch(); + const { isVisible, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, isOpenSavesSearches, needsInit } = useNavigatorUiState(); const elementRef = useRef(null); useNitroEvent(RoomSessionEvent.CREATED, event => @@ -72,7 +71,10 @@ export const NavigatorView: FC<{}> = props => return; case 'search': if(parts.length <= 2) return; - pendingSearch.current = { value: parts.length > 3 ? parts[3] : '', code: parts[2] }; + const code = parts[2]; + const value = parts.length > 3 ? parts[3] : ''; + store.setTab(code); + if(value) store.setFilter(value); store.show(); return; } @@ -89,27 +91,6 @@ export const NavigatorView: FC<{}> = props => if(elementRef.current) elementRef.current.scrollTop = 0; }, [ searchResult ]); - useEffect(() => - { - if(!isVisible || !isReady || !needsSearch) return; - if(pendingSearch.current) - { - sendSearch(pendingSearch.current.value, pendingSearch.current.code); - pendingSearch.current = null; - } - else - { - reloadCurrentSearch(); - } - useNavigatorUiStore.getState().consumeSearchRequest(); - }, [ isVisible, isReady, needsSearch, sendSearch, reloadCurrentSearch ]); - - useEffect(() => - { - if(isReady || !topLevelContext) return; - useNavigatorUiStore.getState().markReady(); - }, [ isReady, topLevelContext ]); - useEffect(() => { if(!isVisible || !needsInit) return; @@ -142,7 +123,7 @@ export const NavigatorView: FC<{}> = props => sendSearch('', context.code) }> + onClick={ () => useNavigatorUiStore.getState().setTab(context.code) }> { LocalizeText('navigator.toplevelview.' + context.code) } ) } = props => - + { !isCreatorOpen &&
{ isOpenSavesSearches && diff --git a/src/components/navigator/views/search/NavigatorSearchView.tsx b/src/components/navigator/views/search/NavigatorSearchView.tsx index 18980b6..a6b3185 100644 --- a/src/components/navigator/views/search/NavigatorSearchView.tsx +++ b/src/components/navigator/views/search/NavigatorSearchView.tsx @@ -2,35 +2,17 @@ import { FC, KeyboardEvent, useEffect, useState } from 'react'; import { FaSearch } from 'react-icons/fa'; import { INavigatorSearchFilter, LocalizeText, SearchFilterOptions } from '../../../../api'; import { Button } from '../../../../common'; -import { useNavigatorActions, useNavigatorData } from '../../../../hooks'; +import { useNavigatorData, useNavigatorSearch, useNavigatorUiStore } from '../../../../hooks'; export const NavigatorSearchView: FC<{}> = props => { const [ searchFilterIndex, setSearchFilterIndex ] = useState(0); - const [ searchValue, setSearchValue ] = useState(''); - const { topLevelContext, searchResult } = useNavigatorData(); - const { sendSearch } = useNavigatorActions(); - - const processSearch = () => - { - if(!topLevelContext) return; - - let searchFilter = SearchFilterOptions[searchFilterIndex]; - - if(!searchFilter) searchFilter = SearchFilterOptions[0]; - - const searchQuery = ((searchFilter.query ? (searchFilter.query + ':') : '') + searchValue); - - sendSearch((searchQuery || ''), topLevelContext.code); - }; - - const handleKeyDown = (event: KeyboardEvent) => - { - if(event.key !== 'Enter') return; - - processSearch(); - }; + const [ inputText, setInputText ] = useState(''); + const { topLevelContext } = useNavigatorData(); + const { searchResult } = useNavigatorSearch(); + // Sync the input text display when a server result arrives (e.g. on tab switch + // or deep-link navigation that sets the filter through the store directly). useEffect(() => { if(!searchResult) return; @@ -55,9 +37,39 @@ export const NavigatorSearchView: FC<{}> = props => if(!filter) filter = SearchFilterOptions[0]; setSearchFilterIndex(SearchFilterOptions.findIndex(option => (option === filter))); - setSearchValue(value); + setInputText(value); }, [ searchResult ]); + // Debounced filter — 300ms after the user stops typing, push to the store + // which updates the query key and triggers a refetch. + useEffect(() => + { + const timer = setTimeout(() => + { + const searchFilter = SearchFilterOptions[searchFilterIndex] ?? SearchFilterOptions[0]; + const searchQuery = (searchFilter.query ? (searchFilter.query + ':') : '') + inputText; + useNavigatorUiStore.getState().setFilter(searchQuery); + }, 300); + + return () => clearTimeout(timer); + }, [ inputText, searchFilterIndex ]); + + const processSearch = () => + { + if(!topLevelContext) return; + // Immediate submit — skip the debounce timer + const searchFilter = SearchFilterOptions[searchFilterIndex] ?? SearchFilterOptions[0]; + const searchQuery = (searchFilter.query ? (searchFilter.query + ':') : '') + inputText; + useNavigatorUiStore.getState().setFilter(searchQuery); + }; + + const handleKeyDown = (event: KeyboardEvent) => + { + if(event.key !== 'Enter') return; + + processSearch(); + }; + return (
@@ -69,7 +81,7 @@ export const NavigatorSearchView: FC<{}> = props =>
- setSearchValue(event.target.value) } onKeyDown={ event => handleKeyDown(event) } /> + setInputText(event.target.value) } onKeyDown={ event => handleKeyDown(event) } /> diff --git a/src/hooks/navigator/useNavigatorSearch.test.tsx b/src/hooks/navigator/useNavigatorSearch.test.tsx index bd2fb60..f6858f0 100644 --- a/src/hooks/navigator/useNavigatorSearch.test.tsx +++ b/src/hooks/navigator/useNavigatorSearch.test.tsx @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { FlatCreatedEvent, NavigatorSearchComposer, NavigatorSearchEvent, +import { FlatCreatedEvent, NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; @@ -32,14 +32,16 @@ const makeWrapper = (client: QueryClient) => /** Build a fake NavigatorSearchEvent that getParser() returns a result with `code`. */ const makeSearchEvent = (code: string) => { - const result = new NavigatorSearchResultSet() as any; + // Cast constructors as `any` so tsgo doesn't check required args against + // the real renderer SDK types (the mock stubs have no required args). + const result = new (NavigatorSearchResultSet as any)() as any; result.code = code; result.data = ''; result.results = []; - const ev = new NavigatorSearchEvent() as any; + const ev = new (NavigatorSearchEvent as any)() as any; ev.getParser = () => ({ result }); - return ev as NavigatorSearchEvent; + return ev; }; const INITIAL_UI = { @@ -82,7 +84,7 @@ describe('useNavigatorSearch', () => // Dispatch a search event — should be ignored (query disabled) act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); // Data must stay null @@ -107,7 +109,7 @@ describe('useNavigatorSearch', () => // Simulate server response act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); // Query should resolve with the matching result @@ -130,7 +132,7 @@ describe('useNavigatorSearch', () => await waitFor(() => expect(result.current.isFetching).toBe(true)); act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); await waitFor(() => expect(result.current.isFetching).toBe(false)); @@ -145,7 +147,7 @@ describe('useNavigatorSearch', () => // Resolve with matching event act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); await waitFor(() => expect(result.current.isFetching).toBe(false)); @@ -171,7 +173,7 @@ describe('useNavigatorSearch', () => await waitFor(() => expect(result.current.isFetching).toBe(true)); act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); await waitFor(() => expect(result.current.isFetching).toBe(false)); @@ -191,7 +193,7 @@ describe('useNavigatorSearch', () => // Resolve with events result act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('events')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('events') as any); }); await waitFor(() => expect(result.current.isFetching).toBe(false)); @@ -212,7 +214,7 @@ describe('useNavigatorSearch', () => act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); await waitFor(() => @@ -237,7 +239,7 @@ describe('useNavigatorSearch', () => // Dispatch an event for a DIFFERENT tab — should be rejected by accept filter act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('wrong_tab')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('wrong_tab') as any); }); // Still fetching — the wrong-tab event was ignored @@ -248,7 +250,7 @@ describe('useNavigatorSearch', () => // Now dispatch the correct one to unblock the test act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); await waitFor(() => expect(result.current.searchResult).not.toBeNull()); @@ -273,16 +275,16 @@ describe('useNavigatorSearch', () => await waitFor(() => expect(result.current.isFetching).toBe(true)); act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); await waitFor(() => expect(result.current.isFetching).toBe(false)); // Dispatch FlatCreatedEvent — should trigger invalidateQueries - const flatCreatedEv = new FlatCreatedEvent() as any; + const flatCreatedEv = new (FlatCreatedEvent as any)() as any; flatCreatedEv.getParser = () => ({ roomId: 999 }); act(() => { - mockEventDispatcher.dispatchEvent(flatCreatedEv); + mockEventDispatcher.dispatchEvent(flatCreatedEv as any); }); await waitFor(() => expect(invalidateSpy).toHaveBeenCalled()); From 61aceaa42209a9a8b11fa84444beada02c172e07 Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 28 May 2026 02:39:02 +0200 Subject: [PATCH 06/15] feat: rare values panel + fortune wheel UI + prize editor Toolbar buttons, FortuneWheelView (animated wheel, prize icons, recent winners), RareValuesView (diamond price guide + staff prize-editor tab), furni infostand value line, useFortuneWheel/useRareValues hooks, it/en text examples. --- .../configuration/rarevalues-texts-en.example | 6 + .../configuration/rarevalues-texts-it.example | 6 + public/configuration/wheel-texts-en.example | 9 + public/configuration/wheel-texts-it.example | 9 + src/components/MainView.tsx | 4 + .../fortune-wheel/FortuneWheelView.tsx | 191 ++++++++++++++ src/components/rare-values/RareValuesView.tsx | 234 ++++++++++++++++++ .../infostand/InfoStandWidgetFurniView.tsx | 17 +- src/components/toolbar/ToolbarView.tsx | 12 + src/css/icons/icons.css | 14 ++ src/hooks/fortune-wheel/useFortuneWheel.ts | 69 ++++++ src/hooks/index.ts | 2 + src/hooks/rare-values/useRareValues.ts | 31 +++ 13 files changed, 602 insertions(+), 2 deletions(-) create mode 100644 public/configuration/rarevalues-texts-en.example create mode 100644 public/configuration/rarevalues-texts-it.example create mode 100644 public/configuration/wheel-texts-en.example create mode 100644 public/configuration/wheel-texts-it.example create mode 100644 src/components/fortune-wheel/FortuneWheelView.tsx create mode 100644 src/components/rare-values/RareValuesView.tsx create mode 100644 src/hooks/fortune-wheel/useFortuneWheel.ts create mode 100644 src/hooks/rare-values/useRareValues.ts diff --git a/public/configuration/rarevalues-texts-en.example b/public/configuration/rarevalues-texts-en.example new file mode 100644 index 0000000..e9d6368 --- /dev/null +++ b/public/configuration/rarevalues-texts-en.example @@ -0,0 +1,6 @@ +{ + "rarevalues.title": "Rare Values", + "rarevalues.loading": "Loading values…", + "rarevalues.empty": "No rares found", + "rarevalues.infostand.label": "Value:" +} diff --git a/public/configuration/rarevalues-texts-it.example b/public/configuration/rarevalues-texts-it.example new file mode 100644 index 0000000..62fef05 --- /dev/null +++ b/public/configuration/rarevalues-texts-it.example @@ -0,0 +1,6 @@ +{ + "rarevalues.title": "Valore Rari", + "rarevalues.loading": "Caricamento valori…", + "rarevalues.empty": "Nessun raro trovato", + "rarevalues.infostand.label": "Valore:" +} diff --git a/public/configuration/wheel-texts-en.example b/public/configuration/wheel-texts-en.example new file mode 100644 index 0000000..71b3b1b --- /dev/null +++ b/public/configuration/wheel-texts-en.example @@ -0,0 +1,9 @@ +{ + "wheel.title": "Fortune Wheel", + "wheel.free.today": "You have %count% free spins today!", + "wheel.extra": "Extra spins: %count%", + "wheel.spin": "SPIN", + "wheel.buy": "Buy spin", + "wheel.winners": "Latest winners", + "wheel.winners.empty": "No winners yet" +} diff --git a/public/configuration/wheel-texts-it.example b/public/configuration/wheel-texts-it.example new file mode 100644 index 0000000..f5bb934 --- /dev/null +++ b/public/configuration/wheel-texts-it.example @@ -0,0 +1,9 @@ +{ + "wheel.title": "Ruota della Fortuna", + "wheel.free.today": "Hai %count% giri gratis oggi!", + "wheel.extra": "Giri extra: %count%", + "wheel.spin": "GIRA", + "wheel.buy": "Compra giro", + "wheel.winners": "Ultimi vincitori", + "wheel.winners.empty": "Ancora nessun vincitore" +} diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index 071abcb..494e948 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -24,6 +24,8 @@ import { HcCenterView } from './hc-center/HcCenterView'; import { HelpView } from './help/HelpView'; import { HotelView } from './hotel-view/HotelView'; import { HousekeepingView } from './housekeeping/HousekeepingView'; +import { RareValuesView } from './rare-values/RareValuesView'; +import { FortuneWheelView } from './fortune-wheel/FortuneWheelView'; import { InventoryView } from './inventory/InventoryView'; import { ModToolsView } from './mod-tools/ModToolsView'; import { NavigatorView } from './navigator/NavigatorView'; @@ -176,6 +178,8 @@ export const MainView: FC<{}> = props => + + ); diff --git a/src/components/fortune-wheel/FortuneWheelView.tsx b/src/components/fortune-wheel/FortuneWheelView.tsx new file mode 100644 index 0000000..a66af12 --- /dev/null +++ b/src/components/fortune-wheel/FortuneWheelView.tsx @@ -0,0 +1,191 @@ +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 } from '../../hooks'; +import { NitroCard } from '../../layout'; + +// 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 { freeSpins, extraSpins, spinCost, spinCostType, prizes, recentWins, pendingPrizeId, isSpinning, open, spin, buySpin, finishSpin } = useFortuneWheel(); + 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() ]) } + + + + + + + { LocalizeText('wheel.winners') } + + { recentWins.map((win, i) => ( + +
+ +
+ + { win.username } + { win.prizeLabel } + +
+ )) } + { !recentWins.length && + { LocalizeText('wheel.winners.empty') } } +
+
+ + + + ); +}; diff --git a/src/components/rare-values/RareValuesView.tsx b/src/components/rare-values/RareValuesView.tsx new file mode 100644 index 0000000..5cb4ce7 --- /dev/null +++ b/src/components/rare-values/RareValuesView.tsx @@ -0,0 +1,234 @@ +import { AddLinkEventTracker, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, IRareValue, IWheelAdminPrize, IWheelAdminPrizeEdit, 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 { useFortuneWheel, useHasPermission, useRareValues } from '../../hooks'; +import { NitroCard, NitroInput } from '../../layout'; + +interface RareValueRow +{ + spriteId: number; + name: string; + iconUrl: string; + value: IRareValue; +} + +interface EditRow +{ + id: number; + category: string; + num: number; + weight: number; + label: string; +} + +const CATEGORIES: { key: string; label: string }[] = [ + { key: 'item', label: 'Raro (ID)' }, + { key: 'diamanti', label: 'Diamanti' }, + { key: 'duckets', label: 'Duckets' }, + { key: 'crediti', label: 'Crediti' }, + { key: 'giri', label: 'Giri extra' }, + { key: 'nulla', label: 'Nulla' } +]; + +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 }; + } +}; + +export const RareValuesView: FC<{}> = () => +{ + const [ isVisible, setIsVisible ] = useState(false); + const [ tab, setTab ] = useState<'values' | 'editor'>('values'); + const [ searchValue, setSearchValue ] = useState(''); + const { values = null, loaded = false } = useRareValues(); + const { adminPrizes = [], loadAdminPrizes = null, saveAdminPrizes = null } = useFortuneWheel(); + const canEdit = useHasPermission('acc_supporttool'); + const [ editRows, setEditRows ] = useState([]); + + 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); + }, []); + + useEffect(() => + { + if(isVisible && (tab === 'editor') && canEdit && loadAdminPrizes) loadAdminPrizes(); + }, [ isVisible, tab, canEdit, loadAdminPrizes ]); + + useEffect(() => + { + setEditRows(adminPrizes.map(prize => ({ id: prize.id, category: prizeToCategory(prize), num: prizeToNum(prize), weight: prize.weight, label: prize.label }))); + }, [ adminPrizes ]); + + 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; + + const updateRow = (id: number, patch: Partial) => + setEditRows(prev => prev.map(row => (row.id === id) ? { ...row, ...patch } : row)); + + return ( + + setIsVisible(false) } /> + { canEdit && + + setTab('values') }> + { LocalizeText('rarevalues.title') } + + setTab('editor') }> + { LocalizeText('rarevalues.editor.tab') } + + } + + { (tab === 'values' || !canEdit) && + + setSearchValue(event.target.value) } /> + + { !loaded && + { LocalizeText('rarevalues.loading') } } + { (loaded && !filtered.length) && + { LocalizeText('rarevalues.empty') } } + { filtered.map(row => ( + + + { row.name } + + { LocalizeFormattedNumber(row.value.points) } + + + + )) } + + } + + { (tab === 'editor' && canEdit) && + + + { 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]" /> + + )) } + + + } + + + ); +}; diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx index da4db6c..d2a861c 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx @@ -3,8 +3,8 @@ import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { FaCrosshairs, FaTimes } from 'react-icons/fa'; import { GrFormNextLink, GrRotateLeft, GrRotateRight } from 'react-icons/gr'; import { AvatarInfoFurni, GetGroupInformation, LocalizeText, SendMessageComposer } from '../../../../../api'; -import { Button, Column, Flex, LayoutBadgeImageView, LayoutLimitedEditionCompactPlateView, LayoutRarityLevelView, LayoutRoomObjectImageView, Text, UserProfileIconView } from '../../../../../common'; -import { useHasPermission, useMessageEvent, useNitroEvent, useRoom, useWiredTools } from '../../../../../hooks'; +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'; interface InfoStandWidgetFurniViewProps @@ -23,6 +23,8 @@ export const InfoStandWidgetFurniView: FC = props const { roomSession = null } = useRoom(); const { openInspectionForFurni, showInspectButton } = useWiredTools(); const isModerator = useHasPermission('acc_anyroomowner'); + const { getValue: getRareValue } = useRareValues(); + const rareValue = useMemo(() => (avatarInfo ? getRareValue(avatarInfo.spriteId) : null), [ avatarInfo, getRareValue ]); const [ pickupMode, setPickupMode ] = useState(0); const [ canMove, setCanMove ] = useState(false); @@ -563,6 +565,17 @@ export const InfoStandWidgetFurniView: FC = props X: { itemLocation.x } · Y: { itemLocation.y } · H: { itemLocation.z < 0.01 ? 0 : itemLocation.z }
} + { (rareValue && rareValue.points > 0) && + <> +
+ + { LocalizeText('rarevalues.infostand.label') } + + { rareValue.points } + + + + } { godMode && <>
diff --git a/src/components/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index 6bcc9a3..e088a62 100644 --- a/src/components/toolbar/ToolbarView.tsx +++ b/src/components/toolbar/ToolbarView.tsx @@ -250,6 +250,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => { (getFullCount > 0) && } + + CreateLinkEvent('rare-values/toggle') } className="tb-icon" /> + + + CreateLinkEvent('fortune-wheel/toggle') } className="tb-icon" /> + { (isInRoom && showToolbarButton) && @@ -358,6 +364,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => { (getFullCount > 0) && } + + CreateLinkEvent('rare-values/toggle') } className="tb-icon" /> + + + CreateLinkEvent('fortune-wheel/toggle') } className="tb-icon" /> + +{ + const [ freeSpins, setFreeSpins ] = useState(0); + const [ extraSpins, setExtraSpins ] = useState(0); + const [ spinCost, setSpinCost ] = useState(0); + const [ spinCostType, setSpinCostType ] = useState(-1); + const [ prizes, setPrizes ] = useState([]); + const [ recentWins, setRecentWins ] = useState([]); + const [ pendingPrizeId, setPendingPrizeId ] = useState(-1); + const [ isSpinning, setIsSpinning ] = useState(false); + const [ adminPrizes, setAdminPrizes ] = useState([]); + + useMessageEvent(WheelAdminPrizesEvent, event => + { + setAdminPrizes(event.getParser().prizes); + }); + + useMessageEvent(WheelDataEvent, event => + { + const parser = event.getParser(); + setFreeSpins(parser.freeSpins); + setExtraSpins(parser.extraSpins); + setSpinCost(parser.spinCost); + setSpinCostType(parser.spinCostType); + setPrizes(parser.prizes); + }); + + useMessageEvent(WheelResultEvent, event => + { + setPendingPrizeId(event.getParser().prizeId); + setIsSpinning(true); + }); + + useMessageEvent(WheelRecentWinsEvent, event => + { + setRecentWins(event.getParser().wins); + }); + + const open = useCallback(() => SendMessageComposer(new WheelOpenComposer()), []); + const spin = useCallback(() => + { + setIsSpinning(prev => + { + if(!prev) SendMessageComposer(new WheelSpinComposer()); + return prev; + }); + }, []); + const buySpin = useCallback(() => SendMessageComposer(new WheelBuySpinComposer()), []); + const finishSpin = useCallback(() => + { + setIsSpinning(false); + setPendingPrizeId(-1); + }, []); + + const loadAdminPrizes = useCallback(() => SendMessageComposer(new WheelAdminGetPrizesComposer()), []); + const saveAdminPrizes = useCallback((prizes: IWheelAdminPrizeEdit[]) => SendMessageComposer(new WheelAdminSavePrizesComposer(prizes)), []); + + return { freeSpins, extraSpins, spinCost, spinCostType, prizes, recentWins, pendingPrizeId, isSpinning, open, spin, buySpin, finishSpin, adminPrizes, loadAdminPrizes, saveAdminPrizes }; +}; + +export const useFortuneWheel = () => useBetween(useFortuneWheelState); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index a369b37..4b3c1c3 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,6 +4,7 @@ export * from './camera'; export * from './catalog'; export * from './chat-history'; export * from './events'; +export * from './fortune-wheel/useFortuneWheel'; export * from './friends'; export * from './game-center'; export * from './groups'; @@ -14,6 +15,7 @@ export * from './mod-tools'; export * from './navigator'; export * from './notification'; export * from './purse'; +export * from './rare-values/useRareValues'; export * from './rooms'; export * from './rooms/engine'; export * from './rooms/promotes'; diff --git a/src/hooks/rare-values/useRareValues.ts b/src/hooks/rare-values/useRareValues.ts new file mode 100644 index 0000000..d048db1 --- /dev/null +++ b/src/hooks/rare-values/useRareValues.ts @@ -0,0 +1,31 @@ +import { IRareValue, RareValuesEvent, RequestRareValuesComposer } from '@nitrots/nitro-renderer'; +import { useCallback, useEffect, useState } from 'react'; +import { useBetween } from 'use-between'; +import { SendMessageComposer } from '../../api'; +import { useMessageEvent } from '../events'; + +// spriteId -> catalog value, fetched once from the server (RareValuesComposer). +// Shared across all consumers via useBetween so the request fires a single time. +// Read by both the furni infostand and the toolbar "Valore Rari" panel. +const useRareValuesState = () => +{ + const [ values, setValues ] = useState>(() => new Map()); + const [ loaded, setLoaded ] = useState(false); + + useMessageEvent(RareValuesEvent, event => + { + setValues(event.getParser().values); + setLoaded(true); + }); + + useEffect(() => + { + SendMessageComposer(new RequestRareValuesComposer()); + }, []); + + const getValue = useCallback((spriteId: number): IRareValue => (values.get(spriteId) ?? null), [ values ]); + + return { values, loaded, getValue }; +}; + +export const useRareValues = () => useBetween(useRareValuesState); From 7a65e5bf6dbd936b98d1c4de56e12a6c1c6c1d81 Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 28 May 2026 09:04:17 +0200 Subject: [PATCH 07/15] feat: soundboard (room-scoped custom audio pads) Client side of the soundboard. Room owners enable it in Room Settings > Misc (next to the YouTube TV toggle). When enabled, a soundboard icon appears in the toolbar for everyone in the room; pressing a pad broadcasts the sound so all occupants hear it. Incoming SoundboardPlay is played via the HTML5 Audio API. Also: fix FloorplanCanvasSVG to use ReactElement instead of the removed global JSX namespace (React 19), and pair the client Dev branch with the renderer fork that carries the custom features in CI. How sounds are managed (works with any CMS): Sounds are rows in the `soundboard_sounds` table: id, name, url, enabled, sort_order The emulator loads every row with enabled=1 (ordered by sort_order, id) and sends the list to clients on room enter; the client plays `url` directly, so any publicly reachable audio URL works (mp3/ogg/wav). To add a sound from an admin/housekeeping panel of any CMS: 1. Upload the audio file to wherever the CMS stores public assets (same approach as custom badge images). 2. INSERT a row into `soundboard_sounds` with the display name and the public URL of the uploaded file, enabled = 1. 3. Reload the emulator soundboard (or restart) to pick it up. Relative urls resolve against the `soundboard.url.prefix` config key (falls back to `asset.url`); absolute urls are used as-is. --- .github/workflows/ci.yml | 8 ++ public/configuration/wheel-texts-en.example | 6 +- public/configuration/wheel-texts-it.example | 6 +- src/api/index.ts | 1 + src/api/soundboard/SoundboardRoomState.ts | 7 ++ src/api/soundboard/index.ts | 1 + src/components/MainView.tsx | 2 + .../views/FloorplanCanvasSVG.tsx | 4 +- .../NavigatorRoomSettingsMiscTabView.tsx | 28 ++++++- src/components/soundboard/SoundboardView.tsx | 74 +++++++++++++++++++ src/components/toolbar/ToolbarView.tsx | 14 +++- src/css/icons/icons.css | 7 ++ src/hooks/index.ts | 1 + src/hooks/soundboard/useSoundboard.ts | 73 ++++++++++++++++++ 14 files changed, 225 insertions(+), 7 deletions(-) create mode 100644 src/api/soundboard/SoundboardRoomState.ts create mode 100644 src/api/soundboard/index.ts create mode 100644 src/components/soundboard/SoundboardView.tsx create mode 100644 src/hooks/soundboard/useSoundboard.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6acb525..2760f66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - Dev - 'feat/**' pull_request: workflow_dispatch: @@ -93,6 +94,13 @@ jobs: AUTO_REPO="duckietm/Nitro_Render_V3" AUTO_REF="main" ;; + Dev) + # The client `Dev` branch carries the custom features + # (rare values, fortune wheel, soundboard); they live on + # the matching renderer fork branch, not upstream. + AUTO_REPO="medievalshell/Nitro_Render_V3" + AUTO_REF="dev" + ;; feat/housekeeping-panel) AUTO_REPO="simoleo89/Nitro_Render_V3" AUTO_REF="feat/housekeeping-packets" diff --git a/public/configuration/wheel-texts-en.example b/public/configuration/wheel-texts-en.example index 71b3b1b..f10f302 100644 --- a/public/configuration/wheel-texts-en.example +++ b/public/configuration/wheel-texts-en.example @@ -5,5 +5,9 @@ "wheel.spin": "SPIN", "wheel.buy": "Buy spin", "wheel.winners": "Latest winners", - "wheel.winners.empty": "No winners yet" + "wheel.winners.empty": "No winners yet", + "soundboard.title": "Soundboard", + "soundboard.empty": "No sounds available", + "soundboard.lastplayed": "Played by %user%", + "soundboard.room.setting.desc": "Let people in this room play sound effects" } diff --git a/public/configuration/wheel-texts-it.example b/public/configuration/wheel-texts-it.example index f5bb934..0cd342e 100644 --- a/public/configuration/wheel-texts-it.example +++ b/public/configuration/wheel-texts-it.example @@ -5,5 +5,9 @@ "wheel.spin": "GIRA", "wheel.buy": "Compra giro", "wheel.winners": "Ultimi vincitori", - "wheel.winners.empty": "Ancora nessun vincitore" + "wheel.winners.empty": "Ancora nessun vincitore", + "soundboard.title": "Soundboard", + "soundboard.empty": "Nessun suono disponibile", + "soundboard.lastplayed": "Suonato da %user%", + "soundboard.room.setting.desc": "Permetti ai presenti di suonare effetti audio in questa stanza" } diff --git a/src/api/index.ts b/src/api/index.ts index 6108472..424bb4f 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -27,6 +27,7 @@ export * from './purse'; export * from './room'; export * from './room/events'; export * from './room/widgets'; +export * from './soundboard'; export * from './ui-settings'; export * from './user'; export * from './utils'; diff --git a/src/api/soundboard/SoundboardRoomState.ts b/src/api/soundboard/SoundboardRoomState.ts new file mode 100644 index 0000000..cded2a1 --- /dev/null +++ b/src/api/soundboard/SoundboardRoomState.ts @@ -0,0 +1,7 @@ +let _soundboardEnabled = false; + +export const getSoundboardRoomEnabled = () => _soundboardEnabled; +export const setSoundboardRoomEnabled = (enabled: boolean) => +{ + _soundboardEnabled = enabled; +}; diff --git a/src/api/soundboard/index.ts b/src/api/soundboard/index.ts new file mode 100644 index 0000000..2ccb6a5 --- /dev/null +++ b/src/api/soundboard/index.ts @@ -0,0 +1 @@ +export * from './SoundboardRoomState'; diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index 494e948..39ae1b8 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -26,6 +26,7 @@ import { HotelView } from './hotel-view/HotelView'; import { HousekeepingView } from './housekeeping/HousekeepingView'; import { RareValuesView } from './rare-values/RareValuesView'; import { FortuneWheelView } from './fortune-wheel/FortuneWheelView'; +import { SoundboardView } from './soundboard/SoundboardView'; import { InventoryView } from './inventory/InventoryView'; import { ModToolsView } from './mod-tools/ModToolsView'; import { NavigatorView } from './navigator/NavigatorView'; @@ -180,6 +181,7 @@ export const MainView: FC<{}> = props => + ); diff --git a/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx b/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx index 55e9c60..3951ecd 100644 --- a/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx +++ b/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx @@ -1,4 +1,4 @@ -import { Dispatch, FC, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState, WheelEvent } from 'react'; +import { Dispatch, FC, PointerEvent as ReactPointerEvent, ReactElement, useCallback, useEffect, useMemo, useRef, useState, WheelEvent } from 'react'; import { FaCrosshairs, FaSearchMinus, FaSearchPlus, FaSyncAlt } from 'react-icons/fa'; import { FloorplanAction, FloorplanState } from '../state/types'; import { FloorplanTile } from './FloorplanTile'; @@ -140,7 +140,7 @@ export const FloorplanCanvasSVG: FC = ({ state, dispatch, panMode }) => const quarter = TILE_SIZE / 4; const tilesRows = state.tiles.length; const tilesCols = state.tiles[0]?.length ?? 0; - const out: JSX.Element[] = []; + const out: ReactElement[] = []; for(const key of state.selection) { const [ rStr, cStr ] = key.split(','); diff --git a/src/components/navigator/views/room-settings/NavigatorRoomSettingsMiscTabView.tsx b/src/components/navigator/views/room-settings/NavigatorRoomSettingsMiscTabView.tsx index 5d151d3..d82e0ba 100644 --- a/src/components/navigator/views/room-settings/NavigatorRoomSettingsMiscTabView.tsx +++ b/src/components/navigator/views/room-settings/NavigatorRoomSettingsMiscTabView.tsx @@ -1,7 +1,7 @@ import { YouTubeRoomSettingsComposer, YouTubeRoomSettingsEvent } from '@nitrots/nitro-renderer'; import { FC, useState } from 'react'; import { getYoutubeRoomEnabled, IRoomData, LocalizeText, SendMessageComposer, setYoutubeRoomEnabled } from '../../../../api'; -import { useMessageEvent } from '../../../../hooks'; +import { useMessageEvent, useSoundboard } from '../../../../hooks'; interface NavigatorRoomSettingsMiscTabViewProps { @@ -13,6 +13,7 @@ export const NavigatorRoomSettingsMiscTabView: FC(YouTubeRoomSettingsEvent, event => { @@ -29,6 +30,14 @@ export const NavigatorRoomSettingsMiscTabView: FC setCooldown(false), 300); }; + const toggleSoundboard = (enabled: boolean) => + { + if (cooldown) return; + setSoundboardEnabled(enabled); + setCooldown(true); + setTimeout(() => setCooldown(false), 300); + }; + return ( <>
@@ -52,6 +61,23 @@ export const NavigatorRoomSettingsMiscTabView: FC
+
+
+
+
🔊 { LocalizeText('soundboard.title') }
+
{ LocalizeText('soundboard.room.setting.desc') }
+
+ +
+
); diff --git a/src/components/soundboard/SoundboardView.tsx b/src/components/soundboard/SoundboardView.tsx new file mode 100644 index 0000000..6508d78 --- /dev/null +++ b/src/components/soundboard/SoundboardView.tsx @@ -0,0 +1,74 @@ +import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { FC, useEffect, useState } from 'react'; +import { LocalizeText } from '../../api'; +import { Column, Flex, Text } from '../../common'; +import { useSoundboard } from '../../hooks'; +import { NitroCard } from '../../layout'; + +export const SoundboardView: FC<{}> = () => +{ + const [ isVisible, setIsVisible ] = useState(false); + const { enabled, sounds, lastPlayed, play } = useSoundboard(); + + 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: 'soundboard/', + }; + + AddLinkEventTracker(linkTracker); + + return () => RemoveLinkEventTracker(linkTracker); + }, []); + + // The soundboard belongs to the room — close it when the room turns it off. + useEffect(() => + { + if(!enabled) setIsVisible(false); + }, [ enabled ]); + + if(!isVisible || !enabled) return null; + + return ( + + setIsVisible(false) } /> + + + { !sounds.length && + { LocalizeText('soundboard.empty') } } + { !!sounds.length && +
+ { sounds.map(sound => ( + + )) } +
} + { lastPlayed && + + + { LocalizeText('soundboard.lastplayed', [ 'user' ], [ lastPlayed.username ]) } + + } +
+
+
+ ); +}; diff --git a/src/components/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index e088a62..adf3285 100644 --- a/src/components/toolbar/ToolbarView.tsx +++ b/src/components/toolbar/ToolbarView.tsx @@ -3,7 +3,7 @@ import { AnimatePresence, motion, Variants } from 'framer-motion'; import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { GetConfigurationValue, isHousekeepingEnabled, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, VisitDesktop } from '../../api'; import { Flex, LayoutAvatarImageView, LayoutItemCountView } from '../../common'; -import { useAchievements, useFriends, useHasPermission, useInventoryUnseenTracker, useMessageEvent, useMessenger, useModTools, useNitroEvent, useSessionInfo, useWiredTools } from '../../hooks'; +import { useAchievements, useFriends, useHasPermission, useInventoryUnseenTracker, useMessageEvent, useMessenger, useModTools, useNitroEvent, useSessionInfo, useSoundboard, useWiredTools } from '../../hooks'; import { ToolbarItemView } from './ToolbarItemView'; import { ToolbarMeView } from './ToolbarMeView'; import { YouTubePlayerView } from './YouTubePlayerView'; @@ -42,6 +42,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => const { requests = [] } = useFriends(); const { iconState = MessengerIconState.HIDDEN } = useMessenger(); const { openMonitor, showToolbarButton } = useWiredTools(); + const { enabled: soundboardEnabled, reset: resetSoundboard } = useSoundboard(); const isMod = useHasPermission('acc_supporttool'); const isHk = useHasPermission('acc_housekeeping'); const hkEnabled = useMemo(() => isHousekeepingEnabled(), []); @@ -99,8 +100,9 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => { setYoutubeEnabled(false); setYoutubeRoomEnabled(false); + resetSoundboard(); } - }, [ isInRoom ]); + }, [ isInRoom, resetSoundboard ]); useEffect(() => { @@ -268,6 +270,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => } + { (isInRoom && soundboardEnabled) && + + CreateLinkEvent('soundboard/toggle') } className="tb-icon" /> + } { isMod && CreateLinkEvent('mod-tools/toggle') } className="tb-icon" /> @@ -386,6 +392,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => } + { (isInRoom && soundboardEnabled) && + + CreateLinkEvent('soundboard/toggle') } className="tb-icon" /> + } { isMod && CreateLinkEvent('mod-tools/toggle') } className="tb-icon" /> diff --git a/src/css/icons/icons.css b/src/css/icons/icons.css index 0a81781..17a903f 100644 --- a/src/css/icons/icons.css +++ b/src/css/icons/icons.css @@ -216,6 +216,13 @@ height: 36px; } +.nitro-icon.icon-soundboard { + background-image: url("@/assets/images/toolbar/icons/game.png"); + width: 44px; + height: 25px; + filter: hue-rotate(90deg) saturate(1.5); +} + .nitro-icon.icon-message { background-image: url("@/assets/images/toolbar/icons/message.png"); width: 36px; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 4b3c1c3..dc3d9a6 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -22,6 +22,7 @@ export * from './rooms/promotes'; export * from './rooms/widgets'; export * from './rooms/widgets/furniture'; export * from './session'; +export * from './soundboard/useSoundboard'; export * from './translation'; export * from './useLocalStorage'; export * from './useSharedVisibility'; diff --git a/src/hooks/soundboard/useSoundboard.ts b/src/hooks/soundboard/useSoundboard.ts new file mode 100644 index 0000000..d436962 --- /dev/null +++ b/src/hooks/soundboard/useSoundboard.ts @@ -0,0 +1,73 @@ +import { ISoundboardSound, SoundboardPlayComposer, SoundboardPlayEvent, SoundboardSetEnabledComposer, SoundboardSettingsEvent } from '@nitrots/nitro-renderer'; +import { useCallback, useState } from 'react'; +import { useBetween } from 'use-between'; +import { GetConfigurationValue, SendMessageComposer, setSoundboardRoomEnabled } from '../../api'; +import { useMessageEvent } from '../events'; + +// Resolve a stored sound url (which may be relative, like custom badges) to an +// absolute one against the asset host. +const resolveUrl = (url: string): string => +{ + if(!url) return ''; + if(/^https?:\/\//i.test(url) || url.startsWith('//')) return url; + + const base = (GetConfigurationValue('soundboard.url.prefix') || GetConfigurationValue('asset.url') || '').replace(/\/+$/, ''); + return base ? `${ base }/${ url.replace(/^\/+/, '') }` : url; +}; + +// Soundboard state + actions. Shared via useBetween so the event listeners +// register once regardless of how many components read it (toolbar + view). +const useSoundboardState = () => +{ + const [ enabled, setEnabled ] = useState(false); + const [ sounds, setSounds ] = useState([]); + const [ lastPlayed, setLastPlayed ] = useState<{ soundId: number; username: string } | null>(null); + + useMessageEvent(SoundboardSettingsEvent, event => + { + const parser = event.getParser(); + setEnabled(parser.enabled); + setSounds(parser.sounds); + setSoundboardRoomEnabled(parser.enabled); + }); + + useMessageEvent(SoundboardPlayEvent, event => + { + const parser = event.getParser(); + const url = resolveUrl(parser.url); + + if(url) + { + try + { + const audio = new Audio(url); + audio.volume = 0.8; + void audio.play().catch(() => {}); + } + catch {} + } + + setLastPlayed({ soundId: parser.soundId, username: parser.username }); + }); + + const play = useCallback((soundId: number) => SendMessageComposer(new SoundboardPlayComposer(soundId)), []); + const setRoomEnabled = useCallback((value: boolean) => + { + setEnabled(value); + setSoundboardRoomEnabled(value); + SendMessageComposer(new SoundboardSetEnabledComposer(value)); + }, []); + + // Local-only clear (e.g. when leaving the room) — does not notify the server. + const reset = useCallback(() => + { + setEnabled(false); + setSounds([]); + setLastPlayed(null); + setSoundboardRoomEnabled(false); + }, []); + + return { enabled, sounds, lastPlayed, play, setRoomEnabled, reset }; +}; + +export const useSoundboard = () => useBetween(useSoundboardState); From 48ed3ad7ba56ae5e6c062b0d103c805374344f40 Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 28 May 2026 09:20:20 +0200 Subject: [PATCH 08/15] fix: show furniture-occupied tiles in the floor plan editor The editor never requested occupied tiles, so tiles holding furniture were indistinguishable from empty floor and could be edited/voided. - request GetOccupiedTilesMessageComposer when the editor opens - handle RoomOccupiedTilesMessageEvent -> SET_OCCUPIED_TILES - new Tile.occupied flag (kept separate from `blocked`/void): occupied tiles render with a distinct marker and are protected from PAINT/ ERASE/ADJUST and brush-to-selection edits - occupied is purely informational and never changes the saved tilemap (no accidental voiding of floor under furni) Tests: reducer cases for SET_OCCUPIED_TILES + edit protection; container test asserts the occupied event is non-destructive on save; route the canvas pointer test through elementFromPoint (jsdom has no getScreenCTM). --- .../FloorplanEditorView.test.tsx | 7 +++-- .../floorplan-editor/FloorplanEditorView.tsx | 10 ++++++- .../floorplan-editor/state/reducer.test.ts | 30 +++++++++++++++++++ .../floorplan-editor/state/reducer.ts | 21 ++++++++++++- .../floorplan-editor/state/types.ts | 6 +++- .../views/FloorplanCanvasSVG.test.tsx | 9 +++++- .../floorplan-editor/views/FloorplanTile.tsx | 11 +++++++ 7 files changed, 87 insertions(+), 7 deletions(-) diff --git a/src/components/floorplan-editor/FloorplanEditorView.test.tsx b/src/components/floorplan-editor/FloorplanEditorView.test.tsx index 5603025..a665fa3 100644 --- a/src/components/floorplan-editor/FloorplanEditorView.test.tsx +++ b/src/components/floorplan-editor/FloorplanEditorView.test.tsx @@ -161,7 +161,7 @@ describe('FloorplanEditorView container', () => expect(composer.thicknessFloor).toBe(1); }); - it('RoomOccupiedTilesMessageEvent marks blockedTilesMap entries as blocked in state', () => + it('RoomOccupiedTilesMessageEvent marks tiles occupied without altering the saved tilemap', () => { openEditor(); const fhmHandler = messageHandlers.get(FloorHeightMapEvent); @@ -178,8 +178,9 @@ describe('FloorplanEditorView container', () => fireEvent.click(saveBtn!); const composer = sendMessageComposer.mock.calls[0][0]; expect(composer).toBeInstanceOf(UpdateFloorPropertiesMessageComposer); - // Row separator is \r per serializeTilemap; row 0 was '00', col 1 blocked → '0x' - expect(composer.tilemap.split(/\r/)[0]).toBe('0x'); + // Occupied is purely informational: the tile stays walkable and the + // saved tilemap is unchanged (row 0 stays '00', NOT voided to '0x'). + expect(composer.tilemap.split(/\r/)[0]).toBe('00'); }); it('RoomEngineEvent.DISPOSED hides the editor', () => diff --git a/src/components/floorplan-editor/FloorplanEditorView.tsx b/src/components/floorplan-editor/FloorplanEditorView.tsx index 8094d1b..b8c52be 100644 --- a/src/components/floorplan-editor/FloorplanEditorView.tsx +++ b/src/components/floorplan-editor/FloorplanEditorView.tsx @@ -1,4 +1,4 @@ -import { AddLinkEventTracker, convertNumbersForSaving, convertSettingToNumber, FloorHeightMapEvent, GetRoomEntryTileMessageComposer, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomEntryTileMessageEvent, RoomVisualizationSettingsEvent, UpdateFloorPropertiesMessageComposer } from '@nitrots/nitro-renderer'; +import { AddLinkEventTracker, convertNumbersForSaving, convertSettingToNumber, FloorHeightMapEvent, GetOccupiedTilesMessageComposer, GetRoomEntryTileMessageComposer, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomEntryTileMessageEvent, RoomOccupiedTilesMessageEvent, RoomVisualizationSettingsEvent, UpdateFloorPropertiesMessageComposer } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useRef, useState } from 'react'; import { FaBolt, FaBoxOpen, FaCaretLeft, FaCaretRight } from 'react-icons/fa'; import { LocalizeText, SendMessageComposer } from '../../api'; @@ -50,8 +50,16 @@ export const FloorplanEditorView: FC = () => { if(!isVisible) return; SendMessageComposer(new GetRoomEntryTileMessageComposer()); + // Ask the server which tiles currently hold furniture so they can be + // shown (and protected from editing) in the grid. + SendMessageComposer(new GetOccupiedTilesMessageComposer()); }, [ isVisible ]); + useMessageEvent(RoomOccupiedTilesMessageEvent, event => + { + dispatch({ type: 'SET_OCCUPIED_TILES', map: event.getParser().blockedTilesMap }); + }); + useMessageEvent(RoomEntryTileMessageEvent, event => { const parser = event.getParser(); diff --git a/src/components/floorplan-editor/state/reducer.test.ts b/src/components/floorplan-editor/state/reducer.test.ts index 689e9ad..1fb84f8 100644 --- a/src/components/floorplan-editor/state/reducer.test.ts +++ b/src/components/floorplan-editor/state/reducer.test.ts @@ -106,6 +106,36 @@ describe('reducer — ADJUST_HEIGHT', () => const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: 1, source: 'local' }); expect(next).toBe(start); }); + + it('is a no-op on occupied tiles', () => + { + const start = stateWith([[{ h: 5, blocked: false, occupied: true }]]); + const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: 1, source: 'local' }); + expect(next).toBe(start); + }); +}); + +describe('reducer — SET_OCCUPIED_TILES', () => +{ + it('marks tiles occupied per the map without touching h or blocked', () => + { + const start = stateWith([[{ h: 2, blocked: false }, { h: 0, blocked: true }]]); + const next = reducer(start, { type: 'SET_OCCUPIED_TILES', map: [[true, false]] }); + expect(next.tiles[0][0]).toEqual({ h: 2, blocked: false, occupied: true }); + // already-unoccupied tile is left untouched (no spurious occupied key) + expect(next.tiles[0][1]).toEqual({ h: 0, blocked: true }); + }); + + it('does not block editing of non-occupied tiles', () => + { + const start = stateWith([[{ h: 0, blocked: false }, { h: 0, blocked: false }]]); + const occupied = reducer(start, { type: 'SET_OCCUPIED_TILES', map: [[false, true]] }); + // col 0 (not occupied) can still be painted; col 1 (occupied) cannot + const painted = reducer(occupied, { type: 'PAINT_TILE', row: 0, col: 0, h: 5, source: 'local' }); + expect(painted.tiles[0][0].h).toBe(5); + const blocked = reducer(occupied, { type: 'PAINT_TILE', row: 0, col: 1, h: 9, source: 'local' }); + expect(blocked).toBe(occupied); + }); }); describe('reducer — SET_DOOR', () => diff --git a/src/components/floorplan-editor/state/reducer.ts b/src/components/floorplan-editor/state/reducer.ts index 876afcb..c6d5840 100644 --- a/src/components/floorplan-editor/state/reducer.ts +++ b/src/components/floorplan-editor/state/reducer.ts @@ -52,6 +52,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl { const row = clamp64(action.row); const col = clamp64(action.col); + if(state.tiles[row]?.[col]?.occupied) return state; const tiles = ensureRect(state.tiles, row + 1, col + 1); const target = { h: clampHeight(action.h), blocked: false }; const next = setTile(tiles, row, col, target); @@ -64,6 +65,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl const col = action.col | 0; if(row < 0 || col < 0 || row >= state.tiles.length || col >= (state.tiles[0]?.length ?? 0)) return state; const current = state.tiles[row][col]; + if(current.occupied) return state; const target = { h: current.h, blocked: true }; const next = setTile(state.tiles, row, col, target); if(next === state.tiles) return state; @@ -75,7 +77,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl const col = action.col | 0; if(row < 0 || col < 0 || row >= state.tiles.length || col >= (state.tiles[0]?.length ?? 0)) return state; const current = state.tiles[row][col]; - if(current.blocked) return state; + if(current.blocked || current.occupied) return state; const newH = clampHeight(current.h + action.delta); if(newH === current.h) return state; const next = setTile(state.tiles, row, col, { h: newH, blocked: false }); @@ -106,6 +108,22 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl if(value === state.wallHeight) return state; return { ...state, wallHeight: value }; } + case 'SET_OCCUPIED_TILES': + { + // Mark tiles that currently hold furniture (server-reported). Leaves + // height + blocked untouched so it never alters the saved tilemap. + const map = action.map ?? []; + let changed = false; + const tiles = state.tiles.map((r, ri) => r.map((tile, ci) => + { + const occ = !!map[ri]?.[ci]; + if((tile.occupied ?? false) === occ) return tile; + changed = true; + return { ...tile, occupied: occ }; + })); + if(!changed) return state; + return { ...state, tiles }; + } case 'BRUSH_SET': { const h = action.h ?? state.brush.h; @@ -174,6 +192,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl const col = parseInt(cStr, 10); const current = tiles[row]?.[col]; if(!current) continue; + if(current.occupied) continue; switch(state.brush.action) { diff --git a/src/components/floorplan-editor/state/types.ts b/src/components/floorplan-editor/state/types.ts index 1361e38..fb30bd8 100644 --- a/src/components/floorplan-editor/state/types.ts +++ b/src/components/floorplan-editor/state/types.ts @@ -1,4 +1,7 @@ -export type Tile = { h: number; blocked: boolean }; +// `blocked` = void tile (no floor, serialized as 'x'). `occupied` = a tile that +// currently has furniture on it (reported by the server); kept separate so it +// stays visible and is NOT voided on save — it just can't be edited. +export type Tile = { h: number; blocked: boolean; occupied?: boolean }; export type EntryDir = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; export type ThicknessLevel = 0 | 1 | 2 | 3; @@ -39,6 +42,7 @@ export type FloorplanAction = | { type: 'SET_DOOR_DIR'; dir: EntryDir; source: LocalSource } | { type: 'SET_THICKNESS'; wall?: ThicknessLevel; floor?: ThicknessLevel; source: LocalSource } | { type: 'SET_WALL_HEIGHT'; value: number; source: LocalSource } + | { type: 'SET_OCCUPIED_TILES'; map: boolean[][] } | { type: 'BRUSH_SET'; h?: number; action?: FloorActionMode } | { type: 'SELECT_RECT'; from: [number, number]; to: [number, number] } | { type: 'SELECT_ALL' } diff --git a/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx b/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx index bb61ab5..a275776 100644 --- a/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx +++ b/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx @@ -40,11 +40,18 @@ describe('FloorplanCanvasSVG', () => const dispatch = vi.fn(); const { container } = render(); const svg = container.querySelector('svg') as SVGSVGElement; - svg.getBoundingClientRect = () => ({ left: 0, top: 0, right: 2048, bottom: 1024, width: 2048, height: 1024, x: 0, y: 0, toJSON: () => ({}) }); + // usePointerToTile resolves the tile via document.elementFromPoint first + // (the tile polygons carry data-row/data-col). jsdom returns null and has + // no SVGSVGElement.getScreenCTM, so point the hit-test at the tile polygon. + const tilePoly = container.querySelector('polygon[data-row="0"][data-col="0"]') as Element; + // jsdom's document has no elementFromPoint at all — define it for this test. + const prevEfp = (document as { elementFromPoint?: unknown }).elementFromPoint; + (document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }).elementFromPoint = () => tilePoly; fireEvent.pointerDown(svg, { clientX: 1024, clientY: 0, pointerId: 1 }); expect(dispatch).toHaveBeenCalled(); const call = dispatch.mock.calls[0][0]; expect(call.type).toBe('PAINT_TILE'); + (document as { elementFromPoint?: unknown }).elementFromPoint = prevEfp; }); it('zoom in/out buttons adjust the viewBox', () => diff --git a/src/components/floorplan-editor/views/FloorplanTile.tsx b/src/components/floorplan-editor/views/FloorplanTile.tsx index 7fca484..5c0c8ce 100644 --- a/src/components/floorplan-editor/views/FloorplanTile.tsx +++ b/src/components/floorplan-editor/views/FloorplanTile.tsx @@ -104,6 +104,17 @@ const FloorplanTileImpl: FC = ({ row, col, tile, selected, isDoor, southH stroke="#222" strokeWidth={ 0.5 } /> + { tile.occupied && ( + + ) } { selected && ( Date: Thu, 28 May 2026 10:19:16 +0200 Subject: [PATCH 09/15] feat: soundboard pads can load from a JSON5 file (DB fallback) When the server (soundboard_sounds table) returns no pads, the client now loads them from a JSON5 config file (loadGamedata accepts plain JSON and JSON5). Useful when the DB / CMS isn't set up yet. File-defined pads play locally for the clicker; DB-backed pads still go through the server broadcast so everyone in the room hears them. Ships a radio-style soundboard-sounds.json5.example template. --- .../soundboard-sounds.json5.example | 20 +++++ src/components/soundboard/SoundboardView.tsx | 2 +- src/hooks/soundboard/useSoundboard.ts | 87 ++++++++++++++----- 3 files changed, 88 insertions(+), 21 deletions(-) create mode 100644 public/configuration/soundboard-sounds.json5.example diff --git a/public/configuration/soundboard-sounds.json5.example b/public/configuration/soundboard-sounds.json5.example new file mode 100644 index 0000000..dffaaa8 --- /dev/null +++ b/public/configuration/soundboard-sounds.json5.example @@ -0,0 +1,20 @@ +{ + // Soundboard pads loaded from a file — used as a FALLBACK when the server + // (soundboard_sounds DB table) returns no sounds. Copy this file to + // `soundboard-sounds.json5` (without .example) and add your sounds. JSON5: + // // comments and trailing commas are allowed. + // + // Fields: + // id - unique number (pad key) + // name - label shown on the pad + // url - audio file URL (mp3/ogg/wav). Relative urls resolve against + // `soundboard.url.prefix` (falls back to `asset.url`). + // + // NOTE: file-defined pads play LOCALLY for the person who clicks them. To + // broadcast a pad to everyone in the room, the sound must exist server-side + // in the soundboard_sounds table (same flow as custom badges). The file is + // the no-DB / offline option; the DB is the multiplayer one. + sounds: [ + // { id: 1, name: 'Airhorn', url: 'https://your-host/airhorn.mp3' }, + ], +} diff --git a/src/components/soundboard/SoundboardView.tsx b/src/components/soundboard/SoundboardView.tsx index 6508d78..76d7a80 100644 --- a/src/components/soundboard/SoundboardView.tsx +++ b/src/components/soundboard/SoundboardView.tsx @@ -53,7 +53,7 @@ export const SoundboardView: FC<{}> = () => { sounds.map(sound => ( +
+
{ selected ? selected.name : LocalizeText('radio.title') }
+
+ { selectedPlaying && + + Live + } + { selected?.genre && + { selected.genre } } +
+
+ +
+ + { selectedPlaying && +
+ 🔊 + setVolume(e.target.valueAsNumber) } + className="radio-vol h-1 grow cursor-pointer" + /> +
} + + { open && +
+ { loadError && +
{ LocalizeText('radio.error') }
} + { !loadError && !stations.length && +
{ LocalizeText('radio.empty') }
} + { /* ~3 rows tall, scrolls when there are more */ } +
+ { stations.map(station => + { + const isActive = station.id === selectedId; + const playingThis = (currentId === station.id) && isPlaying; + return ( +
onPick(station) } + className={ `flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors ${ isActive ? 'bg-sky-500/15 ring-1 ring-sky-400/40' : 'hover:bg-white/8' }` }> + { station.logo + ? + :
+ { playingThis ? : } +
} +
+
{ station.name }
+ { station.genre && +
{ station.genre }
} +
+ { playingThis && +
+ +
} +
+ ); + }) } +
+
} +
+ ); +}; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index dc3d9a6..8a85f2a 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -15,6 +15,7 @@ export * from './mod-tools'; export * from './navigator'; export * from './notification'; export * from './purse'; +export * from './radio/useRadio'; export * from './rare-values/useRareValues'; export * from './rooms'; export * from './rooms/engine'; diff --git a/src/hooks/radio/useRadio.ts b/src/hooks/radio/useRadio.ts new file mode 100644 index 0000000..88bff81 --- /dev/null +++ b/src/hooks/radio/useRadio.ts @@ -0,0 +1,147 @@ +import { loadGamedata } from '@nitrots/nitro-renderer'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useBetween } from 'use-between'; +import { GetConfigurationValue } from '../../api'; + +export type RadioStation = { + id: string; + name: string; + genre?: string; + url: string; + logo?: string; +}; + +// Hotel radio: a list of streaming URLs played client-side with HTML5 Audio. +// The station list comes from a JSON5 config file (loadGamedata accepts plain +// JSON and JSON5). Shared via useBetween so playback is a single instance no +// matter how many components read it. +const useRadioState = () => +{ + const [ stations, setStations ] = useState([]); + const [ currentId, setCurrentId ] = useState(null); + const [ isPlaying, setIsPlaying ] = useState(false); + const [ loadError, setLoadError ] = useState(null); + const [ volume, setVolumeState ] = useState(0.05); // start quiet (5%) so autostart isn't intrusive + const audioRef = useRef(null); + const loadStartedRef = useRef(false); + const autoStartedRef = useRef(false); + + useEffect(() => + { + if(loadStartedRef.current) return; + loadStartedRef.current = true; + + const url = GetConfigurationValue('radio.stations.url') || 'configuration/radio-stations.json5'; + + (async () => + { + try + { + const json = await loadGamedata<{ stations?: RadioStation[] }>(url); + const list = Array.isArray(json?.stations) + ? json.stations.filter(s => s && s.id && s.url) + : []; + setStations(list); + } + catch(error) + { + setLoadError(String((error as Error)?.message ?? error)); + } + })(); + }, []); + + // Tear down the stream when the hook instance goes away. + useEffect(() => () => + { + if(audioRef.current) + { + audioRef.current.pause(); + audioRef.current.src = ''; + audioRef.current = null; + } + }, []); + + const stop = useCallback(() => + { + if(audioRef.current) + { + audioRef.current.pause(); + audioRef.current.src = ''; + audioRef.current = null; + } + setIsPlaying(false); + setCurrentId(null); + }, []); + + // Browsers block audio that starts without a user gesture (autoplay policy), + // so the startup autostart may be refused. When that happens, resume on the + // very first click / keypress anywhere. + const armResumeOnGesture = useCallback(() => + { + const resume = () => + { + window.removeEventListener('pointerdown', resume); + window.removeEventListener('keydown', resume); + if(audioRef.current) void audioRef.current.play().then(() => setIsPlaying(true)).catch(() => {}); + }; + window.addEventListener('pointerdown', resume, { once: true }); + window.addEventListener('keydown', resume, { once: true }); + }, []); + + const play = useCallback((station: RadioStation) => + { + if(!station?.url) return; + + if(audioRef.current) + { + audioRef.current.pause(); + audioRef.current.src = ''; + audioRef.current = null; + } + + try + { + const audio = new Audio(station.url); + audio.volume = volume; + audioRef.current = audio; + setCurrentId(station.id); + void audio.play().then(() => setIsPlaying(true)).catch(() => + { + // Likely autoplay-blocked — keep the station selected and resume + // on the first user interaction instead of dropping it. + setIsPlaying(false); + armResumeOnGesture(); + }); + } + catch + { + setIsPlaying(false); + setCurrentId(null); + } + }, [ volume, armResumeOnGesture ]); + + // Autostart the first station once on client load (quiet, see initial volume). + useEffect(() => + { + if(autoStartedRef.current || !stations.length) return; + autoStartedRef.current = true; + play(stations[0]); + }, [ stations, play ]); + + const toggle = useCallback((station: RadioStation) => + { + if(currentId === station.id) stop(); + else play(station); + }, [ currentId, play, stop ]); + + const setVolume = useCallback((value: number) => + { + const v = Math.max(0, Math.min(1, value)); + setVolumeState(v); + if(audioRef.current) audioRef.current.volume = v; + }, []); + + return { stations, currentId, isPlaying, volume, loadError, play, stop, toggle, setVolume }; +}; + +export const useRadio = () => useBetween(useRadioState); From 05d71dd163e93b5573dae234f3933d84c71b8aaa Mon Sep 17 00:00:00 2001 From: duckietm Date: Thu, 28 May 2026 13:46:44 +0200 Subject: [PATCH 11/15] =?UTF-8?q?=F0=9F=86=99=20Small=20fix=20for=20the=20?= =?UTF-8?q?navigator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This mirrors what the old god-hook used to do and what the rest of the codebase still uses for everything else. The TanStack one-shot listener pattern (awaitNitroResponse registers a listener, awaits one matching response, removes itself) is fragile against renderer-bundle quirks — the parser fires but the listener never matches, so the promise never resolves and query.data stays undefined forever. That's exactly the symptom you saw: server logs show the response arriving, client UI stays blank. --- src/hooks/navigator/useNavigatorSearch.ts | 66 +++++++++++------------ 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/src/hooks/navigator/useNavigatorSearch.ts b/src/hooks/navigator/useNavigatorSearch.ts index b120001..ab3744d 100644 --- a/src/hooks/navigator/useNavigatorSearch.ts +++ b/src/hooks/navigator/useNavigatorSearch.ts @@ -1,47 +1,45 @@ -import { FlatCreatedEvent, NavigatorSearchComposer, NavigatorSearchEvent, - NavigatorSearchResultSet, RoomSettingsUpdatedEvent } from '@nitrots/nitro-renderer'; -import { useNitroEventInvalidator, useNitroQuery } from '../../api/nitro-query'; +import { NavigatorSearchComposer, NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer'; +import { useEffect, useState } from 'react'; +import { SendMessageComposer } from '../../api'; +import { useMessageEvent } from '../events'; import { useNavigatorUiStore } from './navigatorUiStore'; -const SEARCH_BASE_KEY = [ 'navigator', 'search' ] as const; -/** - * TanStack Query wrapper for navigator search. - * - * Cache key: ['navigator', 'search', tabCode, filter] - * - Fires NavigatorSearchComposer(tabCode, filter) on miss. - * - Listens for NavigatorSearchEvent and resolves with the result. - * - accept-filter: rejects events whose result.code !== tabCode (defends - * against server-side cross-tab pushes resolving the wrong query slot). - * - Disabled when tabCode is '' (initial state, before metadata arrives). - * - Invalidates on FlatCreatedEvent (new room created) and - * RoomSettingsUpdatedEvent (room renamed / settings changed). - */ export const useNavigatorSearch = () => { const tabCode = useNavigatorUiStore(s => s.currentTabCode); const filter = useNavigatorUiStore(s => s.currentFilter); - const query = useNitroQuery({ - key: [ ...SEARCH_BASE_KEY, tabCode, filter ], - request: () => new NavigatorSearchComposer(tabCode, filter), - parser: NavigatorSearchEvent, - select: e => e.getParser()?.result ?? null, - accept: e => - { - const result = e.getParser()?.result; - return !!result && result.code === tabCode; - }, - enabled: !!tabCode, - staleTime: 30_000 + const [ searchResult, setSearchResult ] = useState(null); + const [ isFetching, setIsFetching ] = useState(false); + + useEffect(() => + { + if(!tabCode) return; + + setIsFetching(true); + SendMessageComposer(new NavigatorSearchComposer(tabCode, filter)); + }, [ tabCode, filter ]); + + useMessageEvent(NavigatorSearchEvent, event => + { + const result = event.getParser()?.result; + if(!result) return; + + if(tabCode && result.code !== tabCode) return; + + setSearchResult(result); + setIsFetching(false); }); - useNitroEventInvalidator(FlatCreatedEvent, [ ...SEARCH_BASE_KEY ]); - useNitroEventInvalidator(RoomSettingsUpdatedEvent, [ ...SEARCH_BASE_KEY ]); - return { - searchResult: query.data ?? null, - isFetching: query.isFetching, - refetch: query.refetch + searchResult, + isFetching, + refetch: () => + { + if(!tabCode) return; + setIsFetching(true); + SendMessageComposer(new NavigatorSearchComposer(tabCode, filter)); + } }; }; From e7088595dfbae6372faecd4be51fb1dbdfff272a Mon Sep 17 00:00:00 2001 From: duckietm Date: Thu, 28 May 2026 14:05:09 +0200 Subject: [PATCH 12/15] =?UTF-8?q?=F0=9F=86=99=20Small=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/navigator/NavigatorView.scss | 8 -------- src/components/navigator/NavigatorView.tsx | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) delete mode 100644 src/components/navigator/NavigatorView.scss diff --git a/src/components/navigator/NavigatorView.scss b/src/components/navigator/NavigatorView.scss deleted file mode 100644 index bdb279e..0000000 --- a/src/components/navigator/NavigatorView.scss +++ /dev/null @@ -1,8 +0,0 @@ -.button-search-saves { - padding: 4px; - height: 17px; - margin-top: -1px; - font-size: 10px; - border-radius: 4px; - background-color: #FAA700; -} diff --git a/src/components/navigator/NavigatorView.tsx b/src/components/navigator/NavigatorView.tsx index 10203ef..b93b8d5 100644 --- a/src/components/navigator/NavigatorView.tsx +++ b/src/components/navigator/NavigatorView.tsx @@ -8,7 +8,7 @@ import randomRoomImg from '../../assets/images/navigator/random_room.png'; import promoteRoomImg from '../../assets/images/navigator/promote_room.png'; import { CreateLinkEvent, LocalizeText, SendMessageComposer, TryVisitRoom } from '../../api'; import { Flex, Text, WidgetErrorBoundary } from '../../common'; -import { useNavigatorActions, useNavigatorData, useNavigatorUiState, useNavigatorUiStore, useNitroEvent } from '../../hooks'; +import { useNavigatorData, useNavigatorSearch, useNavigatorUiState, useNavigatorUiStore, useNitroEvent } from '../../hooks'; import { NavigatorDoorStateView } from './views/NavigatorDoorStateView'; import { NavigatorRoomCreatorView } from './views/NavigatorRoomCreatorView'; import { NavigatorRoomInfoView } from './views/NavigatorRoomInfoView'; From bade7e2623729aa0f483999bbf971d22f82529fd Mon Sep 17 00:00:00 2001 From: duckietm Date: Thu, 28 May 2026 15:25:27 +0200 Subject: [PATCH 13/15] =?UTF-8?q?=F0=9F=86=99=20Update=20texts=20to=20use?= =?UTF-8?q?=20JSON5=20and=20added=20all=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/configuration/UITexts.example | 269 ------- public/configuration/UITexts_en.json5.example | 703 ++++++++++++++++++ public/configuration/UITexts_it.json5.example | 703 ++++++++++++++++++ public/configuration/UITexts_nl.json5.example | 703 ++++++++++++++++++ public/configuration/renderer-config.example | 2 +- public/configuration/wheel-texts-en.example | 17 - public/configuration/wheel-texts-it.example | 17 - 7 files changed, 2110 insertions(+), 304 deletions(-) delete mode 100644 public/configuration/UITexts.example create mode 100644 public/configuration/UITexts_en.json5.example create mode 100644 public/configuration/UITexts_it.json5.example create mode 100644 public/configuration/UITexts_nl.json5.example delete mode 100644 public/configuration/wheel-texts-en.example delete mode 100644 public/configuration/wheel-texts-it.example diff --git a/public/configuration/UITexts.example b/public/configuration/UITexts.example deleted file mode 100644 index cf24e72..0000000 --- a/public/configuration/UITexts.example +++ /dev/null @@ -1,269 +0,0 @@ -{ - "notification.badge.received": "Nuovo Distintivo!", - "wiredfurni.badgereceived.title": "Distintivo ricevuto!", - "wiredfurni.badgereceived.body": "Hai appena ricevuto un nuovo Distintivo! Controlla nel tuo Inventario!", - "friendlist.search": "Search friends", - "purse.seasonal.currency.101": "cash", - "widget.chooser.checkall": "Select furniture", - "widget.chooser.btn.pickall": "pick up selected items!", - "wiredfurni.params.requireall.2": "If one of the selected furni has an avatar", - "wiredfurni.params.requireall.3": "If all selected furni have avatars on them", - "widget.settings.general": "General", - "widget.settings.general.title": "Adjust the default Nitro settings", - "widget.settings.volume": "Volume", - "widget.settings.interface": "Interface", - "widget.settings.interface.title": "Adjust the interface settings", - "widget.settings.interface.fps.automatic": "Set FPS to unlimited", - "widget.settings.interface.fps.warning": "Setting FPS to unlimited may cause performance issues!", - "widget.settings.interface.secondary": "Change the window header color", - "widget.settings.interface.reset": "Reset header color to default", - "widget.room.chat.hide_pets": "Hide pets", - "widget.room.chat.hide_avatars": "Hide avatars", - "widget.room.chat.hide_balloon": "Hide speech bubble", - "widget.room.chat.show_balloon": "Speech bubble", - "widget.room.chat.clear_history": "clear history", - "widget.room.youtube.shared": "YouTube is being shared", - "widget.room.youtube.open_video": "Open the video", - "wiredfurni.tooltip.select.tile": "Select tile", - "wiredfurni.tooltip.remove.tile": "Deselect tile", - "wiredfurni.tooltip.remove.5x5_tile": "select 5x5 tiles", - "wiredfurni.tooltip.remove.clear_tile": "Clear all selections", - "wiredfurni.params.furni_neighborhood.group.user": "Players", - "wiredfurni.params.furni_neighborhood.group.furni": "Furniture", - "wiredfurni.params.selector_option.bot": "No bots", - "wiredfurni.params.selector_option.pet": "No pets", - "catalog.title": "Catalog", - "catalog.favorites": "Favorites", - "catalog.favorites.pages": "Pages", - "catalog.favorites.furni": "Furni", - "catalog.favorites.empty": "No favorites", - "catalog.favorites.empty.hint": "Click the heart on furni or the star on pages to add them.", - "catalog.admin": "Admin", - "catalog.admin.new": "New", - "catalog.admin.root": "Root", - "catalog.admin.new.root.category": "New root category", - "catalog.admin.edit.root": "Edit Root", - "catalog.admin.edit": "Edit:", - "catalog.admin.edit.page": "Edit Page", - "catalog.admin.hidden": "hidden", - "catalog.admin.edit.title": "Edit \"%name%\"", - "catalog.admin.show": "Show", - "catalog.admin.hide": "Hide", - "catalog.admin.delete": "Delete", - "catalog.admin.delete.title": "Delete \"%name%\"", - "catalog.admin.delete.category.confirm": "Delete category \"%name%\" and all its content?", - "catalog.admin.delete.page": "Delete page", - "catalog.admin.delete.page.confirm": "Delete page \"%name%\"?", - "catalog.admin.delete.offer.confirm": "Are you sure you want to delete this offer?", - "catalog.admin.create": "Create", - "catalog.admin.save": "Save", - "catalog.admin.create.subpage": "Create sub-page", - "catalog.admin.order": "Order", - "catalog.admin.visible": "Visible", - "catalog.admin.enabled": "Enabled", - "catalog.admin.offer.new": "New Offer", - "catalog.admin.offer.edit": "Edit Offer", - "catalog.admin.offer.name": "Catalog Name", - "catalog.admin.offer.general": "General", - "catalog.admin.offer.quantity": "Quantity", - "catalog.admin.offer.prices": "Prices", - "catalog.admin.offer.credits": "Credits", - "catalog.admin.offer.points": "Points", - "catalog.admin.offer.points.type": "Points Type", - "catalog.admin.offer.options": "Options", - "catalog.admin.offer.club.only": "Club Only", - "catalog.admin.offer.extradata": "Extra Data (optional)....", - "catalog.admin.offer.have.offer": "Multi-discount (have_offer)", - "catalog.trophies.title": "Trophies", - "catalog.trophies.write.hint": "Write a text for the trophy before purchasing", - "catalog.trophies.inscription": "Trophy Inscription", - "catalog.trophies.inscription.placeholder": "Write the text that will appear on the trophy...", - "catalog.pets.show.colors": "Show colors", - "catalog.pets.choose.color": "Choose color", - "catalog.pets.choose.breed": "Choose breed", - "catalog.pets.back.breeds": "? Breeds", - "catalog.prefix.text": "Text", - "catalog.prefix.text.placeholder": "Enter text...", - "catalog.prefix.icon": "Icon", - "catalog.prefix.icon.remove": "Remove icon", - "catalog.prefix.effect": "Effect", - "catalog.prefix.color": "Color", - "catalog.prefix.color.single": "?? Single", - "catalog.prefix.color.per.letter": "?? Per Letter", - "catalog.prefix.color.hint": "Select a letter, then choose the color. Auto-advances.", - "catalog.prefix.color.apply.all.title": "Apply current color to all letters", - "catalog.prefix.color.apply.all": "Apply to all", - "catalog.prefix.color.selected": "Selected letter:", - "catalog.prefix.price": "Price:", - "catalog.prefix.price.amount": "5 Credits", - "catalog.prefix.purchased": "? Purchased!", - "catalog.prefix.purchase": "Purchase", - "modtools.userinfo.title": "User Info: %username%", - "modtools.userinfo.userName": "Name", - "modtools.userinfo.cfhCount": "CFHs", - "modtools.userinfo.abusiveCfhCount": "Abusive CFHs", - "modtools.userinfo.cautionCount": "Cautions", - "modtools.userinfo.banCount": "Bans", - "modtools.userinfo.lastSanctionTime": "Last Sanction", - "modtools.userinfo.tradingLockCount": "Trade Locks", - "modtools.userinfo.tradingExpiryDate": "Lock Expires", - "modtools.userinfo.minutesSinceLastLogin": "Last Login", - "modtools.userinfo.lastPurchaseDate": "Last Purchase", - "modtools.userinfo.primaryEmailAddress": "Email", - "modtools.userinfo.identityRelatedBanCount": "Banned Accs", - "modtools.userinfo.registrationAgeInMinutes": "Registered", - "modtools.userinfo.userClassification": "Rank", - "modtools.window.title": "Mod Tools", - "modtools.window.tools.room": "Room Tool", - "modtools.window.tools.chatlog": "Chatlog Tool", - "modtools.window.tools.report": "Report Tool", - "modtools.window.select.user": "Select a user", - "modtools.window.no.room": "Enter a room first", - "modtools.window.user.in_room": "Still in this room", - "modtools.window.user.left_room": "No longer in this room", - "modtools.window.user.clear": "Clear selection", - "modtools.window.tickets.open": "%count% open ticket", - "modtools.window.tickets.open.many": "%count% open tickets", - "modtools.window.section.room": "Room", - "modtools.window.section.user": "User", - "modtools.window.section.reports": "Reports", - "modtools.window.user.open_info": "Open Info", - "modtools.userinfo.refresh": "Refresh user info", - "modtools.userinfo.presence.in_room": "In room", - "modtools.userinfo.presence.in_room.title": "In the room you are observing", - "modtools.userinfo.presence.online": "Online", - "modtools.userinfo.presence.online.title": "Online on the hotel", - "modtools.userinfo.presence.offline": "Offline", - "modtools.userinfo.presence.offline.title": "Offline at panel open", - "modtools.userinfo.section.account": "Account", - "modtools.userinfo.section.activity": "Activity", - "modtools.userinfo.section.sanctions": "Sanctions", - "modtools.userinfo.section.trading": "Trading", - "modtools.userinfo.button.room.chat": "Room Chat", - "modtools.userinfo.button.send.message": "Send Message", - "modtools.userinfo.button.room.visits": "Room Visits", - "modtools.userinfo.button.mod.action": "Mod Action", - "modtools.userinfo.stat.cfh": "CFH", - "modtools.userinfo.stat.cautions": "Cautions", - "modtools.userinfo.stat.bans": "Bans", - "modtools.userinfo.stat.trade.locks": "Trade locks", - "modtools.roominfo.title": "Room Info", - "modtools.roominfo.refresh": "Refresh room info", - "modtools.roominfo.loading": "Loading…", - "modtools.roominfo.owner.here": "Owner here", - "modtools.roominfo.owner.away": "Owner away", - "modtools.roominfo.owner.title.here": "The room owner is currently inside", - "modtools.roominfo.owner.title.away": "The room owner is NOT inside", - "modtools.roominfo.stat.users": "Users", - "modtools.roominfo.stat.owner": "Owner", - "modtools.roominfo.owner.open": "Open %username%'s info", - "modtools.roominfo.button.visit": "Visit Room", - "modtools.roominfo.button.chatlog": "Chatlog", - "modtools.roominfo.moderate.title": "Moderate room", - "modtools.roominfo.moderate.kick": "Kick everyone out", - "modtools.roominfo.moderate.doorbell": "Enable the doorbell", - "modtools.roominfo.moderate.rename": "Change room name", - "modtools.roominfo.moderate.message.placeholder": "Mandatory message to deliver with the action…", - "modtools.roominfo.moderate.send.caution": "Send Caution", - "modtools.roominfo.moderate.send.alert": "Send Alert", - "modtools.user.message.title": "Send Message", - "modtools.user.message.recipient": "Message to", - "modtools.user.message.label": "Message", - "modtools.user.message.placeholder": "Write something useful — the user will see it as a moderator message.", - "modtools.user.message.empty": "Empty", - "modtools.user.message.chars": "%count% chars", - "modtools.user.message.send": "Send Message", - "modtools.user.modaction.title": "Mod Action: %username%", - "modtools.user.modaction.sanctioning": "Sanctioning", - "modtools.user.modaction.step.topic": "1. CFH Topic", - "modtools.user.modaction.step.topic.placeholder": "Select a topic…", - "modtools.user.modaction.step.sanction": "2. Sanction", - "modtools.user.modaction.step.sanction.placeholder": "Select a sanction…", - "modtools.user.modaction.step.message": "3. Custom message", - "modtools.user.modaction.step.message.optional": "(optional — overrides default)", - "modtools.user.modaction.message.placeholder": "Leave empty to use the default topic message", - "modtools.user.modaction.preview": "Preview", - "modtools.user.modaction.button.default": "Default Sanction", - "modtools.user.modaction.button.apply": "Apply Sanction", - "modtools.user.modaction.error.no.topic": "You must select a CFH topic", - "modtools.user.modaction.error.no.action": "You must select a CFH topic and Sanction", - "modtools.user.modaction.error.no.permission": "You do not have permission to do this", - "modtools.user.modaction.error.no.message": "Please write a message to user", - "modtools.user.modaction.error.no.permission.alert": "You have insufficient permissions", - "modtools.user.visits.title": "User Visits", - "modtools.user.visits.recent": "Recent visited rooms", - "modtools.user.visits.entries.one": "%count% entry", - "modtools.user.visits.entries.many": "%count% entries", - "modtools.user.visits.empty": "No recent visits", - "modtools.user.visits.time": "Time", - "modtools.user.visits.room": "Room name", - "modtools.user.visits.action": "Action", - "modtools.user.visits.visit": "Visit", - "modtools.user.visits.visit.title": "Visit room", - "modtools.user.chatlog.title": "User Chatlog", - "modtools.user.chatlog.title.with": "User Chatlog: %username%", - "modtools.user.chatlog.loading": "Loading chatlog…", - "modtools.room.chatlog.title": "Room Chatlog", - "modtools.chatlog.column.time": "Time", - "modtools.chatlog.column.user": "User", - "modtools.chatlog.column.message": "Message", - "modtools.chatlog.empty": "No messages", - "modtools.chatlog.visit": "Visit", - "modtools.chatlog.tools": "Tools", - "modtools.tickets.title": "Tickets", - "modtools.tickets.tab.open": "Open", - "modtools.tickets.tab.mine": "Mine", - "modtools.tickets.tab.picked": "All picked", - "modtools.tickets.column.type": "Type", - "modtools.tickets.column.reported": "Reported", - "modtools.tickets.column.opened": "Opened", - "modtools.tickets.column.picker": "Picker", - "modtools.tickets.empty.open": "No open issues", - "modtools.tickets.empty.mine": "No issues picked by you", - "modtools.tickets.empty.picked": "No picked issues", - "modtools.tickets.action.pick": "Pick", - "modtools.tickets.action.handle": "Handle", - "modtools.tickets.action.release": "Release", - "modtools.tickets.issue.title": "Resolving issue #%issueId%", - "modtools.tickets.issue.label": "Issue #%issueId%", - "modtools.tickets.issue.details": "Details", - "modtools.tickets.issue.field.source": "Source", - "modtools.tickets.issue.field.category": "Category", - "modtools.tickets.issue.field.description": "Description", - "modtools.tickets.issue.field.caller": "Caller", - "modtools.tickets.issue.field.reported": "Reported", - "modtools.tickets.issue.chatlog.view": "View chatlog", - "modtools.tickets.issue.chatlog.close": "Close chatlog", - "modtools.tickets.issue.resolve.heading": "Resolve as", - "modtools.tickets.issue.resolve.resolved": "Resolved", - "modtools.tickets.issue.resolve.useless": "Useless", - "modtools.tickets.issue.resolve.abusive": "Abusive", - "modtools.tickets.issue.release": "Release back to queue", - "modtools.tickets.cfh.chatlog.title": "Issue #%issueId% Chatlog", - "groupforum.list.tab.most_active": "Most active threads", - "groupforum.list.tab.my_forums": "My group forums", - "groupforum.list.no_forums": "There are no forums", - "groupforum.view.threads": "Number of threads", - "groupforum.thread.pin": "Pin thread", - "groupforum.thread.unpin": "Unpin thread", - "groupforum.thread.lock": "Lock thread", - "groupforum.thread.unlock": "Unlock thread", - "groupforum.thread.hide": "Hide thread", - "groupforum.thread.restore": "Restore thread", - "groupforum.thread.delete": "Delete thread + posts", - "groupforum.message.hide": "Hide message", - "group.forum.enable.caption": "Enable / Disable group forum", - "group.forum.enable.help": "If you disable the group forum, all posts will also be deleted!", - "groupforum.view.no_threads": "There are currently no active threads", - "loading.task.session": "Verifying session...", - "loading.task.renderer": "Initializing renderer...", - "loading.task.assets": "loading game assets...", - "loading.task.localization": "loading translations...", - "loading.task.avatar": "loading wardrobe...", - "loading.task.sounds": "loading sounds...", - "loading.task.startsession": "Starting session...", - "loading.task.userdata": "loading user data...", - "loading.task.rooms": "loading rooms...", - "loading.task.engine": "loading graphics engine...", - "catalog.gift_wrapping.gift_sent": "Done!" -} diff --git a/public/configuration/UITexts_en.json5.example b/public/configuration/UITexts_en.json5.example new file mode 100644 index 0000000..63fb47c --- /dev/null +++ b/public/configuration/UITexts_en.json5.example @@ -0,0 +1,703 @@ +{ + // ------------------------------------------------------------------------ + // Friendlist + // ------------------------------------------------------------------------ + 'friendlist.search': 'Search friends', + + // ------------------------------------------------------------------------ + // Purse / Currency + // ------------------------------------------------------------------------ + 'purse.seasonal.currency.101': 'doekoes', + + // ------------------------------------------------------------------------ + // Widget: furni chooser + // ------------------------------------------------------------------------ + 'widget.chooser.checkall': 'Select furni', + 'widget.chooser.btn.pickall': 'pick up the selected items!', + + // ------------------------------------------------------------------------ + // Game center + // ------------------------------------------------------------------------ + 'gamecenter.players': 'Players', + 'gamecenter.players.2to6': '2 to 6 players', + 'gamecenter.players.2to8': '2 to 8 players', + 'gamecenter.players.4to12': '4 to 12 players', + 'gamecenter.players.single': 'Single player', + 'gamecenter.players.score': 'Score:', + 'gamecenter.players.theme': 'Theme:', + 'gamecenter.players.winner': 'Winner!', + + // Game descriptions + 'gamecenter.battleball.description': 'BattleBall is a colorful game in which you must color more surfaces than your opponent. Items appear randomly and give you unique powers to boost your chances. Tactics, skill and quick decisions are the key to victory. Will you become the champion of BattleBall?', + 'gamecenter.tombrunner.description': 'This treasure hunter is determined to find as many old coins as possible while running through ancient corridors and leaping over enormous cracks. On your journey through this endless 3D running game you will also encounter unstable and fragile bridges. Find out how long you can survive.', + 'gamecenter.flappybirds.description': 'Flappy Bird is an arcade-style game in which we control the Faby bird, which moves to the right. It is your task to guide Faby through pipes that have equal gaps placed at random heights.', + 'gamecenter.bargame.description': 'Show off your skills by working in the best bar of the hotel and serving the best drinks to the most demanding customers. Try to be the waiter with the best skills, delivering glasses to win the game and demonstrate your abilities at working with cocktails.', + 'gamecenter.roombuildergame.description': 'Are you good at building rooms? Do you have enough imagination? Take on the challenge and build a themed room in under 6 minutes. The nicest room wins!', + + // Game center: voting + 'gamecenter.vote.description': 'Vote on the rooms', + 'gamecenter.vote.room.made.by': 'Room made by', + 'gamecenter.vote.room.bestihaveseen': 'This is the nicest room I have ever seen!', + 'gamecenter.vote.room.nice': 'Fine room, nicely done.', + 'gamecenter.vote.room.normal': 'An OK room, not bad and not super cool.', + 'gamecenter.vote.room.couldbebetter': 'This could have been a lot better', + 'gamecenter.vote.room.bad': 'Help, where is the exit, my eyes hurt!', + 'gamecenter.vote.room.wait': 'The other players are now voting on your room, please wait!', + + // ------------------------------------------------------------------------ + // Wired furniture + // ------------------------------------------------------------------------ + 'wiredfurni.params.requireall.2': 'If one of the selected furni has an avatar', + 'wiredfurni.params.requireall.3': 'If all selected furni have avatars on them', + 'wiredfurni.tooltip.select.tile': 'Select tile', + 'wiredfurni.tooltip.remove.tile': 'Deselect tile', + 'wiredfurni.tooltip.remove.5x5_tile': 'select 5x5 tiles', + 'wiredfurni.tooltip.remove.clear_tile': 'Remove all selections', + 'wiredfurni.params.furni_neighborhood.group.user': 'Players', + 'wiredfurni.params.furni_neighborhood.group.furni': 'Furni', + 'wiredfurni.params.selector_option.bot': 'No BOTs', + 'wiredfurni.params.selector_option.pet': 'No Pets', + + // Wired furniture: badge received + 'wiredfurni.badgereceived.title': 'Badge received!', + 'wiredfurni.badgereceived.body': 'You just received a new badge! Check it out in your inventory!', + + // ------------------------------------------------------------------------ + // Notifications + // ------------------------------------------------------------------------ + 'notification.badge.received': 'New badge!', + + // ------------------------------------------------------------------------ + // Settings widget + // ------------------------------------------------------------------------ + 'widget.settings.general': 'Default', + 'widget.settings.general.title': 'Adjust the default nitro settings', + 'widget.settings.volume': 'Volume', + 'widget.settings.interface': 'Interface', + 'widget.settings.interface.title': 'Adjust the settings for the interface', + 'widget.settings.interface.fps.automatic': 'Set FPS to unlimited', + 'widget.settings.interface.fps.warning': 'Setting FPS to unlimited can cause performance problems!', + 'widget.settings.interface.secondary': 'Change the window header color', + 'widget.settings.interface.reset': 'Reset header color to default', + + // ------------------------------------------------------------------------ + // Room widgets: chat + youtube + // ------------------------------------------------------------------------ + 'widget.room.chat.hide_pets': 'Hide pets', + 'widget.room.chat.hide_avatars': 'Hide avatars', + 'widget.room.chat.hide_balloon': 'Hide speech bubble', + 'widget.room.chat.show_balloon': 'Speech bubble', + 'widget.room.chat.clear_history': 'clear history', + 'widget.room.youtube.shared': 'YouTube is being shared', + 'widget.room.youtube.open_video': 'Open the video', + + // ------------------------------------------------------------------------ + // Catalog + // ------------------------------------------------------------------------ + + // Catalog: general + 'catalog.title': 'Catalog', + 'catalog.favorites': 'Favorites', + 'catalog.favorites.pages': 'Pages', + 'catalog.favorites.furni': 'Furni', + 'catalog.favorites.empty': 'No favorites', + 'catalog.favorites.empty.hint': 'Click the heart on furni or the star on pages to add them.', + + // Catalog: admin + 'catalog.admin': 'Admin', + 'catalog.admin.new': 'New', + 'catalog.admin.root': 'Root', + 'catalog.admin.new.root.category': 'New root category', + 'catalog.admin.edit.root': 'Edit root', + 'catalog.admin.edit': 'Edit:', + 'catalog.admin.edit.page': 'Edit page', + 'catalog.admin.hidden': 'hidden', + 'catalog.admin.edit.title': 'Edit "%name%"', + 'catalog.admin.show': 'Show', + 'catalog.admin.hide': 'Hide', + 'catalog.admin.delete': 'Delete', + 'catalog.admin.delete.title': 'Delete "%name%"', + 'catalog.admin.delete.category.confirm': 'Delete category "%name%" and all its contents?', + 'catalog.admin.delete.page': 'Delete page', + 'catalog.admin.delete.page.confirm': 'Delete page "%name%"?', + 'catalog.admin.delete.offer.confirm': 'Are you sure you want to delete this offer?', + 'catalog.admin.create': 'Create', + 'catalog.admin.save': 'Save', + 'catalog.admin.create.subpage': 'Create subpage', + 'catalog.admin.order': 'Order', + 'catalog.admin.visible': 'Visible', + 'catalog.admin.enabled': 'Enabled', + + // Catalog admin: offer editor + 'catalog.admin.offer.new': 'New offer', + 'catalog.admin.offer.edit': 'Edit offer', + 'catalog.admin.offer.name': 'Catalog name', + 'catalog.admin.offer.general': 'General', + 'catalog.admin.offer.quantity': 'Quantity', + 'catalog.admin.offer.prices': 'Prices', + 'catalog.admin.offer.credits': 'Credits', + 'catalog.admin.offer.points': 'Points', + 'catalog.admin.offer.points.type': 'Points type', + 'catalog.admin.offer.options': 'Options', + 'catalog.admin.offer.club.only': 'Club only', + 'catalog.admin.offer.extradata': 'Extra data (optional)....', + 'catalog.admin.offer.have.offer': 'Multi-discount (have_offer)', + + // Catalog: trophies + 'catalog.trophies.title': 'Trophies', + 'catalog.trophies.write.hint': 'Write a text for the trophy before buying', + 'catalog.trophies.inscription': 'Trophy inscription', + 'catalog.trophies.inscription.placeholder': 'Write the text that will appear on the trophy...', + + // Catalog: pets + 'catalog.pets.show.colors': 'Show colors', + 'catalog.pets.choose.color': 'Choose color', + 'catalog.pets.choose.breed': 'Choose breed', + 'catalog.pets.back.breeds': '← Breeds', + + // Catalog: name prefix editor + 'catalog.prefix.text': 'Text', + 'catalog.prefix.text.placeholder': 'Enter text...', + 'catalog.prefix.icon': 'Icon', + 'catalog.prefix.icon.remove': 'Remove icon', + 'catalog.prefix.effect': 'Effect', + 'catalog.prefix.color': 'Color', + 'catalog.prefix.color.single': '🎨 Single', + 'catalog.prefix.color.per.letter': '🌈 Per letter', + 'catalog.prefix.color.hint': 'Select a letter and then choose the color. Advances automatically.', + 'catalog.prefix.color.apply.all.title': 'Apply current color to all letters', + 'catalog.prefix.color.apply.all': 'Apply to all', + 'catalog.prefix.color.selected': 'Selected letter:', + 'catalog.prefix.price': 'Price:', + 'catalog.prefix.price.amount': '5 Credits', + 'catalog.prefix.purchased': '✓ Purchased!', + 'catalog.prefix.purchase': 'Buy', + + // Catalog: gift wrapping + 'catalog.gift_wrapping.gift_sent': 'Done!', + + // ------------------------------------------------------------------------ + // Group forum + // ------------------------------------------------------------------------ + 'groupforum.list.tab.most_active': 'Most active topics', + 'groupforum.list.tab.my_forums': 'My group forums', + 'groupforum.list.no_forums': 'There are no forums', + 'groupforum.view.threads': 'Number of topics', + 'groupforum.thread.pin': 'Pin topic', + 'groupforum.thread.unpin': 'Unpin topic', + 'groupforum.thread.lock': 'Lock topic', + 'groupforum.thread.unlock': 'Unlock topic', + 'groupforum.thread.hide': 'Hide topic', + 'groupforum.thread.restore': 'Make topic visible again', + 'groupforum.thread.delete': 'Delete topic + posts', + 'groupforum.message.hide': 'Hide message', + 'group.forum.enable.caption': 'Enable/disable group forum', + 'group.forum.enable.help': 'If you disable the group forum, all posts will be deleted too!', + 'groupforum.view.no_threads': 'There are currently no active topics', + + // ------------------------------------------------------------------------ + // Mod tools: window + // ------------------------------------------------------------------------ + 'modtools.window.title': 'Mod Tools', + 'modtools.window.tools.room': 'Room tool', + 'modtools.window.tools.chatlog': 'Chatlog tool', + 'modtools.window.tools.report': 'Report tool', + 'modtools.window.select.user': 'Select a user', + 'modtools.window.no.room': 'Enter a room first', + 'modtools.window.user.in_room': 'Still in this room', + 'modtools.window.user.left_room': 'No longer in this room', + 'modtools.window.user.clear': 'Clear selection', + 'modtools.window.tickets.open': '%count% open ticket', + 'modtools.window.tickets.open.many': '%count% open tickets', + 'modtools.window.section.room': 'Room', + 'modtools.window.section.user': 'User', + 'modtools.window.section.reports': 'Reports', + 'modtools.window.user.open_info': 'Open info', + + // ------------------------------------------------------------------------ + // Mod tools: user info + // ------------------------------------------------------------------------ + 'modtools.userinfo.title': 'User info: %username%', + 'modtools.userinfo.userName': 'Name', + 'modtools.userinfo.cfhCount': 'CFHs', + 'modtools.userinfo.abusiveCfhCount': 'Abusive CFHs', + 'modtools.userinfo.cautionCount': 'Cautions', + 'modtools.userinfo.banCount': 'Bans', + 'modtools.userinfo.lastSanctionTime': 'Last sanction', + 'modtools.userinfo.tradingLockCount': 'Trade locks', + 'modtools.userinfo.tradingExpiryDate': 'Lock expires', + 'modtools.userinfo.minutesSinceLastLogin': 'Last login', + 'modtools.userinfo.lastPurchaseDate': 'Last purchase', + 'modtools.userinfo.primaryEmailAddress': 'Email', + 'modtools.userinfo.identityRelatedBanCount': 'Banned accounts', + 'modtools.userinfo.registrationAgeInMinutes': 'Registered', + 'modtools.userinfo.userClassification': 'Rank', + 'modtools.userinfo.refresh': 'Refresh user info', + 'modtools.userinfo.presence.in_room': 'In room', + 'modtools.userinfo.presence.in_room.title': 'In the room you are observing', + 'modtools.userinfo.presence.online': 'Online', + 'modtools.userinfo.presence.online.title': 'Online in the hotel', + 'modtools.userinfo.presence.offline': 'Offline', + 'modtools.userinfo.presence.offline.title': 'Offline when panel opened', + 'modtools.userinfo.section.account': 'Account', + 'modtools.userinfo.section.activity': 'Activity', + 'modtools.userinfo.section.sanctions': 'Sanctions', + 'modtools.userinfo.section.trading': 'Trading', + 'modtools.userinfo.button.room.chat': 'Room chat', + 'modtools.userinfo.button.send.message': 'Send message', + 'modtools.userinfo.button.room.visits': 'Room visits', + 'modtools.userinfo.button.mod.action': 'Mod action', + 'modtools.userinfo.stat.cfh': 'CFH', + 'modtools.userinfo.stat.cautions': 'Cautions', + 'modtools.userinfo.stat.bans': 'Bans', + 'modtools.userinfo.stat.trade.locks': 'Trade locks', + + // ------------------------------------------------------------------------ + // Mod tools: room info + // ------------------------------------------------------------------------ + 'modtools.roominfo.title': 'Room info', + 'modtools.roominfo.refresh': 'Refresh room info', + 'modtools.roominfo.loading': 'Loading…', + 'modtools.roominfo.owner.here': 'Owner present', + 'modtools.roominfo.owner.away': 'Owner away', + 'modtools.roominfo.owner.title.here': 'The room owner is currently inside', + 'modtools.roominfo.owner.title.away': 'The room owner is NOT inside', + 'modtools.roominfo.stat.users': 'Users', + 'modtools.roominfo.stat.owner': 'Owner', + 'modtools.roominfo.owner.open': 'Open info of %username%', + 'modtools.roominfo.button.visit': 'Visit room', + 'modtools.roominfo.button.chatlog': 'Chatlog', + 'modtools.roominfo.moderate.title': 'Moderate room', + 'modtools.roominfo.moderate.kick': 'Kick everyone out', + 'modtools.roominfo.moderate.doorbell': 'Enable doorbell', + 'modtools.roominfo.moderate.rename': 'Change room name', + 'modtools.roominfo.moderate.message.placeholder': 'Required message sent along with the action…', + 'modtools.roominfo.moderate.send.caution': 'Send caution', + 'modtools.roominfo.moderate.send.alert': 'Send alert', + + // ------------------------------------------------------------------------ + // Mod tools: user message + // ------------------------------------------------------------------------ + 'modtools.user.message.title': 'Send message', + 'modtools.user.message.recipient': 'Message to', + 'modtools.user.message.label': 'Message', + 'modtools.user.message.placeholder': 'Write something useful — the user sees it as a moderator message.', + 'modtools.user.message.empty': 'Empty', + 'modtools.user.message.chars': '%count% characters', + 'modtools.user.message.send': 'Send message', + + // ------------------------------------------------------------------------ + // Mod tools: mod action + // ------------------------------------------------------------------------ + 'modtools.user.modaction.title': 'Mod action: %username%', + 'modtools.user.modaction.sanctioning': 'Sanctioning', + 'modtools.user.modaction.step.topic': '1. CFH topic', + 'modtools.user.modaction.step.topic.placeholder': 'Select a topic…', + 'modtools.user.modaction.step.sanction': '2. Sanction', + 'modtools.user.modaction.step.sanction.placeholder': 'Select a sanction…', + 'modtools.user.modaction.step.message': '3. Custom message', + 'modtools.user.modaction.step.message.optional': '(optional — overrides default)', + 'modtools.user.modaction.message.placeholder': 'Leave empty to use the default topic message', + 'modtools.user.modaction.preview': 'Preview', + 'modtools.user.modaction.button.default': 'Default sanction', + 'modtools.user.modaction.button.apply': 'Apply sanction', + 'modtools.user.modaction.error.no.topic': 'You must select a CFH topic', + 'modtools.user.modaction.error.no.action': 'You must select a CFH topic and sanction', + 'modtools.user.modaction.error.no.permission': 'You do not have permission to do this', + 'modtools.user.modaction.error.no.message': 'Write a message to the user', + 'modtools.user.modaction.error.no.permission.alert': 'You have insufficient rights', + + // ------------------------------------------------------------------------ + // Mod tools: user visits + // ------------------------------------------------------------------------ + 'modtools.user.visits.title': 'User visits', + 'modtools.user.visits.recent': 'Recently visited rooms', + 'modtools.user.visits.entries.one': '%count% entry', + 'modtools.user.visits.entries.many': '%count% entries', + 'modtools.user.visits.empty': 'No recent visits', + 'modtools.user.visits.time': 'Time', + 'modtools.user.visits.room': 'Room name', + 'modtools.user.visits.action': 'Action', + 'modtools.user.visits.visit': 'Visit', + 'modtools.user.visits.visit.title': 'Visit room', + + // ------------------------------------------------------------------------ + // Mod tools: chatlog + // ------------------------------------------------------------------------ + 'modtools.user.chatlog.title': 'User chatlog', + 'modtools.user.chatlog.title.with': 'User chatlog: %username%', + 'modtools.user.chatlog.loading': 'Loading chatlog…', + 'modtools.room.chatlog.title': 'Room chatlog', + 'modtools.chatlog.column.time': 'Time', + 'modtools.chatlog.column.user': 'User', + 'modtools.chatlog.column.message': 'Message', + 'modtools.chatlog.empty': 'No messages', + 'modtools.chatlog.visit': 'Visit', + 'modtools.chatlog.tools': 'Tools', + + // ------------------------------------------------------------------------ + // Mod tools: tickets + // ------------------------------------------------------------------------ + 'modtools.tickets.title': 'Tickets', + 'modtools.tickets.tab.open': 'Open', + 'modtools.tickets.tab.mine': 'Mine', + 'modtools.tickets.tab.picked': 'All picked', + 'modtools.tickets.column.type': 'Type', + 'modtools.tickets.column.reported': 'Reported', + 'modtools.tickets.column.opened': 'Opened', + 'modtools.tickets.column.picker': 'Picked up by', + 'modtools.tickets.empty.open': 'No open reports', + 'modtools.tickets.empty.mine': 'No reports picked up by you', + 'modtools.tickets.empty.picked': 'No picked-up reports', + 'modtools.tickets.action.pick': 'Pick up', + 'modtools.tickets.action.handle': 'Handle', + 'modtools.tickets.action.release': 'Release', + 'modtools.tickets.issue.title': 'Resolve report #%issueId%', + 'modtools.tickets.issue.label': 'Report #%issueId%', + 'modtools.tickets.issue.details': 'Details', + 'modtools.tickets.issue.field.source': 'Source', + 'modtools.tickets.issue.field.category': 'Category', + 'modtools.tickets.issue.field.description': 'Description', + 'modtools.tickets.issue.field.caller': 'Reporter', + 'modtools.tickets.issue.field.reported': 'Reported', + 'modtools.tickets.issue.chatlog.view': 'View chatlog', + 'modtools.tickets.issue.chatlog.close': 'Close chatlog', + 'modtools.tickets.issue.resolve.heading': 'Resolve as', + 'modtools.tickets.issue.resolve.resolved': 'Resolved', + 'modtools.tickets.issue.resolve.useless': 'Useless', + 'modtools.tickets.issue.resolve.abusive': 'Abusive', + 'modtools.tickets.issue.release': 'Put back in queue', + 'modtools.tickets.cfh.chatlog.title': 'Report #%issueId% chatlog', + + // ------------------------------------------------------------------------ + // Login + // ------------------------------------------------------------------------ + 'login.username': 'What is your habbo name', + 'login.forgot_password': 'Forgot password?', + + // First-time visitors card + 'nitro.login.firsttime.title': 'First time here?', + 'nitro.login.firsttime.text': 'Don\'t have a habbo account yet?', + 'nitro.login.firsttime.link': 'You can create one here', + 'nitro.login.card.title': 'Sign in to habbo', + + // Server status checks + 'nitro.login.server.offline.short': 'The game server is not running right now. Try again in a moment.', + 'nitro.login.server.offline.long': 'The game server is not running right now, so no new accounts can be created. Try again in a moment.', + 'nitro.login.server.checking': 'Checking…', + 'nitro.login.server.retry': 'Try again', + + // Registration flow + 'nitro.login.register.title': 'habbo details', + 'nitro.login.register.next': 'Next', + 'nitro.login.register.finish': 'Finish', + 'nitro.login.register.creating': 'Creating…', + 'nitro.login.register.intro.credentials': 'Let\'s create your account. Enter your email address and choose a password — we\'ll check that this email is not already in use.', + 'nitro.login.register.intro.avatar': 'Now it\'s time to create your own habbo character! Start by choosing your habbo name.', + 'nitro.login.register.intro.room': 'Last step — choose a starter room, or skip this and make your own room later.', + 'nitro.login.register.confirm.label': 'Confirm password', + 'nitro.login.register.username.placeholder': 'HabboName', + 'nitro.login.register.hotlooks.count': '%count% looks available', + 'nitro.login.register.hotlooks.none': 'No looks loaded', + 'nitro.login.register.room.skip.title': 'Fine — I\'ll make my own rooms', + 'nitro.login.register.room.skip.description': 'Skip this and start with an empty hotel inventory.', + 'nitro.login.register.room.loading': 'Loading rooms…', + 'nitro.login.register.room.error': 'Could not load room options. You can still skip this step.', + 'nitro.login.register.success': 'Welcome aboard, %username%! Your account is ready — log in below with the password you just chose.', + + // Forgot password + 'nitro.login.forgot.title': 'Reset password', + 'nitro.login.forgot.email.label': 'Email address', + 'nitro.login.forgot.send': 'Send email', + 'nitro.login.forgot.success': 'Email sent! If an account is linked to this address, you\'ll find a reset link in your inbox shortly (check your spam if you see nothing within a minute).', + + // Login errors (validation + transport) + 'nitro.login.error.missing_credentials': 'Enter both your habbo name and password.', + 'nitro.login.error.invalid_credentials': 'Invalid habbo name or password.', + 'nitro.login.error.too_many_attempts': 'Too many attempts. Try again in %seconds%s.', + 'nitro.login.error.turnstile': 'Complete the security check.', + 'nitro.login.error.server_offline': 'The game server is not running. Try again later.', + 'nitro.login.error.login_unreachable': 'Cannot reach the login service. Try again.', + 'nitro.login.error.register_failed': 'Cannot create your account.', + 'nitro.login.error.register_unreachable': 'Cannot reach the registration service.', + 'nitro.login.error.forgot_failed': 'Cannot send a reset email right now.', + 'nitro.login.error.forgot_unreachable': 'Cannot reach the password reset service.', + 'nitro.login.error.missing_fields': 'Fill in all fields.', + 'nitro.login.error.invalid_email': 'Enter a valid email address.', + 'nitro.login.error.password_too_short': 'Your password must be at least 8 characters long.', + 'nitro.login.error.password_mismatch': 'Passwords do not match.', + 'nitro.login.error.email_taken': 'This email address is already in use.', + 'nitro.login.error.missing_username': 'Choose a habbo name.', + 'nitro.login.error.username_length': 'The habbo name must be 3–16 characters.', + 'nitro.login.error.username_taken': 'This habbo name is already in use.', + 'nitro.login.error.missing_email': 'Enter your email address.', + + // ------------------------------------------------------------------------ + // Inventory + // ------------------------------------------------------------------------ + 'inventory.effects.activate': 'Use effect', + 'inventory.effects.remove': 'remove effect', + + // ------------------------------------------------------------------------ + // Loading screen — boot-stage labels read by App.tsx (taskLabel) + // ------------------------------------------------------------------------ + 'loading.task.session': 'Verifying session...', + 'loading.task.renderer': 'Initializing renderer...', + 'loading.task.assets': 'Loading game assets...', + 'loading.task.localization': 'Loading translations...', + 'loading.task.avatar': 'Loading wardrobe...', + 'loading.task.sounds': 'Loading sounds...', + 'loading.task.startsession': 'Starting session...', + 'loading.task.userdata': 'Loading user data...', + 'loading.task.rooms': 'Loading rooms...', + 'loading.task.engine': 'Loading graphics engine...', + + // ------------------------------------------------------------------------ + // Housekeeping + // ------------------------------------------------------------------------ + 'housekeeping.title': 'Housekeeping', + 'housekeeping.mode.light': 'Light', + + // Housekeeping: tabs + 'housekeeping.tab.dashboard': 'Dashboard', + 'housekeeping.tab.users': 'Users', + 'housekeeping.tab.rooms': 'Rooms', + 'housekeeping.tab.economy': 'Economy', + 'housekeeping.tab.audit': 'Audit log', + + // Housekeeping: confirm + status + 'housekeeping.confirm.title': 'Confirm action', + 'housekeeping.confirm.proceed': 'Proceed', + 'housekeeping.confirm.cancel': 'Cancel', + 'housekeeping.status.dismiss': 'Dismiss', + + // Housekeeping: action status + 'housekeeping.action.pending': 'Action in progress…', + 'housekeeping.action.success': 'Action completed', + 'housekeeping.action.error': 'Action failed', + 'housekeeping.action.reset_password.done': 'Password reset — new password below.', + + // Housekeeping: generated password card + 'housekeeping.password.title': '%username% (#%id%) · new password', + 'housekeeping.password.value_label': 'Generated password', + 'housekeeping.password.copy': 'Copy', + 'housekeeping.password.copied': 'Copied', + 'housekeeping.password.copy_failed': 'Copy failed', + 'housekeeping.password.dismiss': 'Dismiss', + 'housekeeping.password.hint': 'Share this with the user outside the hotel. It is shown once — close this card when you\'re done; the password will never be displayed again.', + + // Housekeeping: errors + 'housekeeping.error.invalid_input': 'Invalid input — check the user ID and the entered value.', + 'housekeeping.error.user_not_found': 'User not found.', + 'housekeeping.error.user_offline': 'User is offline — this action only works on online users.', + 'housekeeping.error.target_unkickable': 'This user cannot be kicked.', + 'housekeeping.error.ban_failed': 'Ban could not be applied — the server refused the request.', + 'housekeeping.error.no_active_ban': 'No active ban to lift for this user.', + 'housekeeping.error.rank_not_found': 'Rank not found — choose a rank that exists in permission_ranks.', + 'housekeeping.error.db_failed': 'Database error — see the emulator log for the SQL exception.', + 'housekeeping.error.hash_failed': 'Could not hash the new password — SHA-256 not available on this JVM.', + 'housekeeping.error.room_not_found': 'Room not found.', + 'housekeeping.error.room_action_failed': 'Room action could not be applied.', + 'housekeeping.error.new_owner_not_found': 'New owner not found.', + 'housekeeping.error.economy_failed': 'Economy action could not be applied — check the user ID and the amount.', + 'housekeeping.error.alert_empty': 'Hotel alert may not be empty.', + + // Housekeeping: actions + 'housekeeping.action.ban_h': 'Ban %h%h', + 'housekeeping.action.mute_min': 'Mute %m%m', + 'housekeeping.action.trade_lock_h': 'Trade lock %h%h', + 'housekeeping.action.kick': 'Kick', + 'housekeeping.action.unban': 'Lift ban', + 'housekeeping.action.force_disconnect': 'Disconnect', + 'housekeeping.action.set_rank': 'Set rank', + 'housekeeping.action.reset_password': 'Reset password', + + // Housekeeping: user panel + 'housekeeping.user.search.placeholder': 'Search by username…', + 'housekeeping.user.search.button': 'Search', + 'housekeeping.user.clear': 'Clear selection', + 'housekeeping.user.none': 'No user selected — search above to pick one.', + 'housekeeping.user.not_found': 'User not found.', + 'housekeeping.user.credits': 'Credits', + 'housekeeping.user.duckets': 'Duckets / pixels', + 'housekeeping.user.diamonds': 'Diamonds', + 'housekeeping.user.audit_hint': 'All actions are recorded in the audit log tab.', + 'housekeeping.user.live.label': 'Live (in current room)', + 'housekeeping.user.live.kick': 'Kick', + 'housekeeping.user.live.mute_2m': 'Mute 2m', + 'housekeeping.user.live.mute_10m': 'Mute 10m', + 'housekeeping.user.live.ban_h': 'Ban 1h', + 'housekeeping.user.live.ban_d': 'Ban 1d', + + // Housekeeping: room panel + 'housekeeping.room.search.placeholder': 'Room ID…', + 'housekeeping.room.search.button': 'Search', + 'housekeeping.room.clear': 'Clear selection', + 'housekeeping.room.none': 'No room selected — enter an ID above.', + 'housekeeping.room.not_found': 'Room not found.', + 'housekeeping.room.open': 'Open', + 'housekeeping.room.close': 'Close', + 'housekeeping.room.mute_min': 'Mute %m%m', + 'housekeeping.room.kick_all': 'Kick everyone', + 'housekeeping.room.kick_all.confirm': 'Kick every user currently in the room?', + 'housekeeping.room.delete': 'Delete room', + 'housekeeping.room.delete.confirm': 'Permanently delete this room and all its furni?', + 'housekeeping.room.transfer': 'Transfer', + 'housekeeping.room.transfer.label': 'Transfer ownership', + 'housekeeping.room.transfer.new_owner': 'New owner ID', + + // Housekeeping: economy + 'housekeeping.economy.select_user': 'Pick a user in the Users tab first.', + 'housekeeping.economy.target': 'Target: %username% (#%id%)', + 'housekeeping.economy.give_credits': 'Give credits', + 'housekeeping.economy.give_duckets': 'Give duckets', + 'housekeeping.economy.give_diamonds': 'Give diamonds', + 'housekeeping.economy.grant_item': 'Grant item', + 'housekeeping.economy.grant_item.label': 'Grant catalog item', + 'housekeeping.economy.item_id': 'Item ID', + 'housekeeping.economy.item_quantity': 'Quantity', + 'housekeeping.economy.set_hc_days': 'Set HC days', + + // Housekeeping: hotel-wide alert + 'housekeeping.hotel.alert.label': 'Hotel-wide alert', + 'housekeeping.hotel.alert.placeholder': 'Message broadcast to every connected user…', + 'housekeeping.hotel.alert.send': 'Send to hotel', + 'housekeeping.hotel.alert.confirm': 'Broadcast a %count%-character alert to every connected user?', + + // Housekeeping: dashboard + 'housekeeping.dashboard.title': 'Overview', + 'housekeeping.dashboard.refresh': 'Refresh', + 'housekeeping.dashboard.loading': 'Loading dashboard…', + 'housekeeping.dashboard.unavailable': 'Dashboard unavailable — check the admin endpoint.', + 'housekeeping.dashboard.online': 'Online', + 'housekeeping.dashboard.total_users': '%count% total', + 'housekeeping.dashboard.rooms_active': 'Active rooms', + 'housekeeping.dashboard.total_rooms': '%count% total', + 'housekeeping.dashboard.peak_today': 'Peak today', + 'housekeeping.dashboard.peak_alltime': 'All-time peak %count%', + 'housekeeping.dashboard.pending_tickets': 'Tickets', + 'housekeeping.dashboard.sanctions_24h': '%count% sanctions / 24h', + 'housekeeping.dashboard.server': 'Server', + 'housekeeping.dashboard.recent_sanctions': 'Recent sanctions', + 'housekeeping.dashboard.recent_lookups': 'Recent lookups', + + // Housekeeping: audit log + 'housekeeping.audit.title': 'Audit log', + 'housekeeping.audit.refresh': 'Refresh', + 'housekeeping.audit.filter.all': 'All', + 'housekeeping.audit.filter.users': 'Users', + 'housekeeping.audit.filter.rooms': 'Rooms', + 'housekeeping.audit.filter.hotel': 'Hotel', + 'housekeeping.audit.search.placeholder': 'Search actor / target / action…', + 'housekeeping.audit.empty': 'No audit entries yet.', + 'housekeeping.audit.no_match': 'No entries match the current filters.', + + // Housekeeping: shared fields + 'housekeeping.field.reason': 'Reason', + 'housekeeping.field.reason.placeholder': 'Free-text reason (optional)', + 'housekeeping.field.duration': 'Duration', + 'housekeeping.reason.default': 'No reason given.', + + // Housekeeping: context menu + 'housekeeping.menu.send_to_hk': 'Send to housekeeping', + + // Housekeeping: bulk actions + 'housekeeping.bulk.done': 'Bulk done', + 'housekeeping.bulk.success': 'All bulk actions succeeded.', + 'housekeeping.bulk.partial': 'Bulk completed with some failures.', + 'housekeeping.bulk.failed': 'Every bulk action failed.', + 'housekeeping.bulk.confirm': 'Apply %action% to %count% selected users?', + 'housekeeping.bulk.label': '%count% selected', + 'housekeeping.bulk.clear': 'Clear selection', + 'housekeeping.bulk.apply': 'Apply to selection', + + // Housekeeping: telemetry + 'housekeeping.telemetry.title': 'Telemetry', + 'housekeeping.telemetry.empty': 'No actions observed yet.', + 'housekeeping.telemetry.reset': 'Reset statistics', + + // Housekeeping: live room session + 'housekeeping.live.no_room': 'No active room session.', + 'housekeeping.live.kicked': 'Kicked from the room.', + 'housekeeping.live.banned': 'Banned from the room.', + 'housekeeping.live.muted': 'Muted in the room.', + + // Housekeeping: validation + 'housekeeping.validation.empty_username': 'Username may not be empty.', + 'housekeeping.validation.invalid_user_id': 'Invalid user ID.', + 'housekeeping.validation.invalid_room_id': 'Invalid room ID.', + 'housekeeping.validation.invalid_amount': 'Invalid amount.', + 'housekeeping.validation.amount_too_large': 'Amount exceeds the safety limit.', + 'housekeeping.validation.empty_reason': 'Reason may not be empty.', + 'housekeeping.validation.invalid_hours': 'Invalid duration in hours.', + 'housekeeping.validation.invalid_rank': 'Invalid rank — must be between 1 and 12.', + + // ------------------------------------------------------------------------ + // Fortune Wheel + // ------------------------------------------------------------------------ + 'wheel.title': 'Fortune Wheel', + 'wheel.free.today': 'You have %count% free spins today!', + 'wheel.extra': 'Extra spins: %count%', + 'wheel.spin': 'SPIN', + 'wheel.buy': 'Buy spin', + 'wheel.winners': 'Latest winners', + 'wheel.winners.empty': 'No winners yet', + + // ------------------------------------------------------------------------ + // Soundboard + // ------------------------------------------------------------------------ + 'soundboard.title': 'Soundboard', + 'soundboard.empty': 'No sounds available', + 'soundboard.lastplayed': 'Played by %user%', + 'soundboard.room.setting.desc': 'Let people in this room play sound effects', + + // ------------------------------------------------------------------------ + // Radio + // ------------------------------------------------------------------------ + 'radio.title': 'Radio', + 'radio.empty': 'No stations', + 'radio.error': 'Couldn\'t load stations', + 'radio.stop': 'Stop', + + // ------------------------------------------------------------------------ + // Rare Values + // ------------------------------------------------------------------------ + 'rarevalues.title': 'Rare Values', + 'rarevalues.loading': 'Loading values…', + 'rarevalues.empty': 'No rares found', + 'rarevalues.infostand.label': 'Value:', + + // Rare Values: editor + 'rarevalues.editor.tab': 'Edit', + 'rarevalues.editor.type': 'Type', + 'rarevalues.editor.value': 'Value', + 'rarevalues.editor.weight': 'Chance', + 'rarevalues.editor.label': 'Label', + 'rarevalues.editor.save': 'Save', + 'rarevalues.editor.cat.item': 'Furni (ID)', + 'rarevalues.editor.cat.spin': 'Extra spins', + 'rarevalues.editor.cat.nothing': 'Nothing', + + // ------------------------------------------------------------------------ + // Chat commands: client + // ------------------------------------------------------------------------ + 'chatcmd.client.shake': 'Shake the room', + 'chatcmd.client.rotate': 'Rotate the room', + 'chatcmd.client.zoom': 'Zoom in/out', + 'chatcmd.client.flip': 'Reset zoom', + 'chatcmd.client.iddqd': 'Turn the room upside down', + 'chatcmd.client.screenshot': 'Screenshot of the room', + 'chatcmd.client.togglefps': 'Toggle FPS', + 'chatcmd.client.laugh': 'Laugh (VIP)', + 'chatcmd.client.kiss': 'Blow a kiss (VIP)', + 'chatcmd.client.jump': 'Jump (VIP)', + 'chatcmd.client.idle': 'Go idle', + 'chatcmd.client.sign': 'Show sign', + 'chatcmd.client.furni': 'Furni chooser', + 'chatcmd.client.chooser': 'User chooser', + 'chatcmd.client.floor': 'Floor editor', + 'chatcmd.client.pickall': 'Pick up all furni', + 'chatcmd.client.ejectall': 'Eject all furni', + 'chatcmd.client.settings': 'Room settings', + 'chatcmd.client.info': 'Client info', +} diff --git a/public/configuration/UITexts_it.json5.example b/public/configuration/UITexts_it.json5.example new file mode 100644 index 0000000..2b4c6da --- /dev/null +++ b/public/configuration/UITexts_it.json5.example @@ -0,0 +1,703 @@ +{ + // ------------------------------------------------------------------------ + // Friendlist + // ------------------------------------------------------------------------ + 'friendlist.search': 'Cerca amici', + + // ------------------------------------------------------------------------ + // Purse / Currency + // ------------------------------------------------------------------------ + 'purse.seasonal.currency.101': 'doekoes', + + // ------------------------------------------------------------------------ + // Widget: furni chooser + // ------------------------------------------------------------------------ + 'widget.chooser.checkall': 'Seleziona arredi', + 'widget.chooser.btn.pickall': 'raccogli gli oggetti selezionati!', + + // ------------------------------------------------------------------------ + // Game center + // ------------------------------------------------------------------------ + 'gamecenter.players': 'Giocatori', + 'gamecenter.players.2to6': 'Da 2 a 6 giocatori', + 'gamecenter.players.2to8': 'Da 2 a 8 giocatori', + 'gamecenter.players.4to12': 'Da 4 a 12 giocatori', + 'gamecenter.players.single': 'Giocatore singolo', + 'gamecenter.players.score': 'Punteggio:', + 'gamecenter.players.theme': 'Tema:', + 'gamecenter.players.winner': 'Vincitore!', + + // Game descriptions + 'gamecenter.battleball.description': 'BattleBall è un gioco colorato in cui devi colorare più superfici del tuo avversario. Gli oggetti compaiono in modo casuale e ti danno poteri unici per aumentare le tue possibilità. Tattica, abilità e decisioni rapide sono la chiave della vittoria. Diventerai il campione di BattleBall?', + 'gamecenter.tombrunner.description': 'Questo cacciatore di tesori è determinato a trovare quante più monete antiche possibile mentre corre attraverso corridoi millenari e salta enormi crepe. Nel tuo viaggio attraverso questo infinito gioco di corsa in 3D incontrerai anche ponti instabili e fragili. Scopri quanto a lungo riesci a sopravvivere.', + 'gamecenter.flappybirds.description': 'Flappy Bird è un gioco in stile arcade in cui controlliamo l\'uccellino Faby, che si muove verso destra. Il tuo compito è guidare Faby attraverso i tubi che hanno aperture uguali poste ad altezze casuali.', + 'gamecenter.bargame.description': 'Mostra le tue abilità lavorando nel miglior bar dell\'hotel e servendo i migliori drink ai clienti più esigenti. Cerca di essere il cameriere con le migliori abilità, consegnando i bicchieri per vincere la partita e dimostrare la tua bravura con i cocktail.', + 'gamecenter.roombuildergame.description': 'Sei bravo a costruire stanze? Hai abbastanza fantasia? Accetta la sfida e costruisci una stanza a tema in meno di 6 minuti. La stanza più bella vince!', + + // Game center: voting + 'gamecenter.vote.description': 'Vota le stanze', + 'gamecenter.vote.room.made.by': 'Stanza creata da', + 'gamecenter.vote.room.bestihaveseen': 'Questa è la stanza più bella che abbia mai visto!', + 'gamecenter.vote.room.nice': 'Bella stanza, ben fatto.', + 'gamecenter.vote.room.normal': 'Una stanza OK, né male né eccezionale.', + 'gamecenter.vote.room.couldbebetter': 'Si poteva fare molto meglio', + 'gamecenter.vote.room.bad': 'Aiuto, dov\'è l\'uscita, mi fanno male gli occhi!', + 'gamecenter.vote.room.wait': 'Gli altri giocatori stanno votando la tua stanza, attendi!', + + // ------------------------------------------------------------------------ + // Wired furniture + // ------------------------------------------------------------------------ + 'wiredfurni.params.requireall.2': 'Se uno degli arredi selezionati ha un avatar', + 'wiredfurni.params.requireall.3': 'Se tutti gli arredi selezionati hanno avatar sopra di essi', + 'wiredfurni.tooltip.select.tile': 'Seleziona riquadro', + 'wiredfurni.tooltip.remove.tile': 'Deseleziona riquadro', + 'wiredfurni.tooltip.remove.5x5_tile': 'seleziona riquadri 5x5', + 'wiredfurni.tooltip.remove.clear_tile': 'Rimuovi tutte le selezioni', + 'wiredfurni.params.furni_neighborhood.group.user': 'Giocatori', + 'wiredfurni.params.furni_neighborhood.group.furni': 'Arredi', + 'wiredfurni.params.selector_option.bot': 'Nessun BOT', + 'wiredfurni.params.selector_option.pet': 'Nessun animale', + + // Wired furniture: badge received + 'wiredfurni.badgereceived.title': 'Distintivo ricevuto!', + 'wiredfurni.badgereceived.body': 'Hai appena ricevuto un nuovo distintivo! Guardalo nel tuo inventario!', + + // ------------------------------------------------------------------------ + // Notifications + // ------------------------------------------------------------------------ + 'notification.badge.received': 'Nuovo distintivo!', + + // ------------------------------------------------------------------------ + // Settings widget + // ------------------------------------------------------------------------ + 'widget.settings.general': 'Predefinito', + 'widget.settings.general.title': 'Modifica le impostazioni predefinite di nitro', + 'widget.settings.volume': 'Volume', + 'widget.settings.interface': 'Interfaccia', + 'widget.settings.interface.title': 'Modifica le impostazioni dell\'interfaccia', + 'widget.settings.interface.fps.automatic': 'Imposta FPS su illimitato', + 'widget.settings.interface.fps.warning': 'Impostare gli FPS su illimitato può causare problemi di prestazioni!', + 'widget.settings.interface.secondary': 'Cambia il colore dell\'intestazione della finestra', + 'widget.settings.interface.reset': 'Ripristina il colore dell\'intestazione predefinito', + + // ------------------------------------------------------------------------ + // Room widgets: chat + youtube + // ------------------------------------------------------------------------ + 'widget.room.chat.hide_pets': 'Nascondi animali', + 'widget.room.chat.hide_avatars': 'Nascondi avatar', + 'widget.room.chat.hide_balloon': 'Nascondi fumetto', + 'widget.room.chat.show_balloon': 'Fumetto', + 'widget.room.chat.clear_history': 'cancella cronologia', + 'widget.room.youtube.shared': 'YouTube è condiviso', + 'widget.room.youtube.open_video': 'Apri il video', + + // ------------------------------------------------------------------------ + // Catalog + // ------------------------------------------------------------------------ + + // Catalog: general + 'catalog.title': 'Catalogo', + 'catalog.favorites': 'Preferiti', + 'catalog.favorites.pages': 'Pagine', + 'catalog.favorites.furni': 'Arredi', + 'catalog.favorites.empty': 'Nessun preferito', + 'catalog.favorites.empty.hint': 'Clicca sul cuore sugli arredi o sulla stella sulle pagine per aggiungerli.', + + // Catalog: admin + 'catalog.admin': 'Gestione', + 'catalog.admin.new': 'Nuovo', + 'catalog.admin.root': 'Radice', + 'catalog.admin.new.root.category': 'Nuova categoria radice', + 'catalog.admin.edit.root': 'Modifica radice', + 'catalog.admin.edit': 'Modifica:', + 'catalog.admin.edit.page': 'Modifica pagina', + 'catalog.admin.hidden': 'nascosto', + 'catalog.admin.edit.title': 'Modifica "%name%"', + 'catalog.admin.show': 'Mostra', + 'catalog.admin.hide': 'Nascondi', + 'catalog.admin.delete': 'Elimina', + 'catalog.admin.delete.title': 'Elimina "%name%"', + 'catalog.admin.delete.category.confirm': 'Eliminare la categoria "%name%" e tutto il suo contenuto?', + 'catalog.admin.delete.page': 'Elimina pagina', + 'catalog.admin.delete.page.confirm': 'Eliminare la pagina "%name%"?', + 'catalog.admin.delete.offer.confirm': 'Sei sicuro di voler eliminare questa offerta?', + 'catalog.admin.create': 'Crea', + 'catalog.admin.save': 'Salva', + 'catalog.admin.create.subpage': 'Crea sottopagina', + 'catalog.admin.order': 'Ordine', + 'catalog.admin.visible': 'Visibile', + 'catalog.admin.enabled': 'Abilitato', + + // Catalog admin: offer editor + 'catalog.admin.offer.new': 'Nuova offerta', + 'catalog.admin.offer.edit': 'Modifica offerta', + 'catalog.admin.offer.name': 'Nome catalogo', + 'catalog.admin.offer.general': 'Generale', + 'catalog.admin.offer.quantity': 'Quantità', + 'catalog.admin.offer.prices': 'Prezzi', + 'catalog.admin.offer.credits': 'Crediti', + 'catalog.admin.offer.points': 'Punti', + 'catalog.admin.offer.points.type': 'Tipo di punti', + 'catalog.admin.offer.options': 'Opzioni', + 'catalog.admin.offer.club.only': 'Solo Club', + 'catalog.admin.offer.extradata': 'Dati extra (opzionale)....', + 'catalog.admin.offer.have.offer': 'Multi-sconto (have_offer)', + + // Catalog: trophies + 'catalog.trophies.title': 'Trofei', + 'catalog.trophies.write.hint': 'Scrivi un testo per il trofeo prima di acquistare', + 'catalog.trophies.inscription': 'Iscrizione del trofeo', + 'catalog.trophies.inscription.placeholder': 'Scrivi il testo che apparirà sul trofeo...', + + // Catalog: pets + 'catalog.pets.show.colors': 'Mostra colori', + 'catalog.pets.choose.color': 'Scegli colore', + 'catalog.pets.choose.breed': 'Scegli razza', + 'catalog.pets.back.breeds': '← Razze', + + // Catalog: name prefix editor + 'catalog.prefix.text': 'Testo', + 'catalog.prefix.text.placeholder': 'Inserisci testo...', + 'catalog.prefix.icon': 'Icona', + 'catalog.prefix.icon.remove': 'Rimuovi icona', + 'catalog.prefix.effect': 'Effetto', + 'catalog.prefix.color': 'Colore', + 'catalog.prefix.color.single': '🎨 Singolo', + 'catalog.prefix.color.per.letter': '🌈 Per lettera', + 'catalog.prefix.color.hint': 'Seleziona una lettera e poi scegli il colore. Avanza automaticamente.', + 'catalog.prefix.color.apply.all.title': 'Applica il colore corrente a tutte le lettere', + 'catalog.prefix.color.apply.all': 'Applica a tutte', + 'catalog.prefix.color.selected': 'Lettera selezionata:', + 'catalog.prefix.price': 'Prezzo:', + 'catalog.prefix.price.amount': '5 Crediti', + 'catalog.prefix.purchased': '✓ Acquistato!', + 'catalog.prefix.purchase': 'Acquista', + + // Catalog: gift wrapping + 'catalog.gift_wrapping.gift_sent': 'Fatto!', + + // ------------------------------------------------------------------------ + // Group forum + // ------------------------------------------------------------------------ + 'groupforum.list.tab.most_active': 'Argomenti più attivi', + 'groupforum.list.tab.my_forums': 'I miei forum di gruppo', + 'groupforum.list.no_forums': 'Non ci sono forum', + 'groupforum.view.threads': 'Numero di argomenti', + 'groupforum.thread.pin': 'Fissa argomento', + 'groupforum.thread.unpin': 'Sblocca argomento', + 'groupforum.thread.lock': 'Blocca argomento', + 'groupforum.thread.unlock': 'Sblocca argomento', + 'groupforum.thread.hide': 'Nascondi argomento', + 'groupforum.thread.restore': 'Rendi di nuovo visibile l\'argomento', + 'groupforum.thread.delete': 'Elimina argomento + messaggi', + 'groupforum.message.hide': 'Nascondi messaggio', + 'group.forum.enable.caption': 'Abilita/disabilita forum di gruppo', + 'group.forum.enable.help': 'Se disabiliti il forum di gruppo, verranno eliminati anche tutti i messaggi!', + 'groupforum.view.no_threads': 'Al momento non ci sono argomenti attivi', + + // ------------------------------------------------------------------------ + // Mod tools: window + // ------------------------------------------------------------------------ + 'modtools.window.title': 'Strumenti Mod', + 'modtools.window.tools.room': 'Strumento stanza', + 'modtools.window.tools.chatlog': 'Strumento chatlog', + 'modtools.window.tools.report': 'Strumento segnalazioni', + 'modtools.window.select.user': 'Seleziona un utente', + 'modtools.window.no.room': 'Entra prima in una stanza', + 'modtools.window.user.in_room': 'Ancora in questa stanza', + 'modtools.window.user.left_room': 'Non più in questa stanza', + 'modtools.window.user.clear': 'Cancella selezione', + 'modtools.window.tickets.open': '%count% ticket aperto', + 'modtools.window.tickets.open.many': '%count% ticket aperti', + 'modtools.window.section.room': 'Stanza', + 'modtools.window.section.user': 'Utente', + 'modtools.window.section.reports': 'Segnalazioni', + 'modtools.window.user.open_info': 'Apri info', + + // ------------------------------------------------------------------------ + // Mod tools: user info + // ------------------------------------------------------------------------ + 'modtools.userinfo.title': 'Info utente: %username%', + 'modtools.userinfo.userName': 'Nome', + 'modtools.userinfo.cfhCount': 'CFH', + 'modtools.userinfo.abusiveCfhCount': 'CFH abusivi', + 'modtools.userinfo.cautionCount': 'Avvertimenti', + 'modtools.userinfo.banCount': 'Ban', + 'modtools.userinfo.lastSanctionTime': 'Ultima sanzione', + 'modtools.userinfo.tradingLockCount': 'Blocchi scambio', + 'modtools.userinfo.tradingExpiryDate': 'Blocco scade', + 'modtools.userinfo.minutesSinceLastLogin': 'Ultimo accesso', + 'modtools.userinfo.lastPurchaseDate': 'Ultimo acquisto', + 'modtools.userinfo.primaryEmailAddress': 'Email', + 'modtools.userinfo.identityRelatedBanCount': 'Account bannati', + 'modtools.userinfo.registrationAgeInMinutes': 'Registrato', + 'modtools.userinfo.userClassification': 'Grado', + 'modtools.userinfo.refresh': 'Aggiorna info utente', + 'modtools.userinfo.presence.in_room': 'In stanza', + 'modtools.userinfo.presence.in_room.title': 'Nella stanza che stai osservando', + 'modtools.userinfo.presence.online': 'Online', + 'modtools.userinfo.presence.online.title': 'Online nell\'hotel', + 'modtools.userinfo.presence.offline': 'Offline', + 'modtools.userinfo.presence.offline.title': 'Offline all\'apertura del pannello', + 'modtools.userinfo.section.account': 'Account', + 'modtools.userinfo.section.activity': 'Attività', + 'modtools.userinfo.section.sanctions': 'Sanzioni', + 'modtools.userinfo.section.trading': 'Scambi', + 'modtools.userinfo.button.room.chat': 'Chat stanza', + 'modtools.userinfo.button.send.message': 'Invia messaggio', + 'modtools.userinfo.button.room.visits': 'Visite stanze', + 'modtools.userinfo.button.mod.action': 'Azione mod', + 'modtools.userinfo.stat.cfh': 'CFH', + 'modtools.userinfo.stat.cautions': 'Avvertimenti', + 'modtools.userinfo.stat.bans': 'Ban', + 'modtools.userinfo.stat.trade.locks': 'Blocchi scambio', + + // ------------------------------------------------------------------------ + // Mod tools: room info + // ------------------------------------------------------------------------ + 'modtools.roominfo.title': 'Info stanza', + 'modtools.roominfo.refresh': 'Aggiorna info stanza', + 'modtools.roominfo.loading': 'Caricamento…', + 'modtools.roominfo.owner.here': 'Proprietario presente', + 'modtools.roominfo.owner.away': 'Proprietario assente', + 'modtools.roominfo.owner.title.here': 'Il proprietario della stanza è attualmente all\'interno', + 'modtools.roominfo.owner.title.away': 'Il proprietario della stanza NON è all\'interno', + 'modtools.roominfo.stat.users': 'Utenti', + 'modtools.roominfo.stat.owner': 'Proprietario', + 'modtools.roominfo.owner.open': 'Apri le info di %username%', + 'modtools.roominfo.button.visit': 'Visita stanza', + 'modtools.roominfo.button.chatlog': 'Chatlog', + 'modtools.roominfo.moderate.title': 'Modera stanza', + 'modtools.roominfo.moderate.kick': 'Caccia tutti', + 'modtools.roominfo.moderate.doorbell': 'Abilita campanello', + 'modtools.roominfo.moderate.rename': 'Cambia nome stanza', + 'modtools.roominfo.moderate.message.placeholder': 'Messaggio obbligatorio inviato insieme all\'azione…', + 'modtools.roominfo.moderate.send.caution': 'Invia avvertimento', + 'modtools.roominfo.moderate.send.alert': 'Invia avviso', + + // ------------------------------------------------------------------------ + // Mod tools: user message + // ------------------------------------------------------------------------ + 'modtools.user.message.title': 'Invia messaggio', + 'modtools.user.message.recipient': 'Messaggio a', + 'modtools.user.message.label': 'Messaggio', + 'modtools.user.message.placeholder': 'Scrivi qualcosa di utile — l\'utente lo vede come un messaggio del moderatore.', + 'modtools.user.message.empty': 'Vuoto', + 'modtools.user.message.chars': '%count% caratteri', + 'modtools.user.message.send': 'Invia messaggio', + + // ------------------------------------------------------------------------ + // Mod tools: mod action + // ------------------------------------------------------------------------ + 'modtools.user.modaction.title': 'Azione mod: %username%', + 'modtools.user.modaction.sanctioning': 'Sanzionamento', + 'modtools.user.modaction.step.topic': '1. Argomento CFH', + 'modtools.user.modaction.step.topic.placeholder': 'Seleziona un argomento…', + 'modtools.user.modaction.step.sanction': '2. Sanzione', + 'modtools.user.modaction.step.sanction.placeholder': 'Seleziona una sanzione…', + 'modtools.user.modaction.step.message': '3. Messaggio personalizzato', + 'modtools.user.modaction.step.message.optional': '(opzionale — sostituisce il predefinito)', + 'modtools.user.modaction.message.placeholder': 'Lascia vuoto per usare il messaggio predefinito dell\'argomento', + 'modtools.user.modaction.preview': 'Anteprima', + 'modtools.user.modaction.button.default': 'Sanzione predefinita', + 'modtools.user.modaction.button.apply': 'Applica sanzione', + 'modtools.user.modaction.error.no.topic': 'Devi selezionare un argomento CFH', + 'modtools.user.modaction.error.no.action': 'Devi selezionare un argomento CFH e una sanzione', + 'modtools.user.modaction.error.no.permission': 'Non hai il permesso di farlo', + 'modtools.user.modaction.error.no.message': 'Scrivi un messaggio all\'utente', + 'modtools.user.modaction.error.no.permission.alert': 'Non hai diritti sufficienti', + + // ------------------------------------------------------------------------ + // Mod tools: user visits + // ------------------------------------------------------------------------ + 'modtools.user.visits.title': 'Visite utente', + 'modtools.user.visits.recent': 'Stanze visitate di recente', + 'modtools.user.visits.entries.one': '%count% voce', + 'modtools.user.visits.entries.many': '%count% voci', + 'modtools.user.visits.empty': 'Nessuna visita recente', + 'modtools.user.visits.time': 'Ora', + 'modtools.user.visits.room': 'Nome stanza', + 'modtools.user.visits.action': 'Azione', + 'modtools.user.visits.visit': 'Visita', + 'modtools.user.visits.visit.title': 'Visita stanza', + + // ------------------------------------------------------------------------ + // Mod tools: chatlog + // ------------------------------------------------------------------------ + 'modtools.user.chatlog.title': 'Chatlog utente', + 'modtools.user.chatlog.title.with': 'Chatlog utente: %username%', + 'modtools.user.chatlog.loading': 'Caricamento chatlog…', + 'modtools.room.chatlog.title': 'Chatlog stanza', + 'modtools.chatlog.column.time': 'Ora', + 'modtools.chatlog.column.user': 'Utente', + 'modtools.chatlog.column.message': 'Messaggio', + 'modtools.chatlog.empty': 'Nessun messaggio', + 'modtools.chatlog.visit': 'Visita', + 'modtools.chatlog.tools': 'Strumenti', + + // ------------------------------------------------------------------------ + // Mod tools: tickets + // ------------------------------------------------------------------------ + 'modtools.tickets.title': 'Ticket', + 'modtools.tickets.tab.open': 'Aperti', + 'modtools.tickets.tab.mine': 'Miei', + 'modtools.tickets.tab.picked': 'Tutti presi', + 'modtools.tickets.column.type': 'Tipo', + 'modtools.tickets.column.reported': 'Segnalato', + 'modtools.tickets.column.opened': 'Aperto', + 'modtools.tickets.column.picker': 'Preso in carico da', + 'modtools.tickets.empty.open': 'Nessuna segnalazione aperta', + 'modtools.tickets.empty.mine': 'Nessuna segnalazione presa in carico da te', + 'modtools.tickets.empty.picked': 'Nessuna segnalazione presa in carico', + 'modtools.tickets.action.pick': 'Prendi in carico', + 'modtools.tickets.action.handle': 'Gestisci', + 'modtools.tickets.action.release': 'Rilascia', + 'modtools.tickets.issue.title': 'Risolvi segnalazione #%issueId%', + 'modtools.tickets.issue.label': 'Segnalazione #%issueId%', + 'modtools.tickets.issue.details': 'Dettagli', + 'modtools.tickets.issue.field.source': 'Origine', + 'modtools.tickets.issue.field.category': 'Categoria', + 'modtools.tickets.issue.field.description': 'Descrizione', + 'modtools.tickets.issue.field.caller': 'Segnalatore', + 'modtools.tickets.issue.field.reported': 'Segnalato', + 'modtools.tickets.issue.chatlog.view': 'Visualizza chatlog', + 'modtools.tickets.issue.chatlog.close': 'Chiudi chatlog', + 'modtools.tickets.issue.resolve.heading': 'Risolvi come', + 'modtools.tickets.issue.resolve.resolved': 'Risolto', + 'modtools.tickets.issue.resolve.useless': 'Inutile', + 'modtools.tickets.issue.resolve.abusive': 'Abusivo', + 'modtools.tickets.issue.release': 'Rimetti in coda', + 'modtools.tickets.cfh.chatlog.title': 'Chatlog segnalazione #%issueId%', + + // ------------------------------------------------------------------------ + // Login + // ------------------------------------------------------------------------ + 'login.username': 'Qual è il tuo nome habbo', + 'login.forgot_password': 'Password dimenticata?', + + // First-time visitors card + 'nitro.login.firsttime.title': 'È la prima volta qui?', + 'nitro.login.firsttime.text': 'Non hai ancora un account habbo?', + 'nitro.login.firsttime.link': 'Puoi crearne uno qui', + 'nitro.login.card.title': 'Accedi a habbo', + + // Server status checks + 'nitro.login.server.offline.short': 'Il server di gioco al momento non è attivo. Riprova tra poco.', + 'nitro.login.server.offline.long': 'Il server di gioco al momento non è attivo, quindi non è possibile creare nuovi account. Riprova tra poco.', + 'nitro.login.server.checking': 'Verifica in corso…', + 'nitro.login.server.retry': 'Riprova', + + // Registration flow + 'nitro.login.register.title': 'Dati habbo', + 'nitro.login.register.next': 'Avanti', + 'nitro.login.register.finish': 'Completa', + 'nitro.login.register.creating': 'Creazione in corso…', + 'nitro.login.register.intro.credentials': 'Creiamo il tuo account. Inserisci il tuo indirizzo email e scegli una password — verificheremo che questa email non sia già in uso.', + 'nitro.login.register.intro.avatar': 'Ora è il momento di creare il tuo personaggio habbo! Inizia scegliendo il tuo nome habbo.', + 'nitro.login.register.intro.room': 'Ultimo passaggio — scegli una stanza iniziale, oppure salta e crea la tua stanza più tardi.', + 'nitro.login.register.confirm.label': 'Conferma password', + 'nitro.login.register.username.placeholder': 'NomeHabbo', + 'nitro.login.register.hotlooks.count': '%count% look disponibili', + 'nitro.login.register.hotlooks.none': 'Nessun look caricato', + 'nitro.login.register.room.skip.title': 'Va bene — creerò le mie stanze', + 'nitro.login.register.room.skip.description': 'Salta questo passaggio e inizia con un inventario hotel vuoto.', + 'nitro.login.register.room.loading': 'Caricamento stanze…', + 'nitro.login.register.room.error': 'Impossibile caricare le opzioni delle stanze. Puoi comunque saltare questo passaggio.', + 'nitro.login.register.success': 'Benvenuto a bordo, %username%! Il tuo account è pronto — accedi qui sotto con la password che hai appena scelto.', + + // Forgot password + 'nitro.login.forgot.title': 'Reimposta password', + 'nitro.login.forgot.email.label': 'Indirizzo email', + 'nitro.login.forgot.send': 'Invia email', + 'nitro.login.forgot.success': 'Email inviata! Se a questo indirizzo è associato un account, troverai presto un link per il reset nella tua casella di posta (controlla lo spam se non vedi nulla entro un minuto).', + + // Login errors (validation + transport) + 'nitro.login.error.missing_credentials': 'Inserisci sia il tuo nome habbo che la password.', + 'nitro.login.error.invalid_credentials': 'Nome habbo o password non validi.', + 'nitro.login.error.too_many_attempts': 'Troppi tentativi. Riprova tra %seconds%s.', + 'nitro.login.error.turnstile': 'Completa il controllo di sicurezza.', + 'nitro.login.error.server_offline': 'Il server di gioco non è attivo. Riprova più tardi.', + 'nitro.login.error.login_unreachable': 'Impossibile raggiungere il servizio di accesso. Riprova.', + 'nitro.login.error.register_failed': 'Impossibile creare il tuo account.', + 'nitro.login.error.register_unreachable': 'Impossibile raggiungere il servizio di registrazione.', + 'nitro.login.error.forgot_failed': 'Impossibile inviare ora un\'email di reset.', + 'nitro.login.error.forgot_unreachable': 'Impossibile raggiungere il servizio di reset password.', + 'nitro.login.error.missing_fields': 'Compila tutti i campi.', + 'nitro.login.error.invalid_email': 'Inserisci un indirizzo email valido.', + 'nitro.login.error.password_too_short': 'La tua password deve contenere almeno 8 caratteri.', + 'nitro.login.error.password_mismatch': 'Le password non corrispondono.', + 'nitro.login.error.email_taken': 'Questo indirizzo email è già in uso.', + 'nitro.login.error.missing_username': 'Scegli un nome habbo.', + 'nitro.login.error.username_length': 'Il nome habbo deve contenere 3–16 caratteri.', + 'nitro.login.error.username_taken': 'Questo nome habbo è già in uso.', + 'nitro.login.error.missing_email': 'Inserisci il tuo indirizzo email.', + + // ------------------------------------------------------------------------ + // Inventory + // ------------------------------------------------------------------------ + 'inventory.effects.activate': 'Usa effetto', + 'inventory.effects.remove': 'rimuovi effetto', + + // ------------------------------------------------------------------------ + // Loading screen — boot-stage labels read by App.tsx (taskLabel) + // ------------------------------------------------------------------------ + 'loading.task.session': 'Verifica della sessione...', + 'loading.task.renderer': 'Inizializzazione del renderer...', + 'loading.task.assets': 'Caricamento risorse di gioco...', + 'loading.task.localization': 'Caricamento traduzioni...', + 'loading.task.avatar': 'Caricamento guardaroba...', + 'loading.task.sounds': 'Caricamento suoni...', + 'loading.task.startsession': 'Avvio della sessione...', + 'loading.task.userdata': 'Caricamento dati utente...', + 'loading.task.rooms': 'Caricamento stanze...', + 'loading.task.engine': 'Caricamento motore grafico...', + + // ------------------------------------------------------------------------ + // Housekeeping + // ------------------------------------------------------------------------ + 'housekeeping.title': 'Gestione', + 'housekeeping.mode.light': 'Chiaro', + + // Housekeeping: tabs + 'housekeeping.tab.dashboard': 'Dashboard', + 'housekeeping.tab.users': 'Utenti', + 'housekeeping.tab.rooms': 'Stanze', + 'housekeeping.tab.economy': 'Economia', + 'housekeeping.tab.audit': 'Registro', + + // Housekeeping: confirm + status + 'housekeeping.confirm.title': 'Conferma azione', + 'housekeeping.confirm.proceed': 'Procedi', + 'housekeeping.confirm.cancel': 'Annulla', + 'housekeeping.status.dismiss': 'Chiudi', + + // Housekeeping: action status + 'housekeeping.action.pending': 'Azione in corso…', + 'housekeeping.action.success': 'Azione completata', + 'housekeeping.action.error': 'Azione fallita', + 'housekeeping.action.reset_password.done': 'Password reimpostata — nuova password qui sotto.', + + // Housekeeping: generated password card + 'housekeeping.password.title': '%username% (#%id%) · nuova password', + 'housekeeping.password.value_label': 'Password generata', + 'housekeeping.password.copy': 'Copia', + 'housekeeping.password.copied': 'Copiato', + 'housekeeping.password.copy_failed': 'Copia fallita', + 'housekeeping.password.dismiss': 'Chiudi', + 'housekeeping.password.hint': 'Condividi questa password con l\'utente al di fuori dell\'hotel. Viene mostrata una sola volta — chiudi questa scheda quando hai finito; la password non verrà mai più visualizzata.', + + // Housekeeping: errors + 'housekeeping.error.invalid_input': 'Input non valido — controlla l\'ID utente e il valore inserito.', + 'housekeeping.error.user_not_found': 'Utente non trovato.', + 'housekeeping.error.user_offline': 'L\'utente è offline — questa azione funziona solo su utenti online.', + 'housekeeping.error.target_unkickable': 'Questo utente non può essere cacciato.', + 'housekeeping.error.ban_failed': 'Impossibile applicare il ban — il server ha rifiutato la richiesta.', + 'housekeeping.error.no_active_ban': 'Nessun ban attivo da revocare per questo utente.', + 'housekeeping.error.rank_not_found': 'Grado non trovato — scegli un grado che esiste in permission_ranks.', + 'housekeeping.error.db_failed': 'Errore del database — consulta il log dell\'emulatore per l\'eccezione SQL.', + 'housekeeping.error.hash_failed': 'Impossibile generare l\'hash della nuova password — SHA-256 non disponibile su questa JVM.', + 'housekeeping.error.room_not_found': 'Stanza non trovata.', + 'housekeeping.error.room_action_failed': 'Impossibile applicare l\'azione sulla stanza.', + 'housekeeping.error.new_owner_not_found': 'Nuovo proprietario non trovato.', + 'housekeeping.error.economy_failed': 'Impossibile applicare l\'azione economica — controlla l\'ID utente e la quantità.', + 'housekeeping.error.alert_empty': 'L\'avviso hotel non può essere vuoto.', + + // Housekeeping: actions + 'housekeeping.action.ban_h': 'Ban %h%h', + 'housekeeping.action.mute_min': 'Muta %m%m', + 'housekeeping.action.trade_lock_h': 'Blocco scambio %h%h', + 'housekeeping.action.kick': 'Caccia', + 'housekeeping.action.unban': 'Revoca ban', + 'housekeeping.action.force_disconnect': 'Disconnetti', + 'housekeeping.action.set_rank': 'Imposta grado', + 'housekeeping.action.reset_password': 'Reimposta password', + + // Housekeeping: user panel + 'housekeeping.user.search.placeholder': 'Cerca per nome utente…', + 'housekeeping.user.search.button': 'Cerca', + 'housekeeping.user.clear': 'Cancella selezione', + 'housekeeping.user.none': 'Nessun utente selezionato — cerca sopra per sceglierne uno.', + 'housekeeping.user.not_found': 'Utente non trovato.', + 'housekeeping.user.credits': 'Crediti', + 'housekeeping.user.duckets': 'Duckets / pixel', + 'housekeeping.user.diamonds': 'Diamanti', + 'housekeeping.user.audit_hint': 'Tutte le azioni vengono registrate nella scheda del registro.', + 'housekeeping.user.live.label': 'Live (nella stanza corrente)', + 'housekeeping.user.live.kick': 'Caccia', + 'housekeeping.user.live.mute_2m': 'Muta 2m', + 'housekeeping.user.live.mute_10m': 'Muta 10m', + 'housekeeping.user.live.ban_h': 'Ban 1h', + 'housekeeping.user.live.ban_d': 'Ban 1g', + + // Housekeeping: room panel + 'housekeeping.room.search.placeholder': 'ID stanza…', + 'housekeeping.room.search.button': 'Cerca', + 'housekeeping.room.clear': 'Cancella selezione', + 'housekeeping.room.none': 'Nessuna stanza selezionata — inserisci un ID sopra.', + 'housekeeping.room.not_found': 'Stanza non trovata.', + 'housekeeping.room.open': 'Apri', + 'housekeeping.room.close': 'Chiudi', + 'housekeeping.room.mute_min': 'Muta %m%m', + 'housekeeping.room.kick_all': 'Caccia tutti', + 'housekeeping.room.kick_all.confirm': 'Cacciare ogni utente attualmente nella stanza?', + 'housekeeping.room.delete': 'Elimina stanza', + 'housekeeping.room.delete.confirm': 'Eliminare definitivamente questa stanza e tutti i suoi arredi?', + 'housekeeping.room.transfer': 'Trasferisci', + 'housekeeping.room.transfer.label': 'Trasferisci proprietà', + 'housekeeping.room.transfer.new_owner': 'ID nuovo proprietario', + + // Housekeeping: economy + 'housekeeping.economy.select_user': 'Scegli prima un utente nella scheda Utenti.', + 'housekeeping.economy.target': 'Destinatario: %username% (#%id%)', + 'housekeeping.economy.give_credits': 'Dai crediti', + 'housekeeping.economy.give_duckets': 'Dai duckets', + 'housekeeping.economy.give_diamonds': 'Dai diamanti', + 'housekeeping.economy.grant_item': 'Assegna oggetto', + 'housekeeping.economy.grant_item.label': 'Assegna oggetto del catalogo', + 'housekeeping.economy.item_id': 'ID oggetto', + 'housekeeping.economy.item_quantity': 'Quantità', + 'housekeeping.economy.set_hc_days': 'Imposta giorni HC', + + // Housekeeping: hotel-wide alert + 'housekeeping.hotel.alert.label': 'Avviso a tutto l\'hotel', + 'housekeeping.hotel.alert.placeholder': 'Messaggio trasmesso a ogni utente connesso…', + 'housekeeping.hotel.alert.send': 'Invia all\'hotel', + 'housekeeping.hotel.alert.confirm': 'Trasmettere un avviso di %count% caratteri a ogni utente connesso?', + + // Housekeeping: dashboard + 'housekeeping.dashboard.title': 'Panoramica', + 'housekeeping.dashboard.refresh': 'Aggiorna', + 'housekeeping.dashboard.loading': 'Caricamento dashboard…', + 'housekeeping.dashboard.unavailable': 'Dashboard non disponibile — controlla l\'endpoint admin.', + 'housekeeping.dashboard.online': 'Online', + 'housekeeping.dashboard.total_users': '%count% totali', + 'housekeeping.dashboard.rooms_active': 'Stanze attive', + 'housekeeping.dashboard.total_rooms': '%count% totali', + 'housekeeping.dashboard.peak_today': 'Picco di oggi', + 'housekeeping.dashboard.peak_alltime': 'Picco di sempre %count%', + 'housekeeping.dashboard.pending_tickets': 'Ticket', + 'housekeeping.dashboard.sanctions_24h': '%count% sanzioni / 24h', + 'housekeeping.dashboard.server': 'Server', + 'housekeeping.dashboard.recent_sanctions': 'Sanzioni recenti', + 'housekeeping.dashboard.recent_lookups': 'Ricerche recenti', + + // Housekeeping: audit log + 'housekeeping.audit.title': 'Registro', + 'housekeeping.audit.refresh': 'Aggiorna', + 'housekeeping.audit.filter.all': 'Tutti', + 'housekeeping.audit.filter.users': 'Utenti', + 'housekeeping.audit.filter.rooms': 'Stanze', + 'housekeeping.audit.filter.hotel': 'Hotel', + 'housekeeping.audit.search.placeholder': 'Cerca esecutore / destinatario / azione…', + 'housekeeping.audit.empty': 'Nessuna voce di registro ancora.', + 'housekeeping.audit.no_match': 'Nessuna voce corrisponde ai filtri attuali.', + + // Housekeeping: shared fields + 'housekeeping.field.reason': 'Motivo', + 'housekeeping.field.reason.placeholder': 'Motivo libero (opzionale)', + 'housekeeping.field.duration': 'Durata', + 'housekeeping.reason.default': 'Nessun motivo fornito.', + + // Housekeeping: context menu + 'housekeeping.menu.send_to_hk': 'Invia alla gestione', + + // Housekeeping: bulk actions + 'housekeeping.bulk.done': 'Azione di massa completata', + 'housekeeping.bulk.success': 'Tutte le azioni di massa sono riuscite.', + 'housekeeping.bulk.partial': 'Azione di massa completata con alcuni errori.', + 'housekeeping.bulk.failed': 'Ogni azione di massa è fallita.', + 'housekeeping.bulk.confirm': 'Applicare %action% a %count% utenti selezionati?', + 'housekeeping.bulk.label': '%count% selezionati', + 'housekeeping.bulk.clear': 'Cancella selezione', + 'housekeeping.bulk.apply': 'Applica alla selezione', + + // Housekeeping: telemetry + 'housekeeping.telemetry.title': 'Telemetria', + 'housekeeping.telemetry.empty': 'Nessuna azione osservata ancora.', + 'housekeeping.telemetry.reset': 'Reimposta statistiche', + + // Housekeeping: live room session + 'housekeeping.live.no_room': 'Nessuna sessione di stanza attiva.', + 'housekeeping.live.kicked': 'Cacciato dalla stanza.', + 'housekeeping.live.banned': 'Bannato dalla stanza.', + 'housekeeping.live.muted': 'Mutato nella stanza.', + + // Housekeeping: validation + 'housekeeping.validation.empty_username': 'Il nome utente non può essere vuoto.', + 'housekeeping.validation.invalid_user_id': 'ID utente non valido.', + 'housekeeping.validation.invalid_room_id': 'ID stanza non valido.', + 'housekeeping.validation.invalid_amount': 'Quantità non valida.', + 'housekeeping.validation.amount_too_large': 'La quantità supera il limite di sicurezza.', + 'housekeeping.validation.empty_reason': 'Il motivo non può essere vuoto.', + 'housekeeping.validation.invalid_hours': 'Durata in ore non valida.', + 'housekeeping.validation.invalid_rank': 'Grado non valido — deve essere compreso tra 1 e 12.', + + // ------------------------------------------------------------------------ + // Fortune Wheel + // ------------------------------------------------------------------------ + 'wheel.title': 'Ruota della Fortuna', + 'wheel.free.today': 'Hai %count% giri gratuiti oggi!', + 'wheel.extra': 'Giri extra: %count%', + 'wheel.spin': 'GIRA', + 'wheel.buy': 'Acquista giro', + 'wheel.winners': 'Ultimi vincitori', + 'wheel.winners.empty': 'Ancora nessun vincitore', + + // ------------------------------------------------------------------------ + // Soundboard + // ------------------------------------------------------------------------ + 'soundboard.title': 'Soundboard', + 'soundboard.empty': 'Nessun suono disponibile', + 'soundboard.lastplayed': 'Riprodotto da %user%', + 'soundboard.room.setting.desc': 'Permetti alle persone in questa stanza di riprodurre effetti sonori', + + // ------------------------------------------------------------------------ + // Radio + // ------------------------------------------------------------------------ + 'radio.title': 'Radio', + 'radio.empty': 'Nessuna stazione', + 'radio.error': 'Impossibile caricare le stazioni', + 'radio.stop': 'Ferma', + + // ------------------------------------------------------------------------ + // Rare Values + // ------------------------------------------------------------------------ + 'rarevalues.title': 'Valori dei Rari', + 'rarevalues.loading': 'Caricamento valori…', + 'rarevalues.empty': 'Nessun raro trovato', + 'rarevalues.infostand.label': 'Valore:', + + // Rare Values: editor + 'rarevalues.editor.tab': 'Modifica', + 'rarevalues.editor.type': 'Tipo', + 'rarevalues.editor.value': 'Valore', + 'rarevalues.editor.weight': 'Probabilità', + 'rarevalues.editor.label': 'Etichetta', + 'rarevalues.editor.save': 'Salva', + 'rarevalues.editor.cat.item': 'Arredo (ID)', + 'rarevalues.editor.cat.spin': 'Giri extra', + 'rarevalues.editor.cat.nothing': 'Niente', + + // ------------------------------------------------------------------------ + // Chat commands: client + // ------------------------------------------------------------------------ + 'chatcmd.client.shake': 'Scuoti la stanza', + 'chatcmd.client.rotate': 'Ruota la stanza', + 'chatcmd.client.zoom': 'Zoom avanti/indietro', + 'chatcmd.client.flip': 'Reimposta zoom', + 'chatcmd.client.iddqd': 'Capovolgi la stanza', + 'chatcmd.client.screenshot': 'Screenshot della stanza', + 'chatcmd.client.togglefps': 'Attiva/disattiva FPS', + 'chatcmd.client.laugh': 'Ridi (VIP)', + 'chatcmd.client.kiss': 'Manda un bacio (VIP)', + 'chatcmd.client.jump': 'Salta (VIP)', + 'chatcmd.client.idle': 'Vai inattivo', + 'chatcmd.client.sign': 'Mostra cartello', + 'chatcmd.client.furni': 'Selettore arredi', + 'chatcmd.client.chooser': 'Selettore utenti', + 'chatcmd.client.floor': 'Editor pavimento', + 'chatcmd.client.pickall': 'Raccogli tutti gli arredi', + 'chatcmd.client.ejectall': 'Rimuovi tutti gli arredi', + 'chatcmd.client.settings': 'Impostazioni stanza', + 'chatcmd.client.info': 'Info client', +} diff --git a/public/configuration/UITexts_nl.json5.example b/public/configuration/UITexts_nl.json5.example new file mode 100644 index 0000000..68f003f --- /dev/null +++ b/public/configuration/UITexts_nl.json5.example @@ -0,0 +1,703 @@ +{ + // ------------------------------------------------------------------------ + // Friendlist + // ------------------------------------------------------------------------ + 'friendlist.search': 'Zoek vrienden', + + // ------------------------------------------------------------------------ + // Purse / Currency + // ------------------------------------------------------------------------ + 'purse.seasonal.currency.101': "doekoe's", + + // ------------------------------------------------------------------------ + // Widget: furni chooser + // ------------------------------------------------------------------------ + 'widget.chooser.checkall': 'Selecteer meubels', + 'widget.chooser.btn.pickall': 'pak de geselecteerde items op!', + + // ------------------------------------------------------------------------ + // Game center + // ------------------------------------------------------------------------ + 'gamecenter.players': 'Spelers', + 'gamecenter.players.2to6': '2 tot 6 spelers', + 'gamecenter.players.2to8': '2 tot 8 spelers', + 'gamecenter.players.4to12': '4 tot 12 spelers', + 'gamecenter.players.single': 'Één speler', + 'gamecenter.players.score': 'Score:', + 'gamecenter.players.theme': 'Thema:', + 'gamecenter.players.winner': 'Winnaar!', + + // Game descriptions + 'gamecenter.battleball.description': 'BattleBall is een kleurrijk spel waarin je meer oppervlakken moet kleuren dan je tegenstander. Items verschijnen willekeurig en geven je unieke krachten om je kansen te vergroten. Tactiek, vaardigheid en snelle beslissingen zijn de sleutel tot de overwinning. Word jij de kampioen van BattleBall?', + 'gamecenter.tombrunner.description': 'Deze schatzoeker is vastbesloten om zoveel mogelijk oude munten te vinden terwijl hij door eeuwenoude gangen loopt en over enorme scheuren springt. Op je reis door dit eindeloze 3D-hardloopspel kom je ook onstabiele en kwetsbare bruggen tegen. Ontdek hoe lang je kunt overleven.', + 'gamecenter.flappybirds.description': 'Flappy Bird is een spel in arcadestijl waarin we de Faby-vogel besturen die naar rechts beweegt. Het is jouw taak om Faby door pijpen te loodsen die op willekeurige hoogte gelijke openingen hebben.', + 'gamecenter.bargame.description': 'Toon uw vaardigheden door in de beste bar van het hotel te werken en de beste drankjes te serveren aan de meest veeleisende klanten. Probeer de ober te zijn met de beste vaardigheden die glazen aflevert om het spel te winnen en demonstreer je vaardigheden bij het werken met cocktails.', + 'gamecenter.roombuildergame.description': 'Ben jij goed in het bouwen van kamers? Heb je voldoende fantasie? Ga de strijd aan en bouw in minder dan 6 minuten een kamer rond een thema. De mooiste kamer wint!', + + // Game center: voting + 'gamecenter.vote.description': 'Stem op de kamers', + 'gamecenter.vote.room.made.by': 'Kamer gemaakt door', + 'gamecenter.vote.room.bestihaveseen': 'Dit is de mooiste kamer die ik ooit heb gezien!', + 'gamecenter.vote.room.nice': 'Prima kamer, leuk gedaan.', + 'gamecenter.vote.room.normal': 'Een OK kamer, niet slecht en niet super cool.', + 'gamecenter.vote.room.couldbebetter': 'Dit had veel beter gekund', + 'gamecenter.vote.room.bad': 'Help waar is de uitgang, mijn ogen doen pijn!', + 'gamecenter.vote.room.wait': 'De andere spelers zijn nu aan het stemmen op jou kamer, even geduld!', + + // ------------------------------------------------------------------------ + // Wired furniture + // ------------------------------------------------------------------------ + 'wiredfurni.params.requireall.2': 'Als een van de geselecteerde furni een avatar heeft', + 'wiredfurni.params.requireall.3': 'Als alle geselecteerde furni avatars op hen hebben', + 'wiredfurni.tooltip.select.tile': 'Selecteer tegel', + 'wiredfurni.tooltip.remove.tile': 'Deselecteer tegel', + 'wiredfurni.tooltip.remove.5x5_tile': 'selecteer 5x5 tegels', + 'wiredfurni.tooltip.remove.clear_tile': 'Verwijder alle selecties', + 'wiredfurni.params.furni_neighborhood.group.user': 'Speelers', + 'wiredfurni.params.furni_neighborhood.group.furni': 'Meubels', + 'wiredfurni.params.selector_option.bot': "Geen BOT's", + 'wiredfurni.params.selector_option.pet': 'Geen Huisdieren', + + // Wired furniture: badge received + 'wiredfurni.badgereceived.title': 'Badge ontvangen!', + 'wiredfurni.badgereceived.body': 'Je hebt zojuist een nieuwe badge ontvangen! Bekijk hem in je inventaris!', + + // ------------------------------------------------------------------------ + // Notifications + // ------------------------------------------------------------------------ + 'notification.badge.received': 'Nieuwe badge!', + + // ------------------------------------------------------------------------ + // Settings widget + // ------------------------------------------------------------------------ + 'widget.settings.general': 'Standaard', + 'widget.settings.general.title': 'Pas de standaard nitro settings aan', + 'widget.settings.volume': 'Volume', + 'widget.settings.interface': 'Interface', + 'widget.settings.interface.title': 'Pas de settings aan voor de interface', + 'widget.settings.interface.fps.automatic': 'Zet FPS naar unlimited', + 'widget.settings.interface.fps.warning': 'Het zetten van FPS naar unlimited kan prestatie problemen veroorzaken!', + 'widget.settings.interface.secondary': 'Verander de window header kleur', + 'widget.settings.interface.reset': 'Reset header kleur naar default', + + // ------------------------------------------------------------------------ + // Room widgets: chat + youtube + // ------------------------------------------------------------------------ + 'widget.room.chat.hide_pets': 'Verberg dieren', + 'widget.room.chat.hide_avatars': 'Verberg avatars', + 'widget.room.chat.hide_balloon': 'Verberg Spreekballon', + 'widget.room.chat.show_balloon': 'Spreekballon', + 'widget.room.chat.clear_history': 'leeg geschiedenis', + 'widget.room.youtube.shared': 'YouTube word gedeeld', + 'widget.room.youtube.open_video': 'Open de video', + + // ------------------------------------------------------------------------ + // Catalog + // ------------------------------------------------------------------------ + + // Catalog: general + 'catalog.title': 'Catalogus', + 'catalog.favorites': 'Favorieten', + 'catalog.favorites.pages': 'Pagina’s', + 'catalog.favorites.furni': 'Furni', + 'catalog.favorites.empty': 'Geen favorieten', + 'catalog.favorites.empty.hint': 'Klik op het hartje bij furni of de ster bij pagina’s om ze toe te voegen.', + + // Catalog: admin + 'catalog.admin': 'Beheer', + 'catalog.admin.new': 'Nieuw', + 'catalog.admin.root': 'Hoofdmap', + 'catalog.admin.new.root.category': 'Nieuwe hoofdcategorie', + 'catalog.admin.edit.root': 'Hoofdmap bewerken', + 'catalog.admin.edit': 'Bewerken:', + 'catalog.admin.edit.page': 'Pagina bewerken', + 'catalog.admin.hidden': 'verborgen', + 'catalog.admin.edit.title': '"%name%" bewerken', + 'catalog.admin.show': 'Tonen', + 'catalog.admin.hide': 'Verbergen', + 'catalog.admin.delete': 'Verwijderen', + 'catalog.admin.delete.title': '"%name%" verwijderen', + 'catalog.admin.delete.category.confirm': 'Categorie "%name%" en alle inhoud verwijderen?', + 'catalog.admin.delete.page': 'Pagina verwijderen', + 'catalog.admin.delete.page.confirm': 'Pagina "%name%" verwijderen?', + 'catalog.admin.delete.offer.confirm': 'Weet je zeker dat je deze aanbieding wilt verwijderen?', + 'catalog.admin.create': 'Aanmaken', + 'catalog.admin.save': 'Opslaan', + 'catalog.admin.create.subpage': 'Subpagina aanmaken', + 'catalog.admin.order': 'Volgorde', + 'catalog.admin.visible': 'Zichtbaar', + 'catalog.admin.enabled': 'Ingeschakeld', + + // Catalog admin: offer editor + 'catalog.admin.offer.new': 'Nieuwe aanbieding', + 'catalog.admin.offer.edit': 'Aanbieding bewerken', + 'catalog.admin.offer.name': 'Catalogusnaam', + 'catalog.admin.offer.general': 'Algemeen', + 'catalog.admin.offer.quantity': 'Aantal', + 'catalog.admin.offer.prices': 'Prijzen', + 'catalog.admin.offer.credits': 'Credits', + 'catalog.admin.offer.points': 'Punten', + 'catalog.admin.offer.points.type': 'Type punten', + 'catalog.admin.offer.options': 'Opties', + 'catalog.admin.offer.club.only': 'Alleen Club', + 'catalog.admin.offer.extradata': 'Extra data (optioneel)....', + 'catalog.admin.offer.have.offer': 'Multi-korting (have_offer)', + + // Catalog: trophies + 'catalog.trophies.title': 'Trofeeën', + 'catalog.trophies.write.hint': 'Schrijf een tekst voor de trofee voordat je koopt', + 'catalog.trophies.inscription': 'Trofee-inscriptie', + 'catalog.trophies.inscription.placeholder': 'Schrijf de tekst die op de trofee komt te staan...', + + // Catalog: pets + 'catalog.pets.show.colors': 'Toon kleuren', + 'catalog.pets.choose.color': 'Kies kleur', + 'catalog.pets.choose.breed': 'Kies ras', + 'catalog.pets.back.breeds': '← Rassen', + + // Catalog: name prefix editor + 'catalog.prefix.text': 'Tekst', + 'catalog.prefix.text.placeholder': 'Voer tekst in...', + 'catalog.prefix.icon': 'Icoon', + 'catalog.prefix.icon.remove': 'Icoon verwijderen', + 'catalog.prefix.effect': 'Effect', + 'catalog.prefix.color': 'Kleur', + 'catalog.prefix.color.single': '🎨 Enkel', + 'catalog.prefix.color.per.letter': '🌈 Per letter', + 'catalog.prefix.color.hint': 'Selecteer een letter en kies vervolgens de kleur. Gaat automatisch door.', + 'catalog.prefix.color.apply.all.title': 'Huidige kleur op alle letters toepassen', + 'catalog.prefix.color.apply.all': 'Op alles toepassen', + 'catalog.prefix.color.selected': 'Geselecteerde letter:', + 'catalog.prefix.price': 'Prijs:', + 'catalog.prefix.price.amount': '5 Credits', + 'catalog.prefix.purchased': '✓ Gekocht!', + 'catalog.prefix.purchase': 'Kopen', + + // Catalog: gift wrapping + 'catalog.gift_wrapping.gift_sent': 'Klaar!', + + // ------------------------------------------------------------------------ + // Group forum + // ------------------------------------------------------------------------ + 'groupforum.list.tab.most_active': 'Meest actieve onderwerpen', + 'groupforum.list.tab.my_forums': 'Mijn groepsforums', + 'groupforum.list.no_forums': 'Er zijn geen forums', + 'groupforum.view.threads': 'Aantal onderwerpen', + 'groupforum.thread.pin': 'Onderwerp vastpinnen', + 'groupforum.thread.unpin': 'Onderwerp losmaken', + 'groupforum.thread.lock': 'Onderwerp vergrendelen', + 'groupforum.thread.unlock': 'Onderwerp ontgrendelen', + 'groupforum.thread.hide': 'Onderwerp verbergen', + 'groupforum.thread.restore': 'Onderwerp weer zichtbaar maken', + 'groupforum.thread.delete': 'Onderwerp + berichten verwijderen', + 'groupforum.message.hide': 'Bericht verbergen', + 'group.forum.enable.caption': 'Groepsforum in-/uitschakelen', + 'group.forum.enable.help': 'Als je het groepsforum uitschakelt, worden ook alle berichten verwijderd!', + 'groupforum.view.no_threads': 'Er zijn op dit moment geen actieve onderwerpen', + + // ------------------------------------------------------------------------ + // Mod tools: window + // ------------------------------------------------------------------------ + 'modtools.window.title': 'Mod Tools', + 'modtools.window.tools.room': 'Kamertool', + 'modtools.window.tools.chatlog': 'Chatlogtool', + 'modtools.window.tools.report': 'Rapporttool', + 'modtools.window.select.user': 'Selecteer een gebruiker', + 'modtools.window.no.room': 'Ga eerst een kamer binnen', + 'modtools.window.user.in_room': 'Nog steeds in deze kamer', + 'modtools.window.user.left_room': 'Niet langer in deze kamer', + 'modtools.window.user.clear': 'Selectie wissen', + 'modtools.window.tickets.open': '%count% open ticket', + 'modtools.window.tickets.open.many': '%count% open tickets', + 'modtools.window.section.room': 'Kamer', + 'modtools.window.section.user': 'Gebruiker', + 'modtools.window.section.reports': 'Rapporten', + 'modtools.window.user.open_info': 'Info openen', + + // ------------------------------------------------------------------------ + // Mod tools: user info + // ------------------------------------------------------------------------ + 'modtools.userinfo.title': 'Gebruikersinfo: %username%', + 'modtools.userinfo.userName': 'Naam', + 'modtools.userinfo.cfhCount': 'CFH’s', + 'modtools.userinfo.abusiveCfhCount': 'Misbruikte CFH’s', + 'modtools.userinfo.cautionCount': 'Waarschuwingen', + 'modtools.userinfo.banCount': 'Bans', + 'modtools.userinfo.lastSanctionTime': 'Laatste sanctie', + 'modtools.userinfo.tradingLockCount': 'Ruilblokkades', + 'modtools.userinfo.tradingExpiryDate': 'Blokkade verloopt', + 'modtools.userinfo.minutesSinceLastLogin': 'Laatste login', + 'modtools.userinfo.lastPurchaseDate': 'Laatste aankoop', + 'modtools.userinfo.primaryEmailAddress': 'E-mail', + 'modtools.userinfo.identityRelatedBanCount': 'Verbannen accounts', + 'modtools.userinfo.registrationAgeInMinutes': 'Geregistreerd', + 'modtools.userinfo.userClassification': 'Rang', + 'modtools.userinfo.refresh': 'Gebruikersinfo vernieuwen', + 'modtools.userinfo.presence.in_room': 'In kamer', + 'modtools.userinfo.presence.in_room.title': 'In de kamer die je observeert', + 'modtools.userinfo.presence.online': 'Online', + 'modtools.userinfo.presence.online.title': 'Online op het hotel', + 'modtools.userinfo.presence.offline': 'Offline', + 'modtools.userinfo.presence.offline.title': 'Offline bij openen paneel', + 'modtools.userinfo.section.account': 'Account', + 'modtools.userinfo.section.activity': 'Activiteit', + 'modtools.userinfo.section.sanctions': 'Sancties', + 'modtools.userinfo.section.trading': 'Ruilen', + 'modtools.userinfo.button.room.chat': 'Kamerchat', + 'modtools.userinfo.button.send.message': 'Bericht verzenden', + 'modtools.userinfo.button.room.visits': 'Kamerbezoeken', + 'modtools.userinfo.button.mod.action': 'Mod-actie', + 'modtools.userinfo.stat.cfh': 'CFH', + 'modtools.userinfo.stat.cautions': 'Waarschuwingen', + 'modtools.userinfo.stat.bans': 'Bans', + 'modtools.userinfo.stat.trade.locks': 'Ruilblokkades', + + // ------------------------------------------------------------------------ + // Mod tools: room info + // ------------------------------------------------------------------------ + 'modtools.roominfo.title': 'Kamerinfo', + 'modtools.roominfo.refresh': 'Kamerinfo vernieuwen', + 'modtools.roominfo.loading': 'Laden…', + 'modtools.roominfo.owner.here': 'Eigenaar aanwezig', + 'modtools.roominfo.owner.away': 'Eigenaar afwezig', + 'modtools.roominfo.owner.title.here': 'De kamereigenaar is op dit moment binnen', + 'modtools.roominfo.owner.title.away': 'De kamereigenaar is NIET binnen', + 'modtools.roominfo.stat.users': 'Gebruikers', + 'modtools.roominfo.stat.owner': 'Eigenaar', + 'modtools.roominfo.owner.open': 'Info van %username% openen', + 'modtools.roominfo.button.visit': 'Kamer bezoeken', + 'modtools.roominfo.button.chatlog': 'Chatlog', + 'modtools.roominfo.moderate.title': 'Kamer modereren', + 'modtools.roominfo.moderate.kick': 'Iedereen eruit kicken', + 'modtools.roominfo.moderate.doorbell': 'Deurbel inschakelen', + 'modtools.roominfo.moderate.rename': 'Kamernaam wijzigen', + 'modtools.roominfo.moderate.message.placeholder': 'Verplicht bericht dat met de actie wordt meegestuurd…', + 'modtools.roominfo.moderate.send.caution': 'Waarschuwing sturen', + 'modtools.roominfo.moderate.send.alert': 'Melding sturen', + + // ------------------------------------------------------------------------ + // Mod tools: user message + // ------------------------------------------------------------------------ + 'modtools.user.message.title': 'Bericht verzenden', + 'modtools.user.message.recipient': 'Bericht aan', + 'modtools.user.message.label': 'Bericht', + 'modtools.user.message.placeholder': 'Schrijf iets nuttigs — de gebruiker ziet het als een moderatorbericht.', + 'modtools.user.message.empty': 'Leeg', + 'modtools.user.message.chars': '%count% tekens', + 'modtools.user.message.send': 'Bericht verzenden', + + // ------------------------------------------------------------------------ + // Mod tools: mod action + // ------------------------------------------------------------------------ + 'modtools.user.modaction.title': 'Mod-actie: %username%', + 'modtools.user.modaction.sanctioning': 'Sanctioneren', + 'modtools.user.modaction.step.topic': '1. CFH-onderwerp', + 'modtools.user.modaction.step.topic.placeholder': 'Selecteer een onderwerp…', + 'modtools.user.modaction.step.sanction': '2. Sanctie', + 'modtools.user.modaction.step.sanction.placeholder': 'Selecteer een sanctie…', + 'modtools.user.modaction.step.message': '3. Eigen bericht', + 'modtools.user.modaction.step.message.optional': '(optioneel — overschrijft standaard)', + 'modtools.user.modaction.message.placeholder': 'Laat leeg om het standaard onderwerpbericht te gebruiken', + 'modtools.user.modaction.preview': 'Voorbeeld', + 'modtools.user.modaction.button.default': 'Standaardsanctie', + 'modtools.user.modaction.button.apply': 'Sanctie toepassen', + 'modtools.user.modaction.error.no.topic': 'Je moet een CFH-onderwerp selecteren', + 'modtools.user.modaction.error.no.action': 'Je moet een CFH-onderwerp en sanctie selecteren', + 'modtools.user.modaction.error.no.permission': 'Je hebt geen toestemming om dit te doen', + 'modtools.user.modaction.error.no.message': 'Schrijf een bericht aan de gebruiker', + 'modtools.user.modaction.error.no.permission.alert': 'Je hebt onvoldoende rechten', + + // ------------------------------------------------------------------------ + // Mod tools: user visits + // ------------------------------------------------------------------------ + 'modtools.user.visits.title': 'Gebruikersbezoeken', + 'modtools.user.visits.recent': 'Recent bezochte kamers', + 'modtools.user.visits.entries.one': '%count% vermelding', + 'modtools.user.visits.entries.many': '%count% vermeldingen', + 'modtools.user.visits.empty': 'Geen recente bezoeken', + 'modtools.user.visits.time': 'Tijd', + 'modtools.user.visits.room': 'Kamernaam', + 'modtools.user.visits.action': 'Actie', + 'modtools.user.visits.visit': 'Bezoeken', + 'modtools.user.visits.visit.title': 'Kamer bezoeken', + + // ------------------------------------------------------------------------ + // Mod tools: chatlog + // ------------------------------------------------------------------------ + 'modtools.user.chatlog.title': 'Gebruikerschatlog', + 'modtools.user.chatlog.title.with': 'Gebruikerschatlog: %username%', + 'modtools.user.chatlog.loading': 'Chatlog laden…', + 'modtools.room.chatlog.title': 'Kamerchatlog', + 'modtools.chatlog.column.time': 'Tijd', + 'modtools.chatlog.column.user': 'Gebruiker', + 'modtools.chatlog.column.message': 'Bericht', + 'modtools.chatlog.empty': 'Geen berichten', + 'modtools.chatlog.visit': 'Bezoeken', + 'modtools.chatlog.tools': 'Tools', + + // ------------------------------------------------------------------------ + // Mod tools: tickets + // ------------------------------------------------------------------------ + 'modtools.tickets.title': 'Tickets', + 'modtools.tickets.tab.open': 'Open', + 'modtools.tickets.tab.mine': 'Mijn', + 'modtools.tickets.tab.picked': 'Alle opgepakt', + 'modtools.tickets.column.type': 'Type', + 'modtools.tickets.column.reported': 'Gerapporteerd', + 'modtools.tickets.column.opened': 'Geopend', + 'modtools.tickets.column.picker': 'Opgepakt door', + 'modtools.tickets.empty.open': 'Geen open meldingen', + 'modtools.tickets.empty.mine': 'Geen door jou opgepakte meldingen', + 'modtools.tickets.empty.picked': 'Geen opgepakte meldingen', + 'modtools.tickets.action.pick': 'Oppakken', + 'modtools.tickets.action.handle': 'Afhandelen', + 'modtools.tickets.action.release': 'Vrijgeven', + 'modtools.tickets.issue.title': 'Melding #%issueId% oplossen', + 'modtools.tickets.issue.label': 'Melding #%issueId%', + 'modtools.tickets.issue.details': 'Details', + 'modtools.tickets.issue.field.source': 'Bron', + 'modtools.tickets.issue.field.category': 'Categorie', + 'modtools.tickets.issue.field.description': 'Beschrijving', + 'modtools.tickets.issue.field.caller': 'Melder', + 'modtools.tickets.issue.field.reported': 'Gerapporteerd', + 'modtools.tickets.issue.chatlog.view': 'Chatlog bekijken', + 'modtools.tickets.issue.chatlog.close': 'Chatlog sluiten', + 'modtools.tickets.issue.resolve.heading': 'Oplossen als', + 'modtools.tickets.issue.resolve.resolved': 'Opgelost', + 'modtools.tickets.issue.resolve.useless': 'Nutteloos', + 'modtools.tickets.issue.resolve.abusive': 'Misbruik', + 'modtools.tickets.issue.release': 'Terug in wachtrij plaatsen', + 'modtools.tickets.cfh.chatlog.title': 'Melding #%issueId% chatlog', + + // ------------------------------------------------------------------------ + // Login + // ------------------------------------------------------------------------ + 'login.username': 'Wat is jou Camwijs naam', + 'login.forgot_password': 'Wachtwoord vergeten?', + + // First-time visitors card + 'nitro.login.firsttime.title': 'Voor het eerst hier?', + 'nitro.login.firsttime.text': 'Heb je nog geen Camwijs account?', + 'nitro.login.firsttime.link': 'Je kunt er hier een aanmaken', + 'nitro.login.card.title': 'Aanmelden bij Camwijs', + + // Server status checks + 'nitro.login.server.offline.short': 'De gameserver draait momenteel niet. Probeer het zo meteen opnieuw.', + 'nitro.login.server.offline.long': 'De gameserver draait momenteel niet, dus er kunnen geen nieuwe accounts worden aangemaakt. Probeer het zo meteen opnieuw.', + 'nitro.login.server.checking': 'Controleren…', + 'nitro.login.server.retry': 'Opnieuw proberen', + + // Registration flow + 'nitro.login.register.title': 'Camwijs-gegevens', + 'nitro.login.register.next': 'Volgende', + 'nitro.login.register.finish': 'Voltooien', + 'nitro.login.register.creating': 'Bezig met aanmaken…', + 'nitro.login.register.intro.credentials': 'Laten we je account aanmaken. Voer je e-mailadres in en kies een wachtwoord — we controleren of dit e-mailadres nog niet in gebruik is.', + 'nitro.login.register.intro.avatar': 'Nu is het tijd om je eigen Camwijs-personage te maken! Begin met het kiezen van je Camwijs-naam.', + 'nitro.login.register.intro.room': 'Laatste stap — kies een startkamer, of sla dit over en maak later je eigen kamer.', + 'nitro.login.register.confirm.label': 'Bevestig wachtwoord', + 'nitro.login.register.username.placeholder': 'HabboNaam', + 'nitro.login.register.hotlooks.count': '%count% looks beschikbaar', + 'nitro.login.register.hotlooks.none': 'Geen looks geladen', + 'nitro.login.register.room.skip.title': 'Prima — ik maak mijn eigen kamers', + 'nitro.login.register.room.skip.description': 'Sla dit over en begin met een lege hotelinventaris.', + 'nitro.login.register.room.loading': 'Kamers laden…', + 'nitro.login.register.room.error': 'Kon kameropties niet laden. Je kunt deze stap nog steeds overslaan.', + 'nitro.login.register.success': 'Welkom aan boord, %username%! Je account is klaar — log hieronder in met het wachtwoord dat je zojuist hebt gekozen.', + + // Forgot password + 'nitro.login.forgot.title': 'Wachtwoord resetten', + 'nitro.login.forgot.email.label': 'E-mailadres', + 'nitro.login.forgot.send': 'E-mail verzenden', + 'nitro.login.forgot.success': 'E-mail verzonden! Als er een account bij dit adres hoort, vind je binnenkort een resetlink in je inbox (controleer je spam als je binnen een minuut niets ziet).', + + // Login errors (validation + transport) + 'nitro.login.error.missing_credentials': 'Voer zowel je Camwijs-naam als wachtwoord in.', + 'nitro.login.error.invalid_credentials': 'Ongeldige Camwijs-naam of wachtwoord.', + 'nitro.login.error.too_many_attempts': 'Te veel pogingen. Probeer het opnieuw over %seconds%s.', + 'nitro.login.error.turnstile': 'Voltooi de beveiligingscontrole.', + 'nitro.login.error.server_offline': 'De gameserver draait niet. Probeer het later opnieuw.', + 'nitro.login.error.login_unreachable': 'Kan de inlogservice niet bereiken. Probeer het opnieuw.', + 'nitro.login.error.register_failed': 'Kan je account niet aanmaken.', + 'nitro.login.error.register_unreachable': 'Kan de registratieservice niet bereiken.', + 'nitro.login.error.forgot_failed': 'Kan momenteel geen reset-e-mail verzenden.', + 'nitro.login.error.forgot_unreachable': 'Kan de wachtwoordresetservice niet bereiken.', + 'nitro.login.error.missing_fields': 'Vul alle velden in.', + 'nitro.login.error.invalid_email': 'Voer een geldig e-mailadres in.', + 'nitro.login.error.password_too_short': 'Je wachtwoord moet minimaal 8 tekens lang zijn.', + 'nitro.login.error.password_mismatch': 'Wachtwoorden komen niet overeen.', + 'nitro.login.error.email_taken': 'Dit e-mailadres is al in gebruik.', + 'nitro.login.error.missing_username': 'Kies een Camwijs-naam.', + 'nitro.login.error.username_length': 'De Camwijs-naam moet 3–16 tekens bevatten.', + 'nitro.login.error.username_taken': 'Deze Camwijs-naam is al in gebruik.', + 'nitro.login.error.missing_email': 'Voer je e-mailadres in.', + + // ------------------------------------------------------------------------ + // Inventory + // ------------------------------------------------------------------------ + 'inventory.effects.activate': 'Gebruik effect', + 'inventory.effects.remove': 'verwijder effect', + + // ------------------------------------------------------------------------ + // Loading screen — boot-stage labels read by App.tsx (taskLabel) + // ------------------------------------------------------------------------ + 'loading.task.session': 'Sessie verifiëren...', + 'loading.task.renderer': 'Renderer initialiseren...', + 'loading.task.assets': 'Spelmiddelen laden...', + 'loading.task.localization': 'Vertalingen laden...', + 'loading.task.avatar': 'Garderobe laden...', + 'loading.task.sounds': 'Geluiden laden...', + 'loading.task.startsession': 'Sessie starten...', + 'loading.task.userdata': 'Gebruikersgegevens laden...', + 'loading.task.rooms': 'Kamers laden...', + 'loading.task.engine': 'Grafische engine laden...', + + // ------------------------------------------------------------------------ + // Housekeeping + // ------------------------------------------------------------------------ + 'housekeeping.title': 'Beheer', + 'housekeeping.mode.light': 'Licht', + + // Housekeeping: tabs + 'housekeeping.tab.dashboard': 'Dashboard', + 'housekeeping.tab.users': 'Gebruikers', + 'housekeeping.tab.rooms': 'Kamers', + 'housekeeping.tab.economy': 'Economie', + 'housekeeping.tab.audit': 'Logboek', + + // Housekeeping: confirm + status + 'housekeeping.confirm.title': 'Actie bevestigen', + 'housekeeping.confirm.proceed': 'Doorgaan', + 'housekeeping.confirm.cancel': 'Annuleren', + 'housekeeping.status.dismiss': 'Sluiten', + + // Housekeeping: action status + 'housekeeping.action.pending': 'Actie bezig…', + 'housekeeping.action.success': 'Actie voltooid', + 'housekeeping.action.error': 'Actie mislukt', + 'housekeeping.action.reset_password.done': 'Wachtwoord gereset — nieuw wachtwoord hieronder.', + + // Housekeeping: generated password card + 'housekeeping.password.title': '%username% (#%id%) · nieuw wachtwoord', + 'housekeeping.password.value_label': 'Gegenereerd wachtwoord', + 'housekeeping.password.copy': 'Kopiëren', + 'housekeeping.password.copied': 'Gekopieerd', + 'housekeeping.password.copy_failed': 'Kopiëren mislukt', + 'housekeeping.password.dismiss': 'Sluiten', + 'housekeeping.password.hint': 'Deel dit buiten het hotel om met de gebruiker. Dit wordt eenmalig getoond — sluit deze kaart als je klaar bent; het wachtwoord wordt nooit meer weergegeven.', + + // Housekeeping: errors + 'housekeeping.error.invalid_input': 'Ongeldige invoer — controleer de gebruikers-ID en de ingevoerde waarde.', + 'housekeeping.error.user_not_found': 'Gebruiker niet gevonden.', + 'housekeeping.error.user_offline': 'Gebruiker is offline — deze actie werkt alleen bij online gebruikers.', + 'housekeeping.error.target_unkickable': 'Deze gebruiker kan niet gekickt worden.', + 'housekeeping.error.ban_failed': 'Ban kon niet worden toegepast — de server weigerde het verzoek.', + 'housekeeping.error.no_active_ban': 'Geen actieve ban om op te heffen voor deze gebruiker.', + 'housekeeping.error.rank_not_found': 'Rang niet gevonden — kies een rang die bestaat in permission_ranks.', + 'housekeeping.error.db_failed': 'Databasefout — zie het emulator-log voor de SQL-uitzondering.', + 'housekeeping.error.hash_failed': 'Kon het nieuwe wachtwoord niet hashen — SHA-256 niet beschikbaar op deze JVM.', + 'housekeeping.error.room_not_found': 'Kamer niet gevonden.', + 'housekeeping.error.room_action_failed': 'Kameractie kon niet worden toegepast.', + 'housekeeping.error.new_owner_not_found': 'Nieuwe eigenaar niet gevonden.', + 'housekeeping.error.economy_failed': 'Economie-actie kon niet worden toegepast — controleer de gebruikers-ID en het aantal.', + 'housekeeping.error.alert_empty': 'Hotelmelding mag niet leeg zijn.', + + // Housekeeping: actions + 'housekeeping.action.ban_h': 'Ban %h%u', + 'housekeeping.action.mute_min': 'Mute %m%m', + 'housekeeping.action.trade_lock_h': 'Ruilblokkade %h%u', + 'housekeeping.action.kick': 'Kick', + 'housekeeping.action.unban': 'Ban opheffen', + 'housekeeping.action.force_disconnect': 'Verbinding verbreken', + 'housekeeping.action.set_rank': 'Rang instellen', + 'housekeeping.action.reset_password': 'Wachtwoord resetten', + + // Housekeeping: user panel + 'housekeeping.user.search.placeholder': 'Zoek op gebruikersnaam…', + 'housekeeping.user.search.button': 'Zoeken', + 'housekeeping.user.clear': 'Selectie wissen', + 'housekeeping.user.none': 'Geen gebruiker geselecteerd — zoek hierboven om er een te kiezen.', + 'housekeeping.user.not_found': 'Gebruiker niet gevonden.', + 'housekeeping.user.credits': 'Credits', + 'housekeeping.user.duckets': 'Duckets / pixels', + 'housekeeping.user.diamonds': 'Diamonds', + 'housekeeping.user.audit_hint': 'Alle acties worden vastgelegd in het logboek-tabblad.', + 'housekeeping.user.live.label': 'Live (in huidige kamer)', + 'housekeeping.user.live.kick': 'Kick', + 'housekeeping.user.live.mute_2m': 'Mute 2m', + 'housekeeping.user.live.mute_10m': 'Mute 10m', + 'housekeeping.user.live.ban_h': 'Ban 1u', + 'housekeeping.user.live.ban_d': 'Ban 1d', + + // Housekeeping: room panel + 'housekeeping.room.search.placeholder': 'Kamer-ID…', + 'housekeeping.room.search.button': 'Zoeken', + 'housekeeping.room.clear': 'Selectie wissen', + 'housekeeping.room.none': 'Geen kamer geselecteerd — voer hierboven een ID in.', + 'housekeeping.room.not_found': 'Kamer niet gevonden.', + 'housekeeping.room.open': 'Openen', + 'housekeeping.room.close': 'Sluiten', + 'housekeeping.room.mute_min': 'Mute %m%m', + 'housekeeping.room.kick_all': 'Iedereen kicken', + 'housekeeping.room.kick_all.confirm': 'Elke gebruiker die nu in de kamer is kicken?', + 'housekeeping.room.delete': 'Kamer verwijderen', + 'housekeeping.room.delete.confirm': 'Deze kamer en alle meubels permanent verwijderen?', + 'housekeeping.room.transfer': 'Overdragen', + 'housekeeping.room.transfer.label': 'Eigendom overdragen', + 'housekeeping.room.transfer.new_owner': 'ID nieuwe eigenaar', + + // Housekeeping: economy + 'housekeeping.economy.select_user': 'Kies eerst een gebruiker in het tabblad Gebruikers.', + 'housekeeping.economy.target': 'Doel: %username% (#%id%)', + 'housekeeping.economy.give_credits': 'Credits geven', + 'housekeeping.economy.give_duckets': 'Duckets geven', + 'housekeeping.economy.give_diamonds': 'Diamonds geven', + 'housekeeping.economy.grant_item': 'Item toekennen', + 'housekeeping.economy.grant_item.label': 'Catalogusitem toekennen', + 'housekeeping.economy.item_id': 'Item-ID', + 'housekeeping.economy.item_quantity': 'Aantal', + 'housekeeping.economy.set_hc_days': 'HC-dagen instellen', + + // Housekeeping: hotel-wide alert + 'housekeeping.hotel.alert.label': 'Hotelbrede melding', + 'housekeeping.hotel.alert.placeholder': 'Bericht dat naar elke verbonden gebruiker wordt uitgezonden…', + 'housekeeping.hotel.alert.send': 'Naar hotel sturen', + 'housekeeping.hotel.alert.confirm': 'Melding van %count% tekens naar elke verbonden gebruiker uitzenden?', + + // Housekeeping: dashboard + 'housekeeping.dashboard.title': 'Overzicht', + 'housekeeping.dashboard.refresh': 'Vernieuwen', + 'housekeeping.dashboard.loading': 'Dashboard laden…', + 'housekeeping.dashboard.unavailable': 'Dashboard niet beschikbaar — controleer het admin-endpoint.', + 'housekeeping.dashboard.online': 'Online', + 'housekeeping.dashboard.total_users': '%count% totaal', + 'housekeeping.dashboard.rooms_active': 'Actieve kamers', + 'housekeeping.dashboard.total_rooms': '%count% totaal', + 'housekeeping.dashboard.peak_today': 'Piek vandaag', + 'housekeeping.dashboard.peak_alltime': 'Aller-tijden piek %count%', + 'housekeeping.dashboard.pending_tickets': 'Tickets', + 'housekeeping.dashboard.sanctions_24h': '%count% sancties / 24u', + 'housekeeping.dashboard.server': 'Server', + 'housekeeping.dashboard.recent_sanctions': 'Recente sancties', + 'housekeeping.dashboard.recent_lookups': 'Recente opzoekingen', + + // Housekeeping: audit log + 'housekeeping.audit.title': 'Logboek', + 'housekeeping.audit.refresh': 'Vernieuwen', + 'housekeeping.audit.filter.all': 'Alle', + 'housekeeping.audit.filter.users': 'Gebruikers', + 'housekeeping.audit.filter.rooms': 'Kamers', + 'housekeeping.audit.filter.hotel': 'Hotel', + 'housekeeping.audit.search.placeholder': 'Zoek uitvoerder / doel / actie…', + 'housekeeping.audit.empty': 'Nog geen logboekvermeldingen.', + 'housekeeping.audit.no_match': 'Geen vermeldingen komen overeen met de huidige filters.', + + // Housekeeping: shared fields + 'housekeeping.field.reason': 'Reden', + 'housekeeping.field.reason.placeholder': 'Vrije reden (optioneel)', + 'housekeeping.field.duration': 'Duur', + 'housekeeping.reason.default': 'Geen reden opgegeven.', + + // Housekeeping: context menu + 'housekeeping.menu.send_to_hk': 'Naar beheer sturen', + + // Housekeeping: bulk actions + 'housekeeping.bulk.done': 'Bulk klaar', + 'housekeeping.bulk.success': 'Alle bulkacties geslaagd.', + 'housekeeping.bulk.partial': 'Bulk voltooid met enkele mislukkingen.', + 'housekeeping.bulk.failed': 'Elke bulkactie is mislukt.', + 'housekeeping.bulk.confirm': '%action% toepassen op %count% geselecteerde gebruikers?', + 'housekeeping.bulk.label': '%count% geselecteerd', + 'housekeeping.bulk.clear': 'Selectie wissen', + 'housekeeping.bulk.apply': 'Toepassen op selectie', + + // Housekeeping: telemetry + 'housekeeping.telemetry.title': 'Telemetrie', + 'housekeeping.telemetry.empty': 'Nog geen acties waargenomen.', + 'housekeeping.telemetry.reset': 'Statistieken resetten', + + // Housekeeping: live room session + 'housekeeping.live.no_room': 'Geen actieve kamersessie.', + 'housekeeping.live.kicked': 'Uit de kamer gekickt.', + 'housekeeping.live.banned': 'Verbannen uit de kamer.', + 'housekeeping.live.muted': 'Gemute in de kamer.', + + // Housekeeping: validation + 'housekeeping.validation.empty_username': 'Gebruikersnaam mag niet leeg zijn.', + 'housekeeping.validation.invalid_user_id': 'Ongeldige gebruikers-ID.', + 'housekeeping.validation.invalid_room_id': 'Ongeldige kamer-ID.', + 'housekeeping.validation.invalid_amount': 'Ongeldig aantal.', + 'housekeeping.validation.amount_too_large': 'Aantal overschrijdt de veiligheidslimiet.', + 'housekeeping.validation.empty_reason': 'Reden mag niet leeg zijn.', + 'housekeeping.validation.invalid_hours': 'Ongeldige duur in uren.', + 'housekeeping.validation.invalid_rank': 'Ongeldige rang — moet tussen 1 en 12 liggen.', + + // ------------------------------------------------------------------------ + // Fortune Wheel + // ------------------------------------------------------------------------ + 'wheel.title': 'Rad van Fortuin', + 'wheel.free.today': 'Je hebt vandaag %count% gratis draaibeurten!', + 'wheel.extra': 'Extra draaibeurten: %count%', + 'wheel.spin': 'DRAAIEN', + 'wheel.buy': 'Draaibeurt kopen', + 'wheel.winners': 'Laatste winnaars', + 'wheel.winners.empty': 'Nog geen winnaars', + + // ------------------------------------------------------------------------ + // Soundboard + // ------------------------------------------------------------------------ + 'soundboard.title': 'Soundboard', + 'soundboard.empty': 'Geen geluiden beschikbaar', + 'soundboard.lastplayed': 'Afgespeeld door %user%', + 'soundboard.room.setting.desc': 'Laat mensen in deze kamer geluidseffecten afspelen', + + // ------------------------------------------------------------------------ + // Radio + // ------------------------------------------------------------------------ + 'radio.title': 'Radio', + 'radio.empty': 'Geen stations', + 'radio.error': 'Kon de stations niet laden', + 'radio.stop': 'Stoppen', + + // ------------------------------------------------------------------------ + // Rare Values + // ------------------------------------------------------------------------ + 'rarevalues.title': 'Zeldzame waarden', + 'rarevalues.loading': 'Waarden laden…', + 'rarevalues.empty': 'Geen rares gevonden', + 'rarevalues.infostand.label': 'Waarde:', + + // Rare Values: editor + 'rarevalues.editor.tab': 'Bewerken', + 'rarevalues.editor.type': 'Type', + 'rarevalues.editor.value': 'Waarde', + 'rarevalues.editor.weight': 'Kans', + 'rarevalues.editor.label': 'Label', + 'rarevalues.editor.save': 'Opslaan', + 'rarevalues.editor.cat.item': 'Meubel (ID)', + 'rarevalues.editor.cat.spin': 'Extra draaien', + 'rarevalues.editor.cat.nothing': 'Niets', + + // ------------------------------------------------------------------------ + // Chat commands: client + // ------------------------------------------------------------------------ + 'chatcmd.client.shake': 'Schud de kamer', + 'chatcmd.client.rotate': 'Draai de kamer', + 'chatcmd.client.zoom': 'Zoom in/uit', + 'chatcmd.client.flip': 'Reset zoom', + 'chatcmd.client.iddqd': 'Zet kamer op zijn kop', + 'chatcmd.client.screenshot': 'Schermafbeelding van de kamer', + 'chatcmd.client.togglefps': 'FPS aan/uit', + 'chatcmd.client.laugh': 'Lach (VIP)', + 'chatcmd.client.kiss': 'Stuur een kus (VIP)', + 'chatcmd.client.jump': 'Spring (VIP)', + 'chatcmd.client.idle': 'Ga afwezig', + 'chatcmd.client.sign': 'Toon bordje', + 'chatcmd.client.furni': 'Meubelkiezer', + 'chatcmd.client.chooser': 'Gebruikerskiezer', + 'chatcmd.client.floor': 'Vloer-editor', + 'chatcmd.client.pickall': 'Pak alle meubels op', + 'chatcmd.client.ejectall': 'Verwijder alle meubels', + 'chatcmd.client.settings': 'Kamerinstellingen', + 'chatcmd.client.info': 'Client info', +} diff --git a/public/configuration/renderer-config.example b/public/configuration/renderer-config.example index 5745409..792be76 100644 --- a/public/configuration/renderer-config.example +++ b/public/configuration/renderer-config.example @@ -12,7 +12,7 @@ "sounds.url": "${asset.url}/sounds/%sample%.mp3", "external.texts.url": [ "${gamedata.url}/ExternalTexts.json?t=%timestamp%", - "${gamedata.url}/UITexts.json?t=%timestamp%" + "${gamedata.url}/UITexts.json5?t=%timestamp%" ], "external.texts.translation.url": "${gamedata.url}/text_translate/ExternalTexts_%locale%.json?t=%timestamp%", "external.samples.url": "${hof.furni.url}/mp3/sound_machine_sample_%sample%.mp3", diff --git a/public/configuration/wheel-texts-en.example b/public/configuration/wheel-texts-en.example deleted file mode 100644 index 0ba7b3e..0000000 --- a/public/configuration/wheel-texts-en.example +++ /dev/null @@ -1,17 +0,0 @@ -{ - "wheel.title": "Fortune Wheel", - "wheel.free.today": "You have %count% free spins today!", - "wheel.extra": "Extra spins: %count%", - "wheel.spin": "SPIN", - "wheel.buy": "Buy spin", - "wheel.winners": "Latest winners", - "wheel.winners.empty": "No winners yet", - "soundboard.title": "Soundboard", - "soundboard.empty": "No sounds available", - "soundboard.lastplayed": "Played by %user%", - "soundboard.room.setting.desc": "Let people in this room play sound effects", - "radio.title": "Radio", - "radio.empty": "No stations", - "radio.error": "Couldn't load stations", - "radio.stop": "Stop" -} diff --git a/public/configuration/wheel-texts-it.example b/public/configuration/wheel-texts-it.example deleted file mode 100644 index dadcbc7..0000000 --- a/public/configuration/wheel-texts-it.example +++ /dev/null @@ -1,17 +0,0 @@ -{ - "wheel.title": "Ruota della Fortuna", - "wheel.free.today": "Hai %count% giri gratis oggi!", - "wheel.extra": "Giri extra: %count%", - "wheel.spin": "GIRA", - "wheel.buy": "Compra giro", - "wheel.winners": "Ultimi vincitori", - "wheel.winners.empty": "Ancora nessun vincitore", - "soundboard.title": "Soundboard", - "soundboard.empty": "Nessun suono disponibile", - "soundboard.lastplayed": "Suonato da %user%", - "soundboard.room.setting.desc": "Permetti ai presenti di suonare effetti audio in questa stanza", - "radio.title": "Radio", - "radio.empty": "Nessuna stazione", - "radio.error": "Impossibile caricare le stazioni", - "radio.stop": "Stop" -} From 06b8fda1c9ef8d3a3451843e3416b92e52200009 Mon Sep 17 00:00:00 2001 From: duckietm Date: Thu, 28 May 2026 15:52:29 +0200 Subject: [PATCH 14/15] =?UTF-8?q?=F0=9F=86=99=20Enable=20or=20disable=20th?= =?UTF-8?q?e=20radio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/configuration/renderer-config.example | 3 +++ src/components/MainView.tsx | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/public/configuration/renderer-config.example b/public/configuration/renderer-config.example index 792be76..c40cdee 100644 --- a/public/configuration/renderer-config.example +++ b/public/configuration/renderer-config.example @@ -30,6 +30,9 @@ "pet.asset.url": "${asset.url}/pets/%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%", + "soundboard.url": "${gamedata.url}/soundboard-sounds.json5?t=%timestamp%", + "radio_ui": false, "furni.rotation.bounce.steps": 20, "furni.rotation.bounce.height": 0.0625, "enable.avatar.arrow": false, diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index aeb5d91..e155f05 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -1,6 +1,7 @@ import { AddLinkEventTracker, GetCommunication, GetRoomSessionManager, HabboWebTools, ILinkEventTracker, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer'; import { AnimatePresence, motion } from 'framer-motion'; import { FC, useEffect, useState } from 'react'; +import { GetConfigurationValue } from '../api'; import { useNitroEventReducer } from '../hooks'; import { AchievementsView } from './achievements/AchievementsView'; import { AvatarEditorView } from './avatar-editor'; @@ -183,7 +184,7 @@ export const MainView: FC<{}> = props => - + { GetConfigurationValue('radio_ui', true) && } ); From 47e8338570611be4084273c8184a5a299a0c7603 Mon Sep 17 00:00:00 2001 From: duckietm Date: Thu, 28 May 2026 16:27:48 +0200 Subject: [PATCH 15/15] =?UTF-8?q?=F0=9F=86=99=20Wheel=20of=20prizes=20!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/configuration/UITexts_nl.json5.example | 2 + .../FortuneWheelSettingsView.tsx | 147 +++++++++++++ .../fortune-wheel/FortuneWheelView.tsx | 13 +- src/components/rare-values/RareValuesView.tsx | 165 ++------------ .../FortuneWheelSettingsView.tsx | 147 +++++++++++++ .../fortune-wheel/FortuneWheelView.tsx | 202 ++++++++++++++++++ .../rare-values/RareValuesView.tsx | 115 ++++++++++ .../rooms/widgets/useChatCommandSelector.ts | 58 ++--- 8 files changed, 677 insertions(+), 172 deletions(-) create mode 100644 src/components/fortune-wheel/FortuneWheelSettingsView.tsx create mode 100644 src/components/user-settings/fortune-wheel/FortuneWheelSettingsView.tsx create mode 100644 src/components/user-settings/fortune-wheel/FortuneWheelView.tsx create mode 100644 src/components/user-settings/rare-values/RareValuesView.tsx diff --git a/public/configuration/UITexts_nl.json5.example b/public/configuration/UITexts_nl.json5.example index 68f003f..8bbedba 100644 --- a/public/configuration/UITexts_nl.json5.example +++ b/public/configuration/UITexts_nl.json5.example @@ -639,6 +639,8 @@ 'wheel.free.today': 'Je hebt vandaag %count% gratis draaibeurten!', 'wheel.extra': 'Extra draaibeurten: %count%', 'wheel.spin': 'DRAAIEN', + 'wheel.settings': 'Settings', + 'wheel.settings.title': 'Rad van Fortuin Settings', 'wheel.buy': 'Draaibeurt kopen', 'wheel.winners': 'Laatste winnaars', 'wheel.winners.empty': 'Nog geen winnaars', diff --git a/src/components/fortune-wheel/FortuneWheelSettingsView.tsx b/src/components/fortune-wheel/FortuneWheelSettingsView.tsx new file mode 100644 index 0000000..628eeb0 --- /dev/null +++ b/src/components/fortune-wheel/FortuneWheelSettingsView.tsx @@ -0,0 +1,147 @@ +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: 'diamonds', labelKey: 'achievements.activitypoint.5' }, + { key: 'duckets', labelKey: 'achievements.activitypoint.0' }, + { key: 'credits', labelKey: 'credits' }, + { key: 'spins', labelKey: 'rarevalues.editor.cat.spin' }, + { key: 'nothing', labelKey: 'rarevalues.editor.cat.nothing' } +]; + +const prizeToCategory = (prize: IWheelAdminPrize): string => +{ + switch(prize.type) + { + case 'item': return 'item'; + case 'points': return (prize.pointsType === 5) ? 'diamonds' : 'duckets'; + case 'credits': return 'credits'; + case 'spin': return 'spins'; + default: return 'nothing'; + } +}; + +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 'diamonds': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 5 }; + case 'duckets': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 0 }; + case 'credits': return { ...base, type: 'credits', value: '', amount: row.num, pointsType: 0 }; + case 'spins': 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/fortune-wheel/FortuneWheelView.tsx b/src/components/fortune-wheel/FortuneWheelView.tsx index a66af12..37aa94b 100644 --- a/src/components/fortune-wheel/FortuneWheelView.tsx +++ b/src/components/fortune-wheel/FortuneWheelView.tsx @@ -2,8 +2,9 @@ import { AddLinkEventTracker, GetRoomEngine, ILinkEventTracker, IWheelPrize, Rem import { FC, useEffect, useMemo, useRef, useState } from 'react'; import { LocalizeText } from '../../api'; import { Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, LayoutCurrencyIcon, LayoutImage, Text } from '../../common'; -import { useFortuneWheel } from '../../hooks'; +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' ]; @@ -42,7 +43,9 @@ const renderPrizeIcon = (prize: IWheelPrize) => 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([]); @@ -164,6 +167,12 @@ export const FortuneWheelView: FC<{}> = () => { LocalizeText('wheel.buy') } { spinCost } + { canManage && + } @@ -186,6 +195,8 @@ export const FortuneWheelView: FC<{}> = () => + { canManage && isSettingsOpen && + setIsSettingsOpen(false) } /> } ); }; diff --git a/src/components/rare-values/RareValuesView.tsx b/src/components/rare-values/RareValuesView.tsx index 5cb4ce7..c03f787 100644 --- a/src/components/rare-values/RareValuesView.tsx +++ b/src/components/rare-values/RareValuesView.tsx @@ -1,8 +1,8 @@ -import { AddLinkEventTracker, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, IRareValue, IWheelAdminPrize, IWheelAdminPrizeEdit, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +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 { useFortuneWheel, useHasPermission, useRareValues } from '../../hooks'; +import { useRareValues } from '../../hooks'; import { NitroCard, NitroInput } from '../../layout'; interface RareValueRow @@ -13,63 +13,11 @@ interface RareValueRow value: IRareValue; } -interface EditRow -{ - id: number; - category: string; - num: number; - weight: number; - label: string; -} - -const CATEGORIES: { key: string; label: string }[] = [ - { key: 'item', label: 'Raro (ID)' }, - { key: 'diamanti', label: 'Diamanti' }, - { key: 'duckets', label: 'Duckets' }, - { key: 'crediti', label: 'Crediti' }, - { key: 'giri', label: 'Giri extra' }, - { key: 'nulla', label: 'Nulla' } -]; - -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 }; - } -}; - export const RareValuesView: FC<{}> = () => { const [ isVisible, setIsVisible ] = useState(false); - const [ tab, setTab ] = useState<'values' | 'editor'>('values'); const [ searchValue, setSearchValue ] = useState(''); const { values = null, loaded = false } = useRareValues(); - const { adminPrizes = [], loadAdminPrizes = null, saveAdminPrizes = null } = useFortuneWheel(); - const canEdit = useHasPermission('acc_supporttool'); - const [ editRows, setEditRows ] = useState([]); useEffect(() => { @@ -94,16 +42,6 @@ export const RareValuesView: FC<{}> = () => return () => RemoveLinkEventTracker(linkTracker); }, []); - useEffect(() => - { - if(isVisible && (tab === 'editor') && canEdit && loadAdminPrizes) loadAdminPrizes(); - }, [ isVisible, tab, canEdit, loadAdminPrizes ]); - - useEffect(() => - { - setEditRows(adminPrizes.map(prize => ({ id: prize.id, category: prizeToCategory(prize), num: prizeToNum(prize), weight: prize.weight, label: prize.label }))); - }, [ adminPrizes ]); - const rows = useMemo(() => { if(!values) return []; @@ -143,91 +81,34 @@ export const RareValuesView: FC<{}> = () => if(!isVisible) return null; - const updateRow = (id: number, patch: Partial) => - setEditRows(prev => prev.map(row => (row.id === id) ? { ...row, ...patch } : row)); - return ( setIsVisible(false) } /> - { canEdit && - - setTab('values') }> - { LocalizeText('rarevalues.title') } - - setTab('editor') }> - { LocalizeText('rarevalues.editor.tab') } - - } - { (tab === 'values' || !canEdit) && - - setSearchValue(event.target.value) } /> - - { !loaded && - { LocalizeText('rarevalues.loading') } } - { (loaded && !filtered.length) && - { LocalizeText('rarevalues.empty') } } - { filtered.map(row => ( - - - { row.name } - - { LocalizeFormattedNumber(row.value.points) } - - + + setSearchValue(event.target.value) } /> + + { !loaded && + { LocalizeText('rarevalues.loading') } } + { (loaded && !filtered.length) && + { LocalizeText('rarevalues.empty') } } + { filtered.map(row => ( + + + { row.name } + + { LocalizeFormattedNumber(row.value.points) } + - )) } - - } - - { (tab === 'editor' && canEdit) && - - - { 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]" /> - - )) } - - - } + + )) } + + ); diff --git a/src/components/user-settings/fortune-wheel/FortuneWheelSettingsView.tsx b/src/components/user-settings/fortune-wheel/FortuneWheelSettingsView.tsx new file mode 100644 index 0000000..91cc44c --- /dev/null +++ b/src/components/user-settings/fortune-wheel/FortuneWheelSettingsView.tsx @@ -0,0 +1,147 @@ +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 new file mode 100644 index 0000000..37aa94b --- /dev/null +++ b/src/components/user-settings/fortune-wheel/FortuneWheelView.tsx @@ -0,0 +1,202 @@ +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 new file mode 100644 index 0000000..c03f787 --- /dev/null +++ b/src/components/user-settings/rare-values/RareValuesView.tsx @@ -0,0 +1,115 @@ +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/hooks/rooms/widgets/useChatCommandSelector.ts b/src/hooks/rooms/widgets/useChatCommandSelector.ts index 9584e92..31dd495 100644 --- a/src/hooks/rooms/widgets/useChatCommandSelector.ts +++ b/src/hooks/rooms/widgets/useChatCommandSelector.ts @@ -1,36 +1,35 @@ import { AvailableCommandsEvent, GetCommunication } from '@nitrots/nitro-renderer'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { CommandDefinition } from '../../../api'; +import { CommandDefinition, LocalizeText } from '../../../api'; import { createNitroStore } from '../../../state/createNitroStore'; import { useMessageEvent } from '../../events'; -// Client-only commands are static; safe to keep at module scope. -const CLIENT_COMMANDS: CommandDefinition[] = [ - // Effetti stanza - { key: 'shake', description: 'Scuoti la stanza' }, - { key: 'rotate', description: 'Ruota la stanza' }, - { key: 'zoom', description: 'Zoom stanza' }, - { key: 'flip', description: 'Reset zoom' }, - { key: 'iddqd', description: 'Reset zoom' }, - { key: 'screenshot', description: 'Screenshot stanza' }, - { key: 'togglefps', description: 'Toggle FPS' }, - // Espressioni - { key: 'd', description: 'Ridi (VIP)' }, - { key: 'kiss', description: 'Manda un bacio (VIP)' }, - { key: 'jump', description: 'Salta (VIP)' }, - { key: 'idle', description: 'Vai in idle' }, - { key: 'sign', description: 'Mostra cartello' }, - // Gestione stanza - { key: 'furni', description: 'Furni chooser' }, - { key: 'chooser', description: 'User chooser' }, - { key: 'floor', description: 'Floor editor' }, - { key: 'bcfloor', description: 'Floor editor' }, - { key: 'pickall', description: 'Raccogli tutti i furni' }, - { key: 'ejectall', description: 'Espelli tutti i furni' }, - { key: 'settings', description: 'Impostazioni stanza' }, +const CLIENT_COMMANDS: { key: string; descriptionKey: string }[] = [ + // Room effects + { key: 'shake', descriptionKey: 'chatcmd.client.shake' }, + { key: 'rotate', descriptionKey: 'chatcmd.client.rotate' }, + { key: 'zoom', descriptionKey: 'chatcmd.client.zoom' }, + { key: 'flip', descriptionKey: 'chatcmd.client.flip' }, + { key: 'iddqd', descriptionKey: 'chatcmd.client.iddqd' }, + { key: 'screenshot', descriptionKey: 'chatcmd.client.screenshot' }, + { key: 'togglefps', descriptionKey: 'chatcmd.client.togglefps' }, + // Expressions + { key: 'd', descriptionKey: 'chatcmd.client.laugh' }, + { key: 'kiss', descriptionKey: 'chatcmd.client.kiss' }, + { key: 'jump', descriptionKey: 'chatcmd.client.jump' }, + { key: 'idle', descriptionKey: 'chatcmd.client.idle' }, + { key: 'sign', descriptionKey: 'chatcmd.client.sign' }, + // Room management + { key: 'furni', descriptionKey: 'chatcmd.client.furni' }, + { key: 'chooser', descriptionKey: 'chatcmd.client.chooser' }, + { key: 'floor', descriptionKey: 'chatcmd.client.floor' }, + { key: 'bcfloor', descriptionKey: 'chatcmd.client.floor' }, + { key: 'pickall', descriptionKey: 'chatcmd.client.pickall' }, + { key: 'ejectall', descriptionKey: 'chatcmd.client.ejectall' }, + { key: 'settings', descriptionKey: 'chatcmd.client.settings' }, // Info - { key: 'client', description: 'Info client' }, - { key: 'nitro', description: 'Info client' }, + { key: 'client', descriptionKey: 'chatcmd.client.info' }, + { key: 'nitro', descriptionKey: 'chatcmd.client.info' }, ]; /** @@ -110,11 +109,12 @@ export const useChatCommandSelector = (chatValue: string) => const allCommands = useMemo(() => { - const merged = [ ...serverCommands ]; + const merged: CommandDefinition[] = [ ...serverCommands ]; for(const clientCmd of CLIENT_COMMANDS) { - if(!merged.some(cmd => cmd.key === clientCmd.key)) merged.push(clientCmd); + if(merged.some(cmd => cmd.key === clientCmd.key)) continue; + merged.push({ key: clientCmd.key, description: LocalizeText(clientCmd.descriptionKey) }); } return merged.sort((a, b) => a.key.localeCompare(b.key));