55 Commits
Author SHA1 Message Date
SimoandCursor c433e5a52f fix(deploy): clear EADDRINUSE orphans and update deploy contract tests
CI / check (push) Successful in 23s
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m13s
Co-authored-by: Cursor <[email protected]>
2026-07-30 18:41:26 +02:00
SimoandCursor 540bce911f fix(deploy): free PORT before PM2 start to clear EADDRINUSE orphans
Co-authored-by: Cursor <[email protected]>
2026-07-30 18:40:42 +02:00
SimoandCursor 749dc237f1 fix(deploy): export PORT from .env before PM2 reload so health check matches
CI / check (push) Successful in 26s
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 2m6s
Co-authored-by: Cursor <[email protected]>
2026-07-30 18:33:38 +02:00
openhands 8c193936f6 chore: update pnpm-lock.yaml after removing @lhci/cli and @playwright/test
CI / check (push) Successful in 20s
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 1m32s
2026-07-30 18:09:44 +02:00
openhands d3068ce88b fix: remove invalid MySQL2 connection options parseTime/loc/socket_timeout
CI / check (push) Failing after 6s
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 4s
2026-07-30 18:08:02 +02:00
openhands 340ecb42c8 cleanup: remove old unused tooling and reference configs
CI / check (push) Failing after 6s
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 5s
Remove:
- @lhci/cli + lighthouserc.cjs (Lighthouse CI, never used in CI pipeline)
- @playwright/test + e2e/ tests + playwright.config.ts (E2E tests not used)
- setup/ directory (emulator/nitro reference install configs)
- Build artifacts: .next.prev/, coverage/, backups/
2026-07-30 18:05:44 +02:00
Admin 39b211084d test push from within container
CI / check (push) Successful in 21s
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 1m43s
2026-07-30 18:04:54 +02:00
remco 22162fa8b9 cleanup: remove test file
CI / check (push) Successful in 22s
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 1m39s
2026-07-30 17:59:16 +02:00
remco ae2b9328b9 test: verify hooks work after fix
CI / check (push) Successful in 21s
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 1m41s
2026-07-30 17:59:09 +02:00
openhands 84f64b6615 ci: fix CI trigger - run on push too, remove duplicate 2026-07-30 17:53:22 +02:00
openhands 91357aca3b ci: fix Gitea Actions workflow 2026-07-30 17:51:24 +02:00
openhands cc95a0d690 ci: add Gitea Actions workflow 2026-07-30 17:49:38 +02:00
remco da390e447f chore(deps): update All dependencies 2026-07-30 17:01:53 +02:00
openhands 4bd717d627 fix: restore renovate workflow 2026-07-30 16:44:15 +02:00
remco 1311d36933 chore(deps): update All dependencies 2026-07-30 00:00:20 +02:00
openhands ded8fa62af test: trigger actions after adding [actions] config 2026-07-29 23:38:05 +02:00
openhands 3bf0644a9a fix(ci): repair corrupted UTF-8 in renovate.yaml breaking all workflows 2026-07-29 23:26:47 +02:00
openhands d87c4284fe test: trigger workflow 2026-07-29 23:21:08 +02:00
openhands 9cdb0b85fb ci: force trigger workflow after runner fix 2026-07-29 23:00:51 +02:00
openhands 7cdb785218 Remove argon2id, use bcrypt-only password hashing 2026-07-29 22:50:17 +02:00
openhands 7f58e428ed chore: trigger pipeline to verify workflows 2026-07-28 23:19:28 +02:00
openhands a8d86a10bc fix(ci): add missing env vars for robust CI builds
Add NODE_ENV=test and REDIS_URL so tests run cleanly
and Prisma can resolve all required configuration.
2026-07-28 23:09:15 +02:00
openhands b926ffa93c fix(ci): set DATABASE_URL and AUTH_SECRET for Prisma in CI
Prisma requires DATABASE_URL even for client generation.
The CI workflow cloned to a fresh temp dir has no .env file,
so these must be provided as env vars.
2026-07-28 23:05:11 +02:00
remco 65fae257ba chore(deps): update dependency @tanstack/react-virtual to ^3.14.9 2026-07-28 23:00:41 +02:00
openhands 22a9fc13f7 fix(deploy): use correct PORT for health check (was hardcoded to 3000, env uses 3002)
The health check URL was hardcoded to http://127.0.0.1:3000 but the
production .env sets PORT=3002. Read the PORT from .env dynamically
so the health check matches the actual server port.
2026-07-28 23:00:19 +02:00
openhands 3b853efba0 chore: trigger deploy pipeline 2026-07-28 22:57:22 +02:00
openhands 72079050f4 fix(deploy): use deploy user for file ownership instead of www-data
Changing ownership to www-data at end of deploy breaks permission
handling when pm2 runs as a different user (e.g., root or the deploy
user). Keep ownership as the deploy user throughout.
2026-07-28 22:36:39 +02:00
openhands e08e366266 fix: remove orphaned @node-rs/argon2 and restore CI workflow
The hash-wasm package now handles both argon2id and bcrypt hashing,
making @node-rs/argon2 unused. Leaving it in package.json causes
native binary compilation failures on deploy servers (EACCES/build
errors), which breaks the deploy pipeline entirely.

Also restore .gitea/workflows/ci.yaml so CI pipelines run again.
2026-07-28 22:32:56 +02:00
openhands 1e661a2b41 ci: activeer pipeline na server herstart 2026-07-28 21:41:36 +02:00
openhands a637d09ca5 ci: fix permissies en extensie 2026-07-28 21:39:06 +02:00
openhands 15eeab8a59 ci: probeer self-hosted runner label 2026-07-28 21:37:03 +02:00
openhands 54fa1aecdd ci: test of de pipeline start 2026-07-28 21:35:35 +02:00
openhands 5140784dc7 ci: update workflow to use checkout action 2026-07-28 21:33:55 +02:00
openhands 41e41f0d22 fix: permanent permission fix test 2026-07-28 21:31:54 +02:00
openhands 46db76219f fix: refresh gitea status 2026-07-28 21:30:01 +02:00
openhands 5b9e2166df fix: [ Aegon fix] 2026-07-28 21:26:31 +02:00
SimoandCursor 738d7b8223 chore: retrigger deploy after isomorphic-dompurify v3
Co-authored-by: Cursor <[email protected]>
2026-07-28 21:04:37 +02:00
SimoandCursor ab63de000b fix(ci): clone bare repo and fetch PR branch tip
Co-authored-by: Cursor <[email protected]>
2026-07-28 20:55:00 +02:00
SimoandCursor 3532972357 fix(ci): checkout PR SHA via bare-repo worktree
CI / check (pull_request) Failing after 11s
Co-authored-by: Cursor <[email protected]>
2026-07-28 20:53:21 +02:00
SimoandCursor e644362d09 chore(deps): update dependency isomorphic-dompurify to v3
CI / check (pull_request) Failing after 10s
Co-authored-by: Cursor <[email protected]>
2026-07-28 20:51:46 +02:00
SimoandCursor b6b8625246 feat(mod): help-center tickets queue with reduced PII
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m35s
Co-authored-by: Cursor <[email protected]>
2026-07-28 20:26:57 +02:00
SimoandCursor 01126207dc fix(admin): live online widget, ticket queue clarity, i18n+contract coverage
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m27s
Co-authored-by: Cursor <[email protected]>
2026-07-28 20:22:50 +02:00
remco 9177230dc2 chore(deps): update dependency isomorphic-dompurify to ^1.13.0
CI / check (pull_request) Failing after 11s
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m25s
2026-07-28 18:00:36 +00:00
openhands db39fb335c chore: successfully migrate atomcms-next to typescript 7
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m6s
2026-07-28 19:30:10 +02:00
openhands 9ea67ecf72 fix: add useTypeScriptCli experimental flag for typescript 7 support
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m5s
2026-07-28 19:25:13 +02:00
openhands 15a76ffe84 chore: lockfile update for typescript 7
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 43s
2026-07-28 19:22:32 +02:00
openhands 9c0b339736 chore: update typescript configuration for typescript 7 compatibility
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 5s
2026-07-28 19:19:28 +02:00
openhands 7384041bb6 Fix test expectations: default driver now emits argon2id hashes
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 2m2s
2026-07-28 19:08:19 +02:00
openhands a72646c933 Fix type errors: remove unused bcryptRounds, align test with argon2 API
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 36s
2026-07-28 19:06:40 +02:00
openhands a513d9b7bd Migrate dependencies: bcrypt→@node-rs/argon2, sanitize-html→isomorphic-dompurify, remove nodemailer/next-view-transitions
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 27s
2026-07-28 19:03:04 +02:00
openhands 3827f3e686 Migrate from framer-motion to motion/react
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 2m4s
2026-07-28 18:49:25 +02:00
openhands e408fdd5e6 chore(deps): update dependency framer-motion to ^12.43.0
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m36s
2026-07-28 18:40:53 +02:00
openhands 78fab3c9ac chore: update .env.example with high-performance Zod-proof Sentry fallbacks
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m37s
2026-07-28 18:37:41 +02:00
openhands e69c2fbb04 chore: add ultimate high-performance .env.example for Epicnextcms
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 1m15s
2026-07-28 18:32:40 +02:00
openhands 2e2aba11af Configure environment for Epicnextcms with Redis and Arcturus
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m36s
2026-07-28 18:23:51 +02:00
79 changed files with 3121 additions and 4137 deletions

No files matched your search

