feat(locker): add personal locker page and UI
This commit is contained in:
parent
d32f36a39c
commit
126d5b6dae
10 changed files with 1362 additions and 5 deletions
76
src/app/(frontend)/locker/page.tsx
Normal file
76
src/app/(frontend)/locker/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="p-5 flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LockKeyholeIcon className="size-5 text-muted-foreground" />
|
||||||
|
<h1 className="text-lg font-semibold">Personal Locker</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Store, organize, and equip your personal gear and loadouts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LockerView
|
||||||
|
locker={lockerFull}
|
||||||
|
gridWidth={grid.width}
|
||||||
|
gridHeight={grid.height}
|
||||||
|
loadouts={loadouts}
|
||||||
|
assetsCatalog={assetsCatalog}
|
||||||
|
isManager={isManager}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
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 { NavCore } from "@/components/frontend/blocks/NavCore";
|
||||||
import { NavSecondary } from "@/components/frontend/blocks/NavSecondary";
|
import { NavSecondary } from "@/components/frontend/blocks/NavSecondary";
|
||||||
|
|
@ -94,6 +94,13 @@ const data = {
|
||||||
icon: Send,
|
icon: Send,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
navPersonal: [
|
||||||
|
{
|
||||||
|
title: "Locker",
|
||||||
|
url: "/locker",
|
||||||
|
icon: Backpack,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AppSidebar({
|
export function AppSidebar({
|
||||||
|
|
@ -140,6 +147,7 @@ export function AppSidebar({
|
||||||
<NavCore label="Platform" items={data.navMain} />
|
<NavCore label="Platform" items={data.navMain} />
|
||||||
<NavCore label="Intelligence" items={data.navIntelligence} />
|
<NavCore label="Intelligence" items={data.navIntelligence} />
|
||||||
<NavCore label="Infrastructure" items={data.navLogistics} />
|
<NavCore label="Infrastructure" items={data.navLogistics} />
|
||||||
|
<NavCore label="Personal" items={data.navPersonal} />
|
||||||
{/*<NavProjects projects={data.projects} />*/}
|
{/*<NavProjects projects={data.projects} />*/}
|
||||||
<NavSecondary
|
<NavSecondary
|
||||||
items={
|
items={
|
||||||
|
|
|
||||||
126
src/components/frontend/locker/AddLockerItemDialog.tsx
Normal file
126
src/components/frontend/locker/AddLockerItemDialog.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { PlusIcon } from "lucide-react";
|
||||||
|
import type { Asset } from "@/payload-types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { getAssetTypeLabel } from "@/lib/locker";
|
||||||
|
import { addItemToLocker } from "@/app/(frontend)/locker/actions";
|
||||||
|
|
||||||
|
interface AddLockerItemDialogProps {
|
||||||
|
assets: Asset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddLockerItemDialog({ assets }: AddLockerItemDialogProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [assetId, setAssetId] = useState("");
|
||||||
|
const [quantity, setQuantity] = useState("1");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm">
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
Add Item
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Item to Locker</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Provision an asset into this member's locker.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Asset</Label>
|
||||||
|
<Select value={assetId} onValueChange={setAssetId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select an asset" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{assets.map((a) => (
|
||||||
|
<SelectItem key={a.id} value={String(a.id)}>
|
||||||
|
{a.name} · {getAssetTypeLabel(a.assetType)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Quantity</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={quantity}
|
||||||
|
onChange={(e) => setQuantity(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={loading}>
|
||||||
|
{loading ? "Adding..." : "Add to Locker"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
src/components/frontend/locker/LoadoutCard.tsx
Normal file
69
src/components/frontend/locker/LoadoutCard.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Item variant="outline">
|
||||||
|
<ItemHeader>
|
||||||
|
<ItemTitle>{loadout.name}</ItemTitle>
|
||||||
|
{loadout.description && (
|
||||||
|
<span className="text-xs text-muted-foreground">{loadout.description}</span>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="outline" size="sm" onClick={onEdit}>
|
||||||
|
<PencilIcon className="size-3.5" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={onDelete}>
|
||||||
|
<TrashIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ItemHeader>
|
||||||
|
<ItemContent>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{filled.length === 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">No equipment assigned.</span>
|
||||||
|
)}
|
||||||
|
{filled.map((slot) => {
|
||||||
|
const value = slots[slot.name];
|
||||||
|
const asset = resolveSlotValue(value);
|
||||||
|
if (!asset) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={slot.name}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs bg-muted px-2.5 py-1 rounded-md"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">{slot.label}:</span>
|
||||||
|
<span className="font-medium">{asset.name}</span>
|
||||||
|
<span className="text-muted-foreground">({getAssetTypeLabel(asset.assetType)})</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
src/components/frontend/locker/LoadoutEditor.tsx
Normal file
212
src/components/frontend/locker/LoadoutEditor.tsx
Normal file
|
|
@ -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<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setLoading(false);
|
||||||
|
setName(loadout?.name ?? "");
|
||||||
|
setDescription(loadout?.description ?? "");
|
||||||
|
const initial: Record<string, string> = {};
|
||||||
|
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<number>();
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{mode === "create" ? "New Loadout" : "Edit Loadout"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Define a loadout and assign assets from your locker to each equipment slot.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g., CQB" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Optional notes"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{LOADOUT_SLOTS.map((slot) => {
|
||||||
|
const options = ownedByType(slot.assetTypes);
|
||||||
|
return (
|
||||||
|
<div key={slot.name} className="flex flex-col gap-1.5">
|
||||||
|
<Label>{slot.label}</Label>
|
||||||
|
<Select
|
||||||
|
value={slots[slot.name] ?? ""}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setSlots((prev) => ({ ...prev, [slot.name]: v === "none" ? "" : v }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={slot.description} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">Empty</SelectItem>
|
||||||
|
{options.map((item) => (
|
||||||
|
<SelectItem key={item.assetId} value={String(item.assetId)}>
|
||||||
|
{item.asset.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{options.length === 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
No matching assets in your locker.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={loading}>
|
||||||
|
{loading ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/components/frontend/locker/LoadoutsPanel.tsx
Normal file
75
src/components/frontend/locker/LoadoutsPanel.tsx
Normal file
|
|
@ -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<Loadout | null>(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 (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Save and manage equipment presets for different roles.
|
||||||
|
</p>
|
||||||
|
<Button size="sm" onClick={() => setCreating(true)}>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
New Loadout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadouts.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground border rounded-lg p-4">
|
||||||
|
No loadouts yet. Create one to start building your equipment presets.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{loadouts.map((loadout) => (
|
||||||
|
<LoadoutCard
|
||||||
|
key={loadout.id}
|
||||||
|
loadout={loadout}
|
||||||
|
onEdit={() => setEditing(loadout)}
|
||||||
|
onDelete={() => handleDelete(loadout)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LoadoutEditor
|
||||||
|
mode="create"
|
||||||
|
ownedItems={ownedItems}
|
||||||
|
open={creating}
|
||||||
|
onOpenChange={setCreating}
|
||||||
|
/>
|
||||||
|
<LoadoutEditor
|
||||||
|
mode="edit"
|
||||||
|
loadout={editing}
|
||||||
|
ownedItems={ownedItems}
|
||||||
|
open={!!editing}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setEditing(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
458
src/components/frontend/locker/LockerGrid.tsx
Normal file
458
src/components/frontend/locker/LockerGrid.tsx
Normal file
|
|
@ -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 <Crosshair className={cls} />;
|
||||||
|
case "magazine":
|
||||||
|
return <Package className={cls} />;
|
||||||
|
case "optic":
|
||||||
|
return <Target className={cls} />;
|
||||||
|
case "muzzle":
|
||||||
|
return <FlaskConical className={cls} />;
|
||||||
|
case "bipod":
|
||||||
|
return <Hammer className={cls} />;
|
||||||
|
case "uniform":
|
||||||
|
return <Shirt className={cls} />;
|
||||||
|
case "vest":
|
||||||
|
return <Shield className={cls} />;
|
||||||
|
case "backpack":
|
||||||
|
return <Backpack className={cls} />;
|
||||||
|
case "helmet":
|
||||||
|
return <Shield className={cls} />;
|
||||||
|
case "facewear":
|
||||||
|
return <Glasses className={cls} />;
|
||||||
|
case "nvg":
|
||||||
|
return <MoonStar className={cls} />;
|
||||||
|
case "radio":
|
||||||
|
return <Headset className={cls} />;
|
||||||
|
case "medical":
|
||||||
|
return <Monitor className={cls} />;
|
||||||
|
case "tool":
|
||||||
|
return <Wrench className={cls} />;
|
||||||
|
case "explosive":
|
||||||
|
return <Gem className={cls} />;
|
||||||
|
case "miscellaneous":
|
||||||
|
return <Luggage className={cls} />;
|
||||||
|
default:
|
||||||
|
return <Package className={cls} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 opacity-20" style={{ backgroundColor: bgColor }} />
|
||||||
|
<div className="relative flex flex-col items-center justify-center gap-0.5 p-1 w-full h-full">
|
||||||
|
{thumb ? (
|
||||||
|
<img src={thumb} alt={asset.name} className="size-6 object-contain opacity-80" />
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/70">{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">
|
||||||
|
{asset.name}
|
||||||
|
</span>
|
||||||
|
{quantity > 1 && (
|
||||||
|
<span
|
||||||
|
className="text-[9px] font-bold leading-none px-1 py-0.5 rounded-sm"
|
||||||
|
style={{ backgroundColor: bgColor, color: "white" }}
|
||||||
|
>
|
||||||
|
{quantity.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
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",
|
||||||
|
)}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
onClick={handleClick}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
|
<LockerItemFace asset={item.asset} quantity={item.quantity} rotated={item.rotated} cellSize={cellSize} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LockerGridProps {
|
||||||
|
gridWidth: number;
|
||||||
|
gridHeight: number;
|
||||||
|
items: LockerGridItem[];
|
||||||
|
onMoveItem: (entryId: string, gridX: number, gridY: number, rotated?: boolean) => Promise<void>;
|
||||||
|
onMergeStacks: (sourceId: string, targetId: string) => Promise<void>;
|
||||||
|
onRotateItem: (entryId: string) => Promise<void>;
|
||||||
|
onRemoveItem: (entryId: string) => Promise<void>;
|
||||||
|
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<LockerGridItem | null>(null);
|
||||||
|
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const [pendingRotated, setPendingRotated] = useState(false);
|
||||||
|
const [cellSize, setCellSize] = useState(48);
|
||||||
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(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<string>();
|
||||||
|
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<string>();
|
||||||
|
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 (
|
||||||
|
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragMove={handleDragMove} onDragEnd={handleDragEnd}>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div ref={containerRef} className="rounded-lg border bg-muted/20 p-2">
|
||||||
|
<div
|
||||||
|
ref={gridRef}
|
||||||
|
className="relative grid"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(${gridWidth}, ${cellSize}px)`,
|
||||||
|
gridTemplateRows: `repeat(${gridHeight}, ${cellSize}px)`,
|
||||||
|
}}
|
||||||
|
onClick={handleBackgroundClick}
|
||||||
|
>
|
||||||
|
{Array.from({ length: gridHeight }, (_, y) =>
|
||||||
|
Array.from({ length: gridWidth }, (_, x) => {
|
||||||
|
const key = `${x},${y}`;
|
||||||
|
return (
|
||||||
|
<GridCell
|
||||||
|
key={key}
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
cellSize={cellSize}
|
||||||
|
isOccupied={occupiedCells.has(key)}
|
||||||
|
isDropTarget={!!activeItem}
|
||||||
|
isHovered={hoverInfo !== null && hoverInfo.cells.has(key)}
|
||||||
|
isInvalidDrop={hoverInfo !== null && !hoverInfo.valid}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.map((item) => (
|
||||||
|
<DraggableLockerItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
cellSize={cellSize}
|
||||||
|
onSelect={onSelectItem}
|
||||||
|
selected={selectedItem?.id === item.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{occupancy.used} / {occupancy.total} cells used
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{gridWidth} x {gridHeight}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DragOverlay dropAnimation={null}>
|
||||||
|
{activeItem && (
|
||||||
|
<div className="rounded-md border border-primary/50 shadow-xl ring-2 ring-primary/50">
|
||||||
|
<LockerItemFace
|
||||||
|
asset={activeItem.asset}
|
||||||
|
quantity={activeItem.quantity}
|
||||||
|
rotated={pendingRotated}
|
||||||
|
cellSize={cellSize}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/components/frontend/locker/LockerGridToolbar.tsx
Normal file
117
src/components/frontend/locker/LockerGridToolbar.tsx
Normal file
|
|
@ -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<void>;
|
||||||
|
onRotate: (entryId: string) => Promise<void>;
|
||||||
|
onRemove: (entryId: string) => Promise<void>;
|
||||||
|
onSplit: (entryId: string, amount: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={onAutoArrange}>
|
||||||
|
<ShuffleIcon className="size-3.5 mr-1" />
|
||||||
|
Auto-Arrange
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "grid" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onViewModeChange("grid")}
|
||||||
|
>
|
||||||
|
<LayoutGridIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "list" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onViewModeChange("list")}
|
||||||
|
>
|
||||||
|
<ListIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedItem && asset && (
|
||||||
|
<div className="flex items-center gap-3 p-2 rounded-md border bg-muted/30 text-xs">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">{asset.name}</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{selectedItem.quantity.toLocaleString()}× · {getAssetTypeLabel(asset.assetType)}
|
||||||
|
{fp && ` · ${fp.width}x${fp.height}`}
|
||||||
|
{selectedItem.rotated && " · rotated"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{lockerIsRotatable(asset) && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onRotate(selectedItem.id)}>
|
||||||
|
<RotateCwIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{lockerIsStackable(asset) && selectedItem.quantity > 1 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!canSplit}
|
||||||
|
onClick={() => onSplit(selectedItem.id, Math.floor(selectedItem.quantity / 2))}
|
||||||
|
>
|
||||||
|
<ScissorsIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onRemove(selectedItem.id)}>
|
||||||
|
<TrashIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
src/components/frontend/locker/LockerView.tsx
Normal file
207
src/components/frontend/locker/LockerView.tsx
Normal file
|
|
@ -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<LockerGridItem | null>(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 (
|
||||||
|
<Tabs defaultValue="inventory">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="inventory">Inventory</TabsTrigger>
|
||||||
|
<TabsTrigger value="loadouts">Loadouts</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="inventory" className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<LockerGridToolbar
|
||||||
|
gridWidth={gridWidth}
|
||||||
|
gridHeight={gridHeight}
|
||||||
|
items={items}
|
||||||
|
selectedItem={selectedItem}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
|
onAutoArrange={handleAutoArrange}
|
||||||
|
onRotate={handleRotateItem}
|
||||||
|
onRemove={handleRemoveItem}
|
||||||
|
onSplit={handleSplitStack}
|
||||||
|
/>
|
||||||
|
{isManager && <AddLockerItemDialog assets={assetsCatalog} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<Item variant="outline">
|
||||||
|
<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={setSelectedItem}
|
||||||
|
selectedItem={selectedItem}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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={() => setSelectedItem(entry)}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium flex-1 min-w-0 truncate">
|
||||||
|
{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
|
||||||
|
targetCollection="locker-storages"
|
||||||
|
targetId={locker.id}
|
||||||
|
refreshKey={eventLogKey}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="loadouts">
|
||||||
|
<LoadoutsPanel loadouts={loadouts} ownedItems={items} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -32,9 +32,13 @@ function formatType(type: string) {
|
||||||
|
|
||||||
export function EventLedger({
|
export function EventLedger({
|
||||||
structureId,
|
structureId,
|
||||||
|
targetCollection,
|
||||||
|
targetId,
|
||||||
refreshKey,
|
refreshKey,
|
||||||
}: {
|
}: {
|
||||||
structureId: number;
|
structureId?: number;
|
||||||
|
targetCollection?: string;
|
||||||
|
targetId?: number;
|
||||||
refreshKey?: number;
|
refreshKey?: number;
|
||||||
}) {
|
}) {
|
||||||
const [entries, setEntries] = useState<LogEntry[]>([]);
|
const [entries, setEntries] = useState<LogEntry[]>([]);
|
||||||
|
|
@ -67,11 +71,16 @@ export function EventLedger({
|
||||||
const fetchEntries = useCallback(async () => {
|
const fetchEntries = useCallback(async () => {
|
||||||
if (!initialDone.current) setLoading(true);
|
if (!initialDone.current) setLoading(true);
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
"where[structure.id][equals]": String(structureId),
|
|
||||||
sort: "-timestamp",
|
sort: "-timestamp",
|
||||||
limit: "50",
|
limit: "50",
|
||||||
depth: "1",
|
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 {
|
try {
|
||||||
const r = await fetch(`/api/game-event-logs?${params}`);
|
const r = await fetch(`/api/game-event-logs?${params}`);
|
||||||
const body = await r.json();
|
const body = await r.json();
|
||||||
|
|
@ -82,7 +91,7 @@ export function EventLedger({
|
||||||
initialDone.current = true;
|
initialDone.current = true;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [structureId]);
|
}, [structureId, targetCollection, targetId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/users/me")
|
fetch("/api/users/me")
|
||||||
|
|
@ -133,7 +142,7 @@ export function EventLedger({
|
||||||
<ScrollTextIcon className="size-4" />
|
<ScrollTextIcon className="size-4" />
|
||||||
Event Log
|
Event Log
|
||||||
</ItemTitle>
|
</ItemTitle>
|
||||||
<ItemDescription>Recent activity for this structure.</ItemDescription>
|
<ItemDescription>Recent activity.</ItemDescription>
|
||||||
</ItemHeader>
|
</ItemHeader>
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
{eventTypes.length > 1 && (
|
{eventTypes.length > 1 && (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue