feat(locker): refine inventory workspace
This commit is contained in:
parent
503e7a35f9
commit
a6b06569da
7 changed files with 673 additions and 212 deletions
16
DESIGN.md
16
DESIGN.md
|
|
@ -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.
|
||||||
|
|
|
||||||
134
src/components/frontend/locker/LockerCharacterPanel.tsx
Normal file
134
src/components/frontend/locker/LockerCharacterPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue