mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
+44
-11
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- Dev
|
||||
- 'feat/**'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
@@ -71,19 +72,51 @@ jobs:
|
||||
VAR_REPO: ${{ vars.RENDERER_REPO }}
|
||||
VAR_REF: ${{ vars.RENDERER_REF }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REPO="${{ github.event.inputs.renderer_repo }}"
|
||||
REF="${{ github.event.inputs.renderer_ref }}"
|
||||
|
||||
# Branch context of the *client* build.
|
||||
case "${GITHUB_EVENT_NAME}" in
|
||||
pull_request) CTX="${GITHUB_BASE_REF}" ;;
|
||||
*) CTX="${GITHUB_REF_NAME}" ;;
|
||||
esac
|
||||
if [ -z "$REPO" ] || [ -z "$REF" ]; then
|
||||
# For PRs we usually pair against the base ref, but the HK
|
||||
# PR specifically needs to pair against its OWN head ref —
|
||||
# the renderer companion PR is named identically
|
||||
# (`feat/housekeeping-packets`) and lives on the same fork.
|
||||
case "${GITHUB_EVENT_NAME}" in
|
||||
pull_request)
|
||||
if [ "${GITHUB_HEAD_REF}" = "feat/housekeeping-panel" ]; then
|
||||
CTX="${GITHUB_HEAD_REF}"
|
||||
else
|
||||
CTX="${GITHUB_BASE_REF}"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
CTX="${GITHUB_REF_NAME}"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Upstream fallback ref depends on client context.
|
||||
if [ "$CTX" = "main" ]; then
|
||||
DEFAULT_REF="main"
|
||||
else
|
||||
DEFAULT_REF="Dev"
|
||||
case "$CTX" in
|
||||
main)
|
||||
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"
|
||||
;;
|
||||
*)
|
||||
AUTO_REPO="duckietm/Nitro_Render_V3"
|
||||
AUTO_REF="Dev"
|
||||
;;
|
||||
esac
|
||||
|
||||
[ -z "$REPO" ] && REPO="$AUTO_REPO"
|
||||
[ -z "$REF" ] && REF="$AUTO_REF"
|
||||
fi
|
||||
|
||||
# Precedence: dispatch input → repo variable → upstream default.
|
||||
|
||||
@@ -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<typeof NavigatorSearchEvent, NavigatorSearchResultSet>({
|
||||
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<NavigatorSearchResultSet>(null)` for `searchResult`
|
||||
- `useMessageEvent<NavigatorSearchEvent>` 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 `<NitroCard.Content isLoading={ isLoading }>` 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)`
|
||||
@@ -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!"
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
{
|
||||
// ------------------------------------------------------------------------
|
||||
// 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.settings': 'Settings',
|
||||
'wheel.settings.title': 'Rad van Fortuin Settings',
|
||||
'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',
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
// Hotel radio stations. Copy this file to `radio-stations.json5` (without the
|
||||
// .example suffix) and add your own stations — each entry is just a streaming
|
||||
// URL the client plays with the HTML5 Audio API. JSON5: // comments and
|
||||
// trailing commas are allowed. Add / remove / reorder freely, no rebuild needed.
|
||||
//
|
||||
// Fields:
|
||||
// id - unique key (string)
|
||||
// name - label shown in the radio widget
|
||||
// genre - optional subtitle
|
||||
// url - the audio stream URL (mp3/aac/ogg Icecast or Shoutcast)
|
||||
// logo - optional image URL shown next to the station
|
||||
//
|
||||
// The first station autostarts (quietly) on client load. The list can later
|
||||
// be moved to the CMS (website_settings) so it's editable from the admin.
|
||||
stations: [
|
||||
// { id: 'mystation', name: 'My Station', genre: 'Hotel Radio', url: 'https://your-stream-host/stream' },
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"rarevalues.title": "Rare Values",
|
||||
"rarevalues.loading": "Loading values…",
|
||||
"rarevalues.empty": "No rares found",
|
||||
"rarevalues.infostand.label": "Value:"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"rarevalues.title": "Valore Rari",
|
||||
"rarevalues.loading": "Caricamento valori…",
|
||||
"rarevalues.empty": "Nessun raro trovato",
|
||||
"rarevalues.infostand.label": "Valore:"
|
||||
}
|
||||
@@ -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",
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
let _soundboardEnabled = false;
|
||||
|
||||
export const getSoundboardRoomEnabled = () => _soundboardEnabled;
|
||||
export const setSoundboardRoomEnabled = (enabled: boolean) =>
|
||||
{
|
||||
_soundboardEnabled = enabled;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './SoundboardRoomState';
|
||||
@@ -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';
|
||||
@@ -24,6 +25,10 @@ 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 { SoundboardView } from './soundboard/SoundboardView';
|
||||
import { RadioView } from './radio/RadioView';
|
||||
import { InventoryView } from './inventory/InventoryView';
|
||||
import { ModToolsView } from './mod-tools/ModToolsView';
|
||||
import { NavigatorView } from './navigator/NavigatorView';
|
||||
@@ -176,6 +181,10 @@ export const MainView: FC<{}> = props =>
|
||||
<GameCenterView />
|
||||
<FloorplanEditorView />
|
||||
<FurniEditorView />
|
||||
<RareValuesView />
|
||||
<FortuneWheelView />
|
||||
<SoundboardView />
|
||||
{ GetConfigurationValue<boolean>('radio_ui', true) && <RadioView /> }
|
||||
<ExternalPluginLoader />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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', () =>
|
||||
|
||||
@@ -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>(RoomOccupiedTilesMessageEvent, event =>
|
||||
{
|
||||
dispatch({ type: 'SET_OCCUPIED_TILES', map: event.getParser().blockedTilesMap });
|
||||
});
|
||||
|
||||
useMessageEvent<RoomEntryTileMessageEvent>(RoomEntryTileMessageEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
@@ -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', () =>
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -40,11 +40,18 @@ describe('FloorplanCanvasSVG', () =>
|
||||
const dispatch = vi.fn();
|
||||
const { container } = render(<FloorplanCanvasSVG state={ state } dispatch={ dispatch } />);
|
||||
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', () =>
|
||||
|
||||
@@ -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<Props> = ({ 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(',');
|
||||
|
||||
@@ -104,6 +104,17 @@ const FloorplanTileImpl: FC<Props> = ({ row, col, tile, selected, isDoor, southH
|
||||
stroke="#222"
|
||||
strokeWidth={ 0.5 }
|
||||
/>
|
||||
{ tile.occupied && (
|
||||
<polygon
|
||||
data-testid="occupied-marker"
|
||||
points={ points }
|
||||
fill="rgba(249, 115, 22, 0.40)"
|
||||
stroke="#f97316"
|
||||
strokeWidth={ 1 }
|
||||
strokeDasharray="2 2"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
) }
|
||||
{ selected && (
|
||||
<polygon
|
||||
data-testid="selection-ring"
|
||||
|
||||
@@ -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<FortuneWheelSettingsViewProps> = ({ onClose }) =>
|
||||
{
|
||||
const { adminPrizes = [], loadAdminPrizes = null, saveAdminPrizes = null } = useFortuneWheel();
|
||||
const [ editRows, setEditRows ] = useState<EditRow[]>([]);
|
||||
|
||||
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<EditRow>) =>
|
||||
setEditRows(prev => prev.map(row => (row.id === id) ? { ...row, ...patch } : row));
|
||||
|
||||
return (
|
||||
<NitroCard className="w-[480px] h-[520px]" uniqueKey="fortune-wheel-settings">
|
||||
<NitroCard.Header
|
||||
headerText={ LocalizeText('wheel.settings.title') }
|
||||
onCloseClick={ onClose } />
|
||||
<NitroCard.Content>
|
||||
<Column gap={ 1 } className="h-full p-1">
|
||||
<Flex gap={ 1 } className="px-1 text-[11px] font-bold text-black/60">
|
||||
<span className="w-28">{ LocalizeText('rarevalues.editor.type') }</span>
|
||||
<span className="w-16">{ LocalizeText('rarevalues.editor.value') }</span>
|
||||
<span className="w-12">{ LocalizeText('rarevalues.editor.weight') }</span>
|
||||
<span className="grow">{ LocalizeText('rarevalues.editor.label') }</span>
|
||||
</Flex>
|
||||
<Column gap={ 1 } overflow="auto" className="grow">
|
||||
{ editRows.map(row => (
|
||||
<Flex key={ row.id } alignItems="center" gap={ 1 } className="border-b border-black/10 pb-1">
|
||||
<select
|
||||
value={ row.category }
|
||||
onChange={ event => updateRow(row.id, { category: event.target.value }) }
|
||||
className="w-28 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]">
|
||||
{ CATEGORIES.map(cat => (
|
||||
<option key={ cat.key } value={ cat.key }>{ LocalizeText(cat.labelKey) }</option>
|
||||
)) }
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
value={ row.num }
|
||||
disabled={ row.category === 'nothing' }
|
||||
onChange={ event => 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" />
|
||||
<input
|
||||
type="number"
|
||||
value={ row.weight }
|
||||
onChange={ event => 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]" />
|
||||
<input
|
||||
type="text"
|
||||
value={ row.label }
|
||||
onChange={ event => 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]" />
|
||||
</Flex>
|
||||
)) }
|
||||
{ !editRows.length &&
|
||||
<Text small className="text-black/50">{ LocalizeText('wheel.settings.empty') }</Text> }
|
||||
</Column>
|
||||
<button
|
||||
type="button"
|
||||
disabled={ !editRows.length }
|
||||
onClick={ () => saveAdminPrizes?.(editRows.map(rowToEdit)) }
|
||||
className="cursor-pointer rounded bg-[#3a7bb5] px-4 py-2 font-bold text-white hover:bg-[#336ea3] disabled:cursor-default disabled:opacity-40">
|
||||
{ LocalizeText('rarevalues.editor.save') }
|
||||
</button>
|
||||
</Column>
|
||||
</NitroCard.Content>
|
||||
</NitroCard>
|
||||
);
|
||||
};
|
||||
@@ -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 <LayoutImage imageUrl={ GetRoomEngine().getFurnitureFloorIconUrl(prize.spriteId) } className="h-9 w-9 bg-contain bg-center bg-no-repeat" />;
|
||||
case 'badge':
|
||||
return <LayoutBadgeImageView badgeCode={ prize.badgeCode } />;
|
||||
case 'credits':
|
||||
return (
|
||||
<Column alignItems="center" gap={ 0 }>
|
||||
<LayoutCurrencyIcon type={ -1 } />
|
||||
<span className="text-[10px] font-bold text-[#2a3a42]">{ prize.amount }</span>
|
||||
</Column>);
|
||||
case 'points':
|
||||
return (
|
||||
<Column alignItems="center" gap={ 0 }>
|
||||
<LayoutCurrencyIcon type={ prize.pointsType } />
|
||||
<span className="text-[10px] font-bold text-[#2a3a42]">{ prize.amount }</span>
|
||||
</Column>);
|
||||
case 'spin':
|
||||
return <span className="text-xs font-bold text-[#2a3a42]">+{ prize.amount }</span>;
|
||||
default:
|
||||
return <span className="text-xs font-bold text-[#2a3a42]/60">—</span>;
|
||||
}
|
||||
};
|
||||
|
||||
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<IWheelPrize[]>([]);
|
||||
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 (
|
||||
<NitroCard className="w-[800px] max-w-[96vw]" uniqueKey="fortune-wheel">
|
||||
<NitroCard.Header headerText={ LocalizeText('wheel.title') } onCloseClick={ () => setIsVisible(false) } />
|
||||
<NitroCard.Content>
|
||||
<Flex gap={ 3 }>
|
||||
<Column alignItems="center" gap={ 2 } className="shrink-0">
|
||||
<div className="relative" style={ { width: WHEEL_SIZE, height: WHEEL_SIZE } }>
|
||||
<div
|
||||
className="absolute left-1/2 -top-2 z-20 -translate-x-1/2 drop-shadow-[0_2px_2px_rgba(0,0,0,0.25)]"
|
||||
style={ { width: 0, height: 0, borderLeft: '14px solid transparent', borderRight: '14px solid transparent', borderTop: `28px solid ${ RIM }` } } />
|
||||
<div
|
||||
className="absolute inset-0 rounded-full shadow-[0_0_0_4px_rgba(0,0,0,0.12),inset_0_0_18px_rgba(0,0,0,0.1)]"
|
||||
style={ { background, border: `8px solid ${ RIM }`, transform: `rotate(${ rotation }deg)`, transition: isSpinning ? 'transform 4.5s cubic-bezier(0.15,0.85,0.25,1)' : 'none' } }
|
||||
onTransitionEnd={ () => { if(isSpinning) finishSpin(); } }>
|
||||
{ prizes.map((_, i) => (
|
||||
<div
|
||||
key={ `divider-${ i }` }
|
||||
className="absolute bottom-1/2 left-1/2 origin-bottom"
|
||||
style={ { width: '2px', height: `${ WHEEL_SIZE / 2 }px`, transform: `translateX(-1px) rotate(${ i * sliceAngle }deg)`, background: 'rgba(76,96,108,0.3)' } } />
|
||||
)) }
|
||||
{ prizes.map((prize, i) =>
|
||||
{
|
||||
const centerAngle = ((i + 0.5) * sliceAngle);
|
||||
return (
|
||||
<div
|
||||
key={ prize.id }
|
||||
className="absolute left-1/2 top-1/2"
|
||||
style={ { transform: `rotate(${ centerAngle }deg) translateY(-${ ICON_RADIUS }px) rotate(-${ centerAngle }deg)` } }>
|
||||
<div className="-translate-x-1/2 -translate-y-1/2">
|
||||
{ renderPrizeIcon(prize) }
|
||||
</div>
|
||||
</div>);
|
||||
}) }
|
||||
</div>
|
||||
<div className="absolute left-1/2 top-1/2 z-10 h-14 w-14 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#eef2f5] shadow-[0_0_8px_rgba(0,0,0,0.25)]" style={ { border: `4px solid ${ RIM }` } } />
|
||||
</div>
|
||||
<Text bold className="text-[#2f6f95]">{ LocalizeText('wheel.free.today', [ 'count' ], [ freeSpins.toString() ]) }</Text>
|
||||
<Text small className="text-[#33424c]">{ LocalizeText('wheel.extra', [ 'count' ], [ extraSpins.toString() ]) }</Text>
|
||||
<Flex gap={ 2 } alignItems="center">
|
||||
<button
|
||||
disabled={ !canSpin }
|
||||
onClick={ () => spin() }
|
||||
className="cursor-pointer rounded bg-[#3a7bb5] px-4 py-2 font-bold text-white hover:bg-[#336ea3] disabled:cursor-default disabled:opacity-40">
|
||||
{ LocalizeText('wheel.spin') }
|
||||
</button>
|
||||
<button
|
||||
onClick={ () => buySpin() }
|
||||
className="flex cursor-pointer items-center gap-1 rounded bg-[#6b7884] px-3 py-2 text-white hover:bg-[#5e6a75]">
|
||||
{ LocalizeText('wheel.buy') } { spinCost }
|
||||
<LayoutCurrencyIcon type={ spinCostType } />
|
||||
</button>
|
||||
{ canManage &&
|
||||
<button
|
||||
onClick={ () => setIsSettingsOpen(true) }
|
||||
className="cursor-pointer rounded bg-[#8a6b3a] px-3 py-2 font-bold text-white hover:bg-[#735730]">
|
||||
{ LocalizeText('wheel.settings') }
|
||||
</button> }
|
||||
</Flex>
|
||||
</Column>
|
||||
<Column gap={ 2 } className="min-w-[300px] grow rounded-lg border border-black/10 bg-black/5 p-3">
|
||||
<Text bold className="text-base text-[#33424c]">{ LocalizeText('wheel.winners') }</Text>
|
||||
<Column gap={ 1 } overflow="auto" className="h-[440px]">
|
||||
{ recentWins.map((win, i) => (
|
||||
<Flex key={ i } alignItems="center" gap={ 2 } className="rounded border-b border-black/10 py-1.5 hover:bg-black/5">
|
||||
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded bg-black/5">
|
||||
<LayoutAvatarImageView figure={ win.look } headOnly direction={ 2 } style={ { backgroundSize: 'auto', backgroundPosition: '-22px -32px' } } />
|
||||
</div>
|
||||
<Column gap={ 0 } className="min-w-0">
|
||||
<Text bold truncate className="text-[#1f2d34]">{ win.username }</Text>
|
||||
<Text small truncate className="text-[#2f6f95]">{ win.prizeLabel }</Text>
|
||||
</Column>
|
||||
</Flex>
|
||||
)) }
|
||||
{ !recentWins.length &&
|
||||
<Text small className="text-black/50">{ LocalizeText('wheel.winners.empty') }</Text> }
|
||||
</Column>
|
||||
</Column>
|
||||
</Flex>
|
||||
</NitroCard.Content>
|
||||
{ canManage && isSettingsOpen &&
|
||||
<FortuneWheelSettingsView onClose={ () => setIsSettingsOpen(false) } /> }
|
||||
</NitroCard>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
.button-search-saves {
|
||||
padding: 4px;
|
||||
height: 17px;
|
||||
margin-top: -1px;
|
||||
font-size: 10px;
|
||||
border-radius: 4px;
|
||||
background-color: #FAA700;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
|
||||
useNitroEvent<RoomSessionEvent>(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 =>
|
||||
<NitroCard.TabItem
|
||||
key={ index }
|
||||
isActive={ topLevelContext === context && !isCreatorOpen }
|
||||
onClick={ () => sendSearch('', context.code) }>
|
||||
onClick={ () => useNavigatorUiStore.getState().setTab(context.code) }>
|
||||
{ LocalizeText('navigator.toplevelview.' + context.code) }
|
||||
</NitroCard.TabItem>) }
|
||||
<NitroCard.TabItem
|
||||
@@ -151,7 +132,7 @@ export const NavigatorView: FC<{}> = props =>
|
||||
<FaPlus className="fa-icon" />
|
||||
</NitroCard.TabItem>
|
||||
</NitroCard.Tabs>
|
||||
<NitroCard.Content isLoading={ isLoading }>
|
||||
<NitroCard.Content isLoading={ isFetching }>
|
||||
{ !isCreatorOpen &&
|
||||
<div className="flex h-full overflow-hidden gap-2">
|
||||
{ isOpenSavesSearches &&
|
||||
|
||||
@@ -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<NavigatorRoomSettingsMiscTabVi
|
||||
const { roomData = null } = props;
|
||||
const [ youtubeEnabled, setYoutubeEnabled ] = useState(getYoutubeRoomEnabled());
|
||||
const [ cooldown, setCooldown ] = useState(false);
|
||||
const { enabled: soundboardEnabled, setRoomEnabled: setSoundboardEnabled } = useSoundboard();
|
||||
|
||||
useMessageEvent<YouTubeRoomSettingsEvent>(YouTubeRoomSettingsEvent, event =>
|
||||
{
|
||||
@@ -29,6 +30,14 @@ export const NavigatorRoomSettingsMiscTabView: FC<NavigatorRoomSettingsMiscTabVi
|
||||
setTimeout(() => setCooldown(false), 300);
|
||||
};
|
||||
|
||||
const toggleSoundboard = (enabled: boolean) =>
|
||||
{
|
||||
if (cooldown) return;
|
||||
setSoundboardEnabled(enabled);
|
||||
setCooldown(true);
|
||||
setTimeout(() => setCooldown(false), 300);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
@@ -52,6 +61,23 @@ export const NavigatorRoomSettingsMiscTabView: FC<NavigatorRoomSettingsMiscTabVi
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`p-3 rounded transition-colors ${cooldown ? 'bg-gray-200 opacity-60' : 'bg-gray-100'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-bold text-sm">🔊 { LocalizeText('soundboard.title') }</div>
|
||||
<div className="text-xs text-gray-500">{ LocalizeText('soundboard.room.setting.desc') }</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ soundboardEnabled }
|
||||
disabled={ cooldown }
|
||||
onChange={ e => toggleSoundboard(e.target.checked) }
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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<HTMLInputElement>) =>
|
||||
{
|
||||
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<HTMLInputElement>) =>
|
||||
{
|
||||
if(event.key !== 'Enter') return;
|
||||
|
||||
processSearch();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full gap-1">
|
||||
<div className="flex shrink-0">
|
||||
@@ -69,7 +81,7 @@ export const NavigatorSearchView: FC<{}> = props =>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex w-full gap-1">
|
||||
<input className="w-full form-control" placeholder={ LocalizeText('navigator.filter.input.placeholder') } type="text" value={ searchValue } onChange={ event => setSearchValue(event.target.value) } onKeyDown={ event => handleKeyDown(event) } />
|
||||
<input className="w-full form-control" placeholder={ LocalizeText('navigator.filter.input.placeholder') } type="text" value={ inputText } onChange={ event => setInputText(event.target.value) } onKeyDown={ event => handleKeyDown(event) } />
|
||||
<Button variant="primary" onClick={ processSearch }>
|
||||
<FaSearch className="fa-icon" />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { FaBroadcastTower, FaChevronDown, FaPlay, FaStop } from 'react-icons/fa';
|
||||
import { LocalizeText } from '../../api';
|
||||
import { LayoutImage } from '../../common';
|
||||
import { RadioStation, useRadio } from '../../hooks';
|
||||
|
||||
const RADIO_STYLES = `
|
||||
.radio-widget { font-feature-settings: "tnum"; }
|
||||
.radio-eq { display: flex; align-items: flex-end; gap: 2px; height: 12px; }
|
||||
.radio-eq span { width: 3px; height: 30%; border-radius: 2px; background: #38bdf8; opacity: .55; }
|
||||
.radio-eq.is-live span { opacity: 1; animation: radioEq .9s ease-in-out infinite; }
|
||||
.radio-eq span:nth-child(2) { animation-delay: .18s; }
|
||||
.radio-eq span:nth-child(3) { animation-delay: .36s; }
|
||||
.radio-eq span:nth-child(4) { animation-delay: .12s; }
|
||||
@keyframes radioEq { 0%, 100% { height: 22%; } 50% { height: 100%; } }
|
||||
.radio-scroll::-webkit-scrollbar { width: 6px; }
|
||||
.radio-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,.18); border-radius: 3px; }
|
||||
.radio-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||
.radio-vol { accent-color: #38bdf8; }
|
||||
`;
|
||||
|
||||
// Compact, polished top-left radio widget. Shows the selected station with a
|
||||
// dropdown (3 visible, scrolls if more) to switch. Nudged down so it clears the
|
||||
// CMS top bar most hotels render there.
|
||||
export const RadioView: FC<{}> = () =>
|
||||
{
|
||||
const { stations, currentId, isPlaying, volume, loadError, play, stop, setVolume } = useRadio();
|
||||
const [ open, setOpen ] = useState(false);
|
||||
const [ selectedId, setSelectedId ] = useState<string | null>(null);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!selectedId && stations.length) setSelectedId(stations[0].id);
|
||||
}, [ stations, selectedId ]);
|
||||
|
||||
const selected: RadioStation | null = stations.find(s => s.id === selectedId) ?? stations[0] ?? null;
|
||||
const selectedPlaying = !!selected && (currentId === selected.id) && isPlaying;
|
||||
|
||||
const onPlayToggle = () =>
|
||||
{
|
||||
if(!selected) return;
|
||||
if(selectedPlaying) stop();
|
||||
else play(selected);
|
||||
};
|
||||
|
||||
const onPick = (station: RadioStation) =>
|
||||
{
|
||||
setSelectedId(station.id);
|
||||
setOpen(false);
|
||||
play(station);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="radio-widget fixed left-2 top-12 z-40 w-[244px] max-w-[64vw] select-none overflow-hidden rounded-xl border border-white/10 bg-gradient-to-b from-[rgba(22,24,30,0.94)] to-[rgba(10,11,14,0.94)] text-white shadow-[0_8px_24px_rgba(0,0,0,0.4)] backdrop-blur-sm">
|
||||
<style>{ RADIO_STYLES }</style>
|
||||
|
||||
<div className="flex items-center gap-2 border-b border-white/10 px-3 py-1.5">
|
||||
<FaBroadcastTower className={ `text-[11px] ${ isPlaying ? 'text-sky-400' : 'text-white/45' }` } />
|
||||
<span className="grow text-[10px] font-bold uppercase tracking-[0.14em] text-white/55">{ LocalizeText('radio.title') }</span>
|
||||
<div className={ `radio-eq ${ isPlaying ? 'is-live' : '' }` }>
|
||||
<span /><span /><span /><span />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={ onPlayToggle }
|
||||
disabled={ !selected }
|
||||
title={ selectedPlaying ? LocalizeText('radio.stop') : LocalizeText('radio.title') }
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-500 text-xs text-white shadow-inner transition-all hover:bg-emerald-400 disabled:opacity-40">
|
||||
{ selectedPlaying ? <FaStop /> : <FaPlay className="translate-x-px" /> }
|
||||
</button>
|
||||
<div className="min-w-0 grow">
|
||||
<div className="truncate text-sm font-bold leading-tight">{ selected ? selected.name : LocalizeText('radio.title') }</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5">
|
||||
{ selectedPlaying &&
|
||||
<span className="flex items-center gap-1 text-[9px] font-bold uppercase tracking-wide text-sky-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-sky-400" /> Live
|
||||
</span> }
|
||||
{ selected?.genre &&
|
||||
<span className="truncate text-[10px] text-white/45">{ selected.genre }</span> }
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={ () => setOpen(value => !value) }
|
||||
title={ LocalizeText('radio.title') }
|
||||
className={ `flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition-colors ${ open ? 'bg-white/20' : 'bg-white/8 hover:bg-white/15' }` }>
|
||||
<FaChevronDown className={ `text-[10px] transition-transform ${ open ? 'rotate-180' : '' }` } />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{ selectedPlaying &&
|
||||
<div className="flex items-center gap-2 px-3 pb-2.5">
|
||||
<span className="text-xs text-white/55">🔊</span>
|
||||
<input
|
||||
type="range"
|
||||
min={ 0 }
|
||||
max={ 1 }
|
||||
step={ 0.01 }
|
||||
value={ volume }
|
||||
onChange={ e => setVolume(e.target.valueAsNumber) }
|
||||
className="radio-vol h-1 grow cursor-pointer"
|
||||
/>
|
||||
</div> }
|
||||
|
||||
{ open &&
|
||||
<div className="border-t border-white/10 bg-black/20 p-1.5">
|
||||
{ loadError &&
|
||||
<div className="px-2 py-2 text-[11px] text-red-400">{ LocalizeText('radio.error') }</div> }
|
||||
{ !loadError && !stations.length &&
|
||||
<div className="px-2 py-2 text-[11px] text-white/50">{ LocalizeText('radio.empty') }</div> }
|
||||
{ /* ~3 rows tall, scrolls when there are more */ }
|
||||
<div className="radio-scroll flex max-h-[156px] flex-col gap-1 overflow-y-auto pr-0.5">
|
||||
{ stations.map(station =>
|
||||
{
|
||||
const isActive = station.id === selectedId;
|
||||
const playingThis = (currentId === station.id) && isPlaying;
|
||||
return (
|
||||
<div
|
||||
key={ station.id }
|
||||
onClick={ () => 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
|
||||
? <LayoutImage imageUrl={ station.logo } className="h-7 w-7 shrink-0 rounded bg-contain bg-center bg-no-repeat" />
|
||||
: <div className={ `flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-[11px] ${ playingThis ? 'bg-sky-500/80' : 'bg-white/10' }` }>
|
||||
{ playingThis ? <FaStop /> : <FaPlay className="translate-x-px" /> }
|
||||
</div> }
|
||||
<div className="min-w-0 grow">
|
||||
<div className="truncate text-xs font-bold leading-tight">{ station.name }</div>
|
||||
{ station.genre &&
|
||||
<div className="truncate text-[10px] text-white/45">{ station.genre }</div> }
|
||||
</div>
|
||||
{ playingThis &&
|
||||
<div className="radio-eq is-live shrink-0">
|
||||
<span /><span /><span /><span />
|
||||
</div> }
|
||||
</div>
|
||||
);
|
||||
}) }
|
||||
</div>
|
||||
</div> }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<RareValueRow[]>(() =>
|
||||
{
|
||||
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<RareValueRow[]>(() =>
|
||||
{
|
||||
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 (
|
||||
<NitroCard className="w-[420px] h-[480px]" uniqueKey="rare-values">
|
||||
<NitroCard.Header
|
||||
headerText={ LocalizeText('rarevalues.title') }
|
||||
onCloseClick={ () => setIsVisible(false) } />
|
||||
<NitroCard.Content>
|
||||
<Column gap={ 2 } className="h-full p-1">
|
||||
<NitroInput
|
||||
placeholder={ LocalizeText('generic.search') }
|
||||
value={ searchValue }
|
||||
onChange={ event => setSearchValue(event.target.value) } />
|
||||
<Column gap={ 0 } overflow="auto" className="grow">
|
||||
{ !loaded &&
|
||||
<Text center className="mt-2 text-black/60">{ LocalizeText('rarevalues.loading') }</Text> }
|
||||
{ (loaded && !filtered.length) &&
|
||||
<Text center className="mt-2 text-black/60">{ LocalizeText('rarevalues.empty') }</Text> }
|
||||
{ filtered.map(row => (
|
||||
<Flex key={ row.spriteId } alignItems="center" gap={ 2 } className="border-b border-black/10 py-1.5 hover:bg-black/5">
|
||||
<LayoutImage imageUrl={ row.iconUrl } className="h-10 w-10 shrink-0 bg-contain bg-center bg-no-repeat" />
|
||||
<Text truncate className="grow text-[#1f2d34]">{ row.name }</Text>
|
||||
<Flex alignItems="center" gap={ 1 } className="shrink-0">
|
||||
<Text bold textEnd className="text-[#2f6f95]">{ LocalizeFormattedNumber(row.value.points) }</Text>
|
||||
<LayoutCurrencyIcon type={ row.value.pointsType } />
|
||||
</Flex>
|
||||
</Flex>
|
||||
)) }
|
||||
</Column>
|
||||
</Column>
|
||||
</NitroCard.Content>
|
||||
</NitroCard>
|
||||
);
|
||||
};
|
||||
@@ -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<InfoStandWidgetFurniViewProps> = 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<InfoStandWidgetFurniViewProps> = props
|
||||
<Text small textBreak variant="white">X: { itemLocation.x } · Y: { itemLocation.y } · H: { itemLocation.z < 0.01 ? 0 : itemLocation.z }</Text>
|
||||
</div>
|
||||
</> }
|
||||
{ (rareValue && rareValue.points > 0) &&
|
||||
<>
|
||||
<hr className="m-0 bg-[#0003] border-0 opacity-[.5] h-px" />
|
||||
<Flex alignItems="center" gap={ 2 }>
|
||||
<Text small variant="white">{ LocalizeText('rarevalues.infostand.label') }</Text>
|
||||
<Flex alignItems="center" gap={ 1 }>
|
||||
<Text small variant="white">{ rareValue.points }</Text>
|
||||
<LayoutCurrencyIcon type={ rareValue.pointsType } />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</> }
|
||||
{ godMode &&
|
||||
<>
|
||||
<hr className="m-0 bg-[#0003] border-0 opacity-[.5] h-px" />
|
||||
|
||||
@@ -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 (
|
||||
<NitroCard className="w-[420px] max-w-[96vw]" uniqueKey="soundboard">
|
||||
<NitroCard.Header headerText={ LocalizeText('soundboard.title') } onCloseClick={ () => setIsVisible(false) } />
|
||||
<NitroCard.Content>
|
||||
<Column gap={ 2 }>
|
||||
{ !sounds.length &&
|
||||
<Text small className="text-black/50">{ LocalizeText('soundboard.empty') }</Text> }
|
||||
{ !!sounds.length &&
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{ sounds.map(sound => (
|
||||
<button
|
||||
key={ sound.id }
|
||||
onClick={ () => play(sound) }
|
||||
title={ sound.name }
|
||||
className="flex h-20 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg bg-[#3a7bb5] px-2 text-white shadow transition-transform hover:bg-[#336ea3] active:scale-95">
|
||||
<span className="text-2xl leading-none">🔊</span>
|
||||
<span className="line-clamp-2 text-center text-[11px] font-bold leading-tight">{ sound.name }</span>
|
||||
</button>
|
||||
)) }
|
||||
</div> }
|
||||
{ lastPlayed &&
|
||||
<Flex alignItems="center" justifyContent="center" className="pt-1">
|
||||
<Text small className="text-[#2f6f95]">
|
||||
{ LocalizeText('soundboard.lastplayed', [ 'user' ], [ lastPlayed.username ]) }
|
||||
</Text>
|
||||
</Flex> }
|
||||
</Column>
|
||||
</NitroCard.Content>
|
||||
</NitroCard>
|
||||
);
|
||||
};
|
||||
@@ -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(() =>
|
||||
{
|
||||
@@ -250,6 +252,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
{ (getFullCount > 0) &&
|
||||
<LayoutItemCountView count={ getFullCount } className="absolute -right-1 top-0" /> }
|
||||
</motion.div>
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="rare-values" onClick={ () => CreateLinkEvent('rare-values/toggle') } className="tb-icon" />
|
||||
</motion.div>
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="fortune-wheel" onClick={ () => CreateLinkEvent('fortune-wheel/toggle') } className="tb-icon" />
|
||||
</motion.div>
|
||||
{ (isInRoom && showToolbarButton) &&
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="wired-tools" onClick={ openMonitor } className="tb-icon" />
|
||||
@@ -262,6 +270,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="youtube" onClick={ openYouTubePlayer } className="tb-icon" />
|
||||
</motion.div> }
|
||||
{ (isInRoom && soundboardEnabled) &&
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="soundboard" onClick={ () => CreateLinkEvent('soundboard/toggle') } className="tb-icon" />
|
||||
</motion.div> }
|
||||
{ isMod &&
|
||||
<motion.div variants={ itemVariants } className="relative">
|
||||
<ToolbarItemView icon="modtools" onClick={ () => CreateLinkEvent('mod-tools/toggle') } className="tb-icon" />
|
||||
@@ -358,6 +370,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
{ (getFullCount > 0) &&
|
||||
<LayoutItemCountView count={ getFullCount } className="absolute -right-1 top-0" /> }
|
||||
</motion.div>
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="rare-values" onClick={ () => CreateLinkEvent('rare-values/toggle') } className="tb-icon" />
|
||||
</motion.div>
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="fortune-wheel" onClick={ () => CreateLinkEvent('fortune-wheel/toggle') } className="tb-icon" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
variants={ containerVariants }
|
||||
@@ -374,6 +392,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="youtube" onClick={ openYouTubePlayer } className="tb-icon" />
|
||||
</motion.div> }
|
||||
{ (isInRoom && soundboardEnabled) &&
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="soundboard" onClick={ () => CreateLinkEvent('soundboard/toggle') } className="tb-icon" />
|
||||
</motion.div> }
|
||||
{ isMod &&
|
||||
<motion.div variants={ itemVariants } className="relative">
|
||||
<ToolbarItemView icon="modtools" onClick={ () => CreateLinkEvent('mod-tools/toggle') } className="tb-icon" />
|
||||
|
||||
@@ -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<FortuneWheelSettingsViewProps> = ({ onClose }) =>
|
||||
{
|
||||
const { adminPrizes = [], loadAdminPrizes = null, saveAdminPrizes = null } = useFortuneWheel();
|
||||
const [ editRows, setEditRows ] = useState<EditRow[]>([]);
|
||||
|
||||
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<EditRow>) =>
|
||||
setEditRows(prev => prev.map(row => (row.id === id) ? { ...row, ...patch } : row));
|
||||
|
||||
return (
|
||||
<NitroCard className="w-[480px] h-[520px]" uniqueKey="fortune-wheel-settings">
|
||||
<NitroCard.Header
|
||||
headerText={ LocalizeText('wheel.settings.title') }
|
||||
onCloseClick={ onClose } />
|
||||
<NitroCard.Content>
|
||||
<Column gap={ 1 } className="h-full p-1">
|
||||
<Flex gap={ 1 } className="px-1 text-[11px] font-bold text-black/60">
|
||||
<span className="w-28">{ LocalizeText('rarevalues.editor.type') }</span>
|
||||
<span className="w-16">{ LocalizeText('rarevalues.editor.value') }</span>
|
||||
<span className="w-12">{ LocalizeText('rarevalues.editor.weight') }</span>
|
||||
<span className="grow">{ LocalizeText('rarevalues.editor.label') }</span>
|
||||
</Flex>
|
||||
<Column gap={ 1 } overflow="auto" className="grow">
|
||||
{ editRows.map(row => (
|
||||
<Flex key={ row.id } alignItems="center" gap={ 1 } className="border-b border-black/10 pb-1">
|
||||
<select
|
||||
value={ row.category }
|
||||
onChange={ event => updateRow(row.id, { category: event.target.value }) }
|
||||
className="w-28 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]">
|
||||
{ CATEGORIES.map(cat => (
|
||||
<option key={ cat.key } value={ cat.key }>{ LocalizeText(cat.labelKey) }</option>
|
||||
)) }
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
value={ row.num }
|
||||
disabled={ row.category === 'nulla' }
|
||||
onChange={ event => 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" />
|
||||
<input
|
||||
type="number"
|
||||
value={ row.weight }
|
||||
onChange={ event => 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]" />
|
||||
<input
|
||||
type="text"
|
||||
value={ row.label }
|
||||
onChange={ event => 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]" />
|
||||
</Flex>
|
||||
)) }
|
||||
{ !editRows.length &&
|
||||
<Text small className="text-black/50">{ LocalizeText('wheel.settings.empty') }</Text> }
|
||||
</Column>
|
||||
<button
|
||||
type="button"
|
||||
disabled={ !editRows.length }
|
||||
onClick={ () => saveAdminPrizes?.(editRows.map(rowToEdit)) }
|
||||
className="cursor-pointer rounded bg-[#3a7bb5] px-4 py-2 font-bold text-white hover:bg-[#336ea3] disabled:cursor-default disabled:opacity-40">
|
||||
{ LocalizeText('rarevalues.editor.save') }
|
||||
</button>
|
||||
</Column>
|
||||
</NitroCard.Content>
|
||||
</NitroCard>
|
||||
);
|
||||
};
|
||||
@@ -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 <LayoutImage imageUrl={ GetRoomEngine().getFurnitureFloorIconUrl(prize.spriteId) } className="h-9 w-9 bg-contain bg-center bg-no-repeat" />;
|
||||
case 'badge':
|
||||
return <LayoutBadgeImageView badgeCode={ prize.badgeCode } />;
|
||||
case 'credits':
|
||||
return (
|
||||
<Column alignItems="center" gap={ 0 }>
|
||||
<LayoutCurrencyIcon type={ -1 } />
|
||||
<span className="text-[10px] font-bold text-[#2a3a42]">{ prize.amount }</span>
|
||||
</Column>);
|
||||
case 'points':
|
||||
return (
|
||||
<Column alignItems="center" gap={ 0 }>
|
||||
<LayoutCurrencyIcon type={ prize.pointsType } />
|
||||
<span className="text-[10px] font-bold text-[#2a3a42]">{ prize.amount }</span>
|
||||
</Column>);
|
||||
case 'spin':
|
||||
return <span className="text-xs font-bold text-[#2a3a42]">+{ prize.amount }</span>;
|
||||
default:
|
||||
return <span className="text-xs font-bold text-[#2a3a42]/60">—</span>;
|
||||
}
|
||||
};
|
||||
|
||||
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<IWheelPrize[]>([]);
|
||||
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 (
|
||||
<NitroCard className="w-[800px] max-w-[96vw]" uniqueKey="fortune-wheel">
|
||||
<NitroCard.Header headerText={ LocalizeText('wheel.title') } onCloseClick={ () => setIsVisible(false) } />
|
||||
<NitroCard.Content>
|
||||
<Flex gap={ 3 }>
|
||||
<Column alignItems="center" gap={ 2 } className="shrink-0">
|
||||
<div className="relative" style={ { width: WHEEL_SIZE, height: WHEEL_SIZE } }>
|
||||
<div
|
||||
className="absolute left-1/2 -top-2 z-20 -translate-x-1/2 drop-shadow-[0_2px_2px_rgba(0,0,0,0.25)]"
|
||||
style={ { width: 0, height: 0, borderLeft: '14px solid transparent', borderRight: '14px solid transparent', borderTop: `28px solid ${ RIM }` } } />
|
||||
<div
|
||||
className="absolute inset-0 rounded-full shadow-[0_0_0_4px_rgba(0,0,0,0.12),inset_0_0_18px_rgba(0,0,0,0.1)]"
|
||||
style={ { background, border: `8px solid ${ RIM }`, transform: `rotate(${ rotation }deg)`, transition: isSpinning ? 'transform 4.5s cubic-bezier(0.15,0.85,0.25,1)' : 'none' } }
|
||||
onTransitionEnd={ () => { if(isSpinning) finishSpin(); } }>
|
||||
{ prizes.map((_, i) => (
|
||||
<div
|
||||
key={ `divider-${ i }` }
|
||||
className="absolute bottom-1/2 left-1/2 origin-bottom"
|
||||
style={ { width: '2px', height: `${ WHEEL_SIZE / 2 }px`, transform: `translateX(-1px) rotate(${ i * sliceAngle }deg)`, background: 'rgba(76,96,108,0.3)' } } />
|
||||
)) }
|
||||
{ prizes.map((prize, i) =>
|
||||
{
|
||||
const centerAngle = ((i + 0.5) * sliceAngle);
|
||||
return (
|
||||
<div
|
||||
key={ prize.id }
|
||||
className="absolute left-1/2 top-1/2"
|
||||
style={ { transform: `rotate(${ centerAngle }deg) translateY(-${ ICON_RADIUS }px) rotate(-${ centerAngle }deg)` } }>
|
||||
<div className="-translate-x-1/2 -translate-y-1/2">
|
||||
{ renderPrizeIcon(prize) }
|
||||
</div>
|
||||
</div>);
|
||||
}) }
|
||||
</div>
|
||||
<div className="absolute left-1/2 top-1/2 z-10 h-14 w-14 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#eef2f5] shadow-[0_0_8px_rgba(0,0,0,0.25)]" style={ { border: `4px solid ${ RIM }` } } />
|
||||
</div>
|
||||
<Text bold className="text-[#2f6f95]">{ LocalizeText('wheel.free.today', [ 'count' ], [ freeSpins.toString() ]) }</Text>
|
||||
<Text small className="text-[#33424c]">{ LocalizeText('wheel.extra', [ 'count' ], [ extraSpins.toString() ]) }</Text>
|
||||
<Flex gap={ 2 } alignItems="center">
|
||||
<button
|
||||
disabled={ !canSpin }
|
||||
onClick={ () => spin() }
|
||||
className="cursor-pointer rounded bg-[#3a7bb5] px-4 py-2 font-bold text-white hover:bg-[#336ea3] disabled:cursor-default disabled:opacity-40">
|
||||
{ LocalizeText('wheel.spin') }
|
||||
</button>
|
||||
<button
|
||||
onClick={ () => buySpin() }
|
||||
className="flex cursor-pointer items-center gap-1 rounded bg-[#6b7884] px-3 py-2 text-white hover:bg-[#5e6a75]">
|
||||
{ LocalizeText('wheel.buy') } { spinCost }
|
||||
<LayoutCurrencyIcon type={ spinCostType } />
|
||||
</button>
|
||||
{ canManage &&
|
||||
<button
|
||||
onClick={ () => setIsSettingsOpen(true) }
|
||||
className="cursor-pointer rounded bg-[#8a6b3a] px-3 py-2 font-bold text-white hover:bg-[#735730]">
|
||||
{ LocalizeText('wheel.settings') }
|
||||
</button> }
|
||||
</Flex>
|
||||
</Column>
|
||||
<Column gap={ 2 } className="min-w-[300px] grow rounded-lg border border-black/10 bg-black/5 p-3">
|
||||
<Text bold className="text-base text-[#33424c]">{ LocalizeText('wheel.winners') }</Text>
|
||||
<Column gap={ 1 } overflow="auto" className="h-[440px]">
|
||||
{ recentWins.map((win, i) => (
|
||||
<Flex key={ i } alignItems="center" gap={ 2 } className="rounded border-b border-black/10 py-1.5 hover:bg-black/5">
|
||||
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded bg-black/5">
|
||||
<LayoutAvatarImageView figure={ win.look } headOnly direction={ 2 } style={ { backgroundSize: 'auto', backgroundPosition: '-22px -32px' } } />
|
||||
</div>
|
||||
<Column gap={ 0 } className="min-w-0">
|
||||
<Text bold truncate className="text-[#1f2d34]">{ win.username }</Text>
|
||||
<Text small truncate className="text-[#2f6f95]">{ win.prizeLabel }</Text>
|
||||
</Column>
|
||||
</Flex>
|
||||
)) }
|
||||
{ !recentWins.length &&
|
||||
<Text small className="text-black/50">{ LocalizeText('wheel.winners.empty') }</Text> }
|
||||
</Column>
|
||||
</Column>
|
||||
</Flex>
|
||||
</NitroCard.Content>
|
||||
{ canManage && isSettingsOpen &&
|
||||
<FortuneWheelSettingsView onClose={ () => setIsSettingsOpen(false) } /> }
|
||||
</NitroCard>
|
||||
);
|
||||
};
|
||||
@@ -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<RareValueRow[]>(() =>
|
||||
{
|
||||
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<RareValueRow[]>(() =>
|
||||
{
|
||||
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 (
|
||||
<NitroCard className="w-[420px] h-[480px]" uniqueKey="rare-values">
|
||||
<NitroCard.Header
|
||||
headerText={ LocalizeText('rarevalues.title') }
|
||||
onCloseClick={ () => setIsVisible(false) } />
|
||||
<NitroCard.Content>
|
||||
<Column gap={ 2 } className="h-full p-1">
|
||||
<NitroInput
|
||||
placeholder={ LocalizeText('generic.search') }
|
||||
value={ searchValue }
|
||||
onChange={ event => setSearchValue(event.target.value) } />
|
||||
<Column gap={ 0 } overflow="auto" className="grow">
|
||||
{ !loaded &&
|
||||
<Text center className="mt-2 text-black/60">{ LocalizeText('rarevalues.loading') }</Text> }
|
||||
{ (loaded && !filtered.length) &&
|
||||
<Text center className="mt-2 text-black/60">{ LocalizeText('rarevalues.empty') }</Text> }
|
||||
{ filtered.map(row => (
|
||||
<Flex key={ row.spriteId } alignItems="center" gap={ 2 } className="border-b border-black/10 py-1.5 hover:bg-black/5">
|
||||
<LayoutImage imageUrl={ row.iconUrl } className="h-10 w-10 shrink-0 bg-contain bg-center bg-no-repeat" />
|
||||
<Text truncate className="grow text-[#1f2d34]">{ row.name }</Text>
|
||||
<Flex alignItems="center" gap={ 1 } className="shrink-0">
|
||||
<Text bold textEnd className="text-[#2f6f95]">{ LocalizeFormattedNumber(row.value.points) }</Text>
|
||||
<LayoutCurrencyIcon type={ row.value.pointsType } />
|
||||
</Flex>
|
||||
</Flex>
|
||||
)) }
|
||||
</Column>
|
||||
</Column>
|
||||
</NitroCard.Content>
|
||||
</NitroCard>
|
||||
);
|
||||
};
|
||||
@@ -77,6 +77,20 @@
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.nitro-icon.icon-rare-values {
|
||||
background-image: url("@/assets/images/toolbar/icons/catalog.png");
|
||||
width: 37px;
|
||||
height: 36px;
|
||||
filter: hue-rotate(280deg) saturate(1.4);
|
||||
}
|
||||
|
||||
.nitro-icon.icon-fortune-wheel {
|
||||
background-image: url("@/assets/images/toolbar/icons/game.png");
|
||||
width: 44px;
|
||||
height: 25px;
|
||||
filter: hue-rotate(300deg) saturate(1.6);
|
||||
}
|
||||
|
||||
.nitro-icon.icon-housekeeping {
|
||||
background-image: url("@/assets/images/toolbar/icons/modtools.png");
|
||||
width: 29px;
|
||||
@@ -202,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;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { IWheelAdminPrize, IWheelAdminPrizeEdit, IWheelPrize, IWheelRecentWin, WheelAdminGetPrizesComposer, WheelAdminPrizesEvent, WheelAdminSavePrizesComposer, WheelBuySpinComposer, WheelDataEvent, WheelOpenComposer, WheelRecentWinsEvent, WheelResultEvent, WheelSpinComposer } from '@nitrots/nitro-renderer';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useBetween } from 'use-between';
|
||||
import { SendMessageComposer } from '../../api';
|
||||
import { useMessageEvent } from '../events';
|
||||
|
||||
// Fortune wheel state + actions. Shared via useBetween so the event listeners
|
||||
// register once regardless of how many components read it.
|
||||
const useFortuneWheelState = () =>
|
||||
{
|
||||
const [ freeSpins, setFreeSpins ] = useState(0);
|
||||
const [ extraSpins, setExtraSpins ] = useState(0);
|
||||
const [ spinCost, setSpinCost ] = useState(0);
|
||||
const [ spinCostType, setSpinCostType ] = useState(-1);
|
||||
const [ prizes, setPrizes ] = useState<IWheelPrize[]>([]);
|
||||
const [ recentWins, setRecentWins ] = useState<IWheelRecentWin[]>([]);
|
||||
const [ pendingPrizeId, setPendingPrizeId ] = useState<number>(-1);
|
||||
const [ isSpinning, setIsSpinning ] = useState(false);
|
||||
const [ adminPrizes, setAdminPrizes ] = useState<IWheelAdminPrize[]>([]);
|
||||
|
||||
useMessageEvent<WheelAdminPrizesEvent>(WheelAdminPrizesEvent, event =>
|
||||
{
|
||||
setAdminPrizes(event.getParser().prizes);
|
||||
});
|
||||
|
||||
useMessageEvent<WheelDataEvent>(WheelDataEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
setFreeSpins(parser.freeSpins);
|
||||
setExtraSpins(parser.extraSpins);
|
||||
setSpinCost(parser.spinCost);
|
||||
setSpinCostType(parser.spinCostType);
|
||||
setPrizes(parser.prizes);
|
||||
});
|
||||
|
||||
useMessageEvent<WheelResultEvent>(WheelResultEvent, event =>
|
||||
{
|
||||
setPendingPrizeId(event.getParser().prizeId);
|
||||
setIsSpinning(true);
|
||||
});
|
||||
|
||||
useMessageEvent<WheelRecentWinsEvent>(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);
|
||||
@@ -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,12 +15,15 @@ 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';
|
||||
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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<NavigatorUiState & NavigatorUiActions>()((set) => ({
|
||||
@@ -40,6 +44,8 @@ export const useNavigatorUiStore = createNitroStore<NavigatorUiState & Navigator
|
||||
isLoading: false,
|
||||
needsInit: true,
|
||||
needsSearch: false,
|
||||
currentTabCode: '',
|
||||
currentFilter: '',
|
||||
|
||||
show: () => set({ isVisible: true, needsSearch: true }),
|
||||
hide: () => set({ isVisible: false }),
|
||||
@@ -57,5 +63,7 @@ export const useNavigatorUiStore = createNitroStore<NavigatorUiState & Navigator
|
||||
markReady: () => 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 })
|
||||
}));
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { useBetween } from 'use-between';
|
||||
import { useNavigatorStore } from './useNavigatorStore';
|
||||
|
||||
export const useNavigatorActions = () =>
|
||||
{
|
||||
const { sendSearch, reloadCurrentSearch } = useBetween(useNavigatorStore);
|
||||
return { sendSearch, reloadCurrentSearch };
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { FlatCreatedEvent, 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 }) => (
|
||||
<QueryClientProvider client={ client }>
|
||||
{ children }
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
/** Build a fake NavigatorSearchEvent that getParser() returns a result with `code`. */
|
||||
const makeSearchEvent = (code: string) =>
|
||||
{
|
||||
// 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)() as any;
|
||||
ev.getParser = () => ({ result });
|
||||
return ev;
|
||||
};
|
||||
|
||||
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') as any);
|
||||
});
|
||||
|
||||
// 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') as any);
|
||||
});
|
||||
|
||||
// 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') as any);
|
||||
});
|
||||
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') as any);
|
||||
});
|
||||
|
||||
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') as any);
|
||||
});
|
||||
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') as any);
|
||||
});
|
||||
|
||||
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') as any);
|
||||
});
|
||||
|
||||
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') as any);
|
||||
});
|
||||
|
||||
// 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') as any);
|
||||
});
|
||||
|
||||
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') as any);
|
||||
});
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
// Dispatch FlatCreatedEvent — should trigger invalidateQueries
|
||||
const flatCreatedEv = new (FlatCreatedEvent as any)() as any;
|
||||
flatCreatedEv.getParser = () => ({ roomId: 999 });
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(flatCreatedEv as any);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
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';
|
||||
|
||||
|
||||
export const useNavigatorSearch = () =>
|
||||
{
|
||||
const tabCode = useNavigatorUiStore(s => s.currentTabCode);
|
||||
const filter = useNavigatorUiStore(s => s.currentFilter);
|
||||
|
||||
const [ searchResult, setSearchResult ] = useState<NavigatorSearchResultSet | null>(null);
|
||||
const [ isFetching, setIsFetching ] = useState(false);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!tabCode) return;
|
||||
|
||||
setIsFetching(true);
|
||||
SendMessageComposer(new NavigatorSearchComposer(tabCode, filter));
|
||||
}, [ tabCode, filter ]);
|
||||
|
||||
useMessageEvent<NavigatorSearchEvent>(NavigatorSearchEvent, event =>
|
||||
{
|
||||
const result = event.getParser()?.result;
|
||||
if(!result) return;
|
||||
|
||||
if(tabCode && result.code !== tabCode) return;
|
||||
|
||||
setSearchResult(result);
|
||||
setIsFetching(false);
|
||||
});
|
||||
|
||||
return {
|
||||
searchResult,
|
||||
isFetching,
|
||||
refetch: () =>
|
||||
{
|
||||
if(!tabCode) return;
|
||||
setIsFetching(true);
|
||||
SendMessageComposer(new NavigatorSearchComposer(tabCode, filter));
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<number[]>([]);
|
||||
const [ topLevelContext, setTopLevelContext ] = useState<NavigatorTopLevelContext>(null);
|
||||
const [ topLevelContexts, setTopLevelContexts ] = useState<NavigatorTopLevelContext[]>(null);
|
||||
const [ searchResult, setSearchResult ] = useState<NavigatorSearchResultSet>(null);
|
||||
const [ navigatorSearches, setNavigatorSearches ] = useState<NavigatorSavedSearch[]>(null);
|
||||
const [ navigatorData, setNavigatorData ] = useState<INavigatorData>({
|
||||
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>(FavouritesEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
@@ -99,12 +65,6 @@ export const useNavigatorStore = () =>
|
||||
});
|
||||
}, []));
|
||||
|
||||
useMessageEvent<RoomSettingsUpdatedEvent>(RoomSettingsUpdatedEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, false, false));
|
||||
}, []));
|
||||
|
||||
useMessageEvent<CanCreateRoomEventEvent>(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>(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>(UserFlatCatsEvent, useCallback(event =>
|
||||
@@ -342,7 +282,6 @@ export const useNavigatorStore = () =>
|
||||
return {
|
||||
categories, eventCategories, favouriteRoomIds,
|
||||
topLevelContext, topLevelContexts,
|
||||
searchResult, navigatorSearches, navigatorData,
|
||||
sendSearch, reloadCurrentSearch
|
||||
navigatorSearches, navigatorData
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<RadioStation[]>([]);
|
||||
const [ currentId, setCurrentId ] = useState<string | null>(null);
|
||||
const [ isPlaying, setIsPlaying ] = useState(false);
|
||||
const [ loadError, setLoadError ] = useState<string | null>(null);
|
||||
const [ volume, setVolumeState ] = useState(0.05); // start quiet (5%) so autostart isn't intrusive
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const loadStartedRef = useRef(false);
|
||||
const autoStartedRef = useRef(false);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(loadStartedRef.current) return;
|
||||
loadStartedRef.current = true;
|
||||
|
||||
const url = GetConfigurationValue<string>('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);
|
||||
@@ -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<Map<number, IRareValue>>(() => new Map());
|
||||
const [ loaded, setLoaded ] = useState(false);
|
||||
|
||||
useMessageEvent<RareValuesEvent>(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);
|
||||
@@ -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));
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { ISoundboardSound, loadGamedata, SoundboardPlayComposer, SoundboardPlayEvent, SoundboardSetEnabledComposer, SoundboardSettingsEvent } from '@nitrots/nitro-renderer';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useBetween } from 'use-between';
|
||||
import { GetConfigurationValue, SendMessageComposer, setSoundboardRoomEnabled } from '../../api';
|
||||
import { useMessageEvent } from '../events';
|
||||
|
||||
// A pad as the client uses it. `local` marks pads that came from the JSON5 file
|
||||
// fallback rather than the server (DB) — those play locally on click because the
|
||||
// server can't resolve their id to broadcast them.
|
||||
export type ClientSoundboardSound = ISoundboardSound & { local?: boolean };
|
||||
|
||||
const playLocal = (url: string) =>
|
||||
{
|
||||
if(!url) return;
|
||||
try
|
||||
{
|
||||
const audio = new Audio(url);
|
||||
audio.volume = 0.8;
|
||||
void audio.play().catch(() => {});
|
||||
}
|
||||
catch {}
|
||||
};
|
||||
|
||||
// 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('//') || url.startsWith('/')) return url;
|
||||
|
||||
const base = (GetConfigurationValue<string>('soundboard.url.prefix') || GetConfigurationValue<string>('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 [ serverSounds, setServerSounds ] = useState<ISoundboardSound[]>([]);
|
||||
const [ fileSounds, setFileSounds ] = useState<ClientSoundboardSound[]>([]);
|
||||
const [ lastPlayed, setLastPlayed ] = useState<{ soundId: number; username: string } | null>(null);
|
||||
const fileLoadStartedRef = useRef(false);
|
||||
|
||||
useMessageEvent<SoundboardSettingsEvent>(SoundboardSettingsEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
setEnabled(parser.enabled);
|
||||
setServerSounds(parser.sounds);
|
||||
setSoundboardRoomEnabled(parser.enabled);
|
||||
});
|
||||
|
||||
useMessageEvent<SoundboardPlayEvent>(SoundboardPlayEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
playLocal(resolveUrl(parser.url));
|
||||
setLastPlayed({ soundId: parser.soundId, username: parser.username });
|
||||
});
|
||||
|
||||
// Fallback: when the soundboard is on but the server (DB) provided no pads,
|
||||
// load them from the JSON5 file once. loadGamedata accepts plain JSON and
|
||||
// JSON5 (// comments) — same loader used for the avatar effect map.
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!enabled || serverSounds.length || fileLoadStartedRef.current) return;
|
||||
fileLoadStartedRef.current = true;
|
||||
|
||||
const url = GetConfigurationValue<string>('soundboard.sounds.url') || 'configuration/soundboard-sounds.json5';
|
||||
|
||||
(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const json = await loadGamedata<{ sounds?: ISoundboardSound[] }>(url);
|
||||
const list = Array.isArray(json?.sounds)
|
||||
? json.sounds
|
||||
.filter(s => s && s.url)
|
||||
.map(s => ({ id: s.id, name: s.name, url: s.url, local: true }))
|
||||
: [];
|
||||
setFileSounds(list);
|
||||
}
|
||||
catch {}
|
||||
})();
|
||||
}, [ enabled, serverSounds.length ]);
|
||||
|
||||
const sounds: ClientSoundboardSound[] = serverSounds.length ? serverSounds : fileSounds;
|
||||
|
||||
const play = useCallback((sound: ClientSoundboardSound) =>
|
||||
{
|
||||
if(!sound) return;
|
||||
// File-defined pad: the server doesn't know it, so play it locally.
|
||||
if(sound.local)
|
||||
{
|
||||
playLocal(resolveUrl(sound.url));
|
||||
return;
|
||||
}
|
||||
// DB-backed pad: let the server broadcast it to everyone in the room.
|
||||
SendMessageComposer(new SoundboardPlayComposer(sound.id));
|
||||
}, []);
|
||||
|
||||
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);
|
||||
setServerSounds([]);
|
||||
setLastPlayed(null);
|
||||
setSoundboardRoomEnabled(false);
|
||||
}, []);
|
||||
|
||||
return { enabled, sounds, lastPlayed, play, setRoomEnabled, reset };
|
||||
};
|
||||
|
||||
export const useSoundboard = () => useBetween(useSoundboardState);
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user