Compare commits
42
Commits
85facd349a
..
v1.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52459c0b74 | ||
|
|
a640e40a5d | ||
|
|
649e2f0663 | ||
|
|
1a9b361420 | ||
|
|
3e0ba73d1c | ||
|
|
bef1775dea | ||
|
|
c5284e0b79 | ||
|
|
539e6d3fad | ||
|
|
7033d65846 | ||
|
|
35d0f3ee01 | ||
|
|
93a7e9a7c2 | ||
|
|
08321df2aa | ||
|
|
55a47949f1 | ||
|
|
0bedc04692 | ||
|
|
814d8650be | ||
|
|
193b6686a9 | ||
|
|
16f35568f5 | ||
|
|
3f2f2c6ed1 | ||
|
|
75dc84d9f1 | ||
|
|
fea8023ac6 | ||
|
|
d1003bb89b | ||
|
|
9b0ea2fb16 | ||
|
|
ef94646d60 | ||
|
|
96234075f8 | ||
|
|
a070004e7b | ||
|
|
35f66d879f | ||
|
|
9dc9d1fa4b | ||
|
|
251738fdda | ||
|
|
b5dcadb67b | ||
|
|
7c1f109a9d | ||
|
|
85a6df162b | ||
|
|
2840ef5d9d | ||
|
|
31691c1c7d | ||
|
|
6146995758 | ||
|
|
9ae03056e2 | ||
|
|
9bc0834bbb | ||
|
|
8c12fc6c60 | ||
|
|
b12e405b34 | ||
|
|
8b59bd1d2d | ||
|
|
180129699c | ||
|
|
51d1284950 | ||
|
|
667637f8da |
No files matched your search
Whitespace-only changes.
@@ -7,6 +7,12 @@ storage
|
||||
prod.log
|
||||
update.log
|
||||
.pm2
|
||||
# Only the pnpm lockfile is used. Ignore other lockfile formats and stray
|
||||
# package managers so they never taint the build context by accident.
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
bun.lockb
|
||||
.npmrc.bak
|
||||
# NOTE: .env is intentionally NOT ignored here — the build loads it (only inside
|
||||
# a build RUN layer) to produce NEXT_PUBLIC_* + validated build-time values.
|
||||
# It is not copied into the runtime image (the runner stage copies only
|
||||
|
||||
+12
-130
@@ -37,6 +37,9 @@ jobs:
|
||||
- name: Lint
|
||||
run: pnpm biome:lint
|
||||
|
||||
- name: CMS translation contracts
|
||||
run: pnpm i18n:check
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
@@ -48,7 +51,12 @@ jobs:
|
||||
REDIS_URL: "redis://127.0.0.1:6379?connect_timeout=2"
|
||||
AUTH_SECRET: "ci-test-secret-key-that-is-long-enough"
|
||||
BCRYPT_ROUNDS: 4
|
||||
run: pnpm test --maxWorkers=1
|
||||
run: |
|
||||
if [ -x /usr/bin/time ]; then
|
||||
/usr/bin/time -f 'Tests: %e seconds; peak process RSS: %M KiB' pnpm test --maxWorkers=2
|
||||
else
|
||||
time pnpm test --maxWorkers=2
|
||||
fi
|
||||
|
||||
- name: Knip (unused files/exports)
|
||||
run: pnpm knip
|
||||
@@ -69,134 +77,8 @@ jobs:
|
||||
repository: ${{ gitea.repository }}
|
||||
token: ${{ gitea.token }}
|
||||
|
||||
- name: Provide production env for build
|
||||
run: |
|
||||
# De Next.js-build bakt NEXT_PUBLIC_* in de client-bundle en
|
||||
# valideert DATABASE_URL/HOTEL_NAME (zie src/env.ts — validatie
|
||||
# overslaan is verboden voor productie). .env staat niet in git,
|
||||
# dus kopieer de productie-.env van de host in de build-context.
|
||||
# Hij belandt alleen in de wegwerp-builder-stage, niet in de
|
||||
# runtime-image (die krijgt env via -e flags bij docker run).
|
||||
cp /var/www/atom-nexst/.env .env
|
||||
|
||||
- name: Build image
|
||||
run: |
|
||||
# --network=host is vereist: deze host heeft Docker iptables
|
||||
# uitgeschakeld, dus build-containers op het bridge-netwerk
|
||||
# hebben geen outbound internet (npm/pnpm zouden hangen).
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--network=host \
|
||||
--build-arg NODE_OPTIONS="--max-old-space-size=1536" \
|
||||
--cache-from epicnext-cms:latest \
|
||||
-t epicnext-cms:latest .
|
||||
|
||||
- name: Run migrations
|
||||
run: |
|
||||
# Draai DB-migraties van deze commit op de host (de slimme
|
||||
# runtime-image heeft geen source/tsx, zie docker-compose.yml).
|
||||
# Idempotent: al toegepaste migraties worden overgeslagen.
|
||||
# Gebruikt .env uit de eerdere stap (productie-DB). Vóór het
|
||||
# vervangen van de container, zodat het schema klaarstaat.
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm db:migrate
|
||||
|
||||
- name: Deploy container
|
||||
- name: Build, deploy and smoke test
|
||||
shell: bash
|
||||
run: |
|
||||
# Maak poort 3002 vrij: stop zowel de vorige CI-container als de
|
||||
# compose-container (beide draaien op het host-netwerk).
|
||||
docker stop epicnext-cms-app 2>/dev/null || true
|
||||
docker rm epicnext-cms-app 2>/dev/null || true
|
||||
docker stop epicnext-cms 2>/dev/null || true
|
||||
docker rm epicnext-cms 2>/dev/null || true
|
||||
|
||||
# Geef de productie-env 1-op-1 door. GEEN env-file-flag: `docker run`
|
||||
# behoudt letterlijke quotes uit het bestand (DATABASE_URL="..." →
|
||||
# ongeldige URL en crash), terwijl de shell ze correct stript.
|
||||
# Sourcen + elke sleutel met -e doorgeven geeft de container exact
|
||||
# dezelfde waarden als waarmee de image gebouwd is.
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
. /var/www/atom-nexst/.env
|
||||
set +a
|
||||
ENV_ARGS=()
|
||||
while IFS='=' read -r key _; do
|
||||
case "$key" in
|
||||
''|'#'*|*[!A-Za-z0-9_]* ) continue ;;
|
||||
esac
|
||||
ENV_ARGS+=(-e "$key")
|
||||
done < /var/www/atom-nexst/.env
|
||||
|
||||
# Zelfde env + volumes als docker-compose.yml, zodat de CI-container
|
||||
# functioneel gelijk is aan de compose-container die hij vervangt.
|
||||
docker run -d \
|
||||
--name epicnext-cms-app \
|
||||
--restart always \
|
||||
--net=host \
|
||||
"${ENV_ARGS[@]}" \
|
||||
-v /var/www/atom-nexst/public/nitro-assets:/app/public/nitro-assets \
|
||||
-v /var/www/atom-nexst/public/swf:/app/public/swf \
|
||||
-v /var/www/atom-nexst/storage:/app/storage \
|
||||
-v /var/www/Gamedata:/var/www/Gamedata \
|
||||
epicnext-cms:latest
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:3002/api/health \
|
||||
| grep -q '"database":true'; then
|
||||
echo "Deploy OK"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting... ($i/30)"
|
||||
sleep 3
|
||||
done
|
||||
|
||||
echo "ERROR: Health check failed" >&2
|
||||
docker logs epicnext-cms-app --tail 50 >&2 || true
|
||||
echo "Rolling back to compose container..." >&2
|
||||
docker stop epicnext-cms-app 2>/dev/null || true
|
||||
docker rm epicnext-cms-app 2>/dev/null || true
|
||||
docker compose -f /var/www/atom-nexst/docker-compose.yml up -d --no-build 2>&1 || true
|
||||
exit 1
|
||||
|
||||
- name: Prune Docker cache
|
||||
if: always()
|
||||
run: |
|
||||
# Ruim de self-hosted runner op zodat de Docker daemon niet
|
||||
# volloopt. Draaiende containers/images (o.a. de net-deployed
|
||||
# epicnext-cms-app) worden nooit geraakt, alleen ongebruikte
|
||||
# images, build-cache, volumes en networks verdwijnen.
|
||||
docker image prune -af || true
|
||||
docker container prune -f || true
|
||||
docker volume prune -f || true
|
||||
docker network prune -f || true
|
||||
docker builder prune -af --keep-storage=2g || true
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# E2E smoke against the freshly deployed container.
|
||||
# Runs after a successful deploy on main/master only
|
||||
# (on PRs the deploy job is skipped, so this is skipped too).
|
||||
# Public read-only endpoints only — safe against production.
|
||||
# ─────────────────────────────────────────────
|
||||
e2e:
|
||||
needs: deploy
|
||||
if: gitea.event_name == 'push' && (gitea.ref_name == 'main' || gitea.ref_name == 'master')
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ gitea.repository }}
|
||||
token: ${{ gitea.token }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright browser
|
||||
run: pnpm exec playwright install chromium
|
||||
|
||||
- name: Smoke test deployed app
|
||||
env:
|
||||
PLAYWRIGHT_BASE_URL: "http://127.0.0.1:3002"
|
||||
run: pnpm test:e2e
|
||||
DEPLOY_BRANCH: ${{ gitea.ref_name }}
|
||||
run: bash scripts/ci-deploy.sh
|
||||
@@ -0,0 +1,30 @@
|
||||
# CMS translation audit and editor
|
||||
|
||||
The CMS editor at `/admin/translations/cms` now uses the same bundled catalogs as the request-time translator. Runtime changes are stored separately in `storage/cms-translations/<locale>.json`, inside the existing persistent `/app/storage` mount. No source-file write, environment-variable change or rebuild is required to apply an edit.
|
||||
|
||||
Only overrides are saved. Unchanged messages continue to receive updates from Git. Saves use a file lock, an atomic replacement and a revision check; a stale editor cannot overwrite another operator's changes. ICU syntax, argument names, rich-text tags and allowed keys are checked server-side. Invalid or obsolete overrides are excluded when reading a new release. Storage read errors are reported to the CMS error monitor and public pages fall back to bundled text.
|
||||
|
||||
The page starts in the operator's language. It shows all English reference keys, including missing translations, and supports search by key, translated text or English source. Filters separate missing, identical and modified text. Drafts survive language switches and failed saves. Users without `SETTINGS_EDIT` can review and export but cannot save.
|
||||
|
||||
## Audit outcome, 6 September 2026
|
||||
|
||||
- Scanned 25 JSON catalogs; 22 languages are selectable. The small Arabic, Finnish and Japanese catalogs are legacy files and remain outside the supported locale list.
|
||||
- Repaired 66 malformed ICU messages across the 22 active catalogs, including HTML fragments and unescaped JSON examples.
|
||||
- Added 199 missing English reference keys used by page components, with Italian translations, and fixed the incorrect navigation namespace in the admin error page.
|
||||
- Completed the 31 previously missing Italian reference keys.
|
||||
- Repaired missing `count` and `preset` variables in other locales.
|
||||
- Final checks: zero malformed messages, zero argument/tag mismatches and zero missing references among the statically resolved translation calls.
|
||||
- English contains 3,423 reference keys. Italian covers all of them; 629 values match English. Dutch is missing 524 reference keys and has 618 identical values. Matching English can be intentional for names and technical labels; this is not proof of translation quality.
|
||||
- Found 1,577 literal JSX text candidates outside translation calls. These include labels, technical strings and names; they are an editorial inventory, not 1,577 confirmed bugs. The largest concentrations are the catalog item table (118), Studio main component (79), import audit (53), sound management (50) and permission editor (42).
|
||||
|
||||
## Repeatable checks
|
||||
|
||||
- `pnpm i18n:check`: fails on malformed messages, incompatible variables/tags, empty messages, source parsing errors or missing statically referenced keys. Runs in Gitea CI.
|
||||
- `pnpm i18n:audit`: prints coverage and findings.
|
||||
- `node scripts/audit-cms-translations.mjs --json`: full machine-readable inventory, including file and line references for literal JSX candidates.
|
||||
|
||||
Static analysis resolves literal translator namespaces and literal message keys. Dynamic key construction, prose embedded in arbitrary JavaScript strings, and the linguistic accuracy of all 22 translations still require targeted review. English fallback remains explicit; copying English into other catalogs would hide untranslated entries and is intentionally avoided.
|
||||
|
||||
## Validation
|
||||
|
||||
Automated tests exercise message syntax, actual translator output, persistent overrides, invalid-message rejection, revision conflicts and reset behavior. A browser fixture mounts the real editor and verifies missing-key editing, validation, failed-save preservation, language switching, successful saves, read-only access and mobile layout. Server actions are simulated in that fixture; authenticated production editing requires a staff session.
|
||||
+44
-109
@@ -1,113 +1,48 @@
|
||||
# ==============================================================================
|
||||
# EpicNext-CMS — Docker image (Node 26.8.1, multi-package-manager, Next.js standalone)
|
||||
# ==============================================================================
|
||||
# Supports pnpm (default), npm, and yarn. The build stage detects which package
|
||||
# manager lockfile is present and uses it automatically.
|
||||
# ==============================================================================
|
||||
|
||||
# --- Builder stage ---
|
||||
FROM node:26.8.1-bookworm-slim AS builder
|
||||
|
||||
# git is needed by next.config.ts (git rev-parse for deploymentId) and
|
||||
# ca-certificates by package registries. Build runs on host network (see
|
||||
# compose: iptables is disabled), so apt has registry access here.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install all three package managers so the build can pick whichever lockfile exists.
|
||||
RUN npm install -g [email protected] yarn
|
||||
|
||||
# syntax=docker/dockerfile:1
|
||||
# node:alpine = latest Node within the supported LTS major (tracks the newest
|
||||
# patch automatically; currently v26.x, which satisfies package.json's
|
||||
# engines ">=26.8.1 <27").
|
||||
FROM node:alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# First copy only the manifests so dependency layers are cached when using pnpm.
|
||||
# For npm/yarn the full context is copied below before install.
|
||||
COPY package.json pnpm-workspace.yaml .npmrc ./
|
||||
|
||||
# Copy the rest of the source (brings in whichever lockfile your project uses).
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# Real release id supplied by CI (ci-deploy.sh) so next.config.ts skips git
|
||||
# entirely (there is no .git in the build context). Defaults to "unknown".
|
||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
|
||||
# pnpm install is always pinned to the version in package.json's
|
||||
# `packageManager` field (pnpm auto-selects it on install), so this global
|
||||
# install only needs to exist as a bootstrap and follows the active major.
|
||||
RUN apk add --no-cache git \
|
||||
&& npm install -g pnpm@latest
|
||||
# pnpm-workspace.yaml + .npmrc must be present too: the lockfile records the
|
||||
# overrides from pnpm-workspace.yaml, and --frozen-lockfile rejects a build
|
||||
# where the workspace config is absent (ERR_PNPM_LOCKFILE_CONFIG_MISMATCH).
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
|
||||
# pnpm fetch: download all deps into $PNPM_STORE first, so only the lockfile
|
||||
# change (not source changes) invalidates the network-heavy download layer.
|
||||
RUN pnpm fetch --ignore-scripts
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts --offline
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
|
||||
# --- Detect package manager & install dependencies ---
|
||||
# Priority: pnpm > yarn > npm
|
||||
# Build arg lets the user force a manager; otherwise it is auto-detected.
|
||||
ARG PACKAGE_MANAGER=
|
||||
|
||||
RUN if [ "$PACKAGE_MANAGER" = "pnpm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f pnpm-lock.yaml ]; }; then \
|
||||
echo ">> Using pnpm" && \
|
||||
pnpm install --frozen-lockfile --ignore-scripts; \
|
||||
elif [ "$PACKAGE_MANAGER" = "yarn" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f yarn.lock ]; }; then \
|
||||
echo ">> Using yarn" && \
|
||||
yarn install --frozen-lockfile --ignore-scripts; \
|
||||
elif [ "$PACKAGE_MANAGER" = "npm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f package-lock.json ]; }; then \
|
||||
echo ">> Using npm" && \
|
||||
npm ci --ignore-scripts; \
|
||||
else \
|
||||
echo "!! No lockfile found — falling back to npm install" && \
|
||||
npm install --ignore-scripts; \
|
||||
fi
|
||||
|
||||
# Build the production bundle.
|
||||
# The .env file is loaded ONLY inside this RUN layer (not persisted as ENV, so no
|
||||
# secrets end up in the image) — Next.js needs NEXT_PUBLIC_* + validated build-time
|
||||
# values (HOTEL_NAME, DATABASE_URL, AUTH_SECRET, ...) at build time.
|
||||
ENV NODE_ENV=production
|
||||
RUN if [ -f .env ]; then set -a && . ./.env && set +a; fi && \
|
||||
if [ "$PACKAGE_MANAGER" = "yarn" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f yarn.lock ]; }; then \
|
||||
yarn build; \
|
||||
elif [ "$PACKAGE_MANAGER" = "npm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f package-lock.json ]; }; then \
|
||||
npm run build; \
|
||||
else \
|
||||
pnpm build; \
|
||||
fi
|
||||
|
||||
# Prune dev dependencies for the runtime image.
|
||||
RUN if [ "$PACKAGE_MANAGER" = "yarn" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f yarn.lock ]; }; then \
|
||||
yarn install --production --ignore-scripts && rm -rf node_modules/.cache; \
|
||||
elif [ "$PACKAGE_MANAGER" = "npm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f package-lock.json ]; }; then \
|
||||
npm prune --production; \
|
||||
else \
|
||||
pnpm prune --prod; \
|
||||
fi
|
||||
|
||||
# --- Runtime stage ---
|
||||
FROM node:26.8.1-bookworm-slim AS runner
|
||||
|
||||
# Catalog Studio publishes to self-hosted Git repositories over HTTPS.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# The CMS writes to bind-mounted host directories (/var/www/Gamedata is owned by
|
||||
# the host's www-data user, UID/GID 33). The node base image already ships a
|
||||
# www-data user with UID/GID 33, which matches that ownership — so we run as
|
||||
# www-data and can write to the shared gamedata directory. If your host owner
|
||||
# differs, override via --build-arg RUN_USER (e.g. --build-arg RUN_USER=1000).
|
||||
ARG RUN_USER=www-data
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3002
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
EXPOSE 3002
|
||||
|
||||
FROM node:alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Storage directory for runtime uploaded media (persistent volume).
|
||||
# Also create the hardcoded gamedata mount point (/var/www/Gamedata is
|
||||
# bind-mounted at runtime so the CMS can read + write imported assets there).
|
||||
RUN mkdir -p /app/storage \
|
||||
/app/public/nitro-assets \
|
||||
/app/public/swf \
|
||||
/var/www/Gamedata \
|
||||
&& chown -R ${RUN_USER} /app /var/www/Gamedata
|
||||
|
||||
# Copy standalone Next.js output (includes a minimal node_modules).
|
||||
COPY --from=builder --chown=${RUN_USER} /app/.next/standalone ./
|
||||
# Copy static assets (public files served directly).
|
||||
COPY --from=builder --chown=${RUN_USER} /app/public ./public
|
||||
# Copy the server-side static build output.
|
||||
COPY --from=builder --chown=${RUN_USER} /app/.next/static ./.next/static
|
||||
|
||||
# Client assets + runtime uploads live outside the image (mounted volumes).
|
||||
VOLUME ["/app/public/nitro-assets", "/app/public/swf", "/app/storage"]
|
||||
|
||||
USER ${RUN_USER}
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
PORT=3002 \
|
||||
HOSTNAME=0.0.0.0
|
||||
RUN apk add --no-cache tini \
|
||||
&& addgroup -g 33 -S nextjs && adduser -u 33 -S -G nextjs nextjs \
|
||||
&& mkdir -p /app/storage /app/public/nitro-assets /app/public/swf /var/www/Gamedata \
|
||||
&& chown -R 33:33 /app/storage /app/public /var/www/Gamedata
|
||||
COPY --from=builder --chown=nextjs:nextjs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static
|
||||
USER nextjs
|
||||
EXPOSE 3002
|
||||
# Self-contained healthcheck so `docker run` (ci-deploy) also gets Docker-level
|
||||
# health; docker-compose overrides this with its own probe if needed.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,4 +1,4 @@
|
||||
# EpicNext-CMS v1.0.1
|
||||
# EpicNext-CMS v1.0.3
|
||||
|
||||
A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Drizzle ORM** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases.
|
||||
|
||||
@@ -112,7 +112,7 @@ Open `http://localhost:3002` in your browser.
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
The CMS ships with a multi-stage Dockerfile that automatically detects your package manager (pnpm, npm, or yarn) based on which lockfile is present. Only the **CMS** runs in Docker; the Nitro/Octane client and its renderer stay outside and are served by nginx on the host.
|
||||
The CMS ships with a multi-stage Dockerfile that automatically detects your package manager (pnpm, npm, or yarn) based on which lockfile is present. The **CMS** runs in Docker; the Nitro/Octane client stays outside and is served by nginx on the host. The server-side **avatar imager** also runs in its own Docker container (`avatar-imaging-pixinode`, port 8082) — see [Avatar Imaging](#avatar-imaging).
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -196,6 +196,7 @@ an exit of 0 means the CMS is healthy on the new commit.
|
||||
| `docker compose pull && docker compose up -d --build` | Update and redeploy |
|
||||
| `pnpm db:migrate` (on the **host**) | Run database migrations (slim image has no source) |
|
||||
| `./scripts/docker-update.sh` | Full automated update (manual or cron) |
|
||||
| `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` bulk-load container |
|
||||
|
||||
### How Package Manager Detection Works
|
||||
|
||||
@@ -212,7 +213,7 @@ This means you can use any package manager on your host machine — the Docker b
|
||||
|
||||
### Volumes
|
||||
|
||||
Only the CMS runs in Docker. The heavy client assets and gamedata stay on the host and are shared into the container so imports and uploads persist:
|
||||
Only the CMS (and the optional avatar imager container, see [Avatar Imaging](#avatar-imaging)) run in Docker. The heavy client assets and gamedata stay on the host and are shared into the container so imports and uploads persist:
|
||||
|
||||
| Container Path | Host Path | Mode | Purpose |
|
||||
| -------------------------- | -------------------------- | ---- | ------------------------------------ |
|
||||
@@ -228,7 +229,7 @@ Only the CMS runs in Docker. The heavy client assets and gamedata stay on the ho
|
||||
| Component | Host path | How it's served |
|
||||
| -------------------- | ----------------------- | ---------------------------------------------------------------------- |
|
||||
| Nitro/Octane client | `/var/www/Octane/dist` | nginx `location ^~ /client/` and `/nitro-client/` alias |
|
||||
| Octane-Renderer | `/var/www/Octane-Renderer` | Optional. Run separately (or as the commented-out `imager` service) proxied by nginx `/imaging` |
|
||||
| Avatar imager | `/docker/Polaris-imager`| Docker container `avatar-imaging-pixinode` on port `8082` — see [Avatar Imaging](#avatar-imaging) |
|
||||
| Camera uploads | `/var/www/Camera` | nginx `/camera/` alias |
|
||||
|
||||
**Container user & write permissions:** the CMS container runs as `www-data` (UID/GID 33) by default to match the host owner of `/var/www/Gamedata`. Ensure the other write volumes (`./public/nitro-assets`, `./public/swf`, `./storage`) are also owned by `www-data` on the host:
|
||||
@@ -258,6 +259,45 @@ The container includes a health check that hits `/api/health` on port 3002 every
|
||||
docker inspect --format='{{.State.Health.Status}}' epicnext-cms
|
||||
```
|
||||
|
||||
### MariaDB Turbo (bulk loads)
|
||||
|
||||
`docker-compose.yml` ships an opt-in **`mariadb-turbo`** service — a dedicated MariaDB 11 container tuned for loading >50 MB JSON dumps (furnidata, external texts, etc.). It configures itself via mysqld flags on the `command:`, so no external `.cnf` file has to be mounted or kept in sync:
|
||||
|
||||
```bash
|
||||
pnpm db:up # docker compose --profile db up -d (starts mariadb-turbo)
|
||||
```
|
||||
|
||||
Key settings (see the `command:` block in `docker-compose.yml`):
|
||||
|
||||
- `max_allowed_packet = 512M` — 50 MB JSON batches no longer hit the 16 MB packet ceiling.
|
||||
- `innodb_flush_log_at_trx_commit = 2` + `innodb_doublewrite = 0` — durability relaxed for bulk writes.
|
||||
- `innodb_buffer_pool_size = 2G`, `innodb_log_file_size = 1G`, `bulk_insert_buffer_size = 512M`.
|
||||
- `net_read_timeout` / `net_write_timeout = 600`, `wait_timeout = 3600` — fixes the `drizzle-kit` **"Pulling schema from database..."** hang by never starving introspection/DDL sessions behind a long import.
|
||||
- `performance_schema = OFF` — saves ~1-2 GB RAM.
|
||||
|
||||
The datadir lives in a **named volume** (`mariadb-turbo-data`), never a host bind-mount: shared-filesystem sync trashes InnoDB files the same way it corrupts pnpm's `node_modules`. Named volumes stay inside the container filesystem, so 50 MB of JSON writes never cross a host-sync boundary.
|
||||
|
||||
> **Port note:** the service uses `network_mode: host` and binds `127.0.0.1:3306` — stop the host MariaDB first, otherwise the port collides with the standalone `.env` `DATABASE_URL` (`localhost:3306`).
|
||||
|
||||
#### node_modules & host-sync corruption
|
||||
|
||||
pnpm's store is hard-linked and its `.bin` shims are symlinks, so **node_modules must never be a host bind-mount** (Docker Desktop gRPC-FUSE/VirtioFS, Unison and Syncthing all corrupt it). The production build bakes `node_modules` into the image at build time; it is never bind-mounted. For local dev, mount a *named volume* (`node_modules:/app/node_modules`) instead of the host directory, and keep `node_modules` / the pnpm store out of any shared-filesystem bind.
|
||||
|
||||
#### Loading a 50 MB JSON dump
|
||||
|
||||
```bash
|
||||
# Habbo furnidata (auto-detects roomitemtypes / wallitemtypes / effecttypes)
|
||||
pnpm db:bulk --file=/var/www/Gamedata/config/FurnitureData.json
|
||||
|
||||
# Flat array of documents → generic JSON store
|
||||
pnpm db:bulk --file=/data/products.json --table=docs --category=furni
|
||||
|
||||
# Key/value texts
|
||||
pnpm db:bulk --file=/data/external_texts.json --table=texts --category=default
|
||||
```
|
||||
|
||||
The importer (`scripts/bulk-import-json.ts`) maps rows onto `src/db/schema-gamedata.ts`, batches them into **2000-row multi-row INSERTs** (one statement per chunk), uses `ON DUPLICATE KEY UPDATE` so re-runs are idempotent and interrupted loads resume, and prints rows/s + ETA. See ["ORM Setup & Type Generation"](#orm-setup--type-generation) → *Bulk JSON storage* for the design.
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -284,10 +324,41 @@ const found = await db.select()
|
||||
| `pnpm db:generate` | Draft SQL from Drizzle schema into `drizzle/drafts/` (review + copy) |
|
||||
| `pnpm db:studio` | Open Drizzle Studio (dev only) |
|
||||
| `pnpm db:introspect` | Reverse-engineer an existing DB into a Drizzle schema draft |
|
||||
| `pnpm db:bulk` | Batch-import >50 MB JSON (2000-row chunks, resumable) via `scripts/bulk-import-json.ts` |
|
||||
| `pnpm db:schema:generate` | Regen committed `src/db/schema.ts` from previous schema + live DB |
|
||||
|
||||
> The CMS does **not** use `drizzle-kit push` or `drizzle-kit migrate` — the database is shared with the emulator. Apply CMS DDL only via `pnpm db:migrate`.
|
||||
|
||||
#### Bulk JSON storage (MariaDB)
|
||||
|
||||
Emulator/hotel JSON dumps (furnidata, external texts, product data) are often >50 MB. MariaDB's `JSON` type is an alias for `LONGTEXT` and can **never be indexed directly**, so `src/db/schema-gamedata.ts` follows the "promote + index" pattern:
|
||||
|
||||
- The raw JSON document stays intact in a `longtext` / `mediumtext` column (`payload`, `value`).
|
||||
- The fields you actually `WHERE` / `ORDER BY` on are promoted into real columns (`sprite_id`, `class_name`, `kind`, `title`, `text_key`) and indexed.
|
||||
- Optional **VIRTUAL generated columns** extract indexed fields from the payload with `JSON_EXTRACT` (MariaDB supports secondary indexes on virtual columns, 10.2+), so no duplicate data has to be written by the importer.
|
||||
|
||||
Three tables are exported:
|
||||
|
||||
| Table | Purpose | Unique key |
|
||||
| -------------------- | ---------------------------------------------- | --------------------------- |
|
||||
| `gamedata_furnidata` | One row per furni/clothing item (habbo furnidata_json shape) | `(source, sprite_id)` |
|
||||
| `gamedata_docs` | Generic JSON document store (per-key documents) | `(category, doc_key)` |
|
||||
| `gamedata_texts` | External-texts style key/value pairs | `(category, text_key)` |
|
||||
|
||||
The bulk importer (`scripts/bulk-import-json.ts`, run via `pnpm db:bulk`) is DB-driven and fast precisely because each chunk is a *single* multi-row `INSERT … VALUES () ()… ON DUPLICATE KEY UPDATE`, so a 50 MB dump is a few dozen statements instead of hundreds of thousands of round-trips. Flags:
|
||||
|
||||
| Flag | Default | Description |
|
||||
| -------------------- | ----------- | ------------------------------------------------- |
|
||||
| `--file=<path>` | — (required)| JSON file (habbo furnidata_json or flat array) |
|
||||
| `--table=<name>` | `furnidata` | One of `furnidata` \| `docs` \| `texts` |
|
||||
| `--source=<x>` | `habbo` | `source` value for `furnidata` |
|
||||
| `--category=<x>` | `default` | `category` value for `docs` / `texts` |
|
||||
| `--chunk-size=N` | `2000` | Rows per multi-row INSERT |
|
||||
| `--limit=N` | `0` | Stop after N rows (dry-test) |
|
||||
| `--truncate` | off | DELETE rows for this source/category first |
|
||||
|
||||
The script sets `FOREIGN_KEY_CHECKS=0` for the session and is safe to interrupt: each chunk commits on its own, and `ON DUPLICATE KEY UPDATE` makes re-runs overwrite instead of appending.
|
||||
|
||||
---
|
||||
|
||||
## DragonflyDB (Caching, Rate Limiting, SSE)
|
||||
@@ -386,9 +457,11 @@ server {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
location /imaging {
|
||||
proxy_pass http://127.0.0.1:3030;
|
||||
add_header Cache-Control "public, max-age=3600, s-maxage=86400";
|
||||
location /imaging/ {
|
||||
proxy_pass http://127.0.0.1:8082/;
|
||||
proxy_set_header Connection "";
|
||||
proxy_http_version 1.1;
|
||||
add_header Cache-Control "public, max-age=300";
|
||||
}
|
||||
|
||||
location /health {
|
||||
@@ -399,6 +472,124 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
> **`/imaging` trailing-slash gotcha:** `location /imaging` (no trailing slash) combined with `proxy_pass http://…8082/;` rewrites `/imaging/avatarimage` into `//avatarimage` and 404s. Always use `location /imaging/` + a trailing-slash `proxy_pass` so `/imaging/avatarimage` reaches the imager's `/avatarimage` route.
|
||||
|
||||
---
|
||||
|
||||
## Avatar Imaging
|
||||
|
||||
Avatar images (profile figures, chat badges, forum avatars, admin previews) are rendered server-side by **Polaris-imager** ([duckietm/Polaris-imager](https://github.com/duckietm/Polaris-imager)) — a headless Nitro/Octane renderer that runs `@pixi/node` (real WebGL via `gl` + Xvfb) inside a Docker container named **`avatar-imaging-pixinode`**. It serves `GET /avatarimage?figure=…` on port **8082** and can emit PNG/APNG/GIF, gestures, actions, scenes and more.
|
||||
|
||||
### Request flow
|
||||
|
||||
```
|
||||
browser → Cloudflare → (page rule /origin for /imaging/*) → host:8082
|
||||
browser → nginx origin → location /imaging/ → 127.0.0.1:8082/avatarimage
|
||||
```
|
||||
|
||||
URLs look like `https://<hotel>/imaging/avatarimage?figure=hd-180-1.ch-210-66&img_format=png&size=l&direction=4`. The public `NEXT_PUBLIC_IMAGER_URL` points at that endpoint (see `src/lib/imager.ts`). Responses carry `Cache-Control: public, max-age=300`, so browsers and Cloudflare reuse a render for 5 minutes.
|
||||
|
||||
### Requirements (host)
|
||||
|
||||
- Docker (daemon with `build.network: host`, same as the CMS build — this host disables Docker iptables).
|
||||
- An nginx that serves `/gamedata/` (FigureData/FigureMap/EffectMap…) and `/gamedata/bundled` (`.nitro` assets) over HTTP so the renderer can fetch them. The compose file points at `host.docker.internal:8081`.
|
||||
|
||||
### Configuration — `/docker/Polaris-imager/.env`
|
||||
|
||||
```dotenv
|
||||
NITRO_GAMEDATA_URL=http://host.docker.internal:8081/gamedata/config
|
||||
NITRO_ASSET_URL=http://host.docker.internal:8081/gamedata/bundled
|
||||
AVATAR_IMAGING_FPS=12
|
||||
AVATAR_IMAGING_MAX_FRAMES=60
|
||||
AVATAR_IMAGING_HOST=0.0.0.0
|
||||
AVATAR_IMAGING_PORT=8082
|
||||
AVATAR_IMAGING_RATELIMIT_MAX=600
|
||||
AVATAR_IMAGING_SCENE=1
|
||||
AVATAR_IMAGING_WARDROBE=1
|
||||
AVATAR_IMAGING_HABBO_FONTS=1
|
||||
AVATAR_IMAGING_CHAT_BUBBLES=1
|
||||
AVATAR_IMAGING_HOTEL_URL=https://<hotel>
|
||||
AVATAR_IMAGING_HOTEL_NAME=<hotel>
|
||||
```
|
||||
|
||||
### Build & start
|
||||
|
||||
The image is self-contained — the `Dockerfile` clones the pinned Nitro/Octane renderer and runs the full `yarn`/`vite` bundle at build time, then produces a slim Debian runtime with Xvfb + Mesa software rendering:
|
||||
|
||||
```bash
|
||||
cd /docker/Polaris-imager
|
||||
docker compose build
|
||||
docker compose up -d # container: avatar-imaging-pixinode
|
||||
docker logs -f avatar-imaging-pixinode # expect: "listening on http://0.0.0.0:8082"
|
||||
```
|
||||
|
||||
The container runs `restart: unless-stopped` and ships a healthcheck that probes `/health` (reports `{ "ready": true }` once the headless renderer has booted). Verify a render:
|
||||
|
||||
```bash
|
||||
curl -o avatar.png 'http://127.0.0.1:8082/avatarimage?figure=hr-893-45.hd-600-1.ch-255-66.lg-280-110.sh-295-62&img_format=png&size=l'
|
||||
```
|
||||
|
||||
A production issue that this section documents: **the `avatar-imaging-pixinode` image can be removed by a `docker image prune`**, which takes the whole site's avatars offline (502 on `/imaging/*`). Keep the image tagged and re-build it via the commands above if it ever disappears (`docker compose build && docker compose up -d`).
|
||||
|
||||
### Figure string validation (`src/app/api/imaging/avatar/route.ts`)
|
||||
|
||||
The CMS-side route re-validates the incoming figure against `/^[a-z]{2}-\d+(-\d+)?(\.[a-z]{2}-\d+(-\d+)?)*$/i`. Real Habbo figure strings use `type-id-color` parts separated by dots (`hd-180-1.ch-210-66`), so each part allows an optional second number. Invalid input is rejected with `400` before any render happens.
|
||||
|
||||
---
|
||||
|
||||
## Performance Tuning (production)
|
||||
|
||||
The CMS shares its MySQL/MariaDB database with the emulator, so the hot wins come from caching, asset serving and database knob-tuning — not from a storage-engine migration.
|
||||
|
||||
### 1. gzip_static pre-compression of gamedata JSON
|
||||
|
||||
The client downloads `FurnitureData.json` (~48 MB, one file per supported language) plus other large JSON from `/gamedata/`. nginx has `gzip` and `gzip_static on` (see `/etc/nginx/nginx.conf`), but with no pre-built `.gz` file it re-compressed the 48 MB **on every request** (~0.25–0.30 s of CPU per miss).
|
||||
|
||||
`scripts/compress-gamedata.mjs` pre-compresses every JSON >1 MB in `<Gamedata>/config` and `<Gamedata>/bundled/config` once (gzip level 9): `FurnitureData*.json` go from ~48 MB to ~2.4 MB (~95 % smaller). It is idempotent — a file is only re-compressed when the source mtime is newer than its `.gz` (e.g. after a Studio catalog export).
|
||||
|
||||
```bash
|
||||
pnpm gamedata:compress # manual run
|
||||
```
|
||||
|
||||
Once the `.gz` exists, nginx `gzip_static` serves it straight from disk with `Content-Encoding: gzip` and effectively zero CPU. **Measured result on the production origin: 0.25–0.30 s → ~0.005 s per file.**
|
||||
|
||||
The production host runs the script on a cron so newly exported catalogs are compressed shortly after they land:
|
||||
|
||||
```bash
|
||||
crontab -e
|
||||
# */15 * * * * node /var/www/atom-nexst/scripts/compress-gamedata.mjs >> /var/www/atom-nexst/logs/gamedata-compress.log 2>&1
|
||||
```
|
||||
|
||||
### 2. MariaDB tuning
|
||||
|
||||
The whole `habbo` datadir is roughly 0.5 GB; the default InnoDB buffer pool (128 MB) pushed index/row reads to disk. The pool is raised to **4 GB** and the slow-query log is enabled (threshold 2 s), both live (`SET GLOBAL …`) and persisted for boot in `/etc/mysql/mariadb.conf.d/zz-cms-performance.cnf`:
|
||||
|
||||
```ini
|
||||
[mysqld]
|
||||
innodb_buffer_pool_size = 4G
|
||||
slow_query_log = 1
|
||||
long_query_time = 2
|
||||
slow_query_log_file = /var/log/mysql/mariadb-slow.log
|
||||
```
|
||||
|
||||
The slow-query log surfaces hot-spot SQL for a follow-up index/query audit. The CMS connection pool itself is already tuned (pool size 25, `connection_limit` in `DATABASE_URL`).
|
||||
|
||||
For bulk-written tables (game-data JSON, imports) a containerized **`mariadb-turbo`** variant is available — see [MariaDB Turbo](#mariadb-turbo-bulk-loads).
|
||||
|
||||
### 3. Asset caching headers
|
||||
|
||||
| Path | Cache-Control |
|
||||
| ------------------------------ | -------------------------------------------------------------- |
|
||||
| `/swf/**`, `/nitro-assets/**` | `public, max-age=604800, stale-while-revalidate=2592000` |
|
||||
| `/gamedata/**`, `/client/**` | `public` + `expires 7–30 d` (nginx) |
|
||||
| `/imaging/*` | `public, max-age=300` |
|
||||
| `/camera/**` | `public, max-age=31536000, immutable` |
|
||||
| `/_next/static/**` | `public, max-age=31536000, immutable` |
|
||||
|
||||
### 4. Cache layers (Redis + CDN)
|
||||
|
||||
The CMS caches through `cached()` / `cachedQuery()` (`src/lib/cache.ts`): an in-memory fast path with a Redis (DragonflyDB-compatible) fallback and single-flight protection against cache stampedes. Public API routes (home, leaderboard, online count, radio, photos, …) fill Redis keys per route; verify with `redis-cli DBSIZE`. With Cloudflare in front, static and `/imaging/*` responses are additionally cached edge-side (`cf-cache-status`), cutting origin load further.
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment (PM2)
|
||||
@@ -435,6 +626,9 @@ pm2 restart next
|
||||
| `pnpm db:generate` | Draft SQL via drizzle-kit → `drizzle/drafts/` |
|
||||
| `pnpm db:studio` | Drizzle Studio (dev) |
|
||||
| `pnpm db:introspect` | drizzle-kit introspect (draft) |
|
||||
| `pnpm db:bulk` | Batch-import >50 MB JSON via `scripts/bulk-import-json.ts` |
|
||||
| `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` container |
|
||||
| `pnpm gamedata:compress` | Pre-compress large gamedata JSON to `.gz` (gzip_static) |
|
||||
| `pnpm analyze` | Build + open bundle analyzer |
|
||||
| `pnpm jobs:worker` | Start background task worker |
|
||||
| `pnpm biome:check` | Lint and format code |
|
||||
@@ -456,6 +650,7 @@ pm2 restart next
|
||||
| **Streaming & Suspense** | Next.js App Router streaming for fast page loads |
|
||||
| **Automatic Image Optimization** | `next/image` with Sharp for resizing and WebP/AVIF |
|
||||
| **Compression** | Gzip compression enabled on all responses |
|
||||
| **gzip_static (gamedata)** | 48 MB FurnitureData JSON served from pre-built `.gz` — no per-request CPU |
|
||||
| **PWA** | Service worker with skip-waiting, stale CSS chunk recovery |
|
||||
| **Biome** | Fast linting and formatting (replaces ESLint + Prettier) |
|
||||
|
||||
@@ -469,11 +664,14 @@ pm2 restart next
|
||||
│ └── drafts/ # drizzle-kit generate output (never auto-applied)
|
||||
├── scripts/
|
||||
│ ├── apply-migrations.ts # SQL migration runner (apply + status)
|
||||
│ ├── bulk-import-json.ts # 50 MB+ JSON importer (2000-row chunks, UPSERT)
|
||||
│ ├── jobs-worker.ts # Background task scheduler
|
||||
│ └── generate-drizzle-schema.mjs # Regen src/db/schema.ts from schema + live DB
|
||||
│ ├── generate-drizzle-schema.mjs # Regen src/db/schema.ts from schema + live DB
|
||||
│ └── compress-gamedata.mjs # Pre-compress large gamedata JSON (gzip_static)
|
||||
├── src/
|
||||
│ ├── db/
|
||||
│ │ ├── schema.ts # Drizzle ORM schema (committed — runtime data layer)
|
||||
│ │ ├── schema-gamedata.ts # Large-JSON storage schema (furnidata/docs/texts)
|
||||
│ │ └── relations.ts # Drizzle relations
|
||||
│ ├── app/ # Next.js App Router (pages & API routes)
|
||||
│ ├── actions/ # Server Actions
|
||||
@@ -577,4 +775,3 @@ pnpm biome:check # Lint and format
|
||||
## License
|
||||
|
||||
CC BY-NC-SA 4.0. See `LICENSE` for details.
|
||||
Test aanpassing voor Gitea Actions
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,205 @@
|
||||
[
|
||||
{
|
||||
"id": 132,
|
||||
"sprite_id": 132,
|
||||
"item_name": "floortile",
|
||||
"public_name": "Floor Tile",
|
||||
"type": "s",
|
||||
"width": 1,
|
||||
"length": 1,
|
||||
"stack_height": 0,
|
||||
"allow_stack": 1,
|
||||
"allow_sit": 0,
|
||||
"allow_lay": 0,
|
||||
"allow_walk": 1,
|
||||
"allow_gift": 1,
|
||||
"allow_trade": 1,
|
||||
"allow_recycle": 0,
|
||||
"allow_marketplace_sell": 0,
|
||||
"allow_inventory_stack": 1,
|
||||
"interaction_type": "default",
|
||||
"interaction_modes_count": 0,
|
||||
"vending_ids": "0",
|
||||
"multiheight": "",
|
||||
"customparams": "",
|
||||
"effect_id_male": 0,
|
||||
"effect_id_female": 0,
|
||||
"clothing_on_walk": "",
|
||||
"page_id": "429",
|
||||
"rare": "0"
|
||||
},
|
||||
{
|
||||
"id": 420,
|
||||
"sprite_id": 420,
|
||||
"item_name": "soft_jaggara_norja",
|
||||
"public_name": "Norja-pehmojakkara",
|
||||
"type": "s",
|
||||
"width": 1,
|
||||
"length": 3,
|
||||
"stack_height": 1.7,
|
||||
"allow_stack": 1,
|
||||
"allow_sit": 1,
|
||||
"allow_lay": 0,
|
||||
"allow_walk": 0,
|
||||
"allow_gift": 1,
|
||||
"allow_trade": 1,
|
||||
"allow_recycle": 0,
|
||||
"allow_marketplace_sell": 0,
|
||||
"allow_inventory_stack": 1,
|
||||
"interaction_type": "default",
|
||||
"interaction_modes_count": 1,
|
||||
"vending_ids": "0",
|
||||
"multiheight": "",
|
||||
"customparams": "",
|
||||
"effect_id_male": 0,
|
||||
"effect_id_female": 0,
|
||||
"clothing_on_walk": "",
|
||||
"page_id": "429",
|
||||
"rare": "0"
|
||||
},
|
||||
{
|
||||
"id": 1001,
|
||||
"sprite_id": 1001,
|
||||
"item_name": "Chess",
|
||||
"public_name": "",
|
||||
"type": "i",
|
||||
"width": 1,
|
||||
"length": 1,
|
||||
"stack_height": 1,
|
||||
"allow_stack": 1,
|
||||
"allow_sit": 0,
|
||||
"allow_lay": 0,
|
||||
"allow_walk": 0,
|
||||
"allow_gift": 1,
|
||||
"allow_trade": 1,
|
||||
"allow_recycle": 0,
|
||||
"allow_marketplace_sell": 0,
|
||||
"allow_inventory_stack": 1,
|
||||
"interaction_type": "default",
|
||||
"interaction_modes_count": 0,
|
||||
"vending_ids": "0",
|
||||
"multiheight": "",
|
||||
"customparams": "",
|
||||
"effect_id_male": 0,
|
||||
"effect_id_female": 0,
|
||||
"clothing_on_walk": "",
|
||||
"page_id": "429",
|
||||
"rare": "0"
|
||||
},
|
||||
{
|
||||
"id": 1011,
|
||||
"sprite_id": 1011,
|
||||
"item_name": "TicTacToe",
|
||||
"public_name": "",
|
||||
"type": "i",
|
||||
"width": 1,
|
||||
"length": 1,
|
||||
"stack_height": 1,
|
||||
"allow_stack": 1,
|
||||
"allow_sit": 0,
|
||||
"allow_lay": 0,
|
||||
"allow_walk": 0,
|
||||
"allow_gift": 1,
|
||||
"allow_trade": 1,
|
||||
"allow_recycle": 0,
|
||||
"allow_marketplace_sell": 0,
|
||||
"allow_inventory_stack": 1,
|
||||
"interaction_type": "default",
|
||||
"interaction_modes_count": 0,
|
||||
"vending_ids": "0",
|
||||
"multiheight": "",
|
||||
"customparams": "",
|
||||
"effect_id_male": 0,
|
||||
"effect_id_female": 0,
|
||||
"clothing_on_walk": "",
|
||||
"page_id": "429",
|
||||
"rare": "0"
|
||||
},
|
||||
{
|
||||
"id": 1021,
|
||||
"sprite_id": 1021,
|
||||
"item_name": "BattleShip",
|
||||
"public_name": "",
|
||||
"type": "i",
|
||||
"width": 1,
|
||||
"length": 1,
|
||||
"stack_height": 1,
|
||||
"allow_stack": 1,
|
||||
"allow_sit": 0,
|
||||
"allow_lay": 0,
|
||||
"allow_walk": 0,
|
||||
"allow_gift": 1,
|
||||
"allow_trade": 1,
|
||||
"allow_recycle": 0,
|
||||
"allow_marketplace_sell": 0,
|
||||
"allow_inventory_stack": 1,
|
||||
"interaction_type": "default",
|
||||
"interaction_modes_count": 0,
|
||||
"vending_ids": "0",
|
||||
"multiheight": "",
|
||||
"customparams": "",
|
||||
"effect_id_male": 0,
|
||||
"effect_id_female": 0,
|
||||
"clothing_on_walk": "",
|
||||
"page_id": "429",
|
||||
"rare": "0"
|
||||
},
|
||||
{
|
||||
"id": 1659,
|
||||
"sprite_id": 1659,
|
||||
"item_name": "ticket",
|
||||
"public_name": "Big Ticket Bundle",
|
||||
"type": "s",
|
||||
"width": 1,
|
||||
"length": 1,
|
||||
"stack_height": 1,
|
||||
"allow_stack": 1,
|
||||
"allow_sit": 0,
|
||||
"allow_lay": 0,
|
||||
"allow_walk": 0,
|
||||
"allow_gift": 1,
|
||||
"allow_trade": 1,
|
||||
"allow_recycle": 0,
|
||||
"allow_marketplace_sell": 0,
|
||||
"allow_inventory_stack": 1,
|
||||
"interaction_type": "default",
|
||||
"interaction_modes_count": 0,
|
||||
"vending_ids": "0",
|
||||
"multiheight": "",
|
||||
"customparams": "",
|
||||
"effect_id_male": 0,
|
||||
"effect_id_female": 0,
|
||||
"clothing_on_walk": "",
|
||||
"page_id": "429",
|
||||
"rare": "0"
|
||||
},
|
||||
{
|
||||
"id": 10056,
|
||||
"sprite_id": 10056,
|
||||
"item_name": "Vacuum",
|
||||
"public_name": "laundry_r18_vacuum",
|
||||
"type": "s",
|
||||
"width": 1,
|
||||
"length": 1,
|
||||
"stack_height": 0,
|
||||
"allow_stack": 0,
|
||||
"allow_sit": 0,
|
||||
"allow_lay": 0,
|
||||
"allow_walk": 0,
|
||||
"allow_gift": 1,
|
||||
"allow_trade": 1,
|
||||
"allow_recycle": 0,
|
||||
"allow_marketplace_sell": 0,
|
||||
"allow_inventory_stack": 1,
|
||||
"interaction_type": "default",
|
||||
"interaction_modes_count": 0,
|
||||
"vending_ids": "0",
|
||||
"multiheight": "",
|
||||
"customparams": "",
|
||||
"effect_id_male": 0,
|
||||
"effect_id_female": 0,
|
||||
"clothing_on_walk": "",
|
||||
"page_id": "9966",
|
||||
"rare": "0"
|
||||
}
|
||||
]
|
||||
+119
-5
@@ -7,11 +7,6 @@ services:
|
||||
# build containers on the bridge network have no outbound NAT/DNS. Build on
|
||||
# the host network instead so pnpm/npm/yarn can reach the registry.
|
||||
network: host
|
||||
args:
|
||||
# Run as the host owner (www-data = UID/GID 33, already present in the
|
||||
# node base image) of /var/www/Gamedata so the container can read + write
|
||||
# the shared gamedata directory.
|
||||
RUN_USER: "www-data"
|
||||
container_name: epicnext-cms
|
||||
# Runs on the host network so existing 127.0.0.1 refs in .env keep working:
|
||||
# MariaDB (3306), DragonflyDB/Redis (6379), emulator RCON (3003) + API (3001),
|
||||
@@ -22,6 +17,8 @@ services:
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- HOSTNAME=0.0.0.0
|
||||
volumes:
|
||||
# ── Write targets (runtime imports/uploads, persistent on the host) ──
|
||||
# The CMS writes imported furni/figures/pets/effects here (see
|
||||
@@ -39,6 +36,16 @@ services:
|
||||
# same directory, so mount it into the container at the same absolute path.
|
||||
# Must be RW so furniture/badge imports can write mirrors to it.
|
||||
- /var/www/Gamedata:/var/www/Gamedata
|
||||
#
|
||||
# ── node_modules corruption (§ host-sync) ──
|
||||
# pnpm node_modules must NEVER be a host bind-mount: shared-filesystem
|
||||
# sync (Docker Desktop gRPC-FUSE/VirtioFS, Unison, Syncthing) corrupts
|
||||
# pnpm's hardlinked store and .bin shims. The production image already
|
||||
# bakes node_modules in at build time and is NOT bind-mounted here.
|
||||
# For local dev use a *named volume* instead of a host bind:
|
||||
# - node_modules:/app/node_modules
|
||||
# and never share `node_modules` / `pnpm store` via the Docker bind.
|
||||
# On macOS add `:cached`/`:delegated` consistency labels per mount.
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||
@@ -48,6 +55,27 @@ services:
|
||||
start_period: 40s
|
||||
mem_limit: 2g
|
||||
|
||||
# ── FlareSolverr (Cloudflare bypass for clone sources) ──
|
||||
# Solves Cloudflare/TLS-fingerprint blocks via a headless Chrome browser.
|
||||
# The CMS calls this on http://localhost:8191 for sources that block Node's
|
||||
# TLS fingerprint (e.g. Leet.city). Used by src/lib/services/flare-solver.ts.
|
||||
flaresolverr:
|
||||
image: ghcr.io/flaresolverr/flaresolverr:latest
|
||||
container_name: flaresolverr
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- LOG_LEVEL=info
|
||||
- BROWSER_TIMEOUT=60000
|
||||
- BROWSER_WORKERS=1
|
||||
mem_limit: 2g
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8191/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# ── Opt-in: Octane-Renderer (Habbo avatar imager) ──
|
||||
# Serves /imaging on port 3030 (the CMS proxies /imaging to it). Renders
|
||||
# avatars into /var/www/Gamedata/habbo-imaging, so it needs RW access.
|
||||
@@ -61,3 +89,89 @@ services:
|
||||
# restart: unless-stopped
|
||||
# volumes:
|
||||
# - /var/www/Gamedata:/var/www/Gamedata
|
||||
|
||||
# ── MariaDB "Turbo" (heavy JSON / bulk-loads) ──
|
||||
# Dedicated MariaDB tuned for >50 MB JSON imports. All tuning lives inline
|
||||
# in the service `command:` (bulk_insert_buffer_size,
|
||||
# innodb_flush_log_at_trx_commit=2, max_allowed_packet=512M, ...) so the
|
||||
# container configures itself — no external .cnf to mount or keep in sync.
|
||||
# Opt-in via profile so `docker compose up` keeps running the standalone
|
||||
# stack as today.
|
||||
#
|
||||
# Start: docker compose --profile db up -d (= pnpm db:up)
|
||||
# Note: host networking binds 127.0.0.1:3306 → STOP the host MariaDB
|
||||
# first (the app's .env DATABASE_URL points at localhost:3306).
|
||||
# Fixes the "Pulling schema from database..." hang: raise
|
||||
# net_read/net_write_timeout (done above) + stop imports while
|
||||
# running `pnpm db:generate` / drizzle-kit introspection.
|
||||
mariadb-turbo:
|
||||
image: mariadb:11
|
||||
container_name: mariadb-turbo
|
||||
profiles: ["db"]
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# mariadb:11 uses MARIADB_*; MYSQL_* also works but is deprecated there.
|
||||
- MARIADB_ROOT_PASSWORD=${MARIADB_ROOT_PASSWORD:-root}
|
||||
- MARIADB_DATABASE=${MARIADB_DATABASE:-habbo}
|
||||
- MARIADB_USER=${MARIADB_USER:-cms}
|
||||
- MARIADB_PASSWORD=${MARIADB_PASSWORD:-cms}
|
||||
- MARIADB_AUTO_UPGRADE=1
|
||||
# MariaDB tunes itself — the Official image appends these flags to mysqld,
|
||||
# no external .cnf file needed.
|
||||
command: [
|
||||
# Charset
|
||||
"--character-set-server=utf8mb4",
|
||||
"--collation-server=utf8mb4_unicode_ci",
|
||||
# 50 MB JSON batches: raise the 16 MB default packet ceiling.
|
||||
"--max-allowed-packet=512M",
|
||||
"--net-buffer-length=1M",
|
||||
# Timeouts — fixes the "Pulling schema from database..." hang
|
||||
# (drizzle-kit introspection no longer starves behind big imports).
|
||||
"--connect-timeout=30",
|
||||
"--wait-timeout=3600",
|
||||
"--interactive-timeout=3600",
|
||||
"--net-read-timeout=600",
|
||||
"--net-write-timeout=600",
|
||||
# InnoDB: durability/performance trade-off for bulk writes.
|
||||
"--innodb-flush-log-at-trx-commit=2",
|
||||
"--innodb-buffer-pool-size=2G",
|
||||
"--innodb-buffer-pool-instances=4",
|
||||
"--innodb-log-file-size=1G",
|
||||
"--innodb-log-buffer-size=64M",
|
||||
"--innodb-flush-method=O_DIRECT",
|
||||
"--innodb-autoextend-increment=512",
|
||||
"--innodb-max-dirty-pages-pct=90",
|
||||
"--bulk-insert-buffer-size=512M",
|
||||
# Raise so the engine nearly never double-writes during a 50 MB load.
|
||||
"--innodb-doublewrite=0",
|
||||
# libaio/native_aio misbehaves in some containers; io_uring path is fine.
|
||||
"--innodb-use-native-aio=0",
|
||||
# Temp tables used when MariaDB scans JSON blobs cannot be indexed.
|
||||
"--tmp-table-size=256M",
|
||||
"--max-heap-table-size=256M",
|
||||
"--read-buffer-size=4M",
|
||||
"--read-rnd-buffer-size=16M",
|
||||
# Save ~1-2 GB RAM; bulk loads don't need the instrumentation.
|
||||
"--performance-schema=OFF",
|
||||
"--skip-name-resolve",
|
||||
]
|
||||
volumes:
|
||||
# Named volume, NOT a host bind — shared-filesystem sync trashes InnoDB
|
||||
# files the same way it trashes pnpm's node_modules. Named volumes live
|
||||
# inside the container filesystem, so 50 MB of JSON writes never cross a
|
||||
# host-sync boundary.
|
||||
- mariadb-turbo-data:/var/lib/mysql
|
||||
healthcheck:
|
||||
# healthcheck.sh ships in the official mariadb image.
|
||||
test: ["CMD-SHELL", "healthcheck.sh --connect --innodb_initialized || mariadb-admin ping --silent"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
mem_limit: 4g
|
||||
|
||||
# Named volumes declared once; used by the MariaDB service above.
|
||||
volumes:
|
||||
mariadb-turbo-data:
|
||||
driver: local
|
||||
@@ -0,0 +1,118 @@
|
||||
# Refactor del catalogo HK — analisi e programma
|
||||
|
||||
Data: 6 settembre 2026. Base verificata: main, commit 9b0ea2fb. Documento di proposta, non implementazione approvata. Il sito /admin/catalog reindirizza al login senza sessione staff: nessuna prova delle mutazioni sul database di produzione. Le criticità indicate sono percorsi verificati nel codice; gli effetti concorrenti richiedono riproduzione controllata.
|
||||
|
||||
## Obiettivo e perimetro
|
||||
|
||||
Un catalogo HK con un solo ambiente di lavoro, regole coerenti e operazioni recuperabili. Conservare stile HK, icone reali, salvataggio diretto, catalogo normale e Builder Club. Nessun ritorno dei Preferiti.
|
||||
|
||||
Inclusi: albero, categorie, offerte, prezzi, bundle, disponibilità, proprietà condivise dei furni, traduzioni, ricerca, anteprima, operazioni massive, manutenzione, collegamenti a Catalog Studio e sincronizzazione Git/hotel.
|
||||
|
||||
Catalog Studio conserva la responsabilità di importare, convertire e riparare .nitro, icone e furnidata. Il refactor collega questi strumenti alla selezione del catalogo; non comporta riscrivere il convertitore, cambiare protocollo dell'emulatore o sostituire il sistema Git/Gitea già esistente.
|
||||
|
||||
## Inventario verificato
|
||||
|
||||
Sono già presenti virtualizzazione dell'albero, trascinamento, multiselezione, griglia/tabella, editor dei prezzi, anteprima negozio, import massivo, traduzioni, manutenzione e coda di export Git. Vanno riutilizzati.
|
||||
|
||||
| File | Righe attuali | Responsabilità da separare |
|
||||
|---|---:|---|
|
||||
| src/app/admin/catalog/[id]/catalog-items-table/catalog-items-table.tsx | 2342 | Rendering, selezione, editor offerta/furno, prezzi massivi, drag and drop, dialoghi |
|
||||
| src/components/admin/catalog-manager/sortable-tree.tsx | 1135 | Lettura albero, mutazioni, ricerca, trascinamento, comandi |
|
||||
| src/components/admin/catalog-manager/inline-editor.tsx | 775 | Fetch, stato modifiche, schede, salvataggio, anteprima |
|
||||
| src/app/admin/catalog/[id]/catalog-page-form.tsx | 738 | Campi, layout, media, salvataggio |
|
||||
| src/app/admin/catalog/[id]/catalog-translate-tab.tsx | 617 | Selezione, proposta traduzioni, applicazione |
|
||||
| src/app/admin/catalog/builder-club/bc-manager.tsx | 608 | Gestione parallela BC |
|
||||
| src/app/admin/catalog/page.tsx | 526 | Query, conteggi, varianti normale/BC, composizione UI |
|
||||
|
||||
Le dimensioni aiutano a trovare i punti di intervento: l'obiettivo non è un limite arbitrario di righe, ma responsabilità verificabili e riutilizzabili.
|
||||
|
||||
## Problemi e interventi
|
||||
|
||||
| Priorità | Evidenza | Intervento |
|
||||
|---|---|---|
|
||||
| P0 | sortable-tree.tsx invia il riordino dei fratelli con Promise.allSettled, una action per riga; catalog.ts aggiorna RCON per ciascuna | Un comando batch con lista completa, validazione, transazione e un solo evento di aggiornamento |
|
||||
| P0 | catalog-items.ts riordina le offerte con update sequenziali fuori transazione | Stesso contratto atomico per l'ordine delle offerte |
|
||||
| P0 | updateCatalogPage accetta parentId direttamente; il controllo cicli è separato in movePage | Validazione comune per creazione, form, spostamento e API; controllare destinazioni inesistenti e concorrenza |
|
||||
| P0 | deletePage normale sposta figli, elimina offerte e pagina separatamente | Transazione, analisi dell'impatto e snapshot ripristinabile |
|
||||
| P0 | updateCatalogItem modifica pagina, offerta e items_base con scritture separate | Transazione e verifica che il furno appartenga all'offerta; scope distinto per proprietà condivise |
|
||||
| P1 | cascadeDelete non mantiene un insieme di nodi visitati; il calcolo profondità BC è ricorsivo senza guardia ai cicli | Lettura tollerante di dati incoerenti, diagnostica e arresto sicuro delle traversate |
|
||||
| P1 | handleEditTab modifica lo stato prima della conferma; chiusura X bypassa la protezione | Un unico controllo delle modifiche per cambio pagina, offerta, scheda, uscita e navigazione |
|
||||
| P1 | loadPage/loadItemsData non annullano o identificano la richiesta precedente | AbortController e identità della selezione; solo la risposta corrente può aggiornare l'editor |
|
||||
| P1 | Il salvataggio ignora il booleano restituito da RCON; Git opera in coda | Distinguere DB salvato, invio hotel riuscito/fallito e stato Git; retry senza risalvare i dati |
|
||||
| P1 | loadCatalogItemsData usa Number(value) || fallback per order_number, offer_id e amount | Definire semantica di zero/null per campo e testare il round trip prima di cambiare i fallback |
|
||||
| P2 | Tutte le offerte e metadati sono caricati insieme; filtro con CAST(page_id AS CHAR) | Misurare query/payload, separare elenco e dettagli, paginazione e adapter compatibile INT/VARCHAR |
|
||||
| P2 | Editor normale/BC e form condividono solo parte delle regole; testi anche letterali | Contratti comuni, differenze BC esplicite, traduzioni e permessi coerenti |
|
||||
|
||||
Riferimenti principali: src/actions/catalog.ts, src/actions/catalog-items.ts, src/actions/catalog-bc.ts, src/lib/services/catalog-tree.ts, src/lib/services/catalog-items-loader.ts, src/app/api/admin/catalog/tree/route.ts, src/components/admin/catalog-manager/catalog-manager-dialog.tsx, src/components/admin/catalog-manager/inline-editor.tsx.
|
||||
|
||||
## Alternative
|
||||
|
||||
1. **Pulizia dei file mantenendo tutti gli editor:** rischio iniziale basso, ma conserva duplicazioni e differenze operative. Utile solo come passaggio iniziale.
|
||||
2. **Refactor progressivo con un editor principale — consigliato:** servizi comuni prima, poi promozione del Visual Manager a pagina. Permette piccoli rilasci e confronti tra vecchio e nuovo percorso.
|
||||
3. **Riscrittura completa:** libertà maggiore, ma più rischio di perdere casi speciali, compatibilità DB e funzioni già presenti. Non giustificata dall'inventario attuale.
|
||||
|
||||
## Architettura proposta
|
||||
|
||||
Modulo src/features/catalog con confini chiari:
|
||||
|
||||
- domain/: tipi Page, Offer, FurnitureReference, CatalogKind; validazione gerarchie, prezzi, bundle e disponibilità; nessuna dipendenza React/DB.
|
||||
- server/queries/: letture albero, elenco offerte, dettaglio e ricerca; output serializzabile esplicito.
|
||||
- server/commands/: create/update/move/reorder/delete; autorizzazione, validazione, transazioni, controllo revisione e audit.
|
||||
- server/repositories/: accesso Drizzle e compatibilità delle colonne; adapter normale/BC senza fingere che tutti i campi coincidano.
|
||||
- client/: stato selezione, modifiche locali, operazioni in corso, caricamento e gestione conflitti.
|
||||
- components/: albero, elenco offerte, editor categoria, editor offerta, dettagli furno, diagnostica, stato sincronizzazione.
|
||||
|
||||
Le route e le action attuali rimangono inizialmente adapter sottili. Un unico risultato di operazione include ID operazione, revisione, elementi modificati, eventuali errori di campo e stato sincronizzazione. Non introdurre nuove librerie prima di verificare i limiti degli strumenti già installati.
|
||||
|
||||
Flusso di scrittura: permesso → validazione → verifica revisione → transazione DB con audit → risposta di salvataggio → aggiornamento hotel/export Git. La durabilità del passaggio DB→coda va garantita con un evento persistito nella transazione o meccanismo equivalente verificato. Un fallimento Git/RCON non deve far ripetere una creazione già committata. Riutilizzare il worker e la coda esistenti, aggiungendo idempotenza dove manca.
|
||||
|
||||
## UX proposta
|
||||
|
||||
Pagina /admin/catalog con barra: Normale/BC, ricerca, nuova categoria, aggiungi furni, stato operazioni. Sotto: categorie a sinistra, offerte al centro, dettagli a destra. Il pannello dettagli si richiude; su schermi piccoli diventa una vista dedicata. Un solo scorrimento per ciascuna area, azioni di salvataggio sempre raggiungibili.
|
||||
|
||||
La URL conserva catalogo, categoria, offerta, vista e ricerca; i campi non salvati restano nello stato locale. Indietro/avanti e ricaricamento devono riaprire il contesto corretto. I vecchi URL dei dettagli continuano a funzionare.
|
||||
|
||||
Tre oggetti riconoscibili:
|
||||
|
||||
- Categoria: percorso, titolo, icona, layout, visibilità e requisiti.
|
||||
- Offerta: prezzo, valuta, quantità, componenti bundle, disponibilità e ordine.
|
||||
- Furno condiviso: classname, sprite, dimensioni e interazioni; mostrare quante offerte lo referenziano prima di una modifica globale.
|
||||
|
||||
Idee operative:
|
||||
|
||||
- Ricerca trasversale per nome, classname, ID pagina/offerta/furno e sprite ID, con percorso nei risultati.
|
||||
- Selettore visuale di categoria e layout; proprietà tecniche nelle Avanzate.
|
||||
- Prezzi con icone reali delle valute; mostrare il prima/dopo delle operazioni massive, arrotondamenti ed elementi esclusi.
|
||||
- Multiselezione con riepilogo di spostamento/eliminazione; dopo un errore mantenere selezionati i falliti.
|
||||
- Anteprima del negozio già esistente integrata nel contesto; non presentarla come prova completa del comportamento del client hotel.
|
||||
- Diagnostica su richiesta: offerta, SQL, furnidata, Nitro e icona separati. Collegamento a Catalog Studio sul furno esatto; nessuna scansione pesante a ogni apertura.
|
||||
- Storico di chi/cosa/quando con differenze e ripristino. Il ripristino controlla revisioni successive: non sovrascrive in silenzio modifiche di altri operatori e non annulla acquisti già avvenuti.
|
||||
- Riepilogo visibile: salvato, invio hotel, Git. Gli errori hanno riferimento al monitor CMS.
|
||||
- Stati vuoti, errori, caricamento e sola lettura distinti; traduzioni complete e uso da tastiera.
|
||||
|
||||
## Programma di lavoro e criteri di uscita
|
||||
|
||||
| Lotto | Consegna | Criterio per proseguire |
|
||||
|---|---|---|
|
||||
| 1. Baseline | Matrice funzioni/route/permessi normale e BC; fixture con bundle, LTD, offerte speciali, zeri/null, alberi incoerenti; misure query e rete | Tutti i flussi esistenti hanno una destinazione nel piano, senza omissioni |
|
||||
| 2. Integrità | Validatori, transazioni di riordino/spostamento/eliminazione, gerarchie sicure, revisioni | Un fallimento intermedio non lascia dati parziali; due operatori non si sovrascrivono |
|
||||
| 3. Servizi condivisi | Query/command/repository e risultato comune; vecchie route come adapter | Vecchie UI superano le stesse prove con il nuovo backend |
|
||||
| 4. Stato editor | Unica gestione delle modifiche, richieste annullabili, risposta coerente con selezione | Annullare l'uscita conserva tutto; cambi rapidi mostrano sempre l'ultima selezione |
|
||||
| 5. Pagina unificata | Visual Manager nella pagina, griglia/tabella condivise, URL, layout adattivo | Parità normale/BC e vecchi link conservati; niente perdita di scroll o azioni nascoste |
|
||||
| 6. Operazioni avanzate | Ricerca, editor bundle, prezzi massivi con differenze, storico e diagnosi contestuale | Gli effetti sono spiegati prima dell'applicazione; retry applica solo ciò che manca |
|
||||
| 7. Sincronizzazione | Stato DB/hotel/Git, operazioni persistenti, retry/idempotenza | Guasto dopo commit e riavvio worker non duplicano né perdono l'operazione |
|
||||
| 8. Prestazioni e rimozione duplicati | Paginazione, caricamento progressivo, accessibilità, eliminazione vecchi componenti | Confronto misurato e prove finali; nessuna route o funzione rimasta senza equivalente |
|
||||
|
||||
I lotti 2 e 7 condividono il contratto delle operazioni: progettare subito evento persistente e idempotenza, anche se la UI di stato arriva dopo. Nessuna stima in giorni finché non sono note dimensioni reali del catalogo, varianti DB e casi speciali attivi. Ogni lotto può richiedere più PR piccole; niente sostituzione monolitica.
|
||||
|
||||
## Verifica e rilascio
|
||||
|
||||
Test unitari delle regole; integrazione su MariaDB per rollback, concorrenza e varianti INT/VARCHAR; browser con permessi lettura/modifica, desktop e schermo ridotto. Simulare doppio submit, timeout, risposta fuori ordine, fallimento RCON, Git non raggiungibile, riavvio dopo commit. Conservare test e componenti esistenti finché la parità non è dimostrata.
|
||||
|
||||
Registrare baseline e risultati per categorie grandi/piccole: richieste per riordino, tempo DB, payload, tempo fino a editor utilizzabile, risposte fallite. Non promettere percentuali senza dati.
|
||||
|
||||
Rilascio progressivo con selezione reversibile del nuovo editor. Il ritorno alla UI precedente deve usare gli stessi servizi corretti. Migrazioni additive e compatibili; il rollback dell'app non deve richiedere la cancellazione di dati. Eliminare le vecchie UI solo dopo parità verificata. Confermare CI, deploy, health e prove staff prima di dichiarare risolto il flusso live.
|
||||
|
||||
## Primo passo consigliato
|
||||
|
||||
Lotti 1 e 2: inventario di compatibilità e correzione delle operazioni a rischio, mantenendo inizialmente l'aspetto corrente. Poi estrarre i servizi e unificare l'editor. È la sequenza che permette di migliorare UX senza portare avanti gli stessi difetti dentro una nuova schermata.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Catalog operations upgrade plan
|
||||
|
||||
User approved bulk editing and complete category duplication, keeping Visual Manager in its button-opened modal.
|
||||
|
||||
Use subagent-driven-development for the independent duplication task; root owns bulk operations and final review.
|
||||
|
||||
- [x] Duplicate category subtree and offers atomically for normal/BC; preview counts, destination/name, fresh IDs, preserve furniture references; fail on stale source, invalid destination, cycles and insert failure. Shared permissions, single export/RCON outcome. UI in Visual Manager.
|
||||
- [x] Extend selected-offer operations to preview and atomically apply prices, currency, and destination. Visibility belongs to categories, so expose it separately with exact affected categories; no fictitious per-offer flag. Preserve unselected and unrelated fields; conflicts prevent overwriting concurrent changes.
|
||||
- [x] Meaningful domain/transaction/action tests, browser fixtures for modal preview/confirm, typecheck/Biome/i18n/Knip; review limits and commit exact scope.
|
||||
|
||||
No production data mutations, added dependencies or schema migrations. Full category duplication shares existing items_base definitions and asset files. Repeated confirmations must be disabled while saving. Preview should be revalidated under locks before write. Existing direct save and permissions remain.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
Bulk editing now works from selected normal-catalog offers and from selection across global searches. Only explicitly chosen prices/currency/destination fields change. Prices support set/add/percentage with integer rounding and range validation; 500 selected offers per transaction. Preview binds rows, changes and category names; concurrent changes reject confirmation. Category visibility remains on the existing category controls, not a fabricated offer property. BC has no offer pricing and does not expose that editor.
|
||||
|
||||
Duplication copies up to 500 categories and 5000 offers, preserving bundle and asset references. New root starts disabled and hidden. Includes and internal offer IDs are remapped. Explicit normal offer IDs use the existing allocator and do not require AUTO_INCREMENT; database collisions roll back the whole copy. Preview binds destination siblings as well as source data so confirmed preview replay is rejected. Dirty editor state blocks copying saved data until drafts are saved/reset.
|
||||
|
||||
No database migration or dependency added. Database transaction tests use mocks and do not prove live MariaDB locking behavior. Browser fixtures use real components with mocked actions, not production writes. Existing allocator is process-local; competing external imports may cause a safe rollback requiring a fresh preview. No automatic retry of uncertain copy commits.
|
||||
|
||||
Verification complete: 1339 unit tests passed, 5 skipped. Typecheck/Knip/i18n and changed-file Biome checked. Real-component browser fixtures passed bulk selection, preview, conflicts, pending guards, mobile bounds and table refresh without phantom drafts; duplication nested inside actual manager verified menus, picker, preview and dirty-state guards. Fixed manager portal layering and invalid menu-label nesting uncovered by those tests. Browser actions are mocked; no live data written or deployment claimed.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Catalog refactor implementation plan
|
||||
|
||||
> For agentic workers: use superpowers:subagent-driven-development for independent changes and review each deliverable.
|
||||
|
||||
Goal: deliver the approved progressive catalog refactor while preserving current features.
|
||||
Architecture: shared domain validation and transactional commands behind existing action contracts; unified editor reuses current views and tools.
|
||||
Stack: Next, React, Drizzle/MariaDB, Zod, Vitest, Playwright; no added dependencies.
|
||||
Spec: docs/CATALOG_REFACTOR_PLAN.md
|
||||
Constraints: preserve normal/BC differences, direct save, real icons, permissions, existing routes, no favorites. No production data mutations for testing.
|
||||
|
||||
- [x] Characterize compatibility and command rules with tests; capture baseline source map.
|
||||
- [x] Domain hierarchy/reorder validation and transactional page commands (normal/BC), deterministic locking, common updates/deletion.
|
||||
- [x] Offer update/reorder atomicity and safe numeric normalization, shared mutation contracts.
|
||||
- [x] Editor cancellation/dirty state protection (agent catalog_editor_state), review and browser verification.
|
||||
- [x] Promote editor as an in-page view with reversible classic view, URL context, responsive panels and coherent tool access.
|
||||
- [ ] Sync outcomes/diagnostics/history integration; safe retry and explicit limits.
|
||||
- [ ] Verify unit tests, database integration if runtime available, browser, typecheck, lint/i18n, full suite; review final scope and remaining environment-only checks.
|
||||
|
||||
Execution notes: changes are progressive, no destructive migration. Existing UI adapters stay until functional parity is tested. Whole-program completion must not be claimed from the first deliverable.
|
||||
|
||||
## First implementation delivery (2026-09-06)
|
||||
|
||||
Completed: shared normal/BC page and offer commands; transactional page reorder/delete/move and offer create/update/reorder; hierarchy validation and optimistic page-save checks; embedded default manager with classic views retained; request cancellation, editor unsaved-change guards and URL selection; bounded global category/offer/furniture search; separate hotel/Git status and hotel retry; export-finalization failures no longer mask committed server actions. Existing permissions and direct-save behavior retained. No added dependency or DB migration.
|
||||
|
||||
Verification: full unit suite 1308 passed / 5 skipped before final retry-classification regression; final focused catalog suite 96 passed. TypeScript, i18n static validation, Knip and Biome on all 54 changed source files pass. Browser fixtures cover editor loading races, retry failures, unsaved changes, URL history, read-only, normal/BC, search and 375px layout. Database calls and mutations are mocked in those fixtures. Full-repository formatter check reports pre-existing Windows CRLF formatting differences; unrelated files were not reformatted.
|
||||
|
||||
Remaining roadmap: per-operation durable dispatch/outbox, category/offer snapshot history and conflict-aware undo, contextual maintenance diagnosis, pagination/performance measurement against representative real data, further decomposition of retained legacy views. Existing coarse audit/export infrastructure remains; the new status file is not a durable outbox and RCON socket success does not prove client application. External furni importer mutation internals remain outside shared editor commands.
|
||||
|
||||
Environment checks still required: real MariaDB locking/rollback integration, staff-authenticated end-to-end smoke test and pipeline/deployment health. No production mutations performed; no production deployment claimed.
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Existing articles remain published. Preserve any publication settings already present.
|
||||
ALTER TABLE website_articles
|
||||
ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'published',
|
||||
ADD COLUMN IF NOT EXISTS publish_at TIMESTAMP NULL DEFAULT NULL,
|
||||
ADD COLUMN IF NOT EXISTS published_at TIMESTAMP NULL DEFAULT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_website_articles_publication
|
||||
ON website_articles (status, publish_at, created_at);
|
||||
+13
-3
@@ -21,15 +21,23 @@
|
||||
"test:e2e": "playwright test",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:introspect": "drizzle-kit introspect",
|
||||
"db:bulk": "tsx scripts/bulk-import-json.ts",
|
||||
"db:up": "docker compose --profile db up -d mariadb-turbo",
|
||||
"db:down": "docker compose --profile db stop mariadb-turbo",
|
||||
"db:schema:generate": "node scripts/generate-drizzle-schema.mjs",
|
||||
"db:migrate": "tsx scripts/apply-migrations.ts",
|
||||
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"gamedata:compress": "node scripts/compress-gamedata.mjs",
|
||||
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
|
||||
"test:housekeeping": "vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts",
|
||||
"prepare": "node scripts/prepare-hooks.mjs",
|
||||
"assets:editor": "node scripts/copy-editor-assets.mjs",
|
||||
"deps:audit": "pnpm audit --audit-level=high",
|
||||
"analyze": "next experimental-analyze"
|
||||
"analyze": "next experimental-analyze",
|
||||
"i18n:check": "node scripts/audit-cms-translations.mjs --check",
|
||||
"i18n:audit": "node scripts/audit-cms-translations.mjs"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,jsx,tsx,json}": "biome check --write --no-errors-on-unmatched",
|
||||
@@ -40,6 +48,7 @@
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@formatjs/icu-messageformat-parser": "3.5.17",
|
||||
"@hookform/resolvers": "5.9.1",
|
||||
"@tanstack/react-virtual": "3.14.10",
|
||||
"class-variance-authority": "0.7.1",
|
||||
@@ -71,10 +80,11 @@
|
||||
"sharp": "^0.35.4",
|
||||
"sonner": "2.0.8",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"zod": "4.5.4",
|
||||
"tinymce": "8.9.0"
|
||||
"tinymce": "8.9.0",
|
||||
"zod": "4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "7.29.8",
|
||||
"@biomejs/biome": "2.5.12",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
|
||||
Generated
+6
@@ -25,6 +25,9 @@ importers:
|
||||
'@dnd-kit/utilities':
|
||||
specifier: 3.2.2
|
||||
version: 3.2.2([email protected])
|
||||
'@formatjs/icu-messageformat-parser':
|
||||
specifier: 3.5.17
|
||||
version: 3.5.17
|
||||
'@hookform/resolvers':
|
||||
specifier: 5.9.1
|
||||
version: 5.9.1(@standard-schema/[email protected])([email protected]([email protected]))([email protected])
|
||||
@@ -125,6 +128,9 @@ importers:
|
||||
specifier: 4.5.4
|
||||
version: 4.5.4
|
||||
devDependencies:
|
||||
'@babel/parser':
|
||||
specifier: 7.29.8
|
||||
version: 7.29.8
|
||||
'@biomejs/biome':
|
||||
specifier: 2.5.12
|
||||
version: 2.5.12
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as babel from "@babel/parser";
|
||||
import { parse } from "@formatjs/icu-messageformat-parser";
|
||||
|
||||
const flat = (obj, p = "", r = {}) => {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = p ? `${p}.${k}` : k;
|
||||
if (v && typeof v === "object") flat(v, key, r);
|
||||
else r[key] = v;
|
||||
}
|
||||
return r;
|
||||
};
|
||||
const messages = Object.fromEntries(
|
||||
fs
|
||||
.readdirSync("src/messages")
|
||||
.filter((n) => n.endsWith(".json"))
|
||||
.map((n) => [
|
||||
n.slice(0, -5),
|
||||
flat(JSON.parse(fs.readFileSync(`src/messages/${n}`, "utf8"))),
|
||||
]),
|
||||
);
|
||||
const base = messages.en,
|
||||
invalid = [],
|
||||
mismatches = [];
|
||||
function vars(ast, r = new Set()) {
|
||||
for (const n of ast) {
|
||||
if ([1, 2, 3, 4, 5, 6, 8].includes(n.type)) r.add(n.value);
|
||||
if (n.options) for (const o of Object.values(n.options)) vars(o.value, r);
|
||||
if (n.children) vars(n.children, r);
|
||||
}
|
||||
return [...r].sort().join(",");
|
||||
}
|
||||
const signatures = {};
|
||||
for (const [k, v] of Object.entries(base)) {
|
||||
try {
|
||||
signatures[k] = vars(parse(v));
|
||||
} catch {}
|
||||
}
|
||||
const locales = Object.entries(messages).map(([locale, m]) => {
|
||||
for (const [k, v] of Object.entries(m)) {
|
||||
try {
|
||||
const sig = vars(parse(v));
|
||||
if (k in signatures && sig !== signatures[k])
|
||||
mismatches.push({
|
||||
locale,
|
||||
key: k,
|
||||
expected: signatures[k],
|
||||
actual: sig,
|
||||
value: v,
|
||||
});
|
||||
} catch (e) {
|
||||
invalid.push({ locale, key: k, value: v, error: e.message });
|
||||
}
|
||||
}
|
||||
return {
|
||||
locale,
|
||||
total: Object.keys(m).length,
|
||||
missing: Object.keys(base).filter((k) => !(k in m)).length,
|
||||
empty: Object.values(m).filter((v) => !v?.trim()).length,
|
||||
sameAsEnglish: Object.keys(base).filter((k) => m[k] === base[k]).length,
|
||||
};
|
||||
});
|
||||
const missingCalls = [],
|
||||
hardcoded = [],
|
||||
unparsedFiles = [];
|
||||
function walk(n, fn) {
|
||||
if (!n || typeof n !== "object") return;
|
||||
fn(n);
|
||||
for (const [k, v] of Object.entries(n)) {
|
||||
if (k === "loc" || k === "extra") continue;
|
||||
if (Array.isArray(v))
|
||||
v.forEach((x) => {
|
||||
walk(x, fn);
|
||||
});
|
||||
else if (v && typeof v === "object") walk(v, fn);
|
||||
}
|
||||
}
|
||||
for (const f of fs
|
||||
.readdirSync("src", { recursive: true })
|
||||
.filter((f) => /\.(tsx|ts)$/.test(f) && !f.includes(".test."))) {
|
||||
let ast;
|
||||
try {
|
||||
ast = babel.parse(fs.readFileSync(path.join("src", f), "utf8"), {
|
||||
sourceType: "module",
|
||||
plugins: ["typescript", "jsx"],
|
||||
});
|
||||
} catch (error) {
|
||||
unparsedFiles.push({ file: f, error: error.message });
|
||||
continue;
|
||||
}
|
||||
const bindings = {};
|
||||
walk(ast, (n) => {
|
||||
if (n.type === "VariableDeclarator" && n.id.type === "Identifier") {
|
||||
let c = n.init;
|
||||
if (c?.type === "AwaitExpression") c = c.argument;
|
||||
if (
|
||||
c?.type === "CallExpression" &&
|
||||
["useTranslations", "getTranslations"].includes(c.callee.name)
|
||||
) {
|
||||
const arg = c.arguments[0];
|
||||
const ns =
|
||||
arg?.type === "StringLiteral"
|
||||
? arg.value
|
||||
: arg?.type === "ObjectExpression"
|
||||
? arg.properties.find((p) => p.key?.name === "namespace")?.value
|
||||
?.value
|
||||
: arg
|
||||
? null
|
||||
: "";
|
||||
if (ns !== null) {
|
||||
bindings[n.id.name] ??= new Set();
|
||||
bindings[n.id.name].add(ns);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
walk(ast, (n) => {
|
||||
if (n.type === "CallExpression") {
|
||||
const name = n.callee.name ?? n.callee.object?.name;
|
||||
const ns = bindings[name];
|
||||
const k = n.arguments[0];
|
||||
if (ns?.size === 1 && k?.type === "StringLiteral") {
|
||||
const full = [...ns][0] ? `${[...ns][0]}.${k.value}` : k.value;
|
||||
if (
|
||||
!(full in base) &&
|
||||
!Object.keys(base).some((b) => b.startsWith(`${full}.`))
|
||||
)
|
||||
missingCalls.push({ file: f, line: n.loc.start.line, key: full });
|
||||
}
|
||||
}
|
||||
if (n.type === "JSXText" && /[A-Za-z]{3}/.test(n.value.trim()))
|
||||
hardcoded.push({
|
||||
file: f,
|
||||
line: n.loc.start.line,
|
||||
text: n.value.trim().replace(/\s+/g, " "),
|
||||
});
|
||||
});
|
||||
}
|
||||
const result = {
|
||||
locales,
|
||||
invalid,
|
||||
mismatches,
|
||||
missingCalls,
|
||||
hardcoded,
|
||||
unparsedFiles,
|
||||
};
|
||||
if (process.argv.includes("--json"))
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
else {
|
||||
console.table(locales);
|
||||
console.log(
|
||||
"Invalid ICU:",
|
||||
invalid.length,
|
||||
"Variable mismatches:",
|
||||
mismatches.length,
|
||||
"Missing static message references:",
|
||||
missingCalls.length,
|
||||
);
|
||||
console.log(
|
||||
"Literal JSX text candidates requiring editorial review:",
|
||||
hardcoded.length,
|
||||
);
|
||||
console.log(
|
||||
"Coverage checks static translator namespaces and literal keys; dynamic keys and semantic translation quality require review.",
|
||||
);
|
||||
if (
|
||||
invalid.length ||
|
||||
mismatches.length ||
|
||||
missingCalls.length ||
|
||||
unparsedFiles.length
|
||||
)
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ invalid, mismatches, missingCalls, unparsedFiles },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (
|
||||
process.argv.includes("--check") &&
|
||||
(invalid.length ||
|
||||
mismatches.length ||
|
||||
missingCalls.length ||
|
||||
unparsedFiles.length ||
|
||||
locales.some((locale) => locale.empty > 0))
|
||||
)
|
||||
process.exitCode = 1;
|
||||
@@ -0,0 +1,254 @@
|
||||
// Bulk-load >50 MB JSON into MariaDB in resumable chunks (2000 rows/batch).
|
||||
//
|
||||
// Drizzle multi-row INSERTs are wrapped in ONE statement per chunk, so a
|
||||
// 50 MB dump becomes a few dozen statements instead of hundreds of thousands
|
||||
// of round-trips — no connect/packet timeouts, no "Pulling schema from
|
||||
// database..." freeze (that hang is introspection racing a saturated server).
|
||||
//
|
||||
// Usage (via pnpm, as required):
|
||||
// pnpm exec tsx scripts/bulk-import-json.ts \
|
||||
// --file=/var/www/Gamedata/.../FurnitureData.json [--chunk-size=2000]
|
||||
//
|
||||
// Input shapes supported automatically:
|
||||
// A) habbo furnidata_json → { roomitemtypes:{furnitype:[…]}, wallitemtypes:{…} }
|
||||
// B) flat array of objects → [ {…}, {…} ]
|
||||
//
|
||||
// Flags:
|
||||
// --file=<path> JSON file to load (required)
|
||||
// --table=<name> one of: furnidata | docs | texts (default: furnidata)
|
||||
// --category=<x> category value for docs/texts tables
|
||||
// --source=<x> source value for furnidata table (default: habbo)
|
||||
// --chunk-size=N rows per multi-row INSERT (default: 2000)
|
||||
// --limit=N stop after N rows (dry-test without loading everything)
|
||||
// --truncate DELETE all rows for this table's source/category first
|
||||
//
|
||||
// Idempotent by default: rows re-import on their unique key with
|
||||
// ON DUPLICATE KEY UPDATE, so interrupted runs resume safely.
|
||||
|
||||
import "./load-env";
|
||||
import { createHash } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import mysql from "mysql2/promise";
|
||||
import {
|
||||
GamedataDocs,
|
||||
GamedataFurnidata,
|
||||
GamedataTexts,
|
||||
} from "@/db/schema-gamedata";
|
||||
import { mysqlConnectionUrl } from "./db-url";
|
||||
|
||||
interface Args {
|
||||
file: string;
|
||||
table: "furnidata" | "docs" | "texts";
|
||||
category: string;
|
||||
source: string;
|
||||
chunkSize: number;
|
||||
limit: number;
|
||||
truncate: boolean;
|
||||
}
|
||||
|
||||
const FURNIDATA_SECTIONS = {
|
||||
roomitemtypes: "s",
|
||||
flooritemtypes: "s",
|
||||
wallitemtypes: "i",
|
||||
effecttypes: "e",
|
||||
} as const;
|
||||
|
||||
function parseArgs(raw: string[]): Args {
|
||||
const get = (name: string) => {
|
||||
const hit = raw.find((a) => a.startsWith(`--${name}=`));
|
||||
return hit?.slice(name.length + 3);
|
||||
};
|
||||
const has = (name: string) => raw.includes(`--${name}`);
|
||||
const file = get("file");
|
||||
if (!file) {
|
||||
console.error(
|
||||
"Usage: pnpm exec tsx scripts/bulk-import-json.ts --file=<path> [--table=furnidata|docs|texts] [--category=<x>] [--source=<x>] [--chunk-size=2000] [--limit=N] [--truncate]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const table = (get("table") ?? "furnidata") as Args["table"];
|
||||
return {
|
||||
file: resolve(file),
|
||||
table,
|
||||
category: get("category") ?? "default",
|
||||
source: get("source") ?? "habbo",
|
||||
chunkSize: Number(get("chunk-size") ?? "2000"),
|
||||
limit: Number(get("limit") ?? "0"),
|
||||
truncate: has("truncate"),
|
||||
};
|
||||
}
|
||||
|
||||
// A >50 MB file parses fine with a single JSON.parse (V8 string limit is far
|
||||
// higher); streaming row-by-row would slow the seed down for no memory win.
|
||||
async function readJsonFile(file: string): Promise<unknown> {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
return JSON.parse(await readFile(file, "utf8"));
|
||||
}
|
||||
|
||||
interface NormalizedRow {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Normalize any supported shape into a flat list of JSON documents.
|
||||
function normalize(json: unknown, table: Args["table"]): NormalizedRow[] {
|
||||
if (table !== "furnidata") {
|
||||
if (Array.isArray(json)) return json as NormalizedRow[];
|
||||
throw new Error(`Expected a top-level array for --table=${table}`);
|
||||
}
|
||||
if (Array.isArray(json)) return json as NormalizedRow[];
|
||||
|
||||
// habbo furnidata_json: { roomitemtypes: { furnitype: [...] }, ... }
|
||||
if (json && typeof json === "object") {
|
||||
const rows: NormalizedRow[] = [];
|
||||
for (const [section, kind] of Object.entries(FURNIDATA_SECTIONS)) {
|
||||
const block = (json as Record<string, unknown>)[section];
|
||||
if (!block || typeof block !== "object") continue;
|
||||
const list = (block as Record<string, unknown>).furnitype;
|
||||
if (!Array.isArray(list)) continue;
|
||||
for (const item of list) rows.push({ ...item, kind });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
throw new Error("Unsupported JSON shape: expected array or furnidata_json");
|
||||
}
|
||||
|
||||
function toFurnidataRow(
|
||||
row: NormalizedRow,
|
||||
source: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
source,
|
||||
kind: (row.kind as "s" | "i" | "e") ?? "s",
|
||||
className: String(row.classname ?? row.className ?? ""),
|
||||
publicName: String(row.public_name ?? row.publicName ?? ""),
|
||||
spriteId: Number(row.id ?? 0),
|
||||
payload: JSON.stringify(row),
|
||||
};
|
||||
}
|
||||
|
||||
function toDocsRow(
|
||||
row: NormalizedRow,
|
||||
category: string,
|
||||
): Record<string, unknown> {
|
||||
const payload = JSON.stringify(row);
|
||||
return {
|
||||
category,
|
||||
docKey: String(row.key ?? row.docKey ?? row.id ?? ""),
|
||||
payload,
|
||||
payloadSha1: createHash("sha1").update(payload).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
function toTextsRow(
|
||||
row: NormalizedRow,
|
||||
category: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
category,
|
||||
textKey: String(row.key ?? row.id ?? ""),
|
||||
value: String(row.value ?? row.text ?? row),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url)
|
||||
throw new Error("DATABASE_URL is required (load-env.ts loaded .env)");
|
||||
|
||||
const conn = await mysql.createConnection(mysqlConnectionUrl(url));
|
||||
const db = drizzle(conn);
|
||||
|
||||
// Session bulk-load flags: worst case per chunk is a lost commit, not a
|
||||
// corrupt table. FOREIGN_KEY_CHECKS off keeps each 50 MB chunk off FK
|
||||
// validation work; UNIQUE_CHECK is a per-statement no-op vs ON DUPLICATE.
|
||||
await conn.query("SET SESSION FOREIGN_KEY_CHECKS=0");
|
||||
await conn.query("SET SESSION sql_mode='NO_ENGINE_SUBSTITUTION'");
|
||||
|
||||
const tableRef =
|
||||
args.table === "furnidata"
|
||||
? GamedataFurnidata
|
||||
: args.table === "docs"
|
||||
? GamedataDocs
|
||||
: GamedataTexts;
|
||||
const keyWhere =
|
||||
args.table === "furnidata"
|
||||
? sql`${GamedataFurnidata.source} = ${args.source}`
|
||||
: args.table === "docs"
|
||||
? sql`${GamedataDocs.category} = ${args.category}`
|
||||
: sql`${GamedataTexts.category} = ${args.category}`;
|
||||
|
||||
if (args.truncate) {
|
||||
await db.execute(sql`DELETE FROM ${tableRef} WHERE ${keyWhere}`);
|
||||
console.log(`[bulk] truncated rows for ${args.table}`);
|
||||
}
|
||||
|
||||
console.log(`[bulk] reading ${args.file} …`);
|
||||
const json = await readJsonFile(args.file);
|
||||
const rows = normalize(json, args.table);
|
||||
console.log(`[bulk] parsed ${rows.length} documents (${args.table})`);
|
||||
|
||||
const mapper: (r: NormalizedRow) => Record<string, unknown> =
|
||||
args.table === "furnidata"
|
||||
? (r) => toFurnidataRow(r, args.source)
|
||||
: args.table === "docs"
|
||||
? (r) => toDocsRow(r, args.category)
|
||||
: (r) => toTextsRow(r, args.category);
|
||||
|
||||
const values = rows
|
||||
.slice(0, args.limit || rows.length)
|
||||
.map<Record<string, unknown>>(mapper);
|
||||
const total = values.length;
|
||||
const chunkSize = args.chunkSize;
|
||||
const started = Date.now();
|
||||
let inserted = 0;
|
||||
|
||||
for (let i = 0; i < total; i += chunkSize) {
|
||||
const chunk = values.slice(i, Math.min(i + chunkSize, total));
|
||||
// `tableRef` is a 3-way union; drizzle's insert is typed per single
|
||||
// table, so narrow through a custom shape here (runtime is unaffected).
|
||||
const insert = db.insert(tableRef) as unknown as {
|
||||
values: (rows: typeof chunk) => {
|
||||
onDuplicateKeyUpdate: (opts: {
|
||||
set: Record<string, unknown>;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
await insert.values(chunk).onDuplicateKeyUpdate({
|
||||
// MariaDB `VALUES(col)` = the incoming row value; re-runs overwrite.
|
||||
set: Object.fromEntries(
|
||||
Object.keys(chunk[0] ?? {}).map((k) => [
|
||||
k,
|
||||
sql.raw(`values(\`${k}\`)`),
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
inserted += chunk.length;
|
||||
const remaining = total - i - chunk.length;
|
||||
if (inserted % (chunkSize * 10) < chunkSize || remaining <= 0) {
|
||||
const elapsedSec = (Date.now() - started) / 1000;
|
||||
const rate = Math.round(inserted / Math.max(elapsedSec, 0.001));
|
||||
const etaMin = Math.round(remaining / Math.max(rate, 1) / 60);
|
||||
console.log(
|
||||
`[bulk] ${inserted}/${total} (${Math.round((inserted / total) * 100)}%) ${rate} rows/s, ETA ~${etaMin}m`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const sec = ((Date.now() - started) / 1000).toFixed(1);
|
||||
console.log(`[bulk] DONE: ${inserted}/${total} rows in ${sec}s`);
|
||||
if (total === args.limit && args.limit > 0) {
|
||||
console.log(
|
||||
" NOTE: --limit was set; run again without it for the full dump.",
|
||||
);
|
||||
}
|
||||
await conn.end();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[bulk] FATAL:", err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env bash
|
||||
# One lock covers build, migrations, cutover, health checks and smoke tests.
|
||||
set -Eeuo pipefail
|
||||
|
||||
deploy_dir="${CMS_DEPLOY_DIR:-/var/www/atom-nexst}"
|
||||
branch="${DEPLOY_BRANCH:-main}"
|
||||
case "$branch" in main|master) ;; *) echo "Unsupported deployment branch" >&2; exit 1 ;; esac
|
||||
exec 9>"$deploy_dir/.deploy.lock"
|
||||
flock -w 1800 9
|
||||
|
||||
sha="$(git rev-parse HEAD)"
|
||||
[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid commit" >&2; exit 1; }
|
||||
image="epicnext-cms:$sha"
|
||||
previous_name=""
|
||||
previous_image=""
|
||||
backup_name="epicnext-cms-rollback"
|
||||
cutover_started=0
|
||||
candidate_attempted=0
|
||||
backup_created=0
|
||||
|
||||
is_current() {
|
||||
local head
|
||||
head="$(git ls-remote --exit-code origin "refs/heads/$branch")" || return 2
|
||||
head="${head%%[[:space:]]*}"
|
||||
if [ "$head" != "$sha" ]; then
|
||||
echo "Skipping superseded commit $sha (branch now at $head)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_current() {
|
||||
local status=0
|
||||
is_current || status=$?
|
||||
case "$status" in 0) ;; 1) exit 0 ;; *) echo "Cannot verify remote branch" >&2; exit 1 ;; esac
|
||||
}
|
||||
|
||||
healthy() {
|
||||
local attempt
|
||||
for attempt in $(seq 1 30); do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:3002/api/health | grep -q '"database":true'; then return 0; fi
|
||||
sleep 3
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
finish() {
|
||||
local status=$?
|
||||
trap - EXIT
|
||||
if [ "$status" -ne 0 ] && [ "$cutover_started" -eq 1 ]; then
|
||||
echo "Deployment failed; restoring previous container" >&2
|
||||
docker logs epicnext-cms-app --tail 50 >&2 || true
|
||||
if [ "$candidate_attempted" -eq 1 ]; then docker rm -f epicnext-cms-app || true; fi
|
||||
if [ "$backup_created" -eq 1 ]; then docker rename "$backup_name" "$previous_name" || true; fi
|
||||
if [ -n "$previous_name" ]; then
|
||||
if docker start "$previous_name" && healthy; then
|
||||
echo "Rollback verified: $previous_image"
|
||||
else
|
||||
echo "ERROR: previous container could not be restored to healthy state" >&2
|
||||
fi
|
||||
else
|
||||
echo "No previous container exists; rollback is unavailable" >&2
|
||||
fi
|
||||
fi
|
||||
exit "$status"
|
||||
}
|
||||
trap finish EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
check_current
|
||||
cp "$deploy_dir/.env" .env
|
||||
pnpm install --frozen-lockfile
|
||||
# Prepare browser before cutover so installation failures cannot interrupt the site.
|
||||
pnpm exec playwright install chromium
|
||||
|
||||
echo "Building $image"
|
||||
# Throw away the previous build cache before each build so disk usage doesn't
|
||||
# grow unbounded across deployments. The current release image (`epicnext-cms`)
|
||||
# is still reused as a base layer via `--cache-from`; only the accumulated
|
||||
# BuildKit intermediate cache is discarded.
|
||||
docker builder prune -af --filter "until=1h" --keep-storage=0 2>/dev/null || true
|
||||
DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest \
|
||||
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
||||
check_current
|
||||
pnpm db:migrate
|
||||
check_current
|
||||
|
||||
# Prefer the active CI container, or the active legacy compose container.
|
||||
for name in epicnext-cms-app epicnext-cms; do
|
||||
if [ "$(docker inspect --format '{{.State.Running}}' "$name" 2>/dev/null || true)" = true ]; then
|
||||
previous_name="$name"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$previous_name" ]; then
|
||||
for name in epicnext-cms-app epicnext-cms; do
|
||||
if docker inspect "$name" >/dev/null 2>&1; then previous_name="$name"; break; fi
|
||||
done
|
||||
fi
|
||||
if docker inspect "$backup_name" >/dev/null 2>&1; then
|
||||
echo "Unresolved rollback container exists; refusing to overwrite it" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$previous_name" ]; then
|
||||
previous_image="$(docker inspect --format '{{.Image}}' "$previous_name")"
|
||||
docker tag "$previous_image" epicnext-cms:previous
|
||||
fi
|
||||
# Remove a stopped leftover CI container when the compose container is active.
|
||||
if [ "$previous_name" != epicnext-cms-app ] && docker inspect epicnext-cms-app >/dev/null 2>&1; then
|
||||
docker rm epicnext-cms-app
|
||||
fi
|
||||
|
||||
cutover_started=1
|
||||
if [ -n "$previous_name" ]; then
|
||||
docker stop "$previous_name"
|
||||
docker rename "$previous_name" "$backup_name"
|
||||
backup_created=1
|
||||
fi
|
||||
candidate_attempted=1
|
||||
(
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
. "$deploy_dir/.env"
|
||||
set +a
|
||||
ENV_ARGS=()
|
||||
while IFS='=' read -r key _; do
|
||||
case "$key" in ''|'#'*|*[!A-Za-z0-9_]* ) continue ;; esac
|
||||
ENV_ARGS+=(-e "$key")
|
||||
done < "$deploy_dir/.env"
|
||||
docker run -d --name epicnext-cms-app --restart always --net=host \
|
||||
"${ENV_ARGS[@]}" \
|
||||
-v "$deploy_dir/public/nitro-assets:/app/public/nitro-assets" \
|
||||
-v "$deploy_dir/public/swf:/app/public/swf" \
|
||||
-v "$deploy_dir/storage:/app/storage" \
|
||||
-v /var/www/Gamedata:/var/www/Gamedata \
|
||||
"$image"
|
||||
)
|
||||
healthy
|
||||
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e
|
||||
# Publish the latest alias only after health and browser checks pass.
|
||||
docker tag "$image" epicnext-cms:latest
|
||||
cutover_started=0
|
||||
if [ "$backup_created" -eq 1 ]; then docker rm "$backup_name" || true; fi
|
||||
|
||||
echo "Deployment verified: $sha"
|
||||
# Retain the current and previous releases; do not remove arbitrary named tags.
|
||||
while IFS= read -r tag; do
|
||||
if [[ "$tag" =~ ^epicnext-cms:[0-9a-f]{40}$ ]] && [ "$tag" != "$image" ]; then
|
||||
tagged_image="$(docker image inspect --format '{{.Id}}' "$tag" 2>/dev/null || true)"
|
||||
if [ -n "$tagged_image" ] && [ "$tagged_image" != "$previous_image" ]; then docker image rm "$tag" || true; fi
|
||||
fi
|
||||
done < <(docker image ls --format '{{.Repository}}:{{.Tag}}' epicnext-cms)
|
||||
docker builder prune -af --filter "until=72h" --keep-storage=2g || true
|
||||
docker image prune -f --filter "until=168h" || true
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Pre-compress large gamedata JSON files to .gz so nginx `gzip_static`
|
||||
* serves them via sendfile instead of re-compressing up to 48 MB on every
|
||||
* request (see /etc/nginx/nginx.conf: `gzip_static on`).
|
||||
*
|
||||
* Idempotent: a file is only re-compressed when its source mtime is newer
|
||||
* than the existing .gz (e.g. after a Studio catalog export rewrites it).
|
||||
*
|
||||
* Usage: node scripts/compress-gamedata.mjs
|
||||
* Env: GAMEDATA_ROOT (default /var/www/Gamedata)
|
||||
* GAMEDATA_GZIP_MIN_BYTES (default 1048576)
|
||||
*/
|
||||
|
||||
import { createReadStream, createWriteStream, promises as fs } from "node:fs";
|
||||
import { cpus } from "node:os";
|
||||
import { join, relative } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { createGzip } from "node:zlib";
|
||||
|
||||
const GAMEDATA_ROOT = process.env.GAMEDATA_ROOT ?? "/var/www/Gamedata";
|
||||
const MIN_BYTES = Number(process.env.GAMEDATA_GZIP_MIN_BYTES ?? 1024 * 1024);
|
||||
const GZIP_LEVEL = 9;
|
||||
const TARGET_DIRS = ["config", "bundled/config"];
|
||||
const CONCURRENCY = Math.max(1, Math.min(4, cpus().length));
|
||||
|
||||
function isCompressible(name) {
|
||||
return (
|
||||
name.endsWith(".json") &&
|
||||
!name.endsWith(".gz") &&
|
||||
!name.endsWith(".min.json")
|
||||
);
|
||||
}
|
||||
|
||||
async function collectCandidates() {
|
||||
const files = [];
|
||||
for (const dir of TARGET_DIRS) {
|
||||
const root = join(GAMEDATA_ROOT, dir);
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(root, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !isCompressible(entry.name)) continue;
|
||||
const full = join(root, entry.name);
|
||||
const stat = await fs.stat(full);
|
||||
if (stat.size < MIN_BYTES) continue;
|
||||
files.push({ src: full, size: stat.size, mtimeMs: stat.mtimeMs });
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function needsCompression({ src, mtimeMs }) {
|
||||
const gz = `${src}.gz`;
|
||||
try {
|
||||
const stat = await fs.stat(gz);
|
||||
return stat.mtimeMs < mtimeMs;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function compressOne({ src }) {
|
||||
const gz = `${src}.gz`;
|
||||
const tmp = `${gz}.tmp-${process.pid}`;
|
||||
await pipeline(
|
||||
createReadStream(src),
|
||||
createGzip({ level: GZIP_LEVEL }),
|
||||
createWriteStream(tmp),
|
||||
);
|
||||
await fs.rename(tmp, gz);
|
||||
try {
|
||||
await fs.chmod(gz, 0o644);
|
||||
} catch {
|
||||
// Best-effort; ownership is up to the caller.
|
||||
}
|
||||
return gz;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const candidates = await collectCandidates();
|
||||
const work = [];
|
||||
const skipped = [];
|
||||
for (const candidate of candidates) {
|
||||
if (await needsCompression(candidate)) {
|
||||
work.push(candidate);
|
||||
} else {
|
||||
skipped.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
const results = [];
|
||||
let idx = 0;
|
||||
async function worker() {
|
||||
while (idx < work.length) {
|
||||
const item = work[idx++];
|
||||
try {
|
||||
const gz = await compressOne(item);
|
||||
const stat = await fs.stat(gz);
|
||||
results.push(
|
||||
`compressed ${relative(GAMEDATA_ROOT, gz)} (${item.size} -> ${stat.size} bytes, ${Math.round((1 - stat.size / item.size) * 1000) / 10}% smaller)`,
|
||||
);
|
||||
} catch (err) {
|
||||
results.push(
|
||||
`FAILED ${relative(GAMEDATA_ROOT, item.src)}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(CONCURRENCY, work.length) },
|
||||
() => worker(),
|
||||
);
|
||||
await Promise.all(workers);
|
||||
|
||||
console.log(
|
||||
`gamedata: ${work.length} to compress, ${skipped.length} up to date`,
|
||||
);
|
||||
for (const line of results) console.log(`gamedata: ${line}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(
|
||||
`gamedata: FATAL ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+139
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# docker-preflight.sh — Verify a VPS is ready to run the Dockerized EpicNext-CMS.
|
||||
#
|
||||
# Lets any supplied .env drive the checks. Checks (all idempotent, none mutating):
|
||||
# 1. Docker + compose available and usable.
|
||||
# 2. Required runtime dirs exist (RW for UID 33 where the CMS writes).
|
||||
# 3. Volume owners are UID/GID 33 (www-data) on the host.
|
||||
# 4. .env exists and required host-network services are reachable.
|
||||
# 5. Port 3002 free (or the host CMS that must be stopped).
|
||||
#
|
||||
# Usage: ./scripts/docker-preflight.sh [--fix]
|
||||
# --fix attempts to auto-correct permission/owner issues (chown).
|
||||
#
|
||||
# Exit codes: 0 ready, 1 not ready (or fixed nothing).
|
||||
# ==============================================================================
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$DIR" || exit 1
|
||||
|
||||
FIX=0
|
||||
[ "${1:-}" = "--fix" ] && FIX=1
|
||||
|
||||
ENV_FILE="${ENV_FILE:-$DIR/.env}"
|
||||
fail=0
|
||||
warn=0
|
||||
|
||||
# Ports that are expected to be reachable ON THE HOST (network_mode: host).
|
||||
# These mirror the defaults in .env / the emulator stack; override via env.
|
||||
CMS_PORT="${CMS_PORT:-3002}"
|
||||
MYSQL_PORT="${MYSQL_PORT:-3306}"
|
||||
REDIS_PORT="${REDIS_PORT:-6379}"
|
||||
RCON_PORT="${RCON_PORT:-3003}"
|
||||
API_PORT="${API_PORT:-3001}"
|
||||
IMAGER_PORT="${IMAGER_PORT:-8082}"
|
||||
|
||||
# Relative dirs the CMS writes to, plus the absolute shared gamedata root.
|
||||
RW_DIRS=(
|
||||
"$DIR/public/nitro-assets"
|
||||
"$DIR/public/swf"
|
||||
"$DIR/storage"
|
||||
"/var/www/Gamedata"
|
||||
)
|
||||
|
||||
say() { printf ' %s\n' "$*"; }
|
||||
ok() { printf ' \033[32m[OK] \033[0m%s\n' "$*"; }
|
||||
err() { printf ' \033[31m[FAIL]\033[0m %s\n' "$*"; fail=1; }
|
||||
wrn() { printf ' \033[33m[WARN]\033[0m %s\n' "$*"; warn=1; }
|
||||
doing(){ printf '\n\033[1m%s\033[0m\n' "$*"; }
|
||||
|
||||
doing "1. Docker engine + compose"
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
ok "docker binary present"
|
||||
if docker info >/dev/null 2>&1; then ok "daemon reachable"; else err "daemon NOT reachable (is it running / does this user have access?)"; fi
|
||||
else
|
||||
err "docker binary missing"
|
||||
fi
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
ok "docker compose plugin present"
|
||||
else
|
||||
err "docker compose plugin missing (install docker-compose-plugin)"
|
||||
fi
|
||||
|
||||
doing "2. Runtime directories exist + RW for UID 33"
|
||||
for d in "${RW_DIRS[@]}"; do
|
||||
if [ ! -e "$d" ]; then
|
||||
err "missing dir: $d"
|
||||
if [ "$FIX" -eq 1 ]; then
|
||||
mkdir -p "$d" && chown 33:33 "$d" && say " created + chown 33:33 $d" || err " could not create $d"
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
if [ ! -d "$d" ]; then err "not a directory: $d"; continue; fi
|
||||
# Test write as the target owner via a temp file drop (as root), then remove.
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
if touch "$d/.preflight-write-test" 2>/dev/null; then
|
||||
rm -f "$d/.preflight-write-test"
|
||||
ok "writable: $d"
|
||||
else
|
||||
err "not writable: $d (needs owner 33:33)"
|
||||
[ "$FIX" -eq 1 ] && { chown -R 33:33 "$d" && say " chown -R 33:33 $d" || err " chown failed"; }
|
||||
fi
|
||||
else
|
||||
if [ -w "$d" ]; then ok "writable: $d"; else err "not writable: $d"; fi
|
||||
fi
|
||||
done
|
||||
|
||||
doing "3. Volume ownership (UID/GID 33 = www-data)"
|
||||
for d in "${RW_DIRS[@]}"; do
|
||||
[ -e "$d" ] || continue
|
||||
owner="$(stat -c '%u:%g' "$d" 2>/dev/null || true)"
|
||||
if [ "$owner" = "33:33" ]; then
|
||||
ok "owner 33:33: $d"
|
||||
else
|
||||
err "owner ${owner:-unknown} (want 33:33): $d"
|
||||
[ "$FIX" -eq 1 ] && { chown 33:33 "$d" && say " chown 33:33 $d" || err " chown failed"; }
|
||||
fi
|
||||
done
|
||||
|
||||
doing "4. Configuration + required services (.env, host network)"
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
ok ".env present: $ENV_FILE"
|
||||
### shellcheck disable=SC1090
|
||||
set -a; . "$ENV_FILE"; set +a
|
||||
else
|
||||
err ".env missing: $ENV_FILE (copy your production env here)"
|
||||
fi
|
||||
|
||||
check_port() { # name port
|
||||
if command -v nc >/dev/null 2>&1; then
|
||||
if nc -z 127.0.0.1 "$2" >/dev/null 2>&1; then ok "reachable 127.0.0.1:$2 ($1)"; else err "NOT reachable 127.0.0.1:$2 ($1)"; fi
|
||||
else
|
||||
if (echo >/dev/tcp/127.0.0.1/"$2") >/dev/null 2>&1; then ok "reachable 127.0.0.1:$2 ($1)"; else err "NOT reachable 127.0.0.1:$2 ($1)"; fi
|
||||
fi
|
||||
}
|
||||
check_port "MariaDB" "$MYSQL_PORT"
|
||||
check_port "Redis" "$REDIS_PORT"
|
||||
check_dep() { # name
|
||||
if command -v "$1" >/dev/null 2>&1; then ok "host binary: $1"; else wrn "host binary not found: $1 (may still work via container)"; fi
|
||||
}
|
||||
check_dep git
|
||||
|
||||
doing "5. Port 3002 state (host CMS clash)"
|
||||
if (echo >/dev/tcp/127.0.0.1/"$CMS_PORT") >/dev/null 2>&1; then
|
||||
err "port $CMS_PORT already in use on host — stop the host-side CMS (pm2 stop next) before starting the container"
|
||||
else
|
||||
ok "port $CMS_PORT free"
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
if [ "$fail" -eq 1 ]; then
|
||||
printf '\033[31m=== NOT READY: %s issue(s) found%s ===\033[0m\n' "$fail" "$([ "$FIX" -eq 1 ] && echo ' after --fix' || echo ' (re-run with --fix to auto-correct)')"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$warn" -eq 1 ]; then printf '\033[33m=== READY (with %s warning(s)) ===\033[0m\n' "$warn"; exit 0; fi
|
||||
printf '\033[32m=== READY ===\033[0m\n'
|
||||
exit 0
|
||||
@@ -18,6 +18,10 @@ set -uo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$DIR" || exit 2
|
||||
|
||||
# Share the CI lock before pulling or touching the live application.
|
||||
exec 9>"$DIR/.deploy.lock"
|
||||
flock -w 1800 9 || exit 2
|
||||
|
||||
LOG_FILE="${LOG_FILE:-$DIR/logs/docker-update.log}"
|
||||
PM2_APP="${PM2_APP:-next}" # host-side CMS that must stay stopped (port 3002)
|
||||
|
||||
@@ -28,7 +32,13 @@ touch "$LOG_FILE"
|
||||
|
||||
log "=== Start docker-update ==="
|
||||
|
||||
# --- 0. Guard: uncommitted changes would break git pull / taint deploys ---
|
||||
# --- 0. Preflight: verify this VPS is ready (permissions, ports, deps) ---
|
||||
if ! "$DIR/scripts/docker-preflight.sh"; then
|
||||
die "preflight failed — fix issues first (see '--fix' flag)" 1
|
||||
fi
|
||||
log "preflight OK"
|
||||
|
||||
# --- 0b. Guard: uncommitted changes would break git pull / taint deploys ---
|
||||
if ! { git diff --quiet --exit-code && git diff --cached --quiet --exit-code; }; then
|
||||
die "working tree has uncommitted changes; commit or stash first" 1
|
||||
fi
|
||||
@@ -51,15 +61,35 @@ else
|
||||
fi
|
||||
log "db:migrate OK"
|
||||
|
||||
# --- 3. Rebuild the image ---
|
||||
# --- 3. Node major gate (patches auto, major upgrades need review) ---
|
||||
# `node:alpine` floats within, then across, Node majors. Patches/minors are
|
||||
# safe to apply silently; a NEW major (e.g. 26 -> 27) is a breaking risk for
|
||||
# native addons / Next compatibility, so require an explicit review before it
|
||||
# goes live. Compare the major of the deployed runtime image vs the floating
|
||||
# tag; abort (not deploy) when they differ.
|
||||
deployed_major="$(docker inspect --format '{{.Config.Image}}' epicnext-cms 2>/dev/null || true)"
|
||||
# Resolve the currently-deployed Node major from its image.
|
||||
if [ -n "$deployed_major" ] && docker image inspect "$deployed_major" >/dev/null 2>&1; then
|
||||
deployed_major="$(docker run --rm --entrypoint sh "$deployed_major" -c 'node -p "process.versions.node.split(\".\")[0]"' 2>/dev/null || true)"
|
||||
fi
|
||||
float_major="$(docker run --rm --entrypoint sh node:alpine -c 'node -p "process.versions.node.split(\".\")[0]"' 2>/dev/null || true)"
|
||||
if [ -n "$deployed_major" ] && [ -n "$float_major" ] && [ "$deployed_major" != "$float_major" ]; then
|
||||
die "Node major change detected (deployed v$deployed_major, floating tag v$float_major). Major upgrades require review; update engines/Dockerfile deliberately first." 1
|
||||
fi
|
||||
log "Node major gate OK (major=${float_major:-?})"
|
||||
|
||||
# --- 4. Rebuild the image ---
|
||||
# Reset the BuildKit cache first so the build doesn't accumulate unbounded
|
||||
# layers on disk across daily rebuilds.
|
||||
docker builder prune -af --filter "until=1h" --keep-storage=0 2>>"$LOG_FILE" || true
|
||||
docker compose build >>"$LOG_FILE" 2>&1 || die "docker compose build failed" 2
|
||||
log "docker compose build OK"
|
||||
|
||||
# --- 4. Recreate the container ---
|
||||
# --- 5. Recreate the container ---
|
||||
docker compose up -d >>"$LOG_FILE" 2>&1 || die "docker compose up failed" 2
|
||||
log "docker compose up OK"
|
||||
|
||||
# --- 5. Wait for health (up to ~4 min) ---
|
||||
# --- 6. Wait for health (up to ~4 min) ---
|
||||
healthy=0
|
||||
for i in $(seq 1 16); do
|
||||
status="$(docker inspect --format='{{.State.Health.Status}}' epicnext-cms 2>/dev/null || true)"
|
||||
@@ -78,7 +108,7 @@ else
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# --- 6. Make sure the stale host-side PM2 CMS stays stopped ---
|
||||
# --- 7. Make sure the stale host-side PM2 CMS stays stopped ---
|
||||
if command -v pm2 >/dev/null 2>&1 && pm2 jlist >/dev/null 2>&1; then
|
||||
if pm2 list 2>/dev/null | grep -q "${PM2_APP}"; then
|
||||
pm2 stop "$PM2_APP" >/dev/null 2>&1 && log "pm2 '${PM2_APP}' kept stopped (avoids port 3002 clash)"
|
||||
|
||||
@@ -12,6 +12,7 @@ import { logger } from "../src/lib/logger";
|
||||
import { redis } from "../src/lib/redis";
|
||||
import { emulatorOffline, healthDegraded } from "../src/lib/services/alert";
|
||||
import { runCatalogExport } from "../src/lib/services/catalog-git-export";
|
||||
import { invalidateNewsCache } from "../src/lib/services/news-cache";
|
||||
import { rcon } from "../src/lib/services/rcon";
|
||||
|
||||
function captureWorkerError(err: unknown, context: string): void {
|
||||
@@ -246,12 +247,10 @@ async function publishScheduledArticles(): Promise<void> {
|
||||
lte(WebsiteArticles.publishAt, now),
|
||||
),
|
||||
);
|
||||
const info = result as unknown as {
|
||||
affectedRows?: number;
|
||||
rowsAffected?: number;
|
||||
};
|
||||
const published = info.affectedRows ?? info.rowsAffected ?? 0;
|
||||
const [info] = result;
|
||||
const published = info.affectedRows ?? 0;
|
||||
if (published > 0) {
|
||||
await invalidateNewsCache();
|
||||
logger.info(`Published ${published} scheduled article(s)`, {
|
||||
module: "jobs",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
rows: [] as unknown[][],
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
invalidate: vi.fn(),
|
||||
log: vi.fn(() => "news-error-1"),
|
||||
notify: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({
|
||||
requirePermission: async () => ({ id: 1, username: "staff" }),
|
||||
}));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { NEWS_EDIT: "news.edit" } }));
|
||||
vi.mock("@/lib/services/webhook", () => ({ notify: state.notify }));
|
||||
vi.mock("@/lib/services/news-cache", () => ({
|
||||
invalidateNewsCache: state.invalidate,
|
||||
}));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: state.log } }));
|
||||
vi.mock("next-intl/server", () => ({
|
||||
getTranslations: async () => (key: string, args?: { reference: string }) =>
|
||||
key + (args?.reference ?? ""),
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: (url: string) => {
|
||||
throw Error(`redirect ${url}`);
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => ({
|
||||
...(await import("@/db/schema")),
|
||||
db: {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({ limit: async () => state.rows.shift() ?? [] }),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({ values: state.insert }),
|
||||
update: () => ({
|
||||
set: (value: unknown) => {
|
||||
state.update(value);
|
||||
return { where: async () => [{ affectedRows: 1 }] };
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { createArticle, updateArticle } from "./admin-articles";
|
||||
|
||||
function form(extra: Record<string, string> = {}) {
|
||||
const f = new FormData();
|
||||
for (const [k, v] of Object.entries({
|
||||
title: "Test news",
|
||||
slug: "test-news",
|
||||
shortStory: "Summary",
|
||||
fullStory: "Body",
|
||||
status: "published",
|
||||
...extra,
|
||||
}))
|
||||
f.set(k, v);
|
||||
return f;
|
||||
}
|
||||
describe("news saves", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.rows = [];
|
||||
state.insert.mockResolvedValue([{ insertId: 1 }]);
|
||||
});
|
||||
it("preserves a recoverable error and logs its reference", async () => {
|
||||
state.insert.mockRejectedValueOnce(Error("Unknown column status"));
|
||||
const result = await createArticle(form());
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: "saveFailedReferencenews-error-1",
|
||||
});
|
||||
expect(state.invalidate).not.toHaveBeenCalled();
|
||||
expect(state.log).toHaveBeenCalled();
|
||||
});
|
||||
it("saves drafts without publication notifications", async () => {
|
||||
expect((await createArticle(form({ status: "draft" }))).ok).toBe(true);
|
||||
expect(state.insert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: "draft",
|
||||
publishAt: null,
|
||||
publishedAt: null,
|
||||
}),
|
||||
);
|
||||
expect(state.notify).not.toHaveBeenCalled();
|
||||
expect(state.invalidate).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("preserves the slug and clears old scheduling for immediate publication", async () => {
|
||||
state.rows = [[{ slug: "test-news", status: "draft", publishedAt: null }]];
|
||||
expect(
|
||||
(
|
||||
await updateArticle(
|
||||
form({ id: "1", publishAt: "2099-01-01T00:00:00Z" }),
|
||||
)
|
||||
).ok,
|
||||
).toBe(true);
|
||||
expect(state.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
slug: "test-news",
|
||||
publishAt: null,
|
||||
status: "published",
|
||||
}),
|
||||
);
|
||||
expect(state.invalidate).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("rejects invalid scheduled dates before writing", async () => {
|
||||
expect(
|
||||
(await createArticle(form({ status: "scheduled", publishAt: "invalid" })))
|
||||
.ok,
|
||||
).toBe(false);
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+98
-103
@@ -1,9 +1,15 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import {
|
||||
ArticleInputError,
|
||||
type ArticleSaveResult,
|
||||
readArticleInput,
|
||||
} from "@/lib/article-input";
|
||||
import {
|
||||
db,
|
||||
WebsiteArticleComments,
|
||||
@@ -12,152 +18,139 @@ import {
|
||||
} from "@/lib/db";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { invalidateNewsCache } from "@/lib/services/news-cache";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
|
||||
const ARTICLE_STATUSES = ["published", "scheduled", "draft"] as const;
|
||||
type ArticleStatus = (typeof ARTICLE_STATUSES)[number];
|
||||
|
||||
function parseArticleStatus(raw: unknown): ArticleStatus {
|
||||
const value = String(raw ?? "published").trim();
|
||||
return (ARTICLE_STATUSES as readonly string[]).includes(value)
|
||||
? (value as ArticleStatus)
|
||||
: "published";
|
||||
}
|
||||
|
||||
function parsePublishAt(raw: unknown): Date | null {
|
||||
const value = String(raw ?? "").trim();
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
async function uniqueSlug(title: string): Promise<string> {
|
||||
async function uniqueSlug(title: string, excludeId?: bigint): Promise<string> {
|
||||
const base = slugify(title);
|
||||
let slug = base;
|
||||
let n = 2;
|
||||
for (;;) {
|
||||
for (let n = 2; ; n++) {
|
||||
const [existing] = await db
|
||||
.select({ id: WebsiteArticles.id })
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.slug, slug))
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteArticles.slug, slug),
|
||||
excludeId ? ne(WebsiteArticles.id, excludeId) : undefined,
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) return slug;
|
||||
slug = `${base}-${n++}`;
|
||||
const suffix = `-${n}`;
|
||||
slug = base.slice(0, 255 - suffix.length) + suffix;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createArticle(formData: FormData): Promise<void> {
|
||||
async function saveFailure(
|
||||
error: unknown,
|
||||
operation: string,
|
||||
): Promise<ArticleSaveResult> {
|
||||
const t = await getTranslations("pages.admin.articles.form");
|
||||
if (error instanceof ArticleInputError)
|
||||
return { ok: false, error: t(error.code) };
|
||||
const reference = logger.error("News save failed", {
|
||||
module: "news",
|
||||
operation,
|
||||
error,
|
||||
});
|
||||
return { ok: false, error: t("saveFailedReference", { reference }) };
|
||||
}
|
||||
async function refreshNews() {
|
||||
await invalidateNewsCache();
|
||||
revalidatePath("/admin/articles");
|
||||
revalidatePath("/news", "layout");
|
||||
revalidatePath("/me");
|
||||
revalidatePath("/");
|
||||
}
|
||||
export async function createArticle(
|
||||
formData: FormData,
|
||||
): Promise<ArticleSaveResult> {
|
||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||
const title = String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const shortStory = String(formData.get("shortStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const fullStory = String(formData.get("fullStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const image = String(formData.get("image") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const rawSlug = String(formData.get("slug") ?? "").trim();
|
||||
const status = parseArticleStatus(formData.get("status"));
|
||||
const rawPublishAt = String(formData.get("publishAt") ?? "").trim();
|
||||
if (!title) return;
|
||||
if (status === "scheduled" && !parsePublishAt(rawPublishAt)) {
|
||||
redirect(
|
||||
"/admin/articles/new?error=Scheduled articles need a valid publish date.",
|
||||
);
|
||||
}
|
||||
|
||||
let input: ReturnType<typeof readArticleInput>;
|
||||
let slug: string;
|
||||
try {
|
||||
input = readArticleInput(formData);
|
||||
const { rawSlug, ...fields } = input;
|
||||
slug = await uniqueSlug(rawSlug || fields.title);
|
||||
const now = new Date();
|
||||
const publishAt = parsePublishAt(rawPublishAt);
|
||||
const slug = rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title);
|
||||
await db.insert(WebsiteArticles).values({
|
||||
...fields,
|
||||
slug,
|
||||
title: title.slice(0, 255),
|
||||
shortStory: shortStory.slice(0, 255),
|
||||
fullStory,
|
||||
image: image.slice(0, 255),
|
||||
userId: staff.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status,
|
||||
publishAt,
|
||||
publishedAt: status === "published" ? now : null,
|
||||
publishedAt: fields.status === "published" ? now : null,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
return saveFailure(error, "create");
|
||||
}
|
||||
// Auxiliary services must not turn a committed insert into an apparent failure.
|
||||
if (input.status === "published")
|
||||
notify({
|
||||
action: "news_publish",
|
||||
actor: staff.username,
|
||||
target: title,
|
||||
target: input.title,
|
||||
details: slug,
|
||||
});
|
||||
} catch {
|
||||
// Database error — re-render unchanged with error.
|
||||
redirect(
|
||||
"/admin/articles/new?error=Database error while creating article. Please try again.",
|
||||
);
|
||||
}
|
||||
redirect("/admin/articles");
|
||||
await refreshNews();
|
||||
return { ok: true, data: { redirectTo: "/admin/articles" } };
|
||||
}
|
||||
|
||||
export async function updateArticle(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.NEWS_EDIT);
|
||||
export async function updateArticle(
|
||||
formData: FormData,
|
||||
): Promise<ArticleSaveResult> {
|
||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||
const t = await getTranslations("pages.admin.articles.form");
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) redirect("/admin/articles?error=Missing article id");
|
||||
const rawSlug = String(formData.get("slug") ?? "").trim();
|
||||
const status = parseArticleStatus(formData.get("status"));
|
||||
const rawPublishAt = String(formData.get("publishAt") ?? "").trim();
|
||||
if (status === "scheduled" && !parsePublishAt(rawPublishAt)) {
|
||||
redirect(
|
||||
"/admin/articles?error=Scheduled articles need a valid publish date.",
|
||||
);
|
||||
}
|
||||
if (!id) return { ok: false, error: t("articleNotFound") };
|
||||
let input: ReturnType<typeof readArticleInput>;
|
||||
let becamePublished = false;
|
||||
let slug: string;
|
||||
try {
|
||||
const publishAt = parsePublishAt(rawPublishAt);
|
||||
input = readArticleInput(formData);
|
||||
const [existing] = await db
|
||||
.select({
|
||||
slug: WebsiteArticles.slug,
|
||||
status: WebsiteArticles.status,
|
||||
publishedAt: WebsiteArticles.publishedAt,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.id, id))
|
||||
.limit(1);
|
||||
const publishedAt =
|
||||
status === "published" ? (existing?.publishedAt ?? new Date()) : null;
|
||||
if (!existing) return { ok: false, error: t("articleNotFound") };
|
||||
const { rawSlug, ...fields } = input;
|
||||
const requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug;
|
||||
slug =
|
||||
requestedSlug === existing.slug
|
||||
? existing.slug
|
||||
: await uniqueSlug(requestedSlug, id);
|
||||
becamePublished =
|
||||
fields.status === "published" && existing.status !== "published";
|
||||
await db
|
||||
.update(WebsiteArticles)
|
||||
.set({
|
||||
title: String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
...(rawSlug ? { slug: await uniqueSlug(rawSlug) } : {}),
|
||||
shortStory: String(formData.get("shortStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
fullStory: String(formData.get("fullStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim(),
|
||||
image: String(formData.get("image") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
status,
|
||||
publishAt,
|
||||
publishedAt,
|
||||
...fields,
|
||||
slug,
|
||||
publishedAt:
|
||||
fields.status === "published"
|
||||
? (existing.publishedAt ?? new Date())
|
||||
: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(WebsiteArticles.id, id));
|
||||
} catch {
|
||||
redirect("/admin/articles?error=Update failed");
|
||||
} catch (error) {
|
||||
return saveFailure(error, "update");
|
||||
}
|
||||
if (becamePublished)
|
||||
notify({
|
||||
action: "news_publish",
|
||||
actor: staff.username,
|
||||
target: input.title,
|
||||
details: slug,
|
||||
});
|
||||
await refreshNews();
|
||||
revalidatePath(`/admin/articles/${id}`);
|
||||
redirect("/admin/articles");
|
||||
return { ok: true, data: { redirectTo: "/admin/articles" } };
|
||||
}
|
||||
|
||||
export async function deleteArticle(formData: FormData): Promise<void> {
|
||||
@@ -185,8 +178,10 @@ export async function deleteArticle(formData: FormData): Promise<void> {
|
||||
actor: staff.username,
|
||||
target: article?.title ?? String(id),
|
||||
});
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logger.error("News deletion failed", { module: "news", error });
|
||||
redirect("/admin/articles?error=Delete failed");
|
||||
}
|
||||
await refreshNews();
|
||||
redirect("/admin/articles");
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ADMIN_NAV_CONFIG_KEY,
|
||||
type AdminNavConfig,
|
||||
serializeAdminNavConfig,
|
||||
} from "@/lib/admin-nav-config";
|
||||
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
const schema = z.object({
|
||||
groupOrder: z.array(z.string()),
|
||||
hiddenGroups: z.array(z.string()),
|
||||
hiddenItems: z.array(z.string()),
|
||||
itemOrder: z.record(z.string(), z.array(z.string())),
|
||||
});
|
||||
|
||||
export const saveAdminNavConfig = adminAction(
|
||||
{
|
||||
permission: PERMS.SETTINGS_EDIT,
|
||||
schema,
|
||||
rateLimitKey: "admin-nav-config-save",
|
||||
rateLimitMax: 30,
|
||||
},
|
||||
async (ctx) => {
|
||||
const config: AdminNavConfig = {
|
||||
groupOrder: ctx.data.groupOrder,
|
||||
hiddenGroups: ctx.data.hiddenGroups,
|
||||
hiddenItems: ctx.data.hiddenItems,
|
||||
itemOrder: ctx.data.itemOrder,
|
||||
};
|
||||
await siteSettings.update(
|
||||
ADMIN_NAV_CONFIG_KEY,
|
||||
serializeAdminNavConfig(config),
|
||||
);
|
||||
revalidatePath("/admin", "layout");
|
||||
revalidatePath("/admin/menu");
|
||||
return actionOk({ saved: true });
|
||||
},
|
||||
);
|
||||
+60
-118
@@ -1,12 +1,24 @@
|
||||
"use server";
|
||||
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { catalogFailure } from "@/features/catalog/server/errors";
|
||||
import {
|
||||
createBcOfferCommand,
|
||||
updateBcOfferCommand,
|
||||
} from "@/features/catalog/server/offer-commands";
|
||||
import {
|
||||
createPageCommand,
|
||||
deletePageCommand,
|
||||
reorderPagesCommand,
|
||||
togglePageCommand,
|
||||
updatePageCommand,
|
||||
} from "@/features/catalog/server/page-commands";
|
||||
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { CatalogItemsBc, CatalogPagesBc, db } from "@/lib/db";
|
||||
import { CatalogItemsBc, db } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
const BC_PAGE_FIELDS = [
|
||||
@@ -50,19 +62,24 @@ function pickAllowed(
|
||||
|
||||
export async function updateBcPage({
|
||||
id,
|
||||
expected,
|
||||
...fields
|
||||
}: { id: number } & Record<string, unknown>) {
|
||||
}: { id: number; expected?: Record<string, unknown> } & Record<
|
||||
string,
|
||||
unknown
|
||||
>) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return await withCatalogExport(async () => {
|
||||
const data = pickAllowed(fields, BC_PAGE_FIELDS);
|
||||
if (Object.keys(data).length === 0) {
|
||||
return { ok: false as const, error: "No valid fields to update" };
|
||||
}
|
||||
await db
|
||||
.update(CatalogPagesBc)
|
||||
.set(data as Partial<typeof CatalogPagesBc.$inferInsert>)
|
||||
.where(eq(CatalogPagesBc.id, id));
|
||||
await rcon.updateCatalog();
|
||||
try {
|
||||
await updatePageCommand("bc", id, data, expected);
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_page_update",
|
||||
@@ -79,7 +96,7 @@ export async function deleteBcItem({ id }: { id: number }) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return await withCatalogExport(async () => {
|
||||
await db.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id));
|
||||
await rcon.updateCatalog();
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_delete",
|
||||
@@ -108,11 +125,12 @@ export async function updateBcItem({
|
||||
if (Object.keys(safe).length === 0) {
|
||||
return { ok: false as const, error: "No valid fields to update" };
|
||||
}
|
||||
await db
|
||||
.update(CatalogItemsBc)
|
||||
.set(safe as Partial<typeof CatalogItemsBc.$inferInsert>)
|
||||
.where(eq(CatalogItemsBc.id, id));
|
||||
await rcon.updateCatalog();
|
||||
try {
|
||||
await updateBcOfferCommand(id, safe);
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_update",
|
||||
@@ -137,11 +155,8 @@ export async function createBcItem({
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return await withCatalogExport(async () => {
|
||||
const [result] = await db
|
||||
.insert(CatalogItemsBc)
|
||||
.values({ pageId, ...data });
|
||||
const createdId = Number(result.insertId);
|
||||
await rcon.updateCatalog();
|
||||
const createdId = await createBcOfferCommand({ pageId, ...data });
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_create",
|
||||
@@ -163,20 +178,8 @@ export async function toggleBcPage({
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return await withCatalogExport(async () => {
|
||||
const [page] = await db
|
||||
.select({
|
||||
enabled: CatalogPagesBc.enabled,
|
||||
visible: CatalogPagesBc.visible,
|
||||
})
|
||||
.from(CatalogPagesBc)
|
||||
.where(eq(CatalogPagesBc.id, id))
|
||||
.limit(1);
|
||||
if (!page) return { ok: false as const, error: "Page not found" };
|
||||
await db
|
||||
.update(CatalogPagesBc)
|
||||
.set({ [field]: page[field] === "1" ? "0" : "1" })
|
||||
.where(eq(CatalogPagesBc.id, id));
|
||||
await rcon.updateCatalog();
|
||||
await togglePageCommand("bc", id, field);
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const };
|
||||
});
|
||||
@@ -194,7 +197,7 @@ export async function createBcPage(input: {
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return await withCatalogExport(async () => {
|
||||
const [result] = await db.insert(CatalogPagesBc).values({
|
||||
const createdId = await createPageCommand("bc", {
|
||||
caption: input.caption,
|
||||
parentId: input.parentId,
|
||||
pageLayout: input.pageLayout ?? "default_3x3",
|
||||
@@ -206,8 +209,7 @@ export async function createBcPage(input: {
|
||||
pageHeadline: "",
|
||||
pageTeaser: "",
|
||||
});
|
||||
const createdId = Number(result.insertId);
|
||||
await rcon.updateCatalog();
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_page_create",
|
||||
@@ -221,28 +223,6 @@ export async function createBcPage(input: {
|
||||
});
|
||||
}
|
||||
|
||||
async function moveBcPage(pageId: number, newParentId: number): Promise<void> {
|
||||
if (newParentId > 0) {
|
||||
let currentId = newParentId;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (currentId === pageId) {
|
||||
throw new Error("Cannot move page: would create a circular hierarchy");
|
||||
}
|
||||
const [parent] = await db
|
||||
.select({ parentId: CatalogPagesBc.parentId })
|
||||
.from(CatalogPagesBc)
|
||||
.where(eq(CatalogPagesBc.id, currentId))
|
||||
.limit(1);
|
||||
if (!parent || parent.parentId <= 0) break;
|
||||
currentId = parent.parentId;
|
||||
}
|
||||
}
|
||||
await db
|
||||
.update(CatalogPagesBc)
|
||||
.set({ parentId: newParentId })
|
||||
.where(eq(CatalogPagesBc.id, pageId));
|
||||
}
|
||||
|
||||
export async function reorderBcTreePage(input: {
|
||||
pageId: number;
|
||||
newParentId?: number;
|
||||
@@ -250,21 +230,11 @@ export async function reorderBcTreePage(input: {
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return await withCatalogExport(async () => {
|
||||
if (input.newParentId !== undefined) {
|
||||
try {
|
||||
await moveBcPage(input.pageId, input.newParentId);
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: err instanceof Error ? err.message : "Invalid move",
|
||||
};
|
||||
}
|
||||
}
|
||||
await db
|
||||
.update(CatalogPagesBc)
|
||||
.set({ orderNum: input.newOrderNum })
|
||||
.where(eq(CatalogPagesBc.id, input.pageId));
|
||||
await rcon.updateCatalog();
|
||||
await updatePageCommand("bc", input.pageId, {
|
||||
parentId: input.newParentId,
|
||||
orderNum: input.newOrderNum,
|
||||
});
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const, data: {} };
|
||||
@@ -277,51 +247,23 @@ export async function deleteBcTreePage(input: {
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return await withCatalogExport(async () => {
|
||||
const [page] = await db
|
||||
.select({ parentId: CatalogPagesBc.parentId })
|
||||
.from(CatalogPagesBc)
|
||||
.where(eq(CatalogPagesBc.id, input.pageId))
|
||||
.limit(1);
|
||||
if (!page) return { ok: false as const, error: "Page not found" };
|
||||
await deletePageCommand("bc", input.pageId, input.mode);
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const, data: {} };
|
||||
});
|
||||
}
|
||||
|
||||
if (input.mode === "reparent") {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(CatalogPagesBc)
|
||||
.set({ parentId: page.parentId })
|
||||
.where(eq(CatalogPagesBc.parentId, input.pageId));
|
||||
await tx
|
||||
.delete(CatalogItemsBc)
|
||||
.where(eq(CatalogItemsBc.pageId, input.pageId));
|
||||
await tx
|
||||
.delete(CatalogPagesBc)
|
||||
.where(eq(CatalogPagesBc.id, input.pageId));
|
||||
});
|
||||
} else {
|
||||
const toDelete: number[] = [input.pageId];
|
||||
const queue: number[] = [input.pageId];
|
||||
while (queue.length > 0) {
|
||||
const children = await db
|
||||
.select({ id: CatalogPagesBc.id })
|
||||
.from(CatalogPagesBc)
|
||||
.where(inArray(CatalogPagesBc.parentId, queue));
|
||||
queue.length = 0;
|
||||
for (const child of children) {
|
||||
toDelete.push(child.id);
|
||||
queue.push(child.id);
|
||||
}
|
||||
}
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(CatalogItemsBc)
|
||||
.where(inArray(CatalogItemsBc.pageId, toDelete));
|
||||
await tx
|
||||
.delete(CatalogPagesBc)
|
||||
.where(inArray(CatalogPagesBc.id, toDelete));
|
||||
});
|
||||
}
|
||||
|
||||
await rcon.updateCatalog();
|
||||
export async function reorderBcCatalogPages(input: {
|
||||
parentId: number;
|
||||
ids: number[];
|
||||
expectedIds: number[];
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return withCatalogExport(async () => {
|
||||
await reorderPagesCommand("bc", input);
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const, data: {} };
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { BulkOfferInput } from "@/features/catalog/domain/bulk-offers";
|
||||
import {
|
||||
applyBulkOffersCommand,
|
||||
listBulkOfferDestinationsCommand,
|
||||
previewBulkOffersCommand,
|
||||
} from "@/features/catalog/server/bulk-offers";
|
||||
import { catalogFailure } from "@/features/catalog/server/errors";
|
||||
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||
export async function previewBulkOffers(input: BulkOfferInput) {
|
||||
await requirePermission(PERMS.CATALOG_VIEW);
|
||||
try {
|
||||
return { ok: true as const, data: await previewBulkOffersCommand(input) };
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
}
|
||||
export async function applyBulkOffers(
|
||||
input: BulkOfferInput,
|
||||
fingerprint: string,
|
||||
) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
try {
|
||||
return await withCatalogExport(async () => {
|
||||
const data = await applyBulkOffersCommand(input, fingerprint);
|
||||
if (data.changedCount > 0) {
|
||||
await logAudit({
|
||||
userId: staff.id,
|
||||
action: "catalog_offers_bulk_update",
|
||||
target: "catalog",
|
||||
after: {
|
||||
ids: input.ids,
|
||||
changes: input.changes,
|
||||
changedCount: data.changedCount,
|
||||
},
|
||||
}).catch((error) =>
|
||||
logger.error("Catalog bulk update committed; audit write failed", {
|
||||
module: "catalog",
|
||||
error,
|
||||
}),
|
||||
);
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog", "layout");
|
||||
}
|
||||
return { ok: true as const, data };
|
||||
});
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBulkOfferDestinations() {
|
||||
await requirePermission(PERMS.CATALOG_VIEW);
|
||||
try {
|
||||
return {
|
||||
ok: true as const,
|
||||
data: await listBulkOfferDestinationsCommand(),
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type {
|
||||
DuplicateInput,
|
||||
DuplicatePreview,
|
||||
} from "@/features/catalog/domain/duplicate";
|
||||
import {
|
||||
duplicateCategoryCommand,
|
||||
duplicateDestinationsCommand,
|
||||
previewDuplicateCommand,
|
||||
} from "@/features/catalog/server/duplicate-commands";
|
||||
import { catalogFailure } from "@/features/catalog/server/errors";
|
||||
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
export async function previewCatalogDuplicate(input: DuplicateInput) {
|
||||
await requirePermission(PERMS.CATALOG_VIEW);
|
||||
try {
|
||||
return { ok: true as const, data: await previewDuplicateCommand(input) };
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
}
|
||||
export async function applyCatalogDuplicate(input: DuplicatePreview) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return withCatalogExport(async () => {
|
||||
let data: Awaited<ReturnType<typeof duplicateCategoryCommand>>;
|
||||
try {
|
||||
data = await duplicateCategoryCommand(input);
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
const hotel = await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_create",
|
||||
description: `Duplicated ${input.kind} category #${input.sourceId} to #${data.id}: ${data.pages} categories, ${data.offers} offers`,
|
||||
targetType: "catalog_page",
|
||||
targetId: data.id,
|
||||
}).catch((error) => {
|
||||
logger.error("Category duplicated but audit logging failed", {
|
||||
module: "catalog",
|
||||
error,
|
||||
});
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: { ...data, hotel } };
|
||||
});
|
||||
}
|
||||
|
||||
export async function catalogDuplicateDestinations(
|
||||
kind: DuplicateInput["kind"],
|
||||
) {
|
||||
await requirePermission(PERMS.CATALOG_VIEW);
|
||||
try {
|
||||
return {
|
||||
ok: true as const,
|
||||
data: await duplicateDestinationsCommand(kind),
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
}
|
||||
+34
-112
@@ -2,72 +2,23 @@
|
||||
|
||||
import { eq, inArray, like, or, sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { offerPatchSchema } from "@/features/catalog/domain/offer-input";
|
||||
import {
|
||||
createOfferCommand,
|
||||
moveOffersCommand,
|
||||
reorderOffersCommand,
|
||||
updateOfferCommand,
|
||||
} from "@/features/catalog/server/offer-commands";
|
||||
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { CatalogItems, db, ItemsBase } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||
import { allocateCatalogItemId } from "@/lib/services/furni-import";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { translateItemsSchema } from "@/lib/validators/catalog";
|
||||
|
||||
const CATALOG_ITEM_FIELDS = [
|
||||
"pageId",
|
||||
"itemIds",
|
||||
"catalogName",
|
||||
"costCredits",
|
||||
"costPoints",
|
||||
"pointsType",
|
||||
"amount",
|
||||
"orderNumber",
|
||||
"offerId",
|
||||
"songId",
|
||||
"limitedSells",
|
||||
"limitedStack",
|
||||
"extradata",
|
||||
"haveOffer",
|
||||
"clubOnly",
|
||||
] as const;
|
||||
|
||||
const ITEMS_BASE_FIELDS = [
|
||||
"publicName",
|
||||
"itemName",
|
||||
"type",
|
||||
"width",
|
||||
"length",
|
||||
"stackHeight",
|
||||
"allowStack",
|
||||
"allowSit",
|
||||
"allowLay",
|
||||
"allowWalk",
|
||||
"allowGift",
|
||||
"allowTrade",
|
||||
"allowRecycle",
|
||||
"allowMarketplaceSell",
|
||||
"allowInventoryStack",
|
||||
"interactionType",
|
||||
"interactionModesCount",
|
||||
"vendingIds",
|
||||
"customparams",
|
||||
"effectIdMale",
|
||||
"effectIdFemale",
|
||||
"clothingOnWalk",
|
||||
] as const;
|
||||
|
||||
function pickAllowed(
|
||||
fields: Record<string, unknown>,
|
||||
allowed: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of allowed) {
|
||||
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
|
||||
out[key] = fields[key];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Raw INSERT — catalog_items.id has no AUTO_INCREMENT on real Habbo DBs; page_id is often VARCHAR. */
|
||||
async function insertCatalogItemRow(data: {
|
||||
pageId: number;
|
||||
@@ -87,8 +38,10 @@ async function insertCatalogItemRow(data: {
|
||||
clubOnly: string;
|
||||
}): Promise<number> {
|
||||
const pageIdStr = String(data.pageId);
|
||||
return allocateCatalogItemId(async (nextId) => {
|
||||
await db.execute(sql`
|
||||
offerPatchSchema.required().parse(data);
|
||||
return allocateCatalogItemId((nextId) =>
|
||||
createOfferCommand("normal", data, async (tx) => {
|
||||
await tx.execute(sql`
|
||||
INSERT INTO catalog_items (
|
||||
id, page_id, item_ids, catalog_name,
|
||||
cost_credits, cost_points, points_type, amount,
|
||||
@@ -102,8 +55,9 @@ async function insertCatalogItemRow(data: {
|
||||
${data.haveOffer}, ${data.clubOnly}
|
||||
)
|
||||
`);
|
||||
return nextId;
|
||||
});
|
||||
return nextId;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createCatalogItem(data: {
|
||||
@@ -141,7 +95,7 @@ export async function createCatalogItem(data: {
|
||||
}
|
||||
}
|
||||
const id = await insertCatalogItemRow({ ...data, catalogName });
|
||||
await rcon.updateCatalog();
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_item_create",
|
||||
@@ -221,7 +175,7 @@ export async function bulkCreateCatalogItems({
|
||||
}
|
||||
|
||||
if (created > 0) {
|
||||
await rcon.updateCatalog();
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_items_bulk_create",
|
||||
@@ -240,7 +194,7 @@ export async function deleteCatalogItems({ ids }: { ids: number[] }) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return await withCatalogExport(async () => {
|
||||
await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids));
|
||||
await rcon.updateCatalog();
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_items_delete",
|
||||
@@ -264,16 +218,8 @@ export async function moveCatalogItems({
|
||||
if (ids.length === 0) {
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
const pageIdStr = String(targetPageId);
|
||||
await db.execute(sql`
|
||||
UPDATE catalog_items
|
||||
SET page_id = ${pageIdStr}
|
||||
WHERE id IN (${sql.join(
|
||||
ids.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})
|
||||
`);
|
||||
await rcon.updateCatalog();
|
||||
await moveOffersCommand(ids, targetPageId);
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
});
|
||||
@@ -286,13 +232,8 @@ export async function reorderCatalogItems({
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return await withCatalogExport(async () => {
|
||||
for (const { id, orderNumber } of orders) {
|
||||
await db
|
||||
.update(CatalogItems)
|
||||
.set({ orderNumber })
|
||||
.where(eq(CatalogItems.id, id));
|
||||
}
|
||||
await rcon.updateCatalog();
|
||||
await reorderOffersCommand(orders);
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
});
|
||||
@@ -309,37 +250,18 @@ export async function updateCatalogItem({
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return await withCatalogExport(async () => {
|
||||
const safeCatalog = pickAllowed(catalogFields, CATALOG_ITEM_FIELDS);
|
||||
if (Object.keys(safeCatalog).length === 0 && !baseItem) {
|
||||
return { ok: false as const, error: "No valid fields to update" };
|
||||
try {
|
||||
await updateOfferCommand({ id, catalogFields, baseItem });
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unable to update catalog item",
|
||||
};
|
||||
}
|
||||
|
||||
// page_id is often VARCHAR — update it via raw SQL when present.
|
||||
const pageIdRaw = safeCatalog.pageId;
|
||||
if (pageIdRaw !== undefined) {
|
||||
const pageIdStr = String(pageIdRaw);
|
||||
await db.execute(sql`
|
||||
UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id = ${id}
|
||||
`);
|
||||
delete safeCatalog.pageId;
|
||||
}
|
||||
|
||||
if (Object.keys(safeCatalog).length > 0) {
|
||||
await db
|
||||
.update(CatalogItems)
|
||||
.set(safeCatalog as Partial<typeof CatalogItems.$inferInsert>)
|
||||
.where(eq(CatalogItems.id, id));
|
||||
}
|
||||
if (baseItem) {
|
||||
const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS);
|
||||
if (Object.keys(safeBase).length > 0) {
|
||||
await db
|
||||
.update(ItemsBase)
|
||||
.set(safeBase as Partial<typeof ItemsBase.$inferInsert>)
|
||||
.where(eq(ItemsBase.id, baseItem.id));
|
||||
}
|
||||
}
|
||||
await rcon.updateCatalog();
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_item_update",
|
||||
@@ -453,7 +375,7 @@ export async function translateCatalogItems(input: {
|
||||
invalidateFurniDataCache();
|
||||
}
|
||||
|
||||
await rcon.updateCatalog();
|
||||
await sendCatalogUpdate();
|
||||
await logAudit({
|
||||
userId: staff.id,
|
||||
action: "items_base_translate",
|
||||
|
||||
+51
-48
@@ -1,14 +1,19 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { catalogFailure } from "@/features/catalog/server/errors";
|
||||
import {
|
||||
createPageCommand,
|
||||
deletePageCommand,
|
||||
reorderPagesCommand,
|
||||
togglePageCommand,
|
||||
updatePageCommand,
|
||||
} from "@/features/catalog/server/page-commands";
|
||||
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { CatalogPages, db } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||
import { deletePage, movePage } from "@/lib/services/catalog-tree";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
const CATALOG_PAGE_FIELDS = [
|
||||
@@ -46,8 +51,12 @@ function pickPageFields(fields: Record<string, unknown>) {
|
||||
|
||||
export async function updateCatalogPage({
|
||||
id,
|
||||
expected,
|
||||
...fields
|
||||
}: { id: number } & Record<string, unknown>): Promise<ActionResult> {
|
||||
}: { id: number; expected?: Record<string, unknown> } & Record<
|
||||
string,
|
||||
unknown
|
||||
>): Promise<ActionResult> {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return await withCatalogExport(async () => {
|
||||
const data = pickPageFields(fields);
|
||||
@@ -57,11 +66,12 @@ export async function updateCatalogPage({
|
||||
if (typeof data.caption === "string" && !data.captionSave) {
|
||||
data.captionSave = data.caption.slice(0, 25);
|
||||
}
|
||||
await db
|
||||
.update(CatalogPages)
|
||||
.set(data as Partial<typeof CatalogPages.$inferInsert>)
|
||||
.where(eq(CatalogPages.id, id));
|
||||
await rcon.updateCatalog();
|
||||
try {
|
||||
await updatePageCommand("normal", id, data, expected);
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_update",
|
||||
@@ -77,8 +87,8 @@ export async function updateCatalogPage({
|
||||
export async function deleteCatalogPage({ id }: { id: number }) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return await withCatalogExport(async () => {
|
||||
await deletePage(id, "reparent");
|
||||
await rcon.updateCatalog();
|
||||
await deletePageCommand("normal", id, "reparent");
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_delete",
|
||||
@@ -100,22 +110,12 @@ export async function toggleCatalogPage({
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return await withCatalogExport(async () => {
|
||||
const [page] = await db
|
||||
.select({
|
||||
enabled: CatalogPages.enabled,
|
||||
visible: CatalogPages.visible,
|
||||
})
|
||||
.from(CatalogPages)
|
||||
.where(eq(CatalogPages.id, id))
|
||||
.limit(1);
|
||||
if (!page) return { ok: false as const, error: "Catalog page not found" };
|
||||
const field = action === "toggleEnabled" ? "enabled" : "visible";
|
||||
const current = action === "toggleEnabled" ? page.enabled : page.visible;
|
||||
await db
|
||||
.update(CatalogPages)
|
||||
.set({ [field]: current === "1" ? "0" : "1" })
|
||||
.where(eq(CatalogPages.id, id));
|
||||
await rcon.updateCatalog();
|
||||
await togglePageCommand(
|
||||
"normal",
|
||||
id,
|
||||
action === "toggleEnabled" ? "enabled" : "visible",
|
||||
);
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
});
|
||||
@@ -134,7 +134,7 @@ export async function createCatalogPage(input: {
|
||||
}): Promise<ActionResult<{ id: number }>> {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return await withCatalogExport(async () => {
|
||||
const [result] = await db.insert(CatalogPages).values({
|
||||
const createdId = await createPageCommand("normal", {
|
||||
caption: input.caption,
|
||||
parentId: input.parentId,
|
||||
pageLayout: input.pageLayout ?? "default_3x3",
|
||||
@@ -151,8 +151,7 @@ export async function createCatalogPage(input: {
|
||||
pageTeaser: "",
|
||||
includes: "",
|
||||
});
|
||||
const createdId = Number(result.insertId);
|
||||
await rcon.updateCatalog();
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_create",
|
||||
@@ -172,21 +171,11 @@ export async function reorderTreePage(input: {
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return await withCatalogExport(async () => {
|
||||
if (input.newParentId !== undefined) {
|
||||
try {
|
||||
await movePage(input.pageId, input.newParentId);
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: err instanceof Error ? err.message : "Invalid move",
|
||||
};
|
||||
}
|
||||
}
|
||||
await db
|
||||
.update(CatalogPages)
|
||||
.set({ orderNum: input.newOrderNum })
|
||||
.where(eq(CatalogPages.id, input.pageId));
|
||||
await rcon.updateCatalog();
|
||||
await updatePageCommand("normal", input.pageId, {
|
||||
parentId: input.newParentId,
|
||||
orderNum: input.newOrderNum,
|
||||
});
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
});
|
||||
@@ -198,8 +187,22 @@ export async function deleteTreePage(input: {
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return await withCatalogExport(async () => {
|
||||
await deletePage(input.pageId, input.mode);
|
||||
await rcon.updateCatalog();
|
||||
await deletePageCommand("normal", input.pageId, input.mode);
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
});
|
||||
}
|
||||
|
||||
export async function reorderCatalogPages(input: {
|
||||
parentId: number;
|
||||
ids: number[];
|
||||
expectedIds: number[];
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return withCatalogExport(async () => {
|
||||
await reorderPagesCommand("normal", input);
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
});
|
||||
|
||||
+25
-38
@@ -3,60 +3,47 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import * as JSONC from "jsonc-parser";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { z } from "zod";
|
||||
import { SUPPORTED_LOCALES } from "@/i18n/locales";
|
||||
import {
|
||||
CLIENT_TRANSLATION_FILES,
|
||||
getClientTranslationFile,
|
||||
} from "@/lib/client-translation-files";
|
||||
import {
|
||||
CmsTranslationError,
|
||||
saveCmsTranslation,
|
||||
} from "@/lib/cms-translations";
|
||||
import { patchJson5 } from "@/lib/json5-patch";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
|
||||
const saveTranslationsSchema = z.object({
|
||||
locale: z.enum([
|
||||
"en",
|
||||
"it",
|
||||
"nl",
|
||||
"de",
|
||||
"fr",
|
||||
"es",
|
||||
"pt",
|
||||
"pl",
|
||||
"sv",
|
||||
"tr",
|
||||
"ro",
|
||||
"hu",
|
||||
"cs",
|
||||
"sk",
|
||||
"da",
|
||||
"no",
|
||||
"el",
|
||||
"bg",
|
||||
"hr",
|
||||
"sr",
|
||||
"uk",
|
||||
"ru",
|
||||
]),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
locale: z.enum(SUPPORTED_LOCALES),
|
||||
revision: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
changes: z
|
||||
.record(z.string().max(300), z.string().max(20000).nullable())
|
||||
.refine((value) => Object.keys(value).length <= 5000),
|
||||
});
|
||||
|
||||
export const saveTranslations = adminAction(
|
||||
{ permission: PERMS.SETTINGS_EDIT, schema: saveTranslationsSchema },
|
||||
async (ctx) => {
|
||||
const filePath = path.join(
|
||||
process.cwd(),
|
||||
"src",
|
||||
"messages",
|
||||
`${ctx.data.locale}.json`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
JSON.stringify(ctx.data.data, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
return actionOk();
|
||||
try {
|
||||
const snapshot = await saveCmsTranslation(
|
||||
ctx.data.locale,
|
||||
ctx.data.revision,
|
||||
ctx.data.changes,
|
||||
);
|
||||
revalidatePath("/", "layout");
|
||||
return actionOk({ snapshot });
|
||||
} catch (error) {
|
||||
if (!(error instanceof CmsTranslationError)) throw error;
|
||||
const t = await getTranslations("pages.admin.translations.cms");
|
||||
return { ok: false, error: t(error.code, { key: error.key }) };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { and, eq, sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db, WebsiteShopVouchers, WebsiteUsedShopVouchers } from "@/lib/db";
|
||||
import { formatNumber } from "@/lib/format-date";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { currencyDb, sendCurrency } from "@/lib/services/send-currency";
|
||||
@@ -175,6 +176,6 @@ export async function redeem(
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: `Success! Your balance has been increased by ${voucher.amount.toLocaleString()} credits.`,
|
||||
message: `Success! Your balance has been increased by ${formatNumber(voucher.amount)} credits.`,
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,5 @@
|
||||
export default function FriendsLoading() {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
|
||||
>
|
||||
<div
|
||||
className="animate-spin w-8 h-8 border-4 rounded-full"
|
||||
style={{
|
||||
borderColor: "var(--color-primary)",
|
||||
borderTopColor: "transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -1,15 +1,5 @@
|
||||
export default function GuildForumLoading() {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
|
||||
>
|
||||
<div
|
||||
className="animate-spin w-8 h-8 border-4 rounded-full"
|
||||
style={{
|
||||
borderColor: "var(--color-primary)",
|
||||
borderTopColor: "transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -1,15 +1,5 @@
|
||||
export default function SiteLoading() {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
|
||||
>
|
||||
<div
|
||||
className="animate-spin w-8 h-8 border-4 rounded-full"
|
||||
style={{
|
||||
borderColor: "var(--color-primary)",
|
||||
borderTopColor: "transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function CopyReferralButton({ value }: { value: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const t = useTranslations("pages.myDashboard");
|
||||
|
||||
async function copy() {
|
||||
setFailed(false);
|
||||
setCopied(false);
|
||||
const absolute =
|
||||
typeof window !== "undefined" && value.startsWith("/")
|
||||
? `${window.location.origin}${value}`
|
||||
@@ -20,9 +25,11 @@ export default function CopyReferralButton({ value }: { value: string }) {
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
if (!document.execCommand("copy")) throw new Error("copy-failed");
|
||||
} catch {
|
||||
/* give up silently */
|
||||
setFailed(true);
|
||||
document.body.removeChild(ta);
|
||||
return;
|
||||
}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
@@ -31,17 +38,21 @@ export default function CopyReferralButton({ value }: { value: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="rounded-xl font-bold text-sm px-5 py-2.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shrink-0"
|
||||
style={{
|
||||
background: "color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
border: `2px solid color-mix(in srgb, var(--color-primary) 20%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{copied ? "Copied!" : "Copy link"}
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="rounded-xl font-bold text-sm px-5 py-2.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shrink-0"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
border: `2px solid color-mix(in srgb, var(--color-primary) 20%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{copied ? t("copied") : t("copy")}
|
||||
</button>
|
||||
{failed && <p role="alert">{t("copyFailed")}</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
.dashboard {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding-bottom: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
background: linear-gradient(
|
||||
130deg,
|
||||
color-mix(in srgb, var(--color-primary) 10%, var(--color-surface)),
|
||||
var(--color-surface)
|
||||
);
|
||||
}
|
||||
.avatar {
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
border-radius: 16px;
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
}
|
||||
.identity {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.identity h1 {
|
||||
font-size: clamp(1.6rem, 3vw, 2rem);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.dates {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 12px;
|
||||
}
|
||||
.shortcuts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.shortcut {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-readable);
|
||||
text-decoration: none;
|
||||
}
|
||||
.shortcut:hover,
|
||||
.friend:hover,
|
||||
.room:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
|
||||
}
|
||||
.count {
|
||||
border-radius: 999px;
|
||||
padding: 2px 7px;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.wallet {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.currency {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 20px;
|
||||
}
|
||||
.currency > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.currency strong {
|
||||
font-size: 1.3rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.currency span {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 350px);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
.friends,
|
||||
.rooms {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.friend,
|
||||
.room {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
color: var(--color-text-readable);
|
||||
text-decoration: none;
|
||||
min-width: 0;
|
||||
}
|
||||
.friend > div,
|
||||
.room {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.friend strong,
|
||||
.friend span,
|
||||
.room strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.friend span,
|
||||
.room span {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
.stats dt {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.stats dd {
|
||||
margin: 4px 0 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
.feedback {
|
||||
margin: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 4px solid var(--color-primary);
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
padding: 16px;
|
||||
}
|
||||
.progressLabel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.progress {
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
.reward {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.referralInput {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.claim {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.claim button {
|
||||
width: 100%;
|
||||
}
|
||||
.rules {
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.rules summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
.badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.badges img {
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.columns {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.shortcuts {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.hero {
|
||||
padding: 18px;
|
||||
gap: 16px;
|
||||
}
|
||||
.hero > a {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.currency {
|
||||
padding: 12px;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.currency strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
.progress {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: var(--color-border);
|
||||
}
|
||||
.progress::-webkit-progress-bar {
|
||||
background: var(--color-border);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.progress::-webkit-progress-value {
|
||||
background: var(--color-primary);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.progress::-moz-progress-bar {
|
||||
background: var(--color-primary);
|
||||
border-radius: 999px;
|
||||
}
|
||||
+300
-559
@@ -1,367 +1,227 @@
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import { claimReferral } from "@/actions/referral";
|
||||
import Link from "@/components/link";
|
||||
|
||||
import { Reveal } from "@/components/motion-reveal";
|
||||
import {
|
||||
CurrencyIcon,
|
||||
type CurrencyKind,
|
||||
} from "@/components/shared/currency-icon";
|
||||
import { ProfileImage } from "@/components/shared/profile-image";
|
||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { SurfaceCard } from "@/components/surface-card";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { loadUserDashboard } from "@/lib/services/user-dashboard";
|
||||
import CopyReferralButton from "./CopyReferralButton";
|
||||
import styles from "./dashboard.module.css";
|
||||
|
||||
export const metadata = { title: "My dashboard" };
|
||||
|
||||
type SearchParams = Promise<{ claimed?: string; error?: string }>;
|
||||
|
||||
function feedbackStyle(tone: "success" | "error" | "warning"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error"
|
||||
? "var(--color-danger)"
|
||||
: tone === "warning"
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
const ERROR_MESSAGES: Record<string, string> = {
|
||||
not_enough: "You do not have enough referrals to claim your reward yet.",
|
||||
no_referrals:
|
||||
"You haven't referred anyone yet — share your link to get started.",
|
||||
bad_config:
|
||||
"The referral reward is not configured correctly. Please contact a staff member.",
|
||||
error: "Something went wrong while claiming your reward. Please try again.",
|
||||
};
|
||||
|
||||
function getErrorMessage(error: string): string {
|
||||
return ERROR_MESSAGES[error] ?? ERROR_MESSAGES.error;
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("pages.myDashboard");
|
||||
return { title: t("title") };
|
||||
}
|
||||
|
||||
export default async function MePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParams;
|
||||
searchParams: Promise<{ claimed?: string; error?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId) || userId <= 0) redirect("/login");
|
||||
|
||||
const { claimed, error } = await searchParams;
|
||||
|
||||
let content: ReactNode;
|
||||
const [t, format, { claimed, error }] = await Promise.all([
|
||||
getTranslations("pages.myDashboard"),
|
||||
getFormatter(),
|
||||
searchParams,
|
||||
]);
|
||||
let data: Awaited<ReturnType<typeof loadUserDashboard>>;
|
||||
try {
|
||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
|
||||
const {
|
||||
userRows,
|
||||
hotelName,
|
||||
neededRaw,
|
||||
rewardAmountRaw,
|
||||
currencySettings,
|
||||
alertRaw,
|
||||
recentRooms,
|
||||
badges,
|
||||
userSettingsRows,
|
||||
friendCountRows,
|
||||
unreadCountRows,
|
||||
referralsRows,
|
||||
friends,
|
||||
} = await loadUserDashboard(userId);
|
||||
|
||||
const user = userRows[0] ?? null;
|
||||
if (!user) throw new Error("user-not-found");
|
||||
|
||||
const userSettings = userSettingsRows[0] ?? null;
|
||||
const friendCount = Number(friendCountRows[0]?.value ?? 0);
|
||||
const unreadCount = Number(unreadCountRows[0]?.value ?? 0);
|
||||
|
||||
const needed = Number.parseInt(neededRaw ?? "5", 10) || 5;
|
||||
const rewardAmount = Number.parseInt(rewardAmountRaw ?? "30", 10) || 0;
|
||||
const rewardCurrency = (
|
||||
currencySettings.referral_reward_currency_type ??
|
||||
currencySettings.referral_reward_currency ??
|
||||
"diamonds"
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const referralTotal = referralsRows[0]
|
||||
? Number(referralsRows[0].referralsTotal)
|
||||
: 0;
|
||||
const canClaim = referralTotal >= needed;
|
||||
const remaining = Math.max(0, needed - referralTotal);
|
||||
|
||||
const onlineFriends = friends.filter((f) => f.online === "1");
|
||||
|
||||
const registered = new Date(user.accountCreated * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
const lastLoginDate = user.lastLogin
|
||||
? new Date(user.lastLogin * 1000).toISOString().slice(0, 10)
|
||||
: "Never";
|
||||
const lastOnlineDate = user.lastOnline
|
||||
? new Date(user.lastOnline * 1000).toISOString().slice(0, 10)
|
||||
: "Never";
|
||||
const referralLink = `/register?ref=${encodeURIComponent(user.username)}`;
|
||||
|
||||
const avatarFull = avatarImageUrl(user.look, {
|
||||
direction: 2,
|
||||
});
|
||||
|
||||
content = (
|
||||
<div className="flex flex-col gap-8 pb-8">
|
||||
{claimed ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
Woah! You have successfully claimed your reward — keep up the good
|
||||
work!
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{getErrorMessage(error)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Reveal>
|
||||
<SurfaceCard
|
||||
className="relative p-5 sm:p-6 flex flex-col sm:flex-row sm:items-start gap-5 sm:gap-6"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 12%, var(--color-surface)), var(--color-surface))`,
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute -top-10 -right-10 w-40 h-40 rounded-full opacity-10"
|
||||
style={{ background: "var(--color-primary)" }}
|
||||
/>
|
||||
<div className="relative shrink-0 flex flex-col items-center gap-3 mx-auto sm:mx-0">
|
||||
<div
|
||||
className="absolute -inset-3 rounded-full opacity-[0.15] blur-[40px]"
|
||||
style={{ background: "var(--color-primary)" }}
|
||||
/>
|
||||
<Image
|
||||
src={avatarFull}
|
||||
alt=""
|
||||
width={120}
|
||||
height={160}
|
||||
className="object-contain drop-shadow-xl relative"
|
||||
unoptimized
|
||||
priority
|
||||
/>
|
||||
<Link
|
||||
href="/client"
|
||||
className="btn-shine flex items-center justify-center gap-1.5 rounded-xl border py-2 px-4 font-extrabold text-xs transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg"
|
||||
style={{
|
||||
background: "var(--color-primary)",
|
||||
color: "var(--color-primary-foreground)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
||||
boxShadow:
|
||||
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
||||
}}
|
||||
data = await loadUserDashboard(userId);
|
||||
} catch {
|
||||
return (
|
||||
<main>
|
||||
<SurfaceCard className="p-6">
|
||||
<p role="alert">{t("loadError")}</p>
|
||||
<Link href="/me" className="btn btn-outline">
|
||||
{t("retry")}
|
||||
</Link>
|
||||
</SurfaceCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
const user = data.userRows[0];
|
||||
if (!user)
|
||||
return (
|
||||
<main>
|
||||
<SurfaceCard className="p-6">
|
||||
<p role="alert">{t("loadError")}</p>
|
||||
<Link href="/login" className="btn btn-outline">
|
||||
{t("login")}
|
||||
</Link>
|
||||
</SurfaceCard>
|
||||
</main>
|
||||
);
|
||||
const {
|
||||
hotelName,
|
||||
neededRaw,
|
||||
rewardAmountRaw,
|
||||
currencySettings,
|
||||
alertRaw,
|
||||
recentRooms,
|
||||
badges,
|
||||
userSettingsRows,
|
||||
friendCountRows,
|
||||
unreadCountRows,
|
||||
referralsRows,
|
||||
friends,
|
||||
currencyRows,
|
||||
} = data;
|
||||
const userSettings = userSettingsRows[0];
|
||||
const friendCount = Number(friendCountRows[0]?.value ?? 0);
|
||||
const unreadCount = Number(unreadCountRows[0]?.value ?? 0);
|
||||
const needed = Math.max(1, Number.parseInt(neededRaw ?? "5", 10) || 5);
|
||||
const rewardAmount = Number.parseInt(rewardAmountRaw ?? "30", 10) || 0;
|
||||
const rewardCurrency = (
|
||||
currencySettings.referral_reward_currency_type ??
|
||||
currencySettings.referral_reward_currency ??
|
||||
"diamonds"
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const rewardKind = ["credits", "duckets", "diamonds", "points"].includes(
|
||||
rewardCurrency,
|
||||
)
|
||||
? (rewardCurrency as CurrencyKind)
|
||||
: null;
|
||||
const referralTotal = Math.max(
|
||||
0,
|
||||
Number(referralsRows[0]?.referralsTotal ?? 0),
|
||||
);
|
||||
const canClaim = referralTotal >= needed;
|
||||
const remaining = Math.max(0, needed - referralTotal);
|
||||
const onlineFriends = friends.filter((friend) => friend.online === "1");
|
||||
const date = (value: number) =>
|
||||
value
|
||||
? format.dateTime(new Date(value * 1000), { dateStyle: "medium" })
|
||||
: t("never");
|
||||
const referralLink = `/register?ref=${encodeURIComponent(user.username)}`;
|
||||
const avatarFull = avatarImageUrl(user.look, { direction: 2 });
|
||||
const wallet = [
|
||||
{ kind: "credits", value: user.credits },
|
||||
{
|
||||
kind: "duckets",
|
||||
value:
|
||||
currencyRows === null
|
||||
? null
|
||||
: (currencyRows.find((row) => row.type === 0)?.amount ?? 0),
|
||||
},
|
||||
{
|
||||
kind: "diamonds",
|
||||
value:
|
||||
currencyRows === null
|
||||
? null
|
||||
: (currencyRows.find((row) => row.type === 5)?.amount ?? 0),
|
||||
},
|
||||
] as const;
|
||||
const errorKey =
|
||||
error &&
|
||||
["not_enough", "no_referrals", "bad_config", "ratelimit"].includes(error)
|
||||
? error
|
||||
: "error";
|
||||
return (
|
||||
<main className={styles.dashboard}>
|
||||
{claimed && (
|
||||
<p role="status" className={styles.feedback}>
|
||||
{t("claimed")}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className={styles.feedback}>
|
||||
{t(`errors.${errorKey}`)}
|
||||
</p>
|
||||
)}
|
||||
<SurfaceCard className={styles.hero}>
|
||||
<ProfileImage
|
||||
src={avatarFull}
|
||||
alt={user.username}
|
||||
width={100}
|
||||
height={140}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
<div className={styles.identity}>
|
||||
<p className="muted">{t("welcome", { hotel: hotelName })}</p>
|
||||
<h1>{user.username}</h1>
|
||||
<p>{user.motto || t("noMotto")}</p>
|
||||
<p className={styles.dates}>
|
||||
{t("registered", { date: date(user.accountCreated) })} ·{" "}
|
||||
{t("lastOnline", { date: date(user.lastOnline) })}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/client" className="btn btn-primary">
|
||||
{t("enter", { hotel: hotelName })} →
|
||||
</Link>
|
||||
</SurfaceCard>
|
||||
<nav aria-label={t("quickLinks")} className={styles.shortcuts}>
|
||||
{[
|
||||
{ href: "/messages", label: t("messages"), count: unreadCount },
|
||||
{
|
||||
href: `/u/${encodeURIComponent(user.username)}`,
|
||||
label: t("profile"),
|
||||
count: 0,
|
||||
},
|
||||
{ href: "/settings", label: t("settings"), count: 0 },
|
||||
{ href: "/shop", label: t("shop"), count: 0 },
|
||||
].map((link) => (
|
||||
<Link key={link.href} href={link.href} className={styles.shortcut}>
|
||||
{link.label}
|
||||
{link.count > 0 && (
|
||||
<span
|
||||
className={styles.count}
|
||||
title={t("unread", { count: link.count })}
|
||||
>
|
||||
<Image
|
||||
src="/assets/images/EnterHubbly.png"
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
unoptimized
|
||||
/>
|
||||
Enter {hotelName}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 sm:pt-1 text-center sm:text-left">
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider mb-3 border"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 18%, transparent)",
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
}}
|
||||
>
|
||||
Rank {user.rank}
|
||||
</div>
|
||||
<h1
|
||||
className="text-2xl md:text-3xl font-black tracking-tight"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
{user.username}
|
||||
</h1>
|
||||
<p
|
||||
className="text-sm mt-1"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{user.motto || "No motto"}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1 mt-4 text-xs">
|
||||
<span style={{ color: "var(--color-text-muted)" }}>
|
||||
Registered:{" "}
|
||||
<strong style={{ color: "var(--color-text-readable)" }}>
|
||||
{registered}
|
||||
</strong>
|
||||
</span>
|
||||
<span style={{ color: "var(--color-text-muted)" }}>
|
||||
Last online:{" "}
|
||||
<strong style={{ color: "var(--color-text-readable)" }}>
|
||||
{lastOnlineDate}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
{link.count > 99 ? "99+" : link.count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
{alertRaw && <p className={styles.feedback}>{alertRaw}</p>}
|
||||
<section aria-label={t("wallet")} className={styles.wallet}>
|
||||
{wallet.map((currency) => (
|
||||
<SurfaceCard key={currency.kind} className={styles.currency}>
|
||||
<CurrencyIcon kind={currency.kind} size={28} alt="" />
|
||||
<div>
|
||||
<strong>
|
||||
{currency.value === null ? "—" : format.number(currency.value)}
|
||||
</strong>
|
||||
<span>{t(`currencies.${currency.kind}`)}</span>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</Reveal>
|
||||
|
||||
{alertRaw ? (
|
||||
<Reveal delay={0.05}>
|
||||
<SurfaceCard
|
||||
className="px-6 py-4 text-center"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-accent) 12%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-accent) 25%, transparent)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-sm font-bold"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
{alertRaw}
|
||||
</p>
|
||||
</SurfaceCard>
|
||||
</Reveal>
|
||||
) : null}
|
||||
|
||||
<Reveal delay={0.1}>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ value: user.credits.toLocaleString(), label: "Credits" },
|
||||
{ value: `Rank ${user.rank}`, label: "Hotel rank" },
|
||||
{
|
||||
value: (userSettings?.achievementScore ?? 0).toLocaleString(),
|
||||
label: "Achievement score",
|
||||
},
|
||||
{
|
||||
value: (userSettings?.respectsReceived ?? 0).toLocaleString(),
|
||||
label: "Respects",
|
||||
},
|
||||
{ value: friendCount.toLocaleString(), label: "Friends" },
|
||||
{ value: lastLoginDate, label: "Last login" },
|
||||
].map((s) => (
|
||||
<SurfaceCard
|
||||
key={s.label}
|
||||
className="relative p-5 text-center transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
>
|
||||
<div
|
||||
className="text-xl md:text-2xl font-black tracking-tight mb-0.5"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
{s.value}
|
||||
</div>
|
||||
<div
|
||||
className="text-[11px] font-bold uppercase tracking-widest"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{s.label}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.15}>
|
||||
<SurfaceCard className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2
|
||||
className="text-lg font-black tracking-tight"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
Online friends
|
||||
</h2>
|
||||
<p
|
||||
className="text-xs mt-0.5"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{onlineFriends.length === 0
|
||||
? "None of your friends are online right now"
|
||||
: `${onlineFriends.length} of your ${friends.length} friends online`}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/friends"
|
||||
className="text-xs font-bold hover:underline shrink-0"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<div className={styles.columns}>
|
||||
<div className={styles.stack}>
|
||||
<SurfaceCard
|
||||
title={t("onlineFriends")}
|
||||
actionHref="/friends"
|
||||
actionLabel={t("allFriends")}
|
||||
bodyClassName="p-5"
|
||||
>
|
||||
{onlineFriends.length === 0 ? (
|
||||
<div className="text-center py-6">
|
||||
<p
|
||||
className="text-sm"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
No friends online right now. Check back later!
|
||||
</p>
|
||||
</div>
|
||||
<p className="muted">{t("noOnlineFriends")}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<div className={styles.friends}>
|
||||
{onlineFriends.map((friend) => (
|
||||
<Link
|
||||
key={friend.id}
|
||||
href={`/u/${friend.username}`}
|
||||
className="flex items-center gap-3 p-3 rounded-xl transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
href={`/u/${encodeURIComponent(friend.username)}`}
|
||||
className={styles.friend}
|
||||
>
|
||||
<UserAvatarThumbnail
|
||||
figure={friend.look}
|
||||
alt=""
|
||||
className="rounded-lg shrink-0"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className="text-sm font-extrabold truncate"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
{friend.username}
|
||||
</p>
|
||||
<p
|
||||
className="text-[10px] font-semibold truncate mt-0.5"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{friend.motto || "No motto"}
|
||||
</p>
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[10px] font-bold mt-1"
|
||||
style={{ color: "var(--color-success)" }}
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ background: "var(--color-success)" }}
|
||||
/>{" "}
|
||||
Online
|
||||
<UserAvatarThumbnail figure={friend.look} alt="" />
|
||||
<div>
|
||||
<strong>{friend.username}</strong>
|
||||
<span className="muted">
|
||||
{friend.motto || t("noMotto")}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -369,247 +229,128 @@ export default async function MePage({
|
||||
</div>
|
||||
)}
|
||||
</SurfaceCard>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.2}>
|
||||
<SurfaceCard className="p-5">
|
||||
<div className="mb-4">
|
||||
<h2
|
||||
className="text-lg font-black tracking-tight"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
Refer a friend ({referralTotal}/{needed})
|
||||
</h2>
|
||||
<p
|
||||
className="text-xs mt-0.5"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Refer new users and earn in-game rewards
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p
|
||||
className="text-sm"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{rewardAmount > 0
|
||||
? `For every ${needed} users who register through your referral link you can claim a reward of ${rewardAmount.toLocaleString()} ${rewardCurrency}!`
|
||||
: `Invite ${needed} users through your referral link to claim a reward!`}
|
||||
</p>
|
||||
<div role="note" style={feedbackStyle("warning")}>
|
||||
Boosting referrals by making your own accounts will lead to
|
||||
removal of all progress, currency, inventory and a potential
|
||||
ban.
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="referral-link"
|
||||
className="text-xs font-bold mb-1.5 block"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Your referral link
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input
|
||||
id="referral-link"
|
||||
type="text"
|
||||
readOnly
|
||||
defaultValue={referralLink}
|
||||
className="input-glow flex-1 min-w-0 rounded-xl border-2 px-4 py-2.5 text-sm font-medium"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
/>
|
||||
<CopyReferralButton value={referralLink} />
|
||||
</div>
|
||||
</div>
|
||||
{canClaim ? (
|
||||
<form action={claimReferral}>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-shine w-full rounded-xl font-extrabold text-sm py-3.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg"
|
||||
style={{
|
||||
background: "var(--color-primary)",
|
||||
color: "var(--color-primary-foreground)",
|
||||
boxShadow:
|
||||
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
||||
}}
|
||||
>
|
||||
Claim your referral reward!
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="w-full rounded-xl font-extrabold text-sm py-3.5 opacity-60 cursor-not-allowed"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{`Refer ${remaining} more ${remaining === 1 ? "user" : "users"} to unlock your reward`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</Reveal>
|
||||
|
||||
{recentRooms.length > 0 && (
|
||||
<Reveal delay={0.25}>
|
||||
<SurfaceCard className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2
|
||||
className="text-lg font-black tracking-tight"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
Recent rooms
|
||||
</h2>
|
||||
<p
|
||||
className="text-xs mt-0.5"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Newest rooms in the hotel
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/community"
|
||||
className="text-xs font-bold hover:underline shrink-0"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<SurfaceCard
|
||||
title={t("recentRooms")}
|
||||
actionHref="/community"
|
||||
actionLabel={t("community")}
|
||||
bodyClassName="p-5"
|
||||
>
|
||||
<p className="muted">{t("roomsHint")}</p>
|
||||
{recentRooms.length === 0 ? (
|
||||
<p className="muted">{t("noRooms")}</p>
|
||||
) : (
|
||||
<div className={styles.rooms}>
|
||||
{recentRooms.map((room) => (
|
||||
<Link
|
||||
key={room.id}
|
||||
href={`/room/${room.id}`}
|
||||
className="block rounded-xl border p-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
||||
}}
|
||||
className={styles.room}
|
||||
>
|
||||
<p
|
||||
className="text-sm font-extrabold truncate"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
{room.name}
|
||||
</p>
|
||||
<p
|
||||
className="text-[10px] font-semibold mt-0.5"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{room.ownerName} · {room.users}/{room.usersMax}
|
||||
</p>
|
||||
<strong>{room.name}</strong>
|
||||
<span className="muted">
|
||||
{room.ownerName} · {room.users}/{room.usersMax}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
{badges.length > 0 && (
|
||||
<Reveal delay={0.3}>
|
||||
<SurfaceCard className="p-5">
|
||||
<div className="mb-4">
|
||||
<h2
|
||||
className="text-lg font-black tracking-tight"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
Badges
|
||||
</h2>
|
||||
<p
|
||||
className="text-xs mt-0.5"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{badges.length} badge{badges.length !== 1 ? "s" : ""} equipped
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
)}
|
||||
</SurfaceCard>
|
||||
<SurfaceCard title={t("activity")} bodyClassName="p-5">
|
||||
<dl className={styles.stats}>
|
||||
{[
|
||||
{
|
||||
label: t("achievements"),
|
||||
value: format.number(userSettings?.achievementScore ?? 0),
|
||||
},
|
||||
{
|
||||
label: t("respects"),
|
||||
value: format.number(userSettings?.respectsReceived ?? 0),
|
||||
},
|
||||
{ label: t("friends"), value: format.number(friendCount) },
|
||||
{ label: t("lastLogin"), value: date(user.lastLogin) },
|
||||
].map((stat) => (
|
||||
<div key={stat.label}>
|
||||
<dt className="muted">{stat.label}</dt>
|
||||
<dd>{stat.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</SurfaceCard>
|
||||
</div>
|
||||
<aside className={styles.stack}>
|
||||
<SurfaceCard title={t("referrals")} bodyClassName="p-5">
|
||||
<p>{t("referralHint")}</p>
|
||||
<div className={styles.progressLabel}>
|
||||
<strong>
|
||||
{referralTotal}/{needed}
|
||||
</strong>
|
||||
<span className="muted">{t("referralProgress")}</span>
|
||||
</div>
|
||||
<progress
|
||||
className={styles.progress}
|
||||
value={Math.min(referralTotal, needed)}
|
||||
max={needed}
|
||||
aria-label={t("referralProgress")}
|
||||
/>
|
||||
<p className={styles.reward}>
|
||||
{rewardKind && (
|
||||
<CurrencyIcon kind={rewardKind} size={20} alt="" />
|
||||
)}
|
||||
{rewardAmount > 0
|
||||
? t("reward", {
|
||||
amount: format.number(rewardAmount),
|
||||
currency: t.has(`currencies.${rewardCurrency}`)
|
||||
? t(`currencies.${rewardCurrency}`)
|
||||
: rewardCurrency,
|
||||
count: needed,
|
||||
})
|
||||
: t("rewardHint", { count: needed })}
|
||||
</p>
|
||||
<label htmlFor="referral-link">{t("referralLink")}</label>
|
||||
<input
|
||||
id="referral-link"
|
||||
value={referralLink}
|
||||
readOnly
|
||||
className={styles.referralInput}
|
||||
/>
|
||||
<CopyReferralButton value={referralLink} />
|
||||
{canClaim ? (
|
||||
<form action={claimReferral} className={styles.claim}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("claim")}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="muted" role="status">
|
||||
{t("remaining", { count: remaining })}
|
||||
</p>
|
||||
)}
|
||||
<details className={styles.rules}>
|
||||
<summary>{t("rules")}</summary>
|
||||
<p>{t("rulesText")}</p>
|
||||
</details>
|
||||
</SurfaceCard>
|
||||
<SurfaceCard title={t("badges")} bodyClassName="p-5">
|
||||
{badges.length === 0 ? (
|
||||
<p className="muted">{t("noBadges")}</p>
|
||||
) : (
|
||||
<div className={styles.badges}>
|
||||
{badges.map((badge) => (
|
||||
<Image
|
||||
<img
|
||||
key={badge.badgeCode}
|
||||
src={`${BADGE_IMG_BASE}/${badge.badgeCode}.gif`}
|
||||
src={`https://images.habbo.com/c_images/album1584/${encodeURIComponent(badge.badgeCode)}.gif`}
|
||||
alt={badge.badgeCode}
|
||||
title={badge.badgeCode}
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded-md transition-transform hover:scale-110"
|
||||
unoptimized
|
||||
loading="lazy"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
<Reveal delay={0.35}>
|
||||
<SurfaceCard className="p-5">
|
||||
<h2
|
||||
className="text-lg font-black tracking-tight mb-4"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
Quick links
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ href: "/messages", label: "Messages", badge: unreadCount },
|
||||
{ href: `/u/${user.username}`, label: "My profile", badge: 0 },
|
||||
{ href: "/settings", label: "Settings", badge: 0 },
|
||||
{ href: "/shop", label: "Shop", badge: 0 },
|
||||
].map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="relative flex items-center justify-center rounded-xl border p-3 text-sm font-bold transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 6%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 18%, transparent)",
|
||||
color: "var(--color-text-readable)",
|
||||
}}
|
||||
>
|
||||
{link.label}
|
||||
{link.badge > 0 && (
|
||||
<span
|
||||
className="absolute -top-1.5 -right-1.5 flex min-w-[18px] h-[18px] items-center justify-center rounded-full px-1 text-[10px] font-bold leading-none"
|
||||
style={{
|
||||
background: "var(--color-danger)",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{link.badge > 99 ? "99+" : link.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SurfaceCard>
|
||||
</Reveal>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
content = (
|
||||
<SurfaceCard className="p-6 text-center">
|
||||
<p
|
||||
className="text-sm font-bold"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
We couldn't load your dashboard right now. Please try again
|
||||
shortly.
|
||||
</p>
|
||||
</SurfaceCard>
|
||||
);
|
||||
}
|
||||
|
||||
return <main>{content}</main>;
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,5 @@
|
||||
export default function MessagesLoading() {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
|
||||
>
|
||||
<div
|
||||
className="animate-spin w-8 h-8 border-4 rounded-full"
|
||||
style={{
|
||||
borderColor: "var(--color-primary)",
|
||||
borderTopColor: "transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import { toggleReaction } from "@/actions/article-reactions";
|
||||
import Link from "@/components/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
||||
import { articleSlug } from "@/lib/article-input";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { cachedQuery } from "@/lib/cached-db";
|
||||
import {
|
||||
db,
|
||||
User,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { sanitize } from "@/lib/sanitize";
|
||||
import { cacheNews } from "@/lib/services/news-cache";
|
||||
|
||||
type SearchParams = Promise<{
|
||||
comment?: string;
|
||||
@@ -46,10 +47,10 @@ function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
params: Promise<{ slug: string[] }>;
|
||||
}): Promise<Metadata> {
|
||||
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
|
||||
const { slug } = await params;
|
||||
const slug = articleSlug((await params).slug);
|
||||
const [article] = await db
|
||||
.select({
|
||||
title: WebsiteArticles.title,
|
||||
@@ -95,44 +96,40 @@ export default async function ArticlePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
params: Promise<{ slug: string[] }>;
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const slug = articleSlug((await params).slug);
|
||||
const { comment, reaction, error } = await searchParams;
|
||||
const t = await getTranslations("pages.article");
|
||||
|
||||
const article = await cachedQuery(
|
||||
`article:slug:${slug}`,
|
||||
async () => {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: WebsiteArticles.id,
|
||||
slug: WebsiteArticles.slug,
|
||||
title: WebsiteArticles.title,
|
||||
shortStory: WebsiteArticles.shortStory,
|
||||
fullStory: WebsiteArticles.fullStory,
|
||||
image: WebsiteArticles.image,
|
||||
createdAt: WebsiteArticles.createdAt,
|
||||
updatedAt: WebsiteArticles.updatedAt,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteArticles.slug, slug),
|
||||
eq(WebsiteArticles.status, "published"),
|
||||
or(
|
||||
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
||||
),
|
||||
const article = await cacheNews(`article:slug:${slug}`, 60_000, async () => {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: WebsiteArticles.id,
|
||||
slug: WebsiteArticles.slug,
|
||||
title: WebsiteArticles.title,
|
||||
shortStory: WebsiteArticles.shortStory,
|
||||
fullStory: WebsiteArticles.fullStory,
|
||||
image: WebsiteArticles.image,
|
||||
createdAt: WebsiteArticles.createdAt,
|
||||
updatedAt: WebsiteArticles.updatedAt,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteArticles.slug, slug),
|
||||
eq(WebsiteArticles.status, "published"),
|
||||
or(
|
||||
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.catch(() => []);
|
||||
return row ?? null;
|
||||
},
|
||||
60,
|
||||
);
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.catch(() => []);
|
||||
return row ?? null;
|
||||
});
|
||||
if (!article) notFound();
|
||||
|
||||
const articleId = article.id;
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
export default function NewsLoading() {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
|
||||
>
|
||||
<div
|
||||
className="animate-spin w-8 h-8 border-4 rounded-full"
|
||||
style={{
|
||||
borderColor: "var(--color-primary)",
|
||||
borderTopColor: "transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -1,15 +1,5 @@
|
||||
export default function ShopLoading() {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
|
||||
>
|
||||
<div
|
||||
className="animate-spin w-8 h-8 border-4 rounded-full"
|
||||
style={{
|
||||
borderColor: "var(--color-primary)",
|
||||
borderTopColor: "transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -2,9 +2,12 @@ import { asc, desc, eq, gte } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { ProfileImage } from "@/components/shared/profile-image";
|
||||
import { SurfaceCard } from "@/components/surface-card";
|
||||
import { db, User, WebsiteTeams } from "@/lib/db";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import styles from "./staff.module.css";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.staff");
|
||||
@@ -52,7 +55,7 @@ export default async function StaffPage() {
|
||||
.where(gte(User.rank, minStaffRank))
|
||||
.orderBy(desc(User.rank), asc(User.username))
|
||||
.limit(100)
|
||||
.catch((): StaffMember[] => []),
|
||||
.catch((): StaffMember[] | null => null),
|
||||
db
|
||||
.select({
|
||||
id: WebsiteTeams.id,
|
||||
@@ -69,71 +72,92 @@ export default async function StaffPage() {
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<ContentCard icon="🛡️" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
{teams.length > 0 ? (
|
||||
<ContentCard
|
||||
icon="⭐"
|
||||
title={t("ranksTitle")}
|
||||
subtitle={t("ranksSubtitle")}
|
||||
>
|
||||
<div className="card-grid sm-2 lg-3">
|
||||
{teams.map((team) => (
|
||||
<div key={team.id} className="card">
|
||||
<h3 style={{ margin: "0 0 0.25rem", color: team.staffColor }}>
|
||||
{team.rankName}
|
||||
</h3>
|
||||
{team.jobDescription ? (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{team.jobDescription}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ContentCard>
|
||||
) : null}
|
||||
|
||||
<ContentCard
|
||||
icon="👥"
|
||||
title={t("membersTitle")}
|
||||
subtitle={
|
||||
staff.length === 0
|
||||
? t("membersEmptySubtitle")
|
||||
: t("membersSubtitle", { count: staff.length })
|
||||
}
|
||||
padded={staff.length === 0}
|
||||
>
|
||||
{staff.length === 0 ? (
|
||||
<EmptyState icon="🛡️">{t("emptyState")}</EmptyState>
|
||||
) : (
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colUsername")}</th>
|
||||
<th>{t("colRank")}</th>
|
||||
<th>{t("colMotto")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{staff.map((u) => (
|
||||
<tr key={u.username}>
|
||||
<td>
|
||||
<Link href={`/u/${u.username}`}>{u.username}</Link>
|
||||
</td>
|
||||
<td>{u.rank}</td>
|
||||
<td className="muted table-cell-truncate">
|
||||
{u.motto || "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<main className={styles.page}>
|
||||
<SurfaceCard className={styles.hero}>
|
||||
<div>
|
||||
<h1>{t("title")}</h1>
|
||||
<p>{t("subtitle")}</p>
|
||||
</div>
|
||||
{staff && staff.length > 0 && (
|
||||
<span className={styles.count}>
|
||||
{t("membersSubtitle", { count: staff.length })}
|
||||
</span>
|
||||
)}
|
||||
</ContentCard>
|
||||
</SurfaceCard>
|
||||
<section aria-labelledby="staff-members">
|
||||
<h2 id="staff-members" className={styles.heading}>
|
||||
{t("membersTitle")}
|
||||
</h2>
|
||||
{staff === null ? (
|
||||
<SurfaceCard className={styles.notice}>
|
||||
<p role="alert">{t("loadError")}</p>
|
||||
<Link href="/staff" className="btn btn-outline">
|
||||
{t("retry")}
|
||||
</Link>
|
||||
</SurfaceCard>
|
||||
) : staff.length === 0 ? (
|
||||
<SurfaceCard className={styles.notice}>
|
||||
<p>{t("emptyState")}</p>
|
||||
</SurfaceCard>
|
||||
) : (
|
||||
<ul className={styles.grid}>
|
||||
{staff.map((member) => (
|
||||
<li key={member.username}>
|
||||
<Link
|
||||
href={`/u/${encodeURIComponent(member.username)}`}
|
||||
className={styles.member}
|
||||
>
|
||||
<ProfileImage
|
||||
src={avatarImageUrl(member.look, {
|
||||
size: "m",
|
||||
direction: 2,
|
||||
headDirection: 3,
|
||||
})}
|
||||
alt={member.username}
|
||||
width={64}
|
||||
height={110}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
<div className={styles.identity}>
|
||||
<h3>{member.username}</h3>
|
||||
<p>{member.motto || t("noMotto")}</p>
|
||||
<span className={styles.profile}>
|
||||
{t("viewProfile")} <span aria-hidden="true">→</span>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
{teams.length > 0 && (
|
||||
<section aria-labelledby="staff-teams">
|
||||
<h2 id="staff-teams" className={styles.heading}>
|
||||
{t("ranksTitle")}
|
||||
</h2>
|
||||
<p className={styles.subtitle}>{t("ranksSubtitle")}</p>
|
||||
<ul className={styles.grid}>
|
||||
{teams.map((team) => (
|
||||
<li key={team.id}>
|
||||
<SurfaceCard className={styles.team}>
|
||||
<span
|
||||
className={styles.accent}
|
||||
style={{
|
||||
backgroundColor: /^#[0-9a-f]{6}$/i.test(team.staffColor)
|
||||
? team.staffColor
|
||||
: "var(--color-primary)",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3>{team.rankName}</h3>
|
||||
{team.jobDescription && <p>{team.jobDescription}</p>}
|
||||
</SurfaceCard>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 28px;
|
||||
min-width: 0;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
padding: 28px;
|
||||
background: linear-gradient(
|
||||
130deg,
|
||||
color-mix(in srgb, var(--color-primary) 10%, var(--color-surface)),
|
||||
var(--color-surface)
|
||||
);
|
||||
}
|
||||
.hero h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: clamp(1.6rem, 3vw, 2rem);
|
||||
}
|
||||
.hero p,
|
||||
.subtitle {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
.count {
|
||||
font-size: 0.85rem;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-readable);
|
||||
}
|
||||
.heading {
|
||||
font-size: 1.15rem;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.subtitle {
|
||||
margin: -6px 0 18px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.grid li {
|
||||
min-width: 0;
|
||||
}
|
||||
.member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 16px;
|
||||
color: var(--color-text-readable);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
.member:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 4px 16px
|
||||
color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
}
|
||||
.member:focus-visible {
|
||||
outline: 3px solid var(--color-primary);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
.avatar {
|
||||
flex-shrink: 0;
|
||||
image-rendering: pixelated;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
}
|
||||
.identity {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.identity h3,
|
||||
.team h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.identity p,
|
||||
.team p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: pre-line;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.profile {
|
||||
display: inline-block;
|
||||
margin-top: 16px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.team {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
padding: 22px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.accent {
|
||||
display: block;
|
||||
width: 32px;
|
||||
height: 4px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.notice {
|
||||
padding: 24px;
|
||||
}
|
||||
.notice p {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.hero {
|
||||
padding: 22px;
|
||||
}
|
||||
.member {
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.member {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,9 @@ import type { CSSProperties } from "react";
|
||||
import { postGuestbook } from "@/actions/guestbook";
|
||||
import { sendFriendRequest } from "@/actions/social";
|
||||
import Link from "@/components/link";
|
||||
import {
|
||||
ContentCard,
|
||||
EmptyState,
|
||||
OnlineBadge,
|
||||
StatBlock,
|
||||
} from "@/components/public/ui";
|
||||
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
|
||||
import { CurrencyIcon } from "@/components/shared/currency-icon";
|
||||
import { ProfileImage } from "@/components/shared/profile-image";
|
||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { auth } from "@/lib/auth";
|
||||
import {
|
||||
@@ -23,10 +20,12 @@ import {
|
||||
User,
|
||||
UsersBadges,
|
||||
UsersCurrency,
|
||||
WebsiteBadges,
|
||||
WebsiteUserGuestbooks,
|
||||
} from "@/lib/db";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import styles from "./profile.module.css";
|
||||
|
||||
type SearchParams = Promise<{
|
||||
friend?: string;
|
||||
@@ -94,7 +93,6 @@ export default async function ProfilePage({
|
||||
username: User.username,
|
||||
motto: User.motto,
|
||||
look: User.look,
|
||||
rank: User.rank,
|
||||
credits: User.credits,
|
||||
online: User.online,
|
||||
accountCreated: User.accountCreated,
|
||||
@@ -261,32 +259,26 @@ export default async function ProfilePage({
|
||||
const wallet = [
|
||||
{
|
||||
key: "credits",
|
||||
icon: "💰",
|
||||
label: t("statCredits"),
|
||||
value: user.credits,
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
},
|
||||
{
|
||||
key: "duckets",
|
||||
icon: "🪙",
|
||||
label: t("statDuckets"),
|
||||
value: ducketsAmount,
|
||||
color: "var(--color-accent-readable, var(--color-accent))",
|
||||
},
|
||||
{
|
||||
key: "diamonds",
|
||||
icon: "💎",
|
||||
label: t("statDiamonds"),
|
||||
value: diamondsAmount,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
},
|
||||
];
|
||||
] as const;
|
||||
|
||||
// Resolve friend usernames/looks and the guestbook author usernames/looks in
|
||||
// two parallel queries (the ids come from the queries above).
|
||||
// parallel queries (the ids come from the queries above).
|
||||
const friendIds = Array.from(new Set(friendships.map((f) => f.userTwoId)));
|
||||
const authorIds = Array.from(new Set(guestbook.map((g) => g.userId)));
|
||||
const [friendUsers, authors] = await Promise.all([
|
||||
const [friendUsers, authors, badgeDetails] = await Promise.all([
|
||||
friendIds.length
|
||||
? db
|
||||
.select({
|
||||
@@ -309,15 +301,28 @@ export default async function ProfilePage({
|
||||
.where(inArray(User.id, authorIds))
|
||||
.catch(() => [])
|
||||
: [],
|
||||
badges.length
|
||||
? db
|
||||
.select()
|
||||
.from(WebsiteBadges)
|
||||
.where(
|
||||
inArray(
|
||||
WebsiteBadges.badgeKey,
|
||||
badges.map((b) => b.badgeCode),
|
||||
),
|
||||
)
|
||||
.catch(() => [])
|
||||
: [],
|
||||
]);
|
||||
const friendById = new Map(friendUsers.map((f) => [f.id, f]));
|
||||
const friends = friendships
|
||||
.map((f) => friendById.get(f.userTwoId))
|
||||
.filter((f): f is NonNullable<typeof f> => Boolean(f));
|
||||
const authorById = new Map(authors.map((a) => [a.id, a]));
|
||||
const badgeByCode = new Map(badgeDetails.map((b) => [b.badgeKey, b]));
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<main className={`page-grid ${styles.profile}`}>
|
||||
{friend === "sent" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.sent")}
|
||||
@@ -335,7 +340,7 @@ export default async function ProfilePage({
|
||||
) : null}
|
||||
|
||||
{/* ── Profile header ──────────────────── */}
|
||||
<ContentCard padded={false}>
|
||||
<ContentCard padded={false} className={styles.hero}>
|
||||
<div
|
||||
className="content-card-body"
|
||||
style={{
|
||||
@@ -346,18 +351,24 @@ export default async function ProfilePage({
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
<ProfileImage
|
||||
src={avatar}
|
||||
alt={`${user.username} avatar`}
|
||||
alt={user.username}
|
||||
width={100}
|
||||
height={150}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className={styles.identity}>
|
||||
<h1 style={{ margin: "0 0 0.25rem" }}>{user.username}</h1>
|
||||
<p style={{ margin: "0 0 0.6rem" }}>
|
||||
{user.motto || <span className="muted">{t("noMotto")}</span>}
|
||||
</p>
|
||||
<OnlineBadge online={user.online === "1"} />
|
||||
<div className={styles.meta}>
|
||||
<OnlineBadge online={user.online === "1"} />
|
||||
<span>
|
||||
{t("statRegistered")}: {registered}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isLoggedIn && !isSelf ? (
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
@@ -385,330 +396,291 @@ export default async function ProfilePage({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="stat-grid" style={{ padding: "0 1.1rem 1.1rem" }}>
|
||||
<StatBlock icon="🏅" value={user.rank} label={t("statRank")} />
|
||||
<StatBlock
|
||||
icon="💰"
|
||||
value={user.credits.toLocaleString()}
|
||||
label={t("statCredits")}
|
||||
/>
|
||||
<StatBlock icon="📅" value={registered} label={t("statRegistered")} />
|
||||
</div>
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Wallet / currencies ─────────────── */}
|
||||
<ContentCard icon="💰" title={t("walletTitle")}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{wallet.map((w) => (
|
||||
<div
|
||||
key={w.key}
|
||||
className="card"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
borderLeft: `4px solid ${w.color}`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
fontSize: "1.6rem",
|
||||
lineHeight: 1,
|
||||
display: "flex",
|
||||
width: "2.4rem",
|
||||
height: "2.4rem",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "10px",
|
||||
background: `color-mix(in srgb, ${w.color} 16%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{w.icon}
|
||||
</span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "1.35rem",
|
||||
fontWeight: 700,
|
||||
color: w.color,
|
||||
}}
|
||||
>
|
||||
{w.value.toLocaleString()}
|
||||
</div>
|
||||
<div className="muted" style={{ fontSize: "0.85rem" }}>
|
||||
{w.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Friends ──────────────────────── */}
|
||||
<ContentCard
|
||||
icon="👥"
|
||||
title={t("friendsTitle")}
|
||||
subtitle={t("friendsSubtitle", { count: friends.length })}
|
||||
padded={friends.length === 0}
|
||||
>
|
||||
{friends.length === 0 ? (
|
||||
<EmptyState icon="👥">{t("friendsEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(72px, 1fr))",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{friends.map((f) => (
|
||||
<Link
|
||||
key={f.id}
|
||||
href={`/u/${encodeURIComponent(f.username)}`}
|
||||
className="card"
|
||||
title={f.username}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "0.35rem",
|
||||
padding: "0.6rem 0.4rem",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<UserAvatarThumbnail figure={f.look} alt={f.username} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 600,
|
||||
maxWidth: "100%",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{f.username}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Rooms ────────────────────────── */}
|
||||
<ContentCard
|
||||
icon="🏠"
|
||||
title={t("roomsTitle")}
|
||||
subtitle={t("roomsSubtitle", { username: user.username })}
|
||||
padded={rooms.length === 0}
|
||||
>
|
||||
{rooms.length === 0 ? (
|
||||
<EmptyState icon="🏠">{t("roomsEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{rooms.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="card"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.4rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<span aria-hidden style={{ fontSize: "1.2rem" }}>
|
||||
{"🏠"}
|
||||
</span>
|
||||
<strong
|
||||
style={{
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{r.name || t("roomUntitled")}
|
||||
</strong>
|
||||
</div>
|
||||
<div
|
||||
className="muted"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{"👤"} {r.users}/{r.usersMax}
|
||||
</span>
|
||||
<span style={{ textTransform: "capitalize" }}>{r.state}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Badges ────────────────────── */}
|
||||
<ContentCard
|
||||
icon="🏅"
|
||||
title={t("badgesTitle")}
|
||||
padded={badges.length === 0}
|
||||
>
|
||||
{badges.length === 0 ? (
|
||||
<EmptyState icon="🏅">{t("badgesEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.75rem",
|
||||
alignItems: "center",
|
||||
padding: "1rem",
|
||||
}}
|
||||
>
|
||||
{badges.map((b) => (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
key={b.id}
|
||||
src={`${BADGE_IMG_BASE}/${b.badgeCode}.gif`}
|
||||
alt={b.badgeCode}
|
||||
title={b.badgeCode}
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Photos ────────────────────── */}
|
||||
<ContentCard
|
||||
icon="📸"
|
||||
title={t("photosTitle")}
|
||||
padded={photos.length === 0}
|
||||
>
|
||||
{photos.length === 0 ? (
|
||||
<EmptyState icon="📸">{t("photosEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{photos.map((p) => (
|
||||
<div key={p.id} className="card" style={{ padding: "0.5rem" }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={p.url}
|
||||
alt={`by ${user.username}`}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
display: "block",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
|
||||
{formatDate(new Date(p.timestamp * 1000), "date")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Guestbook ───────────────────── */}
|
||||
<ContentCard
|
||||
icon="📝"
|
||||
title={t("guestbookTitle")}
|
||||
subtitle={t("guestbookSubtitle", { username: user.username })}
|
||||
>
|
||||
{isLoggedIn ? (
|
||||
<form
|
||||
action={postGuestbook}
|
||||
className="card"
|
||||
style={{ marginBottom: "1rem" }}
|
||||
>
|
||||
{/* Profile owner id drives the write target; the author is taken from
|
||||
the session inside the action, never from this form. */}
|
||||
<input type="hidden" name="profileId" value={String(user.id)} />
|
||||
<input type="hidden" name="username" value={user.username} />
|
||||
<label htmlFor="gb-message" className="muted">
|
||||
{t("leaveMessage")}
|
||||
</label>
|
||||
<textarea
|
||||
id="gb-message"
|
||||
name="message"
|
||||
required
|
||||
maxLength={255}
|
||||
rows={3}
|
||||
placeholder={t("guestbookPlaceholder", {
|
||||
username: user.username,
|
||||
})}
|
||||
style={{
|
||||
width: "100%",
|
||||
margin: "0.4rem 0 0.75rem",
|
||||
resize: "vertical",
|
||||
}}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("post")}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="muted">{t("loginToPost")}</p>
|
||||
)}
|
||||
|
||||
{guestbook.length === 0 ? (
|
||||
<EmptyState icon="📝">{t("guestbookEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div className="grid" style={{ gap: "0.75rem" }}>
|
||||
{guestbook.map((g) => {
|
||||
const author = authorById.get(g.userId);
|
||||
return (
|
||||
<div
|
||||
key={String(g.id)}
|
||||
className="card"
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
{author ? (
|
||||
<UserAvatarThumbnail
|
||||
figure={author.look}
|
||||
alt={author?.username ?? "author"}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ margin: "0 0 0.25rem" }}>
|
||||
<strong>
|
||||
{author?.username ?? t("unknownUser", { id: g.userId })}
|
||||
</strong>{" "}
|
||||
<span className="muted">
|
||||
{formatDate(g.createdAt, "date", "")}
|
||||
</span>
|
||||
</p>
|
||||
<p style={{ margin: 0 }}>{g.message}</p>
|
||||
<div className={styles.columns}>
|
||||
<aside className={styles.sidebar}>
|
||||
{/* Wallet */}
|
||||
<ContentCard title={t("walletTitle")}>
|
||||
<div className={styles.wallet}>
|
||||
{wallet.map((w) => (
|
||||
<div key={w.key} className={styles.currency}>
|
||||
<CurrencyIcon kind={w.key} size={28} alt="" />
|
||||
<div>
|
||||
<strong>{w.value.toLocaleString()}</strong>
|
||||
<span className="muted">{w.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
))}
|
||||
</div>
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Friends ──────────────────────── */}
|
||||
<ContentCard
|
||||
icon="👥"
|
||||
title={t("friendsTitle")}
|
||||
subtitle={t("friendsSubtitle", { count: friends.length })}
|
||||
padded={true}
|
||||
>
|
||||
{friends.length === 0 ? (
|
||||
<EmptyState icon="👥">{t("friendsEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(72px, 1fr))",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{friends.map((f) => (
|
||||
<Link
|
||||
key={f.id}
|
||||
href={`/u/${encodeURIComponent(f.username)}`}
|
||||
className="card"
|
||||
title={f.username}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "0.35rem",
|
||||
padding: "0.6rem 0.4rem",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<UserAvatarThumbnail figure={f.look} alt={f.username} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 600,
|
||||
maxWidth: "100%",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{f.username}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{/* Badges */}
|
||||
<ContentCard icon="🏅" title={t("badgesTitle")}>
|
||||
{badges.length === 0 ? (
|
||||
<EmptyState icon="🏅">{t("badgesEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div className={styles.badges}>
|
||||
{badges.map((b) => {
|
||||
const details = badgeByCode.get(b.badgeCode);
|
||||
const name = details?.badgeName || b.badgeCode;
|
||||
return (
|
||||
<details key={b.id} className={styles.badge}>
|
||||
<summary title={name}>
|
||||
<img
|
||||
src={
|
||||
BADGE_IMG_BASE +
|
||||
"/" +
|
||||
encodeURIComponent(b.badgeCode) +
|
||||
".gif"
|
||||
}
|
||||
alt={name}
|
||||
width={40}
|
||||
height={40}
|
||||
loading="lazy"
|
||||
/>
|
||||
</summary>
|
||||
<div className={styles.badgeInfo}>
|
||||
<strong>{name}</strong>
|
||||
{details?.badgeDescription && (
|
||||
<p>{details.badgeDescription}</p>
|
||||
)}
|
||||
<small>{b.badgeCode}</small>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</aside>
|
||||
<div className={styles.content}>
|
||||
{/* Rooms */}
|
||||
<ContentCard
|
||||
icon="🏠"
|
||||
title={t("roomsTitle")}
|
||||
subtitle={t("roomsSubtitle", { username: user.username })}
|
||||
>
|
||||
{rooms.length === 0 ? (
|
||||
<EmptyState icon="🏠">{t("roomsEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div className={styles.rooms}>
|
||||
{rooms.map((r) => {
|
||||
const photo = photos.find((photo) => photo.roomId === r.id);
|
||||
return (
|
||||
<Link
|
||||
href={`/room/${r.id}`}
|
||||
key={r.id}
|
||||
className={styles.room}
|
||||
>
|
||||
<ProfileImage
|
||||
src={
|
||||
photo?.url ||
|
||||
"/assets/images/profile/room_placeholder.png"
|
||||
}
|
||||
fallback="/assets/images/profile/room_placeholder.png"
|
||||
alt=""
|
||||
width={320}
|
||||
height={140}
|
||||
className={styles.roomImage}
|
||||
/>
|
||||
<div className={styles.roomBody}>
|
||||
<strong>{r.name || t("roomUntitled")}</strong>
|
||||
{!photo && (
|
||||
<span className="muted">{t("noRoomPreview")}</span>
|
||||
)}
|
||||
<div className={styles.roomMeta}>
|
||||
<span>
|
||||
{r.users}/{r.usersMax}
|
||||
</span>
|
||||
<span>
|
||||
{t.has(`roomStates.${r.state}`)
|
||||
? t(`roomStates.${r.state}`)
|
||||
: r.state}
|
||||
</span>
|
||||
</div>
|
||||
<span className={styles.roomLink}>
|
||||
{t("viewRoom")} →
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Photos ────────────────────── */}
|
||||
<ContentCard
|
||||
icon="📸"
|
||||
title={t("photosTitle")}
|
||||
padded={photos.length === 0}
|
||||
>
|
||||
{photos.length === 0 ? (
|
||||
<EmptyState icon="📸">{t("photosEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{photos.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="card"
|
||||
style={{ padding: "0.5rem" }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={p.url}
|
||||
alt={`by ${user.username}`}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
display: "block",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
|
||||
{formatDate(new Date(p.timestamp * 1000), "date")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Guestbook ───────────────────── */}
|
||||
<ContentCard
|
||||
icon="📝"
|
||||
title={t("guestbookTitle")}
|
||||
subtitle={t("guestbookSubtitle", { username: user.username })}
|
||||
>
|
||||
{isLoggedIn ? (
|
||||
<form
|
||||
action={postGuestbook}
|
||||
className="card"
|
||||
style={{ marginBottom: "1rem" }}
|
||||
>
|
||||
{/* Profile owner id drives the write target; the author is taken from
|
||||
the session inside the action, never from this form. */}
|
||||
<input type="hidden" name="profileId" value={String(user.id)} />
|
||||
<input type="hidden" name="username" value={user.username} />
|
||||
<label htmlFor="gb-message" className="muted">
|
||||
{t("leaveMessage")}
|
||||
</label>
|
||||
<textarea
|
||||
id="gb-message"
|
||||
name="message"
|
||||
required
|
||||
maxLength={255}
|
||||
rows={3}
|
||||
placeholder={t("guestbookPlaceholder", {
|
||||
username: user.username,
|
||||
})}
|
||||
style={{
|
||||
width: "100%",
|
||||
margin: "0.4rem 0 0.75rem",
|
||||
resize: "vertical",
|
||||
}}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("post")}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="muted">{t("loginToPost")}</p>
|
||||
)}
|
||||
|
||||
{guestbook.length === 0 ? (
|
||||
<EmptyState icon="📝">{t("guestbookEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div className="grid" style={{ gap: "0.75rem" }}>
|
||||
{guestbook.map((g) => {
|
||||
const author = authorById.get(g.userId);
|
||||
return (
|
||||
<div
|
||||
key={String(g.id)}
|
||||
className="card"
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
{author ? (
|
||||
<UserAvatarThumbnail
|
||||
figure={author.look}
|
||||
alt={author?.username ?? "author"}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ margin: "0 0 0.25rem" }}>
|
||||
<strong>
|
||||
{author?.username ??
|
||||
t("unknownUser", { id: g.userId })}
|
||||
</strong>{" "}
|
||||
<span className="muted">
|
||||
{formatDate(g.createdAt, "date", "")}
|
||||
</span>
|
||||
</p>
|
||||
<p style={{ margin: 0 }}>{g.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
.profile {
|
||||
min-width: 0;
|
||||
}
|
||||
.hero {
|
||||
overflow: hidden;
|
||||
border-top: 4px solid var(--color-primary);
|
||||
background: linear-gradient(
|
||||
120deg,
|
||||
color-mix(in srgb, var(--color-primary) 9%, var(--color-surface)),
|
||||
var(--color-surface)
|
||||
);
|
||||
}
|
||||
.avatar {
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
border-radius: 20px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-primary) 12%,
|
||||
var(--color-surface)
|
||||
);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.identity {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 340px) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
.sidebar,
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
.wallet {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.currency {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.currency > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.currency strong {
|
||||
font-size: 1.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.currency span {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.profile [class~="empty-state"] {
|
||||
padding: 18px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
.profile [class~="empty-state-icon"] {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.rooms {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.room {
|
||||
display: block;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.room:hover,
|
||||
.room:focus-visible {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.roomImage {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
object-fit: contain;
|
||||
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface));
|
||||
}
|
||||
.roomBody {
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.roomBody > [class~="muted"],
|
||||
.roomMeta {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.roomMeta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.roomLink {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary-readable, var(--color-primary));
|
||||
}
|
||||
.badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
.badge {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.badge summary {
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.badge summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.badge img {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.badge[open] {
|
||||
width: 100%;
|
||||
}
|
||||
.badgeInfo {
|
||||
padding: 0 12px 12px;
|
||||
font-size: 0.85rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.badgeInfo p {
|
||||
margin: 6px 0;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.columns {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.sidebar,
|
||||
.content {
|
||||
gap: 16px;
|
||||
}
|
||||
.avatar {
|
||||
width: 80px;
|
||||
height: 120px;
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ export default async function EditArticle({
|
||||
title: WebsiteArticles.title,
|
||||
shortStory: WebsiteArticles.shortStory,
|
||||
fullStory: WebsiteArticles.fullStory,
|
||||
status: WebsiteArticles.status,
|
||||
publishAt: WebsiteArticles.publishAt,
|
||||
image: WebsiteArticles.image,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
@@ -63,7 +65,7 @@ export default async function EditArticle({
|
||||
action={async (fd) => {
|
||||
"use server";
|
||||
fd.set("id", String(article.id));
|
||||
await updateArticle(fd);
|
||||
return updateArticle(fd);
|
||||
}}
|
||||
defaultValues={{
|
||||
title: article.title,
|
||||
@@ -71,6 +73,8 @@ export default async function EditArticle({
|
||||
image: article.image,
|
||||
shortStory: article.shortStory,
|
||||
fullStory: article.fullStory,
|
||||
status: article.status,
|
||||
publishAt: article.publishAt?.toISOString(),
|
||||
}}
|
||||
/>
|
||||
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
|
||||
|
||||
@@ -192,12 +192,13 @@ export function BannersManager({ banners }: Props) {
|
||||
<div className="flex gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="banner-active"
|
||||
checked={form.isActive === 1}
|
||||
onCheckedChange={(v) =>
|
||||
setForm({ ...form, isActive: v ? 1 : 0 })
|
||||
}
|
||||
/>
|
||||
<Label>Active</Label>
|
||||
<Label htmlFor="banner-active">Active</Label>
|
||||
</div>
|
||||
<div className="space-y-2 flex items-center gap-2">
|
||||
<Label>Order</Label>
|
||||
|
||||
@@ -30,11 +30,11 @@ import {
|
||||
RotateCcw,
|
||||
Rows3,
|
||||
Save,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -48,6 +48,7 @@ import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items";
|
||||
import { ItemsShopPreview } from "@/components/admin/catalog/items-shop-preview";
|
||||
import { QuickAddFurni } from "@/components/admin/catalog/quick-add-furni";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { SearchInput } from "@/components/admin/search-input";
|
||||
import {
|
||||
CurrencyIcon,
|
||||
currencyKindFromPointsType,
|
||||
@@ -80,6 +81,12 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { BulkOfferEditor } from "@/features/catalog/components/bulk-offer-editor";
|
||||
import {
|
||||
discoverOffers,
|
||||
OFFER_FILTERS,
|
||||
type OfferFilter,
|
||||
} from "@/features/catalog/domain/offer-discovery";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { getSuggestions, recordMove } from "@/lib/move-suggestions";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -144,6 +151,9 @@ function SortableRow({
|
||||
}
|
||||
|
||||
interface CatalogItemsTableProps {
|
||||
initialSearch?: string;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
onRefresh?: () => void;
|
||||
items: CatalogItemData[];
|
||||
pageId: number;
|
||||
pageLayout: string;
|
||||
@@ -154,6 +164,9 @@ interface CatalogItemsTableProps {
|
||||
}
|
||||
|
||||
export function CatalogItemsTable({
|
||||
initialSearch = "",
|
||||
onDirtyChange,
|
||||
onRefresh,
|
||||
items,
|
||||
pageId,
|
||||
pageLayout,
|
||||
@@ -163,6 +176,11 @@ export function CatalogItemsTable({
|
||||
allPages,
|
||||
}: CatalogItemsTableProps) {
|
||||
const router = useRouter();
|
||||
const bulk = useTranslations("pages.admin.catalog.bulk");
|
||||
const discovery = useTranslations("pages.admin.catalog.discovery");
|
||||
const [bulkRefreshFrom, setBulkRefreshFrom] = useState<
|
||||
CatalogItemData[] | null
|
||||
>(null);
|
||||
const { run, isPending } = useServerAction();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
@@ -176,20 +194,14 @@ export function CatalogItemsTable({
|
||||
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
|
||||
|
||||
// ── Search / Filter ─────────────────────────────────────────────
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!searchQuery.trim()) return items;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return items.filter(
|
||||
(item) =>
|
||||
item.catalogName.toLowerCase().includes(q) ||
|
||||
item.baseItemName.toLowerCase().includes(q) ||
|
||||
item.baseName.toLowerCase().includes(q) ||
|
||||
item.itemIds.includes(q) ||
|
||||
String(item.id).includes(q),
|
||||
);
|
||||
}, [items, searchQuery]);
|
||||
const [searchQuery, setSearchQuery] = useState(initialSearch);
|
||||
const [offerFilter, setOfferFilter] = useState<OfferFilter>("all");
|
||||
const { items: filteredItems, counts: filterCounts } = useMemo(
|
||||
() => discoverOffers(items, searchQuery, offerFilter),
|
||||
[items, searchQuery, offerFilter],
|
||||
);
|
||||
const hasDiscoveryFilter =
|
||||
Boolean(searchQuery.trim()) || offerFilter !== "all";
|
||||
|
||||
// ── Move items dialog ───────────────────────────────────────────
|
||||
const [moveOpen, setMoveOpen] = useState(false);
|
||||
@@ -232,13 +244,36 @@ export function CatalogItemsTable({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const allVisibleSelected =
|
||||
filteredItems.length > 0 &&
|
||||
filteredItems.every((item) => selected.has(item.id));
|
||||
const someVisibleSelected = filteredItems.some((item) =>
|
||||
selected.has(item.id),
|
||||
);
|
||||
const toggleAll = useCallback(() => {
|
||||
setSelected((prev) =>
|
||||
prev.size === filteredItems.length
|
||||
? new Set()
|
||||
: new Set(filteredItems.map((i) => i.id)),
|
||||
);
|
||||
setSelected((previous) => {
|
||||
const next = new Set(previous);
|
||||
const allSelected = filteredItems.every((item) => previous.has(item.id));
|
||||
for (const item of filteredItems) {
|
||||
if (allSelected) next.delete(item.id);
|
||||
else next.add(item.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [filteredItems]);
|
||||
function updateSearch(query: string) {
|
||||
setSearchQuery(query);
|
||||
setSelected(new Set());
|
||||
}
|
||||
function updateOfferFilter(value: string) {
|
||||
setOfferFilter(value as OfferFilter);
|
||||
setSelected(new Set());
|
||||
}
|
||||
function resetDiscovery() {
|
||||
setSearchQuery("");
|
||||
setOfferFilter("all");
|
||||
setSelected(new Set());
|
||||
}
|
||||
|
||||
// ── Inline edits ─────────────────────────────────────────────────
|
||||
const [inlineEdits, setInlineEdits] = useState<Record<number, InlineEdit>>(
|
||||
@@ -290,7 +325,29 @@ export function CatalogItemsTable({
|
||||
return itemOrder.some((id, idx) => id !== originalOrder[idx]);
|
||||
}, [items, itemOrder]);
|
||||
|
||||
const hasUnsavedChanges = modifiedItems.length > 0 || hasOrderChanges;
|
||||
const hasUnsavedChanges =
|
||||
bulkRefreshFrom === null && (modifiedItems.length > 0 || hasOrderChanges);
|
||||
useEffect(() => {
|
||||
if (bulkRefreshFrom === null || items === bulkRefreshFrom) return;
|
||||
setInlineEdits(
|
||||
Object.fromEntries(
|
||||
items.map((item) => [
|
||||
item.id,
|
||||
{
|
||||
costCredits: item.costCredits,
|
||||
costPoints: item.costPoints,
|
||||
pointsType: item.pointsType,
|
||||
amount: item.amount,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
setItemOrder(items.map((item) => item.id));
|
||||
setBulkRefreshFrom(null);
|
||||
}, [items, bulkRefreshFrom]);
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(hasUnsavedChanges);
|
||||
}, [hasUnsavedChanges, onDirtyChange]);
|
||||
|
||||
// Warn before navigating away with unsaved changes
|
||||
useEffect(() => {
|
||||
@@ -383,7 +440,8 @@ export function CatalogItemsTable({
|
||||
errorMessage: `Failed to save item #${item.id}.`,
|
||||
onSuccess: () => {
|
||||
setSavingItemId(null);
|
||||
router.refresh();
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
},
|
||||
onError: () => setSavingItemId(null),
|
||||
},
|
||||
@@ -426,6 +484,10 @@ export function CatalogItemsTable({
|
||||
else failedCount++;
|
||||
}
|
||||
|
||||
if (savedCount > 0) {
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
return {
|
||||
ok: false as const,
|
||||
@@ -456,7 +518,8 @@ export function CatalogItemsTable({
|
||||
errorMessage: "Failed to delete items.",
|
||||
onSuccess: () => {
|
||||
setSelected(new Set());
|
||||
router.refresh();
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -490,7 +553,8 @@ export function CatalogItemsTable({
|
||||
setSelected(new Set());
|
||||
setMoveOpen(false);
|
||||
setMoveTargetPageId(null);
|
||||
router.refresh();
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -590,7 +654,8 @@ export function CatalogItemsTable({
|
||||
onSuccess: () => {
|
||||
setAddOpen(false);
|
||||
setNewItem({ ...DEFAULT_NEW_ITEM });
|
||||
router.refresh();
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -641,7 +706,8 @@ export function CatalogItemsTable({
|
||||
onSuccess: () => {
|
||||
setMoveOneId(null);
|
||||
setMoveOneTarget(null);
|
||||
router.refresh();
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -711,7 +777,8 @@ export function CatalogItemsTable({
|
||||
onSuccess: () => {
|
||||
setEditingItem(null);
|
||||
setEditingBase(null);
|
||||
router.refresh();
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -887,21 +954,34 @@ export function CatalogItemsTable({
|
||||
return (
|
||||
<>
|
||||
{confirmDialog}
|
||||
<Card>
|
||||
{bulkRefreshFrom !== null && (
|
||||
<p
|
||||
role="status"
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{bulk("refreshing")}
|
||||
</p>
|
||||
)}
|
||||
<Card
|
||||
inert={bulkRefreshFrom !== null}
|
||||
aria-busy={bulkRefreshFrom !== null}
|
||||
>
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex flex-row items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<CardTitle>
|
||||
Items ({items.length})
|
||||
{searchQuery && filteredItems.length !== items.length && (
|
||||
<span className="text-muted-foreground font-normal text-sm ml-2">
|
||||
({filteredItems.length} shown)
|
||||
</span>
|
||||
)}
|
||||
{hasDiscoveryFilter &&
|
||||
filteredItems.length !== items.length && (
|
||||
<span className="text-muted-foreground font-normal text-sm ml-2">
|
||||
{discovery("shown", { count: filteredItems.length })}
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
{selected.size > 0 && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{selected.size} selected
|
||||
{discovery("selected", { count: selected.size })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -941,6 +1021,21 @@ export function CatalogItemsTable({
|
||||
<>
|
||||
{selected.size > 0 && (
|
||||
<>
|
||||
<BulkOfferEditor
|
||||
ids={[...selected]}
|
||||
allPages={allPages}
|
||||
disabled={
|
||||
hasUnsavedChanges ||
|
||||
isPending ||
|
||||
savingItemId !== null
|
||||
}
|
||||
onApplied={() => {
|
||||
setBulkRefreshFrom(items);
|
||||
setSelected(new Set());
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -1080,25 +1175,50 @@ export function CatalogItemsTable({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search bar */}
|
||||
{/* Offer discovery */}
|
||||
{items.length > 0 && (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Filter by name, classname, item ID..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SearchInput
|
||||
placeholder={discovery("searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={updateSearch}
|
||||
onClear={() => updateSearch("")}
|
||||
/>
|
||||
</div>
|
||||
<Select value={offerFilter} onValueChange={updateOfferFilter}>
|
||||
<SelectTrigger
|
||||
aria-label={discovery("filterLabel")}
|
||||
className="w-full sm:w-52"
|
||||
>
|
||||
<SelectValue>
|
||||
{discovery(`filters.${offerFilter}`)} (
|
||||
{filterCounts[offerFilter]})
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OFFER_FILTERS.map((filter) => (
|
||||
<SelectItem key={filter} value={filter}>
|
||||
{discovery(`filters.${filter}`)} ({filterCounts[filter]}
|
||||
)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{hasDiscoveryFilter && (
|
||||
<Button variant="ghost" size="sm" onClick={resetDiscovery}>
|
||||
{discovery("reset")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{discovery("results", {
|
||||
count: filteredItems.length,
|
||||
total: items.length,
|
||||
})}
|
||||
{canEdit && ` ${discovery("selectionHint")}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
@@ -1121,7 +1241,7 @@ export function CatalogItemsTable({
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No items match "{searchQuery}".
|
||||
{discovery("noResults")}
|
||||
</p>
|
||||
) : viewMode === "grid" ? (
|
||||
<ItemsShopPreview
|
||||
@@ -1147,12 +1267,12 @@ export function CatalogItemsTable({
|
||||
{canEdit && (
|
||||
<TableHead className="w-[40px] px-2">
|
||||
<Checkbox
|
||||
checked={
|
||||
filteredItems.length > 0 &&
|
||||
selected.size === filteredItems.length
|
||||
checked={allVisibleSelected}
|
||||
indeterminate={
|
||||
someVisibleSelected && !allVisibleSelected
|
||||
}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label="Select all"
|
||||
aria-label={discovery("selectVisible")}
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
@@ -1180,7 +1300,7 @@ export function CatalogItemsTable({
|
||||
const modified = isItemModified(item);
|
||||
const isSaving = savingItemId === item.id;
|
||||
const furniInfo = getFurniInfo(item);
|
||||
const sortableDisabled = !canEdit || !!searchQuery;
|
||||
const sortableDisabled = !canEdit || hasDiscoveryFilter;
|
||||
return (
|
||||
<SortableRow
|
||||
key={item.id}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Check, ChevronsUpDown, Star } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
@@ -37,11 +37,15 @@ export function ToggleField({
|
||||
onChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const id = useId();
|
||||
const isChecked = checked ?? (value === 1 || value === "1");
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs">{label}</Label>
|
||||
<Label htmlFor={id} className="text-xs">
|
||||
{label}
|
||||
</Label>
|
||||
<Switch
|
||||
id={id}
|
||||
checked={isChecked}
|
||||
onCheckedChange={onChange}
|
||||
disabled={disabled}
|
||||
@@ -151,6 +155,8 @@ export function MovePageCombobox({
|
||||
pages,
|
||||
currentPageId,
|
||||
suggestedPageIds,
|
||||
disabled = false,
|
||||
labels,
|
||||
}: {
|
||||
value: number | null;
|
||||
onChange: (id: number | null) => void;
|
||||
@@ -158,6 +164,14 @@ export function MovePageCombobox({
|
||||
currentPageId: number;
|
||||
/** Page IDs to show first as suggestions (from move-suggestions.ts) */
|
||||
suggestedPageIds?: number[];
|
||||
disabled?: boolean;
|
||||
labels?: {
|
||||
placeholder: string;
|
||||
search: string;
|
||||
empty: string;
|
||||
suggested: string;
|
||||
all: string;
|
||||
};
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -194,6 +208,7 @@ export function MovePageCombobox({
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={disabled}
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
@@ -205,7 +220,9 @@ export function MovePageCombobox({
|
||||
{selectedPage.caption}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Select target page...</span>
|
||||
<span className="text-muted-foreground">
|
||||
{labels?.placeholder ?? "Select target page..."}
|
||||
</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -215,13 +232,13 @@ export function MovePageCombobox({
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search pages..." />
|
||||
<CommandInput placeholder={labels?.search ?? "Search pages..."} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No page found.</CommandEmpty>
|
||||
<CommandEmpty>{labels?.empty ?? "No page found."}</CommandEmpty>
|
||||
|
||||
{/* Suggested pages */}
|
||||
{suggested.length > 0 && (
|
||||
<CommandGroup heading="Suggested">
|
||||
<CommandGroup heading={labels?.suggested ?? "Suggested"}>
|
||||
{suggested.map((page) => (
|
||||
<CommandItem
|
||||
key={`s-${page.id}`}
|
||||
@@ -249,7 +266,9 @@ export function MovePageCombobox({
|
||||
|
||||
{/* All pages */}
|
||||
<CommandGroup
|
||||
heading={suggested.length > 0 ? "All pages" : undefined}
|
||||
heading={
|
||||
suggested.length > 0 ? (labels?.all ?? "All pages") : undefined
|
||||
}
|
||||
>
|
||||
{remaining.map((page) => (
|
||||
<CommandItem
|
||||
|
||||
@@ -136,10 +136,18 @@ export function CatalogPageForm({
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
run(() => updateCatalogPage({ id: catalogPage.id, ...form }), {
|
||||
successMessage: "Page updated and catalog synced.",
|
||||
errorMessage: "Failed to update.",
|
||||
});
|
||||
run(
|
||||
() =>
|
||||
updateCatalogPage({
|
||||
id: catalogPage.id,
|
||||
...form,
|
||||
expected: catalogPage as unknown as Record<string, unknown>,
|
||||
}),
|
||||
{
|
||||
successMessage: "Page saved.",
|
||||
errorMessage: "Failed to update.",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
|
||||
@@ -11,3 +11,11 @@ export const CatalogManagerDialog = dynamic(
|
||||
),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export const CatalogManagerWorkspace = dynamic(
|
||||
() =>
|
||||
import("@/components/admin/catalog-manager/catalog-manager-dialog").then(
|
||||
(m) => ({ default: m.CatalogManagerWorkspace }),
|
||||
),
|
||||
{ ssr: false },
|
||||
);
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { FolderTree, HardHat, List, Package } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CatalogViewToggleProps {
|
||||
@@ -15,9 +17,11 @@ export function CatalogViewToggle({
|
||||
catalogType = "normal",
|
||||
}: CatalogViewToggleProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("pages.admin.catalog.workspace");
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
function setView(view: string) {
|
||||
if (view === currentView || !requestCatalogNavigation()) return;
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (view === "tree") {
|
||||
params.delete("view");
|
||||
@@ -28,18 +32,21 @@ export function CatalogViewToggle({
|
||||
}
|
||||
|
||||
function setCatalog(type: string) {
|
||||
if (type === catalogType || !requestCatalogNavigation()) return;
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (type === "normal") {
|
||||
params.delete("catalog");
|
||||
} else {
|
||||
params.set("catalog", type);
|
||||
}
|
||||
params.delete("view"); // reset to tree when switching
|
||||
params.delete("view");
|
||||
params.delete("root");
|
||||
params.delete("category");
|
||||
router.push(`?${params.toString()}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Catalog type selector */}
|
||||
<div className="flex items-center rounded-md border bg-card">
|
||||
<Button
|
||||
@@ -52,7 +59,7 @@ export function CatalogViewToggle({
|
||||
onClick={() => setCatalog("normal")}
|
||||
>
|
||||
<Package className="mr-1.5 h-4 w-4" />
|
||||
Catalog
|
||||
{t("normal")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -80,7 +87,7 @@ export function CatalogViewToggle({
|
||||
onClick={() => setView("tree")}
|
||||
>
|
||||
<FolderTree className="mr-1.5 h-4 w-4" />
|
||||
Tree
|
||||
{t("tree")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -92,7 +99,7 @@ export function CatalogViewToggle({
|
||||
onClick={() => setView("table")}
|
||||
>
|
||||
<List className="mr-1.5 h-4 w-4" />
|
||||
Table
|
||||
{t("table")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,8 +15,10 @@ import { Store } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { CatalogFixAllButton } from "@/components/admin/catalog/catalog-fix-all-button";
|
||||
import { CatalogTree } from "@/components/admin/catalog-tree";
|
||||
import Link from "@/components/link";
|
||||
import { CatalogSearch } from "@/features/catalog/components/catalog-search";
|
||||
import { CatalogSyncStatus } from "@/features/catalog/components/catalog-sync-status";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import {
|
||||
CatalogItems,
|
||||
@@ -56,8 +58,9 @@ export default async function CatalogPagesPage({
|
||||
const params = await searchParams;
|
||||
const sp = new URLSearchParams(params);
|
||||
const { search, perPage, page, sort, order } = parseListParams(sp);
|
||||
const viewMode = sp.get("view") || "tree";
|
||||
const catalogType = sp.get("catalog") || "normal";
|
||||
const requestedView = sp.get("view");
|
||||
const viewMode = requestedView === "table" ? "table" : "tree";
|
||||
const catalogType = sp.get("catalog") === "bc" ? "bc" : "normal";
|
||||
|
||||
type TreeNodeShape = {
|
||||
id: number;
|
||||
@@ -94,7 +97,7 @@ export default async function CatalogPagesPage({
|
||||
.where(
|
||||
or(eq(CatalogPagesBc.parentId, -1), eq(CatalogPagesBc.parentId, 0)),
|
||||
)
|
||||
.orderBy(asc(CatalogPagesBc.orderNum));
|
||||
.orderBy(asc(CatalogPagesBc.orderNum), asc(CatalogPagesBc.id));
|
||||
const bcRootIds = bcRootRaw.map((p) => p.id);
|
||||
const [bcChildCounts, bcItemCounts] = await Promise.all([
|
||||
bcRootIds.length > 0
|
||||
@@ -258,13 +261,17 @@ export default async function CatalogPagesPage({
|
||||
|
||||
return (
|
||||
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
|
||||
<div className="mb-4 space-y-3">
|
||||
<CatalogSyncStatus canEdit={canEdit} />
|
||||
<CatalogSearch catalogType={catalogType} canEdit={canEdit} />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-muted-foreground">
|
||||
{totalBcPages} BC pages ({enabledBcPages} enabled) ·{" "}
|
||||
{totalBcItems} items
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CatalogViewToggle currentView={viewMode} catalogType="bc" />
|
||||
{canEdit && (
|
||||
<CatalogManagerDialog
|
||||
@@ -290,7 +297,12 @@ export default async function CatalogPagesPage({
|
||||
initialParentId={sp.get("newPageParent")}
|
||||
/>
|
||||
)}
|
||||
<CatalogFixAllButton />
|
||||
<Link
|
||||
className="btn btn-outline"
|
||||
href="/admin/catalog/maintenance"
|
||||
>
|
||||
{t("workspace.diagnostics")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "tree" ? (
|
||||
@@ -323,7 +335,7 @@ export default async function CatalogPagesPage({
|
||||
.select()
|
||||
.from(CatalogPages)
|
||||
.where(or(eq(CatalogPages.parentId, -1), eq(CatalogPages.parentId, 0)))
|
||||
.orderBy(asc(CatalogPages.orderNum));
|
||||
.orderBy(asc(CatalogPages.orderNum), asc(CatalogPages.id));
|
||||
const rootIds = rootPagesRaw.map((p) => p.id);
|
||||
const [rootChildCounts, rootItemCountMap] = await Promise.all([
|
||||
rootIds.length > 0
|
||||
@@ -482,13 +494,17 @@ export default async function CatalogPagesPage({
|
||||
|
||||
return (
|
||||
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
|
||||
<div className="mb-4 space-y-3">
|
||||
<CatalogSyncStatus canEdit={canEdit} />
|
||||
<CatalogSearch catalogType={catalogType} canEdit={canEdit} />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-muted-foreground">
|
||||
{totalPages} pages ({enabledPages} enabled) · {totalItems}{" "}
|
||||
items
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
|
||||
{canEdit && (
|
||||
<CatalogManagerDialog
|
||||
@@ -511,7 +527,9 @@ export default async function CatalogPagesPage({
|
||||
{canEdit && (
|
||||
<CatalogActions initialParentId={sp.get("newPageParent")} />
|
||||
)}
|
||||
<CatalogFixAllButton />
|
||||
<Link className="btn btn-outline" href="/admin/catalog/maintenance">
|
||||
{t("workspace.diagnostics")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export function CommandocentrumControls() {
|
||||
const t = useTranslations("pages.admin.commandocentrum");
|
||||
const { run, isPending } = useServerAction();
|
||||
|
||||
const [category, setCategory] = useState("hotel");
|
||||
const [hotelMessage, setHotelMessage] = useState("");
|
||||
const [disconnect, setDisconnect] = useState({ userId: "", username: "" });
|
||||
const [alert, setAlert] = useState({ userId: "", message: "" });
|
||||
@@ -46,10 +47,30 @@ export function CommandocentrumControls() {
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("emulatorControls")}</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<section id="commands" className="my-6 scroll-mt-4 space-y-4">
|
||||
<h2 className="admin-section-title">{t("emulatorControls")}</h2>
|
||||
<fieldset
|
||||
className="flex flex-wrap gap-2"
|
||||
aria-label={t("commandCategories")}
|
||||
>
|
||||
{["hotel", "players", "rewards", "advanced"].map((group) => (
|
||||
<Button
|
||||
key={group}
|
||||
type="button"
|
||||
variant={category === group ? "default" : "outline"}
|
||||
aria-pressed={category === group}
|
||||
disabled={isPending}
|
||||
onClick={() => setCategory(group)}
|
||||
>
|
||||
{t(`groups.${group}`)}
|
||||
</Button>
|
||||
))}
|
||||
</fieldset>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]" role="status">
|
||||
{isPending ? t("executing") : t("chooseCommand")}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{category === "hotel" && (
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("updateCatalog")}
|
||||
@@ -71,7 +92,8 @@ export function CommandocentrumControls() {
|
||||
{t("updateCatalog")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
)}
|
||||
{category === "hotel" && (
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("updateWordFilter")}
|
||||
@@ -93,7 +115,8 @@ export function CommandocentrumControls() {
|
||||
{t("updateWordFilter")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
)}
|
||||
{category === "hotel" && (
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("updateNavigator")}
|
||||
@@ -115,50 +138,44 @@ export function CommandocentrumControls() {
|
||||
{t("updateNavigator")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card mt-6">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("hotelAlert")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("hotelAlertDesc")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={hotelMessage}
|
||||
onChange={(e) => setHotelMessage(e.target.value)}
|
||||
required
|
||||
maxLength={512}
|
||||
placeholder={t("messageToBroadcast")}
|
||||
className="flex-1 min-w-[220px]"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={isPending || !hotelMessage.trim()}
|
||||
onClick={() =>
|
||||
run(() => hotelAlert({ message: hotelMessage }), {
|
||||
successMessage: "Hotel alert sent.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () => setHotelMessage(""),
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("sendAlert")}
|
||||
</Button>
|
||||
)}
|
||||
{category === "hotel" && (
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("hotelAlert")}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
{t("hotelAlertDesc")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
value={hotelMessage}
|
||||
onChange={(e) => setHotelMessage(e.target.value)}
|
||||
required
|
||||
maxLength={512}
|
||||
aria-label={t("messageToBroadcast")}
|
||||
placeholder={t("messageToBroadcast")}
|
||||
className="flex-1 min-w-0 w-full sm:w-auto"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={isPending || !hotelMessage.trim()}
|
||||
onClick={() =>
|
||||
run(() => hotelAlert({ message: hotelMessage }), {
|
||||
successMessage: "Hotel alert sent.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () => setHotelMessage(""),
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("sendAlert")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("userActions")}</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
{t("userActionsDesc")}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
)}
|
||||
{category === "players" && (
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("actions.disconnect")}
|
||||
@@ -170,6 +187,7 @@ export function CommandocentrumControls() {
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
aria-label={t("userId")}
|
||||
placeholder={t("userId")}
|
||||
value={disconnect.userId}
|
||||
onChange={(e) =>
|
||||
@@ -180,6 +198,7 @@ export function CommandocentrumControls() {
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t("usernameRequired")}
|
||||
placeholder={t("usernameRequired")}
|
||||
value={disconnect.username}
|
||||
onChange={(e) =>
|
||||
@@ -215,7 +234,8 @@ export function CommandocentrumControls() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)}
|
||||
{category === "players" && (
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("actions.alert")}
|
||||
@@ -227,6 +247,7 @@ export function CommandocentrumControls() {
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
aria-label={t("userId")}
|
||||
placeholder={t("userId")}
|
||||
value={alert.userId}
|
||||
onChange={(e) =>
|
||||
@@ -237,6 +258,7 @@ export function CommandocentrumControls() {
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t("alertMessage")}
|
||||
placeholder={t("alertMessage")}
|
||||
value={alert.message}
|
||||
onChange={(e) =>
|
||||
@@ -270,7 +292,8 @@ export function CommandocentrumControls() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)}
|
||||
{category === "players" && (
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("forwardToRoom")}
|
||||
@@ -282,6 +305,7 @@ export function CommandocentrumControls() {
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
aria-label={t("userId")}
|
||||
placeholder={t("userId")}
|
||||
value={forward.userId}
|
||||
onChange={(e) =>
|
||||
@@ -293,6 +317,7 @@ export function CommandocentrumControls() {
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
aria-label={t("roomId")}
|
||||
placeholder={t("roomId")}
|
||||
value={forward.roomId}
|
||||
onChange={(e) =>
|
||||
@@ -325,9 +350,8 @@ export function CommandocentrumControls() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
)}
|
||||
{category === "rewards" && (
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("giveCredits")}
|
||||
@@ -339,6 +363,7 @@ export function CommandocentrumControls() {
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
aria-label={t("userId")}
|
||||
placeholder={t("userId")}
|
||||
value={credits.userId}
|
||||
onChange={(e) =>
|
||||
@@ -350,6 +375,7 @@ export function CommandocentrumControls() {
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
aria-label={t("amount")}
|
||||
placeholder={t("amount")}
|
||||
value={credits.credits}
|
||||
onChange={(e) =>
|
||||
@@ -382,7 +408,8 @@ export function CommandocentrumControls() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)}
|
||||
{category === "rewards" && (
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("giveDuckets")}
|
||||
@@ -394,6 +421,7 @@ export function CommandocentrumControls() {
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
aria-label={t("userId")}
|
||||
placeholder={t("userId")}
|
||||
value={duckets.userId}
|
||||
onChange={(e) =>
|
||||
@@ -405,6 +433,7 @@ export function CommandocentrumControls() {
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
aria-label={t("amount")}
|
||||
placeholder={t("amount")}
|
||||
value={duckets.amount}
|
||||
onChange={(e) =>
|
||||
@@ -437,7 +466,8 @@ export function CommandocentrumControls() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)}
|
||||
{category === "rewards" && (
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("giveDiamonds")}
|
||||
@@ -449,6 +479,7 @@ export function CommandocentrumControls() {
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
aria-label={t("userId")}
|
||||
placeholder={t("userId")}
|
||||
value={diamonds.userId}
|
||||
onChange={(e) =>
|
||||
@@ -460,6 +491,7 @@ export function CommandocentrumControls() {
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
aria-label={t("amount")}
|
||||
placeholder={t("amount")}
|
||||
value={diamonds.amount}
|
||||
onChange={(e) =>
|
||||
@@ -492,9 +524,8 @@ export function CommandocentrumControls() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
)}
|
||||
{category === "rewards" && (
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("giveBadge")}
|
||||
@@ -506,6 +537,7 @@ export function CommandocentrumControls() {
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
aria-label={t("userId")}
|
||||
placeholder={t("userId")}
|
||||
value={badge.userId}
|
||||
onChange={(e) =>
|
||||
@@ -516,6 +548,7 @@ export function CommandocentrumControls() {
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t("badgeCode")}
|
||||
placeholder={t("badgeCode")}
|
||||
value={badge.badge}
|
||||
onChange={(e) =>
|
||||
@@ -549,7 +582,8 @@ export function CommandocentrumControls() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)}
|
||||
{category === "players" && (
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("setMotto")}
|
||||
@@ -561,6 +595,7 @@ export function CommandocentrumControls() {
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
aria-label={t("userId")}
|
||||
placeholder={t("userId")}
|
||||
value={motto.userId}
|
||||
onChange={(e) =>
|
||||
@@ -571,6 +606,7 @@ export function CommandocentrumControls() {
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t("newMotto")}
|
||||
placeholder={t("newMotto")}
|
||||
value={motto.motto}
|
||||
onChange={(e) =>
|
||||
@@ -604,7 +640,8 @@ export function CommandocentrumControls() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)}
|
||||
{category === "players" && (
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("setRank")}
|
||||
@@ -616,6 +653,7 @@ export function CommandocentrumControls() {
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
aria-label={t("userId")}
|
||||
placeholder={t("userId")}
|
||||
value={rank.userId}
|
||||
onChange={(e) =>
|
||||
@@ -628,6 +666,7 @@ export function CommandocentrumControls() {
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
aria-label={t("rankPlaceholder")}
|
||||
placeholder={t("rankPlaceholder")}
|
||||
value={rank.rank}
|
||||
onChange={(e) =>
|
||||
@@ -660,9 +699,8 @@ export function CommandocentrumControls() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
)}
|
||||
{category === "advanced" && (
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("executeCommand")}
|
||||
@@ -674,6 +712,7 @@ export function CommandocentrumControls() {
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
aria-label={t("userId")}
|
||||
placeholder={t("userId")}
|
||||
value={command.userId}
|
||||
onChange={(e) =>
|
||||
@@ -684,6 +723,7 @@ export function CommandocentrumControls() {
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t("commandPlaceholder")}
|
||||
placeholder={t("commandPlaceholder")}
|
||||
value={command.command}
|
||||
onChange={(e) =>
|
||||
@@ -719,7 +759,8 @@ export function CommandocentrumControls() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)}
|
||||
{category === "rewards" && (
|
||||
<div className="admin-card">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] mb-2">
|
||||
{t("sendGift")}
|
||||
@@ -731,6 +772,7 @@ export function CommandocentrumControls() {
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
aria-label={t("userId")}
|
||||
placeholder={t("userId")}
|
||||
value={gift.userId}
|
||||
onChange={(e) =>
|
||||
@@ -742,6 +784,7 @@ export function CommandocentrumControls() {
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
aria-label={t("furniId")}
|
||||
placeholder={t("furniId")}
|
||||
value={gift.itemId}
|
||||
onChange={(e) =>
|
||||
@@ -752,6 +795,7 @@ export function CommandocentrumControls() {
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t("giftMessage")}
|
||||
placeholder={t("giftMessage")}
|
||||
value={gift.message}
|
||||
onChange={(e) =>
|
||||
@@ -791,8 +835,8 @@ export function CommandocentrumControls() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
OnlineUsersWidget,
|
||||
StatusCard,
|
||||
} from "@/components/admin/dashboard";
|
||||
import Link from "@/components/link";
|
||||
import { env } from "@/env";
|
||||
import { fetchOpsHealth } from "@/lib/admin/ops-health";
|
||||
import { fetchOpsOnlineUsers } from "@/lib/admin/ops-online-users";
|
||||
import { db, EmulatorErrors, StaffActivities, User } from "@/lib/db";
|
||||
@@ -17,6 +19,7 @@ import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { CommandocentrumControls } from "./commandocentrum-controls";
|
||||
import { RefreshStatus } from "./refresh-status";
|
||||
|
||||
function decodeStacktrace(raw: unknown): string {
|
||||
if (raw == null) return "";
|
||||
@@ -47,28 +50,36 @@ export default async function CommandoCentrum() {
|
||||
|
||||
const t = await getTranslations("pages.admin.commandocentrum");
|
||||
|
||||
const [totalUsersRows, errors, health, maintenance, activity, onlineRoster] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.catch(() => [{ total: -1 }]),
|
||||
db
|
||||
.select()
|
||||
.from(EmulatorErrors)
|
||||
.orderBy(desc(EmulatorErrors.timestamp))
|
||||
.limit(50)
|
||||
.catch(() => []),
|
||||
fetchOpsHealth(),
|
||||
siteSettings.getBool("maintenance_enabled", false),
|
||||
db
|
||||
.select()
|
||||
.from(StaffActivities)
|
||||
.orderBy(desc(StaffActivities.createdAt))
|
||||
.limit(12)
|
||||
.catch(() => [] as (typeof StaffActivities.$inferSelect)[]),
|
||||
fetchOpsOnlineUsers(40),
|
||||
]);
|
||||
const [
|
||||
totalUsersRows,
|
||||
errorRows,
|
||||
health,
|
||||
maintenance,
|
||||
activity,
|
||||
onlineRoster,
|
||||
] = await Promise.all([
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.catch(() => [{ total: -1 }]),
|
||||
db
|
||||
.select()
|
||||
.from(EmulatorErrors)
|
||||
.orderBy(desc(EmulatorErrors.timestamp))
|
||||
.limit(50)
|
||||
.catch(() => null),
|
||||
fetchOpsHealth(),
|
||||
siteSettings.getBool("maintenance_enabled", false),
|
||||
db
|
||||
.select()
|
||||
.from(StaffActivities)
|
||||
.orderBy(desc(StaffActivities.createdAt))
|
||||
.limit(12)
|
||||
.catch(() => [] as (typeof StaffActivities.$inferSelect)[]),
|
||||
fetchOpsOnlineUsers(40),
|
||||
]);
|
||||
const errors = errorRows ?? [];
|
||||
const errorsAvailable = errorRows !== null;
|
||||
const totalUsers = Number(totalUsersRows[0]?.total ?? -1);
|
||||
|
||||
const dbOk = health.dbOk;
|
||||
@@ -91,12 +102,30 @@ export default async function CommandoCentrum() {
|
||||
}
|
||||
|
||||
const appKeySet = !!process.env.APP_KEY;
|
||||
const smtpSet = !!process.env.SMTP_HOST;
|
||||
const rconSet = !!process.env.RCON_HOST;
|
||||
const smtpSet = !!(env.SMTP_HOST || env.RESEND_API_KEY);
|
||||
const rconSet =
|
||||
env.EMULATOR_MODE === "api" ? !!env.EMULATOR_API_URL : !!env.RCON_HOST;
|
||||
|
||||
return (
|
||||
<AdminPageShell icon={Terminal} title={t("title")} subtitle={t("subtitle")}>
|
||||
<main>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<p className="text-sm">
|
||||
{dbOk && emulatorOnline && redisOk !== false
|
||||
? t("servicesHealthy")
|
||||
: t("serviceAttention")}{" "}
|
||||
·{" "}
|
||||
{t("checkedAt", {
|
||||
time: formatDate(new Date(), "datetime-seconds"),
|
||||
})}
|
||||
</p>
|
||||
<RefreshStatus label={t("refreshStatus")} />
|
||||
{canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank) ? (
|
||||
<Link href="/admin/devops/cms-errors" className="btn btn-outline">
|
||||
{t("cmsErrors")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
{/* ── Live status ────────────────────────────────────────── */}
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"
|
||||
@@ -104,7 +133,7 @@ export default async function CommandoCentrum() {
|
||||
>
|
||||
<StatusCard
|
||||
label={t("playersOnline")}
|
||||
value={onlineCount}
|
||||
value={dbOk ? onlineCount : "—"}
|
||||
hint={
|
||||
totalUsers >= 0
|
||||
? t("totalAccounts", { count: totalUsers })
|
||||
@@ -129,13 +158,15 @@ export default async function CommandoCentrum() {
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("emulatorErrors")}
|
||||
value={errors.length}
|
||||
hint={t("last50Logged")}
|
||||
state={errors.length === 0 ? "ok" : "warn"}
|
||||
value={errorsAvailable ? errors.length : "—"}
|
||||
hint={errorsAvailable ? t("last50Logged") : t("readFailed")}
|
||||
state={errorsAvailable && errors.length === 0 ? "ok" : "warn"}
|
||||
icon="⚠️"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CommandocentrumControls />
|
||||
|
||||
{/* ── Online users widget ───────────────────────────────── */}
|
||||
<section id="online" className="admin-card mb-6 scroll-mt-4">
|
||||
<h2 className="admin-section-title">{t("onlineUsers")}</h2>
|
||||
@@ -144,8 +175,10 @@ export default async function CommandoCentrum() {
|
||||
|
||||
{/* ── Server info + diagnostics ──────────────────────────── */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<section className="admin-card p-5">
|
||||
<h2 className="admin-section-title">{t("serverInfo")}</h2>
|
||||
<details className="admin-card p-5 self-start">
|
||||
<summary className="admin-section-title cursor-pointer">
|
||||
{t("serverInfo")}
|
||||
</summary>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-2.5">
|
||||
<InfoItem label={t("node")} value={process.version} />
|
||||
<InfoItem
|
||||
@@ -171,7 +204,7 @@ export default async function CommandoCentrum() {
|
||||
/>
|
||||
<InfoItem label={t("cpus")} value={os.cpus().length} />
|
||||
</div>
|
||||
</section>
|
||||
</details>
|
||||
|
||||
<section className="admin-card p-5">
|
||||
<h2 className="admin-section-title">{t("systemDiagnostics")}</h2>
|
||||
@@ -206,8 +239,8 @@ export default async function CommandoCentrum() {
|
||||
state={emulatorOnline ? "ok" : "warn"}
|
||||
/>
|
||||
<DiagnosticRow
|
||||
label={t("rconConfig")}
|
||||
detail={rconSet ? t("rconHostSet") : t("rconHostNotConfigured")}
|
||||
label={t("emulatorConfig")}
|
||||
detail={rconSet ? t("configured") : t("notConfigured")}
|
||||
state={rconSet ? "ok" : "warn"}
|
||||
/>
|
||||
<DiagnosticRow
|
||||
@@ -216,8 +249,8 @@ export default async function CommandoCentrum() {
|
||||
state={appKeySet ? "ok" : "warn"}
|
||||
/>
|
||||
<DiagnosticRow
|
||||
label={t("emailSmtp")}
|
||||
detail={smtpSet ? t("smtpHostSet") : t("notConfigured")}
|
||||
label={t("emailConfig")}
|
||||
detail={smtpSet ? t("configured") : t("notConfigured")}
|
||||
state={smtpSet ? "ok" : "warn"}
|
||||
/>
|
||||
<DiagnosticRow
|
||||
@@ -231,8 +264,6 @@ export default async function CommandoCentrum() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<CommandocentrumControls />
|
||||
|
||||
{/* ── Staff activity ─────────────────────────────────────── */}
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("recentStaffActivity")}</h2>
|
||||
@@ -277,7 +308,9 @@ export default async function CommandoCentrum() {
|
||||
{/* ── Emulator errors ────────────────────────────────────── */}
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("recentEmulatorErrors")}</h2>
|
||||
{errors.length === 0 ? (
|
||||
{!errorsAvailable ? (
|
||||
<div className="admin-empty">{t("readFailed")}</div>
|
||||
) : errors.length === 0 ? (
|
||||
<div className="admin-empty">{t("noEmulatorErrors")}</div>
|
||||
) : (
|
||||
<div className="admin-card overflow-x-auto">
|
||||
@@ -312,9 +345,15 @@ export default async function CommandoCentrum() {
|
||||
{e.version}
|
||||
</td>
|
||||
<td>
|
||||
<pre className="text-xs p-2 bg-[var(--admin-canvas)] text-[var(--color-text-readable)] rounded overflow-auto max-h-[160px]">
|
||||
{snippet}
|
||||
</pre>
|
||||
<details>
|
||||
<summary className="cursor-pointer text-xs">
|
||||
{trace.split("\n")[0]?.slice(0, 120) ||
|
||||
t("empty")}
|
||||
</summary>
|
||||
<pre className="text-xs p-2 bg-[var(--admin-canvas)] text-[var(--color-text-readable)] rounded overflow-auto max-h-[160px]">
|
||||
{snippet}
|
||||
</pre>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -324,7 +363,7 @@ export default async function CommandoCentrum() {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
export function RefreshStatus({ label }: { label: string }) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={pending}
|
||||
aria-busy={pending}
|
||||
onClick={() => startTransition(() => router.refresh())}
|
||||
>
|
||||
{pending ? "… " : ""}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { ErrorStore } from "@/lib/error-monitor";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
@@ -11,6 +12,7 @@ export default async function CmsErrorsPage({
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank))
|
||||
redirect("/admin");
|
||||
const t = await getTranslations("pages.admin.cmsErrors");
|
||||
const params = await searchParams;
|
||||
const query = (params.q ?? "").trim().slice(0, 200).toLowerCase();
|
||||
let result: Awaited<ReturnType<ErrorStore["read"]>>;
|
||||
@@ -19,14 +21,15 @@ export default async function CmsErrorsPage({
|
||||
} catch {
|
||||
return (
|
||||
<div className="admin-card" role="alert">
|
||||
Error storage could not be read. Check the CMS storage volume and
|
||||
permissions.
|
||||
{t("storageError")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const filtered = result.records.filter(
|
||||
(r) =>
|
||||
(!params.source || r.source === params.source) &&
|
||||
(params.status !== "open" || !r.resolved) &&
|
||||
(params.status !== "resolved" || r.resolved) &&
|
||||
(!query || JSON.stringify(r).toLowerCase().includes(query)),
|
||||
);
|
||||
const groups = new Map<string, typeof filtered>();
|
||||
@@ -43,62 +46,62 @@ export default async function CmsErrorsPage({
|
||||
),
|
||||
);
|
||||
const pageHref = (next: number) =>
|
||||
`?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", page: String(next) })}`;
|
||||
`?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", status: params.status ?? "", page: String(next) })}`;
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">CMS error center</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Server and browser errors from the last 7 days. Browser reports are
|
||||
unverified client observations.
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold">{t("title")}</h1>
|
||||
<p className="text-muted-foreground">{t("subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href="/admin/devops" className="btn btn-outline">
|
||||
DevOps
|
||||
</Link>
|
||||
<Link href="/admin/devops/errors" className="btn btn-outline">
|
||||
Emulator errors
|
||||
{t("emulator")}
|
||||
</Link>
|
||||
<Link href="/admin/devops/cms-errors" className="btn btn-outline">
|
||||
Refresh / clear filters
|
||||
{t("refresh")}
|
||||
</Link>
|
||||
</div>
|
||||
<form className="admin-card flex flex-wrap gap-3">
|
||||
<input
|
||||
aria-label="Search errors"
|
||||
aria-label={t("search")}
|
||||
name="q"
|
||||
defaultValue={params.q}
|
||||
placeholder="Reference, digest, message, route or release"
|
||||
placeholder={t("searchHint")}
|
||||
className="input min-w-0 flex-1"
|
||||
/>
|
||||
<select
|
||||
name="source"
|
||||
aria-label="Error source"
|
||||
aria-label={t("source")}
|
||||
defaultValue={params.source ?? ""}
|
||||
className="input"
|
||||
>
|
||||
<option value="">All sources</option>
|
||||
<option value="">{t("allSources")}</option>
|
||||
<option value="server">Server</option>
|
||||
<option value="browser">Browser</option>
|
||||
</select>
|
||||
<select
|
||||
name="status"
|
||||
aria-label={t("status")}
|
||||
defaultValue={params.status ?? ""}
|
||||
className="input"
|
||||
>
|
||||
<option value="">{t("allStatuses")}</option>
|
||||
<option value="open">{t("open")}</option>
|
||||
<option value="resolved">{t("resolved")}</option>
|
||||
</select>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Search
|
||||
{t("search")}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filtered.length} occurrences · {groups.size} groups.{" "}
|
||||
{result.truncated
|
||||
? "Showing the latest 1,000 events. Narrowing filters searches this retained window."
|
||||
: ""}{" "}
|
||||
Storage is limited to 10 MB per day; excess events remain in server logs
|
||||
where available.
|
||||
{t("counts", { count: filtered.length, groups: groups.size })}{" "}
|
||||
{result.truncated ? t("truncated") : ""} {t("retention")}
|
||||
</p>
|
||||
{groups.size === 0 ? (
|
||||
<div className="admin-card">
|
||||
No captured errors match these filters. Monitoring starts with this
|
||||
deployment; older console logs are not imported.
|
||||
</div>
|
||||
<div className="admin-card">{t("empty")}</div>
|
||||
) : null}
|
||||
{[...groups.entries()]
|
||||
.slice((page - 1) * 25, page * 25)
|
||||
@@ -108,25 +111,24 @@ export default async function CmsErrorsPage({
|
||||
<details key={fingerprint} className="admin-card overflow-hidden">
|
||||
<summary className="cursor-pointer">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{r.resolved ? "Resolved" : "Open"} · {r.source} ·{" "}
|
||||
{events.length} occurrences · {r.at}
|
||||
{t(r.resolved ? "resolved" : "open")} · {r.source} ·{" "}
|
||||
{t("occurrences", { count: events.length })} · {r.at}
|
||||
</span>
|
||||
<p className="font-semibold break-words">{r.event}</p>
|
||||
<p className="text-sm break-words">{r.message}</p>
|
||||
</summary>
|
||||
<div className="mt-4 space-y-3 text-sm">
|
||||
<p>
|
||||
Reference: <code className="break-all">{r.id}</code>
|
||||
{t("reference")}: <code className="break-all">{r.id}</code>
|
||||
</p>
|
||||
<p>
|
||||
Release: <code className="break-all">{r.release}</code>
|
||||
{t("release")}: <code className="break-all">{r.release}</code>
|
||||
</p>
|
||||
<pre className="whitespace-pre-wrap break-all rounded bg-muted p-3">
|
||||
{JSON.stringify(r.context, null, 2)}
|
||||
</pre>
|
||||
<pre className="max-h-96 overflow-auto whitespace-pre-wrap break-all rounded bg-muted p-3">
|
||||
{r.stack ||
|
||||
"No stack supplied. Use the reference or digest to correlate server logs."}
|
||||
{r.stack || t("noStack")}
|
||||
</pre>
|
||||
{!r.resolved &&
|
||||
canAccess(permissions, PERMS.DEVOPS_EDIT, session.user.rank) ? (
|
||||
@@ -137,16 +139,16 @@ export default async function CmsErrorsPage({
|
||||
value={r.fingerprint}
|
||||
/>
|
||||
<button type="submit" className="btn btn-outline">
|
||||
Mark resolved
|
||||
{t("resolve")}
|
||||
</button>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
A new occurrence reopens the group automatically.
|
||||
{t("reopenHint")}
|
||||
</p>
|
||||
</form>
|
||||
) : null}
|
||||
<details>
|
||||
<summary className="cursor-pointer">
|
||||
Occurrence references
|
||||
{t("references")}
|
||||
</summary>
|
||||
<ul>
|
||||
{events.map((e) => (
|
||||
@@ -156,23 +158,20 @@ export default async function CmsErrorsPage({
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
<p className="text-muted-foreground">
|
||||
Browser stack traces may reference compiled chunks. This view
|
||||
does not infer a root cause or include request bodies.
|
||||
</p>
|
||||
<p className="text-muted-foreground">{t("stackHint")}</p>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
<nav aria-label="Error pages" className="flex gap-3">
|
||||
<nav aria-label={t("pages")} className="flex gap-3">
|
||||
{page > 1 ? (
|
||||
<Link href={pageHref(page - 1)} className="btn btn-outline">
|
||||
Previous
|
||||
{t("previous")}
|
||||
</Link>
|
||||
) : null}
|
||||
{page * 25 < groups.size ? (
|
||||
<Link href={pageHref(page + 1)} className="btn btn-outline">
|
||||
Next
|
||||
{t("next")}
|
||||
</Link>
|
||||
) : null}
|
||||
</nav>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { db, EmulatorErrors } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export default async function EmulatorErrorsPage({
|
||||
@@ -55,7 +56,7 @@ export default async function EmulatorErrorsPage({
|
||||
#{error.id}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(error.timestamp * 1000).toLocaleString()}
|
||||
{formatDate(new Date(error.timestamp * 1000))}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs whitespace-pre-wrap break-all font-mono bg-muted p-2 rounded">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { fetchOpsHealth } from "@/lib/admin/ops-health";
|
||||
import { db, EmulatorErrors, EmulatorSettings } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { HealthCheckClient } from "./health-check-client";
|
||||
|
||||
@@ -56,7 +57,7 @@ export default async function DevOpsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<a href="/admin/devops/cms-errors" className="btn btn-outline">
|
||||
CMS error center
|
||||
{t("cmsErrors")}
|
||||
</a>
|
||||
{/* Status cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
@@ -168,7 +169,7 @@ export default async function DevOpsPage() {
|
||||
#{error.id}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(error.timestamp * 1000).toLocaleString()}
|
||||
{formatDate(new Date(error.timestamp * 1000))}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs whitespace-pre-wrap break-all font-mono text-destructive">
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function AdminErrorPage({
|
||||
reset: () => void;
|
||||
}) {
|
||||
const t = useTranslations("pages.error");
|
||||
const tNav = useTranslations("admin.nav");
|
||||
const tNav = useTranslations("pages.admin.nav");
|
||||
|
||||
const [reference, setReference] = useState(error.digest);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
interface Registration {
|
||||
id: number;
|
||||
@@ -36,7 +37,7 @@ export function EventRegistrations({ registrations }: Props) {
|
||||
>
|
||||
<span className="text-sm font-medium">{r.username}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(r.createdAt).toLocaleString()}
|
||||
{formatDate(new Date(r.createdAt))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
interface Winner {
|
||||
id: number;
|
||||
@@ -93,7 +94,7 @@ export function EventWinnersManager({ eventId, winners }: Props) {
|
||||
</span>
|
||||
<span className="font-medium text-sm">{w.username}</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{new Date(w.createdAt).toLocaleDateString()}
|
||||
{formatDate(new Date(w.createdAt), "date")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { db, User, WebsiteEvent, WebsiteEventType } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { EventForm } from "../event-form";
|
||||
import { EventPrizesManager } from "./event-prizes";
|
||||
@@ -94,7 +95,7 @@ export default async function EventDetailPage({
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="m-0 mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
{event.type.name} · {new Date(event.startsAt).toLocaleString()}
|
||||
{event.type.name} · {formatDate(new Date(event.startsAt))}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -156,7 +157,7 @@ export default async function EventDetailPage({
|
||||
{event.endsAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("endsAt", {
|
||||
date: new Date(event.endsAt).toLocaleString(),
|
||||
date: formatDate(new Date(event.endsAt)),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import Link from "@/components/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
interface EventRow {
|
||||
@@ -86,7 +87,7 @@ export function EventsTable({ data }: EventsTableProps) {
|
||||
sortable: true,
|
||||
render: (value) => (
|
||||
<span className="text-sm">
|
||||
{new Date(value as unknown as string).toLocaleString()}
|
||||
{formatDate(new Date(value as unknown as string))}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
interface ThreadMessage {
|
||||
key: string;
|
||||
@@ -162,7 +163,7 @@ export function AdminHelpTicketDetail({
|
||||
{msg.isStaff ? ` (${t("staffBadge")})` : ""}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{new Date(msg.createdAt).toLocaleString()}
|
||||
{formatDate(new Date(msg.createdAt))}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -308,11 +309,11 @@ export function AdminHelpTicketDetail({
|
||||
<CardContent className="space-y-2 text-xs text-muted-foreground">
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>{t("colCreated")}</span>
|
||||
<span>{new Date(ticket.createdAt).toLocaleString()}</span>
|
||||
<span>{formatDate(new Date(ticket.createdAt))}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>{t("colUpdated")}</span>
|
||||
<span>{new Date(ticket.updatedAt).toLocaleString()}</span>
|
||||
<span>{formatDate(new Date(ticket.updatedAt))}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>{t("messageCountLabel")}</span>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
BadgeCheck,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ImportErrorBanner } from "@/components/admin/import-error-banner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -348,12 +348,7 @@ export function ImportBadgesClient() {
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && <ImportErrorBanner error={error} />}
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{loading && badges.length === 0 && (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
CloudDownload,
|
||||
Download,
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { ImportErrorBanner } from "@/components/admin/import-error-banner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -782,12 +782,7 @@ function FurniGrid({ source }: FurniGridProps) {
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && <ImportErrorBanner error={error} />}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && items.length === 0 && !error && (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
Download,
|
||||
Loader2,
|
||||
@@ -11,14 +10,18 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
BatchImportProgress,
|
||||
useBatchImport,
|
||||
} from "@/components/admin/import/use-batch-import";
|
||||
import { ImportErrorBanner } from "@/components/admin/import-error-banner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import { getAvatarUrl } from "@/lib/imager";
|
||||
import { runSseImport } from "@/lib/sse-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ── Shared types ─────────────────────────────────────────────────────────────
|
||||
@@ -97,12 +100,14 @@ function LibsView() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [batchProgress, setBatchProgress] = useState<{
|
||||
done: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const [busyLib, setBusyLib] = useState<string | null>(null);
|
||||
const batchAbortRef = useRef<AbortController | null>(null);
|
||||
const batch = useBatchImport({
|
||||
url: "/api/admin/import/clothing/batch",
|
||||
successAction: "libraries imported",
|
||||
cancelledMessage: "Import cancelled",
|
||||
onItemDone: (label) => markDone(label),
|
||||
onComplete: () => setSelected(new Set()),
|
||||
});
|
||||
|
||||
const fetchItems = useCallback(async (search: string, pageNum: number) => {
|
||||
setLoading(true);
|
||||
@@ -168,41 +173,10 @@ function LibsView() {
|
||||
}
|
||||
|
||||
async function importViaSse(toImport: FigureItem[]) {
|
||||
batchAbortRef.current?.abort();
|
||||
const abort = new AbortController();
|
||||
batchAbortRef.current = abort;
|
||||
setBatchProgress({ done: 0, total: toImport.length });
|
||||
let done = 0;
|
||||
try {
|
||||
await runSseImport(
|
||||
"/api/admin/import/clothing/batch",
|
||||
{ items: toImport, concurrency: 3 },
|
||||
(label) => {
|
||||
done++;
|
||||
markDone(label);
|
||||
setBatchProgress({ done, total: toImport.length });
|
||||
},
|
||||
(succeeded, failed) => {
|
||||
setSelected(new Set());
|
||||
if (succeeded > 0) toast.success(`${succeeded} libraries imported`);
|
||||
if (failed > 0) toast.error(`${failed} failed`);
|
||||
},
|
||||
abort.signal,
|
||||
() => {
|
||||
done++;
|
||||
setBatchProgress({ done, total: toImport.length });
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name === "AbortError") {
|
||||
toast.info("Import cancelled");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
if (batchAbortRef.current === abort) batchAbortRef.current = null;
|
||||
setBatchProgress(null);
|
||||
}
|
||||
await batch.run(
|
||||
toImport.map((p) => p.lib),
|
||||
{ items: toImport, concurrency: 3 },
|
||||
);
|
||||
}
|
||||
|
||||
async function importOne(p: FigureItem) {
|
||||
@@ -315,7 +289,7 @@ function LibsView() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-8"
|
||||
disabled={!!batchProgress}
|
||||
disabled={batch.running}
|
||||
onClick={() =>
|
||||
setSelected((prev) =>
|
||||
prev.size === importable.length
|
||||
@@ -333,9 +307,9 @@ function LibsView() {
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={importSelected}
|
||||
disabled={!!batchProgress}
|
||||
disabled={batch.running}
|
||||
>
|
||||
{batchProgress ? (
|
||||
{batch.running ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-1" />
|
||||
) : (
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
@@ -343,30 +317,16 @@ function LibsView() {
|
||||
Import {selected.size} selected
|
||||
</Button>
|
||||
)}
|
||||
{batchProgress && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{batchProgress.done}/{batchProgress.total}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
onClick={() => batchAbortRef.current?.abort()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
{batch.progress && (
|
||||
<BatchImportProgress
|
||||
progress={batch.progress}
|
||||
onCancel={batch.cancel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && <ImportErrorBanner error={error} />}
|
||||
|
||||
{!loading && items.length === 0 && !error && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
@@ -428,7 +388,7 @@ function LibsView() {
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 shrink-0"
|
||||
title="Remove library"
|
||||
disabled={busyLib === p.lib || !!batchProgress}
|
||||
disabled={busyLib === p.lib || batch.running}
|
||||
onClick={() => remove(p)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
@@ -439,7 +399,7 @@ function LibsView() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full text-xs h-7"
|
||||
disabled={busyLib === p.lib || !!batchProgress}
|
||||
disabled={busyLib === p.lib || batch.running}
|
||||
onClick={() => importOne(p)}
|
||||
>
|
||||
{busyLib === p.lib ? (
|
||||
@@ -498,12 +458,14 @@ function SetsView() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [batchProgress, setBatchProgress] = useState<{
|
||||
done: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const batchAbortRef = useRef<AbortController | null>(null);
|
||||
const batch = useBatchImport({
|
||||
url: "/api/admin/import/clothing/sets/batch",
|
||||
successAction: "sets imported",
|
||||
cancelledMessage: "Import cancelled",
|
||||
onItemDone: (label) => markDone(label),
|
||||
onComplete: () => setSelected(new Set()),
|
||||
});
|
||||
|
||||
function itemKey(it: ClothingSetItem): string {
|
||||
return `${it.setType}-${it.setId}`;
|
||||
@@ -570,47 +532,16 @@ function SetsView() {
|
||||
}
|
||||
|
||||
async function importViaSse(toImport: ClothingSetItem[]) {
|
||||
batchAbortRef.current?.abort();
|
||||
const abort = new AbortController();
|
||||
batchAbortRef.current = abort;
|
||||
setBatchProgress({ done: 0, total: toImport.length });
|
||||
let done = 0;
|
||||
try {
|
||||
await runSseImport(
|
||||
"/api/admin/import/clothing/sets/batch",
|
||||
{
|
||||
items: toImport.map((it) => ({
|
||||
setType: it.setType,
|
||||
setId: it.setId,
|
||||
})),
|
||||
concurrency: 3,
|
||||
},
|
||||
(label) => {
|
||||
done++;
|
||||
markDone(label);
|
||||
setBatchProgress({ done, total: toImport.length });
|
||||
},
|
||||
(succeeded, failed) => {
|
||||
setSelected(new Set());
|
||||
if (succeeded > 0) toast.success(`${succeeded} sets imported`);
|
||||
if (failed > 0) toast.error(`${failed} failed`);
|
||||
},
|
||||
abort.signal,
|
||||
() => {
|
||||
done++;
|
||||
setBatchProgress({ done, total: toImport.length });
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name === "AbortError") {
|
||||
toast.info("Import cancelled");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
if (batchAbortRef.current === abort) batchAbortRef.current = null;
|
||||
setBatchProgress(null);
|
||||
}
|
||||
await batch.run(
|
||||
toImport.map((it) => itemKey(it)),
|
||||
{
|
||||
items: toImport.map((it) => ({
|
||||
setType: it.setType,
|
||||
setId: it.setId,
|
||||
})),
|
||||
concurrency: 3,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function importOne(it: ClothingSetItem) {
|
||||
@@ -703,7 +634,7 @@ function SetsView() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-8"
|
||||
disabled={!!batchProgress}
|
||||
disabled={batch.running}
|
||||
onClick={() =>
|
||||
setSelected((prev) =>
|
||||
prev.size === importable.length
|
||||
@@ -721,9 +652,9 @@ function SetsView() {
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={importSelected}
|
||||
disabled={!!batchProgress}
|
||||
disabled={batch.running}
|
||||
>
|
||||
{batchProgress ? (
|
||||
{batch.running ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-1" />
|
||||
) : (
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
@@ -731,30 +662,16 @@ function SetsView() {
|
||||
Import {selected.size} selected
|
||||
</Button>
|
||||
)}
|
||||
{batchProgress && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{batchProgress.done}/{batchProgress.total}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
onClick={() => batchAbortRef.current?.abort()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
{batch.progress && (
|
||||
<BatchImportProgress
|
||||
progress={batch.progress}
|
||||
onCancel={batch.cancel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && <ImportErrorBanner error={error} />}
|
||||
|
||||
{!loading && items.length === 0 && !error && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
@@ -834,7 +751,7 @@ function SetsView() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full text-xs h-7"
|
||||
disabled={busyKey === key || !!batchProgress}
|
||||
disabled={busyKey === key || batch.running}
|
||||
onClick={() => importOne(it)}
|
||||
>
|
||||
{busyKey === key ? (
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
Download,
|
||||
Loader2,
|
||||
@@ -13,13 +12,17 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
BatchImportProgress,
|
||||
useBatchImport,
|
||||
} from "@/components/admin/import/use-batch-import";
|
||||
import { ImportErrorBanner } from "@/components/admin/import-error-banner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import { runSseImport } from "@/lib/sse-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface EffectItem {
|
||||
@@ -40,12 +43,14 @@ export function ImportEffectsClient() {
|
||||
const [error, setError] = useState("");
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [batchProgress, setBatchProgress] = useState<{
|
||||
done: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const [busyLib, setBusyLib] = useState<string | null>(null);
|
||||
const batchAbortRef = useRef<AbortController | null>(null);
|
||||
const batch = useBatchImport({
|
||||
url: "/api/admin/import/effects/batch",
|
||||
successAction: "effect(s) imported",
|
||||
cancelledMessage: "Import cancelled",
|
||||
onItemDone: (lib) => markDone(lib),
|
||||
onComplete: () => setSelected(new Set()),
|
||||
});
|
||||
|
||||
const fetchEffects = useCallback(async (search: string) => {
|
||||
setLoading(true);
|
||||
@@ -103,41 +108,10 @@ export function ImportEffectsClient() {
|
||||
}
|
||||
|
||||
async function importViaSse(items: EffectItem[]) {
|
||||
batchAbortRef.current?.abort();
|
||||
const abort = new AbortController();
|
||||
batchAbortRef.current = abort;
|
||||
setBatchProgress({ done: 0, total: items.length });
|
||||
let done = 0;
|
||||
try {
|
||||
await runSseImport(
|
||||
"/api/admin/import/effects/batch",
|
||||
{ items, concurrency: 3 },
|
||||
(lib) => {
|
||||
done++;
|
||||
markDone(lib);
|
||||
setBatchProgress({ done, total: items.length });
|
||||
},
|
||||
(succeeded, failed) => {
|
||||
setSelected(new Set());
|
||||
if (succeeded > 0) toast.success(`${succeeded} effect(s) imported`);
|
||||
if (failed > 0) toast.error(`${failed} failed`);
|
||||
},
|
||||
abort.signal,
|
||||
() => {
|
||||
done++;
|
||||
setBatchProgress({ done, total: items.length });
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name === "AbortError") {
|
||||
toast.info("Import cancelled");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
if (batchAbortRef.current === abort) batchAbortRef.current = null;
|
||||
setBatchProgress(null);
|
||||
}
|
||||
await batch.run(
|
||||
items.map((e) => e.lib),
|
||||
{ items, concurrency: 3 },
|
||||
);
|
||||
}
|
||||
|
||||
async function importOne(e: EffectItem) {
|
||||
@@ -267,7 +241,7 @@ export function ImportEffectsClient() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-8"
|
||||
disabled={!!batchProgress}
|
||||
disabled={batch.running}
|
||||
onClick={() =>
|
||||
setSelected((prev) =>
|
||||
prev.size === importable.length
|
||||
@@ -285,9 +259,9 @@ export function ImportEffectsClient() {
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={importSelected}
|
||||
disabled={!!batchProgress}
|
||||
disabled={batch.running}
|
||||
>
|
||||
{batchProgress ? (
|
||||
{batch.running ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-1" />
|
||||
) : (
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
@@ -295,31 +269,17 @@ export function ImportEffectsClient() {
|
||||
Import {selected.size} selected
|
||||
</Button>
|
||||
)}
|
||||
{batchProgress && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{batchProgress.done}/{batchProgress.total}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
onClick={() => batchAbortRef.current?.abort()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
{batch.progress && (
|
||||
<BatchImportProgress
|
||||
progress={batch.progress}
|
||||
onCancel={batch.cancel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && <ImportErrorBanner error={error} />}
|
||||
|
||||
{/* Empty */}
|
||||
{!loading && visible.length === 0 && !error && (
|
||||
@@ -397,7 +357,7 @@ export function ImportEffectsClient() {
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 shrink-0"
|
||||
title="Remove effect"
|
||||
disabled={busyLib === e.lib || !!batchProgress}
|
||||
disabled={busyLib === e.lib || batch.running}
|
||||
onClick={() => remove(e)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
@@ -408,7 +368,7 @@ export function ImportEffectsClient() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full text-xs h-7"
|
||||
disabled={busyLib === e.lib || !!batchProgress}
|
||||
disabled={busyLib === e.lib || batch.running}
|
||||
onClick={() => importOne(e)}
|
||||
>
|
||||
{busyLib === e.lib ? (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
Download,
|
||||
Loader2,
|
||||
@@ -11,13 +10,17 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
BatchImportProgress,
|
||||
useBatchImport,
|
||||
} from "@/components/admin/import/use-batch-import";
|
||||
import { ImportErrorBanner } from "@/components/admin/import-error-banner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import { runSseImport } from "@/lib/sse-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PetItem {
|
||||
@@ -46,12 +49,14 @@ export function ImportPetsClient() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [batchProgress, setBatchProgress] = useState<{
|
||||
done: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const [busyLib, setBusyLib] = useState<string | null>(null);
|
||||
const batchAbortRef = useRef<AbortController | null>(null);
|
||||
const batch = useBatchImport({
|
||||
url: "/api/admin/import/pets/batch",
|
||||
successAction: "pet(s) imported",
|
||||
cancelledMessage: "Import cancelled",
|
||||
onItemDone: (lib) => markDone(lib),
|
||||
onComplete: () => setSelected(new Set()),
|
||||
});
|
||||
|
||||
const fetchPets = useCallback(async (search: string) => {
|
||||
setLoading(true);
|
||||
@@ -100,41 +105,10 @@ export function ImportPetsClient() {
|
||||
}
|
||||
|
||||
async function importViaSse(items: PetItem[]) {
|
||||
batchAbortRef.current?.abort();
|
||||
const abort = new AbortController();
|
||||
batchAbortRef.current = abort;
|
||||
setBatchProgress({ done: 0, total: items.length });
|
||||
let done = 0;
|
||||
try {
|
||||
await runSseImport(
|
||||
"/api/admin/import/pets/batch",
|
||||
{ items, concurrency: 3 },
|
||||
(lib) => {
|
||||
done++;
|
||||
markDone(lib);
|
||||
setBatchProgress({ done, total: items.length });
|
||||
},
|
||||
(succeeded, failed) => {
|
||||
setSelected(new Set());
|
||||
if (succeeded > 0) toast.success(`${succeeded} pet(s) imported`);
|
||||
if (failed > 0) toast.error(`${failed} failed`);
|
||||
},
|
||||
abort.signal,
|
||||
() => {
|
||||
done++;
|
||||
setBatchProgress({ done, total: items.length });
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name === "AbortError") {
|
||||
toast.info("Import cancelled");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
if (batchAbortRef.current === abort) batchAbortRef.current = null;
|
||||
setBatchProgress(null);
|
||||
}
|
||||
await batch.run(
|
||||
items.map((p) => p.lib),
|
||||
{ items, concurrency: 3 },
|
||||
);
|
||||
}
|
||||
|
||||
async function importOne(p: PetItem) {
|
||||
@@ -247,7 +221,7 @@ export function ImportPetsClient() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-8"
|
||||
disabled={!!batchProgress}
|
||||
disabled={batch.running}
|
||||
onClick={() =>
|
||||
setSelected((prev) =>
|
||||
prev.size === importable.length
|
||||
@@ -265,9 +239,9 @@ export function ImportPetsClient() {
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={importSelected}
|
||||
disabled={!!batchProgress}
|
||||
disabled={batch.running}
|
||||
>
|
||||
{batchProgress ? (
|
||||
{batch.running ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-1" />
|
||||
) : (
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
@@ -275,30 +249,16 @@ export function ImportPetsClient() {
|
||||
Import {selected.size} selected
|
||||
</Button>
|
||||
)}
|
||||
{batchProgress && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{batchProgress.done}/{batchProgress.total}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
onClick={() => batchAbortRef.current?.abort()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
{batch.progress && (
|
||||
<BatchImportProgress
|
||||
progress={batch.progress}
|
||||
onCancel={batch.cancel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && <ImportErrorBanner error={error} />}
|
||||
|
||||
{!loading && pets.length === 0 && !error && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
@@ -356,7 +316,7 @@ export function ImportPetsClient() {
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 shrink-0"
|
||||
title="Remove pet"
|
||||
disabled={busyLib === p.lib || !!batchProgress}
|
||||
disabled={busyLib === p.lib || batch.running}
|
||||
onClick={() => remove(p)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
@@ -367,7 +327,7 @@ export function ImportPetsClient() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full text-xs h-7"
|
||||
disabled={busyLib === p.lib || !!batchProgress}
|
||||
disabled={busyLib === p.lib || batch.running}
|
||||
onClick={() => importOne(p)}
|
||||
>
|
||||
{busyLib === p.lib ? (
|
||||
|
||||
@@ -11,14 +11,9 @@ import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import Link from "@/components/link";
|
||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { collectNavPermissionSlugs } from "@/lib/admin-nav";
|
||||
import {
|
||||
ADMIN_NAV_CONFIG_KEY,
|
||||
parseAdminNavConfig,
|
||||
} from "@/lib/admin-nav-config";
|
||||
import { loadAdminNavigation } from "@/lib/admin/navigation";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { readCsrfCookieToken } from "@/lib/foundation/security";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
// Request-time auth, existing CSRF cookie, and optional 2FA gate cannot be
|
||||
@@ -31,6 +26,7 @@ export default async function AdminLayout({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const staff = await requireStaff();
|
||||
const navigation = await loadAdminNavigation();
|
||||
const csrfToken = await readCsrfCookieToken();
|
||||
if (await siteSettings.getBool("force_staff_2fa", false)) {
|
||||
const u = await db
|
||||
@@ -52,7 +48,7 @@ export default async function AdminLayout({
|
||||
className="flex flex-col min-w-0 p-5 lg:p-6 min-h-full"
|
||||
style={{ backgroundColor: "var(--admin-canvas)" }}
|
||||
>
|
||||
<AdminTopbar staff={staff} />
|
||||
<AdminTopbar staff={staff} navigation={navigation} />
|
||||
<section className="admin-page flex-1">
|
||||
<AdminHubChrome>{children}</AdminHubChrome>
|
||||
</section>
|
||||
@@ -69,27 +65,8 @@ async function Sidebar({
|
||||
}) {
|
||||
const t = await getTranslations("pages.admin.nav");
|
||||
const initial = staff.username.charAt(0).toUpperCase();
|
||||
const { permissions } = await getAdminContext();
|
||||
const isSuperAdmin = permissions.isSuperAdmin;
|
||||
const hasDashboard = canAccess(
|
||||
permissions,
|
||||
PERMS.ADMIN_DASHBOARD,
|
||||
staff.rank,
|
||||
);
|
||||
// Super-admins see everything. Everyone else sees items their ACL grants.
|
||||
// If they can open the admin panel but ACL is incomplete (common after HK→ACL
|
||||
// migration gaps), still show *.view nav entries so categories do not vanish.
|
||||
const allowedPermissions = isSuperAdmin
|
||||
? []
|
||||
: collectNavPermissionSlugs().filter((slug) => {
|
||||
if (canAccess(permissions, slug, staff.rank)) return true;
|
||||
if (!hasDashboard) return false;
|
||||
return slug.endsWith(".view") || slug === PERMS.ADMIN_DASHBOARD;
|
||||
});
|
||||
|
||||
const navConfig = parseAdminNavConfig(
|
||||
await siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
|
||||
);
|
||||
const { allowedPermissions, isSuperAdmin, navConfig } =
|
||||
await loadAdminNavigation();
|
||||
|
||||
return (
|
||||
<aside
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
export default function AdminLoading() {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
|
||||
>
|
||||
<div
|
||||
className="animate-spin w-8 h-8 border-4 rounded-full"
|
||||
style={{
|
||||
borderColor: "var(--admin-accent)",
|
||||
borderTopColor: "transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSpinner accentVar="var(--admin-accent)" />;
|
||||
}
|
||||
@@ -9,6 +9,16 @@ type ListParamsInput = {
|
||||
defaultPerPage?: number;
|
||||
};
|
||||
|
||||
type SqlFragment = ReturnType<typeof sql>;
|
||||
|
||||
type LogListSpec<RawRow, Row> = {
|
||||
select: SqlFragment;
|
||||
from: SqlFragment;
|
||||
searchWhere: (like: string) => SqlFragment;
|
||||
orderBy: SqlFragment;
|
||||
mapRow: (raw: RawRow, index: number, offset: number) => Row;
|
||||
};
|
||||
|
||||
async function requireLogsView() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.LOGS_VIEW, session.user.rank)) {
|
||||
@@ -26,11 +36,56 @@ function parsePageParams(
|
||||
return { search: parsed.search.trim(), page: parsed.page, perPage };
|
||||
}
|
||||
|
||||
async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
|
||||
async function rawRows<T>(query: SqlFragment): Promise<T[]> {
|
||||
const [rows] = (await db.execute(query)) as unknown as [T[], unknown];
|
||||
return rows ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared list loader for the chat / command / trade log tables. The SQL
|
||||
* fragments come from a per-table spec while the request handling, search
|
||||
* branching, count + pagination and row mapping stay in one place.
|
||||
*/
|
||||
async function loadLogList<RawRow, Row>(
|
||||
input: ListParamsInput,
|
||||
spec: LogListSpec<RawRow, Row>,
|
||||
): Promise<{
|
||||
rows: Row[];
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
lastPage: number;
|
||||
offset: number;
|
||||
}> {
|
||||
await requireLogsView();
|
||||
|
||||
const rawParams = await input.searchParams;
|
||||
const { search, page, perPage } = parsePageParams(
|
||||
rawParams,
|
||||
input.defaultPerPage ?? 50,
|
||||
);
|
||||
const offset = (page - 1) * perPage;
|
||||
const like = `%${search}%`;
|
||||
const where = search ? sql`WHERE ${spec.searchWhere(like)}` : sql``;
|
||||
|
||||
const [rawRowsResult, countRows] = await Promise.all([
|
||||
rawRows<RawRow>(
|
||||
sql`${spec.select} ${spec.from} ${where} ${spec.orderBy} LIMIT ${perPage} OFFSET ${offset}`,
|
||||
),
|
||||
rawRows<{ count: bigint }>(
|
||||
sql`SELECT COUNT(*) AS count ${spec.from} ${where}`,
|
||||
),
|
||||
]);
|
||||
|
||||
const total = Number(countRows[0]?.count ?? 0);
|
||||
const pagination = calcPagination(total, page, perPage);
|
||||
const rows = rawRowsResult.map((row, index) =>
|
||||
spec.mapRow(row, index, pagination.offset),
|
||||
);
|
||||
|
||||
return { rows, ...pagination };
|
||||
}
|
||||
|
||||
export type ChatLogListRow = {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -39,17 +94,7 @@ export type ChatLogListRow = {
|
||||
roomId: number;
|
||||
};
|
||||
|
||||
export async function loadChatLogList({
|
||||
searchParams,
|
||||
defaultPerPage = 50,
|
||||
}: ListParamsInput) {
|
||||
await requireLogsView();
|
||||
|
||||
const rawParams = await searchParams;
|
||||
const { search, page, perPage } = parsePageParams(rawParams, defaultPerPage);
|
||||
const offset = (page - 1) * perPage;
|
||||
const like = `%${search}%`;
|
||||
|
||||
export function loadChatLogList(input: ListParamsInput) {
|
||||
type RawRow = {
|
||||
roomId: number;
|
||||
message: string;
|
||||
@@ -57,43 +102,20 @@ export async function loadChatLogList({
|
||||
username: string | null;
|
||||
};
|
||||
|
||||
const [rawRowsResult, countRows] = search
|
||||
? await Promise.all([
|
||||
rawRows<RawRow>(sql`
|
||||
SELECT c.room_id AS roomId, c.message, c.timestamp, u.username AS username
|
||||
FROM chatlogs_room c
|
||||
LEFT JOIN users u ON u.id = c.user_from_id
|
||||
WHERE c.message LIKE ${like} OR u.username LIKE ${like}
|
||||
ORDER BY c.timestamp DESC
|
||||
LIMIT ${perPage} OFFSET ${offset}`),
|
||||
rawRows<{ count: bigint }>(sql`
|
||||
SELECT COUNT(*) AS count
|
||||
FROM chatlogs_room c
|
||||
LEFT JOIN users u ON u.id = c.user_from_id
|
||||
WHERE c.message LIKE ${like} OR u.username LIKE ${like}`),
|
||||
])
|
||||
: await Promise.all([
|
||||
rawRows<RawRow>(sql`
|
||||
SELECT c.room_id AS roomId, c.message, c.timestamp, u.username AS username
|
||||
FROM chatlogs_room c
|
||||
LEFT JOIN users u ON u.id = c.user_from_id
|
||||
ORDER BY c.timestamp DESC
|
||||
LIMIT ${perPage} OFFSET ${offset}`),
|
||||
rawRows<{ count: bigint }>(sql`
|
||||
SELECT COUNT(*) AS count FROM chatlogs_room`),
|
||||
]);
|
||||
|
||||
const total = Number(countRows[0]?.count ?? 0);
|
||||
const pagination = calcPagination(total, page, perPage);
|
||||
const rows: ChatLogListRow[] = rawRowsResult.map((row, index) => ({
|
||||
id: pagination.offset + index + 1,
|
||||
username: row.username ?? "-",
|
||||
message: row.message,
|
||||
timestamp: row.timestamp,
|
||||
roomId: row.roomId,
|
||||
}));
|
||||
|
||||
return { rows, ...pagination };
|
||||
return loadLogList<RawRow, ChatLogListRow>(input, {
|
||||
select: sql`SELECT c.room_id AS roomId, c.message, c.timestamp, u.username AS username`,
|
||||
from: sql`FROM chatlogs_room c LEFT JOIN users u ON u.id = c.user_from_id`,
|
||||
searchWhere: (like) =>
|
||||
sql`c.message LIKE ${like} OR u.username LIKE ${like}`,
|
||||
orderBy: sql`ORDER BY c.timestamp DESC`,
|
||||
mapRow: (row, index, offset) => ({
|
||||
id: offset + index + 1,
|
||||
username: row.username ?? "-",
|
||||
message: row.message,
|
||||
timestamp: row.timestamp,
|
||||
roomId: row.roomId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export type CommandLogListRow = {
|
||||
@@ -104,17 +126,7 @@ export type CommandLogListRow = {
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export async function loadCommandLogList({
|
||||
searchParams,
|
||||
defaultPerPage = 50,
|
||||
}: ListParamsInput) {
|
||||
await requireLogsView();
|
||||
|
||||
const rawParams = await searchParams;
|
||||
const { search, page, perPage } = parsePageParams(rawParams, defaultPerPage);
|
||||
const offset = (page - 1) * perPage;
|
||||
const like = `%${search}%`;
|
||||
|
||||
export function loadCommandLogList(input: ListParamsInput) {
|
||||
type RawRow = {
|
||||
command: string;
|
||||
params: string;
|
||||
@@ -122,47 +134,20 @@ export async function loadCommandLogList({
|
||||
username: string | null;
|
||||
};
|
||||
|
||||
const [rawRowsResult, countRows] = search
|
||||
? await Promise.all([
|
||||
rawRows<RawRow>(sql`
|
||||
SELECT c.command, c.params, c.timestamp, u.username AS username
|
||||
FROM commandlogs c
|
||||
LEFT JOIN users u ON u.id = c.user_id
|
||||
WHERE c.command LIKE ${like}
|
||||
OR c.params LIKE ${like}
|
||||
OR u.username LIKE ${like}
|
||||
ORDER BY c.timestamp DESC
|
||||
LIMIT ${perPage} OFFSET ${offset}`),
|
||||
rawRows<{ count: bigint }>(sql`
|
||||
SELECT COUNT(*) AS count
|
||||
FROM commandlogs c
|
||||
LEFT JOIN users u ON u.id = c.user_id
|
||||
WHERE c.command LIKE ${like}
|
||||
OR c.params LIKE ${like}
|
||||
OR u.username LIKE ${like}`),
|
||||
])
|
||||
: await Promise.all([
|
||||
rawRows<RawRow>(sql`
|
||||
SELECT c.command, c.params, c.timestamp, u.username AS username
|
||||
FROM commandlogs c
|
||||
LEFT JOIN users u ON u.id = c.user_id
|
||||
ORDER BY c.timestamp DESC
|
||||
LIMIT ${perPage} OFFSET ${offset}`),
|
||||
rawRows<{ count: bigint }>(sql`
|
||||
SELECT COUNT(*) AS count FROM commandlogs`),
|
||||
]);
|
||||
|
||||
const total = Number(countRows[0]?.count ?? 0);
|
||||
const pagination = calcPagination(total, page, perPage);
|
||||
const rows: CommandLogListRow[] = rawRowsResult.map((row, index) => ({
|
||||
id: pagination.offset + index + 1,
|
||||
username: row.username ?? "-",
|
||||
command: row.command,
|
||||
params: row.params,
|
||||
timestamp: row.timestamp,
|
||||
}));
|
||||
|
||||
return { rows, ...pagination };
|
||||
return loadLogList<RawRow, CommandLogListRow>(input, {
|
||||
select: sql`SELECT c.command, c.params, c.timestamp, u.username AS username`,
|
||||
from: sql`FROM commandlogs c LEFT JOIN users u ON u.id = c.user_id`,
|
||||
searchWhere: (like) =>
|
||||
sql`c.command LIKE ${like} OR c.params LIKE ${like} OR u.username LIKE ${like}`,
|
||||
orderBy: sql`ORDER BY c.timestamp DESC`,
|
||||
mapRow: (row, index, offset) => ({
|
||||
id: offset + index + 1,
|
||||
username: row.username ?? "-",
|
||||
command: row.command,
|
||||
params: row.params,
|
||||
timestamp: row.timestamp,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export type TradeLogListRow = {
|
||||
@@ -173,17 +158,7 @@ export type TradeLogListRow = {
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export async function loadTradeLogList({
|
||||
searchParams,
|
||||
defaultPerPage = 50,
|
||||
}: ListParamsInput) {
|
||||
await requireLogsView();
|
||||
|
||||
const rawParams = await searchParams;
|
||||
const { search, page, perPage } = parsePageParams(rawParams, defaultPerPage);
|
||||
const offset = (page - 1) * perPage;
|
||||
const like = `%${search}%`;
|
||||
|
||||
export function loadTradeLogList(input: ListParamsInput) {
|
||||
type RawRow = {
|
||||
id: number;
|
||||
timestamp: number;
|
||||
@@ -191,47 +166,19 @@ export async function loadTradeLogList({
|
||||
userTwoName: string | null;
|
||||
};
|
||||
|
||||
const [rawRowsResult, countRows] = search
|
||||
? await Promise.all([
|
||||
rawRows<RawRow>(sql`
|
||||
SELECT t.id, t.timestamp,
|
||||
u1.username AS userOneName, u2.username AS userTwoName
|
||||
FROM room_trade_log t
|
||||
LEFT JOIN users u1 ON u1.id = t.user_one_id
|
||||
LEFT JOIN users u2 ON u2.id = t.user_two_id
|
||||
WHERE u1.username LIKE ${like} OR u2.username LIKE ${like}
|
||||
ORDER BY t.timestamp DESC
|
||||
LIMIT ${perPage} OFFSET ${offset}`),
|
||||
rawRows<{ count: bigint }>(sql`
|
||||
SELECT COUNT(*) AS count
|
||||
FROM room_trade_log t
|
||||
LEFT JOIN users u1 ON u1.id = t.user_one_id
|
||||
LEFT JOIN users u2 ON u2.id = t.user_two_id
|
||||
WHERE u1.username LIKE ${like} OR u2.username LIKE ${like}`),
|
||||
])
|
||||
: await Promise.all([
|
||||
rawRows<RawRow>(sql`
|
||||
SELECT t.id, t.timestamp,
|
||||
u1.username AS userOneName, u2.username AS userTwoName
|
||||
FROM room_trade_log t
|
||||
LEFT JOIN users u1 ON u1.id = t.user_one_id
|
||||
LEFT JOIN users u2 ON u2.id = t.user_two_id
|
||||
ORDER BY t.timestamp DESC
|
||||
LIMIT ${perPage} OFFSET ${offset}`),
|
||||
rawRows<{ count: bigint }>(sql`
|
||||
SELECT COUNT(*) AS count FROM room_trade_log`),
|
||||
]);
|
||||
|
||||
const total = Number(countRows[0]?.count ?? 0);
|
||||
const pagination = calcPagination(total, page, perPage);
|
||||
const rows: TradeLogListRow[] = rawRowsResult.map((row) => ({
|
||||
id: row.id,
|
||||
userOneName: row.userOneName ?? "-",
|
||||
userTwoName: row.userTwoName ?? "-",
|
||||
// room_trade_log only stores completed trades; no status column exists
|
||||
status: "completed",
|
||||
timestamp: row.timestamp,
|
||||
}));
|
||||
|
||||
return { rows, ...pagination };
|
||||
return loadLogList<RawRow, TradeLogListRow>(input, {
|
||||
select: sql`SELECT t.id, t.timestamp, u1.username AS userOneName, u2.username AS userTwoName`,
|
||||
from: sql`FROM room_trade_log t LEFT JOIN users u1 ON u1.id = t.user_one_id LEFT JOIN users u2 ON u2.id = t.user_two_id`,
|
||||
searchWhere: (like) =>
|
||||
sql`u1.username LIKE ${like} OR u2.username LIKE ${like}`,
|
||||
orderBy: sql`ORDER BY t.timestamp DESC`,
|
||||
mapRow: (row) => ({
|
||||
id: row.id,
|
||||
userOneName: row.userOneName ?? "-",
|
||||
userTwoName: row.userTwoName ?? "-",
|
||||
// room_trade_log only stores completed trades; no status column exists
|
||||
status: "completed",
|
||||
timestamp: row.timestamp,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Eye,
|
||||
EyeOff,
|
||||
RotateCcw,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { saveAdminNavConfig } from "@/actions/admin-nav-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ADMIN_NAV_PINNED_HREFS,
|
||||
type AdminNavConfig,
|
||||
} from "@/lib/admin-nav-config";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type EditableNavGroup = {
|
||||
labelKey: string;
|
||||
items: { href: string; labelKey: string }[];
|
||||
};
|
||||
|
||||
function moveIndex<T>(arr: T[], from: number, to: number): T[] {
|
||||
if (to < 0 || to >= arr.length || from === to) return arr;
|
||||
const next = [...arr];
|
||||
const [item] = next.splice(from, 1);
|
||||
if (item === undefined) return arr;
|
||||
next.splice(to, 0, item);
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildConfig(
|
||||
groups: EditableNavGroup[],
|
||||
hiddenGroups: Set<string>,
|
||||
hiddenItems: Set<string>,
|
||||
): Required<AdminNavConfig> {
|
||||
return {
|
||||
groupOrder: groups.map((g) => g.labelKey),
|
||||
hiddenGroups: [...hiddenGroups],
|
||||
hiddenItems: [...hiddenItems].filter((h) => !ADMIN_NAV_PINNED_HREFS.has(h)),
|
||||
itemOrder: Object.fromEntries(
|
||||
groups.map((g) => [g.labelKey, g.items.map((i) => i.href)]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function AdminMenuEditor({
|
||||
catalog,
|
||||
initialConfig,
|
||||
canEdit,
|
||||
}: {
|
||||
catalog: EditableNavGroup[];
|
||||
initialConfig: AdminNavConfig;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const tNav = useTranslations("pages.admin.nav");
|
||||
const t = useTranslations("pages.admin.menu");
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const initialGroups = useMemo(() => {
|
||||
const order = initialConfig.groupOrder ?? [];
|
||||
const byKey = new Map(catalog.map((g) => [g.labelKey, g]));
|
||||
const ordered: EditableNavGroup[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const key of order) {
|
||||
const g = byKey.get(key);
|
||||
if (!g || seen.has(key)) continue;
|
||||
const itemOrder = initialConfig.itemOrder?.[key] ?? [];
|
||||
const itemsByHref = new Map(g.items.map((i) => [i.href, i]));
|
||||
const items = [
|
||||
...itemOrder
|
||||
.map((href) => itemsByHref.get(href))
|
||||
.filter((i): i is NonNullable<typeof i> => Boolean(i)),
|
||||
...g.items.filter((i) => !itemOrder.includes(i.href)),
|
||||
];
|
||||
ordered.push({ labelKey: g.labelKey, items });
|
||||
seen.add(key);
|
||||
}
|
||||
for (const g of catalog) {
|
||||
if (seen.has(g.labelKey)) continue;
|
||||
ordered.push({ ...g, items: [...g.items] });
|
||||
}
|
||||
return ordered;
|
||||
}, [catalog, initialConfig]);
|
||||
|
||||
const [groups, setGroups] = useState(initialGroups);
|
||||
const [hiddenGroups, setHiddenGroups] = useState(
|
||||
() => new Set(initialConfig.hiddenGroups ?? []),
|
||||
);
|
||||
const [hiddenItems, setHiddenItems] = useState(
|
||||
() => new Set(initialConfig.hiddenItems ?? []),
|
||||
);
|
||||
|
||||
function reset() {
|
||||
setGroups(catalog.map((g) => ({ ...g, items: [...g.items] })));
|
||||
setHiddenGroups(new Set());
|
||||
setHiddenItems(new Set());
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!canEdit) return;
|
||||
const config = buildConfig(groups, hiddenGroups, hiddenItems);
|
||||
startTransition(async () => {
|
||||
const res = await saveAdminNavConfig(config);
|
||||
if (res.ok) {
|
||||
toast.success(t("saved"));
|
||||
} else {
|
||||
toast.error(res.error ?? t("saveFailed"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{canEdit ? (
|
||||
<>
|
||||
<Button type="button" onClick={save} disabled={pending}>
|
||||
<Save className="h-4 w-4" />
|
||||
{pending ? t("saving") : t("save")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={reset}
|
||||
disabled={pending}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
{t("reset")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t("viewOnly")}</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground w-full sm:w-auto sm:ml-auto">
|
||||
{t("hint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{groups.map((group, gi) => {
|
||||
const groupHidden = hiddenGroups.has(group.labelKey);
|
||||
return (
|
||||
<section
|
||||
key={group.labelKey}
|
||||
className={cn(
|
||||
"rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3",
|
||||
groupHidden && "opacity-55",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||
<span className="font-semibold text-sm uppercase tracking-wide flex-1 min-w-0">
|
||||
{tNav(group.labelKey)}
|
||||
</span>
|
||||
{canEdit && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={gi === 0}
|
||||
aria-label={t("moveUp")}
|
||||
onClick={() =>
|
||||
setGroups((prev) => moveIndex(prev, gi, gi - 1))
|
||||
}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={gi === groups.length - 1}
|
||||
aria-label={t("moveDown")}
|
||||
onClick={() =>
|
||||
setGroups((prev) => moveIndex(prev, gi, gi + 1))
|
||||
}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={groupHidden ? t("showGroup") : t("hideGroup")}
|
||||
onClick={() =>
|
||||
setHiddenGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(group.labelKey))
|
||||
next.delete(group.labelKey);
|
||||
else next.add(group.labelKey);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
{groupHidden ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1">
|
||||
{group.items.map((item, ii) => {
|
||||
const pinned = ADMIN_NAV_PINNED_HREFS.has(item.href);
|
||||
const itemHidden = !pinned && hiddenItems.has(item.href);
|
||||
return (
|
||||
<li
|
||||
key={item.href}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-2 rounded-md px-2 py-1.5 text-sm",
|
||||
"bg-[var(--admin-canvas)]/60",
|
||||
itemHidden && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className="flex-1 min-w-0 truncate">
|
||||
{tNav(item.labelKey)}
|
||||
<span className="ml-2 text-xs text-muted-foreground font-mono">
|
||||
{item.href}
|
||||
</span>
|
||||
{pinned && (
|
||||
<span className="ml-2 text-[0.65rem] uppercase tracking-wide text-muted-foreground">
|
||||
{t("pinned")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{canEdit && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={ii === 0}
|
||||
aria-label={t("moveUp")}
|
||||
onClick={() =>
|
||||
setGroups((prev) =>
|
||||
prev.map((g, idx) =>
|
||||
idx === gi
|
||||
? {
|
||||
...g,
|
||||
items: moveIndex(g.items, ii, ii - 1),
|
||||
}
|
||||
: g,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={ii === group.items.length - 1}
|
||||
aria-label={t("moveDown")}
|
||||
onClick={() =>
|
||||
setGroups((prev) =>
|
||||
prev.map((g, idx) =>
|
||||
idx === gi
|
||||
? {
|
||||
...g,
|
||||
items: moveIndex(g.items, ii, ii + 1),
|
||||
}
|
||||
: g,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={pinned}
|
||||
aria-label={
|
||||
itemHidden ? t("showItem") : t("hideItem")
|
||||
}
|
||||
onClick={() => {
|
||||
if (pinned) return;
|
||||
setHiddenItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.href)) next.delete(item.href);
|
||||
else next.add(item.href);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{itemHidden || pinned ? (
|
||||
itemHidden ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 opacity-40" />
|
||||
)
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { ListOrdered } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { ADMIN_NAV_GROUPS } from "@/lib/admin-nav";
|
||||
import {
|
||||
ADMIN_NAV_CONFIG_KEY,
|
||||
parseAdminNavConfig,
|
||||
} from "@/lib/admin-nav-config";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { AdminMenuEditor } from "./admin-menu-editor";
|
||||
|
||||
export const metadata = { title: "Admin menu" };
|
||||
|
||||
export default async function AdminMenuPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
const canEdit = canAccess(
|
||||
permissions,
|
||||
PERMS.SETTINGS_EDIT,
|
||||
session.user.rank,
|
||||
);
|
||||
const t = await getTranslations("pages.admin.menu");
|
||||
|
||||
const catalog = ADMIN_NAV_GROUPS.map((g) => ({
|
||||
labelKey: g.labelKey,
|
||||
items: g.items.map((i) => ({ href: i.href, labelKey: i.labelKey })),
|
||||
}));
|
||||
const initialConfig = parseAdminNavConfig(
|
||||
await siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminPageShell
|
||||
icon={ListOrdered}
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
>
|
||||
<AdminMenuEditor
|
||||
catalog={catalog}
|
||||
initialConfig={initialConfig}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
const CFH_STATES: Record<number, { label: string; class: string }> = {
|
||||
0: {
|
||||
@@ -130,7 +131,7 @@ export function CfhDetail({
|
||||
</div>
|
||||
<h1 className="text-xl font-bold">Call For Help Report</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(ticket.timestamp * 1000).toLocaleString()} · Room #
|
||||
{formatDate(new Date(ticket.timestamp * 1000))} · Room #
|
||||
{ticket.roomId}
|
||||
</p>
|
||||
</div>
|
||||
@@ -317,7 +318,7 @@ export function CfhDetail({
|
||||
<p className="font-medium">{ban.reason || "No reason"}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{ban.type} · by {ban.staff} ·{" "}
|
||||
{new Date(ban.timestamp * 1000).toLocaleDateString()}
|
||||
{formatDate(new Date(ban.timestamp * 1000), "date")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -431,9 +432,7 @@ export function CfhDetail({
|
||||
<CardContent className="space-y-2 text-xs text-muted-foreground">
|
||||
<div className="flex justify-between">
|
||||
<span>Created</span>
|
||||
<span>
|
||||
{new Date(ticket.timestamp * 1000).toLocaleString()}
|
||||
</span>
|
||||
<span>{formatDate(new Date(ticket.timestamp * 1000))}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Room</span>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
User,
|
||||
WebsiteTicket,
|
||||
} from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export default async function ModTeamPage() {
|
||||
@@ -169,7 +170,7 @@ export default async function ModTeamPage() {
|
||||
{member.online === "1"
|
||||
? "Online"
|
||||
: member.lastOnline
|
||||
? new Date(member.lastOnline * 1000).toLocaleDateString()
|
||||
? formatDate(new Date(member.lastOnline * 1000), "date")
|
||||
: "—"}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { getAvatarUrl } from "@/lib/imager";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
@@ -123,7 +124,7 @@ export function OnlineTable({ data }: OnlineTableProps) {
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
Last updated: {formatDate(lastUpdated, "time")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminWorkQueue } from "@/components/admin/admin-work-queue";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import Link from "@/components/link";
|
||||
import { unixNow } from "@/lib/bans";
|
||||
@@ -126,6 +127,7 @@ export default async function AdminDashboard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminWorkQueue />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<section className="admin-card p-5">
|
||||
<h2 className="text-sm font-bold text-[var(--admin-text)] mb-4 admin-section-title">
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Crown,
|
||||
Loader2,
|
||||
Save,
|
||||
Search,
|
||||
Settings,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
saveRank as saveRankAction,
|
||||
setCmsPermissions,
|
||||
} from "@/actions/permissions";
|
||||
import { SearchInput } from "@/components/admin/search-input";
|
||||
import Link from "@/components/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -347,12 +347,15 @@ export function RankEditClient({
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<Label className="text-sm">Hidden Rank</Label>
|
||||
<Label htmlFor="rank-hidden_rank" className="text-sm">
|
||||
Hidden Rank
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Hide from public
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="rank-hidden_rank"
|
||||
checked={general.hidden_rank === "1"}
|
||||
onCheckedChange={(v) =>
|
||||
setGeneral({ ...general, hidden_rank: v ? "1" : "0" })
|
||||
@@ -361,12 +364,15 @@ export function RankEditClient({
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<Label className="text-sm">Log Commands</Label>
|
||||
<Label htmlFor="rank-log_commands" className="text-sm">
|
||||
Log Commands
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Record to audit log
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="rank-log_commands"
|
||||
checked={general.log_commands === "1"}
|
||||
onCheckedChange={(v) =>
|
||||
setGeneral({ ...general, log_commands: v ? "1" : "0" })
|
||||
@@ -521,15 +527,11 @@ export function RankEditClient({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search permissions..."
|
||||
value={permSearch}
|
||||
onChange={(e) => setPermSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<SearchInput
|
||||
placeholder="Search permissions..."
|
||||
value={permSearch}
|
||||
onChange={setPermSearch}
|
||||
/>
|
||||
|
||||
<PermSection title="Commands" keys={cats.commands} sectionKey="cmd" />
|
||||
<PermSection title="Account" keys={cats.account} sectionKey="acc" />
|
||||
@@ -606,15 +608,11 @@ export function RankEditClient({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search CMS permissions..."
|
||||
value={cmsSearch}
|
||||
onChange={(e) => setCmsSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<SearchInput
|
||||
placeholder="Search CMS permissions..."
|
||||
value={cmsSearch}
|
||||
onChange={setCmsSearch}
|
||||
/>
|
||||
|
||||
<div className="space-y-1">
|
||||
{filteredCms.map((perm) => {
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { formatNumber } from "@/lib/format-date";
|
||||
|
||||
interface RankSummary {
|
||||
id: number;
|
||||
@@ -129,8 +130,7 @@ export function PermissionsList({
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-muted-foreground">
|
||||
{ranks.length} ranks · {totalUsers.toLocaleString()} users
|
||||
total
|
||||
{ranks.length} ranks · {formatNumber(totalUsers)} users total
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
@@ -212,7 +212,7 @@ export function PermissionsList({
|
||||
{/* User count */}
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Users className="h-3 w-3" />
|
||||
{rank.userCount.toLocaleString()} users
|
||||
{formatNumber(rank.userCount)} users
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
@@ -9,6 +9,7 @@ import Link from "@/components/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { CameraWeb, db, User } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export default async function AdminPhotos({
|
||||
@@ -133,11 +134,7 @@ export default async function AdminPhotos({
|
||||
>
|
||||
{t("byUser", { username })}
|
||||
</Link>{" "}
|
||||
·{" "}
|
||||
{new Date(p.timestamp * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
.replace("T", " ")}
|
||||
· {formatDate(new Date(p.timestamp * 1000))}
|
||||
</p>
|
||||
{canEdit ? (
|
||||
<form
|
||||
|
||||
@@ -146,19 +146,23 @@ export function PollForm({ defaultValues }: PollFormProps) {
|
||||
<div className="flex gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="poll-show-results"
|
||||
checked={form.watch("showResults") === 1}
|
||||
onCheckedChange={(v) => form.setValue("showResults", v ? 1 : 0)}
|
||||
/>
|
||||
<Label>{t("formShowResults")}</Label>
|
||||
<Label htmlFor="poll-show-results">{t("formShowResults")}</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="poll-multiple-choice"
|
||||
checked={form.watch("multipleChoice") === 1}
|
||||
onCheckedChange={(v) =>
|
||||
form.setValue("multipleChoice", v ? 1 : 0)
|
||||
}
|
||||
/>
|
||||
<Label>{t("formAllowMultiple")}</Label>
|
||||
<Label htmlFor="poll-multiple-choice">
|
||||
{t("formAllowMultiple")}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import Link from "@/components/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
interface PollRow {
|
||||
@@ -93,7 +94,7 @@ export function PollsTable({ data }: PollsTableProps) {
|
||||
sortable: true,
|
||||
render: (value) => (
|
||||
<span className="text-sm">
|
||||
{new Date(value as unknown as string).toLocaleDateString()}
|
||||
{formatDate(new Date(value as unknown as string), "date")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
import { PrefixDialog } from "./prefix-dialog";
|
||||
import {
|
||||
@@ -314,7 +315,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
|
||||
<TableCell>{prefix.active ? "Yes" : "No"}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{prefix.created_at
|
||||
? new Date(prefix.created_at).toLocaleDateString()
|
||||
? formatDate(new Date(prefix.created_at), "date")
|
||||
: "—"}
|
||||
</TableCell>
|
||||
{canEdit && (
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Search, Trash2 } from "lucide-react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
createSetting,
|
||||
deleteSetting,
|
||||
updateSetting,
|
||||
} from "@/actions/admin-settings";
|
||||
import { SearchInput } from "@/components/admin/search-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { MANAGED_SETTING_KEYS } from "./cms-settings-config";
|
||||
@@ -64,15 +65,11 @@ export function AdvancedSettingsPanel({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search keys, values, comments…"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<SearchInput
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Search keys, values, comments…"
|
||||
/>
|
||||
|
||||
{canEdit ? (
|
||||
<form
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useVisiblePolling } from "@/hooks/use-visible-polling";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
|
||||
import { CatalogGitConfiguration } from "./catalog-git-configuration";
|
||||
@@ -25,7 +26,10 @@ export function CatalogExportClient({
|
||||
const [status, setStatus] = useState<ExportStatus | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const refreshing = useRef(false);
|
||||
const refresh = useCallback(async () => {
|
||||
if (refreshing.current) return;
|
||||
refreshing.current = true;
|
||||
try {
|
||||
const response = await adminFetch("/api/admin/catalog-export", {
|
||||
cache: "no-store",
|
||||
@@ -35,15 +39,11 @@ export function CatalogExportClient({
|
||||
setError("");
|
||||
} catch {
|
||||
setError("Unable to load export status");
|
||||
} finally {
|
||||
refreshing.current = false;
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const timer = setInterval(() => {
|
||||
void refresh();
|
||||
}, 15_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh]);
|
||||
useVisiblePolling(refresh, 15000);
|
||||
async function retry() {
|
||||
setBusy(true);
|
||||
try {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export function ColorField({
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
defaultValue,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
defaultValue: string;
|
||||
backgroundColorKey?: string;
|
||||
}) {
|
||||
const [val, setVal] = useState(defaultValue);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg p-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="color"
|
||||
name={name}
|
||||
value={val}
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
className="w-[42px] h-[36px] p-0 border-none bg-none cursor-pointer flex-shrink-0 rounded"
|
||||
/>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-semibold text-[var(--admin-text)] leading-tight">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] leading-snug line-clamp-2">
|
||||
{description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
import type { EffectKey, LayoutKey, MediaKey } from "@/lib/theme-blocks";
|
||||
import type { ThemeColorKey } from "@/lib/theme-presets";
|
||||
export interface ScopeInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
parentId: number | null;
|
||||
siteDomain: string | null;
|
||||
routePath: string | null;
|
||||
moduleId: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface ScopeValues {
|
||||
light: Record<string, string>;
|
||||
dark: Record<string, string>;
|
||||
blocks: Record<string, string>;
|
||||
layout: Record<string, string | undefined>;
|
||||
effects: Record<string, string | undefined>;
|
||||
media: Record<string, string | undefined>;
|
||||
custom: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
// ─── Color fields ────────────────────────────────────────────────────────────
|
||||
|
||||
export const COLOR_FIELDS: Array<{
|
||||
key: ThemeColorKey;
|
||||
label: string;
|
||||
def: string;
|
||||
desc: string;
|
||||
bgKey?: string;
|
||||
}> = [
|
||||
{
|
||||
key: "color_background",
|
||||
label: "Page background",
|
||||
def: "#f8fafc",
|
||||
desc: "Main page background",
|
||||
},
|
||||
{
|
||||
key: "color_surface",
|
||||
label: "Card / surface",
|
||||
def: "#ffffff",
|
||||
desc: "Cards, panels, elevated surfaces",
|
||||
},
|
||||
{
|
||||
key: "color_dropdown",
|
||||
label: "Dropdown",
|
||||
def: "#ffffff",
|
||||
desc: "Dropdown menus",
|
||||
},
|
||||
{
|
||||
key: "color_navbar",
|
||||
label: "Navbar",
|
||||
def: "#ffffff",
|
||||
desc: "Navigation bar background",
|
||||
},
|
||||
{
|
||||
key: "color_navbar_text",
|
||||
label: "Navbar text",
|
||||
def: "#1e293b",
|
||||
desc: "Nav text color",
|
||||
bgKey: "color_navbar",
|
||||
},
|
||||
{
|
||||
key: "color_text",
|
||||
label: "Body text",
|
||||
def: "#0f172a",
|
||||
desc: "Main text color",
|
||||
bgKey: "color_surface",
|
||||
},
|
||||
{
|
||||
key: "color_text_muted",
|
||||
label: "Muted text",
|
||||
def: "#64748b",
|
||||
desc: "Secondary text",
|
||||
bgKey: "color_surface",
|
||||
},
|
||||
{
|
||||
key: "color_primary",
|
||||
label: "Primary accent",
|
||||
def: "#f59e0b",
|
||||
desc: "Main brand color",
|
||||
},
|
||||
{
|
||||
key: "color_accent",
|
||||
label: "Secondary accent",
|
||||
def: "#10b981",
|
||||
desc: "Secondary brand color",
|
||||
},
|
||||
{
|
||||
key: "color_success",
|
||||
label: "Success",
|
||||
def: "#16a34a",
|
||||
desc: "Success states",
|
||||
},
|
||||
{
|
||||
key: "color_warning",
|
||||
label: "Warning",
|
||||
def: "#eab308",
|
||||
desc: "Warning states",
|
||||
},
|
||||
{ key: "color_error", label: "Error", def: "#ef4444", desc: "Error states" },
|
||||
{
|
||||
key: "color_info",
|
||||
label: "Info",
|
||||
def: "#0ea5e9",
|
||||
desc: "Information states",
|
||||
},
|
||||
{
|
||||
key: "button_primary_color",
|
||||
label: "Button primary",
|
||||
def: "#f59e0b",
|
||||
desc: "Primary button background",
|
||||
},
|
||||
{
|
||||
key: "button_text_color",
|
||||
label: "Button primary text",
|
||||
def: "#1e293b",
|
||||
desc: "Primary button text",
|
||||
},
|
||||
{
|
||||
key: "button_secondary_color",
|
||||
label: "Button secondary",
|
||||
def: "#22c55e",
|
||||
desc: "Secondary button background",
|
||||
},
|
||||
{
|
||||
key: "button_secondary_text_color",
|
||||
label: "Button secondary text",
|
||||
def: "#ffffff",
|
||||
desc: "Secondary button text",
|
||||
},
|
||||
{
|
||||
key: "button_danger_color",
|
||||
label: "Button danger",
|
||||
def: "#ef4444",
|
||||
desc: "Danger button background",
|
||||
},
|
||||
{
|
||||
key: "button_danger_text_color",
|
||||
label: "Button danger text",
|
||||
def: "#ffffff",
|
||||
desc: "Danger button text",
|
||||
},
|
||||
{
|
||||
key: "button_outline_color",
|
||||
label: "Button outline",
|
||||
def: "#eeb425",
|
||||
desc: "Outline button border",
|
||||
},
|
||||
{
|
||||
key: "button_outline_text_color",
|
||||
label: "Button outline text",
|
||||
def: "#1a1a2e",
|
||||
desc: "Outline button text",
|
||||
},
|
||||
{
|
||||
key: "link_color",
|
||||
label: "Link color",
|
||||
def: "#b77900",
|
||||
desc: "Link text color",
|
||||
},
|
||||
{
|
||||
key: "link_hover_color",
|
||||
label: "Link hover",
|
||||
def: "#8a5b00",
|
||||
desc: "Link hover color",
|
||||
},
|
||||
{
|
||||
key: "border_color",
|
||||
label: "Border color",
|
||||
def: "#eeb425",
|
||||
desc: "General borders",
|
||||
},
|
||||
{
|
||||
key: "panel_border_color",
|
||||
label: "Panel border",
|
||||
def: "#e9b124",
|
||||
desc: "Panel borders",
|
||||
},
|
||||
{
|
||||
key: "gradient_from",
|
||||
label: "Gradient start",
|
||||
def: "#f59e0b",
|
||||
desc: "Gradient from color",
|
||||
},
|
||||
{
|
||||
key: "gradient_to",
|
||||
label: "Gradient end",
|
||||
def: "#10b981",
|
||||
desc: "Gradient to color",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Layout fields ───────────────────────────────────────────────────────────
|
||||
|
||||
export const LAYOUT_FIELDS: Array<{
|
||||
key: LayoutKey;
|
||||
label: string;
|
||||
def: string;
|
||||
desc: string;
|
||||
type?: string;
|
||||
}> = [
|
||||
{
|
||||
key: "layout:max_width",
|
||||
label: "Max Width",
|
||||
def: "1280px",
|
||||
desc: "Maximum content width",
|
||||
},
|
||||
{
|
||||
key: "layout:spacing",
|
||||
label: "Grid Spacing",
|
||||
def: "1.5rem",
|
||||
desc: "Gap between grid items",
|
||||
},
|
||||
{
|
||||
key: "layout:grid_columns",
|
||||
label: "Grid Columns",
|
||||
def: "12",
|
||||
desc: "Number of grid columns",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
key: "layout:content_padding",
|
||||
label: "Content Padding",
|
||||
def: "0.75rem",
|
||||
desc: "Horizontal padding on content",
|
||||
},
|
||||
{
|
||||
key: "layout:navbar_height",
|
||||
label: "Navbar Height",
|
||||
def: "56px",
|
||||
desc: "Height of the navigation bar",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Effect fields ───────────────────────────────────────────────────────────
|
||||
|
||||
export const EFFECT_FIELDS: Array<{
|
||||
key: EffectKey;
|
||||
label: string;
|
||||
def: string;
|
||||
desc: string;
|
||||
type?: string;
|
||||
}> = [
|
||||
{
|
||||
key: "effect:card_shadow",
|
||||
label: "Card Shadow",
|
||||
def: "0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)",
|
||||
desc: "Default card box shadow",
|
||||
},
|
||||
{
|
||||
key: "effect:card_shadow_hover",
|
||||
label: "Card Shadow (hover)",
|
||||
def: "0 8px 24px rgba(0,0,0,0.1), 0 2px 6px rgba(0,0,0,0.06)",
|
||||
desc: "Card box shadow on hover",
|
||||
},
|
||||
{
|
||||
key: "effect:border_style",
|
||||
label: "Border Style",
|
||||
def: "solid",
|
||||
desc: "CSS border-style (solid, dashed, dotted, etc.)",
|
||||
},
|
||||
{
|
||||
key: "effect:border_width",
|
||||
label: "Border Width",
|
||||
def: "1px",
|
||||
desc: "Default border width",
|
||||
},
|
||||
{
|
||||
key: "effect:transition_duration",
|
||||
label: "Transition Duration",
|
||||
def: "200ms",
|
||||
desc: "Duration for hover/transition effects",
|
||||
},
|
||||
{
|
||||
key: "effect:animation_scale",
|
||||
label: "Animation Scale",
|
||||
def: "1",
|
||||
desc: "Scale factor for animations (1 = normal)",
|
||||
type: "number",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Media fields ────────────────────────────────────────────────────────────
|
||||
|
||||
export const MEDIA_FIELDS: Array<{
|
||||
key: MediaKey;
|
||||
label: string;
|
||||
def: string;
|
||||
desc: string;
|
||||
type?: string;
|
||||
}> = [
|
||||
{
|
||||
key: "media:logo_url",
|
||||
label: "Logo URL",
|
||||
def: "",
|
||||
desc: "Custom logo image URL (overrides default)",
|
||||
},
|
||||
{
|
||||
key: "media:background_image",
|
||||
label: "Background Image",
|
||||
def: "",
|
||||
desc: "Page background image URL",
|
||||
},
|
||||
{
|
||||
key: "media:background_position",
|
||||
label: "Background Position",
|
||||
def: "center",
|
||||
desc: "CSS background-position",
|
||||
},
|
||||
{
|
||||
key: "media:background_size",
|
||||
label: "Background Size",
|
||||
def: "cover",
|
||||
desc: "CSS background-size (cover, contain, auto)",
|
||||
},
|
||||
{
|
||||
key: "media:background_repeat",
|
||||
label: "Background Repeat",
|
||||
def: "no-repeat",
|
||||
desc: "CSS background-repeat",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getVal(
|
||||
values: ScopeValues | null,
|
||||
key: string,
|
||||
mode: "light" | "dark",
|
||||
): string {
|
||||
if (!values) return "";
|
||||
if (key.startsWith("block:")) return values.blocks[key] ?? "";
|
||||
if (key.startsWith("layout:")) return values.layout[key] ?? "";
|
||||
if (key.startsWith("effect:")) return values.effects[key] ?? "";
|
||||
if (key.startsWith("media:")) return values.media[key] ?? "";
|
||||
if (key.startsWith("custom:")) return values.custom[key] ?? "";
|
||||
if (mode === "dark") return values.dark[key] ?? "";
|
||||
return values.light[key] ?? "";
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
"use client";
|
||||
import { BLOCK_KEY_PREFIX, getBlocksByCategory } from "@/lib/theme-blocks";
|
||||
import { ColorField } from "./color-field";
|
||||
import {
|
||||
COLOR_FIELDS,
|
||||
EFFECT_FIELDS,
|
||||
getVal,
|
||||
LAYOUT_FIELDS,
|
||||
MEDIA_FIELDS,
|
||||
type ScopeValues,
|
||||
} from "./theme-editor-fields";
|
||||
// ─── Colors Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function ColorsTab({
|
||||
values,
|
||||
mode,
|
||||
}: {
|
||||
values: ScopeValues;
|
||||
mode: "light" | "dark";
|
||||
}) {
|
||||
return (
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Colors ({mode} mode)
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Only values you set here override the parent scope. Leave empty to
|
||||
inherit.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{COLOR_FIELDS.map((field) => (
|
||||
<ColorField
|
||||
key={`${field.key}-${mode}`}
|
||||
name={`${field.key}_${mode}`}
|
||||
label={field.label}
|
||||
description={field.desc}
|
||||
defaultValue={getVal(values, field.key, mode) || field.def}
|
||||
backgroundColorKey={
|
||||
field.bgKey ? `${field.bgKey}_${mode}` : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Blocks Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlocksTab({ values }: { values: ScopeValues }) {
|
||||
const grouped = getBlocksByCategory();
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
chrome: "Site Chrome",
|
||||
layout: "Layout",
|
||||
components: "Components",
|
||||
effects: "Effects",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(grouped).map(([category, blocks]) => (
|
||||
<div key={category} className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-1">
|
||||
{categoryLabels[category] ?? category}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Toggle visibility of blocks in this scope. Hidden blocks use{" "}
|
||||
<code className="bg-[var(--admin-surface-elevated)] px-1 rounded">
|
||||
display: none
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{blocks.map((block) => {
|
||||
const key = `${BLOCK_KEY_PREFIX}${block.id}`;
|
||||
const currentVal = values.blocks[key];
|
||||
const isChecked =
|
||||
currentVal === "true" ||
|
||||
(currentVal === undefined && block.defaultVisible);
|
||||
|
||||
return (
|
||||
<label
|
||||
key={block.id}
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--admin-surface-elevated)] cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={key}
|
||||
defaultChecked={isChecked}
|
||||
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-[var(--admin-accent)]"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm text-[var(--admin-text)]">
|
||||
{block.label}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] ml-2">
|
||||
{block.description}
|
||||
</span>
|
||||
</div>
|
||||
<code className="text-[10px] text-[var(--admin-text-muted)] font-mono">
|
||||
{block.selector}
|
||||
</code>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Layout Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function LayoutTab({ values }: { values: ScopeValues }) {
|
||||
return (
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Layout Settings
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Control spacing, width, and grid settings for this scope. Values are
|
||||
applied as CSS custom properties.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{LAYOUT_FIELDS.map((field) => (
|
||||
<div key={field.key} className="rounded-lg p-2">
|
||||
<label className="block text-sm font-semibold text-[var(--admin-text)] mb-1">
|
||||
{field.label}
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-normal">
|
||||
{field.desc}
|
||||
</p>
|
||||
<input
|
||||
type={field.type || "text"}
|
||||
name={field.key}
|
||||
defaultValue={getVal(values, field.key, "light") || field.def}
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Effects Tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function EffectsTab({ values }: { values: ScopeValues }) {
|
||||
return (
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Effects & Transitions
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Control shadows, borders, and animation timing for this scope.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{EFFECT_FIELDS.map((field) => (
|
||||
<div key={field.key} className="rounded-lg p-2">
|
||||
<label className="block text-sm font-semibold text-[var(--admin-text)] mb-1">
|
||||
{field.label}
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-normal">
|
||||
{field.desc}
|
||||
</p>
|
||||
<input
|
||||
type={field.type || "text"}
|
||||
name={field.key}
|
||||
defaultValue={getVal(values, field.key, "light") || field.def}
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Media Tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function MediaTab({ values }: { values: ScopeValues }) {
|
||||
return (
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Media & Backgrounds
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Set custom logos, background images, and background properties for this
|
||||
scope.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{MEDIA_FIELDS.map((field) => (
|
||||
<div key={field.key} className="rounded-lg p-2">
|
||||
<label className="block text-sm font-semibold text-[var(--admin-text)] mb-1">
|
||||
{field.label}
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-normal">
|
||||
{field.desc}
|
||||
</p>
|
||||
<input
|
||||
type={field.type || "text"}
|
||||
name={field.key}
|
||||
defaultValue={getVal(values, field.key, "light") || field.def}
|
||||
placeholder={field.def}
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Custom Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function CustomTab({ values }: { values: ScopeValues }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Custom CSS
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Add custom CSS that applies within this scope. Use{" "}
|
||||
<code className="bg-[var(--admin-surface-elevated)] px-1 rounded">
|
||||
&{" "}
|
||||
</code>{" "}
|
||||
as a placeholder for the scope selector (e.g.{" "}
|
||||
<code className="bg-[var(--admin-surface-elevated)] px-1 rounded">
|
||||
& .content-card {"{"} border: 2px solid red; {"}"}
|
||||
</code>
|
||||
).
|
||||
</p>
|
||||
<textarea
|
||||
name="custom:css"
|
||||
defaultValue={getVal(values, "custom:css", "light")}
|
||||
rows={8}
|
||||
placeholder={".content-card { border-radius: 20px; }"}
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono resize-y"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Custom HTML
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Add custom HTML that injects into the page for this scope. Use for
|
||||
custom banners, scripts, or content blocks.
|
||||
</p>
|
||||
<textarea
|
||||
name="custom:html"
|
||||
defaultValue={getVal(values, "custom:html", "light")}
|
||||
rows={6}
|
||||
placeholder='<div class="my-banner">Welcome!</div>'
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono resize-y"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,361 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
import {
|
||||
deleteScope,
|
||||
duplicateScope,
|
||||
saveScopeValues,
|
||||
} from "@/actions/theme-builder";
|
||||
import { ColorField } from "@/components/admin/color-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ScopeInfo, ScopeValues } from "./theme-editor-fields";
|
||||
import {
|
||||
BlocksTab,
|
||||
ColorsTab,
|
||||
CustomTab,
|
||||
EffectsTab,
|
||||
LayoutTab,
|
||||
MediaTab,
|
||||
} from "./theme-editor-tabs";
|
||||
BLOCK_KEY_PREFIX,
|
||||
type EffectKey,
|
||||
getBlocksByCategory,
|
||||
type LayoutKey,
|
||||
type MediaKey,
|
||||
} from "@/lib/theme-blocks";
|
||||
import type { ThemeColorKey } from "@/lib/theme-presets";
|
||||
|
||||
interface ScopeInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
parentId: number | null;
|
||||
siteDomain: string | null;
|
||||
routePath: string | null;
|
||||
moduleId: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface ScopeValues {
|
||||
light: Record<string, string>;
|
||||
dark: Record<string, string>;
|
||||
blocks: Record<string, string>;
|
||||
layout: Record<string, string | undefined>;
|
||||
effects: Record<string, string | undefined>;
|
||||
media: Record<string, string | undefined>;
|
||||
custom: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
// ─── Color fields ────────────────────────────────────────────────────────────
|
||||
|
||||
const COLOR_FIELDS: Array<{
|
||||
key: ThemeColorKey;
|
||||
label: string;
|
||||
def: string;
|
||||
desc: string;
|
||||
bgKey?: string;
|
||||
}> = [
|
||||
{
|
||||
key: "color_background",
|
||||
label: "Page background",
|
||||
def: "#f8fafc",
|
||||
desc: "Main page background",
|
||||
},
|
||||
{
|
||||
key: "color_surface",
|
||||
label: "Card / surface",
|
||||
def: "#ffffff",
|
||||
desc: "Cards, panels, elevated surfaces",
|
||||
},
|
||||
{
|
||||
key: "color_dropdown",
|
||||
label: "Dropdown",
|
||||
def: "#ffffff",
|
||||
desc: "Dropdown menus",
|
||||
},
|
||||
{
|
||||
key: "color_navbar",
|
||||
label: "Navbar",
|
||||
def: "#ffffff",
|
||||
desc: "Navigation bar background",
|
||||
},
|
||||
{
|
||||
key: "color_navbar_text",
|
||||
label: "Navbar text",
|
||||
def: "#1e293b",
|
||||
desc: "Nav text color",
|
||||
bgKey: "color_navbar",
|
||||
},
|
||||
{
|
||||
key: "color_text",
|
||||
label: "Body text",
|
||||
def: "#0f172a",
|
||||
desc: "Main text color",
|
||||
bgKey: "color_surface",
|
||||
},
|
||||
{
|
||||
key: "color_text_muted",
|
||||
label: "Muted text",
|
||||
def: "#64748b",
|
||||
desc: "Secondary text",
|
||||
bgKey: "color_surface",
|
||||
},
|
||||
{
|
||||
key: "color_primary",
|
||||
label: "Primary accent",
|
||||
def: "#f59e0b",
|
||||
desc: "Main brand color",
|
||||
},
|
||||
{
|
||||
key: "color_accent",
|
||||
label: "Secondary accent",
|
||||
def: "#10b981",
|
||||
desc: "Secondary brand color",
|
||||
},
|
||||
{
|
||||
key: "color_success",
|
||||
label: "Success",
|
||||
def: "#16a34a",
|
||||
desc: "Success states",
|
||||
},
|
||||
{
|
||||
key: "color_warning",
|
||||
label: "Warning",
|
||||
def: "#eab308",
|
||||
desc: "Warning states",
|
||||
},
|
||||
{ key: "color_error", label: "Error", def: "#ef4444", desc: "Error states" },
|
||||
{
|
||||
key: "color_info",
|
||||
label: "Info",
|
||||
def: "#0ea5e9",
|
||||
desc: "Information states",
|
||||
},
|
||||
{
|
||||
key: "button_primary_color",
|
||||
label: "Button primary",
|
||||
def: "#f59e0b",
|
||||
desc: "Primary button background",
|
||||
},
|
||||
{
|
||||
key: "button_text_color",
|
||||
label: "Button primary text",
|
||||
def: "#1e293b",
|
||||
desc: "Primary button text",
|
||||
},
|
||||
{
|
||||
key: "button_secondary_color",
|
||||
label: "Button secondary",
|
||||
def: "#22c55e",
|
||||
desc: "Secondary button background",
|
||||
},
|
||||
{
|
||||
key: "button_secondary_text_color",
|
||||
label: "Button secondary text",
|
||||
def: "#ffffff",
|
||||
desc: "Secondary button text",
|
||||
},
|
||||
{
|
||||
key: "button_danger_color",
|
||||
label: "Button danger",
|
||||
def: "#ef4444",
|
||||
desc: "Danger button background",
|
||||
},
|
||||
{
|
||||
key: "button_danger_text_color",
|
||||
label: "Button danger text",
|
||||
def: "#ffffff",
|
||||
desc: "Danger button text",
|
||||
},
|
||||
{
|
||||
key: "button_outline_color",
|
||||
label: "Button outline",
|
||||
def: "#eeb425",
|
||||
desc: "Outline button border",
|
||||
},
|
||||
{
|
||||
key: "button_outline_text_color",
|
||||
label: "Button outline text",
|
||||
def: "#1a1a2e",
|
||||
desc: "Outline button text",
|
||||
},
|
||||
{
|
||||
key: "link_color",
|
||||
label: "Link color",
|
||||
def: "#b77900",
|
||||
desc: "Link text color",
|
||||
},
|
||||
{
|
||||
key: "link_hover_color",
|
||||
label: "Link hover",
|
||||
def: "#8a5b00",
|
||||
desc: "Link hover color",
|
||||
},
|
||||
{
|
||||
key: "border_color",
|
||||
label: "Border color",
|
||||
def: "#eeb425",
|
||||
desc: "General borders",
|
||||
},
|
||||
{
|
||||
key: "panel_border_color",
|
||||
label: "Panel border",
|
||||
def: "#e9b124",
|
||||
desc: "Panel borders",
|
||||
},
|
||||
{
|
||||
key: "gradient_from",
|
||||
label: "Gradient start",
|
||||
def: "#f59e0b",
|
||||
desc: "Gradient from color",
|
||||
},
|
||||
{
|
||||
key: "gradient_to",
|
||||
label: "Gradient end",
|
||||
def: "#10b981",
|
||||
desc: "Gradient to color",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Layout fields ───────────────────────────────────────────────────────────
|
||||
|
||||
const LAYOUT_FIELDS: Array<{
|
||||
key: LayoutKey;
|
||||
label: string;
|
||||
def: string;
|
||||
desc: string;
|
||||
type?: string;
|
||||
}> = [
|
||||
{
|
||||
key: "layout:max_width",
|
||||
label: "Max Width",
|
||||
def: "1280px",
|
||||
desc: "Maximum content width",
|
||||
},
|
||||
{
|
||||
key: "layout:spacing",
|
||||
label: "Grid Spacing",
|
||||
def: "1.5rem",
|
||||
desc: "Gap between grid items",
|
||||
},
|
||||
{
|
||||
key: "layout:grid_columns",
|
||||
label: "Grid Columns",
|
||||
def: "12",
|
||||
desc: "Number of grid columns",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
key: "layout:content_padding",
|
||||
label: "Content Padding",
|
||||
def: "0.75rem",
|
||||
desc: "Horizontal padding on content",
|
||||
},
|
||||
{
|
||||
key: "layout:navbar_height",
|
||||
label: "Navbar Height",
|
||||
def: "56px",
|
||||
desc: "Height of the navigation bar",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Effect fields ───────────────────────────────────────────────────────────
|
||||
|
||||
const EFFECT_FIELDS: Array<{
|
||||
key: EffectKey;
|
||||
label: string;
|
||||
def: string;
|
||||
desc: string;
|
||||
type?: string;
|
||||
}> = [
|
||||
{
|
||||
key: "effect:card_shadow",
|
||||
label: "Card Shadow",
|
||||
def: "0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)",
|
||||
desc: "Default card box shadow",
|
||||
},
|
||||
{
|
||||
key: "effect:card_shadow_hover",
|
||||
label: "Card Shadow (hover)",
|
||||
def: "0 8px 24px rgba(0,0,0,0.1), 0 2px 6px rgba(0,0,0,0.06)",
|
||||
desc: "Card box shadow on hover",
|
||||
},
|
||||
{
|
||||
key: "effect:border_style",
|
||||
label: "Border Style",
|
||||
def: "solid",
|
||||
desc: "CSS border-style (solid, dashed, dotted, etc.)",
|
||||
},
|
||||
{
|
||||
key: "effect:border_width",
|
||||
label: "Border Width",
|
||||
def: "1px",
|
||||
desc: "Default border width",
|
||||
},
|
||||
{
|
||||
key: "effect:transition_duration",
|
||||
label: "Transition Duration",
|
||||
def: "200ms",
|
||||
desc: "Duration for hover/transition effects",
|
||||
},
|
||||
{
|
||||
key: "effect:animation_scale",
|
||||
label: "Animation Scale",
|
||||
def: "1",
|
||||
desc: "Scale factor for animations (1 = normal)",
|
||||
type: "number",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Media fields ────────────────────────────────────────────────────────────
|
||||
|
||||
const MEDIA_FIELDS: Array<{
|
||||
key: MediaKey;
|
||||
label: string;
|
||||
def: string;
|
||||
desc: string;
|
||||
type?: string;
|
||||
}> = [
|
||||
{
|
||||
key: "media:logo_url",
|
||||
label: "Logo URL",
|
||||
def: "",
|
||||
desc: "Custom logo image URL (overrides default)",
|
||||
},
|
||||
{
|
||||
key: "media:background_image",
|
||||
label: "Background Image",
|
||||
def: "",
|
||||
desc: "Page background image URL",
|
||||
},
|
||||
{
|
||||
key: "media:background_position",
|
||||
label: "Background Position",
|
||||
def: "center",
|
||||
desc: "CSS background-position",
|
||||
},
|
||||
{
|
||||
key: "media:background_size",
|
||||
label: "Background Size",
|
||||
def: "cover",
|
||||
desc: "CSS background-size (cover, contain, auto)",
|
||||
},
|
||||
{
|
||||
key: "media:background_repeat",
|
||||
label: "Background Repeat",
|
||||
def: "no-repeat",
|
||||
desc: "CSS background-repeat",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function getVal(
|
||||
values: ScopeValues | null,
|
||||
key: string,
|
||||
mode: "light" | "dark",
|
||||
): string {
|
||||
if (!values) return "";
|
||||
if (key.startsWith("block:")) return values.blocks[key] ?? "";
|
||||
if (key.startsWith("layout:")) return values.layout[key] ?? "";
|
||||
if (key.startsWith("effect:")) return values.effects[key] ?? "";
|
||||
if (key.startsWith("media:")) return values.media[key] ?? "";
|
||||
if (key.startsWith("custom:")) return values.custom[key] ?? "";
|
||||
if (mode === "dark") return values.dark[key] ?? "";
|
||||
return values.light[key] ?? "";
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
global: "🌍 Global",
|
||||
@@ -110,7 +449,6 @@ export function ThemeEditor({
|
||||
<div className="flex rounded-lg border border-[var(--admin-border)] overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "light"}
|
||||
onClick={() => setMode("light")}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
mode === "light"
|
||||
@@ -122,7 +460,6 @@ export function ThemeEditor({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "dark"}
|
||||
onClick={() => setMode("dark")}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
mode === "dark"
|
||||
@@ -142,7 +479,6 @@ export function ThemeEditor({
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
aria-pressed={tab === t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium whitespace-nowrap transition-colors ${
|
||||
tab === t.id
|
||||
@@ -168,18 +504,276 @@ export function ThemeEditor({
|
||||
{tab === "custom" && <CustomTab values={values} />}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<ThemeSaveButton />
|
||||
<Button type="submit" variant="default">
|
||||
Save Theme Values
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeSaveButton() {
|
||||
const { pending } = useFormStatus();
|
||||
// ─── Colors Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ColorsTab({
|
||||
values,
|
||||
mode,
|
||||
}: {
|
||||
values: ScopeValues;
|
||||
mode: "light" | "dark";
|
||||
}) {
|
||||
return (
|
||||
<Button type="submit" disabled={pending} aria-busy={pending}>
|
||||
{pending ? "Saving theme…" : "Save Theme Values"}
|
||||
</Button>
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Colors ({mode} mode)
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Only values you set here override the parent scope. Leave empty to
|
||||
inherit.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{COLOR_FIELDS.map((field) => (
|
||||
<ColorField
|
||||
key={`${field.key}-${mode}`}
|
||||
name={`${field.key}_${mode}`}
|
||||
label={field.label}
|
||||
description={field.desc}
|
||||
defaultValue={getVal(values, field.key, mode) || field.def}
|
||||
bgValue={
|
||||
field.bgKey
|
||||
? getVal(values, field.bgKey, mode) || undefined
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Blocks Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function BlocksTab({ values }: { values: ScopeValues }) {
|
||||
const grouped = getBlocksByCategory();
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
chrome: "Site Chrome",
|
||||
layout: "Layout",
|
||||
components: "Components",
|
||||
effects: "Effects",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(grouped).map(([category, blocks]) => (
|
||||
<div key={category} className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-1">
|
||||
{categoryLabels[category] ?? category}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Toggle visibility of blocks in this scope. Hidden blocks use{" "}
|
||||
<code className="bg-[var(--admin-surface-elevated)] px-1 rounded">
|
||||
display: none
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{blocks.map((block) => {
|
||||
const key = `${BLOCK_KEY_PREFIX}${block.id}`;
|
||||
const currentVal = values.blocks[key];
|
||||
const isChecked =
|
||||
currentVal === "true" ||
|
||||
(currentVal === undefined && block.defaultVisible);
|
||||
|
||||
return (
|
||||
<label
|
||||
key={block.id}
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--admin-surface-elevated)] cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={key}
|
||||
defaultChecked={isChecked}
|
||||
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-[var(--admin-accent)]"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm text-[var(--admin-text)]">
|
||||
{block.label}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] ml-2">
|
||||
{block.description}
|
||||
</span>
|
||||
</div>
|
||||
<code className="text-[10px] text-[var(--admin-text-muted)] font-mono">
|
||||
{block.selector}
|
||||
</code>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Layout Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function LayoutTab({ values }: { values: ScopeValues }) {
|
||||
return (
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Layout Settings
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Control spacing, width, and grid settings for this scope. Values are
|
||||
applied as CSS custom properties.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{LAYOUT_FIELDS.map((field) => (
|
||||
<div key={field.key} className="rounded-lg p-2">
|
||||
<label className="block text-sm font-semibold text-[var(--admin-text)] mb-1">
|
||||
{field.label}
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-normal">
|
||||
{field.desc}
|
||||
</p>
|
||||
<input
|
||||
type={field.type || "text"}
|
||||
name={field.key}
|
||||
defaultValue={getVal(values, field.key, "light") || field.def}
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Effects Tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
function EffectsTab({ values }: { values: ScopeValues }) {
|
||||
return (
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Effects & Transitions
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Control shadows, borders, and animation timing for this scope.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{EFFECT_FIELDS.map((field) => (
|
||||
<div key={field.key} className="rounded-lg p-2">
|
||||
<label className="block text-sm font-semibold text-[var(--admin-text)] mb-1">
|
||||
{field.label}
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-normal">
|
||||
{field.desc}
|
||||
</p>
|
||||
<input
|
||||
type={field.type || "text"}
|
||||
name={field.key}
|
||||
defaultValue={getVal(values, field.key, "light") || field.def}
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Media Tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
function MediaTab({ values }: { values: ScopeValues }) {
|
||||
return (
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Media & Backgrounds
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Set custom logos, background images, and background properties for this
|
||||
scope.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{MEDIA_FIELDS.map((field) => (
|
||||
<div key={field.key} className="rounded-lg p-2">
|
||||
<label className="block text-sm font-semibold text-[var(--admin-text)] mb-1">
|
||||
{field.label}
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-normal">
|
||||
{field.desc}
|
||||
</p>
|
||||
<input
|
||||
type={field.type || "text"}
|
||||
name={field.key}
|
||||
defaultValue={getVal(values, field.key, "light") || field.def}
|
||||
placeholder={field.def}
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Custom Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function CustomTab({ values }: { values: ScopeValues }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Custom CSS
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Add custom CSS that applies within this scope. Use{" "}
|
||||
<code className="bg-[var(--admin-surface-elevated)] px-1 rounded">
|
||||
&{" "}
|
||||
</code>{" "}
|
||||
as a placeholder for the scope selector (e.g.{" "}
|
||||
<code className="bg-[var(--admin-surface-elevated)] px-1 rounded">
|
||||
& .content-card {"{"} border: 2px solid red; {"}"}
|
||||
</code>
|
||||
).
|
||||
</p>
|
||||
<textarea
|
||||
name="custom:css"
|
||||
defaultValue={getVal(values, "custom:css", "light")}
|
||||
rows={8}
|
||||
placeholder={".content-card { border-radius: 20px; }"}
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono resize-y"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Custom HTML
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Add custom HTML that injects into the page for this scope. Use for
|
||||
custom banners, scripts, or content blocks.
|
||||
</p>
|
||||
<textarea
|
||||
name="custom:html"
|
||||
defaultValue={getVal(values, "custom:html", "light")}
|
||||
rows={6}
|
||||
placeholder='<div class="my-banner">Welcome!</div>'
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono resize-y"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
saveCustomTheme,
|
||||
saveTheme,
|
||||
} from "@/actions/admin-theme";
|
||||
import { ColorField } from "@/components/admin/color-field";
|
||||
import { RenameTheme } from "@/components/admin/theme/rename-theme";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { listCustomThemes } from "@/lib/theme-custom-store";
|
||||
import { FONTS, PRESETS } from "@/lib/theme-presets";
|
||||
import { ColorField } from "./color-field";
|
||||
|
||||
export const metadata = { title: "Theme" };
|
||||
|
||||
@@ -297,13 +297,13 @@ function colorFields(
|
||||
{fields.map((f) => {
|
||||
const val = toHex(current[f.key], f.def);
|
||||
const bgKey = f.bgKey ? `${f.bgKey}${suffix}` : undefined;
|
||||
// eslint-disable-next-line security/detect-object-injection -- bgKey derived from hardcoded field list
|
||||
const bgValue = bgKey ? toHex(current[bgKey], "") : undefined;
|
||||
return (
|
||||
<ColorField
|
||||
key={f.key}
|
||||
field={f}
|
||||
value={val}
|
||||
label={f.label}
|
||||
description={f.desc}
|
||||
defaultValue={val}
|
||||
bgValue={bgValue || undefined}
|
||||
name={`${f.key}${suffix}`}
|
||||
/>
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { TICKET_PRIORITIES, TICKET_STATUSES } from "@/lib/validators/ticket";
|
||||
|
||||
interface TicketMessage {
|
||||
@@ -215,7 +216,7 @@ export function AdminTicketDetail({
|
||||
{msg.isStaff && " (Staff)"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{new Date(msg.createdAt).toLocaleString()}
|
||||
{formatDate(new Date(msg.createdAt))}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -276,7 +277,7 @@ export function AdminTicketDetail({
|
||||
<div className="border-t p-4 text-center text-sm text-muted-foreground">
|
||||
Ticket closed{" "}
|
||||
{ticket.closedAt
|
||||
? `on ${new Date(ticket.closedAt).toLocaleDateString()}`
|
||||
? `on ${formatDate(new Date(ticket.closedAt), "date")}`
|
||||
: ""}
|
||||
</div>
|
||||
)}
|
||||
@@ -413,9 +414,10 @@ export function AdminTicketDetail({
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
Registered{" "}
|
||||
{new Date(
|
||||
ticket.creator.accountCreated * 1000,
|
||||
).toLocaleDateString()}
|
||||
{formatDate(
|
||||
new Date(ticket.creator.accountCreated * 1000),
|
||||
"date",
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -428,12 +430,12 @@ export function AdminTicketDetail({
|
||||
<CardContent className="space-y-2 text-xs text-muted-foreground">
|
||||
<div className="flex justify-between">
|
||||
<span>Created</span>
|
||||
<span>{new Date(ticket.createdAt).toLocaleString()}</span>
|
||||
<span>{formatDate(new Date(ticket.createdAt))}</span>
|
||||
</div>
|
||||
{ticket.closedAt && (
|
||||
<div className="flex justify-between">
|
||||
<span>Closed</span>
|
||||
<span>{new Date(ticket.closedAt).toLocaleString()}</span>
|
||||
<span>{formatDate(new Date(ticket.closedAt))}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
|
||||
@@ -1,48 +1,24 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { SUPPORTED_LOCALES } from "@/i18n/locales";
|
||||
import { readCmsTranslation } from "@/lib/cms-translations";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { TranslationsClient } from "../translations-client";
|
||||
|
||||
const LOCALES = [
|
||||
"en",
|
||||
"it",
|
||||
"nl",
|
||||
"de",
|
||||
"fr",
|
||||
"es",
|
||||
"pt",
|
||||
"pl",
|
||||
"sv",
|
||||
"tr",
|
||||
"ro",
|
||||
"hu",
|
||||
"cs",
|
||||
"sk",
|
||||
"da",
|
||||
"no",
|
||||
"el",
|
||||
"bg",
|
||||
"hr",
|
||||
"sr",
|
||||
"uk",
|
||||
"ru",
|
||||
] as const;
|
||||
|
||||
export default async function CmsTranslationsPage() {
|
||||
const messagesDir = path.join(process.cwd(), "src", "messages");
|
||||
|
||||
const contents: Record<string, string> = {};
|
||||
await Promise.all(
|
||||
LOCALES.map(async (locale) => {
|
||||
try {
|
||||
contents[locale] = await fs.readFile(
|
||||
path.join(messagesDir, `${locale}.json`),
|
||||
"utf-8",
|
||||
);
|
||||
} catch {
|
||||
// Skip missing locale files instead of failing the whole page.
|
||||
}
|
||||
}),
|
||||
const [{ session, permissions }, locale, entries] = await Promise.all([
|
||||
getAdminContext(),
|
||||
getLocale(),
|
||||
Promise.all(
|
||||
SUPPORTED_LOCALES.map(
|
||||
async (locale) => [locale, await readCmsTranslation(locale)] as const,
|
||||
),
|
||||
),
|
||||
]);
|
||||
return (
|
||||
<TranslationsClient
|
||||
snapshots={Object.fromEntries(entries)}
|
||||
initialLocale={locale}
|
||||
canEdit={canAccess(permissions, PERMS.SETTINGS_EDIT, session.user.rank)}
|
||||
/>
|
||||
);
|
||||
|
||||
return <TranslationsClient localeContents={contents} />;
|
||||
}
|
||||
@@ -1,132 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { Download, Save } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
import { saveTranslations } from "@/actions/translations";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { flattenObject, unflattenObject } from "@/lib/translations-utils";
|
||||
|
||||
type Locale =
|
||||
| "en"
|
||||
| "it"
|
||||
| "nl"
|
||||
| "de"
|
||||
| "fr"
|
||||
| "es"
|
||||
| "pt"
|
||||
| "pl"
|
||||
| "sv"
|
||||
| "tr"
|
||||
| "ro"
|
||||
| "hu"
|
||||
| "cs"
|
||||
| "sk"
|
||||
| "da"
|
||||
| "no"
|
||||
| "el"
|
||||
| "bg"
|
||||
| "hr"
|
||||
| "sr"
|
||||
| "uk"
|
||||
| "ru";
|
||||
|
||||
const LOCALES: { code: Locale; label: string }[] = [
|
||||
{ code: "en", label: "EN" },
|
||||
{ code: "it", label: "IT" },
|
||||
{ code: "nl", label: "NL" },
|
||||
{ code: "de", label: "DE" },
|
||||
{ code: "fr", label: "FR" },
|
||||
{ code: "es", label: "ES" },
|
||||
{ code: "pt", label: "PT" },
|
||||
{ code: "pl", label: "PL" },
|
||||
{ code: "sv", label: "SV" },
|
||||
{ code: "tr", label: "TR" },
|
||||
{ code: "ro", label: "RO" },
|
||||
{ code: "hu", label: "HU" },
|
||||
{ code: "cs", label: "CS" },
|
||||
{ code: "sk", label: "SK" },
|
||||
{ code: "da", label: "DA" },
|
||||
{ code: "no", label: "NO" },
|
||||
{ code: "el", label: "EL" },
|
||||
{ code: "bg", label: "BG" },
|
||||
{ code: "hr", label: "HR" },
|
||||
{ code: "sr", label: "SR" },
|
||||
{ code: "uk", label: "UK" },
|
||||
{ code: "ru", label: "RU" },
|
||||
];
|
||||
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||
import {
|
||||
type AppLocale,
|
||||
isSupportedLocale,
|
||||
SUPPORTED_LOCALES,
|
||||
} from "@/i18n/locales";
|
||||
import { validateCmsMessage } from "@/lib/cms-message-validation";
|
||||
import type { CmsTranslationSnapshot } from "@/lib/cms-translations";
|
||||
import { unflattenObject } from "@/lib/translations-utils";
|
||||
|
||||
interface Props {
|
||||
localeContents: Record<string, string>;
|
||||
snapshots: Record<string, CmsTranslationSnapshot>;
|
||||
initialLocale: string;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export function TranslationsClient({ localeContents }: Props) {
|
||||
const [data, setData] = useState<Record<string, Record<string, string>>>(
|
||||
() => {
|
||||
const initial: Record<string, Record<string, string>> = {};
|
||||
for (const [locale, content] of Object.entries(localeContents)) {
|
||||
initial[locale] = flattenObject(JSON.parse(content));
|
||||
}
|
||||
return initial;
|
||||
},
|
||||
const PER_PAGE = 30;
|
||||
export function TranslationsClient({
|
||||
snapshots,
|
||||
initialLocale,
|
||||
canEdit,
|
||||
}: Props) {
|
||||
const t = useTranslations("pages.admin.translations.cms");
|
||||
const router = useRouter();
|
||||
const [saved, setSaved] = useState(snapshots);
|
||||
const [drafts, setDrafts] = useState<Record<string, Record<string, string>>>(
|
||||
{},
|
||||
);
|
||||
const [activeLang, setActiveLang] = useState<AppLocale>(
|
||||
isSupportedLocale(initialLocale) ? initialLocale : "en",
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
const [group, setGroup] = useState("");
|
||||
const [activeLang, setActiveLang] = useState<Locale>("en");
|
||||
const { run, isPending } = useServerAction();
|
||||
const PER_PAGE = 30;
|
||||
const [status, setStatus] = useState("all");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const activeData = data[activeLang];
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const g = new Set<string>();
|
||||
Object.keys(data.en ?? {}).forEach((k) => {
|
||||
const parts = k.split(".");
|
||||
if (parts.length > 1) g.add(parts[0]);
|
||||
});
|
||||
return Array.from(g).sort();
|
||||
}, [data]);
|
||||
|
||||
const filteredKeys = useMemo(() => {
|
||||
if (!activeData) return [];
|
||||
return Object.keys(activeData).filter((key) => {
|
||||
const matchSearch =
|
||||
!search ||
|
||||
key.toLowerCase().includes(search.toLowerCase()) ||
|
||||
activeData[key].toLowerCase().includes(search.toLowerCase());
|
||||
const matchGroup = !group || key.startsWith(`${group}.`);
|
||||
return matchSearch && matchGroup;
|
||||
const { run, isPending } = useServerAction();
|
||||
const source = saved.en.messages;
|
||||
const activeData = saved[activeLang].messages;
|
||||
const draft = drafts[activeLang] ?? {};
|
||||
const keys = useMemo(() => Object.keys(source).sort(), [source]);
|
||||
const groups = useMemo(
|
||||
() => [...new Set(keys.map((key) => key.split(".")[0]))],
|
||||
[keys],
|
||||
);
|
||||
const dirty = Object.values(drafts).some(
|
||||
(values) => Object.keys(values).length > 0,
|
||||
);
|
||||
useUnsavedChanges(dirty, t("leaveWarning"));
|
||||
const valueFor = (key: string) => draft[key] ?? activeData[key] ?? "";
|
||||
const missing = keys.filter((key) => !valueFor(key).trim()).length;
|
||||
const identical =
|
||||
activeLang === "en"
|
||||
? 0
|
||||
: keys.filter((key) => valueFor(key) === source[key]).length;
|
||||
const errors: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(draft)) {
|
||||
const issue = validateCmsMessage(value, source[key]);
|
||||
if (issue) errors[key] = t(issue, { key });
|
||||
}
|
||||
const filteredKeys = keys.filter((key) => {
|
||||
const value = valueFor(key);
|
||||
const matchesSearch = [key, value, source[key]].some((text) =>
|
||||
text.toLocaleLowerCase().includes(search.toLocaleLowerCase()),
|
||||
);
|
||||
const matchesStatus =
|
||||
status === "all" ||
|
||||
(status === "missing" && !activeData[key]?.trim()) ||
|
||||
(status === "identical" &&
|
||||
activeLang !== "en" &&
|
||||
activeData[key] === source[key]) ||
|
||||
(status === "modified" && Object.hasOwn(draft, key));
|
||||
return (
|
||||
matchesSearch && matchesStatus && (!group || key.startsWith(`${group}.`))
|
||||
);
|
||||
});
|
||||
const totalPages = Math.max(1, Math.ceil(filteredKeys.length / PER_PAGE));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pageKeys = filteredKeys.slice(
|
||||
(currentPage - 1) * PER_PAGE,
|
||||
currentPage * PER_PAGE,
|
||||
);
|
||||
function updateValue(key: string, value: string) {
|
||||
setDrafts((current) => {
|
||||
const next = { ...current[activeLang] };
|
||||
if (value === (activeData[key] ?? "")) delete next[key];
|
||||
else next[key] = value;
|
||||
return { ...current, [activeLang]: next };
|
||||
});
|
||||
}, [activeData, search, group]);
|
||||
|
||||
const totalPages = Math.ceil(filteredKeys.length / PER_PAGE);
|
||||
const pageKeys = filteredKeys.slice((page - 1) * PER_PAGE, page * PER_PAGE);
|
||||
|
||||
function updateValue(key: string, value: string) {
|
||||
setData({ ...data, [activeLang]: { ...activeData, [key]: value } });
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!canEdit || !Object.keys(draft).length || Object.keys(errors).length)
|
||||
return;
|
||||
const locale = activeLang;
|
||||
run(
|
||||
() =>
|
||||
saveTranslations({
|
||||
locale: activeLang,
|
||||
data: unflattenObject(activeData),
|
||||
locale,
|
||||
revision: saved[locale].revision,
|
||||
changes: draft,
|
||||
}),
|
||||
{
|
||||
successMessage: "Translations saved!",
|
||||
successMessage: t("saved"),
|
||||
onSuccess: (result) => {
|
||||
const snapshot = result.snapshot as CmsTranslationSnapshot;
|
||||
setSaved((current) => ({ ...current, [locale]: snapshot }));
|
||||
setDrafts((current) => ({ ...current, [locale]: {} }));
|
||||
router.refresh();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
const blob = new Blob(
|
||||
[JSON.stringify(unflattenObject(activeData), null, 2)],
|
||||
{
|
||||
type: "application/json",
|
||||
},
|
||||
[JSON.stringify(unflattenObject({ ...activeData, ...draft }), null, 2)],
|
||||
{ type: "application/json" },
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
@@ -135,106 +130,213 @@ export function TranslationsClient({ localeContents }: Props) {
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
<div className="flex gap-2">
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{t("title")}</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||
<Download className="w-4 h-4 mr-1" /> Export
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={isPending}>
|
||||
<Save className="w-4 h-4 mr-1" /> {isPending ? "Saving..." : "Save"}
|
||||
<Download className="mr-1 h-4 w-4" />
|
||||
{t("export")}
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={
|
||||
isPending ||
|
||||
!Object.keys(draft).length ||
|
||||
!!Object.keys(errors).length
|
||||
}
|
||||
>
|
||||
<Save className="mr-1 h-4 w-4" />
|
||||
{isPending
|
||||
? t("saving")
|
||||
: t("saveLocale", { locale: activeLang.toUpperCase() })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="flex gap-1 overflow-x-auto pb-1 max-w-full">
|
||||
{LOCALES.map(({ code, label }) => (
|
||||
{!canEdit && (
|
||||
<p className="text-sm text-muted-foreground">{t("readOnly")}</p>
|
||||
)}
|
||||
<fieldset disabled={isPending} className="min-w-0 space-y-4">
|
||||
<fieldset
|
||||
className="flex min-w-0 flex-wrap gap-1"
|
||||
aria-label={t("language")}
|
||||
>
|
||||
{SUPPORTED_LOCALES.map((code) => (
|
||||
<Button
|
||||
key={code}
|
||||
variant={activeLang === code ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
variant={activeLang === code ? "default" : "outline"}
|
||||
aria-pressed={activeLang === code}
|
||||
onClick={() => {
|
||||
setActiveLang(code);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{code.toUpperCase()}
|
||||
{Object.keys(drafts[code] ?? {}).length ? " *" : ""}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Filter by key or value..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<select
|
||||
className="rounded-md border bg-background px-3 py-1 text-sm"
|
||||
value={group}
|
||||
onChange={(e) => {
|
||||
setGroup(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">All groups</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-sm text-muted-foreground self-center">
|
||||
{filteredKeys.length} keys
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card divide-y">
|
||||
{pageKeys.map((key) => (
|
||||
<div key={key} className="flex items-center gap-3 px-4 py-2">
|
||||
<span className="text-xs font-mono text-muted-foreground w-64 truncate flex-shrink-0">
|
||||
{key}
|
||||
</span>
|
||||
</fieldset>
|
||||
<p className="text-sm" role="status">
|
||||
{t("coverage", { total: keys.length, missing, identical })}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label
|
||||
htmlFor="cms-translation-search"
|
||||
className="grid w-full min-w-0 gap-1 text-sm md:w-auto md:flex-1"
|
||||
>
|
||||
{t("search")}
|
||||
<Input
|
||||
value={activeData[key]}
|
||||
onChange={(e) => updateValue(key, e.target.value)}
|
||||
className="flex-1 h-8 text-sm"
|
||||
id="cms-translation-search"
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
{t("group")}
|
||||
<select
|
||||
value={group}
|
||||
onChange={(e) => {
|
||||
setGroup(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="rounded-md border bg-background px-3 py-2"
|
||||
>
|
||||
<option value="">{t("allGroups")}</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
{t("status")}
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="rounded-md border bg-background px-3 py-2"
|
||||
>
|
||||
<option value="all">{t("all")}</option>
|
||||
<option value="missing">{t("missing")}</option>
|
||||
{activeLang !== "en" && (
|
||||
<option value="identical">{t("identical")}</option>
|
||||
)}
|
||||
<option value="modified">{t("modified")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("fallbackHint")}</p>
|
||||
{Object.keys(draft).length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-sm">
|
||||
{t("unsaved", { count: Object.keys(draft).length })}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (window.confirm(t("discardConfirm")))
|
||||
setDrafts((current) => ({ ...current, [activeLang]: {} }));
|
||||
}}
|
||||
>
|
||||
{t("discard")}
|
||||
</Button>
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<span role="alert" className="text-sm text-destructive">
|
||||
{t("fixErrors", { count: Object.keys(errors).length })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
)}
|
||||
<div className="divide-y rounded-lg border bg-card">
|
||||
{pageKeys.length === 0 && (
|
||||
<p className="p-6 text-sm text-muted-foreground">
|
||||
{t("noResults")}
|
||||
</p>
|
||||
)}
|
||||
{pageKeys.map((key) => (
|
||||
<div key={key} className="grid min-w-0 gap-2 p-4">
|
||||
<label
|
||||
htmlFor={`translation-${key}`}
|
||||
className="break-all font-mono text-xs text-muted-foreground"
|
||||
>
|
||||
{key}
|
||||
</label>
|
||||
{activeLang !== "en" && (
|
||||
<p className="whitespace-pre-wrap break-words text-sm">
|
||||
<span className="font-medium">{t("source")}: </span>
|
||||
{source[key]}
|
||||
</p>
|
||||
)}
|
||||
<textarea
|
||||
id={`translation-${key}`}
|
||||
aria-invalid={!!errors[key]}
|
||||
aria-describedby={errors[key] ? `error-${key}` : undefined}
|
||||
readOnly={!canEdit}
|
||||
value={valueFor(key)}
|
||||
onChange={(e) => updateValue(key, e.target.value)}
|
||||
placeholder={t("missingPlaceholder")}
|
||||
rows={2}
|
||||
className="w-full min-w-0 resize-y rounded-md border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
{errors[key] && (
|
||||
<p
|
||||
id={`error-${key}`}
|
||||
role="alert"
|
||||
className="break-words text-sm text-destructive"
|
||||
>
|
||||
{errors[key]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {page} of {totalPages}
|
||||
{t("page", {
|
||||
page: currentPage,
|
||||
total: totalPages,
|
||||
count: filteredKeys.length,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setPage(currentPage - 1)}
|
||||
>
|
||||
Prev
|
||||
{t("previous")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setPage(currentPage + 1)}
|
||||
>
|
||||
Next
|
||||
{t("next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { MessageSquare, Terminal } from "lucide-react";
|
||||
import Link from "@/components/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
interface ChatMessage {
|
||||
id: number;
|
||||
@@ -25,13 +26,7 @@ interface UserActivityLogProps {
|
||||
|
||||
function formatTime(ts: number) {
|
||||
if (!ts) return "-";
|
||||
const d = new Date(ts * 1000);
|
||||
return d.toLocaleString(undefined, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return formatDate(new Date(ts * 1000));
|
||||
}
|
||||
|
||||
export function UserActivityLog({
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
interface BanInfo {
|
||||
id: number;
|
||||
@@ -110,7 +111,7 @@ export function UserBanSection({
|
||||
By {activeBan.staffName} ·{" "}
|
||||
{activeBan.banExpire === 0
|
||||
? "Permanent"
|
||||
: `Expires ${new Date(activeBan.banExpire * 1000).toLocaleString()}`}
|
||||
: `Expires ${formatDate(new Date(activeBan.banExpire * 1000))}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -174,7 +175,7 @@ export function UserBanSection({
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(ban.timestamp * 1000).toLocaleDateString()}
|
||||
{formatDate(new Date(ban.timestamp * 1000), "date")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { formatNumber } from "@/lib/format-date";
|
||||
|
||||
interface UserQuickActionsProps {
|
||||
userId: number;
|
||||
@@ -110,7 +111,7 @@ export function UserQuickActions({
|
||||
const amount = parseInt(creditAmount, 10);
|
||||
if (!amount || amount <= 0) return;
|
||||
run(() => sendCredits({ userId, amount }), {
|
||||
successMessage: `${amount.toLocaleString()} credits sent to ${username}.`,
|
||||
successMessage: `${formatNumber(amount)} credits sent to ${username}.`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import Link from "@/components/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
interface UserAccountInfoProps {
|
||||
user: {
|
||||
@@ -36,14 +37,7 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
|
||||
<Calendar className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">Registered</span>
|
||||
<span className="ml-auto font-medium">
|
||||
{new Date(user.accountCreated * 1000).toLocaleDateString(
|
||||
"it-IT",
|
||||
{
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
},
|
||||
)}
|
||||
{formatDate(new Date(user.accountCreated * 1000), "date")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
@@ -51,16 +45,7 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
|
||||
<span className="text-muted-foreground">Last Login</span>
|
||||
<span className="ml-auto font-medium">
|
||||
{user.lastLogin
|
||||
? new Date(user.lastLogin * 1000).toLocaleDateString(
|
||||
"it-IT",
|
||||
{
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
},
|
||||
)
|
||||
? formatDate(new Date(user.lastLogin * 1000))
|
||||
: "Never"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Loaded 100 of 315 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user