Compare commits
14 commits
4905022dd5
...
50bc0e3441
| Author | SHA1 | Date | |
|---|---|---|---|
| 50bc0e3441 | |||
| cdeebd05f8 | |||
| 9f4faa948b | |||
| c27b12c76c | |||
| 50f964cdf9 | |||
| 2acf52788f | |||
| 6685fe68cd | |||
| 5058d9ce6c | |||
| a21564a78d | |||
| 7db1f648bf | |||
| 05c7c83799 | |||
| afb61f6227 | |||
| 088b6cefb6 | |||
| fa70ed0956 |
223 changed files with 18836 additions and 3275 deletions
23
.env.development
Normal file
23
.env.development
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
#DATABASE_URI=postgres://ptfapp:polaristaskforcedb@jmf-usrv-2404:2552/ptf_app_prd
|
||||
PAYLOAD_SECRET=b5ac11acc77ea8427d10fcd0
|
||||
# Added by Payload
|
||||
|
||||
EMAIL_FROM_ADDRESS="arma@onyxsimple.com"
|
||||
EMAIL_FROM_NAME="Arma"
|
||||
EMAIL_HOST="smtp-relay.brevo.com"
|
||||
EMAIL_PORT=587
|
||||
EMAIL_USERNAME="b34870001@smtp-brevo.com"
|
||||
EMAIL_PASSWORD="xsmtpsib-97789fdb52a37baeff8a586a06e849cdaea39bd7dfd850f1a612030328d64899-fVMh5bQkZx0wNQej"
|
||||
|
||||
LAST_UPDATED=1766438881
|
||||
|
||||
APP_URL=http://localhost:3000
|
||||
GAME_TICK_NOTIFY_SECRET=4707fba13f1c66db44df38721fba204f2d6bdc6796ced7a5
|
||||
|
||||
DISCORD_TOKEN=MTUzNzI2MzQ0Njk2NDMwNjEwMQ.GnTFBc.2LHX1FO7aAm9LbrKzj2IlVFNDmTBXsnBebzk3o
|
||||
DISCORD_GUILD_ID=851910807020765275
|
||||
DISCORD_OPS_CHANNEL_ID=1136692636481237173
|
||||
DISCORD_ANNOUNCE_CHANNEL_ID=1136692636481237173
|
||||
DISCORD_STAFF_ROLE_IDS=851910807277404187,851910807277404188,851910807277404189,851910807289462814
|
||||
DISCORD_ATTENDANCE_POLL_MS=5000
|
||||
DISCORD_NOTIFICATION_POLL_MS=5000
|
||||
BIN
public/favicon.webp
Normal file
BIN
public/favicon.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
|
|
@ -9,12 +9,8 @@ import { EventTypes } from "@/utils/event-log/eventTypes";
|
|||
import { notifyUser } from "@/lib/notifications";
|
||||
import { plaintextToLexical } from "@/lib/tickets/lexical";
|
||||
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 { CLOSING_STATUSES, STATUS_LABEL, TICKET_STATUSES } from "@/lib/tickets/ticketMeta";
|
||||
|
||||
export interface ActionResult<T = undefined> {
|
||||
success: boolean;
|
||||
|
|
@ -36,7 +32,10 @@ async function authenticate() {
|
|||
return { payload, user };
|
||||
}
|
||||
|
||||
function displayNameOf(user: { payloadDisplayName?: string | null; username?: string | null }): string {
|
||||
function displayNameOf(user: {
|
||||
payloadDisplayName?: string | null;
|
||||
username?: string | null;
|
||||
}): string {
|
||||
return user.payloadDisplayName || user.username || "A member";
|
||||
}
|
||||
|
||||
|
|
@ -169,9 +168,7 @@ export async function replyToTicket(ticketId: number, content: string): Promise<
|
|||
await payload.update({
|
||||
collection: "tickets",
|
||||
id: ticketId,
|
||||
data: reopen
|
||||
? { activity: newActivity, status: "open" }
|
||||
: { activity: newActivity },
|
||||
data: reopen ? { activity: newActivity, status: "open" } : { activity: newActivity },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
|
|
@ -229,7 +226,10 @@ export async function replyToTicket(ticketId: number, content: string): Promise<
|
|||
}
|
||||
}
|
||||
|
||||
export async function updateTicketStatus(ticketId: number, status: TicketStatus): Promise<ActionResult> {
|
||||
export async function updateTicketStatus(
|
||||
ticketId: number,
|
||||
status: TicketStatus,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const userId = user.id as number;
|
||||
|
|
@ -245,9 +245,7 @@ export async function updateTicketStatus(ticketId: number, status: TicketStatus)
|
|||
const reporterId = reporterIdOf(ticket);
|
||||
if (!isStaff) {
|
||||
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.");
|
||||
}
|
||||
|
||||
|
|
@ -293,7 +291,10 @@ export async function updateTicketStatus(ticketId: number, status: TicketStatus)
|
|||
}
|
||||
}
|
||||
|
||||
export async function assignTicket(ticketId: number, assigneeId: number | null): Promise<ActionResult> {
|
||||
export async function assignTicket(
|
||||
ticketId: number,
|
||||
assigneeId: number | null,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const userId = user.id as number;
|
||||
|
|
|
|||
|
|
@ -2,13 +2,12 @@ import config from "@payload-config";
|
|||
import { getPayload } from "payload";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import type { Campaign, Faction, Media, Mission } from "@/payload-types";
|
||||
import type { Faction, Media, Mission } from "@/payload-types";
|
||||
import { RichText } from "@payloadcms/richtext-lexical/react";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CalendarDaysIcon,
|
||||
ChevronRightIcon,
|
||||
CrosshairIcon,
|
||||
ShieldIcon,
|
||||
TargetIcon,
|
||||
UsersIcon,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import config from "@payload-config";
|
|||
import { getPayload } from "payload";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import type { Faction, Media, Mission, BankAccount, Resource, Campaign } from "@/payload-types";
|
||||
import type { BankAccount, Campaign, Faction, Media, Mission, Resource } from "@/payload-types";
|
||||
import { RichText } from "@payloadcms/richtext-lexical/react";
|
||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||
import {
|
||||
|
|
@ -10,8 +10,8 @@ import {
|
|||
ChevronRightIcon,
|
||||
FlagIcon,
|
||||
LandmarkIcon,
|
||||
ShieldIcon,
|
||||
ScaleIcon,
|
||||
ShieldIcon,
|
||||
} from "lucide-react";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { formatAmount } from "@/lib/banking/format";
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ export default async function FactionsPage() {
|
|||
<h1 className="text-lg font-semibold">Factions</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{factions.length} faction{factions.length !== 1 ? "s" : ""} tracked in
|
||||
the theater of operations.
|
||||
{factions.length} faction{factions.length !== 1 ? "s" : ""} tracked in the theater of
|
||||
operations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,35 +3,18 @@ import { getPayload } from "payload";
|
|||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import type {
|
||||
Mission,
|
||||
Campaign,
|
||||
Map,
|
||||
User,
|
||||
Faction,
|
||||
Qualification,
|
||||
Rank,
|
||||
Media,
|
||||
} from "@/payload-types";
|
||||
import type { Campaign, Map, Media, Mission } from "@/payload-types";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
CalendarDaysIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
MapPinIcon,
|
||||
CrosshairIcon,
|
||||
ServerIcon,
|
||||
FlagIcon,
|
||||
MapPinIcon,
|
||||
ServerIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { MissionBriefingSection } from "@/components/frontend/intelligence/MissionBriefingSection";
|
||||
import { MissionObjectives } from "@/components/frontend/intelligence/MissionObjectives";
|
||||
import { MissionFactions } from "@/components/frontend/intelligence/MissionFactions";
|
||||
|
|
@ -40,10 +23,10 @@ import { MissionLogistics } from "@/components/frontend/intelligence/MissionLogi
|
|||
import { MissionComms } from "@/components/frontend/intelligence/MissionComms";
|
||||
import { MissionAttendance } from "@/components/frontend/intelligence/MissionAttendance";
|
||||
import {
|
||||
type AttendanceResponse,
|
||||
getMissionAttendance,
|
||||
getUserAttendance,
|
||||
isMissionPast,
|
||||
type AttendanceResponse,
|
||||
} from "@/lib/attendance";
|
||||
|
||||
interface MissionDetailPageProps {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { setMissionAttendance as setAttendance } from "@/lib/attendance";
|
||||
import type { AttendanceResponse } from "@/lib/attendance";
|
||||
import { setMissionAttendance as setAttendance } from "@/lib/attendance";
|
||||
|
||||
export interface ActionResult<T = undefined> {
|
||||
success: boolean;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import type { Mission, Campaign, Map } from "@/payload-types";
|
||||
import type { Campaign, Mission } from "@/payload-types";
|
||||
import { CrosshairIcon } from "lucide-react";
|
||||
import { MissionsList } from "@/components/frontend/intelligence/MissionsList";
|
||||
|
||||
|
|
|
|||
|
|
@ -3,16 +3,10 @@ import { getPayload } from "payload";
|
|||
import { headers as nextHeaders } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import type { Campaign, Mission, Faction } from "@/payload-types";
|
||||
import type { Campaign, Faction } from "@/payload-types";
|
||||
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
|
||||
import { CrosshairIcon, ArrowRightIcon } from "lucide-react";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import { ArrowRightIcon, CrosshairIcon } from "lucide-react";
|
||||
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { CampaignCard } from "@/components/frontend/intelligence/CampaignCard";
|
||||
|
||||
export const metadata = {
|
||||
|
|
@ -55,8 +49,7 @@ export default async function IntelligenceOverviewPage() {
|
|||
<h1 className="text-lg font-semibold">Intelligence</h1>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
|
@ -71,9 +64,7 @@ export default async function IntelligenceOverviewPage() {
|
|||
<ArrowRightIcon className="size-4 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<ItemDescription>
|
||||
Active and completed campaigns across all theaters.
|
||||
</ItemDescription>
|
||||
<ItemDescription>Active and completed campaigns across all theaters.</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</Link>
|
||||
|
|
@ -87,9 +78,7 @@ export default async function IntelligenceOverviewPage() {
|
|||
<ArrowRightIcon className="size-4 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<ItemDescription>
|
||||
Operation briefings, objectives, and schedules.
|
||||
</ItemDescription>
|
||||
<ItemDescription>Operation briefings, objectives, and schedules.</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</Link>
|
||||
|
|
@ -104,8 +93,7 @@ export default async function IntelligenceOverviewPage() {
|
|||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<ItemDescription>
|
||||
{factions.length} faction{factions.length !== 1 ? "s" : ""}{" "}
|
||||
tracked in the AO.
|
||||
{factions.length} faction{factions.length !== 1 ? "s" : ""} tracked in the AO.
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import React from "react";
|
||||
import "./styles.css";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { AppSidebar } from "@/components/frontend/blocks/AppSidebar";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import { SiteHeader } from "@/components/frontend/SiteHeader";
|
||||
import { Metadata } from "next";
|
||||
import { resolveLevel, type ResolvedLevel } from "@/utils/xp/resolveLevel";
|
||||
import { type ResolvedLevel, resolveLevel } from "@/utils/xp/resolveLevel";
|
||||
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
|
||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||
import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
|
||||
|
|
@ -26,6 +26,13 @@ export const dynamic = "force-dynamic";
|
|||
export const metadata: Metadata = {
|
||||
description: "A gamified platform for Polaris Task Force, an Arma 3 unit.",
|
||||
title: "Polaris Task Force",
|
||||
icons: [
|
||||
{
|
||||
url: "/favicon.webp",
|
||||
type: "image/webp",
|
||||
rel: "icon"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||
|
|
@ -98,10 +105,10 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
|||
showCallsign={user.preferences?.display?.showCallsign ?? false}
|
||||
className="border-r"
|
||||
/>
|
||||
<main className="min-h-full bg-background w-full">
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
{children}
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
) : (
|
||||
<LandingPage />
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ import {
|
|||
autoArrangeLockerItems,
|
||||
ensureLockerStorage,
|
||||
findLockerEmptySpot,
|
||||
getLoadoutSlot,
|
||||
getLockerGridDimensions,
|
||||
getLockerItemFootprint,
|
||||
getLoadoutSlot,
|
||||
isAttachmentCompatible,
|
||||
isLockerPlacementValid,
|
||||
isSkinAsset,
|
||||
|
|
@ -23,7 +23,6 @@ import {
|
|||
resolveAssetId,
|
||||
skinAppliesTo,
|
||||
toLockerGridItems,
|
||||
type LockerGridItem,
|
||||
} from "@/lib/locker";
|
||||
|
||||
export interface ActionResult<T = undefined> {
|
||||
|
|
@ -219,7 +218,8 @@ export async function removeLockerItem(entryId: string): Promise<ActionResult> {
|
|||
|
||||
const removed = items[idx];
|
||||
const asset = removed.asset as Asset;
|
||||
const removedName = typeof asset === "object" && asset ? asset.name : `asset #${resolveAssetId(removed.asset)}`;
|
||||
const removedName =
|
||||
typeof asset === "object" && asset ? asset.name : `asset #${resolveAssetId(removed.asset)}`;
|
||||
|
||||
items.splice(idx, 1);
|
||||
await payload.update({
|
||||
|
|
@ -236,7 +236,12 @@ export async function removeLockerItem(entryId: string): Promise<ActionResult> {
|
|||
actor: user.id,
|
||||
targetCollection: "locker-storages",
|
||||
targetId: locker.id,
|
||||
data: { entryId, assetId: resolveAssetId(removed.asset), assetName: removedName, quantity: removed.quantity },
|
||||
data: {
|
||||
entryId,
|
||||
assetId: resolveAssetId(removed.asset),
|
||||
assetName: removedName,
|
||||
quantity: removed.quantity,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
|
|
@ -269,19 +274,33 @@ export async function moveLockerItem(
|
|||
const effectiveRotated = rotated ?? entry.rotated ?? false;
|
||||
const fp = getLockerItemFootprint(asset, effectiveRotated);
|
||||
|
||||
if (gridX < 0 || gridY < 0 || gridX + fp.width > grid.width || gridY + fp.height > grid.height) {
|
||||
if (
|
||||
gridX < 0 ||
|
||||
gridY < 0 ||
|
||||
gridX + fp.width > grid.width ||
|
||||
gridY + fp.height > grid.height
|
||||
) {
|
||||
return { success: false, error: "Placement is outside grid bounds." };
|
||||
}
|
||||
|
||||
const gridItems = toLockerGridItems(items);
|
||||
if (!isLockerPlacementValid(gridItems, entryId, gridX, gridY, fp.width, fp.height, grid.width, grid.height)) {
|
||||
if (
|
||||
!isLockerPlacementValid(
|
||||
gridItems,
|
||||
entryId,
|
||||
gridX,
|
||||
gridY,
|
||||
fp.width,
|
||||
fp.height,
|
||||
grid.width,
|
||||
grid.height,
|
||||
)
|
||||
) {
|
||||
return { success: false, error: "Cannot move here — overlaps with another item." };
|
||||
}
|
||||
|
||||
items[idx] =
|
||||
rotated !== undefined
|
||||
? { ...entry, gridX, gridY, rotated }
|
||||
: { ...entry, gridX, gridY };
|
||||
rotated !== undefined ? { ...entry, gridX, gridY, rotated } : { ...entry, gridX, gridY };
|
||||
|
||||
await payload.update({
|
||||
collection: "locker-storages",
|
||||
|
|
@ -297,7 +316,14 @@ export async function moveLockerItem(
|
|||
actor: user.id,
|
||||
targetCollection: "locker-storages",
|
||||
targetId: locker.id,
|
||||
data: { entryId, assetId: asset.id, assetName: asset.name, gridX, gridY, rotated: effectiveRotated },
|
||||
data: {
|
||||
entryId,
|
||||
assetId: asset.id,
|
||||
assetName: asset.name,
|
||||
gridX,
|
||||
gridY,
|
||||
rotated: effectiveRotated,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
|
|
@ -321,7 +347,8 @@ export async function rotateLockerItem(entryId: string): Promise<ActionResult> {
|
|||
const entry = items[idx];
|
||||
const asset = entry.asset as Asset;
|
||||
if (!asset || typeof asset === "number") return { success: false, error: "Invalid asset." };
|
||||
if (!lockerIsRotatable(asset)) return { success: false, error: "This asset cannot be rotated." };
|
||||
if (!lockerIsRotatable(asset))
|
||||
return { success: false, error: "This asset cannot be rotated." };
|
||||
|
||||
const newRotated = !(entry.rotated ?? false);
|
||||
const fp = getLockerItemFootprint(asset, newRotated);
|
||||
|
|
@ -331,7 +358,18 @@ export async function rotateLockerItem(entryId: string): Promise<ActionResult> {
|
|||
}
|
||||
|
||||
const gridItems = toLockerGridItems(items);
|
||||
if (!isLockerPlacementValid(gridItems, entryId, entry.gridX, entry.gridY, fp.width, fp.height, grid.width, grid.height)) {
|
||||
if (
|
||||
!isLockerPlacementValid(
|
||||
gridItems,
|
||||
entryId,
|
||||
entry.gridX,
|
||||
entry.gridY,
|
||||
fp.width,
|
||||
fp.height,
|
||||
grid.width,
|
||||
grid.height,
|
||||
)
|
||||
) {
|
||||
return { success: false, error: "Rotation would overlap with another item." };
|
||||
}
|
||||
|
||||
|
|
@ -362,10 +400,7 @@ 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 {
|
||||
const { payload, user } = await authenticate();
|
||||
const locker = await getLockerWithItems(payload, user.id as number);
|
||||
|
|
@ -385,7 +420,8 @@ export async function mergeLockerStacks(
|
|||
if (srcAsset.id !== tgtAsset.id) {
|
||||
return { success: false, error: "Cannot merge different asset types." };
|
||||
}
|
||||
if (!lockerIsStackable(tgtAsset)) return { success: false, error: "This asset cannot be stacked." };
|
||||
if (!lockerIsStackable(tgtAsset))
|
||||
return { success: false, error: "This asset cannot be stacked." };
|
||||
|
||||
const maxStack = lockerMaxStack(tgtAsset);
|
||||
const spaceInTarget = maxStack === Infinity ? Infinity : Math.max(0, maxStack - tgt.quantity);
|
||||
|
|
@ -426,7 +462,10 @@ export async function mergeLockerStacks(
|
|||
}
|
||||
}
|
||||
|
||||
export async function splitLockerStack(entryId: string, splitAmount: number): Promise<ActionResult> {
|
||||
export async function splitLockerStack(
|
||||
entryId: string,
|
||||
splitAmount: number,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!Number.isInteger(splitAmount) || splitAmount <= 0) {
|
||||
|
|
@ -445,7 +484,8 @@ export async function splitLockerStack(entryId: string, splitAmount: number): Pr
|
|||
}
|
||||
const asset = entry.asset as Asset;
|
||||
if (!asset || typeof asset === "number") return { success: false, error: "Invalid asset." };
|
||||
if (!lockerIsStackable(asset)) return { success: false, error: "This asset cannot be stacked." };
|
||||
if (!lockerIsStackable(asset))
|
||||
return { success: false, error: "This asset cannot be stacked." };
|
||||
|
||||
const gridItems = toLockerGridItems(items);
|
||||
const fp = getLockerItemFootprint(asset, entry.rotated ?? false);
|
||||
|
|
@ -475,7 +515,13 @@ export async function splitLockerStack(entryId: string, splitAmount: number): Pr
|
|||
actor: user.id,
|
||||
targetCollection: "locker-storages",
|
||||
targetId: locker.id,
|
||||
data: { entryId, assetId: asset.id, assetName: asset.name, splitAmount, remainingAmount: entry.quantity - splitAmount },
|
||||
data: {
|
||||
entryId,
|
||||
assetId: asset.id,
|
||||
assetName: asset.name,
|
||||
splitAmount,
|
||||
remainingAmount: entry.quantity - splitAmount,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
|
|
@ -674,7 +720,7 @@ export async function setLoadoutSlot(
|
|||
});
|
||||
|
||||
const equippedName = assetId
|
||||
? (await getAssetOrThrow(payload, assetId).catch(() => null))?.name ?? `asset #${assetId}`
|
||||
? ((await getAssetOrThrow(payload, assetId).catch(() => null))?.name ?? `asset #${assetId}`)
|
||||
: null;
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
|
|
@ -778,10 +824,7 @@ export async function equipLoadout(loadoutId: number, equipped = true): Promise<
|
|||
|
||||
// --- 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 {
|
||||
const { payload, user } = await authenticate();
|
||||
const locker = await getLockerWithItems(payload, user.id as number);
|
||||
|
|
@ -954,7 +997,8 @@ export async function setItemSkin(entryId: string, assetId: number): Promise<Act
|
|||
|
||||
const target = items[targetIdx];
|
||||
const equipment = target.asset as Asset;
|
||||
if (!equipment || typeof equipment === "number") return { success: false, error: "Invalid asset." };
|
||||
if (!equipment || typeof equipment === "number")
|
||||
return { success: false, error: "Invalid asset." };
|
||||
|
||||
const skin = await getAssetOrThrow(payload, assetId);
|
||||
if (!isSkinAsset(skin)) return { success: false, error: `"${skin.name}" is not a skin.` };
|
||||
|
|
@ -1056,7 +1100,13 @@ export async function removeItemSkin(entryId: string): Promise<ActionResult> {
|
|||
|
||||
const skin = await getAssetOrThrow(payload, skinId);
|
||||
const fp = getLockerItemFootprint(skin, false);
|
||||
const spot = findLockerEmptySpot(toLockerGridItems(items), fp.width, fp.height, grid.width, grid.height);
|
||||
const spot = findLockerEmptySpot(
|
||||
toLockerGridItems(items),
|
||||
fp.width,
|
||||
fp.height,
|
||||
grid.width,
|
||||
grid.height,
|
||||
);
|
||||
if (!spot) return { success: false, error: `No space in locker to return ${skin.name}.` };
|
||||
|
||||
const updatedItems = [...items];
|
||||
|
|
@ -1092,10 +1142,7 @@ 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 {
|
||||
const { payload, user } = await authenticate();
|
||||
const locker = await getLockerWithItems(payload, user.id as number);
|
||||
|
|
@ -1121,9 +1168,7 @@ export async function renameLockerItem(
|
|||
|
||||
await emitGameEvent(payload, {
|
||||
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,
|
||||
targetCollection: "locker-storages",
|
||||
targetId: locker.id,
|
||||
|
|
|
|||
|
|
@ -47,10 +47,7 @@ export default async function AccountPage({ params }: AccountPageProps) {
|
|||
? typedAccount.ownerUser?.id
|
||||
: 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) {
|
||||
notFound();
|
||||
|
|
|
|||
|
|
@ -2,13 +2,7 @@ import config from "@payload-config";
|
|||
import { getPayload } from "payload";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import type {
|
||||
GameVehicle,
|
||||
Vehicle,
|
||||
GameStructure,
|
||||
Structure,
|
||||
Resource,
|
||||
} from "@/payload-types";
|
||||
import type { GameStructure, GameVehicle, Resource, Structure, Vehicle } from "@/payload-types";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
FuelIcon,
|
||||
|
|
@ -38,9 +32,7 @@ const statusColors: Record<string, string> = {
|
|||
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 payload = await getPayload({ config });
|
||||
|
||||
|
|
@ -59,11 +51,9 @@ export default async function GameVehicleDetailPage({
|
|||
const vehicleData = vehicle as unknown as GameVehicle & {
|
||||
type: Vehicle;
|
||||
};
|
||||
const vehicleType =
|
||||
typeof vehicleData.type === "object" ? vehicleData.type : null;
|
||||
const vehicleType = typeof vehicleData.type === "object" ? vehicleData.type : null;
|
||||
const deployedAt =
|
||||
typeof vehicleData.deployedAt === "object" &&
|
||||
vehicleData.deployedAt !== null
|
||||
typeof vehicleData.deployedAt === "object" && vehicleData.deployedAt !== null
|
||||
? (vehicleData.deployedAt as GameStructure & { type: Structure })
|
||||
: null;
|
||||
const faction =
|
||||
|
|
@ -100,8 +90,7 @@ export default async function GameVehicleDetailPage({
|
|||
>
|
||||
{vehicleData.status === "in_transit"
|
||||
? "In Transit"
|
||||
: vehicleData.status.charAt(0).toUpperCase() +
|
||||
vehicleData.status.slice(1)}
|
||||
: vehicleData.status.charAt(0).toUpperCase() + vehicleData.status.slice(1)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{vehicleType?.name ?? "Unknown type"}
|
||||
|
|
@ -118,8 +107,7 @@ export default async function GameVehicleDetailPage({
|
|||
<ItemContent>
|
||||
<ItemTitle>Fuel</ItemTitle>
|
||||
<ItemDescription>
|
||||
{vehicleData.currentFuel?.toFixed(1)} /{" "}
|
||||
{vehicleType?.fuel?.fuelCapacity ?? "?"}
|
||||
{vehicleData.currentFuel?.toFixed(1)} / {vehicleType?.fuel?.fuelCapacity ?? "?"}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
|
@ -129,9 +117,7 @@ export default async function GameVehicleDetailPage({
|
|||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Health</ItemTitle>
|
||||
<ItemDescription>
|
||||
{vehicleData.currentHealth}
|
||||
</ItemDescription>
|
||||
<ItemDescription>{vehicleData.currentHealth}</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
<Item variant="muted" size="sm">
|
||||
|
|
@ -140,9 +126,7 @@ export default async function GameVehicleDetailPage({
|
|||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Location</ItemTitle>
|
||||
<ItemDescription>
|
||||
{deployedAt?.name ?? "In transit"}
|
||||
</ItemDescription>
|
||||
<ItemDescription>{deployedAt?.name ?? "In transit"}</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
<Item variant="muted" size="sm">
|
||||
|
|
@ -151,9 +135,7 @@ export default async function GameVehicleDetailPage({
|
|||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Faction</ItemTitle>
|
||||
<ItemDescription>
|
||||
{faction?.name ?? "Unassigned"}
|
||||
</ItemDescription>
|
||||
<ItemDescription>{faction?.name ?? "Unassigned"}</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</div>
|
||||
|
|
@ -180,25 +162,18 @@ export default async function GameVehicleDetailPage({
|
|||
}}
|
||||
/>
|
||||
</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 className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{vehicleData.currentFuel?.toFixed(1)} units remaining
|
||||
</span>
|
||||
<span>
|
||||
Capacity: {vehicleType?.fuel?.fuelCapacity ?? "?"} units
|
||||
</span>
|
||||
<span>{vehicleData.currentFuel?.toFixed(1)} units remaining</span>
|
||||
<span>Capacity: {vehicleType?.fuel?.fuelCapacity ?? "?"} units</span>
|
||||
</div>
|
||||
{vehicleType?.fuel?.fuelType &&
|
||||
typeof vehicleType.fuel.fuelType === "object" && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Fuel type: {vehicleType.fuel.fuelType.name} · Consumption
|
||||
rate: {vehicleType.fuel.fuelConsumptionRate}/unit
|
||||
</div>
|
||||
)}
|
||||
{vehicleType?.fuel?.fuelType && typeof vehicleType.fuel.fuelType === "object" && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Fuel type: {vehicleType.fuel.fuelType.name} · Consumption rate:{" "}
|
||||
{vehicleType.fuel.fuelConsumptionRate}/unit
|
||||
</div>
|
||||
)}
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
||||
|
|
@ -227,14 +202,9 @@ export default async function GameVehicleDetailPage({
|
|||
<tbody>
|
||||
{storedResources.map((entry, index) => {
|
||||
const resource =
|
||||
typeof entry.resource === "object"
|
||||
? (entry.resource as Resource)
|
||||
: null;
|
||||
typeof entry.resource === "object" ? (entry.resource as Resource) : null;
|
||||
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">
|
||||
{resource?.name ?? `Resource #${entry.resource}`}
|
||||
</td>
|
||||
|
|
@ -248,9 +218,7 @@ export default async function GameVehicleDetailPage({
|
|||
</table>
|
||||
</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>
|
||||
</Item>
|
||||
|
|
@ -266,28 +234,25 @@ export default async function GameVehicleDetailPage({
|
|||
<ItemContent>
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<div>
|
||||
<p className="font-semibold">
|
||||
{vehicleType?.name ?? "Unknown"}
|
||||
</p>
|
||||
<p className="font-semibold">{vehicleType?.name ?? "Unknown"}</p>
|
||||
{vehicleType?.classname && (
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
{vehicleType.classname}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{vehicleType?.category &&
|
||||
vehicleType.category.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{vehicleType.category.map((cat) => (
|
||||
<span
|
||||
key={cat}
|
||||
className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted"
|
||||
>
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{vehicleType?.category && vehicleType.category.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{vehicleType.category.map((cat) => (
|
||||
<span
|
||||
key={cat}
|
||||
className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted"
|
||||
>
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
|
|
|||
|
|
@ -52,17 +52,13 @@ export default async function GameVehiclesPage() {
|
|||
{vehicles.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{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
|
||||
? (vehicle.currentFuel / vehicleType.fuel.fuelCapacity) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={vehicle.id}
|
||||
href={`/logistics/game-vehicles/${vehicle.id}`}
|
||||
>
|
||||
<Link key={vehicle.id} href={`/logistics/game-vehicles/${vehicle.id}`}>
|
||||
<Item
|
||||
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)]"
|
||||
|
|
@ -84,8 +80,7 @@ export default async function GameVehiclesPage() {
|
|||
>
|
||||
{vehicle.status === "in_transit"
|
||||
? "In Transit"
|
||||
: vehicle.status.charAt(0).toUpperCase() +
|
||||
vehicle.status.slice(1)}
|
||||
: vehicle.status.charAt(0).toUpperCase() + vehicle.status.slice(1)}
|
||||
</span>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
|
|
@ -94,9 +89,7 @@ export default async function GameVehiclesPage() {
|
|||
<div className="flex-1 h-1.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
fuelPercent > 20
|
||||
? "bg-green-500"
|
||||
: "bg-red-500"
|
||||
fuelPercent > 20 ? "bg-green-500" : "bg-red-500"
|
||||
}`}
|
||||
style={{
|
||||
width: `${Math.min(100, fuelPercent)}%`,
|
||||
|
|
@ -112,16 +105,12 @@ export default async function GameVehiclesPage() {
|
|||
<span>HP: {vehicle.currentHealth}</span>
|
||||
{vehicleType?.fuel?.fuelType &&
|
||||
typeof vehicleType.fuel.fuelType === "object" && (
|
||||
<span>
|
||||
{vehicleType.fuel.fuelType.name}
|
||||
</span>
|
||||
<span>{vehicleType.fuel.fuelType.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ItemContent>
|
||||
<ItemDescription>
|
||||
{vehicleType?.name ?? "Unknown type"}
|
||||
</ItemDescription>
|
||||
<ItemDescription>{vehicleType?.name ?? "Unknown type"}</ItemDescription>
|
||||
</Item>
|
||||
</Link>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,17 +7,22 @@ import hasRoles from "@/utils/access-control/hasRoles";
|
|||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import {
|
||||
buyListing,
|
||||
countCleanQuantity,
|
||||
creditLockerQuantity,
|
||||
deductLockerQuantity,
|
||||
buyListing,
|
||||
negotiationRange,
|
||||
USER_LISTING_DURATION_MS,
|
||||
} from "@/lib/market";
|
||||
import { ensureLockerStorage, resolveAssetId } from "@/lib/locker";
|
||||
import { getMainCurrencyId } from "@/lib/banking";
|
||||
import { notifyUser } from "@/lib/notifications";
|
||||
import { expireNegotiationsForListing, npcDialogue, npcResponseToOffer, type NpcState } from "@/lib/market/negotiations";
|
||||
import {
|
||||
expireNegotiationsForListing,
|
||||
npcDialogue,
|
||||
npcResponseToOffer,
|
||||
type NpcState,
|
||||
} from "@/lib/market/negotiations";
|
||||
|
||||
export interface ActionResult<T = undefined> {
|
||||
success: boolean;
|
||||
|
|
@ -86,7 +91,9 @@ async function getListingOrThrow(
|
|||
|
||||
function assetNameOf(listing: MarketListing): string {
|
||||
const asset = listing.asset as Asset;
|
||||
return typeof asset === "object" && asset ? asset.name : `asset #${resolveAssetId(listing.asset)}`;
|
||||
return typeof asset === "object" && asset
|
||||
? asset.name
|
||||
: `asset #${resolveAssetId(listing.asset)}`;
|
||||
}
|
||||
|
||||
async function getNegotiationOrThrow(
|
||||
|
|
@ -94,7 +101,12 @@ async function getNegotiationOrThrow(
|
|||
negotiationId: number,
|
||||
): Promise<MarketNegotiation> {
|
||||
const negotiation = (await payload
|
||||
.findByID({ collection: "market-negotiations", id: negotiationId, depth: 2, overrideAccess: true })
|
||||
.findByID({
|
||||
collection: "market-negotiations",
|
||||
id: negotiationId,
|
||||
depth: 2,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null)) as unknown as MarketNegotiation | null;
|
||||
if (!negotiation) throw new Error("Negotiation not found.");
|
||||
return negotiation;
|
||||
|
|
@ -146,10 +158,7 @@ function enforceHigherBuyerOffer(negotiation: MarketNegotiation, amount: number)
|
|||
}
|
||||
|
||||
/** 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";
|
||||
const listing = negotiationListing(negotiation);
|
||||
if (typeof listing === "object" && listing) {
|
||||
|
|
@ -175,7 +184,7 @@ async function finalizeNegotiation(
|
|||
const buyerId = buyer.id as number;
|
||||
const sellerId = sellerIdOf(listing);
|
||||
const assetName = assetNameOf(listing);
|
||||
const quantity = negotiation.quantity ?? (listing.quantity ?? 1);
|
||||
const quantity = negotiation.quantity ?? listing.quantity ?? 1;
|
||||
const total = agreedPrice * quantity;
|
||||
|
||||
const result = await buyListing(payload, listing, buyer, { unitPrice: agreedPrice, quantity });
|
||||
|
|
@ -319,7 +328,7 @@ async function resolveNpcOffer(
|
|||
targetId: negotiation.id,
|
||||
data: {
|
||||
listingId: listing.id,
|
||||
quantity: negotiation.quantity ?? (listing.quantity ?? 1),
|
||||
quantity: negotiation.quantity ?? listing.quantity ?? 1,
|
||||
amount,
|
||||
counter: response.amount,
|
||||
patience: response.patience,
|
||||
|
|
@ -343,7 +352,7 @@ async function resolveNpcOffer(
|
|||
targetId: negotiation.id,
|
||||
data: {
|
||||
listingId: listing.id,
|
||||
quantity: negotiation.quantity ?? (listing.quantity ?? 1),
|
||||
quantity: negotiation.quantity ?? listing.quantity ?? 1,
|
||||
amount,
|
||||
counter: response.amount,
|
||||
patience: response.patience,
|
||||
|
|
@ -472,7 +481,12 @@ export async function cancelMarketListing(listingId: number): Promise<ActionResu
|
|||
const assetName =
|
||||
typeof asset === "object" && asset ? asset.name : `asset #${resolveAssetId(listing.asset)}`;
|
||||
|
||||
await creditLockerQuantity(payload, sellerId, resolveAssetId(listing.asset) as number, listing.quantity);
|
||||
await creditLockerQuantity(
|
||||
payload,
|
||||
sellerId,
|
||||
resolveAssetId(listing.asset) as number,
|
||||
listing.quantity,
|
||||
);
|
||||
|
||||
await payload.update({
|
||||
collection: "market-listings",
|
||||
|
|
@ -517,7 +531,7 @@ export async function buyMarketListing(
|
|||
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)) {
|
||||
return { success: false, error: `Quantity must be between 1 and ${listing.quantity}.` };
|
||||
}
|
||||
|
|
@ -580,7 +594,7 @@ export async function makeMarketOffer(
|
|||
return { success: false, error: "Offer must be greater than zero." };
|
||||
}
|
||||
|
||||
const qty = quantity ?? (listing.quantity ?? 1);
|
||||
const qty = quantity ?? 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}.` };
|
||||
}
|
||||
|
|
@ -642,7 +656,9 @@ export async function makeMarketOffer(
|
|||
amount,
|
||||
proposedBy: "buyer",
|
||||
quantity: qty,
|
||||
history: [{ amount, from: "buyer", note: "Initial offer", at: new Date().toISOString() }],
|
||||
history: [
|
||||
{ amount, from: "buyer", note: "Initial offer", at: new Date().toISOString() },
|
||||
],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 1,
|
||||
|
|
@ -728,7 +744,9 @@ export async function makeMarketOffer(
|
|||
* buyer accepts the seller's counteroffer). Completes the sale at the
|
||||
* agreed price.
|
||||
*/
|
||||
export async function acceptMarketOffer(negotiationId: number): Promise<ActionResult<MarketListing>> {
|
||||
export async function acceptMarketOffer(
|
||||
negotiationId: number,
|
||||
): Promise<ActionResult<MarketListing>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const userId = user.id as number;
|
||||
|
|
@ -789,8 +807,12 @@ export async function rejectMarketOffer(negotiationId: number): Promise<ActionRe
|
|||
}
|
||||
|
||||
const listing = negotiationListing(negotiation);
|
||||
const listingId = typeof listing === "object" && listing ? (listing as MarketListing).id : (listing as number);
|
||||
const assetName = typeof listing === "object" && listing ? assetNameOf(listing as MarketListing) : "the listing";
|
||||
const listingId =
|
||||
typeof listing === "object" && listing ? (listing as MarketListing).id : (listing as number);
|
||||
const assetName =
|
||||
typeof listing === "object" && listing
|
||||
? assetNameOf(listing as MarketListing)
|
||||
: "the listing";
|
||||
|
||||
await payload.update({
|
||||
collection: "market-negotiations",
|
||||
|
|
@ -868,9 +890,7 @@ export async function counterMarketOffer(
|
|||
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() });
|
||||
|
||||
const updated = (await payload.update({
|
||||
|
|
@ -900,7 +920,8 @@ export async function counterMarketOffer(
|
|||
return { success: true, data: refreshed };
|
||||
}
|
||||
|
||||
const counterpartyId = role === "buyer" ? sellerId : (negotiationBuyerId(negotiation) as number);
|
||||
const counterpartyId =
|
||||
role === "buyer" ? sellerId : (negotiationBuyerId(negotiation) as number);
|
||||
await notifyUser(payload, {
|
||||
userId: counterpartyId,
|
||||
type: "market:counter",
|
||||
|
|
@ -933,9 +954,14 @@ export async function cancelMarketOffer(negotiationId: number): Promise<ActionRe
|
|||
if (!role) return { success: false, error: "You are not part of this negotiation." };
|
||||
|
||||
const listing = negotiationListing(negotiation);
|
||||
const listingId = typeof listing === "object" && listing ? (listing as MarketListing).id : (listing as number);
|
||||
const assetName = typeof listing === "object" && listing ? assetNameOf(listing as MarketListing) : "the listing";
|
||||
const sellerId = typeof listing === "object" && listing ? sellerIdOf(listing as MarketListing) : null;
|
||||
const listingId =
|
||||
typeof listing === "object" && listing ? (listing as MarketListing).id : (listing as number);
|
||||
const assetName =
|
||||
typeof listing === "object" && listing
|
||||
? assetNameOf(listing as MarketListing)
|
||||
: "the listing";
|
||||
const sellerId =
|
||||
typeof listing === "object" && listing ? sellerIdOf(listing as MarketListing) : null;
|
||||
|
||||
await payload.update({
|
||||
collection: "market-negotiations",
|
||||
|
|
|
|||
|
|
@ -28,10 +28,7 @@ export default async function MarketPage() {
|
|||
and: [
|
||||
{ status: { equals: "active" } },
|
||||
{
|
||||
or: [
|
||||
{ expiresAt: { exists: false } },
|
||||
{ expiresAt: { greater_than: dayAgo } },
|
||||
],
|
||||
or: [{ expiresAt: { exists: false } }, { expiresAt: { greater_than: dayAgo } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -50,7 +47,9 @@ export default async function MarketPage() {
|
|||
|
||||
const myListings = listings.filter((l) => {
|
||||
const sellerId =
|
||||
typeof l.seller === "object" && l.seller ? (l.seller as { id: number }).id : (l.seller as number | null);
|
||||
typeof l.seller === "object" && l.seller
|
||||
? (l.seller as { id: number }).id
|
||||
: (l.seller as number | null);
|
||||
return sellerId === userId;
|
||||
});
|
||||
|
||||
|
|
@ -62,7 +61,9 @@ export default async function MarketPage() {
|
|||
});
|
||||
const negotiations = (negotiationsRes.docs as unknown as MarketNegotiation[]).filter((n) => {
|
||||
const buyerId =
|
||||
typeof n.buyer === "object" && n.buyer ? (n.buyer as { id: number }).id : (n.buyer as number | null);
|
||||
typeof n.buyer === "object" && n.buyer
|
||||
? (n.buyer as { id: number }).id
|
||||
: (n.buyer as number | null);
|
||||
if (buyerId === userId) return true;
|
||||
const listing = n.listing as unknown as MarketListing | number | null;
|
||||
if (typeof listing === "object" && listing) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { GameStructure, Faction, Structure } from "@/payload-types";
|
||||
import { Faction, GameStructure, Structure } from "@/payload-types";
|
||||
import { StorageGrid } from "@/components/frontend/storage/StorageGrid";
|
||||
import { Building2Icon } from "lucide-react";
|
||||
|
||||
|
|
@ -40,11 +40,7 @@ export default async function LogisticsPage() {
|
|||
Overview of all structures and their stored resources across the theater of operations.
|
||||
</p>
|
||||
</div>
|
||||
<StorageGrid
|
||||
structures={structures}
|
||||
categories={categories}
|
||||
factions={factions}
|
||||
/>
|
||||
<StorageGrid structures={structures} categories={categories} factions={factions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import config from "@payload-config";
|
|||
import { getPayload } from "payload";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import type { Resource, Structure, Asset } from "@/payload-types";
|
||||
import type { Asset, Structure } from "@/payload-types";
|
||||
import { RichText } from "@payloadcms/richtext-lexical/react";
|
||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||
import {
|
||||
|
|
@ -12,7 +12,6 @@ import {
|
|||
CoinsIcon,
|
||||
Grid3x3Icon,
|
||||
LayersIcon,
|
||||
MoveIcon,
|
||||
RotateCcwIcon,
|
||||
ScaleIcon,
|
||||
TagIcon,
|
||||
|
|
@ -48,9 +47,7 @@ const typeColors: Record<string, string> = {
|
|||
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 payload = await getPayload({ config });
|
||||
|
||||
|
|
@ -105,9 +102,7 @@ export default async function ResourceDetailPage({
|
|||
|
||||
const allRelatedStructures = [
|
||||
...producers,
|
||||
...autoGenStructures.filter(
|
||||
(s) => !producers.some((p) => p.id === s.id),
|
||||
),
|
||||
...autoGenStructures.filter((s) => !producers.some((p) => p.id === s.id)),
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -124,10 +119,7 @@ export default async function ResourceDetailPage({
|
|||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{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" />
|
||||
)}
|
||||
|
|
@ -243,10 +235,7 @@ export default async function ResourceDetailPage({
|
|||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{allRelatedStructures.map((structure) => (
|
||||
<Link
|
||||
key={structure.id}
|
||||
href={`/logistics/structures/${structure.id}`}
|
||||
>
|
||||
<Link key={structure.id} href={`/logistics/structures/${structure.id}`}>
|
||||
<Item
|
||||
variant="outline"
|
||||
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01]"
|
||||
|
|
|
|||
|
|
@ -38,9 +38,7 @@ export default async function ResourcesPage() {
|
|||
))}
|
||||
</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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,30 +2,16 @@ import config from "@payload-config";
|
|||
import { getPayload } from "payload";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import type {
|
||||
Shipment,
|
||||
GameStructure,
|
||||
GameVehicle,
|
||||
Vehicle,
|
||||
Resource,
|
||||
} from "@/payload-types";
|
||||
import type { GameStructure, GameVehicle, Vehicle } from "@/payload-types";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
ClockIcon,
|
||||
FuelIcon,
|
||||
MapPinIcon,
|
||||
PackageIcon,
|
||||
TruckIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { ShipmentActions } from "@/components/frontend/logistics/ShipmentActions";
|
||||
import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar";
|
||||
|
||||
|
|
@ -44,9 +30,7 @@ const statusColors: Record<string, string> = {
|
|||
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 payload = await getPayload({ config });
|
||||
|
||||
|
|
@ -71,31 +55,20 @@ export default async function ShipmentDetailPage({
|
|||
? (shipment.destination as GameStructure)
|
||||
: null;
|
||||
const vehicle =
|
||||
typeof shipment.transportVehicle === "object" &&
|
||||
shipment.transportVehicle !== null
|
||||
typeof shipment.transportVehicle === "object" && shipment.transportVehicle !== null
|
||||
? (shipment.transportVehicle as GameVehicle & { type: Vehicle })
|
||||
: 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 estimatedArrival = shipment.estimatedArrival
|
||||
? new Date(shipment.estimatedArrival)
|
||||
: null;
|
||||
const completedAt = shipment.completedAt
|
||||
? new Date(shipment.completedAt)
|
||||
: null;
|
||||
const dispatchedAt = shipment.dispatchedAt ? new Date(shipment.dispatchedAt) : null;
|
||||
const estimatedArrival = shipment.estimatedArrival ? new Date(shipment.estimatedArrival) : null;
|
||||
const completedAt = shipment.completedAt ? new Date(shipment.completedAt) : null;
|
||||
|
||||
const now = new Date();
|
||||
const isActive =
|
||||
shipment.status === "dispatched" || shipment.status === "in_transit";
|
||||
const isActive = shipment.status === "dispatched" || shipment.status === "in_transit";
|
||||
|
||||
const fuelPercent =
|
||||
shipment.fuelCost > 0
|
||||
? ((shipment.fuelConsumed ?? 0) / shipment.fuelCost) * 100
|
||||
: 0;
|
||||
shipment.fuelCost > 0 ? ((shipment.fuelConsumed ?? 0) / shipment.fuelCost) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="p-5 flex flex-col gap-6 max-w-7xl">
|
||||
|
|
@ -111,9 +84,7 @@ export default async function ShipmentDetailPage({
|
|||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<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 className="flex items-center gap-2">
|
||||
<span
|
||||
|
|
@ -121,8 +92,7 @@ export default async function ShipmentDetailPage({
|
|||
>
|
||||
{shipment.status === "in_transit"
|
||||
? "In Transit"
|
||||
: shipment.status.charAt(0).toUpperCase() +
|
||||
shipment.status.slice(1)}
|
||||
: shipment.status.charAt(0).toUpperCase() + shipment.status.slice(1)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{shipment.distance?.toFixed(1)} units distance
|
||||
|
|
@ -143,9 +113,7 @@ export default async function ShipmentDetailPage({
|
|||
<ItemContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
|
|
@ -155,9 +123,7 @@ export default async function ShipmentDetailPage({
|
|||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -220,23 +186,14 @@ export default async function ShipmentDetailPage({
|
|||
value: number | { name?: string };
|
||||
};
|
||||
itemType = polymorphic.relationTo;
|
||||
if (
|
||||
typeof polymorphic.value === "object" &&
|
||||
polymorphic.value !== null
|
||||
) {
|
||||
itemName =
|
||||
polymorphic.value.name ?? "Unknown";
|
||||
if (typeof polymorphic.value === "object" && polymorphic.value !== null) {
|
||||
itemName = polymorphic.value.name ?? "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className="border-b border-border/50"
|
||||
>
|
||||
<td className="px-4 py-2 font-medium">
|
||||
{itemName}
|
||||
</td>
|
||||
<tr 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">
|
||||
{itemType}
|
||||
</td>
|
||||
|
|
@ -250,9 +207,7 @@ export default async function ShipmentDetailPage({
|
|||
</table>
|
||||
</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>
|
||||
</Item>
|
||||
|
|
@ -271,9 +226,7 @@ export default async function ShipmentDetailPage({
|
|||
<ItemContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
<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">
|
||||
{vehicleType?.name ?? "Unknown type"}
|
||||
</p>
|
||||
|
|
@ -283,24 +236,19 @@ export default async function ShipmentDetailPage({
|
|||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Fuel</span>
|
||||
<span className="font-mono">
|
||||
{vehicle.currentFuel?.toFixed(1)} /{" "}
|
||||
{vehicleType?.fuel?.fuelCapacity ?? "?"}
|
||||
{vehicle.currentFuel?.toFixed(1)} / {vehicleType?.fuel?.fuelCapacity ?? "?"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
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={{
|
||||
width: `${Math.min(
|
||||
100,
|
||||
vehicleType?.fuel?.fuelCapacity
|
||||
? (vehicle.currentFuel /
|
||||
vehicleType.fuel.fuelCapacity) *
|
||||
100
|
||||
? (vehicle.currentFuel / vehicleType.fuel.fuelCapacity) * 100
|
||||
: 0,
|
||||
)}%`,
|
||||
}}
|
||||
|
|
@ -308,9 +256,7 @@ export default async function ShipmentDetailPage({
|
|||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Health</span>
|
||||
<span className="font-mono">
|
||||
{vehicle.currentHealth}
|
||||
</span>
|
||||
<span className="font-mono">{vehicle.currentHealth}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -334,9 +280,7 @@ export default async function ShipmentDetailPage({
|
|||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<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 className="h-2 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
|
|
@ -375,9 +319,7 @@ export default async function ShipmentDetailPage({
|
|||
{estimatedArrival && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{shipment.status === "completed"
|
||||
? "Arrived"
|
||||
: "Est. Arrival"}
|
||||
{shipment.status === "completed" ? "Arrived" : "Est. Arrival"}
|
||||
</span>
|
||||
<span>
|
||||
{estimatedArrival.toLocaleDateString()}{" "}
|
||||
|
|
@ -411,9 +353,7 @@ export default async function ShipmentDetailPage({
|
|||
<ItemTitle>Failure Reason</ItemTitle>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<p className="text-sm text-red-400">
|
||||
{shipment.failureReason}
|
||||
</p>
|
||||
<p className="text-sm text-red-400">{shipment.failureReason}</p>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,27 +2,13 @@
|
|||
|
||||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import type {
|
||||
GameStructure,
|
||||
GameVehicle,
|
||||
Structure,
|
||||
Vehicle,
|
||||
Resource,
|
||||
} from "@/payload-types";
|
||||
import type { GameStructure, GameVehicle, Resource, Structure, Vehicle } from "@/payload-types";
|
||||
import hasRoles from "@/utils/access-control/hasRoles";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { calculateDistance } from "@/lib/distance";
|
||||
import {
|
||||
calculateFuelCost,
|
||||
calculateTransitTime,
|
||||
getVehicleEffectiveSpeed,
|
||||
} from "@/lib/shipping";
|
||||
import { processShipmentTick } from "@/scripts/processShipmentTick";
|
||||
import {
|
||||
checkStorageDeposit,
|
||||
storageViolationMessage,
|
||||
} from "@/lib/storageRules";
|
||||
import { calculateFuelCost, calculateTransitTime, getVehicleEffectiveSpeed } from "@/lib/shipping";
|
||||
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
||||
|
||||
interface ActionResult {
|
||||
success: boolean;
|
||||
|
|
@ -105,8 +91,7 @@ export async function createShipment(params: {
|
|||
if (!originCoords || !destCoords) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Both origin and destination must have coordinates set.",
|
||||
error: "Both origin and destination must have coordinates set.",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -146,9 +131,7 @@ export async function createShipment(params: {
|
|||
}
|
||||
|
||||
const vehicleAtId =
|
||||
typeof vehicle.deployedAt === "object"
|
||||
? vehicle.deployedAt?.id
|
||||
: vehicle.deployedAt;
|
||||
typeof vehicle.deployedAt === "object" ? vehicle.deployedAt?.id : vehicle.deployedAt;
|
||||
|
||||
if (vehicleAtId !== originId) {
|
||||
return {
|
||||
|
|
@ -158,8 +141,7 @@ export async function createShipment(params: {
|
|||
}
|
||||
|
||||
// Get vehicle type details
|
||||
const vehicleType =
|
||||
typeof vehicle.type === "object" ? vehicle.type : null;
|
||||
const vehicleType = typeof vehicle.type === "object" ? vehicle.type : null;
|
||||
if (!vehicleType) {
|
||||
return { success: false, error: "Vehicle type not found." };
|
||||
}
|
||||
|
|
@ -182,8 +164,7 @@ export async function createShipment(params: {
|
|||
if (cargoItem.relationTo === "resources") {
|
||||
const available = storedResources
|
||||
.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;
|
||||
})
|
||||
.reduce((sum, r) => sum + r.amount, 0);
|
||||
|
|
@ -191,18 +172,12 @@ export async function createShipment(params: {
|
|||
if (available < cargoItem.amount) {
|
||||
const resName =
|
||||
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;
|
||||
})?.resource === "object"
|
||||
? (
|
||||
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;
|
||||
})?.resource as Resource
|
||||
).name
|
||||
|
|
@ -217,8 +192,7 @@ export async function createShipment(params: {
|
|||
}
|
||||
|
||||
// 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) {
|
||||
const destStored = destination.storedResources ?? [];
|
||||
const destVoid = (destination as GameStructure).voidStorage ?? [];
|
||||
|
|
@ -233,8 +207,7 @@ export async function createShipment(params: {
|
|||
itemId: number,
|
||||
) =>
|
||||
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);
|
||||
}, 0);
|
||||
|
||||
|
|
@ -257,8 +230,7 @@ export async function createShipment(params: {
|
|||
}
|
||||
|
||||
const currentStored =
|
||||
storedAmountIn(destStored, cargoItem.value) +
|
||||
storedAmountIn(destVoid, cargoItem.value);
|
||||
storedAmountIn(destStored, cargoItem.value) + storedAmountIn(destVoid, cargoItem.value);
|
||||
|
||||
const ruleCheck = checkStorageDeposit(
|
||||
destType,
|
||||
|
|
@ -278,10 +250,7 @@ export async function createShipment(params: {
|
|||
}
|
||||
|
||||
destMass += (resource.massPerUnit ?? 0) * cargoItem.amount;
|
||||
if (
|
||||
destType.maxCapacityMass &&
|
||||
destMass > destType.maxCapacityMass
|
||||
) {
|
||||
if (destType.maxCapacityMass && destMass > destType.maxCapacityMass) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Destination "${destination.name}" would exceed its mass capacity of ${destType.maxCapacityMass.toLocaleString()} kg.`,
|
||||
|
|
@ -327,8 +296,7 @@ export async function createShipment(params: {
|
|||
let remaining = cargoItem.amount;
|
||||
updatedResources = updatedResources
|
||||
.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) {
|
||||
const deduct = Math.min(r.amount, remaining);
|
||||
remaining -= deduct;
|
||||
|
|
@ -377,9 +345,7 @@ export async function createShipment(params: {
|
|||
fuelConsumed: 0,
|
||||
status: "dispatched",
|
||||
dispatchedAt: now.toISOString(),
|
||||
estimatedArrival: new Date(
|
||||
now.getTime() + transitTimeMs,
|
||||
).toISOString(),
|
||||
estimatedArrival: new Date(now.getTime() + transitTimeMs).toISOString(),
|
||||
autoReturn: false,
|
||||
createdBy: user.id,
|
||||
},
|
||||
|
|
@ -402,8 +368,7 @@ export async function createShipment(params: {
|
|||
|
||||
return { success: true, data: shipment };
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
if (message === "Unauthorized") {
|
||||
return { success: false, error: "You must be logged in." };
|
||||
}
|
||||
|
|
@ -411,9 +376,7 @@ export async function createShipment(params: {
|
|||
}
|
||||
}
|
||||
|
||||
export async function cancelShipment(
|
||||
shipmentId: number,
|
||||
): Promise<ActionResult> {
|
||||
export async function cancelShipment(shipmentId: number): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
|
||||
|
|
@ -433,10 +396,7 @@ export async function cancelShipment(
|
|||
return { success: false, error: "Shipment not found." };
|
||||
}
|
||||
|
||||
if (
|
||||
shipment.status !== "dispatched" &&
|
||||
shipment.status !== "in_transit"
|
||||
) {
|
||||
if (shipment.status !== "dispatched" && shipment.status !== "in_transit") {
|
||||
return {
|
||||
success: false,
|
||||
error: `Cannot cancel shipment with status "${shipment.status}".`,
|
||||
|
|
@ -444,9 +404,7 @@ export async function cancelShipment(
|
|||
}
|
||||
|
||||
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 =
|
||||
typeof shipment.transportVehicle === "object"
|
||||
? shipment.transportVehicle.id
|
||||
|
|
@ -464,9 +422,7 @@ export async function cancelShipment(
|
|||
if (polymorphic.relationTo !== "resources") continue;
|
||||
|
||||
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({
|
||||
collection: "game-structures",
|
||||
|
|
@ -476,8 +432,7 @@ export async function cancelShipment(
|
|||
|
||||
const existingResources = origin.storedResources ?? [];
|
||||
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;
|
||||
});
|
||||
|
||||
|
|
@ -531,8 +486,7 @@ export async function cancelShipment(
|
|||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
if (message === "Unauthorized") {
|
||||
return { success: false, error: "You must be logged in." };
|
||||
}
|
||||
|
|
@ -574,8 +528,7 @@ export async function toggleAutoReturn(
|
|||
|
||||
return { success: true };
|
||||
} 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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import type { Shipment, GameStructure, GameVehicle, Vehicle } from "@/payload-types";
|
||||
import type { Shipment } from "@/payload-types";
|
||||
import { TruckIcon } from "lucide-react";
|
||||
import { ShipmentsList } from "@/components/frontend/logistics/ShipmentsList";
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { getPayload } from "payload";
|
|||
import { notFound } from "next/navigation";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { GameStructure, Structure, Faction, Resource } from "@/payload-types";
|
||||
import { Faction, GameStructure, Resource, Structure } from "@/payload-types";
|
||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||
import { CapacityBar } from "@/components/frontend/storage/CapacityBar";
|
||||
import { StorageRules } from "@/components/frontend/storage/StorageRules";
|
||||
|
|
@ -45,11 +45,13 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
const headers = await nextHeaders();
|
||||
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||
|
||||
const structureRes = await payload.findByID({
|
||||
collection: "game-structures",
|
||||
id: Number(id),
|
||||
depth: 2,
|
||||
}).catch(() => null);
|
||||
const structureRes = await payload
|
||||
.findByID({
|
||||
collection: "game-structures",
|
||||
id: Number(id),
|
||||
depth: 2,
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!structureRes) {
|
||||
notFound();
|
||||
|
|
@ -116,9 +118,7 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
</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">
|
||||
<Item variant="outline">
|
||||
|
|
@ -187,7 +187,9 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Max Capacity</ItemTitle>
|
||||
<ItemDescription>{structureType.maxCapacityMass.toLocaleString()} kg (mass)</ItemDescription>
|
||||
<ItemDescription>
|
||||
{structureType.maxCapacityMass.toLocaleString()} kg (mass)
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
)}
|
||||
|
|
@ -204,9 +206,7 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
)}
|
||||
</div>
|
||||
|
||||
<StructureStorageView
|
||||
structure={structure}
|
||||
/>
|
||||
<StructureStorageView structure={structure} />
|
||||
|
||||
<StorageRules
|
||||
allowedStorage={structureType.allowedStorage}
|
||||
|
|
@ -229,8 +229,12 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
const resource = upkeep.resource as Resource;
|
||||
if (!resource || typeof resource === "number") return null;
|
||||
return (
|
||||
<span key={upkeep.id ?? index} className="text-sm bg-muted px-2.5 py-1 rounded-md">
|
||||
{resource.name}: {upkeep.amount?.toLocaleString()} {pluralize(upkeep.amount ?? 0, resource.unitOfMeasure)}/tick
|
||||
<span
|
||||
key={upkeep.id ?? index}
|
||||
className="text-sm bg-muted px-2.5 py-1 rounded-md"
|
||||
>
|
||||
{resource.name}: {upkeep.amount?.toLocaleString()}{" "}
|
||||
{pluralize(upkeep.amount ?? 0, resource.unitOfMeasure)}/tick
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,7 @@ import { GameStructure, Resource, Structure } from "@/payload-types";
|
|||
import hasRoles from "@/utils/access-control/hasRoles";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import {
|
||||
checkStorageDeposit,
|
||||
storageViolationMessage,
|
||||
} from "@/lib/storageRules";
|
||||
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
||||
|
||||
interface ActionResult {
|
||||
success: boolean;
|
||||
|
|
@ -32,11 +29,13 @@ async function authenticate() {
|
|||
}
|
||||
|
||||
async function getStructure(payload: Awaited<ReturnType<typeof getPayload>>, id: number) {
|
||||
const structure = await payload.findByID({
|
||||
collection: "game-structures",
|
||||
id,
|
||||
depth: 2,
|
||||
}).catch(() => null);
|
||||
const structure = await payload
|
||||
.findByID({
|
||||
collection: "game-structures",
|
||||
id,
|
||||
depth: 2,
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!structure) {
|
||||
throw new Error("Structure not found");
|
||||
|
|
@ -45,7 +44,10 @@ async function getStructure(payload: Awaited<ReturnType<typeof getPayload>>, id:
|
|||
return structure as unknown as GameStructure & { type: Structure };
|
||||
}
|
||||
|
||||
function getStoredAmount(storedResources: GameStructure["storedResources"], resourceId: number): number {
|
||||
function getStoredAmount(
|
||||
storedResources: GameStructure["storedResources"],
|
||||
resourceId: number,
|
||||
): number {
|
||||
if (!storedResources) return 0;
|
||||
const entry = storedResources.find((e) => {
|
||||
const res = e.resource;
|
||||
|
|
@ -73,7 +75,10 @@ export async function addResource(
|
|||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!hasRoles(["admin"], user)) {
|
||||
return { success: false, error: "Insufficient permissions. Admin or Developer role required for deposit." };
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. Admin or Developer role required for deposit.",
|
||||
};
|
||||
}
|
||||
|
||||
if (amount <= 0) {
|
||||
|
|
@ -84,11 +89,13 @@ export async function addResource(
|
|||
const structureType = structure.type;
|
||||
|
||||
// Fetch the resource to check rules + mass
|
||||
const resource = await payload.findByID({
|
||||
collection: "resources",
|
||||
id: resourceId,
|
||||
depth: 0,
|
||||
}).catch(() => null);
|
||||
const resource = await payload
|
||||
.findByID({
|
||||
collection: "resources",
|
||||
id: resourceId,
|
||||
depth: 0,
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!resource) {
|
||||
return { success: false, error: "Resource not found." };
|
||||
|
|
@ -99,12 +106,7 @@ export async function addResource(
|
|||
getStoredAmount(structure.storedResources, resourceId) +
|
||||
getStoredAmount(structure.voidStorage ?? [], resourceId);
|
||||
|
||||
const ruleCheck = checkStorageDeposit(
|
||||
structureType,
|
||||
resourceId,
|
||||
amount,
|
||||
currentStored,
|
||||
);
|
||||
const ruleCheck = checkStorageDeposit(structureType, resourceId, amount, currentStored);
|
||||
|
||||
if (!ruleCheck.allowed) {
|
||||
return {
|
||||
|
|
@ -136,9 +138,7 @@ export async function addResource(
|
|||
const resH = resource.gridHeight ?? 1;
|
||||
const ew = resW;
|
||||
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
|
||||
let remaining = amount;
|
||||
|
|
@ -151,9 +151,8 @@ export async function addResource(
|
|||
|
||||
for (const entry of sameType) {
|
||||
if (remaining <= 0) break;
|
||||
const space = maxStack === Infinity
|
||||
? remaining
|
||||
: Math.min(maxStack - entry.amount, remaining);
|
||||
const space =
|
||||
maxStack === Infinity ? remaining : Math.min(maxStack - entry.amount, remaining);
|
||||
if (space <= 0) continue;
|
||||
const idx = existing.findIndex((e) => e.id === entry.id);
|
||||
if (idx >= 0) {
|
||||
|
|
@ -204,7 +203,13 @@ export async function addResource(
|
|||
}
|
||||
|
||||
const stackAmount = Math.min(remaining, maxStack === Infinity ? remaining : maxStack);
|
||||
existing.push({ resource: resourceId, amount: stackAmount, gridX: spot.x, gridY: spot.y, rotated: false });
|
||||
existing.push({
|
||||
resource: resourceId,
|
||||
amount: stackAmount,
|
||||
gridX: spot.x,
|
||||
gridY: spot.y,
|
||||
rotated: false,
|
||||
});
|
||||
remaining -= stackAmount;
|
||||
}
|
||||
} else {
|
||||
|
|
@ -260,7 +265,10 @@ export async function removeResource(
|
|||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!hasRoles(["admin"], user)) {
|
||||
return { success: false, error: "Insufficient permissions. Admin or Developer role required for withdrawal." };
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. Admin or Developer role required for withdrawal.",
|
||||
};
|
||||
}
|
||||
|
||||
if (amount <= 0) {
|
||||
|
|
@ -331,7 +339,12 @@ export async function removeResource(
|
|||
data: { storedResources, voidStorage },
|
||||
});
|
||||
|
||||
const resName = (await payload.findByID({ collection: "resources", id: resourceId, depth: 0 }).catch(() => null))?.name ?? `resource #${resourceId}`;
|
||||
const resName =
|
||||
(
|
||||
await payload
|
||||
.findByID({ collection: "resources", id: resourceId, depth: 0 })
|
||||
.catch(() => null)
|
||||
)?.name ?? `resource #${resourceId}`;
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.StorageWithdraw,
|
||||
message: `Withdrew ${amount.toLocaleString()}× ${resName} from "${structure.name}"`,
|
||||
|
|
@ -361,7 +374,10 @@ export async function transferResource(
|
|||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) {
|
||||
return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required for transfer." };
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. User, Admin, or Developer role required for transfer.",
|
||||
};
|
||||
}
|
||||
|
||||
if (amount <= 0) {
|
||||
|
|
@ -405,11 +421,13 @@ export async function transferResource(
|
|||
}
|
||||
|
||||
// Fetch the resource for rule + capacity checks
|
||||
const resource = await payload.findByID({
|
||||
collection: "resources",
|
||||
id: resourceId,
|
||||
depth: 0,
|
||||
}).catch(() => null);
|
||||
const resource = await payload
|
||||
.findByID({
|
||||
collection: "resources",
|
||||
id: resourceId,
|
||||
depth: 0,
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!resource) {
|
||||
return { success: false, error: "Resource not found." };
|
||||
|
|
@ -468,7 +486,15 @@ export async function transferResource(
|
|||
structure: toStructureId,
|
||||
targetCollection: "game-structures",
|
||||
targetId: toStructureId,
|
||||
data: { resourceId, amount, resourceName: resName, fromStructureId, toStructureId, fromStructureName: fromStructure.name, toStructureName: toStructure.name },
|
||||
data: {
|
||||
resourceId,
|
||||
amount,
|
||||
resourceName: resName,
|
||||
fromStructureId,
|
||||
toStructureId,
|
||||
fromStructureName: fromStructure.name,
|
||||
toStructureName: toStructure.name,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, data: addResult.data };
|
||||
|
|
@ -499,7 +525,10 @@ export async function placeResourceOnGrid(
|
|||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!hasRoles(["admin"], user)) {
|
||||
return { success: false, error: "Insufficient permissions. Admin or Developer role required for placement." };
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. Admin or Developer role required for placement.",
|
||||
};
|
||||
}
|
||||
|
||||
if (amount <= 0) return { success: false, error: "Amount must be greater than zero." };
|
||||
|
|
@ -507,7 +536,9 @@ export async function placeResourceOnGrid(
|
|||
const structure = await getStructure(payload, structureId);
|
||||
const structureType = structure.type;
|
||||
|
||||
const resource = await payload.findByID({ collection: "resources", id: resourceId, depth: 0 }).catch(() => null);
|
||||
const resource = await payload
|
||||
.findByID({ collection: "resources", id: resourceId, depth: 0 })
|
||||
.catch(() => null);
|
||||
if (!resource) return { success: false, error: "Resource not found." };
|
||||
|
||||
// Check storage rules (prohibited → whitelist → per-item cap)
|
||||
|
|
@ -515,12 +546,7 @@ export async function placeResourceOnGrid(
|
|||
getStoredAmount(structure.storedResources, resourceId) +
|
||||
getStoredAmount(structure.voidStorage ?? [], resourceId);
|
||||
|
||||
const ruleCheck = checkStorageDeposit(
|
||||
structureType,
|
||||
resourceId,
|
||||
amount,
|
||||
currentStored,
|
||||
);
|
||||
const ruleCheck = checkStorageDeposit(structureType, resourceId, amount, currentStored);
|
||||
|
||||
if (!ruleCheck.allowed) {
|
||||
return { success: false, error: storageViolationMessage(resource.name, ruleCheck) };
|
||||
|
|
@ -534,7 +560,10 @@ export async function placeResourceOnGrid(
|
|||
const addedMass = (resource.massPerUnit ?? 0) * amount;
|
||||
const maxMass = structureType.maxCapacityMass;
|
||||
if (maxMass && currentMass + addedMass > maxMass) {
|
||||
return { success: false, error: `Exceeds mass capacity. Available: ${(maxMass - currentMass).toLocaleString()} kg.` };
|
||||
return {
|
||||
success: false,
|
||||
error: `Exceeds mass capacity. Available: ${(maxMass - currentMass).toLocaleString()} kg.`,
|
||||
};
|
||||
}
|
||||
|
||||
const gridW = structureType.xSize ?? 0;
|
||||
|
|
@ -543,8 +572,8 @@ export async function placeResourceOnGrid(
|
|||
return { success: false, error: "This structure does not have grid dimensions configured." };
|
||||
}
|
||||
|
||||
const resW = (resource.gridWidth ?? 1);
|
||||
const resH = (resource.gridHeight ?? 1);
|
||||
const resW = resource.gridWidth ?? 1;
|
||||
const resH = resource.gridHeight ?? 1;
|
||||
const effectiveW = rotated ? resH : resW;
|
||||
const effectiveH = rotated ? resW : resH;
|
||||
|
||||
|
|
@ -578,7 +607,11 @@ export async function placeResourceOnGrid(
|
|||
|
||||
existing.push({ resource: resourceId, amount, gridX, gridY, rotated });
|
||||
|
||||
await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: existing } });
|
||||
await payload.update({
|
||||
collection: "game-structures",
|
||||
id: structureId,
|
||||
data: { storedResources: existing },
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.GridPlace,
|
||||
|
|
@ -609,7 +642,10 @@ export async function moveGridItem(
|
|||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) {
|
||||
return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required." };
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. User, Admin, or Developer role required.",
|
||||
};
|
||||
}
|
||||
const structure = await getStructure(payload, structureId);
|
||||
const existing = [...(structure.storedResources ?? [])];
|
||||
|
|
@ -653,10 +689,13 @@ export async function moveGridItem(
|
|||
}
|
||||
}
|
||||
|
||||
existing[idx] = rotated !== undefined
|
||||
? { ...entry, gridX, gridY, rotated }
|
||||
: { ...entry, gridX, gridY };
|
||||
await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: existing } });
|
||||
existing[idx] =
|
||||
rotated !== undefined ? { ...entry, gridX, gridY, rotated } : { ...entry, gridX, gridY };
|
||||
await payload.update({
|
||||
collection: "game-structures",
|
||||
id: structureId,
|
||||
data: { storedResources: existing },
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.GridMove,
|
||||
|
|
@ -665,7 +704,14 @@ export async function moveGridItem(
|
|||
structure: structureId,
|
||||
targetCollection: "game-structures",
|
||||
targetId: structureId,
|
||||
data: { entryId, resourceId: (typeof res === "object" ? res.id : res), resourceName: res.name, gridX, gridY, rotated },
|
||||
data: {
|
||||
entryId,
|
||||
resourceId: typeof res === "object" ? res.id : res,
|
||||
resourceName: res.name,
|
||||
gridX,
|
||||
gridY,
|
||||
rotated,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
|
|
@ -685,7 +731,10 @@ export async function mergeGridStacks(
|
|||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) {
|
||||
return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required." };
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. User, Admin, or Developer role required.",
|
||||
};
|
||||
}
|
||||
const structure = await getStructure(payload, structureId);
|
||||
const existing = [...(structure.storedResources ?? [])];
|
||||
|
|
@ -705,9 +754,11 @@ export async function mergeGridStacks(
|
|||
|
||||
const srcResId = typeof srcRes === "object" ? srcRes.id : srcRes;
|
||||
const tgtResId = typeof tgtRes === "object" ? tgtRes.id : tgtRes;
|
||||
if (srcResId !== tgtResId) return { success: false, error: "Cannot merge different resource types." };
|
||||
if (srcResId !== tgtResId)
|
||||
return { success: false, error: "Cannot merge different resource types." };
|
||||
|
||||
if (tgtRes.stackable === false) return { success: false, error: "This resource cannot be stacked." };
|
||||
if (tgtRes.stackable === false)
|
||||
return { success: false, error: "This resource cannot be stacked." };
|
||||
|
||||
const maxStack = tgtRes.maxStackSize;
|
||||
const spaceInTarget = maxStack ? maxStack - tgt.amount : Infinity;
|
||||
|
|
@ -723,7 +774,11 @@ export async function mergeGridStacks(
|
|||
existing[srcIdx] = { ...src, amount: src.amount - transferAmount };
|
||||
}
|
||||
|
||||
await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: existing } });
|
||||
await payload.update({
|
||||
collection: "game-structures",
|
||||
id: structureId,
|
||||
data: { storedResources: existing },
|
||||
});
|
||||
|
||||
const mergeResName = typeof srcRes === "object" ? srcRes.name : `resource #${srcResId}`;
|
||||
await emitGameEvent(payload, {
|
||||
|
|
@ -733,7 +788,13 @@ export async function mergeGridStacks(
|
|||
structure: structureId,
|
||||
targetCollection: "game-structures",
|
||||
targetId: structureId,
|
||||
data: { sourceId, targetId, resourceId: srcResId, resourceName: mergeResName, transferAmount },
|
||||
data: {
|
||||
sourceId,
|
||||
targetId,
|
||||
resourceId: srcResId,
|
||||
resourceName: mergeResName,
|
||||
transferAmount,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
|
|
@ -752,7 +813,10 @@ export async function rotateGridItem(
|
|||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) {
|
||||
return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required." };
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. User, Admin, or Developer role required.",
|
||||
};
|
||||
}
|
||||
const structure = await getStructure(payload, structureId);
|
||||
const existing = [...(structure.storedResources ?? [])];
|
||||
|
|
@ -762,7 +826,8 @@ export async function rotateGridItem(
|
|||
const entry = existing[idx];
|
||||
const res = entry.resource as Resource;
|
||||
if (!res || typeof res === "number") return { success: false, error: "Invalid resource." };
|
||||
if (res.rotatable === false) return { success: false, error: "This resource cannot be rotated." };
|
||||
if (res.rotatable === false)
|
||||
return { success: false, error: "This resource cannot be rotated." };
|
||||
|
||||
const newRotated = !(entry.rotated ?? false);
|
||||
const ew = newRotated ? (res.gridHeight ?? 1) : (res.gridWidth ?? 1);
|
||||
|
|
@ -798,7 +863,11 @@ export async function rotateGridItem(
|
|||
}
|
||||
|
||||
existing[idx] = { ...entry, rotated: newRotated };
|
||||
await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: existing } });
|
||||
await payload.update({
|
||||
collection: "game-structures",
|
||||
id: structureId,
|
||||
data: { storedResources: existing },
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.GridRotate,
|
||||
|
|
@ -807,7 +876,12 @@ export async function rotateGridItem(
|
|||
structure: structureId,
|
||||
targetCollection: "game-structures",
|
||||
targetId: structureId,
|
||||
data: { entryId, resourceId: (typeof res === "object" ? res.id : res), resourceName: res.name, rotated: newRotated },
|
||||
data: {
|
||||
entryId,
|
||||
resourceId: typeof res === "object" ? res.id : res,
|
||||
resourceName: res.name,
|
||||
rotated: newRotated,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
|
|
@ -826,7 +900,10 @@ export async function removeGridItem(
|
|||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!hasRoles(["admin"], user)) {
|
||||
return { success: false, error: "Insufficient permissions. Admin or Developer role required for removal." };
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. Admin or Developer role required for removal.",
|
||||
};
|
||||
}
|
||||
const structure = await getStructure(payload, structureId);
|
||||
const existing = [...(structure.storedResources ?? [])];
|
||||
|
|
@ -836,9 +913,15 @@ export async function removeGridItem(
|
|||
const removedEntry = existing[idx];
|
||||
const removedRes = removedEntry.resource as Resource;
|
||||
existing.splice(idx, 1);
|
||||
await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: existing } });
|
||||
await payload.update({
|
||||
collection: "game-structures",
|
||||
id: structureId,
|
||||
data: { storedResources: existing },
|
||||
});
|
||||
|
||||
const removedName = removedRes?.name ?? `resource #${typeof removedRes === "object" ? removedRes.id : removedRes}`;
|
||||
const removedName =
|
||||
removedRes?.name ??
|
||||
`resource #${typeof removedRes === "object" ? removedRes.id : removedRes}`;
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.StorageWithdraw,
|
||||
message: `Removed ${removedEntry.amount}× ${removedName} from "${structure.name}"`,
|
||||
|
|
@ -846,7 +929,12 @@ export async function removeGridItem(
|
|||
structure: structureId,
|
||||
targetCollection: "game-structures",
|
||||
targetId: structureId,
|
||||
data: { entryId, resourceId: typeof removedRes === "object" ? removedRes.id : removedRes, resourceName: removedName, amount: removedEntry.amount },
|
||||
data: {
|
||||
entryId,
|
||||
resourceId: typeof removedRes === "object" ? removedRes.id : removedRes,
|
||||
resourceName: removedName,
|
||||
amount: removedEntry.amount,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
|
|
@ -866,7 +954,10 @@ export async function splitGridStack(
|
|||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) {
|
||||
return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required." };
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. User, Admin, or Developer role required.",
|
||||
};
|
||||
}
|
||||
|
||||
if (splitAmount <= 0) return { success: false, error: "Split amount must be positive." };
|
||||
|
|
@ -877,7 +968,8 @@ export async function splitGridStack(
|
|||
if (idx < 0) return { success: false, error: "Item not found in grid." };
|
||||
|
||||
const entry = existing[idx];
|
||||
if (splitAmount >= entry.amount) return { success: false, error: "Split amount must be less than the stack size." };
|
||||
if (splitAmount >= entry.amount)
|
||||
return { success: false, error: "Split amount must be less than the stack size." };
|
||||
|
||||
const res = entry.resource as Resource;
|
||||
if (!res || typeof res === "number") return { success: false, error: "Invalid resource." };
|
||||
|
|
@ -934,7 +1026,11 @@ export async function splitGridStack(
|
|||
|
||||
if (!placed) return { success: false, error: "No empty spot available for the split stack." };
|
||||
|
||||
await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: existing } });
|
||||
await payload.update({
|
||||
collection: "game-structures",
|
||||
id: structureId,
|
||||
data: { storedResources: existing },
|
||||
});
|
||||
|
||||
const splitResName = typeof res === "object" ? res.name : `resource #${res}`;
|
||||
await emitGameEvent(payload, {
|
||||
|
|
@ -944,7 +1040,13 @@ export async function splitGridStack(
|
|||
structure: structureId,
|
||||
targetCollection: "game-structures",
|
||||
targetId: structureId,
|
||||
data: { entryId, resourceId: typeof res === "object" ? res.id : res, resourceName: splitResName, splitAmount, remainingAmount: entry.amount - splitAmount },
|
||||
data: {
|
||||
entryId,
|
||||
resourceId: typeof res === "object" ? res.id : res,
|
||||
resourceName: splitResName,
|
||||
splitAmount,
|
||||
remainingAmount: entry.amount - splitAmount,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
|
|
@ -1029,9 +1131,10 @@ export async function retrieveFromVoid(structureId: number): Promise<GridActionR
|
|||
const recovered = voidStorage.length - remainingVoid.length;
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.StorageRecover,
|
||||
message: recovered > 0
|
||||
? `Retrieved ${recovered} item(s) from void in "${structure.name}"`
|
||||
: `No void items could be recovered in "${structure.name}" — grid full`,
|
||||
message:
|
||||
recovered > 0
|
||||
? `Retrieved ${recovered} item(s) from void in "${structure.name}"`
|
||||
: `No void items could be recovered in "${structure.name}" — grid full`,
|
||||
actor: user.id,
|
||||
structure: structureId,
|
||||
targetCollection: "game-structures",
|
||||
|
|
@ -1040,9 +1143,10 @@ export async function retrieveFromVoid(structureId: number): Promise<GridActionR
|
|||
});
|
||||
return {
|
||||
success: true,
|
||||
error: recovered > 0
|
||||
? `${recovered} item(s) retrieved from void storage.`
|
||||
: "No items could be retrieved — grid is full.",
|
||||
error:
|
||||
recovered > 0
|
||||
? `${recovered} item(s) retrieved from void storage.`
|
||||
: "No items could be retrieved — grid is full.",
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
|
|
@ -1056,7 +1160,10 @@ export async function autoArrangeGrid(structureId: number): Promise<GridActionRe
|
|||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) {
|
||||
return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required." };
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. User, Admin, or Developer role required.",
|
||||
};
|
||||
}
|
||||
const structure = await getStructure(payload, structureId);
|
||||
const gridW = structure.type.xSize ?? 0;
|
||||
|
|
|
|||
|
|
@ -1,34 +1,25 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Vehicle } from "@/payload-types";
|
||||
import { VehicleCard } from "@/components/frontend/logistics/VehicleCard";
|
||||
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[] }) {
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!selectedCategory) return vehicles;
|
||||
return vehicles.filter(
|
||||
(v) => v.category?.includes(selectedCategory as NonNullable<Vehicle["category"]>[number]),
|
||||
return vehicles.filter((v) =>
|
||||
v.category?.includes(selectedCategory as NonNullable<Vehicle["category"]>[number]),
|
||||
);
|
||||
}, [vehicles, selectedCategory]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<VehicleFilters
|
||||
categories={categories}
|
||||
onFilterChange={setSelectedCategory}
|
||||
/>
|
||||
<VehicleFilters categories={categories} onFilterChange={setSelectedCategory} />
|
||||
{filtered.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{filtered.map((vehicle) => (
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import config from "@payload-config";
|
|||
import { getPayload } from "payload";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import type { Vehicle, Resource, Technology, Structure } from "@/payload-types";
|
||||
import type { Resource, Structure, Technology } from "@/payload-types";
|
||||
import { RichText } from "@payloadcms/richtext-lexical/react";
|
||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||
import {
|
||||
|
|
@ -10,7 +10,6 @@ import {
|
|||
BoxIcon,
|
||||
Building2Icon,
|
||||
ClockIcon,
|
||||
CrosshairIcon,
|
||||
GaugeIcon,
|
||||
HeartIcon,
|
||||
ShieldIcon,
|
||||
|
|
@ -46,9 +45,7 @@ const armorColors: Record<string, string> = {
|
|||
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 payload = await getPayload({ config });
|
||||
|
||||
|
|
@ -144,10 +141,7 @@ export default async function VehicleDetailPage({
|
|||
<ItemContent>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{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()}
|
||||
</span>
|
||||
))}
|
||||
|
|
@ -203,9 +197,7 @@ export default async function VehicleDetailPage({
|
|||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Armor Rating</ItemTitle>
|
||||
<ItemDescription className="capitalize">
|
||||
{vehicle.armorRating}
|
||||
</ItemDescription>
|
||||
<ItemDescription className="capitalize">{vehicle.armorRating}</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
)}
|
||||
|
|
@ -319,15 +311,10 @@ export default async function VehicleDetailPage({
|
|||
<ItemContent>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{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.chance < 100 && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({scrap.chance}%)
|
||||
</span>
|
||||
<span className="text-muted-foreground ml-1">({scrap.chance}%)</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
|
|
@ -348,10 +335,7 @@ export default async function VehicleDetailPage({
|
|||
<ItemContent>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{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
|
||||
</span>
|
||||
))}
|
||||
|
|
@ -369,10 +353,7 @@ export default async function VehicleDetailPage({
|
|||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{producingStructures.map((structure) => (
|
||||
<Link
|
||||
key={structure.id}
|
||||
href={`/logistics/structures/${structure.id}`}
|
||||
>
|
||||
<Link key={structure.id} href={`/logistics/structures/${structure.id}`}>
|
||||
<Item
|
||||
variant="outline"
|
||||
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01]"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import type { User, Rank, Media, Assignment, Campaign, Map, GameStructure } from "@/payload-types";
|
||||
import type { Assignment, Campaign, GameStructure, Map, Media, Rank, User } from "@/payload-types";
|
||||
import { resolveLevel } from "@/utils/xp/resolveLevel";
|
||||
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
|
||||
import { ProfileSummary } from "@/components/frontend/dashboard/ProfileSummary";
|
||||
|
|
|
|||
|
|
@ -2,27 +2,27 @@ import config from "@payload-config";
|
|||
import { getPayload } from "payload";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import type { User, Rank, Award, Media, Assignment, Qualification } from "@/payload-types";
|
||||
import type { Assignment, Award, Media, Qualification, Rank, User } from "@/payload-types";
|
||||
import { RichText } from "@payloadcms/richtext-lexical/react";
|
||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||
import { resolveLevel } from "@/utils/xp/resolveLevel";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ShieldIcon,
|
||||
SwordsIcon,
|
||||
UserIcon,
|
||||
FileTextIcon,
|
||||
CrosshairIcon,
|
||||
AwardIcon,
|
||||
SparkleIcon,
|
||||
CalendarDaysIcon,
|
||||
HashIcon,
|
||||
SkullIcon,
|
||||
TruckIcon,
|
||||
PlaneIcon,
|
||||
TargetIcon,
|
||||
BadgeCheckIcon,
|
||||
CalendarDaysIcon,
|
||||
CrosshairIcon,
|
||||
FileTextIcon,
|
||||
HashIcon,
|
||||
PlaneIcon,
|
||||
ShieldCheck,
|
||||
ShieldIcon,
|
||||
SkullIcon,
|
||||
SparkleIcon,
|
||||
SwordsIcon,
|
||||
TargetIcon,
|
||||
TruckIcon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Item,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import type { User, Rank, Media } from "@/payload-types";
|
||||
import type { Media, Rank, User } from "@/payload-types";
|
||||
import { UsersIcon } from "lucide-react";
|
||||
import { RosterView } from "@/components/frontend/roster/RosterView";
|
||||
|
||||
|
|
@ -64,9 +64,7 @@ export default async function RosterPage() {
|
|||
const user = profile.user as User;
|
||||
const rank = profile.rank as Rank;
|
||||
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 assignmentType: string | undefined;
|
||||
|
|
@ -125,15 +123,12 @@ export default async function RosterPage() {
|
|||
<h1 className="text-lg font-semibold">Unit Roster</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{members.length} operator{members.length !== 1 ? "s" : ""} across{" "}
|
||||
{assignments.length} assignment{assignments.length !== 1 ? "s" : ""}
|
||||
{members.length} operator{members.length !== 1 ? "s" : ""} across {assignments.length}{" "}
|
||||
assignment{assignments.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RosterView
|
||||
members={members}
|
||||
assignments={assignments}
|
||||
/>
|
||||
<RosterView members={members} assignments={assignments} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import '@payloadcms/next/css'
|
||||
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
|
||||
import config from "@payload-config";
|
||||
import "@payloadcms/next/css";
|
||||
import { GRAPHQL_PLAYGROUND_GET } from "@payloadcms/next/routes";
|
||||
|
||||
export const GET = GRAPHQL_PLAYGROUND_GET(config);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
|
||||
import config from "@payload-config";
|
||||
import { GRAPHQL_POST, REST_OPTIONS } from "@payloadcms/next/routes";
|
||||
|
||||
export const POST = GRAPHQL_POST(config);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
type SSEEventClient,
|
||||
} from "@/lib/realtime/bus";
|
||||
import { type SSEEventClient, subscribe, unsubscribe } from "@/lib/realtime/bus";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
|
|
|||
|
|
@ -5,12 +5,7 @@ import type { VersionInfo } from "@/lib/versionInfo";
|
|||
export const dynamic = "force-dynamic";
|
||||
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 = {
|
||||
version: null,
|
||||
|
|
|
|||
|
|
@ -24,8 +24,7 @@ export default async function LoginPage() {
|
|||
<AppLogo />
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<LoginForm />
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ export const announceCommand: BotCommand = {
|
|||
) as SlashCommandBuilder,
|
||||
execute: async (interaction, payload) => {
|
||||
if (!interaction.inGuild() || !interaction.guild) {
|
||||
await interaction.reply({ content: "This command only works in a server.", flags: MessageFlags.Ephemeral });
|
||||
await interaction.reply({
|
||||
content: "This command only works in a server.",
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +52,10 @@ export const announceCommand: BotCommand = {
|
|||
const optionChannel = option && "send" in option ? (option as TextBasedChannelFields) : null;
|
||||
const channel = optionChannel ?? (await interaction.guild.channels.fetch(channelId));
|
||||
if (!channel || !("send" in channel)) {
|
||||
await interaction.reply({ content: "That channel isn't a text channel.", flags: MessageFlags.Ephemeral });
|
||||
await interaction.reply({
|
||||
content: "That channel isn't a text channel.",
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { MessageFlags, type ButtonInteraction } from "discord.js";
|
||||
import { type ButtonInteraction, MessageFlags } from "discord.js";
|
||||
import type { Payload } from "payload";
|
||||
import { findUserByDiscordId } from "@/bot/lib/resolve";
|
||||
import {
|
||||
|
|
@ -9,10 +9,10 @@ import {
|
|||
} from "@/bot/services/missionEmbeds";
|
||||
import {
|
||||
attendanceClosedMessage,
|
||||
type AttendanceResponse,
|
||||
getMissionAttendance,
|
||||
isMissionPast,
|
||||
setMissionAttendance,
|
||||
type AttendanceResponse,
|
||||
} from "@/lib/attendance";
|
||||
|
||||
const ATTENDANCE_LABELS: Record<AttendanceResponse, string> = {
|
||||
|
|
@ -44,7 +44,10 @@ export const handleButtonInteraction = async (
|
|||
try {
|
||||
const user = await findUserByDiscordId(payload, interaction.user.id);
|
||||
if (!user) {
|
||||
await interaction.reply({ content: "Run /signup or /link first.", flags: MessageFlags.Ephemeral });
|
||||
await interaction.reply({
|
||||
content: "Run /signup or /link first.",
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import {
|
|||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
EmbedBuilder,
|
||||
type Client,
|
||||
EmbedBuilder,
|
||||
type TextBasedChannelFields,
|
||||
} from "discord.js";
|
||||
import type { Payload } from "payload";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { EmbedBuilder, type Client } from "discord.js";
|
||||
import { type Client, EmbedBuilder } from "discord.js";
|
||||
import type { Payload } from "payload";
|
||||
import type { User, UserNotification } from "@/payload-types";
|
||||
import { botConfig } from "@/bot/config";
|
||||
|
|
|
|||
40
src/collections/Shims.ts
Normal file
40
src/collections/Shims.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { GlobalConfig } from "payload";
|
||||
import { isDeveloper } from "@/utils/access-control/isRole";
|
||||
|
||||
export const Shims: GlobalConfig = {
|
||||
slug: "shims",
|
||||
access: {
|
||||
update: isDeveloper,
|
||||
read: isDeveloper,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "shims",
|
||||
type: "array",
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
type: "text",
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
name: "summary",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
type: "code",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
type: "select",
|
||||
options: ["css", "js"],
|
||||
required: true,
|
||||
defaultValue: "css",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -7,7 +7,15 @@ export const BankAccounts: CollectionConfig = {
|
|||
admin: {
|
||||
group: "Banking",
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "accountType", "ownerUser", "ownerFaction", "balance", "status", "currency"],
|
||||
defaultColumns: [
|
||||
"name",
|
||||
"accountType",
|
||||
"ownerUser",
|
||||
"ownerFaction",
|
||||
"balance",
|
||||
"status",
|
||||
"currency",
|
||||
],
|
||||
},
|
||||
access: {
|
||||
read: ({ req }) => !!req.user,
|
||||
|
|
@ -21,7 +29,7 @@ export const BankAccounts: CollectionConfig = {
|
|||
type: "text",
|
||||
required: true,
|
||||
admin: {
|
||||
description: "Display name for the account (e.g., \"Unit Treasury\", \"Red Faction Reserve\").",
|
||||
description: 'Display name for the account (e.g., "Unit Treasury", "Red Faction Reserve").',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -35,7 +43,8 @@ export const BankAccounts: CollectionConfig = {
|
|||
{ label: "Personal", value: "personal" },
|
||||
],
|
||||
admin: {
|
||||
description: "Treasury = unit-level central funds. Faction = a specific faction's funds. Personal = an individual operator's wallet.",
|
||||
description:
|
||||
"Treasury = unit-level central funds. Faction = a specific faction's funds. Personal = an individual operator's wallet.",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -64,7 +73,8 @@ export const BankAccounts: CollectionConfig = {
|
|||
relationTo: "resources",
|
||||
required: true,
|
||||
admin: {
|
||||
description: "The currency this account is denominated in (normally the main currency set in Game Rules).",
|
||||
description:
|
||||
"The currency this account is denominated in (normally the main currency set in Game Rules).",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -51,7 +51,8 @@ export const GameEventLogs: CollectionConfig = {
|
|||
type: "checkbox",
|
||||
defaultValue: true,
|
||||
admin: {
|
||||
description: "System-generated events are created automatically. Uncheck for manual/GM narrative entries.",
|
||||
description:
|
||||
"System-generated events are created automatically. Uncheck for manual/GM narrative entries.",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ export const GameHardResources: CollectionConfig = {
|
|||
admin: {
|
||||
group: "Game",
|
||||
useAsTitle: "name",
|
||||
description: "\"Hard Resources\" represent the hard-coded, integral resource types used to power the game. It includes things like Intelligence Points, Command Points, and other non-physical resources that are essential for gameplay mechanics. Unlike regular (\"soft\") resources, hard resources are not calculated by transactions and are instead stored as numeric amounts that are updated by the backend. Soft resources are designed by the players, are defined in the Resources category under Logistics, and are calculated by transactions (ledgers) to ensure their accuracy.",
|
||||
description:
|
||||
'"Hard Resources" represent the hard-coded, integral resource types used to power the game. It includes things like Intelligence Points, Command Points, and other non-physical resources that are essential for gameplay mechanics. Unlike regular ("soft") resources, hard resources are not calculated by transactions and are instead stored as numeric amounts that are updated by the backend. Soft resources are designed by the players, are defined in the Resources category under Logistics, and are calculated by transactions (ledgers) to ensure their accuracy.',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -36,7 +36,12 @@ export const GameStructures: CollectionConfig = {
|
|||
structure: d.id,
|
||||
targetCollection: "game-structures",
|
||||
targetId: d.id,
|
||||
data: { previousStored: prev.storedResources, newStored: d.storedResources, previousVoid: prev.voidStorage, newVoid: d.voidStorage },
|
||||
data: {
|
||||
previousStored: prev.storedResources,
|
||||
newStored: d.storedResources,
|
||||
previousVoid: prev.voidStorage,
|
||||
newVoid: d.voidStorage,
|
||||
},
|
||||
});
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -120,8 +120,7 @@ export const Factions: CollectionConfig = {
|
|||
min: -100,
|
||||
max: 100,
|
||||
admin: {
|
||||
description:
|
||||
"Standing from -100 (hostile) to 100 (allied). 0 is neutral.",
|
||||
description: "Standing from -100 (hostile) to 100 (allied). 0 is neutral.",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { CollectionConfig, Payload, User } from "payload";
|
||||
import type { CollectionConfig, Payload } from "payload";
|
||||
import { isDeveloper } from "@/utils/access-control/isRole";
|
||||
import hasRoles from "@/utils/access-control/hasRoles";
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ export const LockerStorages: CollectionConfig = {
|
|||
name: "items",
|
||||
type: "array",
|
||||
admin: {
|
||||
description: "Items stored in the locker grid. Position and rotation are managed from the member locker UI.",
|
||||
description:
|
||||
"Items stored in the locker grid. Position and rotation are managed from the member locker UI.",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
|
|
@ -133,7 +134,7 @@ export const LockerStorages: CollectionConfig = {
|
|||
name: "customName",
|
||||
type: "text",
|
||||
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").',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -524,8 +524,7 @@ export const Assets: CollectionConfig = {
|
|||
type: "group",
|
||||
label: "Storage Dimensions",
|
||||
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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -149,7 +149,8 @@ export const Shipments: CollectionConfig = {
|
|||
type: "checkbox",
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description: "If true, the vehicle will automatically return to its origin after completing this shipment.",
|
||||
description:
|
||||
"If true, the vehicle will automatically return to its origin after completing this shipment.",
|
||||
},
|
||||
},
|
||||
// Metadata
|
||||
|
|
|
|||
|
|
@ -52,12 +52,8 @@ export const Structures: CollectionConfig = {
|
|||
}
|
||||
|
||||
const rotated = entry.rotated ?? false;
|
||||
const ew = rotated
|
||||
? (res as any).gridHeight ?? 1
|
||||
: (res as any).gridWidth ?? 1;
|
||||
const eh = rotated
|
||||
? (res as any).gridWidth ?? 1
|
||||
: (res as any).gridHeight ?? 1;
|
||||
const ew = rotated ? ((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;
|
||||
for (let y = 0; y <= newY - eh && !spot; y++) {
|
||||
|
|
@ -68,11 +64,11 @@ export const Structures: CollectionConfig = {
|
|||
if (!pRes || typeof pRes !== "object") continue;
|
||||
const pRotated = placed.rotated ?? false;
|
||||
const pw = pRotated
|
||||
? (pRes as any).gridHeight ?? 1
|
||||
: (pRes as any).gridWidth ?? 1;
|
||||
? ((pRes as any).gridHeight ?? 1)
|
||||
: ((pRes as any).gridWidth ?? 1);
|
||||
const ph = pRotated
|
||||
? (pRes as any).gridWidth ?? 1
|
||||
: (pRes as any).gridHeight ?? 1;
|
||||
? ((pRes as any).gridWidth ?? 1)
|
||||
: ((pRes as any).gridHeight ?? 1);
|
||||
const px = placed.gridX;
|
||||
const py = placed.gridY;
|
||||
for (let dx = 0; dx < ew && !overlaps; dx++) {
|
||||
|
|
@ -116,12 +112,8 @@ export const Structures: CollectionConfig = {
|
|||
}
|
||||
|
||||
const rotated = entry.rotated ?? false;
|
||||
const ew = rotated
|
||||
? (res as any).gridHeight ?? 1
|
||||
: (res as any).gridWidth ?? 1;
|
||||
const eh = rotated
|
||||
? (res as any).gridWidth ?? 1
|
||||
: (res as any).gridHeight ?? 1;
|
||||
const ew = rotated ? ((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 gy = entry.gridY;
|
||||
|
|
@ -135,11 +127,11 @@ export const Structures: CollectionConfig = {
|
|||
if (!pRes || typeof pRes !== "object") continue;
|
||||
const pRotated = placed.rotated ?? false;
|
||||
const pw = pRotated
|
||||
? (pRes as any).gridHeight ?? 1
|
||||
: (pRes as any).gridWidth ?? 1;
|
||||
? ((pRes as any).gridHeight ?? 1)
|
||||
: ((pRes as any).gridWidth ?? 1);
|
||||
const ph = pRotated
|
||||
? (pRes as any).gridWidth ?? 1
|
||||
: (pRes as any).gridHeight ?? 1;
|
||||
? ((pRes as any).gridWidth ?? 1)
|
||||
: ((pRes as any).gridHeight ?? 1);
|
||||
|
||||
const px = placed.gridX;
|
||||
const py = placed.gridY;
|
||||
|
|
@ -176,11 +168,11 @@ export const Structures: CollectionConfig = {
|
|||
if (!pRes || typeof pRes !== "object") continue;
|
||||
const pRotated = placed.rotated ?? false;
|
||||
const pw = pRotated
|
||||
? (pRes as any).gridHeight ?? 1
|
||||
: (pRes as any).gridWidth ?? 1;
|
||||
? ((pRes as any).gridHeight ?? 1)
|
||||
: ((pRes as any).gridWidth ?? 1);
|
||||
const ph = pRotated
|
||||
? (pRes as any).gridWidth ?? 1
|
||||
: (pRes as any).gridHeight ?? 1;
|
||||
? ((pRes as any).gridWidth ?? 1)
|
||||
: ((pRes as any).gridHeight ?? 1);
|
||||
|
||||
const px = placed.gridX;
|
||||
const py = placed.gridY;
|
||||
|
|
@ -230,7 +222,15 @@ export const Structures: CollectionConfig = {
|
|||
structure: gsDoc.id,
|
||||
targetCollection: "game-structures",
|
||||
targetId: gsDoc.id,
|
||||
data: { oldX, oldY, newX, newY, structureName: gsDoc.name, voidCount: newVoid.length, repositioned: changed },
|
||||
data: {
|
||||
oldX,
|
||||
oldY,
|
||||
newX,
|
||||
newY,
|
||||
structureName: gsDoc.name,
|
||||
voidCount: newVoid.length,
|
||||
repositioned: changed,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -124,8 +124,7 @@ export const Vehicles: CollectionConfig = {
|
|||
required: true,
|
||||
defaultValue: 1,
|
||||
admin: {
|
||||
description:
|
||||
"Fuel consumed per unit of distance traveled.",
|
||||
description: "Fuel consumed per unit of distance traveled.",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -70,8 +70,7 @@ export const MarketListings: CollectionConfig = {
|
|||
name: "desiredPrice",
|
||||
type: "number",
|
||||
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.",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -87,7 +86,8 @@ export const MarketListings: CollectionConfig = {
|
|||
type: "relationship",
|
||||
relationTo: "resources",
|
||||
admin: {
|
||||
description: "The currency this listing is priced in. Defaults to the main campaign currency.",
|
||||
description:
|
||||
"The currency this listing is priced in. Defaults to the main campaign currency.",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8,7 +8,16 @@ export const MarketNegotiations: CollectionConfig = {
|
|||
admin: {
|
||||
group: "Market",
|
||||
useAsTitle: "id",
|
||||
defaultColumns: ["id", "listing", "buyer", "amount", "patience", "proposedBy", "status", "updatedAt"],
|
||||
defaultColumns: [
|
||||
"id",
|
||||
"listing",
|
||||
"buyer",
|
||||
"amount",
|
||||
"patience",
|
||||
"proposedBy",
|
||||
"status",
|
||||
"updatedAt",
|
||||
],
|
||||
pagination: { defaultLimit: 50 },
|
||||
},
|
||||
access: {
|
||||
|
|
@ -17,10 +26,7 @@ export const MarketNegotiations: CollectionConfig = {
|
|||
if (!user) return false;
|
||||
if (hasRoles(["admin", "developer"], user)) return true;
|
||||
return {
|
||||
or: [
|
||||
{ buyer: { equals: user.id } },
|
||||
{ "listing.seller": { equals: user.id } },
|
||||
],
|
||||
or: [{ buyer: { equals: user.id } }, { "listing.seller": { equals: user.id } }],
|
||||
} as Where;
|
||||
},
|
||||
create: ({ req }) => !!req.user,
|
||||
|
|
@ -29,10 +35,7 @@ export const MarketNegotiations: CollectionConfig = {
|
|||
if (!user) return false;
|
||||
if (hasRoles(["admin", "developer"], user)) return true;
|
||||
return {
|
||||
or: [
|
||||
{ buyer: { equals: user.id } },
|
||||
{ "listing.seller": { equals: user.id } },
|
||||
],
|
||||
or: [{ buyer: { equals: user.id } }, { "listing.seller": { equals: user.id } }],
|
||||
} as Where;
|
||||
},
|
||||
delete: isDeveloper,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,15 @@ export const Tickets: CollectionConfig = {
|
|||
admin: {
|
||||
group: "Helpdesk",
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "status", "priority", "category", "reporter", "assignee", "updatedAt"],
|
||||
defaultColumns: [
|
||||
"title",
|
||||
"status",
|
||||
"priority",
|
||||
"category",
|
||||
"reporter",
|
||||
"assignee",
|
||||
"updatedAt",
|
||||
],
|
||||
listSearchableFields: ["title", "id"],
|
||||
pagination: { defaultLimit: 50 },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -116,13 +116,20 @@ function MedalBody({
|
|||
</defs>
|
||||
|
||||
{shape === "circle" && (
|
||||
<circle cx={cx} cy={cy} r={r} fill={`url(#${gradId})`} stroke={finishColors[2]} strokeWidth={1.5} />
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={r}
|
||||
fill={`url(#${gradId})`}
|
||||
stroke={finishColors[2]}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shape === "star" && (
|
||||
<polygon
|
||||
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;
|
||||
return `${cx + radius * Math.cos(angle)},${cy - radius * Math.sin(angle)}`;
|
||||
}).join(" ")}
|
||||
|
|
@ -228,14 +235,7 @@ function MedalPreview({ design }: { design: MedalDesign }) {
|
|||
})()}
|
||||
|
||||
{/* 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 */}
|
||||
<MedalBody shape={shape} finish={finish} engraving={engraving} cx={medalCx} cy={medalCy} />
|
||||
|
|
@ -424,14 +424,8 @@ export default function MedalDesigner() {
|
|||
(stripes: Stripe[]) => setDesign((d) => ({ ...d, ribbonStripes: stripes })),
|
||||
[],
|
||||
);
|
||||
const setShape = useCallback(
|
||||
(shape: MedalShape) => setDesign((d) => ({ ...d, shape })),
|
||||
[],
|
||||
);
|
||||
const setFinish = useCallback(
|
||||
(finish: MetalFinish) => setDesign((d) => ({ ...d, finish })),
|
||||
[],
|
||||
);
|
||||
const setShape = useCallback((shape: MedalShape) => setDesign((d) => ({ ...d, shape })), []);
|
||||
const setFinish = useCallback((finish: MetalFinish) => setDesign((d) => ({ ...d, finish })), []);
|
||||
const setEngraving = useCallback(
|
||||
(engraving: string) => setDesign((d) => ({ ...d, engraving })),
|
||||
[],
|
||||
|
|
|
|||
|
|
@ -79,9 +79,7 @@ function RibbonPreview({ design }: { design: RibbonDesign }) {
|
|||
const { stripes, devices, mirrored } = design;
|
||||
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 viewWidth = RIBBON_WIDTH;
|
||||
|
|
@ -98,16 +96,7 @@ function RibbonPreview({ design }: { design: RibbonDesign }) {
|
|||
let x = 0;
|
||||
return displayStripes.map((stripe, i) => {
|
||||
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;
|
||||
return el;
|
||||
});
|
||||
|
|
@ -156,9 +145,7 @@ function StripeEditor({
|
|||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<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
|
||||
type="button"
|
||||
onClick={addStripe}
|
||||
|
|
@ -222,19 +209,14 @@ function DeviceEditor({
|
|||
onChange: (d: Device[]) => void;
|
||||
}) {
|
||||
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 updateDevice = (id: string, patch: Partial<Device>) =>
|
||||
onChange(devices.map((d) => (d.id === id ? { ...d, ...patch } : d)));
|
||||
|
||||
return (
|
||||
<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">
|
||||
{(Object.keys(DEVICE_INFO) as DeviceType[]).map((type) => {
|
||||
const info = DEVICE_INFO[type];
|
||||
|
|
@ -268,9 +250,7 @@ function DeviceEditor({
|
|||
min={0}
|
||||
max={100}
|
||||
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"
|
||||
/>
|
||||
<span className="text-[10px] text-zinc-500 font-mono w-5 text-right">
|
||||
|
|
@ -284,9 +264,7 @@ function DeviceEditor({
|
|||
min={0}
|
||||
max={100}
|
||||
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"
|
||||
/>
|
||||
<span className="text-[10px] text-zinc-500 font-mono w-5 text-right">
|
||||
|
|
@ -330,18 +308,9 @@ export default function RibbonDesigner() {
|
|||
}
|
||||
}, [design, setValue]);
|
||||
|
||||
const setStripes = useCallback(
|
||||
(stripes: Stripe[]) => setDesign((d) => ({ ...d, stripes })),
|
||||
[],
|
||||
);
|
||||
const setDevices = useCallback(
|
||||
(devices: Device[]) => setDesign((d) => ({ ...d, devices })),
|
||||
[],
|
||||
);
|
||||
const toggleMirror = useCallback(
|
||||
() => setDesign((d) => ({ ...d, mirrored: !d.mirrored })),
|
||||
[],
|
||||
);
|
||||
const setStripes = useCallback((stripes: Stripe[]) => setDesign((d) => ({ ...d, stripes })), []);
|
||||
const setDevices = useCallback((devices: Device[]) => setDesign((d) => ({ ...d, devices })), []);
|
||||
const toggleMirror = useCallback(() => setDesign((d) => ({ ...d, mirrored: !d.mirrored })), []);
|
||||
|
||||
return (
|
||||
<div className="field-type json flex flex-col gap-3">
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ export function BooleanNode({ data }: NodeProps) {
|
|||
const d = data as { value?: boolean; description?: string };
|
||||
|
||||
return (
|
||||
<NodeShell nodeType="boolean" className="bg-emerald-950/80 border border-emerald-500/50 rounded-lg shadow-md min-w-28">
|
||||
<NodeShell
|
||||
nodeType="boolean"
|
||||
className="bg-emerald-950/80 border border-emerald-500/50 rounded-lg shadow-md min-w-28"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-emerald-500/30">
|
||||
<CheckCircleIcon className="size-4 text-emerald-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-emerald-200 flex-1 truncate">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import { NodeShell } from "./NodeShell";
|
|||
|
||||
export function BranchNode(_props: NodeProps) {
|
||||
return (
|
||||
<NodeShell nodeType="branch" className="bg-rose-950/80 border border-rose-500/50 rounded-lg shadow-md min-w-36">
|
||||
<NodeShell
|
||||
nodeType="branch"
|
||||
className="bg-rose-950/80 border border-rose-500/50 rounded-lg shadow-md min-w-36"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-rose-500/30">
|
||||
<GitBranchIcon className="size-4 text-rose-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-rose-200 flex-1">Branch</span>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ export function ChoiceNode({ data }: NodeProps) {
|
|||
const options = d.options ?? [];
|
||||
|
||||
return (
|
||||
<NodeShell nodeType="choice" className="bg-amber-950/80 border border-amber-500/50 rounded-lg shadow-md min-w-56">
|
||||
<NodeShell
|
||||
nodeType="choice"
|
||||
className="bg-amber-950/80 border border-amber-500/50 rounded-lg shadow-md min-w-56"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-amber-500/30">
|
||||
<WaypointsIcon className="size-4 text-amber-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-amber-200 flex-1 truncate">
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ export function CompareNode({ data }: NodeProps) {
|
|||
const op = d.operator ?? ">";
|
||||
|
||||
return (
|
||||
<NodeShell nodeType="compare" className="bg-slate-950/80 border border-slate-500/50 rounded-lg shadow-md min-w-32">
|
||||
<NodeShell
|
||||
nodeType="compare"
|
||||
className="bg-slate-950/80 border border-slate-500/50 rounded-lg shadow-md min-w-32"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-slate-500/30">
|
||||
<EqualIcon className="size-4 text-slate-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-slate-200 flex-1">Compare</span>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ export function DateNode({ data }: NodeProps) {
|
|||
const d = data as { value?: string; description?: string };
|
||||
|
||||
return (
|
||||
<NodeShell nodeType="date" className="bg-cyan-950/80 border border-cyan-500/50 rounded-lg shadow-md min-w-32">
|
||||
<NodeShell
|
||||
nodeType="date"
|
||||
className="bg-cyan-950/80 border border-cyan-500/50 rounded-lg shadow-md min-w-32"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-cyan-500/30">
|
||||
<CalendarIcon className="size-4 text-cyan-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-cyan-200 flex-1 truncate">
|
||||
|
|
|
|||
|
|
@ -8,12 +8,13 @@ export function FlagNode({ data }: NodeProps) {
|
|||
const d = data as { flagName?: string };
|
||||
|
||||
return (
|
||||
<NodeShell nodeType="flag" className="bg-orange-950/80 border border-orange-500/50 rounded-lg shadow-md min-w-40">
|
||||
<NodeShell
|
||||
nodeType="flag"
|
||||
className="bg-orange-950/80 border border-orange-500/50 rounded-lg shadow-md min-w-40"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-orange-500/30">
|
||||
<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 className="px-3 py-2 text-xs text-orange-300/80 truncate">
|
||||
{d.flagName || "Unnamed flag"}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,11 @@ export function FormatNode({ data }: NodeProps) {
|
|||
];
|
||||
|
||||
return (
|
||||
<NodeShell nodeType="format" dynamicInputs={dynamicInputs} className="bg-sky-950/80 border border-sky-500/50 rounded-lg shadow-md min-w-40">
|
||||
<NodeShell
|
||||
nodeType="format"
|
||||
dynamicInputs={dynamicInputs}
|
||||
className="bg-sky-950/80 border border-sky-500/50 rounded-lg shadow-md min-w-40"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-sky-500/30">
|
||||
<AlignLeftIcon className="size-4 text-sky-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-sky-200 flex-1">Format</span>
|
||||
|
|
|
|||
|
|
@ -20,16 +20,15 @@ export function GameDataNode({ data }: NodeProps) {
|
|||
const fieldLabel = GAME_DATA_FIELDS.find((f) => f.value === d.field)?.label ?? "Select field";
|
||||
|
||||
return (
|
||||
<NodeShell nodeType="gameData" className="bg-indigo-950/80 border border-indigo-500/50 rounded-lg shadow-md min-w-44">
|
||||
<NodeShell
|
||||
nodeType="gameData"
|
||||
className="bg-indigo-950/80 border border-indigo-500/50 rounded-lg shadow-md min-w-44"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-indigo-500/30">
|
||||
<DatabaseIcon className="size-4 text-indigo-400 shrink-0" />
|
||||
<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}
|
||||
<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>
|
||||
</NodeShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ export function LogicNode({ data }: NodeProps) {
|
|||
const op = d.operator ?? "AND";
|
||||
|
||||
return (
|
||||
<NodeShell nodeType="logic" className="bg-lime-950/80 border border-lime-500/50 rounded-lg shadow-md min-w-32">
|
||||
<NodeShell
|
||||
nodeType="logic"
|
||||
className="bg-lime-950/80 border border-lime-500/50 rounded-lg shadow-md min-w-32"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-lime-500/30">
|
||||
<BinaryIcon className="size-4 text-lime-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-lime-200 flex-1">Logic</span>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ export function MathNode({ data }: NodeProps) {
|
|||
const op = d.operator ?? "+";
|
||||
|
||||
return (
|
||||
<NodeShell nodeType="math" className="bg-violet-950/80 border border-violet-500/50 rounded-lg shadow-md min-w-32">
|
||||
<NodeShell
|
||||
nodeType="math"
|
||||
className="bg-violet-950/80 border border-violet-500/50 rounded-lg shadow-md min-w-32"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-violet-500/30">
|
||||
<CalculatorIcon className="size-4 text-violet-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-violet-200 flex-1">Math</span>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ export function NarrativeBeatNode({ data }: NodeProps) {
|
|||
const d = data as { title?: string; text?: string };
|
||||
|
||||
return (
|
||||
<NodeShell nodeType="narrativeBeat" className="bg-blue-950/80 border border-blue-500/50 rounded-lg shadow-md min-w-56">
|
||||
<NodeShell
|
||||
nodeType="narrativeBeat"
|
||||
className="bg-blue-950/80 border border-blue-500/50 rounded-lg shadow-md min-w-56"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-blue-500/30">
|
||||
<BookOpenTextIcon className="size-4 text-blue-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-blue-200 flex-1 truncate">
|
||||
|
|
|
|||
|
|
@ -2,16 +2,16 @@
|
|||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
addEdge,
|
||||
type Node,
|
||||
type Edge,
|
||||
Background,
|
||||
type Connection,
|
||||
Controls,
|
||||
type Edge,
|
||||
MiniMap,
|
||||
type Node,
|
||||
ReactFlow,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ import { NotNode } from "./NotNode";
|
|||
import { BranchNode } from "./BranchNode";
|
||||
import { SequenceNode } from "./SequenceNode";
|
||||
import { NodeEditDialog } from "./NodeEditDialog";
|
||||
import { NODE_SOCKETS, type SocketType } from "./types";
|
||||
import { NODE_SOCKETS } from "./types";
|
||||
|
||||
const nodeTypes = {
|
||||
narrativeBeat: NarrativeBeatNode,
|
||||
|
|
@ -106,7 +106,12 @@ export function NarrativeFlowEditor() {
|
|||
|
||||
const isValidConnection = useCallback(
|
||||
(connection: Edge | Connection) => {
|
||||
if (!connection.source || !connection.target || !connection.sourceHandle || !connection.targetHandle) {
|
||||
if (
|
||||
!connection.source ||
|
||||
!connection.target ||
|
||||
!connection.sourceHandle ||
|
||||
!connection.targetHandle
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const sourceNode = nodes.find((n) => n.id === connection.source);
|
||||
|
|
@ -121,8 +126,16 @@ export function NarrativeFlowEditor() {
|
|||
const targetSocket = targetSockets.inputs.find((s) => s.id === connection.targetHandle);
|
||||
|
||||
// Dynamic handles (arg-*, option-*) are treated as "any"
|
||||
const sourceType = sourceSocket?.type ?? (connection.sourceHandle.startsWith("arg-") || connection.sourceHandle.startsWith("option-") ? "any" : null);
|
||||
const targetType = targetSocket?.type ?? (connection.targetHandle.startsWith("arg-") || connection.targetHandle.startsWith("option-") ? "any" : null);
|
||||
const sourceType =
|
||||
sourceSocket?.type ??
|
||||
(connection.sourceHandle.startsWith("arg-") || connection.sourceHandle.startsWith("option-")
|
||||
? "any"
|
||||
: null);
|
||||
const targetType =
|
||||
targetSocket?.type ??
|
||||
(connection.targetHandle.startsWith("arg-") || connection.targetHandle.startsWith("option-")
|
||||
? "any"
|
||||
: null);
|
||||
|
||||
if (sourceType === null || targetType === null) return false;
|
||||
if (sourceType === "any" || targetType === "any") return true;
|
||||
|
|
@ -135,21 +148,15 @@ export function NarrativeFlowEditor() {
|
|||
setEditingNodeId(node.id);
|
||||
}, []);
|
||||
|
||||
const onNodeContextMenu = useCallback(
|
||||
(e: React.MouseEvent, node: Node) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, nodeId: node.id });
|
||||
},
|
||||
[],
|
||||
);
|
||||
const onNodeContextMenu = useCallback((e: React.MouseEvent, node: Node) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, nodeId: node.id });
|
||||
}, []);
|
||||
|
||||
const onEdgeContextMenu = useCallback(
|
||||
(e: React.MouseEvent, edge: Edge) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, edgeId: edge.id });
|
||||
},
|
||||
[],
|
||||
);
|
||||
const onEdgeContextMenu = useCallback((e: React.MouseEvent, edge: Edge) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, edgeId: edge.id });
|
||||
}, []);
|
||||
|
||||
const onPaneContextMenu = useCallback((e: MouseEvent | React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -178,7 +185,7 @@ export function NarrativeFlowEditor() {
|
|||
[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(
|
||||
(
|
||||
|
|
@ -240,32 +247,129 @@ export function NarrativeFlowEditor() {
|
|||
|
||||
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">
|
||||
<span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold mr-1">Story</span>
|
||||
<button className={`${buttonClass} bg-blue-950 border-blue-700 text-blue-200 hover:bg-blue-900`} onClick={() => addNode("narrativeBeat")}>Beat</button>
|
||||
<button className={`${buttonClass} bg-amber-950 border-amber-700 text-amber-200 hover:bg-amber-900`} onClick={() => addNode("choice")}>Choice</button>
|
||||
<button className={`${buttonClass} bg-emerald-950 border-emerald-700 text-emerald-200 hover:bg-emerald-900`} onClick={() => addNode("outcome")}>Outcome</button>
|
||||
<button className={`${buttonClass} bg-yellow-950 border-yellow-700 text-yellow-200 hover:bg-yellow-900`} onClick={() => addNode("wait")}>Wait</button>
|
||||
<span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold mr-1">
|
||||
Story
|
||||
</span>
|
||||
<button
|
||||
className={`${buttonClass} bg-blue-950 border-blue-700 text-blue-200 hover:bg-blue-900`}
|
||||
onClick={() => addNode("narrativeBeat")}
|
||||
>
|
||||
Beat
|
||||
</button>
|
||||
<button
|
||||
className={`${buttonClass} bg-amber-950 border-amber-700 text-amber-200 hover:bg-amber-900`}
|
||||
onClick={() => addNode("choice")}
|
||||
>
|
||||
Choice
|
||||
</button>
|
||||
<button
|
||||
className={`${buttonClass} bg-emerald-950 border-emerald-700 text-emerald-200 hover:bg-emerald-900`}
|
||||
onClick={() => addNode("outcome")}
|
||||
>
|
||||
Outcome
|
||||
</button>
|
||||
<button
|
||||
className={`${buttonClass} bg-yellow-950 border-yellow-700 text-yellow-200 hover:bg-yellow-900`}
|
||||
onClick={() => addNode("wait")}
|
||||
>
|
||||
Wait
|
||||
</button>
|
||||
|
||||
<span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold ml-2 mr-1">Data</span>
|
||||
<button className={`${buttonClass} bg-purple-950 border-purple-700 text-purple-200 hover:bg-purple-900`} onClick={() => addNode("number")}>Number</button>
|
||||
<button className={`${buttonClass} bg-teal-950 border-teal-700 text-teal-200 hover:bg-teal-900`} onClick={() => addNode("string")}>String</button>
|
||||
<button className={`${buttonClass} bg-emerald-950/30 border-emerald-700/50 text-emerald-200 hover:bg-emerald-900/40`} onClick={() => addNode("boolean")}>Bool</button>
|
||||
<button className={`${buttonClass} bg-cyan-950 border-cyan-700 text-cyan-200 hover:bg-cyan-900`} onClick={() => addNode("date")}>Date</button>
|
||||
<button className={`${buttonClass} bg-pink-950 border-pink-700 text-pink-200 hover:bg-pink-900`} onClick={() => addNode("random")}>Random</button>
|
||||
<button className={`${buttonClass} bg-indigo-950 border-indigo-700 text-indigo-200 hover:bg-indigo-900`} onClick={() => addNode("gameData")}>GameData</button>
|
||||
<button className={`${buttonClass} bg-orange-950 border-orange-700 text-orange-200 hover:bg-orange-900`} onClick={() => addNode("flag")}>Flag</button>
|
||||
<span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold ml-2 mr-1">
|
||||
Data
|
||||
</span>
|
||||
<button
|
||||
className={`${buttonClass} bg-purple-950 border-purple-700 text-purple-200 hover:bg-purple-900`}
|
||||
onClick={() => addNode("number")}
|
||||
>
|
||||
Number
|
||||
</button>
|
||||
<button
|
||||
className={`${buttonClass} bg-teal-950 border-teal-700 text-teal-200 hover:bg-teal-900`}
|
||||
onClick={() => addNode("string")}
|
||||
>
|
||||
String
|
||||
</button>
|
||||
<button
|
||||
className={`${buttonClass} bg-emerald-950/30 border-emerald-700/50 text-emerald-200 hover:bg-emerald-900/40`}
|
||||
onClick={() => addNode("boolean")}
|
||||
>
|
||||
Bool
|
||||
</button>
|
||||
<button
|
||||
className={`${buttonClass} bg-cyan-950 border-cyan-700 text-cyan-200 hover:bg-cyan-900`}
|
||||
onClick={() => addNode("date")}
|
||||
>
|
||||
Date
|
||||
</button>
|
||||
<button
|
||||
className={`${buttonClass} bg-pink-950 border-pink-700 text-pink-200 hover:bg-pink-900`}
|
||||
onClick={() => addNode("random")}
|
||||
>
|
||||
Random
|
||||
</button>
|
||||
<button
|
||||
className={`${buttonClass} bg-indigo-950 border-indigo-700 text-indigo-200 hover:bg-indigo-900`}
|
||||
onClick={() => addNode("gameData")}
|
||||
>
|
||||
GameData
|
||||
</button>
|
||||
<button
|
||||
className={`${buttonClass} bg-orange-950 border-orange-700 text-orange-200 hover:bg-orange-900`}
|
||||
onClick={() => addNode("flag")}
|
||||
>
|
||||
Flag
|
||||
</button>
|
||||
|
||||
<span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold ml-2 mr-1">Logic</span>
|
||||
<button className={`${buttonClass} bg-slate-950 border-slate-700 text-slate-200 hover:bg-slate-900`} onClick={() => addNode("compare")}>Compare</button>
|
||||
<button className={`${buttonClass} bg-violet-950 border-violet-700 text-violet-200 hover:bg-violet-900`} onClick={() => addNode("math")}>Math</button>
|
||||
<button className={`${buttonClass} bg-sky-950 border-sky-700 text-sky-200 hover:bg-sky-900`} onClick={() => addNode("format")}>Format</button>
|
||||
<button className={`${buttonClass} bg-lime-950 border-lime-700 text-lime-200 hover:bg-lime-900`} onClick={() => addNode("logic")}>Logic</button>
|
||||
<button className={`${buttonClass} bg-red-950 border-red-700 text-red-200 hover:bg-red-900`} onClick={() => addNode("not")}>NOT</button>
|
||||
<button className={`${buttonClass} bg-rose-950 border-rose-700 text-rose-200 hover:bg-rose-900`} onClick={() => addNode("branch")}>Branch</button>
|
||||
<button className={`${buttonClass} bg-fuchsia-950 border-fuchsia-700 text-fuchsia-200 hover:bg-fuchsia-900`} onClick={() => addNode("sequence")}>Seq</button>
|
||||
<span className="text-[9px] uppercase tracking-wider text-zinc-500 font-semibold ml-2 mr-1">
|
||||
Logic
|
||||
</span>
|
||||
<button
|
||||
className={`${buttonClass} bg-slate-950 border-slate-700 text-slate-200 hover:bg-slate-900`}
|
||||
onClick={() => addNode("compare")}
|
||||
>
|
||||
Compare
|
||||
</button>
|
||||
<button
|
||||
className={`${buttonClass} bg-violet-950 border-violet-700 text-violet-200 hover:bg-violet-900`}
|
||||
onClick={() => addNode("math")}
|
||||
>
|
||||
Math
|
||||
</button>
|
||||
<button
|
||||
className={`${buttonClass} bg-sky-950 border-sky-700 text-sky-200 hover:bg-sky-900`}
|
||||
onClick={() => addNode("format")}
|
||||
>
|
||||
Format
|
||||
</button>
|
||||
<button
|
||||
className={`${buttonClass} bg-lime-950 border-lime-700 text-lime-200 hover:bg-lime-900`}
|
||||
onClick={() => addNode("logic")}
|
||||
>
|
||||
Logic
|
||||
</button>
|
||||
<button
|
||||
className={`${buttonClass} bg-red-950 border-red-700 text-red-200 hover:bg-red-900`}
|
||||
onClick={() => addNode("not")}
|
||||
>
|
||||
NOT
|
||||
</button>
|
||||
<button
|
||||
className={`${buttonClass} bg-rose-950 border-rose-700 text-rose-200 hover:bg-rose-900`}
|
||||
onClick={() => addNode("branch")}
|
||||
>
|
||||
Branch
|
||||
</button>
|
||||
<button
|
||||
className={`${buttonClass} bg-fuchsia-950 border-fuchsia-700 text-fuchsia-200 hover:bg-fuchsia-900`}
|
||||
onClick={() => addNode("sequence")}
|
||||
>
|
||||
Seq
|
||||
</button>
|
||||
|
||||
<span className="ml-auto text-[10px] text-zinc-500">
|
||||
{nodes.length} node{nodes.length !== 1 ? "s" : ""}, {edges.length} edge{edges.length !== 1 ? "s" : ""}
|
||||
{nodes.length} node{nodes.length !== 1 ? "s" : ""}, {edges.length} edge
|
||||
{edges.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, type FC } from "react";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -22,12 +22,7 @@ interface NodeEditDialogProps {
|
|||
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>>({});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -326,10 +321,7 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const updated = [
|
||||
...options,
|
||||
{ id: crypto.randomUUID(), label: "" },
|
||||
];
|
||||
const updated = [...options, { id: crypto.randomUUID(), label: "" }];
|
||||
updateField("options", updated);
|
||||
}}
|
||||
className="dark:border-zinc-700 dark:text-zinc-300"
|
||||
|
|
@ -584,11 +576,7 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
|
|||
case "not":
|
||||
case "branch":
|
||||
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:
|
||||
|
|
@ -620,7 +608,10 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg dark:bg-zinc-900 dark:text-zinc-100 dark:border-zinc-700 bg-white text-zinc-900" showCloseButton={false}>
|
||||
<DialogContent
|
||||
className="max-w-lg dark:bg-zinc-900 dark:text-zinc-100 dark:border-zinc-700 bg-white text-zinc-900"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between">
|
||||
<span>Edit {label}</span>
|
||||
|
|
@ -642,10 +633,7 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
|
|||
>
|
||||
Cancel
|
||||
</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
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import { SOCKET_COLORS, NODE_SOCKETS, type SocketDef, type SocketType } from "./types";
|
||||
import { NODE_SOCKETS, SOCKET_COLORS, type SocketDef } from "./types";
|
||||
|
||||
function SocketHandle({
|
||||
socket,
|
||||
|
|
@ -81,16 +81,12 @@ export function NodeShell({
|
|||
{/* Input handles - positioned on left edge */}
|
||||
{inputs.map((socket, i) => {
|
||||
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 */}
|
||||
{outputs.map((socket, i) => {
|
||||
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 */}
|
||||
<div className="px-8">{children}</div>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import { NodeShell } from "./NodeShell";
|
|||
|
||||
export function NotNode(_props: NodeProps) {
|
||||
return (
|
||||
<NodeShell nodeType="not" className="bg-red-950/80 border border-red-500/50 rounded-lg shadow-md min-w-28">
|
||||
<NodeShell
|
||||
nodeType="not"
|
||||
className="bg-red-950/80 border border-red-500/50 rounded-lg shadow-md min-w-28"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-red-500/30">
|
||||
<ToggleLeftIcon className="size-4 text-red-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-red-200 flex-1">NOT</span>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ export function NumberNode({ data }: NodeProps) {
|
|||
const d = data as { value?: number; description?: string };
|
||||
|
||||
return (
|
||||
<NodeShell nodeType="number" className="bg-purple-950/80 border border-purple-500/50 rounded-lg shadow-md min-w-32">
|
||||
<NodeShell
|
||||
nodeType="number"
|
||||
className="bg-purple-950/80 border border-purple-500/50 rounded-lg shadow-md min-w-32"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-purple-500/30">
|
||||
<HashIcon className="size-4 text-purple-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-purple-200 flex-1 truncate">
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@ export function OutcomeNode({ data }: NodeProps) {
|
|||
const effects = d.effects ?? [];
|
||||
|
||||
return (
|
||||
<NodeShell nodeType="outcome" className="bg-emerald-950/80 border border-emerald-500/50 rounded-lg shadow-md min-w-56">
|
||||
<NodeShell
|
||||
nodeType="outcome"
|
||||
className="bg-emerald-950/80 border border-emerald-500/50 rounded-lg shadow-md min-w-56"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-emerald-500/30">
|
||||
<FlagIcon className="size-4 text-emerald-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-emerald-200 flex-1 truncate">
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ export function RandomNode({ data }: NodeProps) {
|
|||
const d = data as { min?: number; max?: number; description?: string };
|
||||
|
||||
return (
|
||||
<NodeShell nodeType="random" className="bg-pink-950/80 border border-pink-500/50 rounded-lg shadow-md min-w-32">
|
||||
<NodeShell
|
||||
nodeType="random"
|
||||
className="bg-pink-950/80 border border-pink-500/50 rounded-lg shadow-md min-w-32"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-pink-500/30">
|
||||
<ShuffleIcon className="size-4 text-pink-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-pink-200 flex-1 truncate">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import { NodeShell } from "./NodeShell";
|
|||
|
||||
export function SequenceNode(_props: NodeProps) {
|
||||
return (
|
||||
<NodeShell nodeType="sequence" className="bg-fuchsia-950/80 border border-fuchsia-500/50 rounded-lg shadow-md min-w-32">
|
||||
<NodeShell
|
||||
nodeType="sequence"
|
||||
className="bg-fuchsia-950/80 border border-fuchsia-500/50 rounded-lg shadow-md min-w-32"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-fuchsia-500/30">
|
||||
<ListOrderedIcon className="size-4 text-fuchsia-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-fuchsia-200 flex-1">Sequence</span>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ export function StringNode({ data }: NodeProps) {
|
|||
const d = data as { value?: string; description?: string };
|
||||
|
||||
return (
|
||||
<NodeShell nodeType="string" className="bg-teal-950/80 border border-teal-500/50 rounded-lg shadow-md min-w-32">
|
||||
<NodeShell
|
||||
nodeType="string"
|
||||
className="bg-teal-950/80 border border-teal-500/50 rounded-lg shadow-md min-w-32"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-teal-500/30">
|
||||
<TypeIcon className="size-4 text-teal-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-teal-200 flex-1 truncate">
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ export function WaitNode({ data }: NodeProps) {
|
|||
const d = data as { ticks?: number; input?: unknown };
|
||||
|
||||
return (
|
||||
<NodeShell nodeType="wait" className="bg-yellow-950/80 border border-yellow-500/50 rounded-lg shadow-md min-w-48">
|
||||
<NodeShell
|
||||
nodeType="wait"
|
||||
className="bg-yellow-950/80 border border-yellow-500/50 rounded-lg shadow-md min-w-48"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-yellow-500/30">
|
||||
<TimerIcon className="size-4 text-yellow-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-yellow-200 flex-1 truncate">
|
||||
|
|
|
|||
|
|
@ -10,9 +10,8 @@ export function LandingPage() {
|
|||
<AppLogo />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Classified operations portal for Polaris Task Force, an Arma 3 unit.
|
||||
Log in to access the operations dashboard, intelligence, and
|
||||
logistics.
|
||||
Classified operations portal for Polaris Task Force, an Arma 3 unit. Log in to access the
|
||||
operations dashboard, intelligence, and logistics.
|
||||
</p>
|
||||
<Button asChild className="mt-2">
|
||||
<Link href="/login">Log in to access the portal</Link>
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import { NotificationsBell } from "@/components/frontend/notifications/Notificat
|
|||
|
||||
export const SiteHeader = () => {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<div className="ml-auto flex items-center">
|
||||
<NotificationsBell />
|
||||
</div>
|
||||
<NotificationsBell />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -29,9 +29,7 @@ export function LoginForm() {
|
|||
const body = await res.json().catch(() => null);
|
||||
|
||||
if (!res.ok) {
|
||||
setError(
|
||||
body?.errors?.[0]?.message ?? "Invalid username or password.",
|
||||
);
|
||||
setError(body?.errors?.[0]?.message ?? "Invalid username or password.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,11 +12,7 @@ import {
|
|||
} from "@/components/ui/item";
|
||||
import { LandmarkIcon, UsersIcon, WalletIcon } from "lucide-react";
|
||||
import { formatAmount } from "@/lib/banking/format";
|
||||
import {
|
||||
accountStatusBase,
|
||||
accountStatusClass,
|
||||
type AccountStatus,
|
||||
} from "@/lib/banking/ui";
|
||||
import { type AccountStatus, accountStatusBase, accountStatusClass } from "@/lib/banking/ui";
|
||||
|
||||
const accountTypeMeta = {
|
||||
treasury: {
|
||||
|
|
|
|||
|
|
@ -3,18 +3,9 @@
|
|||
import Link from "next/link";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
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 {
|
||||
accountStatusBase,
|
||||
accountStatusClass,
|
||||
type AccountStatus,
|
||||
} from "@/lib/banking/ui";
|
||||
import { type AccountStatus, accountStatusBase, accountStatusClass } from "@/lib/banking/ui";
|
||||
import { accountTypeInfo } from "@/components/frontend/banking/AccountCard";
|
||||
import { BankTransactionDialog } from "@/components/frontend/banking/BankTransactionDialog";
|
||||
import { LedgerTable } from "@/components/frontend/banking/LedgerTable";
|
||||
|
|
@ -26,12 +17,7 @@ interface AccountDetailProps {
|
|||
canTransact: boolean;
|
||||
}
|
||||
|
||||
export function AccountDetail({
|
||||
account,
|
||||
ledger,
|
||||
otherAccounts,
|
||||
canTransact,
|
||||
}: AccountDetailProps) {
|
||||
export function AccountDetail({ account, ledger, otherAccounts, canTransact }: AccountDetailProps) {
|
||||
const meta = accountTypeInfo(account.accountType);
|
||||
const Icon = meta.icon;
|
||||
const currency = typeof account.currency === "object" ? account.currency : null;
|
||||
|
|
@ -85,10 +71,7 @@ export function AccountDetail({
|
|||
</div>
|
||||
|
||||
{canTransact && (
|
||||
<BankTransactionDialog
|
||||
accountId={account.id}
|
||||
otherAccounts={otherAccounts}
|
||||
/>
|
||||
<BankTransactionDialog accountId={account.id} otherAccounts={otherAccounts} />
|
||||
)}
|
||||
|
||||
<Item variant="outline">
|
||||
|
|
|
|||
|
|
@ -2,19 +2,15 @@
|
|||
|
||||
import Link from "next/link";
|
||||
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 {
|
||||
formatTxTimestamp,
|
||||
txAmountClass,
|
||||
txAmountPrefix,
|
||||
type TxFlow,
|
||||
txTypeColor,
|
||||
txTypeFlow,
|
||||
type TxFlow,
|
||||
} from "@/lib/banking/ui";
|
||||
|
||||
interface ActivityFeedProps {
|
||||
|
|
@ -45,9 +41,10 @@ const txTypeLabel: Record<string, string> = {
|
|||
adjustment: "ADJUST",
|
||||
};
|
||||
|
||||
function resolveAccountLabel(
|
||||
account: (number | null) | BankAccount | undefined,
|
||||
): { label: string; id: number | null } {
|
||||
function resolveAccountLabel(account: (number | null) | BankAccount | undefined): {
|
||||
label: string;
|
||||
id: number | null;
|
||||
} {
|
||||
if (!account) return { label: "—", id: null };
|
||||
if (typeof account === "object") {
|
||||
return { label: account.name ?? `#${account.id}`, id: account.id };
|
||||
|
|
@ -88,10 +85,7 @@ export function ActivityFeed({ transactions, currencyResource }: ActivityFeedPro
|
|||
const to = resolveAccountLabel(tx.toAccount);
|
||||
const actor = resolveActorLabel(tx.actor);
|
||||
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 signed = `${txAmountPrefix(flow)}${amount.toLocaleString()}`;
|
||||
|
||||
|
|
@ -113,23 +107,15 @@ export function ActivityFeed({ transactions, currencyResource }: ActivityFeedPro
|
|||
{to.label}
|
||||
</div>
|
||||
{tx.memo && (
|
||||
<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>
|
||||
<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>}
|
||||
</div>
|
||||
<div
|
||||
className={`font-mono text-sm tabular-nums shrink-0 ml-auto pr-1 ${txAmountClass(flow)}`}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -59,10 +59,7 @@ interface BankTransactionDialogProps {
|
|||
otherAccounts: { id: number; name: string }[];
|
||||
}
|
||||
|
||||
export function BankTransactionDialog({
|
||||
accountId,
|
||||
otherAccounts,
|
||||
}: BankTransactionDialogProps) {
|
||||
export function BankTransactionDialog({ accountId, otherAccounts }: BankTransactionDialogProps) {
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<Mode>("deposit");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
|
|||
|
|
@ -3,12 +3,7 @@
|
|||
import type { ReactNode } from "react";
|
||||
import type { BankAccount, BankTransaction, Resource } from "@/payload-types";
|
||||
import { LandmarkIcon } from "lucide-react";
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
} from "@/components/ui/tabs";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { AccountCard } from "@/components/frontend/banking/AccountCard";
|
||||
import { CreateAccountDialog } from "@/components/frontend/banking/CreateAccountDialog";
|
||||
import { WalletHero } from "@/components/frontend/banking/WalletHero";
|
||||
|
|
@ -46,9 +41,7 @@ function Section({
|
|||
{count === 0 ? (
|
||||
<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>
|
||||
);
|
||||
|
|
@ -69,18 +62,13 @@ export function BankingOverview({
|
|||
const myAccount = currentUser
|
||||
? personal.find((a) => {
|
||||
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;
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// 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(
|
||||
(sum, a) => sum + (a.balance ?? 0),
|
||||
|
|
@ -88,8 +76,7 @@ export function BankingOverview({
|
|||
);
|
||||
|
||||
const visibleAccountsCount =
|
||||
(isManager ? treasury.length + factionAccounts.length : 0) +
|
||||
visiblePersonal.length;
|
||||
(isManager ? treasury.length + factionAccounts.length : 0) + visiblePersonal.length;
|
||||
|
||||
const tickerCurrency = (label: string): string =>
|
||||
label.length > 8 ? `${label.slice(0, 8)}…` : label;
|
||||
|
|
@ -104,10 +91,7 @@ export function BankingOverview({
|
|||
|
||||
<Tabs defaultValue="activity" className="gap-4">
|
||||
<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
|
||||
value="activity"
|
||||
className="font-mono text-[10px] uppercase tracking-wider"
|
||||
|
|
@ -131,10 +115,7 @@ export function BankingOverview({
|
|||
</div>
|
||||
|
||||
<TabsContent value="activity" className="m-0">
|
||||
<ActivityFeed
|
||||
transactions={recentTransactions}
|
||||
currencyResource={currencyResource}
|
||||
/>
|
||||
<ActivityFeed transactions={recentTransactions} currencyResource={currencyResource} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="accounts" className="m-0 flex flex-col gap-6">
|
||||
|
|
|
|||
|
|
@ -94,10 +94,7 @@ export function CreateAccountDialog({ isManager, factions }: CreateAccountDialog
|
|||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Account type</Label>
|
||||
<Select
|
||||
value={accountType}
|
||||
onValueChange={(v) => setAccountType(v as AccountType)}
|
||||
>
|
||||
<Select value={accountType} onValueChange={(v) => setAccountType(v as AccountType)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
|
|
|
|||
|
|
@ -20,9 +20,7 @@ const txTypeLabel: Record<string, string> = {
|
|||
|
||||
export function LedgerTable({ entries }: { entries: LedgerEntry[] }) {
|
||||
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 (
|
||||
|
|
@ -59,17 +57,11 @@ export function LedgerTable({ entries }: { entries: LedgerEntry[] }) {
|
|||
// entry's actual sign — it reflects the perspective of THIS
|
||||
// account, which the canonical flow cannot know.
|
||||
const useEntrySign = amount !== 0 && Math.sign(amount) < 0;
|
||||
const prefix = useEntrySign
|
||||
? txAmountPrefix("out")
|
||||
: txAmountPrefix(flow);
|
||||
const amountCls = useEntrySign
|
||||
? txAmountClass("out")
|
||||
: txAmountClass(flow);
|
||||
const prefix = useEntrySign ? txAmountPrefix("out") : txAmountPrefix(flow);
|
||||
const amountCls = useEntrySign ? txAmountClass("out") : txAmountClass(flow);
|
||||
return (
|
||||
<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 text-muted-foreground">{entry.memo}</td>
|
||||
<td className={`px-4 py-2 text-right font-mono ${amountCls}`}>
|
||||
|
|
|
|||
|
|
@ -30,16 +30,9 @@ export function MyWalletCard() {
|
|||
<WalletIcon className="size-4 text-emerald-400" />
|
||||
<p className="text-sm font-semibold">My wallet</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You don't have a personal account yet.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">You don't have a personal account yet.</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"}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ import type { BankAccount, Resource } from "@/payload-types";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { formatAmount } from "@/lib/banking/format";
|
||||
import {
|
||||
type AccountStatus,
|
||||
accountStatusBase,
|
||||
accountStatusClass,
|
||||
formatLastActivity,
|
||||
type AccountStatus,
|
||||
} from "@/lib/banking/ui";
|
||||
import { BankTransactionDialog } from "@/components/frontend/banking/BankTransactionDialog";
|
||||
import { ensureMyAccount } from "@/app/(frontend)/logistics/banking/actions";
|
||||
|
|
@ -25,11 +25,7 @@ interface WalletHeroProps {
|
|||
// Shared terminal-aesthetic class for small uppercase mono labels in the hero.
|
||||
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 [initializing, setInitializing] = useState(false);
|
||||
const [initError, setInitError] = useState<string | null>(null);
|
||||
|
|
@ -56,9 +52,7 @@ export function WalletHero({
|
|||
<WalletIcon className="size-4 text-blue-400/80" />
|
||||
<span className={labelCls}><OPERATOR WALLET></span>
|
||||
</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">
|
||||
Run initialization to provision your personal account.
|
||||
</p>
|
||||
|
|
@ -85,8 +79,7 @@ export function WalletHero({
|
|||
|
||||
const accountId = account.id;
|
||||
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 statusKey = (account.status ?? "open") as AccountStatus;
|
||||
|
||||
|
|
@ -106,9 +99,7 @@ export function WalletHero({
|
|||
<span className={labelCls}><OPERATOR WALLET></span>
|
||||
</div>
|
||||
<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
|
||||
className={`${accountStatusBase} ${accountStatusClass[statusKey] ?? accountStatusClass.open}`}
|
||||
>
|
||||
|
|
@ -131,10 +122,7 @@ export function WalletHero({
|
|||
</div>
|
||||
|
||||
<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}>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Bolt, Send, Shield, Users, UserCog, Warehouse, Crosshair, Backpack, LifeBuoy } from "lucide-react";
|
||||
import {
|
||||
Backpack,
|
||||
Bolt,
|
||||
Crosshair,
|
||||
LifeBuoy,
|
||||
Send,
|
||||
Shield,
|
||||
UserCog,
|
||||
Users,
|
||||
Warehouse,
|
||||
} from "lucide-react";
|
||||
|
||||
import { NavCore } from "@/components/frontend/blocks/NavCore";
|
||||
import { NavSecondary } from "@/components/frontend/blocks/NavSecondary";
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import Link from "next/link"
|
||||
import { ChevronRight, type LucideIcon } from "lucide-react"
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, type LucideIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
|
|
@ -17,24 +13,24 @@ import {
|
|||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
export function NavCore({
|
||||
label,
|
||||
items,
|
||||
}: {
|
||||
label: string,
|
||||
label: string;
|
||||
items: {
|
||||
title: string
|
||||
url: string
|
||||
icon: LucideIcon
|
||||
isActive?: boolean
|
||||
title: string;
|
||||
url: string;
|
||||
icon: LucideIcon;
|
||||
isActive?: boolean;
|
||||
items?: {
|
||||
title: string
|
||||
url: string
|
||||
icon?: LucideIcon
|
||||
}[]
|
||||
}[]
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: LucideIcon;
|
||||
}[];
|
||||
}[];
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import Link from "next/link"
|
||||
import {
|
||||
Folder,
|
||||
MoreHorizontal,
|
||||
Share,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
import Link from "next/link";
|
||||
import { Folder, type LucideIcon, MoreHorizontal, Share, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -15,7 +9,7 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
|
|
@ -24,18 +18,18 @@ import {
|
|||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
export function NavProjects({
|
||||
projects,
|
||||
}: {
|
||||
projects: {
|
||||
name: string
|
||||
url: string
|
||||
icon: LucideIcon
|
||||
}[]
|
||||
name: string;
|
||||
url: string;
|
||||
icon: LucideIcon;
|
||||
}[];
|
||||
}) {
|
||||
const { isMobile } = useSidebar()
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
|
|
@ -86,5 +80,5 @@ export function NavProjects({
|
|||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { type LucideIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { type LucideIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
|
|
@ -8,17 +8,17 @@ import {
|
|||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
export function NavSecondary({
|
||||
items,
|
||||
...props
|
||||
}: {
|
||||
items: {
|
||||
title: string
|
||||
url: string
|
||||
icon: LucideIcon
|
||||
}[]
|
||||
title: string;
|
||||
url: string;
|
||||
icon: LucideIcon;
|
||||
}[];
|
||||
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
|
||||
return (
|
||||
<SidebarGroup {...props}>
|
||||
|
|
@ -37,5 +37,5 @@ export function NavSecondary({
|
|||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
BadgeCheck,
|
||||
Bell,
|
||||
Bird,
|
||||
ChevronsUpDown,
|
||||
CreditCard,
|
||||
FileTextIcon,
|
||||
LogOut,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { BadgeCheck, Bird, ChevronsUpDown, FileTextIcon, LogOut } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -15,9 +15,7 @@ const XPDisplay = ({ xpLevel }: { xpLevel: ResolvedLevel }) => {
|
|||
<SparkleIcon />
|
||||
<div className="flex flex-col items-end">
|
||||
<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>
|
||||
</ItemTitle>
|
||||
</ItemHeader>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
import Link from "next/link";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { ChevronRightIcon, FlagIcon } from "lucide-react";
|
||||
|
||||
interface CampaignEntry {
|
||||
|
|
@ -35,9 +29,7 @@ export function ActiveCampaigns({ campaigns }: ActiveCampaignsProps) {
|
|||
</div>
|
||||
</ItemHeader>
|
||||
<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>
|
||||
</Item>
|
||||
);
|
||||
|
|
@ -77,9 +69,7 @@ export function ActiveCampaigns({ campaigns }: ActiveCampaignsProps) {
|
|||
{campaign.status === "inProgress" ? "Active" : campaign.status}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{campaign.summary}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate">{campaign.summary}</span>
|
||||
</div>
|
||||
<ChevronRightIcon className="size-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,7 @@
|
|||
import Link from "next/link";
|
||||
import type { Campaign, Map } from "@/payload-types";
|
||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
ClockIcon,
|
||||
CrosshairIcon,
|
||||
MapPinIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { CalendarDaysIcon, ClockIcon, CrosshairIcon, MapPinIcon, UsersIcon } from "lucide-react";
|
||||
|
||||
interface MissionBriefingProps {
|
||||
mission: {
|
||||
|
|
@ -60,18 +47,14 @@ export function MissionBriefing({ mission }: MissionBriefingProps) {
|
|||
</div>
|
||||
</ItemHeader>
|
||||
<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>
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
|
||||
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 =
|
||||
typeof mission.classification.campaign === "object"
|
||||
? (mission.classification.campaign as Campaign)
|
||||
|
|
@ -89,10 +72,7 @@ export function MissionBriefing({ mission }: MissionBriefingProps) {
|
|||
});
|
||||
const durationHours = Math.floor(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 (
|
||||
<Item variant="outline">
|
||||
|
|
@ -112,9 +92,7 @@ export function MissionBriefing({ mission }: MissionBriefingProps) {
|
|||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<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
|
||||
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[mission.ownershipAndStatus.status] ?? "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
|
|
@ -125,9 +103,7 @@ export function MissionBriefing({ mission }: MissionBriefingProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<span className="flex items-center gap-1">
|
||||
|
|
@ -150,9 +126,7 @@ export function MissionBriefing({ mission }: MissionBriefingProps) {
|
|||
{mission.missionRoles.maxPlayers} slots
|
||||
</span>
|
||||
)}
|
||||
{campaign && (
|
||||
<span className="text-primary">{campaign.name}</span>
|
||||
)}
|
||||
{campaign && <span className="text-primary">{campaign.name}</span>}
|
||||
</div>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,8 @@
|
|||
import Link from "next/link";
|
||||
import type { Rank, Media, Assignment } from "@/payload-types";
|
||||
import type { Assignment, Rank } from "@/payload-types";
|
||||
import type { ResolvedLevel } from "@/utils/xp/resolveLevel";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import { BadgeCheckIcon, ShieldIcon, SparkleIcon, UserIcon } from "lucide-react";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { UserIcon } from "lucide-react";
|
||||
|
||||
interface ProfileSummaryProps {
|
||||
displayName: string;
|
||||
|
|
@ -49,9 +43,7 @@ export function ProfileSummary({
|
|||
/>
|
||||
)}
|
||||
<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">
|
||||
<span className="font-medium text-foreground">{rank.name}</span>
|
||||
<span className="text-border">|</span>
|
||||
|
|
@ -83,9 +75,7 @@ export function ProfileSummary({
|
|||
>
|
||||
{a.callsign ? `[${a.callsign}] ` : ""}
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,5 @@
|
|||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import {
|
||||
Building2Icon,
|
||||
CrosshairIcon,
|
||||
ShieldIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@/components/ui/item";
|
||||
import { Building2Icon, CrosshairIcon, ShieldIcon, UsersIcon } from "lucide-react";
|
||||
|
||||
interface QuickStatsProps {
|
||||
activeCampaigns: number;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
import type { User, GameStructure } from "@/payload-types";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import type { GameStructure, User } from "@/payload-types";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { BookOpenTextIcon, ScrollTextIcon } from "lucide-react";
|
||||
import { formatType } from "@/utils/event-log/formatType";
|
||||
|
||||
|
|
@ -48,9 +42,7 @@ export function RecentEvents({ events, currentUserId }: RecentEventsProps) {
|
|||
</div>
|
||||
</ItemHeader>
|
||||
<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>
|
||||
</Item>
|
||||
);
|
||||
|
|
@ -71,12 +63,9 @@ export function RecentEvents({ events, currentUserId }: RecentEventsProps) {
|
|||
typeof event.actor === "object" && event.actor !== null
|
||||
? (event.actor as User)
|
||||
: 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 actorName =
|
||||
actor?.displayName ?? actor?.username ?? null;
|
||||
const actorName = actor?.displayName ?? actor?.username ?? null;
|
||||
const actorLabel = isYou ? "You" : actorName;
|
||||
const isNarrative = event.system === false;
|
||||
|
||||
|
|
@ -90,9 +79,7 @@ export function RecentEvents({ events, currentUserId }: RecentEventsProps) {
|
|||
)}
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
|
||||
<div className="flex 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 && (
|
||||
<span className={`font-medium ${isYou ? "text-foreground" : ""}`}>
|
||||
{actorLabel}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue