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 ( + + + + + + + Add Item to Locker + + Provision an asset into this member's locker. + + +
+
+ + +
+
+ + setQuantity(e.target.value)} + /> +
+ {error &&

{error}

} +
+ + + + +
+
+ ); +} 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 ( + + + + {mode === "create" ? "New Loadout" : "Edit Loadout"} + + Define a loadout and assign assets from your locker to each equipment slot. + + +
+
+ + setName(e.target.value)} placeholder="e.g., CQB" /> +
+
+ +