1
0
Fork 0

Compare commits

...

14 commits

Author SHA1 Message Date
50bc0e3441 style(migrations): rename migration files, add new migration for shims, add favicon for front-end 2026-08-17 02:02:16 -04:00
cdeebd05f8 style(static): format payload admin static components 2026-08-16 23:27:51 -04:00
9f4faa948b style(config): format generated payload schema 2026-08-16 23:27:51 -04:00
c27b12c76c style(bot): format Discord bot modules 2026-08-16 23:27:51 -04:00
50f964cdf9 style(scripts): format tick scripts, seed tools, and migrations 2026-08-16 23:27:51 -04:00
2acf52788f style(collections): format Payload collection configs 2026-08-16 23:27:51 -04:00
6685fe68cd style(lib): format shared libraries and remove unused imports 2026-08-16 23:27:51 -04:00
5058d9ce6c style(admin): format admin narrative-flow and award components 2026-08-16 23:27:51 -04:00
a21564a78d style(frontend): format frontend feature components 2026-08-16 23:27:51 -04:00
7db1f648bf style(app): format app routes, pages, and server actions 2026-08-16 23:27:51 -04:00
05c7c83799 style(ui): format shadcn/ui primitives and utility hooks 2026-08-16 23:27:51 -04:00
afb61f6227 feat(globals): add Shims global for admin CSS/JS shims 2026-08-16 23:27:51 -04:00
088b6cefb6 fix(ui): correct sidebar inset layout and Tailwind v4 custom-property widths
The shadcn component update pulled sidebar.tsx using Tailwind v3-style
bare-bracket custom property syntax (w-[--sidebar-width]). Under Tailwind
v4 this compiles to invalid CSS (width: --sidebar-width) which the browser
ignores, so width fell back to auto and the fixed sidebar blew out to its
content's intrinsic width (the 1372px logo header ~ half the page). The
empty spacer div was equally broken, reserving zero space and leaving the
inset m-2 margin as a stray left gap.

Three fixes:
- sidebar.tsx: wrap all 7 bare-bracket custom properties in var() so they
  compile to width: var(--sidebar-width) / var(--sidebar-width-icon) /
  max-width: var(--skeleton-width). The calc(var(...)) rules were already
  valid; only the bare forms were broken.
- layout.tsx: replace the raw <main className="min-h-full bg-background
  w-full"> with <SidebarInset>. AppSidebar uses variant="inset", whose
  peer styling (md:peer-data-[variant=inset]:m-2 / :rounded-xl / :shadow)
  only applies to a SidebarInset sibling, so the inset gap/rounding/shadow
  were silently dropped.
- SiteHeader.tsx: replace the raw <div> + border-gray-800 + p-4 with the
  standard shadcn <header className="flex h-16 shrink-0 items-center gap-2
  border-b px-4"> using semantic border-b, plus -ml-1 on SidebarTrigger
  and an ml-auto wrapper for NotificationsBell (which takes no className).

Verified via the running dev server: SSR HTML carries
data-variant="inset", a single <main> with the peer-data-[variant=inset]
classes, <header ... border-b>, and zero border-gray-800 occurrences.
tsc --noEmit clean for the changed files.
2026-08-16 23:27:51 -04:00
fa70ed0956 feat(config): add meta information for Polaris Task Force website 2026-08-16 19:41:21 -04:00
223 changed files with 18836 additions and 3275 deletions

23
.env.development Normal file
View file

@ -0,0 +1,23 @@
#DATABASE_URI=postgres://ptfapp:polaristaskforcedb@jmf-usrv-2404:2552/ptf_app_prd
PAYLOAD_SECRET=b5ac11acc77ea8427d10fcd0
# Added by Payload
EMAIL_FROM_ADDRESS="arma@onyxsimple.com"
EMAIL_FROM_NAME="Arma"
EMAIL_HOST="smtp-relay.brevo.com"
EMAIL_PORT=587
EMAIL_USERNAME="b34870001@smtp-brevo.com"
EMAIL_PASSWORD="xsmtpsib-97789fdb52a37baeff8a586a06e849cdaea39bd7dfd850f1a612030328d64899-fVMh5bQkZx0wNQej"
LAST_UPDATED=1766438881
APP_URL=http://localhost:3000
GAME_TICK_NOTIFY_SECRET=4707fba13f1c66db44df38721fba204f2d6bdc6796ced7a5
DISCORD_TOKEN=MTUzNzI2MzQ0Njk2NDMwNjEwMQ.GnTFBc.2LHX1FO7aAm9LbrKzj2IlVFNDmTBXsnBebzk3o
DISCORD_GUILD_ID=851910807020765275
DISCORD_OPS_CHANNEL_ID=1136692636481237173
DISCORD_ANNOUNCE_CHANNEL_ID=1136692636481237173
DISCORD_STAFF_ROLE_IDS=851910807277404187,851910807277404188,851910807277404189,851910807289462814
DISCORD_ATTENDANCE_POLL_MS=5000
DISCORD_NOTIFICATION_POLL_MS=5000

BIN
public/favicon.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View file

@ -9,12 +9,8 @@ import { EventTypes } from "@/utils/event-log/eventTypes";
import { notifyUser } from "@/lib/notifications"; import { notifyUser } from "@/lib/notifications";
import { plaintextToLexical } from "@/lib/tickets/lexical"; import { plaintextToLexical } from "@/lib/tickets/lexical";
import { getStaffUserIds } from "@/lib/tickets/staff"; import { getStaffUserIds } from "@/lib/tickets/staff";
import {
CLOSING_STATUSES,
STATUS_LABEL,
TICKET_STATUSES,
} from "@/lib/tickets/ticketMeta";
import type { TicketCategory, TicketPriority, TicketStatus } from "@/lib/tickets/ticketMeta"; import type { TicketCategory, TicketPriority, TicketStatus } from "@/lib/tickets/ticketMeta";
import { CLOSING_STATUSES, STATUS_LABEL, TICKET_STATUSES } from "@/lib/tickets/ticketMeta";
export interface ActionResult<T = undefined> { export interface ActionResult<T = undefined> {
success: boolean; success: boolean;
@ -36,7 +32,10 @@ async function authenticate() {
return { payload, user }; return { payload, user };
} }
function displayNameOf(user: { payloadDisplayName?: string | null; username?: string | null }): string { function displayNameOf(user: {
payloadDisplayName?: string | null;
username?: string | null;
}): string {
return user.payloadDisplayName || user.username || "A member"; return user.payloadDisplayName || user.username || "A member";
} }
@ -169,9 +168,7 @@ export async function replyToTicket(ticketId: number, content: string): Promise<
await payload.update({ await payload.update({
collection: "tickets", collection: "tickets",
id: ticketId, id: ticketId,
data: reopen data: reopen ? { activity: newActivity, status: "open" } : { activity: newActivity },
? { activity: newActivity, status: "open" }
: { activity: newActivity },
overrideAccess: true, overrideAccess: true,
depth: 0, depth: 0,
}); });
@ -229,7 +226,10 @@ export async function replyToTicket(ticketId: number, content: string): Promise<
} }
} }
export async function updateTicketStatus(ticketId: number, status: TicketStatus): Promise<ActionResult> { export async function updateTicketStatus(
ticketId: number,
status: TicketStatus,
): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
const userId = user.id as number; const userId = user.id as number;
@ -245,9 +245,7 @@ export async function updateTicketStatus(ticketId: number, status: TicketStatus)
const reporterId = reporterIdOf(ticket); const reporterId = reporterIdOf(ticket);
if (!isStaff) { if (!isStaff) {
const reporterCancel = const reporterCancel =
reporterId === userId && reporterId === userId && status === "canceled" && !CLOSING_STATUSES.includes(ticket.status);
status === "canceled" &&
!CLOSING_STATUSES.includes(ticket.status);
if (!reporterCancel) throw new Error("Only staff can change ticket status."); if (!reporterCancel) throw new Error("Only staff can change ticket status.");
} }
@ -293,7 +291,10 @@ export async function updateTicketStatus(ticketId: number, status: TicketStatus)
} }
} }
export async function assignTicket(ticketId: number, assigneeId: number | null): Promise<ActionResult> { export async function assignTicket(
ticketId: number,
assigneeId: number | null,
): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
const userId = user.id as number; const userId = user.id as number;

View file

@ -2,13 +2,12 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import type { Campaign, Faction, Media, Mission } from "@/payload-types"; import type { Faction, Media, Mission } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react"; import { RichText } from "@payloadcms/richtext-lexical/react";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
CalendarDaysIcon, CalendarDaysIcon,
ChevronRightIcon, ChevronRightIcon,
CrosshairIcon,
ShieldIcon, ShieldIcon,
TargetIcon, TargetIcon,
UsersIcon, UsersIcon,

View file

@ -2,7 +2,7 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import type { Faction, Media, Mission, BankAccount, Resource, Campaign } from "@/payload-types"; import type { BankAccount, Campaign, Faction, Media, Mission, Resource } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react"; import { RichText } from "@payloadcms/richtext-lexical/react";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { import {
@ -10,8 +10,8 @@ import {
ChevronRightIcon, ChevronRightIcon,
FlagIcon, FlagIcon,
LandmarkIcon, LandmarkIcon,
ShieldIcon,
ScaleIcon, ScaleIcon,
ShieldIcon,
} from "lucide-react"; } from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { formatAmount } from "@/lib/banking/format"; import { formatAmount } from "@/lib/banking/format";

View file

