Skip to content

Admin Dashboard

The admin app (apps/band-game-admin/) is a Next.js 15 + React 19 internal tool for managing game configuration, XP rewards, and file assets. It is protected by Clerk and requires the admin role.

Tech Stack

LibraryPurpose
Next.js 15Framework — App Router, Server Actions
Clerk (@clerk/nextjs)Auth + role-based access control
AWS SDK v3Cloudflare R2 (S3-compatible) for asset storage
Tailwind CSSStyling — dark theme with green neon accents

Authentication

Two-tier protection:

  1. Clerk middleware (middleware.ts) — all routes except /sign-in require a valid Clerk session. Unauthenticated users are redirected to /sign-in.
  2. Role check — the middleware also reads user.privateMetadata.role. If the value is not 'admin', the user is redirected back to /sign-in with an "Access denied" message. The role must be set in the Clerk dashboard under the user's private metadata.

Server Actions call the NestJS server with an x-admin-key header (value from GAME_SERVER_ADMIN_KEY). This is server-to-server only — the key is never exposed to the browser.


Screen Hierarchy

text
/               →  redirect to /config
/sign-in        →  Clerk <SignIn /> + access-denied state for signed-in non-admins
/config         →  ConfigEditor — match settings, menu buttons, store items
/rewards        →  RewardsEditor — XP rates, bonuses, level formula, rank names
/assets         →  AssetManager — Cloudflare R2 file browser and uploader

Config Page (/config)

Managed by ConfigEditor (components/ConfigEditor.tsx). Fetches config via the getFullConfig() server action, which calls GET /config/full on the NestJS server.

Match Config

FieldPurpose
liteRoundLengthMsTurn duration (ms) for Lite game mode

Ordered list of buttons shown on the client's home menu. Each button has:

FieldPurpose
idUnique identifier
titlePrimary label
subtitleOptional secondary label
routeExpo Router path to navigate to
iconUrlIcon image URL (uploaded to R2 via the Assets page)

Buttons can be reordered with up/down arrows and added or deleted inline. Changes are saved with PUT /config and hot-reloaded with POST /config/reload.

Store Items

Full CRUD for the in-game store catalogue.

FieldOptions
nameDisplay name
emojiEmoji representing the item
priceCost in Riffs (in-game currency)
categoryinstruments · hats · pets · accessories
statuslive · staged · development · hidden · deleted

Status controls client visibility. Items can be filtered by status and category. Color-coding: green = live, gold = staged, orange = development, light-green = hidden, red = deleted.


Rewards Page (/rewards)

Managed by RewardsEditor (components/RewardsEditor.tsx). Fetches and saves via GET/PUT /config/rewards.

Base XP

FieldPurpose
baseWinXpFlat XP awarded for winning a match
baseLossXpFlat XP awarded for losing a match

Win Bonuses

Stacking per-band bonuses applied on top of base win XP.

BonusTriggerConfig field
Long band nameBand name ≥ N charactersminCharactersxp
Unknown bandArtist has ≤ N followers on SpotifymaxFollowersxp
Discovery bonusFirst time this player has used this bandxp

Level Formula

XP required to reach level n:

text
totalXP(n) = n × (n − 1) / 2 × levelMultiplier

levelMultiplier is the only tunable parameter. The editor shows a live preview table for levels 1–10.

Ranks

An ordered list of rank thresholds. The highest threshold a player's level meets determines their rank name.

FieldPurpose
levelMinimum level to hold this rank
nameDisplay name (e.g. "Garage Band", "Rockstar")

Ranks can be added and removed dynamically.


Assets Page (/assets)

Managed by AssetManager and sub-components. Files are stored in Cloudflare R2, namespaced under the band-game/ prefix.

Layout

  • Left sidebarFolderTree: folder navigation. Folders are R2 "prefixes" (no real directories).
  • Right panelFileBrowser: grid of FileCard thumbnails/icons, with file name, size, and last-modified date.
  • Upload panelUploadPanel: multi-file uploader with per-file progress bars.

Operations

ActionHow
Browse folderClick in FolderTree — calls GET /api/r2-list?prefix=…
Create folderType name (lowercase + hyphens only) in FolderTree
Upload filesSelect files → client gets presigned URL from POST /api/r2-presign → uploads directly to R2 via XHR
Copy public URLFileCard copy button — URL built from NEXT_PUBLIC_R2_PUBLIC_URL
Delete fileFileCard delete or bulk-select → POST /api/r2-delete

Upload goes directly browser → R2 via a presigned URL; the server is not in the upload path.


API Routes

These are Next.js route handlers (app/api/), not NestJS endpoints. They run server-side and handle R2 operations.

POST /api/r2-presign

Returns a presigned upload URL for a given R2 key.

Input: { key: string, contentType: string }
Validation: key must start with band-game/
Response: { url: string (presigned, 1 h expiry), publicUrl: string }

GET /api/r2-list

Lists files and immediate subfolders at a given R2 prefix.

Query: prefix (defaults to band-game/)
Validation: prefix must start with band-game/
Response: { files: R2File[], prefixes: string[] }
.meta.json files are filtered out of results.

POST /api/r2-delete

Bulk-deletes files from R2.

Input: { keys: string[] }
Validation: All keys must start with band-game/
Response: { deleted: string[], errors: [...] }


Server Actions

Thin wrappers over NestJS admin endpoints. All attach x-admin-key: {GAME_SERVER_ADMIN_KEY}.

ActionHTTP callUsed by
getFullConfig()GET /config/fullConfig page (initial load)
updateConfig(config)PUT /configConfig page Save button
reloadConfig()POST /config/reloadConfig page Reload button
getRewardsConfig()GET /config/rewardsRewards page (initial load)
updateRewardsConfig(config)PUT /config/rewardsRewards page Save button

Key Types (lib/types.ts)

typescript
type StoreItemStatus = 'live' | 'staged' | 'development' | 'hidden' | 'deleted'

type MenuButton = { id: string; title: string; subtitle?: string; route: string; iconUrl: string }

type StoreItem = { id: string; name: string; price: number; category: string; emoji: string; status: StoreItemStatus }

type MatchConfig = { liteRoundLengthMs: number }

type XpConfig = {
  baseWinXp: number
  baseLossXp: number
  bonuses: { longBandName: { minCharacters: number; xp: number }; unknownBand: { maxFollowers: number; xp: number }; discoveryBonus: { xp: number } }
  levelMultiplier: number
  ranks: { level: number; name: string }[]
}

type AppConfig = { matchConfig: MatchConfig; menuButtons: MenuButton[]; storeItems: StoreItem[]; xpConfig?: XpConfig }