Merge pull request #183 from duckietm/Dev

Dev
This commit is contained in:
DuckieTM
2026-06-01 08:26:41 +02:00
committed by GitHub
138 changed files with 2197 additions and 569 deletions
+1
View File
@@ -44,3 +44,4 @@ Thumbs.db
# the dev server takes minutes to start with 100k+ files under public/. # the dev server takes minutes to start with 100k+ files under public/.
/public/nitro-assets /public/nitro-assets
/public/swf /public/swf
.superpowers/
+40 -12
View File
@@ -6,19 +6,27 @@ the ground running.
## TL;DR ## TL;DR
This branch — **`feat/react19-modernization`** — is a long-running modernization This client carries a long-running React 19.2 modernization: React 19
of the Nitro V3 client: bump to React 19.2 idioms, add the supporting idioms + supporting infrastructure (TanStack Query, Zustand, Vitest,
infrastructure (TanStack Query, Zustand, Vitest, React Compiler, error React Compiler, error boundaries), god-hook splits, and logic-bug audits.
boundaries), split a few god-hooks, and audit logic bugs along the way.
PR is **#2** on `simoleo89/Nitro-V3`.
Upstream `duckietm/Nitro-V3` (`origin/Dev`) is merged in through **Working base is now `main`** (tracking `duckietm/Nitro-V3`). The earlier
`b2318b9` as of 2026-05-18 (merge commit `779a98c`). That brings in `feat/react19-modernization` long-running branch was superseded — feature
JSON5 config support, user-settings (reset password / email / change work now ships as small focused PRs against `duckietm:Dev`, staged through
username), wear-badge popup fix, login screen fix, About update, and Dev then merged to main. (`feat/react19-modernization` still exists on the
the offer-selection refactor. When syncing the next batch of upstream fork as backup; do not force-push it.)
commits, expect conflicts in `App.tsx` / `bootstrap.ts` / `LoginView.tsx`
on React 19 imports — always keep the modernized local version. **Navigator modernization landed** (merged to main 2026-05-28, PRs
#168/#169/#170): the 492-line `useNavigator` god-hook was split into
`useNavigatorStore` + `useNavigatorData`/`useNavigatorUiState`/
`useNavigatorSearch` filters (wired-tools layout), door lifecycle extracted
to `src/hooks/rooms/widgets/useDoorState.ts`, 9 UI flags moved to a Zustand
`navigatorUiStore`, search migrated to a query hook, and 5 sub-views wrapped
in `WidgetErrorBoundary`. **Caveat**: duckietm patched `useNavigatorSearch`
post-merge (`05d71dd1`) — see the `useNitroQuery` fragility note below.
When syncing upstream, expect conflicts in `App.tsx` / `bootstrap.ts` /
`LoginView.tsx` on React 19 imports — always keep the modernized version.
Local-dev game assets are served by a small Vite plugin (`sirv` middleware Local-dev game assets are served by a small Vite plugin (`sirv` middleware
mounted on `/nitro-assets` and `/swf`, reading from mounted on `/nitro-assets` and `/swf`, reading from
@@ -236,6 +244,20 @@ and invalidates the query slot on every push, so server-driven
refresh paths work the same as the initial request/response (e.g. refresh paths work the same as the initial request/response (e.g.
ClubGiftInfoEvent firing again after the user claims a gift). ClubGiftInfoEvent firing again after the user claims a gift).
**⚠️ Fragility — do NOT use `useNitroQuery` for primary visible data.**
The one-shot listener inside `awaitNitroResponse` (register listener →
await one matching response → remove itself) is fragile against
renderer-bundle quirks: for some parsers the event fires but the listener
never matches, so the promise never resolves and `query.data` stays
`undefined` forever — the UI shows the server's response arriving in logs
but renders blank. This bit **ModTools Room/CFH chatlog** (reverted to
`useMessageEvent + useEffect`) and then **Navigator search** (P2 shipped
with `useNitroQuery`, duckietm reverted it in `05d71dd1` to the god-hook
pattern). **Rule: reserve `useNitroQuery` for config / secondary fetches
where a brief blank is tolerable. For anything that is the primary visible
content of a panel, use `useMessageEvent + useState/useEffect`** — that's
what the rest of the codebase does and it's robust.
### Singleton-filter split for `useBetween`-based hooks ### Singleton-filter split for `useBetween`-based hooks
When a hook backs many consumers but most only need either state OR When a hook backs many consumers but most only need either state OR
@@ -339,6 +361,7 @@ into `configurePreviewServer` so `yarn preview` keeps working.
| Zustand | `NavigatorRoomCreatorView` (`useRoomCreatorStore`), `WiredCreatorToolsView` (`useWiredCreatorToolsUiStore` — every panel-lifecycle-relevant flag, snapshot, selection, highlight, inline editor, picker chain hoisted; what's left in the component as `useState` is genuinely transient: keepSelected, globalClock, roomEnteredAt, selectedMonitorErrorType, selectedMonitorLogDetails) | | Zustand | `NavigatorRoomCreatorView` (`useRoomCreatorStore`), `WiredCreatorToolsView` (`useWiredCreatorToolsUiStore` — every panel-lifecycle-relevant flag, snapshot, selection, highlight, inline editor, picker chain hoisted; what's left in the component as `useState` is genuinely transient: keepSelected, globalClock, roomEnteredAt, selectedMonitorErrorType, selectedMonitorLogDetails) |
| God-hook split (state + actions + shim) | `doorbell`, `poll`, `furni-chooser`, `user-chooser`, `friend-request`, `chat-input` | | God-hook split (state + actions + shim) | `doorbell`, `poll`, `furni-chooser`, `user-chooser`, `friend-request`, `chat-input` |
| God-hook split (`useBetween` singleton + state filter + actions filter + shim) | `wired-tools`, `translation`, `notification`, `friends`, `catalog` (three-way: `useCatalogData` / `useCatalogUiState` / `useCatalogActions` — all 48 consumers migrated, deprecated `useCatalog` shim removed) | | God-hook split (`useBetween` singleton + state filter + actions filter + shim) | `wired-tools`, `translation`, `notification`, `friends`, `catalog` (three-way: `useCatalogData` / `useCatalogUiState` / `useCatalogActions` — all 48 consumers migrated, deprecated `useCatalog` shim removed) |
| Navigator modernization (merged to main 2026-05-28, PRs #168/#169/#170) | 492-line `useNavigator` god-hook split into `useNavigatorStore` (internal `useBetween` closure) + flat filters `useNavigatorData` / `useNavigatorUiState` / `useNavigatorSearch`; door bell/password lifecycle extracted to `src/hooks/rooms/widgets/useDoorState.ts` (dual-subscribes `GetGuestRoomResultEvent` + `GenericErrorEvent` alongside the nav store, each filtering by branch/errorCode); 9 UI flags + `currentTabCode`/`currentFilter` in Zustand `navigatorUiStore` (`src/hooks/navigator/navigatorUiStore.ts`); all 5 Navigator sub-views wrapped in `WidgetErrorBoundary`; old shim deleted. **`useNavigatorSearch` was reverted by duckietm (`05d71dd1`) from `useNitroQuery` to `useMessageEvent + useEffect`** — see the useNitroQuery fragility note. Specs/plans under `docs/superpowers/`. |
| `WidgetErrorBoundary` | `RoomWidgetsView` umbrella + per-widget wrap on all 13 room widgets and all 20 furniture widgets (so a crash in one widget no longer takes down its siblings) | | `WidgetErrorBoundary` | `RoomWidgetsView` umbrella + per-widget wrap on all 13 room widgets and all 20 furniture widgets (so a crash in one widget no longer takes down its siblings) |
| Vitest | 207/207 cases — pure helpers (incl. 4 new on `getPetPackageNameError`) + 2 Zustand store suites (`navigatorRoomCreatorStore`, `wiredCreatorToolsUiStore` with 45 cases including the picker-chain hoists) + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock at `src/nitro-renderer.mock.ts`, 34 cases on the catalog pure helpers, 4 contract cases on the catalog filters. **Tests are co-located** under `src/`, alongside their subject. | | Vitest | 207/207 cases — pure helpers (incl. 4 new on `getPetPackageNameError`) + 2 Zustand store suites (`navigatorRoomCreatorStore`, `wiredCreatorToolsUiStore` with 45 cases including the picker-chain hoists) + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock at `src/nitro-renderer.mock.ts`, 34 cases on the catalog pure helpers, 4 contract cases on the catalog filters. **Tests are co-located** under `src/`, alongside their subject. |
| Form Actions | Login / Register / Forgot (LoginView.tsx) | | Form Actions | Login / Register / Forgot (LoginView.tsx) |
@@ -412,6 +435,11 @@ See `docs/ARCHITECTURE.md` "Recently fixed" for fix shapes.
`useCatalogUiState` / `useCatalogActions` in `useCatalogUiState` / `useCatalogActions` in
`src/hooks/catalog/useCatalog.ts` (all 48 consumers migrated; `src/hooks/catalog/useCatalog.ts` (all 48 consumers migrated;
deprecated `useCatalog` shim removed) deprecated `useCatalog` shim removed)
- Navigator hooks: `src/hooks/navigator/``useNavigatorStore.ts`
(internal closure), `useNavigatorData.ts` / `useNavigatorUiState.ts` /
`useNavigatorSearch.ts` (filters), `navigatorUiStore.ts` (Zustand UI
flags + `setTab`/`setFilter`). Door lifecycle: `src/hooks/rooms/widgets/useDoorState.ts`.
Specs/plans: `docs/superpowers/specs/2026-05-2*-navigator-*.md`
- Renderer-SDK mock for Vitest: `src/nitro-renderer.mock.ts` - Renderer-SDK mock for Vitest: `src/nitro-renderer.mock.ts`
(aliased over `@nitrots/nitro-renderer` via `vitest.config.mts`). (aliased over `@nitrots/nitro-renderer` via `vitest.config.mts`).
Hosts the explicit `NitroLogger` mock, the `mockEventDispatcher` / Hosts the explicit `NitroLogger` mock, the `mockEventDispatcher` /
+40
View File
@@ -0,0 +1,40 @@
# Custom themes (graphics-only)
Ecosistema temi caricati a **runtime** (niente rebuild del client). Un tema =
una cartella con un manifest + "pezzi" CSS. Ogni pezzo è attivabile/disattivabile
dall'utente da **Impostazioni → Temi** (checkbox). Se un pezzo è rotto/404 →
fallback automatico al default (solo quel pezzo).
## Dove vivono
- **Questa cartella (`custom-themes/`) è solo il TEMPLATE di riferimento**, versionata su git.
- I temi **veri** stanno sul server in `public/nitro/custom-themes/` (serviti via
l'url configurato in ui-config `theme.base.url`, es. `/client/nitro/custom-themes`).
NON vanno su git → vedi `.gitignore` (`public/custom-themes/`).
## Struttura
```
custom-themes/
index.json # { "themes": [ { "id", "name", "author?" } ] }
<id>/
theme.json # { "name", "pieces": [ { "id", "name", "file" } ] }
cards.css chat.css ... # un file per "pezzo"
assets/... # immagini referenziate dai CSS (url assoluti)
```
## Creare un tema
1. Copia `neon-viola/` in una nuova cartella `<id>/`.
2. Modifica `theme.json` (nome + elenco pezzi).
3. Scrivi i CSS dei pezzi (override con `!important`, caricati dopo il base).
4. Aggiungi `{ "id": "<id>", "name": "..." }` a `index.json`.
5. Carica la cartella in `public/nitro/custom-themes/` sul server. **Nessun rebuild.**
## Default hotel-wide (admin)
In `ui-config.json`:
- `theme.base.url` → dove sono serviti i temi
- `theme.default` → id del tema attivo di default (vuoto = nessuno)
- `theme.default.pieces` → array di id pezzi attivi di default
Ogni utente può comunque sovrascrivere da Impostazioni → Temi (salvato in localStorage).
> Nota: i temi ri-skinnano solo la **grafica** (CSS). Non cambiano la struttura
> dei componenti né il comportamento.
+5
View File
@@ -0,0 +1,5 @@
{
"themes": [
{ "id": "neon-viola", "name": "Neon Viola", "author": "infinityhotel" }
]
}
+24
View File
@@ -0,0 +1,24 @@
/* Tema Neon Viola — pezzo "cards" (finestre / NitroCard).
Ricolora header + cornice delle finestre. Caricato DOPO il CSS base, quindi
usa !important per vincere. Tocca solo la cornice/header (non lo sfondo del
contenuto) per non rovinare la leggibilita' del testo. */
.nitro-card-shell:not(.nitro-wired) {
border-color: #7c3aed !important;
box-shadow: 0 0 14px rgba(124, 58, 237, .55), 0 8px 22px rgba(0, 0, 0, .4) !important;
}
.nitro-card-shell:not(.nitro-wired) .nitro-card-header-shell {
background: linear-gradient(180deg, #9333ea 0%, #6d28d9 100%) !important;
border-color: #a855f7 !important;
border-bottom-color: #2a0a4a !important;
}
.nitro-card-shell:not(.nitro-wired) .nitro-card-title {
color: #fff !important;
text-shadow: 0 0 6px #c084fc, 0 1px 0 #3b0764 !important;
}
.nitro-card-shell:not(.nitro-wired) .nitro-card-tabs-shell .nitro-card-tab-item-active {
box-shadow: inset 0 -2px 0 #a855f7 !important;
}
+10
View File
@@ -0,0 +1,10 @@
/* Tema Neon Viola — pezzo "catalog" (catalogo Hippiehotel, .nitro-catalog). */
.nitro-catalog .nitro-card-header-shell {
background: linear-gradient(180deg, #9333ea 0%, #6d28d9 100%) !important;
}
.nitro-catalog .group\/rail {
background: #1a1030 !important;
border-right-color: #7c3aed !important;
}
+12
View File
@@ -0,0 +1,12 @@
/* Tema Neon Viola — pezzo "chat".
Accento viola sulla bubble di default (bubble-0) e sull'input chat.
(Le bubble custom hanno la loro grafica; qui tocchiamo solo l'accento base.) */
.chat-bubble.bubble-0 {
filter: drop-shadow(0 0 5px rgba(168, 85, 247, .8));
}
.nitro-chat-input-container,
.chat-input-container {
box-shadow: inset 0 0 0 1px #7c3aed !important;
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "Neon Viola",
"author": "infinityhotel",
"pieces": [
{ "id": "cards", "name": "Finestre / Card", "file": "cards.css" },
{ "id": "chat", "name": "Chat", "file": "chat.css" },
{ "id": "toolbar", "name": "Toolbar", "file": "toolbar.css" },
{ "id": "catalog", "name": "Catalogo", "file": "catalog.css" }
]
}
+9
View File
@@ -0,0 +1,9 @@
/* Tema Neon Viola — pezzo "toolbar".
Best-effort: ricolora la barra strumenti in basso. Se i selettori non
matchano nella tua build, il pezzo non ha effetto (fallback sicuro). */
.nitro-toolbar,
[class*="toolbar-container"] {
background: linear-gradient(180deg, #2a0a4a 0%, #1a0730 100%) !important;
box-shadow: 0 -2px 10px rgba(124, 58, 237, .4) !important;
}
@@ -0,0 +1,71 @@
# Navigator — Room Settings "Base" tab: stacked-label layout
**Date:** 2026-05-31
**Component:** Nitro-V3 client
**File:** `src/components/navigator/views/room-settings/NavigatorRoomSettingsBasicTabView.tsx`
**Type:** Layout-only refactor (no logic / data-flow change)
## Problem
The Base tab uses a horizontal two-column row layout: a fixed-width label on the
left, the control on the right. In the narrow room-settings panel the label column
is too tight, so multi-word Italian labels ("Visitatori massimi", "Impostazioni
scambio") wrap onto two lines and look broken. An earlier fix replaced dead
Bootstrap `col-3` classes with `w-1/4 shrink-0`, which stopped the crushing but
still leaves the labels cramped and occasionally wrapping.
The other five room-settings tabs (Access, Rights, VIP/Chat, Mod, Misc) already use
idiomatic vertical/grouped layouts. Base is the outlier.
## Decision
Adopt the **stacked-label** pattern (chosen from three mockup options — A stacked,
B sectioned cards, C wider label column). Each field becomes a vertical block: bold
label on top, full-width control below, validation message underneath. This mirrors
the sibling **Access** tab's existing `<Column gap={1}>` + `<Text bold>` shape, so
the two tabs become visually consistent and labels can never wrap.
## Layout
Every field → its own `<Column gap={1}>` block:
```tsx
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.roomname') }</Text>
<input className="form-control form-control-sm" value={ roomName } onBlur={ saveRoomName } />
{ (roomName.length < ROOM_NAME_MIN_LENGTH) &&
<Text bold small variant="danger">{ LocalizeText('navigator.roomsettings.roomnameismandatory') }</Text> }
</Column>
```
Field-by-field:
- **Nome stanza** — stacked block, mandatory-name validation preserved.
- **Descrizione** — stacked block, `<textarea>` full width.
- **Categoria** — stacked block, `<select>` from `categories`.
- **Visitatori massimi** — stacked block, `<select>` from `GetMaxVisitorsList`.
- **Impostazioni scambio** — stacked block, 3-option `<select>`.
- **Tag** — one "Tag" label, then the two tag inputs side-by-side in a
`<Flex gap={1}>`, each `fullWidth`, each keeping its own length/type validation.
- **allow_walkthrough / allow_underpass** — remain inline `checkbox + label` rows;
remove the empty `<Base className="w-1/4 shrink-0" />` spacers that only existed
to align with the old label column.
- **Delete link** — unchanged at the bottom.
## Explicit non-goals
- No change to `handleChange` field names or values.
- No change to validation thresholds (`ROOM_NAME_MIN_LENGTH=3`,
`ROOM_NAME_MAX_LENGTH=60`, `DESC_MAX_LENGTH=255`, `TAGS_MAX_LENGTH=15`).
- No change to save-on-blur handlers (`saveRoomName`, `saveRoomDescription`,
`saveTags`), the `RoomSettingsSaveErrorEvent` subscription, or `deleteRoom`.
- No change to field order or any localization key.
- No change to the other five tabs.
- The `w-1/4 shrink-0` utility classes added in the prior fix are removed (labels
are full-width now).
## Risk
Single-file, JSX-only diff. No test covers this view, so no test impact. Manual
check: open Room Settings → Base, confirm no label wraps, all controls full width,
validation still appears, save-on-blur still fires.
@@ -642,6 +642,9 @@
'wheel.buy': 'Buy spin', 'wheel.buy': 'Buy spin',
'wheel.winners': 'Latest winners', 'wheel.winners': 'Latest winners',
'wheel.winners.empty': 'No winners yet', 'wheel.winners.empty': 'No winners yet',
'wheel.win.title': 'You won!',
'wheel.win.jackpot': '★ Jackpot ★',
'wheel.win.nothing': 'Better luck next time!',
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
// Soundboard // Soundboard
@@ -674,6 +677,8 @@
'rarevalues.editor.weight': 'Chance', 'rarevalues.editor.weight': 'Chance',
'rarevalues.editor.label': 'Label', 'rarevalues.editor.label': 'Label',
'rarevalues.editor.save': 'Save', 'rarevalues.editor.save': 'Save',
'rarevalues.editor.add': '+ Add prize',
'rarevalues.editor.remove': 'Remove prize',
'rarevalues.editor.cat.item': 'Furni (ID)', 'rarevalues.editor.cat.item': 'Furni (ID)',
'rarevalues.editor.cat.spin': 'Extra spins', 'rarevalues.editor.cat.spin': 'Extra spins',
'rarevalues.editor.cat.nothing': 'Nothing', 'rarevalues.editor.cat.nothing': 'Nothing',
@@ -642,6 +642,9 @@
'wheel.buy': 'Acquista giro', 'wheel.buy': 'Acquista giro',
'wheel.winners': 'Ultimi vincitori', 'wheel.winners': 'Ultimi vincitori',
'wheel.winners.empty': 'Ancora nessun vincitore', 'wheel.winners.empty': 'Ancora nessun vincitore',
'wheel.win.title': 'Hai vinto!',
'wheel.win.jackpot': '★ Jackpot ★',
'wheel.win.nothing': 'Sarà per la prossima!',
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
// Soundboard // Soundboard
@@ -674,6 +677,8 @@
'rarevalues.editor.weight': 'Probabilità', 'rarevalues.editor.weight': 'Probabilità',
'rarevalues.editor.label': 'Etichetta', 'rarevalues.editor.label': 'Etichetta',
'rarevalues.editor.save': 'Salva', 'rarevalues.editor.save': 'Salva',
'rarevalues.editor.add': '+ Aggiungi premio',
'rarevalues.editor.remove': 'Rimuovi premio',
'rarevalues.editor.cat.item': 'Arredo (ID)', 'rarevalues.editor.cat.item': 'Arredo (ID)',
'rarevalues.editor.cat.spin': 'Giri extra', 'rarevalues.editor.cat.spin': 'Giri extra',
'rarevalues.editor.cat.nothing': 'Niente', 'rarevalues.editor.cat.nothing': 'Niente',
@@ -644,6 +644,9 @@
'wheel.buy': 'Draaibeurt kopen', 'wheel.buy': 'Draaibeurt kopen',
'wheel.winners': 'Laatste winnaars', 'wheel.winners': 'Laatste winnaars',
'wheel.winners.empty': 'Nog geen winnaars', 'wheel.winners.empty': 'Nog geen winnaars',
'wheel.win.title': 'Gewonnen!',
'wheel.win.jackpot': '★ Jackpot ★',
'wheel.win.nothing': 'Volgende keer beter!',
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
// Soundboard // Soundboard
@@ -676,6 +679,8 @@
'rarevalues.editor.weight': 'Kans', 'rarevalues.editor.weight': 'Kans',
'rarevalues.editor.label': 'Label', 'rarevalues.editor.label': 'Label',
'rarevalues.editor.save': 'Opslaan', 'rarevalues.editor.save': 'Opslaan',
'rarevalues.editor.add': '+ Prijs toevoegen',
'rarevalues.editor.remove': 'Prijs verwijderen',
'rarevalues.editor.cat.item': 'Meubel (ID)', 'rarevalues.editor.cat.item': 'Meubel (ID)',
'rarevalues.editor.cat.spin': 'Extra draaien', 'rarevalues.editor.cat.spin': 'Extra draaien',
'rarevalues.editor.cat.nothing': 'Niets', 'rarevalues.editor.cat.nothing': 'Niets',
@@ -32,7 +32,6 @@
"badge.asset.url": "${image.library.url}album1584/%badgename%.gif", "badge.asset.url": "${image.library.url}album1584/%badgename%.gif",
"radio.url": "${gamedata.url}/radio-stations.json5?t=%timestamp%", "radio.url": "${gamedata.url}/radio-stations.json5?t=%timestamp%",
"soundboard.url": "${gamedata.url}/soundboard-sounds.json5?t=%timestamp%", "soundboard.url": "${gamedata.url}/soundboard-sounds.json5?t=%timestamp%",
"radio_ui": false,
"furni.rotation.bounce.steps": 20, "furni.rotation.bounce.steps": 20,
"furni.rotation.bounce.height": 0.0625, "furni.rotation.bounce.height": 0.0625,
"enable.avatar.arrow": false, "enable.avatar.arrow": false,
+1
View File
@@ -24,6 +24,7 @@
"wired.action.kick.from.room.max.length": 100, "wired.action.kick.from.room.max.length": 100,
"wired.action.mute.user.max.length": 100, "wired.action.mute.user.max.length": 100,
"game.center.enabled": false, "game.center.enabled": false,
"radio_ui.enabled": false,
"guides.enabled": true, "guides.enabled": true,
"housekeeping.enabled": true, "housekeeping.enabled": true,
"toolbar.hide.quests": true, "toolbar.hide.quests": true,
+1
View File
@@ -28,6 +28,7 @@ export * from './room';
export * from './room/events'; export * from './room/events';
export * from './room/widgets'; export * from './room/widgets';
export * from './soundboard'; export * from './soundboard';
export * from './theme';
export * from './ui-settings'; export * from './ui-settings';
export * from './user'; export * from './user';
export * from './utils'; export * from './utils';
+122
View File
@@ -0,0 +1,122 @@
import { NitroLogger } from '@nitrots/nitro-renderer';
import { GetConfigurationValue } from '../nitro';
// ---------------------------------------------------------------------------
// Custom theme ecosystem (graphics-only, runtime-loaded).
//
// A "theme" is a folder on the server (NOT bundled in the build) made of:
// <base>/index.json -> { "themes": [ { id, name, author? } ] }
// <base>/<id>/theme.json -> { name, pieces: [ { id, name, file } ] }
// <base>/<id>/<file>.css -> one CSS "piece" (cards, chat, catalog, ...)
//
// Each enabled piece is injected as a <link> in <head>. If a piece fails to
// load (404 / network) the link removes itself, so the UI falls back to the
// default look for that piece (per-piece fallback, never breaks the client).
//
// The base url is configurable via ui-config ("theme.base.url") so themes can
// live anywhere (and never need a client rebuild to add/change them).
// ---------------------------------------------------------------------------
export interface ThemeInfo
{
id: string;
name: string;
author?: string;
}
export interface ThemePiece
{
id: string;
name: string;
file: string;
}
export interface ThemeManifest
{
name: string;
pieces: ThemePiece[];
}
const LINK_ATTR = 'data-nitro-theme';
export const GetThemeBaseUrl = (): string =>
GetConfigurationValue<string>('theme.base.url', 'custom-themes').replace(/\/+$/, '');
export const FetchThemeIndex = async (): Promise<ThemeInfo[]> =>
{
try
{
const response = await fetch(`${ GetThemeBaseUrl() }/index.json`, { cache: 'no-cache' });
if(!response.ok) return [];
const data = await response.json();
return Array.isArray(data?.themes) ? data.themes.filter((t: any) => t && t.id) : [];
}
catch(error)
{
NitroLogger.warn('[ThemeManager] index.json non caricabile, nessun tema custom', error);
return [];
}
};
export const FetchThemeManifest = async (themeId: string): Promise<ThemeManifest> =>
{
if(!themeId) return null;
try
{
const response = await fetch(`${ GetThemeBaseUrl() }/${ themeId }/theme.json`, { cache: 'no-cache' });
if(!response.ok) return null;
const data = await response.json();
if(!data || !Array.isArray(data.pieces)) return null;
return {
name: data.name ?? themeId,
pieces: data.pieces.filter((p: any) => p && p.id && p.file)
};
}
catch(error)
{
NitroLogger.warn(`[ThemeManager] manifest non valido per tema "${ themeId }" -> fallback default`, error);
return null;
}
};
export const ClearTheme = (): void =>
{
document.head.querySelectorAll(`link[${ LINK_ATTR }]`).forEach(node => node.remove());
};
export const ApplyThemePieces = (themeId: string, pieces: ThemePiece[]): void =>
{
ClearTheme();
if(!themeId || !pieces || !pieces.length) return;
const base = GetThemeBaseUrl();
for(const piece of pieces)
{
const link = document.createElement('link');
link.rel = 'stylesheet';
link.setAttribute(LINK_ATTR, piece.id);
link.href = `${ base }/${ themeId }/${ piece.file }`;
// Per-piece fallback: a broken piece removes itself, leaving the default.
link.onerror = () =>
{
NitroLogger.warn(`[ThemeManager] pezzo tema rotto "${ themeId }/${ piece.file }" -> fallback default`);
link.remove();
};
document.head.appendChild(link);
}
};
+1
View File
@@ -0,0 +1 @@
export * from './ThemeManager';
+2
View File
@@ -5,4 +5,6 @@ export class LocalStorageKeys
public static CHAT_WINDOW_ENABLED: string = 'chatWindowEnabled'; public static CHAT_WINDOW_ENABLED: string = 'chatWindowEnabled';
public static CHAT_TRANSLATION_SETTINGS: string = 'chatTranslationSettings'; public static CHAT_TRANSLATION_SETTINGS: string = 'chatTranslationSettings';
public static CATALOG_CLASSIC_STYLE: string = 'catalogClassicStyle'; public static CATALOG_CLASSIC_STYLE: string = 'catalogClassicStyle';
public static THEME_ACTIVE: string = 'nitroThemeActive';
public static THEME_PIECES: string = 'nitroThemePieces';
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 695 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 880 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 891 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 632 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 656 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 831 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

+3 -1
View File
@@ -28,6 +28,7 @@ import { HousekeepingView } from './housekeeping/HousekeepingView';
import { RareValuesView } from './rare-values/RareValuesView'; import { RareValuesView } from './rare-values/RareValuesView';
import { FortuneWheelView } from './fortune-wheel/FortuneWheelView'; import { FortuneWheelView } from './fortune-wheel/FortuneWheelView';
import { SoundboardView } from './soundboard/SoundboardView'; import { SoundboardView } from './soundboard/SoundboardView';
import { ThemeApplier } from './theme/ThemeApplier';
import { RadioView } from './radio/RadioView'; import { RadioView } from './radio/RadioView';
import { InventoryView } from './inventory/InventoryView'; import { InventoryView } from './inventory/InventoryView';
import { ModToolsView } from './mod-tools/ModToolsView'; import { ModToolsView } from './mod-tools/ModToolsView';
@@ -134,6 +135,7 @@ export const MainView: FC<{}> = props =>
return ( return (
<> <>
<ThemeApplier />
<div className="hidden" data-localization-version={ localizationVersion } /> <div className="hidden" data-localization-version={ localizationVersion } />
<AnimatePresence> <AnimatePresence>
{ landingViewVisible && { landingViewVisible &&
@@ -184,7 +186,7 @@ export const MainView: FC<{}> = props =>
<RareValuesView /> <RareValuesView />
<FortuneWheelView /> <FortuneWheelView />
<SoundboardView /> <SoundboardView />
{ GetConfigurationValue<boolean>('radio_ui', true) && <RadioView /> } { GetConfigurationValue<boolean>('radio_ui.enabled', false) && <RadioView /> }
<ExternalPluginLoader /> <ExternalPluginLoader />
</> </>
); );
@@ -46,7 +46,9 @@ const prizeToNum = (prize: IWheelAdminPrize): number =>
const rowToEdit = (row: EditRow): IWheelAdminPrizeEdit => const rowToEdit = (row: EditRow): IWheelAdminPrizeEdit =>
{ {
const base = { id: row.id, weight: row.weight, label: row.label }; // Locally-added rows carry a negative temp id; the server treats id <= 0
// as "insert a new prize", so collapse them to 0 on the wire.
const base = { id: row.id > 0 ? row.id : 0, weight: row.weight, label: row.label };
switch(row.category) switch(row.category)
{ {
@@ -88,6 +90,19 @@ export const FortuneWheelSettingsView: FC<FortuneWheelSettingsViewProps> = ({ on
const updateRow = (id: number, patch: Partial<EditRow>) => const updateRow = (id: number, patch: Partial<EditRow>) =>
setEditRows(prev => prev.map(row => (row.id === id) ? { ...row, ...patch } : row)); setEditRows(prev => prev.map(row => (row.id === id) ? { ...row, ...patch } : row));
const removeRow = (id: number) =>
setEditRows(prev => prev.filter(row => row.id !== id));
const addRow = () =>
setEditRows(prev =>
{
// New rows get a decreasing negative temp id so React keys stay
// stable and updateRow/removeRow keep matching before the save
// round-trips real ids back from the server.
const tempId = Math.min(0, ...prev.map(row => row.id)) - 1;
return [ ...prev, { id: tempId, category: 'item', num: 0, weight: 1, label: '' } ];
});
return ( return (
<NitroCard className="w-[480px] h-[520px]" uniqueKey="fortune-wheel-settings"> <NitroCard className="w-[480px] h-[520px]" uniqueKey="fortune-wheel-settings">
<NitroCard.Header <NitroCard.Header
@@ -100,6 +115,7 @@ export const FortuneWheelSettingsView: FC<FortuneWheelSettingsViewProps> = ({ on
<span className="w-16">{ LocalizeText('rarevalues.editor.value') }</span> <span className="w-16">{ LocalizeText('rarevalues.editor.value') }</span>
<span className="w-12">{ LocalizeText('rarevalues.editor.weight') }</span> <span className="w-12">{ LocalizeText('rarevalues.editor.weight') }</span>
<span className="grow">{ LocalizeText('rarevalues.editor.label') }</span> <span className="grow">{ LocalizeText('rarevalues.editor.label') }</span>
<span className="w-6" />
</Flex> </Flex>
<Column gap={ 1 } overflow="auto" className="grow"> <Column gap={ 1 } overflow="auto" className="grow">
{ editRows.map(row => ( { editRows.map(row => (
@@ -128,11 +144,23 @@ export const FortuneWheelSettingsView: FC<FortuneWheelSettingsViewProps> = ({ on
value={ row.label } value={ row.label }
onChange={ event => updateRow(row.id, { label: event.target.value }) } 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]" /> className="min-w-0 grow rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" />
<button
type="button"
title={ LocalizeText('rarevalues.editor.remove') }
onClick={ () => removeRow(row.id) }
className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded bg-[#d9534f] font-bold leading-none text-white hover:bg-[#c44440]">×</button>
</Flex> </Flex>
)) } )) }
{ !editRows.length && { !editRows.length &&
<Text small className="text-black/50">{ LocalizeText('wheel.settings.empty') }</Text> } <Text small className="text-black/50">{ LocalizeText('wheel.settings.empty') }</Text> }
</Column> </Column>
<button
type="button"
disabled={ editRows.length >= 64 }
onClick={ addRow }
className="cursor-pointer rounded border border-dashed border-[#3a7bb5] px-4 py-1.5 text-sm font-bold text-[#3a7bb5] hover:bg-[#3a7bb5]/10 disabled:cursor-default disabled:opacity-40">
{ LocalizeText('rarevalues.editor.add') }
</button>
<button <button
type="button" type="button"
disabled={ !editRows.length } disabled={ !editRows.length }
+197 -104
View File
@@ -1,10 +1,12 @@
import { AddLinkEventTracker, GetRoomEngine, ILinkEventTracker, IWheelPrize, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; import { AddLinkEventTracker, ILinkEventTracker, IWheelPrize, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useRef, useState } from 'react'; import { FC, TransitionEvent, useEffect, useMemo, useRef, useState } from 'react';
import { LocalizeText } from '../../api'; import { LocalizeText } from '../../api';
import { Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, LayoutCurrencyIcon, LayoutImage, Text } from '../../common'; import { Column, Flex, LayoutAvatarImageView, LayoutCurrencyIcon, Text } from '../../common';
import { useFortuneWheel, useHasPermission } from '../../hooks'; import { useFortuneWheel, useHasPermission } from '../../hooks';
import { NitroCard } from '../../layout'; import { NitroCard } from '../../layout';
import { FortuneWheelSettingsView } from './FortuneWheelSettingsView'; import { FortuneWheelSettingsView } from './FortuneWheelSettingsView';
import { WheelWinReveal } from './WheelWinReveal';
import { renderPrizeIcon } from './wheelPrizeIcon';
// Stock UI palette (white / light-blue / grey / black). // Stock UI palette (white / light-blue / grey / black).
const SLICE_COLORS = [ '#eef2f5', '#c3dcec' ]; const SLICE_COLORS = [ '#eef2f5', '#c3dcec' ];
@@ -13,32 +15,14 @@ const WHEEL_SIZE = 420;
const ICON_RADIUS = 150; const ICON_RADIUS = 150;
const FULL_TURNS = 5; const FULL_TURNS = 5;
const renderPrizeIcon = (prize: IWheelPrize) => // Spin motion (wind-back → fast spin past target → settle back).
{ const WINDBACK_DEG = 14;
switch(prize.type) const OVERSHOOT_DEG = 16;
{ const WINDBACK_MS = 250;
case 'item': const SPIN_MS = 4000;
return <LayoutImage imageUrl={ GetRoomEngine().getFurnitureFloorIconUrl(prize.spriteId) } className="h-9 w-9 bg-contain bg-center bg-no-repeat" />; const SETTLE_MS = 550;
case 'badge':
return <LayoutBadgeImageView badgeCode={ prize.badgeCode } />; type SpinPhase = 'idle' | 'windback' | 'spin' | 'settle';
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<{}> = () => export const FortuneWheelView: FC<{}> = () =>
{ {
@@ -46,11 +30,28 @@ export const FortuneWheelView: FC<{}> = () =>
const [ isSettingsOpen, setIsSettingsOpen ] = useState(false); const [ isSettingsOpen, setIsSettingsOpen ] = useState(false);
const { freeSpins, extraSpins, spinCost, spinCostType, prizes, recentWins, pendingPrizeId, isSpinning, open, spin, buySpin, finishSpin } = useFortuneWheel(); const { freeSpins, extraSpins, spinCost, spinCostType, prizes, recentWins, pendingPrizeId, isSpinning, open, spin, buySpin, finishSpin } = useFortuneWheel();
const canManage = useHasPermission('acc_wheeladmin'); const canManage = useHasPermission('acc_wheeladmin');
const [ rotation, setRotation ] = useState(0); const [ rotation, setRotation ] = useState(0);
const [ phase, setPhase ] = useState<SpinPhase>('idle');
const [ revealPrize, setRevealPrize ] = useState<IWheelPrize | null>(null);
const [ wheelScale, setWheelScale ] = useState(1);
const rotationRef = useRef(0); const rotationRef = useRef(0);
const targetRef = useRef(0);
const phaseRef = useRef<SpinPhase>('idle');
const wonPrizeRef = useRef<IWheelPrize | null>(null);
const prizesRef = useRef<IWheelPrize[]>([]); const prizesRef = useRef<IWheelPrize[]>([]);
const wheelHostRef = useRef<HTMLDivElement>(null);
prizesRef.current = prizes; prizesRef.current = prizes;
const reducedMotion = useMemo(() => (typeof window !== 'undefined') && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches, []);
const setSpinPhase = (next: SpinPhase) =>
{
phaseRef.current = next;
setPhase(next);
};
useEffect(() => useEffect(() =>
{ {
const linkTracker: ILinkEventTracker = { const linkTracker: ILinkEventTracker = {
@@ -79,6 +80,24 @@ export const FortuneWheelView: FC<{}> = () =>
if(isVisible) open(); if(isVisible) open();
}, [ isVisible, open ]); }, [ isVisible, open ]);
// Keep the wheel fitting its container on narrow viewports without
// rewriting the px-based slice/icon math: measure the available width
// and scale the whole wheel down to fit.
useEffect(() =>
{
const host = wheelHostRef.current;
if(!host || (typeof ResizeObserver === 'undefined')) return;
const observer = new ResizeObserver(entries =>
{
const width = entries[0]?.contentRect.width ?? WHEEL_SIZE;
setWheelScale(Math.min(1, width / WHEEL_SIZE));
});
observer.observe(host);
return () => observer.disconnect();
}, [ isVisible ]);
// Drive the spin animation when the server reports the winning slice. // Drive the spin animation when the server reports the winning slice.
useEffect(() => useEffect(() =>
{ {
@@ -93,14 +112,69 @@ export const FortuneWheelView: FC<{}> = () =>
return; return;
} }
wonPrizeRef.current = list[idx];
const sliceAngle = 360 / list.length; const sliceAngle = 360 / list.length;
const centerAngle = ((idx + 0.5) * sliceAngle); const centerAngle = ((idx + 0.5) * sliceAngle);
const current = rotationRef.current; const current = rotationRef.current;
const target = (current - (current % 360)) + (FULL_TURNS * 360) + (360 - centerAngle); const target = (current - (current % 360)) + (FULL_TURNS * 360) + (360 - centerAngle);
targetRef.current = target;
rotationRef.current = target; if(reducedMotion)
setRotation(target); {
}, [ pendingPrizeId, finishSpin ]); // Single straightforward move to the target, no flourish.
setSpinPhase('spin');
rotationRef.current = target;
setRotation(target);
return;
}
// Phase 1: tiny anticipation wind-back before the spin.
setSpinPhase('windback');
const back = current - WINDBACK_DEG;
rotationRef.current = back;
setRotation(back);
}, [ pendingPrizeId, finishSpin, reducedMotion ]);
const finishReveal = () =>
{
setSpinPhase('idle');
setRevealPrize(wonPrizeRef.current);
finishSpin();
};
const handleTransitionEnd = (event: TransitionEvent<HTMLDivElement>) =>
{
// Only react to the wheel's own transform transition finishing. Child
// elements (prize icons, badges) can emit their own bubbling
// transitionend events; without this guard they'd advance the spin
// phase machine early and reveal the prize before the wheel stops.
if((event.target !== event.currentTarget) || (event.propertyName !== 'transform')) return;
switch(phaseRef.current)
{
case 'windback':
// Phase 2: spin fast, overshooting the target slightly.
setSpinPhase('spin');
rotationRef.current = targetRef.current + OVERSHOOT_DEG;
setRotation(rotationRef.current);
return;
case 'spin':
if(reducedMotion)
{
finishReveal();
return;
}
// Phase 3: settle back from the overshoot onto the target.
setSpinPhase('settle');
rotationRef.current = targetRef.current;
setRotation(rotationRef.current);
return;
case 'settle':
finishReveal();
return;
}
};
const sliceAngle = prizes.length ? (360 / prizes.length) : 0; const sliceAngle = prizes.length ? (360 / prizes.length) : 0;
@@ -112,88 +186,107 @@ export const FortuneWheelView: FC<{}> = () =>
return `conic-gradient(${ stops })`; return `conic-gradient(${ stops })`;
}, [ prizes, sliceAngle ]); }, [ prizes, sliceAngle ]);
const wheelTransition = useMemo(() =>
{
switch(phase)
{
case 'windback': return `transform ${ WINDBACK_MS }ms ease-in`;
case 'spin': return `transform ${ SPIN_MS }ms cubic-bezier(0.12,0.78,0.2,1)`;
case 'settle': return `transform ${ SETTLE_MS }ms ease-out`;
default: return 'none';
}
}, [ phase ]);
if(!isVisible) return null; if(!isVisible) return null;
const canSpin = ((freeSpins + extraSpins) > 0) && !isSpinning && (prizes.length > 0); const canSpin = ((freeSpins + extraSpins) > 0) && !isSpinning && (prizes.length > 0);
return ( return (
<NitroCard className="w-[800px] max-w-[96vw]" uniqueKey="fortune-wheel"> <NitroCard className="w-[780px] max-w-[96vw]" uniqueKey="fortune-wheel">
<NitroCard.Header headerText={ LocalizeText('wheel.title') } onCloseClick={ () => setIsVisible(false) } /> <NitroCard.Header headerText={ LocalizeText('wheel.title') } onCloseClick={ () => setIsVisible(false) } />
<NitroCard.Content> <NitroCard.Content>
<Flex gap={ 3 }> <div className="relative">
<Column alignItems="center" gap={ 2 } className="shrink-0"> <Flex gap={ 3 } className="flex-col sm:flex-row">
<div className="relative" style={ { width: WHEEL_SIZE, height: WHEEL_SIZE } }> <Column alignItems="center" gap={ 2 } className="w-full shrink-0 sm:w-[420px]">
<div <div ref={ wheelHostRef } className="relative w-full" style={ { height: WHEEL_SIZE * wheelScale } }>
className="absolute left-1/2 -top-2 z-20 -translate-x-1/2 drop-shadow-[0_2px_2px_rgba(0,0,0,0.25)]" <div
style={ { width: 0, height: 0, borderLeft: '14px solid transparent', borderRight: '14px solid transparent', borderTop: `28px solid ${ RIM }` } } /> className="absolute left-1/2 top-0"
<div style={ { width: WHEEL_SIZE, height: WHEEL_SIZE, transform: `translateX(-50%) scale(${ wheelScale })`, transformOrigin: 'top center' } }>
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 <div
key={ `divider-${ i }` } className="absolute left-1/2 -top-2 z-20 -translate-x-1/2 drop-shadow-[0_2px_2px_rgba(0,0,0,0.25)]"
className="absolute bottom-1/2 left-1/2 origin-bottom" style={ { width: 0, height: 0, borderLeft: '14px solid transparent', borderRight: '14px solid transparent', borderTop: `28px solid ${ RIM }` } } />
style={ { width: '2px', height: `${ WHEEL_SIZE / 2 }px`, transform: `translateX(-1px) rotate(${ i * sliceAngle }deg)`, background: 'rgba(76,96,108,0.3)' } } /> <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)]"
{ prizes.map((prize, i) => style={ { background, border: `8px solid ${ RIM }`, transform: `rotate(${ rotation }deg)`, transition: wheelTransition } }
{ onTransitionEnd={ handleTransitionEnd }>
const centerAngle = ((i + 0.5) * sliceAngle); { prizes.map((_, i) => (
return ( <div
<div key={ `divider-${ i }` }
key={ prize.id } className="absolute bottom-1/2 left-1/2 origin-bottom"
className="absolute left-1/2 top-1/2" style={ { width: '2px', height: `${ WHEEL_SIZE / 2 }px`, transform: `translateX(-1px) rotate(${ i * sliceAngle }deg)`, background: 'rgba(76,96,108,0.3)' } } />
style={ { transform: `rotate(${ centerAngle }deg) translateY(-${ ICON_RADIUS }px) rotate(-${ centerAngle }deg)` } }> )) }
<div className="-translate-x-1/2 -translate-y-1/2"> { prizes.map((prize, i) =>
{ renderPrizeIcon(prize) } {
</div> const centerAngle = ((i + 0.5) * sliceAngle);
</div>); return (
}) } <div
</div> key={ prize.id }
<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 }` } } /> className="absolute left-1/2 top-1/2"
</div> style={ { transform: `rotate(${ centerAngle }deg) translateY(-${ ICON_RADIUS }px) rotate(-${ centerAngle }deg)` } }>
<Text bold className="text-[#2f6f95]">{ LocalizeText('wheel.free.today', [ 'count' ], [ freeSpins.toString() ]) }</Text> <div className="-translate-x-1/2 -translate-y-1/2">
<Text small className="text-[#33424c]">{ LocalizeText('wheel.extra', [ 'count' ], [ extraSpins.toString() ]) }</Text> { renderPrizeIcon(prize) }
<Flex gap={ 2 } alignItems="center"> </div>
<button </div>);
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> </div>
<Column gap={ 0 } className="min-w-0"> <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 }` } } />
<Text bold truncate className="text-[#1f2d34]">{ win.username }</Text> </div>
<Text small truncate className="text-[#2f6f95]">{ win.prizeLabel }</Text> </div>
</Column> <Text bold className="text-[#2f6f95]">{ LocalizeText('wheel.free.today', [ 'count' ], [ freeSpins.toString() ]) }</Text>
</Flex> <Text small className="text-[#33424c]">{ LocalizeText('wheel.extra', [ 'count' ], [ extraSpins.toString() ]) }</Text>
)) } <Flex gap={ 2 } alignItems="center" className="flex-wrap justify-center">
{ !recentWins.length && <button
<Text small className="text-black/50">{ LocalizeText('wheel.winners.empty') }</Text> } 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>
</Column> <Column gap={ 2 } className="min-w-[300px] grow rounded-lg border border-black/10 bg-black/5 p-3">
</Flex> <Text bold className="text-base text-[#33424c]">{ LocalizeText('wheel.winners') }</Text>
<Column gap={ 1 } overflow="auto" className="h-[440px] max-h-[60vh]">
{ 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>
{ revealPrize &&
<WheelWinReveal prize={ revealPrize } onDismiss={ () => setRevealPrize(null) } /> }
</div>
</NitroCard.Content> </NitroCard.Content>
{ canManage && isSettingsOpen && { canManage && isSettingsOpen &&
<FortuneWheelSettingsView onClose={ () => setIsSettingsOpen(false) } /> } <FortuneWheelSettingsView onClose={ () => setIsSettingsOpen(false) } /> }

Some files were not shown because too many files have changed in this diff Show More