1
0
Fork 0

Compare commits

..

No commits in common. "50bc0e3441db15cb862539988f86741fc6709a5e" and "4905022dd5ffbf882936a30ec018f1cbb2819e9d" have entirely different histories.

223 changed files with 3268 additions and 18829 deletions

View file

@ -1,23 +0,0 @@
#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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

View file

@ -9,8 +9,12 @@ 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;
@ -32,10 +36,7 @@ async function authenticate() {
return { payload, user }; return { payload, user };
} }
function displayNameOf(user: { function displayNameOf(user: { payloadDisplayName?: string | null; username?: string | null }): string {
payloadDisplayName?: string | null;
username?: string | null;
}): string {
return user.payloadDisplayName || user.username || "A member"; return user.payloadDisplayName || user.username || "A member";
} }
@ -168,7 +169,9 @@ 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 ? { activity: newActivity, status: "open" } : { activity: newActivity }, data: reopen
? { activity: newActivity, status: "open" }
: { activity: newActivity },
overrideAccess: true, overrideAccess: true,
depth: 0, depth: 0,
}); });
@ -226,10 +229,7 @@ export async function replyToTicket(ticketId: number, content: string): Promise<
} }
} }
export async function updateTicketStatus( export async function updateTicketStatus(ticketId: number, status: TicketStatus): Promise<ActionResult> {
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,7 +245,9 @@ export async function updateTicketStatus(
const reporterId = reporterIdOf(ticket); const reporterId = reporterIdOf(ticket);
if (!isStaff) { if (!isStaff) {
const reporterCancel = const reporterCancel =
reporterId === userId && status === "canceled" && !CLOSING_STATUSES.includes(ticket.status); reporterId === userId &&
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.");
} }
@ -291,10 +293,7 @@ export async function updateTicketStatus(
} }
} }
export async function assignTicket( export async function assignTicket(ticketId: number, assigneeId: number | null): Promise<ActionResult> {
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,12 +2,13 @@ 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 } from "@/payload-types"; import type { Campaign, 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 { BankAccount, Campaign, Faction, Media, Mission, Resource } from "@/payload-types"; import type { Faction, Media, Mission, BankAccount, Resource, Campaign } 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,
ScaleIcon,
ShieldIcon, ShieldIcon,
ScaleIcon,
} 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 the theater of {factions.length} faction{factions.length !== 1 ? "s" : ""} tracked in
operations. the theater of operations.
</p> </p>
</div> </div>

View file

@ -3,18 +3,35 @@ 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 { Campaign, Map, Media, Mission } from "@/payload-types"; import type {
Mission,
Campaign,
Map,
User,
Faction,
Qualification,
Rank,
Media,
} from "@/payload-types";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
ArrowRightIcon,
CalendarDaysIcon, CalendarDaysIcon,
ChevronRightIcon, ChevronRightIcon,
ClockIcon, ClockIcon,
CrosshairIcon,
FlagIcon,
MapPinIcon, MapPinIcon,
CrosshairIcon,
ServerIcon, ServerIcon,
FlagIcon,
} from "lucide-react"; } from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import {
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";
@ -23,10 +40,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 type { AttendanceResponse } from "@/lib/attendance";
import { setMissionAttendance as setAttendance } from "@/lib/attendance"; import { setMissionAttendance as setAttendance } from "@/lib/attendance";
import type { AttendanceResponse } 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 { Campaign, Mission } from "@/payload-types"; import type { Mission, Campaign, Map } 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,10 +3,16 @@ 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, Faction } from "@/payload-types"; import type { Campaign, Mission, Faction } from "@/payload-types";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification"; import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
import { ArrowRightIcon, CrosshairIcon } from "lucide-react"; import { CrosshairIcon, ArrowRightIcon } from "lucide-react";
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item"; import {
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 = {
@ -49,7 +55,8 @@ 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 operations. Campaigns, missions, and faction intelligence for the theater of
operations.
</p> </p>
</div> </div>
@ -64,7 +71,9 @@ 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>Active and completed campaigns across all theaters.</ItemDescription> <ItemDescription>
Active and completed campaigns across all theaters.
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
</Link> </Link>
@ -78,7 +87,9 @@ 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>Operation briefings, objectives, and schedules.</ItemDescription> <ItemDescription>
Operation briefings, objectives, and schedules.
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
</Link> </Link>
@ -93,7 +104,8 @@ export default async function IntelligenceOverviewPage() {
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<ItemDescription> <ItemDescription>
{factions.length} faction{factions.length !== 1 ? "s" : ""} tracked in the AO. {factions.length} faction{factions.length !== 1 ? "s" : ""}{" "}
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 { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { 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 { type ResolvedLevel, resolveLevel } from "@/utils/xp/resolveLevel"; import { resolveLevel, type ResolvedLevel } 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,13 +26,6 @@ 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 }) {
@ -105,10 +98,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"
/> />
<SidebarInset> <main className="min-h-full bg-background w-full">
<SiteHeader /> <SiteHeader />
{children} {children}
</SidebarInset> </main>
</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,6 +23,7 @@ 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> {
@ -218,8 +219,7 @@ 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 = const removedName = typeof asset === "object" && asset ? asset.name : `asset #${resolveAssetId(removed.asset)}`;
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,12 +236,7 @@ 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: { data: { entryId, assetId: resolveAssetId(removed.asset), assetName: removedName, quantity: removed.quantity },
entryId,
assetId: resolveAssetId(removed.asset),
assetName: removedName,
quantity: removed.quantity,
},
}); });
return { success: true }; return { success: true };
@ -274,33 +269,19 @@ 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 ( if (gridX < 0 || gridY < 0 || gridX + fp.width > grid.width || gridY + fp.height > grid.height) {
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 ( if (!isLockerPlacementValid(gridItems, entryId, gridX, gridY, fp.width, fp.height, grid.width, grid.height)) {
!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 ? { ...entry, gridX, gridY, rotated } : { ...entry, gridX, gridY }; rotated !== undefined
? { ...entry, gridX, gridY, rotated }
: { ...entry, gridX, gridY };
await payload.update({ await payload.update({
collection: "locker-storages", collection: "locker-storages",
@ -316,14 +297,7 @@ export async function moveLockerItem(
actor: user.id, actor: user.id,
targetCollection: "locker-storages", targetCollection: "locker-storages",
targetId: locker.id, targetId: locker.id,
data: { data: { entryId, assetId: asset.id, assetName: asset.name, gridX, gridY, rotated: effectiveRotated },
entryId,
assetId: asset.id,
assetName: asset.name,
gridX,
gridY,
rotated: effectiveRotated,
},
}); });
return { success: true }; return { success: true };
@ -347,8 +321,7 @@ 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)) if (!lockerIsRotatable(asset)) return { success: false, error: "This asset cannot be rotated." };
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);
@ -358,18 +331,7 @@ export async function rotateLockerItem(entryId: string): Promise<ActionResult> {
} }
const gridItems = toLockerGridItems(items); const gridItems = toLockerGridItems(items);
if ( if (!isLockerPlacementValid(gridItems, entryId, entry.gridX, entry.gridY, fp.width, fp.height, grid.width, grid.height)) {
!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." };
} }
@ -400,7 +362,10 @@ export async function rotateLockerItem(entryId: string): Promise<ActionResult> {
} }
} }
export async function mergeLockerStacks(sourceId: string, targetId: string): Promise<ActionResult> { export async function mergeLockerStacks(
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);
@ -420,8 +385,7 @@ export async function mergeLockerStacks(sourceId: string, targetId: string): Pro
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)) if (!lockerIsStackable(tgtAsset)) return { success: false, error: "This asset cannot be stacked." };
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);
@ -462,10 +426,7 @@ export async function mergeLockerStacks(sourceId: string, targetId: string): Pro
} }
} }
export async function splitLockerStack( export async function splitLockerStack(entryId: string, splitAmount: number): Promise<ActionResult> {
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) {
@ -484,8 +445,7 @@ export async function splitLockerStack(
} }
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)) if (!lockerIsStackable(asset)) return { success: false, error: "This asset cannot be stacked." };
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);
@ -515,13 +475,7 @@ export async function splitLockerStack(
actor: user.id, actor: user.id,
targetCollection: "locker-storages", targetCollection: "locker-storages",
targetId: locker.id, targetId: locker.id,
data: { data: { entryId, assetId: asset.id, assetName: asset.name, splitAmount, remainingAmount: entry.quantity - splitAmount },
entryId,
assetId: asset.id,
assetName: asset.name,
splitAmount,
remainingAmount: entry.quantity - splitAmount,
},
}); });
return { success: true }; return { success: true };
@ -720,7 +674,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, {
@ -824,7 +778,10 @@ export async function equipLoadout(loadoutId: number, equipped = true): Promise<
// --- Equipment customization (attachments & skins) --- // --- Equipment customization (attachments & skins) ---
export async function setItemAttachment(entryId: string, assetId: number): Promise<ActionResult> { export async function setItemAttachment(
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);
@ -997,8 +954,7 @@ 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") if (!equipment || typeof equipment === "number") return { success: false, error: "Invalid asset." };
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.` };
@ -1100,13 +1056,7 @@ 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( const spot = findLockerEmptySpot(toLockerGridItems(items), fp.width, fp.height, grid.width, grid.height);
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];
@ -1142,7 +1092,10 @@ export async function removeItemSkin(entryId: string): Promise<ActionResult> {
} }
} }
export async function renameLockerItem(entryId: string, customName: string): Promise<ActionResult> { export async function renameLockerItem(
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);
@ -1168,7 +1121,9 @@ export async function renameLockerItem(entryId: string, customName: string): Pro
await emitGameEvent(payload, { await emitGameEvent(payload, {
type: EventTypes.LockerItemRename, type: EventTypes.LockerItemRename,
message: name ? `Renamed ${asset.name} to "${name}"` : `Cleared custom name on ${asset.name}`, message: 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,7 +47,10 @@ export default async function AccountPage({ params }: AccountPageProps) {
? typedAccount.ownerUser?.id ? typedAccount.ownerUser?.id
: typedAccount.ownerUser; : typedAccount.ownerUser;
const isOwner = typedAccount.accountType === "personal" && user !== null && ownerId === user.id; const isOwner =
typedAccount.accountType === "personal" &&
user !== null &&
ownerId === user.id;
if (!isManager && !isOwner) { if (!isManager && !isOwner) {
notFound(); notFound();

View file

@ -2,7 +2,13 @@ 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 { GameStructure, GameVehicle, Resource, Structure, Vehicle } from "@/payload-types"; import type {
GameVehicle,
Vehicle,
GameStructure,
Structure,
Resource,
} from "@/payload-types";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
FuelIcon, FuelIcon,
@ -32,7 +38,9 @@ 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({ params }: GameVehicleDetailPageProps) { export default async function GameVehicleDetailPage({
params,
}: GameVehicleDetailPageProps) {
const { id } = await params; const { id } = await params;
const payload = await getPayload({ config }); const payload = await getPayload({ config });
@ -51,9 +59,11 @@ export default async function GameVehicleDetailPage({ params }: GameVehicleDetai
const vehicleData = vehicle as unknown as GameVehicle & { const vehicleData = vehicle as unknown as GameVehicle & {
type: Vehicle; type: Vehicle;
}; };
const vehicleType = typeof vehicleData.type === "object" ? vehicleData.type : null; const vehicleType =
typeof vehicleData.type === "object" ? vehicleData.type : null;
const deployedAt = const deployedAt =
typeof vehicleData.deployedAt === "object" && vehicleData.deployedAt !== null typeof vehicleData.deployedAt === "object" &&
vehicleData.deployedAt !== null
? (vehicleData.deployedAt as GameStructure & { type: Structure }) ? (vehicleData.deployedAt as GameStructure & { type: Structure })
: null; : null;
const faction = const faction =
@ -90,7 +100,8 @@ export default async function GameVehicleDetailPage({ params }: GameVehicleDetai
> >
{vehicleData.status === "in_transit" {vehicleData.status === "in_transit"
? "In Transit" ? "In Transit"
: vehicleData.status.charAt(0).toUpperCase() + vehicleData.status.slice(1)} : vehicleData.status.charAt(0).toUpperCase() +
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"}
@ -107,7 +118,8 @@ export default async function GameVehicleDetailPage({ params }: GameVehicleDetai
<ItemContent> <ItemContent>
<ItemTitle>Fuel</ItemTitle> <ItemTitle>Fuel</ItemTitle>
<ItemDescription> <ItemDescription>
{vehicleData.currentFuel?.toFixed(1)} / {vehicleType?.fuel?.fuelCapacity ?? "?"} {vehicleData.currentFuel?.toFixed(1)} /{" "}
{vehicleType?.fuel?.fuelCapacity ?? "?"}
</ItemDescription> </ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
@ -117,7 +129,9 @@ export default async function GameVehicleDetailPage({ params }: GameVehicleDetai
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>Health</ItemTitle> <ItemTitle>Health</ItemTitle>
<ItemDescription>{vehicleData.currentHealth}</ItemDescription> <ItemDescription>
{vehicleData.currentHealth}
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
<Item variant="muted" size="sm"> <Item variant="muted" size="sm">
@ -126,7 +140,9 @@ export default async function GameVehicleDetailPage({ params }: GameVehicleDetai
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>Location</ItemTitle> <ItemTitle>Location</ItemTitle>
<ItemDescription>{deployedAt?.name ?? "In transit"}</ItemDescription> <ItemDescription>
{deployedAt?.name ?? "In transit"}
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
<Item variant="muted" size="sm"> <Item variant="muted" size="sm">
@ -135,7 +151,9 @@ export default async function GameVehicleDetailPage({ params }: GameVehicleDetai
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>Faction</ItemTitle> <ItemTitle>Faction</ItemTitle>
<ItemDescription>{faction?.name ?? "Unassigned"}</ItemDescription> <ItemDescription>
{faction?.name ?? "Unassigned"}
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
</div> </div>
@ -162,16 +180,23 @@ export default async function GameVehicleDetailPage({ params }: GameVehicleDetai
}} }}
/> />
</div> </div>
<span className="text-sm font-mono font-semibold">{fuelPercent.toFixed(0)}%</span> <span className="text-sm font-mono font-semibold">
{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>{vehicleData.currentFuel?.toFixed(1)} units remaining</span> <span>
<span>Capacity: {vehicleType?.fuel?.fuelCapacity ?? "?"} units</span> {vehicleData.currentFuel?.toFixed(1)} units remaining
</span>
<span>
Capacity: {vehicleType?.fuel?.fuelCapacity ?? "?"} units
</span>
</div> </div>
{vehicleType?.fuel?.fuelType && typeof vehicleType.fuel.fuelType === "object" && ( {vehicleType?.fuel?.fuelType &&
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>
@ -202,9 +227,14 @@ export default async function GameVehicleDetailPage({ params }: GameVehicleDetai
<tbody> <tbody>
{storedResources.map((entry, index) => { {storedResources.map((entry, index) => {
const resource = const resource =
typeof entry.resource === "object" ? (entry.resource as Resource) : null; typeof entry.resource === "object"
? (entry.resource as Resource)
: null;
return ( return (
<tr key={index} className="border-b border-border/50"> <tr
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>
@ -218,7 +248,9 @@ export default async function GameVehicleDetailPage({ params }: GameVehicleDetai
</table> </table>
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground italic">Vehicle cargo is empty.</p> <p className="text-sm text-muted-foreground italic">
Vehicle cargo is empty.
</p>
)} )}
</ItemContent> </ItemContent>
</Item> </Item>
@ -234,14 +266,17 @@ export default async function GameVehicleDetailPage({ params }: GameVehicleDetai
<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">{vehicleType?.name ?? "Unknown"}</p> <p className="font-semibold">
{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.length > 0 && ( {vehicleType?.category &&
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

View file

@ -52,13 +52,17 @@ 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 = typeof vehicle.type === "object" ? vehicle.type : null; const vehicleType =
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 key={vehicle.id} href={`/logistics/game-vehicles/${vehicle.id}`}> <Link
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)]"
@ -80,7 +84,8 @@ export default async function GameVehiclesPage() {
> >
{vehicle.status === "in_transit" {vehicle.status === "in_transit"
? "In Transit" ? "In Transit"
: vehicle.status.charAt(0).toUpperCase() + vehicle.status.slice(1)} : vehicle.status.charAt(0).toUpperCase() +
vehicle.status.slice(1)}
</span> </span>
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
@ -89,7 +94,9 @@ 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 ? "bg-green-500" : "bg-red-500" fuelPercent > 20
? "bg-green-500"
: "bg-red-500"
}`} }`}
style={{ style={{
width: `${Math.min(100, fuelPercent)}%`, width: `${Math.min(100, fuelPercent)}%`,
@ -105,12 +112,16 @@ 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>{vehicleType.fuel.fuelType.name}</span> <span>
{vehicleType.fuel.fuelType.name}
</span>
)} )}
</div> </div>
</div> </div>
</ItemContent> </ItemContent>
<ItemDescription>{vehicleType?.name ?? "Unknown type"}</ItemDescription> <ItemDescription>
{vehicleType?.name ?? "Unknown type"}
</ItemDescription>
</Item> </Item>
</Link> </Link>
); );

View file

@ -7,22 +7,17 @@ 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 { import { expireNegotiationsForListing, npcDialogue, npcResponseToOffer, type NpcState } from "@/lib/market/negotiations";
expireNegotiationsForListing,
npcDialogue,
npcResponseToOffer,
type NpcState,
} from "@/lib/market/negotiations";
export interface ActionResult<T = undefined> { export interface ActionResult<T = undefined> {
success: boolean; success: boolean;
@ -91,9 +86,7 @@ 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 return typeof asset === "object" && asset ? asset.name : `asset #${resolveAssetId(listing.asset)}`;
? asset.name
: `asset #${resolveAssetId(listing.asset)}`;
} }
async function getNegotiationOrThrow( async function getNegotiationOrThrow(
@ -101,12 +94,7 @@ async function getNegotiationOrThrow(
negotiationId: number, negotiationId: number,
): Promise<MarketNegotiation> { ): Promise<MarketNegotiation> {
const negotiation = (await payload const negotiation = (await payload
.findByID({ .findByID({ collection: "market-negotiations", id: negotiationId, depth: 2, overrideAccess: true })
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;
@ -158,7 +146,10 @@ 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(negotiation: MarketNegotiation, userId: number): "buyer" | "seller" | null { function partyRoleOf(
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) {
@ -184,7 +175,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 });
@ -328,7 +319,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,
@ -352,7 +343,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,
@ -481,12 +472,7 @@ 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( await creditLockerQuantity(payload, sellerId, resolveAssetId(listing.asset) as number, listing.quantity);
payload,
sellerId,
resolveAssetId(listing.asset) as number,
listing.quantity,
);
await payload.update({ await payload.update({
collection: "market-listings", collection: "market-listings",
@ -531,7 +517,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}.` };
} }
@ -594,7 +580,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}.` };
} }
@ -656,9 +642,7 @@ export async function makeMarketOffer(
amount, amount,
proposedBy: "buyer", proposedBy: "buyer",
quantity: qty, quantity: qty,
history: [ history: [{ amount, from: "buyer", note: "Initial offer", at: new Date().toISOString() }],
{ amount, from: "buyer", note: "Initial offer", at: new Date().toISOString() },
],
}, },
overrideAccess: true, overrideAccess: true,
depth: 1, depth: 1,
@ -744,9 +728,7 @@ 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( export async function acceptMarketOffer(negotiationId: number): Promise<ActionResult<MarketListing>> {
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;
@ -807,12 +789,8 @@ export async function rejectMarketOffer(negotiationId: number): Promise<ActionRe
} }
const listing = negotiationListing(negotiation); const listing = negotiationListing(negotiation);
const listingId = const listingId = typeof listing === "object" && listing ? (listing as MarketListing).id : (listing as number);
typeof listing === "object" && listing ? (listing as MarketListing).id : (listing as number); const assetName = typeof listing === "object" && listing ? assetNameOf(listing as MarketListing) : "the listing";
const assetName =
typeof listing === "object" && listing
? assetNameOf(listing as MarketListing)
: "the listing";
await payload.update({ await payload.update({
collection: "market-negotiations", collection: "market-negotiations",
@ -890,7 +868,9 @@ 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 = [...((negotiation.history ?? []) as NonNullable<MarketNegotiation["history"]>)]; const 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({
@ -920,8 +900,7 @@ export async function counterMarketOffer(
return { success: true, data: refreshed }; return { success: true, data: refreshed };
} }
const counterpartyId = const counterpartyId = role === "buyer" ? sellerId : (negotiationBuyerId(negotiation) as number);
role === "buyer" ? sellerId : (negotiationBuyerId(negotiation) as number);
await notifyUser(payload, { await notifyUser(payload, {
userId: counterpartyId, userId: counterpartyId,
type: "market:counter", type: "market:counter",
@ -954,14 +933,9 @@ 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 = const listingId = typeof listing === "object" && listing ? (listing as MarketListing).id : (listing as number);
typeof listing === "object" && listing ? (listing as MarketListing).id : (listing as number); const assetName = typeof listing === "object" && listing ? assetNameOf(listing as MarketListing) : "the listing";
const assetName = const sellerId = typeof listing === "object" && listing ? sellerIdOf(listing as MarketListing) : null;
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,7 +28,10 @@ export default async function MarketPage() {
and: [ and: [
{ status: { equals: "active" } }, { status: { equals: "active" } },
{ {
or: [{ expiresAt: { exists: false } }, { expiresAt: { greater_than: dayAgo } }], or: [
{ expiresAt: { exists: false } },
{ expiresAt: { greater_than: dayAgo } },
],
}, },
], ],
}, },
@ -47,9 +50,7 @@ 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 typeof l.seller === "object" && l.seller ? (l.seller as { id: number }).id : (l.seller as number | null);
? (l.seller as { id: number }).id
: (l.seller as number | null);
return sellerId === userId; return sellerId === userId;
}); });
@ -61,9 +62,7 @@ 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 typeof n.buyer === "object" && n.buyer ? (n.buyer as { id: number }).id : (n.buyer as number | null);
? (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 { Faction, GameStructure, Structure } from "@/payload-types"; import { GameStructure, Faction, 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,7 +40,11 @@ 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 structures={structures} categories={categories} factions={factions} /> <StorageGrid
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 { Asset, Structure } from "@/payload-types"; import type { Resource, Structure, Asset } 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,6 +12,7 @@ import {
CoinsIcon, CoinsIcon,
Grid3x3Icon, Grid3x3Icon,
LayersIcon, LayersIcon,
MoveIcon,
RotateCcwIcon, RotateCcwIcon,
ScaleIcon, ScaleIcon,
TagIcon, TagIcon,
@ -47,7 +48,9 @@ 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({ params }: ResourceDetailPageProps) { export default async function ResourceDetailPage({
params,
}: ResourceDetailPageProps) {
const { id } = await params; const { id } = await params;
const payload = await getPayload({ config }); const payload = await getPayload({ config });
@ -102,7 +105,9 @@ export default async function ResourceDetailPage({ params }: ResourceDetailPageP
const allRelatedStructures = [ const allRelatedStructures = [
...producers, ...producers,
...autoGenStructures.filter((s) => !producers.some((p) => p.id === s.id)), ...autoGenStructures.filter(
(s) => !producers.some((p) => p.id === s.id),
),
]; ];
return ( return (
@ -119,7 +124,10 @@ export default async function ResourceDetailPage({ params }: ResourceDetailPageP
<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 className="size-5 rounded-sm" style={{ backgroundColor: resource.color }} /> <div
className="size-5 rounded-sm"
style={{ backgroundColor: resource.color }}
/>
) : ( ) : (
<BoxIcon className="size-5 text-muted-foreground" /> <BoxIcon className="size-5 text-muted-foreground" />
)} )}
@ -235,7 +243,10 @@ export default async function ResourceDetailPage({ params }: ResourceDetailPageP
</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 key={structure.id} href={`/logistics/structures/${structure.id}`}> <Link
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,7 +38,9 @@ export default async function ResourcesPage() {
))} ))}
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground italic">No resources available.</p> <p className="text-sm text-muted-foreground italic">
No resources available.
</p>
)} )}
</div> </div>
); );

View file

@ -2,16 +2,30 @@ 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 { GameStructure, GameVehicle, Vehicle } from "@/payload-types"; import type {
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 { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import {
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";
@ -30,7 +44,9 @@ 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({ params }: ShipmentDetailPageProps) { export default async function ShipmentDetailPage({
params,
}: ShipmentDetailPageProps) {
const { id } = await params; const { id } = await params;
const payload = await getPayload({ config }); const payload = await getPayload({ config });
@ -55,20 +71,31 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
? (shipment.destination as GameStructure) ? (shipment.destination as GameStructure)
: null; : null;
const vehicle = const vehicle =
typeof shipment.transportVehicle === "object" && shipment.transportVehicle !== null typeof shipment.transportVehicle === "object" &&
shipment.transportVehicle !== null
? (shipment.transportVehicle as GameVehicle & { type: Vehicle }) ? (shipment.transportVehicle as GameVehicle & { type: Vehicle })
: null; : null;
const vehicleType = vehicle && typeof vehicle.type === "object" ? vehicle.type : null; const vehicleType =
vehicle && typeof vehicle.type === "object" ? vehicle.type : null;
const dispatchedAt = shipment.dispatchedAt ? new Date(shipment.dispatchedAt) : null; const dispatchedAt = shipment.dispatchedAt
const estimatedArrival = shipment.estimatedArrival ? new Date(shipment.estimatedArrival) : null; ? new Date(shipment.dispatchedAt)
const completedAt = shipment.completedAt ? new Date(shipment.completedAt) : null; : 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 = shipment.status === "dispatched" || shipment.status === "in_transit"; const isActive =
shipment.status === "dispatched" || shipment.status === "in_transit";
const fuelPercent = const fuelPercent =
shipment.fuelCost > 0 ? ((shipment.fuelConsumed ?? 0) / shipment.fuelCost) * 100 : 0; shipment.fuelCost > 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">
@ -84,7 +111,9 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
<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">{shipment.name ?? `SHP-${shipment.id}`}</h1> <h1 className="text-lg font-semibold">
{shipment.name ?? `SHP-${shipment.id}`}
</h1>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
@ -92,7 +121,8 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
> >
{shipment.status === "in_transit" {shipment.status === "in_transit"
? "In Transit" ? "In Transit"
: shipment.status.charAt(0).toUpperCase() + shipment.status.slice(1)} : shipment.status.charAt(0).toUpperCase() +
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
@ -113,7 +143,9 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
<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">{origin?.name ?? "Unknown"}</p> <p className="text-sm font-semibold">
{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">
@ -123,7 +155,9 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
</span> </span>
</div> </div>
<div className="flex-1 text-center"> <div className="flex-1 text-center">
<p className="text-sm font-semibold">{destination?.name ?? "Unknown"}</p> <p className="text-sm font-semibold">
{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>
@ -186,14 +220,23 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
value: number | { name?: string }; value: number | { name?: string };
}; };
itemType = polymorphic.relationTo; itemType = polymorphic.relationTo;
if (typeof polymorphic.value === "object" && polymorphic.value !== null) { if (
itemName = polymorphic.value.name ?? "Unknown"; typeof polymorphic.value === "object" &&
polymorphic.value !== null
) {
itemName =
polymorphic.value.name ?? "Unknown";
} }
} }
return ( return (
<tr key={index} className="border-b border-border/50"> <tr
<td className="px-4 py-2 font-medium">{itemName}</td> key={index}
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>
@ -207,7 +250,9 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
</table> </table>
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground italic">No cargo on this shipment.</p> <p className="text-sm text-muted-foreground italic">
No cargo on this shipment.
</p>
)} )}
</ItemContent> </ItemContent>
</Item> </Item>
@ -226,7 +271,9 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
<ItemContent> <ItemContent>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div> <div>
<p className="text-sm font-semibold">{vehicle?.name ?? "Unknown"}</p> <p className="text-sm font-semibold">
{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>
@ -236,19 +283,24 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
<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)} / {vehicleType?.fuel?.fuelCapacity ?? "?"} {vehicle.currentFuel?.toFixed(1)} /{" "}
{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 ? "bg-green-500" : "bg-red-500" vehicle.currentFuel > 0
? "bg-green-500"
: "bg-red-500"
}`} }`}
style={{ style={{
width: `${Math.min( width: `${Math.min(
100, 100,
vehicleType?.fuel?.fuelCapacity vehicleType?.fuel?.fuelCapacity
? (vehicle.currentFuel / vehicleType.fuel.fuelCapacity) * 100 ? (vehicle.currentFuel /
vehicleType.fuel.fuelCapacity) *
100
: 0, : 0,
)}%`, )}%`,
}} }}
@ -256,7 +308,9 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
</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">{vehicle.currentHealth}</span> <span className="font-mono">
{vehicle.currentHealth}
</span>
</div> </div>
</> </>
)} )}
@ -280,7 +334,9 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
</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">{(shipment.fuelConsumed ?? 0).toFixed(1)}</span> <span className="font-mono">
{(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
@ -319,7 +375,9 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
{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" ? "Arrived" : "Est. Arrival"} {shipment.status === "completed"
? "Arrived"
: "Est. Arrival"}
</span> </span>
<span> <span>
{estimatedArrival.toLocaleDateString()}{" "} {estimatedArrival.toLocaleDateString()}{" "}
@ -353,7 +411,9 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
<ItemTitle>Failure Reason</ItemTitle> <ItemTitle>Failure Reason</ItemTitle>
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<p className="text-sm text-red-400">{shipment.failureReason}</p> <p className="text-sm text-red-400">
{shipment.failureReason}
</p>
</ItemContent> </ItemContent>
</Item> </Item>
)} )}

