From 503e7a35f9d7006437287cec75791601bf16ed82 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Thu, 27 Aug 2026 11:25:28 -0400 Subject: [PATCH 1/7] feat(import): add Arma library importer with PBO icon extraction - importArmaLibrary: upsert Arma export catalog into assets/vehicles via Payload, materialize .paa thumbnails (convertPaaToPng) and link uploads, resolve Fuel resource for vehicle fuel defaults - extractArmaIcons: ref-driven PBO .paa extraction into a --paa-root tree shaped like inventory icon ref paths - package.json: add import:arma script --- package.json | 1 + src/tools/import/extractArmaIcons.ts | 240 +++++++++++++++++++++++++ src/tools/import/importArmaLibrary.ts | 249 ++++++++++++++++++++++++++ 3 files changed, 490 insertions(+) create mode 100644 src/tools/import/extractArmaIcons.ts create mode 100644 src/tools/import/importArmaLibrary.ts diff --git a/package.json b/package.json index ceb5c00..3094736 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap", "generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types", "generate:version-info": "bun run src/scripts/generateVersionInfo.ts", + "import:arma": "bun run src/tools/import/importArmaLibrary.ts", "version:bump": "bun run src/scripts/bumpVersion.ts", "lint": "cross-env NODE_OPTIONS=--no-deprecation next lint", "payload": "cross-env NODE_OPTIONS=--no-deprecation payload", diff --git a/src/tools/import/extractArmaIcons.ts b/src/tools/import/extractArmaIcons.ts new file mode 100644 index 0000000..039d190 --- /dev/null +++ b/src/tools/import/extractArmaIcons.ts @@ -0,0 +1,240 @@ +#!/usr/bin/env bun +/** + * Extract Arma inventory .paa icons referenced by an export catalog out of the + * game's packed PBOs into a --paa-root tree shaped like the reference paths, + * so importArmaLibrary.ts's resolvePaaPath() can find them for PAA->PNG. + * + * Resolution is ref-driven: the second path segment names the PBO (vanilla + * refs look like "\a3\weapons_f\Rifles\MX\data\UI\gear_mx_cqc_M_CA.paa"), so we + * locate that PBO by basename and inspect it on demand (cached), then map the + * ref to a file inside it. A fabricated/unresolvable ref reports MISS rather + * than failing the run. + * + * CLI: + * --catalog export catalog JSON (array of records with "picture") + * --icons icon_map JSON (map of classname -> .paa ref) [optional] + * --game Arma 3 install root (contains /Addons/*.pbo) + * [--mod ]... extra mod roots to search for .pbo files + * --paa-root where extracted .paa files are written (mirrors refs) + * [--out-dir ] write a machine-readable report here (optional) + * [--hemtt ] hemtt binary (default: resolve from PATH) + */ +import { spawnSync } from "child_process"; +import { existsSync, mkdirSync, readFileSync } from "fs"; +import { dirname, join } from "path"; + +interface CliOpts { + catalog?: string[]; + icons?: string[]; + game?: string[]; + mod?: string[]; + "paa-root"?: string[]; + "out-dir"?: string[]; + hemtt?: string[]; +} + +function parseCli(argv: string[]): CliOpts { + const opts: CliOpts = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith("--") && i + 1 < argv.length && !argv[i + 1].startsWith("--")) { + const key = a.slice(2) as keyof CliOpts; + (opts[key] ??= []).push(argv[++i]); + } + } + return opts; +} + +function resolveHemtt(explicit?: string): string { + if (explicit && existsSync(explicit)) return explicit; + const probe = spawnSync("which", ["hemtt"], { encoding: "utf8" }); + if (probe.status === 0 && probe.stdout.trim()) return probe.stdout.trim(); + throw new Error("hemtt not found; pass --hemtt "); +} + +interface PboInfo { + pboPath: string; + prefix: string; + /** lowercase file path -> exact original-case path as stored in the PBO */ + files: Map; +} + +function discoverPbos(game: string, mods: string[]): string[] { + const dirs = [join(game, "Addons")]; + for (const m of mods) { + const cand = join(m, "addons"); + if (existsSync(cand)) dirs.push(cand); + } + const out: string[] = []; + for (const d of dirs) { + if (!existsSync(d)) continue; + for (const f of readFileSync("/dev/null").length ? [] : []) void f; + const entries = spawnSync("ls", [d], { encoding: "utf8" }); + for (const line of (entries.stdout ?? "").split("\n")) { + const n = line.trim(); + if (n.toLowerCase().endsWith(".pbo")) out.push(join(d, n)); + } + } + return out; +} + +function pbosByName(pbos: string[]): Map { + const byName = new Map(); + for (const p of pbos) { + const base = p.split(/[\\/]/).pop()!.replace(/\.pbo$/i, "").toLowerCase(); + if (!byName.has(base)) byName.set(base, p); + } + return byName; +} + +function inspectPbo(hemtt: string, pboPath: string): { prefix: string; files: string[] } | undefined { + const r = spawnSync( + hemtt, + ["utils", "pbo", "inspect", "--format", "json", pboPath], + { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }, + ); + if (r.status !== 0 || !r.stdout) return undefined; + let data: { files?: { name?: string }[]; header?: { properties?: Record } }; + try { + data = JSON.parse(r.stdout); + } catch { + return undefined; + } + const files = (data.files ?? []).map((f) => f.name ?? "").filter(Boolean); + const props = data.header?.properties ?? {}; + const prefix = (props.prefix ?? "").trim().toLowerCase().replace(/\\/g, "/"); + return { prefix, files }; +} + +function ensurePbo( + hemtt: string, + pboPath: string, + cache: Map, +): PboInfo | undefined { + if (cache.has(pboPath)) return cache.get(pboPath); + const info = inspectPbo(hemtt, pboPath); + if (!info || !info.prefix) return undefined; + const files = new Map(); + for (const f of info.files) files.set(f.toLowerCase(), f); + const pbo: PboInfo = { pboPath, prefix: info.prefix, files }; + cache.set(pboPath, pbo); + return pbo; +} + +function normalizeRef(src: string): string { + return src.replace(/\\/g, "/").replace(/^\/+/, "").toLowerCase(); +} + +function resolveRef( + norm: string, + pbos: string[], + byName: Map, + inspected: Map, + hemtt: string, +): { pbo: PboInfo; relFile: string } | undefined { + const seg = norm.split("/").filter(Boolean); + const pboName = seg[1]?.toLowerCase(); + if (pboName && byName.has(pboName)) { + const pbo = ensurePbo(hemtt, byName.get(pboName)!, inspected); + if (pbo) { + const prefixSlash = pbo.prefix + "/"; + if (norm.startsWith(prefixSlash)) { + const relLower = norm.slice(pbo.prefix.length + 1).replace(/\//g, "\\"); + const exact = pbo.files.get(relLower); + if (exact) return { pbo, relFile: exact }; + } + } + } + let best: { prefix: string; pbo: PboInfo } | undefined; + for (const pbo of inspected.values()) { + const prefixSlash = pbo.prefix + "/"; + if (norm.startsWith(prefixSlash) && (!best || pbo.prefix.length > best.prefix.length)) { + best = { prefix: pbo.prefix, pbo }; + } + } + if (!best) return undefined; + const relLower = norm.slice(best.prefix.length + 1).replace(/\//g, "\\"); + const exact = best.pbo.files.get(relLower); + return exact ? { pbo: best.pbo, relFile: exact } : undefined; +} + +async function main() { + const opts = parseCli(process.argv.slice(2)); + const hemtt = resolveHemtt(opts.hemtt?.[0]); + const game = opts.game?.[0]; + if (!game || !existsSync(game)) throw new Error("--game is required and must exist"); + if (!opts["paa-root"]?.[0]) throw new Error("--paa-root is required"); + const paaRoot = opts["paa-root"][0]; + const catalogPath = opts.catalog?.[0]; + if (!catalogPath || !existsSync(catalogPath)) throw new Error("--catalog is required"); + + const pbos = discoverPbos(game, opts.mod ?? []); + console.log(`Discovered ${pbos.length} PBOs across ${1 + (opts.mod?.length ?? 0)} roots.`); + const byName = pbosByName(pbos); + const inspected = new Map(); + + interface Ref { src: string; origin: string; } + const refs = new Map(); + const push = (src: string, origin: string) => { + const norm = normalizeRef(src); + if (!norm || !norm.endsWith(".paa")) return; + if (!refs.has(norm)) refs.set(norm, { src: src.replace(/\\/g, "/").replace(/^\/+/, ""), origin }); + }; + + const catalog = JSON.parse(readFileSync(catalogPath, "utf8")); + for (const rec of Array.isArray(catalog) ? catalog : [catalog]) { + if (typeof rec?.picture === "string") push(rec.picture, "catalog"); + } + const iconsPath = opts.icons?.[0]; + if (iconsPath && existsSync(iconsPath)) { + const icons = JSON.parse(readFileSync(iconsPath, "utf8")); + for (const v of Object.values(icons)) push(String(v), "icon_map"); + } + + console.log(`${refs.size} unique .paa refs to extract.\n`); + const report: string[] = []; + let ok = 0, missing = 0, extractErr = 0; + for (const ref of refs.values()) { + const norm = normalizeRef(ref.src); + const dest = join(paaRoot, ...ref.src.split("/")); + if (existsSync(dest)) { + console.log(` CACHE ${ref.origin}\t${ref.src}`); + ok++; + continue; + } + const res = resolveRef(norm, pbos, byName, inspected, hemtt); + if (!res) { + console.log(` MISS ${ref.origin}\t${ref.src} (not in any indexed PBO)`); + report.push(`MISS\t${ref.origin}\t${ref.src}`); + missing++; + continue; + } + mkdirSync(dirname(dest), { recursive: true }); + const r = spawnSync( + hemtt, + ["utils", "pbo", "extract", res.pbo.pboPath, res.relFile, dest], + { encoding: "utf8" }, + ); + if (r.status === 0 && existsSync(dest)) { + console.log(` EXTRACTED ${ref.origin}\t${ref.src}`); + report.push(`EXTRACTED\t${ref.origin}\t${ref.src}`); + ok++; + } else { + console.log(` ERR ${ref.origin}\t${ref.src} (extract failed)`); + report.push(`ERR\t${ref.origin}\t${ref.src}`); + extractErr++; + } + } + console.log(`\nDone: ${ok} available, ${missing} missing, ${extractErr} extract errors.`); + const outDir = opts["out-dir"]?.[0]; + if (outDir) { + mkdirSync(outDir, { recursive: true }); + const { writeFileSync } = await import("fs"); + writeFileSync(join(outDir, "paa_report.txt"), report.join("\n") + "\n"); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/tools/import/importArmaLibrary.ts b/src/tools/import/importArmaLibrary.ts new file mode 100644 index 0000000..7c19019 --- /dev/null +++ b/src/tools/import/importArmaLibrary.ts @@ -0,0 +1,249 @@ +#!/usr/bin/env bun +/** + * Import an Arma inventory export into the Polaris Task Force Payload CMS. + * + * Consumes a catalog JSON produced by the in-game ptf_export addon (records + * carrying classname, display name, type, description, picture) plus an + * optional icon_map, and upserts rows into the `assets` / `vehicles` + * collections. Thumbnails are materialized from .paa inventory icons found + * under --paa-root (see extractArmaIcons.ts) and converted to PNG uploads via + * `hemtt utils paa convert`; vehicle 3D-rendered screenshots can be supplied + * via --screenshots (matched by classname). + * + * CLI: + * --catalog export catalog JSON (required) + * [--icons ] icon_map JSON (classname -> .paa ref) + * [--paa-root ] root mirroring ref paths where .paa thumbnails live + * [--screenshots ] PNG/WebP per-classname 3D-rendered previews + * [--out ] write converted thumbnails + a report here + * [--hemtt ] hemtt binary (default: resolve from PATH) + * [--approve] apply to the DB (default: proposal only) + * [--dry-run] plan + print, never touch the DB or filesystem + * [--limit ] import at most n records + */ +import { spawnSync } from "child_process"; +import { existsSync, mkdirSync, readFileSync } from "fs"; +import { dirname, join } from "path"; + +interface CliOpts { + catalog?: string[]; + icons?: string[]; + "paa-root"?: string[]; + screenshots?: string[]; + out?: string[]; + hemtt?: string[]; + approve?: boolean; + "dry-run"?: boolean; + limit?: string[]; +} +function parseCli(argv: string[]): CliOpts { + const opts: CliOpts = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--approve") { opts.approve = true; continue; } + if (a === "--dry-run") { opts["dry-run"] = true; continue; } + if (a.startsWith("--") && i + 1 < argv.length && !argv[i + 1].startsWith("--")) { + const key = a.slice(2) as keyof CliOpts; + (opts[key] ??= []).push(argv[++i]); + } + } + return opts; +} +function resolveHemtt(explicit?: string): string { + if (explicit && existsSync(explicit)) return explicit; + const probe = spawnSync("which", ["hemtt"], { encoding: "utf8" }); + if (probe.status === 0 && probe.stdout.trim()) return probe.stdout.trim(); + throw new Error("hemtt not found; pass --hemtt "); +} + +const KNOWN_ASSET_TYPES = new Set([ + "weapon", "magazine", "ammo", "backpack", "vest", "helmet", "uniform", + "item", "attachment", "optics", "muzzle", "grip", "rail", "explosive", + "grenade", "medical", "tool", +]); +function classifyAssetType(clazz: string, type: string): string { + const t = (type || "").toLowerCase(); + if (KNOWN_ASSET_TYPES.has(t)) return t; + const c = clazz.toLowerCase(); + if (c.includes("mag") || c.startsWith("30rnd") || c.startsWith("20rnd")) return "magazine"; + if (/^(arifle|smg|hgun|srifle|lm|mmg|rifle|launch)/.test(c)) return "weapon"; + if (/^(30|40|45|9|7|5|6|12|mag_|rhs_mag)/.test(c)) return "magazine"; + return "item"; +} + +function isVehicle(clazz: string, type: string): boolean { + if (type && /vehicle|car|tank|uav|ship|heli|truck/.test(type.toLowerCase())) return true; + const c = clazz.toLowerCase(); + return /^(b_|o_|i_|c_)(mr|tru|car|heli|tank|uav|boat|apc|lt|soldier)/.test(c) && + !/backpack|vest|helmet/.test(c); +} + +function lookupIcon(icons: Record, clazz: string): string { + if (icons && typeof icons === "object") { + for (const [k, v] of Object.entries(icons)) { + if (k.toLowerCase() === clazz.toLowerCase() && typeof v === "string") return v; + } + } + return ""; +} + +function resolvePaaPath(ref: string, paaRoot: string): string { + if (!ref) return ""; + const clean = ref.replace(/^[\\/]+/, "").replace(/\\/g, "/"); + const cand = join(paaRoot, clean); + return existsSync(cand) ? cand : ""; +} + +function convertPaaToPng(hemtt: string, paaPath: string, outDir: string): string | undefined { + const base = basename(paaPath).replace(/\.paa$/i, "") + ".png"; + const dest = join(outDir, base); + if (existsSync(dest)) return dest; + mkdirSync(outDir, { recursive: true }); + const r = spawnSync(hemtt, ["utils", "paa", "convert", paaPath, dest], { encoding: "utf8" }); + return r.status === 0 && existsSync(dest) ? dest : undefined; +} +function basename(p: string): string { + const seg = p.split(/[\\/]/); + return seg[seg.length - 1]; +} + +interface ImportPlan { + kind: "asset" | "vehicle"; + clazz: string; + displayName: string; + description: string; + assetType: string; + pictureRef: string; + paaPath: string; + pngPath: string; + screenshotPath: string; +} + +function planImport( + rec: Record, + lookups: { icons: Record; paaRoot: string; screenshots: string; outDir: string; hemtt: string }, +): ImportPlan | undefined { + const clazz = String(rec.classname ?? rec.class ?? "").trim(); + const type = String(rec.type ?? rec.category ?? "").trim(); + if (!clazz) return undefined; + const vehicle = isVehicle(clazz, type); + const assetType = vehicle ? "vehicle" : classifyAssetType(clazz, type); + const displayName = String(rec.displayName ?? rec.name ?? clazz).trim() || clazz; + const description = String(rec.description ?? "").trim(); + const pictureRef = String(rec.picture ?? "").trim(); + const iconRef = lookups.icons ? lookupIcon(lookups.icons, clazz) : ""; + const ref = iconRef || pictureRef; + const paaPath = ref ? resolvePaaPath(ref, lookups.paaRoot) : ""; + const pngPath = paaPath + ? convertPaaToPng(lookups.hemtt, paaPath, join(lookups.outDir)) + : ""; + const screenshotPath = lookups.screenshots + ? (["png", "webp", "jpg"].map((e) => join(lookups.screenshots, clazz + "." + e)).find(existsSync) ?? "") + : ""; + return { kind: vehicle ? "vehicle" : "asset", clazz, displayName, description, assetType, pictureRef: ref, paaPath, pngPath, screenshotPath }; +} + +async function resolveFuelConfig(payload: { find: (args: any) => Promise<{ docs: any[] }> }): Promise<{ id: number }> { + const r = await payload.find({ collection: "resources", limit: 50, where: { name: { equals: "Fuel" } } }); + const fuel = r.docs.find((d: any) => String(d.name).toLowerCase() === "fuel"); + if (!fuel) throw new Error("No resource named 'Fuel' found — cannot set vehicle fuel fields"); + return { id: fuel.id }; +} + +async function writeEntry( + payload: { create: (args: { collection: string; data: any }) => Promise; update: (args: { collection: string; id: number; data: any }) => Promise }, + plan: ImportPlan, + opts: { outDir: string }, + fuelId: number, +): Promise { + const common = { + class_name: plan.clazz, + display_name: plan.displayName, + description: plan.description || null, + }; + const mediaId = plan.pngPath || plan.screenshotPath ? await uploadMedia(payload, plan.pngPath || plan.screenshotPath).catch(() => undefined) : undefined; + + if (plan.kind === "vehicle") { + return payload.create({ + collection: "vehicles", + data: { + ...common, + vehicleName: plan.displayName, + ...(mediaId ? { vehicle_metadata_thumbnail: mediaId } : {}), + fuel: { fuelType: fuelId, fuelCapacity: 100, fuelConsumptionRate: 1 }, + }, + }).then((d: any) => `vehicle:${d.id}`); + } + return payload.create({ + collection: "assets", + data: { + ...common, + assetType: plan.assetType, + ...(plan.pngPath ? { "Asset Metadata": { mod: "Arma 3", thumbnail: mediaId, gameDescription: plan.description || null } } : {}), + }, + }).then((d: any) => `asset:${d.id}`); +} + +async function uploadMedia(payload: { create: (args: { collection: string; data: any; file: any }) => Promise }, filePath: string): Promise { + const { readFileSync } = await import("fs"); + const fileName = basename(filePath); + const buffer = readFileSync(filePath); + const res = await payload.create({ + collection: "media", + data: { alt: fileName, filename: fileName }, + file: { data: buffer, mimetype: "image/png", name: fileName, size: buffer.length }, + }); + return Number(res.id); +} + +async function main() { + const opts = parseCli(process.argv.slice(2)); + const hemtt = resolveHemtt(opts.hemtt?.[0]); + const catalogPath = opts.catalog?.[0]; + if (!catalogPath || !existsSync(catalogPath)) throw new Error("--catalog is required and must exist"); + const dryRun = !!opts["dry-run"]; + const approve = !!opts.approve || dryRun; + const outDir = opts.out?.[0] ?? dirname(catalogPath); + const paaRoot = opts["paa-root"]?.[0] ?? ""; + const screenshots = opts.screenshots?.[0] ?? ""; + if (!dryRun && !approve) throw new Error("--approve or --dry-run required to run without a real write"); + + const catalog = JSON.parse(readFileSync(catalogPath, "utf8")); + const records = Array.isArray(catalog) ? catalog : [catalog]; + let icons: Record = {}; + if (opts.icons?.[0] && existsSync(opts.icons[0])) { + icons = JSON.parse(readFileSync(opts.icons[0], "utf8")); + } + const limit = opts.limit ? parseInt(opts.limit[0], 10) : records.length; + const lookups = { icons, paaRoot, screenshots, outDir, hemtt }; + + console.log(`Import plan (dry-run=${dryRun}, approve=${approve}):`); + const plans: ImportPlan[] = []; + for (const rec of records.slice(0, limit)) { + const plan = planImport(rec, lookups); + if (!plan) { console.log(` SKIP ${String((rec as any).classname ?? "")} (not an importable record)`); continue; } + plans.push(plan); + const img = plan.pngPath ? "[img: item-png]" : plan.screenshotPath ? "[img: screenshot]" : "[no img]"; + console.log(` -> ${plan.kind}\t${plan.clazz}\t${plan.displayName}\t${img}`); + } + + if (dryRun) { console.log(`\nDry run: ${plans.length} would import, 0 applied.`); return; } + + const { getPayload } = await import("@payload-config"); + const payload = await getPayload({ config: (await import("@payload-config")).default }); + const fuel = await resolveFuelConfig(payload); + let ok = 0, err = 0; + for (const plan of plans) { + try { + const id = await writeEntry(payload, plan, { outDir }, fuel.id); + console.log(`Applied ${id}`); + ok++; + } catch (e) { + console.error(`ERR ${plan.clazz}: ${(e as Error).message}`); + err++; + } + } + console.log(`Done: ${ok} ok, ${err} errors.`); +} + +main().catch((e) => { console.error(e); process.exit(1); }); From a6b06569dae49f9fba84f5570275418e4b3757b8 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Fri, 28 Aug 2026 01:19:48 -0400 Subject: [PATCH 2/7] feat(locker): refine inventory workspace --- DESIGN.md | 16 ++ .../frontend/locker/LockerCharacterPanel.tsx | 134 +++++++++ src/components/frontend/locker/LockerGrid.tsx | 117 +++++--- .../frontend/locker/LockerGridToolbar.tsx | 131 +++++++-- src/components/frontend/locker/LockerView.tsx | 213 ++++++++------ src/components/frontend/locker/Wardrobe.tsx | 268 ++++++++++++++---- .../frontend/locker/WardrobePanel.tsx | 6 +- 7 files changed, 673 insertions(+), 212 deletions(-) create mode 100644 src/components/frontend/locker/LockerCharacterPanel.tsx diff --git a/DESIGN.md b/DESIGN.md index 5649209..188d7f0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -67,6 +67,22 @@ Spacing follows Tailwind's 4px scale. Existing frontend stacks use `gap-1`, `gap - **Motion**: no section-level motion. - **Layout**: vertical stack. +### Locker inventory surface + +- **Purpose**: The authenticated locker is a Polaris operations surface: a dense, readable inventory rather than a literal game replica. The grid is the focal region and selection context is kept adjacent to it. +- **Palette**: Use the existing semantic neutral tokens: `bg-background` for the page, `bg-card`/`bg-muted` for framed regions, `border-border` for structure, `text-foreground` and `text-muted-foreground` for hierarchy, and `bg-primary`/`ring-ring` for active selection and primary actions. Amber utility accents are reserved for existing equipment/skin meaning; they do not become a second surface palette. +- **Typography**: Keep Geist for interface copy and Geist Mono with `tabular-nums` for coordinates, counts, dimensions, and other inventory metadata. Compact labels use the existing `text-xs` level, never ad-hoc display sizes. +- **Primitives**: + - **`locker-shell`**: one responsive page region with a compact control band and a single-column fallback below `xl`. + - **`inventory-frame`**: a strong rectangular border around the existing two-dimensional grid. The grid may scroll only inside this bounded region when its footprint exceeds the available inline size; the page itself must not gain a horizontal scrollbar. + - **`selection-rail`**: selected-item context and actions sit beside the inventory at `xl` and later, then become an inline section below the grid at narrower widths. It remains readable when no item is selected. + - **`metadata-strip`**: a compact cluster for occupancy, dimensions, and drag/drop guidance, using 4px spacing and semantic muted text. + - **`wardrobe-slot-board`**: a body-ordered rectangular board: head/face spans the top, torso and pack occupy the middle, and weapons/attachments plus sidearm finish the lower row. The primary weapon owns an indented attachment subgroup for optic, muzzle, and bipod slots. Each slot is a labeled semantic button with distinct filled and empty states; the board is driven by `WARDROBE_SLOT_LAYOUT` and never relies on a character image as its control surface. +- **States**: default, hover, focus-visible, selected, search-highlight, valid drop, invalid drop, attachment drop, empty, error, disabled, and reduced-motion. Selected items use a visible semantic ring; placement feedback stays inside the grid cells; empty and error states retain the existing user/manager-specific copy. +- **Accessibility**: Tabs remain native shadcn tab semantics. Every icon-only button has an accessible label, every meaningful thumbnail keeps descriptive alt text, draggable items retain dnd-kit keyboard attributes, and all actions keep visible `focus-visible` rings. The responsive rail must not reorder focus away from the selected item context. +- **Motion**: Preserve the existing dnd-kit activation threshold and absolute-position/ResizeObserver mechanics. Interaction polish is limited to existing shadcn color transitions, transform/opacity drag feedback, and the existing search highlight; `prefers-reduced-motion` disables highlight animation and nonessential transitions. No decorative looping motion is introduced. + - **Responsive behavior**: At 375px the shell becomes one readable column with no viewport-level horizontal overflow. Only the `inventory-frame` may expose bounded two-dimensional scrolling when required by the configured grid; the selection rail follows the grid inline, while the tab/search control cluster wraps before it can overflow. The inventory and selection rail use the side-by-side layout only at `xl` and above so the grid remains dominant and rail labels remain readable. The wardrobe slot board keeps its body order at `lg` and collapses regions to one readable column below that threshold. + ### Mission card - **Structure**: linked `Item` with `ItemHeader`, `ItemMedia`, `ItemTitle`, `ItemContent`, and `ItemDescription` primitives. diff --git a/src/components/frontend/locker/LockerCharacterPanel.tsx b/src/components/frontend/locker/LockerCharacterPanel.tsx new file mode 100644 index 0000000..ac1d5d1 --- /dev/null +++ b/src/components/frontend/locker/LockerCharacterPanel.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import type { Loadout } from "@/payload-types"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { setLoadoutSlot, equipLoadout } from "@/app/(frontend)/locker/actions"; +import { + getLoadoutSlot, + getWardrobeSlot, + type LoadoutSlots, + type LockerGridItem, +} from "@/lib/locker"; +import { Wardrobe } from "./Wardrobe"; +import { WardrobeSlotPicker } from "./WardrobeSlotPicker"; +import { cn } from "@/lib/utils"; + +interface LockerCharacterPanelProps { + loadouts: Loadout[]; + items: LockerGridItem[]; +} + +export function LockerCharacterPanel({ loadouts, items }: LockerCharacterPanelProps) { + const router = useRouter(); + const equippedLoadout = loadouts.find((loadout) => loadout.equipped) ?? null; + const [pickerSlot, setPickerSlot] = useState(null); + const [busy, setBusy] = useState(false); + const slots = (equippedLoadout?.slots ?? {}) as LoadoutSlots; + const pickerLayout = pickerSlot ? getWardrobeSlot(pickerSlot) : null; + const pickerConfig = pickerSlot ? getLoadoutSlot(pickerSlot) : null; + + async function changeLoadout(id: string) { + setBusy(true); + const result = await equipLoadout(Number(id)); + setBusy(false); + if (result.success) router.refresh(); + } + + async function pickSlot(assetId: number | null) { + if (!equippedLoadout || !pickerSlot) return { success: false, error: "No loadout equipped." }; + setBusy(true); + const result = await setLoadoutSlot(equippedLoadout.id, pickerSlot, assetId); + setBusy(false); + if (result.success) { + setPickerSlot(null); + router.refresh(); + } + return result; + } + + return ( +
+
+
+

