@@ -44,3 +44,4 @@ Thumbs.db
|
||||
# the dev server takes minutes to start with 100k+ files under public/.
|
||||
/public/nitro-assets
|
||||
/public/swf
|
||||
.superpowers/
|
||||
|
||||
@@ -6,19 +6,27 @@ the ground running.
|
||||
|
||||
## TL;DR
|
||||
|
||||
This branch — **`feat/react19-modernization`** — is a long-running modernization
|
||||
of the Nitro V3 client: bump to React 19.2 idioms, add the supporting
|
||||
infrastructure (TanStack Query, Zustand, Vitest, React Compiler, error
|
||||
boundaries), split a few god-hooks, and audit logic bugs along the way.
|
||||
PR is **#2** on `simoleo89/Nitro-V3`.
|
||||
This client carries a long-running React 19.2 modernization: React 19
|
||||
idioms + supporting infrastructure (TanStack Query, Zustand, Vitest,
|
||||
React Compiler, error boundaries), god-hook splits, and logic-bug audits.
|
||||
|
||||
Upstream `duckietm/Nitro-V3` (`origin/Dev`) is merged in through
|
||||
`b2318b9` as of 2026-05-18 (merge commit `779a98c`). That brings in
|
||||
JSON5 config support, user-settings (reset password / email / change
|
||||
username), wear-badge popup fix, login screen fix, About update, and
|
||||
the offer-selection refactor. When syncing the next batch of upstream
|
||||
commits, expect conflicts in `App.tsx` / `bootstrap.ts` / `LoginView.tsx`
|
||||
on React 19 imports — always keep the modernized local version.
|
||||
**Working base is now `main`** (tracking `duckietm/Nitro-V3`). The earlier
|
||||
`feat/react19-modernization` long-running branch was superseded — feature
|
||||
work now ships as small focused PRs against `duckietm:Dev`, staged through
|
||||
Dev then merged to main. (`feat/react19-modernization` still exists on the
|
||||
fork as backup; do not force-push it.)
|
||||
|
||||
**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
|
||||
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.
|
||||
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
|
||||
|
||||
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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
@@ -412,6 +435,11 @@ See `docs/ARCHITECTURE.md` "Recently fixed" for fix shapes.
|
||||
`useCatalogUiState` / `useCatalogActions` in
|
||||
`src/hooks/catalog/useCatalog.ts` (all 48 consumers migrated;
|
||||
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`
|
||||
(aliased over `@nitrots/nitro-renderer` via `vitest.config.mts`).
|
||||
Hosts the explicit `NitroLogger` mock, the `mockEventDispatcher` /
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"themes": [
|
||||
{ "id": "neon-viola", "name": "Neon Viola", "author": "infinityhotel" }
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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.winners': 'Latest winners',
|
||||
'wheel.winners.empty': 'No winners yet',
|
||||
'wheel.win.title': 'You won!',
|
||||
'wheel.win.jackpot': '★ Jackpot ★',
|
||||
'wheel.win.nothing': 'Better luck next time!',
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Soundboard
|
||||
@@ -674,6 +677,8 @@
|
||||
'rarevalues.editor.weight': 'Chance',
|
||||
'rarevalues.editor.label': 'Label',
|
||||
'rarevalues.editor.save': 'Save',
|
||||
'rarevalues.editor.add': '+ Add prize',
|
||||
'rarevalues.editor.remove': 'Remove prize',
|
||||
'rarevalues.editor.cat.item': 'Furni (ID)',
|
||||
'rarevalues.editor.cat.spin': 'Extra spins',
|
||||
'rarevalues.editor.cat.nothing': 'Nothing',
|
||||
|
||||
@@ -642,6 +642,9 @@
|
||||
'wheel.buy': 'Acquista giro',
|
||||
'wheel.winners': 'Ultimi vincitori',
|
||||
'wheel.winners.empty': 'Ancora nessun vincitore',
|
||||
'wheel.win.title': 'Hai vinto!',
|
||||
'wheel.win.jackpot': '★ Jackpot ★',
|
||||
'wheel.win.nothing': 'Sarà per la prossima!',
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Soundboard
|
||||
@@ -674,6 +677,8 @@
|
||||
'rarevalues.editor.weight': 'Probabilità',
|
||||
'rarevalues.editor.label': 'Etichetta',
|
||||
'rarevalues.editor.save': 'Salva',
|
||||
'rarevalues.editor.add': '+ Aggiungi premio',
|
||||
'rarevalues.editor.remove': 'Rimuovi premio',
|
||||
'rarevalues.editor.cat.item': 'Arredo (ID)',
|
||||
'rarevalues.editor.cat.spin': 'Giri extra',
|
||||
'rarevalues.editor.cat.nothing': 'Niente',
|
||||
|
||||
@@ -644,6 +644,9 @@
|
||||
'wheel.buy': 'Draaibeurt kopen',
|
||||
'wheel.winners': 'Laatste winnaars',
|
||||
'wheel.winners.empty': 'Nog geen winnaars',
|
||||
'wheel.win.title': 'Gewonnen!',
|
||||
'wheel.win.jackpot': '★ Jackpot ★',
|
||||
'wheel.win.nothing': 'Volgende keer beter!',
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Soundboard
|
||||
@@ -676,6 +679,8 @@
|
||||
'rarevalues.editor.weight': 'Kans',
|
||||
'rarevalues.editor.label': 'Label',
|
||||
'rarevalues.editor.save': 'Opslaan',
|
||||
'rarevalues.editor.add': '+ Prijs toevoegen',
|
||||
'rarevalues.editor.remove': 'Prijs verwijderen',
|
||||
'rarevalues.editor.cat.item': 'Meubel (ID)',
|
||||
'rarevalues.editor.cat.spin': 'Extra draaien',
|
||||
'rarevalues.editor.cat.nothing': 'Niets',
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
"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,
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"wired.action.kick.from.room.max.length": 100,
|
||||
"wired.action.mute.user.max.length": 100,
|
||||
"game.center.enabled": false,
|
||||
"radio_ui.enabled": false,
|
||||
"guides.enabled": true,
|
||||
"housekeeping.enabled": true,
|
||||
"toolbar.hide.quests": true,
|
||||
|
||||
@@ -28,6 +28,7 @@ export * from './room';
|
||||
export * from './room/events';
|
||||
export * from './room/widgets';
|
||||
export * from './soundboard';
|
||||
export * from './theme';
|
||||
export * from './ui-settings';
|
||||
export * from './user';
|
||||
export * from './utils';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './ThemeManager';
|
||||
@@ -5,4 +5,6 @@ export class LocalStorageKeys
|
||||
public static CHAT_WINDOW_ENABLED: string = 'chatWindowEnabled';
|
||||
public static CHAT_TRANSLATION_SETTINGS: string = 'chatTranslationSettings';
|
||||
public static CATALOG_CLASSIC_STYLE: string = 'catalogClassicStyle';
|
||||
public static THEME_ACTIVE: string = 'nitroThemeActive';
|
||||
public static THEME_PIECES: string = 'nitroThemePieces';
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 695 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 806 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 551 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 880 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 381 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 891 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 798 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 645 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 632 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 656 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 534 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 625 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 661 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 325 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 326 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 455 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 533 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 326 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 408 B |
|
After Width: | Height: | Size: 112 B |
|
After Width: | Height: | Size: 825 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 831 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 408 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 906 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 573 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 879 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 548 B |
|
After Width: | Height: | Size: 113 B |
@@ -28,6 +28,7 @@ import { HousekeepingView } from './housekeeping/HousekeepingView';
|
||||
import { RareValuesView } from './rare-values/RareValuesView';
|
||||
import { FortuneWheelView } from './fortune-wheel/FortuneWheelView';
|
||||
import { SoundboardView } from './soundboard/SoundboardView';
|
||||
import { ThemeApplier } from './theme/ThemeApplier';
|
||||
import { RadioView } from './radio/RadioView';
|
||||
import { InventoryView } from './inventory/InventoryView';
|
||||
import { ModToolsView } from './mod-tools/ModToolsView';
|
||||
@@ -134,6 +135,7 @@ export const MainView: FC<{}> = props =>
|
||||
|
||||
return (
|
||||
<>
|
||||
<ThemeApplier />
|
||||
<div className="hidden" data-localization-version={ localizationVersion } />
|
||||
<AnimatePresence>
|
||||
{ landingViewVisible &&
|
||||
@@ -184,7 +186,7 @@ export const MainView: FC<{}> = props =>
|
||||
<RareValuesView />
|
||||
<FortuneWheelView />
|
||||
<SoundboardView />
|
||||
{ GetConfigurationValue<boolean>('radio_ui', true) && <RadioView /> }
|
||||
{ GetConfigurationValue<boolean>('radio_ui.enabled', false) && <RadioView /> }
|
||||
<ExternalPluginLoader />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -46,7 +46,9 @@ const prizeToNum = (prize: IWheelAdminPrize): number =>
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -88,6 +90,19 @@ export const FortuneWheelSettingsView: FC<FortuneWheelSettingsViewProps> = ({ on
|
||||
const updateRow = (id: number, patch: Partial<EditRow>) =>
|
||||
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 (
|
||||
<NitroCard className="w-[480px] h-[520px]" uniqueKey="fortune-wheel-settings">
|
||||
<NitroCard.Header
|
||||
@@ -100,6 +115,7 @@ export const FortuneWheelSettingsView: FC<FortuneWheelSettingsViewProps> = ({ on
|
||||
<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>
|
||||
<span className="w-6" />
|
||||
</Flex>
|
||||
<Column gap={ 1 } overflow="auto" className="grow">
|
||||
{ editRows.map(row => (
|
||||
@@ -128,11 +144,23 @@ export const FortuneWheelSettingsView: FC<FortuneWheelSettingsViewProps> = ({ on
|
||||
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]" />
|
||||
<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>
|
||||
)) }
|
||||
{ !editRows.length &&
|
||||
<Text small className="text-black/50">{ LocalizeText('wheel.settings.empty') }</Text> }
|
||||
</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
|
||||
type="button"
|
||||
disabled={ !editRows.length }
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { AddLinkEventTracker, GetRoomEngine, ILinkEventTracker, IWheelPrize, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AddLinkEventTracker, ILinkEventTracker, IWheelPrize, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { FC, TransitionEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
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 { NitroCard } from '../../layout';
|
||||
import { FortuneWheelSettingsView } from './FortuneWheelSettingsView';
|
||||
import { WheelWinReveal } from './WheelWinReveal';
|
||||
import { renderPrizeIcon } from './wheelPrizeIcon';
|
||||
|
||||
// Stock UI palette (white / light-blue / grey / black).
|
||||
const SLICE_COLORS = [ '#eef2f5', '#c3dcec' ];
|
||||
@@ -13,32 +15,14 @@ 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>;
|
||||
}
|
||||
};
|
||||
// Spin motion (wind-back → fast spin past target → settle back).
|
||||
const WINDBACK_DEG = 14;
|
||||
const OVERSHOOT_DEG = 16;
|
||||
const WINDBACK_MS = 250;
|
||||
const SPIN_MS = 4000;
|
||||
const SETTLE_MS = 550;
|
||||
|
||||
type SpinPhase = 'idle' | 'windback' | 'spin' | 'settle';
|
||||
|
||||
export const FortuneWheelView: FC<{}> = () =>
|
||||
{
|
||||
@@ -46,11 +30,28 @@ export const FortuneWheelView: FC<{}> = () =>
|
||||
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 [ phase, setPhase ] = useState<SpinPhase>('idle');
|
||||
const [ revealPrize, setRevealPrize ] = useState<IWheelPrize | null>(null);
|
||||
const [ wheelScale, setWheelScale ] = useState(1);
|
||||
|
||||
const rotationRef = useRef(0);
|
||||
const targetRef = useRef(0);
|
||||
const phaseRef = useRef<SpinPhase>('idle');
|
||||
const wonPrizeRef = useRef<IWheelPrize | null>(null);
|
||||
const prizesRef = useRef<IWheelPrize[]>([]);
|
||||
const wheelHostRef = useRef<HTMLDivElement>(null);
|
||||
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(() =>
|
||||
{
|
||||
const linkTracker: ILinkEventTracker = {
|
||||
@@ -79,6 +80,24 @@ export const FortuneWheelView: FC<{}> = () =>
|
||||
if(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.
|
||||
useEffect(() =>
|
||||
{
|
||||
@@ -93,14 +112,69 @@ export const FortuneWheelView: FC<{}> = () =>
|
||||
return;
|
||||
}
|
||||
|
||||
wonPrizeRef.current = list[idx];
|
||||
|
||||
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);
|
||||
targetRef.current = target;
|
||||
|
||||
rotationRef.current = target;
|
||||
setRotation(target);
|
||||
}, [ pendingPrizeId, finishSpin ]);
|
||||
if(reducedMotion)
|
||||
{
|
||||
// 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;
|
||||
|
||||
@@ -112,88 +186,107 @@ export const FortuneWheelView: FC<{}> = () =>
|
||||
return `conic-gradient(${ stops })`;
|
||||
}, [ 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;
|
||||
|
||||
const canSpin = ((freeSpins + extraSpins) > 0) && !isSpinning && (prizes.length > 0);
|
||||
|
||||
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.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 className="relative">
|
||||
<Flex gap={ 3 } className="flex-col sm:flex-row">
|
||||
<Column alignItems="center" gap={ 2 } className="w-full shrink-0 sm:w-[420px]">
|
||||
<div ref={ wheelHostRef } className="relative w-full" style={ { height: WHEEL_SIZE * wheelScale } }>
|
||||
<div
|
||||
className="absolute left-1/2 top-0"
|
||||
style={ { width: WHEEL_SIZE, height: WHEEL_SIZE, transform: `translateX(-50%) scale(${ wheelScale })`, transformOrigin: 'top center' } }>
|
||||
<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' } } />
|
||||
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: wheelTransition } }
|
||||
onTransitionEnd={ handleTransitionEnd }>
|
||||
{ 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>
|
||||
<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> }
|
||||
<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>
|
||||
</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" className="flex-wrap justify-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>
|
||||
</Flex>
|
||||
<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] 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>
|
||||
{ canManage && isSettingsOpen &&
|
||||
<FortuneWheelSettingsView onClose={ () => setIsSettingsOpen(false) } /> }
|
||||
|
||||