View file

@ -2,13 +2,27 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { GameStructure, GameVehicle, Resource, Structure, Vehicle } from "@/payload-types"; import type {
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 { calculateFuelCost, calculateTransitTime, getVehicleEffectiveSpeed } from "@/lib/shipping"; import {
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules"; calculateFuelCost,
calculateTransitTime,
getVehicleEffectiveSpeed,
} from "@/lib/shipping";
import { processShipmentTick } from "@/scripts/processShipmentTick";
import {
checkStorageDeposit,
storageViolationMessage,
} from "@/lib/storageRules";
interface ActionResult { interface ActionResult {
success: boolean; success: boolean;
@ -91,7 +105,8 @@ export async function createShipment(params: {
if (!originCoords || !destCoords) { if (!originCoords || !destCoords) {
return { return {
success: false, success: false,
error: "Both origin and destination must have coordinates set.", error:
"Both origin and destination must have coordinates set.",
}; };
} }
@ -131,7 +146,9 @@ export async function createShipment(params: {
} }
const vehicleAtId = const vehicleAtId =
typeof vehicle.deployedAt === "object" ? vehicle.deployedAt?.id : vehicle.deployedAt; typeof vehicle.deployedAt === "object"
? vehicle.deployedAt?.id
: vehicle.deployedAt;
if (vehicleAtId !== originId) { if (vehicleAtId !== originId) {
return { return {
@ -141,7 +158,8 @@ export async function createShipment(params: {
} }
// Get vehicle type details // Get vehicle type details
const vehicleType = typeof vehicle.type === "object" ? vehicle.type : null; const vehicleType =
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." };
} }
@ -164,7 +182,8 @@ 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 = typeof r.resource === "object" ? r.resource.id : r.resource; const resId =
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);
@ -172,12 +191,18 @@ 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 = typeof r.resource === "object" ? r.resource.id : r.resource; const resId =
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 = typeof r.resource === "object" ? r.resource.id : r.resource; const resId =
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
@ -192,7 +217,8 @@ 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 = typeof destination.type === "object" ? destination.type : null; const destType =
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 ?? [];
@ -207,7 +233,8 @@ export async function createShipment(params: {
itemId: number, itemId: number,
) => ) =>
entries.reduce((sum, r) => { entries.reduce((sum, r) => {
const resId = typeof r.resource === "object" ? r.resource.id : r.resource; const resId =
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);
@ -230,7 +257,8 @@ export async function createShipment(params: {
} }
const currentStored = const currentStored =
storedAmountIn(destStored, cargoItem.value) + storedAmountIn(destVoid, cargoItem.value); storedAmountIn(destStored, cargoItem.value) +
storedAmountIn(destVoid, cargoItem.value);
const ruleCheck = checkStorageDeposit( const ruleCheck = checkStorageDeposit(
destType, destType,
@ -250,7 +278,10 @@ export async function createShipment(params: {
} }
destMass += (resource.massPerUnit ?? 0) * cargoItem.amount; destMass += (resource.massPerUnit ?? 0) * cargoItem.amount;
if (destType.maxCapacityMass && destMass > destType.maxCapacityMass) { if (
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.`,
@ -296,7 +327,8 @@ export async function createShipment(params: {
let remaining = cargoItem.amount; let remaining = cargoItem.amount;
updatedResources = updatedResources updatedResources = updatedResources
.map((r) => { .map((r) => {
const resId = typeof r.resource === "object" ? r.resource.id : r.resource; const resId =
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;
@ -345,7 +377,9 @@ export async function createShipment(params: {
fuelConsumed: 0, fuelConsumed: 0,
status: "dispatched", status: "dispatched",
dispatchedAt: now.toISOString(), dispatchedAt: now.toISOString(),
estimatedArrival: new Date(now.getTime() + transitTimeMs).toISOString(), estimatedArrival: new Date(
now.getTime() + transitTimeMs,
).toISOString(),
autoReturn: false, autoReturn: false,
createdBy: user.id, createdBy: user.id,
}, },
@ -368,7 +402,8 @@ export async function createShipment(params: {
return { success: true, data: shipment }; return { success: true, data: shipment };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; const message =
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." };
} }
@ -376,7 +411,9 @@ export async function createShipment(params: {
} }
} }
export async function cancelShipment(shipmentId: number): Promise<ActionResult> { export async function cancelShipment(
shipmentId: number,
): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
@ -396,7 +433,10 @@ export async function cancelShipment(shipmentId: number): Promise<ActionResult>
return { success: false, error: "Shipment not found." }; return { success: false, error: "Shipment not found." };
} }
if (shipment.status !== "dispatched" && shipment.status !== "in_transit") { if (
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}".`,
@ -404,7 +444,9 @@ export async function cancelShipment(shipmentId: number): Promise<ActionResult>
} }
const originId = const originId =
typeof shipment.origin === "object" ? shipment.origin.id : (shipment.origin as number); typeof shipment.origin === "object"
? 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
@ -422,7 +464,9 @@ export async function cancelShipment(shipmentId: number): Promise<ActionResult>
if (polymorphic.relationTo !== "resources") continue; if (polymorphic.relationTo !== "resources") continue;
const itemId = const itemId =
typeof polymorphic.value === "object" ? polymorphic.value.id : polymorphic.value; typeof polymorphic.value === "object"
? polymorphic.value.id
: polymorphic.value;
const origin = await payload.findByID({ const origin = await payload.findByID({
collection: "game-structures", collection: "game-structures",
@ -432,7 +476,8 @@ export async function cancelShipment(shipmentId: number): Promise<ActionResult>
const existingResources = origin.storedResources ?? []; const existingResources = origin.storedResources ?? [];
const existingEntry = existingResources.find((r) => { const existingEntry = existingResources.find((r) => {
const resId = typeof r.resource === "object" ? r.resource.id : r.resource; const resId =
typeof r.resource === "object" ? r.resource.id : r.resource;
return resId === itemId; return resId === itemId;
}); });
@ -486,7 +531,8 @@ export async function cancelShipment(shipmentId: number): Promise<ActionResult>
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; const message =
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." };
} }
@ -528,7 +574,8 @@ export async function toggleAutoReturn(
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; const message =
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 } from "@/payload-types"; import type { Shipment, GameStructure, GameVehicle, Vehicle } 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 { Faction, GameStructure, Resource, Structure } from "@/payload-types"; import { GameStructure, Structure, Faction, Resource } 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,13 +45,11 @@ 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 const structureRes = await payload.findByID({
.findByID({
collection: "game-structures", collection: "game-structures",
id: Number(id), id: Number(id),
depth: 2, depth: 2,
}) }).catch(() => null);
.catch(() => null);
if (!structureRes) { if (!structureRes) {
notFound(); notFound();
@ -118,7 +116,9 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
</div> </div>
</div> </div>
{description && <p className="text-sm text-muted-foreground">{description}</p>} {description && (
<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,9 +187,7 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>Max Capacity</ItemTitle> <ItemTitle>Max Capacity</ItemTitle>
<ItemDescription> <ItemDescription>{structureType.maxCapacityMass.toLocaleString()} kg (mass)</ItemDescription>
{structureType.maxCapacityMass.toLocaleString()} kg (mass)
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
)} )}
@ -206,7 +204,9 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
)} )}
</div> </div>
<StructureStorageView structure={structure} /> <StructureStorageView
structure={structure}
/>
<StorageRules <StorageRules
allowedStorage={structureType.allowedStorage} allowedStorage={structureType.allowedStorage}
@ -229,12 +229,8 @@ 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 <span key={upkeep.id ?? index} className="text-sm bg-muted px-2.5 py-1 rounded-md">
key={upkeep.id ?? index} {resource.name}: {upkeep.amount?.toLocaleString()} {pluralize(upkeep.amount ?? 0, resource.unitOfMeasure)}/tick
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,7 +6,10 @@ 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 { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules"; import {
checkStorageDeposit,
storageViolationMessage,
} from "@/lib/storageRules";
interface ActionResult { interface ActionResult {
success: boolean; success: boolean;
@ -29,13 +32,11 @@ 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 const structure = await payload.findByID({
.findByID({
collection: "game-structures", collection: "game-structures",
id, id,
depth: 2, depth: 2,
}) }).catch(() => null);
.catch(() => null);
if (!structure) { if (!structure) {
throw new Error("Structure not found"); throw new Error("Structure not found");
@ -44,10 +45,7 @@ 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( function getStoredAmount(storedResources: GameStructure["storedResources"], resourceId: number): number {
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;
@ -75,10 +73,7 @@ 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 { return { success: false, error: "Insufficient permissions. Admin or Developer role required for deposit." };
success: false,
error: "Insufficient permissions. Admin or Developer role required for deposit.",
};
} }
if (amount <= 0) { if (amount <= 0) {
@ -89,13 +84,11 @@ 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 const resource = await payload.findByID({
.findByID({
collection: "resources", collection: "resources",
id: resourceId, id: resourceId,
depth: 0, depth: 0,
}) }).catch(() => null);
.catch(() => null);
if (!resource) { if (!resource) {
return { success: false, error: "Resource not found." }; return { success: false, error: "Resource not found." };
@ -106,7 +99,12 @@ export async function addResource(
getStoredAmount(structure.storedResources, resourceId) + getStoredAmount(structure.storedResources, resourceId) +
getStoredAmount(structure.voidStorage ?? [], resourceId); getStoredAmount(structure.voidStorage ?? [], resourceId);
const ruleCheck = checkStorageDeposit(structureType, resourceId, amount, currentStored); const ruleCheck = checkStorageDeposit(
structureType,
resourceId,
amount,
currentStored,
);
if (!ruleCheck.allowed) { if (!ruleCheck.allowed) {
return { return {
@ -138,7 +136,9 @@ 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 ? 1 : (resource.maxStackSize ?? Infinity); const maxStack = resource.stackable === false
? 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,8 +151,9 @@ export async function addResource(
for (const entry of sameType) { for (const entry of sameType) {
if (remaining <= 0) break; if (remaining <= 0) break;
const space = const space = maxStack === Infinity
maxStack === Infinity ? remaining : Math.min(maxStack - entry.amount, remaining); ? 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) {
@ -203,13 +204,7 @@ export async function addResource(
} }
const stackAmount = Math.min(remaining, maxStack === Infinity ? remaining : maxStack); const stackAmount = Math.min(remaining, maxStack === Infinity ? remaining : maxStack);
existing.push({ existing.push({ resource: resourceId, amount: stackAmount, gridX: spot.x, gridY: spot.y, rotated: false });
resource: resourceId,
amount: stackAmount,
gridX: spot.x,
gridY: spot.y,
rotated: false,
});
remaining -= stackAmount; remaining -= stackAmount;
} }
} else { } else {
@ -265,10 +260,7 @@ 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 { return { success: false, error: "Insufficient permissions. Admin or Developer role required for withdrawal." };
success: false,
error: "Insufficient permissions. Admin or Developer role required for withdrawal.",
};
} }
if (amount <= 0) { if (amount <= 0) {
@ -339,12 +331,7 @@ export async function removeResource(
data: { storedResources, voidStorage }, data: { storedResources, voidStorage },
}); });
const resName = const resName = (await payload.findByID({ collection: "resources", id: resourceId, depth: 0 }).catch(() => null))?.name ?? `resource #${resourceId}`;
(
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}"`,
@ -374,10 +361,7 @@ 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 { return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required for transfer." };
success: false,
error: "Insufficient permissions. User, Admin, or Developer role required for transfer.",
};
} }
if (amount <= 0) { if (amount <= 0) {
@ -421,13 +405,11 @@ export async function transferResource(
} }
// Fetch the resource for rule + capacity checks // Fetch the resource for rule + capacity checks
const resource = await payload const resource = await payload.findByID({
.findByID({
collection: "resources", collection: "resources",
id: resourceId, id: resourceId,
depth: 0, depth: 0,
}) }).catch(() => null);
.catch(() => null);
if (!resource) { if (!resource) {
return { success: false, error: "Resource not found." }; return { success: false, error: "Resource not found." };
@ -486,15 +468,7 @@ export async function transferResource(
structure: toStructureId, structure: toStructureId,
targetCollection: "game-structures", targetCollection: "game-structures",
targetId: toStructureId, targetId: toStructureId,
data: { data: { resourceId, amount, resourceName: resName, fromStructureId, toStructureId, fromStructureName: fromStructure.name, toStructureName: toStructure.name },
resourceId,
amount,
resourceName: resName,
fromStructureId,
toStructureId,
fromStructureName: fromStructure.name,
toStructureName: toStructure.name,
},
}); });
return { success: true, data: addResult.data }; return { success: true, data: addResult.data };
@ -525,10 +499,7 @@ 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 { return { success: false, error: "Insufficient permissions. Admin or Developer role required for placement." };
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." };
@ -536,9 +507,7 @@ 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 const resource = await payload.findByID({ collection: "resources", id: resourceId, depth: 0 }).catch(() => null);
.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)
@ -546,7 +515,12 @@ export async function placeResourceOnGrid(
getStoredAmount(structure.storedResources, resourceId) + getStoredAmount(structure.storedResources, resourceId) +
getStoredAmount(structure.voidStorage ?? [], resourceId); getStoredAmount(structure.voidStorage ?? [], resourceId);
const ruleCheck = checkStorageDeposit(structureType, resourceId, amount, currentStored); const ruleCheck = checkStorageDeposit(
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) };
@ -560,10 +534,7 @@ 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 { return { success: false, error: `Exceeds mass capacity. Available: ${(maxMass - currentMass).toLocaleString()} kg.` };
success: false,
error: `Exceeds mass capacity. Available: ${(maxMass - currentMass).toLocaleString()} kg.`,
};
} }
const gridW = structureType.xSize ?? 0; const gridW = structureType.xSize ?? 0;
@ -572,8 +543,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;
@ -607,11 +578,7 @@ export async function placeResourceOnGrid(
existing.push({ resource: resourceId, amount, gridX, gridY, rotated }); existing.push({ resource: resourceId, amount, gridX, gridY, rotated });
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.GridPlace, type: EventTypes.GridPlace,
@ -642,10 +609,7 @@ 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 { return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required." };
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 ?? [])];
@ -689,13 +653,10 @@ export async function moveGridItem(
} }
} }
existing[idx] = existing[idx] = rotated !== undefined
rotated !== undefined ? { ...entry, gridX, gridY, rotated } : { ...entry, gridX, gridY }; ? { ...entry, gridX, gridY, rotated }
await payload.update({ : { ...entry, gridX, gridY };
collection: "game-structures", await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: existing } });
id: structureId,
data: { storedResources: existing },
});
await emitGameEvent(payload, { await emitGameEvent(payload, {
type: EventTypes.GridMove, type: EventTypes.GridMove,
@ -704,14 +665,7 @@ export async function moveGridItem(
structure: structureId, structure: structureId,
targetCollection: "game-structures", targetCollection: "game-structures",
targetId: structureId, targetId: structureId,
data: { data: { entryId, resourceId: (typeof res === "object" ? res.id : res), resourceName: res.name, gridX, gridY, rotated },
entryId,
resourceId: typeof res === "object" ? res.id : res,
resourceName: res.name,
gridX,
gridY,
rotated,
},
}); });
return { success: true }; return { success: true };
@ -731,10 +685,7 @@ 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 { return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required." };
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 ?? [])];
@ -754,11 +705,9 @@ 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) if (srcResId !== tgtResId) return { success: false, error: "Cannot merge different resource types." };
return { success: false, error: "Cannot merge different resource types." };
if (tgtRes.stackable === false) if (tgtRes.stackable === false) return { success: false, error: "This resource cannot be stacked." };
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;
@ -774,11 +723,7 @@ export async function mergeGridStacks(
existing[srcIdx] = { ...src, amount: src.amount - transferAmount }; existing[srcIdx] = { ...src, amount: src.amount - transferAmount };
} }
await payload.update({ await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: existing } });
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, {
@ -788,13 +733,7 @@ export async function mergeGridStacks(
structure: structureId, structure: structureId,
targetCollection: "game-structures", targetCollection: "game-structures",
targetId: structureId, targetId: structureId,
data: { data: { sourceId, targetId, resourceId: srcResId, resourceName: mergeResName, transferAmount },
sourceId,
targetId,
resourceId: srcResId,
resourceName: mergeResName,
transferAmount,
},
}); });
return { success: true }; return { success: true };
@ -813,10 +752,7 @@ 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 { return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required." };
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 ?? [])];
@ -826,8 +762,7 @@ 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) if (res.rotatable === false) return { success: false, error: "This resource cannot be rotated." };
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);
@ -863,11 +798,7 @@ export async function rotateGridItem(
} }
existing[idx] = { ...entry, rotated: newRotated }; existing[idx] = { ...entry, rotated: newRotated };
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.GridRotate, type: EventTypes.GridRotate,
@ -876,12 +807,7 @@ export async function rotateGridItem(
structure: structureId, structure: structureId,
targetCollection: "game-structures", targetCollection: "game-structures",
targetId: structureId, targetId: structureId,
data: { data: { entryId, resourceId: (typeof res === "object" ? res.id : res), resourceName: res.name, rotated: newRotated },
entryId,
resourceId: typeof res === "object" ? res.id : res,
resourceName: res.name,
rotated: newRotated,
},
}); });
return { success: true }; return { success: true };
@ -900,10 +826,7 @@ 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 { return { success: false, error: "Insufficient permissions. Admin or Developer role required for removal." };
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 ?? [])];
@ -913,15 +836,9 @@ 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({ await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: existing } });
collection: "game-structures",
id: structureId,
data: { storedResources: existing },
});
const removedName = const removedName = removedRes?.name ?? `resource #${typeof removedRes === "object" ? removedRes.id : removedRes}`;
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}"`,
@ -929,12 +846,7 @@ export async function removeGridItem(
structure: structureId, structure: structureId,
targetCollection: "game-structures", targetCollection: "game-structures",
targetId: structureId, targetId: structureId,
data: { data: { entryId, resourceId: typeof removedRes === "object" ? removedRes.id : removedRes, resourceName: removedName, amount: removedEntry.amount },
entryId,
resourceId: typeof removedRes === "object" ? removedRes.id : removedRes,
resourceName: removedName,
amount: removedEntry.amount,
},
}); });
return { success: true }; return { success: true };
@ -954,10 +866,7 @@ 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 { return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required." };
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." };
@ -968,8 +877,7 @@ 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) if (splitAmount >= entry.amount) return { success: false, error: "Split amount must be less than the stack size." };
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." };
@ -1026,11 +934,7 @@ 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({ await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: existing } });
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, {
@ -1040,13 +944,7 @@ export async function splitGridStack(
structure: structureId, structure: structureId,
targetCollection: "game-structures", targetCollection: "game-structures",
targetId: structureId, targetId: structureId,
data: { data: { entryId, resourceId: typeof res === "object" ? res.id : res, resourceName: splitResName, splitAmount, remainingAmount: entry.amount - splitAmount },
entryId,
resourceId: typeof res === "object" ? res.id : res,
resourceName: splitResName,
splitAmount,
remainingAmount: entry.amount - splitAmount,
},
}); });
return { success: true }; return { success: true };
@ -1131,8 +1029,7 @@ 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: message: recovered > 0
recovered > 0
? `Retrieved ${recovered} item(s) from void in "${structure.name}"` ? `Retrieved ${recovered} item(s) from void in "${structure.name}"`
: `No void items could be recovered in "${structure.name}" — grid full`, : `No void items could be recovered in "${structure.name}" — grid full`,
actor: user.id, actor: user.id,
@ -1143,8 +1040,7 @@ export async function retrieveFromVoid(structureId: number): Promise<GridActionR
}); });
return { return {
success: true, success: true,
error: error: recovered > 0
recovered > 0
? `${recovered} item(s) retrieved from void storage.` ? `${recovered} item(s) retrieved from void storage.`
: "No items could be retrieved — grid is full.", : "No items could be retrieved — grid is full.",
}; };
@ -1160,10 +1056,7 @@ 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 { return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required." };
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,25 +1,34 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useState, useMemo } 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 = ["civilian", "utility", "transport", "weaponized", "military"]; const categories = [
"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((v) => return vehicles.filter(
v.category?.includes(selectedCategory as NonNullable<Vehicle["category"]>[number]), (v) => v.category?.includes(selectedCategory as NonNullable<Vehicle["category"]>[number]),
); );
}, [vehicles, selectedCategory]); }, [vehicles, selectedCategory]);
return ( return (
<> <>
<VehicleFilters categories={categories} onFilterChange={setSelectedCategory} /> <VehicleFilters
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 { Resource, Structure, Technology } from "@/payload-types"; import type { Vehicle, Resource, Technology, 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 {
@ -10,6 +10,7 @@ import {
BoxIcon, BoxIcon,
Building2Icon, Building2Icon,
ClockIcon, ClockIcon,
CrosshairIcon,
GaugeIcon, GaugeIcon,
HeartIcon, HeartIcon,
ShieldIcon, ShieldIcon,
@ -45,7 +46,9 @@ 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({ params }: VehicleDetailPageProps) { export default async function VehicleDetailPage({
params,
}: VehicleDetailPageProps) {
const { id } = await params; const { id } = await params;
const payload = await getPayload({ config }); const payload = await getPayload({ config });
@ -141,7 +144,10 @@ export default async function VehicleDetailPage({ params }: VehicleDetailPagePro
<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 key={index} className="text-sm bg-muted px-2.5 py-1 rounded-md"> <span
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>
))} ))}
@ -197,7 +203,9 @@ export default async function VehicleDetailPage({ params }: VehicleDetailPagePro
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>Armor Rating</ItemTitle> <ItemTitle>Armor Rating</ItemTitle>
<ItemDescription className="capitalize">{vehicle.armorRating}</ItemDescription> <ItemDescription className="capitalize">
{vehicle.armorRating}
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
)} )}
@ -311,10 +319,15 @@ export default async function VehicleDetailPage({ params }: VehicleDetailPagePro
<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 key={index} className="text-sm bg-muted px-2.5 py-1 rounded-md"> <span
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">({scrap.chance}%)</span> <span className="text-muted-foreground ml-1">
({scrap.chance}%)
</span>
)} )}
</span> </span>
))} ))}
@ -335,7 +348,10 @@ export default async function VehicleDetailPage({ params }: VehicleDetailPagePro
<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 key={index} className="text-sm bg-muted px-2.5 py-1 rounded-md"> <span
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>
))} ))}
@ -353,7 +369,10 @@ export default async function VehicleDetailPage({ params }: VehicleDetailPagePro
</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 key={structure.id} href={`/logistics/structures/${structure.id}`}> <Link
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 { Assignment, Campaign, GameStructure, Map, Media, Rank, User } from "@/payload-types"; import type { User, Rank, Media, Assignment, Campaign, Map, GameStructure } 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 { Assignment, Award, Media, Qualification, Rank, User } from "@/payload-types"; import type { User, Rank, Award, Media, Assignment, Qualification } 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,
AwardIcon,
BadgeCheckIcon,
CalendarDaysIcon,
CrosshairIcon,
FileTextIcon,
HashIcon,
PlaneIcon,
ShieldCheck,
ShieldIcon, ShieldIcon,
SkullIcon,
SparkleIcon,
SwordsIcon, SwordsIcon,
TargetIcon,
TruckIcon,
UserIcon, UserIcon,
FileTextIcon,
CrosshairIcon,
AwardIcon,
SparkleIcon,
CalendarDaysIcon,
HashIcon,
SkullIcon,
TruckIcon,
PlaneIcon,
TargetIcon,
BadgeCheckIcon,
ShieldCheck,
} 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 { Media, Rank, User } from "@/payload-types"; import type { User, Rank, Media } 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,7 +64,9 @@ 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 as Media).url : null; rank.insignia && typeof rank.insignia === "object"
? (rank.insignia as Media).url
: null;
let assignmentName: string | undefined; let assignmentName: string | undefined;
let assignmentType: string | undefined; let assignmentType: string | undefined;
@ -123,12 +125,15 @@ 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 {assignments.length}{" "} {members.length} operator{members.length !== 1 ? "s" : ""} across{" "}
assignment{assignments.length !== 1 ? "s" : ""} {assignments.length} assignment{assignments.length !== 1 ? "s" : ""}
</p> </p>
</div> </div>
<RosterView members={members} assignments={assignments} /> <RosterView
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,5 +1,9 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { type SSEEventClient, subscribe, unsubscribe } from "@/lib/realtime/bus"; import {
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,7 +5,12 @@ 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(process.cwd(), "src", "generated", "versionInfo.json"); const VERSION_INFO_PATH = path.join(
process.cwd(),
"src",
"generated",
"versionInfo.json",
);
const EMPTY: VersionInfo = { const EMPTY: VersionInfo = {
version: null, version: null,

View file

@ -24,7 +24,8 @@ 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 logistics. Log in to access the operations dashboard, intelligence, and
logistics.
</p> </p>
</div> </div>
<LoginForm /> <LoginForm />

View file

@ -22,10 +22,7 @@ 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({ await interaction.reply({ content: "This command only works in a server.", flags: MessageFlags.Ephemeral });
content: "This command only works in a server.",
flags: MessageFlags.Ephemeral,
});
return; return;
} }
@ -52,10 +49,7 @@ 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({ await interaction.reply({ content: "That channel isn't a text channel.", flags: MessageFlags.Ephemeral });
content: "That channel isn't a text channel.",
flags: MessageFlags.Ephemeral,
});
return; return;
} }

