Merge pull request #119 from duckietm/Dev

Dev
This commit is contained in:
DuckieTM
2026-05-06 12:50:29 +02:00
committed by GitHub
138 changed files with 11427 additions and 2793 deletions
+7
View File
@@ -28,3 +28,10 @@ Thumbs.db
*.zip
.env
.claude/
# Local runtime config copies
/public/configuration/renderer-config.json
/public/configuration/ui-config.json
/public/configuration/client-mode.json
/public/configuration/adsense.json
/public/configuration/hotlooks.json
+6 -5
View File
@@ -20,12 +20,13 @@
- `yarn install`
- `yarn link "@nitrots/nitro-renderer"` <== This will link the renderer in the project
- Rename a few files
- Rename `public/renderer-config.json.example` to `public/renderer-config.json`
- Rename `public/ui-config.json.example` to `public/ui-config.json`
- Set your links
- Open `public/renderer-config.json`
- Copy `public/configuration/renderer-config.example` to `public/configuration/renderer-config.json`
- Copy `public/configuration/ui-config.example` to `public/configuration/ui-config.json`
- Copy `public/configuration/client-mode.example` to `public/configuration/client-mode.json`
- Set your links
- Open `public/configuration/renderer-config.json`
- Update `socket.url, asset.url, image.library.url, & hof.furni.url`
- Open `public/ui-config.json`
- Open `public/configuration/ui-config.json`
- Update `camera.url, thumbnails.url, url.prefix, habbopages.url`
- `yarn build` <== the final step to build the DIST folder this is where your browser needs to point / or upload this to your /client if you do the compile on a other machine (preferd)
- You can override any variable by passing it to `NitroConfig` in the index.html
+279
View File
@@ -0,0 +1,279 @@
# Local Development Setup with `yarn start`
This guide explains how to run Nitro locally with Vite, using:
- local UI on `http://localhost:5173`;
- local API/emulator on `http://localhost:2096`;
- local WebSocket on `ws://localhost:2096`;
- remote plain assets and gamedata, so you do not need to copy the full `client/nitro` folder locally.
## 1. Start the emulator
Inside `Arcturus-Morningstar-Extended/Emulator`, start the emulator with WebSocket enabled.
Recommended local `config.ini` values:
```ini
ws.enabled=true
ws.host=0.0.0.0
ws.port=2096
ws.whitelist=*
ws.ip.header=
crypto.ws.enabled=0
nitro.secure.assets.enabled=false
nitro.secure.api.enabled=false
```
For local development, it is easier to disable:
- `crypto.ws.enabled`;
- `nitro.secure.assets.enabled`;
- `nitro.secure.api.enabled`.
This keeps debugging simple and avoids the secure runtime layer.
## 2. `public/configuration/client-mode.json`
File:
```txt
Nitro-V3/public/configuration/client-mode.json
```
Recommended local config:
```json
{
"distObfuscationEnabled": true,
"secureAssetsEnabled": false,
"secureApiEnabled": false,
"apiBaseUrl": "http://localhost:2096",
"plainConfigBaseUrl": "http://localhost:5173/configuration/",
"plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/"
}
```
Notes:
- `secureAssetsEnabled=false` avoids `/nitro-sec/file`.
- `secureApiEnabled=false` avoids encrypted `/api/*` requests.
- `apiBaseUrl` must point to your local emulator.
- `plainGamedataBaseUrl` can stay remote if you do not have gamedata copied locally.
If you want everything local, use:
```json
"plainGamedataBaseUrl": "http://localhost:5173/client/nitro/gamedata/"
```
but the files must really exist under:
```txt
Nitro-V3/public/client/nitro/gamedata/
```
## 3. `public/configuration/renderer-config.json`
File:
```txt
Nitro-V3/public/configuration/renderer-config.json
```
Minimum local values:
```json
{
"socket.url": "ws://localhost:2096",
"api.url": "http://localhost:2096",
"crypto.ws.enabled": false,
"gamedata.url": "https://hotel.example.com/client/nitro/gamedata",
"external.texts.url": [
"${gamedata.url}/ExternalTexts.json",
"${gamedata.url}/UITexts.json"
],
"furnidata.url": "${gamedata.url}/FurnitureData.json?t=%timestamp%",
"productdata.url": "${gamedata.url}/ProductData.json?t=%timestamp%",
"avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?t=%timestamp%",
"avatar.figuredata.url": "${gamedata.url}/FigureData.json?t=%timestamp%",
"avatar.figuremap.url": "${gamedata.url}/FigureMap.json?t=%timestamp%",
"avatar.effectmap.url": "${gamedata.url}/EffectMap.json?t=%timestamp%",
"login.endpoint": "${api.url}/api/auth/login",
"login.register.endpoint": "${api.url}/api/auth/register",
"login.forgot.endpoint": "${api.url}/api/auth/forgot-password",
"login.logout.endpoint": "${api.url}/api/auth/logout",
"login.remember.endpoint": "${api.url}/api/auth/remember",
"login.health.endpoint": "${api.url}/api/health",
"login.health.method": "GET",
"login.check-email.endpoint": "${api.url}/api/auth/check-email",
"login.check-username.endpoint": "${api.url}/api/auth/check-username",
"login.register.imaging.url": "${api.url}/api/avatar/imaging",
"login.news.url": "${api.url}/api/auth/news",
"badges.custom.list.endpoint": "${api.url}/api/badges/custom",
"badges.custom.create.endpoint": "${api.url}/api/badges/custom",
"badges.custom.update.endpoint": "${api.url}/api/badges/custom/%badgeId%",
"badges.custom.delete.endpoint": "${api.url}/api/badges/custom/%badgeId%",
"badges.custom.texts.endpoint": "${api.url}/api/badges/custom/texts"
}
```
Important:
- Do not use `https://localhost:2096/nitro-sec/file` locally if `secureAssetsEnabled=false`.
- Do not use `ws://192.168.x.x/:2096`; it is malformed. Use `ws://localhost:2096` or `ws://192.168.x.x:2096`.
## 4. `public/configuration/ui-config.json`
File:
```txt
Nitro-V3/public/configuration/ui-config.json
```
For the login view, you can use remote plain images:
```json
{
"loginview": {
"images": {
"background": "https://hotel.example.com/client/nitro/images/reception/background_gradient_apr25.png",
"background.colour": "#6eadc8",
"drape": "https://hotel.example.com/client/nitro/images/reception/drape.png",
"left": "https://hotel.example.com/client/nitro/images/reception/mute_reception_backdrop_left.png",
"right": "https://hotel.example.com/client/nitro/images/reception/background_right.png"
}
}
}
```
If you see `ERR_NAME_NOT_RESOLVED`, the configured domain does not exist or is not reachable.
## 5. Database-backed news
Login news should come from the database through the emulator.
In renderer config use:
```json
"login.news.url": "${api.url}/api/auth/news"
```
The emulator reads from:
```txt
ui_news
```
Reference SQL:
```txt
Arcturus-Morningstar-Extended/Database Updates/013_UI_Client_News.sql
```
Main columns:
- `title`
- `body`
- `image`
- `link_text`
- `link_url`
- `enabled`
- `sort_order`
`public/configuration/news.json` can stay as a mock/fallback only, but it is not the correct production flow.
## 6. Start Nitro
Inside `Nitro-V3`:
```bash
yarn start
```
Open:
```txt
http://localhost:5173
```
Recommendation: use `localhost`, not `192.168.x.x`, because cookies and API sessions are host-based and can otherwise cause `401 Unauthorized`.
## 7. Common errors
### `Unable to load renderer-config.json`
Check:
```txt
public/configuration/client-mode.json
```
It must contain:
```json
"secureAssetsEnabled": false
```
### `Invalid JSON ... Unexpected token '<'`
The client requested JSON, but Vite returned HTML.
This happens when a URL points to a file that does not exist, for example:
```txt
http://localhost:5173/client/nitro/gamedata/ExternalTexts.json
```
Fix:
- use remote plain gamedata;
- or copy the gamedata files into `public/client/nitro/gamedata`.
### WebSocket `1006`
Check:
```json
"socket.url": "ws://localhost:2096"
```
and emulator config:
```ini
ws.enabled=true
ws.port=2096
```
### Custom badges `401 Unauthorized`
This is normal if you are not logged in or if you open Nitro from a different host.
Use:
```txt
http://localhost:5173
```
and API:
```txt
http://localhost:2096
```
## 8. Difference from production
Local `yarn start`:
```html
<script type="module" src="/src/bootstrap.ts"></script>
```
Production build:
```html
<script src="/configuration/bootstrap.js"></script>
```
Do not mix the two flows.
+279
View File
@@ -0,0 +1,279 @@
# Setup locale con `yarn start`
Questa guida serve per avviare Nitro in locale con Vite, usando:
- UI locale su `http://localhost:5173`;
- API/emulatore locale su `http://localhost:2096`;
- WebSocket locale su `ws://localhost:2096`;
- asset e gamedata remoti plain, così non devi copiare tutta la cartella `client/nitro`.
## 1. Avvia l'emulatore
Nel repo `Arcturus-Morningstar-Extended/Emulator`, avvia l'emulatore con WebSocket attivo.
Nel tuo `config.ini` locale usa valori tipo:
```ini
ws.enabled=true
ws.host=0.0.0.0
ws.port=2096
ws.whitelist=*
ws.ip.header=
crypto.ws.enabled=0
nitro.secure.assets.enabled=false
nitro.secure.api.enabled=false
```
Per il locale è meglio tenere spenti:
- `crypto.ws.enabled`;
- `nitro.secure.assets.enabled`;
- `nitro.secure.api.enabled`.
Così puoi debuggare senza layer secure in mezzo.
## 2. `public/configuration/client-mode.json`
File:
```txt
Nitro-V3/public/configuration/client-mode.json
```
Config locale consigliato:
```json
{
"distObfuscationEnabled": true,
"secureAssetsEnabled": false,
"secureApiEnabled": false,
"apiBaseUrl": "http://localhost:2096",
"plainConfigBaseUrl": "http://localhost:5173/configuration/",
"plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/"
}
```
Note:
- `secureAssetsEnabled=false` evita `/nitro-sec/file`.
- `secureApiEnabled=false` evita cifratura `/api/*`.
- `apiBaseUrl` deve puntare all'emulatore locale.
- `plainGamedataBaseUrl` può rimanere remoto se non hai gamedata copiato in locale.
Se vuoi tutto locale, usa:
```json
"plainGamedataBaseUrl": "http://localhost:5173/client/nitro/gamedata/"
```
ma devi avere davvero i file sotto:
```txt
Nitro-V3/public/client/nitro/gamedata/
```
## 3. `public/configuration/renderer-config.json`
File:
```txt
Nitro-V3/public/configuration/renderer-config.json
```
Valori minimi locali:
```json
{
"socket.url": "ws://localhost:2096",
"api.url": "http://localhost:2096",
"crypto.ws.enabled": false,
"gamedata.url": "https://hotel.example.com/client/nitro/gamedata",
"external.texts.url": [
"${gamedata.url}/ExternalTexts.json",
"${gamedata.url}/UITexts.json"
],
"furnidata.url": "${gamedata.url}/FurnitureData.json?t=%timestamp%",
"productdata.url": "${gamedata.url}/ProductData.json?t=%timestamp%",
"avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?t=%timestamp%",
"avatar.figuredata.url": "${gamedata.url}/FigureData.json?t=%timestamp%",
"avatar.figuremap.url": "${gamedata.url}/FigureMap.json?t=%timestamp%",
"avatar.effectmap.url": "${gamedata.url}/EffectMap.json?t=%timestamp%",
"login.endpoint": "${api.url}/api/auth/login",
"login.register.endpoint": "${api.url}/api/auth/register",
"login.forgot.endpoint": "${api.url}/api/auth/forgot-password",
"login.logout.endpoint": "${api.url}/api/auth/logout",
"login.remember.endpoint": "${api.url}/api/auth/remember",
"login.health.endpoint": "${api.url}/api/health",
"login.health.method": "GET",
"login.check-email.endpoint": "${api.url}/api/auth/check-email",
"login.check-username.endpoint": "${api.url}/api/auth/check-username",
"login.register.imaging.url": "${api.url}/api/avatar/imaging",
"login.news.url": "${api.url}/api/auth/news",
"badges.custom.list.endpoint": "${api.url}/api/badges/custom",
"badges.custom.create.endpoint": "${api.url}/api/badges/custom",
"badges.custom.update.endpoint": "${api.url}/api/badges/custom/%badgeId%",
"badges.custom.delete.endpoint": "${api.url}/api/badges/custom/%badgeId%",
"badges.custom.texts.endpoint": "${api.url}/api/badges/custom/texts"
}
```
Importante:
- Non usare `https://localhost:2096/nitro-sec/file` in locale se `secureAssetsEnabled=false`.
- Non usare `ws://192.168.x.x/:2096`: è malformato. Usa `ws://localhost:2096` oppure `ws://192.168.x.x:2096`.
## 4. `public/configuration/ui-config.json`
File:
```txt
Nitro-V3/public/configuration/ui-config.json
```
Per la login view puoi usare immagini remote plain:
```json
{
"loginview": {
"images": {
"background": "https://hotel.example.com/client/nitro/images/reception/background_gradient_apr25.png",
"background.colour": "#6eadc8",
"drape": "https://hotel.example.com/client/nitro/images/reception/drape.png",
"left": "https://hotel.example.com/client/nitro/images/reception/mute_reception_backdrop_left.png",
"right": "https://hotel.example.com/client/nitro/images/reception/background_right.png"
}
}
}
```
Se vedi `ERR_NAME_NOT_RESOLVED`, il dominio configurato non esiste o non è raggiungibile.
## 5. News dal database
Le news della login devono arrivare dal database tramite l'emulatore.
Nel renderer config usa:
```json
"login.news.url": "${api.url}/api/auth/news"
```
L'emulatore legge dalla tabella:
```txt
ui_news
```
SQL di riferimento:
```txt
Arcturus-Morningstar-Extended/Database Updates/013_UI_Client_News.sql
```
Colonne principali:
- `title`
- `body`
- `image`
- `link_text`
- `link_url`
- `enabled`
- `sort_order`
`public/configuration/news.json` può rimanere solo come mock/fallback, ma non è il flow corretto.
## 6. Avvio Nitro
Nel repo `Nitro-V3`:
```bash
yarn start
```
Apri:
```txt
http://localhost:5173
```
Consiglio: usa `localhost`, non `192.168.x.x`, perché cookie e sessioni API possono cambiare host e causare `401 Unauthorized`.
## 7. Errori comuni
### `Unable to load renderer-config.json`
Controlla:
```txt
public/configuration/client-mode.json
```
Deve avere:
```json
"secureAssetsEnabled": false
```
### `Invalid JSON ... Unexpected token '<'`
Vuol dire che il client ha chiesto un JSON, ma Vite ha risposto HTML.
Succede quando un URL punta a un file che non esiste, per esempio:
```txt
http://localhost:5173/client/nitro/gamedata/ExternalTexts.json
```
Soluzione:
- usa gamedata remoto plain;
- oppure copia davvero i gamedata in `public/client/nitro/gamedata`.
### WebSocket `1006`
Controlla:
```json
"socket.url": "ws://localhost:2096"
```
e nel config emulator:
```ini
ws.enabled=true
ws.port=2096
```
### Custom badges `401 Unauthorized`
È normale se non sei loggato o se apri Nitro da un host diverso.
Usa:
```txt
http://localhost:5173
```
e API:
```txt
http://localhost:2096
```
## 8. Differenza con produzione
Locale con `yarn start`:
```html
<script type="module" src="/src/bootstrap.ts"></script>
```
Produzione buildata:
```html
<script src="/configuration/bootstrap.js"></script>
```
Non mischiare i due flow.
+365
View File
@@ -0,0 +1,365 @@
# Secure Runtime Production Setup
Quick setup guide for running Nitro with:
- configuration and gamedata served through `/nitro-sec/file`;
- encrypted runtime `/api/*` calls;
- obfuscated production bundles loaded as `.dat`.
Replace the example domains with your real domains:
- `https://hotel.example.com`
- `https://nitro.example.com:2096`
## 1. Build Nitro
Inside the `Nitro-V3` repository:
```bash
yarn build
```
Then publish the `dist` folder to your web server, for example:
```txt
C:/inetpub/wwwroot/hotel/nitro
```
The deployed folder should contain at least:
```txt
configuration/
assets/
asset-loader.js
index.html
src/
```
## 2. `configuration/client-mode.json`
File:
```txt
Nitro-V3/dist/configuration/client-mode.json
```
Secure production configuration:
```json
{
"distObfuscationEnabled": true,
"secureAssetsEnabled": true,
"secureApiEnabled": true,
"apiBaseUrl": "https://nitro.example.com:2096",
"plainConfigBaseUrl": "https://hotel.example.com/configuration/",
"plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/"
}
```
Meaning:
- `distObfuscationEnabled: true` loads `app.js.dat` and `app.css.dat`.
- `secureAssetsEnabled: true` loads `renderer-config.json`, `ui-config.json`, and gamedata through `/nitro-sec/file`.
- `secureApiEnabled: true` automatically encrypts `/api/*` requests.
- `apiBaseUrl` must point to the emulator/API.
- `plainConfigBaseUrl` and `plainGamedataBaseUrl` are fallbacks when secure assets are disabled.
## 3. `configuration/renderer-config.json`
File:
```txt
Nitro-V3/dist/configuration/renderer-config.json
```
Important values:
```json
{
"socket.url": "wss://nitro.example.com:2096",
"api.url": "https://nitro.example.com:2096",
"gamedata.url": "https://nitro.example.com:2096/nitro-sec/file?kind=gamedata&file=",
"external.texts.url": [
"${gamedata.url}/ExternalTexts.json",
"${gamedata.url}/UITexts.json"
],
"furnidata.url": "${gamedata.url}/FurnitureData.json?t=%timestamp%",
"productdata.url": "${gamedata.url}/ProductData.json?t=%timestamp%",
"avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?t=%timestamp%",
"avatar.figuredata.url": "${gamedata.url}/FigureData.json?t=%timestamp%",
"avatar.figuremap.url": "${gamedata.url}/FigureMap.json?t=%timestamp%",
"avatar.effectmap.url": "${gamedata.url}/EffectMap.json?t=%timestamp%",
"crypto.ws.enabled": true
}
```
If you are not using WebSocket crypto yet, use:
```json
"crypto.ws.enabled": false
```
## 4. `configuration/ui-config.json`
File:
```txt
Nitro-V3/dist/configuration/ui-config.json
```
Static image and camera URLs can remain plain:
```json
{
"camera.url": "https://hotel.example.com/client/camera/",
"thumbnails.url": "https://hotel.example.com/client/camera/thumbnail/%thumbnail%.png"
}
```
Non-sensitive images can stay static. JSON configuration and gamedata should go through the secure endpoint.
## 5. Emulator `config.ini`
Inside `Arcturus-Morningstar-Extended`, edit the emulator config:
```txt
Emulator/config.ini
```
Production example:
```ini
ws.enabled=true
ws.host=0.0.0.0
ws.port=2096
ws.whitelist=https://hotel.example.com
ws.ip.header=CF-Connecting-IP
crypto.ws.enabled=1
nitro.secure.assets.enabled=true
nitro.secure.api.enabled=true
nitro.secure.config.root=C:/inetpub/wwwroot/hotel/nitro/configuration
nitro.secure.gamedata.root=C:/inetpub/wwwroot/hotel/nitro/client/nitro/gamedata
nitro.secure.master_key=change-this-to-a-long-random-secret
login.remember.enabled=true
login.remember.duration.days=30
login.remember.jwt.secret=change-this-too-if-you-use-remember-me
```
Notes:
- `nitro.secure.config.root` must point to the folder containing `renderer-config.json`, `ui-config.json`, and `client-mode.json`.
- `nitro.secure.gamedata.root` must point to the live gamedata folder.
- Files are read live from disk: if you update a JSON file, a new browser refresh reads the new version.
- `nitro.secure.master_key` must be secret and stable. Never put it in public files.
## 6. Cloudflare
If you use Cloudflare:
1. Keep the proxy enabled for the website domain `hotel.example.com`.
2. Make sure Cloudflare supports/proxies the port used by `nitro.example.com:2096`.
3. Always use HTTPS/WSS in the browser:
```json
"api.url": "https://nitro.example.com:2096",
"socket.url": "wss://nitro.example.com:2096"
```
If you get CORS errors, check:
```ini
ws.whitelist=https://hotel.example.com
```
## 7. IIS / `.dat` MIME type
If obfuscated `.dat` assets are enabled, IIS must serve them correctly.
Add this MIME type:
```txt
Extension: .dat
MIME type: application/octet-stream
```
Without it, the browser can receive 404 even when the file exists.
## 8. Final checklist
- `client-mode.json` has `secureAssetsEnabled=true`.
- `client-mode.json` has `secureApiEnabled=true`.
- `renderer-config.json` uses `/nitro-sec/file?kind=gamedata&file=`.
- `api.url` points to `https://nitro.example.com:2096`.
- `socket.url` points to `wss://nitro.example.com:2096`.
- `config.ini` has the correct `nitro.secure.config.root`.
- `config.ini` has the correct `nitro.secure.gamedata.root`.
- `config.ini` has a stable `nitro.secure.master_key`.
- IIS knows the `.dat` MIME type.
- Restart the emulator after changing `config.ini`.
- Refresh the browser after changing JSON files in `configuration` or `gamedata`.
## 9. Temporarily disable secure mode
For quick debugging, only change `client-mode.json`:
```json
{
"distObfuscationEnabled": false,
"secureAssetsEnabled": false,
"secureApiEnabled": false,
"apiBaseUrl": "https://nitro.example.com:2096",
"plainConfigBaseUrl": "https://hotel.example.com/configuration/",
"plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/"
}
```
Then hard refresh the browser.
## 10. `configuration/bootstrap.js`
File:
```txt
Nitro-V3/dist/configuration/bootstrap.js
```
This is the first loader when you use the external secure mode.
It does three things:
1. opens an ECDH session with the emulator through `/nitro-sec/bootstrap`;
2. downloads encrypted `client-mode.json` through `/nitro-sec/file?kind=config`;
3. downloads encrypted `asset-loader.js` and imports it as a JavaScript module.
### Value to check
Inside `bootstrap.js` there is:
```js
const API_BASE = "https://nitro.example.com:2096";
```
It must point to your public emulator/API URL.
In production:
```js
const API_BASE = "https://nitro.example.com:2096";
```
In local development:
```js
const API_BASE = "http://localhost:2096";
```
If `bootstrap.js` fails, it automatically falls back to the plain loader:
```txt
configuration/asset-loader.js
```
So `asset-loader.js` must always exist inside the `configuration` folder.
## 11. `configuration/asset-loader.js`
File:
```txt
Nitro-V3/dist/configuration/asset-loader.js
```
This loader loads the actual bundle:
- if `distObfuscationEnabled=true`
- it loads `app.css.dat`;
- it loads `app.js.dat`;
- it decodes, decompresses, and imports the bundle from a blob.
- if `distObfuscationEnabled=false`
- it loads `assets/app.css`;
- it loads `assets/app.js`.
### Required files in production
With obfuscation enabled, these files must exist:
```txt
assets/app.css.dat
assets/app.js.dat
configuration/asset-loader.js
configuration/bootstrap.js
configuration/client-mode.json
```
With obfuscation disabled, these files must exist:
```txt
assets/app.css
assets/app.js
configuration/asset-loader.js
configuration/client-mode.json
```
## 12. `index.html`
`index.html` should stay minimal.
Secure production example:
```html
<div id="root"></div>
<script src="/configuration/bootstrap.js"></script>
```
Vite development example:
```html
<div id="root"></div>
<script type="module" src="/src/bootstrap.ts"></script>
```
Do not mix the two flows:
- production build: use `configuration/bootstrap.js`;
- `yarn start` development: use `/src/bootstrap.ts`.
## 13. Files inside `/configuration`
The `configuration` folder should contain:
```txt
asset-loader.js
bootstrap.js
client-mode.json
renderer-config.json
ui-config.json
adsense.json optional
hotlooks.json if register hot looks are enabled
UITexts.json if separate UI texts are enabled
```
Login news should not live in `news.json` in production. They come from the database through:
```json
"login.news.url": "${api.url}/api/auth/news"
```
The emulator reads from the `ui_news` table.
With `secureAssetsEnabled=true`, client-loaded files go through:
```txt
https://nitro.example.com:2096/nitro-sec/file?kind=config&file=...
```
The emulator reads them from:
```ini
nitro.secure.config.root=C:/inetpub/wwwroot/hotel/nitro/configuration
```
If you add new JSON/JS files inside `configuration` and want to protect them, they must be requested through the secure endpoint or loaded through `bootstrap.js`.
+365
View File
@@ -0,0 +1,365 @@
# Setup Secure Runtime in produzione
Guida rapida per avviare Nitro con:
- configurazioni e gamedata serviti da `/nitro-sec/file`;
- API `/api/*` cifrate dal wrapper runtime;
- bundle buildati offuscati come `.dat`.
Negli esempi usa i tuoi domini reali al posto di:
- `https://hotel.example.com`
- `https://nitro.example.com:2096`
## 1. Build Nitro
Nel repo `Nitro-V3`:
```bash
yarn build
```
Poi pubblica la cartella `dist` nel web server del sito, ad esempio:
```txt
C:/inetpub/wwwroot/hotel/nitro
```
La struttura pubblicata deve contenere almeno:
```txt
configuration/
assets/
asset-loader.js
index.html
src/
```
## 2. `configuration/client-mode.json`
File:
```txt
Nitro-V3/dist/configuration/client-mode.json
```
Configurazione produzione secure:
```json
{
"distObfuscationEnabled": true,
"secureAssetsEnabled": true,
"secureApiEnabled": true,
"apiBaseUrl": "https://nitro.example.com:2096",
"plainConfigBaseUrl": "https://hotel.example.com/configuration/",
"plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/"
}
```
Significato:
- `distObfuscationEnabled: true` carica `app.js.dat` e `app.css.dat`.
- `secureAssetsEnabled: true` carica `renderer-config.json`, `ui-config.json` e gamedata da `/nitro-sec/file`.
- `secureApiEnabled: true` cifra automaticamente le chiamate `/api/*`.
- `apiBaseUrl` deve puntare all'emulatore/API.
- `plainConfigBaseUrl` e `plainGamedataBaseUrl` restano fallback quando spegni secure assets.
## 3. `configuration/renderer-config.json`
File:
```txt
Nitro-V3/dist/configuration/renderer-config.json
```
Valori importanti:
```json
{
"socket.url": "wss://nitro.example.com:2096",
"api.url": "https://nitro.example.com:2096",
"gamedata.url": "https://nitro.example.com:2096/nitro-sec/file?kind=gamedata&file=",
"external.texts.url": [
"${gamedata.url}/ExternalTexts.json",
"${gamedata.url}/UITexts.json"
],
"furnidata.url": "${gamedata.url}/FurnitureData.json?t=%timestamp%",
"productdata.url": "${gamedata.url}/ProductData.json?t=%timestamp%",
"avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?t=%timestamp%",
"avatar.figuredata.url": "${gamedata.url}/FigureData.json?t=%timestamp%",
"avatar.figuremap.url": "${gamedata.url}/FigureMap.json?t=%timestamp%",
"avatar.effectmap.url": "${gamedata.url}/EffectMap.json?t=%timestamp%",
"crypto.ws.enabled": true
}
```
Se non usi ancora WebSocket crypto, metti:
```json
"crypto.ws.enabled": false
```
## 4. `configuration/ui-config.json`
File:
```txt
Nitro-V3/dist/configuration/ui-config.json
```
Qui puoi lasciare immagini e camera su URL statici normali:
```json
{
"camera.url": "https://hotel.example.com/client/camera/",
"thumbnails.url": "https://hotel.example.com/client/camera/thumbnail/%thumbnail%.png"
}
```
Le immagini non sensibili possono rimanere statiche. I JSON/gamedata invece passano dal secure endpoint.
## 5. `config.ini` dell'emulatore
Nel repo `Arcturus-Morningstar-Extended`, file usato dall'emulatore:
```txt
Emulator/config.ini
```
Esempio produzione:
```ini
ws.enabled=true
ws.host=0.0.0.0
ws.port=2096
ws.whitelist=https://hotel.example.com
ws.ip.header=CF-Connecting-IP
crypto.ws.enabled=1
nitro.secure.assets.enabled=true
nitro.secure.api.enabled=true
nitro.secure.config.root=C:/inetpub/wwwroot/hotel/nitro/configuration
nitro.secure.gamedata.root=C:/inetpub/wwwroot/hotel/nitro/client/nitro/gamedata
nitro.secure.master_key=change-this-to-a-long-random-secret
login.remember.enabled=true
login.remember.duration.days=30
login.remember.jwt.secret=change-this-too-if-you-use-remember-me
```
Note:
- `nitro.secure.config.root` deve puntare alla cartella dove ci sono `renderer-config.json`, `ui-config.json`, `client-mode.json`.
- `nitro.secure.gamedata.root` deve puntare alla cartella live dei gamedata.
- I file vengono letti live da disco: se cambi un JSON, un nuovo refresh pagina legge la nuova versione.
- `nitro.secure.master_key` deve restare segreta e stabile. Non metterla nei file pubblici.
## 6. Cloudflare
Se usi Cloudflare:
1. Lascia la nuvoletta attiva sul dominio web `hotel.example.com`.
2. Per `nitro.example.com:2096`, assicurati che Cloudflare supporti/proxy il traffico sulla porta usata.
3. Usa sempre HTTPS/WSS lato browser:
```json
"api.url": "https://nitro.example.com:2096",
"socket.url": "wss://nitro.example.com:2096"
```
Se vedi errori CORS, controlla:
```ini
ws.whitelist=https://hotel.example.com
```
## 7. IIS / MIME `.dat`
Se usi gli asset offuscati `.dat`, IIS deve servirli.
Aggiungi MIME type:
```txt
Extension: .dat
MIME type: application/octet-stream
```
Senza questo, il browser può dare 404 anche se il file esiste davvero.
## 8. Checklist finale
- `client-mode.json` ha `secureAssetsEnabled=true`.
- `client-mode.json` ha `secureApiEnabled=true`.
- `renderer-config.json` usa `/nitro-sec/file?kind=gamedata&file=`.
- `api.url` punta a `https://nitro.example.com:2096`.
- `socket.url` punta a `wss://nitro.example.com:2096`.
- `config.ini` ha `nitro.secure.config.root` corretto.
- `config.ini` ha `nitro.secure.gamedata.root` corretto.
- `config.ini` ha `nitro.secure.master_key` stabile.
- IIS conosce il MIME `.dat`.
- Dopo modifiche a `config.ini`, riavvia l'emulatore.
- Dopo modifiche ai JSON in `configuration` o `gamedata`, basta refresh pagina.
## 9. Spegnere temporaneamente secure
Per debug rapido, cambia solo `client-mode.json`:
```json
{
"distObfuscationEnabled": false,
"secureAssetsEnabled": false,
"secureApiEnabled": false,
"apiBaseUrl": "https://nitro.example.com:2096",
"plainConfigBaseUrl": "https://hotel.example.com/configuration/",
"plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/"
}
```
Poi fai hard refresh.
## 10. `configuration/bootstrap.js`
File:
```txt
Nitro-V3/dist/configuration/bootstrap.js
```
Questo è il primo loader quando usi la modalità secure esterna.
Fa tre cose:
1. apre una sessione ECDH con l'emulatore tramite `/nitro-sec/bootstrap`;
2. scarica `client-mode.json` cifrato da `/nitro-sec/file?kind=config`;
3. scarica `asset-loader.js` cifrato e lo importa come modulo JavaScript.
### Valore da controllare
Dentro `bootstrap.js` esiste:
```js
const API_BASE = "https://nitro.example.com:2096";
```
Deve puntare all'emulatore/API pubblico.
In produzione:
```js
const API_BASE = "https://nitro.example.com:2096";
```
In locale:
```js
const API_BASE = "http://localhost:2096";
```
Se `bootstrap.js` fallisce, prova automaticamente fallback plain su:
```txt
configuration/asset-loader.js
```
Quindi `asset-loader.js` deve esistere sempre nella cartella `configuration`.
## 11. `configuration/asset-loader.js`
File:
```txt
Nitro-V3/dist/configuration/asset-loader.js
```
Questo loader carica il bundle vero:
- se `distObfuscationEnabled=true`
- carica `app.css.dat`;
- carica `app.js.dat`;
- decodifica, decomprime e importa il bundle da blob.
- se `distObfuscationEnabled=false`
- carica `assets/app.css`;
- carica `assets/app.js`.
### File richiesti in produzione
Con offuscamento attivo devono esistere:
```txt
assets/app.css.dat
assets/app.js.dat
configuration/asset-loader.js
configuration/bootstrap.js
configuration/client-mode.json
```
Con offuscamento spento devono esistere:
```txt
assets/app.css
assets/app.js
configuration/asset-loader.js
configuration/client-mode.json
```
## 12. `index.html`
Il file `index.html` deve rimanere minimale.
Esempio secure:
```html
<div id="root"></div>
<script src="/configuration/bootstrap.js"></script>
```
Esempio dev Vite:
```html
<div id="root"></div>
<script type="module" src="/src/bootstrap.ts"></script>
```
Non mischiare i due flow:
- produzione buildata: usa `configuration/bootstrap.js`;
- sviluppo con `yarn start`: usa `/src/bootstrap.ts`.
## 13. File dentro `/configuration`
La cartella `configuration` deve contenere:
```txt
asset-loader.js
bootstrap.js
client-mode.json
renderer-config.json
ui-config.json
adsense.json opzionale
hotlooks.json se usi register hot looks
UITexts.json se usi testi UI separati
```
Le news login non devono stare in `news.json` in produzione: arrivano dal database tramite:
```json
"login.news.url": "${api.url}/api/auth/news"
```
L'emulatore legge dalla tabella `ui_news`.
Con `secureAssetsEnabled=true`, i file letti dal client passano da:
```txt
https://nitro.example.com:2096/nitro-sec/file?kind=config&file=...
```
Quindi l'emulatore li legge da:
```ini
nitro.secure.config.root=C:/inetpub/wwwroot/hotel/nitro/configuration
```
Se aggiungi nuovi file JSON/JS in `configuration` e vuoi proteggerli, devono essere richiesti passando dal secure endpoint o caricati tramite `bootstrap.js`.
+236
View File
@@ -0,0 +1,236 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nitro Secure Runtime Modes</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-950 text-slate-100">
<div class="mx-auto max-w-6xl px-6 py-10">
<div class="mb-8 rounded-3xl border border-cyan-500/20 bg-slate-900/80 p-8 shadow-2xl shadow-cyan-950/30">
<div class="flex flex-wrap items-center gap-3">
<span class="rounded-full border border-cyan-400/30 bg-cyan-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.25em] text-cyan-300">Nitro V3</span>
<span class="rounded-full border border-emerald-400/30 bg-emerald-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.25em] text-emerald-300">Secure Runtime</span>
</div>
<h1 class="mt-5 text-4xl font-black tracking-tight text-white">Runtime configuration guide</h1>
<p class="mt-4 max-w-3xl text-sm leading-7 text-slate-300">
This page gives you a cleaner, readable overview of runtime toggles, example files and the values that belong in config files
rather than hardcoded inside <code class="rounded bg-slate-800 px-1.5 py-0.5 text-cyan-300">src</code>.
</p>
</div>
<div class="grid gap-6 lg:grid-cols-[280px_minmax(0,1fr)]">
<aside class="rounded-3xl border border-slate-800 bg-slate-900/70 p-5 lg:sticky lg:top-6 lg:h-fit">
<h2 class="mb-4 text-sm font-bold uppercase tracking-[0.2em] text-slate-400">Contents</h2>
<nav class="space-y-2 text-sm">
<a class="block rounded-xl px-3 py-2 text-slate-300 transition hover:bg-slate-800 hover:text-white" href="#overview">Overview</a>
<a class="block rounded-xl px-3 py-2 text-slate-300 transition hover:bg-slate-800 hover:text-white" href="#files">Files to use</a>
<a class="block rounded-xl px-3 py-2 text-slate-300 transition hover:bg-slate-800 hover:text-white" href="#client-mode">client-mode</a>
<a class="block rounded-xl px-3 py-2 text-slate-300 transition hover:bg-slate-800 hover:text-white" href="#renderer-config">renderer-config</a>
<a class="block rounded-xl px-3 py-2 text-slate-300 transition hover:bg-slate-800 hover:text-white" href="#ui-config">ui-config</a>
<a class="block rounded-xl px-3 py-2 text-slate-300 transition hover:bg-slate-800 hover:text-white" href="#runtime-code">Runtime code</a>
<a class="block rounded-xl px-3 py-2 text-slate-300 transition hover:bg-slate-800 hover:text-white" href="#emulator">Emulator</a>
<a class="block rounded-xl px-3 py-2 text-slate-300 transition hover:bg-slate-800 hover:text-white" href="#scenarios">Scenarios</a>
<a class="block rounded-xl px-3 py-2 text-slate-300 transition hover:bg-slate-800 hover:text-white" href="#checklist">Checklist</a>
</nav>
</aside>
<main class="space-y-6">
<section id="overview" class="rounded-3xl border border-slate-800 bg-slate-900/70 p-8">
<h2 class="text-2xl font-bold text-white">Overview</h2>
<div class="mt-5 grid gap-4 md:grid-cols-3">
<div class="rounded-2xl border border-slate-800 bg-slate-950/70 p-5">
<h3 class="text-sm font-semibold uppercase tracking-wide text-cyan-300">Dist Obfuscation</h3>
<p class="mt-3 text-sm leading-7 text-slate-300">Chooses whether the client loads <code class="rounded bg-slate-800 px-1">app.js/app.css</code> or the obfuscated <code class="rounded bg-slate-800 px-1">.dat</code> versions.</p>
</div>
<div class="rounded-2xl border border-slate-800 bg-slate-950/70 p-5">
<h3 class="text-sm font-semibold uppercase tracking-wide text-emerald-300">Secure Assets</h3>
<p class="mt-3 text-sm leading-7 text-slate-300">Controls whether <code class="rounded bg-slate-800 px-1">renderer-config</code>, <code class="rounded bg-slate-800 px-1">ui-config</code> and gamedata go through <code class="rounded bg-slate-800 px-1">/nitro-sec/file</code>.</p>
</div>
<div class="rounded-2xl border border-slate-800 bg-slate-950/70 p-5">
<h3 class="text-sm font-semibold uppercase tracking-wide text-fuchsia-300">Secure API</h3>
<p class="mt-3 text-sm leading-7 text-slate-300">Enables or disables runtime encryption for <code class="rounded bg-slate-800 px-1">/api/*</code> requests.</p>
</div>
</div>
</section>
<section id="files" class="rounded-3xl border border-slate-800 bg-slate-900/70 p-8">
<h2 class="text-2xl font-bold text-white">Files to use</h2>
<div class="mt-5 overflow-hidden rounded-2xl border border-slate-800">
<table class="min-w-full divide-y divide-slate-800 text-sm">
<thead class="bg-slate-950/80">
<tr>
<th class="px-4 py-3 text-left font-semibold text-slate-200">File</th>
<th class="px-4 py-3 text-left font-semibold text-slate-200">Purpose</th>
<th class="px-4 py-3 text-left font-semibold text-slate-200">Note</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
<tr class="bg-slate-900/60">
<td class="px-4 py-3"><code>public/configuration/client-mode.example</code></td>
<td class="px-4 py-3">Template for runtime toggles</td>
<td class="px-4 py-3 text-slate-300">Copy it into a real <code>configuration/client-mode.json</code> in deployment; that real file stays ignored by Git</td>
</tr>
<tr class="bg-slate-950/40">
<td class="px-4 py-3"><code>public/configuration/renderer-config.example</code></td>
<td class="px-4 py-3">Clean renderer config template</td>
<td class="px-4 py-3 text-slate-300">Does not touch your local <code>configuration/renderer-config.json</code></td>
</tr>
<tr class="bg-slate-900/60">
<td class="px-4 py-3"><code>public/configuration/ui-config.example</code></td>
<td class="px-4 py-3">UI config reference template</td>
<td class="px-4 py-3 text-slate-300">Use it as the source of truth for UI URLs and widgets</td>
</tr>
<tr class="bg-slate-950/40">
<td class="px-4 py-3"><code>Latest_Compiled_Version/config.ini.example</code></td>
<td class="px-4 py-3">Backend secure flags</td>
<td class="px-4 py-3 text-slate-300">Defines the emulator-side runtime settings</td>
</tr>
</tbody>
</table>
</div>
</section>
<section id="client-mode" class="rounded-3xl border border-slate-800 bg-slate-900/70 p-8">
<h2 class="text-2xl font-bold text-white">client-mode.example</h2>
<p class="mt-3 text-sm leading-7 text-slate-300">This is the main runtime switchboard. You can enable or disable behavior without editing client source code.</p>
<pre class="mt-5 overflow-x-auto rounded-2xl border border-slate-800 bg-slate-950/90 p-5 text-sm text-cyan-300"><code>{
"distObfuscationEnabled": true,
"secureAssetsEnabled": true,
"secureApiEnabled": true,
"apiBaseUrl": "https://nitro.example.com:2096",
"plainConfigBaseUrl": "https://hotel.example.com/configuration/",
"plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/"
}</code></pre>
<div class="mt-5 grid gap-4 md:grid-cols-2">
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 p-5">
<h3 class="font-semibold text-white">Fields</h3>
<ul class="mt-3 space-y-2 text-sm leading-7 text-slate-300">
<li><code class="rounded bg-slate-800 px-1">distObfuscationEnabled</code>: use <code>.dat</code> or plain assets</li>
<li><code class="rounded bg-slate-800 px-1">secureAssetsEnabled</code>: enables <code>/nitro-sec/file</code></li>
<li><code class="rounded bg-slate-800 px-1">secureApiEnabled</code>: encrypts <code>/api/*</code> requests</li>
<li><code class="rounded bg-slate-800 px-1">apiBaseUrl</code>: emulator/API base URL</li>
</ul>
</div>
<div class="rounded-2xl border border-amber-500/20 bg-amber-500/10 p-5">
<h3 class="font-semibold text-amber-200">Recommendation</h3>
<p class="mt-3 text-sm leading-7 text-amber-100/90">Always set <code>apiBaseUrl</code> explicitly so you do not rely on fallback logic.</p>
</div>
</div>
</section>
<section id="renderer-config" class="rounded-3xl border border-slate-800 bg-slate-900/70 p-8">
<h2 class="text-2xl font-bold text-white">renderer-config.example</h2>
<p class="mt-3 text-sm leading-7 text-slate-300">Socket, API, asset and gamedata URLs should live here, not inside React components.</p>
<div class="mt-5 grid gap-4 md:grid-cols-2">
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 p-5">
<h3 class="font-semibold text-white">Main keys</h3>
<ul class="mt-3 space-y-2 text-sm leading-7 text-slate-300">
<li><code>socket.url</code></li>
<li><code>api.url</code></li>
<li><code>asset.url</code></li>
<li><code>image.library.url</code></li>
<li><code>images.url</code></li>
<li><code>gamedata.url</code></li>
</ul>
</div>
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 p-5">
<h3 class="font-semibold text-white">Translations</h3>
<ul class="mt-3 space-y-2 text-sm leading-7 text-slate-300">
<li><code>external.texts.translation.url</code></li>
<li><code>furnidata.translation.url</code></li>
<li>Uses <code>%locale%</code> and <code>%timestamp%</code></li>
</ul>
</div>
</div>
</section>
<section id="ui-config" class="rounded-3xl border border-slate-800 bg-slate-900/70 p-8">
<h2 class="text-2xl font-bold text-white">ui-config.example</h2>
<p class="mt-3 text-sm leading-7 text-slate-300">UI image and login view sources should come from config values here or from renderer config, never from hardcoded URLs in components.</p>
<div class="mt-5 rounded-2xl border border-slate-800 bg-slate-950/60 p-5">
<h3 class="font-semibold text-white">Login view</h3>
<ul class="mt-3 space-y-2 text-sm leading-7 text-slate-300">
<li><code>loginview.images.background</code></li>
<li><code>loginview.images.drape</code></li>
<li><code>loginview.images.left</code></li>
<li><code>loginview.images.right</code></li>
<li><code>loginview.widgets</code> for promotional blocks</li>
</ul>
</div>
</section>
<section id="runtime-code" class="rounded-3xl border border-slate-800 bg-slate-900/70 p-8">
<h2 class="text-2xl font-bold text-white">Runtime code involved</h2>
<div class="mt-5 grid gap-4 md:grid-cols-2">
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 p-5">
<h3 class="font-semibold text-white"><code>src/bootstrap.ts</code></h3>
<p class="mt-3 text-sm leading-7 text-slate-300">Reads <code>client-mode</code>, builds <code>NitroConfig['config.urls']</code> and prepares client bootstrap.</p>
</div>
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 p-5">
<h3 class="font-semibold text-white"><code>src/secure-assets.ts</code></h3>
<p class="mt-3 text-sm leading-7 text-slate-300">Handles ECDH, decrypt/encrypt, plain fallback and secure API runtime behavior.</p>
</div>
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 p-5">
<h3 class="font-semibold text-white"><code>scripts/write-asset-loader.mjs</code></h3>
<p class="mt-3 text-sm leading-7 text-slate-300">Generates <code>public/configuration/asset-loader.js</code> and decides between plain assets and <code>.dat</code>.</p>
</div>
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 p-5">
<h3 class="font-semibold text-white"><code>scripts/minify-dist.mjs</code></h3>
<p class="mt-3 text-sm leading-7 text-slate-300">Generates <code>.dat</code> files while keeping plain files available for runtime switching.</p>
</div>
</div>
</section>
<section id="emulator" class="rounded-3xl border border-slate-800 bg-slate-900/70 p-8">
<h2 class="text-2xl font-bold text-white">Emulator</h2>
<pre class="mt-5 overflow-x-auto rounded-2xl border border-slate-800 bg-slate-950/90 p-5 text-sm text-emerald-300"><code>nitro.secure.assets.enabled=true
nitro.secure.api.enabled=true
nitro.secure.config.root=C:/path/to/Nitro-V3/public
nitro.secure.gamedata.root=C:/path/to/gamedata
nitro.secure.master_key=change-me-to-a-long-random-secret</code></pre>
<ul class="mt-5 space-y-2 text-sm leading-7 text-slate-300">
<li><code>nitro.secure.assets.enabled</code>: enables <code>/nitro-sec/bootstrap</code> and <code>/nitro-sec/file</code></li>
<li><code>nitro.secure.api.enabled</code>: enables secure handling for <code>/api/*</code></li>
<li><code>nitro.secure.config.root</code>: path to live config files</li>
<li><code>nitro.secure.gamedata.root</code>: path to live gamedata</li>
<li><code>nitro.secure.master_key</code>: persistent server-side secret</li>
</ul>
</section>
<section id="scenarios" class="rounded-3xl border border-slate-800 bg-slate-900/70 p-8">
<h2 class="text-2xl font-bold text-white">Quick scenarios</h2>
<div class="mt-5 grid gap-4 md:grid-cols-3">
<div class="rounded-2xl border border-cyan-500/20 bg-cyan-500/10 p-5">
<h3 class="font-semibold text-cyan-200">Everything enabled</h3>
<p class="mt-3 text-sm leading-7 text-cyan-50/90">Secure assets, secure API and dist obfuscation all enabled.</p>
</div>
<div class="rounded-2xl border border-emerald-500/20 bg-emerald-500/10 p-5">
<h3 class="font-semibold text-emerald-200">Only .dat</h3>
<p class="mt-3 text-sm leading-7 text-emerald-50/90">Uses obfuscated assets but leaves config/API in plain mode.</p>
</div>
<div class="rounded-2xl border border-slate-700 bg-slate-950/60 p-5">
<h3 class="font-semibold text-white">Everything plain</h3>
<p class="mt-3 text-sm leading-7 text-slate-300">Complete fallback mode for local testing or debugging.</p>
</div>
</div>
</section>
<section id="checklist" class="rounded-3xl border border-slate-800 bg-slate-900/70 p-8">
<h2 class="text-2xl font-bold text-white">Final checklist</h2>
<div class="mt-5 grid gap-3">
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 px-5 py-4 text-sm text-slate-300">You created real files from <code>client-mode.example</code>, <code>renderer-config.example</code> and <code>ui-config.example</code></div>
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 px-5 py-4 text-sm text-slate-300">Public URLs live in config files, not in React components</div>
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 px-5 py-4 text-sm text-slate-300">Both plain files and <code>.dat</code> files are deployed</div>
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 px-5 py-4 text-sm text-slate-300">Your server exposes a proper MIME type for <code>.dat</code></div>
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 px-5 py-4 text-sm text-slate-300">You set <code>nitro.secure.master_key</code> on the emulator side</div>
</div>
</section>
</main>
</div>
</div>
</body>
</html>
+309
View File
@@ -0,0 +1,309 @@
# Secure runtime modes
This document summarizes all values you may need to configure for:
- `dist` bundle obfuscation (`app.js` / `app.css``.dat`)
- secure runtime assets (`configuration/renderer-config.json`, `configuration/ui-config.json`, `gamedata`)
- secure runtime API (`/api/*`)
- plain fallbacks when you want to disable the secure layer without removing the code
## 1. `Nitro-V3/public/configuration/client-mode.json`
This file controls everything at runtime.
```json
{
"distObfuscationEnabled": true,
"secureAssetsEnabled": true,
"secureApiEnabled": true,
"apiBaseUrl": "https://nitro.example.com:2096",
"plainConfigBaseUrl": "https://hotel.example.com/configuration/",
"plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/"
}
```
### Fields
- `distObfuscationEnabled`
- `true`: `asset-loader.js` loads `app.css.dat` and `app.js.dat`
- `false`: it loads plain `assets/app.css` and `assets/app.js`
- `secureAssetsEnabled`
- `true`: `bootstrap.ts` and `secure-assets.ts` use `/nitro-sec/file`
- `false`: `configuration/renderer-config.json`, `configuration/ui-config.json`, and gamedata are loaded in plain mode
- `secureApiEnabled`
- `true`: the `fetch` wrapper encrypts `/api/*` requests
- `false`: `/api/*` requests stay plain
- `apiBaseUrl`
- Nitro emulator / API base URL
- example: `https://nitro.example.com:2096`
- it is best to always set this explicitly, so you do not depend on the hardcoded fallback
- `plainConfigBaseUrl`
- base URL for plain config files
- usually: `https://hotel.example.com/configuration/`
- `plainGamedataBaseUrl`
- base URL for plain gamedata files
- usually: `https://hotel.example.com/client/nitro/gamedata/`
## 2. `Nitro-V3/src/bootstrap.ts`
`bootstrap.ts`:
- installs the secure fetch wrapper
- reads `window.__nitroClientMode`
- builds `NitroConfig['config.urls']`
### Current behavior
- if `secureAssetsEnabled=true`
- it uses `secureUrl('config', 'renderer-config.json', true)`
- it uses `secureUrl('config', 'ui-config.json', true)`
- if `secureAssetsEnabled=false`
- it uses plain files with cache busting (`?v=...`)
### Important note
The current fallback is:
```ts
(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || 'https://nitro.example.com:2096/';
```
So in production it is better to always set `apiBaseUrl` inside `configuration/client-mode.json`.
## 3. `Nitro-V3/src/secure-assets.ts`
This file contains the runtime logic for:
- ECDH bootstrap
- asset decrypt/encrypt
- secure `/api/*`
- plain fallback when the toggles are disabled
### In practice
- it reads flags from `window.__nitroClientMode`
- if `secureAssetsEnabled=false`
- it automatically rewrites `/nitro-sec/file?...` into plain URLs
- if `secureApiEnabled=false`
- it does not encrypt `/api/*`
Normally you should not need to touch it unless you want to change the secure protocol itself.
## 4. `Nitro-V3/public/configuration/renderer-config.json`
This file still defines the paths used by the renderer.
### Things to check
- `api.url`
- `socket.url`
- `gamedata.url`
- `external.texts.url`
- `external.texts.translation.url`
- `furnidata.url`
- `furnidata.translation.url`
### With secure assets enabled
You can use:
```json
"gamedata.url": "https://nitro.example.com:2096/nitro-sec/file?kind=gamedata&file="
```
and the equivalent secure URLs for the other gamedata resources.
### With secure assets disabled
You can use plain classic paths, for example:
```json
"gamedata.url": "https://hotel.example.com/client/nitro/gamedata"
```
or you can keep the renderer config as-is and let `secure-assets.ts` handle the fallback conversion.
## 5. `Nitro-V3/public/configuration/ui-config.json`
There is no secure logic here, but it is one of the files loaded through `config.urls`.
If `secureAssetsEnabled=true`, it is served from `/nitro-sec/file`.
If `secureAssetsEnabled=false`, it is loaded from the static file with `?v=...`.
So you only need to maintain the content itself correctly.
## 6. `Nitro-V3/scripts/write-asset-loader.mjs`
This script generates `public/configuration/asset-loader.js`.
### What it does now
- renders the initial shell
- reads `configuration/client-mode.json`
- decides whether to load:
- `app.css.dat` / `app.js.dat`
- or `assets/app.css` / `assets/app.js`
### Important
If you modify this script, the updated loader is regenerated on the next:
```bash
yarn build
```
because `package.json` already contains:
```json
"prebuild": "node scripts/write-asset-loader.mjs"
```
## 7. `Nitro-V3/scripts/minify-dist.mjs`
This script now:
- generates the `.dat` files
- keeps the original `app.css` and `app.js` files too
This is required, otherwise `distObfuscationEnabled=false` would not have a working fallback.
## 8. `Arcturus-Morningstar-Extended/Latest_Compiled_Version/config.ini.example`
The current backend flags are:
```ini
nitro.secure.assets.enabled=true
nitro.secure.api.enabled=true
nitro.secure.config.root=
nitro.secure.gamedata.root=
nitro.secure.master_key=change-me-to-a-long-random-secret
```
### Meaning
- `nitro.secure.assets.enabled`
- enables `/nitro-sec/bootstrap` and `/nitro-sec/file`
- `nitro.secure.api.enabled`
- enables the secure layer for `/api/*`
- `nitro.secure.config.root`
- folder used to read `configuration/renderer-config.json` and `configuration/ui-config.json`
- `nitro.secure.gamedata.root`
- folder used to read live gamedata
- `nitro.secure.master_key`
- persistent server-side secret
- especially important when running behind Cloudflare / multiple backend requests
## 9. Example setups
### Everything enabled
`configuration/client-mode.json`
```json
{
"distObfuscationEnabled": true,
"secureAssetsEnabled": true,
"secureApiEnabled": true,
"apiBaseUrl": "https://nitro.example.com:2096",
"plainConfigBaseUrl": "https://hotel.example.com/configuration/",
"plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/"
}
```
`config.ini`
```ini
nitro.secure.assets.enabled=true
nitro.secure.api.enabled=true
nitro.secure.config.root=C:/inetpub/wwwroot/paxxo/nitro
nitro.secure.gamedata.root=C:/inetpub/wwwroot/paxxo/nitro/client/nitro/gamedata
nitro.secure.master_key=a-long-random-secret
```
### `.dat` only, no secure assets/API
`configuration/client-mode.json`
```json
{
"distObfuscationEnabled": true,
"secureAssetsEnabled": false,
"secureApiEnabled": false,
"apiBaseUrl": "https://nitro.example.com:2096",
"plainConfigBaseUrl": "https://hotel.example.com/configuration/",
"plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/"
}
```
`config.ini`
```ini
nitro.secure.assets.enabled=false
nitro.secure.api.enabled=false
```
### Everything plain
`configuration/client-mode.json`
```json
{
"distObfuscationEnabled": false,
"secureAssetsEnabled": false,
"secureApiEnabled": false,
"apiBaseUrl": "https://nitro.example.com:2096",
"plainConfigBaseUrl": "https://hotel.example.com/configuration/",
"plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/"
}
```
## 10. When rebuild is required
### No rebuild required
For changes to:
- `configuration/client-mode.json`
- `configuration/renderer-config.json`
- `configuration/ui-config.json`
- live gamedata
- `config.ini`
### Rebuild required
For changes to:
- `src/bootstrap.ts`
- `src/secure-assets.ts`
- `scripts/write-asset-loader.mjs`
- `scripts/minify-dist.mjs`
## 11. Deployment note
To make the toggles work properly:
- always deploy both plain files and `.dat` files
- make sure IIS / your host serves the `.dat` MIME type
- if you disable secure mode on the client, disable it on the backend too for consistency
## 12. Quick checklist
- `configuration/client-mode.json` configured
- `apiBaseUrl` correct
- `nitro.secure.master_key` set
- `nitro.secure.config.root` correct
- `nitro.secure.gamedata.root` correct
- both `.dat` and plain files deployed
- `.dat` MIME type configured on the web server
+236
View File
@@ -0,0 +1,236 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nitro Secure Runtime Modes</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-950 text-slate-100">
<div class="mx-auto max-w-6xl px-6 py-10">
<div class="mb-8 rounded-3xl border border-cyan-500/20 bg-slate-900/80 p-8 shadow-2xl shadow-cyan-950/30">
<div class="flex flex-wrap items-center gap-3">
<span class="rounded-full border border-cyan-400/30 bg-cyan-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.25em] text-cyan-300">Nitro V3</span>
<span class="rounded-full border border-emerald-400/30 bg-emerald-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.25em] text-emerald-300">Secure Runtime</span>
</div>
<h1 class="mt-5 text-4xl font-black tracking-tight text-white">Documentazione configurazione runtime</h1>
<p class="mt-4 max-w-3xl text-sm leading-7 text-slate-300">
Questa pagina riassume in modo ordinato come configurare i toggle runtime, i file example e i parametri lato client / emulatore
senza sporcare i componenti in <code class="rounded bg-slate-800 px-1.5 py-0.5 text-cyan-300">src</code>.
</p>
</div>
<div class="grid gap-6 lg:grid-cols-[280px_minmax(0,1fr)]">
<aside class="rounded-3xl border border-slate-800 bg-slate-900/70 p-5 lg:sticky lg:top-6 lg:h-fit">
<h2 class="mb-4 text-sm font-bold uppercase tracking-[0.2em] text-slate-400">Indice</h2>
<nav class="space-y-2 text-sm">
<a class="block rounded-xl px-3 py-2 text-slate-300 transition hover:bg-slate-800 hover:text-white" href="#overview">Overview</a>
<a class="block rounded-xl px-3 py-2 text-slate-300 transition hover:bg-slate-800 hover:text-white" href="#files">File da usare</a>
<a class="block rounded-xl px-3 py-2 text-slate-300 transition hover:bg-slate-800 hover:text-white" href="#client-mode">client-mode</a>
<a class="block rounded-xl px-3 py-2 text-slate-300 transition hover:bg-slate-800 hover:text-white" href="#renderer-config">renderer-config</a>
<a class="block rounded-xl px-3 py-2 text-slate-300 transition hover:bg-slate-800 hover:text-white" href="#ui-config">ui-config</a>
<a class="block rounded-xl px-3 py-2 text-slate-300 transition hover:bg-slate-800 hover:text-white" href="#runtime-code">Codice runtime</a>
<a class="block rounded-xl px-3 py-2 text-slate-300 transition hover:bg-slate-800 hover:text-white" href="#emulator">Emulatore</a>
<a class="block rounded-xl px-3 py-2 text-slate-300 transition hover:bg-slate-800 hover:text-white" href="#scenarios">Scenari</a>
<a class="block rounded-xl px-3 py-2 text-slate-300 transition hover:bg-slate-800 hover:text-white" href="#checklist">Checklist</a>
</nav>
</aside>
<main class="space-y-6">
<section id="overview" class="rounded-3xl border border-slate-800 bg-slate-900/70 p-8">
<h2 class="text-2xl font-bold text-white">Overview</h2>
<div class="mt-5 grid gap-4 md:grid-cols-3">
<div class="rounded-2xl border border-slate-800 bg-slate-950/70 p-5">
<h3 class="text-sm font-semibold uppercase tracking-wide text-cyan-300">Dist Obfuscation</h3>
<p class="mt-3 text-sm leading-7 text-slate-300">Sceglie se caricare <code class="rounded bg-slate-800 px-1">app.js/app.css</code> oppure <code class="rounded bg-slate-800 px-1">.dat</code>.</p>
</div>
<div class="rounded-2xl border border-slate-800 bg-slate-950/70 p-5">
<h3 class="text-sm font-semibold uppercase tracking-wide text-emerald-300">Secure Assets</h3>
<p class="mt-3 text-sm leading-7 text-slate-300">Controlla se <code class="rounded bg-slate-800 px-1">renderer-config</code>, <code class="rounded bg-slate-800 px-1">ui-config</code> e gamedata passano da <code class="rounded bg-slate-800 px-1">/nitro-sec/file</code>.</p>
</div>
<div class="rounded-2xl border border-slate-800 bg-slate-950/70 p-5">
<h3 class="text-sm font-semibold uppercase tracking-wide text-fuchsia-300">Secure API</h3>
<p class="mt-3 text-sm leading-7 text-slate-300">Attiva o disattiva la cifratura runtime automatica su <code class="rounded bg-slate-800 px-1">/api/*</code>.</p>
</div>
</div>
</section>
<section id="files" class="rounded-3xl border border-slate-800 bg-slate-900/70 p-8">
<h2 class="text-2xl font-bold text-white">File da usare</h2>
<div class="mt-5 overflow-hidden rounded-2xl border border-slate-800">
<table class="min-w-full divide-y divide-slate-800 text-sm">
<thead class="bg-slate-950/80">
<tr>
<th class="px-4 py-3 text-left font-semibold text-slate-200">File</th>
<th class="px-4 py-3 text-left font-semibold text-slate-200">Scopo</th>
<th class="px-4 py-3 text-left font-semibold text-slate-200">Nota</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
<tr class="bg-slate-900/60">
<td class="px-4 py-3"><code>public/configuration/client-mode.example</code></td>
<td class="px-4 py-3">Template per i toggle runtime</td>
<td class="px-4 py-3 text-slate-300">Da copiare in <code>configuration/client-mode.json</code> nel deploy reale, che resta ignorato da Git</td>
</tr>
<tr class="bg-slate-950/40">
<td class="px-4 py-3"><code>public/configuration/renderer-config.example</code></td>
<td class="px-4 py-3">Template sicuro del renderer config</td>
<td class="px-4 py-3 text-slate-300">Non tocca il tuo <code>configuration/renderer-config.json</code> locale</td>
</tr>
<tr class="bg-slate-900/60">
<td class="px-4 py-3"><code>public/configuration/ui-config.example</code></td>
<td class="px-4 py-3">Template UI config</td>
<td class="px-4 py-3 text-slate-300">Da mantenere come riferimento pulito</td>
</tr>
<tr class="bg-slate-950/40">
<td class="px-4 py-3"><code>Latest_Compiled_Version/config.ini.example</code></td>
<td class="px-4 py-3">Flag backend secure</td>
<td class="px-4 py-3 text-slate-300">Specifica la parte lato emulatore</td>
</tr>
</tbody>
</table>
</div>
</section>
<section id="client-mode" class="rounded-3xl border border-slate-800 bg-slate-900/70 p-8">
<h2 class="text-2xl font-bold text-white">client-mode.example</h2>
<p class="mt-3 text-sm leading-7 text-slate-300">È il punto centrale per attivare o disattivare il comportamento runtime senza dover modificare il codice.</p>
<pre class="mt-5 overflow-x-auto rounded-2xl border border-slate-800 bg-slate-950/90 p-5 text-sm text-cyan-300"><code>{
"distObfuscationEnabled": true,
"secureAssetsEnabled": true,
"secureApiEnabled": true,
"apiBaseUrl": "https://nitro.example.com:2096",
"plainConfigBaseUrl": "https://hotel.example.com/configuration/",
"plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/"
}</code></pre>
<div class="mt-5 grid gap-4 md:grid-cols-2">
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 p-5">
<h3 class="font-semibold text-white">Campi</h3>
<ul class="mt-3 space-y-2 text-sm leading-7 text-slate-300">
<li><code class="rounded bg-slate-800 px-1">distObfuscationEnabled</code>: usa <code>.dat</code> oppure file plain</li>
<li><code class="rounded bg-slate-800 px-1">secureAssetsEnabled</code>: attiva <code>/nitro-sec/file</code></li>
<li><code class="rounded bg-slate-800 px-1">secureApiEnabled</code>: cifra le richieste <code>/api/*</code></li>
<li><code class="rounded bg-slate-800 px-1">apiBaseUrl</code>: base URL emulatore/API</li>
</ul>
</div>
<div class="rounded-2xl border border-amber-500/20 bg-amber-500/10 p-5">
<h3 class="font-semibold text-amber-200">Suggerimento</h3>
<p class="mt-3 text-sm leading-7 text-amber-100/90">Conviene impostare sempre <code>apiBaseUrl</code> in modo esplicito, così non dipendi da fallback impliciti del runtime.</p>
</div>
</div>
</section>
<section id="renderer-config" class="rounded-3xl border border-slate-800 bg-slate-900/70 p-8">
<h2 class="text-2xl font-bold text-white">renderer-config.example</h2>
<p class="mt-3 text-sm leading-7 text-slate-300">Qui definisci URL di socket, API, asset library e gamedata. Tutti i link pubblici dovrebbero vivere qui, non nei componenti React.</p>
<div class="mt-5 grid gap-4 md:grid-cols-2">
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 p-5">
<h3 class="font-semibold text-white">Chiavi principali</h3>
<ul class="mt-3 space-y-2 text-sm leading-7 text-slate-300">
<li><code>socket.url</code></li>
<li><code>api.url</code></li>
<li><code>asset.url</code></li>
<li><code>image.library.url</code></li>
<li><code>images.url</code></li>
<li><code>gamedata.url</code></li>
</ul>
</div>
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 p-5">
<h3 class="font-semibold text-white">Traduzioni</h3>
<ul class="mt-3 space-y-2 text-sm leading-7 text-slate-300">
<li><code>external.texts.translation.url</code></li>
<li><code>furnidata.translation.url</code></li>
<li>Usano <code>%locale%</code> e <code>%timestamp%</code></li>
</ul>
</div>
</div>
</section>
<section id="ui-config" class="rounded-3xl border border-slate-800 bg-slate-900/70 p-8">
<h2 class="text-2xl font-bold text-white">ui-config.example</h2>
<p class="mt-3 text-sm leading-7 text-slate-300">Per la login view e altre immagini UI, la sorgente deve stare qui o in renderer config, non hardcoded nei componenti.</p>
<div class="mt-5 rounded-2xl border border-slate-800 bg-slate-950/60 p-5">
<h3 class="font-semibold text-white">Login view</h3>
<ul class="mt-3 space-y-2 text-sm leading-7 text-slate-300">
<li><code>loginview.images.background</code></li>
<li><code>loginview.images.drape</code></li>
<li><code>loginview.images.left</code></li>
<li><code>loginview.images.right</code></li>
<li><code>loginview.widgets</code> per i blocchi promozionali</li>
</ul>
</div>
</section>
<section id="runtime-code" class="rounded-3xl border border-slate-800 bg-slate-900/70 p-8">
<h2 class="text-2xl font-bold text-white">Codice runtime coinvolto</h2>
<div class="mt-5 grid gap-4 md:grid-cols-2">
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 p-5">
<h3 class="font-semibold text-white"><code>src/bootstrap.ts</code></h3>
<p class="mt-3 text-sm leading-7 text-slate-300">Legge <code>client-mode</code>, costruisce <code>NitroConfig['config.urls']</code> e prepara il bootstrap del client.</p>
</div>
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 p-5">
<h3 class="font-semibold text-white"><code>src/secure-assets.ts</code></h3>
<p class="mt-3 text-sm leading-7 text-slate-300">Gestisce ECDH, decrypt/encrypt, fallback plain e secure API runtime.</p>
</div>
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 p-5">
<h3 class="font-semibold text-white"><code>scripts/write-asset-loader.mjs</code></h3>
<p class="mt-3 text-sm leading-7 text-slate-300">Genera <code>public/configuration/asset-loader.js</code> e decide se usare file plain o <code>.dat</code>.</p>
</div>
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 p-5">
<h3 class="font-semibold text-white"><code>scripts/minify-dist.mjs</code></h3>
<p class="mt-3 text-sm leading-7 text-slate-300">Genera i <code>.dat</code> ma mantiene anche i file plain per il toggle runtime.</p>
</div>
</div>
</section>
<section id="emulator" class="rounded-3xl border border-slate-800 bg-slate-900/70 p-8">
<h2 class="text-2xl font-bold text-white">Emulatore</h2>
<pre class="mt-5 overflow-x-auto rounded-2xl border border-slate-800 bg-slate-950/90 p-5 text-sm text-emerald-300"><code>nitro.secure.assets.enabled=true
nitro.secure.api.enabled=true
nitro.secure.config.root=C:/path/to/Nitro-V3/public
nitro.secure.gamedata.root=C:/path/to/gamedata
nitro.secure.master_key=change-me-to-a-long-random-secret</code></pre>
<ul class="mt-5 space-y-2 text-sm leading-7 text-slate-300">
<li><code>nitro.secure.assets.enabled</code>: abilita <code>/nitro-sec/bootstrap</code> e <code>/nitro-sec/file</code></li>
<li><code>nitro.secure.api.enabled</code>: abilita la cifratura su <code>/api/*</code></li>
<li><code>nitro.secure.config.root</code>: cartella dei config live</li>
<li><code>nitro.secure.gamedata.root</code>: cartella del gamedata live</li>
<li><code>nitro.secure.master_key</code>: chiave persistente server-side</li>
</ul>
</section>
<section id="scenarios" class="rounded-3xl border border-slate-800 bg-slate-900/70 p-8">
<h2 class="text-2xl font-bold text-white">Scenari rapidi</h2>
<div class="mt-5 grid gap-4 md:grid-cols-3">
<div class="rounded-2xl border border-cyan-500/20 bg-cyan-500/10 p-5">
<h3 class="font-semibold text-cyan-200">Tutto attivo</h3>
<p class="mt-3 text-sm leading-7 text-cyan-50/90">Secure assets, secure API e dist obfuscation tutti attivi.</p>
</div>
<div class="rounded-2xl border border-emerald-500/20 bg-emerald-500/10 p-5">
<h3 class="font-semibold text-emerald-200">Solo .dat</h3>
<p class="mt-3 text-sm leading-7 text-emerald-50/90">Usi i <code>.dat</code>, ma lasci config/API in plain.</p>
</div>
<div class="rounded-2xl border border-slate-700 bg-slate-950/60 p-5">
<h3 class="font-semibold text-white">Tutto plain</h3>
<p class="mt-3 text-sm leading-7 text-slate-300">Modalità fallback completa per debug o test locali.</p>
</div>
</div>
</section>
<section id="checklist" class="rounded-3xl border border-slate-800 bg-slate-900/70 p-8">
<h2 class="text-2xl font-bold text-white">Checklist finale</h2>
<div class="mt-5 grid gap-3">
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 px-5 py-4 text-sm text-slate-300">Hai creato i file reali partendo da <code>client-mode.example</code>, <code>renderer-config.example</code> e <code>ui-config.example</code></div>
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 px-5 py-4 text-sm text-slate-300">Gli URL pubblici stanno nei file config, non nei componenti React</div>
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 px-5 py-4 text-sm text-slate-300">Hai deployato sia i file plain sia i <code>.dat</code></div>
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 px-5 py-4 text-sm text-slate-300">Il server espone correttamente il MIME type per <code>.dat</code></div>
<div class="rounded-2xl border border-slate-800 bg-slate-950/60 px-5 py-4 text-sm text-slate-300">Hai impostato <code>nitro.secure.master_key</code> lato emulatore</div>
</div>
</section>
</main>
</div>
</div>
</body>
</html>
+309
View File
@@ -0,0 +1,309 @@
# Secure runtime modes
Questa doc riassume tutti i dati da impostare per:
- offuscamento bundle `dist` (`app.js` / `app.css``.dat`)
- secure assets runtime (`configuration/renderer-config.json`, `configuration/ui-config.json`, `gamedata`)
- secure API runtime (`/api/*`)
- fallback plain quando vuoi spegnere tutto senza togliere il codice
## 1. `Nitro-V3/public/configuration/client-mode.json`
Questo file controlla tutto a runtime.
```json
{
"distObfuscationEnabled": true,
"secureAssetsEnabled": true,
"secureApiEnabled": true,
"apiBaseUrl": "https://nitro.example.com:2096",
"plainConfigBaseUrl": "https://hotel.example.com/configuration/",
"plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/"
}
```
### Campi
- `distObfuscationEnabled`
- `true`: `asset-loader.js` carica `app.css.dat` e `app.js.dat`
- `false`: carica i file normali `assets/app.css` e `assets/app.js`
- `secureAssetsEnabled`
- `true`: `bootstrap.ts` e `secure-assets.ts` usano `/nitro-sec/file`
- `false`: `configuration/renderer-config.json`, `configuration/ui-config.json` e gamedata vengono letti in plain
- `secureApiEnabled`
- `true`: il wrapper `fetch` cifra le chiamate `/api/*`
- `false`: le chiamate `/api/*` restano normali
- `apiBaseUrl`
- base URL dellemulatore / API Nitro
- esempio: `https://nitro.example.com:2096`
- meglio valorizzarlo sempre, così non dipendi dal fallback hardcoded
- `plainConfigBaseUrl`
- base URL dei file config plain
- normalmente: `https://hotel.example.com/configuration/`
- `plainGamedataBaseUrl`
- base URL del gamedata plain
- normalmente: `https://hotel.example.com/client/nitro/gamedata/`
## 2. `Nitro-V3/src/bootstrap.ts`
`bootstrap.ts`:
- installa il secure fetch wrapper
- legge `window.__nitroClientMode`
- costruisce `NitroConfig['config.urls']`
### Comportamento attuale
- se `secureAssetsEnabled=true`
- usa `secureUrl('config', 'renderer-config.json', true)`
- usa `secureUrl('config', 'ui-config.json', true)`
- se `secureAssetsEnabled=false`
- usa i file plain con cache bust (`?v=...`)
### Nota importante
Il fallback attuale è:
```ts
(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || 'https://nitro.example.com:2096/';
```
Quindi in produzione conviene sempre valorizzare `apiBaseUrl` dentro `configuration/client-mode.json`.
## 3. `Nitro-V3/src/secure-assets.ts`
Qui vive tutta la logica runtime:
- bootstrap ECDH
- decrypt/encrypt assets
- secure `/api/*`
- fallback plain quando i toggle sono spenti
### In pratica
- legge i flag da `window.__nitroClientMode`
- se `secureAssetsEnabled=false`
- converte automaticamente `/nitro-sec/file?...` in URL plain
- se `secureApiEnabled=false`
- non cifra `/api/*`
Normalmente non serve toccarlo, a meno che tu non voglia cambiare il protocollo secure.
## 4. `Nitro-V3/public/configuration/renderer-config.json`
Questo file continua a definire i path usati dal renderer.
### Da controllare
- `api.url`
- `socket.url`
- `gamedata.url`
- `external.texts.url`
- `external.texts.translation.url`
- `furnidata.url`
- `furnidata.translation.url`
### Con secure assets attivo
Puoi usare:
```json
"gamedata.url": "https://nitro.example.com:2096/nitro-sec/file?kind=gamedata&file="
```
e gli altri URL secure equivalenti.
### Con secure assets disattivo
Conviene usare i path plain classici, per esempio:
```json
"gamedata.url": "https://hotel.example.com/client/nitro/gamedata"
```
oppure lasciare il renderer configurato com’è e demandare il fallback a `secure-assets.ts`.
## 5. `Nitro-V3/public/configuration/ui-config.json`
Qui non c’è logica secure, ma è uno dei file caricati da `config.urls`.
Se `secureAssetsEnabled=true`, arriva da `/nitro-sec/file`.
Se `secureAssetsEnabled=false`, arriva dal file statico con `?v=...`.
Quindi basta mantenerlo corretto come contenuto, non serve altro.
## 6. `Nitro-V3/scripts/write-asset-loader.mjs`
Questo script genera `public/configuration/asset-loader.js`.
### Cosa fa ora
- mostra la shell iniziale
- legge `configuration/client-mode.json`
- decide se caricare:
- `app.css.dat` / `app.js.dat`
- oppure `assets/app.css` / `assets/app.js`
### Importante
Se modifichi questo script, il loader aggiornato viene rigenerato al prossimo:
```bash
yarn build
```
perché in `package.json` c’è:
```json
"prebuild": "node scripts/write-asset-loader.mjs"
```
## 7. `Nitro-V3/scripts/minify-dist.mjs`
Adesso questo script:
- genera i `.dat`
- lascia anche i file originali `app.css` e `app.js`
Questa parte è fondamentale, altrimenti il toggle `distObfuscationEnabled=false` non avrebbe fallback.
## 8. `Arcturus-Morningstar-Extended/Latest_Compiled_Version/config.ini.example`
I flag backend attuali sono:
```ini
nitro.secure.assets.enabled=true
nitro.secure.api.enabled=true
nitro.secure.config.root=
nitro.secure.gamedata.root=
nitro.secure.master_key=change-me-to-a-long-random-secret
```
### Significato
- `nitro.secure.assets.enabled`
- abilita `/nitro-sec/bootstrap` e `/nitro-sec/file`
- `nitro.secure.api.enabled`
- abilita il layer secure per `/api/*`
- `nitro.secure.config.root`
- cartella dove leggere `configuration/renderer-config.json` e `configuration/ui-config.json`
- `nitro.secure.gamedata.root`
- cartella dove leggere il gamedata live
- `nitro.secure.master_key`
- segreto persistente lato server
- necessario soprattutto con Cloudflare / richieste multiple
## 9. Esempi di configurazione
### Tutto attivo
`configuration/client-mode.json`
```json
{
"distObfuscationEnabled": true,
"secureAssetsEnabled": true,
"secureApiEnabled": true,
"apiBaseUrl": "https://nitro.example.com:2096",
"plainConfigBaseUrl": "https://hotel.example.com/configuration/",
"plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/"
}
```
`config.ini`
```ini
nitro.secure.assets.enabled=true
nitro.secure.api.enabled=true
nitro.secure.config.root=C:/inetpub/wwwroot/paxxo/nitro
nitro.secure.gamedata.root=C:/inetpub/wwwroot/paxxo/nitro/client/nitro/gamedata
nitro.secure.master_key=una-chiave-lunga-random
```
### Solo `.dat`, senza secure assets/api
`configuration/client-mode.json`
```json
{
"distObfuscationEnabled": true,
"secureAssetsEnabled": false,
"secureApiEnabled": false,
"apiBaseUrl": "https://nitro.example.com:2096",
"plainConfigBaseUrl": "https://hotel.example.com/configuration/",
"plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/"
}
```
`config.ini`
```ini
nitro.secure.assets.enabled=false
nitro.secure.api.enabled=false
```
### Tutto plain
`configuration/client-mode.json`
```json
{
"distObfuscationEnabled": false,
"secureAssetsEnabled": false,
"secureApiEnabled": false,
"apiBaseUrl": "https://nitro.example.com:2096",
"plainConfigBaseUrl": "https://hotel.example.com/configuration/",
"plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/"
}
```
## 10. Quando serve rebuild
### Non serve rebuild
Per cambiare:
- `configuration/client-mode.json`
- `configuration/renderer-config.json`
- `configuration/ui-config.json`
- gamedata live
- `config.ini`
### Serve rebuild
Per cambiare:
- `src/bootstrap.ts`
- `src/secure-assets.ts`
- `scripts/write-asset-loader.mjs`
- `scripts/minify-dist.mjs`
## 11. Nota pratica deployment
Per usare bene i toggle:
- pubblica sempre sia i file plain sia i `.dat`
- assicurati che IIS/host serva il MIME type per `.dat`
- se spegni il secure mode nel client, spegnilo anche nel backend per coerenza
## 12. Checklist veloce
- `configuration/client-mode.json` configurato
- `apiBaseUrl` corretto
- `nitro.secure.master_key` valorizzata
- `nitro.secure.config.root` corretto
- `nitro.secure.gamedata.root` corretto
- `.dat` e file plain entrambi deployati
- MIME `.dat` presente sul web server
+1 -35
View File
@@ -1,35 +1 @@
<!doctype html>
<html lang="en">
<head>
<title>Nitro</title>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" crossorigin="use-credentials" href="/site.webmanifest" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#000000" />
<meta name="apple-mobile-web-app-title" content="Nitro" />
<meta name="application-name" content="Nitro" />
<meta name="msapplication-TileColor" content="#000000" />
<meta name="theme-color" content="#000000" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="w-full h-full"></div>
<script>
window.NitroConfig = {
"config.urls": ["renderer-config.json?v=" + Math.random(), "infostand_backgrounds.json?v=" + Math.random(), "ui-config.json?v=" + Math.random()],
"sso.ticket": new URLSearchParams(window.location.search).get("sso") || null,
"forward.type": new URLSearchParams(window.location.search).get("room", ) ? 2 : -1,
"forward.id": new URLSearchParams(window.location.search).get("room") || 0,
"friend.id": new URLSearchParams(window.location.search).get("friend") || 0,
};
</script>
<script type="module" src="./src/index.tsx"></script>
</body>
</html>
<div id="root"></div><script type="module" src="/src/bootstrap.ts"></script>
-3
View File
@@ -1,3 +0,0 @@
{
"notification.badge.received": "New Badge!"
}
-3
View File
@@ -1,3 +0,0 @@
{
"notification.badge.received": "Nuovo Distintivo!"
}
-12
View File
@@ -1,12 +0,0 @@
# Mockup Assets
Questa cartella e' pronta per immagini o sprite dedicate ai mockup HTML.
Uso previsto:
- copiare qui versioni statiche di asset che vuoi testare fuori dal progetto
- collegarle da `mockup/index.html`
- tenere separati i file di esperimento dagli asset reali di `src/assets`
Percorso base:
- `Nitro-V3/mockup/index.html`
- `Nitro-V3/mockup/assets/`
-623
View File
@@ -1,623 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nitro V3 Mockup Lab</title>
<style>
:root {
--bg: #17191d;
--panel: #252931;
--panel-2: #1d2026;
--border: #3d4450;
--text: #f4f7fb;
--muted: #aeb6c4;
--card-border: #283f5d;
--card-header: #1e7295;
--card-content: #dfdfdf;
--toolbar: rgba(28, 28, 32, 0.95);
--tab: #b6bec5;
--tab-active: #dfdfdf;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 24px;
background: radial-gradient(circle at top, #222733 0%, #17191d 60%);
color: var(--text);
font-family: Arial, sans-serif;
}
h1, h2, h3, p {
margin: 0;
}
h1 {
font-size: 28px;
margin-bottom: 10px;
}
.intro {
color: var(--muted);
margin-bottom: 24px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 20px;
}
.section {
background: linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%);
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
}
.section-head {
padding: 14px 16px;
border-bottom: 1px solid rgba(255,255,255,0.06);
background: rgba(255,255,255,0.03);
}
.section-head h2 {
font-size: 17px;
margin-bottom: 4px;
}
.section-head p {
font-size: 13px;
color: var(--muted);
}
.section-body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.stage {
background: #121419;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px;
padding: 16px;
min-height: 200px;
}
.paths {
padding: 12px;
background: rgba(0,0,0,0.22);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 10px;
font: 12px/1.5 Consolas, monospace;
color: #d5d9e1;
white-space: pre-wrap;
}
.nitro-card {
width: 320px;
border: 1px solid var(--card-border);
border-radius: 4px;
overflow: hidden;
box-shadow: 0 6px 20px rgba(0,0,0,0.35);
resize: both;
background: var(--card-content);
}
.nitro-card-header {
min-height: 33px;
max-height: 33px;
background: var(--card-header);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.nitro-card-header-text {
color: #fff;
font-size: 20px;
text-shadow: 0 4px 4px rgba(0,0,0,.25);
font-weight: 700;
}
.ubuntu-close-button {
position: absolute;
right: 8px;
width: 18px;
height: 18px;
border-radius: 4px;
box-shadow: 0 0 0 1.6px #fff;
border: 2px solid #921911;
background: repeating-linear-gradient(
rgba(245, 80, 65, 1),
rgba(245, 80, 65, 1) 50%,
rgba(194, 48, 39, 1) 50%,
rgba(194, 48, 39, 1) 100%
);
}
.nitro-card-tabs {
display: flex;
gap: 2px;
justify-content: center;
background: #185d79;
padding: 4px 8px 0;
border-bottom: 1px solid var(--card-border);
}
.nitro-card-tab {
position: relative;
min-width: 90px;
padding: 5px 10px;
background: var(--tab);
color: #000;
text-align: center;
border-top-left-radius: 7px;
border-top-right-radius: 7px;
border-top: 1px solid var(--card-border);
border-left: 1px solid var(--card-border);
border-right: 1px solid var(--card-border);
}
.nitro-card-tab::before {
content: "";
position: absolute;
top: 1.5px;
left: 0;
right: 0;
width: 93%;
height: 3px;
margin: auto;
border-radius: 4px;
background: #c2c9d1;
}
.nitro-card-tab.active {
background: var(--tab-active);
margin-bottom: -1px;
}
.nitro-card-tab.active::before {
background: #fff;
}
.nitro-card-content {
padding: 8px;
min-height: 150px;
color: #000;
}
.purse {
width: 200px;
background: rgba(30, 30, 42, 0.95);
border-radius: 10px;
padding: 6px;
color: #fff;
}
.purse-grid {
display: grid;
grid-template-columns: 1fr 54px 30px;
gap: 6px;
}
.currency-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.currency-row {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: 5px;
color: #fff;
}
.currency--1 { background: #e8b125; border: 2px solid #f4d892; }
.currency-0 { background: #c364c1; border: 2px solid #ecb3ea; }
.currency-5 { background: #6bafaa; border: 2px solid #ace6e2; }
.subscription {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #212131;
border: 2px solid #383853;
border-radius: 8px;
color: white;
font-size: 12px;
}
.side-buttons {
display: flex;
flex-direction: column;
gap: 3px;
}
.side-button {
width: 30px;
height: 28px;
border-radius: 8px;
background: #b69b83;
border: 2px solid rgba(255,255,255,.5);
}
.seasonal {
margin-top: 6px;
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(to right, #95c3e5, transparent);
background-color: #212131;
border-radius: 8px;
min-height: 30px;
color: white;
padding: 0 8px;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 55px;
padding: 8px 12px;
background: var(--toolbar);
box-shadow: inset 0 5px #22222799, inset 0 -4px #12121599;
border-radius: 10px;
}
.toolbar-left, .toolbar-right {
display: flex;
align-items: center;
gap: 10px;
}
.toolbar-icon {
width: 34px;
height: 34px;
border-radius: 8px;
background: linear-gradient(180deg, #4a535f, #2d333b);
border: 1px solid rgba(255,255,255,.15);
}
.toolbar-me {
width: 50px;
height: 45px;
border-radius: 10px;
background: linear-gradient(180deg, #8ec6ff, #4f7fb7);
}
.navigator {
width: 420px;
border: 1px solid var(--card-border);
border-radius: 4px;
overflow: hidden;
box-shadow: 0 6px 20px rgba(0,0,0,.35);
background: var(--card-content);
}
.navigator-body {
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
color: #000;
}
.navigator-search {
height: 34px;
border: 1px solid #9ca3af;
background: white;
border-radius: 6px;
}
.navigator-results {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 140px;
}
.navigator-result {
height: 34px;
background: rgba(0,0,0,.06);
border-radius: 6px;
}
.navigator-footer {
display: flex;
gap: 8px;
border-top: 1px solid #b0b7c2;
padding-top: 8px;
}
.nav-action {
flex: 1;
height: 60px;
border-radius: 10px;
background: linear-gradient(180deg, #4c8fd7, #2e66a6);
}
.notification-bubble {
width: 240px;
padding: 10px;
background-color: #262626;
box-shadow: inset 0 5px rgba(38,38,57,.6), inset 0 -4px rgba(25,25,37,.6);
color: white;
border-radius: .5rem;
font-size: 12px;
}
.friends-bar {
display: flex;
gap: 8px;
align-items: center;
}
.friend-pill {
width: 56px;
height: 56px;
border-radius: 12px;
background: linear-gradient(180deg, #dadfe7, #a4afbf);
border: 2px solid rgba(255,255,255,.35);
}
.hotel-view {
position: relative;
width: 100%;
height: 260px;
overflow: hidden;
border-radius: 12px;
background: linear-gradient(to bottom, #69b4e4 0%, #9fd3f2 55%, #51841e 55%, #51841e 100%);
}
.hotel-slab {
position: absolute;
inset: 24px 40px 36px;
background: linear-gradient(180deg, #d7d1c8 0%, #b8b1a4 100%);
border-radius: 14px;
box-shadow: 0 18px 40px rgba(0,0,0,.25);
}
.hotel-hotspot {
position: absolute;
width: 58px;
height: 40px;
border-radius: 8px;
background: rgba(255,255,255,.25);
border: 1px dashed rgba(255,255,255,.7);
}
.hotel-hotspot.a { top: 30px; left: 40px; }
.hotel-hotspot.b { top: 98px; left: 160px; }
.hotel-hotspot.c { top: 150px; right: 90px; }
@media (max-width: 780px) {
body {
padding: 14px;
}
.grid {
grid-template-columns: 1fr;
}
.navigator,
.nitro-card {
width: 100%;
}
}
</style>
</head>
<body>
<h1>Nitro V3 Mockup Lab</h1>
<p class="intro">Mockup HTML standalone dei componenti principali attuali. La resa è pensata per darti una base visiva da modificare rapidamente fuori dal progetto reale.</p>
<div class="grid">
<section class="section">
<div class="section-head">
<h2>NitroCard</h2>
<p>Base card attuale con header blu, tabs grigie e content chiaro.</p>
</div>
<div class="section-body">
<div class="stage">
<div class="nitro-card">
<div class="nitro-card-header">
<div class="nitro-card-header-text">Navigator</div>
<div class="ubuntu-close-button"></div>
</div>
<div class="nitro-card-tabs">
<div class="nitro-card-tab active">Hotel</div>
<div class="nitro-card-tab">Rooms</div>
<div class="nitro-card-tab">+</div>
</div>
<div class="nitro-card-content">
Contenuto card attuale, usato come base da vari componenti.
</div>
</div>
</div>
<div class="paths">Source files:
src/common/card/NitroCardView.tsx
src/common/card/NitroCardHeaderView.tsx
src/common/card/NitroCardContentView.tsx
src/common/card/tabs/NitroCardTabsView.tsx
src/common/card/tabs/NitroCardTabsItemView.tsx
src/css/nitrocard/NitroCardView.css</div>
</div>
</section>
<section class="section">
<div class="section-head">
<h2>Purse</h2>
<p>Layout attuale con currency, box HC, pulsanti laterali e seasonal sotto.</p>
</div>
<div class="section-body">
<div class="stage">
<div class="purse">
<div class="purse-grid">
<div class="currency-list">
<div class="currency-row currency--1">3601 ◉</div>
<div class="currency-row currency-0">5365 ◎</div>
<div class="currency-row currency-5">700 ◈</div>
</div>
<div class="subscription">
<div>HC</div>
<div>78 g</div>
</div>
<div class="side-buttons">
<div class="side-button"></div>
<div class="side-button"></div>
</div>
</div>
<div class="seasonal">
<span>Stagionale</span>
<span>99 999</span>
</div>
</div>
</div>
<div class="paths">Source files:
src/components/purse/PurseView.tsx
src/components/purse/views/CurrencyView.tsx
src/components/purse/views/SeasonalView.tsx
src/css/purse/PurseView.css</div>
</div>
</section>
<section class="section">
<div class="section-head">
<h2>Toolbar</h2>
<p>Barra bassa attuale con area me, icone centrali e blocco friend/message.</p>
</div>
<div class="section-body">
<div class="stage">
<div class="toolbar">
<div class="toolbar-left">
<div class="toolbar-me"></div>
<div class="toolbar-icon"></div>
<div class="toolbar-icon"></div>
<div class="toolbar-icon"></div>
<div class="toolbar-icon"></div>
</div>
<div style="flex:1; height:34px; border-radius:8px; background:rgba(255,255,255,.07);"></div>
<div class="toolbar-right">
<div class="toolbar-icon"></div>
<div class="toolbar-icon"></div>
</div>
</div>
</div>
<div class="paths">Source files:
src/components/toolbar/ToolbarView.tsx
src/components/toolbar/ToolbarItemView.tsx
src/components/toolbar/ToolbarMeView.tsx</div>
</div>
</section>
<section class="section">
<div class="section-head">
<h2>Navigator</h2>
<p>Finestra navigator attuale con card base, search e footer actions.</p>
</div>
<div class="section-body">
<div class="stage">
<div class="navigator">
<div class="nitro-card-header">
<div class="nitro-card-header-text">Navigator</div>
<div class="ubuntu-close-button"></div>
</div>
<div class="nitro-card-tabs">
<div class="nitro-card-tab active">Saved</div>
<div class="nitro-card-tab">Rooms</div>
<div class="nitro-card-tab">+</div>
</div>
<div class="navigator-body">
<div class="navigator-search"></div>
<div class="navigator-results">
<div class="navigator-result"></div>
<div class="navigator-result"></div>
<div class="navigator-result"></div>
</div>
<div class="navigator-footer">
<div class="nav-action"></div>
<div class="nav-action" style="background:linear-gradient(180deg,#64a86b,#41794c);"></div>
</div>
</div>
</div>
</div>
<div class="paths">Source files:
src/components/navigator/NavigatorView.tsx
src/components/navigator/NavigatorView.scss
src/css/room/NavigatorRoomSettings.css</div>
</div>
</section>
<section class="section">
<div class="section-head">
<h2>Notifications</h2>
<p>Bubble attuale con fondo scuro e inner shadow.</p>
</div>
<div class="section-body">
<div class="stage">
<div class="notification-bubble">
Hai ricevuto una nuova notifica. Questo box rappresenta lo stato attuale delle bubble notifications.
</div>
</div>
<div class="paths">Source files:
src/components/notification-center/NotificationCenterView.tsx
src/css/notification/NotificationCenterView.css</div>
</div>
</section>
<section class="section">
<div class="section-head">
<h2>Friends</h2>
<p>Barra amici e blocchi friend pill attuali.</p>
</div>
<div class="section-body">
<div class="stage">
<div class="friends-bar">
<div class="friend-pill"></div>
<div class="friend-pill"></div>
<div class="friend-pill"></div>
<div class="friend-pill"></div>
</div>
</div>
<div class="paths">Source files:
src/components/friends/FriendsView.tsx
src/css/friends/FriendsView.css</div>
</div>
</section>
<section class="section">
<div class="section-head">
<h2>HotelView</h2>
<p>Mockup della scena hotel attuale con sfondo e hotspot.</p>
</div>
<div class="section-body">
<div class="stage">
<div class="hotel-view">
<div class="hotel-slab"></div>
<div class="hotel-hotspot a"></div>
<div class="hotel-hotspot b"></div>
<div class="hotel-hotspot c"></div>
</div>
</div>
<div class="paths">Source files:
src/components/hotel-view/HotelView.tsx
src/css/hotelview/HotelView.css</div>
</div>
</section>
</div>
</body>
</html>
+3 -2
View File
@@ -4,8 +4,9 @@
"homepage": ".",
"private": true,
"scripts": {
"start": "vite --base=/client/ --host",
"build": "vite --base=/client/ build",
"prebuild": "node scripts/write-asset-loader.mjs",
"start": "vite --host",
"build": "vite build && node scripts/minify-dist.mjs",
"build:prod": "npx browserslist@latest --update-db && yarn build",
"eslint": "eslint ./src"
},
-174
View File
@@ -1,174 +0,0 @@
{
"friendlist.search": "Search friends",
"purse.seasonal.currency.101": "cash",
"widget.chooser.checkall": "Select furniture",
"widget.chooser.btn.pickall": "pick up selected items!",
"wiredfurni.params.requireall.2": "If one of the selected furni has an avatar",
"wiredfurni.params.requireall.3": "If all selected furni have avatars on them",
"widget.settings.general": "General",
"widget.settings.general.title": "Adjust the default Nitro settings",
"widget.settings.volume": "Volume",
"widget.settings.interface": "Interface",
"widget.settings.interface.title": "Adjust the interface settings",
"widget.settings.interface.fps.automatic": "Set FPS to unlimited",
"widget.settings.interface.fps.warning": "Setting FPS to unlimited may cause performance issues!",
"widget.settings.interface.secondary": "Change the window header color",
"widget.settings.interface.reset": "Reset header color to default",
"widget.room.chat.hide_pets": "Hide pets",
"widget.room.chat.hide_avatars": "Hide avatars",
"widget.room.chat.hide_balloon": "Hide speech bubble",
"widget.room.chat.show_balloon": "Speech bubble",
"widget.room.chat.clear_history": "clear history",
"widget.room.youtube.shared": "YouTube is being shared",
"widget.room.youtube.open_video": "Open the video",
"wiredfurni.tooltip.select.tile": "Select tile",
"wiredfurni.tooltip.remove.tile": "Deselect tile",
"wiredfurni.tooltip.remove.5x5_tile": "select 5x5 tiles",
"wiredfurni.tooltip.remove.clear_tile": "Clear all selections",
"wiredfurni.params.furni_neighborhood.group.user": "Players",
"wiredfurni.params.furni_neighborhood.group.furni": "Furniture",
"wiredfurni.params.selector_option.bot": "No bots",
"wiredfurni.params.selector_option.pet": "No pets",
"catalog.title": "Catalog",
"catalog.favorites": "Favorites",
"catalog.favorites.pages": "Pages",
"catalog.favorites.furni": "Furni",
"catalog.favorites.empty": "No favorites",
"catalog.favorites.empty.hint": "Click the heart on furni or the star on pages to add them.",
"catalog.admin": "Admin",
"catalog.admin.new": "New",
"catalog.admin.root": "Root",
"catalog.admin.new.root.category": "New root category",
"catalog.admin.edit.root": "Edit Root",
"catalog.admin.edit": "Edit:",
"catalog.admin.edit.page": "Edit Page",
"catalog.admin.hidden": "hidden",
"catalog.admin.edit.title": "Edit \"%name%\"",
"catalog.admin.show": "Show",
"catalog.admin.hide": "Hide",
"catalog.admin.delete": "Delete",
"catalog.admin.delete.title": "Delete \"%name%\"",
"catalog.admin.delete.category.confirm": "Delete category \"%name%\" and all its content?",
"catalog.admin.delete.page": "Delete page",
"catalog.admin.delete.page.confirm": "Delete page \"%name%\"?",
"catalog.admin.delete.offer.confirm": "Are you sure you want to delete this offer?",
"catalog.admin.create": "Create",
"catalog.admin.save": "Save",
"catalog.admin.create.subpage": "Create sub-page",
"catalog.admin.order": "Order",
"catalog.admin.visible": "Visible",
"catalog.admin.enabled": "Enabled",
"catalog.admin.offer.new": "New Offer",
"catalog.admin.offer.edit": "Edit Offer",
"catalog.admin.offer.name": "Catalog Name",
"catalog.admin.offer.general": "General",
"catalog.admin.offer.quantity": "Quantity",
"catalog.admin.offer.prices": "Prices",
"catalog.admin.offer.credits": "Credits",
"catalog.admin.offer.points": "Points",
"catalog.admin.offer.points.type": "Points Type",
"catalog.admin.offer.options": "Options",
"catalog.admin.offer.club.only": "Club Only",
"catalog.admin.offer.extradata": "Extra Data (optional)....",
"catalog.admin.offer.have.offer": "Multi-discount (have_offer)",
"catalog.trophies.title": "Trophies",
"catalog.trophies.write.hint": "Write a text for the trophy before purchasing",
"catalog.trophies.inscription": "Trophy Inscription",
"catalog.trophies.inscription.placeholder": "Write the text that will appear on the trophy...",
"catalog.pets.show.colors": "Show colors",
"catalog.pets.choose.color": "Choose color",
"catalog.pets.choose.breed": "Choose breed",
"catalog.pets.back.breeds": "← Breeds",
"catalog.prefix.text": "Text",
"catalog.prefix.text.placeholder": "Enter text...",
"catalog.prefix.icon": "Icon",
"catalog.prefix.icon.remove": "Remove icon",
"catalog.prefix.effect": "Effect",
"catalog.prefix.color": "Color",
"catalog.prefix.color.single": "🎨 Single",
"catalog.prefix.color.per.letter": "🌈 Per Letter",
"catalog.prefix.color.hint": "Select a letter, then choose the color. Auto-advances.",
"catalog.prefix.color.apply.all.title": "Apply current color to all letters",
"catalog.prefix.color.apply.all": "Apply to all",
"catalog.prefix.color.selected": "Selected letter:",
"catalog.prefix.price": "Price:",
"catalog.prefix.price.amount": "5 Credits",
"catalog.prefix.purchased": "✓ Purchased!",
"catalog.prefix.purchase": "Purchase",
"groupforum.list.tab.most_active": "Most active threads",
"groupforum.list.tab.my_forums": "My group forums",
"groupforum.list.no_forums": "There are no forums",
"groupforum.view.threads": "Number of threads",
"groupforum.thread.pin": "Pin thread",
"groupforum.thread.unpin": "Unpin thread",
"groupforum.thread.lock": "Lock thread",
"groupforum.thread.unlock": "Unlock thread",
"groupforum.thread.hide": "Hide thread",
"groupforum.thread.restore": "Restore thread",
"groupforum.thread.delete": "Delete thread + posts",
"groupforum.message.hide": "Hide message",
"group.forum.enable.caption": "Enable / Disable group forum",
"group.forum.enable.help": "If you disable the group forum, all posts will also be deleted!",
"groupforum.view.no_threads": "There are currently no active threads",
"login.username": "Name of your Habbo",
"login.forgot_password": "Forgotten your password?",
"nitro.login.firsttime.title": "First time here?",
"nitro.login.firsttime.text": "Don't have a Habbo yet?",
"nitro.login.firsttime.link": "You can create one here",
"nitro.login.card.title": "What's your Habbo called?",
"nitro.login.server.offline.short": "The gameserver isn't running right now. Please try again in a moment.",
"nitro.login.server.offline.long": "The gameserver isn't running right now, so new accounts can't be created. Please try again in a moment.",
"nitro.login.server.checking": "Checking…",
"nitro.login.server.retry": "Retry",
"nitro.login.register.title": "Habbo Details",
"nitro.login.register.next": "Next",
"nitro.login.register.finish": "Finish",
"nitro.login.register.creating": "Creating…",
"nitro.login.register.intro.credentials": "Let's create your account. Enter your email and pick a password — we'll check that email isn't already in use.",
"nitro.login.register.intro.avatar": "Now it's time to make your own Habbo character! To make your own Habbo, please start by choosing your Habbo Name.",
"nitro.login.register.intro.room": "Last step — pick a starter room, or skip and create your own later.",
"nitro.login.register.confirm.label": "Confirm password",
"nitro.login.register.username.placeholder": "HabboName",
"nitro.login.register.hotlooks.count": "%count% looks available",
"nitro.login.register.hotlooks.none": "No hot looks loaded",
"nitro.login.register.room.skip.title": "I'm okay — I'll create my own rooms",
"nitro.login.register.room.skip.description": "Skip for now and start with an empty hotel inventory.",
"nitro.login.register.room.loading": "Loading rooms…",
"nitro.login.register.room.error": "Could not load room options. You can still skip this step.",
"nitro.login.register.success": "Welcome aboard, %username%! Your account is ready — log in below with the password you just chose.",
"nitro.login.forgot.title": "Reset password",
"nitro.login.forgot.email.label": "Email address",
"nitro.login.forgot.send": "Send email",
"nitro.login.forgot.success": "Email sent! If an account matches that address you'll find a reset link in your inbox shortly (check spam if it doesn't show up within a minute).",
"nitro.login.error.missing_credentials": "Please enter both your Habbo name and password.",
"nitro.login.error.invalid_credentials": "Invalid Habbo name or password.",
"nitro.login.error.too_many_attempts": "Too many attempts. Try again in %seconds%s.",
"nitro.login.error.turnstile": "Please complete the security check.",
"nitro.login.error.server_offline": "The gameserver is not running. Please try again later.",
"nitro.login.error.login_unreachable": "Unable to reach the login service. Please try again.",
"nitro.login.error.register_failed": "Unable to create your account.",
"nitro.login.error.register_unreachable": "Unable to reach the registration service.",
"nitro.login.error.forgot_failed": "Unable to send a reset email right now.",
"nitro.login.error.forgot_unreachable": "Unable to reach the password reset service.",
"nitro.login.error.missing_fields": "Please fill in every field.",
"nitro.login.error.invalid_email": "Please enter a valid email address.",
"nitro.login.error.password_too_short": "Your password must be at least 8 characters.",
"nitro.login.error.password_mismatch": "Passwords do not match.",
"nitro.login.error.email_taken": "This email is already in use.",
"nitro.login.error.missing_username": "Please choose a Habbo name.",
"nitro.login.error.username_length": "Habbo name must be 316 characters.",
"nitro.login.error.username_taken": "This Habbo name is already taken.",
"nitro.login.error.missing_email": "Please enter your email address.",
"inventory.effects.activate": "Use selected effect"
}
}
+116
View File
@@ -0,0 +1,116 @@
{
"notification.badge.received": "Nuovo Distintivo!",
"wiredfurni.badgereceived.title": "Distintivo ricevuto!",
"wiredfurni.badgereceived.body": "Hai appena ricevuto un nuovo Distintivo! Controlla nel tuo Inventario!",
"friendlist.search": "Search friends",
"purse.seasonal.currency.101": "cash",
"widget.chooser.checkall": "Select furniture",
"widget.chooser.btn.pickall": "pick up selected items!",
"wiredfurni.params.requireall.2": "If one of the selected furni has an avatar",
"wiredfurni.params.requireall.3": "If all selected furni have avatars on them",
"widget.settings.general": "General",
"widget.settings.general.title": "Adjust the default Nitro settings",
"widget.settings.volume": "Volume",
"widget.settings.interface": "Interface",
"widget.settings.interface.title": "Adjust the interface settings",
"widget.settings.interface.fps.automatic": "Set FPS to unlimited",
"widget.settings.interface.fps.warning": "Setting FPS to unlimited may cause performance issues!",
"widget.settings.interface.secondary": "Change the window header color",
"widget.settings.interface.reset": "Reset header color to default",
"widget.room.chat.hide_pets": "Hide pets",
"widget.room.chat.hide_avatars": "Hide avatars",
"widget.room.chat.hide_balloon": "Hide speech bubble",
"widget.room.chat.show_balloon": "Speech bubble",
"widget.room.chat.clear_history": "clear history",
"widget.room.youtube.shared": "YouTube is being shared",
"widget.room.youtube.open_video": "Open the video",
"wiredfurni.tooltip.select.tile": "Select tile",
"wiredfurni.tooltip.remove.tile": "Deselect tile",
"wiredfurni.tooltip.remove.5x5_tile": "select 5x5 tiles",
"wiredfurni.tooltip.remove.clear_tile": "Clear all selections",
"wiredfurni.params.furni_neighborhood.group.user": "Players",
"wiredfurni.params.furni_neighborhood.group.furni": "Furniture",
"wiredfurni.params.selector_option.bot": "No bots",
"wiredfurni.params.selector_option.pet": "No pets",
"catalog.title": "Catalog",
"catalog.favorites": "Favorites",
"catalog.favorites.pages": "Pages",
"catalog.favorites.furni": "Furni",
"catalog.favorites.empty": "No favorites",
"catalog.favorites.empty.hint": "Click the heart on furni or the star on pages to add them.",
"catalog.admin": "Admin",
"catalog.admin.new": "New",
"catalog.admin.root": "Root",
"catalog.admin.new.root.category": "New root category",
"catalog.admin.edit.root": "Edit Root",
"catalog.admin.edit": "Edit:",
"catalog.admin.edit.page": "Edit Page",
"catalog.admin.hidden": "hidden",
"catalog.admin.edit.title": "Edit \"%name%\"",
"catalog.admin.show": "Show",
"catalog.admin.hide": "Hide",
"catalog.admin.delete": "Delete",
"catalog.admin.delete.title": "Delete \"%name%\"",
"catalog.admin.delete.category.confirm": "Delete category \"%name%\" and all its content?",
"catalog.admin.delete.page": "Delete page",
"catalog.admin.delete.page.confirm": "Delete page \"%name%\"?",
"catalog.admin.delete.offer.confirm": "Are you sure you want to delete this offer?",
"catalog.admin.create": "Create",
"catalog.admin.save": "Save",
"catalog.admin.create.subpage": "Create sub-page",
"catalog.admin.order": "Order",
"catalog.admin.visible": "Visible",
"catalog.admin.enabled": "Enabled",
"catalog.admin.offer.new": "New Offer",
"catalog.admin.offer.edit": "Edit Offer",
"catalog.admin.offer.name": "Catalog Name",
"catalog.admin.offer.general": "General",
"catalog.admin.offer.quantity": "Quantity",
"catalog.admin.offer.prices": "Prices",
"catalog.admin.offer.credits": "Credits",
"catalog.admin.offer.points": "Points",
"catalog.admin.offer.points.type": "Points Type",
"catalog.admin.offer.options": "Options",
"catalog.admin.offer.club.only": "Club Only",
"catalog.admin.offer.extradata": "Extra Data (optional)....",
"catalog.admin.offer.have.offer": "Multi-discount (have_offer)",
"catalog.trophies.title": "Trophies",
"catalog.trophies.write.hint": "Write a text for the trophy before purchasing",
"catalog.trophies.inscription": "Trophy Inscription",
"catalog.trophies.inscription.placeholder": "Write the text that will appear on the trophy...",
"catalog.pets.show.colors": "Show colors",
"catalog.pets.choose.color": "Choose color",
"catalog.pets.choose.breed": "Choose breed",
"catalog.pets.back.breeds": "? Breeds",
"catalog.prefix.text": "Text",
"catalog.prefix.text.placeholder": "Enter text...",
"catalog.prefix.icon": "Icon",
"catalog.prefix.icon.remove": "Remove icon",
"catalog.prefix.effect": "Effect",
"catalog.prefix.color": "Color",
"catalog.prefix.color.single": "?? Single",
"catalog.prefix.color.per.letter": "?? Per Letter",
"catalog.prefix.color.hint": "Select a letter, then choose the color. Auto-advances.",
"catalog.prefix.color.apply.all.title": "Apply current color to all letters",
"catalog.prefix.color.apply.all": "Apply to all",
"catalog.prefix.color.selected": "Selected letter:",
"catalog.prefix.price": "Price:",
"catalog.prefix.price.amount": "5 Credits",
"catalog.prefix.purchased": "? Purchased!",
"catalog.prefix.purchase": "Purchase",
"groupforum.list.tab.most_active": "Most active threads",
"groupforum.list.tab.my_forums": "My group forums",
"groupforum.list.no_forums": "There are no forums",
"groupforum.view.threads": "Number of threads",
"groupforum.thread.pin": "Pin thread",
"groupforum.thread.unpin": "Unpin thread",
"groupforum.thread.lock": "Lock thread",
"groupforum.thread.unlock": "Unlock thread",
"groupforum.thread.hide": "Hide thread",
"groupforum.thread.restore": "Restore thread",
"groupforum.thread.delete": "Delete thread + posts",
"groupforum.message.hide": "Hide message",
"group.forum.enable.caption": "Enable / Disable group forum",
"group.forum.enable.help": "If you disable the group forum, all posts will also be deleted!",
"groupforum.view.no_threads": "There are currently no active threads"
}
+337
View File
@@ -0,0 +1,337 @@
(() => {
const ASSET_KEY = new TextEncoder().encode("slogga-dist-assets-2026");
const MODE_DEFAULTS = {
distObfuscationEnabled: false,
secureAssetsEnabled: false,
secureApiEnabled: false
};
const isDebug = () => {
try {
const search = new URLSearchParams(location.search);
return search.get("loaderDebug") === "1" || localStorage.getItem("nitro.loader.debug") === "1";
} catch {
return false;
}
};
const debug = (message) => {
try {
window.__nitroLoaderDebug = message;
const log = Array.isArray(window.__nitroLoaderDebugLog) ? window.__nitroLoaderDebugLog : [];
log.push(message);
window.__nitroLoaderDebugLog = log.slice(-30);
if(!isDebug()) {
document.getElementById("nitro-loader-debug")?.remove();
return;
}
let node = document.getElementById("nitro-loader-debug");
if(!node) {
node = document.createElement("div");
node.id = "nitro-loader-debug";
node.style.cssText = "position:fixed;left:8px;top:8px;z-index:2147483647;padding:6px 8px;max-width:70vw;background:rgba(0,0,0,.85);color:#fff;font:12px monospace;white-space:pre-wrap";
document.body.appendChild(node);
}
node.textContent = window.__nitroLoaderDebugLog.slice(-10).join("\n");
} catch {}
};
const getBase = () => {
if(typeof window.__nitroLoaderBase === "string" && window.__nitroLoaderBase) {
try { return new URL(window.__nitroLoaderBase); } catch {}
}
const source = document.currentScript?.src || location.href;
return new URL(".", source);
};
const withCacheBust = (url) => {
url.searchParams.set("v", Date.now().toString(36));
return url;
};
const renderShell = () => {
const root = document.getElementById("root");
if(!root || root.firstChild) return;
root.innerHTML = '<div style="position:fixed;inset:0;background:linear-gradient(180deg,#6eadc8 0%,#78b7cf 45%,#8ec4d7 100%);overflow:hidden;z-index:1"><div style="position:absolute;left:0;top:0;width:220px;height:220px;background:linear-gradient(135deg,rgba(255,255,255,.18),rgba(255,255,255,0));clip-path:polygon(0 0,100% 0,0 100%)"></div><div style="position:absolute;right:0;bottom:0;width:32vw;max-width:420px;height:100%;background:linear-gradient(270deg,rgba(255,255,255,.16),rgba(255,255,255,0))"></div><div style="position:absolute;top:50%;right:8vw;transform:translateY(-50%);display:flex;flex-direction:column;gap:18px;width:260px"><div style="height:86px;background:#a2bfd1;border:2px solid #3f6a85;border-radius:8px;box-shadow:inset 0 2px rgba(255,255,255,.35),0 4px 6px rgba(0,0,0,.25)"></div><div style="height:190px;background:#a2bfd1;border:2px solid #3f6a85;border-radius:8px;box-shadow:inset 0 2px rgba(255,255,255,.35),0 4px 6px rgba(0,0,0,.25)"></div></div></div>';
};
const decodeAsset = (bytes) => {
const output = new Uint8Array(bytes.length);
for(let index = 0; index < bytes.length; index++) {
output[index] = bytes[index] ^ ASSET_KEY[index % ASSET_KEY.length] ^ ((index * 31) & 255);
}
return output;
};
const gunzip = async (bytes) => {
if(!("DecompressionStream" in self)) throw new Error("gzip decompression unsupported");
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
return new Uint8Array(await new Response(stream).arrayBuffer());
};
const resolveAssetCandidates = (path) => {
const base = getBase();
const normalized = path.replace(/^\.\//, "");
const file = normalized.split("/").pop();
const urls = [
new URL("./src/assets/" + file, base),
new URL("./assets/" + file, base),
new URL("/src/assets/" + file, base.origin),
new URL("/assets/" + file, base.origin),
new URL("/client/src/assets/" + file, base.origin),
new URL("/client/assets/" + file, base.origin)
];
return [...new Map(urls.map(url => [url.href, url])).values()];
};
const expandAssetCandidates = (path) => {
const base = getBase();
if(/^https?:\/\//i.test(path)) return [new URL(path)];
if(path.startsWith("/")) return [new URL(path, base.origin + "/")];
return resolveAssetCandidates(path);
};
const fetchBytes = async (path) => {
let error = null;
debug("loader: fetching " + path);
for(const candidate of expandAssetCandidates(path)) {
try {
debug("loader: try " + candidate.href);
const response = await fetch(withCacheBust(candidate), { cache: "no-store" });
if(!response.ok) {
error = new Error("asset " + candidate.pathname + " " + response.status);
continue;
}
debug("loader: ok " + candidate.href);
return new Uint8Array(await response.arrayBuffer());
} catch(caught) {
error = caught;
}
}
throw error || new Error("asset " + path + " not found");
};
const loadDatAsset = async (path) => gunzip(decodeAsset(await fetchBytes(path)));
const injectCssText = (bytes) => {
const node = document.createElement("style");
node.textContent = new TextDecoder().decode(bytes);
document.head.appendChild(node);
debug("loader: css injected from dat");
};
const matchesContentType = (contentType, accepted) => {
if(!contentType) return true;
return accepted.some(token => contentType.indexOf(token) !== -1);
};
const probePlainAsset = async (path, accepted) => {
let lastError = null;
for(const candidate of expandAssetCandidates(path)) {
try {
debug("loader: probe " + candidate.href);
const response = await fetch(withCacheBust(candidate), { cache: "no-store" });
if(!response.ok) {
lastError = new Error("asset " + candidate.pathname + " " + response.status);
continue;
}
const contentType = (response.headers.get("content-type") || "").toLowerCase();
if(!matchesContentType(contentType, accepted)) {
lastError = new Error("asset " + candidate.pathname + " wrong type " + contentType);
continue;
}
debug("loader: probe ok " + candidate.href);
const url = new URL(candidate.href);
url.searchParams.set("v", Date.now().toString(36));
return url;
} catch(caught) {
lastError = caught;
}
}
throw lastError || new Error("asset " + path + " not found");
};
const loadPlainCss = async (path) => {
const href = await probePlainAsset(path, ["text/css"]);
await new Promise((resolve, reject) => {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = href.href;
link.onload = () => resolve();
link.onerror = () => reject(new Error("plain css failed"));
document.head.appendChild(link);
});
debug("loader: css linked");
};
const importBytes = async (bytes) => {
const blobUrl = URL.createObjectURL(new Blob([bytes], { type: "text/javascript" }));
try {
debug("loader: importing app blob");
await import(blobUrl);
debug("loader: app blob imported");
} finally {
URL.revokeObjectURL(blobUrl);
}
};
const importPlainJs = async (path) => {
const href = await probePlainAsset(path, ["javascript", "ecmascript"]);
debug("loader: importing plain js " + href.href);
await import(href.href);
debug("loader: plain js imported");
};
const readClientMode = async () => {
try {
if(window.__nitroClientMode && typeof window.__nitroClientMode === "object") {
debug("loader: client-mode preset");
return window.__nitroClientMode;
}
const url = withCacheBust(new URL("./client-mode.json", getBase()));
const response = await fetch(url, { cache: "no-store" });
if(!response.ok) throw new Error("client-mode " + response.status);
const payload = await response.json();
const mode = { ...MODE_DEFAULTS, ...(payload && typeof payload === "object" ? payload : {}) };
window.__nitroClientMode = mode;
debug("loader: client-mode loaded");
return mode;
} catch(error) {
window.__nitroClientMode = { ...MODE_DEFAULTS };
debug("loader: client-mode fallback " + (error?.message || error));
return window.__nitroClientMode;
}
};
const fetchManifest = async () => {
const base = getBase();
const candidates = [
new URL(".vite/manifest.json", base.origin + "/"),
new URL("manifest.json", base.origin + "/"),
new URL(".vite/manifest.json", base),
new URL("manifest.json", base)
];
const seen = new Set();
for(const candidate of candidates) {
if(seen.has(candidate.href)) continue;
seen.add(candidate.href);
try {
const response = await fetch(withCacheBust(new URL(candidate.href)), { cache: "no-store" });
if(!response.ok) continue;
const json = await response.json();
if(json && typeof json === "object") {
debug("loader: manifest from " + candidate.href);
return { manifest: json, base: new URL(".", candidate.href) };
}
} catch {}
}
return null;
};
const findEntryFromManifest = (manifest) => {
let bootstrap = null;
for(const key of Object.keys(manifest)) {
const entry = manifest[key];
if(!entry || typeof entry !== "object" || !entry.isEntry) continue;
if(/bootstrap\./.test(key) || /bootstrap\./.test(entry.file || "")) {
bootstrap = entry;
break;
}
if(!bootstrap) bootstrap = entry;
}
if(!bootstrap) return null;
const css = Array.isArray(bootstrap.css) ? bootstrap.css.slice() : [];
return { js: bootstrap.file, css };
};
const resolveManifestPath = (manifestBase, file) => {
if(/^https?:\/\//i.test(file)) return file;
if(file.startsWith("/")) return file;
return new URL(file, manifestBase.origin + "/").pathname;
};
const isLoaderUrl = (href) => /(?:^|\/)bootstrap\.js(?:$|\?|#)/i.test(href) || /(?:^|\/)asset-loader\.js(?:$|\?|#)/i.test(href);
const fetchEntryFromIndexHtml = async () => {
const base = getBase();
const candidates = [
new URL("/index.html", base.origin + "/"),
new URL("/", base.origin + "/")
];
for(const candidate of candidates) {
try {
const response = await fetch(withCacheBust(new URL(candidate.href)), { cache: "no-store" });
if(!response.ok) continue;
const contentType = (response.headers.get("content-type") || "").toLowerCase();
if(contentType && contentType.indexOf("html") === -1) continue;
const html = await response.text();
const doc = new DOMParser().parseFromString(html, "text/html");
if(!doc) continue;
const resolveAttr = (raw) => {
if(!raw) return "";
if(/^https?:\/\//i.test(raw)) return raw;
try { return new URL(raw, candidate.href).pathname; }
catch { return raw; }
};
const scriptNode = Array.from(doc.querySelectorAll('script[type="module"][src]'))
.map(node => node.getAttribute("src") || "")
.find(src => src && !isLoaderUrl(src));
if(!scriptNode) continue;
const cssNodes = Array.from(doc.querySelectorAll('link[rel="stylesheet"][href]'))
.map(node => node.getAttribute("href") || "")
.filter(href => href && !isLoaderUrl(href));
const jsAbs = resolveAttr(scriptNode);
const cssAbs = cssNodes.map(resolveAttr);
debug("loader: entry from index.html " + jsAbs);
return { js: jsAbs, css: cssAbs };
} catch {}
}
return null;
};
(async () => {
debug("loader: start");
renderShell();
const mode = await readClientMode();
let jsPath = null;
let cssPaths = [];
const manifestResult = await fetchManifest();
if(manifestResult) {
const entry = findEntryFromManifest(manifestResult.manifest);
if(entry) {
jsPath = resolveManifestPath(manifestResult.base, entry.js);
if(entry.css.length) cssPaths = entry.css.map(file => resolveManifestPath(manifestResult.base, file));
debug("loader: entry from manifest " + jsPath);
}
}
if(!jsPath) {
const indexEntry = await fetchEntryFromIndexHtml();
if(indexEntry) {
jsPath = indexEntry.js;
if(indexEntry.css.length) cssPaths = indexEntry.css;
}
}
if(!jsPath) {
jsPath = "./assets/app.js";
cssPaths = ["./assets/app.css"];
debug("loader: entry fallback to app.js/app.css");
}
if(mode.distObfuscationEnabled) {
const [cssBytesList, jsBytes] = await Promise.all([
Promise.all(cssPaths.map(path => loadDatAsset(path + ".dat"))),
loadDatAsset(jsPath + ".dat")
]);
cssBytesList.forEach(bytes => injectCssText(bytes));
await importBytes(jsBytes);
return;
}
for(const css of cssPaths) await loadPlainCss(css);
await importPlainJs(jsPath);
})().catch(error => {
console.error(error);
debug("loader: failed " + (error?.message || error));
document.body.textContent = "Unable to load client.";
});
})();
+160
View File
@@ -0,0 +1,160 @@
(() => {
const FALLBACK_API_BASE = "";
const getBase = () => {
const source = document.currentScript?.src || location.href;
return new URL(".", source);
};
const LOADER_BASE = getBase();
window.__nitroLoaderBase = LOADER_BASE.href;
const withCacheBust = (url) => {
url.searchParams.set("v", Date.now().toString(36));
return url;
};
const bytesToBase64 = (buffer) => {
let binary = "";
const bytes = new Uint8Array(buffer);
for(let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
};
const hexValue = (code) => {
if(code >= 48 && code <= 57) return code - 48;
if(code >= 65 && code <= 70) return code - 55;
if(code >= 97 && code <= 102) return code - 87;
return -1;
};
const hexToBytes = (hex) => {
const normalized = hex.trim();
if((normalized.length % 2) !== 0) throw new Error("Invalid encrypted hex payload.");
const bytes = new Uint8Array(normalized.length / 2);
for(let i = 0; i < bytes.length; i++) {
const high = hexValue(normalized.charCodeAt(i * 2));
const low = hexValue(normalized.charCodeAt((i * 2) + 1));
if(high < 0 || low < 0) throw new Error("Invalid encrypted hex payload.");
bytes[i] = (high << 4) | low;
}
return bytes;
};
const deriveAesKey = async (privateKey, serverKeyBase64) => {
const serverBytes = Uint8Array.from(atob(serverKeyBase64), char => char.charCodeAt(0));
const serverKey = await crypto.subtle.importKey("spki", serverBytes, { name: "ECDH", namedCurve: "P-256" }, false, []);
const secret = await crypto.subtle.deriveBits({ name: "ECDH", public: serverKey }, privateKey, 256);
const salt = new TextEncoder().encode("nitro-secure-assets-v1");
const material = new Uint8Array(secret.byteLength + salt.length);
material.set(new Uint8Array(secret), 0);
material.set(salt, secret.byteLength);
const hash = await crypto.subtle.digest("SHA-256", material);
return crypto.subtle.importKey("raw", hash, "AES-GCM", false, ["decrypt"]);
};
const decryptPayload = async (key, response) => {
if(response.headers.get("X-Nitro-Sec") !== "1") return response.text();
const bytes = hexToBytes(await response.text());
if(bytes.length < 13) throw new Error("Encrypted response is too short.");
const iv = bytes.slice(0, 12);
const payload = bytes.slice(12);
const clear = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, payload);
return new TextDecoder().decode(clear);
};
const importTextModule = async (sourceText) => {
const blobUrl = URL.createObjectURL(new Blob([sourceText], { type: "text/javascript" }));
try {
await import(blobUrl);
} finally {
URL.revokeObjectURL(blobUrl);
}
};
const fetchPlainClientMode = async () => {
try {
const url = withCacheBust(new URL("./client-mode.json", LOADER_BASE));
const response = await fetch(url, { cache: "no-store" });
if(!response.ok) throw new Error("HTTP " + response.status);
const payload = await response.json();
if(payload && typeof payload === "object") {
window.__nitroClientMode = payload;
return payload;
}
} catch(error) {
console.warn("[Nitro] client-mode fetch failed:", error?.message || error);
}
return null;
};
const loadPlainBootstrap = async () => {
const url = withCacheBust(new URL("./asset-loader.js", LOADER_BASE));
await import(url.href);
};
const loadSecureBootstrap = async (apiBase) => {
if(!apiBase) throw new Error("Missing apiBaseUrl for secure bootstrap.");
const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
const publicKeyBuffer = await crypto.subtle.exportKey("spki", pair.publicKey);
const publicKey = bytesToBase64(publicKeyBuffer);
const base = apiBase.replace(/\/$/, "");
const bootstrapResponse = await fetch(base + "/nitro-sec/bootstrap", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: publicKey })
});
if(!bootstrapResponse.ok) throw new Error("Secure bootstrap failed: HTTP " + bootstrapResponse.status);
const bootstrapPayload = await bootstrapResponse.json();
if(!bootstrapPayload || typeof bootstrapPayload.key !== "string" || !bootstrapPayload.key.length) {
throw new Error("Secure bootstrap returned an invalid server key.");
}
const sessionKey = await deriveAesKey(pair.privateKey, bootstrapPayload.key);
const fetchSecureConfig = async (file) => {
const url = new URL(base + "/nitro-sec/file");
url.searchParams.set("kind", "config");
url.searchParams.set("file", file);
url.searchParams.set("v", Date.now().toString(36));
const response = await fetch(url.toString(), {
headers: { "X-Nitro-Key": publicKey },
cache: "no-store"
});
if(!response.ok) throw new Error("Failed to load secure config " + file + ": HTTP " + response.status);
return decryptPayload(sessionKey, response);
};
const modeText = await fetchSecureConfig("client-mode.json");
window.__nitroClientMode = JSON.parse(modeText);
const loaderText = await fetchSecureConfig("asset-loader.js");
await importTextModule(loaderText);
};
(async () => {
const mode = await fetchPlainClientMode();
const wantsSecure = !!(mode && mode.secureAssetsEnabled);
const apiBase = (mode && typeof mode.apiBaseUrl === "string" && mode.apiBaseUrl) || FALLBACK_API_BASE;
if(wantsSecure) {
try {
await loadSecureBootstrap(apiBase);
return;
} catch(error) {
console.warn("[Nitro] Secure bootstrap fallback:", error?.message || error);
}
}
await loadPlainBootstrap();
})().catch(error => {
console.error(error);
document.body.textContent = "Unable to load client.";
});
})();
+8
View File
@@ -0,0 +1,8 @@
{
"distObfuscationEnabled": true,
"secureAssetsEnabled": true,
"secureApiEnabled": true,
"apiBaseUrl": "https://nitro.example.com:2096",
"plainConfigBaseUrl": "https://hotel.example.com/configuration/",
"plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/"
}
+12
View File
@@ -0,0 +1,12 @@
{
"news": [
{
"id": 1,
"title": "Welcome to Nitro",
"body": "This news entry is loaded from configuration/news.json.",
"image": "${image.library.url}web_promo_small/spromo_Canal_Bundle.png",
"link": "",
"linkText": "Read more"
}
]
}
@@ -0,0 +1,610 @@
{
"socket.url": "wss://nitro.example.com:2096",
"api.url": "https://nitro.example.com:2096",
"asset.url": "https://hotel.example.com/client/nitro/bundled",
"image.library.url": "https://hotel.example.com/client/c_images/",
"hof.furni.url": "https://hotel.example.com/client/c_images/dcr/hof_furni",
"images.url": "https://hotel.example.com/client/nitro/images",
"gamedata.url": "https://nitro.example.com:2096/nitro-sec/file?kind=gamedata&file=",
"sounds.url": "${asset.url}/sounds/%sample%.mp3",
"external.texts.url": [
"${gamedata.url}/ExternalTexts.json",
"${gamedata.url}/UITexts.json"
],
"external.texts.translation.url": "${gamedata.url}/text_translate/ExternalTexts_%locale%.json?t=%timestamp%",
"external.samples.url": "${hof.furni.url}/mp3/sound_machine_sample_%sample%.mp3",
"furnidata.url": "${gamedata.url}/FurnitureData.json?t=%timestamp%",
"furnidata.translation.url": "${gamedata.url}/furniture_translate/FurnitureData_%locale%.json?t=%timestamp%",
"productdata.url": "${gamedata.url}/ProductData.json?t=%timestamp%",
"avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?t=%timestamp%",
"avatar.figuredata.url": "${gamedata.url}/FigureData.json?t=%timestamp%",
"avatar.figuremap.url": "${gamedata.url}/FigureMap.json?t=%timestamp%",
"avatar.effectmap.url": "${gamedata.url}/EffectMap.json?t=%timestamp%",
"avatar.asset.url": "${asset.url}/figure/%libname%.nitro",
"avatar.asset.effect.url": "${asset.url}/effect/%libname%.nitro",
"furni.asset.url": "${asset.url}/furniture/%libname%.nitro",
"furni.asset.icon.url": "${hof.furni.url}/icons/%libname%%param%_icon.png",
"pet.asset.url": "${asset.url}/pets/%libname%.nitro",
"generic.asset.url": "${asset.url}/generic/%libname%.nitro",
"badge.asset.url": "${image.library.url}album1584/%badgename%.gif",
"furni.rotation.bounce.steps": 20,
"furni.rotation.bounce.height": 0.0625,
"enable.avatar.arrow": false,
"system.log.debug": true,
"system.log.warn": true,
"system.log.error": true,
"system.log.events": false,
"system.log.packets": false,
"system.fps.animation": 24,
"system.fps.max": 60,
"system.pong.manually": true,
"system.pong.interval.ms": 20000,
"room.color.skip.transition": true,
"room.landscapes.enabled": true,
"room.zoom.enabled": true,
"timezone.settings": "Europe/Amsterdam",
"youtube.publish.disabled": false,
"user.badges.group.slot.enabled": true,
"login.screen.enabled": true,
"login.endpoint": "${api.url}/api/auth/login",
"login.register.endpoint": "${api.url}/api/auth/register",
"login.forgot.endpoint": "${api.url}/api/auth/forgot-password",
"login.logout.endpoint": "${api.url}/api/auth/logout",
"login.health.endpoint": "${api.url}/api/health",
"login.check-email.endpoint": "${api.url}/api/auth/check-email",
"login.check-username.endpoint": "${api.url}/api/auth/check-username",
"login.room_templates.endpoint": "${api.url}/api/auth/room-templates",
"login.remember.endpoint": "${api.url}/api/auth/remember",
"login.server_key.endpoint": "${api.url}/api/auth/server-key",
"login.sso-token.endpoint": "${api.url}/api/auth/sso-token",
"login.refresh.endpoint": "${api.url}/api/auth/refresh",
"badges.custom.list.endpoint": "${api.url}/api/badges/custom",
"badges.custom.create.endpoint": "${api.url}/api/badges/custom",
"badges.custom.update.endpoint": "${api.url}/api/badges/custom/%badgeId%",
"badges.custom.delete.endpoint": "${api.url}/api/badges/custom/%badgeId%",
"badges.custom.texts.endpoint": "${api.url}/api/badges/custom/texts",
"login.turnstile.enabled": true,
"login.turnstile.sitekey": "1x00000000000000000000AA",
"avatar.mandatory.libraries": [
"bd:1",
"li:0"
],
"avatar.mandatory.effect.libraries": [
"dance.1",
"dance.2",
"dance.3",
"dance.4"
],
"avatar.default.figuredata": {
"palettes": [
{
"id": 1,
"colors": [
{
"id": 99999,
"index": 1001,
"club": 0,
"selectable": false,
"hexCode": "DDDDDD"
},
{
"id": 99998,
"index": 1001,
"club": 0,
"selectable": false,
"hexCode": "FAFAFA"
}
]
},
{
"id": 3,
"colors": [
{
"id": 10001,
"index": 1001,
"club": 0,
"selectable": false,
"hexCode": "EEEEEE"
},
{
"id": 10002,
"index": 1002,
"club": 0,
"selectable": false,
"hexCode": "FA3831"
},
{
"id": 10003,
"index": 1003,
"club": 0,
"selectable": false,
"hexCode": "FD92A0"
},
{
"id": 10004,
"index": 1004,
"club": 0,
"selectable": false,
"hexCode": "2AC7D2"
},
{
"id": 10005,
"index": 1005,
"club": 0,
"selectable": false,
"hexCode": "35332C"
},
{
"id": 10006,
"index": 1006,
"club": 0,
"selectable": false,
"hexCode": "EFFF92"
},
{
"id": 10007,
"index": 1007,
"club": 0,
"selectable": false,
"hexCode": "C6FF98"
},
{
"id": 10008,
"index": 1008,
"club": 0,
"selectable": false,
"hexCode": "FF925A"
},
{
"id": 10009,
"index": 1009,
"club": 0,
"selectable": false,
"hexCode": "9D597E"
},
{
"id": 10010,
"index": 1010,
"club": 0,
"selectable": false,
"hexCode": "B6F3FF"
},
{
"id": 10011,
"index": 1011,
"club": 0,
"selectable": false,
"hexCode": "6DFF33"
},
{
"id": 10012,
"index": 1012,
"club": 0,
"selectable": false,
"hexCode": "3378C9"
},
{
"id": 10013,
"index": 1013,
"club": 0,
"selectable": false,
"hexCode": "FFB631"
},
{
"id": 10014,
"index": 1014,
"club": 0,
"selectable": false,
"hexCode": "DFA1E9"
},
{
"id": 10015,
"index": 1015,
"club": 0,
"selectable": false,
"hexCode": "F9FB32"
},
{
"id": 10016,
"index": 1016,
"club": 0,
"selectable": false,
"hexCode": "CAAF8F"
},
{
"id": 10017,
"index": 1017,
"club": 0,
"selectable": false,
"hexCode": "C5C6C5"
},
{
"id": 10018,
"index": 1018,
"club": 0,
"selectable": false,
"hexCode": "47623D"
},
{
"id": 10019,
"index": 1019,
"club": 0,
"selectable": false,
"hexCode": "8A8361"
},
{
"id": 10020,
"index": 1020,
"club": 0,
"selectable": false,
"hexCode": "FF8C33"
},
{
"id": 10021,
"index": 1021,
"club": 0,
"selectable": false,
"hexCode": "54C627"
},
{
"id": 10022,
"index": 1022,
"club": 0,
"selectable": false,
"hexCode": "1E6C99"
},
{
"id": 10023,
"index": 1023,
"club": 0,
"selectable": false,
"hexCode": "984F88"
},
{
"id": 10024,
"index": 1024,
"club": 0,
"selectable": false,
"hexCode": "77C8FF"
},
{
"id": 10025,
"index": 1025,
"club": 0,
"selectable": false,
"hexCode": "FFC08E"
},
{
"id": 10026,
"index": 1026,
"club": 0,
"selectable": false,
"hexCode": "3C4B87"
},
{
"id": 10027,
"index": 1027,
"club": 0,
"selectable": false,
"hexCode": "7C2C47"
},
{
"id": 10028,
"index": 1028,
"club": 0,
"selectable": false,
"hexCode": "D7FFE3"
},
{
"id": 10029,
"index": 1029,
"club": 0,
"selectable": false,
"hexCode": "8F3F1C"
},
{
"id": 10030,
"index": 1030,
"club": 0,
"selectable": false,
"hexCode": "FF6393"
},
{
"id": 10031,
"index": 1031,
"club": 0,
"selectable": false,
"hexCode": "1F9B79"
},
{
"id": 10032,
"index": 1032,
"club": 0,
"selectable": false,
"hexCode": "FDFF33"
}
]
}
],
"setTypes": [
{
"type": "hd",
"paletteId": 1,
"mandatory_f_0": true,
"mandatory_f_1": true,
"mandatory_m_0": true,
"mandatory_m_1": true,
"sets": [
{
"id": 99999,
"gender": "U",
"club": 0,
"colorable": true,
"selectable": false,
"preselectable": false,
"sellable": false,
"parts": [
{
"id": 1,
"type": "bd",
"colorable": true,
"index": 0,
"colorindex": 1
},
{
"id": 1,
"type": "hd",
"colorable": true,
"index": 0,
"colorindex": 1
},
{
"id": 1,
"type": "lh",
"colorable": true,
"index": 0,
"colorindex": 1
},
{
"id": 1,
"type": "rh",
"colorable": true,
"index": 0,
"colorindex": 1
}
]
}
]
},
{
"type": "bds",
"paletteId": 1,
"mandatory_f_0": false,
"mandatory_f_1": false,
"mandatory_m_0": false,
"mandatory_m_1": false,
"sets": [
{
"id": 10001,
"gender": "U",
"club": 0,
"colorable": true,
"selectable": false,
"preselectable": false,
"sellable": false,
"parts": [
{
"id": 10001,
"type": "bds",
"colorable": true,
"index": 0,
"colorindex": 1
},
{
"id": 10001,
"type": "lhs",
"colorable": true,
"index": 0,
"colorindex": 1
},
{
"id": 10001,
"type": "rhs",
"colorable": true,
"index": 0,
"colorindex": 1
}
],
"hiddenLayers": [
{
"partType": "bd"
},
{
"partType": "rh"
},
{
"partType": "lh"
}
]
}
]
},
{
"type": "ss",
"paletteId": 3,
"mandatory_f_0": false,
"mandatory_f_1": false,
"mandatory_m_0": false,
"mandatory_m_1": false,
"sets": [
{
"id": 10010,
"gender": "F",
"club": 0,
"colorable": true,
"selectable": false,
"preselectable": false,
"sellable": false,
"parts": [
{
"id": 10001,
"type": "ss",
"colorable": true,
"index": 0,
"colorindex": 1
}
],
"hiddenLayers": [
{
"partType": "ch"
},
{
"partType": "lg"
},
{
"partType": "ca"
},
{
"partType": "wa"
},
{
"partType": "sh"
},
{
"partType": "ls"
},
{
"partType": "rs"
},
{
"partType": "lc"
},
{
"partType": "rc"
},
{
"partType": "cc"
},
{
"partType": "cp"
}
]
},
{
"id": 10011,
"gender": "M",
"club": 0,
"colorable": true,
"selectable": false,
"preselectable": false,
"sellable": false,
"parts": [
{
"id": 10002,
"type": "ss",
"colorable": true,
"index": 0,
"colorindex": 1
}
],
"hiddenLayers": [
{
"partType": "ch"
},
{
"partType": "lg"
},
{
"partType": "ca"
},
{
"partType": "wa"
},
{
"partType": "sh"
},
{
"partType": "ls"
},
{
"partType": "rs"
},
{
"partType": "lc"
},
{
"partType": "rc"
},
{
"partType": "cc"
},
{
"partType": "cp"
}
]
}
]
}
]
},
"avatar.default.actions": {
"actions": [
{
"id": "Default",
"state": "std",
"precedence": 1000,
"main": true,
"isDefault": true,
"geometryType": "vertical",
"activePartSet": "figure",
"assetPartDefinition": "std"
}
]
},
"pet.types": [
"dog",
"cat",
"croco",
"terrier",
"bear",
"pig",
"lion",
"rhino",
"spider",
"turtle",
"chicken",
"frog",
"dragon",
"monster",
"monkey",
"horse",
"monsterplant",
"bunnyeaster",
"bunnyevil",
"bunnydepressed",
"bunnylove",
"pigeongood",
"pigeonevil",
"demonmonkey",
"bearbaby",
"terrierbaby",
"gnome",
"gnome",
"kittenbaby",
"puppybaby",
"pigletbaby",
"haloompa",
"fools",
"pterosaur",
"velociraptor",
"cow",
"LeetPen",
"bbwibb",
"elephants"
],
"preload.assets.urls": [
"${images.url}/loading_icon.png",
"${images.url}/clear_icon.png",
"${images.url}/big_arrow.png"
]
}
File diff suppressed because it is too large Load Diff
-534
View File
@@ -1,534 +0,0 @@
{
"socket.url": "ws://localhost:2096",
"crypto.ws.enabled": false,
"crypto.ws.signing.enabled": true,
"crypto.ws.signing.public_key": "### PASTE HERE THE PUBLIC KEY !!!! NOT THE PRIVATE !!!! ###",
"api.url": "http://localhost:2096",
"asset.url": "https://localhost/nitro/bundled",
"image.library.url": "https://localhost/c_images/",
"hof.furni.url": "https://localhost/c_images/dcr/hof_furni",
"images.url": "https://localhost/nitro/images",
"gamedata.url": "https://localhost/nitro/gamedata",
"sounds.url": "${asset.url}/sounds/%sample%.mp3",
"external.texts.url": [
"${gamedata.url}/ExternalTexts.json",
"${gamedata.url}/UITexts.json"
],
"external.samples.url": "${hof.furni.url}/mp3/sound_machine_sample_%sample%.mp3",
"furnidata.url": "${gamedata.url}/FurnitureData.json?v=2",
"productdata.url": "${gamedata.url}/ProductData.json?v=2",
"avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?v=2",
"avatar.figuredata.url": "${gamedata.url}/FigureData.json?v=2",
"avatar.figuremap.url": "${gamedata.url}/FigureMap.json?v=2",
"avatar.effectmap.url": "${gamedata.url}/EffectMap.json?v=2",
"avatar.asset.url": "${asset.url}/figure/%libname%.nitro",
"avatar.asset.effect.url": "${asset.url}/effect/%libname%.nitro",
"furni.asset.url": "${asset.url}/furniture/%libname%.nitro",
"furni.asset.icon.url": "${hof.furni.url}/icons/%libname%%param%_icon.png",
"pet.asset.url": "${asset.url}/pets/%libname%.nitro",
"generic.asset.url": "${asset.url}/generic/%libname%.nitro",
"badge.asset.url": "${image.library.url}album1584/%badgename%.gif",
"furni.rotation.bounce.steps": 20,
"furni.rotation.bounce.height": 0.0625,
"enable.avatar.arrow": false,
"system.log.debug": true,
"system.log.warn": true,
"system.log.error": true,
"system.log.events": false,
"system.log.packets": true,
"system.fps.animation": 24,
"system.fps.max": 60,
"system.pong.manually": true,
"system.pong.interval.ms": 20000,
"room.color.skip.transition": true,
"room.landscapes.enabled": true,
"room.zoom.enabled": true,
"login.screen.enabled": true,
"login.endpoint": "${api.url}/api/auth/login",
"login.register.endpoint": "${api.url}/api/auth/register",
"login.forgot.endpoint": "${api.url}/api/auth/forgot-password",
"login.logout.endpoint": "${api.url}/api/auth/logout",
"login.health.endpoint": "${api.url}/api/health",
"login.check-email.endpoint": "${api.url}/api/auth/check-email",
"login.check-username.endpoint": "${api.url}/api/auth/check-username",
"login.room_templates.endpoint": "${api.url}/api/auth/room-templates",
"login.remember.endpoint": "${api.url}/api/auth/remember",
"login.server_key.endpoint": "${api.url}/api/auth/server-key",
"login.news.endpoint": "${api.url}/api/auth/news",
"login.sso-token.endpoint": "${api.url}/api/auth/sso-token",
"login.refresh.endpoint": "${api.url}/api/auth/refresh",
"badges.custom.list.endpoint": "${api.url}/api/badges/custom",
"badges.custom.create.endpoint": "${api.url}/api/badges/custom",
"badges.custom.update.endpoint": "${api.url}/api/badges/custom/%badgeId%",
"badges.custom.delete.endpoint": "${api.url}/api/badges/custom/%badgeId%",
"badges.custom.texts.endpoint": "${api.url}/api/badges/custom/texts",
"login.turnstile.enabled": true,
"login.turnstile.sitekey": "",
"avatar.mandatory.libraries": [
"bd:1",
"li:0"
],
"avatar.mandatory.effect.libraries": [
"dance.1",
"dance.2",
"dance.3",
"dance.4"
],
"avatar.default.figuredata": {
"palettes": [{
"id": 1,
"colors": [{
"id": 99999,
"index": 1001,
"club": 0,
"selectable": false,
"hexCode": "DDDDDD"
}, {
"id": 99998,
"index": 1001,
"club": 0,
"selectable": false,
"hexCode": "FAFAFA"
}
]
}, {
"id": 3,
"colors": [{
"id": 10001,
"index": 1001,
"club": 0,
"selectable": false,
"hexCode": "EEEEEE"
}, {
"id": 10002,
"index": 1002,
"club": 0,
"selectable": false,
"hexCode": "FA3831"
}, {
"id": 10003,
"index": 1003,
"club": 0,
"selectable": false,
"hexCode": "FD92A0"
}, {
"id": 10004,
"index": 1004,
"club": 0,
"selectable": false,
"hexCode": "2AC7D2"
}, {
"id": 10005,
"index": 1005,
"club": 0,
"selectable": false,
"hexCode": "35332C"
}, {
"id": 10006,
"index": 1006,
"club": 0,
"selectable": false,
"hexCode": "EFFF92"
}, {
"id": 10007,
"index": 1007,
"club": 0,
"selectable": false,
"hexCode": "C6FF98"
}, {
"id": 10008,
"index": 1008,
"club": 0,
"selectable": false,
"hexCode": "FF925A"
}, {
"id": 10009,
"index": 1009,
"club": 0,
"selectable": false,
"hexCode": "9D597E"
}, {
"id": 10010,
"index": 1010,
"club": 0,
"selectable": false,
"hexCode": "B6F3FF"
}, {
"id": 10011,
"index": 1011,
"club": 0,
"selectable": false,
"hexCode": "6DFF33"
}, {
"id": 10012,
"index": 1012,
"club": 0,
"selectable": false,
"hexCode": "3378C9"
}, {
"id": 10013,
"index": 1013,
"club": 0,
"selectable": false,
"hexCode": "FFB631"
}, {
"id": 10014,
"index": 1014,
"club": 0,
"selectable": false,
"hexCode": "DFA1E9"
}, {
"id": 10015,
"index": 1015,
"club": 0,
"selectable": false,
"hexCode": "F9FB32"
}, {
"id": 10016,
"index": 1016,
"club": 0,
"selectable": false,
"hexCode": "CAAF8F"
}, {
"id": 10017,
"index": 1017,
"club": 0,
"selectable": false,
"hexCode": "C5C6C5"
}, {
"id": 10018,
"index": 1018,
"club": 0,
"selectable": false,
"hexCode": "47623D"
}, {
"id": 10019,
"index": 1019,
"club": 0,
"selectable": false,
"hexCode": "8A8361"
}, {
"id": 10020,
"index": 1020,
"club": 0,
"selectable": false,
"hexCode": "FF8C33"
}, {
"id": 10021,
"index": 1021,
"club": 0,
"selectable": false,
"hexCode": "54C627"
}, {
"id": 10022,
"index": 1022,
"club": 0,
"selectable": false,
"hexCode": "1E6C99"
}, {
"id": 10023,
"index": 1023,
"club": 0,
"selectable": false,
"hexCode": "984F88"
}, {
"id": 10024,
"index": 1024,
"club": 0,
"selectable": false,
"hexCode": "77C8FF"
}, {
"id": 10025,
"index": 1025,
"club": 0,
"selectable": false,
"hexCode": "FFC08E"
}, {
"id": 10026,
"index": 1026,
"club": 0,
"selectable": false,
"hexCode": "3C4B87"
}, {
"id": 10027,
"index": 1027,
"club": 0,
"selectable": false,
"hexCode": "7C2C47"
}, {
"id": 10028,
"index": 1028,
"club": 0,
"selectable": false,
"hexCode": "D7FFE3"
}, {
"id": 10029,
"index": 1029,
"club": 0,
"selectable": false,
"hexCode": "8F3F1C"
}, {
"id": 10030,
"index": 1030,
"club": 0,
"selectable": false,
"hexCode": "FF6393"
}, {
"id": 10031,
"index": 1031,
"club": 0,
"selectable": false,
"hexCode": "1F9B79"
}, {
"id": 10032,
"index": 1032,
"club": 0,
"selectable": false,
"hexCode": "FDFF33"
}
]
}
],
"setTypes": [{
"type": "hd",
"paletteId": 1,
"mandatory_f_0": true,
"mandatory_f_1": true,
"mandatory_m_0": true,
"mandatory_m_1": true,
"sets": [{
"id": 99999,
"gender": "U",
"club": 0,
"colorable": true,
"selectable": false,
"preselectable": false,
"sellable": false,
"parts": [{
"id": 1,
"type": "bd",
"colorable": true,
"index": 0,
"colorindex": 1
}, {
"id": 1,
"type": "hd",
"colorable": true,
"index": 0,
"colorindex": 1
}, {
"id": 1,
"type": "lh",
"colorable": true,
"index": 0,
"colorindex": 1
}, {
"id": 1,
"type": "rh",
"colorable": true,
"index": 0,
"colorindex": 1
}
]
}
]
}, {
"type": "bds",
"paletteId": 1,
"mandatory_f_0": false,
"mandatory_f_1": false,
"mandatory_m_0": false,
"mandatory_m_1": false,
"sets": [{
"id": 10001,
"gender": "U",
"club": 0,
"colorable": true,
"selectable": false,
"preselectable": false,
"sellable": false,
"parts": [{
"id": 10001,
"type": "bds",
"colorable": true,
"index": 0,
"colorindex": 1
}, {
"id": 10001,
"type": "lhs",
"colorable": true,
"index": 0,
"colorindex": 1
}, {
"id": 10001,
"type": "rhs",
"colorable": true,
"index": 0,
"colorindex": 1
}
],
"hiddenLayers": [{
"partType": "bd"
}, {
"partType": "rh"
}, {
"partType": "lh"
}
]
}
]
}, {
"type": "ss",
"paletteId": 3,
"mandatory_f_0": false,
"mandatory_f_1": false,
"mandatory_m_0": false,
"mandatory_m_1": false,
"sets": [{
"id": 10010,
"gender": "F",
"club": 0,
"colorable": true,
"selectable": false,
"preselectable": false,
"sellable": false,
"parts": [{
"id": 10001,
"type": "ss",
"colorable": true,
"index": 0,
"colorindex": 1
}
],
"hiddenLayers": [{
"partType": "ch"
}, {
"partType": "lg"
}, {
"partType": "ca"
}, {
"partType": "wa"
}, {
"partType": "sh"
}, {
"partType": "ls"
}, {
"partType": "rs"
}, {
"partType": "lc"
}, {
"partType": "rc"
}, {
"partType": "cc"
}, {
"partType": "cp"
}
]
}, {
"id": 10011,
"gender": "M",
"club": 0,
"colorable": true,
"selectable": false,
"preselectable": false,
"sellable": false,
"parts": [{
"id": 10002,
"type": "ss",
"colorable": true,
"index": 0,
"colorindex": 1
}
],
"hiddenLayers": [{
"partType": "ch"
}, {
"partType": "lg"
}, {
"partType": "ca"
}, {
"partType": "wa"
}, {
"partType": "sh"
}, {
"partType": "ls"
}, {
"partType": "rs"
}, {
"partType": "lc"
}, {
"partType": "rc"
}, {
"partType": "cc"
}, {
"partType": "cp"
}
]
}
]
}
]
},
"avatar.default.actions": {
"actions": [{
"id": "Default",
"state": "std",
"precedence": 1000,
"main": true,
"isDefault": true,
"geometryType": "vertical",
"activePartSet": "figure",
"assetPartDefinition": "std"
}
]
},
"pet.types": [
"dog",
"cat",
"croco",
"terrier",
"bear",
"pig",
"lion",
"rhino",
"spider",
"turtle",
"chicken",
"frog",
"dragon",
"monster",
"monkey",
"horse",
"monsterplant",
"bunnyeaster",
"bunnyevil",
"bunnydepressed",
"bunnylove",
"pigeongood",
"pigeonevil",
"demonmonkey",
"bearbaby",
"terrierbaby",
"gnome",
"gnome",
"kittenbaby",
"puppybaby",
"pigletbaby",
"haloompa",
"fools",
"pterosaur",
"velociraptor",
"cow",
"LeetPen",
"bbwibb",
"elephants"
],
"preload.assets.urls": [
"${asset.url}/generic/avatar_additions.nitro",
"${asset.url}/generic/group_badge.nitro",
"${asset.url}/generic/floor_editor.nitro",
"${images.url}/loading_icon.png",
"${images.url}/clear_icon.png",
"${images.url}/big_arrow.png"
]
}
+13
View File
@@ -0,0 +1,13 @@
Place localized external text files here, for example:
- `ExternalTexts_br.json`
- `ExternalTexts_com.json`
- `ExternalTexts_de.json`
- `ExternalTexts_es.json`
- `ExternalTexts_fi.json`
- `ExternalTexts_fr.json`
- `ExternalTexts_it.json`
- `ExternalTexts_nl.json`
- `ExternalTexts_tr.json`
The client loads them from `/text_translate/` when selected in the Google Translate panel.
+13
View File
@@ -0,0 +1,13 @@
const KEY = new TextEncoder().encode('slogga-dist-assets-2026');
export const encodeBytes = bytes =>
{
const output = new Uint8Array(bytes.length);
for(let index = 0; index < bytes.length; index++)
{
output[index] = bytes[index] ^ KEY[index % KEY.length] ^ ((index * 31) & 255);
}
return output;
};
+81
View File
@@ -0,0 +1,81 @@
import { encodeBytes } from './asset-codec.mjs';
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs';
import { join } from 'path';
import { gzipSync } from 'zlib';
const dist = 'dist';
const buildVersion = Date.now().toString(36);
const walk = dir =>
{
const out = [];
for(const entry of readdirSync(dir))
{
const path = join(dir, entry);
const stat = statSync(path);
if(stat.isDirectory()) out.push(...walk(path));
else out.push(path);
}
return out;
};
const minifyJson = path =>
{
try
{
writeFileSync(path, JSON.stringify(JSON.parse(readFileSync(path, 'utf8'))));
}
catch {}
};
const encryptFile = path =>
{
const bytes = gzipSync(readFileSync(path), { level: 9 });
writeFileSync(path + '.dat', encodeBytes(bytes));
};
if(!existsSync(dist)) throw new Error('dist folder not found');
for(const file of walk(dist))
{
if(file.endsWith('.json')) minifyJson(file);
}
for(const file of walk(dist))
{
if(file.endsWith('.js') && !file.endsWith('asset-loader.js')) encryptFile(file);
if(file.endsWith('.css')) encryptFile(file);
}
const assetMirrorDir = join(dist, 'src', 'assets');
mkdirSync(assetMirrorDir, { recursive: true });
for(const file of [ 'app.css.dat', 'app.js.dat' ])
{
const source = join(dist, 'assets', file);
const target = join(assetMirrorDir, file);
if(existsSync(source)) copyFileSync(source, target);
}
const publicLoaderAssets = [
[ 'src/assets/images/loading/loading.gif', 'loading.gif' ],
[ 'src/assets/images/notifications/nitro_v3.png', 'nitro_v3.png' ]
];
for(const [ source, file ] of publicLoaderAssets)
{
const target = join(dist, 'assets', file);
const mirrorTarget = join(assetMirrorDir, file);
if(existsSync(source))
{
copyFileSync(source, target);
copyFileSync(source, mirrorTarget);
}
}
writeFileSync(join(dist, 'index.html'), `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="root"></div><script src="configuration/bootstrap.js?v=${ buildVersion }"></script></body></html>`);
+345
View File
@@ -0,0 +1,345 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { dirname, resolve } from 'path';
const loader = `(() => {
const ASSET_KEY = new TextEncoder().encode("slogga-dist-assets-2026");
const MODE_DEFAULTS = {
distObfuscationEnabled: true,
secureAssetsEnabled: true,
secureApiEnabled: true
};
const isDebug = () => {
try {
const search = new URLSearchParams(location.search);
return search.get("loaderDebug") === "1" || localStorage.getItem("nitro.loader.debug") === "1";
} catch {
return false;
}
};
const debug = (message) => {
try {
window.__nitroLoaderDebug = message;
const log = Array.isArray(window.__nitroLoaderDebugLog) ? window.__nitroLoaderDebugLog : [];
log.push(message);
window.__nitroLoaderDebugLog = log.slice(-30);
if(!isDebug()) {
document.getElementById("nitro-loader-debug")?.remove();
return;
}
let node = document.getElementById("nitro-loader-debug");
if(!node) {
node = document.createElement("div");
node.id = "nitro-loader-debug";
node.style.cssText = "position:fixed;left:8px;top:8px;z-index:2147483647;padding:6px 8px;max-width:70vw;background:rgba(0,0,0,.85);color:#fff;font:12px monospace;white-space:pre-wrap";
document.body.appendChild(node);
}
node.textContent = window.__nitroLoaderDebugLog.slice(-10).join("\\n");
} catch {}
};
const getBase = () => {
const source = document.currentScript?.src || location.href;
return new URL(".", source);
};
const withCacheBust = (url) => {
url.searchParams.set("v", Date.now().toString(36));
return url;
};
const renderShell = () => {
const root = document.getElementById("root");
if(!root || root.firstChild) return;
root.innerHTML = '<div style="position:fixed;inset:0;background:linear-gradient(180deg,#6eadc8 0%,#78b7cf 45%,#8ec4d7 100%);overflow:hidden;z-index:1"><div style="position:absolute;left:0;top:0;width:220px;height:220px;background:linear-gradient(135deg,rgba(255,255,255,.18),rgba(255,255,255,0));clip-path:polygon(0 0,100% 0,0 100%)"></div><div style="position:absolute;right:0;bottom:0;width:32vw;max-width:420px;height:100%;background:linear-gradient(270deg,rgba(255,255,255,.16),rgba(255,255,255,0))"></div><div style="position:absolute;top:50%;right:8vw;transform:translateY(-50%);display:flex;flex-direction:column;gap:18px;width:260px"><div style="height:86px;background:#a2bfd1;border:2px solid #3f6a85;border-radius:8px;box-shadow:inset 0 2px rgba(255,255,255,.35),0 4px 6px rgba(0,0,0,.25)"></div><div style="height:190px;background:#a2bfd1;border:2px solid #3f6a85;border-radius:8px;box-shadow:inset 0 2px rgba(255,255,255,.35),0 4px 6px rgba(0,0,0,.25)"></div></div></div>';
};
const decodeAsset = (bytes) => {
const output = new Uint8Array(bytes.length);
for(let index = 0; index < bytes.length; index++) {
output[index] = bytes[index] ^ ASSET_KEY[index % ASSET_KEY.length] ^ ((index * 31) & 255);
}
return output;
};
const gunzip = async (bytes) => {
if(!("DecompressionStream" in self)) throw new Error("gzip decompression unsupported");
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
return new Uint8Array(await new Response(stream).arrayBuffer());
};
const resolveAssetCandidates = (path) => {
const base = getBase();
const normalized = path.replace(/^\\.\\//, "");
const file = normalized.split("/").pop();
const urls = [
new URL("./src/assets/" + file, base),
new URL("./assets/" + file, base),
new URL("/src/assets/" + file, base.origin),
new URL("/assets/" + file, base.origin),
new URL("/client/src/assets/" + file, base.origin),
new URL("/client/assets/" + file, base.origin)
];
return [...new Map(urls.map(url => [url.href, url])).values()];
};
const fetchBytes = async (path) => {
let error = null;
debug("loader: fetching " + path);
for(const candidate of resolveAssetCandidates(path)) {
try {
debug("loader: try " + candidate.href);
const response = await fetch(withCacheBust(candidate), { cache: "no-store" });
if(!response.ok) {
error = new Error("asset " + candidate.pathname + " " + response.status);
continue;
}
debug("loader: ok " + candidate.href);
return new Uint8Array(await response.arrayBuffer());
} catch(caught) {
error = caught;
}
}
throw error || new Error("asset " + path + " not found");
};
const loadDatAsset = async (path) => gunzip(decodeAsset(await fetchBytes(path)));
const injectCssText = (bytes) => {
const node = document.createElement("style");
node.textContent = new TextDecoder().decode(bytes);
document.head.appendChild(node);
debug("loader: css injected from dat");
};
const loadPlainCss = async (path) => {
const href = resolveAssetCandidates(path)[0];
href.searchParams.set("v", Date.now().toString(36));
await new Promise((resolve, reject) => {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = href.href;
link.onload = () => resolve();
link.onerror = () => reject(new Error("plain css failed"));
document.head.appendChild(link);
});
debug("loader: css linked");
};
const importBytes = async (bytes) => {
const blobUrl = URL.createObjectURL(new Blob([bytes], { type: "text/javascript" }));
try {
debug("loader: importing app blob");
await import(blobUrl);
debug("loader: app blob imported");
} finally {
URL.revokeObjectURL(blobUrl);
}
};
const importPlainJs = async (path) => {
const href = resolveAssetCandidates(path)[0];
href.searchParams.set("v", Date.now().toString(36));
debug("loader: importing plain js");
await import(href.href);
debug("loader: plain js imported");
};
const readClientMode = async () => {
try {
if(window.__nitroClientMode && typeof window.__nitroClientMode === "object") {
debug("loader: client-mode preset");
return window.__nitroClientMode;
}
const url = withCacheBust(new URL("./client-mode.json", getBase()));
const response = await fetch(url, { cache: "no-store" });
if(!response.ok) throw new Error("client-mode " + response.status);
const payload = await response.json();
const mode = { ...MODE_DEFAULTS, ...(payload && typeof payload === "object" ? payload : {}) };
window.__nitroClientMode = mode;
debug("loader: client-mode loaded");
return mode;
} catch(error) {
window.__nitroClientMode = { ...MODE_DEFAULTS };
debug("loader: client-mode fallback " + (error?.message || error));
return window.__nitroClientMode;
}
};
(async () => {
debug("loader: start");
renderShell();
const mode = await readClientMode();
if(mode.distObfuscationEnabled) {
const [cssBytes, jsBytes] = await Promise.all([
loadDatAsset("./assets/app.css.dat"),
loadDatAsset("./assets/app.js.dat")
]);
injectCssText(cssBytes);
await importBytes(jsBytes);
return;
}
await loadPlainCss("./assets/app.css");
await importPlainJs("./assets/app.js");
})().catch(error => {
console.error(error);
debug("loader: failed " + (error?.message || error));
document.body.textContent = "Unable to load client.";
});
})();`;
const clientModePath = resolve('public', 'configuration', 'client-mode.json');
let bootstrapApiBase = '';
if(existsSync(clientModePath))
{
try
{
const clientMode = JSON.parse(readFileSync(clientModePath, 'utf8'));
if(typeof clientMode.apiBaseUrl === 'string') bootstrapApiBase = clientMode.apiBaseUrl;
}
catch {}
}
const bootstrap = `(() => {
const API_BASE = ${ JSON.stringify(bootstrapApiBase) };
const getBase = () => {
const source = document.currentScript?.src || location.href;
return new URL(".", source);
};
const withCacheBust = (url) => {
url.searchParams.set("v", Date.now().toString(36));
return url;
};
const bytesToBase64 = (buffer) => {
let binary = "";
const bytes = new Uint8Array(buffer);
for(let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
};
const hexValue = (code) => {
if(code >= 48 && code <= 57) return code - 48;
if(code >= 65 && code <= 70) return code - 55;
if(code >= 97 && code <= 102) return code - 87;
return -1;
};
const hexToBytes = (hex) => {
const normalized = hex.trim();
if((normalized.length % 2) !== 0) throw new Error("Invalid encrypted hex payload.");
const bytes = new Uint8Array(normalized.length / 2);
for(let i = 0; i < bytes.length; i++) {
const high = hexValue(normalized.charCodeAt(i * 2));
const low = hexValue(normalized.charCodeAt((i * 2) + 1));
if(high < 0 || low < 0) throw new Error("Invalid encrypted hex payload.");
bytes[i] = (high << 4) | low;
}
return bytes;
};
const deriveAesKey = async (privateKey, serverKeyBase64) => {
const serverBytes = Uint8Array.from(atob(serverKeyBase64), char => char.charCodeAt(0));
const serverKey = await crypto.subtle.importKey("spki", serverBytes, { name: "ECDH", namedCurve: "P-256" }, false, []);
const secret = await crypto.subtle.deriveBits({ name: "ECDH", public: serverKey }, privateKey, 256);
const salt = new TextEncoder().encode("nitro-secure-assets-v1");
const material = new Uint8Array(secret.byteLength + salt.length);
material.set(new Uint8Array(secret), 0);
material.set(salt, secret.byteLength);
const hash = await crypto.subtle.digest("SHA-256", material);
return crypto.subtle.importKey("raw", hash, "AES-GCM", false, ["decrypt"]);
};
const decryptPayload = async (key, response) => {
if(response.headers.get("X-Nitro-Sec") !== "1") return response.text();
const bytes = hexToBytes(await response.text());
if(bytes.length < 13) throw new Error("Encrypted response is too short.");
const iv = bytes.slice(0, 12);
const payload = bytes.slice(12);
const clear = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, payload);
return new TextDecoder().decode(clear);
};
const importTextModule = async (sourceText) => {
const blobUrl = URL.createObjectURL(new Blob([sourceText], { type: "text/javascript" }));
try {
await import(blobUrl);
} finally {
URL.revokeObjectURL(blobUrl);
}
};
const loadPlainBootstrap = async () => {
const url = withCacheBust(new URL("./asset-loader.js", getBase()));
await import(url.href);
};
const loadSecureBootstrap = async () => {
if(!API_BASE) throw new Error("Missing apiBaseUrl for secure bootstrap.");
const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
const publicKeyBuffer = await crypto.subtle.exportKey("spki", pair.publicKey);
const publicKey = bytesToBase64(publicKeyBuffer);
const base = API_BASE.replace(/\\/$/, "");
const bootstrapResponse = await fetch(base + "/nitro-sec/bootstrap", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: publicKey })
});
if(!bootstrapResponse.ok) throw new Error("Secure bootstrap failed: HTTP " + bootstrapResponse.status);
const bootstrapPayload = await bootstrapResponse.json();
if(!bootstrapPayload || typeof bootstrapPayload.key !== "string" || !bootstrapPayload.key.length) {
throw new Error("Secure bootstrap returned an invalid server key.");
}
const sessionKey = await deriveAesKey(pair.privateKey, bootstrapPayload.key);
const fetchSecureConfig = async (file) => {
const url = new URL(base + "/nitro-sec/file");
url.searchParams.set("kind", "config");
url.searchParams.set("file", file);
url.searchParams.set("v", Date.now().toString(36));
const response = await fetch(url.toString(), {
headers: { "X-Nitro-Key": publicKey },
cache: "no-store"
});
if(!response.ok) throw new Error("Failed to load secure config " + file + ": HTTP " + response.status);
return decryptPayload(sessionKey, response);
};
const modeText = await fetchSecureConfig("client-mode.json");
window.__nitroClientMode = JSON.parse(modeText);
const loaderText = await fetchSecureConfig("asset-loader.js");
await importTextModule(loaderText);
};
(async () => {
try {
await loadSecureBootstrap();
} catch(error) {
console.warn("[Nitro] Secure bootstrap fallback:", error?.message || error);
await loadPlainBootstrap();
}
})().catch(error => {
console.error(error);
document.body.textContent = "Unable to load client.";
});
})();`;
const target = resolve('public', 'configuration', 'asset-loader.js');
const bootstrapTarget = resolve('public', 'configuration', 'bootstrap.js');
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, loader);
writeFileSync(bootstrapTarget, bootstrap);
+283 -191
View File
@@ -1,6 +1,6 @@
import { GetAssetManager, GetAvatarRenderManager, GetCommunication, GetConfiguration, GetLocalizationManager, GetRoomEngine, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, GetStage, GetTexturePool, GetTicker, HabboWebTools, LegacyExternalInterface, LoadGameUrlEvent, NitroEventType, NitroLogger, NitroVersion, PrepareRenderer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useState } from 'react';
import { clearAccessToken, getAccessToken, getAccessTokenExpiresAt, GetUIVersion, persistAccessTokenFromPayload } from './api';
import { FC, useCallback, useEffect, useRef, useState } from 'react';
import { ClearRememberLogin, GetRememberLogin, GetUIVersion, StoreRememberLoginFromPayload } from './api';
import { Base } from './common';
import { LoadingView } from './components/loading/LoadingView';
import { LoginView } from './components/login/LoginView';
@@ -10,13 +10,54 @@ import { useMessageEvent, useNitroEvent } from './hooks';
NitroVersion.UI_VERSION = GetUIVersion();
const preloadUrl = async (url: string): Promise<void> =>
{
if(!url) return;
try
{
const response = await fetch(url, { cache: 'force-cache' });
await response.arrayBuffer();
}
catch {}
};
const preloadImage = (url: string): void =>
{
if(!url) return;
try
{
const image = new Image();
image.decoding = 'async';
image.src = url;
}
catch {}
};
const asStringArray = (value: unknown): string[] =>
{
if(Array.isArray(value)) return value.filter(item => typeof item === 'string');
if(typeof value === 'string' && value.length) return [ value ];
return [];
};
const hasRememberLogin = (): boolean => !!GetRememberLogin();
export const App: FC<{}> = props =>
{
const [ isReady, setIsReady ] = useState(false);
const [ errorMessage, setErrorMessage ] = useState('');
const [ homeUrl, setHomeUrl ] = useState('');
const [ showLogin, setShowLogin ] = useState(false);
const [ showLogin, setShowLogin ] = useState(() => !window.NitroConfig?.['sso.ticket'] && !hasRememberLogin());
const [ isEnteringHotel, setIsEnteringHotel ] = useState(() => !!window.NitroConfig?.['sso.ticket'] || hasRememberLogin());
const [ prepareTrigger, setPrepareTrigger ] = useState(0);
const warmupPromiseRef = useRef<Promise<void>>(null);
const rendererPromiseRef = useRef<Promise<any>>(null);
const tickersStartedRef = useRef(false);
const heartbeatIntervalRef = useRef<number>(null);
const rememberRotateIntervalRef = useRef<number>(null);
const showSessionExpired = useCallback(() =>
{
const baseUrl = window.location.origin + '/';
@@ -24,17 +65,117 @@ export const App: FC<{}> = props =>
setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.');
setIsReady(false);
setShowLogin(false);
setIsEnteringHotel(false);
}, []);
const applySsoTicket = useCallback((ssoTicket: string) =>
{
if(!ssoTicket) return;
window.NitroConfig['sso.ticket'] = ssoTicket;
GetConfiguration().setValue('sso.ticket', ssoTicket);
}, []);
const handleAuthenticated = useCallback((ssoTicket: string) =>
{
if(!ssoTicket) return;
window.NitroConfig['sso.ticket'] = ssoTicket;
setShowLogin(false);
applySsoTicket(ssoTicket);
setIsEnteringHotel(true);
setErrorMessage('');
setPrepareTrigger(prev => prev + 1);
}, [ applySsoTicket ]);
const tryRememberLogin = useCallback(async (): Promise<string> =>
{
const remembered = GetRememberLogin();
if(!remembered) return '';
if(!remembered.token?.length && remembered.ssoTicket?.length) return remembered.ssoTicket;
let allowSsoFallback = true;
try
{
const rawEndpoint = GetConfiguration().getValue<string>('login.remember.endpoint', '${api.url}/api/auth/remember');
const endpoint = GetConfiguration().interpolate(rawEndpoint);
const response = await fetch(endpoint, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'NitroRememberLogin'
},
body: JSON.stringify({ rememberToken: remembered.token })
});
let payload: Record<string, unknown> = {};
try { payload = await response.json(); }
catch {}
const ssoTicket = typeof payload.ssoTicket === 'string' ? payload.ssoTicket : (typeof payload.sso === 'string' ? payload.sso : '');
if(response.ok && ssoTicket)
{
StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : remembered.username, ssoTicket);
return ssoTicket;
}
if(response.status === 400 || response.status === 401 || response.status === 403)
{
allowSsoFallback = false;
ClearRememberLogin();
}
}
catch(error)
{
NitroLogger.error('[LoginScreen] Remember login failed', error);
}
if(allowSsoFallback && remembered.ssoTicket?.length) return remembered.ssoTicket;
return '';
}, []);
const rotateRememberLogin = useCallback(async (): Promise<void> =>
{
const remembered = GetRememberLogin();
if(!remembered?.token?.length) return;
try
{
const rawEndpoint = GetConfiguration().getValue<string>('login.refresh.endpoint', '${api.url}/api/auth/refresh');
const endpoint = GetConfiguration().interpolate(rawEndpoint);
const response = await fetch(endpoint, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'NitroRememberRotate'
},
body: JSON.stringify({ rememberToken: remembered.token })
});
let payload: Record<string, unknown> = {};
try { payload = await response.json(); }
catch {}
if(response.ok)
{
StoreRememberLoginFromPayload(payload, remembered.username, remembered.ssoTicket);
return;
}
if(response.status === 400 || response.status === 401 || response.status === 403) ClearRememberLogin();
}
catch(error)
{
NitroLogger.error('[LoginScreen] Remember rotation failed', error);
}
}, []);
// Listen for socket closed events (code 1000 "Bye" - server rejected SSO)
useNitroEvent(NitroEventType.SOCKET_CLOSED, showSessionExpired);
useMessageEvent<LoadGameUrlEvent>(LoadGameUrlEvent, event =>
@@ -46,11 +187,89 @@ export const App: FC<{}> = props =>
LegacyExternalInterface.callGame('showGame', parser.url);
});
const startRenderer = useCallback((width: number, height: number) =>
{
if(rendererPromiseRef.current) return rendererPromiseRef.current;
const rawUseBackBuffer = window.NitroConfig?.['renderer.useBackBuffer'];
const useBackBuffer = (rawUseBackBuffer === undefined)
? true
: ((rawUseBackBuffer === true) || (rawUseBackBuffer === 'true'));
rendererPromiseRef.current = PrepareRenderer({
width: Math.floor(width),
height: Math.floor(height),
resolution: window.devicePixelRatio,
autoDensity: true,
backgroundAlpha: 0,
preference: 'webgl',
eventMode: 'none',
failIfMajorPerformanceCaveat: false,
roundPixels: true,
useBackBuffer
});
return rendererPromiseRef.current;
}, []);
const startWarmup = useCallback((width: number, height: number) =>
{
if(warmupPromiseRef.current) return warmupPromiseRef.current;
warmupPromiseRef.current = (async () =>
{
await GetConfiguration().init();
GetTicker().maxFPS = GetConfiguration().getValue<number>('system.fps.max', 24);
NitroLogger.LOG_DEBUG = GetConfiguration().getValue<boolean>('system.log.debug', true);
NitroLogger.LOG_WARN = GetConfiguration().getValue<boolean>('system.log.warn', false);
NitroLogger.LOG_ERROR = GetConfiguration().getValue<boolean>('system.log.error', false);
NitroLogger.LOG_EVENTS = GetConfiguration().getValue<boolean>('system.log.events', false);
NitroLogger.LOG_PACKETS = GetConfiguration().getValue<boolean>('system.log.packets', false);
startRenderer(width, height).catch(error => NitroLogger.error('[LoginScreen] Renderer warmup failed', error));
const interpolate = (value: string) => GetConfiguration().interpolate(value);
const assetUrls = asStringArray(GetConfiguration().getValue<unknown>('preload.assets.urls')).map(interpolate);
const gamedataUrls = [
...asStringArray(GetConfiguration().getValue<unknown>('external.texts.url')).map(interpolate),
...[
'furnidata.url',
'productdata.url',
'avatar.actions.url',
'avatar.figuredata.url',
'avatar.figuremap.url',
'avatar.effectmap.url'
].map(key => interpolate(GetConfiguration().getValue<string>(key, ''))).filter(Boolean)
];
const loginImages = ((GetConfiguration().getValue<Record<string, unknown>>('loginview', {})?.images) as Record<string, string>) ?? {};
const loginImageUrls = [
loginImages.background,
loginImages.sun,
loginImages.drape,
loginImages.left,
loginImages['right.repeat'],
loginImages.right
].filter(Boolean).map(interpolate);
loginImageUrls.forEach(preloadImage);
gamedataUrls.forEach(url => preloadUrl(url));
await Promise.all(
[
GetAssetManager().downloadAssets(assetUrls),
GetLocalizationManager().init(),
GetAvatarRenderManager().init(),
GetSoundManager().init()
]
);
})();
return warmupPromiseRef.current;
}, [ startRenderer ]);
useEffect(() =>
{
let heartbeatInterval: number = null;
let rememberRotateInterval: number = null;
const prepare = async (width: number, height: number) =>
{
try
@@ -58,168 +277,64 @@ export const App: FC<{}> = props =>
if(!window.NitroConfig) throw new Error('NitroConfig is not defined!');
let ssoTicket = window.NitroConfig['sso.ticket'];
let configInitError: unknown = null;
if(ssoTicket) GetConfiguration().setValue('sso.ticket', ssoTicket);
if(!ssoTicket || ssoTicket === '')
{
// Configuration is loaded lazily — fetch it up-front so the login
// screen toggle and Turnstile keys are available before we decide.
let configInitError: unknown = null;
try { await GetConfiguration().init(); }
catch(e) { configInitError = e; }
const rawLoginEnabled = GetConfiguration().getValue<unknown>('login.screen.enabled', false);
const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1;
if(configInitError)
{
NitroLogger.error('[LoginScreen] Failed to load renderer-config.json — cannot resolve login.screen.enabled', configInitError);
}
if(!configInitError)
{
let storedRemember: string | null = null;
try { storedRemember = window.localStorage.getItem('nitro.remember.token'); }
catch {}
if(storedRemember)
{
const rememberUrlTemplate = GetConfiguration().getValue<string>('login.remember.endpoint', '/api/auth/remember');
const rememberUrl = GetConfiguration().interpolate(rememberUrlTemplate);
try
{
const response = await fetch(rememberUrl, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'NitroRememberMe'
},
body: JSON.stringify({ rememberToken: storedRemember })
});
if(response.ok)
{
const payload = await response.json();
const ticket = typeof payload.ssoTicket === 'string' ? payload.ssoTicket : '';
if(ticket)
{
window.NitroConfig['sso.ticket'] = ticket;
ssoTicket = ticket;
try
{
if(typeof payload.rememberToken === 'string' && payload.rememberToken.length)
window.localStorage.setItem('nitro.remember.token', payload.rememberToken);
}
catch {}
persistAccessTokenFromPayload(payload);
}
}
else if(response.status === 401)
{
try { window.localStorage.removeItem('nitro.remember.token'); } catch {}
clearAccessToken();
}
}
catch {}
}
}
}
if(ssoTicket)
{
const expiresAt = getAccessTokenExpiresAt();
const nowSec = Math.floor(Date.now() / 1000);
const accessNeedsRefresh = !getAccessToken() || (expiresAt > 0 && expiresAt - nowSec < 60);
if(accessNeedsRefresh)
{
const ssoTokenUrlTemplate = GetConfiguration().getValue<string>('login.sso-token.endpoint', '/api/auth/sso-token');
const ssoTokenUrl = GetConfiguration().interpolate(ssoTokenUrlTemplate);
try
{
const response = await fetch(ssoTokenUrl, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'NitroSsoExchange'
},
body: JSON.stringify({ ssoTicket })
});
if(response.ok)
{
const payload = await response.json();
persistAccessTokenFromPayload(payload);
}
}
catch {}
}
}
if(!ssoTicket || ssoTicket === '')
{
const rawLoginEnabled = GetConfiguration().getValue<unknown>('login.screen.enabled', false);
const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1;
if(loginScreenEnabled)
{
try { await GetLocalizationManager().init(); }
catch(localizationErr) { NitroLogger.error('[LoginScreen] Localization init failed', localizationErr); }
const rememberedSsoTicket = await tryRememberLogin();
setIsReady(false);
setShowLogin(true);
return;
if(rememberedSsoTicket)
{
ssoTicket = rememberedSsoTicket;
applySsoTicket(rememberedSsoTicket);
setShowLogin(false);
}
else
{
setIsReady(false);
setShowLogin(true);
startWarmup(width, height).catch(error => NitroLogger.error('[LoginScreen] Warmup failed', error));
return;
}
}
if(configInitError)
else
{
setHomeUrl(window.location.origin + '/');
setErrorMessage(`Unable to load renderer-config.json.\n${ String((configInitError as Error)?.message ?? configInitError) }`);
setIsReady(false);
setShowLogin(false);
if(configInitError)
{
setHomeUrl(window.location.origin + '/');
setErrorMessage(`Unable to load renderer-config.json.\n${ String((configInitError as Error)?.message ?? configInitError) }`);
setIsReady(false);
setShowLogin(false);
setIsEnteringHotel(false);
return;
}
showSessionExpired();
return;
}
showSessionExpired();
return;
}
const rawUseBackBuffer = window.NitroConfig['renderer.useBackBuffer'];
const useBackBuffer = (rawUseBackBuffer === undefined)
? true
: ((rawUseBackBuffer === true) || (rawUseBackBuffer === 'true'));
const renderer = await PrepareRenderer({
width: Math.floor(width),
height: Math.floor(height),
resolution: window.devicePixelRatio,
autoDensity: true,
backgroundAlpha: 0,
preference: 'webgl',
eventMode: 'none',
failIfMajorPerformanceCaveat: false,
roundPixels: true,
useBackBuffer
});
await GetConfiguration().init();
GetTicker().maxFPS = GetConfiguration().getValue<number>('system.fps.max', 24);
NitroLogger.LOG_DEBUG = GetConfiguration().getValue<boolean>('system.log.debug', true);
NitroLogger.LOG_WARN = GetConfiguration().getValue<boolean>('system.log.warn', false);
NitroLogger.LOG_ERROR = GetConfiguration().getValue<boolean>('system.log.error', false);
NitroLogger.LOG_EVENTS = GetConfiguration().getValue<boolean>('system.log.events', false);
NitroLogger.LOG_PACKETS = GetConfiguration().getValue<boolean>('system.log.packets', false);
const assetUrls = GetConfiguration().getValue<string[]>('preload.assets.urls').map(url => GetConfiguration().interpolate(url)) ?? [];
await Promise.all(
[
GetAssetManager().downloadAssets(assetUrls),
GetLocalizationManager().init(),
GetAvatarRenderManager().init(),
GetSoundManager().init(),
GetSessionDataManager().init(),
GetRoomSessionManager().init()
]
);
const renderer = await startRenderer(width, height);
await startWarmup(width, height);
await GetSessionDataManager().init();
await GetRoomSessionManager().init();
await GetRoomEngine().init();
await GetCommunication().init();
@@ -227,53 +342,30 @@ export const App: FC<{}> = props =>
HabboWebTools.sendHeartBeat();
heartbeatInterval = window.setInterval(() => HabboWebTools.sendHeartBeat(), 10000);
if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current);
heartbeatIntervalRef.current = window.setInterval(() => HabboWebTools.sendHeartBeat(), 10000);
if(rememberRotateIntervalRef.current !== null) window.clearInterval(rememberRotateIntervalRef.current);
const rotateMinutes = Math.max(1, Number(GetConfiguration().getValue<unknown>('login.remember.rotate.interval.minutes', 15)) || 15);
const refreshUrlTemplate = GetConfiguration().getValue<string>('login.refresh.endpoint', '/api/auth/refresh');
const refreshUrl = GetConfiguration().interpolate(refreshUrlTemplate);
const rotateRemember = async () =>
{
let stored: string = null;
try { stored = window.localStorage.getItem('nitro.remember.token'); }
catch { return; }
if(!stored) return;
try
{
const resp = await fetch(refreshUrl, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Requested-With': 'NitroRememberRotate' },
body: JSON.stringify({ rememberToken: stored })
});
if(resp.ok)
{
const payload = await resp.json();
if(typeof payload.rememberToken === 'string' && payload.rememberToken.length)
{
try { window.localStorage.setItem('nitro.remember.token', payload.rememberToken); } catch {}
}
persistAccessTokenFromPayload(payload);
}
else if(resp.status === 401)
{
try { window.localStorage.removeItem('nitro.remember.token'); } catch {}
clearAccessToken();
}
}
catch {}
};
rememberRotateInterval = window.setInterval(rotateRemember, rotateMinutes * 60 * 1000);
if(GetRememberLogin()?.token?.length) rememberRotateIntervalRef.current = window.setInterval(() => rotateRememberLogin(), rotateMinutes * 60 * 1000);
GetTicker().add(ticker => GetRoomEngine().update(ticker));
GetTicker().add(ticker => renderer.render(GetStage()));
GetTicker().add(ticker => GetTexturePool().run());
if(!tickersStartedRef.current)
{
tickersStartedRef.current = true;
GetTicker().add(ticker => GetRoomEngine().update(ticker));
GetTicker().add(ticker => renderer.render(GetStage()));
GetTicker().add(ticker => GetTexturePool().run());
}
setIsReady(true);
setShowLogin(false);
setIsEnteringHotel(false);
}
catch(err)
{
NitroLogger.error(err);
setIsEnteringHotel(false);
showSessionExpired();
}
};
@@ -282,16 +374,16 @@ export const App: FC<{}> = props =>
return () =>
{
if(heartbeatInterval !== null) window.clearInterval(heartbeatInterval);
if(rememberRotateInterval !== null) window.clearInterval(rememberRotateInterval);
if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current);
if(rememberRotateIntervalRef.current !== null) window.clearInterval(rememberRotateIntervalRef.current);
};
}, [ prepareTrigger ]);
}, [ prepareTrigger, startWarmup, startRenderer, tryRememberLogin, applySsoTicket, rotateRememberLogin ]);
return (
<Base fit overflow="hidden" className={ !(window.devicePixelRatio % 1) && 'image-rendering-pixelated' }>
{ !isReady && !showLogin &&
<LoadingView isError={ errorMessage.length > 0 } message={ errorMessage } homeUrl={ homeUrl } /> }
{ !isReady && showLogin && <LoginView onAuthenticated={ handleAuthenticated } /> }
{ !isReady && showLogin && <LoginView onAuthenticated={ handleAuthenticated } isEntering={ isEnteringHotel } /> }
{ isReady && <MainView /> }
<ReconnectView />
<Base id="draggable-windows-container" />
+8
View File
@@ -152,6 +152,10 @@ export class Offer implements IPurchasableOffer
public get localizationName(): string
{
const furnitureProduct = this.product;
if(furnitureProduct?.furnitureData?.name?.length) return furnitureProduct.furnitureData.name;
const productData = GetProductDataForLocalization(this._localizationId);
if(productData) return productData.name;
@@ -161,6 +165,10 @@ export class Offer implements IPurchasableOffer
public get localizationDescription(): string
{
const furnitureProduct = this.product;
if(furnitureProduct?.furnitureData?.description?.length) return furnitureProduct.furnitureData.description;
const productData = GetProductDataForLocalization(this._localizationId);
if(productData) return productData.description;
+5
View File
@@ -11,6 +11,11 @@ export interface IChatEntry
chatType?: number;
imageUrl?: string;
color?: string;
showTranslation?: boolean;
originalMessage?: string;
translatedMessage?: string;
detectedLanguage?: string;
targetLanguage?: string;
roomId: number;
timestamp: string;
type: number;
+12
View File
@@ -48,6 +48,18 @@ export class MessengerThread
return chat;
}
public getChat(chatId: number): MessengerThreadChat
{
for(const group of this._groups)
{
const chat = group.chats.find(existingChat => (existingChat.id === chatId));
if(chat) return chat;
}
return null;
}
private pruneChats(): void
{
let totalChats = this._groups.reduce((total, current) => (total + current.chats.length), 0);
+52
View File
@@ -4,22 +4,49 @@ export class MessengerThreadChat
public static ROOM_INVITE: number = 1;
public static STATUS_NOTIFICATION: number = 2;
public static SECURITY_NOTIFICATION: number = 3;
private static CHAT_ID: number = 0;
private _id: number;
private _type: number;
private _senderId: number;
private _message: string;
private _secondsSinceSent: number;
private _extraData: string;
private _date: Date;
private _showTranslation: boolean;
private _originalMessage: string;
private _translatedMessage: string;
private _detectedLanguage: string;
private _targetLanguage: string;
constructor(senderId: number, message: string, secondsSinceSent: number = 0, extraData: string = null, type: number = 0)
{
this._id = ++MessengerThreadChat.CHAT_ID;
this._type = type;
this._senderId = senderId;
this._message = message;
this._secondsSinceSent = secondsSinceSent;
this._extraData = extraData;
this._date = new Date();
this._showTranslation = false;
this._originalMessage = message;
this._translatedMessage = '';
this._detectedLanguage = '';
this._targetLanguage = '';
}
public setTranslation(originalMessage: string, translatedMessage: string, detectedLanguage: string, targetLanguage: string): void
{
this._showTranslation = true;
this._originalMessage = originalMessage || this._message || '';
this._translatedMessage = translatedMessage || this._originalMessage;
this._detectedLanguage = detectedLanguage || '';
this._targetLanguage = targetLanguage || '';
}
public get id(): number
{
return this._id;
}
public get type(): number
@@ -51,4 +78,29 @@ export class MessengerThreadChat
{
return this._date;
}
public get showTranslation(): boolean
{
return this._showTranslation;
}
public get originalMessage(): string
{
return this._originalMessage;
}
public get translatedMessage(): string
{
return this._translatedMessage;
}
public get detectedLanguage(): string
{
return this._detectedLanguage;
}
public get targetLanguage(): string
{
return this._targetLanguage;
}
}
+6
View File
@@ -65,6 +65,12 @@ export class GroupItem
this.setDescription();
}
public refreshLocalization(): void
{
this.setName();
this.setDescription();
}
public dispose(): void
{
+10
View File
@@ -0,0 +1,10 @@
export interface INickIconItem
{
id: number;
iconKey: string;
displayName: string;
points: number;
pointsType: number;
owned: boolean;
active: boolean;
}
+6
View File
@@ -1,9 +1,15 @@
export interface IPrefixItem
{
id: number;
displayName?: string;
text: string;
color: string;
icon: string;
effect: string;
font?: string;
active: boolean;
isCustom?: boolean;
points?: number;
pointsType?: number;
catalogPrefixId?: number;
}
+1
View File
@@ -4,6 +4,7 @@ export * from './FurnitureUtilities';
export * from './GroupItem';
export * from './IBotItem';
export * from './IFurnitureItem';
export * from './INickIconItem';
export * from './IPetItem';
export * from './IPrefixItem';
export * from './IUnseenItemTracker';
+5
View File
@@ -4,3 +4,8 @@ export function GetConfigurationValue<T = string>(key: string, value: T = null):
{
return GetConfiguration().getValue(key, value);
}
export function GetOptionalConfigurationValue<T = string>(key: string, value: T = null): T
{
return GetConfiguration().definitions.has(key) ? GetConfiguration().getValue(key, value) : value;
}
+7
View File
@@ -12,6 +12,13 @@ export class AvatarInfoUser implements IAvatarInfo
public name: string = '';
public motto: string = '';
public nickIcon: string = '';
public prefixText: string = '';
public prefixColor: string = '';
public prefixIcon: string = '';
public prefixEffect: string = '';
public prefixFont: string = '';
public displayOrder: string = 'icon-prefix-name';
public achievementScore: number = 0;
public backgroundId: number = 0;
public standId: number = 0;
+13 -8
View File
@@ -32,17 +32,16 @@ export class AvatarInfoUtilities
else
{
let furniData: IFurnitureData = null;
const typeId = roomObject.model.getValue<number>(RoomObjectVariable.FURNITURE_TYPE_ID);
const className = roomObject.type;
if(category === RoomObjectCategory.FLOOR)
{
furniData = GetSessionDataManager().getFloorItemData(typeId);
furniData = GetSessionDataManager().getFloorItemDataByName(className);
}
else if(category === RoomObjectCategory.WALL)
{
furniData = GetSessionDataManager().getWallItemData(typeId);
furniData = GetSessionDataManager().getWallItemDataByName(className);
}
if(!furniData) break;
@@ -102,18 +101,17 @@ export class AvatarInfoUtilities
}
else
{
const typeId = model.getValue<number>(RoomObjectVariable.FURNITURE_TYPE_ID);
let furnitureData: IFurnitureData = null;
const className = roomObject.type;
if(category === RoomObjectCategory.FLOOR)
{
furnitureData = GetSessionDataManager().getFloorItemData(typeId);
furnitureData = GetSessionDataManager().getFloorItemDataByName(className);
}
else if(category === RoomObjectCategory.WALL)
{
furnitureData = GetSessionDataManager().getWallItemData(typeId);
furnitureData = GetSessionDataManager().getWallItemDataByName(className);
}
if(furnitureData)
@@ -183,6 +181,13 @@ export class AvatarInfoUtilities
userInfo.isSpectatorMode = roomSession.isSpectator;
userInfo.name = userData.name;
userInfo.motto = userData.custom;
userInfo.nickIcon = userData.nickIcon;
userInfo.prefixText = userData.prefixText;
userInfo.prefixColor = userData.prefixColor;
userInfo.prefixIcon = userData.prefixIcon;
userInfo.prefixEffect = userData.prefixEffect;
userInfo.prefixFont = userData.prefixFont;
userInfo.displayOrder = userData.displayOrder;
userInfo.backgroundId = userData.background;
userInfo.standId = userData.stand;
userInfo.overlayId = userData.overlay;
+12
View File
@@ -11,6 +11,16 @@ export class ChatBubbleMessage
public prefixColor: string = '';
public prefixIcon: string = '';
public prefixEffect: string = '';
public prefixFont: string = '';
public nickIcon: string = '';
public displayOrder: string = 'icon-prefix-name';
public originalText: string = '';
public originalFormattedText: string = '';
public translatedText: string = '';
public translatedFormattedText: string = '';
public showTranslation: boolean = false;
public translationDetectedLanguage: string = '';
public translationTargetLanguage: string = '';
private _top: number = 0;
private _left: number = 0;
@@ -30,6 +40,8 @@ export class ChatBubbleMessage
)
{
this.id = ++ChatBubbleMessage.BUBBLE_COUNTER;
this.originalText = text;
this.originalFormattedText = formattedText;
}
public get top(): number
+1 -1
View File
@@ -2,7 +2,7 @@ export const GetLocalStorage = <T>(key: string) =>
{
try
{
JSON.parse(window.localStorage.getItem(key)) as T ?? null;
return JSON.parse(window.localStorage.getItem(key)) as T ?? null;
}
catch (e)
{
+1
View File
@@ -3,4 +3,5 @@ export class LocalStorageKeys
public static CATALOG_PLACE_MULTIPLE_OBJECTS: string = 'catalogPlaceMultipleObjects';
public static CATALOG_SKIP_PURCHASE_CONFIRMATION: string = 'catalogSkipPurchaseConfirmation';
public static CHAT_WINDOW_ENABLED: string = 'chatWindowEnabled';
public static CHAT_TRANSLATION_SETTINGS: string = 'chatTranslationSettings';
}
+182 -8
View File
@@ -1,11 +1,41 @@
export const PRESET_PREFIX_EFFECTS: { id: string; label: string; icon: string }[] = [
{ id: '', label: 'None', icon: '—' },
{ id: 'glow', label: 'Glow', icon: '✨' },
{ id: 'shadow', label: 'Shadow', icon: '🌑' },
{ id: 'italic', label: 'Italic', icon: '𝑰' },
{ id: 'outline', label: 'Outline', icon: '🔲' },
{ id: 'pulse', label: 'Pulse', icon: '💫' },
{ id: 'bold-glow', label: 'Neon', icon: '💡' },
export type PrefixFontTier = 'basic' | 'premium';
export type PrefixFontOption = {
id: string;
label: string;
family: string;
tier: PrefixFontTier;
};
export const PRESET_PREFIX_FONTS: PrefixFontOption[] = [
{ id: '', label: 'Default', family: 'Ubuntu, sans-serif', tier: 'basic' },
{ id: 'pixel', label: 'Pixelify Sans', family: '"Pixelify Sans", cursive', tier: 'premium' },
{ id: 'cherry', label: 'Cherry Bomb One', family: '"Cherry Bomb One", cursive', tier: 'premium' },
{ id: 'vampiro', label: 'Vampiro One', family: '"Vampiro One", cursive', tier: 'premium' }
];
export const PRESET_PREFIX_EFFECTS: { id: string; label: string; icon: string; tier: 'basic' | 'premium' }[] = [
{ id: '', label: 'None', icon: '-', tier: 'basic' },
{ id: 'glow', label: 'Glow', icon: '*', tier: 'basic' },
{ id: 'shadow', label: 'Shadow', icon: 'S', tier: 'basic' },
{ id: 'italic', label: 'Italic', icon: 'I', tier: 'basic' },
{ id: 'outline', label: 'Outline', icon: 'O', tier: 'basic' },
{ id: 'underline', label: 'Underline', icon: 'U', tier: 'basic' },
{ id: 'pulse', label: 'Pulse', icon: 'P', tier: 'basic' },
{ id: 'bounce', label: 'Bounce', icon: 'B', tier: 'basic' },
{ id: 'wave', label: 'Wave', icon: 'W', tier: 'basic' },
{ id: 'shake', label: 'Shake', icon: '!', tier: 'basic' },
{ id: 'discord-neon', label: 'Discord Neon', icon: 'D', tier: 'premium' },
{ id: 'cartoon', label: 'Cartoon', icon: 'C', tier: 'premium' },
{ id: 'toon', label: 'Toon', icon: 'T', tier: 'premium' },
{ id: 'pop', label: 'Pop', icon: 'P+', tier: 'premium' },
{ id: 'bold-glow', label: 'Neon', icon: 'N', tier: 'premium' },
{ id: 'rainbow', label: 'Rainbow', icon: 'R', tier: 'premium' },
{ id: 'frost', label: 'Frost', icon: 'F', tier: 'premium' },
{ id: 'gold', label: 'Gold Shine', icon: 'G', tier: 'premium' },
{ id: 'glitch', label: 'Glitch', icon: 'X', tier: 'premium' },
{ id: 'fire', label: 'Fire', icon: 'H', tier: 'premium' },
{ id: 'matrix', label: 'Matrix', icon: 'M', tier: 'premium' },
{ id: 'sparkle', label: 'Sparkle', icon: '+', tier: 'premium' }
];
export const parsePrefixColors = (text: string, colorStr: string): string[] =>
@@ -16,6 +46,15 @@ export const parsePrefixColors = (text: string, colorStr: string): string[] =>
return [ ...text ].map((_, i) => colors[Math.min(i, colors.length - 1)]);
};
export const getPrefixFontStyle = (font: string): Record<string, string> =>
{
const option = PRESET_PREFIX_FONTS.find(entry => entry.id === font);
if(!option || !option.id.length) return {};
return { fontFamily: option.family };
};
export const getPrefixEffectStyle = (effect: string, color?: string): Record<string, string | number> =>
{
const baseColor = color || '#FFFFFF';
@@ -33,13 +72,95 @@ export const getPrefixEffectStyle = (effect: string, color?: string): Record<str
WebkitTextStroke: '0.5px rgba(0,0,0,0.6)',
textShadow: '1px 1px 0 rgba(0,0,0,0.3), -1px -1px 0 rgba(0,0,0,0.3), 1px -1px 0 rgba(0,0,0,0.3), -1px 1px 0 rgba(0,0,0,0.3)'
};
case 'underline':
return {
textDecoration: 'underline',
textDecorationThickness: '2px',
textUnderlineOffset: '2px'
};
case 'pulse':
return { animation: 'prefix-pulse 1.5s ease-in-out infinite' };
case 'bounce':
return {
animation: 'prefix-bounce 1.2s ease-in-out infinite',
display: 'inline-block'
};
case 'wave':
return {
animation: 'prefix-wave 1.6s ease-in-out infinite',
display: 'inline-block',
transformOrigin: 'center bottom'
};
case 'shake':
return {
animation: 'prefix-shake 0.9s ease-in-out infinite',
display: 'inline-block'
};
case 'discord-neon':
return {
textShadow: `0 0 5px ${ baseColor }, 0 0 10px ${ baseColor }, 0 0 18px ${ baseColor }90`,
fontWeight: 900,
letterSpacing: '0.2px'
};
case 'cartoon':
return {
WebkitTextStroke: '1px rgba(0,0,0,0.75)',
textShadow: '2px 2px 0 rgba(0,0,0,0.55)',
fontWeight: 900
};
case 'toon':
return {
WebkitTextStroke: '0.8px rgba(0,0,0,0.65)',
textShadow: '1px 2px 0 rgba(0,0,0,0.45)',
fontWeight: 900,
transform: 'skew(-4deg)'
};
case 'pop':
return {
textShadow: '0 2px 0 rgba(0,0,0,0.28), 0 4px 8px rgba(0,0,0,0.2)',
fontWeight: 900,
letterSpacing: '0.3px'
};
case 'bold-glow':
return {
textShadow: `0 0 4px ${ baseColor }, 0 0 8px ${ baseColor }, 0 0 16px ${ baseColor }60`,
fontWeight: 900
};
case 'rainbow':
return {
animation: 'prefix-rainbow 2.6s linear infinite',
textShadow: '0 0 8px rgba(255,255,255,0.35)'
};
case 'frost':
return {
textShadow: '0 0 4px rgba(255,255,255,0.75), 0 0 10px rgba(125,211,252,0.45)',
filter: 'drop-shadow(0 0 2px rgba(191,219,254,0.75))'
};
case 'gold':
return {
animation: 'prefix-gold 2s ease-in-out infinite',
textShadow: '0 0 6px rgba(255,215,0,0.45), 0 0 14px rgba(255,193,7,0.35)'
};
case 'glitch':
return {
animation: 'prefix-glitch 0.8s steps(2, end) infinite',
textShadow: '-1px 0 rgba(255,0,102,0.75), 1px 0 rgba(0,255,255,0.75)'
};
case 'fire':
return {
animation: 'prefix-fire 1.1s ease-in-out infinite',
textShadow: '0 0 5px rgba(255,120,0,0.7), 0 -1px 8px rgba(255,200,0,0.55), 0 -2px 12px rgba(255,60,0,0.45)'
};
case 'matrix':
return {
animation: 'prefix-matrix 1.8s linear infinite',
textShadow: '0 0 6px rgba(57,255,20,0.65), 0 0 12px rgba(57,255,20,0.35)'
};
case 'sparkle':
return {
animation: 'prefix-sparkle 1.4s ease-in-out infinite',
textShadow: `0 0 4px ${ baseColor }, 0 0 10px ${ baseColor }80, 0 0 16px rgba(255,255,255,0.45)`
};
default:
return {};
}
@@ -50,4 +171,57 @@ export const PREFIX_EFFECT_KEYFRAMES = `
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes prefix-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-2px); }
}
@keyframes prefix-wave {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(-5deg); }
75% { transform: rotate(5deg); }
}
@keyframes prefix-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-1px); }
40% { transform: translateX(1px); }
60% { transform: translateX(-1px); }
80% { transform: translateX(1px); }
}
@keyframes prefix-rainbow {
0% { filter: hue-rotate(0deg); }
100% { filter: hue-rotate(360deg); }
}
@keyframes prefix-gold {
0%, 100% { filter: brightness(1); }
50% { filter: brightness(1.25) saturate(1.2); }
}
@keyframes prefix-glitch {
0%, 100% { transform: translate(0, 0); }
20% { transform: translate(-1px, 0); }
40% { transform: translate(1px, 0); }
60% { transform: translate(-1px, 1px); }
80% { transform: translate(1px, -1px); }
}
@keyframes prefix-fire {
0%, 100% { transform: translateY(0); filter: brightness(1); }
50% { transform: translateY(-1px); filter: brightness(1.15); }
}
@keyframes prefix-matrix {
0% { opacity: 0.85; letter-spacing: 0; }
50% { opacity: 1; letter-spacing: 0.4px; }
100% { opacity: 0.85; letter-spacing: 0; }
}
@keyframes prefix-sparkle {
0%, 100% { opacity: 1; filter: brightness(1); }
50% { opacity: 0.92; filter: brightness(1.35); }
}
`;
+82
View File
@@ -0,0 +1,82 @@
export interface RememberLoginData
{
token?: string;
ssoTicket?: string;
expiresAt: number;
username?: string;
}
const REMEMBER_LOGIN_KEY = 'nitro.auth.remember';
const LEGACY_REMEMBER_LOGIN_KEY = 'nitro.remember.token';
const DEFAULT_REMEMBER_SECONDS = 30 * 24 * 60 * 60;
export const GetRememberLogin = (): RememberLoginData | null =>
{
try
{
const data = JSON.parse(window.localStorage.getItem(REMEMBER_LOGIN_KEY) || 'null') as RememberLoginData | null;
if(!data?.token?.length && !data?.ssoTicket?.length) return null;
if(data.expiresAt && ((data.expiresAt * 1000) <= Date.now()))
{
ClearRememberLogin();
return null;
}
return data;
}
catch
{
try
{
const legacyToken = window.localStorage.getItem(LEGACY_REMEMBER_LOGIN_KEY) || '';
if(!legacyToken.length) return null;
const data: RememberLoginData = {
token: legacyToken,
expiresAt: Math.floor(Date.now() / 1000) + DEFAULT_REMEMBER_SECONDS
};
SetRememberLogin(data);
return data;
}
catch
{
return null;
}
}
};
export const SetRememberLogin = (data: RememberLoginData): void =>
{
if(!data?.token?.length && !data?.ssoTicket?.length) return;
try { window.localStorage.setItem(REMEMBER_LOGIN_KEY, JSON.stringify(data)); }
catch {}
};
export const ClearRememberLogin = (): void =>
{
try
{
window.localStorage.removeItem(REMEMBER_LOGIN_KEY);
window.localStorage.removeItem(LEGACY_REMEMBER_LOGIN_KEY);
}
catch {}
};
export const StoreRememberLoginFromPayload = (payload: Record<string, unknown>, username?: string, ssoTicket?: string): void =>
{
const token = typeof payload.rememberToken === 'string' ? payload.rememberToken : '';
const rawExpiresAt = (payload.rememberExpiresAt ?? payload.expiresAt);
const parsedExpiresAt = typeof rawExpiresAt === 'number' ? rawExpiresAt : Number(rawExpiresAt || 0);
const expiresAt = (Number.isFinite(parsedExpiresAt) && parsedExpiresAt > 0)
? parsedExpiresAt
: Math.floor(Date.now() / 1000) + DEFAULT_REMEMBER_SECONDS;
if(!token.length && !ssoTicket?.length) return;
SetRememberLogin({ token: token || undefined, ssoTicket: ssoTicket || undefined, expiresAt, username });
};
+43 -1
View File
@@ -39,11 +39,53 @@ const encodeHTML = (str: string) =>
});
};
const formatTag = (content: string, tag: string, replacement: (value: string) => string) =>
{
const pattern = new RegExp(`\\[${ tag }\\]([\\s\\S]*?)\\[\\/${ tag }\\]`, 'gi');
let previous = '';
let next = content;
let guard = 0;
while((previous !== next) && (guard < 20))
{
previous = next;
next = next.replace(pattern, (match, value) => replacement(value));
guard++;
}
return next;
};
const applyWiredTextMarkup = (content: string) =>
{
const colorStyles: Record<string, string> = {
green: '#008000',
cyan: '#008b8b',
red: '#d60000',
blue: '#005dff',
purple: '#7d31b8'
};
let result = content;
result = formatTag(result, 'b', value => `<strong>${ value }</strong>`);
result = formatTag(result, 'i', value => `<em>${ value }</em>`);
result = formatTag(result, 'u', value => `<u>${ value }</u>`);
Object.entries(colorStyles).forEach(([ tag, color ]) =>
{
result = formatTag(result, tag, value => `<span style="color:${ color }">${ value }</span>`);
});
return result;
};
export const RoomChatFormatter = (content: string) =>
{
let result = '';
content = encodeHTML(content);
content = applyWiredTextMarkup(content);
//content = (joypixels.shortnameToUnicode(content) as string)
if(content.startsWith('@') && content.indexOf('@', 1) > -1)
@@ -73,5 +115,5 @@ export const RoomChatFormatter = (content: string) =>
result = content;
}
return result;
return result.replace(/\r\n|\r|\n/g, '<br />');
};
+1
View File
@@ -14,6 +14,7 @@ export * from './PlaySound';
export * from './PrefixUtils';
export * from './ProductImageUtility';
export * from './Randomizer';
export * from './RememberLogin';
export * from './RoomChatFormatter';
export * from './SanitizeHtml';
export * from './SetLocalStorage';
+37 -1
View File
@@ -10,6 +10,10 @@ export class WiredSelectionVisualizer
lineColor: [ 0.45, 0.78, 1 ],
color: [ 0.20, 0.52, 0.95 ]
});
private static _variableHighlightShader: WiredFilter = new WiredFilter({
lineColor: [ 0.52, 0.92, 1 ],
color: [ 0.20, 0.70, 1 ]
});
public static show(furniId: number): void
{
@@ -73,12 +77,37 @@ export class WiredSelectionVisualizer
if(roomId < 0) return;
const roomObjects = roomEngine.getRoomObjects(roomId, RoomObjectCategory.FLOOR);
const roomObjects = [
...roomEngine.getRoomObjects(roomId, RoomObjectCategory.FLOOR),
...roomEngine.getRoomObjects(roomId, RoomObjectCategory.WALL),
...roomEngine.getRoomObjects(roomId, RoomObjectCategory.UNIT)
];
for(const roomObject of roomObjects)
{
WiredSelectionVisualizer.clearSelectionShader(roomObject, WiredSelectionVisualizer._selectionShader);
WiredSelectionVisualizer.clearSelectionShader(roomObject, WiredSelectionVisualizer._secondarySelectionShader);
WiredSelectionVisualizer.clearSelectionShader(roomObject, WiredSelectionVisualizer._variableHighlightShader);
}
}
public static applyVariableHighlightToObjects(objects: Array<{ category: number; objectId: number; }>): void
{
for(const object of objects)
{
WiredSelectionVisualizer.applySelectionShader(
WiredSelectionVisualizer.getRoomObjectByCategory(object.objectId, object.category),
WiredSelectionVisualizer._variableHighlightShader);
}
}
public static clearVariableHighlightFromObjects(objects: Array<{ category: number; objectId: number; }>): void
{
for(const object of objects)
{
WiredSelectionVisualizer.clearSelectionShader(
WiredSelectionVisualizer.getRoomObjectByCategory(object.objectId, object.category),
WiredSelectionVisualizer._variableHighlightShader);
}
}
@@ -89,6 +118,13 @@ export class WiredSelectionVisualizer
return roomEngine.getRoomObject(roomEngine.activeRoomId, objectId, RoomObjectCategory.FLOOR);
}
private static getRoomObjectByCategory(objectId: number, category: number): IRoomObject
{
const roomEngine = GetRoomEngine();
return roomEngine.getRoomObject(roomEngine.activeRoomId, objectId, category);
}
private static applySelectionShader(roomObject: IRoomObject, filter: WiredFilter): void
{
if(!roomObject) return;
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 904 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

@@ -0,0 +1,19 @@
const rawNickIcons = import.meta.glob('./*.gif', { eager: true, import: 'default' }) as Record<string, string>;
export const NICK_ICON_URLS: Record<string, string> = Object.entries(rawNickIcons).reduce((accumulator, [ path, url ]) =>
{
const filename = path.split('/').pop() || '';
const stem = filename.replace(/\.gif$/i, '');
if(stem) accumulator[stem] = url;
if(filename) accumulator[filename] = url;
return accumulator;
}, {} as Record<string, string>);
export const GetNickIconUrl = (iconKey: string) =>
{
if(!iconKey) return '';
return (NICK_ICON_URLS[iconKey] || NICK_ICON_URLS[iconKey.toLowerCase()] || '');
};
+75
View File
@@ -0,0 +1,75 @@
import { getClientMode, installSecureFetch, secureUrl } from './secure-assets';
installSecureFetch();
const setBootDebug = (message: string) =>
{
try
{
(window as any).__nitroBootDebug = message;
const secureNode = document.getElementById('nitro-secure-debug');
if(secureNode) secureNode.textContent = `${ secureNode.textContent }\n${ message }`;
}
catch {}
};
setBootDebug('boot: secure fetch installed');
const loadClientMode = async () =>
{
try
{
if((window as any).__nitroClientMode) return;
const url = new URL('configuration/client-mode.json', `${ window.location.origin }/`);
url.searchParams.set('v', Date.now().toString(36));
const response = await fetch(url.toString());
if(!response.ok) throw new Error(`HTTP ${ response.status }`);
(window as any).__nitroClientMode = await response.json();
setBootDebug('boot: client-mode loaded');
}
catch(error)
{
setBootDebug(`boot: client-mode fallback ${ error?.message || error }`);
}
};
await loadClientMode();
const search = new URLSearchParams(window.location.search);
const clientMode = getClientMode();
const cacheBustUrl = (path: string): string =>
{
const url = new URL(path.replace(/^\/+/, ''), `${ window.location.origin }/`);
url.searchParams.set('v', Date.now().toString(36));
return url.toString();
};
(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || window.location.origin;
(window as any).NitroClientMode = clientMode;
(window as any).NitroConfig = {
'config.urls': [
clientMode.secureAssetsEnabled ? secureUrl('config', 'renderer-config.json', true) : cacheBustUrl('configuration/renderer-config.json'),
clientMode.secureAssetsEnabled ? secureUrl('config', 'ui-config.json', true) : cacheBustUrl('configuration/ui-config.json')
],
'sso.ticket': search.get('sso') || null,
'forward.type': search.get('room') ? 2 : -1,
'forward.id': search.get('room') || 0,
'friend.id': search.get('friend') || 0
};
setBootDebug('boot: NitroConfig assigned');
import('./index')
.then(() => setBootDebug('boot: app bundle imported'))
.catch(error =>
{
setBootDebug(`boot: import failed ${ error?.message || error }`);
throw error;
});
+102
View File
@@ -0,0 +1,102 @@
import { FC, useMemo } from 'react';
import { GetNickIconUrl } from '../assets/images/user_custom/nick_icons';
import { PREFIX_EFFECT_KEYFRAMES, getPrefixEffectStyle, getPrefixFontStyle, parsePrefixColors } from '../api';
interface UserIdentityViewProps
{
username: string;
nickIcon?: string;
prefixText?: string;
prefixColor?: string;
prefixIcon?: string;
prefixEffect?: string;
prefixFont?: string;
displayOrder?: string;
showColon?: boolean;
className?: string;
iconClassName?: string;
nameClassName?: string;
prefixClassName?: string;
}
const sanitizeDisplayOrder = (displayOrder?: string) =>
{
const fallback = [ 'icon', 'prefix', 'name' ];
if(!displayOrder?.length) return fallback;
const parts = displayOrder.toLowerCase().split('-');
if(parts.length !== 3) return fallback;
const unique = new Set(parts);
if(unique.size !== 3) return fallback;
if(parts.some(part => !fallback.includes(part))) return fallback;
return parts;
};
export const UserIdentityView: FC<UserIdentityViewProps> = ({
username = '',
nickIcon = '',
prefixText = '',
prefixColor = '',
prefixIcon = '',
prefixEffect = '',
prefixFont = '',
displayOrder = 'icon-prefix-name',
showColon = false,
className = '',
iconClassName = 'inline-block w-auto h-auto align-[-1px]',
nameClassName = 'username font-bold',
prefixClassName = ''
}) =>
{
const nickIconUrl = GetNickIconUrl(nickIcon);
const prefixColors = useMemo(() => parsePrefixColors(prefixText, prefixColor), [ prefixText, prefixColor ]);
const hasMultiColor = (prefixColors.length > 1) && (new Set(prefixColors).size > 1);
const prefixStyle = getPrefixEffectStyle(prefixEffect, prefixColors[0] || '#FFFFFF');
const prefixFontStyle = getPrefixFontStyle(prefixFont);
const displayParts = sanitizeDisplayOrder(displayOrder);
const parts = displayParts.map(part =>
{
switch(part)
{
case 'icon':
if(!nickIconUrl) return null;
return <img key="identity-icon" className={ `${ iconClassName } mr-1` } src={ nickIconUrl } alt="" />;
case 'prefix':
if(!prefixText?.length) return null;
return (
<span key="identity-prefix" className={ `prefix inline-block whitespace-nowrap font-bold mr-1 ${ prefixClassName }` } style={ { ...prefixFontStyle, ...prefixStyle } }>
{ prefixIcon && <span className="mr-0.5 text-[13px] leading-none">{ prefixIcon }</span> }
<span style={ hasMultiColor ? { ...prefixFontStyle, ...prefixStyle } : { ...prefixFontStyle, ...prefixStyle, color: prefixColors[0] || '#FFFFFF' } }>
{'{'}
{ hasMultiColor
? [ ...prefixText ].map((char, index) => (
<span key={ index } style={ { ...prefixFontStyle, color: prefixColors[index] || prefixColors[prefixColors.length - 1], ...getPrefixEffectStyle(prefixEffect, prefixColors[index]) } }>{ char }</span>
))
: prefixText }
{'}'}
</span>
</span>
);
case 'name':
return <span key="identity-name" className={ `${ nameClassName } whitespace-nowrap` }>{ username }{ showColon ? ':' : '' }{ showColon ? ' ' : '' }</span>;
default:
return null;
}
}).filter(Boolean);
return (
<span className={ `inline-flex items-center whitespace-nowrap align-middle ${ className }` }>
{ !!prefixEffect && <style>{ PREFIX_EFFECT_KEYFRAMES }</style> }
{ parts }
</span>
);
};
+1
View File
@@ -11,6 +11,7 @@ export * from './GridContext';
export * from './HorizontalRule';
export * from './InfiniteScroll';
export * from './Text';
export * from './UserIdentityView';
export * from './card';
export * from './card/accordion';
export * from './card/tabs';
+17
View File
@@ -10,6 +10,7 @@ import { CameraWidgetView } from './camera/CameraWidgetView';
import { CampaignView } from './campaign/CampaignView';
import { CatalogView } from './catalog/CatalogView';
import { ChatHistoryView } from './chat-history/ChatHistoryView';
import { CustomizeNickIconView } from './customize/CustomizeNickIconView';
import { FloorplanEditorView } from './floorplan-editor/FloorplanEditorView';
import { FurniEditorView } from './furni-editor/FurniEditorView';
import { FriendsView } from './friends/FriendsView';
@@ -30,6 +31,8 @@ import { GoogleAdsView } from './ads/GoogleAdsView';
import { RightSideView } from './right-side/RightSideView';
import { RoomView } from './room/RoomView';
import { ToolbarView } from './toolbar/ToolbarView';
import { TranslationBootstrap } from './translation/TranslationBootstrap';
import { TranslationSettingsView } from './translation/TranslationSettingsView';
import { UserProfileView } from './user-profile/UserProfileView';
import { UserSettingsView } from './user-settings/UserSettingsView';
import { WiredView } from './wired/WiredView';
@@ -39,6 +42,7 @@ export const MainView: FC<{}> = props =>
{
const [ isReady, setIsReady ] = useState(false);
const [ landingViewVisible, setLandingViewVisible ] = useState(true);
const [ localizationVersion, setLocalizationVersion ] = useState(0);
useNitroEvent<RoomSessionEvent>(RoomSessionEvent.CREATED, event => setLandingViewVisible(false));
useNitroEvent<RoomSessionEvent>(RoomSessionEvent.ENDED, event => setLandingViewVisible(event.openLandingView));
@@ -88,8 +92,18 @@ export const MainView: FC<{}> = props =>
return () => RemoveLinkEventTracker(linkTracker);
}, []);
useEffect(() =>
{
const refreshLocalization = () => setLocalizationVersion(value => (value + 1));
window.addEventListener('nitro-localization-updated', refreshLocalization);
return () => window.removeEventListener('nitro-localization-updated', refreshLocalization);
}, []);
return (
<>
<div className="hidden" data-localization-version={ localizationVersion } />
<AnimatePresence>
{ landingViewVisible &&
<motion.div
@@ -100,11 +114,13 @@ export const MainView: FC<{}> = props =>
</motion.div> }
</AnimatePresence>
<ToolbarView isInRoom={ !landingViewVisible } />
<TranslationBootstrap />
<GoogleAdsView />
<ModToolsView />
<WiredCreatorToolsView />
<RoomView />
<ChatHistoryView />
<CustomizeNickIconView />
<WiredView />
<AvatarEditorView />
<BadgeCreatorView />
@@ -117,6 +133,7 @@ export const MainView: FC<{}> = props =>
<FriendsView />
<RightSideView />
<UserSettingsView />
<TranslationSettingsView />
<UserProfileView />
<GroupsView />
<GroupForumView />
+3 -2
View File
@@ -1,6 +1,7 @@
import { FC, useEffect, useRef, useState } from 'react';
import { GetConfigurationValue } from '../../api';
import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
import { configFileUrl } from '../../secure-assets';
interface AdsenseConfig {
slot: string;
@@ -70,7 +71,7 @@ export const GoogleAdsView: FC<{}> = () => {
try {
const [ adsTxtRes, configRes ] = await Promise.all([
fetch('/ads.txt', { cache: 'no-cache' }),
fetch('/adsense.json', { cache: 'no-cache' })
fetch(configFileUrl('adsense.json', true), { cache: 'no-cache' })
]);
if (!adsTxtRes.ok) throw new Error(`ads.txt ${ adsTxtRes.status }`);
@@ -156,7 +157,7 @@ export const GoogleAdsView: FC<{}> = () => {
data-full-width-responsive={ (config.fullWidthResponsive ?? true) ? 'true' : 'false' }
/> }
{ !loadError && publisherId && config && !config.slot &&
<div className="text-xs text-gray-500 text-center px-2">Ad slot not configured in adsense.json</div> }
<div className="text-xs text-gray-500 text-center px-2">Ad slot not configured in configuration/adsense.json</div> }
</div>
</NitroCardContentView>
</NitroCardView>
+27 -8
View File
@@ -1,7 +1,8 @@
import { Dispatch, FC, SetStateAction, useCallback, useMemo, useState } from 'react';
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react';
import { Base, Grid, Flex, NitroCardView, NitroCardHeaderView, NitroCardTabsView, NitroCardTabsItemView, NitroCardContentView, Text } from '../../common';
import { useRoom } from '../../hooks';
import { GetConfigurationValue } from '../../api';
import { GetOptionalConfigurationValue } from '../../api';
import { configFileUrl } from '../../secure-assets';
interface ItemData {
id: number;
@@ -22,6 +23,8 @@ interface BackgroundsViewProps {
const TABS = ['backgrounds', 'stands', 'overlays', 'cards'] as const;
type TabType = typeof TABS[number];
type RemoteData = Partial<Record<'backgrounds.data' | 'stands.data' | 'overlays.data' | 'cards.data', any[]>>;
export const BackgroundsView: FC<BackgroundsViewProps> = ({
setIsVisible,
selectedBackground,
@@ -34,20 +37,36 @@ export const BackgroundsView: FC<BackgroundsViewProps> = ({
setSelectedCardBackground
}) => {
const [activeTab, setActiveTab] = useState<TabType>('backgrounds');
const [remoteData, setRemoteData] = useState<RemoteData | null>(null);
const { roomSession } = useRoom();
useEffect(() => {
let cancelled = false;
fetch(configFileUrl('infostand_backgrounds.json'), { credentials: 'omit' })
.then(r => r.ok ? r.json() : null)
.then(json => { if(!cancelled && json && typeof json === 'object') setRemoteData(json as RemoteData); })
.catch(() => {});
return () => { cancelled = true; };
}, []);
const processData = useCallback((configData: any[], idField: string): ItemData[] => {
if (!configData?.length) return [];
return configData.map(item => ({ id: item[idField] }));
return configData.map(item => ({ id: typeof item === 'number' ? item : item[idField] }));
}, []);
const readData = useCallback((key: 'backgrounds.data' | 'stands.data' | 'overlays.data' | 'cards.data'): any[] => {
const fromRemote = remoteData?.[key];
if(Array.isArray(fromRemote)) return fromRemote;
return GetOptionalConfigurationValue<any[]>(key, []) || [];
}, [remoteData]);
const allData = useMemo(() => ({
backgrounds: processData(GetConfigurationValue('backgrounds.data'), 'backgroundId'),
stands: processData(GetConfigurationValue('stands.data'), 'standId'),
overlays: processData(GetConfigurationValue('overlays.data'), 'overlayId'),
cards: processData(GetConfigurationValue('cards.data') || GetConfigurationValue('backgrounds.data'), 'backgroundId')
}), [processData]);
backgrounds: processData(readData('backgrounds.data'), 'backgroundId'),
stands: processData(readData('stands.data'), 'standId'),
overlays: processData(readData('overlays.data'), 'overlayId'),
cards: processData(readData('cards.data').length ? readData('cards.data') : readData('backgrounds.data'), 'backgroundId')
}), [processData, readData]);
const handleSelection = useCallback((id: number) => {
if (!roomSession) return;
+14 -2
View File
@@ -1,13 +1,25 @@
import { FC } from 'react';
import { GetConfigurationValue } from '../../api';
import { useCatalog } from '../../hooks';
import { CatalogClassicView } from './CatalogClassicView';
import { CatalogModernView } from './CatalogModernView';
export const CatalogView: FC<{}> = () =>
{
const { catalogLocalizationVersion = 0 } = useCatalog();
const useNewStyle = GetConfigurationValue<boolean>('catalog.style.new', false);
if(useNewStyle) return <CatalogModernView />;
if(useNewStyle) return (
<>
<div className="hidden" data-catalog-localization-version={ catalogLocalizationVersion } />
<CatalogModernView />
</>
);
return <CatalogClassicView />;
return (
<>
<div className="hidden" data-catalog-localization-version={ catalogLocalizationVersion } />
<CatalogClassicView />
</>
);
};
@@ -1,17 +1,24 @@
import { GetSessionDataManager, IFurnitureData } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FaSearch, FaTimes } from 'react-icons/fa';
import { CatalogPage, CatalogType, FilterCatalogNode, FurnitureOffer, GetOfferNodes, ICatalogNode, ICatalogPage, IPurchasableOffer, LocalizeText, PageLocalization, SearchResult } from '../../../../../api';
import { CatalogPage, CatalogType, FilterCatalogNode, FurnitureOffer, ICatalogNode, ICatalogPage, IPurchasableOffer, LocalizeText, PageLocalization, SearchResult } from '../../../../../api';
import { useCatalog } from '../../../../../hooks';
export const CatalogSearchView: FC<{}> = () =>
{
const [ searchValue, setSearchValue ] = useState('');
const { currentType = null, rootNode = null, offersToNodes = null, searchResult = null, setSearchResult = null, setCurrentPage = null } = useCatalog();
const { currentType = null, rootNode = null, searchResult = null, setSearchResult = null, setCurrentPage = null } = useCatalog();
const normalizeSearchText = (value: string) => (value || '')
.toLocaleLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim();
useEffect(() =>
{
let search = searchValue?.toLocaleLowerCase().replace(' ', '');
const search = normalizeSearchText(searchValue);
if(!search || !search.length)
{
@@ -22,7 +29,7 @@ export const CatalogSearchView: FC<{}> = () =>
const timeout = setTimeout(() =>
{
if(!offersToNodes || !rootNode) return;
if(!rootNode) return;
const furnitureDatas = GetSessionDataManager().getAllFurnitureData();
@@ -39,34 +46,35 @@ export const CatalogSearchView: FC<{}> = () =>
if((currentType === CatalogType.NORMAL) && furniture.excludeDynamic) continue;
const searchValues = [ furniture.className || '', furniture.name || '', furniture.description || '' ].join(' ').replace(/ /gi, '').toLowerCase();
const name = normalizeSearchText(furniture.name || '');
const matchesSearch = name.includes(search);
if((currentType === CatalogType.BUILDER) && (furniture.purchaseOfferId === -1) && (furniture.rentOfferId === -1))
{
if((furniture.furniLine !== '') && (foundFurniLines.indexOf(furniture.furniLine) < 0))
{
if(searchValues.indexOf(search) >= 0) foundFurniLines.push(furniture.furniLine);
if(matchesSearch) foundFurniLines.push(furniture.furniLine);
}
}
else
else if(matchesSearch)
{
const foundNodes = [
...GetOfferNodes(offersToNodes, furniture.purchaseOfferId),
...GetOfferNodes(offersToNodes, furniture.rentOfferId)
];
foundFurniture.push(furniture);
if(foundNodes.length)
if(furniture.furniLine && furniture.furniLine.length && (foundFurniLines.indexOf(furniture.furniLine) < 0))
{
if(searchValues.indexOf(search) >= 0) foundFurniture.push(furniture);
if(foundFurniture.length === 250) break;
foundFurniLines.push(furniture.furniLine);
}
if(foundFurniture.length === 250) break;
}
}
const offers: IPurchasableOffer[] = [];
for(const furniture of foundFurniture) offers.push(new FurnitureOffer(furniture));
for(const furniture of foundFurniture)
{
offers.push(new FurnitureOffer(furniture));
}
let nodes: ICatalogNode[] = [];
@@ -77,7 +85,7 @@ export const CatalogSearchView: FC<{}> = () =>
}, 300);
return () => clearTimeout(timeout);
}, [ offersToNodes, currentType, rootNode, searchValue, setCurrentPage, setSearchResult ]);
}, [ currentType, rootNode, searchValue, setCurrentPage, setSearchResult ]);
return (
<div className="relative w-full">
@@ -1,5 +1,5 @@
import { FC, useCallback, useEffect, useRef, useState } from 'react';
import { IPurchasableOffer, ProductTypeEnum } from '../../../../../api';
import { IPurchasableOffer } from '../../../../../api';
import { AutoGrid, AutoGridProps } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogAdmin } from '../../../CatalogAdminContext';
@@ -13,7 +13,7 @@ interface CatalogItemGridWidgetViewProps extends AutoGridProps
export const CatalogItemGridWidgetView: FC<CatalogItemGridWidgetViewProps> = props =>
{
const { columnCount = 5, children = null, ...rest } = props;
const { currentOffer = null, setCurrentOffer = null, currentPage = null, setPurchaseOptions = null } = useCatalog();
const { currentOffer = null, currentPage = null, selectCatalogOffer = null } = useCatalog();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const elementRef = useRef<HTMLDivElement>();
@@ -29,23 +29,7 @@ export const CatalogItemGridWidgetView: FC<CatalogItemGridWidgetViewProps> = pro
const selectOffer = (offer: IPurchasableOffer) =>
{
offer.activate();
if(offer.isLazy) return;
setCurrentOffer(offer);
if(offer.product && (offer.product.productType === ProductTypeEnum.WALL))
{
setPurchaseOptions(prevValue =>
{
const newValue = { ...prevValue };
newValue.extraData = (offer.product.extraParam || null);
return newValue;
});
}
selectCatalogOffer(offer);
};
const handleDragStart = useCallback((index: number) =>
@@ -0,0 +1,584 @@
import { AddLinkEventTracker, ILinkEventTracker, PurchaseCatalogPrefixComposer, PurchaseNickIconComposer, PurchasePrefixComposer, RemoveLinkEventTracker, RequestNickIconsComposer, SetActiveNickIconComposer, SetActivePrefixComposer, SetDisplayOrderComposer, UserNickIconsEvent } from '@nitrots/nitro-renderer';
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
import { FC, useEffect, useMemo, useState } from 'react';
import { INickIconItem, IPrefixItem, PRESET_PREFIX_EFFECTS, PRESET_PREFIX_FONTS, SendMessageComposer, getPrefixEffectStyle, getPrefixFontStyle, parsePrefixColors } from '../../api';
import { GetNickIconUrl } from '../../assets/images/user_custom/nick_icons';
import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Text, UserIdentityView } from '../../common';
import { LayoutCurrencyIcon } from '../../common/layout/LayoutCurrencyIcon';
import { useMessageEvent } from '../../hooks';
type CustomizeTab = 'icons' | 'prefix' | 'settings';
type PrefixSubTab = 'library' | 'custom';
interface ICatalogPrefixItem extends IPrefixItem
{
points: number;
pointsType: number;
owned: boolean;
ownedPrefixId: number;
}
interface ICombinedPrefixItem extends IPrefixItem
{
points: number;
pointsType: number;
owned: boolean;
ownedPrefixId: number;
}
const ORDER_LABELS: Record<string, string> = {
'icon-prefix-name': 'Icon / Prefix / Name',
'prefix-icon-name': 'Prefix / Icon / Name',
'name-icon-prefix': 'Name / Icon / Prefix',
'name-prefix-icon': 'Name / Prefix / Icon',
'icon-name-prefix': 'Icon / Name / Prefix',
'prefix-name-icon': 'Prefix / Name / Icon'
};
const PRESET_COLORS: string[] = [
'#D62828', '#E85D04', '#F77F00', '#2A9D8F',
'#0077B6', '#4361EE', '#6A4C93', '#C1121F',
'#B5179E', '#3A86FF', '#3F8E00', '#8D5524'
];
export const CustomizeNickIconView: FC<{}> = () =>
{
const [ isVisible, setIsVisible ] = useState(false);
const [ isLoading, setIsLoading ] = useState(false);
const [ activeTab, setActiveTab ] = useState<CustomizeTab>('icons');
const [ activePrefixSubTab, setActivePrefixSubTab ] = useState<PrefixSubTab>('library');
const [ iconItems, setIconItems ] = useState<INickIconItem[]>([]);
const [ prefixItems, setPrefixItems ] = useState<IPrefixItem[]>([]);
const [ catalogPrefixes, setCatalogPrefixes ] = useState<ICatalogPrefixItem[]>([]);
const [ displayOrder, setDisplayOrder ] = useState('icon-prefix-name');
const [ customPrefixMaxLength, setCustomPrefixMaxLength ] = useState(15);
const [ customPrefixPriceCredits, setCustomPrefixPriceCredits ] = useState(0);
const [ customPrefixPricePoints, setCustomPrefixPricePoints ] = useState(0);
const [ customPrefixPointsType, setCustomPrefixPointsType ] = useState(0);
const [ customPrefixFontPriceCredits, setCustomPrefixFontPriceCredits ] = useState(0);
const [ customPrefixFontPricePoints, setCustomPrefixFontPricePoints ] = useState(0);
const [ customPrefixFontPointsType, setCustomPrefixFontPointsType ] = useState(0);
const [ customPrefixText, setCustomPrefixText ] = useState('');
const [ customPrefixColor, setCustomPrefixColor ] = useState('#FFFFFF');
const [ customPrefixIcon, setCustomPrefixIcon ] = useState('');
const [ customPrefixEffect, setCustomPrefixEffect ] = useState('');
const [ customPrefixFont, setCustomPrefixFont ] = useState('');
const [ showEmojiPicker, setShowEmojiPicker ] = useState(false);
useMessageEvent<UserNickIconsEvent>(UserNickIconsEvent, event =>
{
const parser = event.getParser();
setIconItems(parser.nickIcons.map(icon => ({
id: icon.id,
iconKey: icon.iconKey,
displayName: icon.displayName,
points: icon.points,
pointsType: icon.pointsType,
owned: icon.owned,
active: icon.active
})));
setPrefixItems(parser.ownedPrefixes.map(prefix => ({
id: prefix.id,
displayName: prefix.displayName,
text: prefix.text,
color: prefix.color,
icon: prefix.icon || '',
effect: prefix.effect || '',
font: prefix.font || '',
active: prefix.active,
isCustom: prefix.isCustom,
points: prefix.points,
pointsType: prefix.pointsType,
catalogPrefixId: prefix.catalogPrefixId
})));
setCatalogPrefixes(parser.prefixCatalog.map(prefix => ({
id: prefix.id,
displayName: prefix.displayName,
text: prefix.text,
color: prefix.color,
icon: prefix.icon || '',
effect: prefix.effect || '',
font: prefix.font || '',
active: prefix.active,
points: prefix.points,
pointsType: prefix.pointsType,
owned: prefix.owned,
ownedPrefixId: prefix.ownedPrefixId
})));
setDisplayOrder(parser.displayOrder || 'icon-prefix-name');
setCustomPrefixMaxLength(parser.customPrefixMaxLength || 15);
setCustomPrefixPriceCredits(parser.customPrefixPriceCredits || 0);
setCustomPrefixPricePoints(parser.customPrefixPricePoints || 0);
setCustomPrefixPointsType(parser.customPrefixPointsType || 0);
setCustomPrefixFontPriceCredits(parser.customPrefixFontPriceCredits || 0);
setCustomPrefixFontPricePoints(parser.customPrefixFontPricePoints || 0);
setCustomPrefixFontPointsType(parser.customPrefixFontPointsType || 0);
setIsLoading(false);
});
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return;
switch(parts[1])
{
case 'show':
setIsVisible(true);
return;
case 'hide':
setIsVisible(false);
return;
case 'toggle':
setIsVisible(previousValue => !previousValue);
return;
}
},
eventUrlPrefix: 'customize/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
useEffect(() =>
{
if(!isVisible) return;
setIsLoading(true);
SendMessageComposer(new RequestNickIconsComposer());
}, [ isVisible ]);
const activeIcon = useMemo(() => iconItems.find(item => item.active) || null, [ iconItems ]);
const activePrefix = useMemo(() => prefixItems.find(item => item.active) || null, [ prefixItems ]);
const combinedPrefixes = useMemo(() =>
{
const ownedByCatalogId = new Map<number, IPrefixItem>();
for(const prefix of prefixItems)
{
if(prefix.catalogPrefixId && (prefix.catalogPrefixId > 0)) ownedByCatalogId.set(prefix.catalogPrefixId, prefix);
}
const catalogEntries: ICombinedPrefixItem[] = catalogPrefixes.map(prefix =>
{
const ownedPrefix = ownedByCatalogId.get(prefix.id);
return {
id: ownedPrefix?.id || prefix.id,
displayName: ownedPrefix?.displayName || prefix.displayName,
text: ownedPrefix?.text || prefix.text,
color: ownedPrefix?.color || prefix.color,
icon: ownedPrefix?.icon || prefix.icon,
effect: ownedPrefix?.effect || prefix.effect,
font: ownedPrefix?.font || prefix.font,
active: ownedPrefix?.active || prefix.active,
isCustom: false,
points: prefix.points,
pointsType: prefix.pointsType,
catalogPrefixId: prefix.id,
owned: prefix.owned || !!ownedPrefix,
ownedPrefixId: prefix.ownedPrefixId || ownedPrefix?.id || 0
};
});
const customEntries: ICombinedPrefixItem[] = prefixItems
.filter(prefix => !prefix.catalogPrefixId || (prefix.catalogPrefixId <= 0))
.map(prefix => ({
id: prefix.id,
displayName: prefix.displayName,
text: prefix.text,
color: prefix.color,
icon: prefix.icon,
effect: prefix.effect,
font: prefix.font || '',
active: prefix.active,
isCustom: true,
points: prefix.points || customPrefixPricePoints,
pointsType: prefix.pointsType || customPrefixPointsType,
catalogPrefixId: 0,
owned: true,
ownedPrefixId: prefix.id
}));
return [ ...catalogEntries, ...customEntries ];
}, [ catalogPrefixes, customPrefixPointsType, customPrefixPricePoints, prefixItems ]);
const selectedEffectOption = useMemo(() => PRESET_PREFIX_EFFECTS.find(effect => effect.id === customPrefixEffect) || PRESET_PREFIX_EFFECTS[0], [ customPrefixEffect ]);
const selectedFontOption = useMemo(() => PRESET_PREFIX_FONTS.find(font => font.id === customPrefixFont) || PRESET_PREFIX_FONTS[0], [ customPrefixFont ]);
const basicEffects = useMemo(() => PRESET_PREFIX_EFFECTS.filter(effect => effect.tier === 'basic'), []);
const premiumEffects = useMemo(() => PRESET_PREFIX_EFFECTS.filter(effect => effect.tier === 'premium'), []);
const basicFonts = useMemo(() => PRESET_PREFIX_FONTS.filter(font => font.tier === 'basic'), []);
const premiumFonts = useMemo(() => PRESET_PREFIX_FONTS.filter(font => font.tier === 'premium'), []);
const prefixPreviewColors = useMemo(() => parsePrefixColors(customPrefixText || 'Preview', customPrefixColor || '#FFFFFF'), [ customPrefixText, customPrefixColor ]);
const customPrefixPreviewStyle = useMemo(() => getPrefixEffectStyle(customPrefixEffect, prefixPreviewColors[0] || '#FFFFFF'), [ customPrefixEffect, prefixPreviewColors ]);
const customPrefixFontStyle = useMemo(() => getPrefixFontStyle(customPrefixFont), [ customPrefixFont ]);
const customPrefixTotalCredits = useMemo(() => customPrefixPriceCredits + (customPrefixFont ? customPrefixFontPriceCredits : 0), [ customPrefixFont, customPrefixFontPriceCredits, customPrefixPriceCredits ]);
const customPrefixTotalPoints = useMemo(() => customPrefixPricePoints + ((customPrefixFont && (customPrefixFontPointsType === customPrefixPointsType)) ? customPrefixFontPricePoints : 0), [ customPrefixFont, customPrefixFontPointsType, customPrefixFontPricePoints, customPrefixPointsType, customPrefixPricePoints ]);
const customPrefixIsValid = useMemo(() =>
{
const trimmed = customPrefixText.trim();
if(!trimmed.length || (trimmed.length > customPrefixMaxLength)) return false;
return customPrefixColor.split(',').every(color => /^#[0-9A-Fa-f]{6}$/.test(color));
}, [ customPrefixColor, customPrefixMaxLength, customPrefixText ]);
const refreshCustomizeData = () =>
{
setIsLoading(true);
SendMessageComposer(new RequestNickIconsComposer());
};
const handleIconAction = (item: INickIconItem) =>
{
setIsLoading(true);
if(!item.owned)
{
SendMessageComposer(new PurchaseNickIconComposer(item.iconKey));
return;
}
SendMessageComposer(new SetActiveNickIconComposer(item.active ? 0 : item.id));
};
const handleCombinedPrefixAction = (item: ICombinedPrefixItem) =>
{
setIsLoading(true);
if(item.owned)
{
SendMessageComposer(new SetActivePrefixComposer(item.active ? 0 : item.ownedPrefixId));
return;
}
SendMessageComposer(new PurchaseCatalogPrefixComposer(item.catalogPrefixId || item.id));
};
const handleCustomPrefixPurchase = () =>
{
if(!customPrefixIsValid) return;
setIsLoading(true);
SendMessageComposer(new PurchasePrefixComposer(customPrefixText.trim(), customPrefixColor, customPrefixIcon, customPrefixEffect, customPrefixFont));
};
const handleDisplayOrderChange = (nextDisplayOrder: string) =>
{
if(nextDisplayOrder === displayOrder) return;
setDisplayOrder(nextDisplayOrder);
setIsLoading(true);
SendMessageComposer(new SetDisplayOrderComposer(nextDisplayOrder));
};
if(!isVisible) return null;
return (
<NitroCardView className="customize-nick-icon-window w-[680px] max-w-[95vw]" theme="primary-slim" uniqueKey="customize-nick-icons">
<NitroCardHeaderView headerText="Customize Bubble Identity" onCloseClick={ () => setIsVisible(false) } />
<NitroCardTabsView>
<NitroCardTabsItemView isActive={ activeTab === 'icons' } onClick={ () => setActiveTab('icons') }>
<Text>Icons</Text>
</NitroCardTabsItemView>
<NitroCardTabsItemView isActive={ activeTab === 'prefix' } onClick={ () => setActiveTab('prefix') }>
<Text>Prefix</Text>
</NitroCardTabsItemView>
<NitroCardTabsItemView isActive={ activeTab === 'settings' } onClick={ () => setActiveTab('settings') }>
<Text>Settings</Text>
</NitroCardTabsItemView>
</NitroCardTabsView>
<NitroCardContentView className="flex max-h-[78vh] flex-col gap-3 overflow-y-auto text-black">
<div className="rounded border border-black/10 bg-black/5 p-3">
<Text bold>Live preview</Text>
<div className="mt-2 flex min-h-[54px] items-center justify-center rounded border border-black/10 bg-[#1f2937] px-3 py-2 text-white">
<UserIdentityView
displayOrder={ displayOrder }
nickIcon={ activeIcon?.iconKey || '' }
prefixColor={ activePrefix?.color || customPrefixColor }
prefixEffect={ activePrefix?.effect || customPrefixEffect }
prefixFont={ activePrefix?.font || customPrefixFont }
prefixIcon={ activePrefix?.icon || customPrefixIcon }
prefixText={ activePrefix?.text || customPrefixText }
username="Username" />
</div>
</div>
{ activeTab === 'icons' &&
<>
<div className="rounded border border-black/10 bg-black/5 p-2 text-[11px] leading-4">
Choose the icon shown in your bubble identity.
</div>
<div className="grid grid-cols-3 gap-2">
{ iconItems.map(item =>
{
const iconUrl = GetNickIconUrl(item.iconKey);
return (
<div
key={ item.iconKey }
className={ `relative flex min-h-[126px] flex-col items-center justify-between gap-2 rounded border p-3 transition-colors ${ item.active ? 'border-[#1e7295] bg-[#dff3fb]' : 'border-black/10 bg-black/5' }` }>
{ item.active && <span className="absolute right-1 top-1 rounded bg-[#1e7295] px-1.5 py-0.5 text-[9px] font-bold uppercase text-white">Active</span> }
<img className="h-auto max-h-[28px] w-auto object-contain" src={ iconUrl } alt={ item.iconKey } />
<div className="flex flex-col items-center gap-1 text-center text-[11px]">
<span>{ item.owned ? (item.active ? 'Owned - Active' : 'Owned') : 'Locked' }</span>
<span className="max-w-[140px] truncate">{ item.displayName || `Icon #${ item.iconKey }` }</span>
<span className="inline-flex items-center gap-1">
<LayoutCurrencyIcon type={ item.pointsType } />
{ item.points }
</span>
</div>
<Button disabled={ isLoading } onClick={ () => handleIconAction(item) }>
{ !item.owned && 'Buy' }
{ item.owned && !item.active && 'Activate' }
{ item.owned && item.active && 'Deactivate' }
</Button>
</div>
);
}) }
</div>
</> }
{ activeTab === 'prefix' &&
<div className="flex flex-col gap-3">
<div className="rounded border border-black/10 bg-black/5 p-1">
<div className="flex items-center gap-2">
<button
className={ `rounded px-3 py-1.5 text-[11px] font-bold transition-colors ${ activePrefixSubTab === 'library' ? 'bg-[#1e7295] text-white' : 'bg-white text-black' }` }
type="button"
onClick={ () => setActivePrefixSubTab('library') }>
Library
</button>
<button
className={ `rounded px-3 py-1.5 text-[11px] font-bold transition-colors ${ activePrefixSubTab === 'custom' ? 'bg-[#1e7295] text-white' : 'bg-white text-black' }` }
type="button"
onClick={ () => setActivePrefixSubTab('custom') }>
Custom
</button>
</div>
</div>
{ activePrefixSubTab === 'library' &&
<>
<div className="rounded border border-black/10 bg-black/5 p-2 text-[11px] leading-4">
Choose a preset or custom prefix for your bubble identity.
</div>
<div className="grid grid-cols-2 gap-2">
{ combinedPrefixes.map(item => (
<div key={ `${ item.catalogPrefixId || 'custom' }-${ item.ownedPrefixId || item.id }` } className={ `relative flex min-h-[96px] flex-col gap-2 rounded border p-2.5 ${ item.active ? 'border-[#1e7295] bg-[#dff3fb]' : 'border-black/10 bg-black/5' }` }>
{ item.active && <span className="absolute right-1 top-1 rounded bg-[#1e7295] px-1.5 py-0.5 text-[9px] font-bold uppercase text-white">Active</span> }
<UserIdentityView
displayOrder={ displayOrder }
nickIcon={ activeIcon?.iconKey || '' }
prefixColor={ item.color }
prefixEffect={ item.effect }
prefixFont={ item.font || '' }
prefixIcon={ item.icon }
prefixText={ item.text }
username="Username" />
<div className="flex flex-col gap-1 text-[11px]">
<span>{ item.owned ? (item.active ? 'Owned - Active' : 'Owned') : 'Locked' }</span>
<span className="truncate">{ item.displayName || item.text }{ item.isCustom ? ' - Custom' : '' }</span>
<span className="inline-flex items-center gap-1">
<LayoutCurrencyIcon type={ item.pointsType } />
{ item.points }
</span>
</div>
<Button disabled={ isLoading } onClick={ () => handleCombinedPrefixAction(item) }>
{ !item.owned && 'Buy' }
{ item.owned && !item.active && 'Activate' }
{ item.owned && item.active && 'Deactivate' }
</Button>
</div>
)) }
</div>
</> }
{ activePrefixSubTab === 'custom' &&
<div className="rounded border border-black/10 bg-black/5 p-3">
<div className="mb-2 flex items-center justify-between">
<Text bold>Custom prefix</Text>
<Button disabled={ isLoading } onClick={ refreshCustomizeData }>Refresh</Button>
</div>
<div className="mt-2 flex flex-col gap-2">
<div className="flex items-center gap-2">
<input
className="flex-1 rounded border border-black/10 bg-white px-3 py-2 text-sm"
maxLength={ customPrefixMaxLength }
placeholder="Enter your prefix"
type="text"
value={ customPrefixText }
onChange={ event => setCustomPrefixText(event.target.value) } />
<span className="text-[11px] text-black/60">{ customPrefixText.length }/{ customPrefixMaxLength }</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<button className="rounded border border-black/10 bg-white px-3 py-2 text-sm" type="button" onClick={ () => setShowEmojiPicker(true) }>
{ customPrefixIcon || 'Emoji' }
</button>
{ !!customPrefixIcon && <Button onClick={ () => setCustomPrefixIcon('') }>Clear</Button> }
</div>
<div className="rounded border border-black/10 bg-white p-2">
<div className="mb-2 text-[11px] leading-4 text-black/70">
Safe colors only, chosen to stay readable on both light and dark backgrounds.
</div>
<div className="grid grid-cols-6 gap-2">
{ PRESET_COLORS.map(color => (
<button
key={ color }
className={ `flex h-[28px] items-center justify-center rounded border text-[10px] font-bold uppercase ${ customPrefixColor === color ? 'border-[#1e7295] ring-1 ring-[#1e7295]' : 'border-black/10' }` }
style={ { backgroundColor: color } }
type="button"
onClick={ () => setCustomPrefixColor(color) }>
{ customPrefixColor === color ? 'ON' : '' }
</button>
)) }
</div>
</div>
<div className="rounded border border-black/10 bg-white p-2">
<div className="mb-2 text-[11px] leading-4 text-black/70">
Effect
</div>
<div className="flex items-center gap-2">
<select
className="flex-1 rounded border border-black/10 bg-white px-2 py-2 text-sm"
value={ customPrefixEffect }
onChange={ event => setCustomPrefixEffect(event.target.value) }>
<optgroup label="Basic">
{ basicEffects.map(effect => (
<option key={ effect.id || 'none' } value={ effect.id }>
{ effect.label }
</option>
)) }
</optgroup>
<optgroup label="Premium">
{ premiumEffects.map(effect => (
<option key={ effect.id } value={ effect.id }>
{ effect.label }
</option>
)) }
</optgroup>
</select>
<div className="min-w-[130px] rounded border border-black/10 bg-black/5 px-2 py-2 text-center text-[11px] font-bold">
{ selectedEffectOption.icon } { selectedEffectOption.label }
<div className="mt-1 text-[9px] uppercase text-black/60">
{ selectedEffectOption.tier }
</div>
</div>
</div>
</div>
<div className="rounded border border-black/10 bg-white p-2">
<div className="mb-2 text-[11px] leading-4 text-black/70">
Font
</div>
<div className="flex items-center gap-2">
<select
className="flex-1 rounded border border-black/10 bg-white px-2 py-2 text-sm"
value={ customPrefixFont }
onChange={ event => setCustomPrefixFont(event.target.value) }>
<optgroup label="Basic">
{ basicFonts.map(font => (
<option key={ font.id || 'default' } value={ font.id }>
{ font.label }
</option>
)) }
</optgroup>
<optgroup label="Premium">
{ premiumFonts.map(font => (
<option key={ font.id } value={ font.id }>
{ font.label }
</option>
)) }
</optgroup>
</select>
<div className="min-w-[130px] rounded border border-black/10 bg-black/5 px-2 py-2 text-center text-[11px] font-bold">
<span style={ customPrefixFontStyle }>{ selectedFontOption.label }</span>
<div className="mt-1 text-[9px] uppercase text-black/60">
{ selectedFontOption.tier }
</div>
</div>
</div>
{ !!customPrefixFont &&
<div className="mt-2 text-[10px] leading-4 text-black/60">
Premium fonts add an extra price on top of the custom prefix.
</div> }
</div>
<div className="rounded border border-black/10 bg-[#1f2937] px-3 py-2 text-white" style={ customPrefixPreviewStyle }>
<UserIdentityView
displayOrder={ displayOrder }
nickIcon={ activeIcon?.iconKey || '' }
prefixColor={ customPrefixColor }
prefixEffect={ customPrefixEffect }
prefixFont={ customPrefixFont }
prefixIcon={ customPrefixIcon }
prefixText={ customPrefixText || 'Preview' }
username="Username" />
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-[12px]">
{ customPrefixTotalCredits > 0 && <span>{ customPrefixTotalCredits } credits</span> }
{ customPrefixTotalPoints > 0 &&
<span className="inline-flex items-center gap-1">
<LayoutCurrencyIcon type={ customPrefixPointsType } />
{ customPrefixTotalPoints }
</span> }
{ !!customPrefixFont && (customPrefixFontPointsType !== customPrefixPointsType) && (customPrefixFontPricePoints > 0) &&
<span className="inline-flex items-center gap-1">
<LayoutCurrencyIcon type={ customPrefixFontPointsType } />
{ customPrefixFontPricePoints }
</span> }
</div>
<Button disabled={ !customPrefixIsValid || isLoading } onClick={ handleCustomPrefixPurchase }>
Buy custom prefix
</Button>
</div>
</div>
</div> }
</div> }
{ activeTab === 'settings' &&
<div className="flex flex-col gap-3">
<div className="rounded border border-black/10 bg-black/5 p-3">
<Text bold>Display order</Text>
<div className="mt-2 grid grid-cols-2 gap-2">
{ Object.entries(ORDER_LABELS).map(([ key, label ]) => (
<Button key={ key } disabled={ isLoading && (displayOrder === key) } onClick={ () => handleDisplayOrderChange(key) }>
{ displayOrder === key ? '* ' : '' }{ label }
</Button>
)) }
</div>
</div>
<div className="rounded border border-black/10 bg-black/5 p-3">
<div className="mb-2 flex items-center justify-between">
<Text bold>Refresh data</Text>
<Button disabled={ isLoading } onClick={ refreshCustomizeData }>Refresh</Button>
</div>
<div className="text-[11px] leading-4 text-black/70">
Use this tab to control how your icon, prefix and username are ordered in bubbles, profile and infostand.
</div>
</div>
</div> }
</NitroCardContentView>
{ showEmojiPicker &&
<>
<div className="fixed inset-0 z-[999]" onClick={ () => setShowEmojiPicker(false) } />
<div className="fixed left-1/2 top-1/2 z-[1000] -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-xl shadow-2xl">
<Picker
data={ data }
locale="en"
onEmojiSelect={ (emoji: { native: string }) => { setCustomPrefixIcon(emoji.native); setShowEmojiPicker(false); } }
previewPosition="none"
set="native"
theme="dark" />
</div>
</> }
</NitroCardView>
);
};
@@ -13,13 +13,19 @@ interface FriendsRemoveConfirmationViewProps
export const FriendsRemoveConfirmationView: FC<FriendsRemoveConfirmationViewProps> = props =>
{
const { selectedFriendsIds = null, removeFriendsText = null, removeSelectedFriends = null, onCloseClick = null } = props;
const separatorIndex = removeFriendsText.indexOf(':');
const removeFriendsLeadText = (separatorIndex >= 0) ? removeFriendsText.substring(0, separatorIndex + 1) : removeFriendsText;
const removeFriendsNamesText = (separatorIndex >= 0) ? removeFriendsText.substring(separatorIndex + 1).trimStart() : '';
return (
<NitroCardView className="nitro-friends-remove-confirmation" theme="primary-slim">
<NitroCardView className="nitro-friends-remove-confirmation" theme="primary-slim" isResizable={ false } style={ { width: 270, height: 225, minWidth: 270, minHeight: 225, maxWidth: 270, maxHeight: 225 } }>
<NitroCardHeaderView headerText={ LocalizeText('friendlist.removefriendconfirm.title') } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black">
<div>{ removeFriendsText }</div>
<div className="flex gap-1">
<NitroCardContentView className="nitro-friends-remove-confirmation-content text-black">
<div className="nitro-friends-remove-confirmation-text">
<div>{ removeFriendsLeadText }</div>
{ removeFriendsNamesText.length > 0 && <div className="nitro-friends-remove-confirmation-names">{ removeFriendsNamesText }</div> }
</div>
<div className="nitro-friends-remove-confirmation-actions">
<Button fullWidth disabled={ (selectedFriendsIds.length === 0) } variant="danger" onClick={ removeSelectedFriends }>{ LocalizeText('generic.ok') }</Button>
<Button fullWidth onClick={ onCloseClick }>{ LocalizeText('generic.cancel') }</Button>
</div>
@@ -15,13 +15,13 @@ export const FriendsRoomInviteView: FC<FriendsRoomInviteViewProps> = props =>
const [ roomInviteMessage, setRoomInviteMessage ] = useState<string>('');
return (
<NitroCardView className="nitro-friends-room-invite" theme="primary-slim" uniqueKey="nitro-friends-room-invite">
<NitroCardView className="nitro-friends-room-invite" theme="primary-slim" uniqueKey="nitro-friends-room-invite" isResizable={ false } style={ { width: 270, height: 225, minWidth: 270, minHeight: 225, maxWidth: 270, maxHeight: 225 } }>
<NitroCardHeaderView headerText={ LocalizeText('friendlist.invite.title') } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black">
{ LocalizeText('friendlist.invite.summary', [ 'count' ], [ selectedFriendsIds.length.toString() ]) }
<textarea className="min-h-[calc(1.5em+ .5rem+2px)] px-[.5rem] py-[.25rem] rounded-[.2rem]" maxLength={ 255 } value={ roomInviteMessage } onChange={ event => setRoomInviteMessage(event.target.value) }></textarea>
<Text center className="bg-muted rounded p-1">{ LocalizeText('friendlist.invite.note') }</Text>
<div className="flex gap-1">
<NitroCardContentView className="nitro-friends-room-invite-content text-black" gap={ 2 }>
<Text className="nitro-friends-room-invite-summary">{ LocalizeText('friendlist.invite.summary', [ 'count' ], [ selectedFriendsIds.length.toString() ]) }</Text>
<textarea className="nitro-friends-room-invite-textarea" maxLength={ 255 } value={ roomInviteMessage } onChange={ event => setRoomInviteMessage(event.target.value) }></textarea>
<Text center className="nitro-friends-room-invite-note">{ LocalizeText('friendlist.invite.note') }</Text>
<div className="nitro-friends-room-invite-actions">
<Button fullWidth disabled={ ((roomInviteMessage.length === 0) || (selectedFriendsIds.length === 0)) } variant="success" onClick={ () => sendRoomInvite(roomInviteMessage) }>{ LocalizeText('friendlist.invite.send') }</Button>
<Button fullWidth onClick={ onCloseClick }>{ LocalizeText('generic.cancel') }</Button>
</div>
@@ -1,8 +1,10 @@
import { HabboSearchComposer, HabboSearchResultData, HabboSearchResultEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { LocalizeText, OpenMessengerChat, SendMessageComposer } from '../../../../api';
import { Column, NitroCardAccordionItemView, NitroCardAccordionSetView, NitroCardAccordionSetViewProps, Text, UserProfileIconView } from '../../../../common';
import { Column, LayoutAvatarImageView, NitroCardAccordionItemView, NitroCardAccordionSetView, NitroCardAccordionSetViewProps, Text, UserProfileIconView } from '../../../../common';
import { useFriends, useMessageEvent } from '../../../../hooks';
import { resolveAvatarFigure } from './resolveAvatarFigure';
import { resolveAvatarGender } from './resolveAvatarGender';
interface FriendsSearchViewProps extends NitroCardAccordionSetViewProps
{
@@ -17,6 +19,22 @@ export const FriendsSearchView: FC<FriendsSearchViewProps> = props =>
const [ otherResults, setOtherResults ] = useState<HabboSearchResultData[]>(null);
const { canRequestFriend = null, requestFriend = null } = useFriends();
const getSearchResultFigure = (result: HabboSearchResultData) =>
{
if(!result) return null;
const typedResult = (result as HabboSearchResultData & { figureString?: string; avatarFigure?: string; figure?: string; avatarFigureString?: string });
return typedResult.figureString || typedResult.avatarFigure || typedResult.figure || typedResult.avatarFigureString || null;
};
const getSearchResultGender = (result: HabboSearchResultData) =>
{
const typedResult = (result as HabboSearchResultData & { gender?: string | number; avatarGender?: string | number });
return resolveAvatarGender(typedResult.avatarGender ?? typedResult.gender);
};
useMessageEvent<HabboSearchResultEvent>(HabboSearchResultEvent, event =>
{
const parser = event.getParser();
@@ -55,10 +73,15 @@ export const FriendsSearchView: FC<FriendsSearchViewProps> = props =>
{ friendResults.map(result =>
{
return (
<NitroCardAccordionItemView key={ result.avatarId } className="px-2 py-1" justifyContent="between">
<div className="flex items-center gap-1">
<UserProfileIconView userId={ result.avatarId } />
<div>{ result.avatarName }</div>
<NitroCardAccordionItemView key={ result.avatarId } className="friends-list-item px-2 py-1" justifyContent="between">
<div className="friends-list-user">
<div className="friends-list-avatar">
<LayoutAvatarImageView figure={ resolveAvatarFigure(getSearchResultFigure(result), getSearchResultGender(result)) } gender={ getSearchResultGender(result) } headOnly={ true } direction={ 2 } />
</div>
<div>
<UserProfileIconView userId={ result.avatarId } />
</div>
<div className="friends-list-name">{ result.avatarName }</div>
</div>
<div className="flex items-center gap-1">
{ result.isAvatarOnline &&
@@ -82,10 +105,15 @@ export const FriendsSearchView: FC<FriendsSearchViewProps> = props =>
{ otherResults.map(result =>
{
return (
<NitroCardAccordionItemView key={ result.avatarId } className="px-2 py-1" justifyContent="between">
<div className="flex items-center gap-1">
<UserProfileIconView userId={ result.avatarId } />
<div>{ result.avatarName }</div>
<NitroCardAccordionItemView key={ result.avatarId } className="friends-list-item px-2 py-1" justifyContent="between">
<div className="friends-list-user">
<div className="friends-list-avatar">
<LayoutAvatarImageView figure={ resolveAvatarFigure(getSearchResultFigure(result), getSearchResultGender(result)) } gender={ getSearchResultGender(result) } headOnly={ true } direction={ 2 } />
</div>
<div>
<UserProfileIconView userId={ result.avatarId } />
</div>
<div className="friends-list-name">{ result.avatarName }</div>
</div>
<div className="flex items-center gap-1">
{ canRequestFriend(result.avatarId) &&
@@ -34,7 +34,7 @@ export const FriendsListView: FC<{}> = props =>
userNames.push(existingFriend.name);
}
return LocalizeText('friendlist.removefriendconfirm.userlist', [ 'user_names' ], [ userNames.join(', ') ]);
return LocalizeText('friendlist.removefriendconfirm.userlist', [ 'user_names' ], [ userNames.join('\n') ]);
}, [ offlineFriends, onlineFriends, selectedFriendsIds ]);
const selectFriend = useCallback((userId: number) =>
@@ -60,6 +60,27 @@ export const FriendsListView: FC<{}> = props =>
});
}, [ setSelectedFriendsIds ]);
const toggleSelectFriends = useCallback((friendIds: number[]) =>
{
if(!friendIds.length) return;
setSelectedFriendsIds(prevValue =>
{
const allSelected = friendIds.every(friendId => (prevValue.indexOf(friendId) >= 0));
if(allSelected) return prevValue.filter(friendId => (friendIds.indexOf(friendId) === -1));
const nextValue = [ ...prevValue ];
for(const friendId of friendIds)
{
if(nextValue.indexOf(friendId) === -1) nextValue.push(friendId);
}
return nextValue;
});
}, []);
const sendRoomInvite = (message: string) =>
{
if(!selectedFriendsIds.length || !message || !message.length || (message.length > 255)) return;
@@ -125,10 +146,24 @@ export const FriendsListView: FC<{}> = props =>
<NitroCardHeaderView headerText={ LocalizeText('friendlist.friends') } onCloseClick={ event => setIsVisible(false) } />
<NitroCardContentView className="text-black p-0" gap={ 1 } overflow="hidden">
<NitroCardAccordionView fullHeight overflow="hidden">
<NitroCardAccordionSetView headerText={ LocalizeText('friendlist.friends') + ` (${ onlineFriends.length })` } isExpanded={ true }>
<NitroCardAccordionSetView className="friends-list-section" headerText={ LocalizeText('friendlist.friends') + ` (${ onlineFriends.length })` } isExpanded={ true }>
<Flex className="friends-list-toolbar px-2 py-1" justifyContent="end">
<span className="friends-list-toolbar-link" onClick={ event => { event.stopPropagation(); toggleSelectFriends(onlineFriends.map(friend => friend.id)); } }>
{ onlineFriends.length && onlineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0))
? LocalizeText('friendlist.unselect_all')
: LocalizeText('friendlist.select_all') }
</span>
</Flex>
<FriendsListGroupView list={ onlineFriends } selectedFriendsIds={ selectedFriendsIds } selectFriend={ selectFriend } />
</NitroCardAccordionSetView>
<NitroCardAccordionSetView headerText={ LocalizeText('friendlist.friends.offlinecaption') + ` (${ offlineFriends.length })` }>
<Flex className="friends-list-toolbar px-2 py-1" justifyContent="end">
<span className="friends-list-toolbar-link" onClick={ event => { event.stopPropagation(); toggleSelectFriends(offlineFriends.map(friend => friend.id)); } }>
{ offlineFriends.length && offlineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0))
? LocalizeText('friendlist.unselect_all')
: LocalizeText('friendlist.select_all') }
</span>
</Flex>
<FriendsListGroupView list={ offlineFriends } selectedFriendsIds={ selectedFriendsIds } selectFriend={ selectFriend } />
</NitroCardAccordionSetView>
<FriendsListRequestView headerText={ LocalizeText('friendlist.tab.friendrequests') + ` (${ requests.length })` } isExpanded={ true } />
@@ -1,7 +1,9 @@
import { FC, MouseEvent, useState } from 'react';
import { LocalizeText, MessengerFriend, OpenMessengerChat } from '../../../../../api';
import { NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common';
import { LayoutAvatarImageView, NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common';
import { useFriends } from '../../../../../hooks';
import { resolveAvatarFigure } from '../resolveAvatarFigure';
import { resolveAvatarGender } from '../resolveAvatarGender';
export const FriendsListGroupItemView: FC<{ friend: MessengerFriend, selected: boolean, selectFriend: (userId: number) => void }> = props =>
{
@@ -55,14 +57,17 @@ export const FriendsListGroupItemView: FC<{ friend: MessengerFriend, selected: b
if(!friend) return null;
return (
<NitroCardAccordionItemView className={ `px-2 py-1 ${ selected && 'bg-primary text-white' }` } justifyContent="between" onClick={ event => selectFriend(friend.id) }>
<div className="flex items-center gap-1">
<NitroCardAccordionItemView className={ `friends-list-item ${ selected ? 'selected' : '' }` } justifyContent="between" onClick={ event => selectFriend(friend.id) }>
<div className="friends-list-user">
<div className="friends-list-avatar">
<LayoutAvatarImageView figure={ resolveAvatarFigure(friend.figure, friend.gender) } gender={ resolveAvatarGender(friend.gender) } headOnly={ true } direction={ 2 } />
</div>
<div onClick={ event => event.stopPropagation() }>
<UserProfileIconView userId={ friend.id } />
</div>
<div>{ friend.name }</div>
<div className="friends-list-name">{ friend.name }</div>
</div>
<div className="flex items-center gap-1">
<div className="friends-list-actions">
{ !isRelationshipOpen &&
<>
{ friend.online &&
@@ -1,7 +1,9 @@
import { FC } from 'react';
import { MessengerRequest } from '../../../../../api';
import { NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common';
import { LocalizeText, MessengerRequest } from '../../../../../api';
import { Button, LayoutAvatarImageView, NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common';
import { useFriends } from '../../../../../hooks';
import { resolveAvatarFigure } from '../resolveAvatarFigure';
import { resolveAvatarGender } from '../resolveAvatarGender';
export const FriendsListRequestItemView: FC<{ request: MessengerRequest }> = props =>
{
@@ -11,14 +13,23 @@ export const FriendsListRequestItemView: FC<{ request: MessengerRequest }> = pro
if(!request) return null;
return (
<NitroCardAccordionItemView className="px-2 py-1" justifyContent="between">
<div className="flex items-center gap-1">
<UserProfileIconView userId={ request.id } />
<div>{ request.name }</div>
<NitroCardAccordionItemView className="friends-list-item px-2 py-1" justifyContent="between">
<div className="friends-list-user">
<div className="friends-list-avatar">
<LayoutAvatarImageView figure={ resolveAvatarFigure(request.figureString) } gender={ resolveAvatarGender(undefined) } headOnly={ true } direction={ 2 } />
</div>
<div>
<UserProfileIconView userId={ request.requesterUserId } />
</div>
<div className="friends-list-name">{ request.name }</div>
</div>
<div className="flex items-center gap-1">
<div className="nitro-friends-spritesheet icon-accept cursor-pointer" onClick={ event => requestResponse(request.id, true) } />
<div className="nitro-friends-spritesheet icon-deny cursor-pointer" onClick={ event => requestResponse(request.id, false) } />
<Button size="sm" onClick={ event => requestResponse(request.id, true) }>
{ LocalizeText('friendlist.request_accept') }
</Button>
<Button size="sm" variant="danger" onClick={ event => requestResponse(request.id, false) }>
{ LocalizeText('friendlist.request_decline') }
</Button>
</div>
</NitroCardAccordionItemView>
);
@@ -17,8 +17,11 @@ export const FriendsListRequestView: FC<NitroCardAccordionSetViewProps> = props
<Column gap={ 0 }>
{ requests.map((request, index) => <FriendsListRequestItemView key={ index } request={ request } />) }
</Column>
<div className="flex justify-center px-2 py-1">
<Button onClick={ event => requestResponse(-1, false) }>
<div className="flex justify-center gap-2 px-2 py-1">
<Button onClick={ event => requests.forEach(request => requestResponse(request.id, true)) }>
{ LocalizeText('friendlist.requests.acceptall') }
</Button>
<Button variant="danger" onClick={ event => requestResponse(-1, false) }>
{ LocalizeText('friendlist.requests.dismissall') }
</Button>
</div>
@@ -0,0 +1,15 @@
import { resolveAvatarGender } from './resolveAvatarGender';
const DEFAULT_AVATAR_FIGURES: Record<string, string> = {
M: 'hd-180-1.ch-210-66.lg-270-82.sh-290-80',
F: 'hd-600-1.ch-630-66.lg-695-82.sh-725-80'
};
export const resolveAvatarFigure = (figure: string | null | undefined, gender?: string | number | null) =>
{
const normalizedFigure = (figure || '').trim();
if(normalizedFigure.length && normalizedFigure.includes('hd-')) return normalizedFigure;
return DEFAULT_AVATAR_FIGURES[resolveAvatarGender(gender)] || DEFAULT_AVATAR_FIGURES.M;
};
@@ -0,0 +1,20 @@
export const resolveAvatarGender = (value: string | number | null | undefined) =>
{
if(typeof value === 'string')
{
const normalized = value.trim().toUpperCase();
if(normalized === 'F') return 'F';
if(normalized === 'M') return 'M';
if(normalized === 'FEMALE') return 'F';
if(normalized === 'MALE') return 'M';
}
if(typeof value === 'number')
{
if(value === 2) return 'F';
if(value === 1) return 'M';
}
return 'M';
};
@@ -2,9 +2,8 @@ import { AddLinkEventTracker, FollowFriendMessageComposer, GetSessionDataManager
import { FC, KeyboardEvent, useEffect, useRef, useState } from 'react';
import { FaTimes } from 'react-icons/fa';
import { GetUserProfile, LocalizeText, ReportType, SendMessageComposer } from '../../../../api';
import { Button, Column, Flex, Grid, LayoutAvatarImageView, LayoutGridItem, LayoutItemCountView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
import { useHelp, useMessenger } from '../../../../hooks';
import { NitroInput } from '../../../../layout';
import { DraggableWindowPosition, LayoutAvatarImageView, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useHelp, useMessenger, useTranslation } from '../../../../hooks';
import { FriendsMessengerThreadView } from './messenger-thread/FriendsMessengerThreadView';
export const FriendsMessengerView: FC<{}> = props =>
@@ -14,15 +13,35 @@ export const FriendsMessengerView: FC<{}> = props =>
const [ messageText, setMessageText ] = useState('');
const { visibleThreads = [], activeThread = null, getMessageThread = null, sendMessage = null, setActiveThreadId = null, closeThread = null } = useMessenger();
const { report = null } = useHelp();
const { settings, translateOutgoing } = useTranslation();
const messagesBox = useRef<HTMLDivElement>();
const followFriend = () => (activeThread && activeThread.participant && SendMessageComposer(new FollowFriendMessageComposer(activeThread.participant.id)));
const openProfile = () => (activeThread && activeThread.participant && GetUserProfile(activeThread.participant.id));
const send = () =>
const send = async () =>
{
if(!activeThread || !messageText.length) return;
const trimmedText = messageText.trimStart();
const shouldTranslateOutgoing = settings.enabled && !!trimmedText.length && (trimmedText.charAt(0) !== ':');
if(!shouldTranslateOutgoing)
{
sendMessage(activeThread, GetSessionDataManager().userId, messageText);
setMessageText('');
return;
}
const translation = await translateOutgoing(messageText);
if(translation && translation.translatedText?.length && (translation.translatedText.length <= 255))
{
sendMessage(activeThread, GetSessionDataManager().userId, translation.translatedText, 0, null, undefined, translation);
setMessageText('');
return;
}
sendMessage(activeThread, GetSessionDataManager().userId, messageText);
setMessageText('');
@@ -32,7 +51,7 @@ export const FriendsMessengerView: FC<{}> = props =>
{
if(event.key !== 'Enter') return;
send();
void send();
};
useEffect(() =>
@@ -107,71 +126,60 @@ export const FriendsMessengerView: FC<{}> = props =>
if(!isVisible) return null;
return (
<NitroCardView className="nitro-friends-messenger w-[800px] h-[720px]" theme="primary-slim" uniqueKey="nitro-friends-messenger">
<NitroCardView className="messenger-card" theme="primary-slim" uniqueKey={ null } windowPosition={ DraggableWindowPosition.TOP_CENTER } offsetTop={ 8 } isResizable={ false }>
<NitroCardHeaderView headerText={ LocalizeText('messenger.window.title', [ 'OPEN_CHAT_COUNT' ], [ visibleThreads.length.toString() ]) } onCloseClick={ event => setIsVisible(false) } />
<NitroCardContentView>
<Grid overflow="hidden">
<Column overflow="hidden" size={ 4 }>
<Text bold>{ LocalizeText('toolbar.icon.label.messenger') }</Text>
<Column fit overflow="auto">
<Column>
{ visibleThreads && (visibleThreads.length > 0) && visibleThreads.map(thread =>
{
return (
<LayoutGridItem key={ thread.threadId } itemActive={ (activeThread === thread) } onClick={ event => setActiveThreadId(thread.threadId) } className="py-1 px-2">
{ thread.unread && <LayoutItemCountView className="text-black" count={ thread.unreadCount } /> }
<Flex fullWidth gap={ 1 } style={{ minHeight: '50px' }}>
<LayoutAvatarImageView
figure={ thread.participant.id > 0 ? thread.participant.figure : thread.participant.figure === 'ADM' ? 'ha-3409-1413-70.lg-285-89.ch-3032-1334-109.sh-3016-110.hd-185-1359.ca-3225-110-62.wa-3264-62-62.fa-1206-90.hr-3322-1403' : thread.participant.figure }
headOnly={ true }
direction={ thread.participant.id > 0 ? 2 : 3 }
style={{ width: '50px', height: '80px', backgroundPosition: 'center 45%', flexShrink: 0, alignSelf: 'flex-end' }}
/>
<Text truncate grow className="self-center">{ thread.participant.name }</Text>
</Flex>
</LayoutGridItem>
);
}) }
</Column>
</Column>
</Column>
<Column overflow="hidden" size={ 8 }>
{ activeThread &&
<>
<Text bold center>{ LocalizeText('messenger.window.separator', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) }</Text>
<Flex alignItems="center" gap={ 1 } justifyContent="between">
<NitroCardContentView className="text-black p-0" gap={ 0 } overflow="hidden">
<div className="messenger-card-body">
<div className="messenger-avatar-bar">
{ visibleThreads && (visibleThreads.length > 0) && visibleThreads.map(thread =>
{
return (
<button key={ thread.threadId } className={ 'messenger-avatar-tab' + ((activeThread === thread) ? ' active' : '') + (thread.unread ? ' unread' : '') } onClick={ event => setActiveThreadId(thread.threadId) }>
<LayoutAvatarImageView
figure={ thread.participant.id > 0 ? thread.participant.figure : thread.participant.figure === 'ADM' ? 'ha-3409-1413-70.lg-285-89.ch-3032-1334-109.sh-3016-110.hd-185-1359.ca-3225-110-62.wa-3264-62-62.fa-1206-90.hr-3322-1403' : thread.participant.figure }
headOnly={ true }
direction={ thread.participant.id > 0 ? 2 : 3 }
/>
</button>
);
}) }
</div>
{ activeThread &&
<>
<div className="messenger-thread-header">
<span className="messenger-thread-name">{ LocalizeText('messenger.window.separator', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) }</span>
<div className="messenger-actions">
{ (activeThread.participant.id > 0) &&
<div className="flex gap-1">
<div className="relative inline-flex align-middle">
<Button onClick={ followFriend }>
<div className="nitro-friends-spritesheet icon-follow" />
</Button>
<Button onClick={ openProfile }>
<div className="nitro-friends-spritesheet icon-profile-sm" />
</Button>
</div>
<Button variant="danger" onClick={ () => report(ReportType.IM, { reportedUserId: activeThread.participant.id }) }>
<>
<button className="messenger-btn icon-btn" onClick={ followFriend }>
<div className="nitro-friends-spritesheet icon-follow" />
</button>
<button className="messenger-btn icon-btn" onClick={ openProfile }>
<div className="nitro-friends-spritesheet icon-profile-sm" />
</button>
<button className="messenger-btn danger" onClick={ () => report(ReportType.IM, { reportedUserId: activeThread.participant.id }) }>
{ LocalizeText('messenger.window.button.report') }
</Button>
</div> }
<Button onClick={ event => closeThread(activeThread.threadId) }>
<FaTimes className="fa-icon" />
</Button>
</Flex>
<Column fit className="bg-muted p-2 rounded chat-messages">
<Column innerRef={ messagesBox } overflow="auto">
<FriendsMessengerThreadView thread={ activeThread } />
</Column>
</Column>
<div className="flex gap-1">
<NitroInput maxLength={ 255 } placeholder={ LocalizeText('messenger.window.input.default', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) } type="text" value={ messageText } onChange={ event => setMessageText(event.target.value) } onKeyDown={ onKeyDown } />
<Button variant="success" onClick={ send }>
{ LocalizeText('widgets.chatinput.say') }
</Button>
</button>
</> }
<button className="messenger-btn close-btn" onClick={ event => closeThread(activeThread.threadId) }>
<FaTimes />
</button>
</div>
</> }
</Column>
</Grid>
</div>
<div ref={ messagesBox } className="chat-messages">
<FriendsMessengerThreadView thread={ activeThread } />
</div>
<div className="messenger-input-row">
<input maxLength={ 255 } placeholder={ LocalizeText('messenger.window.input.default', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) } type="text" value={ messageText } onChange={ event => setMessageText(event.target.value) } onKeyDown={ onKeyDown } />
<button className="messenger-btn send" onClick={ () => void send() }>
{ LocalizeText('widgets.chatinput.say') }
</button>
</div>
</> }
</div>
</NitroCardContentView>
</NitroCardView>
);
@@ -28,14 +28,11 @@ export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: M
<>
{ group.chats.map((chat, index) =>
{
if(chat.type === MessengerThreadChat.SECURITY_NOTIFICATION) return null;
return (
<Flex key={ index } fullWidth gap={ 2 } justifyContent="start">
<Base className="w-full text-break">
{ (chat.type === MessengerThreadChat.SECURITY_NOTIFICATION) &&
<Flex alignItems="center" className="bg-light rounded mb-2 px-2 py-1 small text-muted" gap={ 2 }>
<Base className="nitro-friends-spritesheet icon-warning shrink-0" />
<Base>{ chat.message }</Base>
</Flex> }
{ (chat.type === MessengerThreadChat.ROOM_INVITE) &&
<Flex alignItems="center" className="bg-light rounded mb-2 px-2 py-1 small text-black" gap={ 2 }>
<Base className="messenger-notification-icon shrink-0" />
@@ -50,24 +47,46 @@ export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: M
}
return (
<Flex fullWidth gap={ 2 } justifyContent={ isOwnChat ? 'end' : 'start' }>
<Flex fullWidth gap={ 2 } justifyContent={ isOwnChat ? 'end' : 'start' } className={ 'messenger-message-row ' + (isOwnChat ? 'own' : '') }>
<Base shrink className="message-avatar">
{ ((group.type === MessengerGroupType.PRIVATE_CHAT) && !isOwnChat) &&
<LayoutAvatarImageView direction={ 2 } figure={ thread.participant.figure } /> }
<LayoutAvatarImageView direction={ 2 } figure={ thread.participant.figure } headOnly={ true } /> }
{ (groupChatData && !isOwnChat) &&
<LayoutAvatarImageView direction={ 2 } figure={ groupChatData.figure } /> }
<LayoutAvatarImageView direction={ 2 } figure={ groupChatData.figure } headOnly={ true } /> }
</Base>
<Base className={ 'bg-light text-black border-radius mb-2 rounded py-1 px-2 messages-group-' + (isOwnChat ? 'right' : 'left') }>
<Base className="font-bold">
<Base className="small text-muted">{ group.chats[0].date.toLocaleTimeString() }</Base>
<Base className="messenger-message-body">
<Base className={ 'messenger-message-name ' + (isOwnChat ? 'text-end' : '') }>
{ isOwnChat && GetSessionDataManager().userName }
{ !isOwnChat && (groupChatData ? groupChatData.username : thread.participant.name) }
:
</Base>
{ group.chats.map((chat, index) => <Base key={ index } className="text-break">{ chat.message }</Base>) }
<Base className={ 'messenger-message-bubble messages-group-' + (isOwnChat ? 'right' : 'left') }>
{ group.chats.map((chat, index) =>
{
if(!chat.showTranslation)
{
return <Base key={ index } className="text-break">{ chat.message }</Base>;
}
return (
<Base key={ index } className="messenger-translation-block">
<Base className="messenger-translation-row">
<span className="messenger-translation-label">original:</span>
<span className="text-break">{ chat.originalMessage || chat.message }</span>
</Base>
<Base className="messenger-translation-row">
<span className="messenger-translation-label">translate:</span>
<span className="text-break">{ chat.translatedMessage || chat.message }</span>
</Base>
</Base>
);
}) }
</Base>
<Base className="messenger-message-time">{ group.chats[0].date.toLocaleTimeString() }</Base>
</Base>
{ isOwnChat &&
<Base shrink className="message-avatar">
<LayoutAvatarImageView direction={ 4 } figure={ GetSessionDataManager().figure } />
<LayoutAvatarImageView direction={ 4 } figure={ GetSessionDataManager().figure } headOnly={ true } />
</Base> }
</Flex>
);
@@ -12,7 +12,7 @@ export const InterfaceImageTabView: FC<{}> = () =>
const baseUrl = useMemo(() =>
{
return GetConfigurationValue<string>('ui.header.images.url', 'https://image.webbo.city/image/headerImage/image{id}.gif');
return GetConfigurationValue<string>('ui.header.images.url', '');
}, []);
const images = useMemo(() =>
@@ -1,24 +1,29 @@
import { FC, useEffect, useState } from 'react';
import { FC, useEffect, useMemo, useState } from 'react';
import { FaTrashAlt } from 'react-icons/fa';
import { IPrefixItem, LocalizeText, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api';
import { useInventoryPrefixes, useNotification } from '../../../../hooks';
import { IPrefixItem, LocalizeText, parsePrefixColors, getPrefixEffectStyle, getPrefixFontStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api';
import { Button } from '../../../../common';
import { GetNickIconUrl } from '../../../../assets/images/user_custom/nick_icons';
import { useInventoryNickIcons, useInventoryPrefixes, useNotification } from '../../../../hooks';
import { NitroButton } from '../../../../layout';
const PrefixPreview: FC<{ text: string; color: string; icon: string; effect?: string; className?: string; textSize?: string }> = ({ text, color, icon, effect = '', className = '', textSize = 'text-sm' }) =>
type InventoryIdentityTab = 'prefixes' | 'icons';
const PrefixPreview: FC<{ text: string; color: string; icon: string; effect?: string; font?: string; className?: string; textSize?: string }> = ({ text, color, icon, effect = '', font = '', className = '', textSize = 'text-sm' }) =>
{
const colors = parsePrefixColors(text, color);
const hasMultiColor = colors.length > 1 && new Set(colors).size > 1;
const fxStyle = getPrefixEffectStyle(effect, colors[0] || '#FFFFFF');
const fontStyle = getPrefixFontStyle(font);
return (
<span className={ `font-bold ${ textSize } ${ className }` } style={ fxStyle }>
{ effect === 'pulse' && <style>{ PREFIX_EFFECT_KEYFRAMES }</style> }
<span className={ `font-bold ${ textSize } ${ className }` } style={ { ...fontStyle, ...fxStyle } }>
{ !!effect && <style>{ PREFIX_EFFECT_KEYFRAMES }</style> }
{ icon && <span className="mr-0.5">{ icon }</span> }
<span style={ hasMultiColor ? fxStyle : { ...fxStyle, color: colors[0] || '#FFFFFF' } }>
<span style={ hasMultiColor ? { ...fontStyle, ...fxStyle } : { ...fontStyle, ...fxStyle, color: colors[0] || '#FFFFFF' } }>
{'{'}
{ hasMultiColor
? [ ...text ].map((char, i) => (
<span key={ i } style={ { color: colors[i] || colors[colors.length - 1], ...getPrefixEffectStyle(effect, colors[i]) } }>{ char }</span>
<span key={ i } style={ { ...fontStyle, color: colors[i] || colors[colors.length - 1], ...getPrefixEffectStyle(effect, colors[i]) } }>{ char }</span>
))
: text
}
@@ -40,7 +45,30 @@ const PrefixItemView: FC<{
${ isSelected ? 'border-card-grid-item-active bg-card-grid-item-active' : 'border-card-grid-item-border bg-card-grid-item' }
${ prefix.active ? 'ring-2 ring-green-400' : '' }` }
onClick={ onClick }>
<PrefixPreview className="truncate" color={ prefix.color } effect={ prefix.effect } icon={ prefix.icon } text={ prefix.text } />
<PrefixPreview className="truncate" color={ prefix.color } effect={ prefix.effect } font={ prefix.font } icon={ prefix.icon } text={ prefix.text } />
</div>
);
};
const NickIconItemView: FC<{
iconKey: string;
displayName: string;
isSelected: boolean;
isActive: boolean;
onClick: () => void;
}> = ({ iconKey, displayName, isSelected, isActive, onClick }) =>
{
return (
<div
className={ `relative flex cursor-pointer items-center justify-center rounded-md border-2 p-2 transition-colors
${ isSelected ? 'border-card-grid-item-active bg-card-grid-item-active' : 'border-card-grid-item-border bg-card-grid-item' }
${ isActive ? 'ring-2 ring-green-400' : '' }` }
onClick={ onClick }>
{ isActive && <span className="absolute right-1 top-1 rounded bg-[#15954c] px-1 py-0.5 text-[8px] font-bold uppercase text-white">Active</span> }
<div className="flex flex-col items-center gap-1">
<img className="h-auto max-h-[28px] w-auto object-contain" src={ GetNickIconUrl(iconKey) } alt={ displayName || iconKey } />
<span className="max-w-[90px] truncate text-center text-[11px] font-bold">{ displayName || iconKey }</span>
</div>
</div>
);
};
@@ -48,8 +76,13 @@ const PrefixItemView: FC<{
export const InventoryPrefixView: FC<{}> = () =>
{
const [ isVisible, setIsVisible ] = useState(false);
const [ activeTab, setActiveTab ] = useState<InventoryIdentityTab>('prefixes');
const { prefixes = [], activePrefix = null, selectedPrefix = null, setSelectedPrefix = null, activatePrefix = null, deactivatePrefix = null, deletePrefix = null, activate = null, deactivate = null } = useInventoryPrefixes();
const { nickIcons = [], activeNickIcon = null, selectedNickIcon = null, setSelectedNickIcon = null, activateNickIcon = null, deactivateNickIcon = null, activate: activateNickIcons = null, deactivate: deactivateNickIcons = null } = useInventoryNickIcons();
const { showConfirm = null } = useNotification();
const hasPrefixes = prefixes && (prefixes.length > 0);
const hasNickIcons = nickIcons && (nickIcons.length > 0);
const selectedIconUrl = useMemo(() => selectedNickIcon ? GetNickIconUrl(selectedNickIcon.iconKey) : '', [ selectedNickIcon ]);
const attemptDeletePrefix = () =>
{
@@ -69,10 +102,15 @@ export const InventoryPrefixView: FC<{}> = () =>
{
if(!isVisible) return;
const id = activate();
const prefixVisibilityId = activate();
const iconVisibilityId = activateNickIcons();
return () => deactivate(id);
}, [ isVisible, activate, deactivate ]);
return () =>
{
deactivate(prefixVisibilityId);
deactivateNickIcons(iconVisibilityId);
};
}, [ isVisible, activate, activateNickIcons, deactivate, deactivateNickIcons ]);
useEffect(() =>
{
@@ -82,55 +120,115 @@ export const InventoryPrefixView: FC<{}> = () =>
}, []);
return (
<div className="grid h-full grid-cols-12 gap-2">
<div className="flex flex-col col-span-7 gap-1 overflow-auto">
<div className="grid grid-cols-3 gap-1">
{ prefixes.map(prefix => (
<PrefixItemView
key={ prefix.id }
isSelected={ selectedPrefix?.id === prefix.id }
prefix={ prefix }
onClick={ () => setSelectedPrefix(prefix) } />
)) }
<div className="flex h-full flex-col gap-2">
<div className="shrink-0 rounded border border-black/10 bg-[#C9C9C9] p-1">
<div className="flex items-center gap-2">
<button
className={ `rounded px-3 py-1.5 text-[11px] font-bold transition-colors ${ activeTab === 'prefixes' ? 'bg-[#1e7295] text-white' : 'bg-white text-black' }` }
type="button"
onClick={ () => setActiveTab('prefixes') }>
Prefixes
</button>
<button
className={ `rounded px-3 py-1.5 text-[11px] font-bold transition-colors ${ activeTab === 'icons' ? 'bg-[#1e7295] text-white' : 'bg-white text-black' }` }
type="button"
onClick={ () => setActiveTab('icons') }>
Icons
</button>
</div>
{ (!prefixes || prefixes.length === 0) &&
<div className="flex items-center justify-center h-full text-sm opacity-50">
{ LocalizeText('inventory.empty.title') }
</div> }
</div>
<div className="flex flex-col justify-between col-span-5 overflow-auto">
{ activePrefix &&
<div className="flex flex-col gap-1">
<span className="text-sm truncate min-h-[1.25rem] leading-5">Active prefix</span>
<div className="flex items-center justify-center p-3 rounded-md border-2 border-green-400 bg-card-grid-item">
<PrefixPreview color={ activePrefix.color } effect={ activePrefix.effect } icon={ activePrefix.icon } text={ activePrefix.text } textSize="text-lg" />
{ activeTab === 'prefixes' &&
<div className="grid h-full grid-cols-12 gap-2">
<div className="col-span-7 flex flex-col gap-1 overflow-auto pr-1">
<div className="grid grid-cols-3 gap-1">
{ prefixes.map(prefix => (
<PrefixItemView
key={ prefix.id }
isSelected={ selectedPrefix?.id === prefix.id }
prefix={ prefix }
onClick={ () => setSelectedPrefix(prefix) } />
)) }
</div>
</div> }
{ !activePrefix &&
<div className="flex flex-col gap-1">
<span className="text-sm truncate min-h-[1.25rem] leading-5">Active prefix</span>
<div className="flex items-center justify-center p-3 rounded-md border-2 border-dashed border-card-grid-item-border bg-card-grid-item opacity-50">
<span className="text-sm">No active prefix</span>
{ !hasPrefixes &&
<div className="flex h-full items-center justify-center text-sm opacity-50">
{ LocalizeText('inventory.empty.title') }
</div> }
</div>
<div className="col-span-5 flex flex-col justify-between overflow-auto">
{ activePrefix &&
<div className="flex flex-col gap-1">
<span className="min-h-[1.25rem] truncate text-sm leading-5">Active prefix</span>
<div className="flex items-center justify-center rounded-md border-2 border-green-400 bg-card-grid-item p-3">
<PrefixPreview color={ activePrefix.color } effect={ activePrefix.effect } font={ activePrefix.font } icon={ activePrefix.icon } text={ activePrefix.text } textSize="text-lg" />
</div>
</div> }
{ !activePrefix &&
<div className="flex flex-col gap-1">
<span className="min-h-[1.25rem] truncate text-sm leading-5">Active prefix</span>
<div className="flex items-center justify-center rounded-md border-2 border-dashed border-card-grid-item-border bg-card-grid-item p-3 opacity-50">
<span className="text-sm">No active prefix</span>
</div>
</div> }
{ !!selectedPrefix &&
<div className="mt-2 flex flex-col gap-2">
<div className="flex items-center justify-center gap-2 rounded bg-card-grid-item p-2">
<PrefixPreview color={ selectedPrefix.color } effect={ selectedPrefix.effect } font={ selectedPrefix.font } icon={ selectedPrefix.icon } text={ selectedPrefix.text } textSize="text-lg" />
</div>
<div className="flex items-center gap-2">
<NitroButton
className="grow"
onClick={ () => selectedPrefix.active ? deactivatePrefix() : activatePrefix(selectedPrefix.id) }>
{ selectedPrefix.active ? 'Deactivate' : 'Activate' }
</NitroButton>
{ !selectedPrefix.active &&
<NitroButton className="bg-danger! hover:bg-danger/80! p-1" onClick={ attemptDeletePrefix }>
<FaTrashAlt className="fa-icon" />
</NitroButton> }
</div>
</div> }
</div>
</div> }
{ activeTab === 'icons' &&
<div className="grid h-full grid-cols-12 gap-2">
<div className="col-span-7 flex flex-col gap-1 overflow-auto pr-1">
<div className="grid grid-cols-3 gap-1">
{ nickIcons.map(icon => (
<NickIconItemView
key={ icon.id }
displayName={ icon.displayName }
iconKey={ icon.iconKey }
isActive={ !!icon.active }
isSelected={ selectedNickIcon?.id === icon.id }
onClick={ () => setSelectedNickIcon(icon) } />
)) }
</div>
</div> }
{ !!selectedPrefix &&
<div className="flex flex-col gap-2 mt-2">
<div className="flex items-center justify-center gap-2 p-2 rounded bg-card-grid-item">
<PrefixPreview color={ selectedPrefix.color } effect={ selectedPrefix.effect } icon={ selectedPrefix.icon } text={ selectedPrefix.text } textSize="text-lg" />
{ !hasNickIcons &&
<div className="flex h-full items-center justify-center text-sm opacity-50">
No purchased icons yet
</div> }
</div>
<div className="col-span-5 flex flex-col justify-between overflow-auto">
<div className="flex flex-col gap-1">
<span className="min-h-[1.25rem] truncate text-sm leading-5">Active icon</span>
<div className={ `flex min-h-[88px] items-center justify-center rounded-md border-2 bg-card-grid-item p-3 ${ activeNickIcon ? 'border-green-400' : 'border-dashed border-card-grid-item-border opacity-50' }` }>
{ activeNickIcon && <img className="h-auto max-h-[36px] w-auto object-contain" src={ GetNickIconUrl(activeNickIcon.iconKey) } alt={ activeNickIcon.displayName || activeNickIcon.iconKey } /> }
{ !activeNickIcon && <span className="text-sm">No active icon</span> }
</div>
</div>
<div className="flex items-center gap-2">
<NitroButton
className="grow"
onClick={ () => selectedPrefix.active ? deactivatePrefix() : activatePrefix(selectedPrefix.id) }>
{ selectedPrefix.active ? 'Deactivate' : 'Activate' }
</NitroButton>
{ !selectedPrefix.active &&
<NitroButton className="bg-danger! hover:bg-danger/80! p-1" onClick={ attemptDeletePrefix }>
<FaTrashAlt className="fa-icon" />
</NitroButton> }
</div>
</div> }
</div>
{ !!selectedNickIcon &&
<div className="mt-2 flex flex-col gap-2">
<div className="flex min-h-[100px] flex-col items-center justify-center gap-2 rounded bg-card-grid-item p-3 text-center">
<img className="h-auto max-h-[40px] w-auto object-contain" src={ selectedIconUrl } alt={ selectedNickIcon.displayName || selectedNickIcon.iconKey } />
<span className="text-sm font-bold">{ selectedNickIcon.displayName || selectedNickIcon.iconKey }</span>
</div>
<Button disabled={ false } onClick={ () => selectedNickIcon.active ? deactivateNickIcon() : activateNickIcon(selectedNickIcon.id) }>
{ selectedNickIcon.active ? 'Deactivate' : 'Activate' }
</Button>
</div> }
</div>
</div> }
</div>
);
};
+12 -7
View File
@@ -1,4 +1,5 @@
import { FC } from 'react';
import loadingGif from '@/assets/images/loading/loading.gif';
import { Base, Column, Text } from '../../common';
interface LoadingViewProps {
@@ -11,11 +12,9 @@ export const LoadingView: FC<LoadingViewProps> = props => {
const { isError = false, message = '', homeUrl = '' } = props;
return (
<Column fullHeight position="relative" className="relative z-[100] bg-[radial-gradient(#1d1a24,#003a6b)]">
<Column fullHeight position="fixed" className="fixed inset-0 z-[2147483000] bg-[radial-gradient(#1d1a24,#003a6b)]">
<Base fullHeight className="container h-100">
<Column fullHeight alignItems="center" justifyContent="center">
{ !isError &&
<Base className="absolute inset-0 m-auto w-[84px] h-[84px] [zoom:1.5] [image-rendering:pixelated] bg-[url('@/assets/images/loading/loading.gif')] bg-no-repeat bg-left-top" /> }
<Base className="absolute top-[20px] left-[20px] z-[2] w-[150px] h-[100px] bg-[url('@/assets/images/notifications/nitro_v3.png')] bg-no-repeat bg-left-top" />
{ isError && (message && message.length) ?
<Column alignItems="center" className="absolute bottom-[20px] left-1/2 z-[3] -translate-x-1/2 max-w-[80%]" gap={ 2 }>
@@ -32,12 +31,18 @@ export const LoadingView: FC<LoadingViewProps> = props => {
}
</Column>
:
<Text fontSizeCustom={32} variant="white" className="absolute bottom-[20px] left-1/2 z-[3] -translate-x-1/2 [text-shadow:0px_4px_4px_rgba(0,0,0,0.25)]">
The hotel is loading ...
</Text>
<Column alignItems="center" justifyContent="center" gap={ 3 } className="z-[3]">
<img src={ loadingGif } alt="" draggable={ false } className="block w-auto h-auto select-none pointer-events-none" />
{ message && message.length ?
<Text fontSizeCustom={ 20 } variant="white" className="text-center [text-shadow:0px_4px_4px_rgba(0,0,0,0.25)]">
{ message }
</Text>
: null
}
</Column>
}
</Column>
</Base>
</Column>
);
};
};
File diff suppressed because it is too large Load Diff
+25 -5
View File
@@ -1,5 +1,5 @@
import { FC, useEffect, useState } from 'react';
import { t } from '../utils/i18n';
import { interpolate, t } from '../utils/i18n';
import { resolveNewsImage, resolveNewsLink } from '../utils/news';
interface NewsItem
@@ -12,6 +12,26 @@ interface NewsItem
linkUrl: string;
}
interface RawNewsItem
{
id?: number;
title?: string;
body?: string;
image?: string | null;
link?: string;
linkUrl?: string;
linkText?: string;
}
const normalizeNewsItem = (raw: RawNewsItem, fallbackId: number): NewsItem => ({
id: typeof raw.id === 'number' ? raw.id : fallbackId,
title: typeof raw.title === 'string' ? raw.title : '',
body: typeof raw.body === 'string' ? raw.body : '',
image: typeof raw.image === 'string' && raw.image.length ? interpolate(raw.image) : null,
linkText: typeof raw.linkText === 'string' ? raw.linkText : '',
linkUrl: interpolate((typeof raw.linkUrl === 'string' && raw.linkUrl) || (typeof raw.link === 'string' ? raw.link : ''))
});
interface NewsWindowProps { newsUrl: string; }
const NEWS_AUTO_ADVANCE_MS = 10000;
@@ -36,10 +56,10 @@ export const NewsWindow: FC<NewsWindowProps> = ({ newsUrl }) =>
.then((json: unknown) =>
{
if(cancelled) return;
const list = Array.isArray((json as { news?: unknown })?.news)
? (json as { news: NewsItem[] }).news
: [];
setItems(list);
const rawList = Array.isArray((json as { news?: unknown })?.news)
? (json as { news: RawNewsItem[] }).news
: Array.isArray(json) ? (json as RawNewsItem[]) : [];
setItems(rawList.map((raw, idx) => normalizeNewsItem(raw, idx + 1)));
})
.catch(() => { if(!cancelled) setFailed(true); });
return () => { cancelled = true; };
+7 -6
View File
@@ -1,7 +1,7 @@
import { CreateLinkEvent, HabboClubLevelEnum } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FaChevronDown, FaQuestionCircle, FaSignOutAlt } from 'react-icons/fa';
import { FriendlyTime, GetConfigurationValue, LocalizeText } from '../../api';
import { FaChevronDown, FaLanguage, FaQuestionCircle, FaSignOutAlt } from 'react-icons/fa';
import { ClearRememberLogin, FriendlyTime, GetConfigurationValue, GetRememberLogin, LocalizeText } from '../../api';
import { Column, Flex, LayoutCurrencyIcon, Text } from '../../common';
import { usePurse } from '../../hooks';
import purseIcon from '../../assets/images/rightside/purse.gif';
@@ -64,9 +64,7 @@ export const PurseView: FC<{}> = props => {
const logoutUrl = GetConfigurationValue<string>('login.logout.endpoint', '/api/auth/logout');
const ssoTicket = (window.NitroConfig?.['sso.ticket'] as string) ?? '';
let rememberToken = '';
try { rememberToken = window.localStorage.getItem('nitro.remember.token') ?? ''; }
catch { /* localStorage may be disabled */ }
const rememberToken = GetRememberLogin()?.token || '';
try
{
@@ -84,7 +82,7 @@ export const PurseView: FC<{}> = props => {
}
catch { /* best-effort — proceed with local logout regardless */ }
try { window.localStorage.removeItem('nitro.remember.token'); } catch { /* noop */ }
ClearRememberLogin();
if(window.NitroConfig) window.NitroConfig['sso.ticket'] = '';
window.location.reload();
}, []);
@@ -122,6 +120,9 @@ export const PurseView: FC<{}> = props => {
</div>
</div> }
<div className="nitro-purse__actions">
<button type="button" className="nitro-purse__action-button nitro-purse__action-button--translate" onClick={ event => { event.stopPropagation(); CreateLinkEvent('translation-settings/toggle'); } } title="Google Translate">
<FaLanguage />
</button>
<button type="button" className="nitro-purse__action-button nitro-purse__action-button--help" onClick={ event => { event.stopPropagation(); CreateLinkEvent('help/show'); } } title={ LocalizeText('help.button.name') }>
<FaQuestionCircle />
</button>
@@ -3,7 +3,7 @@ import { GetSessionDataManager, RelationshipStatusInfoEvent, RelationshipStatusI
import { Dispatch, FC, FocusEvent, KeyboardEvent, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react';
import { FaPencilAlt, FaTimes } from 'react-icons/fa';
import { AvatarInfoUser, CloneObject, GetConfigurationValue, GetGroupInformation, GetUserProfile, LocalizeText, SendMessageComposer } from '../../../../../api';
import { Base, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, Text, UserProfileIconView } from '../../../../../common';
import { Base, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, Text, UserIdentityView, UserProfileIconView } from '../../../../../common';
import { useMessageEvent, useNitroEvent, useRoom } from '../../../../../hooks';
import { InfoStandBadgeSlotView } from './InfoStandBadgeSlotView';
import { InfoStandWidgetUserRelationshipsView } from './InfoStandWidgetUserRelationshipsView';
@@ -32,7 +32,6 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
const infostandStandClass = `stand-${standId ?? 'default'}`;
const infostandOverlayClass = `overlay-${overlayId ?? 'default'}`;
const infostandCardBackgroundClass = cardBackgroundId ? `card-background-${cardBackgroundId}` : '';
const handleProfileClick = useCallback(() => { GetUserProfile(avatarInfo.webID); }, [avatarInfo.webID]);
const handleEditClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); setIsVisible(prev => !prev); }, []);
@@ -90,6 +89,12 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
newValue.figure = event.figure;
newValue.motto = event.customInfo;
newValue.achievementScore = event.activityPoints;
newValue.nickIcon = event.nickIcon;
newValue.prefixText = event.prefixText;
newValue.prefixColor = event.prefixColor;
newValue.prefixIcon = event.prefixIcon;
newValue.prefixEffect = event.prefixEffect;
newValue.displayOrder = event.displayOrder;
newValue.backgroundId = event.backgroundId;
newValue.standId = event.standId;
newValue.overlayId = event.overlayId;
@@ -147,7 +152,17 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<UserProfileIconView userId={avatarInfo.webID} />
<Text small wrap variant="white">{avatarInfo.name}</Text>
<UserIdentityView
className="text-[12px]"
displayOrder={ avatarInfo.displayOrder }
nameClassName="text-white"
nickIcon={ avatarInfo.nickIcon }
prefixColor={ avatarInfo.prefixColor }
prefixEffect={ avatarInfo.prefixEffect }
prefixFont={ avatarInfo.prefixFont }
prefixIcon={ avatarInfo.prefixIcon }
prefixText={ avatarInfo.prefixText }
username={ avatarInfo.name } />
</div>
<FaTimes className="cursor-pointer fa-icon" onClick={onClose} />
</div>
@@ -279,7 +279,7 @@ export const ChatInputView: FC<{}> = props =>
return (
createPortal(
<div className="nitro-chat-input-container flex justify-between items-center h-10 border-2 border-black bg-gray-200 pr-2.5 overflow-visible rounded-lg lg:relative lg:w-full max-lg:fixed max-lg:bottom-[70px] max-lg:left-1/2 max-lg:-translate-x-1/2 max-lg:z-50 max-lg:w-[80vw] max-lg:max-w-[500px] max-lg:shadow-lg">
<div className="nitro-chat-input-container relative flex h-[38px] w-full items-center justify-between overflow-visible rounded-[12px] border-2 border-black bg-white pr-[8px]">
{ commandSelectorVisible &&
<ChatInputCommandSelectorView
commands={ filteredCommands }
@@ -1,6 +1,7 @@
import { GetRoomEngine, RoomChatSettings, RoomObjectCategory } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { ChatBubbleMessage, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api';
import { ChatBubbleMessage } from '../../../../api';
import { UserIdentityView } from '../../../../common';
import { useOnClickChat } from '../../../../hooks';
interface ChatWidgetMessageViewProps
@@ -38,11 +39,11 @@ export const ChatWidgetMessageView: FC<ChatWidgetMessageViewProps> = ({
useEffect(() =>
{
setIsVisible(false);
const element = elementRef.current;
if(!element) return;
const previousWidth = chat.width;
const previousHeight = chat.height;
const { offsetWidth: width, offsetHeight: height } = element;
chat.width = width;
@@ -62,10 +63,14 @@ export const ChatWidgetMessageView: FC<ChatWidgetMessageViewProps> = ({
setIsReady(true);
if(isVisible && ((previousWidth !== width) || (previousHeight !== height)) && makeRoom) makeRoom(chat);
}, [ chat, chat.formattedText, chat.originalFormattedText, chat.showTranslation, chat.translatedFormattedText, isVisible, makeRoom ]);
useEffect(() =>
{
return () =>
{
chat.elementRef = null;
setIsReady(false);
};
}, [ chat ]);
@@ -77,6 +82,8 @@ export const ChatWidgetMessageView: FC<ChatWidgetMessageViewProps> = ({
setIsVisible(true);
}, [ chat, isReady, isVisible, makeRoom ]);
const messageClassName = `message [overflow-wrap:anywhere] break-words${ chat.type === 1 ? ' italic text-[#595959]' : '' }${ chat.type === 2 ? ' font-bold' : '' }`;
return (
<div ref={ elementRef } className={ `bubble-container newbubblehe ${ isVisible ? 'visible' : 'invisible' } w-max absolute select-none pointer-events-auto` }
onClick={ () => GetRoomEngine().selectRoomObject(chat.roomId, chat.senderId, RoomObjectCategory.UNIT) }>
@@ -90,29 +97,33 @@ export const ChatWidgetMessageView: FC<ChatWidgetMessageViewProps> = ({
) }
</div>
<div className="chat-content py-[5px] px-[6px] ml-[27px] leading-none min-h-[25px]">
{ chat.prefixEffect === 'pulse' && <style>{ PREFIX_EFFECT_KEYFRAMES }</style> }
{ chat.prefixText && (() => {
const colors = parsePrefixColors(chat.prefixText, chat.prefixColor);
const hasMultiColor = colors.length > 1 && new Set(colors).size > 1;
const fxStyle = getPrefixEffectStyle(chat.prefixEffect, colors[0] || '#FFFFFF');
return (
<span className="prefix font-bold mr-1" style={ fxStyle }>
{ chat.prefixIcon && <span className="mr-0.5 text-[13px]">{ chat.prefixIcon }</span> }
<span style={ hasMultiColor ? fxStyle : { ...fxStyle, color: colors[0] || '#FFFFFF' } }>
{'{'}
{ hasMultiColor
? [ ...chat.prefixText ].map((char, i) => (
<span key={ i } style={ { color: colors[i] || colors[colors.length - 1], ...getPrefixEffectStyle(chat.prefixEffect, colors[i]) } }>{ char }</span>
))
: chat.prefixText
}
{'}'}
</span>
</span>
);
})() }
<b className="username" dangerouslySetInnerHTML={ { __html: `${ chat.username }: ` } } />
<span className={ `message${ chat.type === 1 ? ' italic text-[#595959]' : '' }` } dangerouslySetInnerHTML={ { __html: `${ chat.formattedText }` } } onClick={ onClickChat } />
<UserIdentityView
className="mr-1 align-middle"
displayOrder={ chat.displayOrder }
iconClassName="inline-block w-auto h-auto align-[-1px]"
nameClassName="username font-bold"
nickIcon={ chat.nickIcon }
prefixClassName=""
prefixColor={ chat.prefixColor }
prefixEffect={ chat.prefixEffect }
prefixFont={ chat.prefixFont }
prefixIcon={ chat.prefixIcon }
prefixText={ chat.prefixText }
showColon={ true }
username={ chat.username } />
{ !chat.showTranslation &&
<span className={ `${ messageClassName } align-middle` } dangerouslySetInnerHTML={ { __html: `${ chat.formattedText }` } } onClick={ onClickChat } /> }
{ chat.showTranslation &&
<div className="mt-[2px] flex flex-col gap-[2px]" onClick={ onClickChat }>
<div className="flex items-start gap-1 leading-[1.1]">
<span className="inline-block min-w-[52px] font-bold" style={ { opacity: 0.75 } }>original:</span>
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: `${ chat.originalFormattedText || chat.formattedText }` } } />
</div>
<div className="flex items-start gap-1 leading-[1.1]">
<span className="inline-block min-w-[52px] font-bold" style={ { opacity: 0.75 } }>translate:</span>
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: `${ chat.translatedFormattedText || chat.formattedText }` } } />
</div>
</div> }
</div>
<div className="pointer absolute left-[50%] translate-x-[-50%] w-[9px] h-[6px] bottom-[-5px]" />
</div>
@@ -2,7 +2,7 @@ import { GetSessionDataManager, RoomObjectType } from '@nitrots/nitro-renderer';
import { FC, UIEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChatEntryType, LocalizeText } from '../../../../api';
import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useChatHistory, useChatWindow } from '../../../../hooks';
import { useChatHistory, useChatWindow, useOnClickChat } from '../../../../hooks';
import { useRoom } from '../../../../hooks/rooms';
const BOTTOM_SCROLL_THRESHOLD = 20;
@@ -19,6 +19,7 @@ export const ChatWidgetWindowView: FC<{}> = () =>
const { chatHistory = [], clearChatHistory = null } = useChatHistory();
const [ , setChatWindowEnabled ] = useChatWindow();
const { roomSession = null } = useRoom();
const { onClickChat } = useOnClickChat();
const ownUserId = (GetSessionDataManager()?.userId || -1);
const roomChatHistory = useMemo(() =>
@@ -33,7 +34,7 @@ export const ChatWidgetWindowView: FC<{}> = () =>
if(!normalizedSearch.length) return true;
return (`${ chat.name } ${ chat.message }`.toLowerCase().includes(normalizedSearch));
return (`${ chat.name } ${ chat.message || '' } ${ chat.originalMessage || '' } ${ chat.translatedMessage || '' }`.toLowerCase().includes(normalizedSearch));
});
}, [ chatHistory, roomSession?.roomId, hidePets, search ]);
@@ -125,14 +126,27 @@ export const ChatWidgetWindowView: FC<{}> = () =>
{
const isOwnMessage = (chat.webId === ownUserId);
const rowClassName = `mb-1 flex items-start gap-1 break-words ${ isOwnMessage ? 'justify-end' : '' }`;
const messageClassName = `message${ chat.chatType === 1 ? ' italic text-[#595959]' : '' }${ chat.chatType === 2 ? ' font-bold' : '' }`;
return (
<div key={ `${ chat.timestamp }-${ chat.id }` } className={ rowClassName }>
{ hideBalloons && !hideAvatars && <div className={ `w-[65px] h-[55px] shrink-0 mt-[-18px] rounded-sm bg-no-repeat bg-center scale-70 ${ isOwnMessage ? 'order-2' : '' }` } style={ chat.imageUrl ? { backgroundImage: `url(${ chat.imageUrl })` } : undefined } /> }
{ hideBalloons && (
<div>
<div onClick={ onClickChat }>
<b dangerouslySetInnerHTML={ { __html: `${ chat.name }: ` } } />
<span dangerouslySetInnerHTML={ { __html: chat.message } } />
{ !chat.showTranslation &&
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: chat.message } } /> }
{ chat.showTranslation &&
<div className="mt-[2px] flex flex-col gap-[2px]">
<div className="flex items-start gap-1 leading-[1.15]">
<span className="inline-block min-w-[52px] font-bold opacity-75">original:</span>
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: chat.originalMessage || chat.message || '' } } />
</div>
<div className="flex items-start gap-1 leading-[1.15]">
<span className="inline-block min-w-[52px] font-bold opacity-75">translate:</span>
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: chat.translatedMessage || chat.message || '' } } />
</div>
</div> }
</div>
) }
{ !hideBalloons && (
@@ -148,7 +162,19 @@ export const ChatWidgetWindowView: FC<{}> = () =>
</div>
<div className={ `chat-content py-[5px] px-[6px] leading-none min-h-[25px] ${ !hideAvatars ? (isOwnMessage ? 'mr-[27px]' : 'ml-[27px]') : '' }` }>
<b className="username" dangerouslySetInnerHTML={ { __html: `${ chat.name }: ` } } />
<span className="message" dangerouslySetInnerHTML={ { __html: `${ chat.message }` } } />
{ !chat.showTranslation &&
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: `${ chat.message }` } } onClick={ onClickChat } /> }
{ chat.showTranslation &&
<div className="mt-[2px] flex flex-col gap-[2px]" onClick={ onClickChat }>
<div className="flex items-start gap-1 leading-[1.1]">
<span className="inline-block min-w-[52px] font-bold" style={ { opacity: 0.75 } }>original:</span>
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: `${ chat.originalMessage || chat.message || '' }` } } />
</div>
<div className="flex items-start gap-1 leading-[1.1]">
<span className="inline-block min-w-[52px] font-bold" style={ { opacity: 0.75 } }>translate:</span>
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: `${ chat.translatedMessage || chat.message || '' }` } } />
</div>
</div> }
</div>
</div>
</div>

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