+ CHARACTER +

+

EQUIPMENT STATUS

+
+ +
+ +
+ +
+ +
+ +
+ +
+ Click a slot to equip + +
+ + {pickerLayout && pickerConfig && ( + { + if (!open) setPickerSlot(null); + }} + slot={pickerConfig} + items={items} + currentValue={slots[pickerSlot as keyof LoadoutSlots] ?? null} + busy={busy} + onPick={pickSlot} + /> + )} +
+ ); +} diff --git a/src/components/frontend/locker/LockerGrid.tsx b/src/components/frontend/locker/LockerGrid.tsx index 0a1310a..cbc87b6 100644 --- a/src/components/frontend/locker/LockerGrid.tsx +++ b/src/components/frontend/locker/LockerGrid.tsx @@ -36,7 +36,6 @@ import type { Asset } from "@/payload-types"; import { ATTACHMENT_CATEGORIES, findLockerMergeTarget, - getAssetTypeColor, getLockerGridOccupancy, getLockerItemFootprint, isAttachmentCompatible, @@ -118,7 +117,6 @@ export function LockerItemFace({ equipment?: LockerItemEquipment; }) { const fp = getLockerItemFootprint(asset, rotated ?? false); - const bgColor = getAssetTypeColor(asset.assetType); const thumb = assetThumbnailUrl(asset); const displayName = equipment?.customName?.trim() || asset.name; const hasCustomName = !!equipment?.customName?.trim(); @@ -131,48 +129,54 @@ export function LockerItemFace({ width: fp.width * cellSize - 2, height: fp.height * cellSize - 2, }} - className="relative rounded-md overflow-hidden flex flex-col items-center justify-center gap-0.5 select-none" + className="relative flex select-none flex-col items-center justify-center overflow-hidden rounded-sm bg-card/60" > -
+ {/* Subtle selection glow */} {(hasSkin || attachmentCount > 0 || hasCustomName) && ( -
+
+ )} + {(hasSkin || attachmentCount > 0 || hasCustomName) && ( +
{hasCustomName && ( - + )} {hasSkin && ( - + )} {attachmentCount > 0 && ( - {attachmentCount} + + {attachmentCount} + )}
)} -
+
{thumb ? ( - {asset.name} + {displayName} ) : ( - - {getAssetIcon(asset.assetType, "size-5")} - + {getAssetIcon(asset.assetType, "size-4")} )} - + {displayName} {quantity > 1 && ( - + {quantity.toLocaleString()} )} @@ -196,6 +200,7 @@ function DraggableLockerItem({ }) { const didDrag = useRef(false); const fp = getLockerItemFootprint(item.asset, item.rotated); + const displayName = item.equipment?.customName?.trim() || item.asset.name; const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: `grid-item-${item.id}`, @@ -217,6 +222,15 @@ function DraggableLockerItem({ [item, onSelect], ); + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + onSelect(item); + }, + [item, onSelect], + ); + const style: React.CSSProperties = { position: "absolute", left: item.gridX * cellSize, @@ -234,15 +248,17 @@ function DraggableLockerItem({ ref={setNodeRef} style={style} className={cn( - "group rounded-md border cursor-grab active:cursor-grabbing select-none overflow-hidden", - "transition-shadow duration-150", - selected && "ring-2 ring-primary", - !selected && "hover:ring-1 hover:ring-primary/50", - highlight && "locker-search-highlight", + "group cursor-grab select-none overflow-hidden rounded-sm border border-border/60 bg-card/80 active:cursor-grabbing", + "transition-all duration-200 hover:border-border/90 hover:bg-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none", + selected && "ring-2 ring-primary ring-offset-2 ring-offset-background", + !selected && "hover:ring-1 hover:ring-primary/40", + highlight && "locker-search-highlight motion-reduce:animate-none", )} {...listeners} {...attributes} onClick={handleClick} + onKeyDown={handleKeyDown} + aria-label={`${displayName}, quantity ${item.quantity.toLocaleString()}`} suppressHydrationWarning > -
-
+
+ {/* Tactical grid frame */} +
-
- - {occupancy.used} / {occupancy.total} cells used - - - {gridWidth} x {gridHeight} + {/* Tactical metadata strip */} +
+
+ + + USAGE + + : + + {occupancy.used} / {occupancy.total} + + + + + GRID + + : + + {gridWidth}×{gridHeight} + + +
+ + SELECT to inspect · DRAG to move · R to rotate
- {dragError &&

{dragError}

} + {dragError && ( +

+ {dragError} +

+ )}
+ {/* Tactical drag overlay */} {activeItem && ( -
+
)} diff --git a/src/components/frontend/locker/LockerGridToolbar.tsx b/src/components/frontend/locker/LockerGridToolbar.tsx index bc159b8..81380d7 100644 --- a/src/components/frontend/locker/LockerGridToolbar.tsx +++ b/src/components/frontend/locker/LockerGridToolbar.tsx @@ -19,6 +19,7 @@ import { lockerIsRotatable, lockerIsStackable, } from "@/lib/locker"; +import { getAssetIcon } from "./LockerGrid"; interface LockerGridToolbarProps { gridWidth: number; @@ -60,72 +61,144 @@ export function LockerGridToolbar({ : false; return ( -
-
-
- +
+
+
+

+ TOOLS +

+

ARRANGE // INSPECT

+ +
-
+
+ VIEW +
{selectedItem && asset && ( -
-
-
{displayName}
-
- {selectedItem.quantity.toLocaleString()}× · {getAssetTypeLabel(asset.assetType)} - {hasCustomName && asset && displayName !== asset.name && ` · as "${asset.name}"`} - {fp && ` · ${fp.width}x${fp.height}`} - {selectedItem.rotated && " · rotated"} +
+
+
+ {getAssetIcon(asset.assetType)} +
+
+

{displayName}

+

+ {getAssetTypeLabel(asset.assetType)} · {selectedItem.quantity.toLocaleString()}× +

-
+ +
+
+
FOOTPRINT
+
+ {fp ? `${fp.width}×${fp.height}` : "—"} +
+
+
+
STATE
+
{selectedItem.rotated ? "ROTATED" : "READY"}
+
+ {hasCustomName && displayName !== asset.name && ( +
+
CATALOG
+
{asset.name}
+
+ )} +
+ +
{asset?.assetType === "weapon" && ( )} {lockerIsRotatable(asset) && ( - )} {lockerIsStackable(asset) && selectedItem.quantity > 1 && ( )} -
)} + + {!selectedItem && ( +
+

NO SELECTION

+

+ Select a stored item to inspect its footprint and actions. +

+
+ )} + +
+ + R + to rotate + + + USAGE + : + + {occupancy.used} / {occupancy.total} + + +
); } diff --git a/src/components/frontend/locker/LockerView.tsx b/src/components/frontend/locker/LockerView.tsx index f479ed7..a09a488 100644 --- a/src/components/frontend/locker/LockerView.tsx +++ b/src/components/frontend/locker/LockerView.tsx @@ -26,6 +26,7 @@ import { } from "@/app/(frontend)/locker/actions"; import { usePageCommands } from "@/components/command-palette/CommandPaletteContext"; import { LayoutGridIcon } from "lucide-react"; +import { LockerCharacterPanel } from "./LockerCharacterPanel"; interface LockerViewProps { locker: LockerStorage; @@ -47,7 +48,7 @@ export function LockerView({ userName, }: LockerViewProps) { const router = useRouter(); - const [tab, setTab] = useState("wardrobe"); + const [tab, setTab] = useState("inventory"); const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); const [selectedId, setSelectedId] = useState(null); const [customizeId, setCustomizeId] = useState(null); @@ -195,17 +196,26 @@ export function LockerView({ ); return ( - -
- - Wardrobe - Inventory - Loadouts + +
+ + WARDROBE + INVENTORY + LOADOUTS - +
+ + {items.length.toLocaleString()} ITEMS · {gridWidth}×{gridHeight} GRID + + +
- + - -
- setCustomizeId(selectedItem?.id ?? null)} - /> - {isManager && } + +
+
+

+ STASH // PERSONAL STORAGE +

+

+ DRAG TO REPOSITION · SELECT TO INSPECT · R TO ROTATE +

+
+ {isManager && ( +
+ +
+ )}
- {items.length === 0 ? ( - - - Empty Locker - - -

- {isManager - ? "Your locker is empty. Add items to get started." - : "Your locker is empty. Reach out to logistics to have equipment issued."} -

-
-
- ) : viewMode === "grid" ? ( - setSelectedId(item?.id ?? null)} - selectedItem={selectedItem} - highlightId={highlightId} - onAttachItem={handleAttachItem} - /> - ) : ( - - -
- {items.map((entry, index) => ( -
- - {index < items.length - 1 &&
} -
- ))} -
- - - )} +
+ - +
+
+ {items.length === 0 ? ( + + + LOCKER EMPTY + + +

+ {isManager + ? "Your locker is empty. Add items to get started." + : "Your locker is empty. Reach out to logistics to have equipment issued."} +

+
+
+ ) : viewMode === "grid" ? ( + setSelectedId(item?.id ?? null)} + selectedItem={selectedItem} + highlightId={highlightId} + onAttachItem={handleAttachItem} + /> + ) : ( + + +
+ {items.map((entry, index) => ( +
+ + {index < items.length - 1 && ( +
+ )} +
+ ))} +
+ + + )} +
+ + +
+
- + diff --git a/src/components/frontend/locker/Wardrobe.tsx b/src/components/frontend/locker/Wardrobe.tsx index 6d53be1..33bcf39 100644 --- a/src/components/frontend/locker/Wardrobe.tsx +++ b/src/components/frontend/locker/Wardrobe.tsx @@ -1,6 +1,5 @@ "use client"; -import { useMemo } from "react"; import { Backpack, Crosshair, @@ -14,6 +13,7 @@ import { } from "lucide-react"; import type { Asset, Loadout } from "@/payload-types"; import { + getAssetTypeLabel, type LoadoutSlots, type LockerGridItem, WARDROBE_SLOT_LAYOUT, @@ -23,8 +23,8 @@ import { cn } from "@/lib/utils"; import { getAssetIcon } from "./LockerGrid"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -function slotIcon(icon: WardrobeSlotIcon, filled: boolean) { - const cls = filled ? "size-3.5" : "size-3"; +function slotIcon(icon: WardrobeSlotIcon) { + const cls = "size-3.5"; switch (icon) { case "head": return ; @@ -57,74 +57,224 @@ function resolveSlotAsset( return items.find((i) => i.assetId === id)?.asset ?? null; } +function resolveSlotDisplayName(asset: Asset, items: LockerGridItem[]): string { + const custom = items.find((i) => i.assetId === asset.id)?.equipment?.customName?.trim(); + return custom || asset.name; +} + +const WARDROBE_REGIONS = [ + { id: "head", label: "HEAD / FACE", description: "Protection and optics" }, + { id: "torso", label: "TORSO", description: "Carrier and uniform" }, + { id: "pack", label: "PACK", description: "Field storage" }, + { id: "weapons", label: "WEAPONS / ATTACHMENTS", description: "Primary, heavy, and weapon fit" }, + { id: "sidearm", label: "SIDEARM", description: "Secondary weapon" }, +] as const; + +type WardrobeRegionId = (typeof WARDROBE_REGIONS)[number]["id"]; + +function getWardrobeRegion(zone: string): WardrobeRegionId { + switch (zone) { + case "helmet": + case "nvg": + case "facewear": + return "head"; + case "radio": + case "vest": + case "uniform": + return "torso"; + case "backpack": + return "pack"; + case "secondary": + return "sidearm"; + default: + return "weapons"; + } +} + +const WARDROBE_REGION_SLOTS = WARDROBE_REGIONS.map((region) => ({ + ...region, + slots: WARDROBE_SLOT_LAYOUT.filter((layout) => getWardrobeRegion(layout.zone) === region.id), +})); + +function WardrobeSlotButton({ + layout, + asset, + displayName, + onSlotClick, +}: { + readonly layout: (typeof WARDROBE_SLOT_LAYOUT)[number]; + readonly asset: Asset | null; + readonly displayName: string | null; + readonly onSlotClick: (slotName: string) => void; +}) { + return ( + + + + + +
+ {layout.label} + {displayName ?? "Empty"} +
+
+
+ ); +} + export function Wardrobe({ loadout, items, onSlotClick, }: { - loadout: Loadout; - items: LockerGridItem[]; - onSlotClick: (slotName: string) => void; + readonly loadout: Loadout; + readonly items: LockerGridItem[]; + readonly onSlotClick: (slotName: string) => void; }) { const slots = (loadout.slots ?? {}) as LoadoutSlots; + const filledCount = WARDROBE_SLOT_LAYOUT.filter((layout) => slots[layout.name] != null).length; - const filled = useMemo(() => { - const set = new Set(); - for (const layout of WARDROBE_SLOT_LAYOUT) { - const value = slots[layout.name]; - if (value != null) set.add(layout.zone); - } - return set; - }, [slots]); + const renderSlot = (layout: (typeof WARDROBE_SLOT_LAYOUT)[number]) => { + const asset = resolveSlotAsset(slots[layout.name], items); + return ( + + ); + }; return ( -
-
- Character wardrobe reflecting the equipped loadout +
+
+
+
+

+ EQUIPMENT MATRIX +

+

+ SELECT A SLOT TO CONFIGURE YOUR LOADOUT +

+
+
+

+ {filledCount}/{WARDROBE_SLOT_LAYOUT.length} +

+

READY

+
+
- {WARDROBE_SLOT_LAYOUT.map((layout) => { - const value = slots[layout.name]; - const asset = resolveSlotAsset(value, items); - return ( - - - - - -
- {layout.label} - {asset ? asset.name : "Empty"} +
+ {WARDROBE_REGION_SLOTS.map((region) => ( +
+
+
+

+ {region.label} +

+

+ {region.description} +

- - - ); - })} + + {region.slots.length} SLOT{region.slots.length === 1 ? "" : "S"} + +
+ + {region.id === "weapons" ? ( +
+ {region.slots.filter((layout) => layout.name === "primary").map(renderSlot)} +
+

+ PRIMARY ATTACHMENTS +

+
+ {region.slots + .filter((layout) => ["optic", "muzzle", "bipod"].includes(layout.name)) + .map(renderSlot)} +
+
+ {region.slots.filter((layout) => layout.name === "launcher").map(renderSlot)} +
+ ) : ( +
+ {region.slots.map(renderSlot)} +
+ )} +
+ ))} +
); diff --git a/src/components/frontend/locker/WardrobePanel.tsx b/src/components/frontend/locker/WardrobePanel.tsx index b41a937..eaaa96c 100644 --- a/src/components/frontend/locker/WardrobePanel.tsx +++ b/src/components/frontend/locker/WardrobePanel.tsx @@ -82,7 +82,7 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa : []; return ( -
+
Character @@ -93,9 +93,9 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa items={items} onSlotClick={(name) => setPickerSlot(name)} /> -

+

{equippedLoadout - ? "Click a highlighted point to equip gear from your locker." + ? "Select a slot block to equip gear from your locker." : "Equip a loadout to start dressing your character."}

From 1f2f7b6269c0d00538af4171e85c67dbbee75b5b Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Fri, 28 Aug 2026 01:20:12 -0400 Subject: [PATCH 3/7] feat(preferences): add background texture toggle --- src/app/(frontend)/account/actions.ts | 4 +- src/app/(frontend)/account/page.tsx | 37 ++++----- src/app/(frontend)/layout.tsx | 14 ++-- src/collections/users/Users.ts | 76 ++++++++++--------- .../frontend/account/PreferencesForm.tsx | 34 +++++++-- src/components/frontend/blocks/AppSidebar.tsx | 1 + ...11500_add_background_texture_preference.ts | 15 ++++ src/migrations/index.ts | 30 +++++--- src/payload-types.ts | 2 + tests/int/preferences-form.int.spec.ts | 2 + 10 files changed, 137 insertions(+), 78 deletions(-) create mode 100644 src/migrations/20260828_011500_add_background_texture_preference.ts diff --git a/src/app/(frontend)/account/actions.ts b/src/app/(frontend)/account/actions.ts index 5f9d519..4817b65 100644 --- a/src/app/(frontend)/account/actions.ts +++ b/src/app/(frontend)/account/actions.ts @@ -205,6 +205,7 @@ export async function updatePreferences(input: { mutedTypes: string[]; showCallsign: boolean; showVersionOverlay: boolean; + showBackgroundTexture: boolean; leadOrActual: "lead" | "actual"; discordEnabled: boolean; discordMutedTypes: string[]; @@ -221,7 +222,7 @@ export async function updatePreferences(input: { (MUTEABLE_NOTIFICATION_VALUES as readonly string[]).includes(t), ); - await payload.update({ + await payload.update({ collection: "users", id: userId, data: { @@ -230,6 +231,7 @@ export async function updatePreferences(input: { display: { showCallsign: input.showCallsign, showVersionOverlay: input.showVersionOverlay, + showBackgroundTexture: input.showBackgroundTexture, leadOrActual: input.leadOrActual, xpDisplayMini: input.xpDisplayMini, }, diff --git a/src/app/(frontend)/account/page.tsx b/src/app/(frontend)/account/page.tsx index 064e0db..75ebb67 100644 --- a/src/app/(frontend)/account/page.tsx +++ b/src/app/(frontend)/account/page.tsx @@ -81,24 +81,25 @@ export default async function AccountPage() { - - - Preferences - Control in-app notifications and display options. - - - - - + + + Preferences + Control in-app notifications and display options. + + + + +
); diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index fde806f..5d63d16 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -133,7 +133,13 @@ export default async function RootLayout(props: { children: React.ReactNode }) { preferences={user.preferences} className="border-r" /> - + {impersonating && } {children} @@ -156,11 +162,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) { {user && } {isLogistics && } - + ); diff --git a/src/collections/users/Users.ts b/src/collections/users/Users.ts index 2080b50..6028b59 100644 --- a/src/collections/users/Users.ts +++ b/src/collections/users/Users.ts @@ -268,41 +268,47 @@ export const Users: CollectionConfig = { }, ], }, - { - name: "display", - label: "Display", - type: "group", - fields: [ - { - name: "showCallsign", - label: "Show callsign (username) instead of display name in the sidebar", - type: "checkbox", - defaultValue: false, - }, - { - name: "leadOrActual", - label: 'Use "Lead" or "Actual" when reviewing the unit roster', - type: "select", - options: [ - { label: "Lead", value: "lead" }, - { label: "Actual", value: "actual" }, - ], - defaultValue: "lead", - }, - { - name: "showVersionOverlay", - label: "Show version overlay at top of page", - type: "checkbox", - defaultValue: true, - }, - { - name: "xpDisplayMini", - label: "Use mini mode for XP display", - type: "checkbox", - defaultValue: false, - }, - ], - }, + { + name: "display", + label: "Display", + type: "group", + fields: [ + { + name: "showCallsign", + label: "Show callsign (username) instead of display name in the sidebar", + type: "checkbox", + defaultValue: false, + }, + { + name: "leadOrActual", + label: 'Use "Lead" or "Actual" when reviewing the unit roster', + type: "select", + options: [ + { label: "Lead", value: "lead" }, + { label: "Actual", value: "actual" }, + ], + defaultValue: "lead", + }, + { + name: "showVersionOverlay", + label: "Show version overlay at top of page", + type: "checkbox", + defaultValue: true, + }, + { + name: "xpDisplayMini", + label: "Use mini mode for XP display", + type: "checkbox", + defaultValue: false, + }, + { + name: "showBackgroundTexture", + label: "Show diagonal background texture", + type: "checkbox", + defaultValue: true, + }, + ], + }, { name: "discord", label: "Discord", diff --git a/src/components/frontend/account/PreferencesForm.tsx b/src/components/frontend/account/PreferencesForm.tsx index 505512e..e5233aa 100644 --- a/src/components/frontend/account/PreferencesForm.tsx +++ b/src/components/frontend/account/PreferencesForm.tsx @@ -17,6 +17,7 @@ interface PreferencesFormProps { readonly mutedTypes: readonly string[]; readonly showCallsign: boolean; readonly showVersionOverlay: boolean; + readonly showBackgroundTexture: boolean; readonly leadOrActual: "lead" | "actual"; readonly discordEnabled: boolean; readonly discordMutedTypes: readonly string[]; @@ -28,6 +29,7 @@ export function PreferencesForm({ mutedTypes: initialMutedTypes, showCallsign: initialShowCallsign, showVersionOverlay: initialShowVersionOverlay, + showBackgroundTexture: initialShowBackgroundTexture, leadOrActual: initialLeadOrActual, discordEnabled: initialDiscordEnabled, discordMutedTypes: initialDiscordMutedTypes, @@ -38,6 +40,7 @@ export function PreferencesForm({ const [mutedTypes, setMutedTypes] = useState(() => [...initialMutedTypes]); const [showCallsign, setShowCallsign] = useState(initialShowCallsign); const [showVersionOverlay, setShowVersionOverlay] = useState(initialShowVersionOverlay); + const [showBackgroundTexture, setShowBackgroundTexture] = useState(initialShowBackgroundTexture); const [leadOrActual, setLeadOrActual] = useState<"lead" | "actual">(initialLeadOrActual); const [discordEnabled, setDiscordEnabled] = useState(initialDiscordEnabled); const [discordMutedTypes, setDiscordMutedTypes] = useState(() => [ @@ -72,6 +75,7 @@ export function PreferencesForm({ mutedTypes, showCallsign, showVersionOverlay, + showBackgroundTexture, leadOrActual, discordEnabled, discordMutedTypes, @@ -104,10 +108,7 @@ export function PreferencesForm({ Choose what appears in the app shell and roster views. - -