mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 07:26:19 +00:00
@@ -0,0 +1,586 @@
|
|||||||
|
# Nitro V3 — Cold-load performance
|
||||||
|
|
||||||
|
Practical recipe to take a Nitro V3 cold load from the typical
|
||||||
|
60-90 s (and intermittent "Session expired") baseline down to ~4 s.
|
||||||
|
The wins compound: each section below has measurable impact, in
|
||||||
|
roughly the order of cost vs benefit.
|
||||||
|
|
||||||
|
Three things matter on the client (this repo): granular code split,
|
||||||
|
a real progress bar driven by boot stages, and capturing the
|
||||||
|
remember-token from the iframe URL on first boot. The other three —
|
||||||
|
gzip on the static server, long cache on gamedata, and a server
|
||||||
|
endpoint to mint fresh SSO tickets — are documented further down
|
||||||
|
under §5 (nginx) and §6 (IIS) plus a quick note in §4 about the
|
||||||
|
CMS contract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The three Nitro-side changes that matter
|
||||||
|
|
||||||
|
1. **Granular code split** (`vite.config.mjs`) — a 1 MB vendor bundle
|
||||||
|
is replaced by ~12 smaller chunks the browser fetches in parallel
|
||||||
|
via HTTP/2 multiplexing.
|
||||||
|
2. **Loading screen with a real progress bar** + per-stage labels
|
||||||
|
(`src/components/loading/LoadingView.tsx`, driven by
|
||||||
|
`src/App.tsx::prepare()`) so a slow boot looks like progress, not
|
||||||
|
a frozen GIF.
|
||||||
|
3. **Remember-token capture from URL** (`src/App.tsx::prepare()`) so
|
||||||
|
that when the WS drops the existing `tryRememberLogin()` round
|
||||||
|
can hit the CMS `POST /api/auth/remember` endpoint and get a
|
||||||
|
fresh SSO ticket instead of falling through to "Session expired".
|
||||||
|
|
||||||
|
The server-side wins (gzip, cache, SSO TTL) live outside this repo —
|
||||||
|
without them this client still loads, but you stay at the 60-90 s
|
||||||
|
baseline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Vite `manualChunks` — split the vendor blob
|
||||||
|
|
||||||
|
Default `yarn build` ships:
|
||||||
|
|
||||||
|
- `vendor` ~1 MB (react + tanstack-query + framer-motion + jodit +
|
||||||
|
emoji-mart + react-icons + howler + zustand + json5 — everything
|
||||||
|
merged)
|
||||||
|
- `nitro-renderer` ~2.5 MB (renderer source + pixi.js inlined)
|
||||||
|
- `src` ~1.7 MB (app code)
|
||||||
|
|
||||||
|
The vendor blob forces the browser to wait on the slowest dependency
|
||||||
|
before it can hydrate. Split it by domain — see
|
||||||
|
[`vite.config.mjs`](../vite.config.mjs) for the live version, the
|
||||||
|
intent is captured below:
|
||||||
|
|
||||||
|
```js
|
||||||
|
manualChunks: id => {
|
||||||
|
const norm = id.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
// Vendors first — pixi.js / howler / emoji-mart / jodit are aliased
|
||||||
|
// to ../Nitro_Render_V3/node_modules, so they would otherwise be
|
||||||
|
// swallowed by the `Nitro_Render_V3` branch lower down and pulled
|
||||||
|
// into the renderer chunk.
|
||||||
|
if(norm.includes('pixi.js') || norm.includes('pixi-filters')) return 'vendor-pixi';
|
||||||
|
if(norm.includes('howler')) return 'vendor-audio';
|
||||||
|
if(norm.includes('@emoji-mart')) return 'vendor-emoji';
|
||||||
|
if(norm.includes('jodit') || norm.includes('@react-page')) return 'vendor-editor';
|
||||||
|
|
||||||
|
if(id.includes('Nitro_Render_V3') || id.includes(`${ rendererRoot }`)) {
|
||||||
|
if(id.includes('/packages/avatar/')) return 'nitro-renderer-avatar';
|
||||||
|
if(id.includes('/packages/communication/')) return 'nitro-renderer-comm';
|
||||||
|
if(id.includes('/packages/room/')) return 'nitro-renderer-room';
|
||||||
|
if(id.includes('/packages/assets/')) return 'nitro-renderer-assets';
|
||||||
|
return 'nitro-renderer';
|
||||||
|
}
|
||||||
|
|
||||||
|
if(id.includes('node_modules')) {
|
||||||
|
if(id.includes('@nitrots/nitro-renderer') || id.includes('renderer3')) return 'nitro-renderer';
|
||||||
|
if(id.match(/\/react(-dom)?\/|\/scheduler\//) || id.includes('react-error-boundary')) return 'vendor-react';
|
||||||
|
if(id.includes('framer-motion')) return 'vendor-motion';
|
||||||
|
if(id.includes('@tanstack')) return 'vendor-query';
|
||||||
|
if(id.includes('zustand') || id.includes('use-between')) return 'vendor-state';
|
||||||
|
if(id.includes('react-icons')) return 'vendor-icons';
|
||||||
|
if(id.includes('json5')) return 'vendor-json5';
|
||||||
|
return 'vendor';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Two practical points the comments don't make obvious:
|
||||||
|
|
||||||
|
- **Vendor checks come first.** Pixi.js, howler, emoji-mart and jodit
|
||||||
|
are pulled in via an alias to `../Nitro_Render_V3/node_modules`,
|
||||||
|
so their `id` matches `Nitro_Render_V3`. If the renderer branch
|
||||||
|
runs before the vendor one, those modules end up bundled into the
|
||||||
|
renderer chunk instead of their own — defeating the whole point.
|
||||||
|
|
||||||
|
- **Pixi often stays inlined.** Rollup keeps a module in the chunk
|
||||||
|
of its sole importer, and `pixi.js` is consumed only through the
|
||||||
|
`@nitrots/nitro-renderer` umbrella. Expect `vendor-pixi` to be
|
||||||
|
near-empty until something *outside* the renderer also imports
|
||||||
|
pixi. This is fine — pixi gets the renderer chunk's cache lifetime
|
||||||
|
anyway.
|
||||||
|
|
||||||
|
Verify after `yarn build`:
|
||||||
|
|
||||||
|
```
|
||||||
|
dist/assets/nitro-renderer-*.js ~2.5 MB raw, ~765 KB gzip
|
||||||
|
dist/assets/vendor-*.js ~12 chunks, 4-430 KB each
|
||||||
|
dist/assets/src-*.js ~1.7 MB raw, ~550 KB gzip
|
||||||
|
```
|
||||||
|
|
||||||
|
If you see a single `vendor-*.js` over 800 KB raw, the chunk
|
||||||
|
function isn't matching the way you expect — log `id` from inside
|
||||||
|
`manualChunks` during build to find out what's actually being
|
||||||
|
handed in.
|
||||||
|
|
||||||
|
Also add the connection hint to [`index.html`](../index.html):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel="preconnect" href="https://challenges.cloudflare.com" crossorigin />
|
||||||
|
```
|
||||||
|
|
||||||
|
Saves one TLS handshake on cold load — the Turnstile script tag
|
||||||
|
already loads from that domain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. LoadingView — real progress, real labels
|
||||||
|
|
||||||
|
[`src/components/loading/LoadingView.tsx`](../src/components/loading/LoadingView.tsx)
|
||||||
|
renders the dark-blue boot screen the user sees before `isReady`
|
||||||
|
flips. It accepts a `progress` number (0-100) and a `currentTask`
|
||||||
|
string. The progress bar is hidden when `progress` is `undefined`
|
||||||
|
(error / Suspense fallback path) and animates between updates.
|
||||||
|
|
||||||
|
The state lives in [`src/App.tsx`](../src/App.tsx):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const [ loadingProgress, setLoadingProgress ] = useState(0);
|
||||||
|
const [ loadingTask, setLoadingTask ] = useState('');
|
||||||
|
|
||||||
|
const taskLabel = useCallback((key: string, fallback: string): string => {
|
||||||
|
// … reads from renderer-config so the strings are translatable
|
||||||
|
});
|
||||||
|
|
||||||
|
const bumpProgress = useCallback((value: number, task?: string) => {
|
||||||
|
setLoadingProgress(prev => (value > prev ? value : prev));
|
||||||
|
if(task !== undefined) setLoadingTask(task);
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
`prepare()` bumps the progress through 12 stages as it goes:
|
||||||
|
|
||||||
|
| % | Stage | Default label |
|
||||||
|
|---|---|---|
|
||||||
|
| 5 | App start | `Avvio in corso...` |
|
||||||
|
| 10 | NitroConfig validated | `Verifica sessione` |
|
||||||
|
| 20 | Renderer constructed | `Inizializzazione renderer` |
|
||||||
|
| 25 | Config init done | `Caricamento contenuti...` |
|
||||||
|
| 36, 47, 58, 70 | each warmup task resolves | per-task (`Sto caricando il guardaroba`, …) |
|
||||||
|
| 78 | `GetSessionDataManager().init()` done | `Caricamento dati utente` |
|
||||||
|
| 85 | `GetRoomSessionManager().init()` done | `Caricamento stanze` |
|
||||||
|
| 92 | `GetRoomEngine().init()` done | `Caricamento engine grafico` |
|
||||||
|
| 98 | `GetCommunication().init()` done | `Connessione al server` |
|
||||||
|
| 100 | `setIsReady(true)` about to fire | `Pronto!` |
|
||||||
|
|
||||||
|
The labels are config-driven — `taskLabel('loading.task.boot', 'Avvio in corso...')`
|
||||||
|
reads `loading.task.*` keys from the renderer-config and falls back
|
||||||
|
to the Italian baseline if unset. To localise, add the keys to
|
||||||
|
`public/configuration/renderer-config.json` (see the `.example`
|
||||||
|
file for the full list).
|
||||||
|
|
||||||
|
Logo and background are also configurable via the same mechanism —
|
||||||
|
`loading.logo.url`, `loading.background`, `loading.progress.color`.
|
||||||
|
Leaving them empty keeps the shipped dark-blue radial + Nitro V3
|
||||||
|
logo top-left.
|
||||||
|
|
||||||
|
### 3.1 The pre-React shell (asset-loader.js)
|
||||||
|
|
||||||
|
There is a second, tiny loading screen that the asset loader writes
|
||||||
|
into `#root` *before* React mounts. It used to be a light-blue
|
||||||
|
login-skeleton with two grey rectangles — visible for ~200 ms before
|
||||||
|
React took over, producing a hated flash. The template lives in
|
||||||
|
[`scripts/write-asset-loader.mjs`](../scripts/write-asset-loader.mjs)
|
||||||
|
(`renderShell`) and now paints the same `radial-gradient(#1d1a24,#003a6b)`
|
||||||
|
as the React `LoadingView`, so the handoff is invisible.
|
||||||
|
|
||||||
|
Don't hand-edit `public/configuration/asset-loader.js` — the
|
||||||
|
`prebuild` hook regenerates it from the template every `yarn build`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Remember-token capture — making reconnect work
|
||||||
|
|
||||||
|
Arcturus clears `auth_ticket` to `''` the moment it consumes an SSO
|
||||||
|
ticket. Without a remember-token the client retries reconnect with
|
||||||
|
the same (now empty) ticket and falls through to "Session expired"
|
||||||
|
after 2-7 attempts.
|
||||||
|
|
||||||
|
The CMS issues a UUID family token when it serves `/client`, and
|
||||||
|
passes it on the iframe URL as `&token=<uuid>&token_exp=<unix>`. Nitro
|
||||||
|
captures it on first boot:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/App.tsx::prepare()
|
||||||
|
try {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const tokenParam = urlParams.get('token');
|
||||||
|
const tokenExpParam = urlParams.get('token_exp');
|
||||||
|
if(tokenParam && !GetRememberLogin()) {
|
||||||
|
const parsedExpiry = Number(tokenExpParam || 0);
|
||||||
|
const expiresAt = (Number.isFinite(parsedExpiry) && parsedExpiry > 0)
|
||||||
|
? parsedExpiry
|
||||||
|
: Math.floor(Date.now() / 1000) + (30 * 24 * 60 * 60);
|
||||||
|
SetRememberLogin({ token: tokenParam, expiresAt });
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.warn('[App] failed to persist remember token from URL', e);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The capture is guarded by `!GetRememberLogin()` — if the user has
|
||||||
|
visited before, the stored token wins and the URL one is ignored.
|
||||||
|
|
||||||
|
Once stored, the existing `tryRememberLogin()` machinery picks it up
|
||||||
|
on every reconnect: it POSTs to
|
||||||
|
`${api.url}/api/auth/remember` (configurable via the
|
||||||
|
`login.remember.endpoint` renderer-config key), receives a fresh
|
||||||
|
SSO ticket back, and rotates the connection. See the CMS doc for the
|
||||||
|
server endpoint's contract.
|
||||||
|
|
||||||
|
Verify the stored token in browser DevTools:
|
||||||
|
|
||||||
|
```
|
||||||
|
Application → Local Storage → https://<your-domain>
|
||||||
|
Key: nitro.auth.remember
|
||||||
|
Value: {"token":"<uuid>","expiresAt":1781912345,"username":"<u>"}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `nitro.auth.remember` is missing after a successful first load,
|
||||||
|
the CMS isn't passing `token=` on the iframe URL. Check
|
||||||
|
`AuthController.client` on the CMS side.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Server-side: nginx gzip + long cache (the single biggest win)
|
||||||
|
|
||||||
|
The Nitro client ships ~4.3 MB raw across the main bundle, renderer
|
||||||
|
chunk and vendor splits. If the server doesn't compress and doesn't
|
||||||
|
let the browser cache, every visitor pays the full price on every
|
||||||
|
load — that's exactly the 60-90 s baseline you avoid by configuring
|
||||||
|
nginx properly.
|
||||||
|
|
||||||
|
### 5.1 Enable gzip globally
|
||||||
|
|
||||||
|
Default nginx ships with the `gzip` block commented out. Edit
|
||||||
|
`/etc/nginx/nginx.conf` and replace the `#gzip on;` line inside the
|
||||||
|
`http {}` block:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/xml
|
||||||
|
text/javascript
|
||||||
|
application/javascript
|
||||||
|
application/x-javascript
|
||||||
|
application/json
|
||||||
|
application/xml
|
||||||
|
application/rss+xml
|
||||||
|
application/atom+xml
|
||||||
|
image/svg+xml
|
||||||
|
font/ttf
|
||||||
|
font/otf
|
||||||
|
application/font-woff
|
||||||
|
application/vnd.ms-fontobject;
|
||||||
|
```
|
||||||
|
|
||||||
|
Back up the file before editing, then reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak-$(date +%Y%m%d-%H%M%S)
|
||||||
|
nginx -t # validate syntax first
|
||||||
|
systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
The impact is *enormous* — `palettes.json5` drops from 330 KB to 18 KB
|
||||||
|
on the wire (~17×), and the renderer JS bundle from 2.5 MB to 765 KB
|
||||||
|
(~3.3×). Verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sI -H 'Accept-Encoding: gzip' \
|
||||||
|
'https://<your-domain>/nitro/assets/nitro-renderer-XXXXX.js' \
|
||||||
|
| grep -i 'content-encoding'
|
||||||
|
# expected: content-encoding: gzip
|
||||||
|
```
|
||||||
|
|
||||||
|
If you forget `application/json` from `gzip_types` you lose the
|
||||||
|
gamedata compression — that's the one that matters the most because
|
||||||
|
the gamedata files are by far the heaviest payload.
|
||||||
|
|
||||||
|
### 5.2 Long Cache-Control on gamedata
|
||||||
|
|
||||||
|
Inside the `/nitro-assets/` or `/nitro-assets/` location
|
||||||
|
block, the gamedata `.json5` files deserve a 30-day cache because
|
||||||
|
they only change on deploy:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /nitro-assets/ {
|
||||||
|
alias /var/www/cmsjs/public/nitro-assets/;
|
||||||
|
try_files $uri ${uri}manifest.json5 ${uri}manifest.json =404;
|
||||||
|
autoindex off;
|
||||||
|
default_type application/json;
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, max-age=604800, immutable";
|
||||||
|
|
||||||
|
location ~ \.json5?$ {
|
||||||
|
types {} default_type application/json;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, max-age=2592000";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The outer 7-day cache covers PNG / nitro / mp3 files. The inner
|
||||||
|
location block raises the JSON5 lifetime to 30 days because the
|
||||||
|
content is effectively immutable per deploy. Cloudflare honours
|
||||||
|
`Last-Modified` so revalidation still works — you don't need to
|
||||||
|
cache-bust by filename.
|
||||||
|
|
||||||
|
For the JS / CSS chunks the filenames are content-hashed by Vite, so
|
||||||
|
a long cache is safe — apply the same `Cache-Control: max-age=2592000`
|
||||||
|
to the `/nitro/assets/` location.
|
||||||
|
|
||||||
|
### 5.3 The `try_files → manifest.json5` fallback
|
||||||
|
|
||||||
|
`loadGamedata(url)` in the renderer SDK can be pointed at either a
|
||||||
|
single JSON file or a directory containing `manifest.json5` + tier
|
||||||
|
sub-directories. The directory pattern is what we use in production,
|
||||||
|
so requests like `/nitro-assets/gamedata/figuremap/` (note the
|
||||||
|
trailing slash) need to resolve to the directory's manifest.
|
||||||
|
|
||||||
|
The `try_files $uri ${uri}manifest.json5 ${uri}manifest.json =404;`
|
||||||
|
above does exactly that — try the URI as-is, fall back to the
|
||||||
|
`manifest.json5` inside the directory, fall back to `.json` for
|
||||||
|
legacy deploys, then 404. Without it nginx returns 403 (autoindex
|
||||||
|
off) on directory URLs and the loader cascades into the manifest 404
|
||||||
|
path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Server-side: Windows + IIS deployment
|
||||||
|
|
||||||
|
You can reach the same 4 s cold load on Windows Server with IIS. The
|
||||||
|
same three wins (gzip, long cache, JSON5 fallback) are replicable —
|
||||||
|
syntax changes, performance ceiling doesn't.
|
||||||
|
|
||||||
|
### 6.1 Don't host Node inside IIS
|
||||||
|
|
||||||
|
`IISNode` is unmaintained. The current MS recommendation is to run
|
||||||
|
Node as a Windows service and let IIS reverse-proxy to it:
|
||||||
|
|
||||||
|
1. Install Node 22 LTS, run the CMS app as a Windows Service (via
|
||||||
|
`nssm`, `pm2-windows-startup`, or a scheduled task on boot) bound
|
||||||
|
to `127.0.0.1:3003` — same layout as `docker-compose.yml` on the
|
||||||
|
Linux host.
|
||||||
|
2. Install Arcturus separately as a Windows service running
|
||||||
|
`Habbo-x.y.z-jar-with-dependencies.jar` against MariaDB. WS ports
|
||||||
|
30001 + 30002 stay on `127.0.0.1`.
|
||||||
|
3. IIS handles HTTPS termination, static file serving, compression
|
||||||
|
and reverse-proxying `/api/*` + `/client` + the Inertia entry
|
||||||
|
point to `127.0.0.1:3003`.
|
||||||
|
|
||||||
|
Install these IIS features (Server Manager → Web Server → Add Roles
|
||||||
|
& Features):
|
||||||
|
|
||||||
|
- **URL Rewrite** — proxy rules
|
||||||
|
- **Application Request Routing (ARR)** — lets IIS act as a forward
|
||||||
|
proxy; *enable proxy in the ARR feature page* after install
|
||||||
|
- **WebSocket Protocol** — required for the Arcturus WS upgrade
|
||||||
|
- **Static Content** + **Static Content Compression**
|
||||||
|
- **Dynamic Content Compression** — **off by default**, this is the
|
||||||
|
single most important toggle on a vanilla Windows Server
|
||||||
|
|
||||||
|
### 6.2 Enable compression site-wide
|
||||||
|
|
||||||
|
IIS Manager → site → **Compression** feature → tick *both*
|
||||||
|
"Enable dynamic content compression" and "Enable static content
|
||||||
|
compression". Equivalent of nginx's `gzip on;`.
|
||||||
|
|
||||||
|
Without ticking both you ship raw bytes. Static covers JS / CSS /
|
||||||
|
JSON files, Dynamic covers Node responses (HTML from the Inertia
|
||||||
|
render). Add `application/json` to the compressor (and `.json5` to
|
||||||
|
its MIME map) in `applicationHost.config` or the site's `web.config`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<system.webServer>
|
||||||
|
<httpCompression directory="%SystemDrive%\inetpub\temp\IIS Temporary Compressed Files">
|
||||||
|
<scheme name="gzip" dll="%Windir%\system32\inetsrv\gzip.dll"
|
||||||
|
staticCompressionLevel="6" dynamicCompressionLevel="6" />
|
||||||
|
<dynamicTypes>
|
||||||
|
<add mimeType="application/json" enabled="true" />
|
||||||
|
<add mimeType="application/javascript" enabled="true" />
|
||||||
|
<add mimeType="text/css" enabled="true" />
|
||||||
|
<add mimeType="text/javascript" enabled="true" />
|
||||||
|
<add mimeType="text/*" enabled="true" />
|
||||||
|
</dynamicTypes>
|
||||||
|
<staticTypes>
|
||||||
|
<add mimeType="application/json" enabled="true" />
|
||||||
|
<add mimeType="application/javascript" enabled="true" />
|
||||||
|
<add mimeType="text/css" enabled="true" />
|
||||||
|
<add mimeType="text/javascript" enabled="true" />
|
||||||
|
<add mimeType="image/svg+xml" enabled="true" />
|
||||||
|
<add mimeType="font/ttf" enabled="true" />
|
||||||
|
<add mimeType="font/otf" enabled="true" />
|
||||||
|
<add mimeType="application/font-woff" enabled="true" />
|
||||||
|
<add mimeType="application/vnd.ms-fontobject" enabled="true" />
|
||||||
|
</staticTypes>
|
||||||
|
</httpCompression>
|
||||||
|
</system.webServer>
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify with PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri 'https://<your-domain>/nitro-assets/gamedata/figuredata/core/palettes.json5' `
|
||||||
|
-Headers @{ 'Accept-Encoding' = 'gzip' } `
|
||||||
|
-MaximumRedirection 0 | Select-Object -ExpandProperty Headers
|
||||||
|
# expected: Content-Encoding = gzip
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Long cache for gamedata
|
||||||
|
|
||||||
|
Drop a `web.config` inside the `nitro-assets/` virtual
|
||||||
|
directory (or nest under `<location>`):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<location path="nitro-assets">
|
||||||
|
<system.webServer>
|
||||||
|
<staticContent>
|
||||||
|
<clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="30.00:00:00" />
|
||||||
|
<mimeMap fileExtension=".json5" mimeType="application/json" />
|
||||||
|
<mimeMap fileExtension=".nitro" mimeType="application/octet-stream" />
|
||||||
|
</staticContent>
|
||||||
|
</system.webServer>
|
||||||
|
</location>
|
||||||
|
```
|
||||||
|
|
||||||
|
`30.00:00:00` is the IIS TimeSpan for 30 days — same effect as
|
||||||
|
`Cache-Control: public, max-age=2592000` on nginx.
|
||||||
|
|
||||||
|
Set a separate, shorter cache (e.g. 5 minutes) on `index.html` so
|
||||||
|
deploys propagate without forcing visitors to clear their cache.
|
||||||
|
|
||||||
|
### 6.4 Directory → manifest.json5 fallback
|
||||||
|
|
||||||
|
nginx's `try_files $uri ${uri}manifest.json5 ${uri}manifest.json =404;`
|
||||||
|
has no native IIS equivalent. Use **URL Rewrite** to chain two rules
|
||||||
|
inside the same `<location>`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<system.webServer>
|
||||||
|
<rewrite>
|
||||||
|
<rules>
|
||||||
|
<rule name="gamedata-dir-to-manifest-json5" stopProcessing="true">
|
||||||
|
<match url="^(nitro-assets/gamedata/[^?]+)/$" />
|
||||||
|
<conditions>
|
||||||
|
<add input="{REQUEST_FILENAME}/manifest.json5" matchType="IsFile" />
|
||||||
|
</conditions>
|
||||||
|
<action type="Rewrite" url="{R:1}/manifest.json5" />
|
||||||
|
</rule>
|
||||||
|
<rule name="gamedata-dir-to-manifest-json" stopProcessing="true">
|
||||||
|
<match url="^(nitro-assets/gamedata/[^?]+)/$" />
|
||||||
|
<conditions>
|
||||||
|
<add input="{REQUEST_FILENAME}/manifest.json" matchType="IsFile" />
|
||||||
|
</conditions>
|
||||||
|
<action type="Rewrite" url="{R:1}/manifest.json" />
|
||||||
|
</rule>
|
||||||
|
</rules>
|
||||||
|
</rewrite>
|
||||||
|
</system.webServer>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 Reverse proxy to Node + WebSocket upgrade
|
||||||
|
|
||||||
|
Once ARR is installed and proxy enabled (IIS Manager → server node →
|
||||||
|
ARR → Server Proxy Settings → check "Enable proxy"), add a top-level
|
||||||
|
rule that forwards everything *not* matching a static file:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<system.webServer>
|
||||||
|
<rewrite>
|
||||||
|
<rules>
|
||||||
|
<rule name="reverse-proxy-to-node" stopProcessing="true">
|
||||||
|
<match url="(.*)" />
|
||||||
|
<conditions>
|
||||||
|
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
|
||||||
|
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
|
||||||
|
</conditions>
|
||||||
|
<action type="Rewrite" url="http://127.0.0.1:3003/{R:1}" />
|
||||||
|
<serverVariables>
|
||||||
|
<set name="HTTP_X_FORWARDED_FOR" value="{REMOTE_ADDR}" />
|
||||||
|
<set name="HTTP_X_FORWARDED_PROTO" value="https" />
|
||||||
|
<set name="HTTP_X_FORWARDED_HOST" value="{HTTP_HOST}" />
|
||||||
|
</serverVariables>
|
||||||
|
</rule>
|
||||||
|
<rule name="ws-proxy" stopProcessing="true">
|
||||||
|
<match url="^ws/(.*)" />
|
||||||
|
<action type="Rewrite" url="http://127.0.0.1:30001/{R:1}" />
|
||||||
|
</rule>
|
||||||
|
</rules>
|
||||||
|
</rewrite>
|
||||||
|
</system.webServer>
|
||||||
|
```
|
||||||
|
|
||||||
|
ARR transparently handles the WebSocket upgrade once the WebSocket
|
||||||
|
Protocol IIS feature is installed.
|
||||||
|
|
||||||
|
### 6.6 IIS trade-offs (honest)
|
||||||
|
|
||||||
|
- **Compression CPU**: IIS dynamic compression is more CPU-hungry than
|
||||||
|
nginx's worker-pool gzip. On 2-vCPU droplets expect ~10-15 % extra
|
||||||
|
CPU during peak concurrency.
|
||||||
|
- **Docker overhead**: Docker Desktop on Windows goes through the
|
||||||
|
WSL2 file-system bridge. Bind-mounting Linux-style paths into a
|
||||||
|
container is measurably slower than the same on a native Linux
|
||||||
|
host. Recommendation: run Node + Arcturus as native Windows
|
||||||
|
services, *not* containerised.
|
||||||
|
- **Java JDBC on Windows**: Arcturus's JDBC pool exhibits slightly
|
||||||
|
higher lock-wait under concurrent room load on Windows than on
|
||||||
|
Linux. Re-tune `db.pool.maxsize` if you saturate.
|
||||||
|
|
||||||
|
Browser-perceived performance is identical to nginx once the config
|
||||||
|
above is in place. The 4 s cold-load target is achievable on any
|
||||||
|
Windows Server 2019 / 2022 box.
|
||||||
|
|
||||||
|
The one deployment to **avoid**: shared Windows hosting where the
|
||||||
|
hoster doesn't let you enable Dynamic Compression at the application
|
||||||
|
host level. You stay stuck at the 60-90 s baseline because neither
|
||||||
|
Node's gzip nor IIS's compressor can be turned on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. End-to-end verification
|
||||||
|
|
||||||
|
Run each probe in order — they walk the request through every layer
|
||||||
|
covered above. A green light on all four means the cold load is
|
||||||
|
correctly tuned.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Build artefact has the granular chunks
|
||||||
|
yarn build
|
||||||
|
ls dist/assets/ | grep -E '^(vendor|nitro-renderer)-' | wc -l
|
||||||
|
# expected: ~12-14 chunks
|
||||||
|
|
||||||
|
# 2. Server is compressing JSON5 (or JS — pick either)
|
||||||
|
curl -sI -H 'Accept-Encoding: gzip' \
|
||||||
|
'https://<your-domain>/nitro-assets/gamedata/figuredata/core/palettes.json5' \
|
||||||
|
| grep -iE 'content-encoding|cache-control'
|
||||||
|
# expected:
|
||||||
|
# content-encoding: gzip
|
||||||
|
# cache-control: public, max-age=2592000
|
||||||
|
|
||||||
|
# 3. Directory → manifest.json5 fallback
|
||||||
|
curl -sI 'https://<your-domain>/nitro-assets/gamedata/figuremap/' \
|
||||||
|
| head -1
|
||||||
|
# expected: HTTP/2 200 (not 403 or 404)
|
||||||
|
|
||||||
|
# 4. LoadingView renders the progress bar — easiest from the live site:
|
||||||
|
# DevTools → Performance → Record → reload /client
|
||||||
|
# Look for the progress bar transitioning 5→100% within 4s on a
|
||||||
|
# warm-cache load, ~10-20s on a cold one with empty CF cache.
|
||||||
|
|
||||||
|
# 5. Remember-token captured to localStorage:
|
||||||
|
# DevTools → Application → Local Storage → check nitro.auth.remember
|
||||||
|
# is populated after the first successful load.
|
||||||
|
```
|
||||||
|
|
||||||
|
If the build artefact is correct but the live site doesn't pick up
|
||||||
|
the new chunks, the deploy didn't replace `dist/` on the server.
|
||||||
|
Wipe the target dir's `assets/*.js` and `src/assets/*.css` before
|
||||||
|
extracting the new tarball — old chunk filenames stick around
|
||||||
|
otherwise.
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<title>Nitro</title>
|
<title>Nitro</title>
|
||||||
|
<!-- Connection hints for the two domains the loader hits first.
|
||||||
|
dns-prefetch + preconnect halve the TLS round-trips on cold load. -->
|
||||||
|
<link rel="preconnect" href="https://challenges.cloudflare.com" crossorigin />
|
||||||
<script async defer src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
|
<script async defer src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,116 +1,268 @@
|
|||||||
{
|
{
|
||||||
"notification.badge.received": "Nuovo Distintivo!",
|
"notification.badge.received": "Nuovo Distintivo!",
|
||||||
"wiredfurni.badgereceived.title": "Distintivo ricevuto!",
|
"wiredfurni.badgereceived.title": "Distintivo ricevuto!",
|
||||||
"wiredfurni.badgereceived.body": "Hai appena ricevuto un nuovo Distintivo! Controlla nel tuo Inventario!",
|
"wiredfurni.badgereceived.body": "Hai appena ricevuto un nuovo Distintivo! Controlla nel tuo Inventario!",
|
||||||
"friendlist.search": "Search friends",
|
"friendlist.search": "Search friends",
|
||||||
"purse.seasonal.currency.101": "cash",
|
"purse.seasonal.currency.101": "cash",
|
||||||
"widget.chooser.checkall": "Select furniture",
|
"widget.chooser.checkall": "Select furniture",
|
||||||
"widget.chooser.btn.pickall": "pick up selected items!",
|
"widget.chooser.btn.pickall": "pick up selected items!",
|
||||||
"wiredfurni.params.requireall.2": "If one of the selected furni has an avatar",
|
"wiredfurni.params.requireall.2": "If one of the selected furni has an avatar",
|
||||||
"wiredfurni.params.requireall.3": "If all selected furni have avatars on them",
|
"wiredfurni.params.requireall.3": "If all selected furni have avatars on them",
|
||||||
"widget.settings.general": "General",
|
"widget.settings.general": "General",
|
||||||
"widget.settings.general.title": "Adjust the default Nitro settings",
|
"widget.settings.general.title": "Adjust the default Nitro settings",
|
||||||
"widget.settings.volume": "Volume",
|
"widget.settings.volume": "Volume",
|
||||||
"widget.settings.interface": "Interface",
|
"widget.settings.interface": "Interface",
|
||||||
"widget.settings.interface.title": "Adjust the interface settings",
|
"widget.settings.interface.title": "Adjust the interface settings",
|
||||||
"widget.settings.interface.fps.automatic": "Set FPS to unlimited",
|
"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.fps.warning": "Setting FPS to unlimited may cause performance issues!",
|
||||||
"widget.settings.interface.secondary": "Change the window header color",
|
"widget.settings.interface.secondary": "Change the window header color",
|
||||||
"widget.settings.interface.reset": "Reset header color to default",
|
"widget.settings.interface.reset": "Reset header color to default",
|
||||||
"widget.room.chat.hide_pets": "Hide pets",
|
"widget.room.chat.hide_pets": "Hide pets",
|
||||||
"widget.room.chat.hide_avatars": "Hide avatars",
|
"widget.room.chat.hide_avatars": "Hide avatars",
|
||||||
"widget.room.chat.hide_balloon": "Hide speech bubble",
|
"widget.room.chat.hide_balloon": "Hide speech bubble",
|
||||||
"widget.room.chat.show_balloon": "Speech bubble",
|
"widget.room.chat.show_balloon": "Speech bubble",
|
||||||
"widget.room.chat.clear_history": "clear history",
|
"widget.room.chat.clear_history": "clear history",
|
||||||
"widget.room.youtube.shared": "YouTube is being shared",
|
"widget.room.youtube.shared": "YouTube is being shared",
|
||||||
"widget.room.youtube.open_video": "Open the video",
|
"widget.room.youtube.open_video": "Open the video",
|
||||||
"wiredfurni.tooltip.select.tile": "Select tile",
|
"wiredfurni.tooltip.select.tile": "Select tile",
|
||||||
"wiredfurni.tooltip.remove.tile": "Deselect tile",
|
"wiredfurni.tooltip.remove.tile": "Deselect tile",
|
||||||
"wiredfurni.tooltip.remove.5x5_tile": "select 5x5 tiles",
|
"wiredfurni.tooltip.remove.5x5_tile": "select 5x5 tiles",
|
||||||
"wiredfurni.tooltip.remove.clear_tile": "Clear all selections",
|
"wiredfurni.tooltip.remove.clear_tile": "Clear all selections",
|
||||||
"wiredfurni.params.furni_neighborhood.group.user": "Players",
|
"wiredfurni.params.furni_neighborhood.group.user": "Players",
|
||||||
"wiredfurni.params.furni_neighborhood.group.furni": "Furniture",
|
"wiredfurni.params.furni_neighborhood.group.furni": "Furniture",
|
||||||
"wiredfurni.params.selector_option.bot": "No bots",
|
"wiredfurni.params.selector_option.bot": "No bots",
|
||||||
"wiredfurni.params.selector_option.pet": "No pets",
|
"wiredfurni.params.selector_option.pet": "No pets",
|
||||||
"catalog.title": "Catalog",
|
"catalog.title": "Catalog",
|
||||||
"catalog.favorites": "Favorites",
|
"catalog.favorites": "Favorites",
|
||||||
"catalog.favorites.pages": "Pages",
|
"catalog.favorites.pages": "Pages",
|
||||||
"catalog.favorites.furni": "Furni",
|
"catalog.favorites.furni": "Furni",
|
||||||
"catalog.favorites.empty": "No favorites",
|
"catalog.favorites.empty": "No favorites",
|
||||||
"catalog.favorites.empty.hint": "Click the heart on furni or the star on pages to add them.",
|
"catalog.favorites.empty.hint": "Click the heart on furni or the star on pages to add them.",
|
||||||
"catalog.admin": "Admin",
|
"catalog.admin": "Admin",
|
||||||
"catalog.admin.new": "New",
|
"catalog.admin.new": "New",
|
||||||
"catalog.admin.root": "Root",
|
"catalog.admin.root": "Root",
|
||||||
"catalog.admin.new.root.category": "New root category",
|
"catalog.admin.new.root.category": "New root category",
|
||||||
"catalog.admin.edit.root": "Edit Root",
|
"catalog.admin.edit.root": "Edit Root",
|
||||||
"catalog.admin.edit": "Edit:",
|
"catalog.admin.edit": "Edit:",
|
||||||
"catalog.admin.edit.page": "Edit Page",
|
"catalog.admin.edit.page": "Edit Page",
|
||||||
"catalog.admin.hidden": "hidden",
|
"catalog.admin.hidden": "hidden",
|
||||||
"catalog.admin.edit.title": "Edit \"%name%\"",
|
"catalog.admin.edit.title": "Edit \"%name%\"",
|
||||||
"catalog.admin.show": "Show",
|
"catalog.admin.show": "Show",
|
||||||
"catalog.admin.hide": "Hide",
|
"catalog.admin.hide": "Hide",
|
||||||
"catalog.admin.delete": "Delete",
|
"catalog.admin.delete": "Delete",
|
||||||
"catalog.admin.delete.title": "Delete \"%name%\"",
|
"catalog.admin.delete.title": "Delete \"%name%\"",
|
||||||
"catalog.admin.delete.category.confirm": "Delete category \"%name%\" and all its content?",
|
"catalog.admin.delete.category.confirm": "Delete category \"%name%\" and all its content?",
|
||||||
"catalog.admin.delete.page": "Delete page",
|
"catalog.admin.delete.page": "Delete page",
|
||||||
"catalog.admin.delete.page.confirm": "Delete page \"%name%\"?",
|
"catalog.admin.delete.page.confirm": "Delete page \"%name%\"?",
|
||||||
"catalog.admin.delete.offer.confirm": "Are you sure you want to delete this offer?",
|
"catalog.admin.delete.offer.confirm": "Are you sure you want to delete this offer?",
|
||||||
"catalog.admin.create": "Create",
|
"catalog.admin.create": "Create",
|
||||||
"catalog.admin.save": "Save",
|
"catalog.admin.save": "Save",
|
||||||
"catalog.admin.create.subpage": "Create sub-page",
|
"catalog.admin.create.subpage": "Create sub-page",
|
||||||
"catalog.admin.order": "Order",
|
"catalog.admin.order": "Order",
|
||||||
"catalog.admin.visible": "Visible",
|
"catalog.admin.visible": "Visible",
|
||||||
"catalog.admin.enabled": "Enabled",
|
"catalog.admin.enabled": "Enabled",
|
||||||
"catalog.admin.offer.new": "New Offer",
|
"catalog.admin.offer.new": "New Offer",
|
||||||
"catalog.admin.offer.edit": "Edit Offer",
|
"catalog.admin.offer.edit": "Edit Offer",
|
||||||
"catalog.admin.offer.name": "Catalog Name",
|
"catalog.admin.offer.name": "Catalog Name",
|
||||||
"catalog.admin.offer.general": "General",
|
"catalog.admin.offer.general": "General",
|
||||||
"catalog.admin.offer.quantity": "Quantity",
|
"catalog.admin.offer.quantity": "Quantity",
|
||||||
"catalog.admin.offer.prices": "Prices",
|
"catalog.admin.offer.prices": "Prices",
|
||||||
"catalog.admin.offer.credits": "Credits",
|
"catalog.admin.offer.credits": "Credits",
|
||||||
"catalog.admin.offer.points": "Points",
|
"catalog.admin.offer.points": "Points",
|
||||||
"catalog.admin.offer.points.type": "Points Type",
|
"catalog.admin.offer.points.type": "Points Type",
|
||||||
"catalog.admin.offer.options": "Options",
|
"catalog.admin.offer.options": "Options",
|
||||||
"catalog.admin.offer.club.only": "Club Only",
|
"catalog.admin.offer.club.only": "Club Only",
|
||||||
"catalog.admin.offer.extradata": "Extra Data (optional)....",
|
"catalog.admin.offer.extradata": "Extra Data (optional)....",
|
||||||
"catalog.admin.offer.have.offer": "Multi-discount (have_offer)",
|
"catalog.admin.offer.have.offer": "Multi-discount (have_offer)",
|
||||||
"catalog.trophies.title": "Trophies",
|
"catalog.trophies.title": "Trophies",
|
||||||
"catalog.trophies.write.hint": "Write a text for the trophy before purchasing",
|
"catalog.trophies.write.hint": "Write a text for the trophy before purchasing",
|
||||||
"catalog.trophies.inscription": "Trophy Inscription",
|
"catalog.trophies.inscription": "Trophy Inscription",
|
||||||
"catalog.trophies.inscription.placeholder": "Write the text that will appear on the trophy...",
|
"catalog.trophies.inscription.placeholder": "Write the text that will appear on the trophy...",
|
||||||
"catalog.pets.show.colors": "Show colors",
|
"catalog.pets.show.colors": "Show colors",
|
||||||
"catalog.pets.choose.color": "Choose color",
|
"catalog.pets.choose.color": "Choose color",
|
||||||
"catalog.pets.choose.breed": "Choose breed",
|
"catalog.pets.choose.breed": "Choose breed",
|
||||||
"catalog.pets.back.breeds": "? Breeds",
|
"catalog.pets.back.breeds": "? Breeds",
|
||||||
"catalog.prefix.text": "Text",
|
"catalog.prefix.text": "Text",
|
||||||
"catalog.prefix.text.placeholder": "Enter text...",
|
"catalog.prefix.text.placeholder": "Enter text...",
|
||||||
"catalog.prefix.icon": "Icon",
|
"catalog.prefix.icon": "Icon",
|
||||||
"catalog.prefix.icon.remove": "Remove icon",
|
"catalog.prefix.icon.remove": "Remove icon",
|
||||||
"catalog.prefix.effect": "Effect",
|
"catalog.prefix.effect": "Effect",
|
||||||
"catalog.prefix.color": "Color",
|
"catalog.prefix.color": "Color",
|
||||||
"catalog.prefix.color.single": "?? Single",
|
"catalog.prefix.color.single": "?? Single",
|
||||||
"catalog.prefix.color.per.letter": "?? Per Letter",
|
"catalog.prefix.color.per.letter": "?? Per Letter",
|
||||||
"catalog.prefix.color.hint": "Select a letter, then choose the color. Auto-advances.",
|
"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.title": "Apply current color to all letters",
|
||||||
"catalog.prefix.color.apply.all": "Apply to all",
|
"catalog.prefix.color.apply.all": "Apply to all",
|
||||||
"catalog.prefix.color.selected": "Selected letter:",
|
"catalog.prefix.color.selected": "Selected letter:",
|
||||||
"catalog.prefix.price": "Price:",
|
"catalog.prefix.price": "Price:",
|
||||||
"catalog.prefix.price.amount": "5 Credits",
|
"catalog.prefix.price.amount": "5 Credits",
|
||||||
"catalog.prefix.purchased": "? Purchased!",
|
"catalog.prefix.purchased": "? Purchased!",
|
||||||
"catalog.prefix.purchase": "Purchase",
|
"catalog.prefix.purchase": "Purchase",
|
||||||
"groupforum.list.tab.most_active": "Most active threads",
|
"modtools.userinfo.title": "User Info: %username%",
|
||||||
"groupforum.list.tab.my_forums": "My group forums",
|
"modtools.userinfo.userName": "Name",
|
||||||
"groupforum.list.no_forums": "There are no forums",
|
"modtools.userinfo.cfhCount": "CFHs",
|
||||||
"groupforum.view.threads": "Number of threads",
|
"modtools.userinfo.abusiveCfhCount": "Abusive CFHs",
|
||||||
"groupforum.thread.pin": "Pin thread",
|
"modtools.userinfo.cautionCount": "Cautions",
|
||||||
"groupforum.thread.unpin": "Unpin thread",
|
"modtools.userinfo.banCount": "Bans",
|
||||||
"groupforum.thread.lock": "Lock thread",
|
"modtools.userinfo.lastSanctionTime": "Last Sanction",
|
||||||
"groupforum.thread.unlock": "Unlock thread",
|
"modtools.userinfo.tradingLockCount": "Trade Locks",
|
||||||
"groupforum.thread.hide": "Hide thread",
|
"modtools.userinfo.tradingExpiryDate": "Lock Expires",
|
||||||
"groupforum.thread.restore": "Restore thread",
|
"modtools.userinfo.minutesSinceLastLogin": "Last Login",
|
||||||
"groupforum.thread.delete": "Delete thread + posts",
|
"modtools.userinfo.lastPurchaseDate": "Last Purchase",
|
||||||
"groupforum.message.hide": "Hide message",
|
"modtools.userinfo.primaryEmailAddress": "Email",
|
||||||
"group.forum.enable.caption": "Enable / Disable group forum",
|
"modtools.userinfo.identityRelatedBanCount": "Banned Accs",
|
||||||
"group.forum.enable.help": "If you disable the group forum, all posts will also be deleted!",
|
"modtools.userinfo.registrationAgeInMinutes": "Registered",
|
||||||
"groupforum.view.no_threads": "There are currently no active threads"
|
"modtools.userinfo.userClassification": "Rank",
|
||||||
|
"modtools.window.title": "Mod Tools",
|
||||||
|
"modtools.window.tools.room": "Room Tool",
|
||||||
|
"modtools.window.tools.chatlog": "Chatlog Tool",
|
||||||
|
"modtools.window.tools.report": "Report Tool",
|
||||||
|
"modtools.window.select.user": "Select a user",
|
||||||
|
"modtools.window.no.room": "Enter a room first",
|
||||||
|
"modtools.window.user.in_room": "Still in this room",
|
||||||
|
"modtools.window.user.left_room": "No longer in this room",
|
||||||
|
"modtools.window.user.clear": "Clear selection",
|
||||||
|
"modtools.window.tickets.open": "%count% open ticket",
|
||||||
|
"modtools.window.tickets.open.many": "%count% open tickets",
|
||||||
|
"modtools.window.section.room": "Room",
|
||||||
|
"modtools.window.section.user": "User",
|
||||||
|
"modtools.window.section.reports": "Reports",
|
||||||
|
"modtools.window.user.open_info": "Open Info",
|
||||||
|
"modtools.userinfo.refresh": "Refresh user info",
|
||||||
|
"modtools.userinfo.presence.in_room": "In room",
|
||||||
|
"modtools.userinfo.presence.in_room.title": "In the room you are observing",
|
||||||
|
"modtools.userinfo.presence.online": "Online",
|
||||||
|
"modtools.userinfo.presence.online.title": "Online on the hotel",
|
||||||
|
"modtools.userinfo.presence.offline": "Offline",
|
||||||
|
"modtools.userinfo.presence.offline.title": "Offline at panel open",
|
||||||
|
"modtools.userinfo.section.account": "Account",
|
||||||
|
"modtools.userinfo.section.activity": "Activity",
|
||||||
|
"modtools.userinfo.section.sanctions": "Sanctions",
|
||||||
|
"modtools.userinfo.section.trading": "Trading",
|
||||||
|
"modtools.userinfo.button.room.chat": "Room Chat",
|
||||||
|
"modtools.userinfo.button.send.message": "Send Message",
|
||||||
|
"modtools.userinfo.button.room.visits": "Room Visits",
|
||||||
|
"modtools.userinfo.button.mod.action": "Mod Action",
|
||||||
|
"modtools.userinfo.stat.cfh": "CFH",
|
||||||
|
"modtools.userinfo.stat.cautions": "Cautions",
|
||||||
|
"modtools.userinfo.stat.bans": "Bans",
|
||||||
|
"modtools.userinfo.stat.trade.locks": "Trade locks",
|
||||||
|
"modtools.roominfo.title": "Room Info",
|
||||||
|
"modtools.roominfo.refresh": "Refresh room info",
|
||||||
|
"modtools.roominfo.loading": "Loading…",
|
||||||
|
"modtools.roominfo.owner.here": "Owner here",
|
||||||
|
"modtools.roominfo.owner.away": "Owner away",
|
||||||
|
"modtools.roominfo.owner.title.here": "The room owner is currently inside",
|
||||||
|
"modtools.roominfo.owner.title.away": "The room owner is NOT inside",
|
||||||
|
"modtools.roominfo.stat.users": "Users",
|
||||||
|
"modtools.roominfo.stat.owner": "Owner",
|
||||||
|
"modtools.roominfo.owner.open": "Open %username%'s info",
|
||||||
|
"modtools.roominfo.button.visit": "Visit Room",
|
||||||
|
"modtools.roominfo.button.chatlog": "Chatlog",
|
||||||
|
"modtools.roominfo.moderate.title": "Moderate room",
|
||||||
|
"modtools.roominfo.moderate.kick": "Kick everyone out",
|
||||||
|
"modtools.roominfo.moderate.doorbell": "Enable the doorbell",
|
||||||
|
"modtools.roominfo.moderate.rename": "Change room name",
|
||||||
|
"modtools.roominfo.moderate.message.placeholder": "Mandatory message to deliver with the action…",
|
||||||
|
"modtools.roominfo.moderate.send.caution": "Send Caution",
|
||||||
|
"modtools.roominfo.moderate.send.alert": "Send Alert",
|
||||||
|
"modtools.user.message.title": "Send Message",
|
||||||
|
"modtools.user.message.recipient": "Message to",
|
||||||
|
"modtools.user.message.label": "Message",
|
||||||
|
"modtools.user.message.placeholder": "Write something useful — the user will see it as a moderator message.",
|
||||||
|
"modtools.user.message.empty": "Empty",
|
||||||
|
"modtools.user.message.chars": "%count% chars",
|
||||||
|
"modtools.user.message.send": "Send Message",
|
||||||
|
"modtools.user.modaction.title": "Mod Action: %username%",
|
||||||
|
"modtools.user.modaction.sanctioning": "Sanctioning",
|
||||||
|
"modtools.user.modaction.step.topic": "1. CFH Topic",
|
||||||
|
"modtools.user.modaction.step.topic.placeholder": "Select a topic…",
|
||||||
|
"modtools.user.modaction.step.sanction": "2. Sanction",
|
||||||
|
"modtools.user.modaction.step.sanction.placeholder": "Select a sanction…",
|
||||||
|
"modtools.user.modaction.step.message": "3. Custom message",
|
||||||
|
"modtools.user.modaction.step.message.optional": "(optional — overrides default)",
|
||||||
|
"modtools.user.modaction.message.placeholder": "Leave empty to use the default topic message",
|
||||||
|
"modtools.user.modaction.preview": "Preview",
|
||||||
|
"modtools.user.modaction.button.default": "Default Sanction",
|
||||||
|
"modtools.user.modaction.button.apply": "Apply Sanction",
|
||||||
|
"modtools.user.modaction.error.no.topic": "You must select a CFH topic",
|
||||||
|
"modtools.user.modaction.error.no.action": "You must select a CFH topic and Sanction",
|
||||||
|
"modtools.user.modaction.error.no.permission": "You do not have permission to do this",
|
||||||
|
"modtools.user.modaction.error.no.message": "Please write a message to user",
|
||||||
|
"modtools.user.modaction.error.no.permission.alert": "You have insufficient permissions",
|
||||||
|
"modtools.user.visits.title": "User Visits",
|
||||||
|
"modtools.user.visits.recent": "Recent visited rooms",
|
||||||
|
"modtools.user.visits.entries.one": "%count% entry",
|
||||||
|
"modtools.user.visits.entries.many": "%count% entries",
|
||||||
|
"modtools.user.visits.empty": "No recent visits",
|
||||||
|
"modtools.user.visits.time": "Time",
|
||||||
|
"modtools.user.visits.room": "Room name",
|
||||||
|
"modtools.user.visits.action": "Action",
|
||||||
|
"modtools.user.visits.visit": "Visit",
|
||||||
|
"modtools.user.visits.visit.title": "Visit room",
|
||||||
|
"modtools.user.chatlog.title": "User Chatlog",
|
||||||
|
"modtools.user.chatlog.title.with": "User Chatlog: %username%",
|
||||||
|
"modtools.user.chatlog.loading": "Loading chatlog…",
|
||||||
|
"modtools.room.chatlog.title": "Room Chatlog",
|
||||||
|
"modtools.chatlog.column.time": "Time",
|
||||||
|
"modtools.chatlog.column.user": "User",
|
||||||
|
"modtools.chatlog.column.message": "Message",
|
||||||
|
"modtools.chatlog.empty": "No messages",
|
||||||
|
"modtools.chatlog.visit": "Visit",
|
||||||
|
"modtools.chatlog.tools": "Tools",
|
||||||
|
"modtools.tickets.title": "Tickets",
|
||||||
|
"modtools.tickets.tab.open": "Open",
|
||||||
|
"modtools.tickets.tab.mine": "Mine",
|
||||||
|
"modtools.tickets.tab.picked": "All picked",
|
||||||
|
"modtools.tickets.column.type": "Type",
|
||||||
|
"modtools.tickets.column.reported": "Reported",
|
||||||
|
"modtools.tickets.column.opened": "Opened",
|
||||||
|
"modtools.tickets.column.picker": "Picker",
|
||||||
|
"modtools.tickets.empty.open": "No open issues",
|
||||||
|
"modtools.tickets.empty.mine": "No issues picked by you",
|
||||||
|
"modtools.tickets.empty.picked": "No picked issues",
|
||||||
|
"modtools.tickets.action.pick": "Pick",
|
||||||
|
"modtools.tickets.action.handle": "Handle",
|
||||||
|
"modtools.tickets.action.release": "Release",
|
||||||
|
"modtools.tickets.issue.title": "Resolving issue #%issueId%",
|
||||||
|
"modtools.tickets.issue.label": "Issue #%issueId%",
|
||||||
|
"modtools.tickets.issue.details": "Details",
|
||||||
|
"modtools.tickets.issue.field.source": "Source",
|
||||||
|
"modtools.tickets.issue.field.category": "Category",
|
||||||
|
"modtools.tickets.issue.field.description": "Description",
|
||||||
|
"modtools.tickets.issue.field.caller": "Caller",
|
||||||
|
"modtools.tickets.issue.field.reported": "Reported",
|
||||||
|
"modtools.tickets.issue.chatlog.view": "View chatlog",
|
||||||
|
"modtools.tickets.issue.chatlog.close": "Close chatlog",
|
||||||
|
"modtools.tickets.issue.resolve.heading": "Resolve as",
|
||||||
|
"modtools.tickets.issue.resolve.resolved": "Resolved",
|
||||||
|
"modtools.tickets.issue.resolve.useless": "Useless",
|
||||||
|
"modtools.tickets.issue.resolve.abusive": "Abusive",
|
||||||
|
"modtools.tickets.issue.release": "Release back to queue",
|
||||||
|
"modtools.tickets.cfh.chatlog.title": "Issue #%issueId% Chatlog",
|
||||||
|
"groupforum.list.tab.most_active": "Most active threads",
|
||||||
|
"groupforum.list.tab.my_forums": "My group forums",
|
||||||
|
"groupforum.list.no_forums": "There are no forums",
|
||||||
|
"groupforum.view.threads": "Number of threads",
|
||||||
|
"groupforum.thread.pin": "Pin thread",
|
||||||
|
"groupforum.thread.unpin": "Unpin thread",
|
||||||
|
"groupforum.thread.lock": "Lock thread",
|
||||||
|
"groupforum.thread.unlock": "Unlock thread",
|
||||||
|
"groupforum.thread.hide": "Hide thread",
|
||||||
|
"groupforum.thread.restore": "Restore thread",
|
||||||
|
"groupforum.thread.delete": "Delete thread + posts",
|
||||||
|
"groupforum.message.hide": "Hide message",
|
||||||
|
"group.forum.enable.caption": "Enable / Disable group forum",
|
||||||
|
"group.forum.enable.help": "If you disable the group forum, all posts will also be deleted!",
|
||||||
|
"groupforum.view.no_threads": "There are currently no active threads",
|
||||||
|
"loading.task.session": "Verifying session...",
|
||||||
|
"loading.task.renderer": "Initializing renderer...",
|
||||||
|
"loading.task.assets": "loading game assets...",
|
||||||
|
"loading.task.localization": "loading translations...",
|
||||||
|
"loading.task.avatar": "loading wardrobe...",
|
||||||
|
"loading.task.sounds": "loading sounds...",
|
||||||
|
"loading.task.startsession": "Starting session...",
|
||||||
|
"loading.task.userdata": "loading user data...",
|
||||||
|
"loading.task.rooms": "loading rooms...",
|
||||||
|
"loading.task.engine": "loading graphics engine...",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,10 @@
|
|||||||
const renderShell = () => {
|
const renderShell = () => {
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
if(!root || root.firstChild) return;
|
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>';
|
// Match the React LoadingView background so the pre-React shell paints
|
||||||
|
// the same gradient — no light-blue login-skeleton flash before the
|
||||||
|
// loader takes over.
|
||||||
|
root.innerHTML = '<div style="position:fixed;inset:0;background:radial-gradient(#1d1a24,#003a6b);overflow:hidden;z-index:1"></div>';
|
||||||
};
|
};
|
||||||
|
|
||||||
const decodeAsset = (bytes) => {
|
const decodeAsset = (bytes) => {
|
||||||
|
|||||||
@@ -48,6 +48,9 @@
|
|||||||
"timezone.settings": "Europe/Amsterdam",
|
"timezone.settings": "Europe/Amsterdam",
|
||||||
"youtube.publish.disabled": false,
|
"youtube.publish.disabled": false,
|
||||||
"user.badges.group.slot.enabled": true,
|
"user.badges.group.slot.enabled": true,
|
||||||
|
"loading.logo.url": "",
|
||||||
|
"loading.background": "",
|
||||||
|
"loading.progress.color": "linear-gradient(90deg,#4f8cff,#2563eb)",
|
||||||
"login.screen.enabled": true,
|
"login.screen.enabled": true,
|
||||||
"login.endpoint": "${api.url}/api/auth/login",
|
"login.endpoint": "${api.url}/api/auth/login",
|
||||||
"login.register.endpoint": "${api.url}/api/auth/register",
|
"login.register.endpoint": "${api.url}/api/auth/register",
|
||||||
|
|||||||
@@ -228,7 +228,10 @@ const ASSET_LOADER_JS = `(() => {
|
|||||||
const renderShell = () => {
|
const renderShell = () => {
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
if(!root || root.firstChild) return;
|
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>';
|
// Match the React LoadingView background so the pre-React shell paints
|
||||||
|
// the same gradient — no light-blue login-skeleton flash before the
|
||||||
|
// loader takes over.
|
||||||
|
root.innerHTML = '<div style="position:fixed;inset:0;background:radial-gradient(#1d1a24,#003a6b);overflow:hidden;z-index:1"></div>';
|
||||||
};
|
};
|
||||||
|
|
||||||
const decodeAsset = (bytes) => {
|
const decodeAsset = (bytes) => {
|
||||||
|
|||||||
+106
-82
@@ -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 { 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, useEffectEvent, useRef, useState } from 'react';
|
import { FC, useCallback, useEffect, useEffectEvent, useRef, useState } from 'react';
|
||||||
import { ClearRememberLogin, GetRememberLogin, GetUIVersion, StoreRememberLoginFromPayload, persistAccessTokenFromPayload } from './api';
|
import { ClearRememberLogin, GetRememberLogin, GetUIVersion, SetRememberLogin, StoreRememberLoginFromPayload, persistAccessTokenFromPayload } from './api';
|
||||||
import { Base } from './common';
|
import { Base } from './common';
|
||||||
import { LoadingView } from './components/loading/LoadingView';
|
import { LoadingView } from './components/loading/LoadingView';
|
||||||
import { LoginView } from './components/login/LoginView';
|
import { LoginView } from './components/login/LoginView';
|
||||||
@@ -72,6 +72,38 @@ export const App: FC<{}> = props =>
|
|||||||
const [ showLogin, setShowLogin ] = useState(false);
|
const [ showLogin, setShowLogin ] = useState(false);
|
||||||
const [ isEnteringHotel, setIsEnteringHotel ] = useState(() => !!window.NitroConfig?.['sso.ticket'] || hasRememberLogin());
|
const [ isEnteringHotel, setIsEnteringHotel ] = useState(() => !!window.NitroConfig?.['sso.ticket'] || hasRememberLogin());
|
||||||
const [ prepareTrigger, setPrepareTrigger ] = useState(0);
|
const [ prepareTrigger, setPrepareTrigger ] = useState(0);
|
||||||
|
const [ loadingProgress, setLoadingProgress ] = useState(0);
|
||||||
|
const [ loadingTask, setLoadingTask ] = useState('');
|
||||||
|
const taskLabel = useCallback((key: string, fallback: string): string =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const locManager = GetLocalizationManager();
|
||||||
|
if(locManager && typeof locManager.getValue === 'function')
|
||||||
|
{
|
||||||
|
const fromLoc = locManager.getValue(key, false);
|
||||||
|
|
||||||
|
if(typeof fromLoc === 'string' && fromLoc.length && fromLoc !== key) return fromLoc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{ }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const fromConfig = GetConfiguration().getValue<string>(key, '');
|
||||||
|
if(typeof fromConfig === 'string' && fromConfig.length) return fromConfig;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{ }
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}, []);
|
||||||
|
const bumpProgress = useCallback((value: number, task?: string) =>
|
||||||
|
{
|
||||||
|
setLoadingProgress(prev => (value > prev ? value : prev));
|
||||||
|
if(task !== undefined) setLoadingTask(task);
|
||||||
|
}, []);
|
||||||
const warmupPromiseRef = useRef<Promise<void>>(null);
|
const warmupPromiseRef = useRef<Promise<void>>(null);
|
||||||
const rendererPromiseRef = useRef<Promise<any>>(null);
|
const rendererPromiseRef = useRef<Promise<any>>(null);
|
||||||
const gameInitPromiseRef = useRef<Promise<void> | null>(null);
|
const gameInitPromiseRef = useRef<Promise<void> | null>(null);
|
||||||
@@ -88,9 +120,6 @@ export const App: FC<{}> = props =>
|
|||||||
ClearRememberLogin();
|
ClearRememberLogin();
|
||||||
try { delete (window as any).NitroConfig?.['sso.ticket']; } catch {}
|
try { delete (window as any).NitroConfig?.['sso.ticket']; } catch {}
|
||||||
try { GetConfiguration().setValue('sso.ticket', ''); } catch {}
|
try { GetConfiguration().setValue('sso.ticket', ''); } catch {}
|
||||||
// Drop `?sso=` from the URL too — otherwise the next reload re-applies
|
|
||||||
// the same already-consumed ticket via bootstrap.ts and we loop right
|
|
||||||
// back into "Session expired" without ever showing the login form.
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
@@ -104,23 +133,6 @@ export const App: FC<{}> = props =>
|
|||||||
catch {}
|
catch {}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fallbackToLogin = useCallback(() =>
|
|
||||||
{
|
|
||||||
// Using console.warn (not NitroLogger.log) on purpose: NitroLogger
|
|
||||||
// is gated on LOG_DEBUG, which only flips to true once startWarmup's
|
|
||||||
// GetConfiguration().init() completes. Auth-failure paths fire before
|
|
||||||
// that, so NitroLogger swallows their messages silently.
|
|
||||||
console.warn('[App] fallbackToLogin — surfacing login form, credentials cleared');
|
|
||||||
// Wipe whatever credential the server just rejected so the form is
|
|
||||||
// pristine and the next attempt isn't sabotaged by the same stale data.
|
|
||||||
clearStoredCredentials();
|
|
||||||
setHomeUrl('');
|
|
||||||
setErrorMessage('');
|
|
||||||
setIsReady(false);
|
|
||||||
setShowLogin(true);
|
|
||||||
setIsEnteringHotel(false);
|
|
||||||
}, [ clearStoredCredentials ]);
|
|
||||||
|
|
||||||
const showSessionExpired = useCallback(() =>
|
const showSessionExpired = useCallback(() =>
|
||||||
{
|
{
|
||||||
console.warn('[App] showSessionExpired — diagnostic shown (mid-game close)');
|
console.warn('[App] showSessionExpired — diagnostic shown (mid-game close)');
|
||||||
@@ -134,6 +146,26 @@ export const App: FC<{}> = props =>
|
|||||||
setIsEnteringHotel(false);
|
setIsEnteringHotel(false);
|
||||||
}, [ clearStoredCredentials ]);
|
}, [ clearStoredCredentials ]);
|
||||||
|
|
||||||
|
const fallbackToLogin = useCallback(() =>
|
||||||
|
{
|
||||||
|
const rawLoginEnabled = GetConfiguration().getValue<unknown>('login.screen.enabled', false);
|
||||||
|
const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1;
|
||||||
|
|
||||||
|
if(!loginScreenEnabled)
|
||||||
|
{
|
||||||
|
console.warn('[App] fallbackToLogin — login.screen.enabled=false, redirecting to home instead');
|
||||||
|
showSessionExpired();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.warn('[App] fallbackToLogin — surfacing login form, credentials cleared');
|
||||||
|
clearStoredCredentials();
|
||||||
|
setHomeUrl('');
|
||||||
|
setErrorMessage('');
|
||||||
|
setIsReady(false);
|
||||||
|
setShowLogin(true);
|
||||||
|
setIsEnteringHotel(false);
|
||||||
|
}, [ clearStoredCredentials, showSessionExpired ]);
|
||||||
|
|
||||||
const applySsoTicket = useCallback((ssoTicket: string) =>
|
const applySsoTicket = useCallback((ssoTicket: string) =>
|
||||||
{
|
{
|
||||||
if(!ssoTicket) return;
|
if(!ssoTicket) return;
|
||||||
@@ -162,8 +194,6 @@ export const App: FC<{}> = props =>
|
|||||||
|
|
||||||
if(!remembered?.token?.length)
|
if(!remembered?.token?.length)
|
||||||
{
|
{
|
||||||
// No remember token means we'd be reusing a one-shot ssoTicket that
|
|
||||||
// the server already consumed. Force the login screen instead.
|
|
||||||
if(remembered) ClearRememberLogin();
|
if(remembered) ClearRememberLogin();
|
||||||
console.warn('[App] tryRememberLogin → no token, returning empty');
|
console.warn('[App] tryRememberLogin → no token, returning empty');
|
||||||
return '';
|
return '';
|
||||||
@@ -212,9 +242,6 @@ export const App: FC<{}> = props =>
|
|||||||
console.warn('[App] tryRememberLogin → fetch threw', error);
|
console.warn('[App] tryRememberLogin → fetch threw', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any failure (rejected token, bad payload, network error) — drop the
|
|
||||||
// stored credentials. Never fall back to the cached ssoTicket: it's
|
|
||||||
// one-shot and reusing it leads straight to "Session expired".
|
|
||||||
ClearRememberLogin();
|
ClearRememberLogin();
|
||||||
console.warn('[App] tryRememberLogin → cleared remember, returning empty');
|
console.warn('[App] tryRememberLogin → cleared remember, returning empty');
|
||||||
|
|
||||||
@@ -265,35 +292,12 @@ export const App: FC<{}> = props =>
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Mirror isReady into a ref so the socket handlers below can read the
|
|
||||||
// freshest value without needing to re-subscribe on every state change.
|
|
||||||
useEffect(() => { isReadyRef.current = isReady; }, [ isReady ]);
|
useEffect(() => { isReadyRef.current = isReady; }, [ isReady ]);
|
||||||
|
|
||||||
// Track whether a reconnect cycle is active. The renderer dispatches
|
|
||||||
// SOCKET_RECONNECTING when it starts retrying after an abnormal close
|
|
||||||
// (code != 1000/1001). On exhausted retries it fires SOCKET_RECONNECT_FAILED
|
|
||||||
// *and* a final SOCKET_CLOSED — we keep the flag set through that pair
|
|
||||||
// so ReconnectView's own overlay owns the UX and we don't double-render.
|
|
||||||
useNitroEvent(NitroEventType.SOCKET_RECONNECTING, () => { reconnectInProgressRef.current = true; });
|
useNitroEvent(NitroEventType.SOCKET_RECONNECTING, () => { reconnectInProgressRef.current = true; });
|
||||||
useNitroEvent(NitroEventType.SOCKET_REAUTHENTICATED, () => { reconnectInProgressRef.current = false; });
|
useNitroEvent(NitroEventType.SOCKET_REAUTHENTICATED, () => { reconnectInProgressRef.current = false; });
|
||||||
|
|
||||||
useNitroEvent(NitroEventType.SOCKET_CLOSED, () =>
|
useNitroEvent(NitroEventType.SOCKET_CLOSED, () =>
|
||||||
{
|
{
|
||||||
// Three distinct close scenarios converge here:
|
|
||||||
//
|
|
||||||
// 1. !isReady — initial handshake just failed (server replied
|
|
||||||
// with "Bye" / code 1000 to a bad SSO ticket). The user never
|
|
||||||
// had a session. Surface the login form instead of the
|
|
||||||
// misleading "Session expired" diagnostic.
|
|
||||||
//
|
|
||||||
// 2. isReady && reconnect in progress — ReconnectView already
|
|
||||||
// owns the UX (its overlay shows attempts and the "Session
|
|
||||||
// expired" message on RECONNECT_FAILED). Stay out of its way.
|
|
||||||
//
|
|
||||||
// 3. isReady && no reconnect — instant server kick mid-game
|
|
||||||
// (code 1000 from the server side). No reconnect path will
|
|
||||||
// run. Show the legacy session-expired diagnostic so the
|
|
||||||
// user knows to reload.
|
|
||||||
console.warn('[App] SOCKET_CLOSED fired', {
|
console.warn('[App] SOCKET_CLOSED fired', {
|
||||||
isReady: isReadyRef.current,
|
isReady: isReadyRef.current,
|
||||||
reconnectInProgress: reconnectInProgressRef.current
|
reconnectInProgress: reconnectInProgressRef.current
|
||||||
@@ -352,6 +356,7 @@ export const App: FC<{}> = props =>
|
|||||||
warmupPromiseRef.current = (async () =>
|
warmupPromiseRef.current = (async () =>
|
||||||
{
|
{
|
||||||
await GetConfiguration().init();
|
await GetConfiguration().init();
|
||||||
|
bumpProgress(25, taskLabel('loader.waiting', 'Loading content...'));
|
||||||
|
|
||||||
GetTicker().maxFPS = GetConfiguration().getValue<number>('system.fps.max', 24);
|
GetTicker().maxFPS = GetConfiguration().getValue<number>('system.fps.max', 24);
|
||||||
NitroLogger.LOG_DEBUG = GetConfiguration().getValue<boolean>('system.log.debug', true);
|
NitroLogger.LOG_DEBUG = GetConfiguration().getValue<boolean>('system.log.debug', true);
|
||||||
@@ -388,18 +393,25 @@ export const App: FC<{}> = props =>
|
|||||||
loginImageUrls.forEach(preloadImage);
|
loginImageUrls.forEach(preloadImage);
|
||||||
gamedataUrls.forEach(url => preloadUrl(url));
|
gamedataUrls.forEach(url => preloadUrl(url));
|
||||||
|
|
||||||
await Promise.all(
|
const warmupTasks: { promise: Promise<any>; label: string }[] = [
|
||||||
[
|
{ promise: GetAssetManager().downloadAssets(assetUrls), label: taskLabel('loading.task.assets', 'Loading game assets...') },
|
||||||
GetAssetManager().downloadAssets(assetUrls),
|
{ promise: GetLocalizationManager().init(), label: taskLabel('loading.task.localization', 'Loading translations...') },
|
||||||
GetLocalizationManager().init(),
|
{ promise: GetAvatarRenderManager().init(), label: taskLabel('loading.task.avatar', 'Loading wardrobe...') },
|
||||||
GetAvatarRenderManager().init(),
|
{ promise: GetSoundManager().init(), label: taskLabel('loading.task.sounds', 'Loading sounds...') }
|
||||||
GetSoundManager().init()
|
];
|
||||||
]
|
let warmupDone = 0;
|
||||||
);
|
const warmupStart = 25;
|
||||||
|
const warmupSpan = 45;
|
||||||
|
await Promise.all(warmupTasks.map(t => t.promise.then(value =>
|
||||||
|
{
|
||||||
|
warmupDone++;
|
||||||
|
bumpProgress(warmupStart + Math.round((warmupSpan * warmupDone) / warmupTasks.length), t.label);
|
||||||
|
return value;
|
||||||
|
})));
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return warmupPromiseRef.current;
|
return warmupPromiseRef.current;
|
||||||
}, [ startRenderer ]);
|
}, [ startRenderer, bumpProgress, taskLabel ]);
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
@@ -431,9 +443,14 @@ export const App: FC<{}> = props =>
|
|||||||
hasNitroConfig: !!window.NitroConfig,
|
hasNitroConfig: !!window.NitroConfig,
|
||||||
ssoTicketInConfig: !!window.NitroConfig?.['sso.ticket'],
|
ssoTicketInConfig: !!window.NitroConfig?.['sso.ticket'],
|
||||||
hasRememberLocal: !!GetRememberLogin(),
|
hasRememberLocal: !!GetRememberLogin(),
|
||||||
urlSso: new URLSearchParams(window.location.search).get('sso')
|
hasUrlSso: !!new URLSearchParams(window.location.search).get('sso')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const bootLabel = taskLabel('loader', 'Booting...');
|
||||||
|
setLoadingProgress(0);
|
||||||
|
setLoadingTask(bootLabel);
|
||||||
|
bumpProgress(5, bootLabel);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if(!window.NitroConfig) throw new Error('NitroConfig is not defined!');
|
if(!window.NitroConfig) throw new Error('NitroConfig is not defined!');
|
||||||
@@ -441,10 +458,29 @@ export const App: FC<{}> = props =>
|
|||||||
let ssoTicket = window.NitroConfig['sso.ticket'];
|
let ssoTicket = window.NitroConfig['sso.ticket'];
|
||||||
if(ssoTicket) GetConfiguration().setValue('sso.ticket', ssoTicket);
|
if(ssoTicket) GetConfiguration().setValue('sso.ticket', ssoTicket);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const tokenParam = urlParams.get('token');
|
||||||
|
const tokenExpParam = urlParams.get('token_exp');
|
||||||
|
if(tokenParam && !GetRememberLogin())
|
||||||
|
{
|
||||||
|
const parsedExpiry = Number(tokenExpParam || 0);
|
||||||
|
const expiresAt = (Number.isFinite(parsedExpiry) && parsedExpiry > 0)
|
||||||
|
? parsedExpiry
|
||||||
|
: Math.floor(Date.now() / 1000) + (30 * 24 * 60 * 60);
|
||||||
|
SetRememberLogin({ token: tokenParam, expiresAt });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(e)
|
||||||
|
{
|
||||||
|
console.warn('[App] failed to persist remember token from URL', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
bumpProgress(10, taskLabel('loading.task.session', 'Verifying session...'));
|
||||||
|
|
||||||
if(!ssoTicket || 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;
|
let configInitError: unknown = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -506,17 +542,23 @@ export const App: FC<{}> = props =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderer = await startRenderer(width, height);
|
const renderer = await startRenderer(width, height);
|
||||||
|
bumpProgress(20, taskLabel('loading.task.renderer', 'Initializing renderer...'));
|
||||||
|
|
||||||
await startWarmup(width, height);
|
await startWarmup(width, height);
|
||||||
|
bumpProgress(70, taskLabel('loading.task.startsession', 'Starting session...'));
|
||||||
|
|
||||||
if(!gameInitPromiseRef.current)
|
if(!gameInitPromiseRef.current)
|
||||||
{
|
{
|
||||||
gameInitPromiseRef.current = (async () =>
|
gameInitPromiseRef.current = (async () =>
|
||||||
{
|
{
|
||||||
await GetSessionDataManager().init();
|
await GetSessionDataManager().init();
|
||||||
|
bumpProgress(78, taskLabel('loading.task.userdata', 'Loading user data...'));
|
||||||
await GetRoomSessionManager().init();
|
await GetRoomSessionManager().init();
|
||||||
|
bumpProgress(85, taskLabel('loading.task.rooms', 'Loading rooms...'));
|
||||||
await GetRoomEngine().init();
|
await GetRoomEngine().init();
|
||||||
|
bumpProgress(92, taskLabel('loading.task.engine', 'Loading graphics engine...'));
|
||||||
await GetCommunication().init();
|
await GetCommunication().init();
|
||||||
|
bumpProgress(98, taskLabel('generic.reconnecting', 'Connecting to server...'));
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,6 +587,7 @@ export const App: FC<{}> = props =>
|
|||||||
GetTicker().add(ticker => GetTexturePool().run());
|
GetTicker().add(ticker => GetTexturePool().run());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bumpProgress(100, taskLabel('onboarding.button.ready', 'Ready!'));
|
||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
setShowLogin(false);
|
setShowLogin(false);
|
||||||
setIsEnteringHotel(false);
|
setIsEnteringHotel(false);
|
||||||
@@ -552,23 +595,10 @@ export const App: FC<{}> = props =>
|
|||||||
catch(err)
|
catch(err)
|
||||||
{
|
{
|
||||||
NitroLogger.error('[App] Initialization failed — falling back to login', err);
|
NitroLogger.error('[App] Initialization failed — falling back to login', err);
|
||||||
// Anything thrown out of the post-auth chain (renderer init,
|
|
||||||
// asset download, GetCommunication().init()) is an init/connect
|
|
||||||
// failure, not session expiration. The credential we used is
|
|
||||||
// suspect — drop it and present the login form so the user
|
|
||||||
// can retry instead of getting stuck on a stale "Session expired".
|
|
||||||
onInitFailure();
|
onInitFailure();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// React Strict Mode in dev runs every effect twice (mount → cleanup → mount).
|
|
||||||
// `prepare()` is full of one-shot side effects (renderer init, websocket
|
|
||||||
// connect, NitroConfig mutation) — calling it twice with the same trigger
|
|
||||||
// value causes the second pass to race with the first and clobber state
|
|
||||||
// (e.g. the second pass falls through to onSessionExpired while the first
|
|
||||||
// had just set showLogin=true). Guard by trigger value: skip duplicate
|
|
||||||
// runs at the same trigger, but still re-run when handleAuthenticated
|
|
||||||
// bumps prepareTrigger after a successful login.
|
|
||||||
if(lastPrepareTriggerRef.current === prepareTrigger) return;
|
if(lastPrepareTriggerRef.current === prepareTrigger) return;
|
||||||
lastPrepareTriggerRef.current = prepareTrigger;
|
lastPrepareTriggerRef.current = prepareTrigger;
|
||||||
|
|
||||||
@@ -581,20 +611,14 @@ export const App: FC<{}> = props =>
|
|||||||
if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current);
|
if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current);
|
||||||
if(rememberRotateIntervalRef.current !== null) window.clearInterval(rememberRotateIntervalRef.current);
|
if(rememberRotateIntervalRef.current !== null) window.clearInterval(rememberRotateIntervalRef.current);
|
||||||
};
|
};
|
||||||
}, [ prepareTrigger, startWarmup, startRenderer, tryRememberLogin, applySsoTicket, rotateRememberLogin ]);
|
}, [ prepareTrigger, startWarmup, startRenderer, tryRememberLogin, applySsoTicket, rotateRememberLogin, bumpProgress, taskLabel ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Base fit overflow="hidden" className={ `nitro-app-root ${ !(window.devicePixelRatio % 1) ? 'image-rendering-pixelated' : '' }` }>
|
<Base fit overflow="hidden" className={ `nitro-app-root ${ !(window.devicePixelRatio % 1) ? 'image-rendering-pixelated' : '' }` }>
|
||||||
{ !isReady && !showLogin &&
|
{ !isReady && !showLogin &&
|
||||||
<LoadingView isError={ errorMessage.length > 0 } message={ errorMessage } homeUrl={ homeUrl } /> }
|
<LoadingView isError={ errorMessage.length > 0 } message={ errorMessage } homeUrl={ homeUrl } progress={ loadingProgress } currentTask={ loadingTask } /> }
|
||||||
{ !isReady && showLogin && <LoginView onAuthenticated={ handleAuthenticated } isEntering={ isEnteringHotel } /> }
|
{ !isReady && showLogin && <LoginView onAuthenticated={ handleAuthenticated } isEntering={ isEnteringHotel } /> }
|
||||||
{ isReady && <MainView /> }
|
{ isReady && <MainView /> }
|
||||||
{ /* Reconnect overlay must NOT render before we've actually entered
|
|
||||||
the hotel — otherwise the renderer's auto-retry on an initial
|
|
||||||
handshake failure (e.g. emulator unreachable) would cover the
|
|
||||||
login form with "Reconnecting..." → "Session expired" and the
|
|
||||||
user wouldn't be able to interact with the form we just put up
|
|
||||||
via fallbackToLogin. */ }
|
|
||||||
{ isReady && <ReconnectView /> }
|
{ isReady && <ReconnectView /> }
|
||||||
<Base id="draggable-windows-container" />
|
<Base id="draggable-windows-container" />
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const Button: FC<ButtonProps> = props =>
|
|||||||
|
|
||||||
// fucked up method i know (i dont have a clue what im doing because im a ninja)
|
// fucked up method i know (i dont have a clue what im doing because im a ninja)
|
||||||
|
|
||||||
const newClassNames: string[] = [ 'pointer-events-auto inline-block font-normal leading-normal text-[#fff] text-center no-underline align-middle cursor-pointer select-none border border-[solid] border-transparent px-[.75rem] py-[.375rem] text-[.9rem] rounded-[.25rem] [transition:color_.15s_ease-in-out,background-color_.15s_ease-in-out,border-color_.15s_ease-in-out,box-shadow_.15s_ease-in-out]' ];
|
const newClassNames: string[] = [ 'pointer-events-auto font-normal leading-normal text-[#fff] text-center no-underline cursor-pointer select-none border border-[solid] border-transparent px-[.75rem] py-[.375rem] text-[.9rem] rounded-[.25rem] [transition:color_.15s_ease-in-out,background-color_.15s_ease-in-out,border-color_.15s_ease-in-out,box-shadow_.15s_ease-in-out]' ];
|
||||||
|
|
||||||
if(variant)
|
if(variant)
|
||||||
{
|
{
|
||||||
@@ -67,5 +67,5 @@ export const Button: FC<ButtonProps> = props =>
|
|||||||
return newClassNames;
|
return newClassNames;
|
||||||
}, [ variant, size, active, disabled, classNames ]);
|
}, [ variant, size, active, disabled, classNames ]);
|
||||||
|
|
||||||
return <Flex center classNames={ getClassNames } { ...rest } />;
|
return <Flex center display="inline-flex" classNames={ getClassNames } { ...rest } />;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const DraggableWindow: FC<DraggableWindowProps> = props =>
|
|||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isPositioned, setIsPositioned] = useState(false);
|
const [isPositioned, setIsPositioned] = useState(false);
|
||||||
const [dragHandler, setDragHandler] = useState<HTMLElement>(null);
|
const [dragHandler, setDragHandler] = useState<HTMLElement>(null);
|
||||||
const elementRef = useRef<HTMLDivElement>();
|
const elementRef = useRef<HTMLDivElement>(null);
|
||||||
const bringToTop = useCallback(() => {
|
const bringToTop = useCallback(() => {
|
||||||
let zIndex = 400;
|
let zIndex = 400;
|
||||||
for (const existingWindow of CURRENT_WINDOWS)
|
for (const existingWindow of CURRENT_WINDOWS)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AddLinkEventTracker, AvatarDirectionAngle, AvatarEffectActivatedComposer, GetConfiguration, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
import { AddLinkEventTracker, AvatarDirectionAngle, AvatarEffectActivatedComposer, GetConfiguration, GetSessionDataManager, ILinkEventTracker, loadGamedata, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||||
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { FaChevronLeft, FaChevronRight, FaSearch } from 'react-icons/fa';
|
import { FaChevronLeft, FaChevronRight, FaSearch } from 'react-icons/fa';
|
||||||
import { LocalizeText, SendMessageComposer } from '../../api';
|
import { LocalizeText, SendMessageComposer } from '../../api';
|
||||||
@@ -65,9 +65,11 @@ export const AvatarEffectsView: FC<{}> = () =>
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const response = await fetch(url);
|
// The effectmap is served either as a single JSON file or as a
|
||||||
if(!response.ok) throw new Error(`HTTP ${ response.status }`);
|
// tiered directory with core/custom/seasonal manifests using
|
||||||
const json = await response.json();
|
// JSON5 syntax (// comments allowed). loadGamedata picks the
|
||||||
|
// right mode for us and merges tiers.
|
||||||
|
const json = await loadGamedata<{ effects?: EffectMapEntry[] }>(url);
|
||||||
if(cancelled) return;
|
if(cancelled) return;
|
||||||
|
|
||||||
const list: EffectMapEntry[] = Array.isArray(json?.effects)
|
const list: EffectMapEntry[] = Array.isArray(json?.effects)
|
||||||
|
|||||||
@@ -1,22 +1,77 @@
|
|||||||
import { FC } from 'react';
|
import { GetConfiguration } from '@nitrots/nitro-renderer';
|
||||||
|
import { FC, useMemo } from 'react';
|
||||||
import loadingGif from '@/assets/images/loading/loading.gif';
|
import loadingGif from '@/assets/images/loading/loading.gif';
|
||||||
|
import nitroV3Logo from '@/assets/images/notifications/nitro_v3.png';
|
||||||
import { Base, Column, Text } from '../../common';
|
import { Base, Column, Text } from '../../common';
|
||||||
|
|
||||||
interface LoadingViewProps {
|
interface LoadingViewProps {
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
homeUrl?: string;
|
homeUrl?: string;
|
||||||
|
progress?: number;
|
||||||
|
currentTask?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveConfigUrl = (key: string): string =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const raw = GetConfiguration().getValue<string>(key, '');
|
||||||
|
if(!raw) return '';
|
||||||
|
|
||||||
|
const interpolated = GetConfiguration().interpolate(raw) || raw;
|
||||||
|
return interpolated;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveConfigString = (key: string, fallback = ''): string =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const raw = GetConfiguration().getValue<string>(key, '');
|
||||||
|
if(!raw) return fallback;
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const LoadingView: FC<LoadingViewProps> = props =>
|
export const LoadingView: FC<LoadingViewProps> = props =>
|
||||||
{
|
{
|
||||||
const { isError = false, message = '', homeUrl = '' } = props;
|
const { isError = false, message = '', homeUrl = '', progress, currentTask = '' } = props;
|
||||||
|
|
||||||
|
const customLogoUrl = useMemo(() => resolveConfigUrl('loading.logo.url'), []);
|
||||||
|
const customBackground = useMemo(() => resolveConfigString('loading.background', ''), []);
|
||||||
|
const progressBarColor = useMemo(() => resolveConfigString('loading.progress.color', 'linear-gradient(90deg,#4f8cff,#2563eb)'), []);
|
||||||
|
|
||||||
|
const clampedProgress = typeof progress === 'number' && Number.isFinite(progress)
|
||||||
|
? Math.max(0, Math.min(100, Math.round(progress)))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const backgroundStyle = customBackground
|
||||||
|
? { background: customBackground }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const backgroundClassName = customBackground
|
||||||
|
? 'fixed inset-0 z-[2147483000]'
|
||||||
|
: 'fixed inset-0 z-[2147483000] bg-[radial-gradient(#1d1a24,#003a6b)]';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column fullHeight position="fixed" className="fixed inset-0 z-[2147483000] bg-[radial-gradient(#1d1a24,#003a6b)]">
|
<Column fullHeight position="fixed" className={ backgroundClassName } style={ backgroundStyle }>
|
||||||
|
<img
|
||||||
|
src={ nitroV3Logo }
|
||||||
|
alt="Nitro V3"
|
||||||
|
draggable={ false }
|
||||||
|
className="absolute top-5 left-0 z-2 w-37.5 h-auto select-none pointer-events-none"
|
||||||
|
/>
|
||||||
<Base fullHeight className="container h-100">
|
<Base fullHeight className="container h-100">
|
||||||
<Column fullHeight alignItems="center" justifyContent="center">
|
<Column fullHeight alignItems="center" justifyContent="center">
|
||||||
<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) ?
|
{ 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 }>
|
<Column alignItems="center" className="absolute bottom-[20px] left-1/2 z-[3] -translate-x-1/2 max-w-[80%]" gap={ 2 }>
|
||||||
<Text fontSizeCustom={ 20 } variant="white" className="text-center [text-shadow:0px_4px_4px_rgba(0,0,0,0.25)]">
|
<Text fontSizeCustom={ 20 } variant="white" className="text-center [text-shadow:0px_4px_4px_rgba(0,0,0,0.25)]">
|
||||||
@@ -32,15 +87,59 @@ export const LoadingView: FC<LoadingViewProps> = props =>
|
|||||||
}
|
}
|
||||||
</Column>
|
</Column>
|
||||||
:
|
:
|
||||||
<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" />
|
<Column alignItems="center" justifyContent="center" className="z-[3] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||||
{ message && message.length ?
|
<img
|
||||||
<Text fontSizeCustom={ 20 } variant="white" className="text-center [text-shadow:0px_4px_4px_rgba(0,0,0,0.25)]">
|
src={ customLogoUrl || loadingGif }
|
||||||
{ message }
|
alt=""
|
||||||
</Text>
|
draggable={ false }
|
||||||
: null
|
className="block w-auto h-auto max-w-[80vw] max-h-[40vh] select-none pointer-events-none"
|
||||||
|
/>
|
||||||
|
{ message && message.length ?
|
||||||
|
<Text fontSizeCustom={ 22 } variant="white" className="text-center mt-4 [text-shadow:0px_4px_4px_rgba(0,0,0,0.4)] tracking-wide">
|
||||||
|
{ message }
|
||||||
|
</Text>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</Column>
|
||||||
|
{ clampedProgress !== null &&
|
||||||
|
<Column
|
||||||
|
alignItems="center"
|
||||||
|
gap={ 2 }
|
||||||
|
className="absolute bottom-[8vh] left-1/2 -translate-x-1/2 z-[4] w-[min(900px,90vw)]"
|
||||||
|
>
|
||||||
|
<Base
|
||||||
|
className="relative w-full h-8 rounded-full overflow-hidden border border-white/30 shadow-[0_8px_24px_rgba(0,0,0,0.45)]"
|
||||||
|
style={ { background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(4px)' } }
|
||||||
|
>
|
||||||
|
<Base
|
||||||
|
className="h-full rounded-full transition-[width] duration-300 ease-out"
|
||||||
|
style={ { width: `${ clampedProgress }%`, background: progressBarColor, boxShadow: '0 0 18px rgba(79,140,255,0.55)' } }
|
||||||
|
/>
|
||||||
|
<Base
|
||||||
|
className="absolute inset-0 flex items-center justify-center pointer-events-none"
|
||||||
|
style={ { fontFamily: '"Poppins","Segoe UI",system-ui,sans-serif', fontWeight: 700, fontSize: '16px', color: '#fff', letterSpacing: '0.08em', textShadow: '0 2px 4px rgba(0,0,0,0.6)' } }
|
||||||
|
>
|
||||||
|
{ clampedProgress }%
|
||||||
|
</Base>
|
||||||
|
</Base>
|
||||||
|
<Base
|
||||||
|
className="text-center"
|
||||||
|
style={ {
|
||||||
|
fontFamily: '"Poppins","Segoe UI",system-ui,sans-serif',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '15px',
|
||||||
|
color: 'rgba(255,255,255,0.85)',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
textShadow: '0 2px 4px rgba(0,0,0,0.5)',
|
||||||
|
minHeight: '22px'
|
||||||
|
} }
|
||||||
|
>
|
||||||
|
{ currentTask }
|
||||||
|
</Base>
|
||||||
|
</Column>
|
||||||
}
|
}
|
||||||
</Column>
|
</>
|
||||||
}
|
}
|
||||||
</Column>
|
</Column>
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AddLinkEventTracker, CreateLinkEvent, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomId, RoomObjectCategory, RoomObjectType } from '@nitrots/nitro-renderer';
|
import { AddLinkEventTracker, CreateLinkEvent, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomId, RoomObjectCategory, RoomObjectType } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useEffect, useMemo, useRef, useState } from 'react';
|
import { FC, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { FaTimes } from 'react-icons/fa';
|
import { FaTimes, FaUserSlash } from 'react-icons/fa';
|
||||||
import { GetRoomSession, ISelectedUser, LocalizeText } from '../../api';
|
import { GetRoomSession, ISelectedUser, LocalizeText } from '../../api';
|
||||||
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
|
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
|
||||||
import { useModTools, useNitroEvent, useObjectSelectedEvent, useRoomUserListSnapshot } from '../../hooks';
|
import { useModTools, useNitroEvent, useObjectSelectedEvent, useRoomUserListSnapshot } from '../../hooks';
|
||||||
@@ -134,63 +134,82 @@ export const ModToolsView: FC<{}> = props =>
|
|||||||
return () => RemoveLinkEventTracker(linkTracker);
|
return () => RemoveLinkEventTracker(linkTracker);
|
||||||
}, [ openRoomInfo, closeRoomInfo, toggleRoomInfo, openRoomChatlog, closeRoomChatlog, toggleRoomChatlog, openUserInfo, closeUserInfo, toggleUserInfo, openUserChatlog, closeUserChatlog, toggleUserChatlog ]);
|
}, [ openRoomInfo, closeRoomInfo, toggleRoomInfo, openRoomChatlog, closeRoomChatlog, toggleRoomChatlog, openUserInfo, closeUserInfo, toggleUserInfo, openUserChatlog, closeUserChatlog, toggleUserChatlog ]);
|
||||||
|
|
||||||
const isRoomInfoOpen = currentRoomId > 0 && openRooms.includes(currentRoomId);
|
const isInRoom = currentRoomId > 0;
|
||||||
const isRoomChatlogOpen = currentRoomId > 0 && openRoomChatlogs.includes(currentRoomId);
|
const isRoomInfoOpen = isInRoom && openRooms.includes(currentRoomId);
|
||||||
|
const isRoomChatlogOpen = isInRoom && openRoomChatlogs.includes(currentRoomId);
|
||||||
const isUserInfoOpen = selectedUser && openUserInfos.includes(selectedUser.userId);
|
const isUserInfoOpen = selectedUser && openUserInfos.includes(selectedUser.userId);
|
||||||
const noRoomHint = LocalizeText('mod.tools.no.room') || 'Enter a room first';
|
const noRoomHint = LocalizeText('modtools.window.no.room');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{ isVisible &&
|
{ isVisible &&
|
||||||
<NitroCardView className="nitro-mod-tools min-w-[220px]" theme="primary-slim" uniqueKey="mod-tools" windowPosition={ DraggableWindowPosition.TOP_LEFT } >
|
<NitroCardView className="nitro-mod-tools min-w-[240px] max-w-[260px]" theme="primary-slim" uniqueKey="mod-tools" windowPosition={ DraggableWindowPosition.TOP_LEFT } >
|
||||||
<NitroCardHeaderView headerText={ 'Mod Tools' } onCloseClick={ event => setIsVisible(false) } />
|
<NitroCardHeaderView headerText={ LocalizeText('modtools.window.title') } onCloseClick={ event => setIsVisible(false) } />
|
||||||
<NitroCardContentView className="text-black" gap={ 2 }>
|
<NitroCardContentView className="text-black" gap={ 2 }>
|
||||||
<Button active={ isRoomInfoOpen } disabled={ (currentRoomId <= 0) } gap={ 2 } justifyContent="start" title={ (currentRoomId <= 0) ? noRoomHint : undefined } onClick={ event => CreateLinkEvent(`mod-tools/toggle-room-info/${ currentRoomId }`) }>
|
{/* Room tools */}
|
||||||
<div className="nitro-icon icon-small-room shrink-0" />
|
<div className="flex flex-col gap-1.5">
|
||||||
<span className="grow text-start">Room Tool</span>
|
<div className="text-[.6rem] uppercase tracking-wide opacity-60 font-semibold pl-1">{ LocalizeText('modtools.window.section.room') }</div>
|
||||||
</Button>
|
<Button active={ isRoomInfoOpen } disabled={ !isInRoom } gap={ 2 } justifyContent="start" title={ !isInRoom ? noRoomHint : undefined } onClick={ () => CreateLinkEvent(`mod-tools/toggle-room-info/${ currentRoomId }`) }>
|
||||||
<Button active={ isRoomChatlogOpen } disabled={ (currentRoomId <= 0) } gap={ 2 } innerRef={ elementRef } justifyContent="start" title={ (currentRoomId <= 0) ? noRoomHint : undefined } onClick={ event => CreateLinkEvent(`mod-tools/toggle-room-chatlog/${ currentRoomId }`) }>
|
<div className="nitro-icon icon-small-room shrink-0" />
|
||||||
<div className="nitro-icon icon-chat-history shrink-0" />
|
<span className="grow text-start">{ LocalizeText('modtools.window.tools.room') }</span>
|
||||||
<span className="grow text-start">Chatlog Tool</span>
|
</Button>
|
||||||
</Button>
|
<Button active={ isRoomChatlogOpen } disabled={ !isInRoom } gap={ 2 } innerRef={ elementRef } justifyContent="start" title={ !isInRoom ? noRoomHint : undefined } onClick={ () => CreateLinkEvent(`mod-tools/toggle-room-chatlog/${ currentRoomId }`) }>
|
||||||
<Button active={ !!isUserInfoOpen } disabled={ !selectedUser } gap={ 2 } justifyContent="start" onClick={ () => selectedUser && CreateLinkEvent(`mod-tools/toggle-user-info/${ selectedUser.userId }`) }>
|
<div className="nitro-icon icon-chat-history shrink-0" />
|
||||||
<div className="nitro-icon icon-user shrink-0" />
|
<span className="grow text-start">{ LocalizeText('modtools.window.tools.chatlog') }</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected user */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="text-[.6rem] uppercase tracking-wide opacity-60 font-semibold pl-1">{ LocalizeText('modtools.window.section.user') }</div>
|
||||||
{ selectedUser
|
{ selectedUser
|
||||||
? (
|
? (
|
||||||
<>
|
<div className={ `flex flex-col gap-1.5 rounded p-1.5 border ${ isSelectedUserPresent ? 'bg-gradient-to-r from-emerald-50 to-transparent border-emerald-100' : 'bg-gradient-to-r from-zinc-50 to-transparent border-zinc-200' }` }>
|
||||||
<span className="truncate grow text-start">{ selectedUser.username }</span>
|
<div className="flex items-center gap-1.5">
|
||||||
<span
|
<span className={ `inline-block w-2 h-2 rounded-full shrink-0 ${ isSelectedUserPresent ? 'bg-emerald-500' : 'bg-zinc-400' }` }
|
||||||
aria-label={ isSelectedUserPresent ? 'In room' : 'Left room' }
|
title={ isSelectedUserPresent ? LocalizeText('modtools.window.user.in_room') : LocalizeText('modtools.window.user.left_room') }
|
||||||
className={ `inline-block w-2 h-2 rounded-full shrink-0 ${ isSelectedUserPresent ? 'bg-emerald-500' : 'bg-zinc-400' }` }
|
aria-label={ isSelectedUserPresent ? LocalizeText('modtools.userinfo.presence.in_room') : LocalizeText('modtools.window.user.left_room') } />
|
||||||
title={ isSelectedUserPresent ? 'Still in this room' : 'No longer in this room' }
|
<span className="truncate grow text-start text-sm font-semibold leading-tight">{ selectedUser.username }</span>
|
||||||
/>
|
<button
|
||||||
<span
|
className="inline-flex items-center justify-center w-5 h-5 rounded text-zinc-500 hover:text-rose-600 hover:bg-rose-100 shrink-0 transition-colors"
|
||||||
className="inline-flex items-center justify-center w-4 h-4 rounded text-xs text-zinc-500 hover:text-rose-600 hover:bg-rose-100 shrink-0"
|
onClick={ event =>
|
||||||
onClick={ event =>
|
{
|
||||||
{
|
event.stopPropagation();
|
||||||
event.stopPropagation();
|
setSelectedUser(null);
|
||||||
setSelectedUser(null);
|
} }
|
||||||
} }
|
title={ LocalizeText('modtools.window.user.clear') }>
|
||||||
role="button"
|
<FaTimes size={ 10 } />
|
||||||
tabIndex={ 0 }
|
</button>
|
||||||
title="Clear selection">
|
</div>
|
||||||
<FaTimes />
|
<Button active={ !!isUserInfoOpen } gap={ 2 } justifyContent="start" onClick={ () => CreateLinkEvent(`mod-tools/toggle-user-info/${ selectedUser.userId }`) }>
|
||||||
</span>
|
<div className="nitro-icon icon-user shrink-0" />
|
||||||
</>
|
<span className="grow text-start">{ LocalizeText('modtools.window.user.open_info') }</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="flex items-center gap-2 rounded p-2 border border-dashed border-zinc-300 bg-zinc-50/50 opacity-70">
|
||||||
|
<FaUserSlash className="text-zinc-400 shrink-0" size={ 14 } />
|
||||||
|
<span className="text-xs italic">{ LocalizeText('modtools.window.select.user') }</span>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
: <span className="opacity-50 italic grow text-start">Select a user</span>
|
|
||||||
}
|
}
|
||||||
</Button>
|
</div>
|
||||||
<Button active={ isTicketsVisible } gap={ 2 } justifyContent="start" onClick={ () => setIsTicketsVisible(prevValue => !prevValue) }>
|
|
||||||
<div className="nitro-icon icon-tickets shrink-0" />
|
{/* Reports */}
|
||||||
<span className="grow text-start">Report Tool</span>
|
<div className="flex flex-col gap-1.5">
|
||||||
{ (openTicketsCount > 0) &&
|
<div className="text-[.6rem] uppercase tracking-wide opacity-60 font-semibold pl-1">{ LocalizeText('modtools.window.section.reports') }</div>
|
||||||
<span
|
<Button active={ isTicketsVisible } gap={ 2 } justifyContent="start" onClick={ () => setIsTicketsVisible(prevValue => !prevValue) }>
|
||||||
className="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded-full bg-rose-500 text-white text-xs font-semibold shrink-0"
|
<div className="nitro-icon icon-tickets shrink-0" />
|
||||||
title={ `${ openTicketsCount } open ticket${ openTicketsCount === 1 ? '' : 's' }` }>
|
<span className="grow text-start">{ LocalizeText('modtools.window.tools.report') }</span>
|
||||||
{ openTicketsCount > 99 ? '99+' : openTicketsCount }
|
{ (openTicketsCount > 0) &&
|
||||||
</span> }
|
<span
|
||||||
</Button>
|
className="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 rounded-full bg-rose-500 text-white text-xs font-semibold shrink-0 [box-shadow:0_0_0_2px_rgba(244,63,94,.25)]"
|
||||||
|
title={ LocalizeText(openTicketsCount === 1 ? 'modtools.window.tickets.open' : 'modtools.window.tickets.open.many', [ 'count' ], [ openTicketsCount.toString() ]) }>
|
||||||
|
{ openTicketsCount > 99 ? '99+' : openTicketsCount }
|
||||||
|
</span> }
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</NitroCardContentView>
|
</NitroCardContentView>
|
||||||
</NitroCardView> }
|
</NitroCardView> }
|
||||||
{ (openRooms.length > 0) && openRooms.map(roomId => <ModToolsRoomView key={ roomId } roomId={ roomId } onCloseClick={ () => CreateLinkEvent(`mod-tools/close-room-info/${ roomId }`) } />) }
|
{ (openRooms.length > 0) && openRooms.map(roomId => <ModToolsRoomView key={ roomId } roomId={ roomId } onCloseClick={ () => CreateLinkEvent(`mod-tools/close-room-info/${ roomId }`) } />) }
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { ChatRecordData, CreateLinkEvent } from '@nitrots/nitro-renderer';
|
import { ChatRecordData, CreateLinkEvent } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useMemo } from 'react';
|
import { FC, useMemo } from 'react';
|
||||||
import { TryVisitRoom } from '../../../../api';
|
import { FaCommentDots, FaDoorOpen, FaSignInAlt, FaTools } from 'react-icons/fa';
|
||||||
import { Button, Column, Flex, Grid, InfiniteScroll, Text } from '../../../../common';
|
import { LocalizeText, TryVisitRoom } from '../../../../api';
|
||||||
|
import { Column, InfiniteScroll } from '../../../../common';
|
||||||
import { useModTools } from '../../../../hooks';
|
import { useModTools } from '../../../../hooks';
|
||||||
import { ChatlogRecord } from './ChatlogRecord';
|
import { ChatlogRecord } from './ChatlogRecord';
|
||||||
|
|
||||||
@@ -43,46 +44,61 @@ export const ChatlogView: FC<ChatlogViewProps> = props =>
|
|||||||
return results;
|
return results;
|
||||||
}, [ records ]);
|
}, [ records ]);
|
||||||
|
|
||||||
const RoomInfo = (props: { roomId: number, roomName: string }) =>
|
const totalMessages = useMemo(
|
||||||
{
|
() => allRecords.filter(r => !r.isRoomInfo).length,
|
||||||
return (
|
[ allRecords ]
|
||||||
<Flex alignItems="center" className="bg-muted rounded p-2" gap={ 2 } justifyContent="between">
|
);
|
||||||
<Text bold truncate>{ props.roomName }</Text>
|
|
||||||
<div className="flex gap-1 shrink-0">
|
const RoomInfo = (props: { roomId: number, roomName: string }) => (
|
||||||
<Button size="sm" onClick={ event => TryVisitRoom(props.roomId) }>Visit</Button>
|
<div className="flex items-center gap-2 bg-gradient-to-r from-sky-50 to-transparent rounded p-2 border border-sky-100 my-1">
|
||||||
<Button size="sm" onClick={ event => openRoomInfo(props.roomId) }>Room Tools</Button>
|
<FaDoorOpen className="text-sky-600 shrink-0" size={ 14 } />
|
||||||
</div>
|
<div className="font-semibold leading-tight grow truncate">{ props.roomName }</div>
|
||||||
</Flex>
|
<div className="flex gap-1 shrink-0">
|
||||||
);
|
<button
|
||||||
};
|
className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs bg-white border border-sky-200 text-sky-700 hover:bg-sky-100 transition-colors"
|
||||||
|
onClick={ () => TryVisitRoom(props.roomId) }>
|
||||||
|
<FaSignInAlt size={ 10 } /> { LocalizeText('modtools.chatlog.visit') }
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs bg-white border border-sky-200 text-sky-700 hover:bg-sky-100 transition-colors"
|
||||||
|
onClick={ () => openRoomInfo(props.roomId) }>
|
||||||
|
<FaTools size={ 10 } /> { LocalizeText('modtools.chatlog.tools') }
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const isEmpty = !records || records.length === 0 || totalMessages === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Column fit gap={ 0 } overflow="hidden">
|
||||||
<Column fit gap={ 0 } overflow="hidden">
|
{/* Column headers */}
|
||||||
<Column gap={ 2 }>
|
<div className="grid grid-cols-[60px_120px_1fr] gap-2 text-[.7rem] uppercase tracking-wide opacity-60 font-semibold border-b border-zinc-200 pb-1 px-1">
|
||||||
<Grid className="text-black font-bold border-bottom pb-1 text-[11px] uppercase opacity-60 tracking-wider" gap={ 1 }>
|
<div>{ LocalizeText('modtools.chatlog.column.time') }</div>
|
||||||
<div className="col-span-2">Time</div>
|
<div>{ LocalizeText('modtools.chatlog.column.user') }</div>
|
||||||
<div className="col-span-3">User</div>
|
<div>{ LocalizeText('modtools.chatlog.column.message') }</div>
|
||||||
<div className="col-span-7">Message</div>
|
</div>
|
||||||
</Grid>
|
{ isEmpty
|
||||||
</Column>
|
? <div className="flex flex-col items-center justify-center gap-1 py-6 opacity-50 text-sm">
|
||||||
{ (records && (records.length > 0)) &&
|
<FaCommentDots size={ 22 } />
|
||||||
<InfiniteScroll rowRender={ (row: ChatlogRecord) =>
|
<span>{ LocalizeText('modtools.chatlog.empty') }</span>
|
||||||
{
|
</div>
|
||||||
return (
|
: <InfiniteScroll rowRender={ (row: ChatlogRecord) =>
|
||||||
<>
|
{
|
||||||
{ row.isRoomInfo &&
|
if(row.isRoomInfo) return <RoomInfo roomId={ row.roomId } roomName={ row.roomName } />;
|
||||||
<RoomInfo roomId={ row.roomId } roomName={ row.roomName } /> }
|
|
||||||
{ !row.isRoomInfo &&
|
return (
|
||||||
<Grid alignItems="center" className="log-entry py-1.5 border-bottom even:bg-black/[0.03]" fullHeight={ false } gap={ 1 }>
|
<div className={ `grid grid-cols-[60px_120px_1fr] gap-2 items-start px-1 py-1.5 text-sm border-b border-zinc-100 even:bg-black/[0.02] hover:bg-sky-50/50 transition-colors ${ row.hasHighlighting ? 'bg-amber-50/60' : '' }` }>
|
||||||
<Text className="col-span-2 opacity-60 text-[11px]">{ row.timestamp }</Text>
|
<span className="font-mono text-[.7rem] opacity-70 tabular-nums whitespace-nowrap">{ row.timestamp }</span>
|
||||||
<Text bold pointer underline className="col-span-3" onClick={ event => CreateLinkEvent(`mod-tools/open-user-info/${ row.habboId }`) }>{ row.username }</Text>
|
<button
|
||||||
<Text textBreak wrap className="col-span-7">{ row.message }</Text>
|
className="text-left font-semibold text-sky-700 hover:text-sky-900 hover:underline truncate"
|
||||||
</Grid> }
|
onClick={ () => CreateLinkEvent(`mod-tools/open-user-info/${ row.habboId }`) }>
|
||||||
</>
|
{ row.username }
|
||||||
);
|
</button>
|
||||||
} } rows={ allRecords } /> }
|
<span className="break-words">{ row.message }</span>
|
||||||
</Column>
|
</div>
|
||||||
</>
|
);
|
||||||
|
} } rows={ allRecords } /> }
|
||||||
|
</Column>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { ChatRecordData, GetRoomChatlogMessageComposer, RoomChatlogEvent } from '@nitrots/nitro-renderer';
|
import { ChatRecordData, GetRoomChatlogMessageComposer, RoomChatlogEvent } from '@nitrots/nitro-renderer';
|
||||||
import { FC } from 'react';
|
import { FC, useEffect, useState } from 'react';
|
||||||
import { useNitroQuery } from '../../../../api/nitro-query';
|
import { FaSpinner } from 'react-icons/fa';
|
||||||
|
import { LocalizeText, SendMessageComposer } from '../../../../api';
|
||||||
import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
|
import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
|
||||||
|
import { useMessageEvent } from '../../../../hooks';
|
||||||
import { ChatlogView } from '../chatlog/ChatlogView';
|
import { ChatlogView } from '../chatlog/ChatlogView';
|
||||||
|
|
||||||
interface ModToolsChatlogViewProps
|
interface ModToolsChatlogViewProps
|
||||||
@@ -13,24 +15,32 @@ interface ModToolsChatlogViewProps
|
|||||||
export const ModToolsChatlogView: FC<ModToolsChatlogViewProps> = props =>
|
export const ModToolsChatlogView: FC<ModToolsChatlogViewProps> = props =>
|
||||||
{
|
{
|
||||||
const { roomId = null, onCloseClick = null } = props;
|
const { roomId = null, onCloseClick = null } = props;
|
||||||
|
const [ roomChatlog, setRoomChatlog ] = useState<ChatRecordData>(null);
|
||||||
|
|
||||||
const { data: roomChatlog } = useNitroQuery<RoomChatlogEvent, ChatRecordData>({
|
useMessageEvent<RoomChatlogEvent>(RoomChatlogEvent, event =>
|
||||||
key: [ 'nitro', 'mod-tools', 'room-chatlog', roomId ],
|
{
|
||||||
request: () => new GetRoomChatlogMessageComposer(roomId),
|
const parser = event.getParser();
|
||||||
parser: RoomChatlogEvent,
|
|
||||||
accept: e => e.getParser()?.data.roomId === roomId,
|
if(!parser || parser.data.roomId !== roomId) return;
|
||||||
select: e => e.getParser().data,
|
|
||||||
enabled: roomId !== null
|
setRoomChatlog(parser.data);
|
||||||
});
|
});
|
||||||
|
|
||||||
if(!roomChatlog) return null;
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
SendMessageComposer(new GetRoomChatlogMessageComposer(roomId));
|
||||||
|
}, [ roomId ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NitroCardView className="nitro-mod-tools-chatlog min-w-[400px] max-h-[500px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
|
<NitroCardView className="nitro-mod-tools-chatlog min-w-[460px] max-w-[520px] max-h-[500px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
|
||||||
<NitroCardHeaderView headerText={ 'Room Chatlog' } onCloseClick={ onCloseClick } />
|
<NitroCardHeaderView headerText={ LocalizeText('modtools.room.chatlog.title') } onCloseClick={ onCloseClick } />
|
||||||
<NitroCardContentView className="text-black" overflow="auto">
|
<NitroCardContentView className="text-black" gap={ 1 } overflow="auto">
|
||||||
{ roomChatlog &&
|
{ roomChatlog
|
||||||
<ChatlogView records={ [ roomChatlog ] } /> }
|
? <ChatlogView records={ [ roomChatlog ] } />
|
||||||
|
: <div className="flex flex-col items-center justify-center gap-2 py-8 opacity-50 text-sm">
|
||||||
|
<FaSpinner className="animate-spin" size={ 22 } />
|
||||||
|
<span>{ LocalizeText('modtools.user.chatlog.loading') }</span>
|
||||||
|
</div> }
|
||||||
</NitroCardContentView>
|
</NitroCardContentView>
|
||||||
</NitroCardView>
|
</NitroCardView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { CreateLinkEvent, GetModeratorRoomInfoMessageComposer, ModerateRoomMessageComposer, ModeratorActionMessageComposer, ModeratorRoomInfoEvent } from '@nitrots/nitro-renderer';
|
import { CreateLinkEvent, GetModeratorRoomInfoMessageComposer, ModerateRoomMessageComposer, ModeratorActionMessageComposer, ModeratorRoomInfoEvent } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useEffect, useState } from 'react';
|
import { FC, useEffect, useState } from 'react';
|
||||||
import { SendMessageComposer, TryVisitRoom } from '../../../../api';
|
import { FaBullhorn, FaCommentDots, FaDoorOpen, FaExclamationTriangle, FaSignInAlt, FaSync, FaUserShield, FaUsers } from 'react-icons/fa';
|
||||||
import { Button, Column, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
|
import { LocalizeText, SendMessageComposer, TryVisitRoom } from '../../../../api';
|
||||||
|
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
|
||||||
import { useMessageEvent } from '../../../../hooks';
|
import { useMessageEvent } from '../../../../hooks';
|
||||||
|
|
||||||
interface ModToolsRoomViewProps
|
interface ModToolsRoomViewProps
|
||||||
@@ -25,7 +26,9 @@ export const ModToolsRoomView: FC<ModToolsRoomViewProps> = props =>
|
|||||||
const [ changeRoomName, setChangeRoomName ] = useState(false);
|
const [ changeRoomName, setChangeRoomName ] = useState(false);
|
||||||
const [ message, setMessage ] = useState('');
|
const [ message, setMessage ] = useState('');
|
||||||
|
|
||||||
const handleClick = (action: string, value?: string) =>
|
const refresh = () => SendMessageComposer(new GetModeratorRoomInfoMessageComposer(roomId));
|
||||||
|
|
||||||
|
const handleClick = (action: string) =>
|
||||||
{
|
{
|
||||||
if(!action) return;
|
if(!action) return;
|
||||||
|
|
||||||
@@ -66,55 +69,102 @@ export const ModToolsRoomView: FC<ModToolsRoomViewProps> = props =>
|
|||||||
|
|
||||||
SendMessageComposer(new GetModeratorRoomInfoMessageComposer(roomId));
|
SendMessageComposer(new GetModeratorRoomInfoMessageComposer(roomId));
|
||||||
setInfoRequested(true);
|
setInfoRequested(true);
|
||||||
}, [ roomId, infoRequested, setInfoRequested ]);
|
}, [ roomId, infoRequested ]);
|
||||||
|
|
||||||
|
const isLoaded = loadedRoomId !== null;
|
||||||
|
const hasMessage = message.trim().length > 0;
|
||||||
|
const ownerPillClass = ownerInRoom
|
||||||
|
? 'bg-emerald-100 text-emerald-700 border-emerald-200'
|
||||||
|
: 'bg-zinc-100 text-zinc-600 border-zinc-200';
|
||||||
|
const ownerDotClass = ownerInRoom ? 'bg-emerald-500' : 'bg-zinc-400';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NitroCardView className="nitro-mod-tools-room min-w-[280px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
|
<NitroCardView className="nitro-mod-tools-room min-w-[400px] max-w-[460px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
|
||||||
<NitroCardHeaderView headerText={ 'Room Info' } onCloseClick={ event => onCloseClick() } />
|
<NitroCardHeaderView headerText={ LocalizeText('modtools.roominfo.title') } onCloseClick={ () => onCloseClick() } />
|
||||||
<NitroCardContentView className="text-black" gap={ 2 }>
|
<NitroCardContentView className="text-black" gap={ 2 }>
|
||||||
{ name &&
|
{/* Identity header */}
|
||||||
<div className="bg-muted rounded px-2 py-1.5 text-center">
|
<div className="flex items-center gap-2 bg-gradient-to-r from-sky-50 to-transparent rounded p-2 border border-sky-100">
|
||||||
<Text bold truncate>{ name }</Text>
|
<FaDoorOpen className="text-sky-600 shrink-0" size={ 16 } />
|
||||||
|
<div className="flex flex-col grow min-w-0">
|
||||||
|
<Text bold className="truncate text-base leading-tight">{ name || LocalizeText('modtools.roominfo.loading') }</Text>
|
||||||
|
<Text className="opacity-60 text-xs truncate">#{ roomId }</Text>
|
||||||
</div>
|
</div>
|
||||||
}
|
<span
|
||||||
<div className="flex gap-2">
|
className={ `inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium border ${ ownerPillClass }` }
|
||||||
<Column grow gap={ 1 }>
|
title={ ownerInRoom ? LocalizeText('modtools.roominfo.owner.title.here') : LocalizeText('modtools.roominfo.owner.title.away') }>
|
||||||
<div className="flex items-center gap-1">
|
<span className={ `inline-block w-2 h-2 rounded-full ${ ownerDotClass }` } />
|
||||||
<Text bold className="opacity-60 shrink-0">Owner:</Text>
|
{ ownerInRoom ? LocalizeText('modtools.roominfo.owner.here') : LocalizeText('modtools.roominfo.owner.away') }
|
||||||
<Text bold pointer truncate underline onClick={ () => CreateLinkEvent(`mod-tools/open-user-info/${ ownerId }`) }>{ ownerName }</Text>
|
</span>
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center w-7 h-7 rounded text-zinc-500 hover:text-sky-700 hover:bg-sky-100 transition-colors shrink-0"
|
||||||
|
onClick={ refresh }
|
||||||
|
title={ LocalizeText('modtools.roominfo.refresh') }>
|
||||||
|
<FaSync size={ 12 } />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stat strip */}
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<div className="flex flex-col items-center justify-center px-2 py-1.5 rounded border bg-sky-50 border-sky-200 text-sky-700 grow min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5 text-[.7rem] uppercase tracking-wide opacity-70">
|
||||||
|
<FaUsers size={ 10 } /><span>{ LocalizeText('modtools.roominfo.stat.users') }</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="text-lg font-semibold tabular-nums leading-tight">{ usersInRoom }</div>
|
||||||
<Text bold className="opacity-60 shrink-0">Users in room:</Text>
|
</div>
|
||||||
<Text>{ usersInRoom }</Text>
|
<div className="flex flex-col items-center justify-center px-2 py-1.5 rounded border bg-zinc-50 border-zinc-200 text-zinc-700 grow min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5 text-[.7rem] uppercase tracking-wide opacity-70">
|
||||||
|
<FaUserShield size={ 10 } /><span>{ LocalizeText('modtools.roominfo.stat.owner') }</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div
|
||||||
<Text bold className="opacity-60 shrink-0">Owner here:</Text>
|
className="text-sm font-semibold leading-tight truncate max-w-full underline cursor-pointer hover:text-sky-700"
|
||||||
<Text className={ ownerInRoom ? 'text-green-700' : 'text-red-700' }>{ ownerInRoom ? 'Yes' : 'No' }</Text>
|
onClick={ () => ownerId && CreateLinkEvent(`mod-tools/open-user-info/${ ownerId }`) }
|
||||||
|
title={ ownerName ? LocalizeText('modtools.roominfo.owner.open', [ 'username' ], [ ownerName ]) : '' }>
|
||||||
|
{ ownerName || '-' }
|
||||||
</div>
|
</div>
|
||||||
</Column>
|
|
||||||
<div className="flex flex-col gap-1 shrink-0">
|
|
||||||
<Button onClick={ event => TryVisitRoom(roomId) }>Visit Room</Button>
|
|
||||||
<Button onClick={ event => CreateLinkEvent(`mod-tools/open-room-chatlog/${ roomId }`) }>Chatlog</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Column className="bg-muted rounded p-2" gap={ 1 }>
|
|
||||||
<div className="flex items-center gap-2">
|
{/* Quick actions */}
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
<Button gap={ 1 } variant="secondary" onClick={ () => TryVisitRoom(roomId) }>
|
||||||
|
<FaSignInAlt size={ 12 } /> { LocalizeText('modtools.roominfo.button.visit') }
|
||||||
|
</Button>
|
||||||
|
<Button gap={ 1 } variant="secondary" onClick={ () => CreateLinkEvent(`mod-tools/open-room-chatlog/${ roomId }`) }>
|
||||||
|
<FaCommentDots size={ 12 } /> { LocalizeText('modtools.roominfo.button.chatlog') }
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Moderate panel */}
|
||||||
|
<div className="flex flex-col gap-1.5 bg-amber-50 border border-amber-200 rounded p-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-[.7rem] uppercase tracking-wide font-semibold text-amber-800">
|
||||||
|
<FaExclamationTriangle size={ 10 } /> { LocalizeText('modtools.roominfo.moderate.title') }
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<input checked={ kickUsers } className="form-check-input" type="checkbox" onChange={ event => setKickUsers(event.target.checked) } />
|
<input checked={ kickUsers } className="form-check-input" type="checkbox" onChange={ event => setKickUsers(event.target.checked) } />
|
||||||
<Text small>Kick everyone out</Text>
|
<span>{ LocalizeText('modtools.roominfo.moderate.kick') }</span>
|
||||||
</div>
|
</label>
|
||||||
<div className="flex items-center gap-2">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<input checked={ lockRoom } className="form-check-input" type="checkbox" onChange={ event => setLockRoom(event.target.checked) } />
|
<input checked={ lockRoom } className="form-check-input" type="checkbox" onChange={ event => setLockRoom(event.target.checked) } />
|
||||||
<Text small>Enable the doorbell</Text>
|
<span>{ LocalizeText('modtools.roominfo.moderate.doorbell') }</span>
|
||||||
</div>
|
</label>
|
||||||
<div className="flex items-center gap-2">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<input checked={ changeRoomName } className="form-check-input" type="checkbox" onChange={ event => setChangeRoomName(event.target.checked) } />
|
<input checked={ changeRoomName } className="form-check-input" type="checkbox" onChange={ event => setChangeRoomName(event.target.checked) } />
|
||||||
<Text small>Change room name</Text>
|
<span>{ LocalizeText('modtools.roominfo.moderate.rename') }</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="min-h-[60px] px-2 py-1.5 rounded text-sm border border-amber-300 bg-white/70 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||||
|
placeholder={ LocalizeText('modtools.roominfo.moderate.message.placeholder') }
|
||||||
|
value={ message }
|
||||||
|
onChange={ event => setMessage(event.target.value) }
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button className="grow" disabled={ !hasMessage || !isLoaded } gap={ 1 } variant="danger" onClick={ () => handleClick('send_message') }>
|
||||||
|
<FaBullhorn size={ 12 } /> { LocalizeText('modtools.roominfo.moderate.send.caution') }
|
||||||
|
</Button>
|
||||||
|
<Button className="grow" disabled={ !hasMessage || !isLoaded } gap={ 1 } variant="warning" onClick={ () => handleClick('alert_only') }>
|
||||||
|
<FaExclamationTriangle size={ 12 } /> { LocalizeText('modtools.roominfo.moderate.send.alert') }
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Column>
|
|
||||||
<textarea className="min-h-[60px] px-2 py-1.5 rounded text-sm border border-black/10" placeholder="Type a mandatory message..." value={ message } onChange={ event => setMessage(event.target.value) }></textarea>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button className="grow" variant="danger" onClick={ event => handleClick('send_message') }>Send Caution</Button>
|
|
||||||
<Button className="grow" onClick={ event => handleClick('alert_only') }>Send Alert</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</NitroCardContentView>
|
</NitroCardContentView>
|
||||||
</NitroCardView>
|
</NitroCardView>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { CfhChatlogData, CfhChatlogEvent, GetCfhChatlogMessageComposer } from '@nitrots/nitro-renderer';
|
import { CfhChatlogData, CfhChatlogEvent, GetCfhChatlogMessageComposer } from '@nitrots/nitro-renderer';
|
||||||
import { FC } from 'react';
|
import { FC, useEffect, useState } from 'react';
|
||||||
import { useNitroQuery } from '../../../../api/nitro-query';
|
import { FaSpinner } from 'react-icons/fa';
|
||||||
import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
|
import { LocalizeText, SendMessageComposer } from '../../../../api';
|
||||||
|
import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
|
||||||
|
import { useMessageEvent } from '../../../../hooks';
|
||||||
import { ChatlogView } from '../chatlog/ChatlogView';
|
import { ChatlogView } from '../chatlog/ChatlogView';
|
||||||
|
|
||||||
interface CfhChatlogViewProps
|
interface CfhChatlogViewProps
|
||||||
@@ -13,21 +15,32 @@ interface CfhChatlogViewProps
|
|||||||
export const CfhChatlogView: FC<CfhChatlogViewProps> = props =>
|
export const CfhChatlogView: FC<CfhChatlogViewProps> = props =>
|
||||||
{
|
{
|
||||||
const { onCloseClick = null, issueId = null } = props;
|
const { onCloseClick = null, issueId = null } = props;
|
||||||
|
const [ chatlogData, setChatlogData ] = useState<CfhChatlogData>(null);
|
||||||
|
|
||||||
const { data: chatlogData } = useNitroQuery<CfhChatlogEvent, CfhChatlogData>({
|
useMessageEvent<CfhChatlogEvent>(CfhChatlogEvent, event =>
|
||||||
key: [ 'nitro', 'mod-tools', 'cfh-chatlog', issueId ],
|
{
|
||||||
request: () => new GetCfhChatlogMessageComposer(issueId),
|
const parser = event.getParser();
|
||||||
parser: CfhChatlogEvent,
|
|
||||||
accept: e => e.getParser()?.data.issueId === issueId,
|
if(!parser || parser.data.issueId !== issueId) return;
|
||||||
select: e => e.getParser().data,
|
|
||||||
enabled: issueId !== null
|
setChatlogData(parser.data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
SendMessageComposer(new GetCfhChatlogMessageComposer(issueId));
|
||||||
|
}, [ issueId ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NitroCardView className="nitro-mod-tools-chatlog" theme="primary-slim">
|
<NitroCardView className="nitro-mod-tools-chatlog min-w-[460px] max-w-[520px] max-h-[500px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
|
||||||
<NitroCardHeaderView headerText={ 'Issue Chatlog' } onCloseClick={ onCloseClick } />
|
<NitroCardHeaderView headerText={ LocalizeText('modtools.tickets.cfh.chatlog.title', [ 'issueId' ], [ issueId.toString() ]) } onCloseClick={ onCloseClick } />
|
||||||
<NitroCardContentView className="text-black">
|
<NitroCardContentView className="text-black" gap={ 1 }>
|
||||||
{ chatlogData && <ChatlogView records={ [ chatlogData.chatRecord ] } /> }
|
{ chatlogData
|
||||||
|
? <ChatlogView records={ [ chatlogData.chatRecord ] } />
|
||||||
|
: <div className="flex flex-col items-center justify-center gap-2 py-8 opacity-50 text-sm">
|
||||||
|
<FaSpinner className="animate-spin" size={ 22 } />
|
||||||
|
<span>{ LocalizeText('modtools.user.chatlog.loading') }</span>
|
||||||
|
</div> }
|
||||||
</NitroCardContentView>
|
</NitroCardContentView>
|
||||||
</NitroCardView>
|
</NitroCardView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { CloseIssuesMessageComposer, ReleaseIssuesMessageComposer } from '@nitrots/nitro-renderer';
|
import { CloseIssuesMessageComposer, ReleaseIssuesMessageComposer } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useState } from 'react';
|
import { FC, useState } from 'react';
|
||||||
|
import { FaBan, FaCheck, FaCommentDots, FaExternalLinkAlt, FaSignOutAlt, FaTrashAlt } from 'react-icons/fa';
|
||||||
import { GetIssueCategoryName, LocalizeText, SendMessageComposer } from '../../../../api';
|
import { GetIssueCategoryName, LocalizeText, SendMessageComposer } from '../../../../api';
|
||||||
import { Button, Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
|
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
|
||||||
import { useModTools } from '../../../../hooks';
|
import { useModTools } from '../../../../hooks';
|
||||||
import { CfhChatlogView } from './CfhChatlogView';
|
import { CfhChatlogView } from './CfhChatlogView';
|
||||||
|
|
||||||
@@ -11,76 +12,102 @@ interface IssueInfoViewProps
|
|||||||
onIssueInfoClosed(issueId: number): void;
|
onIssueInfoClosed(issueId: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Field: FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
|
||||||
|
<>
|
||||||
|
<dt className="opacity-60 whitespace-nowrap">{ label }</dt>
|
||||||
|
<dd className="m-0 break-words font-medium">{ children || <span className="opacity-40">-</span> }</dd>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
export const ModToolsIssueInfoView: FC<IssueInfoViewProps> = props =>
|
export const ModToolsIssueInfoView: FC<IssueInfoViewProps> = props =>
|
||||||
{
|
{
|
||||||
const { issueId = null, onIssueInfoClosed = null } = props;
|
const { issueId = null, onIssueInfoClosed = null } = props;
|
||||||
const [ cfhChatlogOpen, setcfhChatlogOpen ] = useState(false);
|
const [ cfhChatlogOpen, setCfhChatlogOpen ] = useState(false);
|
||||||
const { tickets = [], openUserInfo = null } = useModTools();
|
const { tickets = [], openUserInfo = null } = useModTools();
|
||||||
const ticket = tickets.find(issue => (issue.issueId === issueId));
|
const ticket = tickets.find(issue => (issue.issueId === issueId));
|
||||||
|
|
||||||
const releaseIssue = (issueId: number) =>
|
const releaseIssue = () =>
|
||||||
{
|
{
|
||||||
SendMessageComposer(new ReleaseIssuesMessageComposer([ issueId ]));
|
SendMessageComposer(new ReleaseIssuesMessageComposer([ issueId ]));
|
||||||
|
|
||||||
onIssueInfoClosed(issueId);
|
onIssueInfoClosed(issueId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeIssue = (resolutionType: number) =>
|
const closeIssue = (resolutionType: number) =>
|
||||||
{
|
{
|
||||||
SendMessageComposer(new CloseIssuesMessageComposer([ issueId ], resolutionType));
|
SendMessageComposer(new CloseIssuesMessageComposer([ issueId ], resolutionType));
|
||||||
|
|
||||||
onIssueInfoClosed(issueId);
|
onIssueInfoClosed(issueId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if(!ticket) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NitroCardView className="nitro-mod-tools-handle-issue" theme="primary-slim">
|
<NitroCardView className="nitro-mod-tools-handle-issue min-w-[440px] max-w-[500px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
|
||||||
<NitroCardHeaderView headerText={ 'Resolving issue ' + issueId } onCloseClick={ () => onIssueInfoClosed(issueId) } />
|
<NitroCardHeaderView headerText={ LocalizeText('modtools.tickets.issue.title', [ 'issueId' ], [ issueId.toString() ]) } onCloseClick={ () => onIssueInfoClosed(issueId) } />
|
||||||
<NitroCardContentView className="text-black">
|
<NitroCardContentView className="text-black" gap={ 2 }>
|
||||||
<Text fontSize={ 4 }>Issue Information</Text>
|
{/* Issue header */}
|
||||||
<Grid overflow="auto">
|
<div className="flex items-center gap-2 bg-gradient-to-r from-amber-50 to-transparent rounded p-2 border border-amber-100">
|
||||||
<Column size={ 8 }>
|
<FaCommentDots className="text-amber-600 shrink-0" size={ 16 } />
|
||||||
<table className="table table-striped table-sm table-text-small text-black m-0">
|
<div className="flex flex-col grow min-w-0">
|
||||||
<tbody>
|
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">{ LocalizeText('modtools.tickets.issue.label', [ 'issueId' ], [ issueId.toString() ]) }</div>
|
||||||
<tr>
|
<div className="font-semibold leading-tight truncate">{ GetIssueCategoryName(ticket.categoryId) }</div>
|
||||||
<th>Source</th>
|
</div>
|
||||||
<td>{ GetIssueCategoryName(ticket.categoryId) }</td>
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border bg-white border-amber-200 text-amber-800">
|
||||||
</tr>
|
{ LocalizeText('help.cfh.topic.' + ticket.reportedCategoryId) }
|
||||||
<tr>
|
</span>
|
||||||
<th>Category</th>
|
</div>
|
||||||
<td className="text-break">{ LocalizeText('help.cfh.topic.' + ticket.reportedCategoryId) }</td>
|
|
||||||
</tr>
|
{/* Details */}
|
||||||
<tr>
|
<div className="flex flex-col gap-1">
|
||||||
<th>Description</th>
|
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold border-b border-zinc-200 pb-1 mb-0.5">{ LocalizeText('modtools.tickets.issue.details') }</div>
|
||||||
<td className="text-break">{ ticket.message }</td>
|
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[.8rem] m-0">
|
||||||
</tr>
|
<Field label={ LocalizeText('modtools.tickets.issue.field.source') }>{ GetIssueCategoryName(ticket.categoryId) }</Field>
|
||||||
<tr>
|
<Field label={ LocalizeText('modtools.tickets.issue.field.category') }>{ LocalizeText('help.cfh.topic.' + ticket.reportedCategoryId) }</Field>
|
||||||
<th>Caller</th>
|
<Field label={ LocalizeText('modtools.tickets.issue.field.description') }>{ ticket.message }</Field>
|
||||||
<td>
|
<Field label={ LocalizeText('modtools.tickets.issue.field.caller') }>
|
||||||
<Text bold pointer underline onClick={ event => openUserInfo(ticket.reporterUserId) }>{ ticket.reporterUserName }</Text>
|
<button
|
||||||
</td>
|
className="font-semibold text-sky-700 hover:text-sky-900 hover:underline inline-flex items-center gap-1"
|
||||||
</tr>
|
onClick={ () => openUserInfo(ticket.reporterUserId) }>
|
||||||
<tr>
|
{ ticket.reporterUserName } <FaExternalLinkAlt size={ 8 } className="opacity-60" />
|
||||||
<th>Reported User</th>
|
</button>
|
||||||
<td>
|
</Field>
|
||||||
<Text bold pointer underline onClick={ event => openUserInfo(ticket.reportedUserId) }>{ ticket.reportedUserName }</Text>
|
<Field label={ LocalizeText('modtools.tickets.issue.field.reported') }>
|
||||||
</td>
|
<button
|
||||||
</tr>
|
className="font-semibold text-sky-700 hover:text-sky-900 hover:underline inline-flex items-center gap-1"
|
||||||
</tbody>
|
onClick={ () => openUserInfo(ticket.reportedUserId) }>
|
||||||
</table>
|
{ ticket.reportedUserName } <FaExternalLinkAlt size={ 8 } className="opacity-60" />
|
||||||
</Column>
|
</button>
|
||||||
<Column gap={ 1 } size={ 4 }>
|
</Field>
|
||||||
<Button variant="secondary" onClick={ () => setcfhChatlogOpen(!cfhChatlogOpen) }>Chatlog</Button>
|
</dl>
|
||||||
<Button onClick={ event => closeIssue(CloseIssuesMessageComposer.RESOLUTION_USELESS) }>Close as useless</Button>
|
</div>
|
||||||
<Button variant="danger" onClick={ event => closeIssue(CloseIssuesMessageComposer.RESOLUTION_ABUSIVE) }>Close as abusive</Button>
|
|
||||||
<Button variant="success" onClick={ event => closeIssue(CloseIssuesMessageComposer.RESOLUTION_RESOLVED) }>Close as resolved</Button>
|
{/* Tools */}
|
||||||
<Button variant="secondary" onClick={ event => releaseIssue(issueId) } >Release</Button>
|
<Button gap={ 1 } variant="secondary" onClick={ () => setCfhChatlogOpen(prev => !prev) }>
|
||||||
</Column>
|
<FaCommentDots size={ 12 } /> { cfhChatlogOpen ? LocalizeText('modtools.tickets.issue.chatlog.close') : LocalizeText('modtools.tickets.issue.chatlog.view') }
|
||||||
</Grid>
|
</Button>
|
||||||
|
|
||||||
|
{/* Resolution buttons */}
|
||||||
|
<div className="flex flex-col gap-1.5 pt-1 border-t border-zinc-200">
|
||||||
|
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">{ LocalizeText('modtools.tickets.issue.resolve.heading') }</div>
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
<Button gap={ 1 } variant="success" onClick={ () => closeIssue(CloseIssuesMessageComposer.RESOLUTION_RESOLVED) }>
|
||||||
|
<FaCheck size={ 11 } /> { LocalizeText('modtools.tickets.issue.resolve.resolved') }
|
||||||
|
</Button>
|
||||||
|
<Button gap={ 1 } variant="dark" onClick={ () => closeIssue(CloseIssuesMessageComposer.RESOLUTION_USELESS) }>
|
||||||
|
<FaTrashAlt size={ 11 } /> { LocalizeText('modtools.tickets.issue.resolve.useless') }
|
||||||
|
</Button>
|
||||||
|
<Button gap={ 1 } variant="danger" onClick={ () => closeIssue(CloseIssuesMessageComposer.RESOLUTION_ABUSIVE) }>
|
||||||
|
<FaBan size={ 11 } /> { LocalizeText('modtools.tickets.issue.resolve.abusive') }
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button gap={ 1 } variant="secondary" onClick={ releaseIssue }>
|
||||||
|
<FaSignOutAlt size={ 12 } /> { LocalizeText('modtools.tickets.issue.release') }
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</NitroCardContentView>
|
</NitroCardContentView>
|
||||||
</NitroCardView>
|
</NitroCardView>
|
||||||
{ cfhChatlogOpen &&
|
{ cfhChatlogOpen &&
|
||||||
<CfhChatlogView issueId={ issueId } onCloseClick={ () => setcfhChatlogOpen(false) }/> }
|
<CfhChatlogView issueId={ issueId } onCloseClick={ () => setCfhChatlogOpen(false) } /> }
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { IssueMessageData, ReleaseIssuesMessageComposer } from '@nitrots/nitro-renderer';
|
import { IssueMessageData, ReleaseIssuesMessageComposer } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useRef } from 'react';
|
import { FC, useRef } from 'react';
|
||||||
import { SendMessageComposer } from '../../../../api';
|
import { FaClock, FaInbox, FaSignOutAlt, FaTools, FaUser } from 'react-icons/fa';
|
||||||
import { Button, Column, Grid } from '../../../../common';
|
import { GetIssueCategoryName, LocalizeText, SendMessageComposer } from '../../../../api';
|
||||||
|
|
||||||
interface ModToolsMyIssuesTabViewProps
|
interface ModToolsMyIssuesTabViewProps
|
||||||
{
|
{
|
||||||
@@ -24,35 +24,45 @@ export const ModToolsMyIssuesTabView: FC<ModToolsMyIssuesTabViewProps> = props =
|
|||||||
setTimeout(() => pendingReleasesRef.current.delete(issueId), 2000);
|
setTimeout(() => pendingReleasesRef.current.delete(issueId), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isEmpty = !myIssues || myIssues.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column gap={ 0 } overflow="hidden">
|
<div className="flex flex-col gap-1 overflow-hidden">
|
||||||
<Column gap={ 2 }>
|
<div className="grid grid-cols-[100px_1fr_100px_90px_90px] gap-2 text-[.7rem] uppercase tracking-wide opacity-60 font-semibold border-b border-zinc-200 pb-1 px-1">
|
||||||
<Grid className="text-black font-bold border-bottom pb-1" gap={ 1 }>
|
<div>{ LocalizeText('modtools.tickets.column.type') }</div>
|
||||||
<div className="col-span-2">Type</div>
|
<div className="flex items-center gap-1"><FaUser size={ 10 } /> { LocalizeText('modtools.tickets.column.reported') }</div>
|
||||||
<div className="col-span-3">Room/Player</div>
|
<div className="flex items-center gap-1"><FaClock size={ 10 } /> { LocalizeText('modtools.tickets.column.opened') }</div>
|
||||||
<div className="col-span-3">Opened</div>
|
<div></div>
|
||||||
<div className="col-span-2"></div>
|
<div></div>
|
||||||
<div className="col-span-2"></div>
|
</div>
|
||||||
</Grid>
|
{ isEmpty
|
||||||
</Column>
|
? <div className="flex flex-col items-center justify-center gap-1 py-8 opacity-50 text-sm">
|
||||||
<Column className="striped-children" gap={ 0 } overflow="auto">
|
<FaInbox size={ 22 } />
|
||||||
{ myIssues && (myIssues.length > 0) && myIssues.map(issue =>
|
<span>{ LocalizeText('modtools.tickets.empty.mine') }</span>
|
||||||
{
|
</div>
|
||||||
return (
|
: <div className="flex flex-col overflow-auto">
|
||||||
<Grid key={ issue.issueId } alignItems="center" className="text-black py-1 border-bottom" gap={ 1 }>
|
{ myIssues.map(issue => (
|
||||||
<div className="col-span-2">{ issue.categoryId }</div>
|
<div key={ issue.issueId } className="grid grid-cols-[100px_1fr_100px_90px_90px] gap-2 items-center px-1 py-1.5 text-sm border-b border-zinc-100 even:bg-black/[0.02] hover:bg-sky-50/50 transition-colors">
|
||||||
<div className="col-span-3">{ issue.reportedUserName }</div>
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border bg-sky-50 text-sky-800 border-sky-200 w-fit">
|
||||||
<div className="col-span-3">{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }</div>
|
{ GetIssueCategoryName(issue.categoryId) }
|
||||||
<div className="col-span-2">
|
</span>
|
||||||
<Button variant="primary" onClick={ event => handleIssue(issue.issueId) }>Handle</Button>
|
<span className="font-medium truncate">{ issue.reportedUserName }</span>
|
||||||
</div>
|
<span className="font-mono text-[.75rem] opacity-70 tabular-nums">
|
||||||
<div className="col-span-2">
|
{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }
|
||||||
<Button variant="danger" onClick={ () => releaseIssue(issue.issueId) }>Release</Button>
|
</span>
|
||||||
</div>
|
<button
|
||||||
</Grid>
|
className="inline-flex items-center justify-center gap-1 px-2 py-1 rounded text-xs font-medium bg-sky-600 text-white hover:bg-sky-700 transition-colors"
|
||||||
);
|
onClick={ () => handleIssue(issue.issueId) }>
|
||||||
}) }
|
<FaTools size={ 10 } /> { LocalizeText('modtools.tickets.action.handle') }
|
||||||
</Column>
|
</button>
|
||||||
</Column>
|
<button
|
||||||
|
className="inline-flex items-center justify-center gap-1 px-2 py-1 rounded text-xs font-medium bg-rose-600 text-white hover:bg-rose-700 transition-colors"
|
||||||
|
onClick={ () => releaseIssue(issue.issueId) }>
|
||||||
|
<FaSignOutAlt size={ 10 } /> { LocalizeText('modtools.tickets.action.release') }
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)) }
|
||||||
|
</div> }
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { IssueMessageData, PickIssuesMessageComposer } from '@nitrots/nitro-renderer';
|
import { IssueMessageData, PickIssuesMessageComposer } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useRef } from 'react';
|
import { FC, useRef } from 'react';
|
||||||
import { SendMessageComposer } from '../../../../api';
|
import { FaClock, FaHandPointer, FaInbox, FaUser } from 'react-icons/fa';
|
||||||
import { Button, Column, Grid } from '../../../../common';
|
import { GetIssueCategoryName, LocalizeText, SendMessageComposer } from '../../../../api';
|
||||||
|
|
||||||
interface ModToolsOpenIssuesTabViewProps
|
interface ModToolsOpenIssuesTabViewProps
|
||||||
{
|
{
|
||||||
@@ -23,31 +23,39 @@ export const ModToolsOpenIssuesTabView: FC<ModToolsOpenIssuesTabViewProps> = pro
|
|||||||
setTimeout(() => pendingPicksRef.current.delete(issueId), 2000);
|
setTimeout(() => pendingPicksRef.current.delete(issueId), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isEmpty = !openIssues || openIssues.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column gap={ 0 } overflow="hidden">
|
<div className="flex flex-col gap-1 overflow-hidden">
|
||||||
<Column gap={ 2 }>
|
<div className="grid grid-cols-[100px_1fr_100px_100px] gap-2 text-[.7rem] uppercase tracking-wide opacity-60 font-semibold border-b border-zinc-200 pb-1 px-1">
|
||||||
<Grid className="text-black font-bold border-bottom pb-1" gap={ 1 }>
|
<div>{ LocalizeText('modtools.tickets.column.type') }</div>
|
||||||
<div className="col-span-2">Type</div>
|
<div className="flex items-center gap-1"><FaUser size={ 10 } /> { LocalizeText('modtools.tickets.column.reported') }</div>
|
||||||
<div className="col-span-3">Room/Player</div>
|
<div className="flex items-center gap-1"><FaClock size={ 10 } /> { LocalizeText('modtools.tickets.column.opened') }</div>
|
||||||
<div className="col-span-4">Opened</div>
|
<div></div>
|
||||||
<div className="col-span-3"></div>
|
</div>
|
||||||
</Grid>
|
{ isEmpty
|
||||||
</Column>
|
? <div className="flex flex-col items-center justify-center gap-1 py-8 opacity-50 text-sm">
|
||||||
<Column className="striped-children" gap={ 0 } overflow="auto">
|
<FaInbox size={ 22 } />
|
||||||
{ openIssues && (openIssues.length > 0) && openIssues.map(issue =>
|
<span>{ LocalizeText('modtools.tickets.empty.open') }</span>
|
||||||
{
|
</div>
|
||||||
return (
|
: <div className="flex flex-col overflow-auto">
|
||||||
<Grid key={ issue.issueId } alignItems="center" className="text-black py-1 border-bottom" gap={ 1 }>
|
{ openIssues.map(issue => (
|
||||||
<div className="col-span-2">{ issue.categoryId }</div>
|
<div key={ issue.issueId } className="grid grid-cols-[100px_1fr_100px_100px] gap-2 items-center px-1 py-1.5 text-sm border-b border-zinc-100 even:bg-black/[0.02] hover:bg-amber-50/50 transition-colors">
|
||||||
<div className="col-span-3">{ issue.reportedUserName }</div>
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border bg-amber-50 text-amber-800 border-amber-200 w-fit">
|
||||||
<div className="col-span-4">{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }</div>
|
{ GetIssueCategoryName(issue.categoryId) }
|
||||||
<div className="col-span-3">
|
</span>
|
||||||
<Button variant="success" onClick={ () => pickIssue(issue.issueId) }>Pick Issue</Button>
|
<span className="font-medium truncate">{ issue.reportedUserName }</span>
|
||||||
</div>
|
<span className="font-mono text-[.75rem] opacity-70 tabular-nums">
|
||||||
</Grid>
|
{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }
|
||||||
);
|
</span>
|
||||||
}) }
|
<button
|
||||||
</Column>
|
className="inline-flex items-center justify-center gap-1 px-2 py-1 rounded text-xs font-medium bg-emerald-600 text-white hover:bg-emerald-700 transition-colors"
|
||||||
</Column>
|
onClick={ () => pickIssue(issue.issueId) }>
|
||||||
|
<FaHandPointer size={ 10 } /> { LocalizeText('modtools.tickets.action.pick') }
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)) }
|
||||||
|
</div> }
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { IssueMessageData } from '@nitrots/nitro-renderer';
|
import { IssueMessageData } from '@nitrots/nitro-renderer';
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { Column, Grid } from '../../../../common';
|
import { FaClock, FaInbox, FaUser, FaUserShield } from 'react-icons/fa';
|
||||||
|
import { GetIssueCategoryName, LocalizeText } from '../../../../api';
|
||||||
|
|
||||||
interface ModToolsPickedIssuesTabViewProps
|
interface ModToolsPickedIssuesTabViewProps
|
||||||
{
|
{
|
||||||
@@ -10,30 +11,35 @@ interface ModToolsPickedIssuesTabViewProps
|
|||||||
export const ModToolsPickedIssuesTabView: FC<ModToolsPickedIssuesTabViewProps> = props =>
|
export const ModToolsPickedIssuesTabView: FC<ModToolsPickedIssuesTabViewProps> = props =>
|
||||||
{
|
{
|
||||||
const { pickedIssues = null } = props;
|
const { pickedIssues = null } = props;
|
||||||
|
const isEmpty = !pickedIssues || pickedIssues.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column gap={ 0 } overflow="hidden">
|
<div className="flex flex-col gap-1 overflow-hidden">
|
||||||
<Column gap={ 2 }>
|
<div className="grid grid-cols-[100px_1fr_100px_120px] gap-2 text-[.7rem] uppercase tracking-wide opacity-60 font-semibold border-b border-zinc-200 pb-1 px-1">
|
||||||
<Grid className="text-black font-bold border-bottom pb-1" gap={ 1 }>
|
<div>{ LocalizeText('modtools.tickets.column.type') }</div>
|
||||||
<div className="col-span-2">Type</div>
|
<div className="flex items-center gap-1"><FaUser size={ 10 } /> { LocalizeText('modtools.tickets.column.reported') }</div>
|
||||||
<div className="col-span-3">Room/Player</div>
|
<div className="flex items-center gap-1"><FaClock size={ 10 } /> { LocalizeText('modtools.tickets.column.opened') }</div>
|
||||||
<div className="col-span-4">Opened</div>
|
<div className="flex items-center gap-1"><FaUserShield size={ 10 } /> { LocalizeText('modtools.tickets.column.picker') }</div>
|
||||||
<div className="col-span-3">Picker</div>
|
</div>
|
||||||
</Grid>
|
{ isEmpty
|
||||||
</Column>
|
? <div className="flex flex-col items-center justify-center gap-1 py-8 opacity-50 text-sm">
|
||||||
<Column className="striped-children" gap={ 0 } overflow="auto">
|
<FaInbox size={ 22 } />
|
||||||
{ pickedIssues && (pickedIssues.length > 0) && pickedIssues.map(issue =>
|
<span>{ LocalizeText('modtools.tickets.empty.picked') }</span>
|
||||||
{
|
</div>
|
||||||
return (
|
: <div className="flex flex-col overflow-auto">
|
||||||
<Grid key={ issue.issueId } alignItems="center" className="text-black py-1 border-bottom" gap={ 1 }>
|
{ pickedIssues.map(issue => (
|
||||||
<div className="col-span-2">{ issue.categoryId }</div>
|
<div key={ issue.issueId } className="grid grid-cols-[100px_1fr_100px_120px] gap-2 items-center px-1 py-1.5 text-sm border-b border-zinc-100 even:bg-black/[0.02]">
|
||||||
<div className="col-span-3">{ issue.reportedUserName }</div>
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border bg-zinc-50 text-zinc-700 border-zinc-200 w-fit">
|
||||||
<div className="col-span-4">{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }</div>
|
{ GetIssueCategoryName(issue.categoryId) }
|
||||||
<div className="col-span-3">{ issue.pickerUserName }</div>
|
</span>
|
||||||
</Grid>
|
<span className="font-medium truncate">{ issue.reportedUserName }</span>
|
||||||
);
|
<span className="font-mono text-[.75rem] opacity-70 tabular-nums">
|
||||||
}) }
|
{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }
|
||||||
</Column>
|
</span>
|
||||||
</Column>
|
<span className="truncate font-medium opacity-80">{ issue.pickerUserName }</span>
|
||||||
|
</div>
|
||||||
|
)) }
|
||||||
|
</div> }
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { GetSessionDataManager, IssueMessageData } from '@nitrots/nitro-renderer';
|
import { GetSessionDataManager, IssueMessageData } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useState } from 'react';
|
import { FC, useMemo, useState } from 'react';
|
||||||
|
import { FaCheckSquare, FaListUl, FaUserCheck } from 'react-icons/fa';
|
||||||
|
import { LocalizeText } from '../../../../api';
|
||||||
import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../../../common';
|
import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../../../common';
|
||||||
import { useModTools } from '../../../../hooks';
|
import { useModTools } from '../../../../hooks';
|
||||||
import { ModToolsIssueInfoView } from './ModToolsIssueInfoView';
|
import { ModToolsIssueInfoView } from './ModToolsIssueInfoView';
|
||||||
@@ -12,11 +14,30 @@ interface ModToolsTicketsViewProps
|
|||||||
onCloseClick: () => void;
|
onCloseClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABS: string[] = [
|
interface TabBadgeProps
|
||||||
'Open Issues',
|
{
|
||||||
'My Issues',
|
label: string;
|
||||||
'Picked Issues'
|
count: number;
|
||||||
];
|
icon: React.ReactNode;
|
||||||
|
tone: 'amber' | 'sky' | 'zinc';
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONE_MAP: Record<TabBadgeProps['tone'], string> = {
|
||||||
|
amber: 'bg-amber-500 text-white',
|
||||||
|
sky: 'bg-sky-500 text-white',
|
||||||
|
zinc: 'bg-zinc-400 text-white'
|
||||||
|
};
|
||||||
|
|
||||||
|
const TabLabel: FC<TabBadgeProps> = ({ label, count, icon, tone }) => (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="opacity-80">{ icon }</span>
|
||||||
|
<span>{ label }</span>
|
||||||
|
{ count > 0 &&
|
||||||
|
<span className={ `inline-flex items-center justify-center min-w-[1.25rem] h-4 px-1 rounded-full text-[10px] font-semibold ${ TONE_MAP[tone] }` }>
|
||||||
|
{ count > 99 ? '99+' : count }
|
||||||
|
</span> }
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
export const ModToolsTicketsView: FC<ModToolsTicketsViewProps> = props =>
|
export const ModToolsTicketsView: FC<ModToolsTicketsViewProps> = props =>
|
||||||
{
|
{
|
||||||
@@ -25,9 +46,15 @@ export const ModToolsTicketsView: FC<ModToolsTicketsViewProps> = props =>
|
|||||||
const [ issueInfoWindows, setIssueInfoWindows ] = useState<number[]>([]);
|
const [ issueInfoWindows, setIssueInfoWindows ] = useState<number[]>([]);
|
||||||
const { tickets = [] } = useModTools();
|
const { tickets = [] } = useModTools();
|
||||||
|
|
||||||
const openIssues = tickets.filter(issue => issue.state === IssueMessageData.STATE_OPEN);
|
const { openIssues, myIssues, pickedIssues } = useMemo(() =>
|
||||||
const myIssues = tickets.filter(issue => (issue.state === IssueMessageData.STATE_PICKED) && (issue.pickerUserId === GetSessionDataManager().userId));
|
{
|
||||||
const pickedIssues = tickets.filter(issue => issue.state === IssueMessageData.STATE_PICKED);
|
const ownId = GetSessionDataManager()?.userId;
|
||||||
|
return {
|
||||||
|
openIssues: tickets.filter(issue => issue.state === IssueMessageData.STATE_OPEN),
|
||||||
|
myIssues: tickets.filter(issue => (issue.state === IssueMessageData.STATE_PICKED) && (issue.pickerUserId === ownId)),
|
||||||
|
pickedIssues: tickets.filter(issue => issue.state === IssueMessageData.STATE_PICKED)
|
||||||
|
};
|
||||||
|
}, [ tickets ]);
|
||||||
|
|
||||||
const closeIssue = (issueId: number) =>
|
const closeIssue = (issueId: number) =>
|
||||||
{
|
{
|
||||||
@@ -56,32 +83,34 @@ export const ModToolsTicketsView: FC<ModToolsTicketsViewProps> = props =>
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const CurrentTabComponent = () =>
|
const renderTab = () =>
|
||||||
{
|
{
|
||||||
switch(currentTab)
|
switch(currentTab)
|
||||||
{
|
{
|
||||||
case 0: return <ModToolsOpenIssuesTabView openIssues={ openIssues }/>;
|
case 0: return <ModToolsOpenIssuesTabView openIssues={ openIssues } />;
|
||||||
case 1: return <ModToolsMyIssuesTabView handleIssue={ handleIssue } myIssues={ myIssues }/>;
|
case 1: return <ModToolsMyIssuesTabView handleIssue={ handleIssue } myIssues={ myIssues } />;
|
||||||
case 2: return <ModToolsPickedIssuesTabView pickedIssues={ pickedIssues }/>;
|
case 2: return <ModToolsPickedIssuesTabView pickedIssues={ pickedIssues } />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NitroCardView className="nitro-mod-tools-tickets">
|
<NitroCardView className="nitro-mod-tools-tickets min-w-[520px] max-w-[640px] max-h-[520px]">
|
||||||
<NitroCardHeaderView headerText={ 'Tickets' } onCloseClick={ onCloseClick } />
|
<NitroCardHeaderView headerText={ LocalizeText('modtools.tickets.title') } onCloseClick={ onCloseClick } />
|
||||||
<NitroCardTabsView>
|
<NitroCardTabsView>
|
||||||
{ TABS.map((tab, index) =>
|
<NitroCardTabsItemView isActive={ currentTab === 0 } onClick={ () => setCurrentTab(0) }>
|
||||||
{
|
<TabLabel label={ LocalizeText('modtools.tickets.tab.open') } count={ openIssues.length } icon={ <FaListUl size={ 10 } /> } tone="amber" />
|
||||||
return (<NitroCardTabsItemView key={ index } isActive={ (currentTab === index) } onClick={ event => setCurrentTab(index) }>
|
</NitroCardTabsItemView>
|
||||||
{ tab }
|
<NitroCardTabsItemView isActive={ currentTab === 1 } onClick={ () => setCurrentTab(1) }>
|
||||||
</NitroCardTabsItemView>);
|
<TabLabel label={ LocalizeText('modtools.tickets.tab.mine') } count={ myIssues.length } icon={ <FaUserCheck size={ 10 } /> } tone="sky" />
|
||||||
}) }
|
</NitroCardTabsItemView>
|
||||||
|
<NitroCardTabsItemView isActive={ currentTab === 2 } onClick={ () => setCurrentTab(2) }>
|
||||||
|
<TabLabel label={ LocalizeText('modtools.tickets.tab.picked') } count={ pickedIssues.length } icon={ <FaCheckSquare size={ 10 } /> } tone="zinc" />
|
||||||
|
</NitroCardTabsItemView>
|
||||||
</NitroCardTabsView>
|
</NitroCardTabsView>
|
||||||
<NitroCardContentView gap={ 1 }>
|
<NitroCardContentView gap={ 1 }>
|
||||||
<CurrentTabComponent />
|
{ renderTab() }
|
||||||
</NitroCardContentView>
|
</NitroCardContentView>
|
||||||
</NitroCardView>
|
</NitroCardView>
|
||||||
{ issueInfoWindows && (issueInfoWindows.length > 0) && issueInfoWindows.map(issueId => <ModToolsIssueInfoView key={ issueId } issueId={ issueId } onIssueInfoClosed={ closeIssue } />) }
|
{ issueInfoWindows && (issueInfoWindows.length > 0) && issueInfoWindows.map(issueId => <ModToolsIssueInfoView key={ issueId } issueId={ issueId } onIssueInfoClosed={ closeIssue } />) }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ChatRecordData, GetUserChatlogMessageComposer, UserChatlogEvent } from '@nitrots/nitro-renderer';
|
import { ChatRecordData, GetUserChatlogMessageComposer, UserChatlogEvent } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useEffect, useState } from 'react';
|
import { FC, useEffect, useState } from 'react';
|
||||||
import { SendMessageComposer } from '../../../../api';
|
import { FaSpinner } from 'react-icons/fa';
|
||||||
|
import { LocalizeText, SendMessageComposer } from '../../../../api';
|
||||||
import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
|
import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
|
||||||
import { useMessageEvent } from '../../../../hooks';
|
import { useMessageEvent } from '../../../../hooks';
|
||||||
import { ChatlogView } from '../chatlog/ChatlogView';
|
import { ChatlogView } from '../chatlog/ChatlogView';
|
||||||
@@ -33,11 +34,15 @@ export const ModToolsUserChatlogView: FC<ModToolsUserChatlogViewProps> = props =
|
|||||||
}, [ userId ]);
|
}, [ userId ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NitroCardView className="nitro-mod-tools-chatlog" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
|
<NitroCardView className="nitro-mod-tools-chatlog min-w-[460px] max-w-[520px] max-h-[500px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
|
||||||
<NitroCardHeaderView headerText={ `User Chatlog: ${ username || '' }` } onCloseClick={ onCloseClick } />
|
<NitroCardHeaderView headerText={ username ? LocalizeText('modtools.user.chatlog.title.with', [ 'username' ], [ username ]) : LocalizeText('modtools.user.chatlog.title') } onCloseClick={ onCloseClick } />
|
||||||
<NitroCardContentView className="text-black h-full">
|
<NitroCardContentView className="text-black h-full" gap={ 1 }>
|
||||||
{ userChatlog &&
|
{ userChatlog
|
||||||
<ChatlogView records={ userChatlog } /> }
|
? <ChatlogView records={ userChatlog } />
|
||||||
|
: <div className="flex flex-col items-center justify-center gap-2 py-8 opacity-50 text-sm">
|
||||||
|
<FaSpinner className="animate-spin" size={ 22 } />
|
||||||
|
<span>{ LocalizeText('modtools.user.chatlog.loading') }</span>
|
||||||
|
</div> }
|
||||||
</NitroCardContentView>
|
</NitroCardContentView>
|
||||||
</NitroCardView>
|
</NitroCardView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { CallForHelpTopicData, DefaultSanctionMessageComposer, ModAlertMessageComposer, ModBanMessageComposer, ModKickMessageComposer, ModMessageMessageComposer, ModMuteMessageComposer, ModTradingLockMessageComposer } from '@nitrots/nitro-renderer';
|
import { CallForHelpTopicData, DefaultSanctionMessageComposer, ModAlertMessageComposer, ModBanMessageComposer, ModKickMessageComposer, ModMessageMessageComposer, ModMuteMessageComposer, ModTradingLockMessageComposer } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useMemo, useRef, useState } from 'react';
|
import { FC, useMemo, useRef, useState } from 'react';
|
||||||
|
import { FaBan, FaBolt, FaEnvelope, FaExclamationTriangle, FaGavel, FaUserSlash, FaVolumeMute } from 'react-icons/fa';
|
||||||
import { ISelectedUser, LocalizeText, ModActionDefinition, NotificationAlertType, SendMessageComposer } from '../../../../api';
|
import { ISelectedUser, LocalizeText, ModActionDefinition, NotificationAlertType, SendMessageComposer } from '../../../../api';
|
||||||
import { Button, DraggableWindowPosition, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
|
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
|
||||||
import { useModTools, useNotification } from '../../../../hooks';
|
import { useModTools, useNotification } from '../../../../hooks';
|
||||||
|
|
||||||
interface ModToolsUserModActionViewProps
|
interface ModToolsUserModActionViewProps
|
||||||
@@ -25,6 +26,24 @@ const MOD_ACTION_DEFINITIONS = [
|
|||||||
new ModActionDefinition(105, 'Message', ModActionDefinition.MESSAGE, 0, 0),
|
new ModActionDefinition(105, 'Message', ModActionDefinition.MESSAGE, 0, 0),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ACTION_ICONS: Record<number, React.ReactNode> = {
|
||||||
|
[ModActionDefinition.ALERT]: <FaExclamationTriangle size={ 10 } />,
|
||||||
|
[ModActionDefinition.MUTE]: <FaVolumeMute size={ 10 } />,
|
||||||
|
[ModActionDefinition.BAN]: <FaBan size={ 10 } />,
|
||||||
|
[ModActionDefinition.KICK]: <FaUserSlash size={ 10 } />,
|
||||||
|
[ModActionDefinition.TRADE_LOCK]: <FaGavel size={ 10 } />,
|
||||||
|
[ModActionDefinition.MESSAGE]: <FaEnvelope size={ 10 } />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_TONE: Record<number, string> = {
|
||||||
|
[ModActionDefinition.ALERT]: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||||
|
[ModActionDefinition.MUTE]: 'bg-sky-100 text-sky-800 border-sky-200',
|
||||||
|
[ModActionDefinition.BAN]: 'bg-rose-100 text-rose-800 border-rose-200',
|
||||||
|
[ModActionDefinition.KICK]: 'bg-orange-100 text-orange-800 border-orange-200',
|
||||||
|
[ModActionDefinition.TRADE_LOCK]: 'bg-fuchsia-100 text-fuchsia-800 border-fuchsia-200',
|
||||||
|
[ModActionDefinition.MESSAGE]: 'bg-zinc-100 text-zinc-800 border-zinc-200',
|
||||||
|
};
|
||||||
|
|
||||||
export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = props =>
|
export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = props =>
|
||||||
{
|
{
|
||||||
const { user = null, onCloseClick = null } = props;
|
const { user = null, onCloseClick = null } = props;
|
||||||
@@ -50,26 +69,20 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
|
|||||||
return values;
|
return values;
|
||||||
}, [ cfhCategories ]);
|
}, [ cfhCategories ]);
|
||||||
|
|
||||||
const sendAlert = (message: string) => simpleAlert(message, NotificationAlertType.DEFAULT, null, null, 'Error');
|
const sendAlert = (m: string) => simpleAlert(m, NotificationAlertType.DEFAULT, null, null, 'Error');
|
||||||
|
|
||||||
const sendDefaultSanction = () =>
|
const sendDefaultSanction = () =>
|
||||||
{
|
{
|
||||||
if(isSendingRef.current) return;
|
if(isSendingRef.current) return;
|
||||||
|
|
||||||
let errorMessage: string = null;
|
|
||||||
|
|
||||||
const category = topics[selectedTopic];
|
const category = topics[selectedTopic];
|
||||||
|
|
||||||
if(selectedTopic === -1) errorMessage = 'You must select a CFH topic';
|
if(selectedTopic === -1) return sendAlert(LocalizeText('modtools.user.modaction.error.no.topic'));
|
||||||
|
|
||||||
if(errorMessage) return sendAlert(errorMessage);
|
|
||||||
|
|
||||||
const messageOrDefault = (message.trim().length === 0) ? LocalizeText(`help.cfh.topic.${ category.id }`) : message;
|
const messageOrDefault = (message.trim().length === 0) ? LocalizeText(`help.cfh.topic.${ category.id }`) : message;
|
||||||
|
|
||||||
isSendingRef.current = true;
|
isSendingRef.current = true;
|
||||||
|
|
||||||
SendMessageComposer(new DefaultSanctionMessageComposer(user.userId, selectedTopic, messageOrDefault));
|
SendMessageComposer(new DefaultSanctionMessageComposer(user.userId, selectedTopic, messageOrDefault));
|
||||||
|
|
||||||
onCloseClick();
|
onCloseClick();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,34 +91,22 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
|
|||||||
if(isSendingRef.current) return;
|
if(isSendingRef.current) return;
|
||||||
|
|
||||||
let errorMessage: string = null;
|
let errorMessage: string = null;
|
||||||
|
|
||||||
const category = topics[selectedTopic];
|
const category = topics[selectedTopic];
|
||||||
const sanction = MOD_ACTION_DEFINITIONS[selectedAction];
|
const sanction = MOD_ACTION_DEFINITIONS[selectedAction];
|
||||||
|
|
||||||
if((selectedTopic === -1) || (selectedAction === -1)) errorMessage = 'You must select a CFH topic and Sanction';
|
if((selectedTopic === -1) || (selectedAction === -1)) errorMessage = LocalizeText('modtools.user.modaction.error.no.action');
|
||||||
else if(!settings || !settings.cfhPermission) errorMessage = 'You do not have permission to do this';
|
else if(!settings || !settings.cfhPermission) errorMessage = LocalizeText('modtools.user.modaction.error.no.permission');
|
||||||
else if(!category) errorMessage = 'You must select a CFH topic';
|
else if(!category) errorMessage = LocalizeText('modtools.user.modaction.error.no.topic');
|
||||||
else if(!sanction) errorMessage = 'You must select a sanction';
|
else if(!sanction) errorMessage = LocalizeText('modtools.user.modaction.error.no.action');
|
||||||
|
|
||||||
if(errorMessage)
|
if(errorMessage) return sendAlert(errorMessage);
|
||||||
{
|
|
||||||
sendAlert(errorMessage);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const messageOrDefault = (message.trim().length === 0) ? LocalizeText(`help.cfh.topic.${ category.id }`) : message;
|
const messageOrDefault = (message.trim().length === 0) ? LocalizeText(`help.cfh.topic.${ category.id }`) : message;
|
||||||
|
|
||||||
switch(sanction.actionType)
|
switch(sanction.actionType)
|
||||||
{
|
{
|
||||||
case ModActionDefinition.ALERT: {
|
case ModActionDefinition.ALERT: {
|
||||||
if(!settings.alertPermission)
|
if(!settings.alertPermission) return sendAlert(LocalizeText('modtools.user.modaction.error.no.permission.alert'));
|
||||||
{
|
|
||||||
sendAlert('You have insufficient permissions');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
SendMessageComposer(new ModAlertMessageComposer(user.userId, messageOrDefault, category.id));
|
SendMessageComposer(new ModAlertMessageComposer(user.userId, messageOrDefault, category.id));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -113,72 +114,108 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
|
|||||||
SendMessageComposer(new ModMuteMessageComposer(user.userId, messageOrDefault, category.id));
|
SendMessageComposer(new ModMuteMessageComposer(user.userId, messageOrDefault, category.id));
|
||||||
break;
|
break;
|
||||||
case ModActionDefinition.BAN: {
|
case ModActionDefinition.BAN: {
|
||||||
if(!settings.banPermission)
|
if(!settings.banPermission) return sendAlert(LocalizeText('modtools.user.modaction.error.no.permission.alert'));
|
||||||
{
|
|
||||||
sendAlert('You have insufficient permissions');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
SendMessageComposer(new ModBanMessageComposer(user.userId, messageOrDefault, category.id, selectedAction, (sanction.actionId === 106)));
|
SendMessageComposer(new ModBanMessageComposer(user.userId, messageOrDefault, category.id, selectedAction, (sanction.actionId === 106)));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case ModActionDefinition.KICK: {
|
case ModActionDefinition.KICK: {
|
||||||
if(!settings.kickPermission)
|
if(!settings.kickPermission) return sendAlert(LocalizeText('modtools.user.modaction.error.no.permission.alert'));
|
||||||
{
|
|
||||||
sendAlert('You have insufficient permissions');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
SendMessageComposer(new ModKickMessageComposer(user.userId, messageOrDefault, category.id));
|
SendMessageComposer(new ModKickMessageComposer(user.userId, messageOrDefault, category.id));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case ModActionDefinition.TRADE_LOCK: {
|
case ModActionDefinition.TRADE_LOCK: {
|
||||||
const numSeconds = (sanction.actionLengthHours * 60);
|
const numSeconds = (sanction.actionLengthHours * 60);
|
||||||
|
|
||||||
SendMessageComposer(new ModTradingLockMessageComposer(user.userId, messageOrDefault, numSeconds, category.id));
|
SendMessageComposer(new ModTradingLockMessageComposer(user.userId, messageOrDefault, numSeconds, category.id));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case ModActionDefinition.MESSAGE: {
|
case ModActionDefinition.MESSAGE: {
|
||||||
if(message.trim().length === 0)
|
if(message.trim().length === 0) return sendAlert(LocalizeText('modtools.user.modaction.error.no.message'));
|
||||||
{
|
|
||||||
sendAlert('Please write a message to user');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
SendMessageComposer(new ModMessageMessageComposer(user.userId, message, category.id));
|
SendMessageComposer(new ModMessageMessageComposer(user.userId, message, category.id));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isSendingRef.current = true;
|
isSendingRef.current = true;
|
||||||
|
|
||||||
onCloseClick();
|
onCloseClick();
|
||||||
};
|
};
|
||||||
|
|
||||||
if(!user) return null;
|
if(!user) return null;
|
||||||
|
|
||||||
|
const selectedSanction = selectedAction >= 0 ? MOD_ACTION_DEFINITIONS[selectedAction] : null;
|
||||||
|
const selectedTopicName = selectedTopic >= 0 && topics[selectedTopic] ? LocalizeText('help.cfh.topic.' + topics[selectedTopic].id) : null;
|
||||||
|
const sanctionTone = selectedSanction ? ACTION_TONE[selectedSanction.actionType] : '';
|
||||||
|
const sanctionIcon = selectedSanction ? ACTION_ICONS[selectedSanction.actionType] : null;
|
||||||
|
const canSubmit = (selectedTopic !== -1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NitroCardView className="nitro-mod-tools-user-action" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
|
<NitroCardView className="nitro-mod-tools-user-action min-w-[420px] max-w-[460px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
|
||||||
<NitroCardHeaderView headerText={ 'Mod Action: ' + (user ? user.username : '') } onCloseClick={ () => onCloseClick() } />
|
<NitroCardHeaderView headerText={ LocalizeText('modtools.user.modaction.title', [ 'username' ], [ user.username ]) } onCloseClick={ () => onCloseClick() } />
|
||||||
<NitroCardContentView className="text-black">
|
<NitroCardContentView className="text-black" gap={ 2 }>
|
||||||
<select className="form-select form-select-sm" value={ selectedTopic } onChange={ event => setSelectedTopic(parseInt(event.target.value)) }>
|
{/* Target header */}
|
||||||
<option disabled value={ -1 }>CFH Topic</option>
|
<div className="flex items-center gap-2 bg-gradient-to-r from-rose-50 to-transparent rounded p-2 border border-rose-100">
|
||||||
{ topics.map((topic, index) => <option key={ index } value={ index }>{ LocalizeText('help.cfh.topic.' + topic.id) }</option>) }
|
<FaGavel className="text-rose-600 shrink-0" size={ 16 } />
|
||||||
</select>
|
<div className="flex flex-col grow min-w-0">
|
||||||
<select className="form-select form-select-sm" value={ selectedAction } onChange={ event => setSelectedAction(parseInt(event.target.value)) }>
|
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">{ LocalizeText('modtools.user.modaction.sanctioning') }</div>
|
||||||
<option disabled value={ -1 }>Sanction Type</option>
|
<div className="font-semibold leading-tight truncate">{ user.username }</div>
|
||||||
{ MOD_ACTION_DEFINITIONS.map((action, index) => <option key={ index } value={ index }>{ action.name }</option>) }
|
</div>
|
||||||
</select>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<Text small>Optional message type, overrides default</Text>
|
{/* CFH topic */}
|
||||||
<textarea className="min-h-[calc(1.5em+ .5rem+2px)] px-[.5rem] py-[.25rem] rounded-[.2rem]" value={ message } onChange={ event => setMessage(event.target.value) } />
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">{ LocalizeText('modtools.user.modaction.step.topic') }</label>
|
||||||
|
<select className="form-select form-select-sm" value={ selectedTopic } onChange={ event => setSelectedTopic(parseInt(event.target.value)) }>
|
||||||
|
<option disabled value={ -1 }>{ LocalizeText('modtools.user.modaction.step.topic.placeholder') }</option>
|
||||||
|
{ topics.map((topic, index) => <option key={ index } value={ index }>{ LocalizeText('help.cfh.topic.' + topic.id) }</option>) }
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sanction type */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">{ LocalizeText('modtools.user.modaction.step.sanction') }</label>
|
||||||
|
<select className="form-select form-select-sm" value={ selectedAction } onChange={ event => setSelectedAction(parseInt(event.target.value)) }>
|
||||||
|
<option disabled value={ -1 }>{ LocalizeText('modtools.user.modaction.step.sanction.placeholder') }</option>
|
||||||
|
{ MOD_ACTION_DEFINITIONS.map((action, index) => <option key={ index } value={ index }>{ action.name }</option>) }
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">
|
||||||
|
{ LocalizeText('modtools.user.modaction.step.message') } <span className="opacity-50 normal-case font-normal">{ LocalizeText('modtools.user.modaction.step.message.optional') }</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="min-h-[60px] px-2 py-1.5 rounded text-sm border border-zinc-300 focus:outline-none focus:ring-2 focus:ring-rose-300"
|
||||||
|
placeholder={ LocalizeText('modtools.user.modaction.message.placeholder') }
|
||||||
|
value={ message }
|
||||||
|
onChange={ event => setMessage(event.target.value) }
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
{ (selectedSanction || selectedTopicName) &&
|
||||||
|
<div className="flex flex-col gap-1 bg-zinc-50 border border-zinc-200 rounded p-2">
|
||||||
|
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">{ LocalizeText('modtools.user.modaction.preview') }</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{ selectedTopicName &&
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border bg-white border-zinc-200">
|
||||||
|
{ selectedTopicName }
|
||||||
|
</span> }
|
||||||
|
{ selectedSanction &&
|
||||||
|
<span className={ `inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${ sanctionTone }` }>
|
||||||
|
{ sanctionIcon } { selectedSanction.name }
|
||||||
|
</span> }
|
||||||
|
</div>
|
||||||
|
</div> }
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex gap-1.5 pt-1 border-t border-zinc-200">
|
||||||
|
<Button className="grow" disabled={ !canSubmit } gap={ 1 } variant="primary" onClick={ sendDefaultSanction }>
|
||||||
|
<FaBolt size={ 12 } /> { LocalizeText('modtools.user.modaction.button.default') }
|
||||||
|
</Button>
|
||||||
|
<Button className="grow" disabled={ !canSubmit || selectedAction === -1 } gap={ 1 } variant="success" onClick={ sendSanction }>
|
||||||
|
<FaGavel size={ 12 } /> { LocalizeText('modtools.user.modaction.button.apply') }
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Flex gap={ 1 } justifyContent="between">
|
|
||||||
<Button variant="primary" onClick={ sendDefaultSanction }>Default Sanction</Button>
|
|
||||||
<Button variant="success" onClick={ sendSanction }>Sanction</Button>
|
|
||||||
</Flex>
|
|
||||||
</NitroCardContentView>
|
</NitroCardContentView>
|
||||||
</NitroCardView>
|
</NitroCardView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { GetRoomVisitsMessageComposer, RoomVisitsData, RoomVisitsEvent } from '@nitrots/nitro-renderer';
|
import { GetRoomVisitsMessageComposer, RoomVisitsData, RoomVisitsEvent } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useEffect, useState } from 'react';
|
import { FC, useEffect, useState } from 'react';
|
||||||
import { SendMessageComposer, TryVisitRoom } from '../../../../api';
|
import { FaClock, FaDoorOpen, FaSignInAlt } from 'react-icons/fa';
|
||||||
import { Column, DraggableWindowPosition, Grid, InfiniteScroll, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
|
import { LocalizeText, SendMessageComposer, TryVisitRoom } from '../../../../api';
|
||||||
|
import { DraggableWindowPosition, InfiniteScroll, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
|
||||||
import { useMessageEvent } from '../../../../hooks';
|
import { useMessageEvent } from '../../../../hooks';
|
||||||
|
|
||||||
interface ModToolsUserRoomVisitsViewProps
|
interface ModToolsUserRoomVisitsViewProps
|
||||||
@@ -31,29 +32,55 @@ export const ModToolsUserRoomVisitsView: FC<ModToolsUserRoomVisitsViewProps> = p
|
|||||||
|
|
||||||
if(!userId) return null;
|
if(!userId) return null;
|
||||||
|
|
||||||
|
const rows = roomVisitData?.rooms ?? [];
|
||||||
|
const isEmpty = rows.length === 0;
|
||||||
|
|
||||||
|
const countLabel = rows.length === 1
|
||||||
|
? LocalizeText('modtools.user.visits.entries.one', [ 'count' ], [ rows.length.toString() ])
|
||||||
|
: LocalizeText('modtools.user.visits.entries.many', [ 'count' ], [ rows.length.toString() ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NitroCardView className="nitro-mod-tools-user-visits" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
|
<NitroCardView className="nitro-mod-tools-user-visits min-w-[400px] max-w-[460px] max-h-[460px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
|
||||||
<NitroCardHeaderView headerText={ 'User Visits' } onCloseClick={ onCloseClick } />
|
<NitroCardHeaderView headerText={ LocalizeText('modtools.user.visits.title') } onCloseClick={ onCloseClick } />
|
||||||
<NitroCardContentView className="text-black" gap={ 1 }>
|
<NitroCardContentView className="text-black" gap={ 1 }>
|
||||||
<Column fullHeight gap={ 0 } overflow="hidden">
|
{/* Header strip */}
|
||||||
<Column gap={ 2 }>
|
<div className="flex items-center gap-2 bg-gradient-to-r from-sky-50 to-transparent rounded p-2 border border-sky-100">
|
||||||
<Grid className="text-black font-bold border-bottom pb-1" gap={ 1 }>
|
<FaDoorOpen className="text-sky-600 shrink-0" size={ 14 } />
|
||||||
<div className="col-span-2">Time</div>
|
<div className="text-sm font-semibold leading-tight grow">{ LocalizeText('modtools.user.visits.recent') }</div>
|
||||||
<div className="col-span-7">Room name</div>
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border bg-white border-zinc-200">
|
||||||
<div className="col-span-3">Visit</div>
|
{ countLabel }
|
||||||
</Grid>
|
</span>
|
||||||
</Column>
|
</div>
|
||||||
<InfiniteScroll rowRender={ row =>
|
|
||||||
{
|
{/* Table head */}
|
||||||
return (
|
<div className="grid grid-cols-[60px_1fr_80px] gap-2 text-[.7rem] uppercase tracking-wide opacity-60 font-semibold border-b border-zinc-200 pb-1 px-1">
|
||||||
<Grid alignItems="center" className="text-black py-1 border-bottom" fullHeight={ false } gap={ 1 }>
|
<div className="flex items-center gap-1"><FaClock size={ 10 } /> { LocalizeText('modtools.user.visits.time') }</div>
|
||||||
<Text className="col-span-2">{ row.enterHour.toString().padStart(2, '0') }: { row.enterMinute.toString().padStart(2, '0') }</Text>
|
<div>{ LocalizeText('modtools.user.visits.room') }</div>
|
||||||
<Text className="col-span-7">{ row.roomName }</Text>
|
<div className="text-right">{ LocalizeText('modtools.user.visits.action') }</div>
|
||||||
<Text bold pointer underline className="col-span-3" variant="primary" onClick={ event => TryVisitRoom(row.roomId) }>Visit Room</Text>
|
</div>
|
||||||
</Grid>
|
|
||||||
);
|
{/* Rows */}
|
||||||
} } rows={ roomVisitData?.rooms ?? [] } />
|
{ isEmpty
|
||||||
</Column>
|
? <div className="flex flex-col items-center justify-center gap-1 py-6 opacity-50 text-sm">
|
||||||
|
<FaDoorOpen size={ 22 } />
|
||||||
|
<span>{ LocalizeText('modtools.user.visits.empty') }</span>
|
||||||
|
</div>
|
||||||
|
: <div className="flex flex-col grow min-h-0 overflow-hidden">
|
||||||
|
<InfiniteScroll rowRender={ row => (
|
||||||
|
<div className="grid grid-cols-[60px_1fr_80px] gap-2 items-center px-1 py-1.5 text-sm border-b border-zinc-100 even:bg-black/[0.02] hover:bg-sky-50 transition-colors">
|
||||||
|
<span className="font-mono text-[.75rem] opacity-70 tabular-nums">
|
||||||
|
{ row.enterHour.toString().padStart(2, '0') }:{ row.enterMinute.toString().padStart(2, '0') }
|
||||||
|
</span>
|
||||||
|
<span className="truncate font-medium">{ row.roomName }</span>
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-end gap-1 text-sky-700 hover:text-sky-900 hover:underline text-xs"
|
||||||
|
onClick={ () => TryVisitRoom(row.roomId) }
|
||||||
|
title={ LocalizeText('modtools.user.visits.visit.title') }>
|
||||||
|
<FaSignInAlt size={ 10 } /> { LocalizeText('modtools.user.visits.visit') }
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) } rows={ rows } />
|
||||||
|
</div> }
|
||||||
</NitroCardContentView>
|
</NitroCardContentView>
|
||||||
</NitroCardView>
|
</NitroCardView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { ModMessageMessageComposer } from '@nitrots/nitro-renderer';
|
import { ModMessageMessageComposer } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useState } from 'react';
|
import { FC, useState } from 'react';
|
||||||
import { ISelectedUser, SendMessageComposer } from '../../../../api';
|
import { FaEnvelope, FaPaperPlane, FaUser } from 'react-icons/fa';
|
||||||
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
|
import { ISelectedUser, LocalizeText, SendMessageComposer } from '../../../../api';
|
||||||
|
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
|
||||||
import { useNotification } from '../../../../hooks';
|
import { useNotification } from '../../../../hooks';
|
||||||
|
|
||||||
interface ModToolsUserSendMessageViewProps
|
interface ModToolsUserSendMessageViewProps
|
||||||
@@ -18,27 +19,55 @@ export const ModToolsUserSendMessageView: FC<ModToolsUserSendMessageViewProps> =
|
|||||||
|
|
||||||
if(!user) return null;
|
if(!user) return null;
|
||||||
|
|
||||||
|
const trimmed = message.trim();
|
||||||
|
const canSend = trimmed.length > 0;
|
||||||
|
|
||||||
const sendMessage = () =>
|
const sendMessage = () =>
|
||||||
{
|
{
|
||||||
if(message.trim().length === 0)
|
if(!canSend)
|
||||||
{
|
{
|
||||||
simpleAlert('Please write a message to user.', null, null, null, 'Error', null);
|
simpleAlert('Please write a message to user.', null, null, null, 'Error', null);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
SendMessageComposer(new ModMessageMessageComposer(user.userId, message, -999));
|
SendMessageComposer(new ModMessageMessageComposer(user.userId, message, -999));
|
||||||
|
|
||||||
onCloseClick();
|
onCloseClick();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NitroCardView className="nitro-mod-tools-user-message" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
|
<NitroCardView className="nitro-mod-tools-user-message min-w-[360px] max-w-[420px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
|
||||||
<NitroCardHeaderView headerText={ 'Send Message' } onCloseClick={ () => onCloseClick() } />
|
<NitroCardHeaderView headerText={ LocalizeText('modtools.user.message.title') } onCloseClick={ () => onCloseClick() } />
|
||||||
<NitroCardContentView className="text-black">
|
<NitroCardContentView className="text-black" gap={ 2 }>
|
||||||
<Text>Message To: { user.username }</Text>
|
{/* Recipient header */}
|
||||||
<textarea className="min-h-[calc(1.5em+ .5rem+2px)] px-[.5rem] py-[.25rem] rounded-[.2rem]" value={ message } onChange={ event => setMessage(event.target.value) }></textarea>
|
<div className="flex items-center gap-2 bg-gradient-to-r from-sky-50 to-transparent rounded p-2 border border-sky-100">
|
||||||
<Button fullWidth onClick={ sendMessage }>Send message</Button>
|
<FaEnvelope className="text-sky-600 shrink-0" size={ 16 } />
|
||||||
|
<div className="flex flex-col grow min-w-0">
|
||||||
|
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">{ LocalizeText('modtools.user.message.recipient') }</div>
|
||||||
|
<div className="flex items-center gap-1.5 font-semibold leading-tight truncate">
|
||||||
|
<FaUser className="opacity-60" size={ 11 } />
|
||||||
|
<span className="truncate">{ user.username }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">{ LocalizeText('modtools.user.message.label') }</label>
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
className="min-h-[100px] px-2 py-1.5 rounded text-sm border border-zinc-300 focus:outline-none focus:ring-2 focus:ring-sky-300"
|
||||||
|
placeholder={ LocalizeText('modtools.user.message.placeholder') }
|
||||||
|
value={ message }
|
||||||
|
onChange={ event => setMessage(event.target.value) }
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs opacity-60">
|
||||||
|
<span>{ canSend ? LocalizeText('modtools.user.message.chars', [ 'count' ], [ trimmed.length.toString() ]) : LocalizeText('modtools.user.message.empty') }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button disabled={ !canSend } fullWidth gap={ 1 } variant="primary" onClick={ sendMessage }>
|
||||||
|
<FaPaperPlane size={ 12 } /> { LocalizeText('modtools.user.message.send') }
|
||||||
|
</Button>
|
||||||
</NitroCardContentView>
|
</NitroCardContentView>
|
||||||
</NitroCardView>
|
</NitroCardView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { CreateLinkEvent, GetModeratorUserInfoMessageComposer, ModeratorUserInfoData, ModeratorUserInfoEvent } from '@nitrots/nitro-renderer';
|
import { CreateLinkEvent, GetModeratorUserInfoMessageComposer, ModeratorActionResultMessageEvent, ModeratorUserInfoData, ModeratorUserInfoEvent } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useEffect, useMemo, useState } from 'react';
|
import { FC, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { FaBan, FaCommentDots, FaDoorOpen, FaEnvelope, FaExchangeAlt, FaExclamationTriangle, FaGavel, FaSync } from 'react-icons/fa';
|
||||||
import { FriendlyTime, LocalizeText, SendMessageComposer } from '../../../../api';
|
import { FriendlyTime, LocalizeText, SendMessageComposer } from '../../../../api';
|
||||||
import { Button, Column, DraggableWindowPosition, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
|
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
|
||||||
import { useMessageEvent } from '../../../../hooks';
|
import { useMessageEvent, useRoomUserListSnapshot } from '../../../../hooks';
|
||||||
import { ModToolsUserModActionView } from './ModToolsUserModActionView';
|
import { ModToolsUserModActionView } from './ModToolsUserModActionView';
|
||||||
import { ModToolsUserRoomVisitsView } from './ModToolsUserRoomVisitsView';
|
import { ModToolsUserRoomVisitsView } from './ModToolsUserRoomVisitsView';
|
||||||
import { ModToolsUserSendMessageView } from './ModToolsUserSendMessageView';
|
import { ModToolsUserSendMessageView } from './ModToolsUserSendMessageView';
|
||||||
@@ -13,6 +14,52 @@ interface ModToolsUserViewProps
|
|||||||
onCloseClick: () => void;
|
onCloseClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StatCardProps
|
||||||
|
{
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
tone?: 'neutral' | 'warn' | 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatCard: FC<StatCardProps> = ({ icon, label, value, tone = 'neutral' }) =>
|
||||||
|
{
|
||||||
|
const numericValue = typeof value === 'number' ? value : parseInt(value as string, 10);
|
||||||
|
const isElevated = !Number.isNaN(numericValue) && numericValue > 0;
|
||||||
|
const toneClasses = (() =>
|
||||||
|
{
|
||||||
|
if(tone === 'danger' && isElevated) return 'bg-rose-50 border-rose-200 text-rose-700';
|
||||||
|
if(tone === 'warn' && isElevated) return 'bg-amber-50 border-amber-200 text-amber-700';
|
||||||
|
return 'bg-zinc-50 border-zinc-200 text-zinc-700';
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={ `flex flex-col items-center justify-center px-2 py-1.5 rounded border ${ toneClasses } grow min-w-0` }>
|
||||||
|
<div className="flex items-center gap-1.5 text-[.7rem] uppercase tracking-wide opacity-70">
|
||||||
|
<span className="shrink-0">{ icon }</span>
|
||||||
|
<span className="truncate">{ label }</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-semibold tabular-nums leading-tight">{ value }</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Section: FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold border-b border-zinc-200 pb-1 mb-0.5">{ title }</div>
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[.8rem] m-0">
|
||||||
|
{ children }
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Field: FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
|
||||||
|
<>
|
||||||
|
<dt className="opacity-60 whitespace-nowrap">{ label }</dt>
|
||||||
|
<dd className="m-0 break-words font-medium">{ (value || value === 0) ? value : <span className="opacity-40">-</span> }</dd>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
export const ModToolsUserView: FC<ModToolsUserViewProps> = props =>
|
export const ModToolsUserView: FC<ModToolsUserViewProps> = props =>
|
||||||
{
|
{
|
||||||
const { onCloseClick = null, userId = null } = props;
|
const { onCloseClick = null, userId = null } = props;
|
||||||
@@ -20,71 +67,33 @@ export const ModToolsUserView: FC<ModToolsUserViewProps> = props =>
|
|||||||
const [ sendMessageVisible, setSendMessageVisible ] = useState(false);
|
const [ sendMessageVisible, setSendMessageVisible ] = useState(false);
|
||||||
const [ modActionVisible, setModActionVisible ] = useState(false);
|
const [ modActionVisible, setModActionVisible ] = useState(false);
|
||||||
const [ roomVisitsVisible, setRoomVisitsVisible ] = useState(false);
|
const [ roomVisitsVisible, setRoomVisitsVisible ] = useState(false);
|
||||||
|
// Reactive presence: if the target user is currently in the room
|
||||||
|
// we're observing, they're online — irrespective of what the
|
||||||
|
// one-shot ModeratorUserInfoData.online said when the panel opened.
|
||||||
|
const roomUserList = useRoomUserListSnapshot();
|
||||||
|
const isPresentInCurrentRoom = useMemo(
|
||||||
|
() => roomUserList.some(user => user && (user.webID === userId)),
|
||||||
|
[ roomUserList, userId ]
|
||||||
|
);
|
||||||
|
const isOnline = isPresentInCurrentRoom || !!(userInfo && userInfo.online);
|
||||||
|
const presenceLabel = isPresentInCurrentRoom
|
||||||
|
? LocalizeText('modtools.userinfo.presence.in_room')
|
||||||
|
: (isOnline ? LocalizeText('modtools.userinfo.presence.online') : LocalizeText('modtools.userinfo.presence.offline'));
|
||||||
|
const presenceTitle = isPresentInCurrentRoom
|
||||||
|
? LocalizeText('modtools.userinfo.presence.in_room.title')
|
||||||
|
: (isOnline ? LocalizeText('modtools.userinfo.presence.online.title') : LocalizeText('modtools.userinfo.presence.offline.title'));
|
||||||
|
const presencePillClass = isPresentInCurrentRoom
|
||||||
|
? 'bg-emerald-100 text-emerald-700 border-emerald-200'
|
||||||
|
: isOnline
|
||||||
|
? 'bg-sky-100 text-sky-700 border-sky-200'
|
||||||
|
: 'bg-zinc-100 text-zinc-600 border-zinc-200';
|
||||||
|
const presenceDotClass = isPresentInCurrentRoom
|
||||||
|
? 'bg-emerald-500'
|
||||||
|
: isOnline
|
||||||
|
? 'bg-sky-500'
|
||||||
|
: 'bg-zinc-400';
|
||||||
|
|
||||||
const userProperties = useMemo(() =>
|
const refresh = () => SendMessageComposer(new GetModeratorUserInfoMessageComposer(userId));
|
||||||
{
|
|
||||||
if(!userInfo) return null;
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
localeKey: 'modtools.userinfo.userName',
|
|
||||||
value: userInfo.userName,
|
|
||||||
showOnline: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
localeKey: 'modtools.userinfo.cfhCount',
|
|
||||||
value: userInfo.cfhCount.toString()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
localeKey: 'modtools.userinfo.abusiveCfhCount',
|
|
||||||
value: userInfo.abusiveCfhCount.toString()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
localeKey: 'modtools.userinfo.cautionCount',
|
|
||||||
value: userInfo.cautionCount.toString()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
localeKey: 'modtools.userinfo.banCount',
|
|
||||||
value: userInfo.banCount.toString()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
localeKey: 'modtools.userinfo.lastSanctionTime',
|
|
||||||
value: userInfo.lastSanctionTime
|
|
||||||
},
|
|
||||||
{
|
|
||||||
localeKey: 'modtools.userinfo.tradingLockCount',
|
|
||||||
value: userInfo.tradingLockCount.toString()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
localeKey: 'modtools.userinfo.tradingExpiryDate',
|
|
||||||
value: userInfo.tradingExpiryDate
|
|
||||||
},
|
|
||||||
{
|
|
||||||
localeKey: 'modtools.userinfo.minutesSinceLastLogin',
|
|
||||||
value: FriendlyTime.format(userInfo.minutesSinceLastLogin * 60, '.ago', 2)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
localeKey: 'modtools.userinfo.lastPurchaseDate',
|
|
||||||
value: userInfo.lastPurchaseDate
|
|
||||||
},
|
|
||||||
{
|
|
||||||
localeKey: 'modtools.userinfo.primaryEmailAddress',
|
|
||||||
value: userInfo.primaryEmailAddress
|
|
||||||
},
|
|
||||||
{
|
|
||||||
localeKey: 'modtools.userinfo.identityRelatedBanCount',
|
|
||||||
value: userInfo.identityRelatedBanCount.toString()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
localeKey: 'modtools.userinfo.registrationAgeInMinutes',
|
|
||||||
value: FriendlyTime.format(userInfo.registrationAgeInMinutes * 60, '.ago', 2)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
localeKey: 'modtools.userinfo.userClassification',
|
|
||||||
value: userInfo.userClassification
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}, [ userInfo ]);
|
|
||||||
|
|
||||||
useMessageEvent<ModeratorUserInfoEvent>(ModeratorUserInfoEvent, event =>
|
useMessageEvent<ModeratorUserInfoEvent>(ModeratorUserInfoEvent, event =>
|
||||||
{
|
{
|
||||||
@@ -95,6 +104,19 @@ export const ModToolsUserView: FC<ModToolsUserViewProps> = props =>
|
|||||||
setUserInfo(parser.data);
|
setUserInfo(parser.data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Refresh counters (cfhCount / banCount / cautionCount /
|
||||||
|
// lastSanctionTime) after the moderator applies a sanction on THIS
|
||||||
|
// user — otherwise the table stays frozen on the values at panel
|
||||||
|
// open. Parser carries userId so we can filter precisely.
|
||||||
|
useMessageEvent<ModeratorActionResultMessageEvent>(ModeratorActionResultMessageEvent, event =>
|
||||||
|
{
|
||||||
|
const parser = event.getParser();
|
||||||
|
|
||||||
|
if(!parser || !parser.success || parser.userId !== userId) return;
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
SendMessageComposer(new GetModeratorUserInfoMessageComposer(userId));
|
SendMessageComposer(new GetModeratorUserInfoMessageComposer(userId));
|
||||||
@@ -104,45 +126,73 @@ export const ModToolsUserView: FC<ModToolsUserViewProps> = props =>
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NitroCardView className="nitro-mod-tools-user" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
|
<NitroCardView className="nitro-mod-tools-user min-w-[420px] max-w-[480px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
|
||||||
<NitroCardHeaderView headerText={ LocalizeText('modtools.userinfo.title', [ 'username' ], [ userInfo.userName ]) } onCloseClick={ () => onCloseClick() } />
|
<NitroCardHeaderView headerText={ LocalizeText('modtools.userinfo.title', [ 'username' ], [ userInfo.userName ]) } onCloseClick={ () => onCloseClick() } />
|
||||||
<NitroCardContentView className="text-black">
|
<NitroCardContentView className="text-black" gap={ 2 }>
|
||||||
<Grid overflow="hidden">
|
{/* Identity header: name + presence pill + manual refresh */}
|
||||||
<Column overflow="auto" size={ 8 }>
|
<div className="flex items-center gap-2 bg-gradient-to-r from-sky-50 to-transparent rounded p-2 border border-sky-100">
|
||||||
<table className="table table-striped table-sm table-text-small text-black m-0">
|
<div className="flex flex-col grow min-w-0">
|
||||||
<tbody>
|
<Text bold className="truncate text-base leading-tight">{ userInfo.userName }</Text>
|
||||||
{ userProperties.map( (property, index) =>
|
<Text className="opacity-60 text-xs truncate">ID #{ userInfo.userId }{ userInfo.userClassification ? ` · ${ userInfo.userClassification }` : '' }</Text>
|
||||||
{
|
</div>
|
||||||
|
<span
|
||||||
|
className={ `inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium border ${ presencePillClass }` }
|
||||||
|
title={ presenceTitle }>
|
||||||
|
<span className={ `inline-block w-2 h-2 rounded-full ${ presenceDotClass }` } />
|
||||||
|
{ presenceLabel }
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center w-7 h-7 rounded text-zinc-500 hover:text-sky-700 hover:bg-sky-100 transition-colors shrink-0"
|
||||||
|
onClick={ refresh }
|
||||||
|
title={ LocalizeText('modtools.userinfo.refresh') }>
|
||||||
|
<FaSync size={ 12 } />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
return (
|
{/* Moderation stat strip */}
|
||||||
<tr key={ index }>
|
<div className="flex gap-1.5">
|
||||||
<th scope="row">{ LocalizeText(property.localeKey) }</th>
|
<StatCard icon={ <FaExclamationTriangle size={ 10 } /> } label={ LocalizeText('modtools.userinfo.stat.cfh') } tone="warn" value={ userInfo.cfhCount } />
|
||||||
<td>
|
<StatCard icon={ <FaGavel size={ 10 } /> } label={ LocalizeText('modtools.userinfo.stat.cautions') } tone="warn" value={ userInfo.cautionCount } />
|
||||||
{ property.value }
|
<StatCard icon={ <FaBan size={ 10 } /> } label={ LocalizeText('modtools.userinfo.stat.bans') } tone="danger" value={ userInfo.banCount } />
|
||||||
{ property.showOnline &&
|
<StatCard icon={ <FaExchangeAlt size={ 10 } /> } label={ LocalizeText('modtools.userinfo.stat.trade.locks') } tone="danger" value={ userInfo.tradingLockCount } />
|
||||||
<i className={ `icon icon-pf-${ userInfo.online ? 'online' : 'offline' } ms-2` } /> }
|
</div>
|
||||||
</td>
|
|
||||||
</tr>
|
{/* Body sections */}
|
||||||
);
|
<div className="flex flex-col gap-2 max-h-[300px] overflow-auto pr-1">
|
||||||
}) }
|
<Section title={ LocalizeText('modtools.userinfo.section.account') }>
|
||||||
</tbody>
|
<Field label={ LocalizeText('modtools.userinfo.primaryEmailAddress') } value={ userInfo.primaryEmailAddress } />
|
||||||
</table>
|
<Field label={ LocalizeText('modtools.userinfo.registrationAgeInMinutes') } value={ FriendlyTime.format(userInfo.registrationAgeInMinutes * 60, '.ago', 2) } />
|
||||||
</Column>
|
<Field label={ LocalizeText('modtools.userinfo.userClassification') } value={ userInfo.userClassification } />
|
||||||
<Column gap={ 1 } size={ 4 }>
|
</Section>
|
||||||
<Button onClick={ event => CreateLinkEvent(`mod-tools/open-user-chatlog/${ userId }`) }>
|
<Section title={ LocalizeText('modtools.userinfo.section.activity') }>
|
||||||
Room Chat
|
<Field label={ LocalizeText('modtools.userinfo.minutesSinceLastLogin') } value={ FriendlyTime.format(userInfo.minutesSinceLastLogin * 60, '.ago', 2) } />
|
||||||
</Button>
|
<Field label={ LocalizeText('modtools.userinfo.lastPurchaseDate') } value={ userInfo.lastPurchaseDate } />
|
||||||
<Button onClick={ event => setSendMessageVisible(!sendMessageVisible) }>
|
</Section>
|
||||||
Send Message
|
<Section title={ LocalizeText('modtools.userinfo.section.sanctions') }>
|
||||||
</Button>
|
<Field label={ LocalizeText('modtools.userinfo.abusiveCfhCount') } value={ userInfo.abusiveCfhCount } />
|
||||||
<Button onClick={ event => setRoomVisitsVisible(!roomVisitsVisible) }>
|
<Field label={ LocalizeText('modtools.userinfo.lastSanctionTime') } value={ userInfo.lastSanctionTime } />
|
||||||
Room Visits
|
<Field label={ LocalizeText('modtools.userinfo.identityRelatedBanCount') } value={ userInfo.identityRelatedBanCount } />
|
||||||
</Button>
|
</Section>
|
||||||
<Button onClick={ event => setModActionVisible(!modActionVisible) }>
|
<Section title={ LocalizeText('modtools.userinfo.section.trading') }>
|
||||||
Mod Action
|
<Field label={ LocalizeText('modtools.userinfo.tradingExpiryDate') } value={ userInfo.tradingExpiryDate } />
|
||||||
</Button>
|
</Section>
|
||||||
</Column>
|
</div>
|
||||||
</Grid>
|
|
||||||
|
{/* Action bar */}
|
||||||
|
<div className="grid grid-cols-2 gap-1.5 pt-1 border-t border-zinc-200">
|
||||||
|
<Button gap={ 1 } variant="secondary" onClick={ () => CreateLinkEvent(`mod-tools/open-user-chatlog/${ userId }`) }>
|
||||||
|
<FaCommentDots size={ 12 } /> { LocalizeText('modtools.userinfo.button.room.chat') }
|
||||||
|
</Button>
|
||||||
|
<Button gap={ 1 } variant="secondary" onClick={ () => setSendMessageVisible(prev => !prev) }>
|
||||||
|
<FaEnvelope size={ 12 } /> { LocalizeText('modtools.userinfo.button.send.message') }
|
||||||
|
</Button>
|
||||||
|
<Button gap={ 1 } variant="secondary" onClick={ () => setRoomVisitsVisible(prev => !prev) }>
|
||||||
|
<FaDoorOpen size={ 12 } /> { LocalizeText('modtools.userinfo.button.room.visits') }
|
||||||
|
</Button>
|
||||||
|
<Button gap={ 1 } variant="danger" onClick={ () => setModActionVisible(prev => !prev) }>
|
||||||
|
<FaGavel size={ 12 } /> { LocalizeText('modtools.userinfo.button.mod.action') }
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</NitroCardContentView>
|
</NitroCardContentView>
|
||||||
</NitroCardView>
|
</NitroCardView>
|
||||||
{ sendMessageVisible &&
|
{ sendMessageVisible &&
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { CreateLinkEvent, Dispose, DropBounce, EaseOut, JumpBy, Motions, NitroToolbarAnimateIconEvent, PerkAllowancesMessageEvent, PerkEnum, Queue, Wait, YouTubeRoomSettingsEvent } from '@nitrots/nitro-renderer';
|
import { CreateLinkEvent, Dispose, DropBounce, EaseOut, JumpBy, Motions, NitroToolbarAnimateIconEvent, PerkAllowancesMessageEvent, PerkEnum, Queue, Wait, YouTubeRoomSettingsEvent } from '@nitrots/nitro-renderer';
|
||||||
import { AnimatePresence, motion, Variants } from 'framer-motion';
|
import { AnimatePresence, motion, Variants } from 'framer-motion';
|
||||||
import { FC, useEffect, useState } from 'react';
|
import { FC, useEffect, useMemo, useState } from 'react';
|
||||||
import { GetConfigurationValue, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, VisitDesktop } from '../../api';
|
import { GetConfigurationValue, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, VisitDesktop } from '../../api';
|
||||||
import { Flex, LayoutAvatarImageView, LayoutItemCountView } from '../../common';
|
import { Flex, LayoutAvatarImageView, LayoutItemCountView } from '../../common';
|
||||||
import { useAchievements, useFriends, useHasPermission, useInventoryUnseenTracker, useMessageEvent, useMessenger, useNitroEvent, useSessionInfo, useWiredTools } from '../../hooks';
|
import { useAchievements, useFriends, useHasPermission, useInventoryUnseenTracker, useMessageEvent, useMessenger, useModTools, useNitroEvent, useSessionInfo, useWiredTools } from '../../hooks';
|
||||||
import { ToolbarItemView } from './ToolbarItemView';
|
import { ToolbarItemView } from './ToolbarItemView';
|
||||||
import { ToolbarMeView } from './ToolbarMeView';
|
import { ToolbarMeView } from './ToolbarMeView';
|
||||||
import { YouTubePlayerView } from './YouTubePlayerView';
|
import { YouTubePlayerView } from './YouTubePlayerView';
|
||||||
@@ -50,6 +50,14 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
|||||||
const { iconState = MessengerIconState.HIDDEN } = useMessenger();
|
const { iconState = MessengerIconState.HIDDEN } = useMessenger();
|
||||||
const { openMonitor, showToolbarButton } = useWiredTools();
|
const { openMonitor, showToolbarButton } = useWiredTools();
|
||||||
const isMod = useHasPermission('acc_supporttool');
|
const isMod = useHasPermission('acc_supporttool');
|
||||||
|
// Surface the open-ticket count on the toolbar ModTools button so a
|
||||||
|
// new CFH pings the mod even when the launcher itself is closed.
|
||||||
|
// useBetween-shared state — no extra subscription cost.
|
||||||
|
const { tickets = [] } = useModTools();
|
||||||
|
const openTicketsCount = useMemo(
|
||||||
|
() => isMod ? tickets.filter(ticket => ticket && (ticket.state === 1)).length : 0,
|
||||||
|
[ isMod, tickets ]
|
||||||
|
);
|
||||||
const isVisible = (isToolbarOpen || !isInRoom);
|
const isVisible = (isToolbarOpen || !isInRoom);
|
||||||
const visibilityVariant = isVisible ? 'visible' : 'hidden';
|
const visibilityVariant = isVisible ? 'visible' : 'hidden';
|
||||||
|
|
||||||
@@ -260,8 +268,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
|||||||
<ToolbarItemView icon="youtube" onClick={ openYouTubePlayer } className="tb-icon" />
|
<ToolbarItemView icon="youtube" onClick={ openYouTubePlayer } className="tb-icon" />
|
||||||
</motion.div> }
|
</motion.div> }
|
||||||
{ isMod &&
|
{ isMod &&
|
||||||
<motion.div variants={ itemVariants }>
|
<motion.div variants={ itemVariants } className="relative">
|
||||||
<ToolbarItemView icon="modtools" onClick={ () => CreateLinkEvent('mod-tools/toggle') } className="tb-icon" />
|
<ToolbarItemView icon="modtools" onClick={ () => CreateLinkEvent('mod-tools/toggle') } className="tb-icon" />
|
||||||
|
{ (openTicketsCount > 0) &&
|
||||||
|
<LayoutItemCountView count={ openTicketsCount } className="pointer-events-none absolute -right-1 -top-1 z-10" /> }
|
||||||
</motion.div> }
|
</motion.div> }
|
||||||
{ isMod &&
|
{ isMod &&
|
||||||
<motion.div variants={ itemVariants }>
|
<motion.div variants={ itemVariants }>
|
||||||
@@ -370,8 +380,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
|||||||
<ToolbarItemView icon="youtube" onClick={ openYouTubePlayer } className="tb-icon" />
|
<ToolbarItemView icon="youtube" onClick={ openYouTubePlayer } className="tb-icon" />
|
||||||
</motion.div> }
|
</motion.div> }
|
||||||
{ isMod &&
|
{ isMod &&
|
||||||
<motion.div variants={ itemVariants }>
|
<motion.div variants={ itemVariants } className="relative">
|
||||||
<ToolbarItemView icon="modtools" onClick={ () => CreateLinkEvent('mod-tools/toggle') } className="tb-icon" />
|
<ToolbarItemView icon="modtools" onClick={ () => CreateLinkEvent('mod-tools/toggle') } className="tb-icon" />
|
||||||
|
{ (openTicketsCount > 0) &&
|
||||||
|
<LayoutItemCountView count={ openTicketsCount } className="pointer-events-none absolute -right-1 -top-1 z-10" /> }
|
||||||
</motion.div> }
|
</motion.div> }
|
||||||
{ isMod &&
|
{ isMod &&
|
||||||
<motion.div variants={ itemVariants }>
|
<motion.div variants={ itemVariants }>
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const { fakeStore } = vi.hoisted(() =>
|
|||||||
selectCatalogOffer: vi.fn(),
|
selectCatalogOffer: vi.fn(),
|
||||||
getNodeById: vi.fn(),
|
getNodeById: vi.fn(),
|
||||||
getNodeByName: vi.fn(),
|
getNodeByName: vi.fn(),
|
||||||
|
getNodesByOfferId: vi.fn(),
|
||||||
getBuilderFurniPlaceableStatus: vi.fn()
|
getBuilderFurniPlaceableStatus: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,6 +143,7 @@ describe('useCatalog filter contract', () =>
|
|||||||
'getBuilderFurniPlaceableStatus',
|
'getBuilderFurniPlaceableStatus',
|
||||||
'getNodeById',
|
'getNodeById',
|
||||||
'getNodeByName',
|
'getNodeByName',
|
||||||
|
'getNodesByOfferId',
|
||||||
'openCatalogByType',
|
'openCatalogByType',
|
||||||
'openPageById',
|
'openPageById',
|
||||||
'openPageByName',
|
'openPageByName',
|
||||||
|
|||||||
+34
-6
@@ -164,18 +164,46 @@ export default defineConfig({
|
|||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
assetFileNames: 'src/assets/[name]-[hash].[ext]',
|
assetFileNames: 'src/assets/[name]-[hash].[ext]',
|
||||||
|
// Granular chunking: split the monolithic vendor / nitro-renderer
|
||||||
|
// bundles into smaller chunks so the browser can fetch them in
|
||||||
|
// parallel and CF can cache each independently. Splits chosen
|
||||||
|
// by size impact (pixi ~600KB, react ~150KB, framer-motion ~100KB,
|
||||||
|
// jodit ~250KB lazy-loaded only by admin news, etc.).
|
||||||
manualChunks: id =>
|
manualChunks: id =>
|
||||||
{
|
{
|
||||||
// Renderer source is consumed via filesystem alias
|
// Vendor checks first — pixi.js/howler are aliased to
|
||||||
// (../Nitro_Render_V3/packages/*/src) so it is NOT
|
// ../Nitro_Render_V3/node_modules so they match
|
||||||
// under node_modules — needs its own branch before
|
// `Nitro_Render_V3` too. Without this priority, they end
|
||||||
// the node_modules check.
|
// up bundled into nitro-renderer instead of getting their
|
||||||
if(id.includes('Nitro_Render_V3') || id.includes(`${ rendererRoot }`)) return 'nitro-renderer';
|
// own chunks (pixi alone is ~600KB). Use `/pixi.js/` to
|
||||||
|
// avoid matching path fragments like `assets/pixi.js/`.
|
||||||
|
const norm = id.replace(/\\/g, '/');
|
||||||
|
if(norm.includes('pixi.js') || norm.includes('pixi-filters')) return 'vendor-pixi';
|
||||||
|
if(norm.includes('howler')) return 'vendor-audio';
|
||||||
|
if(norm.includes('@emoji-mart')) return 'vendor-emoji';
|
||||||
|
if(norm.includes('jodit') || norm.includes('@react-page')) return 'vendor-editor';
|
||||||
|
|
||||||
|
if(id.includes('Nitro_Render_V3') || id.includes(`${ rendererRoot }`))
|
||||||
|
{
|
||||||
|
// Heaviest renderer packages get their own chunks so
|
||||||
|
// pages that don't touch them (login flow, very early
|
||||||
|
// boot) don't have to pay for them upfront.
|
||||||
|
if(id.includes('/packages/avatar/')) return 'nitro-renderer-avatar';
|
||||||
|
if(id.includes('/packages/communication/')) return 'nitro-renderer-comm';
|
||||||
|
if(id.includes('/packages/room/')) return 'nitro-renderer-room';
|
||||||
|
if(id.includes('/packages/assets/')) return 'nitro-renderer-assets';
|
||||||
|
return 'nitro-renderer';
|
||||||
|
}
|
||||||
|
|
||||||
if(id.includes('node_modules'))
|
if(id.includes('node_modules'))
|
||||||
{
|
{
|
||||||
if(id.includes('@nitrots/nitro-renderer') || id.includes('renderer3')) return 'nitro-renderer';
|
if(id.includes('@nitrots/nitro-renderer') || id.includes('renderer3')) return 'nitro-renderer';
|
||||||
|
if(id.match(/\/react(-dom)?\/|\/scheduler\//) || id.includes('react-error-boundary')) return 'vendor-react';
|
||||||
|
if(id.includes('framer-motion')) return 'vendor-motion';
|
||||||
|
if(id.includes('@tanstack')) return 'vendor-query';
|
||||||
|
if(id.includes('zustand') || id.includes('use-between')) return 'vendor-state';
|
||||||
|
if(id.includes('react-icons')) return 'vendor-icons';
|
||||||
|
if(id.includes('json5')) return 'vendor-json5';
|
||||||
return 'vendor';
|
return 'vendor';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user