1
0
Fork 0

feat(locker): refine inventory workspace

This commit is contained in:
Jason Fraley 2026-08-28 01:19:48 -04:00
parent 503e7a35f9
commit a6b06569da
7 changed files with 673 additions and 212 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. - **Motion**: no section-level motion.
- **Layout**: vertical stack. - **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 ### Mission card
- **Structure**: linked `Item` with `ItemHeader`, `ItemMedia`, `ItemTitle`, `ItemContent`, and `ItemDescription` primitives. - **Structure**: linked `Item` with `ItemHeader`, `ItemMedia`, `ItemTitle`, `ItemContent`, and `ItemDescription` primitives.

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 { import {
ATTACHMENT_CATEGORIES, ATTACHMENT_CATEGORIES,
findLockerMergeTarget, findLockerMergeTarget,
getAssetTypeColor,
getLockerGridOccupancy, getLockerGridOccupancy,
getLockerItemFootprint, getLockerItemFootprint,
isAttachmentCompatible, isAttachmentCompatible,
@ -118,7 +117,6 @@ export function LockerItemFace({
equipment?: LockerItemEquipment; equipment?: LockerItemEquipment;
}) { }) {
const fp = getLockerItemFootprint(asset, rotated ?? false); const fp = getLockerItemFootprint(asset, rotated ?? false);
const bgColor = getAssetTypeColor(asset.assetType);
const thumb = assetThumbnailUrl(asset); const thumb = assetThumbnailUrl(asset);
const displayName = equipment?.customName?.trim() || asset.name; const displayName = equipment?.customName?.trim() || asset.name;
const hasCustomName = !!equipment?.customName?.trim(); const hasCustomName = !!equipment?.customName?.trim();
@ -131,48 +129,54 @@ export function LockerItemFace({
width: fp.width * cellSize - 2, width: fp.width * cellSize - 2,
height: fp.height * 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) && ( {(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 && ( {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" /> <Tag className="size-2.5" />
</span> </span>
)} )}
{hasSkin && ( {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" /> <Palette className="size-2.5" />
</span> </span>
)} )}
{attachmentCount > 0 && ( {attachmentCount > 0 && (
<span <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"}`} title={`${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`}
> >
<Crosshair className="size-2.5" /> <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> </span>
)} )}
</div> </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 ? ( {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"> <span className="text-primary/60">{getAssetIcon(asset.assetType, "size-4")}</span>
{getAssetIcon(asset.assetType, "size-5")}
</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} {displayName}
</span> </span>
{quantity > 1 && ( {quantity > 1 && (
<span <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">
className="text-[9px] font-bold leading-none px-1 py-0.5 rounded-sm"
style={{ backgroundColor: bgColor, color: "white" }}
>
{quantity.toLocaleString()} {quantity.toLocaleString()}
</span> </span>
)} )}
@ -196,6 +200,7 @@ function DraggableLockerItem({
}) { }) {
const didDrag = useRef(false); const didDrag = useRef(false);
const fp = getLockerItemFootprint(item.asset, item.rotated); const fp = getLockerItemFootprint(item.asset, item.rotated);
const displayName = item.equipment?.customName?.trim() || item.asset.name;
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: `grid-item-${item.id}`, id: `grid-item-${item.id}`,
@ -217,6 +222,15 @@ function DraggableLockerItem({
[item, onSelect], [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 = { const style: React.CSSProperties = {
position: "absolute", position: "absolute",
left: item.gridX * cellSize, left: item.gridX * cellSize,
@ -234,15 +248,17 @@ function DraggableLockerItem({
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={cn( className={cn(
"group rounded-md border cursor-grab active:cursor-grabbing select-none overflow-hidden", "group cursor-grab select-none overflow-hidden rounded-sm border border-border/60 bg-card/80 active:cursor-grabbing",
"transition-shadow duration-150", "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", selected && "ring-2 ring-primary ring-offset-2 ring-offset-background",
!selected && "hover:ring-1 hover:ring-primary/50", !selected && "hover:ring-1 hover:ring-primary/40",
highlight && "locker-search-highlight", highlight && "locker-search-highlight motion-reduce:animate-none",
)} )}
{...listeners} {...listeners}
{...attributes} {...attributes}
onClick={handleClick} onClick={handleClick}
onKeyDown={handleKeyDown}
aria-label={`${displayName}, quantity ${item.quantity.toLocaleString()}`}
suppressHydrationWarning suppressHydrationWarning
> >
<LockerItemFace <LockerItemFace
@ -535,11 +551,18 @@ export function LockerGrid({
onDragMove={handleDragMove} onDragMove={handleDragMove}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-2">
<div ref={containerRef} className="rounded-lg border bg-muted/20 p-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 <div
ref={gridRef} ref={gridRef}
className="relative grid" className="relative grid bg-background/10"
style={{ style={{
gridTemplateColumns: `repeat(${gridWidth}, ${cellSize}px)`, gridTemplateColumns: `repeat(${gridWidth}, ${cellSize}px)`,
gridTemplateRows: `repeat(${gridHeight}, ${cellSize}px)`, gridTemplateRows: `repeat(${gridHeight}, ${cellSize}px)`,
@ -578,25 +601,49 @@ export function LockerGrid({
</div> </div>
</div> </div>
<div className="flex items-center justify-between text-xs text-muted-foreground"> {/* Tactical metadata strip */}
<span> <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">
{occupancy.used} / {occupancy.total} cells used <div className="flex items-center gap-4">
</span> <span>
<span> <span className="font-mono tabular-nums text-foreground/80">
{gridWidth} x {gridHeight} 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>
<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>
<span className="hidden sm:inline">
SELECT to inspect · DRAG to move · R to rotate
</span> </span>
</div> </div>
{dragError && <p className="text-xs text-red-400">{dragError}</p>} {dragError && (
<p role="alert" className="text-[10px] text-destructive">
{dragError}
</p>
)}
</div> </div>
{/* Tactical drag overlay */}
<DragOverlay dropAnimation={null}> <DragOverlay dropAnimation={null}>
{activeItem && ( {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 <LockerItemFace
asset={activeItem.asset} asset={activeItem.asset}
quantity={activeItem.quantity} quantity={activeItem.quantity}
rotated={pendingRotated} rotated={pendingRotated}
cellSize={cellSize} cellSize={cellSize}
equipment={activeItem.equipment}
/> />
</div> </div>
)} )}

View file

@ -19,6 +19,7 @@ import {
lockerIsRotatable, lockerIsRotatable,
lockerIsStackable, lockerIsStackable,
} from "@/lib/locker"; } from "@/lib/locker";
import { getAssetIcon } from "./LockerGrid";
interface LockerGridToolbarProps { interface LockerGridToolbarProps {
gridWidth: number; gridWidth: number;
@ -60,72 +61,144 @@ export function LockerGridToolbar({
: false; : false;
return ( return (
<div className="flex flex-col gap-3"> <div className="flex min-w-0 flex-col gap-2">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/60 pb-2">
<div className="flex items-center gap-2"> <div className="min-w-0">
<Button variant="outline" size="sm" onClick={onAutoArrange}> <p className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground/60">
<ShuffleIcon className="size-3.5 mr-1" /> TOOLS
Auto-Arrange </p>
</Button> <p className="text-sm font-medium text-foreground/80">ARRANGE // INSPECT</p>
</div> </div>
<Button variant="outline" size="sm" onClick={onAutoArrange}>
<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 <Button
variant={viewMode === "grid" ? "default" : "outline"} variant={viewMode === "grid" ? "default" : "outline"}
size="sm" size="icon"
aria-label="Grid view"
aria-pressed={viewMode === "grid"}
title="Grid view"
onClick={() => onViewModeChange("grid")} onClick={() => onViewModeChange("grid")}
> >
<LayoutGridIcon className="size-3.5" /> <LayoutGridIcon aria-hidden="true" />
</Button> </Button>
<Button <Button
variant={viewMode === "list" ? "default" : "outline"} variant={viewMode === "list" ? "default" : "outline"}
size="sm" size="icon"
aria-label="List view"
aria-pressed={viewMode === "list"}
title="List view"
onClick={() => onViewModeChange("list")} onClick={() => onViewModeChange("list")}
> >
<ListIcon className="size-3.5" /> <ListIcon aria-hidden="true" />
</Button> </Button>
</div> </div>
</div> </div>
{selectedItem && asset && ( {selectedItem && asset && (
<div className="flex items-center gap-3 p-2 rounded-md border bg-muted/30 text-xs"> <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-1 min-w-0"> <div className="flex min-w-0 items-start gap-2">
<div className="font-medium truncate">{displayName}</div> <div className="flex size-7 shrink-0 items-center justify-center border border-border/30 bg-muted/40 text-primary/90">
<div className="text-muted-foreground"> {getAssetIcon(asset.assetType)}
{selectedItem.quantity.toLocaleString()}× · {getAssetTypeLabel(asset.assetType)} </div>
{hasCustomName && asset && displayName !== asset.name && ` · as "${asset.name}"`} <div className="min-w-0">
{fp && ` · ${fp.width}x${fp.height}`} <p className="truncate font-medium text-foreground/90">{displayName}</p>
{selectedItem.rotated && " · rotated"} <p className="truncate text-muted-foreground/60">
{getAssetTypeLabel(asset.assetType)} · {selectedItem.quantity.toLocaleString()}×
</p>
</div> </div>
</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" && ( {asset?.assetType === "weapon" && (
<Button variant="outline" size="sm" onClick={onCustomize}> <Button variant="outline" size="sm" onClick={onCustomize}>
<SlidersHorizontalIcon className="size-3.5" /> <SlidersHorizontalIcon data-icon="inline-start" />
Customize CUSTOM
</Button> </Button>
)} )}
{lockerIsRotatable(asset) && ( {lockerIsRotatable(asset) && (
<Button variant="outline" size="sm" onClick={() => onRotate(selectedItem.id)}> <Button
<RotateCwIcon className="size-3.5" /> variant="outline"
size="icon"
aria-label="Rotate selected item"
title="Rotate selected item"
onClick={() => onRotate(selectedItem.id)}
>
<RotateCwIcon aria-hidden="true" />
</Button> </Button>
)} )}
{lockerIsStackable(asset) && selectedItem.quantity > 1 && ( {lockerIsStackable(asset) && selectedItem.quantity > 1 && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="icon"
disabled={!canSplit} disabled={!canSplit}
aria-label="Split selected stack"
title="Split selected stack"
onClick={() => onSplit(selectedItem.id, Math.floor(selectedItem.quantity / 2))} onClick={() => onSplit(selectedItem.id, Math.floor(selectedItem.quantity / 2))}
> >
<ScissorsIcon className="size-3.5" /> <ScissorsIcon aria-hidden="true" />
</Button> </Button>
)} )}
<Button variant="outline" size="sm" onClick={() => onRemove(selectedItem.id)}> <Button
<TrashIcon className="size-3.5" /> variant="outline"
size="icon"
aria-label="Remove selected item"
title="Remove selected item"
onClick={() => onRemove(selectedItem.id)}
>
<TrashIcon aria-hidden="true" />
</Button> </Button>
</div> </div>
</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> </div>
); );
} }

View file

@ -26,6 +26,7 @@ import {
} from "@/app/(frontend)/locker/actions"; } from "@/app/(frontend)/locker/actions";
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext"; import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
import { LayoutGridIcon } from "lucide-react"; import { LayoutGridIcon } from "lucide-react";
import { LockerCharacterPanel } from "./LockerCharacterPanel";
interface LockerViewProps { interface LockerViewProps {
locker: LockerStorage; locker: LockerStorage;
@ -47,7 +48,7 @@ export function LockerView({
userName, userName,
}: LockerViewProps) { }: LockerViewProps) {
const router = useRouter(); const router = useRouter();
const [tab, setTab] = useState("wardrobe"); const [tab, setTab] = useState("inventory");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [customizeId, setCustomizeId] = useState<string | null>(null); const [customizeId, setCustomizeId] = useState<string | null>(null);
@ -195,17 +196,26 @@ export function LockerView({
); );
return ( return (
<Tabs value={tab} onValueChange={setTab}> <Tabs
<div className="flex items-center justify-between gap-3"> value={tab}
<TabsList> onValueChange={setTab}
<TabsTrigger value="wardrobe">Wardrobe</TabsTrigger> className="flex min-w-0 w-full max-w-none flex-col gap-4"
<TabsTrigger value="inventory">Inventory</TabsTrigger> >
<TabsTrigger value="loadouts">Loadouts</TabsTrigger> <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> </TabsList>
<LockerSearch items={items} loadouts={loadouts} onSelect={handleSearchSelect} /> <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> </div>
<TabsContent value="wardrobe"> <TabsContent value="wardrobe" className="mt-0">
<WardrobePanel <WardrobePanel
userName={userName} userName={userName}
loadouts={loadouts} loadouts={loadouts}
@ -214,88 +224,119 @@ export function LockerView({
/> />
</TabsContent> </TabsContent>
<TabsContent value="inventory" className="flex flex-col gap-4"> <TabsContent value="inventory" className="mt-0 flex min-w-0 w-full flex-col gap-3">
<div className="flex items-center justify-between"> <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">
<LockerGridToolbar <div className="min-w-0">
gridWidth={gridWidth} <p className="font-mono text-xs font-medium uppercase tracking-[0.18em] text-foreground/90">
gridHeight={gridHeight} STASH // PERSONAL STORAGE
items={items} </p>
selectedItem={selectedItem} <p className="text-xs text-muted-foreground/70">
viewMode={viewMode} DRAG TO REPOSITION · SELECT TO INSPECT · R TO ROTATE
onViewModeChange={setViewMode} </p>
onAutoArrange={handleAutoArrange} </div>
onRotate={handleRotateItem} {isManager && (
onRemove={handleRemoveItem} <div className="shrink-0">
onSplit={handleSplitStack} <AddLockerItemDialog assets={assetsCatalog} />
onCustomize={() => setCustomizeId(selectedItem?.id ?? null)} </div>
/> )}
{isManager && <AddLockerItemDialog assets={assetsCatalog} />}
</div> </div>
{items.length === 0 ? ( <div className="grid min-w-0 items-start gap-4 xl:grid-cols-[minmax(24rem,30rem)_minmax(0,1fr)]">
<Item variant="outline"> <LockerCharacterPanel loadouts={loadouts} items={items} />
<ItemHeader>
<ItemTitle className="text-muted-foreground">Empty Locker</ItemTitle>
</ItemHeader>
<ItemContent>
<p className="text-sm text-muted-foreground">
{isManager
? "Your locker is empty. Add items to get started."
: "Your locker is empty. Reach out to logistics to have equipment issued."}
</p>
</ItemContent>
</Item>
) : viewMode === "grid" ? (
<LockerGrid
gridWidth={gridWidth}
gridHeight={gridHeight}
items={items}
onMoveItem={handleMoveItem}
onMergeStacks={handleMergeStacks}
onRotateItem={handleRotateItem}
onRemoveItem={handleRemoveItem}
onSelectItem={(item) => setSelectedId(item?.id ?? null)}
selectedItem={selectedItem}
highlightId={highlightId}
onAttachItem={handleAttachItem}
/>
) : (
<Item variant="outline">
<ItemContent>
<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"
onClick={() => setSelectedId(entry.id)}
>
<span className="text-sm font-medium flex-1 min-w-0 truncate">
{entry.equipment?.customName?.trim() || entry.asset.name}
</span>
<span className="text-xs text-muted-foreground">
{getAssetTypeLabel(entry.asset.assetType)}
</span>
<span className="text-xs text-muted-foreground shrink-0">
{entry.quantity.toLocaleString()}×
</span>
</button>
{index < items.length - 1 && <div className="border-t border-border" />}
</div>
))}
</div>
</ItemContent>
</Item>
)}
<EventLedger <div className="grid min-w-0 items-start gap-3 2xl:grid-cols-[minmax(0,1fr)_18rem]">
targetCollection="locker-storages" <div className="min-w-0">
targetId={locker.id} {items.length === 0 ? (
refreshKey={eventLogKey} <Item variant="outline" className="rounded-sm border border-border/30 bg-card/40">
/> <ItemHeader>
<ItemTitle className="text-muted-foreground/70">LOCKER EMPTY</ItemTitle>
</ItemHeader>
<ItemContent>
<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."}
</p>
</ItemContent>
</Item>
) : viewMode === "grid" ? (
<LockerGrid
gridWidth={gridWidth}
gridHeight={gridHeight}
items={items}
onMoveItem={handleMoveItem}
onMergeStacks={handleMergeStacks}
onRotateItem={handleRotateItem}
onRemoveItem={handleRemoveItem}
onSelectItem={(item) => setSelectedId(item?.id ?? null)}
selectedItem={selectedItem}
highlightId={highlightId}
onAttachItem={handleAttachItem}
/>
) : (
<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 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="min-w-0 flex-1 truncate font-medium text-foreground/90">
{entry.equipment?.customName?.trim() || entry.asset.name}
</span>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/50 uppercase">
{getAssetTypeLabel(entry.asset.assetType)}
</span>
<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/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>
<TabsContent value="loadouts"> <TabsContent value="loadouts" className="mt-0">
<LoadoutsPanel loadouts={loadouts} ownedItems={items} /> <LoadoutsPanel loadouts={loadouts} ownedItems={items} />
</TabsContent> </TabsContent>

View file

@ -1,6 +1,5 @@
"use client"; "use client";
import { useMemo } from "react";
import { import {
Backpack, Backpack,
Crosshair, Crosshair,
@ -14,6 +13,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import type { Asset, Loadout } from "@/payload-types"; import type { Asset, Loadout } from "@/payload-types";
import { import {
getAssetTypeLabel,
type LoadoutSlots, type LoadoutSlots,
type LockerGridItem, type LockerGridItem,
WARDROBE_SLOT_LAYOUT, WARDROBE_SLOT_LAYOUT,
@ -23,8 +23,8 @@ import { cn } from "@/lib/utils";
import { getAssetIcon } from "./LockerGrid"; import { getAssetIcon } from "./LockerGrid";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
function slotIcon(icon: WardrobeSlotIcon, filled: boolean) { function slotIcon(icon: WardrobeSlotIcon) {
const cls = filled ? "size-3.5" : "size-3"; const cls = "size-3.5";
switch (icon) { switch (icon) {
case "head": case "head":
return <User className={cls} />; return <User className={cls} />;
@ -57,74 +57,224 @@ function resolveSlotAsset(
return items.find((i) => i.assetId === id)?.asset ?? null; 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({ export function Wardrobe({
loadout, loadout,
items, items,
onSlotClick, onSlotClick,
}: { }: {
loadout: Loadout; readonly loadout: Loadout;
items: LockerGridItem[]; readonly items: LockerGridItem[];
onSlotClick: (slotName: string) => void; readonly onSlotClick: (slotName: string) => void;
}) { }) {
const slots = (loadout.slots ?? {}) as LoadoutSlots; const slots = (loadout.slots ?? {}) as LoadoutSlots;
const filledCount = WARDROBE_SLOT_LAYOUT.filter((layout) => slots[layout.name] != null).length;
const filled = useMemo(() => { const renderSlot = (layout: (typeof WARDROBE_SLOT_LAYOUT)[number]) => {
const set = new Set<string>(); const asset = resolveSlotAsset(slots[layout.name], items);
for (const layout of WARDROBE_SLOT_LAYOUT) { return (
const value = slots[layout.name]; <WardrobeSlotButton
if (value != null) set.add(layout.zone); key={layout.name}
} layout={layout}
return set; asset={asset}
}, [slots]); displayName={asset ? resolveSlotDisplayName(asset, items) : null}
onSlotClick={onSlotClick}
/>
);
};
return ( return (
<div className="relative mx-auto w-full max-w-100"> <div className="@container w-full min-w-0">
<div className="relative" style={{ aspectRatio: "260 / 380" }}> <div className="flex flex-col gap-3 border border-border/60 bg-card/40 p-3">
<img <div className="flex items-start justify-between gap-3 border-b border-border/50 pb-3">
src="/images/soldier-pose-alpha.png" <div className="min-w-0">
alt="Character wardrobe reflecting the equipped loadout" <p className="font-mono text-xs uppercase tracking-widest text-muted-foreground/70">
className="h-full w-full object-contain pointer-events-none select-none" EQUIPMENT MATRIX
draggable={false} </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>
{WARDROBE_SLOT_LAYOUT.map((layout) => { <div className="grid min-w-0 gap-3 @sm:grid-cols-2 @xl:grid-cols-3">
const value = slots[layout.name]; {WARDROBE_REGION_SLOTS.map((region) => (
const asset = resolveSlotAsset(value, items); <section
return ( key={region.id}
<Tooltip key={layout.name}> aria-labelledby={`wardrobe-region-${region.id}`}
<TooltipTrigger asChild> className={cn(
<button "flex min-w-0 flex-col gap-2 border-2 border-border/80 bg-background/20 p-3",
type="button" region.id === "head" && "@xl:col-span-3",
aria-label={`${layout.label} slot: ${asset ? asset.name : "empty"}`} region.id === "torso" && "@xl:col-span-2",
onPointerDown={(e) => { region.id === "weapons" && "@xl:col-span-2",
e.preventDefault(); )}
onSlotClick(layout.name); >
}} <div className="flex items-start justify-between gap-2 border-b border-border/70 pb-2">
className={cn( <div className="min-w-0">
"absolute flex items-center justify-center rounded-full border transition-colors", <h3
"-translate-x-1/2 -translate-y-1/2 cursor-pointer", id={`wardrobe-region-${region.id}`}
asset className="whitespace-normal break-words text-xs font-semibold tracking-wide text-foreground/90"
? "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", {region.label}
)} </h3>
style={{ left: `${layout.x}%`, top: `${layout.y}%` }} <p className="whitespace-normal break-words text-xs text-muted-foreground/60">
> {region.description}
{asset ? getAssetIcon(asset.assetType, "size-3.5") : slotIcon(layout.icon, false)} </p>
</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> </div>
</TooltipContent> <span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground/60">
</Tooltip> {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>
</div> </div>
); );

View file

@ -82,7 +82,7 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
: []; : [];
return ( 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"> <Item variant="outline" className="self-start">
<ItemHeader> <ItemHeader>
<ItemTitle>Character</ItemTitle> <ItemTitle>Character</ItemTitle>
@ -93,9 +93,9 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
items={items} items={items}
onSlotClick={(name) => setPickerSlot(name)} 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 {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."} : "Equip a loadout to start dressing your character."}
</p> </p>
</ItemContent> </ItemContent>