+43 -70
View File
@@ -1,95 +1,68 @@
# Connection to the LIVE/COPY emulator MySQL/MariaDB database.
# The schema is owned by the Arcturus emulator — this app reads/writes data,
# it does NOT own or migrate the emulator tables. Format:
DATABASE_URL=mysql://user:[email protected]:3306/atomcms
# ==============================================================================
# Epicnextcms — Ultimate Speed & Low-Latency Example Configuration
# ==============================================================================
# Optional pool tuning (defaults shown)
DATABASE_POOL_SIZE=10
DATABASE_IDLE_TIMEOUT_MS=300000
DATABASE_CONNECT_TIMEOUT_MS=10000
# --- DATABASE (High Performance Pooling & Strict Timeouts) ---
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=150&connect_timeout=5"
DATABASE_POOL_SIZE=150
DATABASE_IDLE_TIMEOUT_MS=60000
DATABASE_CONNECT_TIMEOUT_MS=5000
# Redis for rate limits, site-settings cache, and JWT invalidation
# REDIS_URL=redis://localhost:6379
# --- REDIS (Lightning Fast Caching & Sessions) ---
REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2
REDIS_CACHE_TTL_DEFAULT=7200
# Used by SSO ticket generation ({HOTEL_NAME}-{uuid})
HOTEL_NAME=Atom
APP_URL=http://localhost:3000
# NEXT_PUBLIC_APP_URL=https://yourdomain.com
AUTH_URL=http://localhost:3000
# --- CORE RUNTIME & PERFORMANCE FLAGS ---
NODE_ENV=production
PORT=3002
NEXT_TELEMETRY_DISABLED=1
UV_THREADPOOL_SIZE=16
# NextAuth — required in production (>=32 chars). Optional in development.
# Laravel APP_KEY (base64:...) for existing 2FA secrets.
AUTH_SECRET=
APP_KEY=
CONVERT_PASSWORDS=false
# --- HOTEL & URLS ---
HOTEL_NAME=EPIC WEB CONTROL
APP_URL=http://localhost:3002
NEXT_PUBLIC_APP_URL=http://localhost:3002
AUTH_URL=http://localhost:3002
# Password hashing for NEW/upgraded passwords: "bcrypt" (default; 60-char $2y$,
# fits a varchar(64) users.password) or "argon2id" (~97 chars, needs a wider
# column). Existing accounts in either format still verify on login.
PASSWORD_HASH=bcrypt
# --- IMAGER ---
IMAGING_UPSTREAM_URL=http://127.0.0.1:3030/imaging
NEXT_PUBLIC_IMAGER_URL=http://localhost:3002/imaging
# Filesystem directory the badge uploader (/admin/badges) writes <code>.gif into
# — the emulator's badge image folder (e.g. .../assets/c_images/album1584).
# Leave unset to disable badge uploads.
BADGE_UPLOAD_DIR=
# --- SECURITY & HASHING ---
AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
APP_KEY=base64:your-app-key-here=
CONVERT_PASSWORDS=true
BCRYPT_ROUNDS=12
# Emulator JAR backup job (jobs-worker, runs host-side). When both are set, the
# worker copies the JAR daily into the backup dir, keeping the newest N.
EMULATOR_JAR_PATH=
EMULATOR_BACKUP_DIR=
# --- PATHS ---
BADGE_UPLOAD_DIR=./public/assets/images/badges
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
EMULATOR_BACKUP_DIR=./backups/emulator
EMULATOR_BACKUP_KEEP=7
# RCON link to the Arcturus emulator
# --- RCON (Low Latency Loop) ---
RCON_HOST=127.0.0.1
RCON_PORT=3001
RCON_TIMEOUT_MS=2000
# Public imager URL — overrides the default /imaging relative path.
# Falls back to NEXT_PUBLIC_APP_URL/imaging when only the app URL is set.
# NEXT_PUBLIC_IMAGER_URL=https://epicnabbo.nl/imaging
# Preferred email provider (HTTP API). Used before SMTP when set.
RESEND_API_KEY=
# Optional SMTP fallback (password reset / alert emails)
# --- EMAIL & NOTIFICATIONS ---
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
SMTP_FROM=[email protected]
# Optional alerting (jobs worker / alert service)
# --- ALERTING & MONITORING ---
DISCORD_WEBHOOK_URL=
ALERT_EMAIL=
# Optional AI content moderation (user comments / guestbook).
# When set, posts are checked against the OpenAI Moderations endpoint in
# addition to the website_wordfilter blocklist. Fail-open if unset/erroring.
# --- MODERATION & PAYMENTS ---
OPENAI_API_KEY=
# Optional PayPal top-up (sandbox by default)
PAYPAL_CLIENT_ID=
PAYPAL_SECRET=
PAYPAL_API=https://api-m.sandbox.paypal.com
# Redis — REQUIRED for production (shared rate limits, site-settings cache,
# JWT session invalidation cache). Without REDIS_URL the app falls back to
# in-process memory: limits reset on restart and do not work across instances.
# Deploy logs a loud warning when this is unset in production.
REDIS_URL=redis://127.0.0.1:6379
# Logging level (debug | info | warn | error). Defaults to 'info' in production,
# 'debug' in development. Production logs use structured JSON via pino.
LOG_LEVEL=info
# Optional Sentry error monitoring (no-op when unset).
# Server/edge use SENTRY_DSN; browser uses NEXT_PUBLIC_SENTRY_DSN.
SENTRY_DSN=
NEXT_PUBLIC_SENTRY_DSN=
# Optional source-map upload during CI builds (requires SENTRY_AUTH_TOKEN).
SENTRY_ORG=
SENTRY_PROJECT=
SENTRY_AUTH_TOKEN=
# Optional release tag shown in Sentry (e.g. git sha).
# Deploy sets APP_VERSION + NEXT_PUBLIC_APP_VERSION from git sha.
APP_VERSION=
NEXT_PUBLIC_APP_VERSION=
# --- LOGGING & SENTRY (Zod-Proof Dummy URLs) ---
LOG_LEVEL=error
SENTRY_DSN=https://[email protected]/0
NEXT_PUBLIC_SENTRY_DSN=https://[email protected]/0
+9 -3
View File
@@ -1,8 +1,12 @@
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
jobs:
check:
@@ -30,12 +34,14 @@ jobs:
git checkout -f "${REF}"
export SKIP_ENV_VALIDATION=1
export ARGON2_MEMORY_SIZE=1024
export ARGON2_ITERATIONS=1
export NODE_ENV=test
export DATABASE_URL="mysql://test:test@localhost:3306/test?charset=utf8mb4"
export AUTH_SECRET="ci-test-secret-key-that-is-long-enough"
export REDIS_URL="redis://127.0.0.1:6379?connect_timeout=1"
export BCRYPT_ROUNDS=4
pnpm install --frozen-lockfile
pnpm prisma:generate
pnpm biome:lint
pnpm typecheck
pnpm test
echo "--- CI checks passed ---"
echo "--- CI checks passed ---"
+41 -9
View File
@@ -54,7 +54,7 @@ jobs:
echo '<a id="what-is-epicnext-cms"></a>'
echo "## What is EpicNext-CMS?"
echo ""
echo "EpicNext-CMS is a full public-facing hotel website plus an administrative panel. It features NextAuth authentication (argon2id/bcrypt with MD5 upgrade), real-time RCON communication with the emulator, Server-Sent Events for live radio, smooth page transitions and extensive extensibility. Full documentation: https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/README.md"
echo "EpicNext-CMS is a full public-facing hotel website plus an administrative panel. It features NextAuth authentication (bcrypt with MD5 upgrade), real-time RCON communication with the emulator, Server-Sent Events for live radio, smooth page transitions and extensive extensibility. Full documentation: https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/README.md"
echo ""
echo '<a id="system-requirements"></a>'
echo "## System Requirements"
@@ -314,7 +314,7 @@ jobs:
pnpm install --frozen-lockfile
# prisma generate does not need a live DB connection.
pnpm prisma:generate
export ARGON2_MEMORY_SIZE=1024 ARGON2_ITERATIONS=1 BCRYPT_ROUNDS=4
export BCRYPT_ROUNDS=4
pnpm typecheck
pnpm test
# Validate production env (AUTH_SECRET, DATABASE_URL, …) during build.
@@ -377,10 +377,39 @@ jobs:
# Prisma client is gitignored — regenerate into live src/generated.
pnpm prisma:generate
sudo chown -R www-data:www-data "${LIVE}" 2>/dev/null || true
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "${LIVE}" 2>/dev/null || true
echo "Starting PM2 (zero-downtime reload)..."
pm2 reload next --update-env || pm2 start next --update-env
# Next.js prefers an already-set process PORT over .env. PM2 may still
# have PORT=3000 from an older start, while .env (and nginx) expect 3002.
# Export PORT before start so the process matches health checks.
DEPLOY_PORT="$(grep -E '^[[:space:]]*PORT=' "${LIVE}/.env" 2>/dev/null | tail -1 | cut -d= -f2- || true)"
DEPLOY_PORT="$(printf '%s' "${DEPLOY_PORT}" | tr -cd '0-9')"
DEPLOY_PORT="${DEPLOY_PORT:-3002}"
export PORT="${DEPLOY_PORT}"
echo "PM2/health PORT=${PORT}"
free_tcp_port() {
local port="$1"
[ -n "${port}" ] || return 0
if command -v fuser >/dev/null 2>&1; then
fuser -k "${port}/tcp" 2>/dev/null || true
elif command -v lsof >/dev/null 2>&1; then
# Portable fallback when fuser is unavailable.
lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null | xargs -r kill -9 2>/dev/null || true
fi
}
echo "Starting PM2 with a clean PORT=${PORT} listener..."
cd "${LIVE}"
# Cutover already stopped the app, but failed deploys can leave orphans
# on 3002 (or an old PM2 env still bound to 3000).
pm2 stop next --kill-timeout 10000 2>/dev/null || true
free_tcp_port "${PORT}"
free_tcp_port 3000
sleep 1
pm2 delete next 2>/dev/null || true
PORT="${PORT}" pm2 start pnpm --name next -- start
pm2 save 2>/dev/null || true
sleep 3
if ! pm2 show next 2>/dev/null | grep -q 'online'; then
@@ -389,22 +418,25 @@ jobs:
exit 1
fi
echo "Waiting for HTTP health check..."
HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:3000/api/health}"
echo "Waiting for HTTP health check on port ${PORT}..."
HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:${PORT}/api/health}"
HEALTH_OK=0
for i in $(seq 1 15); do
for i in $(seq 1 20); do
BODY="$(curl -sf --max-time 5 "${HEALTH_URL}" 2>/dev/null || true)"
if echo "${BODY}" | grep -q '"database":true'; then
echo "Health OK (${HEALTH_URL})"
HEALTH_OK=1
break
fi
echo "Health attempt ${i}/15 failed, retrying..."
echo "Health attempt ${i}/20 failed (body=${BODY:-<empty>}), retrying..."
sleep 2
done
if [ "${HEALTH_OK}" != "1" ]; then
echo "ERROR: Health check failed after deploy (${HEALTH_URL})" >&2
echo "Last body: ${BODY:-<empty>}" >&2
echo "Listeners on PORT ${PORT}:" >&2
ss -tlnp 2>/dev/null | grep ":${PORT} " >&2 || netstat -tlnp 2>/dev/null | grep ":${PORT} " >&2 || true
pm2 env 0 2>/dev/null | grep -E '^PORT=' >&2 || true
pm2 logs next --lines 40 --nostream >&2 || true
exit 1
fi
+3 -4
View File
@@ -11,9 +11,8 @@ jobs:
- name: Self-hosted Renovate
run: |
set -e
# Zorg ervoor dat de cache-map lokaal bestaat vóór Docker start
# Dit voorkomt dat Docker de map automatisch als 'root' aanmaakt
# Ensure cache dir exists before Docker starts
mkdir -p /var/tmp/renovate-cache
docker run --rm \
@@ -25,4 +24,4 @@ jobs:
-e RENOVATE_CONFIG_FILE='{"extends":["config:recommended"]}' \
-e LOG_LEVEL=info \
-v /var/tmp/renovate-cache:/tmp/renovate-cache \
ghcr.io/renovatebot/renovate:latest
ghcr.io/renovatebot/renovate:latest
+1
View File
@@ -0,0 +1 @@
pnpm exec lint-staged
+2
View File
@@ -0,0 +1,2 @@
pnpm typecheck
pnpm test
+1 -1
View File
@@ -2,7 +2,7 @@
A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Prisma 7** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases.
Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (argon2id/bcrypt with MD5-to-argon2id upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment.
Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (bcrypt with MD5-to-bcrypt upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment.
---
-14
View File
@@ -1,14 +0,0 @@
import { expect, test } from "@playwright/test";
test.describe("Admin panel", () => {
test("admin login page redirects unauthenticated users", async ({ page }) => {
await page.goto("/admin");
await expect(page).toHaveURL(/login/);
});
test("admin page has login form", async ({ page }) => {
await page.goto("/admin");
await expect(page.locator('input[name="username"]')).toBeVisible();
await expect(page.locator('input[name="password"]')).toBeVisible();
});
});
-32
View File
@@ -1,32 +0,0 @@
import { expect, test } from "@playwright/test";
test.describe("Authentication flows", () => {
test("login form validates required fields", async ({ page }) => {
await page.goto("/login");
await page.click('button[type="submit"]');
await expect(page.locator("text=required")).toBeVisible({ timeout: 5000 });
});
test("login with invalid credentials shows error", async ({ page }) => {
await page.goto("/login");
await page.fill('input[name="username"]', "nonexistent");
await page.fill('input[name="password"]', "wrongpassword");
await page.click('button[type="submit"]');
await expect(page.locator("text=invalid")).toBeVisible({ timeout: 5000 });
});
test("register page has password confirmation field", async ({ page }) => {
await page.goto("/register");
await expect(page.locator('input[name="confirmPassword"]')).toBeVisible();
});
test("register form validates password match", async ({ page }) => {
await page.goto("/register");
await page.fill('input[name="password"]', "Password123!");
await page.fill('input[name="confirmPassword"]', "DifferentPass123!");
await page.click('button[type="submit"]');
await expect(page.locator("text=match|komen overeen|kloppen")).toBeVisible({
timeout: 5000,
});
});
});
-16
View File
@@ -1,16 +0,0 @@
import { expect, test } from "@playwright/test";
test("homepage has title", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/Magic Hotel|Atom/i);
});
test("register page loads", async ({ page }) => {
await page.goto("/register");
await expect(page).toHaveTitle(/registreer|register|konto/i);
});
test("login page loads", async ({ page }) => {
await page.goto("/login");
await expect(page).toHaveTitle(/inloggen|login/i);
});
-24
View File
@@ -1,24 +0,0 @@
import { expect, test } from "@playwright/test";
test.describe("Public navigation", () => {
test("homepage loads with expected elements", async ({ page }) => {
await page.goto("/");
await expect(page.locator("nav")).toBeVisible();
await expect(page.locator("footer")).toBeVisible();
});
test("community page loads", async ({ page }) => {
await page.goto("/community");
await expect(page).toHaveTitle(/community/i);
});
test("shop page loads", async ({ page }) => {
await page.goto("/shop");
await expect(page.locator("h1, h2").first()).toBeVisible();
});
test("404 page for unknown routes", async ({ page }) => {
const response = await page.goto("/this-page-does-not-exist");
expect(response?.status()).toBe(404);
});
});
-20
View File
@@ -1,20 +0,0 @@
module.exports = {
ci: {
collect: {
url: ["http://localhost:3000"],
numberOfRuns: 3,
},
assert: {
preset: "lighthouse:no-pwa",
assertions: {
"categories:performance": ["error", { minScore: 0.9 }],
"categories:accessibility": ["error", { minScore: 0.9 }],
"categories:best-practices": ["error", { minScore: 0.9 }],
"categories:seo": ["error", { minScore: 0.8 }],
"first-contentful-paint": ["error", { maxNumericValue: 2000 }],
"largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
"cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }],
},
},
},
};
+6 -1
View File
@@ -40,6 +40,11 @@ const nextConfig: NextConfig = {
// Disable Next.js telemetry
productionBrowserSourceMaps: false,
// Enable TypeScript CLI integration for TypeScript 7 compatibility
experimental: {
useTypeScriptCli: true,
},
// Add caching headers for static assets
async headers() {
return [
@@ -94,4 +99,4 @@ export default withBA(
},
telemetry: false,
}),
);
);
+12 -33
View File
@@ -11,19 +11,13 @@
"build": "next build",
"start": "next start",
"prisma:generate": "prisma generate",
"typecheck": "tsc6 --noEmit --incremental false",
"typecheck": "tsc --noEmit --incremental",
"biome:check": "biome check --write .",
"biome:lint": "biome lint .",
"biome:format": "biome format --write .",
"knip": "knip",
"analyze": "ANALYZE=true pnpm build",
"test": "vitest run",
"test:e2e": "playwright test",
"lint": "eslint . --ext .ts,.tsx --max-warnings=50",
"lint:fix": "eslint . --ext .ts,.tsx --fix --max-warnings=50",
"lhci:collect": "lhci collect",
"lhci:assert": "lhci assert",
"lhci:server": "lhci server",
"db:migrate": "tsx scripts/apply-migrations.ts",
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
"jobs:worker": "tsx scripts/jobs-worker.ts",
@@ -31,8 +25,7 @@
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"biome check --write",
"eslint --fix --max-warnings=50"
"biome check --write"
],
"*.{json,md,css,scss,html}": [
"biome format --write"
@@ -46,36 +39,33 @@
"@hookform/resolvers": "^5.5.7",
"@prisma/adapter-mariadb": "^7.9.1",
"@prisma/client": "^7.9.1",
"@sentry/nextjs": "^10.68.0",
"@tanstack/react-virtual": "^3.14.8",
"bcrypt": "^6.0.0",
"@sentry/nextjs": "^10.69.0",
"@tanstack/react-virtual": "^3.14.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"croner": "^10.0.1",
"framer-motion": "^12.42.2",
"hash-wasm": "^4.12.0",
"ioredis": "^5.11.1",
"isomorphic-dompurify": "^3.20.0",
"jpeg-js": "^0.4.4",
"json5": "^2.2.3",
"jszip": "^3.10.1",
"lenis": "^1.3.25",
"lucide-react": "^1.27.0",
"lucide-react": "^1.28.0",
"lzma": "^2.3.2",
"motion": "^12.43.0",
"music-metadata": "^11.14.0",
"mysql2": "^3.23.2",
"next": "^16.2.12",
"next-auth": "5.0.0-beta.32",
"next-intl": "^4.13.4",
"next-view-transitions": "^0.3.5",
"nodemailer": "^9.0.3",
"otplib": "^13.4.1",
"pino": "^10.3.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.83.0",
"resend": "^6.18.1",
"sanitize-html": "^2.17.6",
"server-only": "^0.0.1",
"sharp": "^0.35.3",
"sonner": "^2.0.7",
@@ -85,37 +75,26 @@
},
"devDependencies": {
"@biomejs/biome": "2.5.6",
"@eslint/js": "10.0.1",
"@lhci/cli": "^0.15.1",
"@next/bundle-analyzer": "^16.2.12",
"@next/eslint-plugin-next": "^16.2.12",
"@playwright/test": "1.62.0",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/postcss": "^4.3.3",
"@tailwindcss/typography": "^0.5.20",
"@types/bcrypt": "^6.0.0",
"@types/node": "^26.1.2",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/sanitize-html": "^2.16.1",
"@vitest/coverage-v8": "4.1.10",
"babel-plugin-react-compiler": "^1.0.0",
"dotenv": "^17.4.2",
"eslint": "10.8.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-security": "^4.0.1",
"eslint-plugin-unused-imports": "4.4.1",
"husky": "^9.1.7",
"knip": "^6.29.0",
"lint-staged": "^17.2.0",
"pino-pretty": "^13.1.3",
"postcss": "^8.5.24",
"postcss": "^8.5.25",
"prisma": "^7.9.1",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.1",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"typescript-eslint": "^8.65.0",
"vite": "8.1.5",
"typescript": "^7.0.2",
"vite": "8.2.0",
"vitest": "4.1.10"
}
}
}
-33
View File
@@ -1,33 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
],
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});
+800 -3235
View File
File diff suppressed because it is too large. Load diff
+47
View File
@@ -0,0 +1,47 @@
const fs = require('fs');
const JSON5 = require('json5');
const exampleFile = process.argv[2];
const targetFile = process.argv[3];
if (!exampleFile || !targetFile) {
process.exit(1);
}
function deepMerge(target, source) {
const result = { ...target };
for (const key of Object.keys(source)) {
if (source[key] !== null && typeof source[key] === 'object' && !Array.isArray(source[key])) {
result[key] = deepMerge(target[key] || {}, source[key]);
} else {
if (!(key in result)) {
result[key] = source[key];
}
}
}
return result;
}
let example;
try {
const content = fs.readFileSync(exampleFile, 'utf-8');
example = JSON5.parse(content);
} catch {
process.exit(1);
}
let current = {};
try {
if (fs.existsSync(targetFile)) {
const content = fs.readFileSync(targetFile, 'utf-8');
current = JSON5.parse(content);
}
} catch {
current = {};
}
const merged = deepMerge(current, example);
const isJson5 = targetFile.endsWith('.json5');
const output = isJson5 ? JSON5.stringify(merged, null, 4) : JSON.stringify(merged, null, 4);
fs.writeFileSync(targetFile, output + '\n');
-66
View File
@@ -1,66 +0,0 @@
# ===========================================================================
# Polaris Emulator — reference config.ini
# Copy this to /var/www/emulator/Emulator/target/config.ini and fill in the
# values marked CHANGE_ME. The emulator reads this file on startup.
# Full guide: https://github.com/duckietm/Complete-Retro-on-Ubuntu
# ===========================================================================
# ---- Database configuration ----
db.hostname=127.0.0.1
db.port=3306
db.database=habbo # DB name (shared with the CMS)
db.username=root # Username
db.password=CHANGE_ME # Password
db.params=?characterEncoding=utf8&useSSL=false&serverTimezone=Europe/Amsterdam
db.pool.minsize=25
db.pool.maxsize=100
db.pool.connection_timeout_ms=10000
db.pool.idle_timeout_ms=600000
db.pool.max_lifetime_ms=1800000
db.pool.validation_timeout_ms=5000
db.pool.leak_detection_ms=20000 # set to 0 to disable leak detection
# ---- Game configuration ----
# Host IP. Use 0.0.0.0 in most cases. Use 127.0.0.1 for LAN only.
game.host=0.0.0.0
game.port=3000
# ---- RCON configuration ----
# Leave host at 127.0.0.1 if the CMS runs on the same server as the emulator.
rcon.host=127.0.0.1
rcon.port=3001
rcon.allowed=127.0.0.1;127.0.0.2
# ---- Nitro secure runtime assets (disabled by default) ----
nitro.secure.assets.enabled=false
nitro.secure.api.enabled=false
nitro.secure.session_ttl_sec=900
nitro.secure.config.root=
nitro.secure.gamedata.root=
# Set a persistent secret when using Cloudflare or multiple backend requests.
nitro.secure.master_key=change-me-to-a-long-random-secret
# ---- Login ----
login.remember.enabled=true
login.remember.duration.days=30
# Optional: set a persistent remember-me JWT secret here.
login.remember.jwt.secret=
login.news.limit=5
# ---- Secure runtime ECDH session TTL in seconds ----
# (kept for compatibility; unused when secure assets are disabled)
# ---- WebSockets (Nitro client) ----
ws.enabled=true
ws.host=0.0.0.0
ws.port=2096
# Header used to obtain the real client IP when behind a proxy
# (usually X-Forwarded-For, or CF-Connecting-IP behind Cloudflare).
ws.ip.header=X-Forwarded-For
# ---- Console / emulator_settings ----
# Run these SQL statements ONCE, after importing the emulator database:
# USE habbo;
# UPDATE emulator_settings SET `value` = '0' WHERE `key` = 'console.mode';
# UPDATE emulator_settings SET `value` = 'MY_DOMAIN.COM,*.MY_DOMAIN.COM,localhost,127.0.0.1' WHERE `key` = 'websockets.whitelist';
# console.mode MUST be 0 and the whitelist MUST match your domain.
-17
View File
@@ -1,17 +0,0 @@
#!/bin/sh
# Launcher for the Polaris emulator.
# Place at /var/www/emulator/Emulator/target/emulator and chmod +x.
# Update the JAR name to match the version built by `mvn clean package`.
file_name_emulator=emulator.log
current_time=$(date "+%H%M_%d-%m-%Y")
file_name=$file_name_emulator.$current_time
# Rotate the previous log
mkdir -p /var/log/emu
mv /var/log/emu/emulator.log /var/log/emu/$file_name 2>/dev/null || true
# -Xmx4096m = 4 GB. 1 GB=1024, 2 GB=2048, 3 GB=3072, 4 GB=4096
java -Dfile.encoding=UTF8 -Xmx4096m \
-jar /var/www/emulator/Emulator/target/Habbo-*-jar-with-dependencies.jar \
>/var/log/emu/emulator.log
-25
View File
@@ -1,25 +0,0 @@
[Unit]
Description=Habbo Emulator
# Make sure the database has started before the emulator can start
After=mariadb.service
Requires=mariadb.service
# If you want to run as a less privileged account, change User below.
# Make sure that account has the right permissions on the working directory and target folders.
[Service]
User=root
# Working directory where config.ini and the JAR live
WorkingDirectory=/var/www/emulator/Emulator/target
# The launcher script we created below. It is a shell wrapper that calls the JAR.
ExecStart=/var/www/emulator/Emulator/target/emulator
SuccessExitStatus=143
TimeoutStopSec=10
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
-8
View File
@@ -1,8 +0,0 @@
{
"distObfuscationEnabled": false,
"secureAssetsEnabled": false,
"secureApiEnabled": false,
"apiBaseUrl": "https://MY_DOMAIN:2096",
"plainConfigBaseUrl": "https://MY_DOMAIN/configuration/",
"plainGamedataBaseUrl": "https://MY_DOMAIN/gamedata/"
}
-75
View File
@@ -1,75 +0,0 @@
{
"socket.url": "wss://MY_DOMAIN.COM:2096",
"crypto.ws.enabled": false,
"crypto.ws.signing.enabled": false,
"crypto.ws.signing.public_key": "",
"api.url": "https://MY_DOMAIN.COM:2096",
"asset.url": "https://MY_DOMAIN.COM/gamedata",
"image.library.url": "http://MY_DOMAIN.COM/gamedata/c_images/",
"hof.furni.url": "https://MY_DOMAIN.COM",
"images.url": "${asset.url}/images",
"gamedata.url": "${asset.url}",
"sounds.url": "${asset.url}/sounds/%sample%.mp3",
"external.texts.url": [
"${gamedata.url}/config/ExternalTexts.json?v=1",
"${gamedata.url}/config/UITexts.json?v=1"
],
"external.samples.url": "${gamedata.url}/sounds/sound_machine_sample_%sample%.mp3",
"furnidata.url": "${gamedata.url}/config/FurnitureData.json?v=1",
"productdata.url": "${gamedata.url}/config/ProductData.json?v=1",
"avatar.actions.url": "${gamedata.url}/config/HabboAvatarActions.json?v=1",
"avatar.figuredata.url": "${gamedata.url}/config/FigureData.json?v=1",
"avatar.figuremap.url": "${gamedata.url}/config/FigureMap.json?v=1",
"avatar.effectmap.url": "${gamedata.url}/config/EffectMap.json?v=1",
"avatar.asset.url": "${asset.url}/clothes/%libname%.nitro",
"avatar.asset.effect.url": "${asset.url}/effect/%libname%.nitro",
"furni.asset.url": "${asset.url}/furniture/%libname%.nitro",
"furni.asset.icon.url": "http://MY_DOMAIN.COM/gamedata/icons/%libname%%param%_icon.png",
"pet.asset.url": "${asset.url}/pets/%libname%.nitro",
"generic.asset.url": "${asset.url}/bundled/generic/%libname%.nitro",
"room.asset.url": "${asset.url}/room/%libname%/%libname%.json",
"badge.asset.url": "${image.library.url}album1584/%badgename%.gif",
"badge.asset.group.url": "http://MY_DOMAIN.COM/habbo-imaging/badge/%badgedata%",
"badge.asset.group.external.url": "",
"badge.asset.grouparts.url": "https://MY_DOMAIN.COM/gamedata/badgeparts/badgepart_%part%.png",
"furni.rotation.bounce.steps": 20,
"furni.rotation.bounce.height": 0.0625,
"room.color.skip.transition": false,
"enable.avatar.arrow": false,
"system.animation.fps": 60,
"system.limits.fps": false,
"system.dispatcher.log": false,
"system.packet.log": false,
"system.pong.manually": true,
"system.pong.interval.ms": 20000,
"room.color.skip.transition": true,
"user.badges.group.slot.enabled": true,
"timezone.settings": "Europe/Amsterdam",
"login.screen.enabled": true,
"login.endpoint": "${api.url}/api/auth/login",
"login.register.endpoint": "${api.url}/api/auth/register",
"login.forgot.endpoint": "${api.url}/api/auth/forgot-password",
"login.logout.endpoint": "${api.url}/api/auth/logout",
"login.health.endpoint": "${api.url}/api/auth/health",
"login.check-email.endpoint": "${api.url}/api/auth/check-email",
"login.check-username.endpoint": "${api.url}/api/auth/check-username",
"login.room_templates.endpoint": "${api.url}/api/auth/room-templates",
"login.remember.endpoint": "${api.url}/api/auth/remember",
"login.server_key.endpoint": "${api.url}/api/auth/server-key",
"login.sso-token.endpoint": "${api.url}/api/auth/sso-token",
"login.refresh.endpoint": "${api.url}/api/auth/refresh",
"badges.custom.list.endpoint": "${api.url}/api/badges/custom",
"badges.custom.create.endpoint": "${api.url}/api/badges/custom",
"badges.custom.update.endpoint": "${api.url}/api/badges/custom/%badgeId%",
"badges.custom.delete.endpoint": "${api.url}/api/badges/custom/%badgeId%",
"badges.custom.texts.endpoint": "${api.url}/api/badges/custom/texts",
"account.change-password.endpoint": "${api.url}/api/auth/change-password",
"account.change-email.endpoint": "${api.url}/api/auth/change-email",
"account.change-username.endpoint": "${api.url}/api/auth/change-username",
"login.health.method": "GET",
"login.news.url": "${asset.url}/news/news.json",
"login.turnstile.enabled": false,
"login.turnstile.sitekey": "",
"avatar.mandatory.libraries": ["bd:1", "li:0"],
"avatar.mandatory.effect.libraries": ["dance.1", "dance.2", "dance.3", "dance.4"]
}
-38
View File
@@ -1,38 +0,0 @@
{
"image.library.notifications.url": "${image.library.url}notifications/%image%.png",
"achievements.images.url": "${image.library.url}Quests/%image%.png",
"camera.url": "https://MY_DOMAIN.COM/camera/photo",
"thumbnails.url": "https://MY_DOMAIN.COM/camera/photo/thumb/%thumbnail%.png",
"url.prefix": "",
"habbopages.url": "/gamedata/habbopages/",
"group.homepage.url": "${url.prefix}/groups/%groupid%/id",
"guide.help.alpha.groupid": 0,
"chat.viewer.height.percentage": 0.4,
"widget.dimmer.colorwheel": false,
"avatar.wardrobe.max.slots": 10,
"user.badges.max.slots": 5,
"camera.publish.disabled": false,
"hc.disabled": false,
"badge.descriptions.enabled": true,
"motto.max.length": 38,
"bot.name.max.length": 15,
"wired.action.bot.talk.to.avatar.max.length": 64,
"wired.action.bot.talk.max.length": 64,
"wired.action.chat.max.length": 100,
"wired.action.kick.from.room.max.length": 100,
"wired.action.mute.user.max.length": 100,
"game.center.enabled": false,
"catalog.style.new": true,
"show.google.ads": false,
"loginview": {
"images": {
"background": "${asset.url}/c_images/reception/stretch_blue.png",
"background.colour": "#6eadc8",
"sun": "${asset.url}/c_images/reception/sun.png",
"drape": "${asset.url}/c_images/reception/drape.png",
"left": "${asset.url}/c_images/reception/ts.png",
"right": "${asset.url}/c_images/reception/US_right.png",
"right.repeat": "${asset.url}/c_images/reception/US_top_right.png"
}
}
}
+7 -3
View File
@@ -25,12 +25,16 @@ function revalidateHelpCenterTicketPaths(ticketId: bigint) {
const id = String(ticketId);
revalidatePath("/admin/help-tickets");
revalidatePath(`/admin/help-tickets/${id}`);
revalidatePath("/mod/help-tickets");
revalidatePath(`/mod/help-tickets/${id}`);
revalidatePath("/help/tickets");
revalidatePath(`/help/tickets/${id}`);
}
const HELP_TICKET_EDIT = [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT] as const;
export const replyHelpCenterTicket = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: replyHelpCenterTicketSchema },
{ permission: HELP_TICKET_EDIT, schema: replyHelpCenterTicketSchema },
async (ctx) => {
const ticketId = ctx.data.ticketId;
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
@@ -72,7 +76,7 @@ export const replyHelpCenterTicket = adminAction(
);
export const closeHelpCenterTicket = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: helpCenterTicketIdSchema },
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
async (ctx) => {
const ticketId = ctx.data.ticketId;
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
@@ -104,7 +108,7 @@ export const closeHelpCenterTicket = adminAction(
);
export const reopenHelpCenterTicket = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: helpCenterTicketIdSchema },
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
async (ctx) => {
const ticketId = ctx.data.ticketId;
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
+24 -1
View File
@@ -53,6 +53,7 @@ export default async function CommandoCentrum() {
emulatorOnline,
maintenance,
activity,
onlineUsersRaw,
] = await Promise.all([
prisma.user.count({ where: { online: "1" } }).catch(() => 0),
prisma.user.count().catch(() => -1),
@@ -70,8 +71,30 @@ export default async function CommandoCentrum() {
.catch(
() => [] as Awaited<ReturnType<typeof prisma.staffActivities.findMany>>,
),
prisma.user
.findMany({
where: { online: "1" },
select: {
id: true,
username: true,
look: true,
lastOnline: true,
},
orderBy: { lastOnline: "desc" },
take: 40,
})
.catch(() => []),
]);
const onlineUsers = onlineUsersRaw.map((u) => ({
id: u.id,
username: u.username,
look: u.look,
roomId: null as number | null,
roomName: null as string | null,
lastActivity: u.lastOnline > 0 ? new Date(u.lastOnline * 1000) : new Date(),
}));
const staffIds = Array.from(
new Set(activity.map((a) => Number(a.userId))),
).filter((n) => n > 0);
@@ -134,7 +157,7 @@ export default async function CommandoCentrum() {
{/* ── Online users widget ───────────────────────────────── */}
<section className="admin-card mb-6">
<h2 className="admin-section-title">{t("onlineUsers")}</h2>
<OnlineUsersWidget users={[]} isLoading={false} />
<OnlineUsersWidget users={onlineUsers} isLoading={false} />
</section>
{/* ── Server info + diagnostics ──────────────────────────── */}
@@ -42,10 +42,15 @@ export function AdminHelpTicketDetail({
ticket,
messages: initialMessages,
canEdit,
listHref = "/admin/help-tickets",
hideRequesterContact = false,
}: {
ticket: HelpTicketInfo;
messages: ThreadMessage[];
canEdit: boolean;
listHref?: string;
/** Hide email/IP-style contact for mod-lite surfaces. */
hideRequesterContact?: boolean;
}) {
const t = useTranslations("pages.admin.helpTickets");
const [messages, setMessages] = useState(initialMessages);
@@ -92,7 +97,7 @@ export function AdminHelpTicketDetail({
return (
<div className="space-y-6">
<div className="flex items-start gap-4">
<Link href="/admin/help-tickets">
<Link href={listHref}>
<Button variant="ghost" size="icon" aria-label={t("backToList")}>
<ArrowLeft className="h-4 w-4" />
</Button>
@@ -241,7 +246,11 @@ export function AdminHelpTicketDetail({
</CardHeader>
<CardContent className="space-y-2">
<Link
href={`/admin/users/show/${ticket.creator.id}`}
href={
hideRequesterContact
? `/u/${encodeURIComponent(ticket.creator.username)}`
: `/admin/users/show/${ticket.creator.id}`
}
className="flex items-center gap-2 text-sm font-medium hover:text-primary"
>
<User className="h-3.5 w-3.5" />
@@ -250,7 +259,7 @@ export function AdminHelpTicketDetail({
{t("rankLabel", { rank: ticket.creator.rank })}
</Badge>
</Link>
{ticket.creator.mail ? (
{!hideRequesterContact && ticket.creator.mail ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Mail className="h-3 w-3" />
{ticket.creator.mail}
+3 -3
View File
@@ -1,4 +1,5 @@
import { notFound, redirect } from "next/navigation";
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
import { positiveBigInt } from "@/lib/api";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
@@ -6,8 +7,6 @@ import { AdminHelpTicketDetail } from "./admin-help-ticket-detail";
export const dynamic = "force-dynamic";
const STAFF_RANK_THRESHOLD = 4;
export default async function AdminHelpTicketDetailPage({
params,
}: {
@@ -70,6 +69,7 @@ export default async function AdminHelpTicketDetailPage({
const userById = new Map(users.map((u) => [u.id, u]));
const openerId = ticket.userId;
const minStaffRank = await getMinStaffRank();
const messages = [
{
@@ -86,7 +86,7 @@ export default async function AdminHelpTicketDetailPage({
...replies.map((r) => {
const user = userById.get(r.userId);
const isStaff =
r.userId !== openerId && (user?.rank ?? 0) >= STAFF_RANK_THRESHOLD;
r.userId !== openerId && (user?.rank ?? 0) >= minStaffRank;
return {
key: String(r.id),
userId: r.userId,
@@ -19,8 +19,10 @@ export interface HelpTicketRow {
export function HelpTicketsTable({
data,
basePath = "/admin/help-tickets",
}: {
data: PaginatedResult<HelpTicketRow>;
basePath?: string;
}) {
const t = useTranslations("pages.admin.helpTickets");
@@ -36,7 +38,7 @@ export function HelpTicketsTable({
#{row.id}
</span>
<Link
href={`/admin/help-tickets/${row.id}`}
href={`${basePath}/${row.id}`}
className="font-medium truncate hover:underline"
>
{row.title}
+14
View File
@@ -160,6 +160,20 @@ export default async function AdminHelpTicketsPage({
return (
<div className="space-y-6">
<div
className="rounded-lg border border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)] px-4 py-3 text-sm"
role="status"
>
<p className="m-0">{t("queueHint")}</p>
<p className="m-0 mt-1">
<a
href="/admin/tickets"
className="font-medium text-[var(--admin-accent)] underline-offset-2 hover:underline"
>
{t("queueOtherCta")}
</a>
</p>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
+14
View File
@@ -116,6 +116,20 @@ export default async function AdminTicketsPage({
return (
<div className="space-y-6">
<div
className="rounded-lg border border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)] px-4 py-3 text-sm"
role="status"
>
<p className="m-0">{t("queueHint")}</p>
<p className="m-0 mt-1">
<a
href="/admin/help-tickets"
className="font-medium text-[var(--admin-accent)] underline-offset-2 hover:underline"
>
{t("queueOtherCta")}
</a>
</p>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
<StatusCard
+2 -17
View File
@@ -33,22 +33,7 @@ export async function GET() {
const emulator = await rcon.send("ping", null).catch(() => false);
let smtp = null;
if (env.SMTP_HOST) {
const nodemailer = await import("nodemailer");
const test = nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_SECURE,
auth: env.SMTP_USER
? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD ?? "" }
: undefined,
});
smtp = await test
.verify()
.then(() => true)
.catch(() => false);
}
const resendAvailable = !!env.RESEND_API_KEY;
const degraded = !database || redisOk === false;
return apiJson({
@@ -56,7 +41,7 @@ export async function GET() {
database,
redis: redisOk,
emulator,
smtp,
resend: resendAvailable,
node: process.version,
uptime: Math.round(process.uptime()),
time: new Date().toISOString(),
+2 -2
View File
@@ -4,7 +4,7 @@ import { headers } from "next/headers";
import Script from "next/script";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import { ViewTransitions } from "next-view-transitions";
import type { ReactNode } from "react";
import { Toaster } from "sonner";
import { PwaRegister } from "@/components/pwa-register";
@@ -90,7 +90,7 @@ export default async function RootLayout({
>
<NextIntlClientProvider locale={locale} messages={messages}>
<ThemeVars nonce={nonce} />
<ViewTransitions>{children}</ViewTransitions>
{children}
<PwaRegister />
<SmoothScroll />
<Toaster
+129
View File
@@ -0,0 +1,129 @@
import { notFound } from "next/navigation";
import { AdminHelpTicketDetail } from "@/app/admin/help-tickets/[id]/admin-help-ticket-detail";
import { requireModPermission } from "@/lib/admin/guard";
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
import { positiveBigInt } from "@/lib/api";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function ModHelpTicketDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]);
const { session, permissions } = await getAdminContext();
const { id } = await params;
const ticketId = positiveBigInt(id);
if (!ticketId) notFound();
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: {
id: true,
userId: true,
title: true,
content: true,
open: true,
createdAt: true,
updatedAt: true,
},
});
if (!ticket) notFound();
const replies = await prisma.websiteHelpCenterTicketReplies.findMany({
where: { ticketId: ticket.id },
orderBy: { id: "asc" },
select: {
id: true,
userId: true,
content: true,
createdAt: true,
},
});
const authorIds = [
...new Set([
...(ticket.userId != null ? [ticket.userId] : []),
...replies.map((r) => r.userId),
]),
];
const users =
authorIds.length > 0
? await prisma.user.findMany({
where: { id: { in: authorIds } },
select: {
id: true,
username: true,
rank: true,
mail: true,
},
})
: [];
const userById = new Map(users.map((u) => [u.id, u]));
const openerId = ticket.userId;
const minStaffRank = await getMinStaffRank();
const messages = [
{
key: "opening",
userId: openerId ?? 0,
username:
openerId != null
? (userById.get(openerId)?.username ?? `#${openerId}`)
: "—",
isStaff: false,
content: ticket.content,
createdAt: (ticket.createdAt ?? new Date()).toISOString(),
},
...replies.map((r) => {
const user = userById.get(r.userId);
const isStaff =
r.userId !== openerId && (user?.rank ?? 0) >= minStaffRank;
return {
key: String(r.id),
userId: r.userId,
username: user?.username ?? `#${r.userId}`,
isStaff,
content: r.content,
createdAt: (r.createdAt ?? new Date()).toISOString(),
};
}),
];
const creator = openerId != null ? userById.get(openerId) : undefined;
const canEdit =
canAccess(permissions, PERMS.MOD_TICKETS_EDIT, session.user.rank) ||
canAccess(permissions, PERMS.TICKETS_EDIT, session.user.rank);
return (
<AdminHelpTicketDetail
ticket={{
id: String(ticket.id),
title: ticket.title,
open: ticket.open,
createdAt: (ticket.createdAt ?? new Date()).toISOString(),
updatedAt: (ticket.updatedAt ?? new Date()).toISOString(),
creator: creator
? {
id: creator.id,
username: creator.username,
rank: creator.rank,
mail: creator.mail ?? "",
}
: null,
}}
messages={messages}
canEdit={canEdit}
listHref="/mod/help-tickets"
hideRequesterContact
/>
);
}
+184
View File
@@ -0,0 +1,184 @@
import { getTranslations } from "next-intl/server";
import {
type HelpTicketRow,
HelpTicketsTable,
} from "@/app/admin/help-tickets/help-tickets-table";
import { StatusCard } from "@/components/admin/dashboard";
import type { Prisma } from "@/generated/prisma/client";
import { requireModPermission } from "@/lib/admin/guard";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { formatDate } from "@/lib/format-date";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function ModHelpTicketsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
}) {
await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]);
const t = await getTranslations("pages.admin.helpTickets");
const raw = await searchParams;
const parsed = parseListParams(new URLSearchParams(raw));
const statusFilter = raw.filter_status || "open";
const conditions: Prisma.WebsiteHelpCenterTicketsWhereInput[] = [];
if (statusFilter === "open") {
conditions.push({ open: true });
} else if (statusFilter === "closed") {
conditions.push({ open: false });
}
if (parsed.search.trim()) {
const q = parsed.search.trim();
const asId = /^\d+$/.test(q) ? BigInt(q) : null;
const matchingUsers = await prisma.user
.findMany({
where: { username: { contains: q } },
select: { id: true },
take: 50,
})
.catch(() => []);
conditions.push({
OR: [
{ title: { contains: q } },
{ content: { contains: q } },
...(matchingUsers.length > 0
? [{ userId: { in: matchingUsers.map((u) => u.id) } }]
: []),
...(asId !== null ? [{ id: asId }] : []),
],
});
}
const where: Prisma.WebsiteHelpCenterTicketsWhereInput =
conditions.length === 0
? {}
: conditions.length === 1
? conditions[0]
: { AND: conditions };
const SORT_MAP: Record<
string,
Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput
> = {
title: { title: "asc" },
open: { open: "desc" },
user: { userId: "asc" },
date: { createdAt: "desc" },
updated: { updatedAt: "desc" },
id: { id: "desc" },
};
const baseOrder = SORT_MAP[parsed.sort ?? "updated"] ?? { updatedAt: "desc" };
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
const orderBy = {
[orderField]: parsed.order,
} as Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput;
const [total, openCount, closedCount] = await Promise.all([
prisma.websiteHelpCenterTickets.count({ where }).catch(() => 0),
prisma.websiteHelpCenterTickets
.count({ where: { open: true } })
.catch(() => 0),
prisma.websiteHelpCenterTickets
.count({ where: { open: false } })
.catch(() => 0),
]);
const pagination = calcPagination(total, parsed.page, parsed.perPage);
const tickets = await prisma.websiteHelpCenterTickets
.findMany({
where,
orderBy,
skip: pagination.offset,
take: pagination.perPage,
select: {
id: true,
userId: true,
title: true,
open: true,
createdAt: true,
updatedAt: true,
},
})
.catch(() => []);
const ticketIds = tickets.map((ticket) => ticket.id);
const userIds = [
...new Set(
tickets
.map((ticket) => ticket.userId)
.filter((id): id is number => id != null),
),
];
const [replyGroups, users] = await Promise.all([
ticketIds.length > 0
? prisma.websiteHelpCenterTicketReplies
.groupBy({
by: ["ticketId"],
where: { ticketId: { in: ticketIds } },
_count: true,
})
.catch(() => [])
: Promise.resolve([]),
userIds.length > 0
? prisma.user
.findMany({
where: { id: { in: userIds } },
select: { id: true, username: true },
})
.catch(() => [])
: Promise.resolve([]),
]);
const replyCountByTicket = new Map(
replyGroups.map((g) => [String(g.ticketId), g._count]),
);
const usernameById = new Map(users.map((u) => [u.id, u.username]));
const rows: HelpTicketRow[] = tickets.map((ticket) => ({
id: String(ticket.id),
title: ticket.title,
user:
ticket.userId != null
? (usernameById.get(ticket.userId) ?? `#${ticket.userId}`)
: "—",
userId: ticket.userId,
replies: replyCountByTicket.get(String(ticket.id)) ?? 0,
open: ticket.open,
date: formatDate(ticket.createdAt, "date"),
updated: formatDate(ticket.updatedAt, "date"),
}));
return (
<div className="space-y-6">
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
</div>
{total === 0 && !parsed.search && statusFilter === "open" ? (
<div className="admin-empty">{t("noTickets")}</div>
) : (
<HelpTicketsTable
basePath="/mod/help-tickets"
data={{
rows,
total,
page: pagination.page,
perPage: pagination.perPage,
lastPage: pagination.lastPage,
}}
/>
)}
</div>
);
}
+16 -1
View File
@@ -1,4 +1,13 @@
import { Ban, Gavel, LogOut, Radio, Shield, Ticket, Users } from "lucide-react";
import {
Ban,
Gavel,
LifeBuoy,
LogOut,
Radio,
Shield,
Ticket,
Users,
} from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
@@ -59,6 +68,12 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
icon: Ticket,
show: showTickets,
},
{
href: "/mod/help-tickets",
label: t("nav.helpTickets"),
icon: LifeBuoy,
show: showTickets,
},
{
href: "/mod/actions",
label: t("nav.actions"),
+59 -27
View File
@@ -1,4 +1,12 @@
import { Ban, Gavel, Radio, Shield, Ticket, Users } from "lucide-react";
import {
Ban,
Gavel,
LifeBuoy,
Radio,
Shield,
Ticket,
Users,
} from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { StatsCard } from "@/components/admin/stats-card";
@@ -30,24 +38,30 @@ export default async function ModDashboardPage() {
canAccess(permissions, PERMS.MOD_TEAM_VIEW, rank) ||
canAccess(permissions, PERMS.MODERATION_VIEW, rank);
const [openCfh, todayBans, modsOnline, openTickets] = await Promise.all([
showCfh
? prisma.supportTickets.count({ where: { state: 0 } }).catch(() => 0)
: Promise.resolve(0),
showBans
? prisma.ban
.count({ where: { timestamp: { gte: todayStart } } })
.catch(() => 0)
: Promise.resolve(0),
prisma.user
.count({ where: { online: "1", rank: { gte: minStaffRank } } })
.catch(() => 0),
showTickets
? prisma.websiteTicket
.count({ where: { status: { not: "closed" } } })
.catch(() => 0)
: Promise.resolve(0),
]);
const [openCfh, todayBans, modsOnline, openTickets, openHelpTickets] =
await Promise.all([
showCfh
? prisma.supportTickets.count({ where: { state: 0 } }).catch(() => 0)
: Promise.resolve(0),
showBans
? prisma.ban
.count({ where: { timestamp: { gte: todayStart } } })
.catch(() => 0)
: Promise.resolve(0),
prisma.user
.count({ where: { online: "1", rank: { gte: minStaffRank } } })
.catch(() => 0),
showTickets
? prisma.websiteTicket
.count({ where: { status: { not: "closed" } } })
.catch(() => 0)
: Promise.resolve(0),
showTickets
? prisma.websiteHelpCenterTickets
.count({ where: { open: true } })
.catch(() => 0)
: Promise.resolve(0),
]);
return (
<div className="space-y-6">
@@ -75,6 +89,14 @@ export default async function ModDashboardPage() {
href="/mod/tickets"
/>
) : null}
{showTickets ? (
<StatsCard
title={t("stats.openHelpTickets")}
value={openHelpTickets}
icon={<LifeBuoy className="h-4 w-4" />}
href="/mod/help-tickets"
/>
) : null}
{showBans ? (
<StatsCard
title={t("stats.bansToday")}
@@ -120,14 +142,24 @@ export default async function ModDashboardPage() {
</li>
) : null}
{showTickets ? (
<li>
<Link
href="/mod/tickets"
className="text-primary hover:underline"
>
{t("nav.tickets")}
</Link>
</li>
<>
<li>
<Link
href="/mod/tickets"
className="text-primary hover:underline"
>
{t("nav.tickets")}
</Link>
</li>
<li>
<Link
href="/mod/help-tickets"
className="text-primary hover:underline"
>
{t("nav.helpTickets")}
</Link>
</li>
</>
) : null}
{showActions ? (
<li>
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { motion, useInView } from "framer-motion";
import { motion, useInView } from "motion/react";
import { useEffect, useRef, useState } from "react";
export function AnimatedCounter({
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
const cmsDetails = [
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { type ReactNode, useRef, useState } from "react";
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, motion } from "motion/react";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { pageTransition } from "@/lib/motion";
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import type { ReactNode } from "react";
interface RevealProps {
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { dropdownVariants } from "@/lib/motion";
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useState } from "react";
/**
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEventSource } from "@/lib/use-event-source";
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { useEffect, useState } from "react";
export function TypewriterText({
+1 -7
View File
@@ -50,17 +50,11 @@ const schema = z
// Laravel APP_KEY (base64:...) — needed to read existing 2FA secrets.
APP_KEY: z.string().optional(),
// Mirrors Laravel config('habbo.site.convert_passwords') — enables md5->argon2id.
// Mirrors Laravel config('habbo.site.convert_passwords') — enables md5->bcrypt upgrade.
CONVERT_PASSWORDS: z
.string()
.optional()
.transform((v) => v === "true" || v === "1"),
// Hashing driver for NEW passwords: bcrypt (default, fits varchar(64)) | argon2id.
PASSWORD_HASH: z.enum(["bcrypt", "argon2id"]).optional(),
// Argon2id parameters (mirrors config/hashing.php).
ARGON2_PARALLELISM: z.coerce.number().int().positive().default(1),
ARGON2_ITERATIONS: z.coerce.number().int().positive().default(4),
ARGON2_MEMORY_SIZE: z.coerce.number().int().positive().default(65536),
// Bcrypt cost factor (rounds).
BCRYPT_ROUNDS: z.coerce.number().int().positive().default(12),
// Filesystem dir the badge uploader writes <code>.gif into (the emulator's
+34
View File
@@ -21,6 +21,7 @@ const ROUTES: Array<[string, string]> = [
["shop", "PERMS.SHOP_VIEW"],
["transactions", "PERMS.SHOP_VIEW"],
["vouchers", "PERMS.SHOP_VIEW"],
["marketplace", "PERMS.SHOP_VIEW"],
["vpn", "PERMS.SETTINGS_VIEW"],
["ip", "PERMS.SETTINGS_VIEW"],
["maintenance", "PERMS.SETTINGS_VIEW"],
@@ -32,11 +33,26 @@ const ROUTES: Array<[string, string]> = [
["badges", "PERMS.CATALOG_VIEW"],
["teams", "PERMS.USERS_VIEW"],
["applications", "PERMS.USERS_VIEW"],
["guilds", "PERMS.USERS_VIEW"],
["tickets", "PERMS.TICKETS_VIEW"],
["help-tickets", "PERMS.TICKETS_VIEW"],
["permissions", "PERMS.PERMISSIONS_MANAGE"],
["housekeeping", "PERMS.SETTINGS_VIEW"],
["logs", "PERMS.LOGS_VIEW"],
["catalog", "PERMS.CATALOG_VIEW"],
["rooms/edit/[id]", "PERMS.ROOMS_EDIT"],
];
const MOD_ROUTES: Array<[string, string]> = [
["", "requireMod"],
["cfh", "PERMS.MOD_CFH_VIEW"],
["actions", "PERMS.MOD_ACTIONS"],
["bans", "PERMS.MOD_BANS_VIEW"],
["tickets", "PERMS.MOD_TICKETS_VIEW"],
["help-tickets", "PERMS.MOD_TICKETS_VIEW"],
["team", "PERMS.MOD_TEAM_VIEW"],
];
const ACTION_GATES: Array<[string, string]> = [
["admin-settings.ts", "PERMS.SETTINGS_EDIT"],
["admin-theme.ts", "PERMS.SETTINGS_EDIT"],
@@ -54,6 +70,11 @@ const ACTION_GATES: Array<[string, string]> = [
["admin-alerts.ts", "PERMS.NOTIFICATIONS_EDIT"],
["commandocentrum.ts", "PERMS.RCON_EXECUTE"],
["translations.ts", "PERMS.SETTINGS_EDIT"],
["tickets.ts", "PERMS.TICKETS_EDIT"],
["admin-help-tickets.ts", "PERMS.TICKETS_EDIT"],
["permissions.ts", "PERMS.PERMISSIONS_MANAGE"],
["admin-guilds.ts", "PERMS.USERS_EDIT"],
["moderation.ts", "PERMS.MODERATION_EDIT"],
];
describe("admin operations route contract", () => {
@@ -63,6 +84,19 @@ describe("admin operations route contract", () => {
expect(readFileSync(path, "utf8"), path).toContain(permission);
});
it.each(MOD_ROUTES)("provides and guards /mod/%s", (route, permission) => {
const path =
route === "" ? "src/app/mod/page.tsx" : `src/app/mod/${route}/page.tsx`;
expect(existsSync(path), path).toBe(true);
const source = readFileSync(path, "utf8");
const layout = readFileSync("src/app/mod/layout.tsx", "utf8");
if (permission === "requireMod") {
expect(layout).toContain("requireMod");
} else {
expect(source).toContain(permission);
}
});
it("guards radio section via layout", () => {
const path = "src/app/admin/radio/layout.tsx";
expect(existsSync(path), path).toBe(true);
+1 -1
View File
@@ -96,7 +96,7 @@ export const { handlers, signOut, auth } = NextAuth({
return null;
}
// Byte-compatible AtomCMS check (argon2id/bcrypt + md5->argon2id upgrade).
// Byte-compatible AtomCMS check (bcrypt + md5->bcrypt upgrade).
const res = await checkLogin(password, user.password, {
convertPasswords: env.CONVERT_PASSWORDS,
});
+13 -37
View File
@@ -1,11 +1,6 @@
import { hash as bcryptHash } from "bcrypt";
import { describe, expect, it, vi } from "vitest";
const mockEnv = vi.hoisted(() => ({
PASSWORD_HASH: undefined as string | undefined,
ARGON2_PARALLELISM: 1,
ARGON2_ITERATIONS: 4,
ARGON2_MEMORY_SIZE: 65536,
BCRYPT_ROUNDS: 12,
}));
@@ -28,36 +23,20 @@ describe("md5Hex", () => {
});
});
describe("hashPassword (default driver: bcrypt)", () => {
it("emits a PHP-style $2y$ bcrypt hash that fits varchar(64) and round-trips", async () => {
mockEnv.PASSWORD_HASH = undefined;
describe("hashPassword", () => {
it("emits a bcrypt hash and round-trips", async () => {
const h = await hashPassword("s3cret!");
expect(h).toMatch(/^\$2y\$\d{2}\$/);
expect(await verifyPassword("s3cret!", h)).toBe(true);
expect(await verifyPassword("wrong", h)).toBe(false);
});
});
describe("verifyPassword", () => {
it("verifies legacy bcrypt hashes ($2y$)", async () => {
const h = await hashPassword("hunter2");
expect(h).toMatch(/^\$2y\$/);
expect(h.length).toBeLessThanOrEqual(60);
expect(await verifyPassword("s3cret!", h)).toBe(true);
expect(await verifyPassword("wrong", h)).toBe(false);
});
});
describe("hashPassword (PASSWORD_HASH=argon2id)", () => {
it("hashes with the AtomCMS params and round-trips", async () => {
mockEnv.PASSWORD_HASH = "argon2id";
mockEnv.ARGON2_MEMORY_SIZE = 1024;
mockEnv.ARGON2_ITERATIONS = 1;
const h = await hashPassword("s3cret!");
expect(h).toMatch(/^\$argon2id\$v=19\$m=1024,t=1,p=1\$/);
expect(await verifyPassword("s3cret!", h)).toBe(true);
expect(await verifyPassword("wrong", h)).toBe(false);
});
});
describe("bcrypt", () => {
it("verifies a bcrypt hash and accepts the PHP $2y$ prefix", async () => {
mockEnv.BCRYPT_ROUNDS = 4;
const h = await bcryptHash("hunter2", 4);
expect(await verifyPassword("hunter2", h)).toBe(true);
const phpStyle = h.replace(/^\$2[ab]\$/, "$2y$");
expect(await verifyPassword("hunter2", phpStyle)).toBe(true);
expect(await verifyPassword("nope", h)).toBe(false);
});
});
@@ -71,13 +50,11 @@ describe("isMd5Of", () => {
});
describe("checkLogin", () => {
it("upgrades a legacy md5 hash to the configured hash when conversion is enabled", async () => {
mockEnv.PASSWORD_HASH = undefined;
it("upgrades a legacy md5 hash to bcrypt when conversion is enabled", async () => {
const stored = await md5Hex("oldpass");
const res = await checkLogin("oldpass", stored, { convertPasswords: true });
expect(res.valid).toBe(true);
expect(res.upgradedHash).toMatch(/^\$2y\$/);
expect((res.upgradedHash as string).length).toBeLessThanOrEqual(60);
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
true,
);
@@ -93,10 +70,9 @@ describe("checkLogin", () => {
});
it("validates an existing modern hash with no upgrade", async () => {
mockEnv.PASSWORD_HASH = undefined;
const stored = await hashPassword("modern");
const res = await checkLogin("modern", stored, { convertPasswords: true });
expect(res.valid).toBe(true);
expect(res.upgradedHash).toBeUndefined();
});
});
});
+15 -75
View File
@@ -1,66 +1,26 @@
import { randomBytes } from "node:crypto";
import { compare as bcryptCompare, hash as bcryptHash } from "bcrypt";
import { argon2id, argon2Verify, md5 } from "hash-wasm";
import {
bcrypt,
bcryptVerify,
md5,
} from "hash-wasm";
import { env } from "@/env";
// AtomCMS hashing (config/hashing.php): argon2id with memory=65536 KiB, time=4,
// threads=1; bcrypt rounds=12 as the legacy fallback. The game emulator
// validates the SAME users.password hash, so these must match.
function argon2Params() {
return {
parallelism: env.ARGON2_PARALLELISM,
iterations: env.ARGON2_ITERATIONS,
memorySize: env.ARGON2_MEMORY_SIZE,
hashLength: 32,
} as const;
export async function hashPassword(password: string): Promise<string> {
const h = await bcrypt({
password,
salt: randomBytes(16),
costFactor: env.BCRYPT_ROUNDS,
outputType: "encoded",
});
return h.replace(/^\$2[ab]\$/, "$2y$");
}
function bcryptRounds(): number {
return env.BCRYPT_ROUNDS;
}
// Which algorithm hashPassword() emits for NEW/upgraded passwords.
// - "bcrypt" (DEFAULT): 60-char $2y$ hash. Fits varchar(255) users.password.
// - "argon2id": ~97-char PHC hash. Opt in with PASSWORD_HASH=argon2id.
// verifyPassword() always accepts BOTH, so logins keep working either way.
function hashDriver(): "bcrypt" | "argon2id" {
return env.PASSWORD_HASH === "argon2id" ? "argon2id" : "bcrypt";
}
/**
* Lowercase hex md5 of a UTF-8 string (matches PHP md5()).
*
* This uses hash-wasm's MD5 (not node:crypto) to match PHP's md5() output,
* enabling verification of legacy AtomCMS password hashes during the on-login
* upgrade path (isMd5Of → checkLogin). It is NOT used to hash new passwords
* and does NOT affect credential security.
*/
export async function md5Hex(input: string): Promise<string> {
return await md5(input);
}
/**
* Hash a new password with the configured driver. Defaults to bcrypt ($2y$,
* rounds=12) so the result fits a varchar(64) column; set PASSWORD_HASH=argon2id
* for argon2id (requires a wider column). Both are verifiable by verifyPassword.
*/
export async function hashPassword(password: string): Promise<string> {
if (hashDriver() === "argon2id") {
return argon2id({
password,
salt: randomBytes(16),
outputType: "encoded",
...argon2Params(),
});
}
// native bcrypt emits $2a$/$2b$; normalise to the PHP-canonical $2y$ the
// emulator and existing AtomCMS rows use.
const h = await bcryptHash(password, bcryptRounds());
return h.replace(/^\$2[ab]\$/, "$2y$");
}
/** True when `stored` is exactly the md5 of `password` (legacy AtomCMS accounts). */
export async function isMd5Of(
password: string,
stored: string,
@@ -71,27 +31,13 @@ export async function isMd5Of(
);
}
/**
* Verify a password against a stored hash, auto-detecting the algorithm the way
* Laravel's Hash::check does. Returns false for unknown/legacy formats (md5 is
* handled by the conversion path in checkLogin, not here).
*/
export async function verifyPassword(
password: string,
stored: string,
): Promise<boolean> {
if (stored.startsWith("$argon2")) {
try {
return await argon2Verify({ password, hash: stored });
} catch {
return false;
}
}
if (/^\$2[aby]\$/.test(stored)) {
try {
// PHP/AtomCMS store $2y$; native bcrypt only accepts $2a$/$2b$.
const normalized = stored.replace(/^\$2y\$/, "$2a$");
return await bcryptCompare(password, normalized);
return await bcryptVerify({ password, hash: stored });
} catch {
return false;
}
@@ -101,15 +47,9 @@ export async function verifyPassword(
export interface LoginCheck {
valid: boolean;
/** Set when a legacy md5 hash was upgraded — persist it to users.password. */
upgradedHash?: string;
}
/**
* Full AtomCMS credential check including the md5 -> argon2id on-login upgrade
* (gated by `convertPasswords`, i.e. config('habbo.site.convert_passwords')).
* Mirrors RedirectIfTwoFactorAuthenticatable::convertUserPassword + validate.
*/
export async function checkLogin(
password: string,
stored: string,
@@ -119,4 +59,4 @@ export async function checkLogin(
return { valid: true, upgradedHash: await hashPassword(password) };
}
return { valid: await verifyPassword(password, stored) };
}
}
+7 -2
View File
@@ -68,7 +68,10 @@ describe("production deploy workflow", () => {
const stopAt = deployJob.indexOf("pm2 stop next");
const migrateAt = deployJob.indexOf("pnpm db:migrate");
const startLabelAt = deployJob.indexOf("Starting PM2");
const startAt = deployJob.indexOf("pm2 reload next", startLabelAt);
const startAt = deployJob.indexOf(
"pm2 start pnpm --name next -- start",
startLabelAt,
);
expect(buildAt).toBeGreaterThan(-1);
expect(stopAt).toBeGreaterThan(buildAt);
expect(migrateAt).toBeGreaterThan(stopAt);
@@ -103,7 +106,9 @@ describe("production deploy workflow", () => {
it("runs an HTTP health check before declaring deploy success", () => {
expect(workflow).toContain("/api/health");
expect(workflow).toContain('"database":true');
const startAt = deployJob.indexOf("pm2 reload next");
expect(deployJob).toContain("export PORT=");
expect(deployJob).toContain("free_tcp_port");
const startAt = deployJob.indexOf("pm2 start pnpm --name next -- start");
const healthAt = deployJob.indexOf("/api/health");
const successAt = deployJob.indexOf("--- Deployed successfully ---");
expect(startAt).toBeGreaterThan(-1);
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Variants } from "framer-motion";
import type { Variants } from "motion/react";
export const pageTransition: Variants = {
initial: { opacity: 0, y: 8 },
+3 -71
View File
@@ -1,76 +1,8 @@
import sanitizeHtml from "sanitize-html";
import DOMPurify from "isomorphic-dompurify";
/**
* Server-side HTML sanitiser for user/staff-authored rich content before it is
* injected via dangerouslySetInnerHTML — the AtomCMS HTMLPurifier equivalent.
* Allows a safe formatting subset (no <script>/<style>/<iframe>, no on* event
* handlers, no javascript: URLs); images/links are permitted with safe schemes.
*/
const OPTIONS: sanitizeHtml.IOptions = {
allowedTags: [
"a",
"b",
"i",
"em",
"strong",
"u",
"s",
"p",
"br",
"hr",
"span",
"div",
"ul",
"ol",
"li",
"blockquote",
"code",
"pre",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"img",
"figure",
"figcaption",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
],
allowedAttributes: {
a: ["href", "title", "target", "rel"],
img: ["src", "alt", "title", "width", "height"],
"*": ["style", "class"],
},
allowedSchemes: ["http", "https", "mailto"],
allowedSchemesByTag: { img: ["http", "https", "data"] },
// Drop any style declarations that aren't simple, safe properties.
allowedStyles: {
"*": {
color: [/.*/],
"background-color": [/.*/],
"text-align": [/^left$|^right$|^center$|^justify$/],
"font-weight": [/.*/],
"font-style": [/.*/],
"text-decoration": [/.*/],
"font-size": [/.*/],
margin: [/.*/],
padding: [/.*/],
},
},
transformTags: {
a: sanitizeHtml.simpleTransform("a", {
rel: "noopener noreferrer nofollow",
}),
},
};
const sanitizeHtml = (html: string) => DOMPurify.sanitize(html);
export function sanitize(html: string | null | undefined): string {
if (!html) return "";
return sanitizeHtml(html, OPTIONS);
return sanitizeHtml(html);
}
+1 -31
View File
@@ -1,29 +1,12 @@
import { exec } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import nodemailer, { type Transporter } from "nodemailer";
import { Resend } from "resend";
import { env } from "@/env";
import { logger } from "@/lib/logger";
let transporter: Transporter | null = null;
let resend: Resend | null = null;
function getTransport(): Transporter | null {
if (!env.SMTP_HOST) return null;
if (!transporter) {
transporter = nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT ?? 587,
secure: env.SMTP_SECURE,
auth: env.SMTP_USER
? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD }
: undefined,
});
}
return transporter;
}
function getResend(): Resend | null {
if (!env.RESEND_API_KEY) return null;
if (!resend) resend = new Resend(env.RESEND_API_KEY);
@@ -90,7 +73,7 @@ async function writeToFile(
}
}
/** Send an HTML email. Tries Resend → SMTP → local sendmail → file fallback. Always returns true. */
/** Send an HTML email. Tries Resend → local sendmail → file fallback. Always returns true. */
export async function sendMail(
to: string,
subject: string,
@@ -111,19 +94,6 @@ export async function sendMail(
}
}
const t = getTransport();
if (t) {
try {
await t.sendMail({ from, to, subject, html });
return true;
} catch (e) {
logger.error("SMTP failed", {
module: "email",
error: (e as Error).message,
});
}
}
const ok = await sendViaSendmail(to, subject, html, from);
if (ok) return true;
+82 -5
View File
@@ -23,7 +23,8 @@
"closeMenu": "Затваряне на менюто",
"drawBadge": "Начертайте значка",
"events": "Events",
"polls": "Polls"
"polls": "Polls",
"mod": "Mod"
},
"header": {
"online": "{count} {hotel} онлайн",
@@ -906,7 +907,8 @@
"chatLog": "Chat log",
"commandLog": "Command log",
"tradeLog": "Trade log",
"tools": "Tools"
"tools": "Tools",
"guilds": "Guilds"
},
"dashboard": {
"title": "Табло за управление",
@@ -2213,7 +2215,24 @@
"auditTarget": "Цел",
"selectAllMatching": "Select all {count} matching",
"save": "Save",
"importTitle": "Import permissions"
"importTitle": "Import permissions",
"legacyBannerTitle": "Use Permissions to change who can do what",
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
"legacyBannerCta": "Open Permissions",
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
"tabCompare": "ACL compare",
"tabExportOnly": "Export archive",
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
"compareAligned": "{count} aligned",
"compareMissingAcl": "{count} missing in ACL",
"compareMismatch": "{count} rank mismatch",
"colAclPresent": "In ACL",
"colAclMinRank": "ACL min rank",
"colStatus": "Status",
"statusAligned": "Aligned",
"statusMissingAcl": "Missing in ACL",
"statusMismatch": "Rank differs",
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
},
"rooms": {
"title": "Стаи",
@@ -2442,7 +2461,9 @@
"colPriority": "Priority",
"colAssigned": "Assigned",
"colDate": "Date",
"meta": "by {user} · {count} msg · {category}"
"meta": "by {user} · {count} msg · {category}",
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
"queueOtherCta": "Open help-center tickets"
},
"cfh": {
"title": "Call for Help",
@@ -2704,7 +2725,9 @@
"replied": "Reply sent.",
"closed": "Ticket closed.",
"reopened": "Ticket reopened."
}
},
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
"queueOtherCta": "Open CMS support desk"
},
"marketplace": {
"title": "Marketplace",
@@ -2799,6 +2822,31 @@
"less": "Less",
"more": "More"
}
},
"guilds": {
"title": "Guilds",
"subtitle": "Hotel groups and membership",
"totalGuilds": "Guilds",
"searchPlaceholder": "Search by name, description or ID…",
"search": "Search",
"empty": "No guilds found.",
"loadError": "Could not load guilds (database unavailable).",
"colId": "ID",
"colBadge": "Badge",
"colName": "Name",
"colOwner": "Owner",
"colMembers": "Members",
"colCreated": "Created",
"colThreads": "Forum threads",
"colRoom": "Room",
"colUser": "User",
"colLevel": "Level",
"colJoined": "Joined",
"members": "Members",
"noMembers": "No members.",
"back": "Back to guilds",
"viewPublic": "Open public page",
"disband": "Disband guild"
}
},
"error": {
@@ -2944,6 +2992,35 @@
"voteCount": "{count} ({pct}%)",
"totalVotes": "{count} total votes",
"textResponses": "{count} free-text responses"
},
"mod": {
"title": "Moderation panel",
"badge": "Mod",
"overviewTitle": "Moderator overview",
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
"shortcutsTitle": "Shortcuts",
"nav": {
"overview": "Overview",
"cfh": "Call for Help",
"actions": "Quick actions",
"bans": "Bans",
"fullAdmin": "Full admin",
"backToSite": "Back to site",
"tickets": "Tickets",
"team": "Team",
"helpTickets": "Help-center tickets"
},
"stats": {
"openCfh": "Open CFH",
"bansToday": "Bans today",
"modsOnline": "Mods online",
"quickActions": "Quick actions",
"quickActionsHint": "Kick, mute, alert",
"openTickets": "Open tickets",
"team": "Mod team",
"teamHint": "Online staff & workload",
"openHelpTickets": "Open help tickets"
}
}
},
"emails": {
+82 -5
View File
@@ -23,7 +23,8 @@
"closeMenu": "Zavřít nabídku",
"drawBadge": "Nakreslete odznak",
"events": "Events",
"polls": "Polls"
"polls": "Polls",
"mod": "Mod"
},
"header": {
"online": "{count} {hotel} online",
@@ -906,7 +907,8 @@
"chatLog": "Chat log",
"commandLog": "Command log",
"tradeLog": "Trade log",
"tools": "Tools"
"tools": "Tools",
"guilds": "Guilds"
},
"dashboard": {
"title": "Dashboard",
@@ -2213,7 +2215,24 @@
"auditTarget": "Cíl",
"selectAllMatching": "Select all {count} matching",
"save": "Save",
"importTitle": "Import permissions"
"importTitle": "Import permissions",
"legacyBannerTitle": "Use Permissions to change who can do what",
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
"legacyBannerCta": "Open Permissions",
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
"tabCompare": "ACL compare",
"tabExportOnly": "Export archive",
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
"compareAligned": "{count} aligned",
"compareMissingAcl": "{count} missing in ACL",
"compareMismatch": "{count} rank mismatch",
"colAclPresent": "In ACL",
"colAclMinRank": "ACL min rank",
"colStatus": "Status",
"statusAligned": "Aligned",
"statusMissingAcl": "Missing in ACL",
"statusMismatch": "Rank differs",
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
},
"rooms": {
"title": "Pokoje",
@@ -2442,7 +2461,9 @@
"colPriority": "Priority",
"colAssigned": "Assigned",
"colDate": "Date",
"meta": "by {user} · {count} msg · {category}"
"meta": "by {user} · {count} msg · {category}",
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
"queueOtherCta": "Open help-center tickets"
},
"cfh": {
"title": "Call for Help",
@@ -2704,7 +2725,9 @@
"replied": "Reply sent.",
"closed": "Ticket closed.",
"reopened": "Ticket reopened."
}
},
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
"queueOtherCta": "Open CMS support desk"
},
"marketplace": {
"title": "Marketplace",
@@ -2799,6 +2822,31 @@
"less": "Less",
"more": "More"
}
},
"guilds": {
"title": "Guilds",
"subtitle": "Hotel groups and membership",
"totalGuilds": "Guilds",
"searchPlaceholder": "Search by name, description or ID…",
"search": "Search",
"empty": "No guilds found.",
"loadError": "Could not load guilds (database unavailable).",
"colId": "ID",
"colBadge": "Badge",
"colName": "Name",
"colOwner": "Owner",
"colMembers": "Members",
"colCreated": "Created",
"colThreads": "Forum threads",
"colRoom": "Room",
"colUser": "User",
"colLevel": "Level",
"colJoined": "Joined",
"members": "Members",
"noMembers": "No members.",
"back": "Back to guilds",
"viewPublic": "Open public page",
"disband": "Disband guild"
}
},
"error": {
@@ -2944,6 +2992,35 @@
"voteCount": "{count} ({pct}%)",
"totalVotes": "{count} total votes",
"textResponses": "{count} free-text responses"
},
"mod": {
"title": "Moderation panel",
"badge": "Mod",
"overviewTitle": "Moderator overview",
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
"shortcutsTitle": "Shortcuts",
"nav": {
"overview": "Overview",
"cfh": "Call for Help",
"actions": "Quick actions",
"bans": "Bans",
"fullAdmin": "Full admin",
"backToSite": "Back to site",
"tickets": "Tickets",
"team": "Team",
"helpTickets": "Help-center tickets"
},
"stats": {
"openCfh": "Open CFH",
"bansToday": "Bans today",
"modsOnline": "Mods online",
"quickActions": "Quick actions",
"quickActionsHint": "Kick, mute, alert",
"openTickets": "Open tickets",
"team": "Mod team",
"teamHint": "Online staff & workload",
"openHelpTickets": "Open help tickets"
}
}
},
"emails": {
+82 -5
View File
@@ -23,7 +23,8 @@
"closeMenu": "Luk menuen",
"drawBadge": "Tegn badge",
"events": "Events",
"polls": "Polls"
"polls": "Polls",
"mod": "Mod"
},
"header": {
"online": "{count} {hotel} online",
@@ -906,7 +907,8 @@
"chatLog": "Chat log",
"commandLog": "Command log",
"tradeLog": "Trade log",
"tools": "Tools"
"tools": "Tools",
"guilds": "Guilds"
},
"dashboard": {
"title": "Dashboard",
@@ -2213,7 +2215,24 @@
"auditTarget": "Mål",
"selectAllMatching": "Select all {count} matching",
"save": "Save",
"importTitle": "Import permissions"
"importTitle": "Import permissions",
"legacyBannerTitle": "Use Permissions to change who can do what",
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
"legacyBannerCta": "Open Permissions",
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
"tabCompare": "ACL compare",
"tabExportOnly": "Export archive",
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
"compareAligned": "{count} aligned",
"compareMissingAcl": "{count} missing in ACL",
"compareMismatch": "{count} rank mismatch",
"colAclPresent": "In ACL",
"colAclMinRank": "ACL min rank",
"colStatus": "Status",
"statusAligned": "Aligned",
"statusMissingAcl": "Missing in ACL",
"statusMismatch": "Rank differs",
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
},
"rooms": {
"title": "Værelser",
@@ -2442,7 +2461,9 @@
"colPriority": "Priority",
"colAssigned": "Assigned",
"colDate": "Date",
"meta": "by {user} · {count} msg · {category}"
"meta": "by {user} · {count} msg · {category}",
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
"queueOtherCta": "Open help-center tickets"
},
"cfh": {
"title": "Call for Help",
@@ -2704,7 +2725,9 @@
"replied": "Reply sent.",
"closed": "Ticket closed.",
"reopened": "Ticket reopened."
}
},
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
"queueOtherCta": "Open CMS support desk"
},
"marketplace": {
"title": "Marketplace",
@@ -2799,6 +2822,31 @@
"less": "Less",
"more": "More"
}
},
"guilds": {
"title": "Guilds",
"subtitle": "Hotel groups and membership",
"totalGuilds": "Guilds",
"searchPlaceholder": "Search by name, description or ID…",
"search": "Search",
"empty": "No guilds found.",
"loadError": "Could not load guilds (database unavailable).",
"colId": "ID",
"colBadge": "Badge",
"colName": "Name",
"colOwner": "Owner",
"colMembers": "Members",
"colCreated": "Created",
"colThreads": "Forum threads",
"colRoom": "Room",
"colUser": "User",
"colLevel": "Level",
"colJoined": "Joined",
"members": "Members",
"noMembers": "No members.",
"back": "Back to guilds",
"viewPublic": "Open public page",
"disband": "Disband guild"
}
},
"error": {
@@ -2944,6 +2992,35 @@
"voteCount": "{count} ({pct}%)",
"totalVotes": "{count} total votes",
"textResponses": "{count} free-text responses"
},
"mod": {
"title": "Moderation panel",
"badge": "Mod",
"overviewTitle": "Moderator overview",
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
"shortcutsTitle": "Shortcuts",
"nav": {
"overview": "Overview",
"cfh": "Call for Help",
"actions": "Quick actions",
"bans": "Bans",
"fullAdmin": "Full admin",
"backToSite": "Back to site",
"tickets": "Tickets",
"team": "Team",
"helpTickets": "Help-center tickets"
},
"stats": {
"openCfh": "Open CFH",
"bansToday": "Bans today",
"modsOnline": "Mods online",
"quickActions": "Quick actions",
"quickActionsHint": "Kick, mute, alert",
"openTickets": "Open tickets",
"team": "Mod team",
"teamHint": "Online staff & workload",
"openHelpTickets": "Open help tickets"
}
}
},
"emails": {
+82 -5
View File
@@ -23,7 +23,8 @@
"closeMenu": "Close menu",
"drawBadge": "Abzeichen zeichnen",
"events": "Events",
"polls": "Polls"
"polls": "Polls",
"mod": "Mod"
},
"header": {
"online": "{count} {hotel} online",
@@ -876,7 +877,8 @@
"chatLog": "Chat log",
"commandLog": "Command log",
"tradeLog": "Trade log",
"tools": "Tools"
"tools": "Tools",
"guilds": "Guilds"
},
"dashboard": {
"title": "Dashboard",
@@ -2183,7 +2185,24 @@
"auditTarget": "Ziel",
"selectAllMatching": "Select all {count} matching",
"save": "Save",
"importTitle": "Import permissions"
"importTitle": "Import permissions",
"legacyBannerTitle": "Use Permissions to change who can do what",
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
"legacyBannerCta": "Open Permissions",
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
"tabCompare": "ACL compare",
"tabExportOnly": "Export archive",
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
"compareAligned": "{count} aligned",
"compareMissingAcl": "{count} missing in ACL",
"compareMismatch": "{count} rank mismatch",
"colAclPresent": "In ACL",
"colAclMinRank": "ACL min rank",
"colStatus": "Status",
"statusAligned": "Aligned",
"statusMissingAcl": "Missing in ACL",
"statusMismatch": "Rank differs",
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
},
"rooms": {
"title": "Räume",
@@ -2412,7 +2431,9 @@
"colPriority": "Priority",
"colAssigned": "Assigned",
"colDate": "Date",
"meta": "by {user} · {count} msg · {category}"
"meta": "by {user} · {count} msg · {category}",
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
"queueOtherCta": "Open help-center tickets"
},
"cfh": {
"title": "Call for Help",
@@ -2674,7 +2695,9 @@
"replied": "Reply sent.",
"closed": "Ticket closed.",
"reopened": "Ticket reopened."
}
},
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
"queueOtherCta": "Open CMS support desk"
},
"marketplace": {
"title": "Marketplace",
@@ -2769,6 +2792,31 @@
"less": "Less",
"more": "More"
}
},
"guilds": {
"title": "Guilds",
"subtitle": "Hotel groups and membership",
"totalGuilds": "Guilds",
"searchPlaceholder": "Search by name, description or ID…",
"search": "Search",
"empty": "No guilds found.",
"loadError": "Could not load guilds (database unavailable).",
"colId": "ID",
"colBadge": "Badge",
"colName": "Name",
"colOwner": "Owner",
"colMembers": "Members",
"colCreated": "Created",
"colThreads": "Forum threads",
"colRoom": "Room",
"colUser": "User",
"colLevel": "Level",
"colJoined": "Joined",
"members": "Members",
"noMembers": "No members.",
"back": "Back to guilds",
"viewPublic": "Open public page",
"disband": "Disband guild"
}
},
"radioRequests": {
@@ -2944,6 +2992,35 @@
"voteCount": "{count} ({pct}%)",
"totalVotes": "{count} total votes",
"textResponses": "{count} free-text responses"
},
"mod": {
"title": "Moderation panel",
"badge": "Mod",
"overviewTitle": "Moderator overview",
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
"shortcutsTitle": "Shortcuts",
"nav": {
"overview": "Overview",
"cfh": "Call for Help",
"actions": "Quick actions",
"bans": "Bans",
"fullAdmin": "Full admin",
"backToSite": "Back to site",
"tickets": "Tickets",
"team": "Team",
"helpTickets": "Help-center tickets"
},
"stats": {
"openCfh": "Open CFH",
"bansToday": "Bans today",
"modsOnline": "Mods online",
"quickActions": "Quick actions",
"quickActionsHint": "Kick, mute, alert",
"openTickets": "Open tickets",
"team": "Mod team",
"teamHint": "Online staff & workload",
"openHelpTickets": "Open help tickets"
}
}
},
"emails": {
+82 -5
View File
@@ -23,7 +23,8 @@
"closeMenu": "Κλείσιμο μενού",
"drawBadge": "Σχεδιάστε το σήμα",
"events": "Events",
"polls": "Polls"
"polls": "Polls",
"mod": "Mod"
},
"header": {
"online": "{count} {hotel} στο διαδίκτυο",
@@ -906,7 +907,8 @@
"chatLog": "Chat log",
"commandLog": "Command log",
"tradeLog": "Trade log",
"tools": "Tools"
"tools": "Tools",
"guilds": "Guilds"
},
"dashboard": {
"title": "Ταμπλό",
@@ -2213,7 +2215,24 @@
"auditTarget": "Στόχος",
"selectAllMatching": "Select all {count} matching",
"save": "Save",
"importTitle": "Import permissions"
"importTitle": "Import permissions",
"legacyBannerTitle": "Use Permissions to change who can do what",
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
"legacyBannerCta": "Open Permissions",
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
"tabCompare": "ACL compare",
"tabExportOnly": "Export archive",
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
"compareAligned": "{count} aligned",
"compareMissingAcl": "{count} missing in ACL",
"compareMismatch": "{count} rank mismatch",
"colAclPresent": "In ACL",
"colAclMinRank": "ACL min rank",
"colStatus": "Status",
"statusAligned": "Aligned",
"statusMissingAcl": "Missing in ACL",
"statusMismatch": "Rank differs",
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
},
"rooms": {
"title": "Δωμάτια",
@@ -2442,7 +2461,9 @@
"colPriority": "Priority",
"colAssigned": "Assigned",
"colDate": "Date",
"meta": "by {user} · {count} msg · {category}"
"meta": "by {user} · {count} msg · {category}",
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
"queueOtherCta": "Open help-center tickets"
},
"cfh": {
"title": "Call for Help",
@@ -2704,7 +2725,9 @@
"replied": "Reply sent.",
"closed": "Ticket closed.",
"reopened": "Ticket reopened."
}
},
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
"queueOtherCta": "Open CMS support desk"
},
"marketplace": {
"title": "Marketplace",
@@ -2799,6 +2822,31 @@
"less": "Less",
"more": "More"
}
},
"guilds": {
"title": "Guilds",
"subtitle": "Hotel groups and membership",
"totalGuilds": "Guilds",
"searchPlaceholder": "Search by name, description or ID…",
"search": "Search",
"empty": "No guilds found.",
"loadError": "Could not load guilds (database unavailable).",
"colId": "ID",
"colBadge": "Badge",
"colName": "Name",
"colOwner": "Owner",
"colMembers": "Members",
"colCreated": "Created",
"colThreads": "Forum threads",
"colRoom": "Room",
"colUser": "User",
"colLevel": "Level",
"colJoined": "Joined",
"members": "Members",
"noMembers": "No members.",
"back": "Back to guilds",
"viewPublic": "Open public page",
"disband": "Disband guild"
}
},
"error": {
@@ -2944,6 +2992,35 @@
"voteCount": "{count} ({pct}%)",
"totalVotes": "{count} total votes",
"textResponses": "{count} free-text responses"
},
"mod": {
"title": "Moderation panel",
"badge": "Mod",
"overviewTitle": "Moderator overview",
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
"shortcutsTitle": "Shortcuts",
"nav": {
"overview": "Overview",
"cfh": "Call for Help",
"actions": "Quick actions",
"bans": "Bans",
"fullAdmin": "Full admin",
"backToSite": "Back to site",
"tickets": "Tickets",
"team": "Team",
"helpTickets": "Help-center tickets"
},
"stats": {
"openCfh": "Open CFH",
"bansToday": "Bans today",
"modsOnline": "Mods online",
"quickActions": "Quick actions",
"quickActionsHint": "Kick, mute, alert",
"openTickets": "Open tickets",
"team": "Mod team",
"teamHint": "Online staff & workload",
"openHelpTickets": "Open help tickets"
}
}
},
"emails": {
+12 -6
View File
@@ -1492,8 +1492,10 @@
"settingsSaved": "VPN settings saved successfully"
},
"tickets": {
"title": "Tickets",
"subtitle": "Support tickets from players",
"title": "CMS support desk",
"subtitle": "Internal CMS tickets (assignee, priority, staff workflow). Not the same as help-center tickets from /help.",
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
"queueOtherCta": "Open help-center tickets",
"noTickets": "No active tickets",
"searchPlaceholder": "Search subject, user, category or ID…",
"presetActive": "Active",
@@ -1510,7 +1512,9 @@
},
"helpTickets": {
"title": "Help center tickets",
"subtitle": "Player tickets from /help/tickets",
"subtitle": "Tickets players open at /help/tickets. Separate from the CMS support desk.",
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
"queueOtherCta": "Open CMS support desk",
"noTickets": "No open help center tickets",
"searchPlaceholder": "Search title, user or ID…",
"presetOpen": "Open",
@@ -2520,7 +2524,7 @@
"events": "Events",
"eventsSubtitle": "Hotel events and event types",
"tickets": "Support desk",
"ticketsSubtitle": "CMS tickets, help-center tickets, and reply templates",
"ticketsSubtitle": "Two separate queues: CMS support desk and public help-center tickets",
"staffAccess": "Ranks & permissions",
"staffAccessSubtitle": "Staff ranks and live access. Archive is compare/export only.",
"analytics": "Analytics",
@@ -3004,7 +3008,8 @@
"bans": "Bans",
"fullAdmin": "Full admin",
"backToSite": "Back to site",
"tickets": "Tickets",
"tickets": "CMS tickets",
"helpTickets": "Help-center tickets",
"team": "Team"
},
"stats": {
@@ -3013,7 +3018,8 @@
"modsOnline": "Mods online",
"quickActions": "Quick actions",
"quickActionsHint": "Kick, mute, alert",
"openTickets": "Open tickets",
"openTickets": "Open CMS tickets",
"openHelpTickets": "Open help tickets",
"team": "Mod team",
"teamHint": "Online staff & workload"
}
+82 -5
View File
@@ -23,7 +23,8 @@
"closeMenu": "Close menu",
"drawBadge": "Diseñar insignia",
"events": "Events",
"polls": "Polls"
"polls": "Polls",
"mod": "Mod"
},
"header": {
"online": "{count} {hotel} en línea",
@@ -876,7 +877,8 @@
"chatLog": "Chat log",
"commandLog": "Command log",
"tradeLog": "Trade log",
"tools": "Tools"
"tools": "Tools",
"guilds": "Guilds"
},
"dashboard": {
"title": "Panel",
@@ -2183,7 +2185,24 @@
"auditTarget": "Objetivo",
"selectAllMatching": "Select all {count} matching",
"save": "Save",
"importTitle": "Import permissions"
"importTitle": "Import permissions",
"legacyBannerTitle": "Use Permissions to change who can do what",
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
"legacyBannerCta": "Open Permissions",
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
"tabCompare": "ACL compare",
"tabExportOnly": "Export archive",
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
"compareAligned": "{count} aligned",
"compareMissingAcl": "{count} missing in ACL",
"compareMismatch": "{count} rank mismatch",
"colAclPresent": "In ACL",
"colAclMinRank": "ACL min rank",
"colStatus": "Status",
"statusAligned": "Aligned",
"statusMissingAcl": "Missing in ACL",
"statusMismatch": "Rank differs",
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
},
"rooms": {
"title": "Salas",
@@ -2412,7 +2431,9 @@
"colPriority": "Priority",
"colAssigned": "Assigned",
"colDate": "Date",
"meta": "by {user} · {count} msg · {category}"
"meta": "by {user} · {count} msg · {category}",
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
"queueOtherCta": "Open help-center tickets"
},
"cfh": {
"title": "Call for Help",
@@ -2674,7 +2695,9 @@
"replied": "Reply sent.",
"closed": "Ticket closed.",
"reopened": "Ticket reopened."
}
},
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
"queueOtherCta": "Open CMS support desk"
},
"marketplace": {
"title": "Marketplace",
@@ -2769,6 +2792,31 @@
"less": "Less",
"more": "More"
}
},
"guilds": {
"title": "Guilds",
"subtitle": "Hotel groups and membership",
"totalGuilds": "Guilds",
"searchPlaceholder": "Search by name, description or ID…",
"search": "Search",
"empty": "No guilds found.",
"loadError": "Could not load guilds (database unavailable).",
"colId": "ID",
"colBadge": "Badge",
"colName": "Name",
"colOwner": "Owner",
"colMembers": "Members",
"colCreated": "Created",
"colThreads": "Forum threads",
"colRoom": "Room",
"colUser": "User",
"colLevel": "Level",
"colJoined": "Joined",
"members": "Members",
"noMembers": "No members.",
"back": "Back to guilds",
"viewPublic": "Open public page",
"disband": "Disband guild"
}
},
"radioRequests": {
@@ -2944,6 +2992,35 @@
"voteCount": "{count} ({pct}%)",
"totalVotes": "{count} total votes",
"textResponses": "{count} free-text responses"
},
"mod": {
"title": "Moderation panel",
"badge": "Mod",
"overviewTitle": "Moderator overview",
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
"shortcutsTitle": "Shortcuts",
"nav": {
"overview": "Overview",
"cfh": "Call for Help",
"actions": "Quick actions",
"bans": "Bans",
"fullAdmin": "Full admin",
"backToSite": "Back to site",
"tickets": "Tickets",
"team": "Team",
"helpTickets": "Help-center tickets"
},
"stats": {
"openCfh": "Open CFH",
"bansToday": "Bans today",
"modsOnline": "Mods online",
"quickActions": "Quick actions",
"quickActionsHint": "Kick, mute, alert",
"openTickets": "Open tickets",
"team": "Mod team",
"teamHint": "Online staff & workload",
"openHelpTickets": "Open help tickets"
}
}
},
"emails": {
+82 -5
View File
@@ -23,7 +23,8 @@
"closeMenu": "Close menu",
"drawBadge": "Dessiner un badge",
"events": "Events",
"polls": "Polls"
"polls": "Polls",
"mod": "Mod"
},
"header": {
"online": "{count} {hotel} en ligne",
@@ -876,7 +877,8 @@
"chatLog": "Chat log",
"commandLog": "Command log",
"tradeLog": "Trade log",
"tools": "Tools"
"tools": "Tools",
"guilds": "Guilds"
},
"dashboard": {
"title": "Tableau de bord",
@@ -2183,7 +2185,24 @@
"auditTarget": "Cible",
"selectAllMatching": "Select all {count} matching",
"save": "Save",
"importTitle": "Import permissions"
"importTitle": "Import permissions",
"legacyBannerTitle": "Use Permissions to change who can do what",
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
"legacyBannerCta": "Open Permissions",
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
"tabCompare": "ACL compare",
"tabExportOnly": "Export archive",
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
"compareAligned": "{count} aligned",
"compareMissingAcl": "{count} missing in ACL",
"compareMismatch": "{count} rank mismatch",
"colAclPresent": "In ACL",
"colAclMinRank": "ACL min rank",
"colStatus": "Status",
"statusAligned": "Aligned",
"statusMissingAcl": "Missing in ACL",
"statusMismatch": "Rank differs",
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
},
"rooms": {
"title": "Salons",
@@ -2412,7 +2431,9 @@
"colPriority": "Priority",
"colAssigned": "Assigned",
"colDate": "Date",
"meta": "by {user} · {count} msg · {category}"
"meta": "by {user} · {count} msg · {category}",
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
"queueOtherCta": "Open help-center tickets"
},
"cfh": {
"title": "Call for Help",
@@ -2674,7 +2695,9 @@
"replied": "Reply sent.",
"closed": "Ticket closed.",
"reopened": "Ticket reopened."
}
},
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
"queueOtherCta": "Open CMS support desk"
},
"marketplace": {
"title": "Marketplace",
@@ -2769,6 +2792,31 @@
"less": "Less",
"more": "More"
}
},
"guilds": {
"title": "Guilds",
"subtitle": "Hotel groups and membership",
"totalGuilds": "Guilds",
"searchPlaceholder": "Search by name, description or ID…",
"search": "Search",
"empty": "No guilds found.",
"loadError": "Could not load guilds (database unavailable).",
"colId": "ID",
"colBadge": "Badge",
"colName": "Name",
"colOwner": "Owner",
"colMembers": "Members",
"colCreated": "Created",
"colThreads": "Forum threads",
"colRoom": "Room",
"colUser": "User",
"colLevel": "Level",
"colJoined": "Joined",
"members": "Members",
"noMembers": "No members.",
"back": "Back to guilds",
"viewPublic": "Open public page",
"disband": "Disband guild"
}
},
"radioRequests": {
@@ -2944,6 +2992,35 @@
"voteCount": "{count} ({pct}%)",
"totalVotes": "{count} total votes",
"textResponses": "{count} free-text responses"
},
"mod": {
"title": "Moderation panel",
"badge": "Mod",
"overviewTitle": "Moderator overview",
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
"shortcutsTitle": "Shortcuts",
"nav": {
"overview": "Overview",
"cfh": "Call for Help",
"actions": "Quick actions",
"bans": "Bans",
"fullAdmin": "Full admin",
"backToSite": "Back to site",
"tickets": "Tickets",
"team": "Team",
"helpTickets": "Help-center tickets"
},
"stats": {
"openCfh": "Open CFH",
"bansToday": "Bans today",
"modsOnline": "Mods online",
"quickActions": "Quick actions",
"quickActionsHint": "Kick, mute, alert",
"openTickets": "Open tickets",
"team": "Mod team",
"teamHint": "Online staff & workload",
"openHelpTickets": "Open help tickets"
}
}
},
"emails": {
+82 -5
View File
@@ -23,7 +23,8 @@
"closeMenu": "Zatvori izbornik",
"drawBadge": "Nacrtajte značku",
"events": "Events",
"polls": "Polls"
"polls": "Polls",
"mod": "Mod"
},
"header": {
"online": "{count} {hotel} online",
@@ -906,7 +907,8 @@
"chatLog": "Chat log",
"commandLog": "Command log",
"tradeLog": "Trade log",
"tools": "Tools"
"tools": "Tools",
"guilds": "Guilds"
},
"dashboard": {
"title": "Nadzorna ploča",
@@ -2213,7 +2215,24 @@
"auditTarget": "cilj",
"selectAllMatching": "Select all {count} matching",
"save": "Save",
"importTitle": "Import permissions"
"importTitle": "Import permissions",
"legacyBannerTitle": "Use Permissions to change who can do what",
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
"legacyBannerCta": "Open Permissions",
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
"tabCompare": "ACL compare",
"tabExportOnly": "Export archive",
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
"compareAligned": "{count} aligned",
"compareMissingAcl": "{count} missing in ACL",
"compareMismatch": "{count} rank mismatch",
"colAclPresent": "In ACL",
"colAclMinRank": "ACL min rank",
"colStatus": "Status",
"statusAligned": "Aligned",
"statusMissingAcl": "Missing in ACL",
"statusMismatch": "Rank differs",
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
},
"rooms": {
"title": "Sobe",
@@ -2442,7 +2461,9 @@
"colPriority": "Priority",
"colAssigned": "Assigned",
"colDate": "Date",
"meta": "by {user} · {count} msg · {category}"
"meta": "by {user} · {count} msg · {category}",
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
"queueOtherCta": "Open help-center tickets"
},
"cfh": {
"title": "Call for Help",
@@ -2704,7 +2725,9 @@
"replied": "Reply sent.",
"closed": "Ticket closed.",
"reopened": "Ticket reopened."
}
},
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
"queueOtherCta": "Open CMS support desk"
},
"marketplace": {
"title": "Marketplace",
@@ -2799,6 +2822,31 @@
"less": "Less",
"more": "More"
}
},
"guilds": {
"title": "Guilds",
"subtitle": "Hotel groups and membership",
"totalGuilds": "Guilds",
"searchPlaceholder": "Search by name, description or ID…",
"search": "Search",
"empty": "No guilds found.",
"loadError": "Could not load guilds (database unavailable).",
"colId": "ID",
"colBadge": "Badge",
"colName": "Name",
"colOwner": "Owner",
"colMembers": "Members",
"colCreated": "Created",
"colThreads": "Forum threads",
"colRoom": "Room",
"colUser": "User",
"colLevel": "Level",
"colJoined": "Joined",
"members": "Members",
"noMembers": "No members.",
"back": "Back to guilds",
"viewPublic": "Open public page",
"disband": "Disband guild"
}
},
"error": {
@@ -2944,6 +2992,35 @@
"voteCount": "{count} ({pct}%)",
"totalVotes": "{count} total votes",
"textResponses": "{count} free-text responses"
},
"mod": {
"title": "Moderation panel",
"badge": "Mod",
"overviewTitle": "Moderator overview",
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
"shortcutsTitle": "Shortcuts",
"nav": {
"overview": "Overview",
"cfh": "Call for Help",
"actions": "Quick actions",
"bans": "Bans",
"fullAdmin": "Full admin",
"backToSite": "Back to site",
"tickets": "Tickets",
"team": "Team",
"helpTickets": "Help-center tickets"
},
"stats": {
"openCfh": "Open CFH",
"bansToday": "Bans today",
"modsOnline": "Mods online",
"quickActions": "Quick actions",
"quickActionsHint": "Kick, mute, alert",
"openTickets": "Open tickets",
"team": "Mod team",
"teamHint": "Online staff & workload",
"openHelpTickets": "Open help tickets"
}
}
},
"emails": {
+82 -5
View File
@@ -23,7 +23,8 @@
"closeMenu": "Menü bezárása",
"drawBadge": "Rajzolj jelvényt",
"events": "Events",
"polls": "Polls"
"polls": "Polls",
"mod": "Mod"
},
"header": {
"online": "{count} {hotel} online",
@@ -906,7 +907,8 @@
"chatLog": "Chat log",
"commandLog": "Command log",
"tradeLog": "Trade log",
"tools": "Tools"
"tools": "Tools",
"guilds": "Guilds"
},
"dashboard": {
"title": "Irányítópult",
@@ -2213,7 +2215,24 @@
"auditTarget": "Cél",
"selectAllMatching": "Select all {count} matching",
"save": "Save",
"importTitle": "Import permissions"
"importTitle": "Import permissions",
"legacyBannerTitle": "Use Permissions to change who can do what",
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
"legacyBannerCta": "Open Permissions",
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
"tabCompare": "ACL compare",
"tabExportOnly": "Export archive",
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
"compareAligned": "{count} aligned",
"compareMissingAcl": "{count} missing in ACL",
"compareMismatch": "{count} rank mismatch",
"colAclPresent": "In ACL",
"colAclMinRank": "ACL min rank",
"colStatus": "Status",
"statusAligned": "Aligned",
"statusMissingAcl": "Missing in ACL",
"statusMismatch": "Rank differs",
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
},
"rooms": {
"title": "Szobák",
@@ -2442,7 +2461,9 @@
"colPriority": "Priority",
"colAssigned": "Assigned",
"colDate": "Date",
"meta": "by {user} · {count} msg · {category}"
"meta": "by {user} · {count} msg · {category}",
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
"queueOtherCta": "Open help-center tickets"
},
"cfh": {
"title": "Call for Help",
@@ -2704,7 +2725,9 @@
"replied": "Reply sent.",
"closed": "Ticket closed.",
"reopened": "Ticket reopened."
}
},
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
"queueOtherCta": "Open CMS support desk"
},
"marketplace": {
"title": "Marketplace",
@@ -2799,6 +2822,31 @@
"less": "Less",
"more": "More"
}
},
"guilds": {
"title": "Guilds",
"subtitle": "Hotel groups and membership",
"totalGuilds": "Guilds",
"searchPlaceholder": "Search by name, description or ID…",
"search": "Search",
"empty": "No guilds found.",
"loadError": "Could not load guilds (database unavailable).",
"colId": "ID",
"colBadge": "Badge",
"colName": "Name",
"colOwner": "Owner",
"colMembers": "Members",
"colCreated": "Created",
"colThreads": "Forum threads",
"colRoom": "Room",
"colUser": "User",
"colLevel": "Level",
"colJoined": "Joined",
"members": "Members",
"noMembers": "No members.",
"back": "Back to guilds",
"viewPublic": "Open public page",
"disband": "Disband guild"
}
},
"error": {
@@ -2944,6 +2992,35 @@
"voteCount": "{count} ({pct}%)",
"totalVotes": "{count} total votes",
"textResponses": "{count} free-text responses"
},
"mod": {
"title": "Moderation panel",
"badge": "Mod",
"overviewTitle": "Moderator overview",
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
"shortcutsTitle": "Shortcuts",
"nav": {
"overview": "Overview",
"cfh": "Call for Help",
"actions": "Quick actions",
"bans": "Bans",
"fullAdmin": "Full admin",
"backToSite": "Back to site",
"tickets": "Tickets",
"team": "Team",
"helpTickets": "Help-center tickets"
},
"stats": {
"openCfh": "Open CFH",
"bansToday": "Bans today",
"modsOnline": "Mods online",
"quickActions": "Quick actions",
"quickActionsHint": "Kick, mute, alert",
"openTickets": "Open tickets",
"team": "Mod team",
"teamHint": "Online staff & workload",
"openHelpTickets": "Open help tickets"
}
}
},
"emails": {
+15 -9
View File
@@ -1467,8 +1467,8 @@
"settingsSaved": "Impostazioni VPN salvate con successo"
},
"tickets": {
"title": "Ticket",
"subtitle": "Ticket di supporto dai giocatori",
"title": "Supporto CMS",
"subtitle": "Ticket interni CMS (assegnatario, priorita, workflow staff). Non sono i ticket del help center /help.",
"noTickets": "Nessun ticket attivo",
"searchPlaceholder": "Cerca oggetto, utente, categoria o ID…",
"presetActive": "Attivi",
@@ -1481,11 +1481,13 @@
"colPriority": "Priorità",
"colAssigned": "Assegnato",
"colDate": "Data",
"meta": "di {user} · {count} msg · {category}"
"meta": "di {user} · {count} msg · {category}",
"queueHint": "Questa coda e il supporto CMS. I ticket del help center pubblici stanno nella tab Help-center tickets.",
"queueOtherCta": "Apri help-center tickets"
},
"helpTickets": {
"title": "Ticket centro assistenza",
"subtitle": "Ticket dei giocatori da /help/tickets",
"title": "Ticket help center",
"subtitle": "Ticket aperti dai player su /help/tickets. Separati dal supporto CMS.",
"noTickets": "Nessun ticket aperto nel centro assistenza",
"searchPlaceholder": "Cerca titolo, utente o ID…",
"presetOpen": "Aperti",
@@ -1516,7 +1518,9 @@
"replied": "Risposta inviata.",
"closed": "Ticket chiuso.",
"reopened": "Ticket riaperto."
}
},
"queueHint": "Questa coda e il help center pubblico. I ticket del supporto CMS stanno nella tab Support desk.",
"queueOtherCta": "Apri supporto CMS"
},
"cfh": {
"title": "Call for Help",
@@ -2791,7 +2795,7 @@
"events": "Eventi",
"eventsSubtitle": "Eventi hotel e tipi evento",
"tickets": "Assistenza",
"ticketsSubtitle": "Ticket CMS, ticket centro assistenza e template risposte",
"ticketsSubtitle": "Due code separate: supporto CMS e ticket help center pubblici",
"staffAccess": "Gradi e permessi",
"staffAccessSubtitle": "Gradi staff e accesso live. L'archivio e solo confronto/export.",
"analytics": "Analitiche",
@@ -3011,7 +3015,8 @@
"bans": "Ban",
"fullAdmin": "Admin completo",
"backToSite": "Torna al sito",
"tickets": "Ticket",
"tickets": "Ticket CMS",
"helpTickets": "Ticket help center",
"team": "Team"
},
"stats": {
@@ -3020,7 +3025,8 @@
"modsOnline": "Mod online",
"quickActions": "Azioni rapide",
"quickActionsHint": "Kick, mute, alert",
"openTickets": "Ticket aperti",
"openTickets": "Ticket CMS aperti",
"openHelpTickets": "Help ticket aperti",
"team": "Team mod",
"teamHint": "Staff online e carico"
}
+15 -9
View File
@@ -2842,7 +2842,7 @@
"events": "Evenementen",
"eventsSubtitle": "Hotel-evenementen en evenementtypes",
"tickets": "Supportbalie",
"ticketsSubtitle": "CMS-tickets, helpcentrum-tickets en antwoordtemplates",
"ticketsSubtitle": "Twee aparte wachtrijen: CMS support desk en publieke helpcenter-tickets",
"staffAccess": "Rangen & rechten",
"staffAccessSubtitle": "Staffrangen en live toegang. Archief is alleen vergelijken/exporteren.",
"analytics": "Analytics",
@@ -2851,8 +2851,8 @@
"devopsSubtitle": "Servicegezondheid en emulatorfouten"
},
"tickets": {
"title": "Tickets",
"subtitle": "Ondersteuningstickets van spelers",
"title": "CMS support desk",
"subtitle": "Interne CMS-tickets (assignee, prioriteit, staff-workflow). Niet hetzelfde als helpcenter-tickets van /help.",
"noTickets": "Geen actieve tickets",
"searchPlaceholder": "Zoek op onderwerp, gebruiker, categorie of ID…",
"presetActive": "Actief",
@@ -2865,11 +2865,13 @@
"colPriority": "Prioriteit",
"colAssigned": "Toegewezen",
"colDate": "Datum",
"meta": "door {user} · {count} berichten · {category}"
"meta": "door {user} · {count} berichten · {category}",
"queueHint": "Deze wachtrij is de CMS support desk. Speler-helpcenter tickets staan onder Help-center tickets.",
"queueOtherCta": "Open helpcenter-tickets"
},
"helpTickets": {
"title": "Helpcentrum-tickets",
"subtitle": "Spelertickets van /help/tickets",
"title": "Helpcenter-tickets",
"subtitle": "Tickets die spelers openen op /help/tickets. Apart van de CMS support desk.",
"noTickets": "Geen open helpcentrum-tickets",
"searchPlaceholder": "Zoek op titel, gebruiker of ID…",
"presetOpen": "Open",
@@ -2900,7 +2902,9 @@
"replied": "Antwoord verzonden.",
"closed": "Ticket gesloten.",
"reopened": "Ticket heropend."
}
},
"queueHint": "Deze wachtrij is het publieke helpcenter. CMS support desk tickets staan onder Support desk.",
"queueOtherCta": "Open CMS support desk"
},
"cfh": {
"title": "Call for Help",
@@ -3009,7 +3013,8 @@
"bans": "Bans",
"fullAdmin": "Volledige admin",
"backToSite": "Terug naar site",
"tickets": "Tickets",
"tickets": "CMS-tickets",
"helpTickets": "Helpcenter-tickets",
"team": "Team"
},
"stats": {
@@ -3018,7 +3023,8 @@
"modsOnline": "Mods online",
"quickActions": "Snelle acties",
"quickActionsHint": "Kick, mute, alert",
"openTickets": "Open tickets",
"openTickets": "Open CMS-tickets",
"openHelpTickets": "Open help-tickets",
"team": "Mod team",
"teamHint": "Online staff & werklast"
}
+82 -5
View File
@@ -23,7 +23,8 @@
"closeMenu": "Lukk menyen",
"drawBadge": "Tegn merke",
"events": "Events",
"polls": "Polls"
"polls": "Polls",
"mod": "Mod"
},
"header": {
"online": "{count} {hotel} online",
@@ -906,7 +907,8 @@
"chatLog": "Chat log",
"commandLog": "Command log",
"tradeLog": "Trade log",
"tools": "Tools"
"tools": "Tools",
"guilds": "Guilds"
},
"dashboard": {
"title": "Dashbord",
@@ -2213,7 +2215,24 @@
"auditTarget": "Mål",
"selectAllMatching": "Select all {count} matching",
"save": "Save",
"importTitle": "Import permissions"
"importTitle": "Import permissions",
"legacyBannerTitle": "Use Permissions to change who can do what",
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
"legacyBannerCta": "Open Permissions",
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
"tabCompare": "ACL compare",
"tabExportOnly": "Export archive",
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
"compareAligned": "{count} aligned",
"compareMissingAcl": "{count} missing in ACL",
"compareMismatch": "{count} rank mismatch",
"colAclPresent": "In ACL",
"colAclMinRank": "ACL min rank",
"colStatus": "Status",
"statusAligned": "Aligned",
"statusMissingAcl": "Missing in ACL",
"statusMismatch": "Rank differs",
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
},
"rooms": {
"title": "Rom",
@@ -2442,7 +2461,9 @@
"colPriority": "Priority",
"colAssigned": "Assigned",
"colDate": "Date",
"meta": "by {user} · {count} msg · {category}"
"meta": "by {user} · {count} msg · {category}",
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
"queueOtherCta": "Open help-center tickets"
},
"cfh": {
"title": "Call for Help",
@@ -2704,7 +2725,9 @@
"replied": "Reply sent.",
"closed": "Ticket closed.",
"reopened": "Ticket reopened."
}
},
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
"queueOtherCta": "Open CMS support desk"
},
"marketplace": {
"title": "Marketplace",
@@ -2799,6 +2822,31 @@
"less": "Less",
"more": "More"
}
},
"guilds": {
"title": "Guilds",
"subtitle": "Hotel groups and membership",
"totalGuilds": "Guilds",
"searchPlaceholder": "Search by name, description or ID…",
"search": "Search",
"empty": "No guilds found.",
"loadError": "Could not load guilds (database unavailable).",
"colId": "ID",
"colBadge": "Badge",
"colName": "Name",
"colOwner": "Owner",
"colMembers": "Members",
"colCreated": "Created",
"colThreads": "Forum threads",
"colRoom": "Room",
"colUser": "User",
"colLevel": "Level",
"colJoined": "Joined",
"members": "Members",
"noMembers": "No members.",
"back": "Back to guilds",
"viewPublic": "Open public page",
"disband": "Disband guild"
}
},
"error": {
@@ -2944,6 +2992,35 @@
"voteCount": "{count} ({pct}%)",
"totalVotes": "{count} total votes",
"textResponses": "{count} free-text responses"
},
"mod": {
"title": "Moderation panel",
"badge": "Mod",
"overviewTitle": "Moderator overview",
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
"shortcutsTitle": "Shortcuts",
"nav": {
"overview": "Overview",
"cfh": "Call for Help",
"actions": "Quick actions",
"bans": "Bans",
"fullAdmin": "Full admin",
"backToSite": "Back to site",
"tickets": "Tickets",
"team": "Team",
"helpTickets": "Help-center tickets"
},
"stats": {
"openCfh": "Open CFH",
"bansToday": "Bans today",
"modsOnline": "Mods online",
"quickActions": "Quick actions",
"quickActionsHint": "Kick, mute, alert",
"openTickets": "Open tickets",
"team": "Mod team",
"teamHint": "Online staff & workload",
"openHelpTickets": "Open help tickets"
}
}
},
"emails": {
+82 -5
View File
@@ -23,7 +23,8 @@
"closeMenu": "Zamknij menu",
"drawBadge": "Narysuj odznakę",
"events": "Events",
"polls": "Polls"
"polls": "Polls",
"mod": "Mod"
},
"header": {
"online": "{count} {hotel} online",
@@ -906,7 +907,8 @@
"chatLog": "Chat log",
"commandLog": "Command log",
"tradeLog": "Trade log",
"tools": "Tools"
"tools": "Tools",
"guilds": "Guilds"
},
"dashboard": {
"title": "Pulpit nawigacyjny",
@@ -2213,7 +2215,24 @@
"auditTarget": "Cel",
"selectAllMatching": "Select all {count} matching",
"save": "Save",
"importTitle": "Import permissions"
"importTitle": "Import permissions",
"legacyBannerTitle": "Use Permissions to change who can do what",
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
"legacyBannerCta": "Open Permissions",
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
"tabCompare": "ACL compare",
"tabExportOnly": "Export archive",
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
"compareAligned": "{count} aligned",
"compareMissingAcl": "{count} missing in ACL",
"compareMismatch": "{count} rank mismatch",
"colAclPresent": "In ACL",
"colAclMinRank": "ACL min rank",
"colStatus": "Status",
"statusAligned": "Aligned",
"statusMissingAcl": "Missing in ACL",
"statusMismatch": "Rank differs",
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
},
"rooms": {
"title": "Pokoje",
@@ -2442,7 +2461,9 @@
"colPriority": "Priority",
"colAssigned": "Assigned",
"colDate": "Date",
"meta": "by {user} · {count} msg · {category}"
"meta": "by {user} · {count} msg · {category}",
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
"queueOtherCta": "Open help-center tickets"
},
"cfh": {
"title": "Call for Help",
@@ -2704,7 +2725,9 @@
"replied": "Reply sent.",
"closed": "Ticket closed.",
"reopened": "Ticket reopened."
}
},
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
"queueOtherCta": "Open CMS support desk"
},
"marketplace": {
"title": "Marketplace",
@@ -2799,6 +2822,31 @@
"less": "Less",
"more": "More"
}
},
"guilds": {
"title": "Guilds",
"subtitle": "Hotel groups and membership",
"totalGuilds": "Guilds",
"searchPlaceholder": "Search by name, description or ID…",
"search": "Search",
"empty": "No guilds found.",
"loadError": "Could not load guilds (database unavailable).",
"colId": "ID",
"colBadge": "Badge",
"colName": "Name",
"colOwner": "Owner",
"colMembers": "Members",
"colCreated": "Created",
"colThreads": "Forum threads",
"colRoom": "Room",
"colUser": "User",
"colLevel": "Level",
"colJoined": "Joined",
"members": "Members",
"noMembers": "No members.",
"back": "Back to guilds",
"viewPublic": "Open public page",
"disband": "Disband guild"
}
},
"error": {
@@ -2944,6 +2992,35 @@
"voteCount": "{count} ({pct}%)",
"totalVotes": "{count} total votes",
"textResponses": "{count} free-text responses"
},
"mod": {
"title": "Moderation panel",
"badge": "Mod",
"overviewTitle": "Moderator overview",
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
"shortcutsTitle": "Shortcuts",
"nav": {
"overview": "Overview",
"cfh": "Call for Help",
"actions": "Quick actions",
"bans": "Bans",
"fullAdmin": "Full admin",
"backToSite": "Back to site",
"tickets": "Tickets",
"team": "Team",
"helpTickets": "Help-center tickets"
},
"stats": {
"openCfh": "Open CFH",
"bansToday": "Bans today",
"modsOnline": "Mods online",
"quickActions": "Quick actions",
"quickActionsHint": "Kick, mute, alert",
"openTickets": "Open tickets",
"team": "Mod team",
"teamHint": "Online staff & workload",
"openHelpTickets": "Open help tickets"
}
}
},
"emails": {
+82 -5
View File
@@ -23,7 +23,8 @@
"closeMenu": "Fechar menu",
"drawBadge": "Desenhar distintivo",
"events": "Events",
"polls": "Polls"
"polls": "Polls",
"mod": "Mod"
},
"header": {
"online": "{count} {hotel} on-line",
@@ -906,7 +907,8 @@
"chatLog": "Chat log",
"commandLog": "Command log",
"tradeLog": "Trade log",
"tools": "Tools"
"tools": "Tools",
"guilds": "Guilds"
},
"dashboard": {
"title": "Painel",
@@ -2213,7 +2215,24 @@
"auditTarget": "Alvo",
"selectAllMatching": "Select all {count} matching",
"save": "Save",
"importTitle": "Import permissions"
"importTitle": "Import permissions",
"legacyBannerTitle": "Use Permissions to change who can do what",
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
"legacyBannerCta": "Open Permissions",
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
"tabCompare": "ACL compare",
"tabExportOnly": "Export archive",
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
"compareAligned": "{count} aligned",
"compareMissingAcl": "{count} missing in ACL",
"compareMismatch": "{count} rank mismatch",
"colAclPresent": "In ACL",
"colAclMinRank": "ACL min rank",
"colStatus": "Status",
"statusAligned": "Aligned",
"statusMissingAcl": "Missing in ACL",
"statusMismatch": "Rank differs",
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
},
"rooms": {
"title": "Quartos",
@@ -2442,7 +2461,9 @@
"colPriority": "Priority",
"colAssigned": "Assigned",
"colDate": "Date",
"meta": "by {user} · {count} msg · {category}"
"meta": "by {user} · {count} msg · {category}",
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
"queueOtherCta": "Open help-center tickets"
},
"cfh": {
"title": "Call for Help",
@@ -2704,7 +2725,9 @@
"replied": "Reply sent.",
"closed": "Ticket closed.",
"reopened": "Ticket reopened."
}
},
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
"queueOtherCta": "Open CMS support desk"
},
"marketplace": {
"title": "Marketplace",
@@ -2799,6 +2822,31 @@
"less": "Less",
"more": "More"
}
},
"guilds": {
"title": "Guilds",
"subtitle": "Hotel groups and membership",
"totalGuilds": "Guilds",
"searchPlaceholder": "Search by name, description or ID…",
"search": "Search",
"empty": "No guilds found.",
"loadError": "Could not load guilds (database unavailable).",
"colId": "ID",
"colBadge": "Badge",
"colName": "Name",
"colOwner": "Owner",
"colMembers": "Members",
"colCreated": "Created",
"colThreads": "Forum threads",
"colRoom": "Room",
"colUser": "User",
"colLevel": "Level",
"colJoined": "Joined",
"members": "Members",
"noMembers": "No members.",
"back": "Back to guilds",
"viewPublic": "Open public page",
"disband": "Disband guild"
}
},
"error": {
@@ -2944,6 +2992,35 @@
"voteCount": "{count} ({pct}%)",
"totalVotes": "{count} total votes",
"textResponses": "{count} free-text responses"
},
"mod": {
"title": "Moderation panel",
"badge": "Mod",
"overviewTitle": "Moderator overview",
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
"shortcutsTitle": "Shortcuts",
"nav": {
"overview": "Overview",
"cfh": "Call for Help",
"actions": "Quick actions",
"bans": "Bans",
"fullAdmin": "Full admin",
"backToSite": "Back to site",
"tickets": "Tickets",
"team": "Team",
"helpTickets": "Help-center tickets"
},
"stats": {
"openCfh": "Open CFH",
"bansToday": "Bans today",
"modsOnline": "Mods online",
"quickActions": "Quick actions",
"quickActionsHint": "Kick, mute, alert",
"openTickets": "Open tickets",
"team": "Mod team",
"teamHint": "Online staff & workload",
"openHelpTickets": "Open help tickets"
}
}
},
"emails": {
+82 -5
View File
@@ -23,7 +23,8 @@
"closeMenu": "Închide meniul",
"drawBadge": "Trage insigna",
"events": "Events",
"polls": "Polls"
"polls": "Polls",
"mod": "Mod"
},
"header": {
"online": "{count} {hotel} online",
@@ -906,7 +907,8 @@
"chatLog": "Chat log",
"commandLog": "Command log",
"tradeLog": "Trade log",
"tools": "Tools"
"tools": "Tools",
"guilds": "Guilds"
},
"dashboard": {
"title": "Tabloul de bord",
@@ -2213,7 +2215,24 @@
"auditTarget": "țintă",
"selectAllMatching": "Select all {count} matching",
"save": "Save",
"importTitle": "Import permissions"
"importTitle": "Import permissions",
"legacyBannerTitle": "Use Permissions to change who can do what",
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
"legacyBannerCta": "Open Permissions",
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
"tabCompare": "ACL compare",
"tabExportOnly": "Export archive",
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
"compareAligned": "{count} aligned",
"compareMissingAcl": "{count} missing in ACL",
"compareMismatch": "{count} rank mismatch",
"colAclPresent": "In ACL",
"colAclMinRank": "ACL min rank",
"colStatus": "Status",
"statusAligned": "Aligned",
"statusMissingAcl": "Missing in ACL",
"statusMismatch": "Rank differs",
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
},
"rooms": {
"title": "Camerele",
@@ -2442,7 +2461,9 @@
"colPriority": "Priority",
"colAssigned": "Assigned",
"colDate": "Date",
"meta": "by {user} · {count} msg · {category}"
"meta": "by {user} · {count} msg · {category}",
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
"queueOtherCta": "Open help-center tickets"
},
"cfh": {
"title": "Call for Help",
@@ -2704,7 +2725,9 @@
"replied": "Reply sent.",
"closed": "Ticket closed.",
"reopened": "Ticket reopened."
}
},
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
"queueOtherCta": "Open CMS support desk"
},
"marketplace": {
"title": "Marketplace",
@@ -2799,6 +2822,31 @@
"less": "Less",
"more": "More"
}
},
"guilds": {
"title": "Guilds",
"subtitle": "Hotel groups and membership",
"totalGuilds": "Guilds",
"searchPlaceholder": "Search by name, description or ID…",
"search": "Search",
"empty": "No guilds found.",
"loadError": "Could not load guilds (database unavailable).",
"colId": "ID",
"colBadge": "Badge",
"colName": "Name",
"colOwner": "Owner",
"colMembers": "Members",
"colCreated": "Created",
"colThreads": "Forum threads",
"colRoom": "Room",
"colUser": "User",
"colLevel": "Level",
"colJoined": "Joined",
"members": "Members",
"noMembers": "No members.",
"back": "Back to guilds",
"viewPublic": "Open public page",
"disband": "Disband guild"
}
},
"error": {
@@ -2944,6 +2992,35 @@
"voteCount": "{count} ({pct}%)",
"totalVotes": "{count} total votes",
"textResponses": "{count} free-text responses"
},
"mod": {
"title": "Moderation panel",
"badge": "Mod",
"overviewTitle": "Moderator overview",
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
"shortcutsTitle": "Shortcuts",
"nav": {
"overview": "Overview",
"cfh": "Call for Help",
"actions": "Quick actions",
"bans": "Bans",
"fullAdmin": "Full admin",
"backToSite": "Back to site",
"tickets": "Tickets",
"team": "Team",
"helpTickets": "Help-center tickets"
},
"stats": {
"openCfh": "Open CFH",
"bansToday": "Bans today",
"modsOnline": "Mods online",
"quickActions": "Quick actions",
"quickActionsHint": "Kick, mute, alert",
"openTickets": "Open tickets",
"team": "Mod team",
"teamHint": "Online staff & workload",
"openHelpTickets": "Open help tickets"
}
}
},
"emails": {
+82 -5
View File
@@ -23,7 +23,8 @@
"closeMenu": "Закрыть меню",
"drawBadge": "Нарисуйте значок",
"events": "Events",
"polls": "Polls"
"polls": "Polls",
"mod": "Mod"
},
"header": {
"online": "{count} {hotel} онлайн",
@@ -904,7 +905,8 @@
"chatLog": "Chat log",
"commandLog": "Command log",
"tradeLog": "Trade log",
"tools": "Tools"
"tools": "Tools",
"guilds": "Guilds"
},
"dashboard": {
"title": "Панель управления",
@@ -2211,7 +2213,24 @@
"auditTarget": "Target",
"selectAllMatching": "Select all {count} matching",
"save": "Save",
"importTitle": "Import permissions"
"importTitle": "Import permissions",
"legacyBannerTitle": "Use Permissions to change who can do what",
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
"legacyBannerCta": "Open Permissions",
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
"tabCompare": "ACL compare",
"tabExportOnly": "Export archive",
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
"compareAligned": "{count} aligned",
"compareMissingAcl": "{count} missing in ACL",
"compareMismatch": "{count} rank mismatch",
"colAclPresent": "In ACL",
"colAclMinRank": "ACL min rank",
"colStatus": "Status",
"statusAligned": "Aligned",
"statusMissingAcl": "Missing in ACL",
"statusMismatch": "Rank differs",
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
},
"rooms": {
"title": "Rooms",
@@ -2440,7 +2459,9 @@
"colPriority": "Priority",
"colAssigned": "Assigned",
"colDate": "Date",
"meta": "by {user} · {count} msg · {category}"
"meta": "by {user} · {count} msg · {category}",
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
"queueOtherCta": "Open help-center tickets"
},
"cfh": {
"title": "Call for Help",
@@ -2702,7 +2723,9 @@
"replied": "Reply sent.",
"closed": "Ticket closed.",
"reopened": "Ticket reopened."
}
},
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
"queueOtherCta": "Open CMS support desk"
},
"marketplace": {
"title": "Marketplace",
@@ -2797,6 +2820,31 @@
"less": "Less",
"more": "More"
}
},
"guilds": {
"title": "Guilds",
"subtitle": "Hotel groups and membership",
"totalGuilds": "Guilds",
"searchPlaceholder": "Search by name, description or ID…",
"search": "Search",
"empty": "No guilds found.",
"loadError": "Could not load guilds (database unavailable).",
"colId": "ID",
"colBadge": "Badge",
"colName": "Name",
"colOwner": "Owner",
"colMembers": "Members",
"colCreated": "Created",
"colThreads": "Forum threads",
"colRoom": "Room",
"colUser": "User",
"colLevel": "Level",
"colJoined": "Joined",
"members": "Members",
"noMembers": "No members.",
"back": "Back to guilds",
"viewPublic": "Open public page",
"disband": "Disband guild"
}
},
"error": {
@@ -2942,6 +2990,35 @@
"voteCount": "{count} ({pct}%)",
"totalVotes": "{count} total votes",
"textResponses": "{count} free-text responses"
},
"mod": {
"title": "Moderation panel",
"badge": "Mod",
"overviewTitle": "Moderator overview",
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
"shortcutsTitle": "Shortcuts",
"nav": {
"overview": "Overview",
"cfh": "Call for Help",
"actions": "Quick actions",
"bans": "Bans",
"fullAdmin": "Full admin",
"backToSite": "Back to site",
"tickets": "Tickets",
"team": "Team",
"helpTickets": "Help-center tickets"
},
"stats": {
"openCfh": "Open CFH",
"bansToday": "Bans today",
"modsOnline": "Mods online",
"quickActions": "Quick actions",
"quickActionsHint": "Kick, mute, alert",
"openTickets": "Open tickets",
"team": "Mod team",
"teamHint": "Online staff & workload",
"openHelpTickets": "Open help tickets"
}
}
},
"emails": {
+82 -5
View File
@@ -23,7 +23,8 @@
"closeMenu": "Zatvoriť ponuku",
"drawBadge": "Nakreslite odznak",
"events": "Events",
"polls": "Polls"
"polls": "Polls",
"mod": "Mod"
},
"header": {
"online": "{count} {hotel} online",
@@ -906,7 +907,8 @@
"chatLog": "Chat log",
"commandLog": "Command log",
"tradeLog": "Trade log",
"tools": "Tools"
"tools": "Tools",
"guilds": "Guilds"
},
"dashboard": {
"title": "Dashboard",
@@ -2213,7 +2215,24 @@
"auditTarget": "Cieľ",
"selectAllMatching": "Select all {count} matching",
"save": "Save",
"importTitle": "Import permissions"
"importTitle": "Import permissions",
"legacyBannerTitle": "Use Permissions to change who can do what",
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
"legacyBannerCta": "Open Permissions",
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
"tabCompare": "ACL compare",
"tabExportOnly": "Export archive",
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
"compareAligned": "{count} aligned",
"compareMissingAcl": "{count} missing in ACL",
"compareMismatch": "{count} rank mismatch",
"colAclPresent": "In ACL",
"colAclMinRank": "ACL min rank",
"colStatus": "Status",
"statusAligned": "Aligned",
"statusMissingAcl": "Missing in ACL",
"statusMismatch": "Rank differs",
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
},
"rooms": {
"title": "Izby",
@@ -2442,7 +2461,9 @@
"colPriority": "Priority",
"colAssigned": "Assigned",
"colDate": "Date",
"meta": "by {user} · {count} msg · {category}"
"meta": "by {user} · {count} msg · {category}",
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
"queueOtherCta": "Open help-center tickets"
},
"cfh": {
"title": "Call for Help",
@@ -2704,7 +2725,9 @@
"replied": "Reply sent.",
"closed": "Ticket closed.",
"reopened": "Ticket reopened."
}
},
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
"queueOtherCta": "Open CMS support desk"
},
"marketplace": {
"title": "Marketplace",
@@ -2799,6 +2822,31 @@
"less": "Less",
"more": "More"
}
},
"guilds": {
"title": "Guilds",
"subtitle": "Hotel groups and membership",
"totalGuilds": "Guilds",
"searchPlaceholder": "Search by name, description or ID…",
"search": "Search",
"empty": "No guilds found.",
"loadError": "Could not load guilds (database unavailable).",
"colId": "ID",
"colBadge": "Badge",
"colName": "Name",
"colOwner": "Owner",
"colMembers": "Members",
"colCreated": "Created",
"colThreads": "Forum threads",
"colRoom": "Room",
"colUser": "User",
"colLevel": "Level",
"colJoined": "Joined",
"members": "Members",
"noMembers": "No members.",
"back": "Back to guilds",
"viewPublic": "Open public page",
"disband": "Disband guild"
}
},
"error": {
@@ -2944,6 +2992,35 @@
"voteCount": "{count} ({pct}%)",
"totalVotes": "{count} total votes",
"textResponses": "{count} free-text responses"
},
"mod": {
"title": "Moderation panel",
"badge": "Mod",
"overviewTitle": "Moderator overview",
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
"shortcutsTitle": "Shortcuts",
"nav": {
"overview": "Overview",
"cfh": "Call for Help",
"actions": "Quick actions",
"bans": "Bans",
"fullAdmin": "Full admin",
"backToSite": "Back to site",
"tickets": "Tickets",
"team": "Team",
"helpTickets": "Help-center tickets"
},
"stats": {
"openCfh": "Open CFH",
"bansToday": "Bans today",
"modsOnline": "Mods online",
"quickActions": "Quick actions",
"quickActionsHint": "Kick, mute, alert",
"openTickets": "Open tickets",
"team": "Mod team",
"teamHint": "Online staff & workload",
"openHelpTickets": "Open help tickets"
}
}
},
"emails": {
+82 -5
View File
@@ -23,7 +23,8 @@
"closeMenu": "Затвори мени",
"drawBadge": "Нацртај значку",
"events": "Events",
"polls": "Polls"
"polls": "Polls",
"mod": "Mod"
},
"header": {
"online": "{count} {hotel} на мрежи",
@@ -906,7 +907,8 @@
"chatLog": "Chat log",
"commandLog": "Command log",
"tradeLog": "Trade log",
"tools": "Tools"
"tools": "Tools",
"guilds": "Guilds"
},
"dashboard": {
"title": "Дасхбоард",
@@ -2213,7 +2215,24 @@
"auditTarget": "Таргет",
"selectAllMatching": "Select all {count} matching",
"save": "Save",
"importTitle": "Import permissions"
"importTitle": "Import permissions",
"legacyBannerTitle": "Use Permissions to change who can do what",
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
"legacyBannerCta": "Open Permissions",
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
"tabCompare": "ACL compare",
"tabExportOnly": "Export archive",
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
"compareAligned": "{count} aligned",
"compareMissingAcl": "{count} missing in ACL",
"compareMismatch": "{count} rank mismatch",
"colAclPresent": "In ACL",
"colAclMinRank": "ACL min rank",
"colStatus": "Status",
"statusAligned": "Aligned",
"statusMissingAcl": "Missing in ACL",
"statusMismatch": "Rank differs",
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
},
"rooms": {
"title": "Собе",
@@ -2442,7 +2461,9 @@
"colPriority": "Priority",
"colAssigned": "Assigned",
"colDate": "Date",
"meta": "by {user} · {count} msg · {category}"
"meta": "by {user} · {count} msg · {category}",
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
"queueOtherCta": "Open help-center tickets"
},
"cfh": {
"title": "Call for Help",
@@ -2704,7 +2725,9 @@
"replied": "Reply sent.",
"closed": "Ticket closed.",
"reopened": "Ticket reopened."
}
},
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
"queueOtherCta": "Open CMS support desk"
},
"marketplace": {
"title": "Marketplace",
@@ -2799,6 +2822,31 @@
"less": "Less",
"more": "More"
}
},
"guilds": {
"title": "Guilds",
"subtitle": "Hotel groups and membership",
"totalGuilds": "Guilds",
"searchPlaceholder": "Search by name, description or ID…",
"search": "Search",
"empty": "No guilds found.",
"loadError": "Could not load guilds (database unavailable).",
"colId": "ID",
"colBadge": "Badge",
"colName": "Name",
"colOwner": "Owner",
"colMembers": "Members",
"colCreated": "Created",
"colThreads": "Forum threads",
"colRoom": "Room",
"colUser": "User",
"colLevel": "Level",
"colJoined": "Joined",
"members": "Members",
"noMembers": "No members.",
"back": "Back to guilds",
"viewPublic": "Open public page",
"disband": "Disband guild"
}
},
"error": {
@@ -2944,6 +2992,35 @@
"voteCount": "{count} ({pct}%)",
"totalVotes": "{count} total votes",
"textResponses": "{count} free-text responses"
},
"mod": {
"title": "Moderation panel",
"badge": "Mod",
"overviewTitle": "Moderator overview",
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
"shortcutsTitle": "Shortcuts",
"nav": {
"overview": "Overview",
"cfh": "Call for Help",
"actions": "Quick actions",
"bans": "Bans",
"fullAdmin": "Full admin",
"backToSite": "Back to site",
"tickets": "Tickets",
"team": "Team",
"helpTickets": "Help-center tickets"
},
"stats": {
"openCfh": "Open CFH",
"bansToday": "Bans today",
"modsOnline": "Mods online",
"quickActions": "Quick actions",
"quickActionsHint": "Kick, mute, alert",
"openTickets": "Open tickets",
"team": "Mod team",
"teamHint": "Online staff & workload",
"openHelpTickets": "Open help tickets"
}
}
},
"emails": {
+82 -5
View File
@@ -23,7 +23,8 @@
"closeMenu": "Stäng menyn",
"drawBadge": "Rita märke",
"events": "Events",
"polls": "Polls"
"polls": "Polls",
"mod": "Mod"
},
"header": {
"online": "{count} {hotel} online",
@@ -906,7 +907,8 @@
"chatLog": "Chat log",
"commandLog": "Command log",
"tradeLog": "Trade log",
"tools": "Tools"
"tools": "Tools",
"guilds": "Guilds"
},
"dashboard": {
"title": "Instrumentbräda",
@@ -2213,7 +2215,24 @@
"auditTarget": "Mål",
"selectAllMatching": "Select all {count} matching",
"save": "Save",
"importTitle": "Import permissions"
"importTitle": "Import permissions",
"legacyBannerTitle": "Use Permissions to change who can do what",
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
"legacyBannerCta": "Open Permissions",
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
"tabCompare": "ACL compare",
"tabExportOnly": "Export archive",
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
"compareAligned": "{count} aligned",
"compareMissingAcl": "{count} missing in ACL",
"compareMismatch": "{count} rank mismatch",
"colAclPresent": "In ACL",
"colAclMinRank": "ACL min rank",
"colStatus": "Status",
"statusAligned": "Aligned",
"statusMissingAcl": "Missing in ACL",
"statusMismatch": "Rank differs",
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
},
"rooms": {
"title": "Rum",
@@ -2442,7 +2461,9 @@
"colPriority": "Priority",
"colAssigned": "Assigned",
"colDate": "Date",
"meta": "by {user} · {count} msg · {category}"
"meta": "by {user} · {count} msg · {category}",
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
"queueOtherCta": "Open help-center tickets"
},
"cfh": {
"title": "Call for Help",
@@ -2704,7 +2725,9 @@
"replied": "Reply sent.",
"closed": "Ticket closed.",
"reopened": "Ticket reopened."
}
},
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
"queueOtherCta": "Open CMS support desk"
},
"marketplace": {
"title": "Marketplace",
@@ -2799,6 +2822,31 @@
"less": "Less",
"more": "More"
}
},
"guilds": {
"title": "Guilds",
"subtitle": "Hotel groups and membership",
"totalGuilds": "Guilds",
"searchPlaceholder": "Search by name, description or ID…",
"search": "Search",
"empty": "No guilds found.",
"loadError": "Could not load guilds (database unavailable).",
"colId": "ID",
"colBadge": "Badge",
"colName": "Name",
"colOwner": "Owner",
"colMembers": "Members",
"colCreated": "Created",
"colThreads": "Forum threads",
"colRoom": "Room",
"colUser": "User",
"colLevel": "Level",
"colJoined": "Joined",
"members": "Members",
"noMembers": "No members.",
"back": "Back to guilds",
"viewPublic": "Open public page",
"disband": "Disband guild"
}
},
"error": {
@@ -2944,6 +2992,35 @@
"voteCount": "{count} ({pct}%)",
"totalVotes": "{count} total votes",
"textResponses": "{count} free-text responses"
},
"mod": {
"title": "Moderation panel",
"badge": "Mod",
"overviewTitle": "Moderator overview",
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
"shortcutsTitle": "Shortcuts",
"nav": {
"overview": "Overview",
"cfh": "Call for Help",
"actions": "Quick actions",
"bans": "Bans",
"fullAdmin": "Full admin",
"backToSite": "Back to site",
"tickets": "Tickets",
"team": "Team",
"helpTickets": "Help-center tickets"
},
"stats": {
"openCfh": "Open CFH",
"bansToday": "Bans today",
"modsOnline": "Mods online",
"quickActions": "Quick actions",
"quickActionsHint": "Kick, mute, alert",
"openTickets": "Open tickets",
"team": "Mod team",
"teamHint": "Online staff & workload",
"openHelpTickets": "Open help tickets"
}
}
},
"emails": {
+82 -5
View File
@@ -23,7 +23,8 @@
"closeMenu": "Menüyü kapat",
"drawBadge": "Rozet çiz",
"events": "Events",
"polls": "Polls"
"polls": "Polls",
"mod": "Mod"
},
"header": {
"online": "{count} {hotel} çevrimiçi",
@@ -906,7 +907,8 @@
"chatLog": "Chat log",
"commandLog": "Command log",
"tradeLog": "Trade log",
"tools": "Tools"
"tools": "Tools",
"guilds": "Guilds"
},
"dashboard": {
"title": "Kontrol Paneli",
@@ -2213,7 +2215,24 @@
"auditTarget": "Hedef",
"selectAllMatching": "Select all {count} matching",
"save": "Save",
"importTitle": "Import permissions"
"importTitle": "Import permissions",
"legacyBannerTitle": "Use Permissions to change who can do what",
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
"legacyBannerCta": "Open Permissions",
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
"tabCompare": "ACL compare",
"tabExportOnly": "Export archive",
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
"compareAligned": "{count} aligned",
"compareMissingAcl": "{count} missing in ACL",
"compareMismatch": "{count} rank mismatch",
"colAclPresent": "In ACL",
"colAclMinRank": "ACL min rank",
"colStatus": "Status",
"statusAligned": "Aligned",
"statusMissingAcl": "Missing in ACL",
"statusMismatch": "Rank differs",
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
},
"rooms": {
"title": "Odalar",
@@ -2442,7 +2461,9 @@
"colPriority": "Priority",
"colAssigned": "Assigned",
"colDate": "Date",
"meta": "by {user} · {count} msg · {category}"
"meta": "by {user} · {count} msg · {category}",
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
"queueOtherCta": "Open help-center tickets"
},
"cfh": {
"title": "Call for Help",
@@ -2704,7 +2725,9 @@
"replied": "Reply sent.",
"closed": "Ticket closed.",
"reopened": "Ticket reopened."
}
},
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
"queueOtherCta": "Open CMS support desk"
},
"marketplace": {
"title": "Marketplace",
@@ -2799,6 +2822,31 @@
"less": "Less",
"more": "More"
}
},
"guilds": {
"title": "Guilds",
"subtitle": "Hotel groups and membership",
"totalGuilds": "Guilds",
"searchPlaceholder": "Search by name, description or ID…",
"search": "Search",
"empty": "No guilds found.",
"loadError": "Could not load guilds (database unavailable).",
"colId": "ID",
"colBadge": "Badge",
"colName": "Name",
"colOwner": "Owner",
"colMembers": "Members",
"colCreated": "Created",
"colThreads": "Forum threads",
"colRoom": "Room",
"colUser": "User",
"colLevel": "Level",
"colJoined": "Joined",
"members": "Members",
"noMembers": "No members.",
"back": "Back to guilds",
"viewPublic": "Open public page",
"disband": "Disband guild"
}
},
"error": {
@@ -2944,6 +2992,35 @@
"voteCount": "{count} ({pct}%)",
"totalVotes": "{count} total votes",
"textResponses": "{count} free-text responses"
},
"mod": {
"title": "Moderation panel",
"badge": "Mod",
"overviewTitle": "Moderator overview",
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
"shortcutsTitle": "Shortcuts",
"nav": {
"overview": "Overview",
"cfh": "Call for Help",
"actions": "Quick actions",
"bans": "Bans",
"fullAdmin": "Full admin",
"backToSite": "Back to site",
"tickets": "Tickets",
"team": "Team",
"helpTickets": "Help-center tickets"
},
"stats": {
"openCfh": "Open CFH",
"bansToday": "Bans today",
"modsOnline": "Mods online",
"quickActions": "Quick actions",
"quickActionsHint": "Kick, mute, alert",
"openTickets": "Open tickets",
"team": "Mod team",
"teamHint": "Online staff & workload",
"openHelpTickets": "Open help tickets"
}
}
},
"emails": {
+82 -5
View File
@@ -23,7 +23,8 @@
"closeMenu": "Закрити меню",
"drawBadge": "Намалюйте значок",
"events": "Events",
"polls": "Polls"
"polls": "Polls",
"mod": "Mod"
},
"header": {
"online": "{count} {hotel} онлайн",
@@ -906,7 +907,8 @@
"chatLog": "Chat log",
"commandLog": "Command log",
"tradeLog": "Trade log",
"tools": "Tools"
"tools": "Tools",
"guilds": "Guilds"
},
"dashboard": {
"title": "Приладова панель",
@@ -2213,7 +2215,24 @@
"auditTarget": "Цільова",
"selectAllMatching": "Select all {count} matching",
"save": "Save",
"importTitle": "Import permissions"
"importTitle": "Import permissions",
"legacyBannerTitle": "Use Permissions to change who can do what",
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
"legacyBannerCta": "Open Permissions",
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
"tabCompare": "ACL compare",
"tabExportOnly": "Export archive",
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
"compareAligned": "{count} aligned",
"compareMissingAcl": "{count} missing in ACL",
"compareMismatch": "{count} rank mismatch",
"colAclPresent": "In ACL",
"colAclMinRank": "ACL min rank",
"colStatus": "Status",
"statusAligned": "Aligned",
"statusMissingAcl": "Missing in ACL",
"statusMismatch": "Rank differs",
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
},
"rooms": {
"title": "Кімнати",
@@ -2442,7 +2461,9 @@
"colPriority": "Priority",
"colAssigned": "Assigned",
"colDate": "Date",
"meta": "by {user} · {count} msg · {category}"
"meta": "by {user} · {count} msg · {category}",
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
"queueOtherCta": "Open help-center tickets"
},
"cfh": {
"title": "Call for Help",
@@ -2704,7 +2725,9 @@
"replied": "Reply sent.",
"closed": "Ticket closed.",
"reopened": "Ticket reopened."
}
},
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
"queueOtherCta": "Open CMS support desk"
},
"marketplace": {
"title": "Marketplace",
@@ -2799,6 +2822,31 @@
"less": "Less",
"more": "More"
}
},
"guilds": {
"title": "Guilds",
"subtitle": "Hotel groups and membership",
"totalGuilds": "Guilds",
"searchPlaceholder": "Search by name, description or ID…",
"search": "Search",
"empty": "No guilds found.",
"loadError": "Could not load guilds (database unavailable).",
"colId": "ID",
"colBadge": "Badge",
"colName": "Name",
"colOwner": "Owner",
"colMembers": "Members",
"colCreated": "Created",
"colThreads": "Forum threads",
"colRoom": "Room",
"colUser": "User",
"colLevel": "Level",
"colJoined": "Joined",
"members": "Members",
"noMembers": "No members.",
"back": "Back to guilds",
"viewPublic": "Open public page",
"disband": "Disband guild"
}
},
"error": {
@@ -2944,6 +2992,35 @@
"voteCount": "{count} ({pct}%)",
"totalVotes": "{count} total votes",
"textResponses": "{count} free-text responses"
},
"mod": {
"title": "Moderation panel",
"badge": "Mod",
"overviewTitle": "Moderator overview",
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
"shortcutsTitle": "Shortcuts",
"nav": {
"overview": "Overview",
"cfh": "Call for Help",
"actions": "Quick actions",
"bans": "Bans",
"fullAdmin": "Full admin",
"backToSite": "Back to site",
"tickets": "Tickets",
"team": "Team",
"helpTickets": "Help-center tickets"
},
"stats": {
"openCfh": "Open CFH",
"bansToday": "Bans today",
"modsOnline": "Mods online",
"quickActions": "Quick actions",
"quickActionsHint": "Kick, mute, alert",
"openTickets": "Open tickets",
"team": "Mod team",
"teamHint": "Online staff & workload",
"openHelpTickets": "Open help tickets"
}
}
},
"emails": {
+1
View File
@@ -0,0 +1 @@
cleanup
-1
View File
@@ -20,7 +20,6 @@
"name": "next"
}
],
"baseUrl": ".",
"ignoreDeprecations": "6.0",
"paths": {
"@/*": ["./src/*"]