From a6b06569dae49f9fba84f5570275418e4b3757b8 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Fri, 28 Aug 2026 01:19:48 -0400 Subject: [PATCH] feat(locker): refine inventory workspace --- DESIGN.md | 16 ++ .../frontend/locker/LockerCharacterPanel.tsx | 134 +++++++++ src/components/frontend/locker/LockerGrid.tsx | 117 +++++--- .../frontend/locker/LockerGridToolbar.tsx | 131 +++++++-- src/components/frontend/locker/LockerView.tsx | 213 ++++++++------ src/components/frontend/locker/Wardrobe.tsx | 268 ++++++++++++++---- .../frontend/locker/WardrobePanel.tsx | 6 +- 7 files changed, 673 insertions(+), 212 deletions(-) create mode 100644 src/components/frontend/locker/LockerCharacterPanel.tsx diff --git a/DESIGN.md b/DESIGN.md index 5649209..188d7f0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -67,6 +67,22 @@ Spacing follows Tailwind's 4px scale. Existing frontend stacks use `gap-1`, `gap - **Motion**: no section-level motion. - **Layout**: vertical stack. +### Locker inventory surface + +- **Purpose**: The authenticated locker is a Polaris operations surface: a dense, readable inventory rather than a literal game replica. The grid is the focal region and selection context is kept adjacent to it. +- **Palette**: Use the existing semantic neutral tokens: `bg-background` for the page, `bg-card`/`bg-muted` for framed regions, `border-border` for structure, `text-foreground` and `text-muted-foreground` for hierarchy, and `bg-primary`/`ring-ring` for active selection and primary actions. Amber utility accents are reserved for existing equipment/skin meaning; they do not become a second surface palette. +- **Typography**: Keep Geist for interface copy and Geist Mono with `tabular-nums` for coordinates, counts, dimensions, and other inventory metadata. Compact labels use the existing `text-xs` level, never ad-hoc display sizes. +- **Primitives**: + - **`locker-shell`**: one responsive page region with a compact control band and a single-column fallback below `xl`. + - **`inventory-frame`**: a strong rectangular border around the existing two-dimensional grid. The grid may scroll only inside this bounded region when its footprint exceeds the available inline size; the page itself must not gain a horizontal scrollbar. + - **`selection-rail`**: selected-item context and actions sit beside the inventory at `xl` and later, then become an inline section below the grid at narrower widths. It remains readable when no item is selected. + - **`metadata-strip`**: a compact cluster for occupancy, dimensions, and drag/drop guidance, using 4px spacing and semantic muted text. + - **`wardrobe-slot-board`**: a body-ordered rectangular board: head/face spans the top, torso and pack occupy the middle, and weapons/attachments plus sidearm finish the lower row. The primary weapon owns an indented attachment subgroup for optic, muzzle, and bipod slots. Each slot is a labeled semantic button with distinct filled and empty states; the board is driven by `WARDROBE_SLOT_LAYOUT` and never relies on a character image as its control surface. +- **States**: default, hover, focus-visible, selected, search-highlight, valid drop, invalid drop, attachment drop, empty, error, disabled, and reduced-motion. Selected items use a visible semantic ring; placement feedback stays inside the grid cells; empty and error states retain the existing user/manager-specific copy. +- **Accessibility**: Tabs remain native shadcn tab semantics. Every icon-only button has an accessible label, every meaningful thumbnail keeps descriptive alt text, draggable items retain dnd-kit keyboard attributes, and all actions keep visible `focus-visible` rings. The responsive rail must not reorder focus away from the selected item context. +- **Motion**: Preserve the existing dnd-kit activation threshold and absolute-position/ResizeObserver mechanics. Interaction polish is limited to existing shadcn color transitions, transform/opacity drag feedback, and the existing search highlight; `prefers-reduced-motion` disables highlight animation and nonessential transitions. No decorative looping motion is introduced. + - **Responsive behavior**: At 375px the shell becomes one readable column with no viewport-level horizontal overflow. Only the `inventory-frame` may expose bounded two-dimensional scrolling when required by the configured grid; the selection rail follows the grid inline, while the tab/search control cluster wraps before it can overflow. The inventory and selection rail use the side-by-side layout only at `xl` and above so the grid remains dominant and rail labels remain readable. The wardrobe slot board keeps its body order at `lg` and collapses regions to one readable column below that threshold. + ### Mission card - **Structure**: linked `Item` with `ItemHeader`, `ItemMedia`, `ItemTitle`, `ItemContent`, and `ItemDescription` primitives. diff --git a/src/components/frontend/locker/LockerCharacterPanel.tsx b/src/components/frontend/locker/LockerCharacterPanel.tsx new file mode 100644 index 0000000..ac1d5d1 --- /dev/null +++ b/src/components/frontend/locker/LockerCharacterPanel.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import type { Loadout } from "@/payload-types"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { setLoadoutSlot, equipLoadout } from "@/app/(frontend)/locker/actions"; +import { + getLoadoutSlot, + getWardrobeSlot, + type LoadoutSlots, + type LockerGridItem, +} from "@/lib/locker"; +import { Wardrobe } from "./Wardrobe"; +import { WardrobeSlotPicker } from "./WardrobeSlotPicker"; +import { cn } from "@/lib/utils"; + +interface LockerCharacterPanelProps { + loadouts: Loadout[]; + items: LockerGridItem[]; +} + +export function LockerCharacterPanel({ loadouts, items }: LockerCharacterPanelProps) { + const router = useRouter(); + const equippedLoadout = loadouts.find((loadout) => loadout.equipped) ?? null; + const [pickerSlot, setPickerSlot] = useState(null); + const [busy, setBusy] = useState(false); + const slots = (equippedLoadout?.slots ?? {}) as LoadoutSlots; + const pickerLayout = pickerSlot ? getWardrobeSlot(pickerSlot) : null; + const pickerConfig = pickerSlot ? getLoadoutSlot(pickerSlot) : null; + + async function changeLoadout(id: string) { + setBusy(true); + const result = await equipLoadout(Number(id)); + setBusy(false); + if (result.success) router.refresh(); + } + + async function pickSlot(assetId: number | null) { + if (!equippedLoadout || !pickerSlot) return { success: false, error: "No loadout equipped." }; + setBusy(true); + const result = await setLoadoutSlot(equippedLoadout.id, pickerSlot, assetId); + setBusy(false); + if (result.success) { + setPickerSlot(null); + router.refresh(); + } + return result; + } + + return ( +
+
+
+

+ CHARACTER +

+

EQUIPMENT STATUS

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

{dragError}

} + {dragError && ( +

+ {dragError} +

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

+ TOOLS +

+

ARRANGE // INSPECT

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

{displayName}

+

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

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

NO SELECTION

+

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

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

+ STASH // PERSONAL STORAGE +

+

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

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

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

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

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

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

+ EQUIPMENT MATRIX +

+

+ SELECT A SLOT TO CONFIGURE YOUR LOADOUT +

+
+
+

+ {filledCount}/{WARDROBE_SLOT_LAYOUT.length} +

+

READY

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

+ {region.label} +

+

+ {region.description} +

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

+ PRIMARY ATTACHMENTS +

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

+

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