@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 dell’emulatore / 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 +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>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"notification.badge.received": "New Badge!"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"notification.badge.received": "Nuovo Distintivo!"
|
||||
}
|
||||
@@ -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/`
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 3–16 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.";
|
||||
});
|
||||
})();
|
||||
@@ -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.";
|
||||
});
|
||||
})();
|
||||
@@ -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/"
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>`);
|
||||
@@ -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);
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,12 @@ export class GroupItem
|
||||
this.setDescription();
|
||||
}
|
||||
|
||||
public refreshLocalization(): void
|
||||
{
|
||||
this.setName();
|
||||
this.setDescription();
|
||||
}
|
||||
|
||||
public dispose(): void
|
||||
{
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface INickIconItem
|
||||
{
|
||||
id: number;
|
||||
iconKey: string;
|
||||
displayName: string;
|
||||
points: number;
|
||||
pointsType: number;
|
||||
owned: boolean;
|
||||
active: boolean;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 />');
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 427 B |
|
After Width: | Height: | Size: 232 B |
|
After Width: | Height: | Size: 715 B |
|
After Width: | Height: | Size: 904 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
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()] || '');
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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; };
|
||||
|
||||
@@ -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>
|
||||
|
||||