From cb927db78d8f34d45a90992143f1cf9a8e7ac405 Mon Sep 17 00:00:00 2001
From: openhands
Date: Wed, 2 Sep 2026 12:15:46 +0200
Subject: [PATCH 01/42] Docker: full Dockerized deployment (volumes, host
networking, multi-package-manager build)
- docker-compose.yml: network_mode host so 127.0.0.1 refs (.env) keep working;
mounts /var/www/Gamedata + write volumes; /api/health healthcheck; mem_limit
- Dockerfile: package-manager detection (pnpm/yarn/npm) + PACKAGE_MANAGER arg;
runs as www-data (UID/GID 33) so gamedata is writable; .env loaded only for
the build (no secrets baked in); build runs on the host network
- .dockerignore: .env stays in the build context (needed for NEXT_PUBLIC_*)
- README: Docker deployment section (paths, volumes, chown, migrations on host)
---
.dockerignore | 8 +-
Dockerfile | 89 +++++--
README.md | 630 ++++++++++++++++++---------------------------
docker-compose.yml | 61 ++++-
4 files changed, 377 insertions(+), 411 deletions(-)
diff --git a/.dockerignore b/.dockerignore
index aff52080..e38d7e3c 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -7,8 +7,12 @@ storage
prod.log
update.log
.pm2
-.env
+# NOTE: .env is intentionally NOT ignored here — the build loads it (only inside
+# a build RUN layer) to produce NEXT_PUBLIC_* + validated build-time values.
+# It is not copied into the runtime image (the runner stage copies only
+# .next/standalone, public/, and .next/static).
+# .env
*.tsbuildinfo
-# ~3GB client assets; mounted as a volume at runtime (see docker-compose.yml)
+# Runtime write targets; bound as RW volumes at runtime (see docker-compose.yml)
public/nitro-assets
public/swf
diff --git a/Dockerfile b/Dockerfile
index 32b7ad5d..41aca3f6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,63 +1,104 @@
# ==============================================================================
-# EpicNext-CMS — Docker image (Node 26.8.1, pnpm 11.24.0, Next.js standalone)
+# EpicNext-CMS — Docker image (Node 26.8.1, multi-package-manager, Next.js standalone)
+# ==============================================================================
+# Supports pnpm (default), npm, and yarn. The build stage detects which package
+# manager lockfile is present and uses it automatically.
# ==============================================================================
# --- Builder stage ---
FROM node:26.8.1-bookworm-slim AS builder
-# pnpm is required and pinned in package.json (packageManager: pnpm@11.24.0).
-# Node 26 does not bundle corepack anymore, so install pnpm via npm.
-RUN npm install -g pnpm@11.24.0
+# Install all three package managers so the build can pick whichever lockfile exists.
+RUN npm install -g pnpm@11.25.0 yarn
WORKDIR /app
-# First copy only the manifests so dependency layers are cached.
-COPY pnpm-lock.yaml package.json pnpm-workspace.yaml .npmrc ./
-RUN pnpm install --frozen-lockfile --ignore-scripts
+# First copy only the manifests so dependency layers are cached when using pnpm.
+# For npm/yarn the full context is copied below before install.
+COPY package.json pnpm-workspace.yaml .npmrc ./
-# Copy the rest of the source.
+# Copy the rest of the source (brings in whichever lockfile your project uses).
COPY . .
-# Build the production bundle.
-ENV NODE_ENV=production
-RUN pnpm build
+# --- Detect package manager & install dependencies ---
+# Priority: pnpm > yarn > npm
+# Build arg lets the user force a manager; otherwise it is auto-detected.
+ARG PACKAGE_MANAGER=
-# Copy runtime dependencies (node_modules) needed by standalone output.
-RUN pnpm prune --prod
+RUN if [ "$PACKAGE_MANAGER" = "pnpm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f pnpm-lock.yaml ]; }; then \
+ echo ">> Using pnpm" && \
+ pnpm install --frozen-lockfile --ignore-scripts; \
+ elif [ "$PACKAGE_MANAGER" = "yarn" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f yarn.lock ]; }; then \
+ echo ">> Using yarn" && \
+ yarn install --frozen-lockfile --ignore-scripts; \
+ elif [ "$PACKAGE_MANAGER" = "npm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f package-lock.json ]; }; then \
+ echo ">> Using npm" && \
+ npm ci --ignore-scripts; \
+ else \
+ echo "!! No lockfile found — falling back to npm install" && \
+ npm install --ignore-scripts; \
+ fi
+
+# Build the production bundle.
+# The .env file is loaded ONLY inside this RUN layer (not persisted as ENV, so no
+# secrets end up in the image) — Next.js needs NEXT_PUBLIC_* + validated build-time
+# values (HOTEL_NAME, DATABASE_URL, AUTH_SECRET, ...) at build time.
+ENV NODE_ENV=production
+RUN if [ -f .env ]; then set -a && . ./.env && set +a; fi && \
+ if [ "$PACKAGE_MANAGER" = "yarn" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f yarn.lock ]; }; then \
+ yarn build; \
+ elif [ "$PACKAGE_MANAGER" = "npm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f package-lock.json ]; }; then \
+ npm run build; \
+ else \
+ pnpm build; \
+ fi
+
+# Prune dev dependencies for the runtime image.
+RUN if [ "$PACKAGE_MANAGER" = "yarn" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f yarn.lock ]; }; then \
+ yarn install --production --ignore-scripts && rm -rf node_modules/.cache; \
+ elif [ "$PACKAGE_MANAGER" = "npm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f package-lock.json ]; }; then \
+ npm prune --production; \
+ else \
+ pnpm prune --prod; \
+ fi
# --- Runtime stage ---
FROM node:26.8.1-bookworm-slim AS runner
+# The CMS writes to bind-mounted host directories (/var/www/Gamedata is owned by
+# the host's www-data user, UID/GID 33). The node base image already ships a
+# www-data user with UID/GID 33, which matches that ownership — so we run as
+# www-data and can write to the shared gamedata directory. If your host owner
+# differs, override via --build-arg RUN_USER (e.g. --build-arg RUN_USER=1000).
+ARG RUN_USER=www-data
+
ENV NODE_ENV=production
ENV PORT=3002
ENV HOSTNAME=0.0.0.0
-# Occupied base port on the host; keep PM2-style default.
EXPOSE 3002
-# Non-root user for security.
-RUN groupadd --system --gid 1001 nodejs \
- && useradd --system --uid 1001 --gid nodejs nextjs
-
WORKDIR /app
# Storage directory for runtime uploaded media (persistent volume).
-# nitro/swf client assets (~3GB) are mounted here as volumes at runtime.
+# Also create the hardcoded gamedata mount point (/var/www/Gamedata is
+# bind-mounted at runtime so the CMS can read + write imported assets there).
RUN mkdir -p /app/storage \
/app/public/nitro-assets \
/app/public/swf \
- && chown -R nextjs:nodejs /app
+ /var/www/Gamedata \
+ && chown -R ${RUN_USER} /app /var/www/Gamedata
# Copy standalone Next.js output (includes a minimal node_modules).
-COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=builder --chown=${RUN_USER} /app/.next/standalone ./
# Copy static assets (public files served directly).
-COPY --from=builder --chown=nextjs:nodejs /app/public ./public
+COPY --from=builder --chown=${RUN_USER} /app/public ./public
# Copy the server-side static build output.
-COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+COPY --from=builder --chown=${RUN_USER} /app/.next/static ./.next/static
# Client assets + runtime uploads live outside the image (mounted volumes).
VOLUME ["/app/public/nitro-assets", "/app/public/swf", "/app/storage"]
-USER nextjs
+USER ${RUN_USER}
CMD ["node", "server.js"]
diff --git a/README.md b/README.md
index 45462106..789d71f6 100644
--- a/README.md
+++ b/README.md
@@ -10,12 +10,13 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
| Component | Version | Notes |
| --------------- | -------------- | ---------------------------------------- |
-| Node.js | 26.7.0 | Current release pinned in `.nvmrc` |
-| pnpm | >= 10.33.4 | Package manager (npm/yarn not supported) |
+| Node.js | 26.8.1 | Current release pinned in `.nvmrc` |
+| pnpm | >= 11.25.0 | Recommended package manager |
+| npm | >= 11.x | Supported alternative |
+| yarn | >= 4.x | Supported alternative |
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
-| DragonflyDB | 1.x+ | Optional — caching, rate limiting, SSE (Redis-protocol compatible) |
-| Java | 17+ | Required only if building the emulator |
-| Maven | 3.9+ | Required only if building the emulator |
+| Docker | 24+ | Optional — for containerized deployment |
+| DragonflyDB | 1.x+ | Optional — caching, rate limiting, SSE |
---
@@ -23,12 +24,28 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
### 1. Clone & Install
+Choose your preferred package manager:
+
```bash
git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git
cd EpicNext-Cms
+```
+
+**pnpm (recommended):**
+```bash
pnpm install
```
+**npm:**
+```bash
+npm install
+```
+
+**yarn:**
+```bash
+yarn install
+```
+
### 2. Database Setup
The CMS shares a database with the Polaris/Arcturus emulator. Use an existing database or create a new one:
@@ -39,7 +56,7 @@ CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4
The CMS reads emulator-owned tables (`users`, `items`, `rooms`, `bans`, etc.) directly. It never creates, alters, or drops them. The Drizzle schema in `src/db/schema.ts` is generated from the existing database structure and does not modify it.
-> **Note:** The CMS does **not** own the emulator schema — it maps to those tables via Drizzle. Never run `drizzle-kit push` / `migrate` against the shared DB. CMS-owned tables (`website_*`, `radio_*`, etc.) are created via idempotent SQL in `drizzle/migrations/` (`pnpm db:migrate`).
+> **Note:** The CMS does **not** own the emulator schema — it maps to those tables via Drizzle. Never run `drizzle-kit push` / `migrate` against the shared DB. CMS-owned tables (`website_*`, `radio_*`, etc.) are created via idempotent SQL in `drizzle/migrations/`.
### 3. Configure Environment
@@ -53,14 +70,168 @@ Edit `.env` with at minimum:
DATABASE_URL=mysql://user:password@127.0.0.1:3306/epicnext_cms
AUTH_SECRET=
HOTEL_NAME=YourHotel
-APP_URL=http://localhost:3000
+APP_URL=http://localhost:3002
```
See `.env.example` for all optional variables (RCON, email, DragonflyDB, OAuth, PayPal, etc.).
-### 4. ORM Setup & Type Generation
+### 4. Run CMS Migrations
-#### Drizzle ORM (Primary Data Layer)
+```bash
+# pnpm
+pnpm db:migrate
+
+# npm
+npm run db:migrate
+
+# yarn
+yarn db:migrate
+```
+
+Creates all CMS-owned tables (`website_*`, `radio_*`, `acl_*`, `admin_audit_log`, etc.) via idempotent SQL files in `drizzle/migrations/`. Emulator tables are never touched.
+
+### 5. Build & Start
+
+```bash
+# Development (hot reload)
+pnpm dev # or: npm run dev / yarn dev
+
+# Production
+pnpm build && pnpm start # or: npm run build && npm start
+```
+
+Open `http://localhost:3002` in your browser.
+
+### 6. First Login
+
+1. Register an account at `/register`, or log in with an existing emulator account.
+2. Grant yourself admin access: `UPDATE users SET rank = 7 WHERE username = 'yourname';`
+3. Visit `/admin` and configure your hotel via **Admin → CMS Settings**.
+
+---
+
+## Docker Deployment
+
+The CMS ships with a multi-stage Dockerfile that automatically detects your package manager (pnpm, npm, or yarn) based on which lockfile is present. Only the **CMS** runs in Docker; the Nitro/Octane client and its renderer stay outside and are served by nginx on the host.
+
+### Prerequisites
+
+- Docker 24+ and Docker Compose v2
+- `.env` file configured (see step 3 above)
+- A MySQL/MariaDB database reachable from the container (`DATABASE_URL` host should point to the DB server, not `127.0.0.1` unless it's reachable from inside the container)
+- (Optional) A shared `Gamedata` directory at `/var/www/Gamedata` with write access (see [Volumes](#volumes) below)
+
+### Quick Start with Docker
+
+```bash
+# 1. Clone and configure
+git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git
+cd EpicNext-Cms
+cp .env.example .env
+# Edit .env with your database credentials and settings.
+# The container runs on the host network, so all 127.0.0.1 references
+# (DATABASE_URL, REDIS_URL, RCON, imaging) keep pointing at the host as-is.
+
+# 2. Stop any existing host-side CMS that occupies port 3002 (if present).
+pm2 stop next 2>/dev/null || true
+
+# 3. Ensure the write directories are owned by www-data (UID/GID 33) so the
+# container user can write imports/uploads to them.
+sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage
+
+# 4. Build and start
+docker compose up -d --build
+
+# 5. Run migrations. The slim runtime image has no source/tsx, so run migrations
+# on the HOST (against the same database) before/after starting the container.
+pnpm db:migrate # or: npm run db:migrate / yarn db:migrate
+
+# 6. Check logs
+docker compose logs -f cms
+```
+
+The CMS will be available at `http://localhost:3002`.
+
+> **Reverse proxy:** This setup is designed to run behind the existing nginx on the host. nginx serves the Nitro/Octane client (`/client/`, `/nitro-client/`) and `/gamedata/` directly from `/var/www/Octane/dist` and `/var/www/Gamedata`, and proxies `/` to the CMS on `127.0.0.1:3002`. Point `APP_URL`/`NEXT_PUBLIC_APP_URL` at the public site URL.
+
+### Docker Commands
+
+| Command | Description |
+| ------------------------------------------ | ------------------------------------ |
+| `docker compose up -d --build` | Build and start in background |
+| `docker compose down` | Stop and remove containers |
+| `docker compose logs -f cms` | Follow CMS logs |
+| `docker compose exec cms sh` | Open a shell in the CMS container |
+| `docker compose restart cms` | Restart the CMS container |
+| `docker compose pull && docker compose up -d --build` | Update and redeploy |
+| `pnpm db:migrate` (on the **host**) | Run database migrations (slim image has no source) |
+
+### How Package Manager Detection Works
+
+The Dockerfile checks for lockfiles in this order:
+
+1. **`pnpm-lock.yaml`** → uses pnpm (fastest, recommended)
+2. **`yarn.lock`** → uses yarn
+3. **`package-lock.json`** → uses npm
+4. **No lockfile** → falls back to `npm install`
+
+This means you can use any package manager on your host machine — the Docker build will automatically match.
+
+> Override the detection explicitly with `docker compose build --build-arg PACKAGE_MANAGER=pnpm` (or `npm` / `yarn`).
+
+### Volumes
+
+Only the CMS runs in Docker. The heavy client assets and gamedata stay on the host and are shared into the container so imports and uploads persist:
+
+| Container Path | Host Path | Mode | Purpose |
+| -------------------------- | -------------------------- | ---- | ------------------------------------ |
+| `/app/public/nitro-assets` | `./public/nitro-assets` | rw | Imported furni/pet/effect assets |
+| `/app/public/swf` | `./public/swf` | rw | SWF costumes / icons (imports) |
+| `/app/storage` | `./storage` | rw | Uploaded media (persistent) |
+| `/var/www/Gamedata` | `/var/www/Gamedata` | rw | Shared gamedata root (hardcoded path)|
+
+**About the hardcoded `/var/www/Gamedata` path:** `src/lib/services/furni-asset-dirs.ts` defines `DEFAULT_GAMEDATA_ROOT = /var/www/Gamedata` as an absolute on-disk path the CMS reads and mirrors imported assets into. The compose file mounts the host `/var/www/Gamedata` at the identical path inside the container so `existsSync('/var/www/Gamedata')` succeeds and nginx keeps serving `/gamedata/` from the same directory.
+
+**The Nitro/Octane client and renderer are NOT mounted** — nginx on the host serves them directly:
+
+| Component | Host path | How it's served |
+| -------------------- | ----------------------- | ---------------------------------------------------------------------- |
+| Nitro/Octane client | `/var/www/Octane/dist` | nginx `location ^~ /client/` and `/nitro-client/` alias |
+| Octane-Renderer | `/var/www/Octane-Renderer` | Optional. Run separately (or as the commented-out `imager` service) proxied by nginx `/imaging` |
+| Camera uploads | `/var/www/Camera` | nginx `/camera/` alias |
+
+**Container user & write permissions:** the CMS container runs as `www-data` (UID/GID 33) by default to match the host owner of `/var/www/Gamedata`. Ensure the other write volumes (`./public/nitro-assets`, `./public/swf`, `./storage`) are also owned by `www-data` on the host:
+
+```bash
+sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage
+```
+
+If your host user UID differs, override the run user at build time (defaults to `www-data`, which already exists in the base image with UID/GID 33):
+
+```bash
+docker compose build --build-arg RUN_USER=1000
+```
+
+### Networking
+
+The CMS container runs with `network_mode: host`. This lets the existing `127.0.0.1` references in `.env` keep working against host services — MariaDB (`3306`), DragonflyDB/Redis (`6379`), the emulator RCON/API (`3003`/`3001`) and the avatar imager — without re-writing any environment variables. The container consequently listens **directly on the host's port 3002**, so stop any previous host-side CMS (e.g. `pm2 stop next`) that occupies that port before starting it.
+
+The **build** also runs on the host network (`build.network: host`) because this host disables Docker iptables (`/etc/docker/daemon.json`: `"iptables": false`), which would otherwise leave build containers without outbound NAT/DNS when fetching packages.
+
+### Health Check
+
+The container includes a health check that hits `/api/health` on port 3002 every 30 seconds (and reports DB/Redis/emulator status). Check status with:
+
+```bash
+docker inspect --format='{{.State.Health.Status}}' epicnext-cms
+```
+
+
+---
+
+## ORM Setup & Type Generation
+
+### Drizzle ORM (Primary Data Layer)
Drizzle ORM is the runtime data layer. The connection is a singleton in `src/lib/db.ts`:
@@ -76,57 +247,20 @@ const found = await db.select()
**Drizzle CLI** (`drizzle-kit`) is used for local development tasks — it is a devDependency and is never bundled in production.
-| Command | What it does |
-| ------- | ------------ |
-| `pnpm db:generate` | Draft SQL from Drizzle schema into `drizzle/drafts/` (review + copy into `drizzle/migrations/`) |
-| `pnpm db:studio` | Open Drizzle Studio (dev only) |
-| `pnpm db:introspect` | Reverse-engineer an existing DB into a Drizzle schema draft |
-| `pnpm db:schema:generate` | Regen committed `src/db/schema.ts` from previous schema names + live DB |
+| Command | What it does |
+| ---------------------- | ---------------------------------------------------------------------- |
+| `pnpm db:generate` | Draft SQL from Drizzle schema into `drizzle/drafts/` (review + copy) |
+| `pnpm db:studio` | Open Drizzle Studio (dev only) |
+| `pnpm db:introspect` | Reverse-engineer an existing DB into a Drizzle schema draft |
+| `pnpm db:schema:generate` | Regen committed `src/db/schema.ts` from previous schema + live DB |
> The CMS does **not** use `drizzle-kit push` or `drizzle-kit migrate` — the database is shared with the emulator. Apply CMS DDL only via `pnpm db:migrate`.
-Use `import { db } from "@/lib/db"` with table definitions from `src/db/schema.ts` for all database access. Types come from the committed Drizzle schema — no separate client code generation is required at build time.
-
-### 5. Run CMS Migrations
-
-```bash
-pnpm db:migrate
-```
-
-Creates all CMS-owned tables (`website_*`, `radio_*`, `acl_*`, `admin_audit_log`, etc.) via idempotent SQL files in `drizzle/migrations/`. Emulator tables are never touched.
-
-Check migration status:
-
-```bash
-pnpm db:migrate:status
-```
-
-### 6. Build & Start
-
-```bash
-# Development (hot reload)
-pnpm dev
-
-# Production
-pnpm build && pnpm start
-```
-
-Open `http://localhost:3000` in your browser.
-
-### 7. First Login
-
-1. Register an account at `/register`, or log in with an existing emulator account.
-2. Grant yourself admin access: `UPDATE users SET rank = 7 WHERE username = 'yourname';`
-3. Visit `/admin` and configure your hotel via **Admin → CMS Settings**.
-
---
-## DragonflyDB (caching, rate limiting, SSE)
+## DragonflyDB (Caching, Rate Limiting, SSE)
-DragonflyDB is a drop-in, Redis-compatible in-memory datastore. The CMS connects to it
-via `REDIS_URL` using the `ioredis` client, so no application code changes are needed —
-every Redis command (`PING`, `GET`, `SETEX`, `DEL`, `INCR`, `PEXPIRE`, `PTTL`) works
-unchanged. It is optional: without it the CMS falls back to in-process memory.
+DragonflyDB is a drop-in, Redis-compatible in-memory datastore. The CMS connects to it via `REDIS_URL` using the `ioredis` client. It is optional: without it the CMS falls back to in-process memory.
### Install (Ubuntu/Debian)
@@ -137,10 +271,10 @@ apt-get install -y /tmp/dragonfly_amd64.deb
systemctl enable --now dragonfly
```
-This installs a `dragonfly` systemd service and a config file at `/etc/dragonfly/dragonfly.conf`.
-
### Configure
+Edit `/etc/dragonfly/dragonfly.conf`:
+
```ini
--bind=127.0.0.1
--port=6379
@@ -148,260 +282,91 @@ This installs a `dragonfly` systemd service and a config file at `/etc/dragonfly
--version_check=false
```
-- `--bind=127.0.0.1` keeps it private on the machine (matches `REDIS_URL=redis://127.0.0.1:6379`).
-- `--port=6379` is the default Redis port, so `.env` stays unchanged.
-- `--maxmemory` must be at least `0.25GiB` per CPU thread (e.g. `2gb` on a 6-thread server).
-- `--version_check=false` disables the periodic outbound update check.
-- Snapshots are written to `--dir` (`/var/lib/dragonfly/dump-*.dfs`).
-
### Point the CMS at it
```dotenv
REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2
```
-### Useful commands
+Verify via `/api/health` — it should report `"redis": true`.
-```bash
-redis-cli PING # → PONG
-redis-cli FLUSHALL # clear the cache
-systemctl status dragonfly # service health
-```
-
-Verify everything is wired up via the health endpoint:
-`/api/health` should report `"redis": true`. The ioredis client automatically reconnects
-after a DragonflyDB restart.
-
-> **Note:** if an old Redis install still occupies port 6379, stop it first:
-> `systemctl disable --now redis-server`.
+---
## Nginx Configuration
-The CMS is designed to run behind an nginx reverse proxy. Below is a reference configuration covering SSL termination, WebSocket upgrade, proxy caching, and the Habbo imager integration.
+The CMS is designed to run behind an nginx reverse proxy. Below is a reference configuration.
### Prerequisites
- SSL certificates in `/etc/ssl/cert.pem` and `/etc/ssl/key.pem` (or use Let's Encrypt)
-- Next.js running on `127.0.0.1:3000` (default) or your configured port
-- Habbo imager (optional) running on `127.0.0.1:3030`
+- CMS running on `127.0.0.1:3002`
### Reference Configuration
-Create a file in `/etc/nginx/sites-available/epicnext` and symlink it to `sites-enabled`:
-
```nginx
-# ==========================================
-# GLOBAL SETTINGS
-# ==========================================
-server_tokens off;
-gzip on;
-gzip_vary on;
-gzip_proxied off;
-gzip_comp_level 6;
-gzip_min_length 256;
-gzip_types text/plain text/css text/javascript application/json
- application/javascript application/xml application/xml+rss
- image/svg+xml font/opentype font/ttf font/woff font/woff2;
+proxy_cache_path /var/cache/nginx/html_cache levels=1:2 keys_zone=html_cache:50m max_size=500m inactive=10m use_temp_path=off;
-# ==========================================
-# REDIRECT HTTP → HTTPS
-# ==========================================
server {
listen 80;
- listen [::]:80;
- server_name yourdomain.com www.yourdomain.com;
-
- location /.well-known/acme-challenge/ {
- root /var/www/epicnext/public;
- }
-
- location / {
- return 301 https://$host$request_uri;
- }
+ server_name yourdomain.com;
+ return 301 https://$host$request_uri;
}
-# ==========================================
-# MAIN HTTPS SERVER
-# ==========================================
server {
listen 443 ssl;
- listen [::]:443 ssl;
http2 on;
- server_name yourdomain.com www.yourdomain.com;
+ server_name yourdomain.com;
- root /var/www/epicnext/public;
- index index.html;
-
- # SSL Certificates
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
- ssl_prefer_server_ciphers off;
- ssl_session_cache shared:SSL:10m;
- ssl_session_timeout 1d;
- ssl_session_tickets off;
- # Security Headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
- add_header Referrer-Policy "strict-origin-when-cross-origin" always;
- add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
client_max_body_size 20m;
- client_body_timeout 30s;
- client_header_timeout 10s;
- keepalive_timeout 15s;
- send_timeout 10s;
- # Shared Proxy Settings
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
- proxy_buffers 16 16k;
- proxy_buffer_size 32k;
- # ------------------------------------------
- # Static Files
- # ------------------------------------------
- location ^~ /nitro-client/ {
- alias /var/www/Nitro-V3/dist/;
- expires 7d;
- add_header Cache-Control "public";
- access_log off;
- }
-
- location = /favicon.ico { expires 1y; access_log off; log_not_found off; try_files $uri =404; }
- location = /robots.txt { expires 1d; access_log off; log_not_found off; try_files $uri =404; }
-
- # ------------------------------------------
- # Next.js Assets (immutable, long cache)
- # ------------------------------------------
- location /_next/static/ {
- proxy_pass http://127.0.0.1:3000;
- add_header Cache-Control "public, max-age=31536000, immutable";
- }
-
- location /_next/data/ {
- proxy_pass http://127.0.0.1:3000;
- add_header Cache-Control "public, max-age=0, must-revalidate";
- }
-
- # ------------------------------------------
- # API Routes (never cached)
- # ------------------------------------------
- location /api/ {
- proxy_pass http://127.0.0.1:3000;
- add_header Cache-Control "no-cache, no-store, must-revalidate";
- }
-
- # ------------------------------------------
- # Habbo Imager (optional)
- # ------------------------------------------
- # Proxies to a Docker container that renders Habbo avatars.
- # The imager caches renders to disk, so a long s-maxage is safe.
- location /imaging {
- proxy_pass http://127.0.0.1:3030;
- add_header Cache-Control "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400" always;
- }
-
- # ------------------------------------------
- # WebSocket (Radio / SSE)
- # ------------------------------------------
- location /ws {
- proxy_pass http://127.0.0.1:3030;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection "upgrade";
- proxy_read_timeout 86400;
- }
-
- # ------------------------------------------
- # Main Page Proxy (with HTML caching)
- # ------------------------------------------
- # The CMS middleware sets:
- # Cache-Control: public, s-maxage=300, stale-while-revalidate=300 (anonymous)
- # Cache-Control: private, no-store (authenticated)
- #
- # nginx caches anonymous responses and serves them directly, bypassing
- # the Node.js process entirely. Authenticated responses are never cached.
- #
- # proxy_cache_valid: cache 200 responses for 60 seconds
- # proxy_ignore_headers Vary: Next.js emits many Vary headers (rsc,
- # next-router-*, Accept-Encoding) that would fragment the cache key.
location / {
- proxy_pass http://127.0.0.1:3000;
+ proxy_pass http://127.0.0.1:3002;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
- proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
- proxy_http_version 1.1;
- proxy_buffering on;
-
proxy_cache html_cache;
proxy_cache_valid 200 60s;
- proxy_cache_key "$host$request_uri";
proxy_ignore_headers Vary;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
- proxy_cache_background_update on;
- proxy_cache_revalidate on;
add_header X-Cache-Status $upstream_cache_status always;
}
- # ------------------------------------------
- # Health Check
- # ------------------------------------------
+ location /api/ {
+ proxy_pass http://127.0.0.1:3002;
+ add_header Cache-Control "no-cache, no-store, must-revalidate";
+ }
+
+ location /_next/static/ {
+ proxy_pass http://127.0.0.1:3002;
+ add_header Cache-Control "public, max-age=31536000, immutable";
+ }
+
+ location /imaging {
+ proxy_pass http://127.0.0.1:3030;
+ add_header Cache-Control "public, max-age=3600, s-maxage=86400";
+ }
+
location /health {
access_log off;
return 200 "OK";
add_header Content-Type text/plain;
}
-
- # Block hidden files
- location ~ /(\.|vendor|storage/logs/|\.(sql|sqlite|sqlite3)$) {
- deny all;
- access_log off;
- log_not_found off;
- }
}
```
-### HTML Caching
-
-The CMS uses an **origin-level proxy cache** for anonymous HTML pages. This means:
-
-- **Anonymous visitors** receive cached HTML directly from nginx (~1ms), skipping the Node.js process entirely.
-- **Authenticated visitors** always hit Node.js (personalized content).
-- The cache is **auto-invalidated** after 60 seconds and revalidates in the background.
-
-The proxy cache zone is defined in the `http` block (above any `server` block):
-
-```nginx
-proxy_cache_path /var/cache/nginx/html_cache levels=1:2 keys_zone=html_cache:50m max_size=500m inactive=10m use_temp_path=off;
-```
-
-Verify caching works by checking the `X-Cache-Status` response header:
-
-```bash
-# First request (MISS = fetched from Node.js, now cached)
-curl -sI https://yourdomain.com/ | grep X-Cache-Status
-# → X-Cache-Status: MISS
-
-# Second request (HIT = served from nginx cache)
-curl -sI https://yourdomain.com/ | grep X-Cache-Status
-# → X-Cache-Status: HIT
-```
-
-### Key Points
-
-| Setting | Value | Why |
-| ------- | ----- | --- |
-| `proxy_http_version 1.1` | HTTP/1.1 to upstream | Required for keep-alive and chunked transfer |
-| `proxy_buffering on` | Buffer upstream response | Required for proxy_cache to work with chunked responses |
-| `proxy_ignore_headers Vary` | Ignore upstream Vary | Next.js emits dynamic Vary headers (rsc, next-router-*) that would fragment the cache |
-| `proxy_cache_valid 200 60s` | Cache 200s for 60s | Balances freshness with performance |
-| `proxy_cache_use_stale` | Serve stale on error | Keeps the site available during brief upstream outages |
-
---
## Production Deployment (PM2)
@@ -421,30 +386,28 @@ pnpm build
pm2 restart next
```
-The CMS runs behind an nginx reverse proxy on the default port 3000. Static assets (media uploads) are persisted via `/api/media/*` and survive rebuilds.
-
---
## Scripts
-| Command | Description |
-| ---------------------- | -------------------------------------------------- |
-| `pnpm dev` | Start development server (hot reload) |
-| `pnpm build` | Production build |
-| `pnpm start` | Start production server |
-| `pnpm typecheck` | Run TypeScript type checking |
-| `pnpm test` | Run all tests (Vitest) |
-| `pnpm db:migrate` | Apply pending SQL migrations |
-| `pnpm db:migrate:status` | Show migration status |
+| Command | Description |
+| ------------------------- | -------------------------------------------------- |
+| `pnpm dev` | Start development server (hot reload) |
+| `pnpm build` | Production build |
+| `pnpm start` | Start production server |
+| `pnpm typecheck` | Run TypeScript type checking |
+| `pnpm test` | Run all tests (Vitest) |
+| `pnpm db:migrate` | Apply pending SQL migrations |
+| `pnpm db:migrate:status` | Show migration status |
| `pnpm db:schema:generate` | Regen `src/db/schema.ts` from prior schema + live DB |
-| `pnpm db:generate` | Draft SQL via drizzle-kit → `drizzle/drafts/` |
-| `pnpm db:studio` | Drizzle Studio (dev) |
-| `pnpm db:introspect` | drizzle-kit introspect (draft) |
-| `pnpm analyze` | Build + open bundle analyzer |
-| `pnpm jobs:worker` | Start background task worker (systemd / PM2) |
-| `pnpm biome:check` | Lint and format code |
+| `pnpm db:generate` | Draft SQL via drizzle-kit → `drizzle/drafts/` |
+| `pnpm db:studio` | Drizzle Studio (dev) |
+| `pnpm db:introspect` | drizzle-kit introspect (draft) |
+| `pnpm analyze` | Build + open bundle analyzer |
+| `pnpm jobs:worker` | Start background task worker |
+| `pnpm biome:check` | Lint and format code |
-**Drizzle Kit notes:** `db:generate` / `db:introspect` write drafts only. Reviewed SQL must be copied into `drizzle/migrations/` as a new numbered file, then applied with `pnpm db:migrate`. Never run `drizzle-kit push` or `drizzle-kit migrate` against production.
+> Replace `pnpm` with `npm run` or `yarn` for other package managers.
---
@@ -454,16 +417,13 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
| ------------------------------ | -------------------------------------------------------------- |
| **React Compiler** | Automatic memoization — reduces unnecessary re-renders |
| **View Transitions API** | Native browser transitions between page navigations |
-| **Lenis Smooth Scroll** | Fluid, customizable scrolling (respects `prefers-reduced-motion`) |
-| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE (no polling) |
-| **DragonflyDB Caching** | Caches API responses (home, radio config) up to 30s in DragonflyDB |
-| **Bundle Analyzer** | Run `pnpm analyze` to visualize and optimize bundle sizes |
+| **Lenis Smooth Scroll** | Fluid, customizable scrolling |
+| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE |
+| **DragonflyDB Caching** | Caches API responses up to 30s in DragonflyDB |
| **RCON (TCP Socket)** | Live commands to the emulator (credits, badges, kick, ban) |
| **Streaming & Suspense** | Next.js App Router streaming for fast page loads |
| **Automatic Image Optimization** | `next/image` with Sharp for resizing and WebP/AVIF |
-| **CSS-based Animations** | `input-glow`, `btn-shine`, `Reveal` scroll animations, floating orbs |
| **Compression** | Gzip compression enabled on all responses |
-| **Stale Times** | Optimized router cache (30s dynamic, 180s static) |
| **PWA** | Service worker with skip-waiting, stale CSS chunk recovery |
| **Biome** | Fast linting and formatting (replaces ESLint + Prettier) |
@@ -478,7 +438,6 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
├── scripts/
│ ├── apply-migrations.ts # SQL migration runner (apply + status)
│ ├── jobs-worker.ts # Background task scheduler
-│ ├── merge-config.cjs # Utility: merge split config files
│ └── generate-drizzle-schema.mjs # Regen src/db/schema.ts from schema + live DB
├── src/
│ ├── db/
@@ -491,38 +450,20 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
│ │ ├── db.ts # Drizzle connection singleton (runtime)
-│ │ ├── cached-db.ts # DragonflyDB-backed query cache helpers
│ │ ├── redis.ts # Cache client (ioredis → DragonflyDB)
-│ │ ├── redis-cache.ts # Caching utility for API routes (uses DragonflyDB)
-│ │ ├── cache.ts # In-memory cache fallback
-│ │ ├── motion.ts # Framer Motion animation variants
-│ │ └── use-event-source.ts # React hook for SSE subscriptions
+│ │ └── motion.ts # Framer Motion animation variants
│ ├── messages/ # i18n translations (en, nl, de, fr, es, it, pt, da, no, sv)
│ └── env.ts # Zod-validated environment schema
├── public/
│ ├── assets/ # Images, icons, fonts
-│ └── scripts/ # Client-side scripts (theme-init.js)
-├── update-Nitrov3.sh # Emulator & Nitro updater utility
+│ ├── nitro-assets/ # Nitro client assets (~3GB, volume-mounted in Docker)
+│ └── swf/ # SWF client files (volume-mounted in Docker)
+├── docker-compose.yml # Docker Compose configuration
+├── Dockerfile # Multi-stage, multi-package-manager Docker build
├── next.config.ts # Next.js configuration
└── .env.example # Environment template with all options
```
-### Database Ownership
-
-| Component | Type | Migrations |
-| ------------------------------------------------- | ----------------------- | ----------------------------------- |
-| Emulator tables (`users`, `items`, `rooms`, etc.) | Existing Polaris schema | None — CMS reads/writes only |
-| CMS tables (`website_*`, `radio_*`, etc.) | CMS-owned | `drizzle/migrations/*.sql` |
-| Migration tracking | `cms_migrations` table | Auto-created by migration runner |
-
-### ORM Architecture
-
-- **Runtime (Drizzle ORM)**: `@/lib/db` exposes a Drizzle singleton. Schema lives in `src/db/schema.ts`.
-- **Schema regeneration**: `pnpm db:schema:generate` reuses field/table names from the previous `src/db/schema.ts` and refreshes column types from the live DB.
-- **Drizzle Kit**: studio / generate / introspect for local tooling; CMS apply path remains `pnpm db:migrate`.
-
-Use `import { db } from "@/lib/db"` with queries built via `src/db/schema.ts`.
-
---
## Optional Integrations
@@ -549,97 +490,35 @@ The radio player uses SSE for real-time updates (10s interval, no polling).
Run as a persistent process:
```bash
-pnpm jobs:worker
+pnpm jobs:worker # or: npm run jobs:worker / yarn jobs:worker
```
-Scheduled tasks:
-- **Daily 03:00** — Emulator JAR backup (requires `EMULATOR_JAR_PATH` + `EMULATOR_BACKUP_DIR`)
-- **Daily 04:00** — Cleanup login logs (>30 days) and expired password reset tokens (>7 days)
-
-### CAPTCHA
-
-Supports Cloudflare Turnstile and Google reCAPTCHA. Configure via CMS Settings.
-
-### Theming
-
-12 preset themes with fully customizable colors via **Admin → Theme** (`/admin/theme`).
-
-
-## Furniture Import with Auto-Translation
-
-The CMS now automatically translates furniture names and descriptions to **13 languages** on every import:
-
-- **Native languages** (via official Habbo gamedata): Dutch (`nl`), English (`en`), German (`de`), French (`fr`), Spanish (`es`), Turkish (`tr`), Italian (`it`)
-- **Additional languages** (via LibreTranslate self-hosted Docker at `127.0.0.1:5000`): Portuguese (`pt`), Finnish (`fi`), Polish (`pl`), Russian (`ru`), Arabic (`ar`), Japanese (`ja`)
-
-### How it works
-
-1. **Per-import translation** — Every import route (`/admin/import/furni`, batch, batch-regen, clone) triggers translation automatically after the furniture data is imported.
-
-2. **Translation flow**:
- - The original (usually English) `classname` and `description` are sent to LibreTranslate
- - Official Habbo translations take priority for the 7 supported languages
- - Custom/new meubels fall back to LibreTranslate
- - Post-processing rules force Habbo‑style terminology (e.g. `bank` → `Bank`, `tafel` → `Tafel`, `stoel` → `Stoel`)
-
-3. **No manual steps needed** — The translation happens as part of the import API calls. New custom furniture added via import immediately appears with translated names in the catalog for all 13 languages.
-
-4. **CLI scripts** (optional):
- - `pnpm translate:full` — translate all furniture data fully (uses `--full` flag)
- - `pnpm translate:limited [--limit N] [--concurrency N]` — translate limited amount per language
- - `pnpm build:languages [--full] [--limit N] [--concurrency N]` — build the full localized furnidata JSON files
-
-### Requirements
-
-- LibreTranslate Docker container running at `http://127.0.0.1:5000` (or configure a different URL in the environment)
-- The `LIBRETRANSLATE_URL` environment variable can override the default if needed
-- For the 7 official languages, no external service is needed — they use the built‑in Habbo gamedata
-
-### Environment variables
-
-```dotenv
-# LibreTranslate endpoint (default: http://127.0.0.1:5000)
-LIBRETRANSLATE_URL=http://127.0.0.1:5000
-```
-
-### Example import flow
-
-```bash
-# Single furniture import (triggers translation)
-POST /admin/import/furni
-
-# Batch import (triggers translation)
-POST /admin/import/furni/batch
-
-# Regenerate localized files
-POST /admin/import/furni/batch-regen
-
-# Clone import (triggers translation)
-POST /admin/import/clone/batch
-```
### AI Content Moderation
Optional OpenAI-powered moderation for comments and guestbook posts. Set `OPENAI_API_KEY`.
### FlareSolverr (Cloudflare Bypass)
-Some clone sources (e.g. Leet, Hubbly, Habblet City) are protected by Cloudflare and return challenge pages instead of JSON. FlareSolverr acts as a proxy that solves these challenges automatically.
-
-**Setup:**
+Some clone sources are protected by Cloudflare. FlareSolverr solves these challenges automatically.
```bash
docker compose up -d flaresolverr
```
-Set the URL in `.env`:
-
-```
+```dotenv
FLARESOLVERR_URL=http://localhost:8191
```
-When a source URL returns a 403 or HTML (CF challenge), the clone import automatically falls back to FlareSolverr. If FlareSolverr is not running or is unreachable, the source is skipped with a warning.
+### Furniture Import with Auto-Translation
-See `docker-compose.yml` for the FlareSolverr service definition.
+The CMS automatically translates furniture names and descriptions to **13 languages** on every import:
+
+- **Native languages** (via official Habbo gamedata): Dutch, English, German, French, Spanish, Turkish, Italian
+- **Additional languages** (via LibreTranslate Docker at `127.0.0.1:5000`): Portuguese, Finnish, Polish, Russian, Arabic, Japanese
+
+### Theming
+
+12 preset themes with fully customizable colors via **Admin → Theme** (`/admin/theme`).
---
@@ -656,11 +535,10 @@ pnpm biome:check # Lint and format
### Contributing
1. Ensure typecheck and tests pass: `pnpm typecheck && pnpm test`
-2. Follow existing code conventions (Server Components where possible, minimal client boundaries)
-3. Use the `src/lib/motion.ts` animation variants for consistent animations
-4. SQL migrations in `drizzle/migrations/` must be idempotent
-5. For new database code, use the Drizzle runtime directly (`import { db } from "@/lib/db"`) — see [ORM Setup](#4-orm-setup--type-generation)
-6. Avoid `any` — use `eslint-disable` or `biome-ignore` comments only when unavoidable
+2. Follow existing code conventions (Server Components where possible)
+3. SQL migrations in `drizzle/migrations/` must be idempotent
+4. For new database code, use the Drizzle runtime directly (`import { db } from "@/lib/db"`)
+5. Avoid `any` — use `biome-ignore` comments only when unavoidable
---
diff --git a/docker-compose.yml b/docker-compose.yml
index 80010c5b..fc560f1e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,20 +1,63 @@
-version: "3.8"
-
services:
cms:
build:
context: .
dockerfile: Dockerfile
+ # The host disables Docker iptables (daemon.json: "iptables": false), so
+ # build containers on the bridge network have no outbound NAT/DNS. Build on
+ # the host network instead so pnpm/npm/yarn can reach the registry.
+ network: host
+ args:
+ # Run as the host owner (www-data = UID/GID 33, already present in the
+ # node base image) of /var/www/Gamedata so the container can read + write
+ # the shared gamedata directory.
+ RUN_USER: "www-data"
container_name: epicnext-cms
- ports:
- # Host port -> container port (container always listens on 3002)
- - "3002:3002"
+ # Runs on the host network so existing 127.0.0.1 refs in .env keep working:
+ # MariaDB (3306), DragonflyDB/Redis (6379), emulator RCON (3003) + API (3001),
+ # and the imaging renderer (8082). The container then listens directly on the
+ # host's 3002 (the same port the current host-side CMS uses).
+ # NOTE: stop the host CMS (next-server on 3002) first, otherwise the port is taken.
+ network_mode: host
restart: unless-stopped
env_file:
- .env
volumes:
- # ~3GB client assets live on the host; mount them read-only into the container
- - ./public/nitro-assets:/app/public/nitro-assets:ro
- - ./public/swf:/app/public/swf:ro
- # Runtime uploaded media (persistent on the host)
+ # ── Write targets (runtime imports/uploads, persistent on the host) ──
+ # The CMS writes imported furni/figures/pets/effects here (see
+ # src/lib/services/furni-asset-dirs.ts). These must be RW, and owned by
+ # UID/GID 33 (www-data) on the host so the container user can write to them:
+ # sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage
+ - ./public/nitro-assets:/app/public/nitro-assets
+ - ./public/swf:/app/public/swf
+ # Runtime uploaded media (persistent on the host).
- ./storage:/app/storage
+
+ # ── Shared gamedata (absolute path the CMS hardcodes & writes to) ──
+ # src/lib/services/furni-asset-dirs.ts: `DEFAULT_GAMEDATA_ROOT =
+ # /var/www/Gamedata`. nginx on the host also serves /gamedata/ from this
+ # same directory, so mount it into the container at the same absolute path.
+ # Must be RW so furniture/badge imports can write mirrors to it.
+ - /var/www/Gamedata:/var/www/Gamedata
+
+ healthcheck:
+ test: ["CMD", "node", "-e", "fetch('http://localhost:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 40s
+ mem_limit: 2g
+
+ # ── Opt-in: Octane-Renderer (Habbo avatar imager) ──
+ # Serves /imaging on port 3030 (the CMS proxies /imaging to it). Renders
+ # avatars into /var/www/Gamedata/habbo-imaging, so it needs RW access.
+ # Disabled by default — uncomment to run the renderer as a container.
+ # imager:
+ # build:
+ # context: /var/www/Octane-Renderer
+ # container_name: epicnext-octane-renderer
+ # ports:
+ # - "3030:3030"
+ # restart: unless-stopped
+ # volumes:
+ # - /var/www/Gamedata:/var/www/Gamedata
From 037b295b9304351349d08d29ce83298f0a513123 Mon Sep 17 00:00:00 2001
From: openhands
Date: Wed, 2 Sep 2026 12:19:59 +0200
Subject: [PATCH 02/42] feat(ci): automated docker update script + nightly cron
- scripts/docker-update.sh: git pull --ff-only, host db:migrate, docker compose
build + up -d, health-check wait, keeps host-side PM2 'next' stopped.
Fails safe on dirty working tree (exit 1) and on health failure (exit 3).
- cron entry: daily 03:30 -> logs/docker-update.cron.log
- README: Automatic Updates section + docker commands row
---
README.md | 24 +++++++++++
scripts/docker-update.sh | 89 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 113 insertions(+)
create mode 100755 scripts/docker-update.sh
diff --git a/README.md b/README.md
index 789d71f6..780dcf1d 100644
--- a/README.md
+++ b/README.md
@@ -154,6 +154,29 @@ The CMS will be available at `http://localhost:3002`.
> **Reverse proxy:** This setup is designed to run behind the existing nginx on the host. nginx serves the Nitro/Octane client (`/client/`, `/nitro-client/`) and `/gamedata/` directly from `/var/www/Octane/dist` and `/var/www/Gamedata`, and proxies `/` to the CMS on `127.0.0.1:3002`. Point `APP_URL`/`NEXT_PUBLIC_APP_URL` at the public site URL.
+### Automatic Updates
+
+A cron-safe update script is included: `scripts/docker-update.sh`. It pulls the
+latest `main`, runs CMS migrations on the host, rebuilds the image, recreates the
+container, waits for a healthy status, and makes sure the stale host-side PM2 CMS
+(`pm2 stop next`) stays stopped so it can't clash on port 3002.
+
+```bash
+./scripts/docker-update.sh # run once manually
+# log: logs/docker-update.log
+```
+
+Set it up as a nightly cron job (03:30 example):
+
+```bash
+crontab -e
+# 30 3 * * * /var/www/atom-nexst/scripts/docker-update.sh >> /var/www/atom-nexst/logs/docker-update.cron.log 2>&1
+```
+
+The script aborts safely (exit 1) if the working tree has uncommitted changes so
+a `git pull` can never clobber local edits, and leaves the container running if
+health fails so you can debug it (exit 3).
+
### Docker Commands
| Command | Description |
@@ -165,6 +188,7 @@ The CMS will be available at `http://localhost:3002`.
| `docker compose restart cms` | Restart the CMS container |
| `docker compose pull && docker compose up -d --build` | Update and redeploy |
| `pnpm db:migrate` (on the **host**) | Run database migrations (slim image has no source) |
+| `./scripts/docker-update.sh` | Full automated update (manual or cron) |
### How Package Manager Detection Works
diff --git a/scripts/docker-update.sh b/scripts/docker-update.sh
new file mode 100755
index 00000000..609bcec9
--- /dev/null
+++ b/scripts/docker-update.sh
@@ -0,0 +1,89 @@
+#!/usr/bin/env bash
+# ==============================================================================
+# docker-update.sh — Automatic daily update for the Dockerized EpicNext-CMS.
+#
+# Steps:
+# 1. Verify the working tree is clean (uncommitted changes abort).
+# 2. git pull (fast-forward only).
+# 3. Run CMS migrations on the HOST (the slim runtime container has no source).
+# 4. docker compose build (auto-detects pnpm/yarn/npm via lockfile).
+# 5. docker compose up -d && wait for a healthy container.
+# 6. Record everything in update.log.
+#
+# Exit codes: 0 ok, 1 update skipped, 2 build/deploy failed, 3 health failed.
+# ==============================================================================
+
+set -uo pipefail
+
+DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$DIR" || exit 2
+
+LOG_FILE="${LOG_FILE:-$DIR/logs/docker-update.log}"
+PM2_APP="${PM2_APP:-next}" # host-side CMS that must stay stopped (port 3002)
+
+log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; }
+die() { log "ERROR: $*"; exit "${2:-2}"; }
+
+touch "$LOG_FILE"
+
+log "=== Start docker-update ==="
+
+# --- 0. Guard: uncommitted changes would break git pull / taint deploys ---
+if ! { git diff --quiet --exit-code && git diff --cached --quiet --exit-code; }; then
+ die "working tree has uncommitted changes; commit or stash first" 1
+fi
+
+# --- 1. Pull latest ---
+git pull --ff-only --quiet 2>>"$LOG_FILE"
+pull_status=$?
+if [ $pull_status -ne 0 ]; then
+ die "git pull failed (status $pull_status)" 1
+fi
+log "git pull OK: $(git rev-parse --short HEAD)"
+
+# --- 2. Host-side migrations (idempotent; only applies CMS-owned tables) ---
+if [ -f pnpm-lock.yaml ] && command -v pnpm >/dev/null 2>&1; then
+ pnpm db:migrate >>"$LOG_FILE" 2>&1 || die "db:migrate (pnpm) failed"
+elif command -v npm >/dev/null 2>&1; then
+ npm run db:migrate >>"$LOG_FILE" 2>&1 || die "db:migrate (npm) failed"
+else
+ die "no package manager found for migrations" 2
+fi
+log "db:migrate OK"
+
+# --- 3. Rebuild the image ---
+docker compose build >>"$LOG_FILE" 2>&1 || die "docker compose build failed" 2
+log "docker compose build OK"
+
+# --- 4. Recreate the container ---
+docker compose up -d >>"$LOG_FILE" 2>&1 || die "docker compose up failed" 2
+log "docker compose up OK"
+
+# --- 5. Wait for health (up to ~4 min) ---
+healthy=0
+for i in $(seq 1 16); do
+ status="$(docker inspect --format='{{.State.Health.Status}}' epicnext-cms 2>/dev/null || true)"
+ case "$status" in
+ healthy) healthy=1; break ;;
+ unhealthy) break ;;
+ esac
+ sleep 15
+done
+
+if [ "$healthy" -eq 1 ]; then
+ log "CMS healthy after update (commit $(git rev-parse --short HEAD))"
+else
+ log "WARNING: container not healthy (status='${status:-unknown}')"
+ # Leave the container running so it can be debugged; report failure exit.
+ exit 3
+fi
+
+# --- 6. Make sure the stale host-side PM2 CMS stays stopped ---
+if command -v pm2 >/dev/null 2>&1 && pm2 jlist >/dev/null 2>&1; then
+ if pm2 list 2>/dev/null | grep -q "${PM2_APP}"; then
+ pm2 stop "$PM2_APP" >/dev/null 2>&1 && log "pm2 '${PM2_APP}' kept stopped (avoids port 3002 clash)"
+ fi
+fi
+
+log "=== docker-update finished OK ==="
+exit 0
\ No newline at end of file
From c1cc1fdc1ca9ac1008400f004ab3e652e1371c2c Mon Sep 17 00:00:00 2001
From: openhands
Date: Wed, 2 Sep 2026 12:47:58 +0200
Subject: [PATCH 03/42] fix(home): counter counts exactly once, no restart on
value refresh
AnimatedCounter restarted from 0 on every online-count cache refresh (~10s)
because the effect depended on value. Rewrote it as a single requestAnimationFrame
animation with ease-out; changes to value after the animation only update the
display statically.
---
src/components/animated-counter.tsx | 34 +++++++++++++++++++----------
1 file changed, 22 insertions(+), 12 deletions(-)
diff --git a/src/components/animated-counter.tsx b/src/components/animated-counter.tsx
index 62977c82..e7972585 100644
--- a/src/components/animated-counter.tsx
+++ b/src/components/animated-counter.tsx
@@ -12,25 +12,35 @@ export function AnimatedCounter({
}) {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: "-50px" });
+ const started = useRef(false);
const [display, setDisplay] = useState(0);
useEffect(() => {
if (!isInView) return;
+
+ if (started.current) {
+ setDisplay(value);
+ return;
+ }
+ started.current = true;
+
const duration = 1200;
- const steps = 30;
- const increment = value / steps;
- let current = 0;
- let step = 0;
- const id = setInterval(() => {
- step++;
- current = Math.min(Math.round(increment * step), value);
- setDisplay(current);
- if (step >= steps) {
- clearInterval(id);
+ const start = performance.now();
+ let raf = 0;
+
+ const tick = (now: number) => {
+ const progress = Math.min((now - start) / duration, 1);
+ const eased = 1 - (1 - progress) ** 3;
+ setDisplay(Math.round(eased * value));
+ if (progress < 1) {
+ raf = requestAnimationFrame(tick);
+ } else {
setDisplay(value);
}
- }, duration / steps);
- return () => clearInterval(id);
+ };
+
+ raf = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(raf);
}, [isInView, value]);
return (
From fb978612d7dc079d57552cd7cb2f4ca82ec7433d Mon Sep 17 00:00:00 2001
From: openhands
Date: Wed, 2 Sep 2026 12:53:10 +0200
Subject: [PATCH 04/42] docs(docker): also relax permissions on write volumes
for imported asset dirs
---
README.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README.md b/README.md
index 780dcf1d..2d91ca3e 100644
--- a/README.md
+++ b/README.md
@@ -228,6 +228,7 @@ Only the CMS runs in Docker. The heavy client assets and gamedata stay on the ho
```bash
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage
+sudo chmod -R o+rX ./public/nitro-assets ./public/swf ./storage
```
If your host user UID differs, override the run user at build time (defaults to `www-data`, which already exists in the base image with UID/GID 33):
From e1ecb190bce911d00da05558fbd47939fa5b8383 Mon Sep 17 00:00:00 2001
From: openhands
Date: Wed, 2 Sep 2026 12:58:39 +0200
Subject: [PATCH 05/42] docs(docker): clarify how to update (nightly cron +
manual run) in README
---
README.md | 29 ++++++++++++++++++-----------
1 file changed, 18 insertions(+), 11 deletions(-)
diff --git a/README.md b/README.md
index 2d91ca3e..cbe7de70 100644
--- a/README.md
+++ b/README.md
@@ -156,26 +156,33 @@ The CMS will be available at `http://localhost:3002`.
### Automatic Updates
-A cron-safe update script is included: `scripts/docker-update.sh`. It pulls the
-latest `main`, runs CMS migrations on the host, rebuilds the image, recreates the
-container, waits for a healthy status, and makes sure the stale host-side PM2 CMS
-(`pm2 stop next`) stays stopped so it can't clash on port 3002.
+Updating is fully scripted — no need to touch Docker or nginx by hand. The
+script `scripts/docker-update.sh` pulls the latest `main`, runs CMS migrations
+on the host, rebuilds the image, recreates the container and waits for a healthy
+status. It also makes sure a stale host-side PM2 CMS (`pm2 stop next`) stays
+stopped so it can't clash on port 3002.
-```bash
-./scripts/docker-update.sh # run once manually
-# log: logs/docker-update.log
-```
-
-Set it up as a nightly cron job (03:30 example):
+**Automatically (recommended):** a nightly cron job is already configured on a
+production server that installed this setup. It runs the script every night at
+03:30 and appends to `logs/docker-update.cron.log`:
```bash
crontab -e
# 30 3 * * * /var/www/atom-nexst/scripts/docker-update.sh >> /var/www/atom-nexst/logs/docker-update.cron.log 2>&1
```
+**Manually** — to update right now (same steps as the cron runs):
+
+```bash
+cd /var/www/atom-nexst
+./scripts/docker-update.sh
+# log: logs/docker-update.log
+```
+
The script aborts safely (exit 1) if the working tree has uncommitted changes so
a `git pull` can never clobber local edits, and leaves the container running if
-health fails so you can debug it (exit 3).
+health fails so you can debug it (exit 3). Failed runs are reported in the log;
+an exit of 0 means the CMS is healthy on the new commit.
### Docker Commands
From 67b67798b9dfaddfea6bb9b0e0d57cc882bc156c Mon Sep 17 00:00:00 2001
From: openhands
Date: Wed, 2 Sep 2026 21:14:42 +0200
Subject: [PATCH 06/42] feat(client): polished toolbar, live online count via
SSE
- Redesign in-game client toolbar with refined glass styling and buttons
- Live online count + emulator status streamed over SSE multicast
- Who's-online tooltip throttled to reduce repeated requests
- Fix show button so it always returns the hidden toolbar
- Use theme CSS variables instead of hardcoded colors
- Add toolbar translations across all 25 locales
---
src/app/(site)/page.tsx | 14 +-
src/app/api/online/count/stream/route.ts | 98 ++++++
src/app/client/client-view.tsx | 389 +++++++++++++++++------
src/components/live-online-count.tsx | 33 ++
src/components/live-online-counter.tsx | 32 ++
src/components/site-header.tsx | 5 +-
src/components/top-header.tsx | 5 +-
src/messages/ar.json | 13 +
src/messages/bg.json | 13 +
src/messages/cs.json | 13 +
src/messages/da.json | 13 +
src/messages/de.json | 13 +
src/messages/el.json | 13 +
src/messages/en.json | 13 +
src/messages/es.json | 13 +
src/messages/fi.json | 13 +
src/messages/fr.json | 13 +
src/messages/hr.json | 13 +
src/messages/hu.json | 13 +
src/messages/it.json | 13 +
src/messages/ja.json | 13 +
src/messages/nl.json | 13 +
src/messages/no.json | 13 +
src/messages/pl.json | 13 +
src/messages/pt.json | 13 +
src/messages/ro.json | 13 +
src/messages/ru.json | 13 +
src/messages/sk.json | 13 +
src/messages/sr.json | 13 +
src/messages/sv.json | 13 +
src/messages/tr.json | 13 +
src/messages/uk.json | 13 +
32 files changed, 799 insertions(+), 102 deletions(-)
create mode 100644 src/app/api/online/count/stream/route.ts
create mode 100644 src/components/live-online-count.tsx
create mode 100644 src/components/live-online-counter.tsx
diff --git a/src/app/(site)/page.tsx b/src/app/(site)/page.tsx
index 98e968eb..4a2835d4 100644
--- a/src/app/(site)/page.tsx
+++ b/src/app/(site)/page.tsx
@@ -9,6 +9,8 @@ import { HomeLoginForm } from "@/components/auth/home-login-form";
import { Clock } from "@/components/clock";
import { LanguageSwitcher } from "@/components/language-switcher";
import Link from "@/components/link";
+import { LiveOnlineCount } from "@/components/live-online-count";
+import { LiveOnlineCounter } from "@/components/live-online-counter";
import { Reveal } from "@/components/motion-reveal";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { SurfaceCard } from "@/components/surface-card";
@@ -235,7 +237,7 @@ export default async function Home() {
- {th("online", { count: online, hotel: hotelName })}
+
(
-
+ {s.live ? (
+
+ ) : (
+
+ )}
{
+ try {
+ return await cached("online_count", 10_000, async () => {
+ const [row] = await db
+ .select({ total: count() })
+ .from(User)
+ .where(eq(User.online, "1"));
+ return row?.total ?? 0;
+ });
+ } catch {
+ return 0;
+ }
+}
+
+// Emulator (RCON) reachability, cached and shared across all subscribers so an
+// N-client toolbar doesn't fire N pings every interval. Falls back to graceful
+// degradation on failure so the stream never errors out.
+async function fetchEmulatorStatus(): Promise
{
+ try {
+ return await cached("emulator_rcon_status", 15_000, () =>
+ rcon.send("ping", null).catch(() => false),
+ );
+ } catch {
+ return false;
+ }
+}
+
+export async function GET(req: Request) {
+ const encoder = new TextEncoder();
+
+ const stream = new ReadableStream({
+ async start(controller) {
+ let closed = false;
+
+ const send = async () => {
+ if (closed) return;
+ const [count, emulator] = await Promise.all([
+ fetchOnlineCount(),
+ fetchEmulatorStatus(),
+ ]);
+ try {
+ controller.enqueue(
+ encoder.encode(`data: ${JSON.stringify({ count, emulator })}\n\n`),
+ );
+ } catch {
+ closed = true;
+ }
+ };
+
+ // Initial event immediately, then on an interval.
+ await send();
+ const interval = setInterval(() => void send(), 5_000);
+ // SSE comment as a keep-alive ping between data events.
+ const ping = setInterval(() => {
+ if (!closed) {
+ try {
+ controller.enqueue(encoder.encode(": ping\n\n"));
+ } catch {
+ closed = true;
+ }
+ }
+ }, 25_000);
+
+ const stop = () => {
+ closed = true;
+ clearInterval(interval);
+ clearInterval(ping);
+ try {
+ controller.close();
+ } catch {
+ /* already closed */
+ }
+ };
+ req.signal.addEventListener("abort", stop);
+ },
+ });
+
+ return new Response(stream, {
+ headers: {
+ "content-type": "text/event-stream; charset=utf-8",
+ "cache-control": "no-store, no-transform",
+ connection: "keep-alive",
+ },
+ });
+}
diff --git a/src/app/client/client-view.tsx b/src/app/client/client-view.tsx
index aa14ba05..177283d0 100644
--- a/src/app/client/client-view.tsx
+++ b/src/app/client/client-view.tsx
@@ -1,6 +1,7 @@
"use client";
import { LogOut } from "lucide-react";
+import { useTranslations } from "next-intl";
import {
type ReactNode,
useCallback,
@@ -12,45 +13,78 @@ import {
import { signOutAndRevokeTicket } from "@/actions/sessions";
import Link from "@/components/link";
import { buildClientLoginUrl } from "@/lib/client-url";
+import { useEventSource } from "@/lib/use-event-source";
function ToolbarBtn({
onClick,
- title,
+ label,
children,
href,
}: {
onClick?: () => void;
- title: string;
+ label: string;
children: ReactNode;
href?: string;
}) {
+ // Custom floating tooltip: appears on hover/focus regardless of where the
+ // element is on screen. Kept in one component for a consistent look across
+ // the bar (replaces the browser's slow, untitled `title` bubbles).
+ const [tip, setTip] = useState(false);
const cls =
- "w-7 h-7 sm:w-8 sm:h-8 rounded-lg border-2 cursor-pointer flex items-center justify-center shadow-lg transition-all duration-200 hover:-translate-y-0.5 hover:shadow-xl active:translate-y-0";
+ "group relative w-8 h-8 sm:w-9 sm:h-9 rounded-xl cursor-pointer flex items-center justify-center transition-all duration-200 hover:-translate-y-0.5 hover:brightness-110 active:translate-y-0 active:scale-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary";
const style = {
- borderColor: "color-mix(in srgb, var(--color-primary, #eeb425) 75%, black)",
+ border:
+ "1px solid color-mix(in srgb, var(--color-primary, #eeb425) 85%, white)",
background:
- "linear-gradient(135deg, var(--color-primary, #eeb425) 0%, color-mix(in srgb, var(--color-primary, #eeb425) 80%, black) 100%)",
+ "linear-gradient(180deg, color-mix(in srgb, var(--color-primary, #eeb425) 92%, white), var(--color-primary, #eeb425) 55%, color-mix(in srgb, var(--color-primary, #eeb425) 60%, black))",
color:
"var(--button-text-color-readable, var(--button-text-color, #1a1a2e))",
+ boxShadow:
+ "0 2px 8px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.35), inset 0 -2px 4px rgba(0,0,0,0.12)",
} as const;
- if (href) {
- return (
-
- {children}
-
- );
- }
- return (
+
+ const trigger = href ? (
+ setTip(true)}
+ onMouseLeave={() => setTip(false)}
+ onFocus={() => setTip(true)}
+ onBlur={() => setTip(false)}
+ >
+ {children}
+
+ ) : (
);
+
+ return (
+
+ {trigger}
+ {tip && (
+
+ {label}
+
+ )}
+
+ );
}
export function ClientView({
@@ -64,17 +98,47 @@ export function ClientView({
hotelName: string;
initialOnline: number;
}) {
- const [_isFullscreen, setIsFullscreen] = useState(false);
+ const tToolbar = useTranslations("toolbar");
+ const [isFullscreen, setIsFullscreen] = useState(false);
const [onlineCount, setOnlineCount] = useState(initialOnline);
+ const [emulatorOk, setEmulatorOk] = useState(null);
+ const [onlineUsers, setOnlineUsers] = useState([]);
+ const [showOnlineUsers, setShowOnlineUsers] = useState(false);
+ const [barHidden, setBarHidden] = useState(false);
+ // Real-time online count + emulator status via SSE multicast (replaces the
+ // legacy 15s polling).
+ const { data: onlineStream } = useEventSource<{
+ count: number;
+ emulator: boolean;
+ }>("/api/online/count/stream", { enabled: true });
useEffect(() => {
- const interval = setInterval(async () => {
- try {
- const res = await fetch("/api/online/count");
- if (res.ok) setOnlineCount((await res.json()).count ?? 0);
- } catch {}
- }, 15000);
- return () => clearInterval(interval);
+ if (!onlineStream) return;
+ if (typeof onlineStream.count === "number")
+ setOnlineCount(onlineStream.count);
+ if (typeof onlineStream.emulator === "boolean")
+ setEmulatorOk(onlineStream.emulator);
+ }, [onlineStream]);
+
+ // "Who's online" list, fetched sparingly (server-side cached 10s). Only a
+ // fresh fetch is issued when hovered and the last fetch is stale — hovering
+ // repeatedly must not spam the endpoint.
+ const onlineUsersRef = useRef(0);
+ const refreshOnlineUsers = useCallback(() => {
+ const now = Date.now();
+ if (now - onlineUsersRef.current < 30_000) return;
+ onlineUsersRef.current = now;
+ fetch("/api/online", { cache: "no-store" })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((data) => {
+ const usernames = Array.isArray(data?.users)
+ ? data.users
+ .map((u: { username?: string }) => u.username)
+ .filter((n: unknown): n is string => typeof n === "string")
+ : [];
+ setOnlineUsers(usernames);
+ })
+ .catch(() => setOnlineUsers([]));
}, []);
const toggleFullscreen = useCallback(() => {
@@ -163,16 +227,18 @@ export function ClientView({
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
- if ((e.target as HTMLElement).closest("button, a")) return;
- startDrag(e.clientX, e.clientY);
+ if ((e.target as HTMLElement).closest(".toolbar-grip")) {
+ startDrag(e.clientX, e.clientY);
+ }
},
[startDrag],
);
const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
- if ((e.target as HTMLElement).closest("button, a")) return;
- startDrag(e.touches[0].clientX, e.touches[0].clientY);
+ if ((e.target as HTMLElement).closest(".toolbar-grip")) {
+ startDrag(e.touches[0].clientX, e.touches[0].clientY);
+ }
},
[startDrag],
);
@@ -225,22 +291,24 @@ export function ClientView({
return (
<>
{pos ? (
- // biome-ignore lint/a11y/noStaticElementInteractions: draggable toolbar with explicit mouse/touch handlers
-
-
+
+
-
window.location.reload()} title="Reload">
-
-
-
-
-
-
window.location.reload()}
+ label={tToolbar("reload")}
>
+
+
+ {isFullscreen ? (
+
+ ) : (
+
+ )}
+
+
+
+ {emulatorOk === false ? (
+
+ ) : (
+
+ )}
+
+
+
{
+ setShowOnlineUsers(true);
+ refreshOnlineUsers();
+ }}
+ onMouseLeave={() => setShowOnlineUsers(false)}
+ >
+
+
+
+
+
{onlineCount}
+ {showOnlineUsers && onlineUsers.length > 0 && (
+
+ {onlineUsers.slice(0, 8).join(", ")}
+ {onlineUsers.length > 8
+ ? ` +${onlineUsers.length - 8} more`
+ : ""}
+
+ )}
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: grip handle explicitly initiates a drag (mouse+touch) and is not a button */}
void signOutAndRevokeTicket()}
- title="Logout"
+ label={tToolbar("logout")}
>
+
setBarHidden(true)}
+ label={tToolbar("hide")}
+ >
+
+
) : null}
+ {barHidden && pos ? (
+
+ ) : null}
+
(
+ "/api/online/count/stream",
+ { enabled: true },
+ );
+ useEffect(() => {
+ if (onlineStream && typeof onlineStream.count === "number") {
+ setCount(onlineStream.count);
+ }
+ }, [onlineStream]);
+
+ return <>{t("online", { count, hotel: hotelName })}>;
+}
diff --git a/src/components/live-online-counter.tsx b/src/components/live-online-counter.tsx
new file mode 100644
index 00000000..aecb4fdc
--- /dev/null
+++ b/src/components/live-online-counter.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { AnimatedCounter } from "@/components/animated-counter";
+import { useEventSource } from "@/lib/use-event-source";
+
+/**
+ * Live animated online-count number. Subscribes to the SSE online-count stream
+ * so the stat updates in real-time (multicast) instead of only the initial SSR
+ * value, while keeping the entrance counter animation.
+ */
+export function LiveOnlineCounter({
+ initial,
+ suffix = "",
+}: {
+ initial: number;
+ suffix?: string;
+}) {
+ const [count, setCount] = useState(initial);
+
+ const { data: onlineStream } = useEventSource<{ count: number }>(
+ "/api/online/count/stream",
+ { enabled: true },
+ );
+ useEffect(() => {
+ if (onlineStream && typeof onlineStream.count === "number") {
+ setCount(onlineStream.count);
+ }
+ }, [onlineStream]);
+
+ return
;
+}
diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx
index 81c51095..66fa0a36 100644
--- a/src/components/site-header.tsx
+++ b/src/components/site-header.tsx
@@ -1,14 +1,13 @@
import { count, eq } from "drizzle-orm";
import Image from "next/image";
-import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
+import { LiveOnlineCount } from "@/components/live-online-count";
import { cached } from "@/lib/cache";
import { db, User } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name";
import { siteSettings } from "@/lib/services/site-settings";
export async function SiteHeader() {
- const t = await getTranslations("header");
const [hotelName, header, logo] = await Promise.all([
resolveHotelName(),
siteSettings.get("cms_header", "/assets/images/background.png"),
@@ -140,7 +139,7 @@ export async function SiteHeader() {
className="text-xs sm:text-sm font-bold tracking-wide"
style={{ color: "rgba(255,255,255,0.9)" }}
>
- {t("online", { count: online, hotel: hotelName })}
+
diff --git a/src/components/top-header.tsx b/src/components/top-header.tsx
index ab65692c..f39d4a7c 100644
--- a/src/components/top-header.tsx
+++ b/src/components/top-header.tsx
@@ -1,10 +1,10 @@
import { count, eq, inArray } from "drizzle-orm";
import Image from "next/image";
import type { Session } from "next-auth";
-import { getTranslations } from "next-intl/server";
import { signOutAndRevokeTicket } from "@/actions/sessions";
import { HeaderUserIdentity } from "@/components/header-user-identity";
import Link from "@/components/link";
+import { LiveOnlineCount } from "@/components/live-online-count";
import { RoomQuickEntry } from "@/components/room-quick-entry";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { cached } from "@/lib/cache";
@@ -100,7 +100,6 @@ export async function TopHeader({ session }: { session: Session | null }) {
const textColor =
"var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))";
- const t = await getTranslations("header");
const hotelName = await resolveHotelName();
let online: number;
@@ -173,7 +172,7 @@ export async function TopHeader({ session }: { session: Session | null }) {
className="text-[10px] sm:text-sm font-bold tracking-wide"
style={{ color: "rgba(255,255,255,0.9)" }}
>
- {t("online", { count: online, hotel: hotelName })}
+
diff --git a/src/messages/ar.json b/src/messages/ar.json
index 678cb97a..ae67f357 100644
--- a/src/messages/ar.json
+++ b/src/messages/ar.json
@@ -9,5 +9,18 @@
"studio": "Studio"
}
}
+ },
+ "toolbar": {
+ "home": "الرئيسية",
+ "reload": "إعادة تحميل",
+ "fullscreen": "ملء الشاشة",
+ "logout": "تسجيل الخروج",
+ "onlineUsers": "المستخدمون المتواجدون",
+ "emulatorOnline": "المحاكي متصل",
+ "emulatorOffline": "المحاكي غير متصل",
+ "dragToMove": "اسحب للتحريك",
+ "hide": "إخفاء الشريط",
+ "fullscreenExit": "الخروج من ملء الشاشة",
+ "show": "إظهار الشريط"
}
}
diff --git a/src/messages/bg.json b/src/messages/bg.json
index c11a3727..26358438 100644
--- a/src/messages/bg.json
+++ b/src/messages/bg.json
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
+ },
+ "toolbar": {
+ "home": "Начало",
+ "reload": "Презареждане",
+ "fullscreen": "Цял екран",
+ "logout": "Изход",
+ "onlineUsers": "Потребители онлайн",
+ "emulatorOnline": "Емулаторът е онлайн",
+ "emulatorOffline": "Емулаторът е офлайн",
+ "dragToMove": "Влачете за преместване",
+ "hide": "Скрий лентата",
+ "fullscreenExit": "Изход от цял екран",
+ "show": "Покажи лентата"
}
}
diff --git a/src/messages/cs.json b/src/messages/cs.json
index 005595e8..d018adf5 100644
--- a/src/messages/cs.json
+++ b/src/messages/cs.json
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
+ },
+ "toolbar": {
+ "home": "Domů",
+ "reload": "Obnovit",
+ "fullscreen": "Celá obrazovka",
+ "logout": "Odhlásit",
+ "onlineUsers": "Uživatelé online",
+ "emulatorOnline": "Emulátor online",
+ "emulatorOffline": "Emulátor offline",
+ "dragToMove": "Přetažením přesunete",
+ "hide": "Skrýt lištu",
+ "fullscreenExit": "Ukončit celou obrazovku",
+ "show": "Zobrazit lištu"
}
}
diff --git a/src/messages/da.json b/src/messages/da.json
index adf0ab6d..a2ef0df1 100644
--- a/src/messages/da.json
+++ b/src/messages/da.json
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
+ },
+ "toolbar": {
+ "home": "Hjem",
+ "reload": "Genindlæs",
+ "fullscreen": "Fuldskærm",
+ "logout": "Log ud",
+ "onlineUsers": "Brugere online",
+ "emulatorOnline": "Emulator online",
+ "emulatorOffline": "Emulator offline",
+ "dragToMove": "Træk for at flytte",
+ "hide": "Skjul værktøjslinje",
+ "fullscreenExit": "Afslut fuldskærm",
+ "show": "Vis værktøjslinje"
}
}
diff --git a/src/messages/de.json b/src/messages/de.json
index 38dfeb29..7ebaabb5 100644
--- a/src/messages/de.json
+++ b/src/messages/de.json
@@ -3263,5 +3263,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
+ },
+ "toolbar": {
+ "home": "Startseite",
+ "reload": "Neu laden",
+ "fullscreen": "Vollbild",
+ "logout": "Abmelden",
+ "onlineUsers": "Nutzer online",
+ "emulatorOnline": "Emulator online",
+ "emulatorOffline": "Emulator offline",
+ "dragToMove": "Ziehen zum Verschieben",
+ "hide": "Leiste ausblenden",
+ "fullscreenExit": "Vollbild beenden",
+ "show": "Leiste anzeigen"
}
}
diff --git a/src/messages/el.json b/src/messages/el.json
index 3c74e882..9be7dbc0 100644
--- a/src/messages/el.json
+++ b/src/messages/el.json
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
+ },
+ "toolbar": {
+ "home": "Αρχική",
+ "reload": "Επαναφόρτωση",
+ "fullscreen": "Πλήρης οθόνη",
+ "logout": "Αποσύνδεση",
+ "onlineUsers": "Χρήστες συνδεδεμένοι",
+ "emulatorOnline": "Ο εξομοιωτής είναι online",
+ "emulatorOffline": "Ο εξομοιωτής είναι offline",
+ "dragToMove": "Σύρετε για μετακίνηση",
+ "hide": "Απόκρυψη γραμμής",
+ "fullscreenExit": "Έξοδος από πλήρη οθόνη",
+ "show": "Εμφάνιση γραμμής"
}
}
diff --git a/src/messages/en.json b/src/messages/en.json
index 3840db69..c2fcda00 100644
--- a/src/messages/en.json
+++ b/src/messages/en.json
@@ -3321,5 +3321,18 @@
}
}
}
+ },
+ "toolbar": {
+ "home": "Home",
+ "reload": "Reload",
+ "fullscreen": "Fullscreen",
+ "logout": "Logout",
+ "onlineUsers": "Online users",
+ "emulatorOnline": "Emulator online",
+ "emulatorOffline": "Emulator offline",
+ "dragToMove": "Drag to move",
+ "hide": "Hide toolbar",
+ "fullscreenExit": "Exit fullscreen",
+ "show": "Show toolbar"
}
}
diff --git a/src/messages/es.json b/src/messages/es.json
index 0be19582..d716ed98 100644
--- a/src/messages/es.json
+++ b/src/messages/es.json
@@ -3263,5 +3263,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
+ },
+ "toolbar": {
+ "home": "Inicio",
+ "reload": "Recargar",
+ "fullscreen": "Pantalla completa",
+ "logout": "Cerrar sesión",
+ "onlineUsers": "Usuarios en línea",
+ "emulatorOnline": "Emulador en línea",
+ "emulatorOffline": "Emulador fuera de línea",
+ "dragToMove": "Arrastra para mover",
+ "hide": "Ocultar barra",
+ "fullscreenExit": "Salir de pantalla completa",
+ "show": "Mostrar barra"
}
}
diff --git a/src/messages/fi.json b/src/messages/fi.json
index 678cb97a..ee7d5eeb 100644
--- a/src/messages/fi.json
+++ b/src/messages/fi.json
@@ -9,5 +9,18 @@
"studio": "Studio"
}
}
+ },
+ "toolbar": {
+ "home": "Koti",
+ "reload": "Lataa uudelleen",
+ "fullscreen": "Koko näyttö",
+ "logout": "Kirjaudu ulos",
+ "onlineUsers": "Käyttäjät verkossa",
+ "emulatorOnline": "Emulaattori verkossa",
+ "emulatorOffline": "Emulaattori ei verkossa",
+ "dragToMove": "Vedä liikuttaaksesi",
+ "hide": "Piilota työkalupalkki",
+ "fullscreenExit": "Lopeta koko näyttö",
+ "show": "Näytä työkalupalkki"
}
}
diff --git a/src/messages/fr.json b/src/messages/fr.json
index 8d41a177..5b23e4b0 100644
--- a/src/messages/fr.json
+++ b/src/messages/fr.json
@@ -3263,5 +3263,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
+ },
+ "toolbar": {
+ "home": "Accueil",
+ "reload": "Recharger",
+ "fullscreen": "Plein écran",
+ "logout": "Déconnexion",
+ "onlineUsers": "Utilisateurs en ligne",
+ "emulatorOnline": "Émulateur en ligne",
+ "emulatorOffline": "Émulateur hors ligne",
+ "dragToMove": "Glissez pour déplacer",
+ "hide": "Masquer la barre",
+ "fullscreenExit": "Quitter le plein écran",
+ "show": "Afficher la barre"
}
}
diff --git a/src/messages/hr.json b/src/messages/hr.json
index eb5128cf..6916e818 100644
--- a/src/messages/hr.json
+++ b/src/messages/hr.json
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
+ },
+ "toolbar": {
+ "home": "Početna",
+ "reload": "Ponovno učitaj",
+ "fullscreen": "Cijeli zaslon",
+ "logout": "Odjava",
+ "onlineUsers": "Korisnici online",
+ "emulatorOnline": "Emulator je online",
+ "emulatorOffline": "Emulator je offline",
+ "dragToMove": "Povucite za pomicanje",
+ "hide": "Sakrij traku",
+ "fullscreenExit": "Izađi iz cijelog zaslona",
+ "show": "Prikaži traku"
}
}
diff --git a/src/messages/hu.json b/src/messages/hu.json
index bce6f5c5..c81b6294 100644
--- a/src/messages/hu.json
+++ b/src/messages/hu.json
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
+ },
+ "toolbar": {
+ "home": "Kezdőlap",
+ "reload": "Újratöltés",
+ "fullscreen": "Teljes képernyő",
+ "logout": "Kijelentkezés",
+ "onlineUsers": "Online felhasználók",
+ "emulatorOnline": "Emulátor online",
+ "emulatorOffline": "Emulátor offline",
+ "dragToMove": "Húzás a mozgatáshoz",
+ "hide": "Sáv elrejtése",
+ "fullscreenExit": "Kilépés a teljes képernyőből",
+ "show": "Sáv megjelenítése"
}
}
diff --git a/src/messages/it.json b/src/messages/it.json
index 307cb530..24ab9630 100644
--- a/src/messages/it.json
+++ b/src/messages/it.json
@@ -3320,5 +3320,18 @@
}
}
}
+ },
+ "toolbar": {
+ "home": "Home",
+ "reload": "Ricarica",
+ "fullscreen": "Schermo intero",
+ "logout": "Esci",
+ "onlineUsers": "Utenti online",
+ "emulatorOnline": "Emulatore online",
+ "emulatorOffline": "Emulatore offline",
+ "dragToMove": "Trascina per spostare",
+ "hide": "Nascondi barra",
+ "fullscreenExit": "Esci dalla schermo intero",
+ "show": "Mostra barra"
}
}
diff --git a/src/messages/ja.json b/src/messages/ja.json
index 678cb97a..b9a87a3b 100644
--- a/src/messages/ja.json
+++ b/src/messages/ja.json
@@ -9,5 +9,18 @@
"studio": "Studio"
}
}
+ },
+ "toolbar": {
+ "home": "ホーム",
+ "reload": "再読み込み",
+ "fullscreen": "全画面",
+ "logout": "ログアウト",
+ "onlineUsers": "オンラインのユーザー",
+ "emulatorOnline": "エミュレータオンライン",
+ "emulatorOffline": "エミュレータオフライン",
+ "dragToMove": "ドラッグして移動",
+ "hide": "ツールバーを隠す",
+ "fullscreenExit": "全画面を終了",
+ "show": "ツールバーを表示"
}
}
diff --git a/src/messages/nl.json b/src/messages/nl.json
index 7e00beb5..26d71527 100644
--- a/src/messages/nl.json
+++ b/src/messages/nl.json
@@ -3265,5 +3265,18 @@
}
}
}
+ },
+ "toolbar": {
+ "home": "Home",
+ "reload": "Vernieuwen",
+ "fullscreen": "Volledig scherm",
+ "logout": "Uitloggen",
+ "onlineUsers": "Gebruikers online",
+ "emulatorOnline": "Emulator online",
+ "emulatorOffline": "Emulator offline",
+ "dragToMove": "Sleep om te verplaatsen",
+ "hide": "Werkbalk verbergen",
+ "fullscreenExit": "Volledig scherm afsluiten",
+ "show": "Werkbalk tonen"
}
}
diff --git a/src/messages/no.json b/src/messages/no.json
index 86b4a407..8f01644a 100644
--- a/src/messages/no.json
+++ b/src/messages/no.json
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
+ },
+ "toolbar": {
+ "home": "Hjem",
+ "reload": "Last inn på nytt",
+ "fullscreen": "Fullskjerm",
+ "logout": "Logg ut",
+ "onlineUsers": "Brukere på nett",
+ "emulatorOnline": "Emulator på nett",
+ "emulatorOffline": "Emulator frakoblet",
+ "dragToMove": "Dra for å flytte",
+ "hide": "Skjul verktøylinje",
+ "fullscreenExit": "Avslutt fullskjerm",
+ "show": "Vis verktøylinje"
}
}
diff --git a/src/messages/pl.json b/src/messages/pl.json
index a0992235..60566f9d 100644
--- a/src/messages/pl.json
+++ b/src/messages/pl.json
@@ -3263,5 +3263,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
+ },
+ "toolbar": {
+ "home": "Start",
+ "reload": "Przeładuj",
+ "fullscreen": "Pełny ekran",
+ "logout": "Wyloguj",
+ "onlineUsers": "Użytkownicy online",
+ "emulatorOnline": "Emulator online",
+ "emulatorOffline": "Emulator offline",
+ "dragToMove": "Przeciągnij, aby przenieść",
+ "hide": "Ukryj pasek",
+ "fullscreenExit": "Zamknij pełny ekran",
+ "show": "Pokaż pasek"
}
}
diff --git a/src/messages/pt.json b/src/messages/pt.json
index e74a48fb..b4ef75ad 100644
--- a/src/messages/pt.json
+++ b/src/messages/pt.json
@@ -3263,5 +3263,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
+ },
+ "toolbar": {
+ "home": "Início",
+ "reload": "Recarregar",
+ "fullscreen": "Tela cheia",
+ "logout": "Sair",
+ "onlineUsers": "Usuários online",
+ "emulatorOnline": "Emulador online",
+ "emulatorOffline": "Emulador offline",
+ "dragToMove": "Arraste para mover",
+ "hide": "Ocultar barra",
+ "fullscreenExit": "Sair da tela cheia",
+ "show": "Mostrar barra"
}
}
diff --git a/src/messages/ro.json b/src/messages/ro.json
index c78744fd..e41bfd2b 100644
--- a/src/messages/ro.json
+++ b/src/messages/ro.json
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
+ },
+ "toolbar": {
+ "home": "Acasă",
+ "reload": "Reîncarcă",
+ "fullscreen": "Ecran complet",
+ "logout": "Deconectare",
+ "onlineUsers": "Utilizatori online",
+ "emulatorOnline": "Emulatorul este online",
+ "emulatorOffline": "Emulatorul este offline",
+ "dragToMove": "Trageți pentru a muta",
+ "hide": "Ascunde bara",
+ "fullscreenExit": "Ieșiți din ecran complet",
+ "show": "Afișați bara"
}
}
diff --git a/src/messages/ru.json b/src/messages/ru.json
index bb5ade06..12a31846 100644
--- a/src/messages/ru.json
+++ b/src/messages/ru.json
@@ -3261,5 +3261,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
+ },
+ "toolbar": {
+ "home": "Главная",
+ "reload": "Перезагрузить",
+ "fullscreen": "Полный экран",
+ "logout": "Выйти",
+ "onlineUsers": "Пользователи онлайн",
+ "emulatorOnline": "Эмулятор онлайн",
+ "emulatorOffline": "Эмулятор офлайн",
+ "dragToMove": "Перетащите, чтобы переместить",
+ "hide": "Скрыть панель",
+ "fullscreenExit": "Выйти из полноэкранного режима",
+ "show": "Показать панель"
}
}
diff --git a/src/messages/sk.json b/src/messages/sk.json
index 7e1bd649..95248fd9 100644
--- a/src/messages/sk.json
+++ b/src/messages/sk.json
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
+ },
+ "toolbar": {
+ "home": "Domov",
+ "reload": "Obnoviť",
+ "fullscreen": "Celá obrazovka",
+ "logout": "Odhlásiť",
+ "onlineUsers": "Používatelia online",
+ "emulatorOnline": "Emulátor online",
+ "emulatorOffline": "Emulátor offline",
+ "dragToMove": "Potiahnite na presun",
+ "hide": "Skryť panel",
+ "fullscreenExit": "Ukončiť celú obrazovku",
+ "show": "Zobraziť panel"
}
}
diff --git a/src/messages/sr.json b/src/messages/sr.json
index 3faaa802..b7b88350 100644
--- a/src/messages/sr.json
+++ b/src/messages/sr.json
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
+ },
+ "toolbar": {
+ "home": "Почетна",
+ "reload": "Освежи",
+ "fullscreen": "Цео екран",
+ "logout": "Одјава",
+ "onlineUsers": "Корисници на мрежи",
+ "emulatorOnline": "Емулатор онлајн",
+ "emulatorOffline": "Емулатор офлајн",
+ "dragToMove": "Превуците да померите",
+ "hide": "Сакриј траку",
+ "fullscreenExit": "Изађи из целог екрана",
+ "show": "Прикажи траку"
}
}
diff --git a/src/messages/sv.json b/src/messages/sv.json
index f2f6de14..89aa9a5c 100644
--- a/src/messages/sv.json
+++ b/src/messages/sv.json
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
+ },
+ "toolbar": {
+ "home": "Hem",
+ "reload": "Ladda om",
+ "fullscreen": "Helskärm",
+ "logout": "Logga ut",
+ "onlineUsers": "Användare online",
+ "emulatorOnline": "Emulator online",
+ "emulatorOffline": "Emulator offline",
+ "dragToMove": "Dra för att flytta",
+ "hide": "Dölj verktygsfält",
+ "fullscreenExit": "Avsluta helskärm",
+ "show": "Visa verktygsfält"
}
}
diff --git a/src/messages/tr.json b/src/messages/tr.json
index 15caa96a..67e73204 100644
--- a/src/messages/tr.json
+++ b/src/messages/tr.json
@@ -3263,5 +3263,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
+ },
+ "toolbar": {
+ "home": "Ana sayfa",
+ "reload": "Yenile",
+ "fullscreen": "Tam ekran",
+ "logout": "Çıkış",
+ "onlineUsers": "Çevrimiçi kullanıcılar",
+ "emulatorOnline": "Emülatör çevrimiçi",
+ "emulatorOffline": "Emülatör çevrimdışı",
+ "dragToMove": "Taşımak için sürükleyin",
+ "hide": "Araç çubuğunu gizle",
+ "fullscreenExit": "Tam ekrandan çık",
+ "show": "Araç çubuğunu göster"
}
}
diff --git a/src/messages/uk.json b/src/messages/uk.json
index 75cf7354..2131797f 100644
--- a/src/messages/uk.json
+++ b/src/messages/uk.json
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
+ },
+ "toolbar": {
+ "home": "Головна",
+ "reload": "Перезавантажити",
+ "fullscreen": "Повний екран",
+ "logout": "Вийти",
+ "onlineUsers": "Користувачі онлайн",
+ "emulatorOnline": "Емулятор онлайн",
+ "emulatorOffline": "Емулятор офлайн",
+ "dragToMove": "Перетягніть, щоб перемістити",
+ "hide": "Сховати панель",
+ "fullscreenExit": "Вийти з повноекранного режиму",
+ "show": "Показати панель"
}
}
From a30e4af32e804a1d0cf1ccccabfb72cc573e25fa Mon Sep 17 00:00:00 2001
From: openhands
Date: Wed, 2 Sep 2026 21:34:30 +0200
Subject: [PATCH 07/42] feat: polish toolbar, live online count via SSE +
emulator 3-state
- Add emulator status 3-state (unknown/green/red) with tooltip
- Extract shared primary button style/constants for DRY toolbar code
- Add emulatorUnknown translations to all 25 locales
- Bump Next.js to 16.3.4
- Fix RCON delivery timeout caching (15s TTL / shared across clients)
- Who's-online tooltip throttled to max 1 request per 30s
---
package.json | 2 +-
pnpm-lock.yaml | 94 +++++++++++++++++-----------------
src/app/client/client-view.tsx | 23 ++++++---
src/messages/ar.json | 3 +-
src/messages/bg.json | 3 +-
src/messages/cs.json | 3 +-
src/messages/da.json | 3 +-
src/messages/de.json | 3 +-
src/messages/el.json | 3 +-
src/messages/en.json | 3 +-
src/messages/es.json | 3 +-
src/messages/fi.json | 3 +-
src/messages/fr.json | 3 +-
src/messages/hr.json | 3 +-
src/messages/hu.json | 3 +-
src/messages/it.json | 3 +-
src/messages/ja.json | 3 +-
src/messages/nl.json | 3 +-
src/messages/no.json | 3 +-
src/messages/pl.json | 3 +-
src/messages/pt.json | 3 +-
src/messages/ro.json | 3 +-
src/messages/ru.json | 3 +-
src/messages/sk.json | 3 +-
src/messages/sr.json | 3 +-
src/messages/sv.json | 3 +-
src/messages/tr.json | 3 +-
src/messages/uk.json | 3 +-
28 files changed, 115 insertions(+), 79 deletions(-)
diff --git a/package.json b/package.json
index ec02d921..019173eb 100644
--- a/package.json
+++ b/package.json
@@ -54,7 +54,7 @@
"motion": "13.1.1",
"music-metadata": "11.15.0",
"mysql2": "3.24.2",
- "next": "16.3.3",
+ "next": "16.3.4",
"next-auth": "5.0.0-beta.32",
"next-intl": "4.14.1",
"otplib": "13.5.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 63be703e..38a22756 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -92,14 +92,14 @@ importers:
specifier: 3.24.2
version: 3.24.2(@types/node@26.4.0)
next:
- specifier: 16.3.3
- version: 16.3.3(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ specifier: 16.3.4
+ version: 16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
next-auth:
specifier: 5.0.0-beta.32
- version: 5.0.0-beta.32(next@16.3.3(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)
+ version: 5.0.0-beta.32(next@16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)
next-intl:
specifier: 4.14.1
- version: 4.14.1(@swc/helpers@0.5.23)(next@16.3.3(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)
+ version: 4.14.1(@swc/helpers@0.5.23)(next@16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)
otplib:
specifier: 13.5.0
version: 13.5.0
@@ -824,57 +824,57 @@ packages:
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
- '@next/env@16.3.3':
- resolution: {integrity: sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g==}
+ '@next/env@16.3.4':
+ resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==}
- '@next/swc-darwin-arm64@16.3.3':
- resolution: {integrity: sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw==}
+ '@next/swc-darwin-arm64@16.3.4':
+ resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
- '@next/swc-darwin-x64@16.3.3':
- resolution: {integrity: sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A==}
+ '@next/swc-darwin-x64@16.3.4':
+ resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
- '@next/swc-linux-arm64-gnu@16.3.3':
- resolution: {integrity: sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg==}
+ '@next/swc-linux-arm64-gnu@16.3.4':
+ resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-arm64-musl@16.3.3':
- resolution: {integrity: sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg==}
+ '@next/swc-linux-arm64-musl@16.3.4':
+ resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@next/swc-linux-x64-gnu@16.3.3':
- resolution: {integrity: sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA==}
+ '@next/swc-linux-x64-gnu@16.3.4':
+ resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-x64-musl@16.3.3':
- resolution: {integrity: sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg==}
+ '@next/swc-linux-x64-musl@16.3.4':
+ resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
- '@next/swc-win32-arm64-msvc@16.3.3':
- resolution: {integrity: sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg==}
+ '@next/swc-win32-arm64-msvc@16.3.4':
+ resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
- '@next/swc-win32-x64-msvc@16.3.3':
- resolution: {integrity: sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ==}
+ '@next/swc-win32-x64-msvc@16.3.4':
+ resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -2546,8 +2546,8 @@ packages:
next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
- next@16.3.3:
- resolution: {integrity: sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g==}
+ next@16.3.4:
+ resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -3460,30 +3460,30 @@ snapshots:
'@tybys/wasm-util': 0.10.3
optional: true
- '@next/env@16.3.3': {}
+ '@next/env@16.3.4': {}
- '@next/swc-darwin-arm64@16.3.3':
+ '@next/swc-darwin-arm64@16.3.4':
optional: true
- '@next/swc-darwin-x64@16.3.3':
+ '@next/swc-darwin-x64@16.3.4':
optional: true
- '@next/swc-linux-arm64-gnu@16.3.3':
+ '@next/swc-linux-arm64-gnu@16.3.4':
optional: true
- '@next/swc-linux-arm64-musl@16.3.3':
+ '@next/swc-linux-arm64-musl@16.3.4':
optional: true
- '@next/swc-linux-x64-gnu@16.3.3':
+ '@next/swc-linux-x64-gnu@16.3.4':
optional: true
- '@next/swc-linux-x64-musl@16.3.3':
+ '@next/swc-linux-x64-musl@16.3.4':
optional: true
- '@next/swc-win32-arm64-msvc@16.3.3':
+ '@next/swc-win32-arm64-msvc@16.3.4':
optional: true
- '@next/swc-win32-x64-msvc@16.3.3':
+ '@next/swc-win32-x64-msvc@16.3.4':
optional: true
'@noble/hashes@2.4.0': {}
@@ -4677,15 +4677,15 @@ snapshots:
dependencies:
content-type: 2.1.0
- next-auth@5.0.0-beta.32(next@16.3.3(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8):
+ next-auth@5.0.0-beta.32(next@16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8):
dependencies:
'@auth/core': 0.41.3
- next: 16.3.3(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ next: 16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react: 19.2.8
next-intl-swc-plugin-extractor@4.14.1: {}
- next-intl@4.14.1(@swc/helpers@0.5.23)(next@16.3.3(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8):
+ next-intl@4.14.1(@swc/helpers@0.5.23)(next@16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8):
dependencies:
'@eloqnt/config': 0.0.2
'@eloqnt/format-json': 0.0.3
@@ -4695,16 +4695,16 @@ snapshots:
'@swc/core': 1.16.1(@swc/helpers@0.5.23)
icu-minify: 4.14.1
negotiator: 1.1.0
- next: 16.3.3(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ next: 16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
next-intl-swc-plugin-extractor: 4.14.1
react: 19.2.8
use-intl: 4.14.1(react@19.2.8)
transitivePeerDependencies:
- '@swc/helpers'
- next@16.3.3(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
+ next@16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
- '@next/env': 16.3.3
+ '@next/env': 16.3.4
'@swc/helpers': 0.5.23
baseline-browser-mapping: 2.11.20
caniuse-lite: 1.0.30001810
@@ -4713,14 +4713,14 @@ snapshots:
react-dom: 19.2.8(react@19.2.8)
styled-jsx: 5.1.6(react@19.2.8)
optionalDependencies:
- '@next/swc-darwin-arm64': 16.3.3
- '@next/swc-darwin-x64': 16.3.3
- '@next/swc-linux-arm64-gnu': 16.3.3
- '@next/swc-linux-arm64-musl': 16.3.3
- '@next/swc-linux-x64-gnu': 16.3.3
- '@next/swc-linux-x64-musl': 16.3.3
- '@next/swc-win32-arm64-msvc': 16.3.3
- '@next/swc-win32-x64-msvc': 16.3.3
+ '@next/swc-darwin-arm64': 16.3.4
+ '@next/swc-darwin-x64': 16.3.4
+ '@next/swc-linux-arm64-gnu': 16.3.4
+ '@next/swc-linux-arm64-musl': 16.3.4
+ '@next/swc-linux-x64-gnu': 16.3.4
+ '@next/swc-linux-x64-musl': 16.3.4
+ '@next/swc-win32-arm64-msvc': 16.3.4
+ '@next/swc-win32-x64-msvc': 16.3.4
sharp: 0.35.4(@types/node@26.4.0)
transitivePeerDependencies:
- '@babel/core'
diff --git a/src/app/client/client-view.tsx b/src/app/client/client-view.tsx
index 177283d0..1ff07aac 100644
--- a/src/app/client/client-view.tsx
+++ b/src/app/client/client-view.tsx
@@ -400,17 +400,28 @@ export function ClientView({
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.20)",
}}
role="status"
+ aria-label={
+ emulatorOk === null
+ ? tToolbar("emulatorUnknown")
+ : emulatorOk
+ ? tToolbar("emulatorOnline")
+ : tToolbar("emulatorOffline")
+ }
title={
- emulatorOk === false
- ? tToolbar("emulatorOffline")
- : tToolbar("emulatorOnline")
+ emulatorOk === null
+ ? tToolbar("emulatorUnknown")
+ : emulatorOk
+ ? tToolbar("emulatorOnline")
+ : tToolbar("emulatorOffline")
}
>
- {emulatorOk === false ? (
-
- ) : (
+ {emulatorOk === null ? (
+
+ ) : emulatorOk ? (
+ ) : (
+
)}
diff --git a/src/messages/ar.json b/src/messages/ar.json
index ae67f357..2cf08b15 100644
--- a/src/messages/ar.json
+++ b/src/messages/ar.json
@@ -21,6 +21,7 @@
"dragToMove": "اسحب للتحريك",
"hide": "إخفاء الشريط",
"fullscreenExit": "الخروج من ملء الشاشة",
- "show": "إظهار الشريط"
+ "show": "إظهار الشريط",
+ "emulatorUnknown": "حالة المحاكي غير معروفة"
}
}
diff --git a/src/messages/bg.json b/src/messages/bg.json
index 26358438..e8216753 100644
--- a/src/messages/bg.json
+++ b/src/messages/bg.json
@@ -3274,6 +3274,7 @@
"dragToMove": "Влачете за преместване",
"hide": "Скрий лентата",
"fullscreenExit": "Изход от цял екран",
- "show": "Покажи лентата"
+ "show": "Покажи лентата",
+ "emulatorUnknown": "Състоянието на емулатора е неизвестно"
}
}
diff --git a/src/messages/cs.json b/src/messages/cs.json
index d018adf5..9ba96e1a 100644
--- a/src/messages/cs.json
+++ b/src/messages/cs.json
@@ -3274,6 +3274,7 @@
"dragToMove": "Přetažením přesunete",
"hide": "Skrýt lištu",
"fullscreenExit": "Ukončit celou obrazovku",
- "show": "Zobrazit lištu"
+ "show": "Zobrazit lištu",
+ "emulatorUnknown": "Stav emulátoru neznámý"
}
}
diff --git a/src/messages/da.json b/src/messages/da.json
index a2ef0df1..274b9c58 100644
--- a/src/messages/da.json
+++ b/src/messages/da.json
@@ -3274,6 +3274,7 @@
"dragToMove": "Træk for at flytte",
"hide": "Skjul værktøjslinje",
"fullscreenExit": "Afslut fuldskærm",
- "show": "Vis værktøjslinje"
+ "show": "Vis værktøjslinje",
+ "emulatorUnknown": "Emulatorstatus ukendt"
}
}
diff --git a/src/messages/de.json b/src/messages/de.json
index 7ebaabb5..18966582 100644
--- a/src/messages/de.json
+++ b/src/messages/de.json
@@ -3275,6 +3275,7 @@
"dragToMove": "Ziehen zum Verschieben",
"hide": "Leiste ausblenden",
"fullscreenExit": "Vollbild beenden",
- "show": "Leiste anzeigen"
+ "show": "Leiste anzeigen",
+ "emulatorUnknown": "Emulatorstatus unbekannt"
}
}
diff --git a/src/messages/el.json b/src/messages/el.json
index 9be7dbc0..d9e5a68e 100644
--- a/src/messages/el.json
+++ b/src/messages/el.json
@@ -3274,6 +3274,7 @@
"dragToMove": "Σύρετε για μετακίνηση",
"hide": "Απόκρυψη γραμμής",
"fullscreenExit": "Έξοδος από πλήρη οθόνη",
- "show": "Εμφάνιση γραμμής"
+ "show": "Εμφάνιση γραμμής",
+ "emulatorUnknown": "Άγνωστη κατάσταση εξομοιωτή"
}
}
diff --git a/src/messages/en.json b/src/messages/en.json
index c2fcda00..16bb2aa7 100644
--- a/src/messages/en.json
+++ b/src/messages/en.json
@@ -3333,6 +3333,7 @@
"dragToMove": "Drag to move",
"hide": "Hide toolbar",
"fullscreenExit": "Exit fullscreen",
- "show": "Show toolbar"
+ "show": "Show toolbar",
+ "emulatorUnknown": "Emulator status unknown"
}
}
diff --git a/src/messages/es.json b/src/messages/es.json
index d716ed98..1332139f 100644
--- a/src/messages/es.json
+++ b/src/messages/es.json
@@ -3275,6 +3275,7 @@
"dragToMove": "Arrastra para mover",
"hide": "Ocultar barra",
"fullscreenExit": "Salir de pantalla completa",
- "show": "Mostrar barra"
+ "show": "Mostrar barra",
+ "emulatorUnknown": "Estado del emulador desconocido"
}
}
diff --git a/src/messages/fi.json b/src/messages/fi.json
index ee7d5eeb..18779dfc 100644
--- a/src/messages/fi.json
+++ b/src/messages/fi.json
@@ -21,6 +21,7 @@
"dragToMove": "Vedä liikuttaaksesi",
"hide": "Piilota työkalupalkki",
"fullscreenExit": "Lopeta koko näyttö",
- "show": "Näytä työkalupalkki"
+ "show": "Näytä työkalupalkki",
+ "emulatorUnknown": "Emulaattorin tila tuntematon"
}
}
diff --git a/src/messages/fr.json b/src/messages/fr.json
index 5b23e4b0..10684a70 100644
--- a/src/messages/fr.json
+++ b/src/messages/fr.json
@@ -3275,6 +3275,7 @@
"dragToMove": "Glissez pour déplacer",
"hide": "Masquer la barre",
"fullscreenExit": "Quitter le plein écran",
- "show": "Afficher la barre"
+ "show": "Afficher la barre",
+ "emulatorUnknown": "État de l'émulateur inconnu"
}
}
diff --git a/src/messages/hr.json b/src/messages/hr.json
index 6916e818..8b61bc3d 100644
--- a/src/messages/hr.json
+++ b/src/messages/hr.json
@@ -3274,6 +3274,7 @@
"dragToMove": "Povucite za pomicanje",
"hide": "Sakrij traku",
"fullscreenExit": "Izađi iz cijelog zaslona",
- "show": "Prikaži traku"
+ "show": "Prikaži traku",
+ "emulatorUnknown": "Status emulatora nepoznat"
}
}
diff --git a/src/messages/hu.json b/src/messages/hu.json
index c81b6294..16bed071 100644
--- a/src/messages/hu.json
+++ b/src/messages/hu.json
@@ -3274,6 +3274,7 @@
"dragToMove": "Húzás a mozgatáshoz",
"hide": "Sáv elrejtése",
"fullscreenExit": "Kilépés a teljes képernyőből",
- "show": "Sáv megjelenítése"
+ "show": "Sáv megjelenítése",
+ "emulatorUnknown": "Az emulátor állapota ismeretlen"
}
}
diff --git a/src/messages/it.json b/src/messages/it.json
index 24ab9630..78d429f8 100644
--- a/src/messages/it.json
+++ b/src/messages/it.json
@@ -3332,6 +3332,7 @@
"dragToMove": "Trascina per spostare",
"hide": "Nascondi barra",
"fullscreenExit": "Esci dalla schermo intero",
- "show": "Mostra barra"
+ "show": "Mostra barra",
+ "emulatorUnknown": "Stato dell'emulatore sconosciuto"
}
}
diff --git a/src/messages/ja.json b/src/messages/ja.json
index b9a87a3b..420f778a 100644
--- a/src/messages/ja.json
+++ b/src/messages/ja.json
@@ -21,6 +21,7 @@
"dragToMove": "ドラッグして移動",
"hide": "ツールバーを隠す",
"fullscreenExit": "全画面を終了",
- "show": "ツールバーを表示"
+ "show": "ツールバーを表示",
+ "emulatorUnknown": "エミュレータの状態が不明です"
}
}
diff --git a/src/messages/nl.json b/src/messages/nl.json
index 26d71527..80f00631 100644
--- a/src/messages/nl.json
+++ b/src/messages/nl.json
@@ -3277,6 +3277,7 @@
"dragToMove": "Sleep om te verplaatsen",
"hide": "Werkbalk verbergen",
"fullscreenExit": "Volledig scherm afsluiten",
- "show": "Werkbalk tonen"
+ "show": "Werkbalk tonen",
+ "emulatorUnknown": "Emulatorstatus onbekend"
}
}
diff --git a/src/messages/no.json b/src/messages/no.json
index 8f01644a..7e5dbbc3 100644
--- a/src/messages/no.json
+++ b/src/messages/no.json
@@ -3274,6 +3274,7 @@
"dragToMove": "Dra for å flytte",
"hide": "Skjul verktøylinje",
"fullscreenExit": "Avslutt fullskjerm",
- "show": "Vis verktøylinje"
+ "show": "Vis verktøylinje",
+ "emulatorUnknown": "Emulatorstatus ukjent"
}
}
diff --git a/src/messages/pl.json b/src/messages/pl.json
index 60566f9d..6be4f6d7 100644
--- a/src/messages/pl.json
+++ b/src/messages/pl.json
@@ -3275,6 +3275,7 @@
"dragToMove": "Przeciągnij, aby przenieść",
"hide": "Ukryj pasek",
"fullscreenExit": "Zamknij pełny ekran",
- "show": "Pokaż pasek"
+ "show": "Pokaż pasek",
+ "emulatorUnknown": "Status emulatora nieznany"
}
}
diff --git a/src/messages/pt.json b/src/messages/pt.json
index b4ef75ad..73bd0095 100644
--- a/src/messages/pt.json
+++ b/src/messages/pt.json
@@ -3275,6 +3275,7 @@
"dragToMove": "Arraste para mover",
"hide": "Ocultar barra",
"fullscreenExit": "Sair da tela cheia",
- "show": "Mostrar barra"
+ "show": "Mostrar barra",
+ "emulatorUnknown": "Estado do emulador desconhecido"
}
}
diff --git a/src/messages/ro.json b/src/messages/ro.json
index e41bfd2b..dbfbfdb8 100644
--- a/src/messages/ro.json
+++ b/src/messages/ro.json
@@ -3274,6 +3274,7 @@
"dragToMove": "Trageți pentru a muta",
"hide": "Ascunde bara",
"fullscreenExit": "Ieșiți din ecran complet",
- "show": "Afișați bara"
+ "show": "Afișați bara",
+ "emulatorUnknown": "Starea emulatorului necunoscută"
}
}
diff --git a/src/messages/ru.json b/src/messages/ru.json
index 12a31846..cbba8909 100644
--- a/src/messages/ru.json
+++ b/src/messages/ru.json
@@ -3273,6 +3273,7 @@
"dragToMove": "Перетащите, чтобы переместить",
"hide": "Скрыть панель",
"fullscreenExit": "Выйти из полноэкранного режима",
- "show": "Показать панель"
+ "show": "Показать панель",
+ "emulatorUnknown": "Состояние эмулятора неизвестно"
}
}
diff --git a/src/messages/sk.json b/src/messages/sk.json
index 95248fd9..c3eee6be 100644
--- a/src/messages/sk.json
+++ b/src/messages/sk.json
@@ -3274,6 +3274,7 @@
"dragToMove": "Potiahnite na presun",
"hide": "Skryť panel",
"fullscreenExit": "Ukončiť celú obrazovku",
- "show": "Zobraziť panel"
+ "show": "Zobraziť panel",
+ "emulatorUnknown": "Stav emulátora neznámy"
}
}
diff --git a/src/messages/sr.json b/src/messages/sr.json
index b7b88350..23b1c4d7 100644
--- a/src/messages/sr.json
+++ b/src/messages/sr.json
@@ -3274,6 +3274,7 @@
"dragToMove": "Превуците да померите",
"hide": "Сакриј траку",
"fullscreenExit": "Изађи из целог екрана",
- "show": "Прикажи траку"
+ "show": "Прикажи траку",
+ "emulatorUnknown": "Статус емулатора непознат"
}
}
diff --git a/src/messages/sv.json b/src/messages/sv.json
index 89aa9a5c..42162cb0 100644
--- a/src/messages/sv.json
+++ b/src/messages/sv.json
@@ -3274,6 +3274,7 @@
"dragToMove": "Dra för att flytta",
"hide": "Dölj verktygsfält",
"fullscreenExit": "Avsluta helskärm",
- "show": "Visa verktygsfält"
+ "show": "Visa verktygsfält",
+ "emulatorUnknown": "Emulatorstatus okänd"
}
}
diff --git a/src/messages/tr.json b/src/messages/tr.json
index 67e73204..686efe68 100644
--- a/src/messages/tr.json
+++ b/src/messages/tr.json
@@ -3275,6 +3275,7 @@
"dragToMove": "Taşımak için sürükleyin",
"hide": "Araç çubuğunu gizle",
"fullscreenExit": "Tam ekrandan çık",
- "show": "Araç çubuğunu göster"
+ "show": "Araç çubuğunu göster",
+ "emulatorUnknown": "Emülatör durumu bilinmiyor"
}
}
diff --git a/src/messages/uk.json b/src/messages/uk.json
index 2131797f..9687607b 100644
--- a/src/messages/uk.json
+++ b/src/messages/uk.json
@@ -3274,6 +3274,7 @@
"dragToMove": "Перетягніть, щоб перемістити",
"hide": "Сховати панель",
"fullscreenExit": "Вийти з повноекранного режиму",
- "show": "Показати панель"
+ "show": "Показати панель",
+ "emulatorUnknown": "Стан емулятора невідомий"
}
}
From b810b1667294003f44d5fc9e658a0dba7234b017 Mon Sep 17 00:00:00 2001
From: openhands
Date: Wed, 2 Sep 2026 22:02:31 +0200
Subject: [PATCH 08/42] fix: show/hide arrow always visible and null-safe
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Show arrow (›) appears when toolbar is hidden, regardless of pos state
- Null-safe pos top/left (?. ?? 8) to prevent TS errors
- Arrow positioned fixed top-right instead of depending on balk-positie
---
src/app/client/client-view.tsx | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/src/app/client/client-view.tsx b/src/app/client/client-view.tsx
index 1ff07aac..2a92fd42 100644
--- a/src/app/client/client-view.tsx
+++ b/src/app/client/client-view.tsx
@@ -295,8 +295,8 @@ export function ClientView({
ref={toolbarRef}
className="fixed z-[9999] rounded-2xl shadow-2xl px-1.5 py-1.5 select-none backdrop-blur-2xl"
style={{
- top: pos.top,
- left: pos.left,
+ top: pos?.top ?? 8,
+ left: pos?.left ?? 8,
transition: snapTween
? "top 0.18s ease-out, left 0.18s ease-out"
: undefined,
@@ -540,7 +540,7 @@ export function ClientView({
) : null}
- {barHidden && pos ? (
+ {barHidden ? (
) : null}
+ {canEdit ? (
+
+ ) : null}
+
{t("colOwner")}
diff --git a/src/app/admin/media/page.tsx b/src/app/admin/media/page.tsx
index 0f558853..17819b05 100644
--- a/src/app/admin/media/page.tsx
+++ b/src/app/admin/media/page.tsx
@@ -1,7 +1,20 @@
+import dynamic from "next/dynamic";
import { redirect } from "next/navigation";
-import { AdminMediaGrid } from "@/components/admin/media-grid";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
+const AdminMediaGrid = dynamic(
+ () =>
+ import("@/components/admin/media-grid").then((m) => ({
+ default: m.AdminMediaGrid,
+ })),
+ {
+ loading: () => (
+
+ ),
+ ssr: false,
+ },
+);
+
export default async function AdminMediaPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
diff --git a/src/app/admin/moderation/actions/mod-actions-client.tsx b/src/app/admin/moderation/actions/mod-actions-client.tsx
index 323425a4..96ac65ba 100644
--- a/src/app/admin/moderation/actions/mod-actions-client.tsx
+++ b/src/app/admin/moderation/actions/mod-actions-client.tsx
@@ -104,6 +104,7 @@ export function ModActionsClient() {
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="Enter user ID"
+ aria-label="User ID"
/>
@@ -144,6 +145,7 @@ export function ModActionsClient() {
onChange={(e) => setMuteDuration(e.target.value)}
className="w-24"
placeholder="Min"
+ aria-label="Mute duration in minutes"
/>