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
| Library | Purpose |
|---|---|
| Next.js 15 | Framework — App Router, Server Actions |
Clerk (@clerk/nextjs) | Auth + role-based access control |
| AWS SDK v3 | Cloudflare R2 (S3-compatible) for asset storage |
| Tailwind CSS | Styling — dark theme with green neon accents |
Authentication
Two-tier protection:
- Clerk middleware (
middleware.ts) — all routes except/sign-inrequire a valid Clerk session. Unauthenticated users are redirected to/sign-in. - Role check — the middleware also reads
user.privateMetadata.role. If the value is not'admin', the user is redirected back to/sign-inwith 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 uploaderConfig 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
| Field | Purpose |
|---|---|
liteRoundLengthMs | Turn duration (ms) for Lite game mode |
Menu Buttons
Ordered list of buttons shown on the client's home menu. Each button has:
| Field | Purpose |
|---|---|
id | Unique identifier |
title | Primary label |
subtitle | Optional secondary label |
route | Expo Router path to navigate to |
iconUrl | Icon 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.
| Field | Options |
|---|---|
name | Display name |
emoji | Emoji representing the item |
price | Cost in Riffs (in-game currency) |
category | instruments · hats · pets · accessories |
status | live · 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
| Field | Purpose |
|---|---|
baseWinXp | Flat XP awarded for winning a match |
baseLossXp | Flat XP awarded for losing a match |
Win Bonuses
Stacking per-band bonuses applied on top of base win XP.
| Bonus | Trigger | Config field |
|---|---|---|
| Long band name | Band name ≥ N characters | minCharacters → xp |
| Unknown band | Artist has ≤ N followers on Spotify | maxFollowers → xp |
| Discovery bonus | First time this player has used this band | xp |
Level Formula
XP required to reach level n:
text
totalXP(n) = n × (n − 1) / 2 × levelMultiplierlevelMultiplier 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.
| Field | Purpose |
|---|---|
level | Minimum level to hold this rank |
name | Display 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 sidebar —
FolderTree: folder navigation. Folders are R2 "prefixes" (no real directories). - Right panel —
FileBrowser: grid ofFileCardthumbnails/icons, with file name, size, and last-modified date. - Upload panel —
UploadPanel: multi-file uploader with per-file progress bars.
Operations
| Action | How |
|---|---|
| Browse folder | Click in FolderTree — calls GET /api/r2-list?prefix=… |
| Create folder | Type name (lowercase + hyphens only) in FolderTree |
| Upload files | Select files → client gets presigned URL from POST /api/r2-presign → uploads directly to R2 via XHR |
| Copy public URL | FileCard copy button — URL built from NEXT_PUBLIC_R2_PUBLIC_URL |
| Delete file | FileCard 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}.
| Action | HTTP call | Used by |
|---|---|---|
getFullConfig() | GET /config/full | Config page (initial load) |
updateConfig(config) | PUT /config | Config page Save button |
reloadConfig() | POST /config/reload | Config page Reload button |
getRewardsConfig() | GET /config/rewards | Rewards page (initial load) |
updateRewardsConfig(config) | PUT /config/rewards | Rewards 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 }