1
0
Fork 0

Merge branch 'feat/tarkov-locker-ui'

# Conflicts:
#	TODO.md
#	src/components/frontend/eod/MinesweeperBoard.tsx
#	src/migrations/index.ts
This commit is contained in:
Jason Fraley 2026-08-28 18:26:00 -04:00
commit b9999fceff
23 changed files with 1634 additions and 497 deletions

View file

@ -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.

View file

@ -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",

View file

@ -205,6 +205,7 @@ export async function updatePreferences(input: {
mutedTypes: string[];
showCallsign: boolean;
showVersionOverlay: boolean;
showBackgroundTexture: boolean;
leadOrActual: "lead" | "actual";
discordEnabled: boolean;
discordMutedTypes: string[];
@ -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,
},

View file

@ -92,6 +92,7 @@ export default async function AccountPage() {
mutedTypes={notifications?.mutedTypes ?? []}
showCallsign={fullUser.preferences?.display?.showCallsign ?? false}
showVersionOverlay={fullUser.preferences?.display?.showVersionOverlay ?? true}
showBackgroundTexture={fullUser.preferences?.display?.showBackgroundTexture ?? true}
leadOrActual={fullUser.preferences?.display?.leadOrActual ?? "lead"}
discordEnabled={discord?.enabled ?? false}
discordMutedTypes={discord?.mutedTypes ?? []}

View file

@ -139,7 +139,13 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
preferences={user.preferences}
className="border-r"
/>
<SidebarInset>
<SidebarInset
className={
user.preferences?.display?.showBackgroundTexture !== false
? "app-background-texture"
: undefined
}
>
<SiteHeader />
{impersonating && <ImpersonationBanner />}
{children}

View file

@ -1,4 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&display=swap');
@import url("https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&display=swap");
@import "tailwindcss";
@import "tw-animate-css";
@ -13,7 +13,6 @@
*/
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
@ -94,7 +93,6 @@
*/
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
@ -159,9 +157,9 @@
--sidebar-ring: oklch(0.708 0 0);
--font-sans: 'Geist', sans-serif;
--font-sans: "Geist", sans-serif;
--font-mono: 'Geist Mono', monospace;
--font-mono: "Geist Mono", monospace;
}
/*
@ -169,7 +167,6 @@
*/
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
@ -320,6 +317,35 @@
box-shadow: 0 0 0 3px rgb(251 191 36 / 0.9);
}
.app-background-texture {
background-image: repeating-linear-gradient(
135deg,
rgb(255 255 255 / 0.018) 0 1px,
transparent 1px 7px
);
}
.inventory-frame {
background-color: rgb(7 10 12 / 0.78);
background-image:
linear-gradient(rgb(255 255 255 / 0.018) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 0.018) 1px, transparent 1px),
radial-gradient(circle at 50% 0%, rgb(120 150 145 / 0.09), transparent 55%);
/*background-size:
48px 48px,
48px 48px,
100% 100%;*/
box-shadow:
inset 0 1px rgb(255 255 255 / 0.04),
0 12px 35px rgb(0 0 0 / 0.18);
}
@media (prefers-reduced-motion: reduce) {
.locker-search-highlight {
animation: none;
}
}
@property --medal-border-angle {
syntax: "<angle>";
inherits: false;
@ -343,13 +369,23 @@
}
.medal-card::before {
content: '';
content: "";
position: absolute;
inset: -0.125rem;
z-index: 0;
border-radius: inherit;
border: 2px solid transparent;
background: linear-gradient(var(--background), var(--background)) padding-box, repeating-conic-gradient(from var(--medal-border-angle), transparent 0deg 150deg, peru 159deg, rgb(253 224 71) 168deg, white 178deg, transparent 178deg) border-box;
background:
linear-gradient(var(--background), var(--background)) padding-box,
repeating-conic-gradient(
from var(--medal-border-angle),
transparent 0deg 150deg,
peru 159deg,
rgb(253 224 71) 168deg,
white 178deg,
transparent 178deg
)
border-box;
animation: border-spin-animate 10s linear infinite;
pointer-events: none;
}
@ -369,7 +405,7 @@
}
.medal-card::after {
content: '';
content: "";
background: radial-gradient(circle, transparent, rgb(253 224 71 / 0.1));
position: absolute;
inset: 0;

View file

@ -302,6 +302,12 @@ export const Users: CollectionConfig = {
type: "checkbox",
defaultValue: false,
},
{
name: "showBackgroundTexture",
label: "Show diagonal background texture",
type: "checkbox",
defaultValue: true,
},
],
},
{

View file

@ -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<string[]>(() => [...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<string[]>(() => [
@ -72,6 +75,7 @@ export function PreferencesForm({
mutedTypes,
showCallsign,
showVersionOverlay,
showBackgroundTexture,
leadOrActual,
discordEnabled,
discordMutedTypes,
@ -104,10 +108,7 @@ export function PreferencesForm({
<CardDescription>Choose what appears in the app shell and roster views.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Label
htmlFor="showCallsign"
className="flex items-center justify-between gap-4 text-sm"
>
<Label htmlFor="showCallsign" className="flex items-center justify-between gap-4 text-sm">
<span className="flex min-w-0 flex-col gap-1">
<span>Show callsign instead of display name</span>
<span className="text-sm font-normal text-muted-foreground">
@ -122,7 +123,10 @@ export function PreferencesForm({
/>
</Label>
<Label htmlFor="xpDisplayMini" className="flex items-center justify-between gap-4 text-sm">
<Label
htmlFor="xpDisplayMini"
className="flex items-center justify-between gap-4 text-sm"
>
<span className="flex min-w-0 flex-col gap-1">
<span>Use mini mode for XP display</span>
<span className="text-sm font-normal text-muted-foreground">
@ -137,6 +141,24 @@ export function PreferencesForm({
/>
</Label>
<Label
htmlFor="showBackgroundTexture"
className="flex items-center justify-between gap-4 text-sm"
>
<span className="flex min-w-0 flex-col gap-1">
<span>Show diagonal background texture</span>
<span className="text-sm font-normal text-muted-foreground">
Adds the subtle tactical line pattern behind page content.
</span>
</span>
<Switch
id="showBackgroundTexture"
checked={showBackgroundTexture}
onCheckedChange={setShowBackgroundTexture}
aria-label="Show diagonal background texture"
/>
</Label>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">Roster leader label</span>

View file

@ -195,6 +195,7 @@ export function AppSidebar({
mutedTypes: preferences?.notifications?.mutedTypes ?? [],
showCallsign: preferences?.display?.showCallsign ?? false,
showVersionOverlay: preferences?.display?.showVersionOverlay ?? true,
showBackgroundTexture: preferences?.display?.showBackgroundTexture ?? true,
leadOrActual: preferences?.display?.leadOrActual ?? "lead",
discordEnabled: preferences?.discord?.enabled ?? false,
discordMutedTypes: preferences?.discord?.mutedTypes ?? [],

View file

@ -1,8 +1,9 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import type { EodDifficulty, EodStats } from "@/app/(frontend)/eod/actions";
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
import { useSidebar } from "@/components/ui/sidebar";
import { EodLeaderboard } from "./EodLeaderboard";
import { MinesweeperBoard } from "./MinesweeperBoard";
@ -16,11 +17,25 @@ export function EodArena({
const [refreshKey, setRefreshKey] = useState(0);
const [playing, setPlaying] = useState(false);
const [selectedDifficulty, setSelectedDifficulty] = useState<EodDifficulty>("training");
const { isMobile, setOpen, setOpenMobile } = useSidebar();
const activeTeams = useRealtimePresence("eod", playing, true);
const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => {
setPlaying(phase === "playing");
}, []);
useEffect(() => {
const landscapeQuery = window.matchMedia("(orientation: landscape)");
const hideSidebarForLandscapeGame = () => {
if (!playing || !landscapeQuery.matches) return;
if (isMobile) setOpenMobile(false);
else setOpen(false);
};
hideSidebarForLandscapeGame();
landscapeQuery.addEventListener("change", hideSidebarForLandscapeGame);
return () => landscapeQuery.removeEventListener("change", hideSidebarForLandscapeGame);
}, [isMobile, playing, setOpen, setOpenMobile]);
return (
<div className="flex flex-col items-center gap-6">
<MinesweeperBoard

View file

@ -2,7 +2,7 @@
// allow: SIZE_OK — the board is a single interactive game state machine required by the route contract.
import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react";
import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import { Bomb, Flag, RotateCcw, ShieldCheck, Timer, X } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
@ -20,7 +20,6 @@ import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
import { useSessionExpired } from "@/hooks/useSessionExpired";
import { LoginLink } from "@/components/frontend/auth/LoginLink";
import { cn } from "@/lib/utils";
import { playClear, playExplosion, playFlag, playReveal, playXp } from "./sounds";
type Phase = "ready" | "playing" | "over";
type GameMode = EodDifficulty | "custom";
@ -39,6 +38,7 @@ const CUSTOM_MIN_SIZE = 9;
const CUSTOM_MAX_SIZE = 500;
const CUSTOM_MAX_SAFE_MINES_BUFFER = 9;
const CUSTOM_FULLSCREEN_CELL_THRESHOLD = 900;
const MOBILE_COLUMN_COUNT = 10;
type Cell = {
readonly mine: boolean;
@ -93,6 +93,19 @@ const NUMBER_CLASSES: Readonly<Record<number, string>> = {
8: "text-gray-400",
};
function getMobileConfig(config: GameConfig, landscape: boolean): GameConfig {
if (landscape) return config;
const rows = Math.max(1, Math.ceil((config.rows * config.cols) / MOBILE_COLUMN_COUNT));
const cells = rows * MOBILE_COLUMN_COUNT;
return {
...config,
rows,
cols: MOBILE_COLUMN_COUNT,
mines: Math.min(config.mines, cells - CUSTOM_MAX_SAFE_MINES_BUFFER),
};
}
function emptyBoard(size: number): Board {
return Array.from({ length: size }, () => ({
mine: false,
@ -202,12 +215,17 @@ export function MinesweeperBoard({
const [stats, setStats] = useState<EodStats>(serverStats);
const [lastRun, setLastRun] = useState<LastRun | null>(null);
const [submitting, setSubmitting] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [isLandscape, setIsLandscape] = useState(false);
const [isNarrow, setIsNarrow] = useState(false);
const generatedRef = useRef(false);
const previousViewportRef = useRef<string | null>(null);
const submittingRef = useRef(false);
const statsRef = useRef<EodStats>(serverStats);
const longPressRef = useRef(false);
const longPressTimerRef = useRef<number | null>(null);
const config: GameConfig = difficulty === "custom" ? customConfig : DIFFICULTIES[difficulty];
const baseConfig: GameConfig = difficulty === "custom" ? customConfig : DIFFICULTIES[difficulty];
const config = isMobile ? getMobileConfig(baseConfig, isLandscape) : baseConfig;
const customUnlocked =
stats.bestTrainingTime > 0 && stats.bestPerimeterTime > 0 && stats.bestSupplyRoadTime > 0;
const isLargeCustomGame =
@ -226,6 +244,27 @@ export function MinesweeperBoard({
onPhaseChange("ready");
}, [onPhaseChange]);
useEffect(() => {
const mobileQuery = window.matchMedia("(max-width: 639px)");
const landscapeQuery = window.matchMedia("(orientation: landscape)");
const narrowQuery = window.matchMedia("(max-width: 1023px)");
const updateViewport = () => {
setIsMobile(mobileQuery.matches);
setIsLandscape(landscapeQuery.matches);
setIsNarrow(narrowQuery.matches);
};
updateViewport();
mobileQuery.addEventListener("change", updateViewport);
landscapeQuery.addEventListener("change", updateViewport);
narrowQuery.addEventListener("change", updateViewport);
return () => {
mobileQuery.removeEventListener("change", updateViewport);
landscapeQuery.removeEventListener("change", updateViewport);
narrowQuery.removeEventListener("change", updateViewport);
};
}, []);
useEffect(() => {
if (phase !== "playing") return;
const timer = window.setInterval(() => {
@ -244,7 +283,11 @@ export function MinesweeperBoard({
(nextDifficulty: GameMode = difficulty) => {
if (submittingRef.current) return;
if (expiredRef.current) return;
const nextConfig = nextDifficulty === "custom" ? customConfig : DIFFICULTIES[nextDifficulty];
const selectedConfig =
nextDifficulty === "custom" ? customConfig : DIFFICULTIES[nextDifficulty];
const nextConfig = isMobile
? getMobileConfig(selectedConfig, isLandscape)
: selectedConfig;
setDifficulty(nextDifficulty);
if (nextDifficulty !== "custom") onDifficultyChange(nextDifficulty);
setBoard(emptyBoard(nextConfig.rows * nextConfig.cols));
@ -253,9 +296,20 @@ export function MinesweeperBoard({
setLastRun(null);
setPhaseSafe("ready");
},
[customConfig, difficulty, expiredRef, onDifficultyChange, setPhaseSafe],
[customConfig, difficulty, expiredRef, isLandscape, isMobile, onDifficultyChange, setPhaseSafe],
);
useEffect(() => {
const viewportKey = `${isMobile}-${isLandscape}`;
if (previousViewportRef.current === null) {
previousViewportRef.current = viewportKey;
return;
}
if (previousViewportRef.current === viewportKey) return;
previousViewportRef.current = viewportKey;
reset();
}, [isLandscape, isMobile, reset]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
@ -443,15 +497,23 @@ export function MinesweeperBoard({
const renderGrid = (modal = false) => (
<div
className={cn(
"overflow-auto rounded-lg border border-border bg-background",
"min-w-0 max-w-full overflow-auto rounded-lg border border-border bg-background",
modal
? "min-h-full min-w-full items-start justify-start p-2"
: "flex items-center justify-center p-2",
)}
style={{ containerType: "inline-size" }}
>
<div
className="grid min-w-max gap-1"
style={{ gridTemplateColumns: `repeat(${config.cols}, 1.75rem)` }}
className="grid min-w-max grid-cols-[repeat(var(--board-cols),var(--cell-size))] gap-1"
style={
{
"--board-cols": config.cols,
"--cell-size": isNarrow
? `min(1.75rem, calc((100cqw - ${(config.cols - 1) * 0.25}rem) / ${config.cols}))`
: "1.75rem",
} as CSSProperties
}
role="grid"
aria-label={`${config.label} minefield`}
>
@ -469,7 +531,7 @@ export function MinesweeperBoard({
aria-colindex={col + 1}
disabled={phase === "over" || expired || cell.revealed}
className={cn(
"flex size-7 items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"flex size-[var(--cell-size)] items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
cell.revealed &&
!cell.mine &&
(highContrast
@ -772,10 +834,20 @@ export function MinesweeperBoard({
</div>
{!isLargeCustomGame && (
<div className="overflow-x-auto rounded-lg border border-border bg-background p-2 items-center justify-center flex">
<div
className="grid min-w-max gap-1"
style={{ gridTemplateColumns: `repeat(${config.cols}, 1.75rem)` }}
className="flex min-w-0 max-w-full items-center justify-center overflow-auto rounded-lg border border-border bg-background p-2"
style={{ containerType: "inline-size" }}
>
<div
className="grid min-w-max grid-cols-[repeat(var(--board-cols),var(--cell-size))] gap-1"
style={
{
"--board-cols": config.cols,
"--cell-size": isNarrow
? `min(1.75rem, calc((100cqw - ${(config.cols - 1) * 0.25}rem) / ${config.cols}))`
: "1.75rem",
} as CSSProperties
}
role="grid"
aria-label={`${config.label} minefield`}
>
@ -793,7 +865,7 @@ export function MinesweeperBoard({
aria-colindex={col + 1}
disabled={phase === "over" || expired || cell.revealed}
className={cn(
"flex size-7 items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"flex size-[var(--cell-size)] items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
cell.revealed &&
!cell.mine &&
(highContrast

View file

@ -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<string | null>(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 (
<section
className="flex min-w-0 flex-col border border-border/70 bg-card/35"
aria-label="Character equipment"
>
<div className="flex items-center justify-between gap-3 border-b border-border/60 bg-muted/20 px-3 py-2">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.2em] text-primary/80">
CHARACTER
</p>
<p className="text-xs text-muted-foreground/70">EQUIPMENT STATUS</p>
</div>
<span
className={cn(
"size-2 rounded-full",
equippedLoadout ? "bg-emerald-400" : "bg-muted-foreground/40",
)}
aria-label={equippedLoadout ? "Loadout equipped" : "No loadout equipped"}
/>
</div>
<div className="border-b border-border/50 p-2">
<Select
value={equippedLoadout ? String(equippedLoadout.id) : ""}
onValueChange={changeLoadout}
disabled={busy || loadouts.length === 0}
>
<SelectTrigger className="h-8 rounded-sm border-border/60 bg-background/40 text-xs uppercase tracking-wide">
<SelectValue placeholder="SELECT LOADOUT" />
</SelectTrigger>
<SelectContent>
{loadouts.map((loadout) => (
<SelectItem key={loadout.id} value={String(loadout.id)}>
{loadout.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="p-2">
<Wardrobe
loadout={equippedLoadout ?? ({ name: "Empty", slots: {} } as unknown as Loadout)}
items={items}
onSlotClick={setPickerSlot}
/>
</div>
<div className="flex items-center justify-between border-t border-border/50 px-3 py-2 font-mono text-[10px] uppercase tracking-wide text-muted-foreground/60">
<span>Click a slot to equip</span>
<Button
variant="ghost"
size="sm"
className="h-6 rounded-sm px-2 text-[10px]"
onClick={() => setPickerSlot("primary")}
disabled={!equippedLoadout || busy}
>
EDIT
</Button>
</div>
{pickerLayout && pickerConfig && (
<WardrobeSlotPicker
open={!!pickerSlot}
onOpenChange={(open) => {
if (!open) setPickerSlot(null);
}}
slot={pickerConfig}
items={items}
currentValue={slots[pickerSlot as keyof LoadoutSlots] ?? null}
busy={busy}
onPick={pickSlot}
/>
)}
</section>
);
}

View file

@ -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"
>
<div className="absolute inset-0 opacity-20" style={{ backgroundColor: bgColor }} />
{/* Subtle selection glow */}
{(hasSkin || attachmentCount > 0 || hasCustomName) && (
<div className="absolute top-0.5 right-0.5 flex flex-col items-end gap-0.5 z-10">
<div className="absolute inset-0 bg-primary/5" />
)}
{(hasSkin || attachmentCount > 0 || hasCustomName) && (
<div className="absolute right-0.5 top-0.5 z-10 flex flex-col items-end gap-0.5">
{hasCustomName && (
<span className="rounded-sm bg-sky-400 p-0.5 text-white shadow" title="Custom name">
<span
className="rounded-sm border border-primary/30 bg-primary/15 p-0.5 text-primary"
title="Custom name"
>
<Tag className="size-2.5" />
</span>
)}
{hasSkin && (
<span className="rounded-sm bg-amber-400 p-0.5 text-white shadow" title="Skin applied">
<span
className="rounded-sm border border-amber-400/30 bg-amber-400/15 p-0.5 text-amber-200"
title="Skin applied"
>
<Palette className="size-2.5" />
</span>
)}
{attachmentCount > 0 && (
<span
className="flex items-center gap-0.5 rounded-sm bg-primary px-0.5 py-0.5 text-white shadow"
className="flex items-center gap-0.5 rounded-sm border border-primary/30 bg-primary/15 px-0.5 py-0.5 text-primary"
title={`${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`}
>
<Crosshair className="size-2.5" />
<span className="text-[8px] font-bold leading-none">{attachmentCount}</span>
<span className="font-mono text-[10px] font-semibold leading-none tabular-nums">
{attachmentCount}
</span>
</span>
)}
</div>
)}
<div className="relative flex flex-col items-center justify-center gap-0.5 p-1 w-full h-full">
<div className="relative flex h-full w-full flex-col items-center justify-center gap-1 p-0.5">
{thumb ? (
<img src={thumb} alt={asset.name} className="size-6 object-contain opacity-80" />
<img src={thumb} alt={displayName} className="size-5.5 object-contain opacity-85" />
) : (
<span className="text-muted-foreground/70">
{getAssetIcon(asset.assetType, "size-5")}
</span>
<span className="text-primary/60">{getAssetIcon(asset.assetType, "size-4")}</span>
)}
<span className="text-[10px] leading-tight font-medium text-center text-foreground/90 line-clamp-2 px-0.5">
<span className="line-clamp-1 px-1 text-center text-[10px] font-medium leading-tight text-foreground/80">
{displayName}
</span>
{quantity > 1 && (
<span
className="text-[9px] font-bold leading-none px-1 py-0.5 rounded-sm"
style={{ backgroundColor: bgColor, color: "white" }}
>
<span className="rounded-sm bg-primary/15 px-0.5 py-0.5 font-mono text-[10px] font-semibold leading-none tabular-nums text-primary">
{quantity.toLocaleString()}
</span>
)}
@ -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
>
<LockerItemFace
@ -535,11 +551,18 @@ export function LockerGrid({
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
>
<div className="flex flex-col gap-3">
<div ref={containerRef} className="rounded-lg border bg-muted/20 p-2">
<div className="flex flex-col gap-2">
{/* Tactical grid frame */}
<div
ref={containerRef}
className="inventory-frame relative min-w-0 max-w-full overflow-auto overscroll-contain rounded border border-border bg-card/80 shadow-sm"
role="region"
aria-label="Locker inventory grid"
tabIndex={0}
>
<div
ref={gridRef}
className="relative grid"
className="relative grid bg-background/10"
style={{
gridTemplateColumns: `repeat(${gridWidth}, ${cellSize}px)`,
gridTemplateRows: `repeat(${gridHeight}, ${cellSize}px)`,
@ -578,25 +601,49 @@ export function LockerGrid({
</div>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
{/* Tactical metadata strip */}
<div className="metadata-strip flex flex-wrap items-center justify-between gap-2 border-t border-border/60 px-2 py-2 text-[10px] text-muted-foreground/70">
<div className="flex items-center gap-4">
<span>
{occupancy.used} / {occupancy.total} cells used
<span className="font-mono tabular-nums text-foreground/80">
USAGE
</span>
<span className="mx-1 text-muted-foreground/40">:</span>
<span className="font-mono tabular-nums text-foreground/80">
{occupancy.used} / {occupancy.total}
</span>
</span>
<span>
{gridWidth} x {gridHeight}
<span className="font-mono tabular-nums text-foreground/80">
GRID
</span>
<span className="mx-1 text-muted-foreground/40">:</span>
<span className="font-mono tabular-nums text-foreground/80">
{gridWidth}×{gridHeight}
</span>
</span>
</div>
{dragError && <p className="text-xs text-red-400">{dragError}</p>}
<span className="hidden sm:inline">
SELECT to inspect · DRAG to move · R to rotate
</span>
</div>
{dragError && (
<p role="alert" className="text-[10px] text-destructive">
{dragError}
</p>
)}
</div>
{/* Tactical drag overlay */}
<DragOverlay dropAnimation={null}>
{activeItem && (
<div className="rounded-md border border-primary/50 shadow-xl ring-2 ring-primary/50">
<div className="rounded-sm border border-primary/50 bg-card shadow-lg ring-2 ring-primary/40">
<LockerItemFace
asset={activeItem.asset}
quantity={activeItem.quantity}
rotated={pendingRotated}
cellSize={cellSize}
equipment={activeItem.equipment}
/>
</div>
)}

View file

@ -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 (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/60 pb-2">
<div className="min-w-0">
<p className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground/60">
TOOLS
</p>
<p className="text-sm font-medium text-foreground/80">ARRANGE // INSPECT</p>
</div>
<Button variant="outline" size="sm" onClick={onAutoArrange}>
<ShuffleIcon className="size-3.5 mr-1" />
Auto-Arrange
<ShuffleIcon data-icon="inline-start" />
AUTO
</Button>
</div>
<div className="flex items-center gap-1">
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] text-muted-foreground/60 uppercase tracking-wide">VIEW</span>
<div className="flex items-center gap-1" role="group" aria-label="Inventory view">
<Button
variant={viewMode === "grid" ? "default" : "outline"}
size="sm"
size="icon"
aria-label="Grid view"
aria-pressed={viewMode === "grid"}
title="Grid view"
onClick={() => onViewModeChange("grid")}
>
<LayoutGridIcon className="size-3.5" />
<LayoutGridIcon aria-hidden="true" />
</Button>
<Button
variant={viewMode === "list" ? "default" : "outline"}
size="sm"
size="icon"
aria-label="List view"
aria-pressed={viewMode === "list"}
title="List view"
onClick={() => onViewModeChange("list")}
>
<ListIcon className="size-3.5" />
<ListIcon aria-hidden="true" />
</Button>
</div>
</div>
{selectedItem && asset && (
<div className="flex items-center gap-3 p-2 rounded-md border bg-muted/30 text-xs">
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{displayName}</div>
<div className="text-muted-foreground">
{selectedItem.quantity.toLocaleString()}× · {getAssetTypeLabel(asset.assetType)}
{hasCustomName && asset && displayName !== asset.name && ` · as "${asset.name}"`}
{fp && ` · ${fp.width}x${fp.height}`}
{selectedItem.rotated && " · rotated"}
<div className="flex min-w-0 flex-col gap-2 border border-border/30 bg-card/40 p-2.5 text-[10px]">
<div className="flex min-w-0 items-start gap-2">
<div className="flex size-7 shrink-0 items-center justify-center border border-border/30 bg-muted/40 text-primary/90">
{getAssetIcon(asset.assetType)}
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground/90">{displayName}</p>
<p className="truncate text-muted-foreground/60">
{getAssetTypeLabel(asset.assetType)} · {selectedItem.quantity.toLocaleString()}×
</p>
</div>
</div>
<div className="flex items-center gap-1">
<dl className="grid grid-cols-2 gap-x-2.5 gap-y-1.5 border-t border-border/50 pt-2">
<div>
<dt className="text-muted-foreground/70">FOOTPRINT</dt>
<dd className="font-mono tabular-nums text-foreground/90">
{fp ? `${fp.width}×${fp.height}` : "—"}
</dd>
</div>
<div>
<dt className="text-muted-foreground/70">STATE</dt>
<dd className="text-foreground/90">{selectedItem.rotated ? "ROTATED" : "READY"}</dd>
</div>
{hasCustomName && displayName !== asset.name && (
<div className="col-span-2 min-w-0">
<dt className="text-muted-foreground/70">CATALOG</dt>
<dd className="truncate text-foreground/90">{asset.name}</dd>
</div>
)}
</dl>
<div className="flex flex-wrap items-center gap-1.5">
{asset?.assetType === "weapon" && (
<Button variant="outline" size="sm" onClick={onCustomize}>
<SlidersHorizontalIcon className="size-3.5" />
Customize
<SlidersHorizontalIcon data-icon="inline-start" />
CUSTOM
</Button>
)}
{lockerIsRotatable(asset) && (
<Button variant="outline" size="sm" onClick={() => onRotate(selectedItem.id)}>
<RotateCwIcon className="size-3.5" />
<Button
variant="outline"
size="icon"
aria-label="Rotate selected item"
title="Rotate selected item"
onClick={() => onRotate(selectedItem.id)}
>
<RotateCwIcon aria-hidden="true" />
</Button>
)}
{lockerIsStackable(asset) && selectedItem.quantity > 1 && (
<Button
variant="outline"
size="sm"
size="icon"
disabled={!canSplit}
aria-label="Split selected stack"
title="Split selected stack"
onClick={() => onSplit(selectedItem.id, Math.floor(selectedItem.quantity / 2))}
>
<ScissorsIcon className="size-3.5" />
<ScissorsIcon aria-hidden="true" />
</Button>
)}
<Button variant="outline" size="sm" onClick={() => onRemove(selectedItem.id)}>
<TrashIcon className="size-3.5" />
<Button
variant="outline"
size="icon"
aria-label="Remove selected item"
title="Remove selected item"
onClick={() => onRemove(selectedItem.id)}
>
<TrashIcon aria-hidden="true" />
</Button>
</div>
</div>
)}
{!selectedItem && (
<div className="border border-dashed border-border/30 bg-muted/20 p-2.5">
<p className="text-[10px] font-medium text-muted-foreground/60 uppercase">NO SELECTION</p>
<p className="mt-1 text-[10px] text-muted-foreground/50">
Select a stored item to inspect its footprint and actions.
</p>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 px-2 py-2 text-[10px] text-muted-foreground/60">
<span className="hidden sm:block">
<span className="font-mono tabular-nums text-foreground/80">R</span>
<span className="mx-1 text-muted-foreground/40">to rotate</span>
</span>
<span>
<span className="font-mono tabular-nums text-foreground/80">USAGE</span>
<span className="mx-1 text-muted-foreground/40">:</span>
<span className="font-mono tabular-nums text-foreground/80">
{occupancy.used} / {occupancy.total}
</span>
</span>
</div>
</div>
);
}

View file

@ -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<string | null>(null);
const [customizeId, setCustomizeId] = useState<string | null>(null);
@ -195,17 +196,26 @@ export function LockerView({
);
return (
<Tabs value={tab} onValueChange={setTab}>
<div className="flex items-center justify-between gap-3">
<TabsList>
<TabsTrigger value="wardrobe">Wardrobe</TabsTrigger>
<TabsTrigger value="inventory">Inventory</TabsTrigger>
<TabsTrigger value="loadouts">Loadouts</TabsTrigger>
<Tabs
value={tab}
onValueChange={setTab}
className="flex min-w-0 w-full max-w-none flex-col gap-4"
>
<div className="flex min-w-0 flex-col gap-3 border-b border-border pb-3 sm:flex-row sm:items-center sm:justify-between">
<TabsList variant="line" aria-label="Locker sections" className="w-full sm:w-auto">
<TabsTrigger value="wardrobe">WARDROBE</TabsTrigger>
<TabsTrigger value="inventory">INVENTORY</TabsTrigger>
<TabsTrigger value="loadouts">LOADOUTS</TabsTrigger>
</TabsList>
<div className="flex shrink-0 items-center gap-3 self-start sm:self-auto">
<span className="hidden font-mono text-[10px] tabular-nums text-muted-foreground/60 lg:inline">
{items.length.toLocaleString()} ITEMS · {gridWidth}×{gridHeight} GRID
</span>
<LockerSearch items={items} loadouts={loadouts} onSelect={handleSearchSelect} />
</div>
</div>
<TabsContent value="wardrobe">
<TabsContent value="wardrobe" className="mt-0">
<WardrobePanel
userName={userName}
loadouts={loadouts}
@ -214,31 +224,35 @@ export function LockerView({
/>
</TabsContent>
<TabsContent value="inventory" className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<LockerGridToolbar
gridWidth={gridWidth}
gridHeight={gridHeight}
items={items}
selectedItem={selectedItem}
viewMode={viewMode}
onViewModeChange={setViewMode}
onAutoArrange={handleAutoArrange}
onRotate={handleRotateItem}
onRemove={handleRemoveItem}
onSplit={handleSplitStack}
onCustomize={() => setCustomizeId(selectedItem?.id ?? null)}
/>
{isManager && <AddLockerItemDialog assets={assetsCatalog} />}
<TabsContent value="inventory" className="mt-0 flex min-w-0 w-full flex-col gap-3">
<div className="flex min-w-0 flex-col gap-2 border-y border-border/50 py-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="font-mono text-xs font-medium uppercase tracking-[0.18em] text-foreground/90">
STASH // PERSONAL STORAGE
</p>
<p className="text-xs text-muted-foreground/70">
DRAG TO REPOSITION · SELECT TO INSPECT · R TO ROTATE
</p>
</div>
{isManager && (
<div className="shrink-0">
<AddLockerItemDialog assets={assetsCatalog} />
</div>
)}
</div>
<div className="grid min-w-0 items-start gap-4 xl:grid-cols-[minmax(24rem,30rem)_minmax(0,1fr)]">
<LockerCharacterPanel loadouts={loadouts} items={items} />
<div className="grid min-w-0 items-start gap-3 2xl:grid-cols-[minmax(0,1fr)_18rem]">
<div className="min-w-0">
{items.length === 0 ? (
<Item variant="outline">
<Item variant="outline" className="rounded-sm border border-border/30 bg-card/40">
<ItemHeader>
<ItemTitle className="text-muted-foreground">Empty Locker</ItemTitle>
<ItemTitle className="text-muted-foreground/70">LOCKER EMPTY</ItemTitle>
</ItemHeader>
<ItemContent>
<p className="text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground/60">
{isManager
? "Your locker is empty. Add items to get started."
: "Your locker is empty. Reach out to logistics to have equipment issued."}
@ -260,42 +274,69 @@ export function LockerView({
onAttachItem={handleAttachItem}
/>
) : (
<Item variant="outline">
<ItemContent>
<Item
variant="outline"
className="rounded-sm border border-border/30 bg-card/40 p-0"
>
<ItemContent className="gap-0 p-1.5">
<div className="flex flex-col">
{items.map((entry, index) => (
<div key={entry.id}>
<button
type="button"
className="flex items-center gap-3 py-2.5 w-full text-left"
className="flex w-full items-center gap-3 rounded-sm px-2 py-2 text-left text-sm transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset motion-reduce:transition-none"
onClick={() => setSelectedId(entry.id)}
aria-label={`Select ${entry.equipment?.customName?.trim() || entry.asset.name}`}
>
<span className="text-sm font-medium flex-1 min-w-0 truncate">
<span className="min-w-0 flex-1 truncate font-medium text-foreground/90">
{entry.equipment?.customName?.trim() || entry.asset.name}
</span>
<span className="text-xs text-muted-foreground">
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/50 uppercase">
{getAssetTypeLabel(entry.asset.assetType)}
</span>
<span className="text-xs text-muted-foreground shrink-0">
<span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground/50">
{entry.quantity.toLocaleString()}×
</span>
</button>
{index < items.length - 1 && <div className="border-t border-border" />}
{index < items.length - 1 && (
<div className="border-t border-border/30" />
)}
</div>
))}
</div>
</ItemContent>
</Item>
)}
</div>
<aside
className="selection-rail flex min-w-0 flex-col gap-3 2xl:sticky 2xl:top-3"
aria-label="Selected item and locker activity"
>
<LockerGridToolbar
gridWidth={gridWidth}
gridHeight={gridHeight}
items={items}
selectedItem={selectedItem}
viewMode={viewMode}
onViewModeChange={setViewMode}
onAutoArrange={handleAutoArrange}
onRotate={handleRotateItem}
onRemove={handleRemoveItem}
onSplit={handleSplitStack}
onCustomize={() => setCustomizeId(selectedItem?.id ?? null)}
/>
<EventLedger
targetCollection="locker-storages"
targetId={locker.id}
refreshKey={eventLogKey}
/>
</aside>
</div>
</div>
</TabsContent>
<TabsContent value="loadouts">
<TabsContent value="loadouts" className="mt-0">
<LoadoutsPanel loadouts={loadouts} ownedItems={items} />
</TabsContent>

View file

@ -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 <User className={cls} />;
@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`${layout.label} slot: ${displayName ?? "empty"}`}
data-slot-state={asset ? "filled" : "empty"}
onClick={() => onSlotClick(layout.name)}
className={cn(
"group flex min-h-20 min-w-0 cursor-pointer flex-col gap-3 rounded-sm border p-3 text-left transition-colors 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",
asset
? "border-primary/40 bg-primary/10 hover:border-primary/70 hover:bg-primary/15"
: "border-border/70 bg-background/30 text-muted-foreground hover:border-primary/50 hover:bg-muted/40 hover:text-foreground",
)}
>
<span className="grid min-w-0 grid-cols-[2rem_minmax(0,1fr)_auto] items-start gap-2">
<span
aria-hidden="true"
className={cn(
"flex size-8 items-center justify-center rounded-sm border",
asset
? "border-primary/30 bg-primary/15 text-primary"
: "border-border/60 bg-muted/40 text-muted-foreground",
)}
>
{asset ? getAssetIcon(asset.assetType) : slotIcon(layout.icon)}
</span>
<span className="min-w-0">
<span className="block whitespace-normal text-xs font-semibold leading-tight text-foreground/90">
{layout.label}
</span>
<span
className={cn(
"block whitespace-normal text-xs font-mono uppercase leading-tight tracking-wide",
asset ? "text-primary/80" : "text-muted-foreground/70",
)}
>
{displayName ?? "EMPTY"}
</span>
</span>
<span
className={cn(
"text-right text-[10px] font-mono uppercase leading-tight tracking-wide whitespace-nowrap",
asset ? "text-primary" : "text-muted-foreground/60",
)}
>
{asset ? "EQUIPPED" : "OPEN"}
</span>
</span>
<span className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 border-t border-border/40 pt-2 text-xs text-muted-foreground/70">
<span className="whitespace-normal leading-tight">
{asset ? getAssetTypeLabel(asset.assetType) : layout.label}
</span>
<span aria-hidden="true">/</span>
<span className="whitespace-normal leading-tight">
{asset ? "READY" : "SELECT TO EQUIP"}
</span>
</span>
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8} className="text-sm">
<div className="flex flex-col gap-0.5">
<span className="font-semibold">{layout.label}</span>
<span className="text-muted-foreground">{displayName ?? "Empty"}</span>
</div>
</TooltipContent>
</Tooltip>
);
}
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<string>();
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 (
<div className="relative mx-auto w-full max-w-100">
<div className="relative" style={{ aspectRatio: "260 / 380" }}>
<img
src="/images/soldier-pose-alpha.png"
alt="Character wardrobe reflecting the equipped loadout"
className="h-full w-full object-contain pointer-events-none select-none"
draggable={false}
<WardrobeSlotButton
key={layout.name}
layout={layout}
asset={asset}
displayName={asset ? resolveSlotDisplayName(asset, items) : null}
onSlotClick={onSlotClick}
/>
{WARDROBE_SLOT_LAYOUT.map((layout) => {
const value = slots[layout.name];
const asset = resolveSlotAsset(value, items);
return (
<Tooltip key={layout.name}>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`${layout.label} slot: ${asset ? asset.name : "empty"}`}
onPointerDown={(e) => {
e.preventDefault();
onSlotClick(layout.name);
}}
className={cn(
"absolute flex items-center justify-center rounded-full border transition-colors",
"-translate-x-1/2 -translate-y-1/2 cursor-pointer",
asset
? "size-7 border-amber-400/70 bg-amber-500/25 text-amber-200 hover:bg-amber-500/40"
: "size-6 border-border bg-background/80 text-muted-foreground hover:border-primary hover:text-foreground",
)}
style={{ left: `${layout.x}%`, top: `${layout.y}%` }}
>
{asset ? getAssetIcon(asset.assetType, "size-3.5") : slotIcon(layout.icon, false)}
</button>
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={8}
className="text-sm px-3.5 py-2"
>
<div className="flex flex-col gap-0.5">
<span className="font-semibold">{layout.label}</span>
<span className="text-muted-foreground">{asset ? asset.name : "Empty"}</span>
</div>
</TooltipContent>
</Tooltip>
);
})}
};
return (
<div className="@container w-full min-w-0">
<div className="flex flex-col gap-3 border border-border/60 bg-card/40 p-3">
<div className="flex items-start justify-between gap-3 border-b border-border/50 pb-3">
<div className="min-w-0">
<p className="font-mono text-xs uppercase tracking-widest text-muted-foreground/70">
EQUIPMENT MATRIX
</p>
<p className="mt-1 text-sm font-medium text-foreground/90">
SELECT A SLOT TO CONFIGURE YOUR LOADOUT
</p>
</div>
<div className="shrink-0 text-right">
<p className="font-mono text-xs tabular-nums text-primary">
{filledCount}/{WARDROBE_SLOT_LAYOUT.length}
</p>
<p className="text-xs uppercase tracking-wide text-muted-foreground/60">READY</p>
</div>
</div>
<div className="grid min-w-0 gap-3 @sm:grid-cols-2 @xl:grid-cols-3">
{WARDROBE_REGION_SLOTS.map((region) => (
<section
key={region.id}
aria-labelledby={`wardrobe-region-${region.id}`}
className={cn(
"flex min-w-0 flex-col gap-2 border-2 border-border/80 bg-background/20 p-3",
region.id === "head" && "@xl:col-span-3",
region.id === "torso" && "@xl:col-span-2",
region.id === "weapons" && "@xl:col-span-2",
)}
>
<div className="flex items-start justify-between gap-2 border-b border-border/70 pb-2">
<div className="min-w-0">
<h3
id={`wardrobe-region-${region.id}`}
className="whitespace-normal break-words text-xs font-semibold tracking-wide text-foreground/90"
>
{region.label}
</h3>
<p className="whitespace-normal break-words text-xs text-muted-foreground/60">
{region.description}
</p>
</div>
<span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground/60">
{region.slots.length} SLOT{region.slots.length === 1 ? "" : "S"}
</span>
</div>
{region.id === "weapons" ? (
<div className="flex min-w-0 flex-col gap-2">
{region.slots.filter((layout) => layout.name === "primary").map(renderSlot)}
<div className="ml-3 flex min-w-0 flex-col gap-2 border-l-2 border-border/70 pl-3">
<p className="font-mono text-xs uppercase tracking-wide text-muted-foreground/70">
PRIMARY ATTACHMENTS
</p>
<div className="grid min-w-0 grid-cols-1 gap-2">
{region.slots
.filter((layout) => ["optic", "muzzle", "bipod"].includes(layout.name))
.map(renderSlot)}
</div>
</div>
{region.slots.filter((layout) => layout.name === "launcher").map(renderSlot)}
</div>
) : (
<div className="grid min-w-0 grid-cols-1 gap-2">
{region.slots.map(renderSlot)}
</div>
)}
</section>
))}
</div>
</div>
</div>
);

View file

@ -82,7 +82,7 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
: [];
return (
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,500px)_1fr] gap-6">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,40rem)_minmax(0,1fr)]">
<Item variant="outline" className="self-start">
<ItemHeader>
<ItemTitle>Character</ItemTitle>
@ -93,9 +93,9 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
items={items}
onSlotClick={(name) => setPickerSlot(name)}
/>
<p className="text-center text-sm text-muted-foreground mt-3">
<p className="mt-3 text-center text-sm text-muted-foreground">
{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."}
</p>
</ItemContent>

View file

@ -0,0 +1,15 @@
import { MigrateDownArgs, MigrateUpArgs, sql } from "@payloadcms/db-postgres";
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "users"
ADD COLUMN "preferences_display_show_background_texture" boolean DEFAULT true;
`);
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "users"
DROP COLUMN "preferences_display_show_background_texture";
`);
}

View file

@ -1,9 +1,10 @@
import * as migration_20260820_004157 from './20260820_004157';
import * as migration_20260825_220000 from './20260825_220000';
import * as migration_20260826_050000_repair_shims_auxiliary_tables from './20260826_050000_repair_shims_auxiliary_tables';
import * as migration_20260826_060000_add_game_servers_event_target from './20260826_060000_add_game_servers_event_target';
import * as migration_20260827_005757 from './20260827_005757';
import * as migration_20260827_022334_add_eod_minesweeper_best_flags from './20260827_022334_add_eod_minesweeper_best_flags';
import * as migration_20260820_004157 from "./20260820_004157";
import * as migration_20260825_220000 from "./20260825_220000";
import * as migration_20260826_050000_repair_shims_auxiliary_tables from "./20260826_050000_repair_shims_auxiliary_tables";
import * as migration_20260826_060000_add_game_servers_event_target from "./20260826_060000_add_game_servers_event_target";
import * as migration_20260827_005757 from "./20260827_005757";
import * as migration_20260827_022334_add_eod_minesweeper_best_flags from "./20260827_022334_add_eod_minesweeper_best_flags";
import * as migration_20260828_011500_add_background_texture_preference from "./20260828_011500_add_background_texture_preference";
import * as migration_20260827_213857_add_evaluations_collection from './20260827_213857_add_evaluations_collection';
import * as migration_20260827_233224_add_mission_completed_at from './20260827_233224_add_mission_completed_at';
import * as migration_20260828_041615_add_campaign_mode from './20260828_041615_add_campaign_mode';
@ -13,32 +14,32 @@ export const migrations = [
{
up: migration_20260820_004157.up,
down: migration_20260820_004157.down,
name: '20260820_004157',
name: "20260820_004157",
},
{
up: migration_20260825_220000.up,
down: migration_20260825_220000.down,
name: '20260825_220000',
name: "20260825_220000",
},
{
up: migration_20260826_050000_repair_shims_auxiliary_tables.up,
down: migration_20260826_050000_repair_shims_auxiliary_tables.down,
name: '20260826_050000_repair_shims_auxiliary_tables',
name: "20260826_050000_repair_shims_auxiliary_tables",
},
{
up: migration_20260826_060000_add_game_servers_event_target.up,
down: migration_20260826_060000_add_game_servers_event_target.down,
name: '20260826_060000_add_game_servers_event_target',
name: "20260826_060000_add_game_servers_event_target",
},
{
up: migration_20260827_005757.up,
down: migration_20260827_005757.down,
name: '20260827_005757',
name: "20260827_005757",
},
{
up: migration_20260827_022334_add_eod_minesweeper_best_flags.up,
down: migration_20260827_022334_add_eod_minesweeper_best_flags.down,
name: '20260827_022334_add_eod_minesweeper_best_flags',
name: "20260827_022334_add_eod_minesweeper_best_flags",
},
{
up: migration_20260827_213857_add_evaluations_collection.up,
@ -50,6 +51,11 @@ export const migrations = [
down: migration_20260827_233224_add_mission_completed_at.down,
name: '20260827_233224_add_mission_completed_at',
},
{
up: migration_20260828_011500_add_background_texture_preference.up,
down: migration_20260828_011500_add_background_texture_preference.down,
name: "20260828_011500_add_background_texture_preference",
},
{
up: migration_20260828_041615_add_campaign_mode.up,
down: migration_20260828_041615_add_campaign_mode.down,

View file

@ -1181,6 +1181,7 @@ export interface User {
leadOrActual?: ('lead' | 'actual') | null;
showVersionOverlay?: boolean | null;
xpDisplayMini?: boolean | null;
showBackgroundTexture?: boolean | null;
};
discord?: {
enabled?: boolean | null;
@ -3409,6 +3410,7 @@ export interface UsersSelect<T extends boolean = true> {
leadOrActual?: T;
showVersionOverlay?: T;
xpDisplayMini?: T;
showBackgroundTexture?: T;
};
discord?:
| T

View file

@ -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 <file> export catalog JSON (array of records with "picture")
* --icons <file> icon_map JSON (map of classname -> .paa ref) [optional]
* --game <dir> Arma 3 install root (contains <game>/Addons/*.pbo)
* [--mod <dir>]... extra mod roots to search for .pbo files
* --paa-root <dir> where extracted .paa files are written (mirrors refs)
* [--out-dir <dir>] write a machine-readable report here (optional)
* [--hemtt <path>] 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 <path>");
}
interface PboInfo {
pboPath: string;
prefix: string;
/** lowercase file path -> exact original-case path as stored in the PBO */
files: Map<string, string>;
}
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<string, string> {
const byName = new Map<string, string>();
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<string, string> } };
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<string, PboInfo>,
): 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<string, string>();
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<string, string>,
inspected: Map<string, PboInfo>,
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 <dir> is required and must exist");
if (!opts["paa-root"]?.[0]) throw new Error("--paa-root <dir> is required");
const paaRoot = opts["paa-root"][0];
const catalogPath = opts.catalog?.[0];
if (!catalogPath || !existsSync(catalogPath)) throw new Error("--catalog <file> 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<string, PboInfo>();
interface Ref { src: string; origin: string; }
const refs = new Map<string, Ref>();
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);
});

View file

@ -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 <file> export catalog JSON (required)
* [--icons <file>] icon_map JSON (classname -> .paa ref)
* [--paa-root <dir>] root mirroring ref paths where .paa thumbnails live
* [--screenshots <dir>] PNG/WebP per-classname 3D-rendered previews
* [--out <dir>] write converted thumbnails + a report here
* [--hemtt <path>] 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 <n>] 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 <path>");
}
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<string, unknown>, 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<string, unknown>,
lookups: { icons: Record<string, unknown>; 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<any>; update: (args: { collection: string; id: number; data: any }) => Promise<any> },
plan: ImportPlan,
opts: { outDir: string },
fuelId: number,
): Promise<string> {
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<any> }, filePath: string): Promise<number> {
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 <file> 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<string, unknown> = {};
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); });

View file

@ -37,6 +37,7 @@ describe("PreferencesForm", () => {
mutedTypes: ["market:offer"],
showCallsign: false,
showVersionOverlay: true,
showBackgroundTexture: true,
leadOrActual: "lead",
discordEnabled: false,
discordMutedTypes: [],
@ -62,6 +63,7 @@ describe("PreferencesForm", () => {
mutedTypes: ["market:offer"],
showCallsign: true,
showVersionOverlay: true,
showBackgroundTexture: true,
leadOrActual: "actual",
discordEnabled: true,
discordMutedTypes: ["market:offer"],