@ -37,8 +37,8 @@ export default async function FactionsPage() {
<h1 className="text-lg font-semibold">Factions</h1> <h1 className="text-lg font-semibold">Factions</h1>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{factions.length} faction{factions.length !== 1 ? "s" : ""} tracked in {factions.length} faction{factions.length !== 1 ? "s" : ""} tracked in the theater of
the theater of operations. operations.
</p> </p>
</div> </div>

View file

@ -3,35 +3,18 @@ import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import type { import type { Campaign, Map, Media, Mission } from "@/payload-types";
Mission,
Campaign,
Map,
User,
Faction,
Qualification,
Rank,
Media,
} from "@/payload-types";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
ArrowRightIcon,
CalendarDaysIcon, CalendarDaysIcon,
ChevronRightIcon, ChevronRightIcon,
ClockIcon, ClockIcon,
MapPinIcon,
CrosshairIcon, CrosshairIcon,
ServerIcon,
FlagIcon, FlagIcon,
MapPinIcon,
ServerIcon,
} from "lucide-react"; } from "lucide-react";
import { import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { MissionBriefingSection } from "@/components/frontend/intelligence/MissionBriefingSection"; import { MissionBriefingSection } from "@/components/frontend/intelligence/MissionBriefingSection";
import { MissionObjectives } from "@/components/frontend/intelligence/MissionObjectives"; import { MissionObjectives } from "@/components/frontend/intelligence/MissionObjectives";
import { MissionFactions } from "@/components/frontend/intelligence/MissionFactions"; import { MissionFactions } from "@/components/frontend/intelligence/MissionFactions";
@ -40,10 +23,10 @@ import { MissionLogistics } from "@/components/frontend/intelligence/MissionLogi
import { MissionComms } from "@/components/frontend/intelligence/MissionComms"; import { MissionComms } from "@/components/frontend/intelligence/MissionComms";
import { MissionAttendance } from "@/components/frontend/intelligence/MissionAttendance"; import { MissionAttendance } from "@/components/frontend/intelligence/MissionAttendance";
import { import {
type AttendanceResponse,
getMissionAttendance, getMissionAttendance,
getUserAttendance, getUserAttendance,
isMissionPast, isMissionPast,
type AttendanceResponse,
} from "@/lib/attendance"; } from "@/lib/attendance";
interface MissionDetailPageProps { interface MissionDetailPageProps {

View file

@ -2,8 +2,8 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { setMissionAttendance as setAttendance } from "@/lib/attendance";
import type { AttendanceResponse } from "@/lib/attendance"; import type { AttendanceResponse } from "@/lib/attendance";
import { setMissionAttendance as setAttendance } from "@/lib/attendance";
export interface ActionResult<T = undefined> { export interface ActionResult<T = undefined> {
success: boolean; success: boolean;

View file

@ -1,6 +1,6 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { Mission, Campaign, Map } from "@/payload-types"; import type { Campaign, Mission } from "@/payload-types";
import { CrosshairIcon } from "lucide-react"; import { CrosshairIcon } from "lucide-react";
import { MissionsList } from "@/components/frontend/intelligence/MissionsList"; import { MissionsList } from "@/components/frontend/intelligence/MissionsList";

View file

@ -3,16 +3,10 @@ import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import type { Campaign, Mission, Faction } from "@/payload-types"; import type { Campaign, Faction } from "@/payload-types";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification"; import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
import { CrosshairIcon, ArrowRightIcon } from "lucide-react"; import { ArrowRightIcon, CrosshairIcon } from "lucide-react";
import { import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
import { CampaignCard } from "@/components/frontend/intelligence/CampaignCard"; import { CampaignCard } from "@/components/frontend/intelligence/CampaignCard";
export const metadata = { export const metadata = {
@ -55,8 +49,7 @@ export default async function IntelligenceOverviewPage() {
<h1 className="text-lg font-semibold">Intelligence</h1> <h1 className="text-lg font-semibold">Intelligence</h1>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Campaigns, missions, and faction intelligence for the theater of Campaigns, missions, and faction intelligence for the theater of operations.
operations.
</p> </p>
</div> </div>
@ -71,9 +64,7 @@ export default async function IntelligenceOverviewPage() {
<ArrowRightIcon className="size-4 text-muted-foreground group-hover:text-primary transition-colors" /> <ArrowRightIcon className="size-4 text-muted-foreground group-hover:text-primary transition-colors" />
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<ItemDescription> <ItemDescription>Active and completed campaigns across all theaters.</ItemDescription>
Active and completed campaigns across all theaters.
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
</Link> </Link>
@ -87,9 +78,7 @@ export default async function IntelligenceOverviewPage() {
<ArrowRightIcon className="size-4 text-muted-foreground group-hover:text-primary transition-colors" /> <ArrowRightIcon className="size-4 text-muted-foreground group-hover:text-primary transition-colors" />
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<ItemDescription> <ItemDescription>Operation briefings, objectives, and schedules.</ItemDescription>
Operation briefings, objectives, and schedules.
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
</Link> </Link>
@ -104,8 +93,7 @@ export default async function IntelligenceOverviewPage() {
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<ItemDescription> <ItemDescription>
{factions.length} faction{factions.length !== 1 ? "s" : ""}{" "} {factions.length} faction{factions.length !== 1 ? "s" : ""} tracked in the AO.
tracked in the AO.
</ItemDescription> </ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>

View file

@ -1,13 +1,13 @@
import React from "react"; import React from "react";
import "./styles.css"; import "./styles.css";
import { SidebarProvider } from "@/components/ui/sidebar"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/frontend/blocks/AppSidebar"; import { AppSidebar } from "@/components/frontend/blocks/AppSidebar";
import { getPayload } from "payload"; import { getPayload } from "payload";
import config from "@payload-config"; import config from "@payload-config";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import { SiteHeader } from "@/components/frontend/SiteHeader"; import { SiteHeader } from "@/components/frontend/SiteHeader";
import { Metadata } from "next"; import { Metadata } from "next";
import { resolveLevel, type ResolvedLevel } from "@/utils/xp/resolveLevel"; import { type ResolvedLevel, resolveLevel } from "@/utils/xp/resolveLevel";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification"; import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts"; import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
@ -26,6 +26,13 @@ export const dynamic = "force-dynamic";
export const metadata: Metadata = { export const metadata: Metadata = {
description: "A gamified platform for Polaris Task Force, an Arma 3 unit.", description: "A gamified platform for Polaris Task Force, an Arma 3 unit.",
title: "Polaris Task Force", title: "Polaris Task Force",
icons: [
{
url: "/favicon.webp",
type: "image/webp",
rel: "icon"
}
]
}; };
export default async function RootLayout(props: { children: React.ReactNode }) { export default async function RootLayout(props: { children: React.ReactNode }) {
@ -98,10 +105,10 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
showCallsign={user.preferences?.display?.showCallsign ?? false} showCallsign={user.preferences?.display?.showCallsign ?? false}
className="border-r" className="border-r"
/> />
<main className="min-h-full bg-background w-full"> <SidebarInset>
<SiteHeader /> <SiteHeader />
{children} {children}
</main> </SidebarInset>
</SidebarProvider> </SidebarProvider>
) : ( ) : (
<LandingPage /> <LandingPage />

View file

@ -11,9 +11,9 @@ import {
autoArrangeLockerItems, autoArrangeLockerItems,
ensureLockerStorage, ensureLockerStorage,
findLockerEmptySpot, findLockerEmptySpot,
getLoadoutSlot,
getLockerGridDimensions, getLockerGridDimensions,
getLockerItemFootprint, getLockerItemFootprint,
getLoadoutSlot,
isAttachmentCompatible, isAttachmentCompatible,
isLockerPlacementValid, isLockerPlacementValid,
isSkinAsset, isSkinAsset,
@ -23,7 +23,6 @@ import {
resolveAssetId, resolveAssetId,
skinAppliesTo, skinAppliesTo,
toLockerGridItems, toLockerGridItems,
type LockerGridItem,
} from "@/lib/locker"; } from "@/lib/locker";
export interface ActionResult<T = undefined> { export interface ActionResult<T = undefined> {
@ -219,7 +218,8 @@ export async function removeLockerItem(entryId: string): Promise<ActionResult> {
const removed = items[idx]; const removed = items[idx];
const asset = removed.asset as Asset; const asset = removed.asset as Asset;
const removedName = typeof asset === "object" && asset ? asset.name : `asset #${resolveAssetId(removed.asset)}`; const removedName =
typeof asset === "object" && asset ? asset.name : `asset #${resolveAssetId(removed.asset)}`;
items.splice(idx, 1); items.splice(idx, 1);
await payload.update({ await payload.update({
@ -236,7 +236,12 @@ export async function removeLockerItem(entryId: string): Promise<ActionResult> {
actor: user.id, actor: user.id,
targetCollection: "locker-storages", targetCollection: "locker-storages",
targetId: locker.id, targetId: locker.id,
data: { entryId, assetId: resolveAssetId(removed.asset), assetName: removedName, quantity: removed.quantity }, data: {
entryId,
assetId: resolveAssetId(removed.asset),
assetName: removedName,
quantity: removed.quantity,
},
}); });
return { success: true }; return { success: true };
@ -269,19 +274,33 @@ export async function moveLockerItem(
const effectiveRotated = rotated ?? entry.rotated ?? false; const effectiveRotated = rotated ?? entry.rotated ?? false;
const fp = getLockerItemFootprint(asset, effectiveRotated); const fp = getLockerItemFootprint(asset, effectiveRotated);
if (gridX < 0 || gridY < 0 || gridX + fp.width > grid.width || gridY + fp.height > grid.height) { if (
gridX < 0 ||
gridY < 0 ||
gridX + fp.width > grid.width ||
gridY + fp.height > grid.height
) {
return { success: false, error: "Placement is outside grid bounds." }; return { success: false, error: "Placement is outside grid bounds." };
} }
const gridItems = toLockerGridItems(items); const gridItems = toLockerGridItems(items);
if (!isLockerPlacementValid(gridItems, entryId, gridX, gridY, fp.width, fp.height, grid.width, grid.height)) { if (
!isLockerPlacementValid(
gridItems,
entryId,
gridX,
gridY,
fp.width,
fp.height,
grid.width,
grid.height,
)
) {
return { success: false, error: "Cannot move here — overlaps with another item." }; return { success: false, error: "Cannot move here — overlaps with another item." };
} }
items[idx] = items[idx] =
rotated !== undefined rotated !== undefined ? { ...entry, gridX, gridY, rotated } : { ...entry, gridX, gridY };
? { ...entry, gridX, gridY, rotated }
: { ...entry, gridX, gridY };
await payload.update({ await payload.update({
collection: "locker-storages", collection: "locker-storages",
@ -297,7 +316,14 @@ export async function moveLockerItem(
actor: user.id, actor: user.id,
targetCollection: "locker-storages", targetCollection: "locker-storages",
targetId: locker.id, targetId: locker.id,
data: { entryId, assetId: asset.id, assetName: asset.name, gridX, gridY, rotated: effectiveRotated }, data: {
entryId,
assetId: asset.id,
assetName: asset.name,
gridX,
gridY,
rotated: effectiveRotated,
},
}); });
return { success: true }; return { success: true };
@ -321,7 +347,8 @@ export async function rotateLockerItem(entryId: string): Promise<ActionResult> {
const entry = items[idx]; const entry = items[idx];
const asset = entry.asset as Asset; const asset = entry.asset as Asset;
if (!asset || typeof asset === "number") return { success: false, error: "Invalid asset." }; if (!asset || typeof asset === "number") return { success: false, error: "Invalid asset." };
if (!lockerIsRotatable(asset)) return { success: false, error: "This asset cannot be rotated." }; if (!lockerIsRotatable(asset))
return { success: false, error: "This asset cannot be rotated." };
const newRotated = !(entry.rotated ?? false); const newRotated = !(entry.rotated ?? false);
const fp = getLockerItemFootprint(asset, newRotated); const fp = getLockerItemFootprint(asset, newRotated);
@ -331,7 +358,18 @@ export async function rotateLockerItem(entryId: string): Promise<ActionResult> {
} }
const gridItems = toLockerGridItems(items); const gridItems = toLockerGridItems(items);
if (!isLockerPlacementValid(gridItems, entryId, entry.gridX, entry.gridY, fp.width, fp.height, grid.width, grid.height)) { if (
!isLockerPlacementValid(
gridItems,
entryId,
entry.gridX,
entry.gridY,
fp.width,
fp.height,
grid.width,
grid.height,
)
) {
return { success: false, error: "Rotation would overlap with another item." }; return { success: false, error: "Rotation would overlap with another item." };
} }
@ -362,10 +400,7 @@ export async function rotateLockerItem(entryId: string): Promise<ActionResult> {
} }
} }
export async function mergeLockerStacks( export async function mergeLockerStacks(sourceId: string, targetId: string): Promise<ActionResult> {
sourceId: string,
targetId: string,
): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
const locker = await getLockerWithItems(payload, user.id as number); const locker = await getLockerWithItems(payload, user.id as number);
@ -385,7 +420,8 @@ export async function mergeLockerStacks(
if (srcAsset.id !== tgtAsset.id) { if (srcAsset.id !== tgtAsset.id) {
return { success: false, error: "Cannot merge different asset types." }; return { success: false, error: "Cannot merge different asset types." };
} }
if (!lockerIsStackable(tgtAsset)) return { success: false, error: "This asset cannot be stacked." }; if (!lockerIsStackable(tgtAsset))
return { success: false, error: "This asset cannot be stacked." };
const maxStack = lockerMaxStack(tgtAsset); const maxStack = lockerMaxStack(tgtAsset);
const spaceInTarget = maxStack === Infinity ? Infinity : Math.max(0, maxStack - tgt.quantity); const spaceInTarget = maxStack === Infinity ? Infinity : Math.max(0, maxStack - tgt.quantity);
@ -426,7 +462,10 @@ export async function mergeLockerStacks(
} }
} }
export async function splitLockerStack(entryId: string, splitAmount: number): Promise<ActionResult> { export async function splitLockerStack(
entryId: string,
splitAmount: number,
): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!Number.isInteger(splitAmount) || splitAmount <= 0) { if (!Number.isInteger(splitAmount) || splitAmount <= 0) {
@ -445,7 +484,8 @@ export async function splitLockerStack(entryId: string, splitAmount: number): Pr
} }
const asset = entry.asset as Asset; const asset = entry.asset as Asset;
if (!asset || typeof asset === "number") return { success: false, error: "Invalid asset." }; if (!asset || typeof asset === "number") return { success: false, error: "Invalid asset." };
if (!lockerIsStackable(asset)) return { success: false, error: "This asset cannot be stacked." }; if (!lockerIsStackable(asset))
return { success: false, error: "This asset cannot be stacked." };
const gridItems = toLockerGridItems(items); const gridItems = toLockerGridItems(items);
const fp = getLockerItemFootprint(asset, entry.rotated ?? false); const fp = getLockerItemFootprint(asset, entry.rotated ?? false);
@ -475,7 +515,13 @@ export async function splitLockerStack(entryId: string, splitAmount: number): Pr
actor: user.id, actor: user.id,
targetCollection: "locker-storages", targetCollection: "locker-storages",
targetId: locker.id, targetId: locker.id,
data: { entryId, assetId: asset.id, assetName: asset.name, splitAmount, remainingAmount: entry.quantity - splitAmount }, data: {
entryId,
assetId: asset.id,
assetName: asset.name,
splitAmount,
remainingAmount: entry.quantity - splitAmount,
},
}); });
return { success: true }; return { success: true };
@ -674,7 +720,7 @@ export async function setLoadoutSlot(
}); });
const equippedName = assetId const equippedName = assetId
? (await getAssetOrThrow(payload, assetId).catch(() => null))?.name ?? `asset #${assetId}` ? ((await getAssetOrThrow(payload, assetId).catch(() => null))?.name ?? `asset #${assetId}`)
: null; : null;
await emitGameEvent(payload, { await emitGameEvent(payload, {
@ -778,10 +824,7 @@ export async function equipLoadout(loadoutId: number, equipped = true): Promise<
// --- Equipment customization (attachments & skins) --- // --- Equipment customization (attachments & skins) ---
export async function setItemAttachment( export async function setItemAttachment(entryId: string, assetId: number): Promise<ActionResult> {
entryId: string,
assetId: number,
): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
const locker = await getLockerWithItems(payload, user.id as number); const locker = await getLockerWithItems(payload, user.id as number);
@ -954,7 +997,8 @@ export async function setItemSkin(entryId: string, assetId: number): Promise<Act
const target = items[targetIdx]; const target = items[targetIdx];
const equipment = target.asset as Asset; const equipment = target.asset as Asset;
if (!equipment || typeof equipment === "number") return { success: false, error: "Invalid asset." }; if (!equipment || typeof equipment === "number")
return { success: false, error: "Invalid asset." };
const skin = await getAssetOrThrow(payload, assetId); const skin = await getAssetOrThrow(payload, assetId);
if (!isSkinAsset(skin)) return { success: false, error: `"${skin.name}" is not a skin.` }; if (!isSkinAsset(skin)) return { success: false, error: `"${skin.name}" is not a skin.` };
@ -1056,7 +1100,13 @@ export async function removeItemSkin(entryId: string): Promise<ActionResult> {
const skin = await getAssetOrThrow(payload, skinId); const skin = await getAssetOrThrow(payload, skinId);
const fp = getLockerItemFootprint(skin, false); const fp = getLockerItemFootprint(skin, false);
const spot = findLockerEmptySpot(toLockerGridItems(items), fp.width, fp.height, grid.width, grid.height); const spot = findLockerEmptySpot(
toLockerGridItems(items),
fp.width,
fp.height,
grid.width,
grid.height,
);
if (!spot) return { success: false, error: `No space in locker to return ${skin.name}.` }; if (!spot) return { success: false, error: `No space in locker to return ${skin.name}.` };
const updatedItems = [...items]; const updatedItems = [...items];
@ -1092,10 +1142,7 @@ export async function removeItemSkin(entryId: string): Promise<ActionResult> {
} }
} }
export async function renameLockerItem( export async function renameLockerItem(entryId: string, customName: string): Promise<ActionResult> {
entryId: string,
customName: string,
): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
const locker = await getLockerWithItems(payload, user.id as number); const locker = await getLockerWithItems(payload, user.id as number);
@ -1121,9 +1168,7 @@ export async function renameLockerItem(
await emitGameEvent(payload, { await emitGameEvent(payload, {
type: EventTypes.LockerItemRename, type: EventTypes.LockerItemRename,
message: name message: name ? `Renamed ${asset.name} to "${name}"` : `Cleared custom name on ${asset.name}`,
? `Renamed ${asset.name} to "${name}"`
: `Cleared custom name on ${asset.name}`,
actor: user.id, actor: user.id,
targetCollection: "locker-storages", targetCollection: "locker-storages",
targetId: locker.id, targetId: locker.id,

View file

@ -47,10 +47,7 @@ export default async function AccountPage({ params }: AccountPageProps) {
? typedAccount.ownerUser?.id ? typedAccount.ownerUser?.id
: typedAccount.ownerUser; : typedAccount.ownerUser;
const isOwner = const isOwner = typedAccount.accountType === "personal" && user !== null && ownerId === user.id;
typedAccount.accountType === "personal" &&
user !== null &&
ownerId === user.id;
if (!isManager && !isOwner) { if (!isManager && !isOwner) {
notFound(); notFound();

View file

@ -2,13 +2,7 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import type { import type { GameStructure, GameVehicle, Resource, Structure, Vehicle } from "@/payload-types";
GameVehicle,
Vehicle,
GameStructure,
Structure,
Resource,
} from "@/payload-types";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
FuelIcon, FuelIcon,
@ -38,9 +32,7 @@ const statusColors: Record<string, string> = {
destroyed: "bg-red-500/20 text-red-400", destroyed: "bg-red-500/20 text-red-400",
}; };
export default async function GameVehicleDetailPage({ export default async function GameVehicleDetailPage({ params }: GameVehicleDetailPageProps) {
params,
}: GameVehicleDetailPageProps) {
const { id } = await params; const { id } = await params;
const payload = await getPayload({ config }); const payload = await getPayload({ config });
@ -59,11 +51,9 @@ export default async function GameVehicleDetailPage({
const vehicleData = vehicle as unknown as GameVehicle & { const vehicleData = vehicle as unknown as GameVehicle & {
type: Vehicle; type: Vehicle;
}; };
const vehicleType = const vehicleType = typeof vehicleData.type === "object" ? vehicleData.type : null;
typeof vehicleData.type === "object" ? vehicleData.type : null;
const deployedAt = const deployedAt =
typeof vehicleData.deployedAt === "object" && typeof vehicleData.deployedAt === "object" && vehicleData.deployedAt !== null
vehicleData.deployedAt !== null
? (vehicleData.deployedAt as GameStructure & { type: Structure }) ? (vehicleData.deployedAt as GameStructure & { type: Structure })
: null; : null;
const faction = const faction =
@ -100,8 +90,7 @@ export default async function GameVehicleDetailPage({
> >
{vehicleData.status === "in_transit" {vehicleData.status === "in_transit"
? "In Transit" ? "In Transit"
: vehicleData.status.charAt(0).toUpperCase() + : vehicleData.status.charAt(0).toUpperCase() + vehicleData.status.slice(1)}
vehicleData.status.slice(1)}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{vehicleType?.name ?? "Unknown type"} {vehicleType?.name ?? "Unknown type"}
@ -118,8 +107,7 @@ export default async function GameVehicleDetailPage({
<ItemContent> <ItemContent>
<ItemTitle>Fuel</ItemTitle> <ItemTitle>Fuel</ItemTitle>
<ItemDescription> <ItemDescription>
{vehicleData.currentFuel?.toFixed(1)} /{" "} {vehicleData.currentFuel?.toFixed(1)} / {vehicleType?.fuel?.fuelCapacity ?? "?"}
{vehicleType?.fuel?.fuelCapacity ?? "?"}
</ItemDescription> </ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
@ -129,9 +117,7 @@ export default async function GameVehicleDetailPage({
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>Health</ItemTitle> <ItemTitle>Health</ItemTitle>
<ItemDescription> <ItemDescription>{vehicleData.currentHealth}</ItemDescription>
{vehicleData.currentHealth}
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
<Item variant="muted" size="sm"> <Item variant="muted" size="sm">
@ -140,9 +126,7 @@ export default async function GameVehicleDetailPage({
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>Location</ItemTitle> <ItemTitle>Location</ItemTitle>
<ItemDescription> <ItemDescription>{deployedAt?.name ?? "In transit"}</ItemDescription>
{deployedAt?.name ?? "In transit"}
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
<Item variant="muted" size="sm"> <Item variant="muted" size="sm">
@ -151,9 +135,7 @@ export default async function GameVehicleDetailPage({
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>Faction</ItemTitle> <ItemTitle>Faction</ItemTitle>
<ItemDescription> <ItemDescription>{faction?.name ?? "Unassigned"}</ItemDescription>
{faction?.name ?? "Unassigned"}
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
</div> </div>
@ -180,25 +162,18 @@ export default async function GameVehicleDetailPage({
}} }}
/> />
</div> </div>
<span className="text-sm font-mono font-semibold"> <span className="text-sm font-mono font-semibold">{fuelPercent.toFixed(0)}%</span>
{fuelPercent.toFixed(0)}%
</span>
</div> </div>
<div className="flex justify-between text-xs text-muted-foreground"> <div className="flex justify-between text-xs text-muted-foreground">
<span> <span>{vehicleData.currentFuel?.toFixed(1)} units remaining</span>
{vehicleData.currentFuel?.toFixed(1)} units remaining <span>Capacity: {vehicleType?.fuel?.fuelCapacity ?? "?"} units</span>
</span>
<span>
Capacity: {vehicleType?.fuel?.fuelCapacity ?? "?"} units
</span>
</div> </div>
{vehicleType?.fuel?.fuelType && {vehicleType?.fuel?.fuelType && typeof vehicleType.fuel.fuelType === "object" && (
typeof vehicleType.fuel.fuelType === "object" && ( <div className="text-xs text-muted-foreground">
<div className="text-xs text-muted-foreground"> Fuel type: {vehicleType.fuel.fuelType.name} · Consumption rate:{" "}
Fuel type: {vehicleType.fuel.fuelType.name} · Consumption {vehicleType.fuel.fuelConsumptionRate}/unit
rate: {vehicleType.fuel.fuelConsumptionRate}/unit </div>
</div> )}
)}
</ItemContent> </ItemContent>
</Item> </Item>
@ -227,14 +202,9 @@ export default async function GameVehicleDetailPage({
<tbody> <tbody>
{storedResources.map((entry, index) => { {storedResources.map((entry, index) => {
const resource = const resource =
typeof entry.resource === "object" typeof entry.resource === "object" ? (entry.resource as Resource) : null;
? (entry.resource as Resource)
: null;
return ( return (
<tr <tr key={index} className="border-b border-border/50">
key={index}
className="border-b border-border/50"
>
<td className="px-4 py-2 font-medium"> <td className="px-4 py-2 font-medium">
{resource?.name ?? `Resource #${entry.resource}`} {resource?.name ?? `Resource #${entry.resource}`}
</td> </td>
@ -248,9 +218,7 @@ export default async function GameVehicleDetailPage({
</table> </table>
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground italic"> <p className="text-sm text-muted-foreground italic">Vehicle cargo is empty.</p>
Vehicle cargo is empty.
</p>
)} )}
</ItemContent> </ItemContent>
</Item> </Item>
@ -266,28 +234,25 @@ export default async function GameVehicleDetailPage({
<ItemContent> <ItemContent>
<div className="flex flex-col gap-2 text-sm"> <div className="flex flex-col gap-2 text-sm">
<div> <div>
<p className="font-semibold"> <p className="font-semibold">{vehicleType?.name ?? "Unknown"}</p>
{vehicleType?.name ?? "Unknown"}
</p>
{vehicleType?.classname && ( {vehicleType?.classname && (
<p className="text-xs text-muted-foreground font-mono"> <p className="text-xs text-muted-foreground font-mono">
{vehicleType.classname} {vehicleType.classname}
</p> </p>
)} )}
</div> </div>
{vehicleType?.category && {vehicleType?.category && vehicleType.category.length > 0 && (
vehicleType.category.length > 0 && ( <div className="flex flex-wrap gap-1">
<div className="flex flex-wrap gap-1"> {vehicleType.category.map((cat) => (
{vehicleType.category.map((cat) => ( <span
<span key={cat}
key={cat} className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted"
className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted" >
> {cat}
{cat} </span>
</span> ))}
))} </div>
</div> )}
)}
</div> </div>
</ItemContent> </ItemContent>
</Item> </Item>

View file

@ -52,17 +52,13 @@ export default async function GameVehiclesPage() {
{vehicles.length > 0 ? ( {vehicles.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{vehicles.map((vehicle) => { {vehicles.map((vehicle) => {
const vehicleType = const vehicleType = typeof vehicle.type === "object" ? vehicle.type : null;
typeof vehicle.type === "object" ? vehicle.type : null;
const fuelPercent = vehicleType?.fuel?.fuelCapacity const fuelPercent = vehicleType?.fuel?.fuelCapacity
? (vehicle.currentFuel / vehicleType.fuel.fuelCapacity) * 100 ? (vehicle.currentFuel / vehicleType.fuel.fuelCapacity) * 100
: 0; : 0;
return ( return (
<Link <Link key={vehicle.id} href={`/logistics/game-vehicles/${vehicle.id}`}>
key={vehicle.id}
href={`/logistics/game-vehicles/${vehicle.id}`}
>
<Item <Item
variant="outline" variant="outline"
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01] hover:[box-shadow:inset_0_2px_8px_rgba(255,255,255,0.33)]" className="cursor-pointer group transition-all duration-200 hover:scale-[1.01] hover:[box-shadow:inset_0_2px_8px_rgba(255,255,255,0.33)]"
@ -84,8 +80,7 @@ export default async function GameVehiclesPage() {
> >
{vehicle.status === "in_transit" {vehicle.status === "in_transit"
? "In Transit" ? "In Transit"
: vehicle.status.charAt(0).toUpperCase() + : vehicle.status.charAt(0).toUpperCase() + vehicle.status.slice(1)}
vehicle.status.slice(1)}
</span> </span>
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
@ -94,9 +89,7 @@ export default async function GameVehiclesPage() {
<div className="flex-1 h-1.5 bg-border rounded-full overflow-hidden"> <div className="flex-1 h-1.5 bg-border rounded-full overflow-hidden">
<div <div
className={`h-full rounded-full transition-all ${ className={`h-full rounded-full transition-all ${
fuelPercent > 20 fuelPercent > 20 ? "bg-green-500" : "bg-red-500"
? "bg-green-500"
: "bg-red-500"
}`} }`}
style={{ style={{
width: `${Math.min(100, fuelPercent)}%`, width: `${Math.min(100, fuelPercent)}%`,
@ -112,16 +105,12 @@ export default async function GameVehiclesPage() {
<span>HP: {vehicle.currentHealth}</span> <span>HP: {vehicle.currentHealth}</span>
{vehicleType?.fuel?.fuelType && {vehicleType?.fuel?.fuelType &&
typeof vehicleType.fuel.fuelType === "object" && ( typeof vehicleType.fuel.fuelType === "object" && (
<span> <span>{vehicleType.fuel.fuelType.name}</span>
{vehicleType.fuel.fuelType.name}
</span>
)} )}
</div> </div>
</div> </div>
</ItemContent> </ItemContent>
<ItemDescription> <ItemDescription>{vehicleType?.name ?? "Unknown type"}</ItemDescription>
{vehicleType?.name ?? "Unknown type"}
</ItemDescription>
</Item> </Item>
</Link> </Link>
); );

View file

@ -7,17 +7,22 @@ import hasRoles from "@/utils/access-control/hasRoles";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { import {
buyListing,
countCleanQuantity, countCleanQuantity,
creditLockerQuantity, creditLockerQuantity,
deductLockerQuantity, deductLockerQuantity,
buyListing,
negotiationRange, negotiationRange,
USER_LISTING_DURATION_MS, USER_LISTING_DURATION_MS,
} from "@/lib/market"; } from "@/lib/market";
import { ensureLockerStorage, resolveAssetId } from "@/lib/locker"; import { ensureLockerStorage, resolveAssetId } from "@/lib/locker";
import { getMainCurrencyId } from "@/lib/banking"; import { getMainCurrencyId } from "@/lib/banking";
import { notifyUser } from "@/lib/notifications"; import { notifyUser } from "@/lib/notifications";
import { expireNegotiationsForListing, npcDialogue, npcResponseToOffer, type NpcState } from "@/lib/market/negotiations"; import {
expireNegotiationsForListing,
npcDialogue,
npcResponseToOffer,
type NpcState,
} from "@/lib/market/negotiations";
export interface ActionResult<T = undefined> { export interface ActionResult<T = undefined> {
success: boolean; success: boolean;
@ -86,7 +91,9 @@ async function getListingOrThrow(
function assetNameOf(listing: MarketListing): string { function assetNameOf(listing: MarketListing): string {
const asset = listing.asset as Asset; const asset = listing.asset as Asset;
return typeof asset === "object" && asset ? asset.name : `asset #${resolveAssetId(listing.asset)}`; return typeof asset === "object" && asset
? asset.name
: `asset #${resolveAssetId(listing.asset)}`;
} }
async function getNegotiationOrThrow( async function getNegotiationOrThrow(
@ -94,7 +101,12 @@ async function getNegotiationOrThrow(
negotiationId: number, negotiationId: number,
): Promise<MarketNegotiation> { ): Promise<MarketNegotiation> {
const negotiation = (await payload const negotiation = (await payload
.findByID({ collection: "market-negotiations", id: negotiationId, depth: 2, overrideAccess: true }) .findByID({
collection: "market-negotiations",
id: negotiationId,
depth: 2,
overrideAccess: true,
})
.catch(() => null)) as unknown as MarketNegotiation | null; .catch(() => null)) as unknown as MarketNegotiation | null;
if (!negotiation) throw new Error("Negotiation not found."); if (!negotiation) throw new Error("Negotiation not found.");
return negotiation; return negotiation;
@ -146,10 +158,7 @@ function enforceHigherBuyerOffer(negotiation: MarketNegotiation, amount: number)
} }
/** Role of the acting user in a negotiation: "buyer", "seller", or null. */ /** Role of the acting user in a negotiation: "buyer", "seller", or null. */
function partyRoleOf( function partyRoleOf(negotiation: MarketNegotiation, userId: number): "buyer" | "seller" | null {
negotiation: MarketNegotiation,
userId: number,
): "buyer" | "seller" | null {
if (negotiationBuyerId(negotiation) === userId) return "buyer"; if (negotiationBuyerId(negotiation) === userId) return "buyer";
const listing = negotiationListing(negotiation); const listing = negotiationListing(negotiation);
if (typeof listing === "object" && listing) { if (typeof listing === "object" && listing) {
@ -175,7 +184,7 @@ async function finalizeNegotiation(
const buyerId = buyer.id as number; const buyerId = buyer.id as number;
const sellerId = sellerIdOf(listing); const sellerId = sellerIdOf(listing);
const assetName = assetNameOf(listing); const assetName = assetNameOf(listing);
const quantity = negotiation.quantity ?? (listing.quantity ?? 1); const quantity = negotiation.quantity ?? listing.quantity ?? 1;
const total = agreedPrice * quantity; const total = agreedPrice * quantity;
const result = await buyListing(payload, listing, buyer, { unitPrice: agreedPrice, quantity }); const result = await buyListing(payload, listing, buyer, { unitPrice: agreedPrice, quantity });
@ -319,7 +328,7 @@ async function resolveNpcOffer(
targetId: negotiation.id, targetId: negotiation.id,
data: { data: {
listingId: listing.id, listingId: listing.id,
quantity: negotiation.quantity ?? (listing.quantity ?? 1), quantity: negotiation.quantity ?? listing.quantity ?? 1,
amount, amount,
counter: response.amount, counter: response.amount,
patience: response.patience, patience: response.patience,
@ -343,7 +352,7 @@ async function resolveNpcOffer(
targetId: negotiation.id, targetId: negotiation.id,
data: { data: {
listingId: listing.id, listingId: listing.id,
quantity: negotiation.quantity ?? (listing.quantity ?? 1), quantity: negotiation.quantity ?? listing.quantity ?? 1,
amount, amount,
counter: response.amount, counter: response.amount,
patience: response.patience, patience: response.patience,
@ -472,7 +481,12 @@ export async function cancelMarketListing(listingId: number): Promise<ActionResu
const assetName = const assetName =
typeof asset === "object" && asset ? asset.name : `asset #${resolveAssetId(listing.asset)}`; typeof asset === "object" && asset ? asset.name : `asset #${resolveAssetId(listing.asset)}`;
await creditLockerQuantity(payload, sellerId, resolveAssetId(listing.asset) as number, listing.quantity); await creditLockerQuantity(
payload,
sellerId,
resolveAssetId(listing.asset) as number,
listing.quantity,
);
await payload.update({ await payload.update({
collection: "market-listings", collection: "market-listings",
@ -517,7 +531,7 @@ export async function buyMarketListing(
return { success: false, error: "This listing is no longer active." }; return { success: false, error: "This listing is no longer active." };
} }
const qty = quantity ?? (listing.quantity ?? 1); const qty = quantity ?? listing.quantity ?? 1;
if (!Number.isInteger(qty) || qty <= 0 || qty > (listing.quantity ?? 1)) { if (!Number.isInteger(qty) || qty <= 0 || qty > (listing.quantity ?? 1)) {
return { success: false, error: `Quantity must be between 1 and ${listing.quantity}.` }; return { success: false, error: `Quantity must be between 1 and ${listing.quantity}.` };
} }
@ -580,7 +594,7 @@ export async function makeMarketOffer(
return { success: false, error: "Offer must be greater than zero." }; return { success: false, error: "Offer must be greater than zero." };
} }
const qty = quantity ?? (listing.quantity ?? 1); const qty = quantity ?? listing.quantity ?? 1;
if (!Number.isInteger(qty) || qty <= 0 || qty > (listing.quantity ?? 1)) { if (!Number.isInteger(qty) || qty <= 0 || qty > (listing.quantity ?? 1)) {
return { success: false, error: `Quantity must be between 1 and ${listing.quantity}.` }; return { success: false, error: `Quantity must be between 1 and ${listing.quantity}.` };
} }
@ -642,7 +656,9 @@ export async function makeMarketOffer(
amount, amount,
proposedBy: "buyer", proposedBy: "buyer",
quantity: qty, quantity: qty,
history: [{ amount, from: "buyer", note: "Initial offer", at: new Date().toISOString() }], history: [
{ amount, from: "buyer", note: "Initial offer", at: new Date().toISOString() },
],
}, },
overrideAccess: true, overrideAccess: true,
depth: 1, depth: 1,
@ -728,7 +744,9 @@ export async function makeMarketOffer(
* buyer accepts the seller's counteroffer). Completes the sale at the * buyer accepts the seller's counteroffer). Completes the sale at the
* agreed price. * agreed price.
*/ */
export async function acceptMarketOffer(negotiationId: number): Promise<ActionResult<MarketListing>> { export async function acceptMarketOffer(
negotiationId: number,
): Promise<ActionResult<MarketListing>> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
const userId = user.id as number; const userId = user.id as number;
@ -789,8 +807,12 @@ export async function rejectMarketOffer(negotiationId: number): Promise<ActionRe
} }
const listing = negotiationListing(negotiation); const listing = negotiationListing(negotiation);
const listingId = typeof listing === "object" && listing ? (listing as MarketListing).id : (listing as number); const listingId =
const assetName = typeof listing === "object" && listing ? assetNameOf(listing as MarketListing) : "the listing"; typeof listing === "object" && listing ? (listing as MarketListing).id : (listing as number);
const assetName =
typeof listing === "object" && listing
? assetNameOf(listing as MarketListing)
: "the listing";
await payload.update({ await payload.update({
collection: "market-negotiations", collection: "market-negotiations",
@ -868,9 +890,7 @@ export async function counterMarketOffer(
return { success: false, error: "This listing is no longer active." }; return { success: false, error: "This listing is no longer active." };
} }
const history = [ const history = [...((negotiation.history ?? []) as NonNullable<MarketNegotiation["history"]>)];
...((negotiation.history ?? []) as NonNullable<MarketNegotiation["history"]>),
];
history.push({ amount, from: role, note: "Counteroffer", at: new Date().toISOString() }); history.push({ amount, from: role, note: "Counteroffer", at: new Date().toISOString() });
const updated = (await payload.update({ const updated = (await payload.update({
@ -900,7 +920,8 @@ export async function counterMarketOffer(
return { success: true, data: refreshed }; return { success: true, data: refreshed };
} }
const counterpartyId = role === "buyer" ? sellerId : (negotiationBuyerId(negotiation) as number); const counterpartyId =
role === "buyer" ? sellerId : (negotiationBuyerId(negotiation) as number);
await notifyUser(payload, { await notifyUser(payload, {
userId: counterpartyId, userId: counterpartyId,
type: "market:counter", type: "market:counter",
@ -933,9 +954,14 @@ export async function cancelMarketOffer(negotiationId: number): Promise<ActionRe
if (!role) return { success: false, error: "You are not part of this negotiation." }; if (!role) return { success: false, error: "You are not part of this negotiation." };
const listing = negotiationListing(negotiation); const listing = negotiationListing(negotiation);
const listingId = typeof listing === "object" && listing ? (listing as MarketListing).id : (listing as number); const listingId =
const assetName = typeof listing === "object" && listing ? assetNameOf(listing as MarketListing) : "the listing"; typeof listing === "object" && listing ? (listing as MarketListing).id : (listing as number);
const sellerId = typeof listing === "object" && listing ? sellerIdOf(listing as MarketListing) : null; const assetName =
typeof listing === "object" && listing
? assetNameOf(listing as MarketListing)
: "the listing";
const sellerId =
typeof listing === "object" && listing ? sellerIdOf(listing as MarketListing) : null;
await payload.update({ await payload.update({
collection: "market-negotiations", collection: "market-negotiations",

View file

@ -28,10 +28,7 @@ export default async function MarketPage() {
and: [ and: [
{ status: { equals: "active" } }, { status: { equals: "active" } },
{ {
or: [ or: [{ expiresAt: { exists: false } }, { expiresAt: { greater_than: dayAgo } }],
{ expiresAt: { exists: false } },
{ expiresAt: { greater_than: dayAgo } },
],
}, },
], ],
}, },
@ -50,7 +47,9 @@ export default async function MarketPage() {
const myListings = listings.filter((l) => { const myListings = listings.filter((l) => {
const sellerId = const sellerId =
typeof l.seller === "object" && l.seller ? (l.seller as { id: number }).id : (l.seller as number | null); typeof l.seller === "object" && l.seller
? (l.seller as { id: number }).id
: (l.seller as number | null);
return sellerId === userId; return sellerId === userId;
}); });
@ -62,7 +61,9 @@ export default async function MarketPage() {
}); });
const negotiations = (negotiationsRes.docs as unknown as MarketNegotiation[]).filter((n) => { const negotiations = (negotiationsRes.docs as unknown as MarketNegotiation[]).filter((n) => {
const buyerId = const buyerId =
typeof n.buyer === "object" && n.buyer ? (n.buyer as { id: number }).id : (n.buyer as number | null); typeof n.buyer === "object" && n.buyer
? (n.buyer as { id: number }).id
: (n.buyer as number | null);
if (buyerId === userId) return true; if (buyerId === userId) return true;
const listing = n.listing as unknown as MarketListing | number | null; const listing = n.listing as unknown as MarketListing | number | null;
if (typeof listing === "object" && listing) { if (typeof listing === "object" && listing) {

View file

@ -1,6 +1,6 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { GameStructure, Faction, Structure } from "@/payload-types"; import { Faction, GameStructure, Structure } from "@/payload-types";
import { StorageGrid } from "@/components/frontend/storage/StorageGrid"; import { StorageGrid } from "@/components/frontend/storage/StorageGrid";
import { Building2Icon } from "lucide-react"; import { Building2Icon } from "lucide-react";
@ -40,11 +40,7 @@ export default async function LogisticsPage() {
Overview of all structures and their stored resources across the theater of operations. Overview of all structures and their stored resources across the theater of operations.
</p> </p>
</div> </div>
<StorageGrid <StorageGrid structures={structures} categories={categories} factions={factions} />
structures={structures}
categories={categories}
factions={factions}
/>
</div> </div>
); );
} }

View file

@ -2,7 +2,7 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import type { Resource, Structure, Asset } from "@/payload-types"; import type { Asset, Structure } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react"; import { RichText } from "@payloadcms/richtext-lexical/react";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { import {
@ -12,7 +12,6 @@ import {
CoinsIcon, CoinsIcon,
Grid3x3Icon, Grid3x3Icon,
LayersIcon, LayersIcon,
MoveIcon,
RotateCcwIcon, RotateCcwIcon,
ScaleIcon, ScaleIcon,
TagIcon, TagIcon,
@ -48,9 +47,7 @@ const typeColors: Record<string, string> = {
abstract: "bg-purple-500/20 text-purple-400", abstract: "bg-purple-500/20 text-purple-400",
}; };
export default async function ResourceDetailPage({ export default async function ResourceDetailPage({ params }: ResourceDetailPageProps) {
params,
}: ResourceDetailPageProps) {
const { id } = await params; const { id } = await params;
const payload = await getPayload({ config }); const payload = await getPayload({ config });
@ -105,9 +102,7 @@ export default async function ResourceDetailPage({
const allRelatedStructures = [ const allRelatedStructures = [
...producers, ...producers,
...autoGenStructures.filter( ...autoGenStructures.filter((s) => !producers.some((p) => p.id === s.id)),
(s) => !producers.some((p) => p.id === s.id),
),
]; ];
return ( return (
@ -124,10 +119,7 @@ export default async function ResourceDetailPage({
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{resource.color ? ( {resource.color ? (
<div <div className="size-5 rounded-sm" style={{ backgroundColor: resource.color }} />
className="size-5 rounded-sm"
style={{ backgroundColor: resource.color }}
/>
) : ( ) : (
<BoxIcon className="size-5 text-muted-foreground" /> <BoxIcon className="size-5 text-muted-foreground" />
)} )}
@ -243,10 +235,7 @@ export default async function ResourceDetailPage({
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{allRelatedStructures.map((structure) => ( {allRelatedStructures.map((structure) => (
<Link <Link key={structure.id} href={`/logistics/structures/${structure.id}`}>
key={structure.id}
href={`/logistics/structures/${structure.id}`}
>
<Item <Item
variant="outline" variant="outline"
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01]" className="cursor-pointer group transition-all duration-200 hover:scale-[1.01]"

View file

@ -38,9 +38,7 @@ export default async function ResourcesPage() {
))} ))}
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground italic"> <p className="text-sm text-muted-foreground italic">No resources available.</p>
No resources available.
</p>
)} )}
</div> </div>
); );

View file

@ -2,30 +2,16 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import type { import type { GameStructure, GameVehicle, Vehicle } from "@/payload-types";
Shipment,
GameStructure,
GameVehicle,
Vehicle,
Resource,
} from "@/payload-types";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
ArrowRightIcon, ArrowRightIcon,
ClockIcon, ClockIcon,
FuelIcon, FuelIcon,
MapPinIcon,
PackageIcon, PackageIcon,
TruckIcon, TruckIcon,
} from "lucide-react"; } from "lucide-react";
import { import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { ShipmentActions } from "@/components/frontend/logistics/ShipmentActions"; import { ShipmentActions } from "@/components/frontend/logistics/ShipmentActions";
import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar"; import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar";
@ -44,9 +30,7 @@ const statusColors: Record<string, string> = {
stranded: "bg-red-500/20 text-red-400", stranded: "bg-red-500/20 text-red-400",
}; };
export default async function ShipmentDetailPage({ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageProps) {
params,
}: ShipmentDetailPageProps) {
const { id } = await params; const { id } = await params;
const payload = await getPayload({ config }); const payload = await getPayload({ config });
@ -71,31 +55,20 @@ export default async function ShipmentDetailPage({
? (shipment.destination as GameStructure) ? (shipment.destination as GameStructure)
: null; : null;
const vehicle = const vehicle =
typeof shipment.transportVehicle === "object" && typeof shipment.transportVehicle === "object" && shipment.transportVehicle !== null
shipment.transportVehicle !== null
? (shipment.transportVehicle as GameVehicle & { type: Vehicle }) ? (shipment.transportVehicle as GameVehicle & { type: Vehicle })
: null; : null;
const vehicleType = const vehicleType = vehicle && typeof vehicle.type === "object" ? vehicle.type : null;
vehicle && typeof vehicle.type === "object" ? vehicle.type : null;
const dispatchedAt = shipment.dispatchedAt const dispatchedAt = shipment.dispatchedAt ? new Date(shipment.dispatchedAt) : null;
? new Date(shipment.dispatchedAt) const estimatedArrival = shipment.estimatedArrival ? new Date(shipment.estimatedArrival) : null;
: null; const completedAt = shipment.completedAt ? new Date(shipment.completedAt) : null;
const estimatedArrival = shipment.estimatedArrival
? new Date(shipment.estimatedArrival)
: null;
const completedAt = shipment.completedAt
? new Date(shipment.completedAt)
: null;
const now = new Date(); const now = new Date();
const isActive = const isActive = shipment.status === "dispatched" || shipment.status === "in_transit";
shipment.status === "dispatched" || shipment.status === "in_transit";
const fuelPercent = const fuelPercent =
shipment.fuelCost > 0 shipment.fuelCost > 0 ? ((shipment.fuelConsumed ?? 0) / shipment.fuelCost) * 100 : 0;
? ((shipment.fuelConsumed ?? 0) / shipment.fuelCost) * 100
: 0;
return ( return (
<div className="p-5 flex flex-col gap-6 max-w-7xl"> <div className="p-5 flex flex-col gap-6 max-w-7xl">
@ -111,9 +84,7 @@ export default async function ShipmentDetailPage({
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TruckIcon className="size-5 text-muted-foreground" /> <TruckIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold"> <h1 className="text-lg font-semibold">{shipment.name ?? `SHP-${shipment.id}`}</h1>
{shipment.name ?? `SHP-${shipment.id}`}
</h1>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
@ -121,8 +92,7 @@ export default async function ShipmentDetailPage({
> >
{shipment.status === "in_transit" {shipment.status === "in_transit"
? "In Transit" ? "In Transit"
: shipment.status.charAt(0).toUpperCase() + : shipment.status.charAt(0).toUpperCase() + shipment.status.slice(1)}
shipment.status.slice(1)}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{shipment.distance?.toFixed(1)} units distance {shipment.distance?.toFixed(1)} units distance
@ -143,9 +113,7 @@ export default async function ShipmentDetailPage({
<ItemContent> <ItemContent>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex-1 text-center"> <div className="flex-1 text-center">
<p className="text-sm font-semibold"> <p className="text-sm font-semibold">{origin?.name ?? "Unknown"}</p>
{origin?.name ?? "Unknown"}
</p>
<p className="text-xs text-muted-foreground">Origin</p> <p className="text-xs text-muted-foreground">Origin</p>
</div> </div>
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
@ -155,9 +123,7 @@ export default async function ShipmentDetailPage({
</span> </span>
</div> </div>
<div className="flex-1 text-center"> <div className="flex-1 text-center">
<p className="text-sm font-semibold"> <p className="text-sm font-semibold">{destination?.name ?? "Unknown"}</p>
{destination?.name ?? "Unknown"}
</p>
<p className="text-xs text-muted-foreground">Destination</p> <p className="text-xs text-muted-foreground">Destination</p>
</div> </div>
</div> </div>
@ -220,23 +186,14 @@ export default async function ShipmentDetailPage({
value: number | { name?: string }; value: number | { name?: string };
}; };
itemType = polymorphic.relationTo; itemType = polymorphic.relationTo;
if ( if (typeof polymorphic.value === "object" && polymorphic.value !== null) {
typeof polymorphic.value === "object" && itemName = polymorphic.value.name ?? "Unknown";
polymorphic.value !== null
) {
itemName =
polymorphic.value.name ?? "Unknown";
} }
} }
return ( return (
<tr <tr key={index} className="border-b border-border/50">
key={index} <td className="px-4 py-2 font-medium">{itemName}</td>
className="border-b border-border/50"
>
<td className="px-4 py-2 font-medium">
{itemName}
</td>
<td className="px-4 py-2 text-muted-foreground capitalize"> <td className="px-4 py-2 text-muted-foreground capitalize">
{itemType} {itemType}
</td> </td>
@ -250,9 +207,7 @@ export default async function ShipmentDetailPage({
</table> </table>
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground italic"> <p className="text-sm text-muted-foreground italic">No cargo on this shipment.</p>
No cargo on this shipment.
</p>
)} )}
</ItemContent> </ItemContent>
</Item> </Item>
@ -271,9 +226,7 @@ export default async function ShipmentDetailPage({
<ItemContent> <ItemContent>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div> <div>
<p className="text-sm font-semibold"> <p className="text-sm font-semibold">{vehicle?.name ?? "Unknown"}</p>
{vehicle?.name ?? "Unknown"}
</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{vehicleType?.name ?? "Unknown type"} {vehicleType?.name ?? "Unknown type"}
</p> </p>
@ -283,24 +236,19 @@ export default async function ShipmentDetailPage({
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Fuel</span> <span className="text-muted-foreground">Fuel</span>
<span className="font-mono"> <span className="font-mono">
{vehicle.currentFuel?.toFixed(1)} /{" "} {vehicle.currentFuel?.toFixed(1)} / {vehicleType?.fuel?.fuelCapacity ?? "?"}
{vehicleType?.fuel?.fuelCapacity ?? "?"}
</span> </span>
</div> </div>
<div className="h-2 bg-border rounded-full overflow-hidden"> <div className="h-2 bg-border rounded-full overflow-hidden">
<div <div
className={`h-full rounded-full transition-all ${ className={`h-full rounded-full transition-all ${
vehicle.currentFuel > 0 vehicle.currentFuel > 0 ? "bg-green-500" : "bg-red-500"
? "bg-green-500"
: "bg-red-500"
}`} }`}
style={{ style={{
width: `${Math.min( width: `${Math.min(
100, 100,
vehicleType?.fuel?.fuelCapacity vehicleType?.fuel?.fuelCapacity
? (vehicle.currentFuel / ? (vehicle.currentFuel / vehicleType.fuel.fuelCapacity) * 100
vehicleType.fuel.fuelCapacity) *
100
: 0, : 0,
)}%`, )}%`,
}} }}
@ -308,9 +256,7 @@ export default async function ShipmentDetailPage({
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Health</span> <span className="text-muted-foreground">Health</span>
<span className="font-mono"> <span className="font-mono">{vehicle.currentHealth}</span>
{vehicle.currentHealth}
</span>
</div> </div>
</> </>
)} )}
@ -334,9 +280,7 @@ export default async function ShipmentDetailPage({
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Consumed</span> <span className="text-muted-foreground">Consumed</span>
<span className="font-mono"> <span className="font-mono">{(shipment.fuelConsumed ?? 0).toFixed(1)}</span>
{(shipment.fuelConsumed ?? 0).toFixed(1)}
</span>
</div> </div>
<div className="h-2 bg-border rounded-full overflow-hidden"> <div className="h-2 bg-border rounded-full overflow-hidden">
<div <div
@ -375,9 +319,7 @@ export default async function ShipmentDetailPage({
{estimatedArrival && ( {estimatedArrival && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{shipment.status === "completed" {shipment.status === "completed" ? "Arrived" : "Est. Arrival"}
? "Arrived"
: "Est. Arrival"}
</span> </span>
<span> <span>
{estimatedArrival.toLocaleDateString()}{" "} {estimatedArrival.toLocaleDateString()}{" "}
@ -411,9 +353,7 @@ export default async function ShipmentDetailPage({
<ItemTitle>Failure Reason</ItemTitle> <ItemTitle>Failure Reason</ItemTitle>
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<p className="text-sm text-red-400"> <p className="text-sm text-red-400">{shipment.failureReason}</p>
{shipment.failureReason}
</p>
</ItemContent> </ItemContent>
</Item> </Item>
)} )}

View file

@ -2,27 +2,13 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { import type { GameStructure, GameVehicle, Resource, Structure, Vehicle } from "@/payload-types";
GameStructure,
GameVehicle,
Structure,
Vehicle,
Resource,
} from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles"; import hasRoles from "@/utils/access-control/hasRoles";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { calculateDistance } from "@/lib/distance"; import { calculateDistance } from "@/lib/distance";
import { import { calculateFuelCost, calculateTransitTime, getVehicleEffectiveSpeed } from "@/lib/shipping";
calculateFuelCost, import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
calculateTransitTime,
getVehicleEffectiveSpeed,
} from "@/lib/shipping";
import { processShipmentTick } from "@/scripts/processShipmentTick";
import {
checkStorageDeposit,
storageViolationMessage,
} from "@/lib/storageRules";
interface ActionResult { interface ActionResult {
success: boolean; success: boolean;
@ -105,8 +91,7 @@ export async function createShipment(params: {
if (!originCoords || !destCoords) { if (!originCoords || !destCoords) {
return { return {
success: false, success: false,
error: error: "Both origin and destination must have coordinates set.",
"Both origin and destination must have coordinates set.",
}; };
} }
@ -146,9 +131,7 @@ export async function createShipment(params: {
} }
const vehicleAtId = const vehicleAtId =
typeof vehicle.deployedAt === "object" typeof vehicle.deployedAt === "object" ? vehicle.deployedAt?.id : vehicle.deployedAt;
? vehicle.deployedAt?.id
: vehicle.deployedAt;
if (vehicleAtId !== originId) { if (vehicleAtId !== originId) {
return { return {
@ -158,8 +141,7 @@ export async function createShipment(params: {
} }
// Get vehicle type details // Get vehicle type details
const vehicleType = const vehicleType = typeof vehicle.type === "object" ? vehicle.type : null;
typeof vehicle.type === "object" ? vehicle.type : null;
if (!vehicleType) { if (!vehicleType) {
return { success: false, error: "Vehicle type not found." }; return { success: false, error: "Vehicle type not found." };
} }
@ -182,8 +164,7 @@ export async function createShipment(params: {
if (cargoItem.relationTo === "resources") { if (cargoItem.relationTo === "resources") {
const available = storedResources const available = storedResources
.filter((r) => { .filter((r) => {
const resId = const resId = typeof r.resource === "object" ? r.resource.id : r.resource;
typeof r.resource === "object" ? r.resource.id : r.resource;
return resId === cargoItem.value; return resId === cargoItem.value;
}) })
.reduce((sum, r) => sum + r.amount, 0); .reduce((sum, r) => sum + r.amount, 0);
@ -191,18 +172,12 @@ export async function createShipment(params: {
if (available < cargoItem.amount) { if (available < cargoItem.amount) {
const resName = const resName =
typeof storedResources.find((r) => { typeof storedResources.find((r) => {
const resId = const resId = typeof r.resource === "object" ? r.resource.id : r.resource;
typeof r.resource === "object"
? r.resource.id
: r.resource;
return resId === cargoItem.value; return resId === cargoItem.value;
})?.resource === "object" })?.resource === "object"
? ( ? (
storedResources.find((r) => { storedResources.find((r) => {
const resId = const resId = typeof r.resource === "object" ? r.resource.id : r.resource;
typeof r.resource === "object"
? r.resource.id
: r.resource;
return resId === cargoItem.value; return resId === cargoItem.value;
})?.resource as Resource })?.resource as Resource
).name ).name
@ -217,8 +192,7 @@ export async function createShipment(params: {
} }
// Validate destination can accept the cargo (storage rules + mass capacity) // Validate destination can accept the cargo (storage rules + mass capacity)
const destType = const destType = typeof destination.type === "object" ? destination.type : null;
typeof destination.type === "object" ? destination.type : null;
if (destType) { if (destType) {
const destStored = destination.storedResources ?? []; const destStored = destination.storedResources ?? [];
const destVoid = (destination as GameStructure).voidStorage ?? []; const destVoid = (destination as GameStructure).voidStorage ?? [];
@ -233,8 +207,7 @@ export async function createShipment(params: {
itemId: number, itemId: number,
) => ) =>
entries.reduce((sum, r) => { entries.reduce((sum, r) => {
const resId = const resId = typeof r.resource === "object" ? r.resource.id : r.resource;
typeof r.resource === "object" ? r.resource.id : r.resource;
return sum + (resId === itemId ? r.amount : 0); return sum + (resId === itemId ? r.amount : 0);
}, 0); }, 0);
@ -257,8 +230,7 @@ export async function createShipment(params: {
} }
const currentStored = const currentStored =
storedAmountIn(destStored, cargoItem.value) + storedAmountIn(destStored, cargoItem.value) + storedAmountIn(destVoid, cargoItem.value);
storedAmountIn(destVoid, cargoItem.value);
const ruleCheck = checkStorageDeposit( const ruleCheck = checkStorageDeposit(
destType, destType,
@ -278,10 +250,7 @@ export async function createShipment(params: {
} }
destMass += (resource.massPerUnit ?? 0) * cargoItem.amount; destMass += (resource.massPerUnit ?? 0) * cargoItem.amount;
if ( if (destType.maxCapacityMass && destMass > destType.maxCapacityMass) {
destType.maxCapacityMass &&
destMass > destType.maxCapacityMass
) {
return { return {
success: false, success: false,
error: `Destination "${destination.name}" would exceed its mass capacity of ${destType.maxCapacityMass.toLocaleString()} kg.`, error: `Destination "${destination.name}" would exceed its mass capacity of ${destType.maxCapacityMass.toLocaleString()} kg.`,
@ -327,8 +296,7 @@ export async function createShipment(params: {
let remaining = cargoItem.amount; let remaining = cargoItem.amount;
updatedResources = updatedResources updatedResources = updatedResources
.map((r) => { .map((r) => {
const resId = const resId = typeof r.resource === "object" ? r.resource.id : r.resource;
typeof r.resource === "object" ? r.resource.id : r.resource;
if (resId === cargoItem.value && remaining > 0) { if (resId === cargoItem.value && remaining > 0) {
const deduct = Math.min(r.amount, remaining); const deduct = Math.min(r.amount, remaining);
remaining -= deduct; remaining -= deduct;
@ -377,9 +345,7 @@ export async function createShipment(params: {
fuelConsumed: 0, fuelConsumed: 0,
status: "dispatched", status: "dispatched",
dispatchedAt: now.toISOString(), dispatchedAt: now.toISOString(),
estimatedArrival: new Date( estimatedArrival: new Date(now.getTime() + transitTimeMs).toISOString(),
now.getTime() + transitTimeMs,
).toISOString(),
autoReturn: false, autoReturn: false,
createdBy: user.id, createdBy: user.id,
}, },
@ -402,8 +368,7 @@ export async function createShipment(params: {
return { success: true, data: shipment }; return { success: true, data: shipment };
} catch (error) { } catch (error) {
const message = const message = error instanceof Error ? error.message : "Unknown error";
error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") { if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." }; return { success: false, error: "You must be logged in." };
} }
@ -411,9 +376,7 @@ export async function createShipment(params: {
} }
} }
export async function cancelShipment( export async function cancelShipment(shipmentId: number): Promise<ActionResult> {
shipmentId: number,
): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
@ -433,10 +396,7 @@ export async function cancelShipment(
return { success: false, error: "Shipment not found." }; return { success: false, error: "Shipment not found." };
} }
if ( if (shipment.status !== "dispatched" && shipment.status !== "in_transit") {
shipment.status !== "dispatched" &&
shipment.status !== "in_transit"
) {
return { return {
success: false, success: false,
error: `Cannot cancel shipment with status "${shipment.status}".`, error: `Cannot cancel shipment with status "${shipment.status}".`,
@ -444,9 +404,7 @@ export async function cancelShipment(
} }
const originId = const originId =
typeof shipment.origin === "object" typeof shipment.origin === "object" ? shipment.origin.id : (shipment.origin as number);
? shipment.origin.id
: (shipment.origin as number);
const vehicleId = const vehicleId =
typeof shipment.transportVehicle === "object" typeof shipment.transportVehicle === "object"
? shipment.transportVehicle.id ? shipment.transportVehicle.id
@ -464,9 +422,7 @@ export async function cancelShipment(
if (polymorphic.relationTo !== "resources") continue; if (polymorphic.relationTo !== "resources") continue;
const itemId = const itemId =
typeof polymorphic.value === "object" typeof polymorphic.value === "object" ? polymorphic.value.id : polymorphic.value;
? polymorphic.value.id
: polymorphic.value;
const origin = await payload.findByID({ const origin = await payload.findByID({
collection: "game-structures", collection: "game-structures",
@ -476,8 +432,7 @@ export async function cancelShipment(
const existingResources = origin.storedResources ?? []; const existingResources = origin.storedResources ?? [];
const existingEntry = existingResources.find((r) => { const existingEntry = existingResources.find((r) => {
const resId = const resId = typeof r.resource === "object" ? r.resource.id : r.resource;
typeof r.resource === "object" ? r.resource.id : r.resource;
return resId === itemId; return resId === itemId;
}); });
@ -531,8 +486,7 @@ export async function cancelShipment(
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
const message = const message = error instanceof Error ? error.message : "Unknown error";
error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") { if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." }; return { success: false, error: "You must be logged in." };
} }
@ -574,8 +528,7 @@ export async function toggleAutoReturn(
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
const message = const message = error instanceof Error ? error.message : "Unknown error";
error instanceof Error ? error.message : "Unknown error";
return { success: false, error: message }; return { success: false, error: message };
} }
} }

View file

@ -1,6 +1,6 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { Shipment, GameStructure, GameVehicle, Vehicle } from "@/payload-types"; import type { Shipment } from "@/payload-types";
import { TruckIcon } from "lucide-react"; import { TruckIcon } from "lucide-react";
import { ShipmentsList } from "@/components/frontend/logistics/ShipmentsList"; import { ShipmentsList } from "@/components/frontend/logistics/ShipmentsList";

View file

@ -3,7 +3,7 @@ import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import Link from "next/link"; import Link from "next/link";
import { GameStructure, Structure, Faction, Resource } from "@/payload-types"; import { Faction, GameStructure, Resource, Structure } from "@/payload-types";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { CapacityBar } from "@/components/frontend/storage/CapacityBar"; import { CapacityBar } from "@/components/frontend/storage/CapacityBar";
import { StorageRules } from "@/components/frontend/storage/StorageRules"; import { StorageRules } from "@/components/frontend/storage/StorageRules";
@ -45,11 +45,13 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
const headers = await nextHeaders(); const headers = await nextHeaders();
const { user } = await payload.auth({ headers, canSetHeaders: false }); const { user } = await payload.auth({ headers, canSetHeaders: false });
const structureRes = await payload.findByID({ const structureRes = await payload
collection: "game-structures", .findByID({
id: Number(id), collection: "game-structures",
depth: 2, id: Number(id),
}).catch(() => null); depth: 2,
})
.catch(() => null);
if (!structureRes) { if (!structureRes) {
notFound(); notFound();
@ -116,9 +118,7 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
</div> </div>
</div> </div>
{description && ( {description && <p className="text-sm text-muted-foreground">{description}</p>}
<p className="text-sm text-muted-foreground">{description}</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Item variant="outline"> <Item variant="outline">
@ -187,7 +187,9 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>Max Capacity</ItemTitle> <ItemTitle>Max Capacity</ItemTitle>
<ItemDescription>{structureType.maxCapacityMass.toLocaleString()} kg (mass)</ItemDescription> <ItemDescription>
{structureType.maxCapacityMass.toLocaleString()} kg (mass)
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
)} )}
@ -204,9 +206,7 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
)} )}
</div> </div>
<StructureStorageView <StructureStorageView structure={structure} />
structure={structure}
/>
<StorageRules <StorageRules
allowedStorage={structureType.allowedStorage} allowedStorage={structureType.allowedStorage}
@ -229,8 +229,12 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
const resource = upkeep.resource as Resource; const resource = upkeep.resource as Resource;
if (!resource || typeof resource === "number") return null; if (!resource || typeof resource === "number") return null;
return ( return (
<span key={upkeep.id ?? index} className="text-sm bg-muted px-2.5 py-1 rounded-md"> <span
{resource.name}: {upkeep.amount?.toLocaleString()} {pluralize(upkeep.amount ?? 0, resource.unitOfMeasure)}/tick key={upkeep.id ?? index}
className="text-sm bg-muted px-2.5 py-1 rounded-md"
>
{resource.name}: {upkeep.amount?.toLocaleString()}{" "}
{pluralize(upkeep.amount ?? 0, resource.unitOfMeasure)}/tick
</span> </span>
); );
})} })}

View file

@ -6,10 +6,7 @@ import { GameStructure, Resource, Structure } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles"; import hasRoles from "@/utils/access-control/hasRoles";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
checkStorageDeposit,
storageViolationMessage,
} from "@/lib/storageRules";
interface ActionResult { interface ActionResult {
success: boolean; success: boolean;
@ -32,11 +29,13 @@ async function authenticate() {
} }
async function getStructure(payload: Awaited<ReturnType<typeof getPayload>>, id: number) { async function getStructure(payload: Awaited<ReturnType<typeof getPayload>>, id: number) {
const structure = await payload.findByID({ const structure = await payload
collection: "game-structures", .findByID({
id, collection: "game-structures",
depth: 2, id,
}).catch(() => null); depth: 2,
})
.catch(() => null);
if (!structure) { if (!structure) {
throw new Error("Structure not found"); throw new Error("Structure not found");
@ -45,7 +44,10 @@ async function getStructure(payload: Awaited<ReturnType<typeof getPayload>>, id:
return structure as unknown as GameStructure & { type: Structure }; return structure as unknown as GameStructure & { type: Structure };
} }
function getStoredAmount(storedResources: GameStructure["storedResources"], resourceId: number): number { function getStoredAmount(
storedResources: GameStructure["storedResources"],
resourceId: number,
): number {
if (!storedResources) return 0; if (!storedResources) return 0;
const entry = storedResources.find((e) => { const entry = storedResources.find((e) => {
const res = e.resource; const res = e.resource;
@ -73,7 +75,10 @@ export async function addResource(
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user)) { if (!hasRoles(["admin"], user)) {
return { success: false, error: "Insufficient permissions. Admin or Developer role required for deposit." }; return {
success: false,
error: "Insufficient permissions. Admin or Developer role required for deposit.",
};
} }
if (amount <= 0) { if (amount <= 0) {
@ -84,11 +89,13 @@ export async function addResource(
const structureType = structure.type; const structureType = structure.type;
// Fetch the resource to check rules + mass // Fetch the resource to check rules + mass
const resource = await payload.findByID({ const resource = await payload
collection: "resources", .findByID({
id: resourceId, collection: "resources",
depth: 0, id: resourceId,
}).catch(() => null); depth: 0,
})
.catch(() => null);
if (!resource) { if (!resource) {
return { success: false, error: "Resource not found." }; return { success: false, error: "Resource not found." };
@ -99,12 +106,7 @@ export async function addResource(
getStoredAmount(structure.storedResources, resourceId) + getStoredAmount(structure.storedResources, resourceId) +
getStoredAmount(structure.voidStorage ?? [], resourceId); getStoredAmount(structure.voidStorage ?? [], resourceId);
const ruleCheck = checkStorageDeposit( const ruleCheck = checkStorageDeposit(structureType, resourceId, amount, currentStored);
structureType,
resourceId,
amount,
currentStored,
);
if (!ruleCheck.allowed) { if (!ruleCheck.allowed) {
return { return {
@ -136,9 +138,7 @@ export async function addResource(
const resH = resource.gridHeight ?? 1; const resH = resource.gridHeight ?? 1;
const ew = resW; const ew = resW;
const eh = resH; const eh = resH;
const maxStack = resource.stackable === false const maxStack = resource.stackable === false ? 1 : (resource.maxStackSize ?? Infinity);
? 1
: resource.maxStackSize ?? Infinity;
// 1. Fill existing stacks of the same resource that have space // 1. Fill existing stacks of the same resource that have space
let remaining = amount; let remaining = amount;
@ -151,9 +151,8 @@ export async function addResource(
for (const entry of sameType) { for (const entry of sameType) {
if (remaining <= 0) break; if (remaining <= 0) break;
const space = maxStack === Infinity const space =
? remaining maxStack === Infinity ? remaining : Math.min(maxStack - entry.amount, remaining);
: Math.min(maxStack - entry.amount, remaining);
if (space <= 0) continue; if (space <= 0) continue;
const idx = existing.findIndex((e) => e.id === entry.id); const idx = existing.findIndex((e) => e.id === entry.id);
if (idx >= 0) { if (idx >= 0) {
@ -204,7 +203,13 @@ export async function addResource(
} }
const stackAmount = Math.min(remaining, maxStack === Infinity ? remaining : maxStack); const stackAmount = Math.min(remaining, maxStack === Infinity ? remaining : maxStack);
existing.push({ resource: resourceId, amount: stackAmount, gridX: spot.x, gridY: spot.y, rotated: false }); existing.push({
resource: resourceId,
amount: stackAmount,
gridX: spot.x,
gridY: spot.y,
rotated: false,
});
remaining -= stackAmount; remaining -= stackAmount;
} }
} else { } else {
@ -260,7 +265,10 @@ export async function removeResource(
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user)) { if (!hasRoles(["admin"], user)) {
return { success: false, error: "Insufficient permissions. Admin or Developer role required for withdrawal." }; return {
success: false,
error: "Insufficient permissions. Admin or Developer role required for withdrawal.",
};
} }
if (amount <= 0) { if (amount <= 0) {
@ -331,7 +339,12 @@ export async function removeResource(
data: { storedResources, voidStorage }, data: { storedResources, voidStorage },
}); });
const resName = (await payload.findByID({ collection: "resources", id: resourceId, depth: 0 }).catch(() => null))?.name ?? `resource #${resourceId}`; const resName =
(
await payload
.findByID({ collection: "resources", id: resourceId, depth: 0 })
.catch(() => null)
)?.name ?? `resource #${resourceId}`;
await emitGameEvent(payload, { await emitGameEvent(payload, {
type: EventTypes.StorageWithdraw, type: EventTypes.StorageWithdraw,
message: `Withdrew ${amount.toLocaleString()}× ${resName} from "${structure.name}"`, message: `Withdrew ${amount.toLocaleString()}× ${resName} from "${structure.name}"`,
@ -361,7 +374,10 @@ export async function transferResource(
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) {
return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required for transfer." }; return {
success: false,
error: "Insufficient permissions. User, Admin, or Developer role required for transfer.",
};
} }
if (amount <= 0) { if (amount <= 0) {
@ -405,11 +421,13 @@ export async function transferResource(
} }
// Fetch the resource for rule + capacity checks // Fetch the resource for rule + capacity checks
const resource = await payload.findByID({ const resource = await payload
collection: "resources", .findByID({
id: resourceId, collection: "resources",
depth: 0, id: resourceId,
}).catch(() => null); depth: 0,
})
.catch(() => null);
if (!resource) { if (!resource) {
return { success: false, error: "Resource not found." }; return { success: false, error: "Resource not found." };
@ -468,7 +486,15 @@ export async function transferResource(
structure: toStructureId, structure: toStructureId,
targetCollection: "game-structures", targetCollection: "game-structures",
targetId: toStructureId, targetId: toStructureId,
data: { resourceId, amount, resourceName: resName, fromStructureId, toStructureId, fromStructureName: fromStructure.name, toStructureName: toStructure.name }, data: {
resourceId,
amount,
resourceName: resName,
fromStructureId,
toStructureId,
fromStructureName: fromStructure.name,
toStructureName: toStructure.name,
},
}); });
return { success: true, data: addResult.data }; return { success: true, data: addResult.data };
@ -499,7 +525,10 @@ export async function placeResourceOnGrid(
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user)) { if (!hasRoles(["admin"], user)) {
return { success: false, error: "Insufficient permissions. Admin or Developer role required for placement." }; return {
success: false,
error: "Insufficient permissions. Admin or Developer role required for placement.",
};
} }
if (amount <= 0) return { success: false, error: "Amount must be greater than zero." }; if (amount <= 0) return { success: false, error: "Amount must be greater than zero." };
@ -507,7 +536,9 @@ export async function placeResourceOnGrid(
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
const structureType = structure.type; const structureType = structure.type;
const resource = await payload.findByID({ collection: "resources", id: resourceId, depth: 0 }).catch(() => null); const resource = await payload
.findByID({ collection: "resources", id: resourceId, depth: 0 })
.catch(() => null);
if (!resource) return { success: false, error: "Resource not found." }; if (!resource) return { success: false, error: "Resource not found." };
// Check storage rules (prohibited → whitelist → per-item cap) // Check storage rules (prohibited → whitelist → per-item cap)
@ -515,12 +546,7 @@ export async function placeResourceOnGrid(
getStoredAmount(structure.storedResources, resourceId) + getStoredAmount(structure.storedResources, resourceId) +
getStoredAmount(structure.voidStorage ?? [], resourceId); getStoredAmount(structure.voidStorage ?? [], resourceId);
const ruleCheck = checkStorageDeposit( const ruleCheck = checkStorageDeposit(structureType, resourceId, amount, currentStored);
structureType,
resourceId,
amount,
currentStored,
);
if (!ruleCheck.allowed) { if (!ruleCheck.allowed) {
return { success: false, error: storageViolationMessage(resource.name, ruleCheck) }; return { success: false, error: storageViolationMessage(resource.name, ruleCheck) };
@ -534,7 +560,10 @@ export async function placeResourceOnGrid(
const addedMass = (resource.massPerUnit ?? 0) * amount; const addedMass = (resource.massPerUnit ?? 0) * amount;
const maxMass = structureType.maxCapacityMass; const maxMass = structureType.maxCapacityMass;
if (maxMass && currentMass + addedMass > maxMass) { if (maxMass && currentMass + addedMass > maxMass) {
return { success: false, error: `Exceeds mass capacity. Available: ${(maxMass - currentMass).toLocaleString()} kg.` }; return {
success: false,
error: `Exceeds mass capacity. Available: ${(maxMass - currentMass).toLocaleString()} kg.`,
};
} }
const gridW = structureType.xSize ?? 0; const gridW = structureType.xSize ?? 0;
@ -543,8 +572,8 @@ export async function placeResourceOnGrid(
return { success: false, error: "This structure does not have grid dimensions configured." }; return { success: false, error: "This structure does not have grid dimensions configured." };
} }
const resW = (resource.gridWidth ?? 1); const resW = resource.gridWidth ?? 1;
const resH = (resource.gridHeight ?? 1); const resH = resource.gridHeight ?? 1;
const effectiveW = rotated ? resH : resW; const effectiveW = rotated ? resH : resW;
const effectiveH = rotated ? resW : resH; const effectiveH = rotated ? resW : resH;
@ -578,7 +607,11 @@ export async function placeResourceOnGrid(
existing.push({ resource: resourceId, amount, gridX, gridY, rotated }); existing.push({ resource: resourceId, amount, gridX, gridY, rotated });
await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: existing } }); await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources: existing },
});
await emitGameEvent(payload, { await emitGameEvent(payload, {
type: EventTypes.GridPlace, type: EventTypes.GridPlace,
@ -609,7 +642,10 @@ export async function moveGridItem(
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) {
return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required." }; return {
success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.",
};
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
const existing = [...(structure.storedResources ?? [])]; const existing = [...(structure.storedResources ?? [])];
@ -653,10 +689,13 @@ export async function moveGridItem(
} }
} }
existing[idx] = rotated !== undefined existing[idx] =
? { ...entry, gridX, gridY, rotated } rotated !== undefined ? { ...entry, gridX, gridY, rotated } : { ...entry, gridX, gridY };
: { ...entry, gridX, gridY }; await payload.update({
await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: existing } }); collection: "game-structures",
id: structureId,
data: { storedResources: existing },
});
await emitGameEvent(payload, { await emitGameEvent(payload, {
type: EventTypes.GridMove, type: EventTypes.GridMove,
@ -665,7 +704,14 @@ export async function moveGridItem(
structure: structureId, structure: structureId,
targetCollection: "game-structures", targetCollection: "game-structures",
targetId: structureId, targetId: structureId,
data: { entryId, resourceId: (typeof res === "object" ? res.id : res), resourceName: res.name, gridX, gridY, rotated }, data: {
entryId,
resourceId: typeof res === "object" ? res.id : res,
resourceName: res.name,
gridX,
gridY,
rotated,
},
}); });
return { success: true }; return { success: true };
@ -685,7 +731,10 @@ export async function mergeGridStacks(
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) {
return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required." }; return {
success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.",
};
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
const existing = [...(structure.storedResources ?? [])]; const existing = [...(structure.storedResources ?? [])];
@ -705,9 +754,11 @@ export async function mergeGridStacks(
const srcResId = typeof srcRes === "object" ? srcRes.id : srcRes; const srcResId = typeof srcRes === "object" ? srcRes.id : srcRes;
const tgtResId = typeof tgtRes === "object" ? tgtRes.id : tgtRes; const tgtResId = typeof tgtRes === "object" ? tgtRes.id : tgtRes;
if (srcResId !== tgtResId) return { success: false, error: "Cannot merge different resource types." }; if (srcResId !== tgtResId)
return { success: false, error: "Cannot merge different resource types." };
if (tgtRes.stackable === false) return { success: false, error: "This resource cannot be stacked." }; if (tgtRes.stackable === false)
return { success: false, error: "This resource cannot be stacked." };
const maxStack = tgtRes.maxStackSize; const maxStack = tgtRes.maxStackSize;
const spaceInTarget = maxStack ? maxStack - tgt.amount : Infinity; const spaceInTarget = maxStack ? maxStack - tgt.amount : Infinity;
@ -723,7 +774,11 @@ export async function mergeGridStacks(
existing[srcIdx] = { ...src, amount: src.amount - transferAmount }; existing[srcIdx] = { ...src, amount: src.amount - transferAmount };
} }
await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: existing } }); await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources: existing },
});
const mergeResName = typeof srcRes === "object" ? srcRes.name : `resource #${srcResId}`; const mergeResName = typeof srcRes === "object" ? srcRes.name : `resource #${srcResId}`;
await emitGameEvent(payload, { await emitGameEvent(payload, {
@ -733,7 +788,13 @@ export async function mergeGridStacks(
structure: structureId, structure: structureId,
targetCollection: "game-structures", targetCollection: "game-structures",
targetId: structureId, targetId: structureId,
data: { sourceId, targetId, resourceId: srcResId, resourceName: mergeResName, transferAmount }, data: {
sourceId,
targetId,
resourceId: srcResId,
resourceName: mergeResName,
transferAmount,
},
}); });
return { success: true }; return { success: true };
@ -752,7 +813,10 @@ export async function rotateGridItem(
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) {
return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required." }; return {
success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.",
};
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
const existing = [...(structure.storedResources ?? [])]; const existing = [...(structure.storedResources ?? [])];
@ -762,7 +826,8 @@ export async function rotateGridItem(
const entry = existing[idx]; const entry = existing[idx];
const res = entry.resource as Resource; const res = entry.resource as Resource;
if (!res || typeof res === "number") return { success: false, error: "Invalid resource." }; if (!res || typeof res === "number") return { success: false, error: "Invalid resource." };
if (res.rotatable === false) return { success: false, error: "This resource cannot be rotated." }; if (res.rotatable === false)
return { success: false, error: "This resource cannot be rotated." };
const newRotated = !(entry.rotated ?? false); const newRotated = !(entry.rotated ?? false);
const ew = newRotated ? (res.gridHeight ?? 1) : (res.gridWidth ?? 1); const ew = newRotated ? (res.gridHeight ?? 1) : (res.gridWidth ?? 1);
@ -798,7 +863,11 @@ export async function rotateGridItem(
} }
existing[idx] = { ...entry, rotated: newRotated }; existing[idx] = { ...entry, rotated: newRotated };
await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: existing } }); await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources: existing },
});
await emitGameEvent(payload, { await emitGameEvent(payload, {
type: EventTypes.GridRotate, type: EventTypes.GridRotate,
@ -807,7 +876,12 @@ export async function rotateGridItem(
structure: structureId, structure: structureId,
targetCollection: "game-structures", targetCollection: "game-structures",
targetId: structureId, targetId: structureId,
data: { entryId, resourceId: (typeof res === "object" ? res.id : res), resourceName: res.name, rotated: newRotated }, data: {
entryId,
resourceId: typeof res === "object" ? res.id : res,
resourceName: res.name,
rotated: newRotated,
},
}); });
return { success: true }; return { success: true };
@ -826,7 +900,10 @@ export async function removeGridItem(
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user)) { if (!hasRoles(["admin"], user)) {
return { success: false, error: "Insufficient permissions. Admin or Developer role required for removal." }; return {
success: false,
error: "Insufficient permissions. Admin or Developer role required for removal.",
};
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
const existing = [...(structure.storedResources ?? [])]; const existing = [...(structure.storedResources ?? [])];
@ -836,9 +913,15 @@ export async function removeGridItem(
const removedEntry = existing[idx]; const removedEntry = existing[idx];
const removedRes = removedEntry.resource as Resource; const removedRes = removedEntry.resource as Resource;
existing.splice(idx, 1); existing.splice(idx, 1);
await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: existing } }); await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources: existing },
});
const removedName = removedRes?.name ?? `resource #${typeof removedRes === "object" ? removedRes.id : removedRes}`; const removedName =
removedRes?.name ??
`resource #${typeof removedRes === "object" ? removedRes.id : removedRes}`;
await emitGameEvent(payload, { await emitGameEvent(payload, {
type: EventTypes.StorageWithdraw, type: EventTypes.StorageWithdraw,
message: `Removed ${removedEntry.amount}× ${removedName} from "${structure.name}"`, message: `Removed ${removedEntry.amount}× ${removedName} from "${structure.name}"`,
@ -846,7 +929,12 @@ export async function removeGridItem(
structure: structureId, structure: structureId,
targetCollection: "game-structures", targetCollection: "game-structures",
targetId: structureId, targetId: structureId,
data: { entryId, resourceId: typeof removedRes === "object" ? removedRes.id : removedRes, resourceName: removedName, amount: removedEntry.amount }, data: {
entryId,
resourceId: typeof removedRes === "object" ? removedRes.id : removedRes,
resourceName: removedName,
amount: removedEntry.amount,
},
}); });
return { success: true }; return { success: true };
@ -866,7 +954,10 @@ export async function splitGridStack(
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) {
return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required." }; return {
success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.",
};
} }
if (splitAmount <= 0) return { success: false, error: "Split amount must be positive." }; if (splitAmount <= 0) return { success: false, error: "Split amount must be positive." };
@ -877,7 +968,8 @@ export async function splitGridStack(
if (idx < 0) return { success: false, error: "Item not found in grid." }; if (idx < 0) return { success: false, error: "Item not found in grid." };
const entry = existing[idx]; const entry = existing[idx];
if (splitAmount >= entry.amount) return { success: false, error: "Split amount must be less than the stack size." }; if (splitAmount >= entry.amount)
return { success: false, error: "Split amount must be less than the stack size." };
const res = entry.resource as Resource; const res = entry.resource as Resource;
if (!res || typeof res === "number") return { success: false, error: "Invalid resource." }; if (!res || typeof res === "number") return { success: false, error: "Invalid resource." };
@ -934,7 +1026,11 @@ export async function splitGridStack(
if (!placed) return { success: false, error: "No empty spot available for the split stack." }; if (!placed) return { success: false, error: "No empty spot available for the split stack." };
await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: existing } }); await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources: existing },
});
const splitResName = typeof res === "object" ? res.name : `resource #${res}`; const splitResName = typeof res === "object" ? res.name : `resource #${res}`;
await emitGameEvent(payload, { await emitGameEvent(payload, {
@ -944,7 +1040,13 @@ export async function splitGridStack(
structure: structureId, structure: structureId,
targetCollection: "game-structures", targetCollection: "game-structures",
targetId: structureId, targetId: structureId,
data: { entryId, resourceId: typeof res === "object" ? res.id : res, resourceName: splitResName, splitAmount, remainingAmount: entry.amount - splitAmount }, data: {
entryId,
resourceId: typeof res === "object" ? res.id : res,
resourceName: splitResName,
splitAmount,
remainingAmount: entry.amount - splitAmount,
},
}); });
return { success: true }; return { success: true };
@ -1029,9 +1131,10 @@ export async function retrieveFromVoid(structureId: number): Promise<GridActionR
const recovered = voidStorage.length - remainingVoid.length; const recovered = voidStorage.length - remainingVoid.length;
await emitGameEvent(payload, { await emitGameEvent(payload, {
type: EventTypes.StorageRecover, type: EventTypes.StorageRecover,
message: recovered > 0 message:
? `Retrieved ${recovered} item(s) from void in "${structure.name}"` recovered > 0
: `No void items could be recovered in "${structure.name}" — grid full`, ? `Retrieved ${recovered} item(s) from void in "${structure.name}"`
: `No void items could be recovered in "${structure.name}" — grid full`,
actor: user.id, actor: user.id,
structure: structureId, structure: structureId,
targetCollection: "game-structures", targetCollection: "game-structures",
@ -1040,9 +1143,10 @@ export async function retrieveFromVoid(structureId: number): Promise<GridActionR
}); });
return { return {
success: true, success: true,
error: recovered > 0 error:
? `${recovered} item(s) retrieved from void storage.` recovered > 0
: "No items could be retrieved — grid is full.", ? `${recovered} item(s) retrieved from void storage.`
: "No items could be retrieved — grid is full.",
}; };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; const message = error instanceof Error ? error.message : "Unknown error";
@ -1056,7 +1160,10 @@ export async function autoArrangeGrid(structureId: number): Promise<GridActionRe
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) {
return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required." }; return {
success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.",
};
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
const gridW = structure.type.xSize ?? 0; const gridW = structure.type.xSize ?? 0;

View file

@ -1,34 +1,25 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useMemo, useState } from "react";
import type { Vehicle } from "@/payload-types"; import type { Vehicle } from "@/payload-types";
import { VehicleCard } from "@/components/frontend/logistics/VehicleCard"; import { VehicleCard } from "@/components/frontend/logistics/VehicleCard";
import { VehicleFilters } from "@/components/frontend/logistics/VehicleFilters"; import { VehicleFilters } from "@/components/frontend/logistics/VehicleFilters";
const categories = [ const categories = ["civilian", "utility", "transport", "weaponized", "military"];
"civilian",
"utility",
"transport",
"weaponized",
"military",
];
export function VehiclesList({ vehicles }: { vehicles: Vehicle[] }) { export function VehiclesList({ vehicles }: { vehicles: Vehicle[] }) {
const [selectedCategory, setSelectedCategory] = useState<string | null>(null); const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!selectedCategory) return vehicles; if (!selectedCategory) return vehicles;
return vehicles.filter( return vehicles.filter((v) =>
(v) => v.category?.includes(selectedCategory as NonNullable<Vehicle["category"]>[number]), v.category?.includes(selectedCategory as NonNullable<Vehicle["category"]>[number]),
); );
}, [vehicles, selectedCategory]); }, [vehicles, selectedCategory]);
return ( return (
<> <>
<VehicleFilters <VehicleFilters categories={categories} onFilterChange={setSelectedCategory} />
categories={categories}
onFilterChange={setSelectedCategory}
/>
{filtered.length > 0 ? ( {filtered.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{filtered.map((vehicle) => ( {filtered.map((vehicle) => (

View file

@ -2,7 +2,7 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import type { Vehicle, Resource, Technology, Structure } from "@/payload-types"; import type { Resource, Structure, Technology } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react"; import { RichText } from "@payloadcms/richtext-lexical/react";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { import {
@ -10,7 +10,6 @@ import {
BoxIcon, BoxIcon,
Building2Icon, Building2Icon,
ClockIcon, ClockIcon,
CrosshairIcon,
GaugeIcon, GaugeIcon,
HeartIcon, HeartIcon,
ShieldIcon, ShieldIcon,
@ -46,9 +45,7 @@ const armorColors: Record<string, string> = {
composite: "bg-purple-500/20 text-purple-400", composite: "bg-purple-500/20 text-purple-400",
}; };
export default async function VehicleDetailPage({ export default async function VehicleDetailPage({ params }: VehicleDetailPageProps) {
params,
}: VehicleDetailPageProps) {
const { id } = await params; const { id } = await params;
const payload = await getPayload({ config }); const payload = await getPayload({ config });
@ -144,10 +141,7 @@ export default async function VehicleDetailPage({
<ItemContent> <ItemContent>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{materials.map((mat, index) => ( {materials.map((mat, index) => (
<span <span key={index} className="text-sm bg-muted px-2.5 py-1 rounded-md">
key={index}
className="text-sm bg-muted px-2.5 py-1 rounded-md"
>
{mat.resource.name}: {mat.amount.toLocaleString()} {mat.resource.name}: {mat.amount.toLocaleString()}
</span> </span>
))} ))}
@ -203,9 +197,7 @@ export default async function VehicleDetailPage({
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>Armor Rating</ItemTitle> <ItemTitle>Armor Rating</ItemTitle>
<ItemDescription className="capitalize"> <ItemDescription className="capitalize">{vehicle.armorRating}</ItemDescription>
{vehicle.armorRating}
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
)} )}
@ -319,15 +311,10 @@ export default async function VehicleDetailPage({
<ItemContent> <ItemContent>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{scrapReturns.map((scrap, index) => ( {scrapReturns.map((scrap, index) => (
<span <span key={index} className="text-sm bg-muted px-2.5 py-1 rounded-md">
key={index}
className="text-sm bg-muted px-2.5 py-1 rounded-md"
>
{scrap.resource.name}: {scrap.amount.toLocaleString()} {scrap.resource.name}: {scrap.amount.toLocaleString()}
{scrap.chance < 100 && ( {scrap.chance < 100 && (
<span className="text-muted-foreground ml-1"> <span className="text-muted-foreground ml-1">({scrap.chance}%)</span>
({scrap.chance}%)
</span>
)} )}
</span> </span>
))} ))}
@ -348,10 +335,7 @@ export default async function VehicleDetailPage({
<ItemContent> <ItemContent>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{upkeepCosts.map((upkeep, index) => ( {upkeepCosts.map((upkeep, index) => (
<span <span key={index} className="text-sm bg-muted px-2.5 py-1 rounded-md">
key={index}
className="text-sm bg-muted px-2.5 py-1 rounded-md"
>
{upkeep.resource.name}: {upkeep.amount.toLocaleString()}/tick {upkeep.resource.name}: {upkeep.amount.toLocaleString()}/tick
</span> </span>
))} ))}
@ -369,10 +353,7 @@ export default async function VehicleDetailPage({
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{producingStructures.map((structure) => ( {producingStructures.map((structure) => (
<Link <Link key={structure.id} href={`/logistics/structures/${structure.id}`}>
key={structure.id}
href={`/logistics/structures/${structure.id}`}
>
<Item <Item
variant="outline" variant="outline"
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01]" className="cursor-pointer group transition-all duration-200 hover:scale-[1.01]"

View file

@ -1,7 +1,7 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import type { User, Rank, Media, Assignment, Campaign, Map, GameStructure } from "@/payload-types"; import type { Assignment, Campaign, GameStructure, Map, Media, Rank, User } from "@/payload-types";
import { resolveLevel } from "@/utils/xp/resolveLevel"; import { resolveLevel } from "@/utils/xp/resolveLevel";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification"; import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
import { ProfileSummary } from "@/components/frontend/dashboard/ProfileSummary"; import { ProfileSummary } from "@/components/frontend/dashboard/ProfileSummary";

View file

@ -2,27 +2,27 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import type { User, Rank, Award, Media, Assignment, Qualification } from "@/payload-types"; import type { Assignment, Award, Media, Qualification, Rank, User } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react"; import { RichText } from "@payloadcms/richtext-lexical/react";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { resolveLevel } from "@/utils/xp/resolveLevel"; import { resolveLevel } from "@/utils/xp/resolveLevel";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
ShieldIcon,
SwordsIcon,
UserIcon,
FileTextIcon,
CrosshairIcon,
AwardIcon, AwardIcon,
SparkleIcon,
CalendarDaysIcon,
HashIcon,
SkullIcon,
TruckIcon,
PlaneIcon,
TargetIcon,
BadgeCheckIcon, BadgeCheckIcon,
CalendarDaysIcon,
CrosshairIcon,
FileTextIcon,
HashIcon,
PlaneIcon,
ShieldCheck, ShieldCheck,
ShieldIcon,
SkullIcon,
SparkleIcon,
SwordsIcon,
TargetIcon,
TruckIcon,
UserIcon,
} from "lucide-react"; } from "lucide-react";
import { import {
Item, Item,

View file

@ -1,6 +1,6 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { User, Rank, Media } from "@/payload-types"; import type { Media, Rank, User } from "@/payload-types";
import { UsersIcon } from "lucide-react"; import { UsersIcon } from "lucide-react";
import { RosterView } from "@/components/frontend/roster/RosterView"; import { RosterView } from "@/components/frontend/roster/RosterView";
@ -64,9 +64,7 @@ export default async function RosterPage() {
const user = profile.user as User; const user = profile.user as User;
const rank = profile.rank as Rank; const rank = profile.rank as Rank;
const insigniaUrl = const insigniaUrl =
rank.insignia && typeof rank.insignia === "object" rank.insignia && typeof rank.insignia === "object" ? (rank.insignia as Media).url : null;
? (rank.insignia as Media).url
: null;
let assignmentName: string | undefined; let assignmentName: string | undefined;
let assignmentType: string | undefined; let assignmentType: string | undefined;
@ -125,15 +123,12 @@ export default async function RosterPage() {
<h1 className="text-lg font-semibold">Unit Roster</h1> <h1 className="text-lg font-semibold">Unit Roster</h1>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{members.length} operator{members.length !== 1 ? "s" : ""} across{" "} {members.length} operator{members.length !== 1 ? "s" : ""} across {assignments.length}{" "}
{assignments.length} assignment{assignments.length !== 1 ? "s" : ""} assignment{assignments.length !== 1 ? "s" : ""}
</p> </p>
</div> </div>
<RosterView <RosterView members={members} assignments={assignments} />
members={members}
assignments={assignments}
/>
</div> </div>
); );
} }

View file

@ -1,7 +1,7 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config' import config from "@payload-config";
import '@payloadcms/next/css' import "@payloadcms/next/css";
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes' import { GRAPHQL_PLAYGROUND_GET } from "@payloadcms/next/routes";
export const GET = GRAPHQL_PLAYGROUND_GET(config); export const GET = GRAPHQL_PLAYGROUND_GET(config);

View file

@ -1,7 +1,7 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config' import config from "@payload-config";
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes' import { GRAPHQL_POST, REST_OPTIONS } from "@payloadcms/next/routes";
export const POST = GRAPHQL_POST(config); export const POST = GRAPHQL_POST(config);

View file

@ -1,9 +1,5 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { import { type SSEEventClient, subscribe, unsubscribe } from "@/lib/realtime/bus";
subscribe,
unsubscribe,
type SSEEventClient,
} from "@/lib/realtime/bus";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const runtime = "nodejs"; export const runtime = "nodejs";

View file

@ -5,12 +5,7 @@ import type { VersionInfo } from "@/lib/versionInfo";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const runtime = "nodejs"; export const runtime = "nodejs";
const VERSION_INFO_PATH = path.join( const VERSION_INFO_PATH = path.join(process.cwd(), "src", "generated", "versionInfo.json");
process.cwd(),
"src",
"generated",
"versionInfo.json",
);
const EMPTY: VersionInfo = { const EMPTY: VersionInfo = {
version: null, version: null,

View file

@ -24,8 +24,7 @@ export default async function LoginPage() {
<AppLogo /> <AppLogo />
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Log in to access the operations dashboard, intelligence, and Log in to access the operations dashboard, intelligence, and logistics.
logistics.
</p> </p>
</div> </div>
<LoginForm /> <LoginForm />

View file

@ -22,7 +22,10 @@ export const announceCommand: BotCommand = {
) as SlashCommandBuilder, ) as SlashCommandBuilder,
execute: async (interaction, payload) => { execute: async (interaction, payload) => {
if (!interaction.inGuild() || !interaction.guild) { if (!interaction.inGuild() || !interaction.guild) {
await interaction.reply({ content: "This command only works in a server.", flags: MessageFlags.Ephemeral }); await interaction.reply({
content: "This command only works in a server.",
flags: MessageFlags.Ephemeral,
});
return; return;
} }
@ -49,7 +52,10 @@ export const announceCommand: BotCommand = {
const optionChannel = option && "send" in option ? (option as TextBasedChannelFields) : null; const optionChannel = option && "send" in option ? (option as TextBasedChannelFields) : null;
const channel = optionChannel ?? (await interaction.guild.channels.fetch(channelId)); const channel = optionChannel ?? (await interaction.guild.channels.fetch(channelId));
if (!channel || !("send" in channel)) { if (!channel || !("send" in channel)) {
await interaction.reply({ content: "That channel isn't a text channel.", flags: MessageFlags.Ephemeral }); await interaction.reply({
content: "That channel isn't a text channel.",
flags: MessageFlags.Ephemeral,
});
return; return;
} }

View file

@ -1,4 +1,4 @@
import { MessageFlags, type ButtonInteraction } from "discord.js"; import { type ButtonInteraction, MessageFlags } from "discord.js";
import type { Payload } from "payload"; import type { Payload } from "payload";
import { findUserByDiscordId } from "@/bot/lib/resolve"; import { findUserByDiscordId } from "@/bot/lib/resolve";
import { import {
@ -9,10 +9,10 @@ import {
} from "@/bot/services/missionEmbeds"; } from "@/bot/services/missionEmbeds";
import { import {
attendanceClosedMessage, attendanceClosedMessage,
type AttendanceResponse,
getMissionAttendance, getMissionAttendance,
isMissionPast, isMissionPast,
setMissionAttendance, setMissionAttendance,
type AttendanceResponse,
} from "@/lib/attendance"; } from "@/lib/attendance";
const ATTENDANCE_LABELS: Record<AttendanceResponse, string> = { const ATTENDANCE_LABELS: Record<AttendanceResponse, string> = {
@ -44,7 +44,10 @@ export const handleButtonInteraction = async (
try { try {
const user = await findUserByDiscordId(payload, interaction.user.id); const user = await findUserByDiscordId(payload, interaction.user.id);
if (!user) { if (!user) {
await interaction.reply({ content: "Run /signup or /link first.", flags: MessageFlags.Ephemeral }); await interaction.reply({
content: "Run /signup or /link first.",
flags: MessageFlags.Ephemeral,
});
return; return;
} }

View file

@ -2,8 +2,8 @@ import {
ActionRowBuilder, ActionRowBuilder,
ButtonBuilder, ButtonBuilder,
ButtonStyle, ButtonStyle,
EmbedBuilder,
type Client, type Client,
EmbedBuilder,
type TextBasedChannelFields, type TextBasedChannelFields,
} from "discord.js"; } from "discord.js";
import type { Payload } from "payload"; import type { Payload } from "payload";

View file

@ -1,4 +1,4 @@
import { EmbedBuilder, type Client } from "discord.js"; import { type Client, EmbedBuilder } from "discord.js";
import type { Payload } from "payload"; import type { Payload } from "payload";
import type { User, UserNotification } from "@/payload-types"; import type { User, UserNotification } from "@/payload-types";
import { botConfig } from "@/bot/config"; import { botConfig } from "@/bot/config";

40
src/collections/Shims.ts Normal file
View file

@ -0,0 +1,40 @@
import { GlobalConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
export const Shims: GlobalConfig = {
slug: "shims",
access: {
update: isDeveloper,
read: isDeveloper,
},
fields: [
{
name: "shims",
type: "array",
fields: [
{
name: "name",
type: "text",
required: true,
unique: true,
},
{
name: "summary",
type: "text",
},
{
name: "content",
type: "code",
required: true,
},
{
name: "type",
type: "select",
options: ["css", "js"],
required: true,
defaultValue: "css",
},
],
},
],
};

View file

@ -7,7 +7,15 @@ export const BankAccounts: CollectionConfig = {
admin: { admin: {
group: "Banking", group: "Banking",
useAsTitle: "name", useAsTitle: "name",
defaultColumns: ["name", "accountType", "ownerUser", "ownerFaction", "balance", "status", "currency"], defaultColumns: [
"name",
"accountType",
"ownerUser",
"ownerFaction",
"balance",
"status",
"currency",
],
}, },
access: { access: {
read: ({ req }) => !!req.user, read: ({ req }) => !!req.user,
@ -21,7 +29,7 @@ export const BankAccounts: CollectionConfig = {
type: "text", type: "text",
required: true, required: true,
admin: { admin: {
description: "Display name for the account (e.g., \"Unit Treasury\", \"Red Faction Reserve\").", description: 'Display name for the account (e.g., "Unit Treasury", "Red Faction Reserve").',
}, },
}, },
{ {
@ -35,7 +43,8 @@ export const BankAccounts: CollectionConfig = {
{ label: "Personal", value: "personal" }, { label: "Personal", value: "personal" },
], ],
admin: { admin: {
description: "Treasury = unit-level central funds. Faction = a specific faction's funds. Personal = an individual operator's wallet.", description:
"Treasury = unit-level central funds. Faction = a specific faction's funds. Personal = an individual operator's wallet.",
}, },
}, },
{ {
@ -64,7 +73,8 @@ export const BankAccounts: CollectionConfig = {
relationTo: "resources", relationTo: "resources",
required: true, required: true,
admin: { admin: {
description: "The currency this account is denominated in (normally the main currency set in Game Rules).", description:
"The currency this account is denominated in (normally the main currency set in Game Rules).",
}, },
}, },
{ {

View file

@ -51,7 +51,8 @@ export const GameEventLogs: CollectionConfig = {
type: "checkbox", type: "checkbox",
defaultValue: true, defaultValue: true,
admin: { admin: {
description: "System-generated events are created automatically. Uncheck for manual/GM narrative entries.", description:
"System-generated events are created automatically. Uncheck for manual/GM narrative entries.",
}, },
}, },
{ {

View file

@ -11,7 +11,8 @@ export const GameHardResources: CollectionConfig = {
admin: { admin: {
group: "Game", group: "Game",
useAsTitle: "name", useAsTitle: "name",
description: "\"Hard Resources\" represent the hard-coded, integral resource types used to power the game. It includes things like Intelligence Points, Command Points, and other non-physical resources that are essential for gameplay mechanics. Unlike regular (\"soft\") resources, hard resources are not calculated by transactions and are instead stored as numeric amounts that are updated by the backend. Soft resources are designed by the players, are defined in the Resources category under Logistics, and are calculated by transactions (ledgers) to ensure their accuracy.", description:
'"Hard Resources" represent the hard-coded, integral resource types used to power the game. It includes things like Intelligence Points, Command Points, and other non-physical resources that are essential for gameplay mechanics. Unlike regular ("soft") resources, hard resources are not calculated by transactions and are instead stored as numeric amounts that are updated by the backend. Soft resources are designed by the players, are defined in the Resources category under Logistics, and are calculated by transactions (ledgers) to ensure their accuracy.',
}, },
fields: [ fields: [
{ {

View file

@ -36,7 +36,12 @@ export const GameStructures: CollectionConfig = {
structure: d.id, structure: d.id,
targetCollection: "game-structures", targetCollection: "game-structures",
targetId: d.id, targetId: d.id,
data: { previousStored: prev.storedResources, newStored: d.storedResources, previousVoid: prev.voidStorage, newVoid: d.voidStorage }, data: {
previousStored: prev.storedResources,
newStored: d.storedResources,
previousVoid: prev.voidStorage,
newVoid: d.voidStorage,
},
}); });
}, },
], ],

View file

@ -120,8 +120,7 @@ export const Factions: CollectionConfig = {
min: -100, min: -100,
max: 100, max: 100,
admin: { admin: {
description: description: "Standing from -100 (hostile) to 100 (allied). 0 is neutral.",
"Standing from -100 (hostile) to 100 (allied). 0 is neutral.",
}, },
}, },
{ {

View file

@ -1,4 +1,4 @@
import type { CollectionConfig, Payload, User } from "payload"; import type { CollectionConfig, Payload } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole"; import { isDeveloper } from "@/utils/access-control/isRole";
import hasRoles from "@/utils/access-control/hasRoles"; import hasRoles from "@/utils/access-control/hasRoles";

View file

@ -45,7 +45,8 @@ export const LockerStorages: CollectionConfig = {
name: "items", name: "items",
type: "array", type: "array",
admin: { admin: {
description: "Items stored in the locker grid. Position and rotation are managed from the member locker UI.", description:
"Items stored in the locker grid. Position and rotation are managed from the member locker UI.",
}, },
fields: [ fields: [
{ {
@ -133,7 +134,7 @@ export const LockerStorages: CollectionConfig = {
name: "customName", name: "customName",
type: "text", type: "text",
admin: { admin: {
description: "Optional custom name for this item (e.g. \"Frank's Karma\").", description: 'Optional custom name for this item (e.g. "Frank\'s Karma").',
}, },
}, },
], ],

View file

@ -524,8 +524,7 @@ export const Assets: CollectionConfig = {
type: "group", type: "group",
label: "Storage Dimensions", label: "Storage Dimensions",
admin: { admin: {
description: description: "How this asset occupies space in member lockers and container grids.",
"How this asset occupies space in member lockers and container grids.",
}, },
fields: [ fields: [
{ {

View file

@ -149,7 +149,8 @@ export const Shipments: CollectionConfig = {
type: "checkbox", type: "checkbox",
defaultValue: false, defaultValue: false,
admin: { admin: {
description: "If true, the vehicle will automatically return to its origin after completing this shipment.", description:
"If true, the vehicle will automatically return to its origin after completing this shipment.",
}, },
}, },
// Metadata // Metadata

View file

@ -52,12 +52,8 @@ export const Structures: CollectionConfig = {
} }
const rotated = entry.rotated ?? false; const rotated = entry.rotated ?? false;
const ew = rotated const ew = rotated ? ((res as any).gridHeight ?? 1) : ((res as any).gridWidth ?? 1);
? (res as any).gridHeight ?? 1 const eh = rotated ? ((res as any).gridWidth ?? 1) : ((res as any).gridHeight ?? 1);
: (res as any).gridWidth ?? 1;
const eh = rotated
? (res as any).gridWidth ?? 1
: (res as any).gridHeight ?? 1;
let spot: { x: number; y: number } | null = null; let spot: { x: number; y: number } | null = null;
for (let y = 0; y <= newY - eh && !spot; y++) { for (let y = 0; y <= newY - eh && !spot; y++) {
@ -68,11 +64,11 @@ export const Structures: CollectionConfig = {
if (!pRes || typeof pRes !== "object") continue; if (!pRes || typeof pRes !== "object") continue;
const pRotated = placed.rotated ?? false; const pRotated = placed.rotated ?? false;
const pw = pRotated const pw = pRotated
? (pRes as any).gridHeight ?? 1 ? ((pRes as any).gridHeight ?? 1)
: (pRes as any).gridWidth ?? 1; : ((pRes as any).gridWidth ?? 1);
const ph = pRotated const ph = pRotated
? (pRes as any).gridWidth ?? 1 ? ((pRes as any).gridWidth ?? 1)
: (pRes as any).gridHeight ?? 1; : ((pRes as any).gridHeight ?? 1);
const px = placed.gridX; const px = placed.gridX;
const py = placed.gridY; const py = placed.gridY;
for (let dx = 0; dx < ew && !overlaps; dx++) { for (let dx = 0; dx < ew && !overlaps; dx++) {
@ -116,12 +112,8 @@ export const Structures: CollectionConfig = {
} }
const rotated = entry.rotated ?? false; const rotated = entry.rotated ?? false;
const ew = rotated const ew = rotated ? ((res as any).gridHeight ?? 1) : ((res as any).gridWidth ?? 1);
? (res as any).gridHeight ?? 1 const eh = rotated ? ((res as any).gridWidth ?? 1) : ((res as any).gridHeight ?? 1);
: (res as any).gridWidth ?? 1;
const eh = rotated
? (res as any).gridWidth ?? 1
: (res as any).gridHeight ?? 1;
const gx = entry.gridX; const gx = entry.gridX;
const gy = entry.gridY; const gy = entry.gridY;
@ -135,11 +127,11 @@ export const Structures: CollectionConfig = {
if (!pRes || typeof pRes !== "object") continue; if (!pRes || typeof pRes !== "object") continue;
const pRotated = placed.rotated ?? false; const pRotated = placed.rotated ?? false;
const pw = pRotated const pw = pRotated
? (pRes as any).gridHeight ?? 1 ? ((pRes as any).gridHeight ?? 1)
: (pRes as any).gridWidth ?? 1; : ((pRes as any).gridWidth ?? 1);
const ph = pRotated const ph = pRotated
? (pRes as any).gridWidth ?? 1 ? ((pRes as any).gridWidth ?? 1)
: (pRes as any).gridHeight ?? 1; : ((pRes as any).gridHeight ?? 1);
const px = placed.gridX; const px = placed.gridX;
const py = placed.gridY; const py = placed.gridY;
@ -176,11 +168,11 @@ export const Structures: CollectionConfig = {
if (!pRes || typeof pRes !== "object") continue; if (!pRes || typeof pRes !== "object") continue;
const pRotated = placed.rotated ?? false; const pRotated = placed.rotated ?? false;
const pw = pRotated const pw = pRotated
? (pRes as any).gridHeight ?? 1 ? ((pRes as any).gridHeight ?? 1)
: (pRes as any).gridWidth ?? 1; : ((pRes as any).gridWidth ?? 1);
const ph = pRotated const ph = pRotated
? (pRes as any).gridWidth ?? 1 ? ((pRes as any).gridWidth ?? 1)
: (pRes as any).gridHeight ?? 1; : ((pRes as any).gridHeight ?? 1);
const px = placed.gridX; const px = placed.gridX;
const py = placed.gridY; const py = placed.gridY;
@ -230,7 +222,15 @@ export const Structures: CollectionConfig = {
structure: gsDoc.id, structure: gsDoc.id,
targetCollection: "game-structures", targetCollection: "game-structures",
targetId: gsDoc.id, targetId: gsDoc.id,
data: { oldX, oldY, newX, newY, structureName: gsDoc.name, voidCount: newVoid.length, repositioned: changed }, data: {
oldX,
oldY,
newX,
newY,
structureName: gsDoc.name,
voidCount: newVoid.length,
repositioned: changed,
},
}); });
} }

View file

@ -124,8 +124,7 @@ export const Vehicles: CollectionConfig = {
required: true, required: true,
defaultValue: 1, defaultValue: 1,
admin: { admin: {
description: description: "Fuel consumed per unit of distance traveled.",
"Fuel consumed per unit of distance traveled.",
}, },
}, },
], ],

View file

@ -70,8 +70,7 @@ export const MarketListings: CollectionConfig = {
name: "desiredPrice", name: "desiredPrice",
type: "number", type: "number",
admin: { admin: {
description: description: "The seller's target price during negotiations. Defaults to the asking price.",
"The seller's target price during negotiations. Defaults to the asking price.",
}, },
}, },
{ {
@ -87,7 +86,8 @@ export const MarketListings: CollectionConfig = {
type: "relationship", type: "relationship",
relationTo: "resources", relationTo: "resources",
admin: { admin: {
description: "The currency this listing is priced in. Defaults to the main campaign currency.", description:
"The currency this listing is priced in. Defaults to the main campaign currency.",
}, },
}, },
{ {

View file

@ -8,7 +8,16 @@ export const MarketNegotiations: CollectionConfig = {
admin: { admin: {
group: "Market", group: "Market",
useAsTitle: "id", useAsTitle: "id",
defaultColumns: ["id", "listing", "buyer", "amount", "patience", "proposedBy", "status", "updatedAt"], defaultColumns: [
"id",
"listing",
"buyer",
"amount",
"patience",
"proposedBy",
"status",
"updatedAt",
],
pagination: { defaultLimit: 50 }, pagination: { defaultLimit: 50 },
}, },
access: { access: {
@ -17,10 +26,7 @@ export const MarketNegotiations: CollectionConfig = {
if (!user) return false; if (!user) return false;
if (hasRoles(["admin", "developer"], user)) return true; if (hasRoles(["admin", "developer"], user)) return true;
return { return {
or: [ or: [{ buyer: { equals: user.id } }, { "listing.seller": { equals: user.id } }],
{ buyer: { equals: user.id } },
{ "listing.seller": { equals: user.id } },
],
} as Where; } as Where;
}, },
create: ({ req }) => !!req.user, create: ({ req }) => !!req.user,
@ -29,10 +35,7 @@ export const MarketNegotiations: CollectionConfig = {
if (!user) return false; if (!user) return false;
if (hasRoles(["admin", "developer"], user)) return true; if (hasRoles(["admin", "developer"], user)) return true;
return { return {
or: [ or: [{ buyer: { equals: user.id } }, { "listing.seller": { equals: user.id } }],
{ buyer: { equals: user.id } },
{ "listing.seller": { equals: user.id } },
],
} as Where; } as Where;
}, },
delete: isDeveloper, delete: isDeveloper,

View file

@ -36,7 +36,15 @@ export const Tickets: CollectionConfig = {
admin: { admin: {
group: "Helpdesk", group: "Helpdesk",
useAsTitle: "title", useAsTitle: "title",
defaultColumns: ["title", "status", "priority", "category", "reporter", "assignee", "updatedAt"], defaultColumns: [
"title",
"status",
"priority",
"category",
"reporter",
"assignee",
"updatedAt",
],
listSearchableFields: ["title", "id"], listSearchableFields: ["title", "id"],
pagination: { defaultLimit: 50 }, pagination: { defaultLimit: 50 },
}, },

View file

@ -116,13 +116,20 @@ function MedalBody({
</defs> </defs>
{shape === "circle" && ( {shape === "circle" && (
<circle cx={cx} cy={cy} r={r} fill={`url(#${gradId})`} stroke={finishColors[2]} strokeWidth={1.5} /> <circle
cx={cx}
cy={cy}
r={r}
fill={`url(#${gradId})`}
stroke={finishColors[2]}
strokeWidth={1.5}
/>
)} )}
{shape === "star" && ( {shape === "star" && (
<polygon <polygon
points={Array.from({ length: 10 }, (_, i) => { points={Array.from({ length: 10 }, (_, i) => {
const angle = (Math.PI / 2) + (i * Math.PI) / 5; const angle = Math.PI / 2 + (i * Math.PI) / 5;
const radius = i % 2 === 0 ? r : r * 0.45; const radius = i % 2 === 0 ? r : r * 0.45;
return `${cx + radius * Math.cos(angle)},${cy - radius * Math.sin(angle)}`; return `${cx + radius * Math.cos(angle)},${cy - radius * Math.sin(angle)}`;
}).join(" ")} }).join(" ")}
@ -228,14 +235,7 @@ function MedalPreview({ design }: { design: MedalDesign }) {
})()} })()}
{/* Small connector tab between ribbon and medal */} {/* Small connector tab between ribbon and medal */}
<rect <rect x={medalCx - 4} y={ribbonBottom} width={8} height={8} rx={2} fill="#9ca3af" />
x={medalCx - 4}
y={ribbonBottom}
width={8}
height={8}
rx={2}
fill="#9ca3af"
/>
{/* Medal body */} {/* Medal body */}
<MedalBody shape={shape} finish={finish} engraving={engraving} cx={medalCx} cy={medalCy} /> <MedalBody shape={shape} finish={finish} engraving={engraving} cx={medalCx} cy={medalCy} />
@ -424,14 +424,8 @@ export default function MedalDesigner() {
(stripes: Stripe[]) => setDesign((d) => ({ ...d, ribbonStripes: stripes })), (stripes: Stripe[]) => setDesign((d) => ({ ...d, ribbonStripes: stripes })),
[], [],
); );
const setShape = useCallback( const setShape = useCallback((shape: MedalShape) => setDesign((d) => ({ ...d, shape })), []);
(shape: MedalShape) => setDesign((d) => ({ ...d, shape })), const setFinish = useCallback((finish: MetalFinish) => setDesign((d) => ({ ...d, finish })), []);
[],
);
const setFinish = useCallback(
(finish: MetalFinish) => setDesign((d) => ({ ...d, finish })),
[],
);
const setEngraving = useCallback( const setEngraving = useCallback(
(engraving: string) => setDesign((d) => ({ ...d, engraving })), (engraving: string) => setDesign((d) => ({ ...d, engraving })),
[], [],

View file

@ -79,9 +79,7 @@ function RibbonPreview({ design }: { design: RibbonDesign }) {
const { stripes, devices, mirrored } = design; const { stripes, devices, mirrored } = design;
if (stripes.length === 0) return null; if (stripes.length === 0) return null;
const displayStripes = mirrored const displayStripes = mirrored ? [...stripes, ...stripes.slice(0, -1).reverse()] : stripes;
? [...stripes, ...stripes.slice(0, -1).reverse()]
: stripes;
const totalWeight = displayStripes.reduce((sum, s) => sum + s.width, 0); const totalWeight = displayStripes.reduce((sum, s) => sum + s.width, 0);
const viewWidth = RIBBON_WIDTH; const viewWidth = RIBBON_WIDTH;
@ -98,16 +96,7 @@ function RibbonPreview({ design }: { design: RibbonDesign }) {
let x = 0; let x = 0;
return displayStripes.map((stripe, i) => { return displayStripes.map((stripe, i) => {
const w = (stripe.width / totalWeight) * viewWidth; const w = (stripe.width / totalWeight) * viewWidth;
const el = ( const el = <rect key={i} x={x} y={0} width={w} height={viewHeight} fill={stripe.color} />;
<rect
key={i}
x={x}
y={0}
width={w}
height={viewHeight}
fill={stripe.color}
/>
);
x += w; x += w;
return el; return el;
}); });
@ -156,9 +145,7 @@ function StripeEditor({
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-medium text-zinc-400 uppercase tracking-wider"> <span className="text-xs font-medium text-zinc-400 uppercase tracking-wider">Stripes</span>
Stripes
</span>
<button <button
type="button" type="button"
onClick={addStripe} onClick={addStripe}
@ -222,19 +209,14 @@ function DeviceEditor({
onChange: (d: Device[]) => void; onChange: (d: Device[]) => void;
}) { }) {
const addDevice = (type: DeviceType) => const addDevice = (type: DeviceType) =>
onChange([ onChange([...devices, { id: crypto.randomUUID(), type, position: 50, positionY: 50 }]);
...devices,
{ id: crypto.randomUUID(), type, position: 50, positionY: 50 },
]);
const removeDevice = (id: string) => onChange(devices.filter((d) => d.id !== id)); const removeDevice = (id: string) => onChange(devices.filter((d) => d.id !== id));
const updateDevice = (id: string, patch: Partial<Device>) => const updateDevice = (id: string, patch: Partial<Device>) =>
onChange(devices.map((d) => (d.id === id ? { ...d, ...patch } : d))); onChange(devices.map((d) => (d.id === id ? { ...d, ...patch } : d)));
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-xs font-medium text-zinc-400 uppercase tracking-wider"> <span className="text-xs font-medium text-zinc-400 uppercase tracking-wider">Devices</span>
Devices
</span>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{(Object.keys(DEVICE_INFO) as DeviceType[]).map((type) => { {(Object.keys(DEVICE_INFO) as DeviceType[]).map((type) => {
const info = DEVICE_INFO[type]; const info = DEVICE_INFO[type];
@ -268,9 +250,7 @@ function DeviceEditor({
min={0} min={0}
max={100} max={100}
value={device.position} value={device.position}
onChange={(e) => onChange={(e) => updateDevice(device.id, { position: Number(e.target.value) })}
updateDevice(device.id, { position: Number(e.target.value) })
}
className="flex-1 h-1 accent-zinc-400" className="flex-1 h-1 accent-zinc-400"
/> />
<span className="text-[10px] text-zinc-500 font-mono w-5 text-right"> <span className="text-[10px] text-zinc-500 font-mono w-5 text-right">
@ -284,9 +264,7 @@ function DeviceEditor({
min={0} min={0}
max={100} max={100}
value={device.positionY} value={device.positionY}
onChange={(e) => onChange={(e) => updateDevice(device.id, { positionY: Number(e.target.value) })}
updateDevice(device.id, { positionY: Number(e.target.value) })
}
className="flex-1 h-1 accent-zinc-400" className="flex-1 h-1 accent-zinc-400"
/> />
<span className="text-[10px] text-zinc-500 font-mono w-5 text-right"> <span className="text-[10px] text-zinc-500 font-mono w-5 text-right">
@ -330,18 +308,9 @@ export default function RibbonDesigner() {
} }
}, [design, setValue]); }, [design, setValue]);
const setStripes = useCallback( const setStripes = useCallback((stripes: Stripe[]) => setDesign((d) => ({ ...d, stripes })), []);
(stripes: Stripe[]) => setDesign((d) => ({ ...d, stripes })), const setDevices = useCallback((devices: Device[]) => setDesign((d) => ({ ...d, devices })), []);
[], const toggleMirror = useCallback(() => setDesign((d) => ({ ...d, mirrored: !d.mirrored })), []);
);
const setDevices = useCallback(
(devices: Device[]) => setDesign((d) => ({ ...d, devices })),
[],
);
const toggleMirror = useCallback(
() => setDesign((d) => ({ ...d, mirrored: !d.mirrored })),
[],
);
return ( return (
<div className="field-type json flex flex-col gap-3"> <div className="field-type json flex flex-col gap-3">

View file

@ -8,7 +8,10 @@ export function BooleanNode({ data }: NodeProps) {
const d = data as { value?: boolean; description?: string }; const d = data as { value?: boolean; description?: string };
return ( return (
<NodeShell nodeType="boolean" className="bg-emerald-950/80 border border-emerald-500/50 rounded-lg shadow-md min-w-28"> <NodeShell
nodeType="boolean"
className="bg-emerald-950/80 border border-emerald-500/50 rounded-lg shadow-md min-w-28"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-emerald-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-emerald-500/30">
<CheckCircleIcon className="size-4 text-emerald-400 shrink-0" /> <CheckCircleIcon className="size-4 text-emerald-400 shrink-0" />
<span className="text-sm font-medium text-emerald-200 flex-1 truncate"> <span className="text-sm font-medium text-emerald-200 flex-1 truncate">

View file

@ -6,7 +6,10 @@ import { NodeShell } from "./NodeShell";
export function BranchNode(_props: NodeProps) { export function BranchNode(_props: NodeProps) {
return ( return (
<NodeShell nodeType="branch" className="bg-rose-950/80 border border-rose-500/50 rounded-lg shadow-md min-w-36"> <NodeShell
nodeType="branch"
className="bg-rose-950/80 border border-rose-500/50 rounded-lg shadow-md min-w-36"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-rose-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-rose-500/30">
<GitBranchIcon className="size-4 text-rose-400 shrink-0" /> <GitBranchIcon className="size-4 text-rose-400 shrink-0" />
<span className="text-sm font-medium text-rose-200 flex-1">Branch</span> <span className="text-sm font-medium text-rose-200 flex-1">Branch</span>

View file

@ -9,7 +9,10 @@ export function ChoiceNode({ data }: NodeProps) {
const options = d.options ?? []; const options = d.options ?? [];
return ( return (
<NodeShell nodeType="choice" className="bg-amber-950/80 border border-amber-500/50 rounded-lg shadow-md min-w-56"> <NodeShell
nodeType="choice"
className="bg-amber-950/80 border border-amber-500/50 rounded-lg shadow-md min-w-56"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-amber-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-amber-500/30">
<WaypointsIcon className="size-4 text-amber-400 shrink-0" /> <WaypointsIcon className="size-4 text-amber-400 shrink-0" />
<span className="text-sm font-medium text-amber-200 flex-1 truncate"> <span className="text-sm font-medium text-amber-200 flex-1 truncate">

View file

@ -9,7 +9,10 @@ export function CompareNode({ data }: NodeProps) {
const op = d.operator ?? ">"; const op = d.operator ?? ">";
return ( return (
<NodeShell nodeType="compare" className="bg-slate-950/80 border border-slate-500/50 rounded-lg shadow-md min-w-32"> <NodeShell
nodeType="compare"
className="bg-slate-950/80 border border-slate-500/50 rounded-lg shadow-md min-w-32"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-slate-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-slate-500/30">
<EqualIcon className="size-4 text-slate-400 shrink-0" /> <EqualIcon className="size-4 text-slate-400 shrink-0" />
<span className="text-sm font-medium text-slate-200 flex-1">Compare</span> <span className="text-sm font-medium text-slate-200 flex-1">Compare</span>

View file

@ -8,7 +8,10 @@ export function DateNode({ data }: NodeProps) {
const d = data as { value?: string; description?: string }; const d = data as { value?: string; description?: string };
return ( return (
<NodeShell nodeType="date" className="bg-cyan-950/80 border border-cyan-500/50 rounded-lg shadow-md min-w-32"> <NodeShell
nodeType="date"
className="bg-cyan-950/80 border border-cyan-500/50 rounded-lg shadow-md min-w-32"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-cyan-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-cyan-500/30">
<CalendarIcon className="size-4 text-cyan-400 shrink-0" /> <CalendarIcon className="size-4 text-cyan-400 shrink-0" />
<span className="text-sm font-medium text-cyan-200 flex-1 truncate"> <span className="text-sm font-medium text-cyan-200 flex-1 truncate">

View file

@ -8,12 +8,13 @@ export function FlagNode({ data }: NodeProps) {
const d = data as { flagName?: string }; const d = data as { flagName?: string };
return ( return (
<NodeShell nodeType="flag" className="bg-orange-950/80 border border-orange-500/50 rounded-lg shadow-md min-w-40"> <NodeShell
nodeType="flag"
className="bg-orange-950/80 border border-orange-500/50 rounded-lg shadow-md min-w-40"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-orange-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-orange-500/30">
<FlagIcon className="size-4 text-orange-400 shrink-0" /> <FlagIcon className="size-4 text-orange-400 shrink-0" />
<span className="text-sm font-medium text-orange-200 flex-1 truncate"> <span className="text-sm font-medium text-orange-200 flex-1 truncate">Flag</span>
Flag
</span>
</div> </div>
<div className="px-3 py-2 text-xs text-orange-300/80 truncate"> <div className="px-3 py-2 text-xs text-orange-300/80 truncate">
{d.flagName || "Unnamed flag"} {d.flagName || "Unnamed flag"}

View file

@ -25,7 +25,11 @@ export function FormatNode({ data }: NodeProps) {
]; ];
return ( return (
<NodeShell nodeType="format" dynamicInputs={dynamicInputs} className="bg-sky-950/80 border border-sky-500/50 rounded-lg shadow-md min-w-40"> <NodeShell
nodeType="format"
dynamicInputs={dynamicInputs}
className="bg-sky-950/80 border border-sky-500/50 rounded-lg shadow-md min-w-40"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-sky-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-sky-500/30">
<AlignLeftIcon className="size-4 text-sky-400 shrink-0" /> <AlignLeftIcon className="size-4 text-sky-400 shrink-0" />
<span className="text-sm font-medium text-sky-200 flex-1">Format</span> <span className="text-sm font-medium text-sky-200 flex-1">Format</span>

View file

@ -20,16 +20,15 @@ export function GameDataNode({ data }: NodeProps) {
const fieldLabel = GAME_DATA_FIELDS.find((f) => f.value === d.field)?.label ?? "Select field"; const fieldLabel = GAME_DATA_FIELDS.find((f) => f.value === d.field)?.label ?? "Select field";
return ( return (
<NodeShell nodeType="gameData" className="bg-indigo-950/80 border border-indigo-500/50 rounded-lg shadow-md min-w-44"> <NodeShell
nodeType="gameData"
className="bg-indigo-950/80 border border-indigo-500/50 rounded-lg shadow-md min-w-44"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-indigo-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-indigo-500/30">
<DatabaseIcon className="size-4 text-indigo-400 shrink-0" /> <DatabaseIcon className="size-4 text-indigo-400 shrink-0" />
<span className="text-sm font-medium text-indigo-200 flex-1 truncate"> <span className="text-sm font-medium text-indigo-200 flex-1 truncate">Game Data</span>
Game Data
</span>
</div>
<div className="px-3 py-2 text-xs text-indigo-300/80">
{fieldLabel}
</div> </div>
<div className="px-3 py-2 text-xs text-indigo-300/80">{fieldLabel}</div>
</NodeShell> </NodeShell>
); );
} }

View file

@ -9,7 +9,10 @@ export function LogicNode({ data }: NodeProps) {
const op = d.operator ?? "AND"; const op = d.operator ?? "AND";
return ( return (
<NodeShell nodeType="logic" className="bg-lime-950/80 border border-lime-500/50 rounded-lg shadow-md min-w-32"> <NodeShell
nodeType="logic"
className="bg-lime-950/80 border border-lime-500/50 rounded-lg shadow-md min-w-32"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-lime-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-lime-500/30">
<BinaryIcon className="size-4 text-lime-400 shrink-0" /> <BinaryIcon className="size-4 text-lime-400 shrink-0" />
<span className="text-sm font-medium text-lime-200 flex-1">Logic</span> <span className="text-sm font-medium text-lime-200 flex-1">Logic</span>

View file

@ -9,7 +9,10 @@ export function MathNode({ data }: NodeProps) {
const op = d.operator ?? "+"; const op = d.operator ?? "+";
return ( return (
<NodeShell nodeType="math" className="bg-violet-950/80 border border-violet-500/50 rounded-lg shadow-md min-w-32"> <NodeShell
nodeType="math"
className="bg-violet-950/80 border border-violet-500/50 rounded-lg shadow-md min-w-32"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-violet-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-violet-500/30">
<CalculatorIcon className="size-4 text-violet-400 shrink-0" /> <CalculatorIcon className="size-4 text-violet-400 shrink-0" />
<span className="text-sm font-medium text-violet-200 flex-1">Math</span> <span className="text-sm font-medium text-violet-200 flex-1">Math</span>

View file

@ -8,7 +8,10 @@ export function NarrativeBeatNode({ data }: NodeProps) {
const d = data as { title?: string; text?: string }; const d = data as { title?: string; text?: string };
return ( return (
<NodeShell nodeType="narrativeBeat" className="bg-blue-950/80 border border-blue-500/50 rounded-lg shadow-md min-w-56"> <NodeShell
nodeType="narrativeBeat"
className="bg-blue-950/80 border border-blue-500/50 rounded-lg shadow-md min-w-56"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-blue-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-blue-500/30">
<BookOpenTextIcon className="size-4 text-blue-400 shrink-0" /> <BookOpenTextIcon className="size-4 text-blue-400 shrink-0" />
<span className="text-sm font-medium text-blue-200 flex-1 truncate"> <span className="text-sm font-medium text-blue-200 flex-1 truncate">

View file

@ -2,16 +2,16 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
ReactFlow,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
addEdge, addEdge,
type Node, Background,
type Edge,
type Connection, type Connection,
Controls,
type Edge,
MiniMap,
type Node,
ReactFlow,
useEdgesState,
useNodesState,
} from "@xyflow/react"; } from "@xyflow/react";
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
@ -38,7 +38,7 @@ import { NotNode } from "./NotNode";
import { BranchNode } from "./BranchNode"; import { BranchNode } from "./BranchNode";
import { SequenceNode } from "./SequenceNode"; import { SequenceNode } from "./SequenceNode";
import { NodeEditDialog } from "./NodeEditDialog"; import { NodeEditDialog } from "./NodeEditDialog";
import { NODE_SOCKETS, type SocketType } from "./types"; import { NODE_SOCKETS } from "./types";
const nodeTypes = { const nodeTypes = {
narrativeBeat: NarrativeBeatNode, narrativeBeat: NarrativeBeatNode,
@ -106,7 +106,12 @@ export function NarrativeFlowEditor() {
const isValidConnection = useCallback( const isValidConnection = useCallback(
(connection: Edge | Connection) => { (connection: Edge | Connection) => {
if (!connection.source || !connection.target || !connection.sourceHandle || !connection.targetHandle) { if (
!connection.source ||
!connection.target ||
!connection.sourceHandle ||
!connection.targetHandle
) {
return false; return false;
} }
const sourceNode = nodes.find((n) => n.id === connection.source); const sourceNode = nodes.find((n) => n.id === connection.source);
@ -121,8 +126,16 @@ export function NarrativeFlowEditor() {
const targetSocket = targetSockets.inputs.find((s) => s.id === connection.targetHandle); const targetSocket = targetSockets.inputs.find((s) => s.id === connection.targetHandle);
// Dynamic handles (arg-*, option-*) are treated as "any" // Dynamic handles (arg-*, option-*) are treated as "any"
const sourceType = sourceSocket?.type ?? (connection.sourceHandle.startsWith("arg-") || connection.sourceHandle.startsWith("option-") ? "any" : null); const sourceType =
const targetType = targetSocket?.type ?? (connection.targetHandle.startsWith("arg-") || connection.targetHandle.startsWith("option-") ? "any" : null); sourceSocket?.type ??
(connection.sourceHandle.startsWith("arg-") || connection.sourceHandle.startsWith("option-")
? "any"
: null);
const targetType =
targetSocket?.type ??
(connection.targetHandle.startsWith("arg-") || connection.targetHandle.startsWith("option-")
? "any"
: null);
if (sourceType === null || targetType === null) return false; if (sourceType === null || targetType === null) return false;
if (sourceType === "any" || targetType === "any") return true; if (sourceType === "any" || targetType === "any") return true;
@ -135,21 +148,15 @@ export function NarrativeFlowEditor() {
setEditingNodeId(node.id); setEditingNodeId(node.id);
}, []); }, []);
const onNodeContextMenu = useCallback( const onNodeContextMenu = useCallback((e: React.MouseEvent, node: Node) => {
(e: React.MouseEvent, node: Node) => { e.preventDefault();
e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, nodeId: node.id });
setContextMenu({ x: e.clientX, y: e.clientY, nodeId: node.id }); }, []);
},
[],
);
const onEdgeContextMenu = useCallback( const onEdgeContextMenu = useCallback((e: React.MouseEvent, edge: Edge) => {
(e: React.MouseEvent, edge: Edge) => { e.preventDefault();
e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, edgeId: edge.id });
setContextMenu({ x: e.clientX, y: e.clientY, edgeId: edge.id }); }, []);
},
[],
);
const onPaneContextMenu = useCallback((e: MouseEvent | React.MouseEvent) => { const onPaneContextMenu = useCallback((e: MouseEvent | React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
@ -178,7 +185,7 @@ export function NarrativeFlowEditor() {
[setNodes], [setNodes],
); );
const selectedNode = editingNodeId ? nodes.find((n) => n.id === editingNodeId) ?? null : null; const selectedNode = editingNodeId ? (nodes.find((n) => n.id === editingNodeId) ?? null) : null;
const addNode = useCallback( const addNode = useCallback(
( (
@ -240,32 +247,129 @@ export function NarrativeFlowEditor() {
const toolbarButtons = ( const toolbarButtons = (
<div className="flex flex-wrap items-center gap-1 px-2 py-1.5 bg-zinc-900 rounded-t-md border border-zinc-700"> <div className="flex flex-wrap items-center gap-1 px-2 py-1.5 bg-zinc-900 rounded-t-md border border-zinc-700">
<span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold mr-1">Story</span> <span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold mr-1">
<button className={`${buttonClass} bg-blue-950 border-blue-700 text-blue-200 hover:bg-blue-900`} onClick={() => addNode("narrativeBeat")}>Beat</button> Story
<button className={`${buttonClass} bg-amber-950 border-amber-700 text-amber-200 hover:bg-amber-900`} onClick={() => addNode("choice")}>Choice</button> </span>
<button className={`${buttonClass} bg-emerald-950 border-emerald-700 text-emerald-200 hover:bg-emerald-900`} onClick={() => addNode("outcome")}>Outcome</button> <button
<button className={`${buttonClass} bg-yellow-950 border-yellow-700 text-yellow-200 hover:bg-yellow-900`} onClick={() => addNode("wait")}>Wait</button> className={`${buttonClass} bg-blue-950 border-blue-700 text-blue-200 hover:bg-blue-900`}
onClick={() => addNode("narrativeBeat")}
>
Beat
</button>
<button
className={`${buttonClass} bg-amber-950 border-amber-700 text-amber-200 hover:bg-amber-900`}
onClick={() => addNode("choice")}
>
Choice
</button>
<button
className={`${buttonClass} bg-emerald-950 border-emerald-700 text-emerald-200 hover:bg-emerald-900`}
onClick={() => addNode("outcome")}
>
Outcome
</button>
<button
className={`${buttonClass} bg-yellow-950 border-yellow-700 text-yellow-200 hover:bg-yellow-900`}
onClick={() => addNode("wait")}
>
Wait
</button>
<span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold ml-2 mr-1">Data</span> <span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold ml-2 mr-1">
<button className={`${buttonClass} bg-purple-950 border-purple-700 text-purple-200 hover:bg-purple-900`} onClick={() => addNode("number")}>Number</button> Data
<button className={`${buttonClass} bg-teal-950 border-teal-700 text-teal-200 hover:bg-teal-900`} onClick={() => addNode("string")}>String</button> </span>
<button className={`${buttonClass} bg-emerald-950/30 border-emerald-700/50 text-emerald-200 hover:bg-emerald-900/40`} onClick={() => addNode("boolean")}>Bool</button> <button
<button className={`${buttonClass} bg-cyan-950 border-cyan-700 text-cyan-200 hover:bg-cyan-900`} onClick={() => addNode("date")}>Date</button> className={`${buttonClass} bg-purple-950 border-purple-700 text-purple-200 hover:bg-purple-900`}
<button className={`${buttonClass} bg-pink-950 border-pink-700 text-pink-200 hover:bg-pink-900`} onClick={() => addNode("random")}>Random</button> onClick={() => addNode("number")}
<button className={`${buttonClass} bg-indigo-950 border-indigo-700 text-indigo-200 hover:bg-indigo-900`} onClick={() => addNode("gameData")}>GameData</button> >
<button className={`${buttonClass} bg-orange-950 border-orange-700 text-orange-200 hover:bg-orange-900`} onClick={() => addNode("flag")}>Flag</button> Number
</button>
<button
className={`${buttonClass} bg-teal-950 border-teal-700 text-teal-200 hover:bg-teal-900`}
onClick={() => addNode("string")}
>
String
</button>
<button
className={`${buttonClass} bg-emerald-950/30 border-emerald-700/50 text-emerald-200 hover:bg-emerald-900/40`}
onClick={() => addNode("boolean")}
>
Bool
</button>
<button
className={`${buttonClass} bg-cyan-950 border-cyan-700 text-cyan-200 hover:bg-cyan-900`}
onClick={() => addNode("date")}
>
Date
</button>
<button
className={`${buttonClass} bg-pink-950 border-pink-700 text-pink-200 hover:bg-pink-900`}
onClick={() => addNode("random")}
>
Random
</button>
<button
className={`${buttonClass} bg-indigo-950 border-indigo-700 text-indigo-200 hover:bg-indigo-900`}
onClick={() => addNode("gameData")}
>
GameData
</button>
<button
className={`${buttonClass} bg-orange-950 border-orange-700 text-orange-200 hover:bg-orange-900`}
onClick={() => addNode("flag")}
>
Flag
</button>
<span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold ml-2 mr-1">Logic</span> <span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold ml-2 mr-1">
<button className={`${buttonClass} bg-slate-950 border-slate-700 text-slate-200 hover:bg-slate-900`} onClick={() => addNode("compare")}>Compare</button> Logic
<button className={`${buttonClass} bg-violet-950 border-violet-700 text-violet-200 hover:bg-violet-900`} onClick={() => addNode("math")}>Math</button> </span>
<button className={`${buttonClass} bg-sky-950 border-sky-700 text-sky-200 hover:bg-sky-900`} onClick={() => addNode("format")}>Format</button> <button
<button className={`${buttonClass} bg-lime-950 border-lime-700 text-lime-200 hover:bg-lime-900`} onClick={() => addNode("logic")}>Logic</button> className={`${buttonClass} bg-slate-950 border-slate-700 text-slate-200 hover:bg-slate-900`}
<button className={`${buttonClass} bg-red-950 border-red-700 text-red-200 hover:bg-red-900`} onClick={() => addNode("not")}>NOT</button> onClick={() => addNode("compare")}
<button className={`${buttonClass} bg-rose-950 border-rose-700 text-rose-200 hover:bg-rose-900`} onClick={() => addNode("branch")}>Branch</button> >
<button className={`${buttonClass} bg-fuchsia-950 border-fuchsia-700 text-fuchsia-200 hover:bg-fuchsia-900`} onClick={() => addNode("sequence")}>Seq</button> Compare
</button>
<button
className={`${buttonClass} bg-violet-950 border-violet-700 text-violet-200 hover:bg-violet-900`}
onClick={() => addNode("math")}
>
Math
</button>
<button
className={`${buttonClass} bg-sky-950 border-sky-700 text-sky-200 hover:bg-sky-900`}
onClick={() => addNode("format")}
>
Format
</button>
<button
className={`${buttonClass} bg-lime-950 border-lime-700 text-lime-200 hover:bg-lime-900`}
onClick={() => addNode("logic")}
>
Logic
</button>
<button
className={`${buttonClass} bg-red-950 border-red-700 text-red-200 hover:bg-red-900`}
onClick={() => addNode("not")}
>
NOT
</button>
<button
className={`${buttonClass} bg-rose-950 border-rose-700 text-rose-200 hover:bg-rose-900`}
onClick={() => addNode("branch")}
>
Branch
</button>
<button
className={`${buttonClass} bg-fuchsia-950 border-fuchsia-700 text-fuchsia-200 hover:bg-fuchsia-900`}
onClick={() => addNode("sequence")}
>
Seq
</button>
<span className="ml-auto text-[10px] text-zinc-500"> <span className="ml-auto text-[10px] text-zinc-500">
{nodes.length} node{nodes.length !== 1 ? "s" : ""}, {edges.length} edge{edges.length !== 1 ? "s" : ""} {nodes.length} node{nodes.length !== 1 ? "s" : ""}, {edges.length} edge
{edges.length !== 1 ? "s" : ""}
</span> </span>
</div> </div>
); );

View file

@ -1,12 +1,12 @@
"use client"; "use client";
import { useEffect, useState, type FC } from "react"; import { type FC, useEffect, useState } from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -22,12 +22,7 @@ interface NodeEditDialogProps {
onSave: (nodeId: string, data: Record<string, unknown>) => void; onSave: (nodeId: string, data: Record<string, unknown>) => void;
} }
export const NodeEditDialog: FC<NodeEditDialogProps> = ({ export const NodeEditDialog: FC<NodeEditDialogProps> = ({ node, open, onOpenChange, onSave }) => {
node,
open,
onOpenChange,
onSave,
}) => {
const [formData, setFormData] = useState<Record<string, unknown>>({}); const [formData, setFormData] = useState<Record<string, unknown>>({});
useEffect(() => { useEffect(() => {
@ -326,10 +321,7 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={() => {
const updated = [ const updated = [...options, { id: crypto.randomUUID(), label: "" }];
...options,
{ id: crypto.randomUUID(), label: "" },
];
updateField("options", updated); updateField("options", updated);
}} }}
className="dark:border-zinc-700 dark:text-zinc-300" className="dark:border-zinc-700 dark:text-zinc-300"
@ -584,11 +576,7 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
case "not": case "not":
case "branch": case "branch":
case "sequence": { case "sequence": {
return ( return <p className="text-sm text-muted-foreground">No configurable properties.</p>;
<p className="text-sm text-muted-foreground">
No configurable properties.
</p>
);
} }
default: default:
@ -620,7 +608,10 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg dark:bg-zinc-900 dark:text-zinc-100 dark:border-zinc-700 bg-white text-zinc-900" showCloseButton={false}> <DialogContent
className="max-w-lg dark:bg-zinc-900 dark:text-zinc-100 dark:border-zinc-700 bg-white text-zinc-900"
showCloseButton={false}
>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center justify-between"> <DialogTitle className="flex items-center justify-between">
<span>Edit {label}</span> <span>Edit {label}</span>
@ -642,10 +633,7 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
> >
Cancel Cancel
</Button> </Button>
<Button <Button onClick={handleSave} className="bg-blue-600 hover:bg-blue-700 text-white">
onClick={handleSave}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
Save Save
</Button> </Button>
</DialogFooter> </DialogFooter>

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { Handle, Position } from "@xyflow/react"; import { Handle, Position } from "@xyflow/react";
import { SOCKET_COLORS, NODE_SOCKETS, type SocketDef, type SocketType } from "./types"; import { NODE_SOCKETS, SOCKET_COLORS, type SocketDef } from "./types";
function SocketHandle({ function SocketHandle({
socket, socket,
@ -81,16 +81,12 @@ export function NodeShell({
{/* Input handles - positioned on left edge */} {/* Input handles - positioned on left edge */}
{inputs.map((socket, i) => { {inputs.map((socket, i) => {
const top = maxSlots === 1 ? 50 : (i / (maxSlots - 1)) * 80 + 10; const top = maxSlots === 1 ? 50 : (i / (maxSlots - 1)) * 80 + 10;
return ( return <SocketHandle key={socket.id} socket={socket} isInput={true} topPercent={top} />;
<SocketHandle key={socket.id} socket={socket} isInput={true} topPercent={top} />
);
})} })}
{/* Output handles - positioned on right edge */} {/* Output handles - positioned on right edge */}
{outputs.map((socket, i) => { {outputs.map((socket, i) => {
const top = maxSlots === 1 ? 50 : (i / (maxSlots - 1)) * 80 + 10; const top = maxSlots === 1 ? 50 : (i / (maxSlots - 1)) * 80 + 10;
return ( return <SocketHandle key={socket.id} socket={socket} isInput={false} topPercent={top} />;
<SocketHandle key={socket.id} socket={socket} isInput={false} topPercent={top} />
);
})} })}
{/* Content area with padding to avoid handle overlap */} {/* Content area with padding to avoid handle overlap */}
<div className="px-8">{children}</div> <div className="px-8">{children}</div>

View file

@ -6,7 +6,10 @@ import { NodeShell } from "./NodeShell";
export function NotNode(_props: NodeProps) { export function NotNode(_props: NodeProps) {
return ( return (
<NodeShell nodeType="not" className="bg-red-950/80 border border-red-500/50 rounded-lg shadow-md min-w-28"> <NodeShell
nodeType="not"
className="bg-red-950/80 border border-red-500/50 rounded-lg shadow-md min-w-28"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-red-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-red-500/30">
<ToggleLeftIcon className="size-4 text-red-400 shrink-0" /> <ToggleLeftIcon className="size-4 text-red-400 shrink-0" />
<span className="text-sm font-medium text-red-200 flex-1">NOT</span> <span className="text-sm font-medium text-red-200 flex-1">NOT</span>

View file

@ -8,7 +8,10 @@ export function NumberNode({ data }: NodeProps) {
const d = data as { value?: number; description?: string }; const d = data as { value?: number; description?: string };
return ( return (
<NodeShell nodeType="number" className="bg-purple-950/80 border border-purple-500/50 rounded-lg shadow-md min-w-32"> <NodeShell
nodeType="number"
className="bg-purple-950/80 border border-purple-500/50 rounded-lg shadow-md min-w-32"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-purple-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-purple-500/30">
<HashIcon className="size-4 text-purple-400 shrink-0" /> <HashIcon className="size-4 text-purple-400 shrink-0" />
<span className="text-sm font-medium text-purple-200 flex-1 truncate"> <span className="text-sm font-medium text-purple-200 flex-1 truncate">

View file

@ -13,7 +13,10 @@ export function OutcomeNode({ data }: NodeProps) {
const effects = d.effects ?? []; const effects = d.effects ?? [];
return ( return (
<NodeShell nodeType="outcome" className="bg-emerald-950/80 border border-emerald-500/50 rounded-lg shadow-md min-w-56"> <NodeShell
nodeType="outcome"
className="bg-emerald-950/80 border border-emerald-500/50 rounded-lg shadow-md min-w-56"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-emerald-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-emerald-500/30">
<FlagIcon className="size-4 text-emerald-400 shrink-0" /> <FlagIcon className="size-4 text-emerald-400 shrink-0" />
<span className="text-sm font-medium text-emerald-200 flex-1 truncate"> <span className="text-sm font-medium text-emerald-200 flex-1 truncate">

View file

@ -8,7 +8,10 @@ export function RandomNode({ data }: NodeProps) {
const d = data as { min?: number; max?: number; description?: string }; const d = data as { min?: number; max?: number; description?: string };
return ( return (
<NodeShell nodeType="random" className="bg-pink-950/80 border border-pink-500/50 rounded-lg shadow-md min-w-32"> <NodeShell
nodeType="random"
className="bg-pink-950/80 border border-pink-500/50 rounded-lg shadow-md min-w-32"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-pink-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-pink-500/30">
<ShuffleIcon className="size-4 text-pink-400 shrink-0" /> <ShuffleIcon className="size-4 text-pink-400 shrink-0" />
<span className="text-sm font-medium text-pink-200 flex-1 truncate"> <span className="text-sm font-medium text-pink-200 flex-1 truncate">

View file

@ -6,7 +6,10 @@ import { NodeShell } from "./NodeShell";
export function SequenceNode(_props: NodeProps) { export function SequenceNode(_props: NodeProps) {
return ( return (
<NodeShell nodeType="sequence" className="bg-fuchsia-950/80 border border-fuchsia-500/50 rounded-lg shadow-md min-w-32"> <NodeShell
nodeType="sequence"
className="bg-fuchsia-950/80 border border-fuchsia-500/50 rounded-lg shadow-md min-w-32"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-fuchsia-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-fuchsia-500/30">
<ListOrderedIcon className="size-4 text-fuchsia-400 shrink-0" /> <ListOrderedIcon className="size-4 text-fuchsia-400 shrink-0" />
<span className="text-sm font-medium text-fuchsia-200 flex-1">Sequence</span> <span className="text-sm font-medium text-fuchsia-200 flex-1">Sequence</span>

View file

@ -8,7 +8,10 @@ export function StringNode({ data }: NodeProps) {
const d = data as { value?: string; description?: string }; const d = data as { value?: string; description?: string };
return ( return (
<NodeShell nodeType="string" className="bg-teal-950/80 border border-teal-500/50 rounded-lg shadow-md min-w-32"> <NodeShell
nodeType="string"
className="bg-teal-950/80 border border-teal-500/50 rounded-lg shadow-md min-w-32"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-teal-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-teal-500/30">
<TypeIcon className="size-4 text-teal-400 shrink-0" /> <TypeIcon className="size-4 text-teal-400 shrink-0" />
<span className="text-sm font-medium text-teal-200 flex-1 truncate"> <span className="text-sm font-medium text-teal-200 flex-1 truncate">

View file

@ -8,7 +8,10 @@ export function WaitNode({ data }: NodeProps) {
const d = data as { ticks?: number; input?: unknown }; const d = data as { ticks?: number; input?: unknown };
return ( return (
<NodeShell nodeType="wait" className="bg-yellow-950/80 border border-yellow-500/50 rounded-lg shadow-md min-w-48"> <NodeShell
nodeType="wait"
className="bg-yellow-950/80 border border-yellow-500/50 rounded-lg shadow-md min-w-48"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-yellow-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-yellow-500/30">
<TimerIcon className="size-4 text-yellow-400 shrink-0" /> <TimerIcon className="size-4 text-yellow-400 shrink-0" />
<span className="text-sm font-medium text-yellow-200 flex-1 truncate"> <span className="text-sm font-medium text-yellow-200 flex-1 truncate">

View file

@ -10,9 +10,8 @@ export function LandingPage() {
<AppLogo /> <AppLogo />
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Classified operations portal for Polaris Task Force, an Arma 3 unit. Classified operations portal for Polaris Task Force, an Arma 3 unit. Log in to access the
Log in to access the operations dashboard, intelligence, and operations dashboard, intelligence, and logistics.
logistics.
</p> </p>
<Button asChild className="mt-2"> <Button asChild className="mt-2">
<Link href="/login">Log in to access the portal</Link> <Link href="/login">Log in to access the portal</Link>

View file

@ -4,12 +4,12 @@ import { NotificationsBell } from "@/components/frontend/notifications/Notificat
export const SiteHeader = () => { export const SiteHeader = () => {
return ( return (
<div className="flex items-center justify-between p-4 border-b border-gray-800"> <header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<div className="flex items-center gap-2"> <SidebarTrigger className="-ml-1" />
<SidebarTrigger /> <Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" /> <div className="ml-auto flex items-center">
<NotificationsBell />
</div> </div>
<NotificationsBell /> </header>
</div>
); );
}; };

View file

@ -29,9 +29,7 @@ export function LoginForm() {
const body = await res.json().catch(() => null); const body = await res.json().catch(() => null);
if (!res.ok) { if (!res.ok) {
setError( setError(body?.errors?.[0]?.message ?? "Invalid username or password.");
body?.errors?.[0]?.message ?? "Invalid username or password.",
);
return; return;
} }

View file

@ -12,11 +12,7 @@ import {
} from "@/components/ui/item"; } from "@/components/ui/item";
import { LandmarkIcon, UsersIcon, WalletIcon } from "lucide-react"; import { LandmarkIcon, UsersIcon, WalletIcon } from "lucide-react";
import { formatAmount } from "@/lib/banking/format"; import { formatAmount } from "@/lib/banking/format";
import { import { type AccountStatus, accountStatusBase, accountStatusClass } from "@/lib/banking/ui";
accountStatusBase,
accountStatusClass,
type AccountStatus,
} from "@/lib/banking/ui";
const accountTypeMeta = { const accountTypeMeta = {
treasury: { treasury: {

View file

@ -3,18 +3,9 @@
import Link from "next/link"; import Link from "next/link";
import { ArrowLeftIcon } from "lucide-react"; import { ArrowLeftIcon } from "lucide-react";
import type { BankAccount, LedgerEntry } from "@/payload-types"; import type { BankAccount, LedgerEntry } from "@/payload-types";
import { import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
Item,
ItemContent,
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
import { formatAmount } from "@/lib/banking/format"; import { formatAmount } from "@/lib/banking/format";
import { import { type AccountStatus, accountStatusBase, accountStatusClass } from "@/lib/banking/ui";
accountStatusBase,
accountStatusClass,
type AccountStatus,
} from "@/lib/banking/ui";
import { accountTypeInfo } from "@/components/frontend/banking/AccountCard"; import { accountTypeInfo } from "@/components/frontend/banking/AccountCard";
import { BankTransactionDialog } from "@/components/frontend/banking/BankTransactionDialog"; import { BankTransactionDialog } from "@/components/frontend/banking/BankTransactionDialog";
import { LedgerTable } from "@/components/frontend/banking/LedgerTable"; import { LedgerTable } from "@/components/frontend/banking/LedgerTable";
@ -26,12 +17,7 @@ interface AccountDetailProps {
canTransact: boolean; canTransact: boolean;
} }
export function AccountDetail({ export function AccountDetail({ account, ledger, otherAccounts, canTransact }: AccountDetailProps) {
account,
ledger,
otherAccounts,
canTransact,
}: AccountDetailProps) {
const meta = accountTypeInfo(account.accountType); const meta = accountTypeInfo(account.accountType);
const Icon = meta.icon; const Icon = meta.icon;
const currency = typeof account.currency === "object" ? account.currency : null; const currency = typeof account.currency === "object" ? account.currency : null;
@ -85,10 +71,7 @@ export function AccountDetail({
</div> </div>
{canTransact && ( {canTransact && (
<BankTransactionDialog <BankTransactionDialog accountId={account.id} otherAccounts={otherAccounts} />
accountId={account.id}
otherAccounts={otherAccounts}
/>
)} )}
<Item variant="outline"> <Item variant="outline">

View file

@ -2,19 +2,15 @@
import Link from "next/link"; import Link from "next/link";
import type { BankAccount, BankTransaction, Resource, User } from "@/payload-types"; import type { BankAccount, BankTransaction, Resource, User } from "@/payload-types";
import { import { ArrowDownLeftIcon, ArrowRightLeftIcon, ArrowUpRightIcon } from "lucide-react";
ArrowDownLeftIcon,
ArrowRightLeftIcon,
ArrowUpRightIcon,
} from "lucide-react";
import { currencyLabel } from "@/lib/banking/format"; import { currencyLabel } from "@/lib/banking/format";
import { import {
formatTxTimestamp, formatTxTimestamp,
txAmountClass, txAmountClass,
txAmountPrefix, txAmountPrefix,
type TxFlow,
txTypeColor, txTypeColor,
txTypeFlow, txTypeFlow,
type TxFlow,
} from "@/lib/banking/ui"; } from "@/lib/banking/ui";
interface ActivityFeedProps { interface ActivityFeedProps {
@ -45,9 +41,10 @@ const txTypeLabel: Record<string, string> = {
adjustment: "ADJUST", adjustment: "ADJUST",
}; };
function resolveAccountLabel( function resolveAccountLabel(account: (number | null) | BankAccount | undefined): {
account: (number | null) | BankAccount | undefined, label: string;
): { label: string; id: number | null } { id: number | null;
} {
if (!account) return { label: "—", id: null }; if (!account) return { label: "—", id: null };
if (typeof account === "object") { if (typeof account === "object") {
return { label: account.name ?? `#${account.id}`, id: account.id }; return { label: account.name ?? `#${account.id}`, id: account.id };
@ -88,10 +85,7 @@ export function ActivityFeed({ transactions, currencyResource }: ActivityFeedPro
const to = resolveAccountLabel(tx.toAccount); const to = resolveAccountLabel(tx.toAccount);
const actor = resolveActorLabel(tx.actor); const actor = resolveActorLabel(tx.actor);
const targetAccountId = to.id ?? from.id; const targetAccountId = to.id ?? from.id;
const href = const href = targetAccountId != null ? `/logistics/banking/${targetAccountId}` : null;
targetAccountId != null
? `/logistics/banking/${targetAccountId}`
: null;
const amount = tx.amount ?? 0; const amount = tx.amount ?? 0;
const signed = `${txAmountPrefix(flow)}${amount.toLocaleString()}`; const signed = `${txAmountPrefix(flow)}${amount.toLocaleString()}`;
@ -113,23 +107,15 @@ export function ActivityFeed({ transactions, currencyResource }: ActivityFeedPro
{to.label} {to.label}
</div> </div>
{tx.memo && ( {tx.memo && (
<p className="text-[11px] text-muted-foreground/70 italic truncate"> <p className="text-[11px] text-muted-foreground/70 italic truncate">{tx.memo}</p>
{tx.memo}
</p>
)}
{actor && (
<p className="text-[10px] text-muted-foreground/50">by {actor}</p>
)} )}
{actor && <p className="text-[10px] text-muted-foreground/50">by {actor}</p>}
</div> </div>
<div <div
className={`font-mono text-sm tabular-nums shrink-0 ml-auto pr-1 ${txAmountClass(flow)}`} className={`font-mono text-sm tabular-nums shrink-0 ml-auto pr-1 ${txAmountClass(flow)}`}
> >
{signed} {signed}
{cur && ( {cur && <span className="ml-1 text-[10px] text-muted-foreground/60">{cur}</span>}
<span className="ml-1 text-[10px] text-muted-foreground/60">
{cur}
</span>
)}
</div> </div>
</div> </div>
); );

View file

@ -59,10 +59,7 @@ interface BankTransactionDialogProps {
otherAccounts: { id: number; name: string }[]; otherAccounts: { id: number; name: string }[];
} }
export function BankTransactionDialog({ export function BankTransactionDialog({ accountId, otherAccounts }: BankTransactionDialogProps) {
accountId,
otherAccounts,
}: BankTransactionDialogProps) {
const router = useRouter(); const router = useRouter();
const [mode, setMode] = useState<Mode>("deposit"); const [mode, setMode] = useState<Mode>("deposit");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);

View file

@ -3,12 +3,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { BankAccount, BankTransaction, Resource } from "@/payload-types"; import type { BankAccount, BankTransaction, Resource } from "@/payload-types";
import { LandmarkIcon } from "lucide-react"; import { LandmarkIcon } from "lucide-react";
import { import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "@/components/ui/tabs";
import { AccountCard } from "@/components/frontend/banking/AccountCard"; import { AccountCard } from "@/components/frontend/banking/AccountCard";
import { CreateAccountDialog } from "@/components/frontend/banking/CreateAccountDialog"; import { CreateAccountDialog } from "@/components/frontend/banking/CreateAccountDialog";
import { WalletHero } from "@/components/frontend/banking/WalletHero"; import { WalletHero } from "@/components/frontend/banking/WalletHero";
@ -46,9 +41,7 @@ function Section({
{count === 0 ? ( {count === 0 ? (
<p className="text-sm text-muted-foreground italic">None.</p> <p className="text-sm text-muted-foreground italic">None.</p>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">{children}</div>
{children}
</div>
)} )}
</section> </section>
); );
@ -69,18 +62,13 @@ export function BankingOverview({
const myAccount = currentUser const myAccount = currentUser
? personal.find((a) => { ? personal.find((a) => {
if (!a.ownerUser) return false; if (!a.ownerUser) return false;
const ownerId = const ownerId = typeof a.ownerUser === "object" ? a.ownerUser.id : a.ownerUser;
typeof a.ownerUser === "object" ? a.ownerUser.id : a.ownerUser;
return ownerId === currentUser.id; return ownerId === currentUser.id;
}) })
: undefined; : undefined;
// Regular users see only their own personal account; managers see everything. // Regular users see only their own personal account; managers see everything.
const visiblePersonal = isManager const visiblePersonal = isManager ? personal : myAccount ? [myAccount] : [];
? personal
: myAccount
? [myAccount]
: [];
const unitFundsTotal = [...treasury, ...factionAccounts].reduce( const unitFundsTotal = [...treasury, ...factionAccounts].reduce(
(sum, a) => sum + (a.balance ?? 0), (sum, a) => sum + (a.balance ?? 0),
@ -88,8 +76,7 @@ export function BankingOverview({
); );
const visibleAccountsCount = const visibleAccountsCount =
(isManager ? treasury.length + factionAccounts.length : 0) + (isManager ? treasury.length + factionAccounts.length : 0) + visiblePersonal.length;
visiblePersonal.length;
const tickerCurrency = (label: string): string => const tickerCurrency = (label: string): string =>
label.length > 8 ? `${label.slice(0, 8)}…` : label; label.length > 8 ? `${label.slice(0, 8)}…` : label;
@ -104,10 +91,7 @@ export function BankingOverview({
<Tabs defaultValue="activity" className="gap-4"> <Tabs defaultValue="activity" className="gap-4">
<div className="flex flex-wrap items-end justify-between gap-2"> <div className="flex flex-wrap items-end justify-between gap-2">
<TabsList <TabsList variant="line" className="grid w-full max-w-sm grid-cols-2">
variant="line"
className="grid w-full max-w-sm grid-cols-2"
>
<TabsTrigger <TabsTrigger
value="activity" value="activity"
className="font-mono text-[10px] uppercase tracking-wider" className="font-mono text-[10px] uppercase tracking-wider"
@ -131,10 +115,7 @@ export function BankingOverview({
</div> </div>
<TabsContent value="activity" className="m-0"> <TabsContent value="activity" className="m-0">
<ActivityFeed <ActivityFeed transactions={recentTransactions} currencyResource={currencyResource} />
transactions={recentTransactions}
currencyResource={currencyResource}
/>
</TabsContent> </TabsContent>
<TabsContent value="accounts" className="m-0 flex flex-col gap-6"> <TabsContent value="accounts" className="m-0 flex flex-col gap-6">

View file

@ -94,10 +94,7 @@ export function CreateAccountDialog({ isManager, factions }: CreateAccountDialog
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label>Account type</Label> <Label>Account type</Label>
<Select <Select value={accountType} onValueChange={(v) => setAccountType(v as AccountType)}>
value={accountType}
onValueChange={(v) => setAccountType(v as AccountType)}
>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select type" /> <SelectValue placeholder="Select type" />
</SelectTrigger> </SelectTrigger>

View file

@ -20,9 +20,7 @@ const txTypeLabel: Record<string, string> = {
export function LedgerTable({ entries }: { entries: LedgerEntry[] }) { export function LedgerTable({ entries }: { entries: LedgerEntry[] }) {
if (entries.length === 0) { if (entries.length === 0) {
return ( return <p className="text-sm text-muted-foreground italic">No ledger entries yet.</p>;
<p className="text-sm text-muted-foreground italic">No ledger entries yet.</p>
);
} }
return ( return (
@ -59,17 +57,11 @@ export function LedgerTable({ entries }: { entries: LedgerEntry[] }) {
// entry's actual sign — it reflects the perspective of THIS // entry's actual sign — it reflects the perspective of THIS
// account, which the canonical flow cannot know. // account, which the canonical flow cannot know.
const useEntrySign = amount !== 0 && Math.sign(amount) < 0; const useEntrySign = amount !== 0 && Math.sign(amount) < 0;
const prefix = useEntrySign const prefix = useEntrySign ? txAmountPrefix("out") : txAmountPrefix(flow);
? txAmountPrefix("out") const amountCls = useEntrySign ? txAmountClass("out") : txAmountClass(flow);
: txAmountPrefix(flow);
const amountCls = useEntrySign
? txAmountClass("out")
: txAmountClass(flow);
return ( return (
<tr key={entry.id} className="border-b border-border/50"> <tr key={entry.id} className="border-b border-border/50">
<td className="px-4 py-2 text-muted-foreground"> <td className="px-4 py-2 text-muted-foreground">{formatDate(entry.timestamp)}</td>
{formatDate(entry.timestamp)}
</td>
<td className={`px-4 py-2 ${colorCls} font-medium`}>{label}</td> <td className={`px-4 py-2 ${colorCls} font-medium`}>{label}</td>
<td className="px-4 py-2 text-muted-foreground">{entry.memo}</td> <td className="px-4 py-2 text-muted-foreground">{entry.memo}</td>
<td className={`px-4 py-2 text-right font-mono ${amountCls}`}> <td className={`px-4 py-2 text-right font-mono ${amountCls}`}>

View file

@ -30,16 +30,9 @@ export function MyWalletCard() {
<WalletIcon className="size-4 text-emerald-400" /> <WalletIcon className="size-4 text-emerald-400" />
<p className="text-sm font-semibold">My wallet</p> <p className="text-sm font-semibold">My wallet</p>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">You don&apos;t have a personal account yet.</p>
You don&apos;t have a personal account yet.
</p>
{error && <p className="text-xs text-red-400">{error}</p>} {error && <p className="text-xs text-red-400">{error}</p>}
<Button <Button size="sm" className="w-fit" onClick={handleCreate} disabled={loading}>
size="sm"
className="w-fit"
onClick={handleCreate}
disabled={loading}
>
{loading ? "Creating..." : "Create wallet"} {loading ? "Creating..." : "Create wallet"}
</Button> </Button>
</div> </div>

View file

@ -8,10 +8,10 @@ import type { BankAccount, Resource } from "@/payload-types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { formatAmount } from "@/lib/banking/format"; import { formatAmount } from "@/lib/banking/format";
import { import {
type AccountStatus,
accountStatusBase, accountStatusBase,
accountStatusClass, accountStatusClass,
formatLastActivity, formatLastActivity,
type AccountStatus,
} from "@/lib/banking/ui"; } from "@/lib/banking/ui";
import { BankTransactionDialog } from "@/components/frontend/banking/BankTransactionDialog"; import { BankTransactionDialog } from "@/components/frontend/banking/BankTransactionDialog";
import { ensureMyAccount } from "@/app/(frontend)/logistics/banking/actions"; import { ensureMyAccount } from "@/app/(frontend)/logistics/banking/actions";
@ -25,11 +25,7 @@ interface WalletHeroProps {
// Shared terminal-aesthetic class for small uppercase mono labels in the hero. // Shared terminal-aesthetic class for small uppercase mono labels in the hero.
const labelCls = "font-mono text-[10px] uppercase tracking-wider text-muted-foreground"; const labelCls = "font-mono text-[10px] uppercase tracking-wider text-muted-foreground";
export function WalletHero({ export function WalletHero({ account, currentUser, currencyResource }: WalletHeroProps) {
account,
currentUser,
currencyResource,
}: WalletHeroProps) {
const router = useRouter(); const router = useRouter();
const [initializing, setInitializing] = useState(false); const [initializing, setInitializing] = useState(false);
const [initError, setInitError] = useState<string | null>(null); const [initError, setInitError] = useState<string | null>(null);
@ -56,9 +52,7 @@ export function WalletHero({
<WalletIcon className="size-4 text-blue-400/80" /> <WalletIcon className="size-4 text-blue-400/80" />
<span className={labelCls}>&lt;OPERATOR WALLET&gt;</span> <span className={labelCls}>&lt;OPERATOR WALLET&gt;</span>
</div> </div>
<p className="font-mono text-sm text-muted-foreground/80"> <p className="font-mono text-sm text-muted-foreground/80">{"> WALLET NOT INITIALIZED"}</p>
{"> WALLET NOT INITIALIZED"}
</p>
<p className="font-mono text-xs text-muted-foreground/60"> <p className="font-mono text-xs text-muted-foreground/60">
Run initialization to provision your personal account. Run initialization to provision your personal account.
</p> </p>
@ -85,8 +79,7 @@ export function WalletHero({
const accountId = account.id; const accountId = account.id;
const lastActivity = formatLastActivity(account.updatedAt); const lastActivity = formatLastActivity(account.updatedAt);
const currency = const currency = typeof account.currency === "object" ? account.currency : currencyResource;
typeof account.currency === "object" ? account.currency : currencyResource;
const operatorLabel = currentUser.displayName ?? currentUser.username; const operatorLabel = currentUser.displayName ?? currentUser.username;
const statusKey = (account.status ?? "open") as AccountStatus; const statusKey = (account.status ?? "open") as AccountStatus;
@ -106,9 +99,7 @@ export function WalletHero({
<span className={labelCls}>&lt;OPERATOR WALLET&gt;</span> <span className={labelCls}>&lt;OPERATOR WALLET&gt;</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-mono text-sm text-muted-foreground/90"> <span className="font-mono text-sm text-muted-foreground/90">@{operatorLabel}</span>
@{operatorLabel}
</span>
<span <span
className={`${accountStatusBase} ${accountStatusClass[statusKey] ?? accountStatusClass.open}`} className={`${accountStatusBase} ${accountStatusClass[statusKey] ?? accountStatusClass.open}`}
> >
@ -131,10 +122,7 @@ export function WalletHero({
</div> </div>
<div className="flex flex-wrap items-center gap-2 pt-3 border-t border-blue-500/15"> <div className="flex flex-wrap items-center gap-2 pt-3 border-t border-blue-500/15">
<BankTransactionDialog <BankTransactionDialog accountId={accountId} otherAccounts={otherAccounts} />
accountId={accountId}
otherAccounts={otherAccounts}
/>
<Link href={`/logistics/banking/${accountId}`} prefetch={false}> <Link href={`/logistics/banking/${accountId}`} prefetch={false}>
<Button <Button
size="sm" size="sm"

View file

@ -1,7 +1,17 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { Bolt, Send, Shield, Users, UserCog, Warehouse, Crosshair, Backpack, LifeBuoy } from "lucide-react"; import {
Backpack,
Bolt,
Crosshair,
LifeBuoy,
Send,
Shield,
UserCog,
Users,
Warehouse,
} 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";

View file

@ -1,13 +1,9 @@
"use client" "use client";
import Link from "next/link" import Link from "next/link";
import { ChevronRight, type LucideIcon } from "lucide-react" import { ChevronRight, type LucideIcon } from "lucide-react";
import { import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { import {
SidebarGroup, SidebarGroup,
SidebarGroupLabel, SidebarGroupLabel,
@ -17,24 +13,24 @@ import {
SidebarMenuSub, SidebarMenuSub,
SidebarMenuSubButton, SidebarMenuSubButton,
SidebarMenuSubItem, SidebarMenuSubItem,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar";
export function NavCore({ export function NavCore({
label, label,
items, items,
}: { }: {
label: string, label: string;
items: { items: {
title: string title: string;
url: string url: string;
icon: LucideIcon icon: LucideIcon;
isActive?: boolean isActive?: boolean;
items?: { items?: {
title: string title: string;
url: string url: string;
icon?: LucideIcon icon?: LucideIcon;
}[] }[];
}[] }[];
}) { }) {
return ( return (
<SidebarGroup> <SidebarGroup>

View file

@ -1,13 +1,7 @@
"use client" "use client";
import Link from "next/link" import Link from "next/link";
import { import { Folder, type LucideIcon, MoreHorizontal, Share, Trash2 } from "lucide-react";
Folder,
MoreHorizontal,
Share,
Trash2,
type LucideIcon,
} from "lucide-react"
import { import {
DropdownMenu, DropdownMenu,
@ -15,7 +9,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu";
import { import {
SidebarGroup, SidebarGroup,
SidebarGroupLabel, SidebarGroupLabel,
@ -24,18 +18,18 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
useSidebar, useSidebar,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar";
export function NavProjects({ export function NavProjects({
projects, projects,
}: { }: {
projects: { projects: {
name: string name: string;
url: string url: string;
icon: LucideIcon icon: LucideIcon;
}[] }[];
}) { }) {
const { isMobile } = useSidebar() const { isMobile } = useSidebar();
return ( return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden"> <SidebarGroup className="group-data-[collapsible=icon]:hidden">
@ -86,5 +80,5 @@ export function NavProjects({
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
) );
} }

View file

@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import Link from "next/link" import Link from "next/link";
import { type LucideIcon } from "lucide-react" import { type LucideIcon } from "lucide-react";
import { import {
SidebarGroup, SidebarGroup,
@ -8,17 +8,17 @@ import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar";
export function NavSecondary({ export function NavSecondary({
items, items,
...props ...props
}: { }: {
items: { items: {
title: string title: string;
url: string url: string;
icon: LucideIcon icon: LucideIcon;
}[] }[];
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) { } & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
return ( return (
<SidebarGroup {...props}> <SidebarGroup {...props}>
@ -37,5 +37,5 @@ export function NavSecondary({
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
) );
} }

View file

@ -1,15 +1,6 @@
"use client"; "use client";
import { import { BadgeCheck, Bird, ChevronsUpDown, FileTextIcon, LogOut } from "lucide-react";
BadgeCheck,
Bell,
Bird,
ChevronsUpDown,
CreditCard,
FileTextIcon,
LogOut,
Sparkles,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";

View file

@ -15,9 +15,7 @@ const XPDisplay = ({ xpLevel }: { xpLevel: ResolvedLevel }) => {
<SparkleIcon /> <SparkleIcon />
<div className="flex flex-col items-end"> <div className="flex flex-col items-end">
<span className="uppercase">{levelName}</span> <span className="uppercase">{levelName}</span>
<span className="text-2xl font-bold uppercase"> <span className="text-2xl font-bold uppercase">{xp.toLocaleString()}</span>
{xp.toLocaleString()}
</span>
</div> </div>
</ItemTitle> </ItemTitle>
</ItemHeader> </ItemHeader>

View file

@ -1,11 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
import { ChevronRightIcon, FlagIcon } from "lucide-react"; import { ChevronRightIcon, FlagIcon } from "lucide-react";
interface CampaignEntry { interface CampaignEntry {
@ -35,9 +29,7 @@ export function ActiveCampaigns({ campaigns }: ActiveCampaignsProps) {
</div> </div>
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<p className="text-sm text-muted-foreground italic"> <p className="text-sm text-muted-foreground italic">No active campaigns.</p>
No active campaigns.
</p>
</ItemContent> </ItemContent>
</Item> </Item>
); );
@ -77,9 +69,7 @@ export function ActiveCampaigns({ campaigns }: ActiveCampaignsProps) {
{campaign.status === "inProgress" ? "Active" : campaign.status} {campaign.status === "inProgress" ? "Active" : campaign.status}
</span> </span>
</div> </div>
<span className="text-xs text-muted-foreground truncate"> <span className="text-xs text-muted-foreground truncate">{campaign.summary}</span>
{campaign.summary}
</span>
</div> </div>
<ChevronRightIcon className="size-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" /> <ChevronRightIcon className="size-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</Link> </Link>

View file

@ -1,20 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import type { Campaign, Map } from "@/payload-types"; import type { Campaign, Map } from "@/payload-types";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { import { CalendarDaysIcon, ClockIcon, CrosshairIcon, MapPinIcon, UsersIcon } from "lucide-react";
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
import {
CalendarDaysIcon,
ClockIcon,
CrosshairIcon,
MapPinIcon,
UsersIcon,
} from "lucide-react";
interface MissionBriefingProps { interface MissionBriefingProps {
mission: { mission: {
@ -60,18 +47,14 @@ export function MissionBriefing({ mission }: MissionBriefingProps) {
</div> </div>
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<p className="text-sm text-muted-foreground italic"> <p className="text-sm text-muted-foreground italic">No missions currently scheduled.</p>
No missions currently scheduled.
</p>
</ItemContent> </ItemContent>
</Item> </Item>
); );
} }
const map = const map =
typeof mission.classification.map === "object" typeof mission.classification.map === "object" ? (mission.classification.map as Map) : null;
? (mission.classification.map as Map)
: null;
const campaign = const campaign =
typeof mission.classification.campaign === "object" typeof mission.classification.campaign === "object"
? (mission.classification.campaign as Campaign) ? (mission.classification.campaign as Campaign)
@ -89,10 +72,7 @@ export function MissionBriefing({ mission }: MissionBriefingProps) {
}); });
const durationHours = Math.floor(mission.classification.estimatedDuration / 60); const durationHours = Math.floor(mission.classification.estimatedDuration / 60);
const durationMins = mission.classification.estimatedDuration % 60; const durationMins = mission.classification.estimatedDuration % 60;
const durationStr = const durationStr = durationMins > 0 ? `${durationHours}h ${durationMins}m` : `${durationHours}h`;
durationMins > 0
? `${durationHours}h ${durationMins}m`
: `${durationHours}h`;
return ( return (
<Item variant="outline"> <Item variant="outline">
@ -112,9 +92,7 @@ export function MissionBriefing({ mission }: MissionBriefingProps) {
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1 min-w-0"> <div className="flex flex-col gap-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground"> <span className="font-mono text-xs text-muted-foreground">{mission.codeName}</span>
{mission.codeName}
</span>
<span <span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[mission.ownershipAndStatus.status] ?? "bg-muted text-muted-foreground"}`} className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[mission.ownershipAndStatus.status] ?? "bg-muted text-muted-foreground"}`}
> >
@ -125,9 +103,7 @@ export function MissionBriefing({ mission }: MissionBriefingProps) {
</div> </div>
</div> </div>
<p className="text-sm text-muted-foreground line-clamp-2"> <p className="text-sm text-muted-foreground line-clamp-2">{mission.summary}</p>
{mission.summary}
</p>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
@ -150,9 +126,7 @@ export function MissionBriefing({ mission }: MissionBriefingProps) {
{mission.missionRoles.maxPlayers} slots {mission.missionRoles.maxPlayers} slots
</span> </span>
)} )}
{campaign && ( {campaign && <span className="text-primary">{campaign.name}</span>}
<span className="text-primary">{campaign.name}</span>
)}
</div> </div>
</ItemContent> </ItemContent>
</Item> </Item>

View file

@ -1,14 +1,8 @@
import Link from "next/link"; import Link from "next/link";
import type { Rank, Media, Assignment } from "@/payload-types"; import type { Assignment, Rank } from "@/payload-types";
import type { ResolvedLevel } from "@/utils/xp/resolveLevel"; import type { ResolvedLevel } from "@/utils/xp/resolveLevel";
import { import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
Item, import { UserIcon } from "lucide-react";
ItemContent,
ItemDescription,
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
import { BadgeCheckIcon, ShieldIcon, SparkleIcon, UserIcon } from "lucide-react";
interface ProfileSummaryProps { interface ProfileSummaryProps {
displayName: string; displayName: string;
@ -49,9 +43,7 @@ export function ProfileSummary({
/> />
)} )}
<div className="flex flex-col gap-1 min-w-0"> <div className="flex flex-col gap-1 min-w-0">
<h3 className="text-lg font-bold tracking-tight uppercase"> <h3 className="text-lg font-bold tracking-tight uppercase">{displayName}</h3>
{displayName}
</h3>
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{rank.name}</span> <span className="font-medium text-foreground">{rank.name}</span>
<span className="text-border">|</span> <span className="text-border">|</span>
@ -83,9 +75,7 @@ export function ProfileSummary({
> >
{a.callsign ? `[${a.callsign}] ` : ""} {a.callsign ? `[${a.callsign}] ` : ""}
{a.name} {a.name}
<span className="text-muted-foreground ml-1.5"> <span className="text-muted-foreground ml-1.5">({a.type})</span>
({a.type})
</span>
</span> </span>
))} ))}
</div> </div>

View file

@ -1,16 +1,5 @@
import { import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@/components/ui/item";
Item, import { Building2Icon, CrosshairIcon, ShieldIcon, UsersIcon } from "lucide-react";
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import {
Building2Icon,
CrosshairIcon,
ShieldIcon,
UsersIcon,
} from "lucide-react";
interface QuickStatsProps { interface QuickStatsProps {
activeCampaigns: number; activeCampaigns: number;

View file

@ -1,11 +1,5 @@
import type { User, GameStructure } from "@/payload-types"; import type { GameStructure, User } from "@/payload-types";
import { import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
import { BookOpenTextIcon, ScrollTextIcon } from "lucide-react"; import { BookOpenTextIcon, ScrollTextIcon } from "lucide-react";
import { formatType } from "@/utils/event-log/formatType"; import { formatType } from "@/utils/event-log/formatType";
@ -48,9 +42,7 @@ export function RecentEvents({ events, currentUserId }: RecentEventsProps) {
</div> </div>
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<p className="text-sm text-muted-foreground italic"> <p className="text-sm text-muted-foreground italic">No recent events recorded.</p>
No recent events recorded.
</p>
</ItemContent> </ItemContent>
</Item> </Item>
); );
@ -71,12 +63,9 @@ export function RecentEvents({ events, currentUserId }: RecentEventsProps) {
typeof event.actor === "object" && event.actor !== null typeof event.actor === "object" && event.actor !== null
? (event.actor as User) ? (event.actor as User)
: null; : null;
const actorId = const actorId = actor?.id ?? (typeof event.actor === "number" ? event.actor : null);
actor?.id ??
(typeof event.actor === "number" ? event.actor : null);
const isYou = actorId != null && actorId === currentUserId; const isYou = actorId != null && actorId === currentUserId;
const actorName = const actorName = actor?.displayName ?? actor?.username ?? null;
actor?.displayName ?? actor?.username ?? null;
const actorLabel = isYou ? "You" : actorName; const actorLabel = isYou ? "You" : actorName;
const isNarrative = event.system === false; const isNarrative = event.system === false;
@ -90,9 +79,7 @@ export function RecentEvents({ events, currentUserId }: RecentEventsProps) {
)} )}
<div className="flex-1 min-w-0 flex flex-col gap-0.5"> <div className="flex-1 min-w-0 flex flex-col gap-0.5">
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="tabular-nums shrink-0"> <span className="tabular-nums shrink-0">{timeAgo(event.timestamp)}</span>
{timeAgo(event.timestamp)}
</span>
{actorLabel && ( {actorLabel && (
<span className={`font-medium ${isYou ? "text-foreground" : ""}`}> <span className={`font-medium ${isYou ? "text-foreground" : ""}`}>
{actorLabel} {actorLabel}

Some files were not shown because too many files have changed in this diff Show more