View file

@ -1,4 +1,4 @@
import { type ButtonInteraction, MessageFlags } from "discord.js"; import { MessageFlags, type ButtonInteraction } 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,10 +44,7 @@ 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({ await interaction.reply({ content: "Run /signup or /link first.", flags: MessageFlags.Ephemeral });
content: "Run /signup or /link first.",
flags: MessageFlags.Ephemeral,
});
return; return;
} }

View file

@ -2,8 +2,8 @@ import {
ActionRowBuilder, ActionRowBuilder,
ButtonBuilder, ButtonBuilder,
ButtonStyle, ButtonStyle,
type Client,
EmbedBuilder, EmbedBuilder,
type Client,
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 { type Client, EmbedBuilder } from "discord.js"; import { EmbedBuilder, type Client } 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";

View file

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

View file

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

View file

@ -11,8 +11,7 @@ export const GameHardResources: CollectionConfig = {
admin: { admin: {
group: "Game", group: "Game",
useAsTitle: "name", useAsTitle: "name",
description: 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.",
'"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,12 +36,7 @@ export const GameStructures: CollectionConfig = {
structure: d.id, structure: d.id,
targetCollection: "game-structures", targetCollection: "game-structures",
targetId: d.id, targetId: d.id,
data: { data: { previousStored: prev.storedResources, newStored: d.storedResources, previousVoid: prev.voidStorage, newVoid: d.voidStorage },
previousStored: prev.storedResources,
newStored: d.storedResources,
previousVoid: prev.voidStorage,
newVoid: d.voidStorage,
},
}); });
}, },
], ],

