diff --git a/src/app/(frontend)/locker/page.tsx b/src/app/(frontend)/locker/page.tsx
new file mode 100644
index 0000000..4748405
--- /dev/null
+++ b/src/app/(frontend)/locker/page.tsx
@@ -0,0 +1,76 @@
+import config from "@payload-config";
+import { getPayload } from "payload";
+import { headers as nextHeaders } from "next/headers";
+import type { Asset, Loadout, LockerStorage } from "@/payload-types";
+import { LockKeyholeIcon } from "lucide-react";
+import { ensureLockerStorage, getLockerGridDimensions } from "@/lib/locker";
+import { LockerView } from "@/components/frontend/locker/LockerView";
+import hasRoles from "@/utils/access-control/hasRoles";
+
+export const metadata = {
+ title: "Locker — Polaris Task Force",
+};
+
+export default async function LockerPage() {
+ const payload = await getPayload({ config });
+ const { user } = await payload.auth({
+ headers: await nextHeaders(),
+ canSetHeaders: false,
+ });
+
+ const userId = user?.id as number;
+ const locker = await ensureLockerStorage(payload, userId);
+ const lockerFull = (await payload.findByID({
+ collection: "locker-storages",
+ id: locker.id,
+ depth: 2,
+ overrideAccess: true,
+ })) as unknown as LockerStorage;
+
+ const loadoutsRes = await payload.find({
+ collection: "loadouts",
+ where: { ownerUser: { equals: userId } },
+ sort: "name",
+ limit: 100,
+ depth: 1,
+ });
+ const loadouts = loadoutsRes.docs as unknown as Loadout[];
+
+ const isManager = user ? hasRoles(["admin", "developer"], user) : false;
+
+ let assetsCatalog: Asset[] = [];
+ if (isManager) {
+ const assetsRes = await payload.find({
+ collection: "assets",
+ sort: "name",
+ limit: 500,
+ depth: 0,
+ });
+ assetsCatalog = assetsRes.docs as unknown as Asset[];
+ }
+
+ const grid = await getLockerGridDimensions(payload);
+
+ return (
+
+
+
+
+
Personal Locker
+
+
+ Store, organize, and equip your personal gear and loadouts.
+
+
+
+
+
+ );
+}
diff --git a/src/components/frontend/blocks/AppSidebar.tsx b/src/components/frontend/blocks/AppSidebar.tsx
index d3eea0c..80b1e71 100644
--- a/src/components/frontend/blocks/AppSidebar.tsx
+++ b/src/components/frontend/blocks/AppSidebar.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import { Bolt, Send, Shield, Users, Warehouse, Crosshair } from "lucide-react";
+import { Bolt, Send, Shield, Users, Warehouse, Crosshair, Backpack } from "lucide-react";
import { NavCore } from "@/components/frontend/blocks/NavCore";
import { NavSecondary } from "@/components/frontend/blocks/NavSecondary";
@@ -94,6 +94,13 @@ const data = {
icon: Send,
},
],
+ navPersonal: [
+ {
+ title: "Locker",
+ url: "/locker",
+ icon: Backpack,
+ },
+ ],
};
export function AppSidebar({
@@ -140,6 +147,7 @@ export function AppSidebar({
+
{/**/}
(null);
+
+ const handleOpenChange = (next: boolean) => {
+ setOpen(next);
+ if (!next) {
+ setLoading(false);
+ setError(null);
+ }
+ };
+
+ async function handleSubmit() {
+ if (!assetId) {
+ setError("Select an asset.");
+ return;
+ }
+ const qty = Number(quantity);
+ if (!Number.isInteger(qty) || qty <= 0) {
+ setError("Quantity must be a positive integer.");
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ const res = await addItemToLocker(Number(assetId), qty);
+ setLoading(false);
+ if (res.success) {
+ setOpen(false);
+ setAssetId("");
+ setQuantity("1");
+ router.refresh();
+ } else {
+ setError(res.error ?? "Failed to add item.");
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/frontend/locker/LoadoutCard.tsx b/src/components/frontend/locker/LoadoutCard.tsx
new file mode 100644
index 0000000..bf31180
--- /dev/null
+++ b/src/components/frontend/locker/LoadoutCard.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { PencilIcon, TrashIcon } from "lucide-react";
+import type { Asset, Loadout } from "@/payload-types";
+import { Button } from "@/components/ui/button";
+import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
+import { getAssetTypeLabel, LOADOUT_SLOTS, type LoadoutSlots } from "@/lib/locker";
+
+function resolveSlotValue(value: LoadoutSlots[keyof LoadoutSlots]): Asset | null {
+ if (value == null) return null;
+ if (typeof value === "object" && "id" in value) return value as Asset;
+ return null;
+}
+
+interface LoadoutCardProps {
+ loadout: Loadout;
+ onEdit: () => void;
+ onDelete: () => void;
+}
+
+export function LoadoutCard({ loadout, onEdit, onDelete }: LoadoutCardProps) {
+ const slots = (loadout.slots ?? {}) as LoadoutSlots;
+ const filled = LOADOUT_SLOTS.filter((slot) => {
+ const v = slots[slot.name];
+ return v != null && (typeof v !== "object" || (typeof v === "object" && "id" in v));
+ });
+
+ return (
+ -
+
+ {loadout.name}
+ {loadout.description && (
+ {loadout.description}
+ )}
+
+
+
+
+ {filled.length === 0 && (
+ No equipment assigned.
+ )}
+ {filled.map((slot) => {
+ const value = slots[slot.name];
+ const asset = resolveSlotValue(value);
+ if (!asset) return null;
+ return (
+
+ {slot.label}:
+ {asset.name}
+ ({getAssetTypeLabel(asset.assetType)})
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/components/frontend/locker/LoadoutEditor.tsx b/src/components/frontend/locker/LoadoutEditor.tsx
new file mode 100644
index 0000000..cbb9f93
--- /dev/null
+++ b/src/components/frontend/locker/LoadoutEditor.tsx
@@ -0,0 +1,212 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import type { Loadout } from "@/payload-types";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ LOADOUT_SLOTS,
+ type LockerGridItem,
+ type LoadoutSlots,
+} from "@/lib/locker";
+import {
+ createLoadout,
+ setLoadoutSlot,
+ updateLoadout,
+} from "@/app/(frontend)/locker/actions";
+
+interface LoadoutEditorProps {
+ mode: "create" | "edit";
+ loadout?: Loadout | null;
+ ownedItems: LockerGridItem[];
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+function assetIdOf(value: unknown): string {
+ if (value == null) return "";
+ if (typeof value === "object" && "id" in value) return String(value.id);
+ return String(value);
+}
+
+export function LoadoutEditor({
+ mode,
+ loadout,
+ ownedItems,
+ open,
+ onOpenChange,
+}: LoadoutEditorProps) {
+ const router = useRouter();
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [slots, setSlots] = useState>({});
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!open) return;
+ setLoading(false);
+ setName(loadout?.name ?? "");
+ setDescription(loadout?.description ?? "");
+ const initial: Record = {};
+ if (loadout) {
+ const current = (loadout.slots ?? {}) as LoadoutSlots;
+ for (const slot of LOADOUT_SLOTS) {
+ initial[slot.name] = assetIdOf(current[slot.name]);
+ }
+ }
+ setSlots(initial);
+ setError(null);
+ }, [open, loadout]);
+
+ const ownedByType = (assetTypes: readonly string[]) => {
+ const types = new Set(assetTypes);
+ const seen = new Set();
+ return ownedItems.filter((item) => {
+ if (seen.has(item.assetId)) return false;
+ if (!types.has(item.asset.assetType as string)) return false;
+ seen.add(item.assetId);
+ return true;
+ });
+ };
+
+ async function handleSubmit() {
+ if (!name.trim()) {
+ setError("A loadout name is required.");
+ return;
+ }
+ setLoading(true);
+ setError(null);
+
+ if (mode === "create") {
+ const res = await createLoadout({
+ name: name.trim(),
+ description: description.trim() || undefined,
+ });
+ if (!res.success || res.data == null) {
+ setError(res.error ?? "Failed to create loadout.");
+ setLoading(false);
+ return;
+ }
+ const loadoutId = res.data;
+ for (const slot of LOADOUT_SLOTS) {
+ const value = slots[slot.name];
+ if (value) {
+ await setLoadoutSlot(loadoutId, slot.name, Number(value));
+ }
+ }
+ setLoading(false);
+ onOpenChange(false);
+ router.refresh();
+ return;
+ }
+
+ if (!loadout) return;
+ const res = await updateLoadout(loadout.id, {
+ name: name.trim(),
+ description: description.trim() || undefined,
+ });
+ if (!res.success) {
+ setError(res.error ?? "Failed to update loadout.");
+ setLoading(false);
+ return;
+ }
+ for (const slot of LOADOUT_SLOTS) {
+ const value = slots[slot.name];
+ await setLoadoutSlot(loadout.id, slot.name, value ? Number(value) : null);
+ }
+ setLoading(false);
+ onOpenChange(false);
+ router.refresh();
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/frontend/locker/LoadoutsPanel.tsx b/src/components/frontend/locker/LoadoutsPanel.tsx
new file mode 100644
index 0000000..3f776c1
--- /dev/null
+++ b/src/components/frontend/locker/LoadoutsPanel.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { PlusIcon } from "lucide-react";
+import type { Loadout } from "@/payload-types";
+import { Button } from "@/components/ui/button";
+import { LoadoutCard } from "./LoadoutCard";
+import { LoadoutEditor } from "./LoadoutEditor";
+import type { LockerGridItem } from "@/lib/locker";
+import { deleteLoadout } from "@/app/(frontend)/locker/actions";
+
+interface LoadoutsPanelProps {
+ loadouts: Loadout[];
+ ownedItems: LockerGridItem[];
+}
+
+export function LoadoutsPanel({ loadouts, ownedItems }: LoadoutsPanelProps) {
+ const router = useRouter();
+ const [creating, setCreating] = useState(false);
+ const [editing, setEditing] = useState(null);
+
+ async function handleDelete(loadout: Loadout) {
+ if (!confirm(`Delete loadout "${loadout.name}"?`)) return;
+ const res = await deleteLoadout(loadout.id);
+ if (res.success) router.refresh();
+ }
+
+ return (
+
+
+
+ Save and manage equipment presets for different roles.
+
+
+
+
+ {loadouts.length === 0 ? (
+
+ No loadouts yet. Create one to start building your equipment presets.
+
+ ) : (
+
+ {loadouts.map((loadout) => (
+ setEditing(loadout)}
+ onDelete={() => handleDelete(loadout)}
+ />
+ ))}
+
+ )}
+
+
+
{
+ if (!open) setEditing(null);
+ }}
+ />
+
+ );
+}
diff --git a/src/components/frontend/locker/LockerGrid.tsx b/src/components/frontend/locker/LockerGrid.tsx
new file mode 100644
index 0000000..5afa17d
--- /dev/null
+++ b/src/components/frontend/locker/LockerGrid.tsx
@@ -0,0 +1,458 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ DndContext,
+ DragOverlay,
+ PointerSensor,
+ useDraggable,
+ useSensor,
+ useSensors,
+ type DragStartEvent,
+ type DragMoveEvent,
+ type DragEndEvent,
+} from "@dnd-kit/core";
+import { CSS } from "@dnd-kit/utilities";
+import {
+ Backpack,
+ FlaskConical,
+ Gem,
+ Glasses,
+ Hammer,
+ Headset,
+ Luggage,
+ Monitor,
+ MoonStar,
+ Package,
+ Shield,
+ Shirt,
+ Target,
+ Wrench,
+ Crosshair,
+} from "lucide-react";
+import type { Asset } from "@/payload-types";
+import {
+ findLockerMergeTarget,
+ getAssetTypeColor,
+ getLockerItemFootprint,
+ getLockerGridOccupancy,
+ isLockerPlacementValid,
+ type LockerGridItem,
+} from "@/lib/locker";
+import { GridCell } from "@/components/frontend/storage/GridCell";
+import { cn } from "@/lib/utils";
+
+export function getAssetIcon(assetType: Asset["assetType"], className?: string) {
+ const cls = className ?? "size-4";
+ switch (assetType) {
+ case "weapon":
+ return ;
+ case "magazine":
+ return ;
+ case "optic":
+ return ;
+ case "muzzle":
+ return ;
+ case "bipod":
+ return ;
+ case "uniform":
+ return ;
+ case "vest":
+ return ;
+ case "backpack":
+ return ;
+ case "helmet":
+ return ;
+ case "facewear":
+ return ;
+ case "nvg":
+ return ;
+ case "radio":
+ return ;
+ case "medical":
+ return ;
+ case "tool":
+ return ;
+ case "explosive":
+ return ;
+ case "miscellaneous":
+ return ;
+ default:
+ return ;
+ }
+}
+
+function assetThumbnailUrl(asset: Asset): string | null {
+ const thumb = asset["Asset Metadata"]?.thumbnail;
+ if (thumb && typeof thumb === "object" && "url" in thumb) return thumb.url ?? null;
+ return null;
+}
+
+export function LockerItemFace({
+ asset,
+ quantity,
+ rotated,
+ cellSize,
+}: {
+ asset: Asset;
+ quantity: number;
+ rotated?: boolean;
+ cellSize: number;
+}) {
+ const fp = getLockerItemFootprint(asset, rotated ?? false);
+ const bgColor = getAssetTypeColor(asset.assetType);
+ const thumb = assetThumbnailUrl(asset);
+
+ return (
+
+
+
+ {thumb ? (
+

+ ) : (
+
{getAssetIcon(asset.assetType, "size-5")}
+ )}
+
+ {asset.name}
+
+ {quantity > 1 && (
+
+ {quantity.toLocaleString()}
+
+ )}
+
+
+ );
+}
+
+function DraggableLockerItem({
+ item,
+ cellSize,
+ onSelect,
+ selected,
+}: {
+ item: LockerGridItem;
+ cellSize: number;
+ onSelect: (item: LockerGridItem) => void;
+ selected: boolean;
+}) {
+ const didDrag = useRef(false);
+ const fp = getLockerItemFootprint(item.asset, item.rotated);
+
+ const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
+ id: `grid-item-${item.id}`,
+ data: { item },
+ });
+
+ useEffect(() => {
+ if (isDragging) didDrag.current = true;
+ }, [isDragging]);
+
+ const handleClick = useCallback(
+ (e: React.MouseEvent) => {
+ if (!didDrag.current) {
+ e.stopPropagation();
+ onSelect(item);
+ }
+ didDrag.current = false;
+ },
+ [item, onSelect],
+ );
+
+ const style: React.CSSProperties = {
+ position: "absolute",
+ left: item.gridX * cellSize,
+ top: item.gridY * cellSize,
+ width: fp.width * cellSize - 2,
+ height: fp.height * cellSize - 2,
+ zIndex: 10,
+ transition: isDragging ? "none" : "left 0.15s ease, top 0.15s ease",
+ transform: CSS.Translate.toString(transform),
+ opacity: isDragging ? 0 : 1,
+ };
+
+ return (
+
+
+
+ );
+}
+
+interface LockerGridProps {
+ gridWidth: number;
+ gridHeight: number;
+ items: LockerGridItem[];
+ onMoveItem: (entryId: string, gridX: number, gridY: number, rotated?: boolean) => Promise;
+ onMergeStacks: (sourceId: string, targetId: string) => Promise;
+ onRotateItem: (entryId: string) => Promise;
+ onRemoveItem: (entryId: string) => Promise;
+ onSelectItem: (item: LockerGridItem | null) => void;
+ selectedItem: LockerGridItem | null;
+}
+
+export function LockerGrid({
+ gridWidth,
+ gridHeight,
+ items,
+ onMoveItem,
+ onMergeStacks,
+ onRotateItem,
+ onRemoveItem,
+ onSelectItem,
+ selectedItem,
+}: LockerGridProps) {
+ const [activeItem, setActiveItem] = useState(null);
+ const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
+ const [pendingRotated, setPendingRotated] = useState(false);
+ const [cellSize, setCellSize] = useState(48);
+ const gridRef = useRef(null);
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el) return;
+ const observer = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ const w = entry.contentRect.width;
+ const computed = Math.floor(w / gridWidth);
+ setCellSize(Math.max(32, Math.min(computed, 80)));
+ }
+ });
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, [gridWidth]);
+
+ useEffect(() => {
+ if (!activeItem) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "r" || e.key === "R") {
+ e.preventDefault();
+ setPendingRotated((v) => !v);
+ }
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [activeItem]);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: { distance: 5 },
+ }),
+ );
+
+ const occupiedCells = useMemo(() => {
+ const cells = new Set();
+ for (const item of items) {
+ const fp = getLockerItemFootprint(item.asset, item.rotated);
+ for (let dx = 0; dx < fp.width; dx++) {
+ for (let dy = 0; dy < fp.height; dy++) {
+ cells.add(`${item.gridX + dx},${item.gridY + dy}`);
+ }
+ }
+ }
+ return cells;
+ }, [items]);
+
+ const occupancy = getLockerGridOccupancy(items, gridWidth, gridHeight);
+
+ const hoverInfo = useMemo(() => {
+ if (!hoverPosition || !activeItem) return null;
+ const fp = getLockerItemFootprint(activeItem.asset, pendingRotated);
+ const cells = new Set();
+ for (let dx = 0; dx < fp.width; dx++) {
+ for (let dy = 0; dy < fp.height; dy++) {
+ cells.add(`${hoverPosition.x + dx},${hoverPosition.y + dy}`);
+ }
+ }
+ const mergeTarget = findLockerMergeTarget(items, activeItem, hoverPosition.x, hoverPosition.y, pendingRotated);
+ const valid =
+ mergeTarget !== null ||
+ isLockerPlacementValid(
+ items,
+ activeItem.id,
+ hoverPosition.x,
+ hoverPosition.y,
+ fp.width,
+ fp.height,
+ gridWidth,
+ gridHeight,
+ );
+ return { cells, valid };
+ }, [hoverPosition, activeItem, items, gridWidth, gridHeight, pendingRotated]);
+
+ const handleDragStart = useCallback((event: DragStartEvent) => {
+ const data = event.active.data.current;
+ if (data?.item) {
+ const item = data.item as LockerGridItem;
+ setActiveItem(item);
+ setPendingRotated(item.rotated ?? false);
+ }
+ setHoverPosition(null);
+ }, []);
+
+ const handleDragMove = useCallback(
+ (event: DragMoveEvent) => {
+ const { activatorEvent, delta } = event;
+ const gridEl = gridRef.current;
+ if (!gridEl) return;
+ const rect = gridEl.getBoundingClientRect();
+ if (!("clientX" in activatorEvent)) return;
+ const px = (activatorEvent as PointerEvent).clientX + delta.x;
+ const py = (activatorEvent as PointerEvent).clientY + delta.y;
+ const x = Math.floor((px - rect.left) / cellSize);
+ const y = Math.floor((py - rect.top) / cellSize);
+ if (x < 0 || y < 0 || x >= gridWidth || y >= gridHeight) {
+ setHoverPosition(null);
+ return;
+ }
+ setHoverPosition({ x, y });
+ },
+ [gridWidth, gridHeight, cellSize],
+ );
+
+ const handleDragEnd = useCallback(
+ async (event: DragEndEvent) => {
+ setActiveItem(null);
+ setHoverPosition(null);
+ const { active, activatorEvent, delta } = event;
+
+ const item = active.data.current?.item as LockerGridItem | undefined;
+ if (!item) return;
+
+ const gridEl = gridRef.current;
+ if (!gridEl) return;
+
+ const rect = gridEl.getBoundingClientRect();
+ if (!("clientX" in activatorEvent)) return;
+ const px = (activatorEvent as PointerEvent).clientX + delta.x;
+ const py = (activatorEvent as PointerEvent).clientY + delta.y;
+
+ const targetX = Math.floor((px - rect.left) / cellSize);
+ const targetY = Math.floor((py - rect.top) / cellSize);
+
+ if (targetX < 0 || targetY < 0 || targetX >= gridWidth || targetY >= gridHeight) return;
+ if (targetX === item.gridX && targetY === item.gridY) {
+ if (pendingRotated === item.rotated) return;
+ await onMoveItem(item.id, targetX, targetY, pendingRotated);
+ return;
+ }
+
+ const mergeTarget = findLockerMergeTarget(items, item, targetX, targetY, pendingRotated);
+ if (mergeTarget) {
+ await onMergeStacks(item.id, mergeTarget.id);
+ return;
+ }
+
+ const fp = getLockerItemFootprint(item.asset, pendingRotated);
+ const valid = isLockerPlacementValid(
+ items,
+ item.id,
+ targetX,
+ targetY,
+ fp.width,
+ fp.height,
+ gridWidth,
+ gridHeight,
+ );
+
+ if (valid) {
+ await onMoveItem(item.id, targetX, targetY, pendingRotated);
+ }
+ },
+ [items, gridWidth, gridHeight, onMoveItem, onMergeStacks, cellSize, pendingRotated],
+ );
+
+ const handleBackgroundClick = useCallback(() => {
+ onSelectItem(null);
+ }, [onSelectItem]);
+
+ return (
+
+
+
+
+ {Array.from({ length: gridHeight }, (_, y) =>
+ Array.from({ length: gridWidth }, (_, x) => {
+ const key = `${x},${y}`;
+ return (
+
+ );
+ }),
+ )}
+
+ {items.map((item) => (
+
+ ))}
+
+
+
+
+
+ {occupancy.used} / {occupancy.total} cells used
+
+
+ {gridWidth} x {gridHeight}
+
+
+
+
+
+ {activeItem && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/frontend/locker/LockerGridToolbar.tsx b/src/components/frontend/locker/LockerGridToolbar.tsx
new file mode 100644
index 0000000..7089424
--- /dev/null
+++ b/src/components/frontend/locker/LockerGridToolbar.tsx
@@ -0,0 +1,117 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ ShuffleIcon,
+ RotateCwIcon,
+ TrashIcon,
+ ScissorsIcon,
+ LayoutGridIcon,
+ ListIcon,
+} from "lucide-react";
+import {
+ findLockerEmptySpot,
+ getAssetTypeLabel,
+ getLockerItemFootprint,
+ getLockerGridOccupancy,
+ lockerIsRotatable,
+ lockerIsStackable,
+ type LockerGridItem,
+} from "@/lib/locker";
+
+interface LockerGridToolbarProps {
+ gridWidth: number;
+ gridHeight: number;
+ items: LockerGridItem[];
+ selectedItem: LockerGridItem | null;
+ viewMode: "grid" | "list";
+ onViewModeChange: (mode: "grid" | "list") => void;
+ onAutoArrange: () => Promise;
+ onRotate: (entryId: string) => Promise;
+ onRemove: (entryId: string) => Promise;
+ onSplit: (entryId: string, amount: number) => Promise;
+}
+
+export function LockerGridToolbar({
+ gridWidth,
+ gridHeight,
+ items,
+ selectedItem,
+ viewMode,
+ onViewModeChange,
+ onAutoArrange,
+ onRotate,
+ onRemove,
+ onSplit,
+}: LockerGridToolbarProps) {
+ const occupancy = getLockerGridOccupancy(items, gridWidth, gridHeight);
+ const asset = selectedItem?.asset ?? null;
+ const fp = selectedItem && asset ? getLockerItemFootprint(asset, selectedItem.rotated) : null;
+ const canSplit =
+ selectedItem && asset && fp
+ ? findLockerEmptySpot(items, fp.width, fp.height, gridWidth, gridHeight) !== null
+ : false;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {selectedItem && asset && (
+
+
+
{asset.name}
+
+ {selectedItem.quantity.toLocaleString()}× · {getAssetTypeLabel(asset.assetType)}
+ {fp && ` · ${fp.width}x${fp.height}`}
+ {selectedItem.rotated && " · rotated"}
+
+
+
+ {lockerIsRotatable(asset) && (
+
+ )}
+ {lockerIsStackable(asset) && selectedItem.quantity > 1 && (
+
+ )}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/frontend/locker/LockerView.tsx b/src/components/frontend/locker/LockerView.tsx
new file mode 100644
index 0000000..c723b90
--- /dev/null
+++ b/src/components/frontend/locker/LockerView.tsx
@@ -0,0 +1,207 @@
+"use client";
+
+import { useCallback, useMemo, useState } from "react";
+import { useRouter } from "next/navigation";
+import type { Asset, Loadout, LockerStorage } from "@/payload-types";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
+import { EventLedger } from "@/components/frontend/storage/EventLedger";
+import { LockerGrid } from "./LockerGrid";
+import { LockerGridToolbar } from "./LockerGridToolbar";
+import { AddLockerItemDialog } from "./AddLockerItemDialog";
+import { LoadoutsPanel } from "./LoadoutsPanel";
+import { getAssetTypeLabel, type LockerGridItem } from "@/lib/locker";
+import {
+ autoArrangeLocker,
+ mergeLockerStacks,
+ moveLockerItem,
+ removeLockerItem,
+ rotateLockerItem,
+ splitLockerStack,
+} from "@/app/(frontend)/locker/actions";
+
+interface LockerViewProps {
+ locker: LockerStorage;
+ gridWidth: number;
+ gridHeight: number;
+ loadouts: Loadout[];
+ assetsCatalog: Asset[];
+ isManager: boolean;
+}
+
+export function LockerView({
+ locker,
+ gridWidth,
+ gridHeight,
+ loadouts,
+ assetsCatalog,
+ isManager,
+}: LockerViewProps) {
+ const router = useRouter();
+ const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
+ const [selectedItem, setSelectedItem] = useState(null);
+ const [eventLogKey, setEventLogKey] = useState(0);
+
+ const items: LockerGridItem[] = useMemo(
+ () =>
+ (locker.items ?? [])
+ .filter((entry) => entry.asset && typeof entry.asset === "object" && "id" in entry.asset)
+ .map((entry) => {
+ const asset = entry.asset as Asset;
+ return {
+ id: entry.id ?? `${asset.id}-${entry.gridX}-${entry.gridY}`,
+ assetId: asset.id,
+ quantity: entry.quantity,
+ gridX: entry.gridX ?? 0,
+ gridY: entry.gridY ?? 0,
+ rotated: entry.rotated ?? false,
+ asset,
+ };
+ }),
+ [locker.items],
+ );
+
+ const refresh = useCallback(() => {
+ router.refresh();
+ setEventLogKey((k) => k + 1);
+ }, [router]);
+
+ const handleMoveItem = useCallback(
+ async (entryId: string, gridX: number, gridY: number, rotated?: boolean) => {
+ const result = await moveLockerItem(entryId, gridX, gridY, rotated);
+ if (result.success) refresh();
+ },
+ [refresh],
+ );
+
+ const handleMergeStacks = useCallback(
+ async (sourceId: string, targetId: string) => {
+ const result = await mergeLockerStacks(sourceId, targetId);
+ if (result.success) refresh();
+ },
+ [refresh],
+ );
+
+ const handleRotateItem = useCallback(
+ async (entryId: string) => {
+ const result = await rotateLockerItem(entryId);
+ if (result.success) refresh();
+ },
+ [refresh],
+ );
+
+ const handleRemoveItem = useCallback(
+ async (entryId: string) => {
+ const result = await removeLockerItem(entryId);
+ if (result.success) {
+ setSelectedItem(null);
+ refresh();
+ }
+ },
+ [refresh],
+ );
+
+ const handleSplitStack = useCallback(
+ async (entryId: string, amount: number) => {
+ const result = await splitLockerStack(entryId, amount);
+ if (result.success) refresh();
+ },
+ [refresh],
+ );
+
+ const handleAutoArrange = useCallback(async () => {
+ const result = await autoArrangeLocker();
+ if (result.success) refresh();
+ }, [refresh]);
+
+ return (
+
+
+ Inventory
+ Loadouts
+
+
+
+
+
+ {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" ? (
+
+ ) : (
+ -
+
+
+ {items.map((entry, index) => (
+
+
+ {index < items.length - 1 &&
}
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/frontend/storage/EventLedger.tsx b/src/components/frontend/storage/EventLedger.tsx
index 56548ce..88c4f1b 100644
--- a/src/components/frontend/storage/EventLedger.tsx
+++ b/src/components/frontend/storage/EventLedger.tsx
@@ -32,9 +32,13 @@ function formatType(type: string) {
export function EventLedger({
structureId,
+ targetCollection,
+ targetId,
refreshKey,
}: {
- structureId: number;
+ structureId?: number;
+ targetCollection?: string;
+ targetId?: number;
refreshKey?: number;
}) {
const [entries, setEntries] = useState([]);
@@ -67,11 +71,16 @@ export function EventLedger({
const fetchEntries = useCallback(async () => {
if (!initialDone.current) setLoading(true);
const params = new URLSearchParams({
- "where[structure.id][equals]": String(structureId),
sort: "-timestamp",
limit: "50",
depth: "1",
});
+ if (targetCollection && targetId != null) {
+ params.set("where[targetCollection][equals]", targetCollection);
+ params.set("where[targetId][equals]", String(targetId));
+ } else if (structureId != null) {
+ params.set("where[structure.id][equals]", String(structureId));
+ }
try {
const r = await fetch(`/api/game-event-logs?${params}`);
const body = await r.json();
@@ -82,7 +91,7 @@ export function EventLedger({
initialDone.current = true;
setLoading(false);
}
- }, [structureId]);
+ }, [structureId, targetCollection, targetId]);
useEffect(() => {
fetch("/api/users/me")
@@ -133,7 +142,7 @@ export function EventLedger({
Event Log
- Recent activity for this structure.
+ Recent activity.
{eventTypes.length > 1 && (