View file

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

View file

@ -1,4 +1,4 @@
import type { CollectionConfig, Payload } from "payload"; import type { CollectionConfig, Payload, User } 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,8 +45,7 @@ export const LockerStorages: CollectionConfig = {
name: "items", name: "items",
type: "array", type: "array",
admin: { admin: {
description: description: "Items stored in the locker grid. Position and rotation are managed from the member locker UI.",
"Items stored in the locker grid. Position and rotation are managed from the member locker UI.",
}, },
fields: [ fields: [
{ {
@ -134,7 +133,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,7 +524,8 @@ export const Assets: CollectionConfig = {
type: "group", type: "group",
label: "Storage Dimensions", label: "Storage Dimensions",
admin: { admin: {
description: "How this asset occupies space in member lockers and container grids.", description:
"How this asset occupies space in member lockers and container grids.",
}, },
fields: [ fields: [
{ {

View file

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

View file

@ -52,8 +52,12 @@ export const Structures: CollectionConfig = {
} }
const rotated = entry.rotated ?? false; const rotated = entry.rotated ?? false;
const ew = rotated ? ((res as any).gridHeight ?? 1) : ((res as any).gridWidth ?? 1); const ew = rotated
const eh = rotated ? ((res as any).gridWidth ?? 1) : ((res as any).gridHeight ?? 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++) {
@ -64,11 +68,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++) {
@ -112,8 +116,12 @@ export const Structures: CollectionConfig = {
} }
const rotated = entry.rotated ?? false; const rotated = entry.rotated ?? false;
const ew = rotated ? ((res as any).gridHeight ?? 1) : ((res as any).gridWidth ?? 1); const ew = rotated
const eh = rotated ? ((res as any).gridWidth ?? 1) : ((res as any).gridHeight ?? 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;
@ -127,11 +135,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;
@ -168,11 +176,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;
@ -222,15 +230,7 @@ export const Structures: CollectionConfig = {
structure: gsDoc.id, structure: gsDoc.id,
targetCollection: "game-structures", targetCollection: "game-structures",
targetId: gsDoc.id, targetId: gsDoc.id,
data: { data: { oldX, oldY, newX, newY, structureName: gsDoc.name, voidCount: newVoid.length, repositioned: changed },
oldX,
oldY,
newX,
newY,
structureName: gsDoc.name,
voidCount: newVoid.length,
repositioned: changed,
},
}); });
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -116,20 +116,13 @@ function MedalBody({
</defs> </defs>
{shape === "circle" && ( {shape === "circle" && (
<circle <circle cx={cx} cy={cy} r={r} fill={`url(#${gradId})`} stroke={finishColors[2]} strokeWidth={1.5} />
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(" ")}
@ -235,7 +228,14 @@ function MedalPreview({ design }: { design: MedalDesign }) {
})()} })()}
{/* Small connector tab between ribbon and medal */} {/* Small connector tab between ribbon and medal */}
<rect x={medalCx - 4} y={ribbonBottom} width={8} height={8} rx={2} fill="#9ca3af" /> <rect
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,8 +424,14 @@ export default function MedalDesigner() {
(stripes: Stripe[]) => setDesign((d) => ({ ...d, ribbonStripes: stripes })), (stripes: Stripe[]) => setDesign((d) => ({ ...d, ribbonStripes: stripes })),
[], [],
); );
const setShape = useCallback((shape: MedalShape) => setDesign((d) => ({ ...d, shape })), []); const setShape = useCallback(
const setFinish = useCallback((finish: MetalFinish) => setDesign((d) => ({ ...d, finish })), []); (shape: MedalShape) => setDesign((d) => ({ ...d, shape })),
[],
);
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,7 +79,9 @@ 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 ? [...stripes, ...stripes.slice(0, -1).reverse()] : stripes; const displayStripes = mirrored
? [...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;
@ -96,7 +98,16 @@ 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 = <rect key={i} x={x} y={0} width={w} height={viewHeight} fill={stripe.color} />; const el = (
<rect
key={i}
x={x}
y={0}
width={w}
height={viewHeight}
fill={stripe.color}
/>
);
x += w; x += w;
return el; return el;
}); });
@ -145,7 +156,9 @@ 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">Stripes</span> <span className="text-xs font-medium text-zinc-400 uppercase tracking-wider">
Stripes
</span>
<button <button
type="button" type="button"
onClick={addStripe} onClick={addStripe}
@ -209,14 +222,19 @@ function DeviceEditor({
onChange: (d: Device[]) => void; onChange: (d: Device[]) => void;
}) { }) {
const addDevice = (type: DeviceType) => const addDevice = (type: DeviceType) =>
onChange([...devices, { id: crypto.randomUUID(), type, position: 50, positionY: 50 }]); onChange([
...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">Devices</span> <span className="text-xs font-medium text-zinc-400 uppercase tracking-wider">
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];
@ -250,7 +268,9 @@ function DeviceEditor({
min={0} min={0}
max={100} max={100}
value={device.position} value={device.position}
onChange={(e) => updateDevice(device.id, { position: Number(e.target.value) })} onChange={(e) =>
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">
@ -264,7 +284,9 @@ function DeviceEditor({
min={0} min={0}
max={100} max={100}
value={device.positionY} value={device.positionY}
onChange={(e) => updateDevice(device.id, { positionY: Number(e.target.value) })} onChange={(e) =>
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">
@ -308,9 +330,18 @@ export default function RibbonDesigner() {
} }
}, [design, setValue]); }, [design, setValue]);
const setStripes = useCallback((stripes: Stripe[]) => setDesign((d) => ({ ...d, stripes })), []); const setStripes = useCallback(
const setDevices = useCallback((devices: Device[]) => setDesign((d) => ({ ...d, devices })), []); (stripes: Stripe[]) => setDesign((d) => ({ ...d, stripes })),
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,10 +8,7 @@ export function BooleanNode({ data }: NodeProps) {
const d = data as { value?: boolean; description?: string }; const d = data as { value?: boolean; description?: string };
return ( return (
<NodeShell <NodeShell nodeType="boolean" className="bg-emerald-950/80 border border-emerald-500/50 rounded-lg shadow-md min-w-28">
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,10 +6,7 @@ import { NodeShell } from "./NodeShell";
export function BranchNode(_props: NodeProps) { export function BranchNode(_props: NodeProps) {
return ( return (
<NodeShell <NodeShell nodeType="branch" className="bg-rose-950/80 border border-rose-500/50 rounded-lg shadow-md min-w-36">
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,10 +9,7 @@ export function ChoiceNode({ data }: NodeProps) {
const options = d.options ?? []; const options = d.options ?? [];
return ( return (
<NodeShell <NodeShell nodeType="choice" className="bg-amber-950/80 border border-amber-500/50 rounded-lg shadow-md min-w-56">
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,10 +9,7 @@ export function CompareNode({ data }: NodeProps) {
const op = d.operator ?? ">"; const op = d.operator ?? ">";
return ( return (
<NodeShell <NodeShell nodeType="compare" className="bg-slate-950/80 border border-slate-500/50 rounded-lg shadow-md min-w-32">
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,10 +8,7 @@ export function DateNode({ data }: NodeProps) {
const d = data as { value?: string; description?: string }; const d = data as { value?: string; description?: string };
return ( return (
<NodeShell <NodeShell nodeType="date" className="bg-cyan-950/80 border border-cyan-500/50 rounded-lg shadow-md min-w-32">
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,13 +8,12 @@ export function FlagNode({ data }: NodeProps) {
const d = data as { flagName?: string }; const d = data as { flagName?: string };
return ( return (
<NodeShell <NodeShell nodeType="flag" className="bg-orange-950/80 border border-orange-500/50 rounded-lg shadow-md min-w-40">
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">Flag</span> <span className="text-sm font-medium text-orange-200 flex-1 truncate">
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,11 +25,7 @@ export function FormatNode({ data }: NodeProps) {
]; ];
return ( return (
<NodeShell <NodeShell nodeType="format" dynamicInputs={dynamicInputs} className="bg-sky-950/80 border border-sky-500/50 rounded-lg shadow-md min-w-40">
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,15 +20,16 @@ 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 <NodeShell nodeType="gameData" className="bg-indigo-950/80 border border-indigo-500/50 rounded-lg shadow-md min-w-44">
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">Game Data</span> <span className="text-sm font-medium text-indigo-200 flex-1 truncate">
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,10 +9,7 @@ export function LogicNode({ data }: NodeProps) {
const op = d.operator ?? "AND"; const op = d.operator ?? "AND";
return ( return (
<NodeShell <NodeShell nodeType="logic" className="bg-lime-950/80 border border-lime-500/50 rounded-lg shadow-md min-w-32">
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,10 +9,7 @@ export function MathNode({ data }: NodeProps) {
const op = d.operator ?? "+"; const op = d.operator ?? "+";
return ( return (
<NodeShell <NodeShell nodeType="math" className="bg-violet-950/80 border border-violet-500/50 rounded-lg shadow-md min-w-32">
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,10 +8,7 @@ export function NarrativeBeatNode({ data }: NodeProps) {
const d = data as { title?: string; text?: string }; const d = data as { title?: string; text?: string };
return ( return (
<NodeShell <NodeShell nodeType="narrativeBeat" className="bg-blue-950/80 border border-blue-500/50 rounded-lg shadow-md min-w-56">
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 {
addEdge,
Background,
type Connection,
Controls,
type Edge,
MiniMap,
type Node,
ReactFlow, ReactFlow,
useEdgesState, Background,
Controls,
MiniMap,
useNodesState, useNodesState,
useEdgesState,
addEdge,
type Node,
type Edge,
type Connection,
} 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 } from "./types"; import { NODE_SOCKETS, type SocketType } from "./types";
const nodeTypes = { const nodeTypes = {
narrativeBeat: NarrativeBeatNode, narrativeBeat: NarrativeBeatNode,
@ -106,12 +106,7 @@ export function NarrativeFlowEditor() {
const isValidConnection = useCallback( const isValidConnection = useCallback(
(connection: Edge | Connection) => { (connection: Edge | Connection) => {
if ( if (!connection.source || !connection.target || !connection.sourceHandle || !connection.targetHandle) {
!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);
@ -126,16 +121,8 @@ 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 = const sourceType = sourceSocket?.type ?? (connection.sourceHandle.startsWith("arg-") || connection.sourceHandle.startsWith("option-") ? "any" : null);
sourceSocket?.type ?? const targetType = targetSocket?.type ?? (connection.targetHandle.startsWith("arg-") || connection.targetHandle.startsWith("option-") ? "any" : null);
(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;
@ -148,15 +135,21 @@ export function NarrativeFlowEditor() {
setEditingNodeId(node.id); setEditingNodeId(node.id);
}, []); }, []);
const onNodeContextMenu = useCallback((e: React.MouseEvent, node: Node) => { const onNodeContextMenu = useCallback(
(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((e: React.MouseEvent, edge: Edge) => { const onEdgeContextMenu = useCallback(
(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();
@ -185,7 +178,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(
( (
@ -247,129 +240,32 @@ 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"> <span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold mr-1">Story</span>
Story <button className={`${buttonClass} bg-blue-950 border-blue-700 text-blue-200 hover:bg-blue-900`} onClick={() => addNode("narrativeBeat")}>Beat</button>
</span> <button className={`${buttonClass} bg-amber-950 border-amber-700 text-amber-200 hover:bg-amber-900`} onClick={() => addNode("choice")}>Choice</button>
<button <button className={`${buttonClass} bg-emerald-950 border-emerald-700 text-emerald-200 hover:bg-emerald-900`} onClick={() => addNode("outcome")}>Outcome</button>
className={`${buttonClass} bg-blue-950 border-blue-700 text-blue-200 hover:bg-blue-900`} <button className={`${buttonClass} bg-yellow-950 border-yellow-700 text-yellow-200 hover:bg-yellow-900`} onClick={() => addNode("wait")}>Wait</button>
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"> <span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold ml-2 mr-1">Data</span>
Data <button className={`${buttonClass} bg-purple-950 border-purple-700 text-purple-200 hover:bg-purple-900`} onClick={() => addNode("number")}>Number</button>
</span> <button className={`${buttonClass} bg-teal-950 border-teal-700 text-teal-200 hover:bg-teal-900`} onClick={() => addNode("string")}>String</button>
<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>
className={`${buttonClass} bg-purple-950 border-purple-700 text-purple-200 hover:bg-purple-900`} <button className={`${buttonClass} bg-cyan-950 border-cyan-700 text-cyan-200 hover:bg-cyan-900`} onClick={() => addNode("date")}>Date</button>
onClick={() => addNode("number")} <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>
Number <button className={`${buttonClass} bg-orange-950 border-orange-700 text-orange-200 hover:bg-orange-900`} onClick={() => addNode("flag")}>Flag</button>
</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"> <span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold ml-2 mr-1">Logic</span>
Logic <button className={`${buttonClass} bg-slate-950 border-slate-700 text-slate-200 hover:bg-slate-900`} onClick={() => addNode("compare")}>Compare</button>
</span> <button className={`${buttonClass} bg-violet-950 border-violet-700 text-violet-200 hover:bg-violet-900`} onClick={() => addNode("math")}>Math</button>
<button <button className={`${buttonClass} bg-sky-950 border-sky-700 text-sky-200 hover:bg-sky-900`} onClick={() => addNode("format")}>Format</button>
className={`${buttonClass} bg-slate-950 border-slate-700 text-slate-200 hover:bg-slate-900`} <button className={`${buttonClass} bg-lime-950 border-lime-700 text-lime-200 hover:bg-lime-900`} onClick={() => addNode("logic")}>Logic</button>
onClick={() => addNode("compare")} <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>
Compare <button className={`${buttonClass} bg-fuchsia-950 border-fuchsia-700 text-fuchsia-200 hover:bg-fuchsia-900`} onClick={() => addNode("sequence")}>Seq</button>
</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 {nodes.length} node{nodes.length !== 1 ? "s" : ""}, {edges.length} edge{edges.length !== 1 ? "s" : ""}
{edges.length !== 1 ? "s" : ""}
</span> </span>
</div> </div>
); );

View file

@ -1,12 +1,12 @@
"use client"; "use client";
import { type FC, useEffect, useState } from "react"; import { useEffect, useState, type FC } 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,7 +22,12 @@ interface NodeEditDialogProps {
onSave: (nodeId: string, data: Record<string, unknown>) => void; onSave: (nodeId: string, data: Record<string, unknown>) => void;
} }
export const NodeEditDialog: FC<NodeEditDialogProps> = ({ node, open, onOpenChange, onSave }) => { export const NodeEditDialog: FC<NodeEditDialogProps> = ({
node,
open,
onOpenChange,
onSave,
}) => {
const [formData, setFormData] = useState<Record<string, unknown>>({}); const [formData, setFormData] = useState<Record<string, unknown>>({});
useEffect(() => { useEffect(() => {
@ -321,7 +326,10 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({ node, open, onOpenChan
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={() => {
const updated = [...options, { id: crypto.randomUUID(), label: "" }]; const updated = [
...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"
@ -576,7 +584,11 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({ node, open, onOpenChan
case "not": case "not":
case "branch": case "branch":
case "sequence": { case "sequence": {
return <p className="text-sm text-muted-foreground">No configurable properties.</p>; return (
<p className="text-sm text-muted-foreground">
No configurable properties.
</p>
);
} }
default: default:
@ -608,10 +620,7 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({ node, open, onOpenChan
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent <DialogContent className="max-w-lg dark:bg-zinc-900 dark:text-zinc-100 dark:border-zinc-700 bg-white text-zinc-900" showCloseButton={false}>
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>
@ -633,7 +642,10 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({ node, open, onOpenChan
> >
Cancel Cancel
</Button> </Button>
<Button onClick={handleSave} className="bg-blue-600 hover:bg-blue-700 text-white"> <Button
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 { NODE_SOCKETS, SOCKET_COLORS, type SocketDef } from "./types"; import { SOCKET_COLORS, NODE_SOCKETS, type SocketDef, type SocketType } from "./types";
function SocketHandle({ function SocketHandle({
socket, socket,
@ -81,12 +81,16 @@ 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 <SocketHandle key={socket.id} socket={socket} isInput={true} topPercent={top} />; return (
<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 <SocketHandle key={socket.id} socket={socket} isInput={false} topPercent={top} />; return (
<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,10 +6,7 @@ import { NodeShell } from "./NodeShell";
export function NotNode(_props: NodeProps) { export function NotNode(_props: NodeProps) {
return ( return (
<NodeShell <NodeShell nodeType="not" className="bg-red-950/80 border border-red-500/50 rounded-lg shadow-md min-w-28">
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,10 +8,7 @@ export function NumberNode({ data }: NodeProps) {
const d = data as { value?: number; description?: string }; const d = data as { value?: number; description?: string };
return ( return (
<NodeShell <NodeShell nodeType="number" className="bg-purple-950/80 border border-purple-500/50 rounded-lg shadow-md min-w-32">
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,10 +13,7 @@ export function OutcomeNode({ data }: NodeProps) {
const effects = d.effects ?? []; const effects = d.effects ?? [];
return ( return (
<NodeShell <NodeShell nodeType="outcome" className="bg-emerald-950/80 border border-emerald-500/50 rounded-lg shadow-md min-w-56">
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,10 +8,7 @@ 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 <NodeShell nodeType="random" className="bg-pink-950/80 border border-pink-500/50 rounded-lg shadow-md min-w-32">
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,10 +6,7 @@ import { NodeShell } from "./NodeShell";
export function SequenceNode(_props: NodeProps) { export function SequenceNode(_props: NodeProps) {
return ( return (
<NodeShell <NodeShell nodeType="sequence" className="bg-fuchsia-950/80 border border-fuchsia-500/50 rounded-lg shadow-md min-w-32">
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,10 +8,7 @@ export function StringNode({ data }: NodeProps) {
const d = data as { value?: string; description?: string }; const d = data as { value?: string; description?: string };
return ( return (
<NodeShell <NodeShell nodeType="string" className="bg-teal-950/80 border border-teal-500/50 rounded-lg shadow-md min-w-32">
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,10 +8,7 @@ export function WaitNode({ data }: NodeProps) {
const d = data as { ticks?: number; input?: unknown }; const d = data as { ticks?: number; input?: unknown };
return ( return (
<NodeShell <NodeShell nodeType="wait" className="bg-yellow-950/80 border border-yellow-500/50 rounded-lg shadow-md min-w-48">
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,8 +10,9 @@ 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. Log in to access the Classified operations portal for Polaris Task Force, an Arma 3 unit.
operations dashboard, intelligence, and logistics. Log in to access the operations dashboard, intelligence, and
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 (
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4"> <div className="flex items-center justify-between p-4 border-b border-gray-800">
<SidebarTrigger className="-ml-1" /> <div className="flex items-center gap-2">
<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"> </div>
<NotificationsBell /> <NotificationsBell />
</div> </div>
</header>
); );
}; };

View file

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

View file

@ -12,7 +12,11 @@ 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 { type AccountStatus, accountStatusBase, accountStatusClass } from "@/lib/banking/ui"; import {
accountStatusBase,
accountStatusClass,
type AccountStatus,
} from "@/lib/banking/ui";
const accountTypeMeta = { const accountTypeMeta = {
treasury: { treasury: {

View file

@ -3,9 +3,18 @@
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 { 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";
import { type AccountStatus, accountStatusBase, accountStatusClass } from "@/lib/banking/ui"; import {
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";
@ -17,7 +26,12 @@ interface AccountDetailProps {
canTransact: boolean; canTransact: boolean;
} }
export function AccountDetail({ account, ledger, otherAccounts, canTransact }: AccountDetailProps) { export function AccountDetail({
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;
@ -71,7 +85,10 @@ export function AccountDetail({ account, ledger, otherAccounts, canTransact }: A
</div> </div>
{canTransact && ( {canTransact && (
<BankTransactionDialog accountId={account.id} otherAccounts={otherAccounts} /> <BankTransactionDialog
accountId={account.id}
otherAccounts={otherAccounts}
/>
)} )}
<Item variant="outline"> <Item variant="outline">

View file

@ -2,15 +2,19 @@
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 { ArrowDownLeftIcon, ArrowRightLeftIcon, ArrowUpRightIcon } from "lucide-react"; import {
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 {
@ -41,10 +45,9 @@ const txTypeLabel: Record<string, string> = {
adjustment: "ADJUST", adjustment: "ADJUST",
}; };
function resolveAccountLabel(account: (number | null) | BankAccount | undefined): { function resolveAccountLabel(
label: string; account: (number | null) | BankAccount | undefined,
id: number | null; ): { label: string; 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 };
@ -85,7 +88,10 @@ 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 = targetAccountId != null ? `/logistics/banking/${targetAccountId}` : null; const href =
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()}`;
@ -107,15 +113,23 @@ 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">{tx.memo}</p> <p className="text-[11px] text-muted-foreground/70 italic truncate">
{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 && <span className="ml-1 text-[10px] text-muted-foreground/60">{cur}</span>} {cur && (
<span className="ml-1 text-[10px] text-muted-foreground/60">
{cur}
</span>
)}
</div> </div>
</div> </div>
); );

View file

@ -59,7 +59,10 @@ interface BankTransactionDialogProps {
otherAccounts: { id: number; name: string }[]; otherAccounts: { id: number; name: string }[];
} }
export function BankTransactionDialog({ accountId, otherAccounts }: BankTransactionDialogProps) { export function BankTransactionDialog({
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,7 +3,12 @@
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import {
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";
@ -41,7 +46,9 @@ 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">{children}</div> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{children}
</div>
)} )}
</section> </section>
); );
@ -62,13 +69,18 @@ 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 = typeof a.ownerUser === "object" ? a.ownerUser.id : a.ownerUser; const ownerId =
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 ? personal : myAccount ? [myAccount] : []; const visiblePersonal = isManager
? 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),
@ -76,7 +88,8 @@ export function BankingOverview({
); );
const visibleAccountsCount = const visibleAccountsCount =
(isManager ? treasury.length + factionAccounts.length : 0) + visiblePersonal.length; (isManager ? treasury.length + factionAccounts.length : 0) +
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;
@ -91,7 +104,10 @@ 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 variant="line" className="grid w-full max-w-sm grid-cols-2"> <TabsList
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"
@ -115,7 +131,10 @@ export function BankingOverview({
</div> </div>
<TabsContent value="activity" className="m-0"> <TabsContent value="activity" className="m-0">
<ActivityFeed transactions={recentTransactions} currencyResource={currencyResource} /> <ActivityFeed
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,7 +94,10 @@ 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 value={accountType} onValueChange={(v) => setAccountType(v as AccountType)}> <Select
value={accountType}
onValueChange={(v) => setAccountType(v as AccountType)}
>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select type" /> <SelectValue placeholder="Select type" />
</SelectTrigger> </SelectTrigger>

View file

@ -20,7 +20,9 @@ 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 <p className="text-sm text-muted-foreground italic">No ledger entries yet.</p>; return (
<p className="text-sm text-muted-foreground italic">No ledger entries yet.</p>
);
} }
return ( return (
@ -57,11 +59,17 @@ 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 ? txAmountPrefix("out") : txAmountPrefix(flow); const prefix = useEntrySign
const amountCls = useEntrySign ? txAmountClass("out") : txAmountClass(flow); ? txAmountPrefix("out")
: 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">{formatDate(entry.timestamp)}</td> <td className="px-4 py-2 text-muted-foreground">
{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,9 +30,16 @@ 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">You don&apos;t have a personal account yet.</p> <p className="text-xs text-muted-foreground">
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 size="sm" className="w-fit" onClick={handleCreate} disabled={loading}> <Button
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,7 +25,11 @@ 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({ account, currentUser, currencyResource }: WalletHeroProps) { export function WalletHero({
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);
@ -52,7 +56,9 @@ export function WalletHero({ account, currentUser, currencyResource }: WalletHer
<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">{"> WALLET NOT INITIALIZED"}</p> <p className="font-mono text-sm text-muted-foreground/80">
{"> 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>
@ -79,7 +85,8 @@ export function WalletHero({ account, currentUser, currencyResource }: WalletHer
const accountId = account.id; const accountId = account.id;
const lastActivity = formatLastActivity(account.updatedAt); const lastActivity = formatLastActivity(account.updatedAt);
const currency = typeof account.currency === "object" ? account.currency : currencyResource; const currency =
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;
@ -99,7 +106,9 @@ export function WalletHero({ account, currentUser, currencyResource }: WalletHer
<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">@{operatorLabel}</span> <span className="font-mono text-sm text-muted-foreground/90">
@{operatorLabel}
</span>
<span <span
className={`${accountStatusBase} ${accountStatusClass[statusKey] ?? accountStatusClass.open}`} className={`${accountStatusBase} ${accountStatusClass[statusKey] ?? accountStatusClass.open}`}
> >
@ -122,7 +131,10 @@ export function WalletHero({ account, currentUser, currencyResource }: WalletHer
</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 accountId={accountId} otherAccounts={otherAccounts} /> <BankTransactionDialog
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,17 +1,7 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { import { Bolt, Send, Shield, Users, UserCog, Warehouse, Crosshair, Backpack, LifeBuoy } from "lucide-react";
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,9 +1,13 @@
"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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { import {
SidebarGroup, SidebarGroup,
SidebarGroupLabel, SidebarGroupLabel,
@ -13,24 +17,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,7 +1,13 @@
"use client"; "use client"
import Link from "next/link"; import Link from "next/link"
import { Folder, type LucideIcon, MoreHorizontal, Share, Trash2 } from "lucide-react"; import {
Folder,
MoreHorizontal,
Share,
Trash2,
type LucideIcon,
} from "lucide-react"
import { import {
DropdownMenu, DropdownMenu,
@ -9,7 +15,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu"
import { import {
SidebarGroup, SidebarGroup,
SidebarGroupLabel, SidebarGroupLabel,
@ -18,18 +24,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">
@ -80,5 +86,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,6 +1,15 @@
"use client"; "use client";
import { BadgeCheck, Bird, ChevronsUpDown, FileTextIcon, LogOut } from "lucide-react"; import {
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,7 +15,9 @@ 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">{xp.toLocaleString()}</span> <span className="text-2xl font-bold uppercase">
{xp.toLocaleString()}
</span>
</div> </div>
</ItemTitle> </ItemTitle>
</ItemHeader> </ItemHeader>

View file

@ -1,5 +1,11 @@
import Link from "next/link"; import Link from "next/link";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import {
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 {
@ -29,7 +35,9 @@ export function ActiveCampaigns({ campaigns }: ActiveCampaignsProps) {
</div> </div>
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<p className="text-sm text-muted-foreground italic">No active campaigns.</p> <p className="text-sm text-muted-foreground italic">
No active campaigns.
</p>
</ItemContent> </ItemContent>
</Item> </Item>
); );
@ -69,7 +77,9 @@ 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">{campaign.summary}</span> <span className="text-xs text-muted-foreground truncate">
{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,7 +1,20 @@
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 { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { CalendarDaysIcon, ClockIcon, CrosshairIcon, MapPinIcon, UsersIcon } from "lucide-react"; import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
import {
CalendarDaysIcon,
ClockIcon,
CrosshairIcon,
MapPinIcon,
UsersIcon,
} from "lucide-react";
interface MissionBriefingProps { interface MissionBriefingProps {
mission: { mission: {
@ -47,14 +60,18 @@ export function MissionBriefing({ mission }: MissionBriefingProps) {
</div> </div>
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<p className="text-sm text-muted-foreground italic">No missions currently scheduled.</p> <p className="text-sm text-muted-foreground italic">
No missions currently scheduled.
</p>
</ItemContent> </ItemContent>
</Item> </Item>
); );
} }
const map = const map =
typeof mission.classification.map === "object" ? (mission.classification.map as Map) : null; typeof mission.classification.map === "object"
? (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)
@ -72,7 +89,10 @@ 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 = durationMins > 0 ? `${durationHours}h ${durationMins}m` : `${durationHours}h`; const durationStr =
durationMins > 0
? `${durationHours}h ${durationMins}m`
: `${durationHours}h`;
return ( return (
<Item variant="outline"> <Item variant="outline">
@ -92,7 +112,9 @@ 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">{mission.codeName}</span> <span className="font-mono text-xs text-muted-foreground">
{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"}`}
> >
@ -103,7 +125,9 @@ export function MissionBriefing({ mission }: MissionBriefingProps) {
</div> </div>
</div> </div>
<p className="text-sm text-muted-foreground line-clamp-2">{mission.summary}</p> <p className="text-sm text-muted-foreground line-clamp-2">
{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">
@ -126,7 +150,9 @@ export function MissionBriefing({ mission }: MissionBriefingProps) {
{mission.missionRoles.maxPlayers} slots {mission.missionRoles.maxPlayers} slots
</span> </span>
)} )}
{campaign && <span className="text-primary">{campaign.name}</span>} {campaign && (
<span className="text-primary">{campaign.name}</span>
)}
</div> </div>
</ItemContent> </ItemContent>
</Item> </Item>

View file

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

View file

@ -1,5 +1,16 @@
import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@/components/ui/item"; import {
import { Building2Icon, CrosshairIcon, ShieldIcon, UsersIcon } from "lucide-react"; Item,
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,5 +1,11 @@
import type { GameStructure, User } from "@/payload-types"; import type { User, GameStructure } from "@/payload-types";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import {
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";
@ -42,7 +48,9 @@ export function RecentEvents({ events, currentUserId }: RecentEventsProps) {
</div> </div>
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<p className="text-sm text-muted-foreground italic">No recent events recorded.</p> <p className="text-sm text-muted-foreground italic">
No recent events recorded.
</p>
</ItemContent> </ItemContent>
</Item> </Item>
); );
@ -63,9 +71,12 @@ 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 = actor?.id ?? (typeof event.actor === "number" ? event.actor : null); const actorId =
actor?.id ??
(typeof event.actor === "number" ? event.actor : null);
const isYou = actorId != null && actorId === currentUserId; const isYou = actorId != null && actorId === currentUserId;
const actorName = actor?.displayName ?? actor?.username ?? null; const actorName =
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;
@ -79,7 +90,9 @@ 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">{timeAgo(event.timestamp)}</span> <span className="tabular-nums shrink-0">
{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