1
0
Fork 0

style(frontend): format frontend feature components

This commit is contained in:
Jason Fraley 2026-08-16 23:27:51 -04:00
parent 7db1f648bf
commit a21564a78d
78 changed files with 1024 additions and 1158 deletions

View file

@ -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>

View file

@ -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;
}

View file

@ -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: {

View file

@ -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">

View file

@ -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>
);

View file

@ -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);

View file

@ -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">

View file

@ -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>

View file

@ -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}`}>

View file

@ -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&apos;t have a personal account yet.
</p>
<p className="text-xs text-muted-foreground">You don&apos;t have a personal account yet.</p>
{error && <p className="text-xs text-red-400">{error}</p>}
<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>

View file

@ -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}>&lt;OPERATOR WALLET&gt;</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}>&lt;OPERATOR WALLET&gt;</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"

View file

@ -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";

View file

@ -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>

View file

@ -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>
)
);
}

View file

@ -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>
)
);
}

View file

@ -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";

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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;

View file

@ -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}

View file

@ -1,13 +1,7 @@
import Link from "next/link";
import type { Campaign, Map } from "@/payload-types";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
import { CalendarDaysIcon, ChevronRightIcon, CrosshairIcon } from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { ChevronRightIcon, CrosshairIcon } from "lucide-react";
interface MissionEntry {
id: number;
@ -38,9 +32,7 @@ export function UpcomingMissions({ missions }: UpcomingMissionsProps) {
</div>
</ItemHeader>
<ItemContent>
<p className="text-sm text-muted-foreground italic">
No missions scheduled.
</p>
<p className="text-sm text-muted-foreground italic">No missions scheduled.</p>
</ItemContent>
</Item>
);
@ -98,9 +90,7 @@ export function UpcomingMissions({ missions }: UpcomingMissionsProps) {
{mission.name}
</span>
{campaign && (
<span className="text-[10px] text-muted-foreground">
{campaign.name}
</span>
<span className="text-[10px] text-muted-foreground">{campaign.name}</span>
)}
</div>
<ChevronRightIcon className="size-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity mt-1 shrink-0" />

View file

@ -2,7 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { submitFlappyScore, type FlappyStats } from "@/app/(frontend)/flappy/actions";
import { type FlappyStats, submitFlappyScore } from "@/app/(frontend)/flappy/actions";
import { playFlap, playHit, playXp } from "./sounds";
const CANVAS_W = 420;

View file

@ -226,8 +226,9 @@ export function CreateTicketDialog() {
<AlertTitle className="text-lg">Important!</AlertTitle>
<AlertDescription>
<p>
Marking this ticket as critical means it <strong>requires immediate attention</strong> and may
impact the stability or functionality of the system.
Marking this ticket as critical means it{" "}
<strong>requires immediate attention</strong> and may impact the stability or
functionality of the system.
</p>
<p>
Please ensure that the description clearly outlines the issue and any potential

View file

@ -9,10 +9,7 @@ import {
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
import {
TicketBadges,
TicketCategoryBadge,
} from "@/components/frontend/helpdesk/TicketBadges";
import { TicketBadges, TicketCategoryBadge } from "@/components/frontend/helpdesk/TicketBadges";
interface TicketCardProps {
readonly ticket: Ticket;
@ -50,7 +47,10 @@ export function TicketCard({ ticket }: TicketCardProps) {
return (
<Link href={`/helpdesk/${ticket.id}`} className="group block h-full">
<Item variant="outline" className="h-full min-h-48 transition-colors group-hover:bg-accent/50">
<Item
variant="outline"
className="h-full min-h-48 transition-colors group-hover:bg-accent/50"
>
<ItemHeader className="items-start">
<TicketCategoryBadge category={ticket.category} />
<span className="font-mono text-[10px] text-muted-foreground">#{ticket.id}</span>
@ -59,9 +59,7 @@ export function TicketCard({ ticket }: TicketCardProps) {
<ItemTitle className="line-clamp-2 min-w-0 max-w-full text-base font-semibold">
{ticket.title}
</ItemTitle>
<ItemDescription>
{description || "No description provided."}
</ItemDescription>
<ItemDescription>{description || "No description provided."}</ItemDescription>
<TicketBadges ticket={ticket} />
</ItemContent>
<ItemFooter className="w-full flex-wrap items-end">

View file

@ -9,16 +9,18 @@ import type { Ticket, User } from "@/payload-types";
import { Button } from "@/components/ui/button";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { TicketBadges, TicketCategoryBadge } from "@/components/frontend/helpdesk/TicketBadges";
import { TicketTimeline } from "@/components/frontend/helpdesk/TicketTimeline";
import { replyToTicket, updateTicketStatus, assignTicket } from "@/app/(frontend)/helpdesk/actions";
import {
CLOSING_STATUSES,
TICKET_STATUSES,
type TicketStatus,
} from "@/lib/tickets/ticketMeta";
import { assignTicket, replyToTicket, updateTicketStatus } from "@/app/(frontend)/helpdesk/actions";
import { CLOSING_STATUSES, TICKET_STATUSES, type TicketStatus } from "@/lib/tickets/ticketMeta";
import { CalendarClockIcon, HistoryIcon, SendIcon, Settings2Icon } from "lucide-react";
interface AssigneeOption {
@ -37,7 +39,7 @@ type UserRelation = number | User | null | undefined;
type ControlKind = "status" | "assignee";
function relationId(relation: UserRelation): number | null {
return typeof relation === "object" && relation ? relation.id : relation ?? null;
return typeof relation === "object" && relation ? relation.id : (relation ?? null);
}
function displayName(relation: UserRelation, fallback: string): string {
@ -62,7 +64,9 @@ function isTicketStatus(value: string): value is TicketStatus {
function MetaItem({ label, value }: { readonly label: string; readonly value: string }) {
return (
<div className="flex min-w-0 flex-col gap-1">
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{label}
</span>
<span className="truncate text-sm">{value}</span>
</div>
);
@ -89,13 +93,17 @@ export function TicketDetail({
const [cancelBusy, setCancelBusy] = useState(false);
const cancelTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (cancelTimerRef.current) clearTimeout(cancelTimerRef.current);
}, []);
useEffect(
() => () => {
if (cancelTimerRef.current) clearTimeout(cancelTimerRef.current);
},
[],
);
const reporterName = displayName(ticket.reporter, "Unknown reporter");
const assigneeName = displayName(ticket.assignee, "Unassigned");
const canCancel = relationId(ticket.reporter) === currentUserId && !CLOSING_STATUSES.includes(ticket.status);
const canCancel =
relationId(ticket.reporter) === currentUserId && !CLOSING_STATUSES.includes(ticket.status);
const descriptionText = convertLexicalToPlaintext({ data: ticket.description });
async function applyControl(kind: ControlKind) {
@ -170,58 +178,169 @@ export function TicketDetail({
<section className="flex flex-col gap-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<h2 className="min-w-0 flex-1 text-xl font-semibold leading-tight">{ticket.title}</h2>
<div className="flex flex-wrap items-center gap-1.5"><TicketCategoryBadge category={ticket.category} /><TicketBadges ticket={ticket} /></div>
<div className="flex flex-wrap items-center gap-1.5">
<TicketCategoryBadge category={ticket.category} />
<TicketBadges ticket={ticket} />
</div>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 rounded-md border bg-muted/20 p-4 sm:grid-cols-4">
<MetaItem label="Reporter" value={reporterName} /><MetaItem label="Assignee" value={assigneeName} />
<MetaItem label="Created" value={formatDate(ticket.createdAt)} /><MetaItem label="Updated" value={formatDate(ticket.updatedAt)} />
<MetaItem label="Reporter" value={reporterName} />
<MetaItem label="Assignee" value={assigneeName} />
<MetaItem label="Created" value={formatDate(ticket.createdAt)} />
<MetaItem label="Updated" value={formatDate(ticket.updatedAt)} />
<MetaItem label="Closed" value={formatDate(ticket.closedAt)} />
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2"><CalendarClockIcon className="size-4 text-muted-foreground" /><h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Description</h3></div>
<div className="flex items-center gap-2">
<CalendarClockIcon className="size-4 text-muted-foreground" />
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Description
</h3>
</div>
<div className="rounded-md border bg-muted/20 p-4 text-sm leading-relaxed text-muted-foreground [&_p]:mb-2 [&_p:last-child]:mb-0 [&_strong]:text-foreground [&_em]:italic">
{descriptionText ? <RichText data={ticket.description} /> : <p>No description provided.</p>}
{descriptionText ? (
<RichText data={ticket.description} />
) : (
<p>No description provided.</p>
)}
</div>
</div>
</section>
{isStaff && (
<Item variant="outline" className="bg-muted/20">
<ItemHeader><div className="flex items-center gap-2"><Settings2Icon className="size-4 text-muted-foreground" /><ItemTitle>Staff controls</ItemTitle></div></ItemHeader>
<ItemHeader>
<div className="flex items-center gap-2">
<Settings2Icon className="size-4 text-muted-foreground" />
<ItemTitle>Staff controls</ItemTitle>
</div>
</ItemHeader>
<ItemContent className="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5"><Label htmlFor="ticket-status">Status</Label><div className="flex gap-2">
<Select value={status} onValueChange={(value) => { if (isTicketStatus(value)) setStatus(value); }} disabled={controlBusy !== null}>
<SelectTrigger id="ticket-status" className="min-w-0 flex-1"><SelectValue /></SelectTrigger>
<SelectContent>{TICKET_STATUSES.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}</SelectContent>
</Select>
<Button size="sm" onClick={() => void applyControl("status")} disabled={controlBusy !== null}>{controlBusy === "status" ? "Applying..." : "Apply"}</Button>
</div></div>
<div className="flex flex-col gap-1.5"><Label htmlFor="ticket-assignee">Assignee</Label><div className="flex gap-2">
<Select value={assignee} onValueChange={setAssignee} disabled={controlBusy !== null}>
<SelectTrigger id="ticket-assignee" className="min-w-0 flex-1"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="unassigned">Unassigned</SelectItem>{assigneeOptions.map((option) => <SelectItem key={option.id} value={String(option.id)}>{option.label}</SelectItem>)}</SelectContent>
</Select>
<Button size="sm" onClick={() => void applyControl("assignee")} disabled={controlBusy !== null}>{controlBusy === "assignee" ? "Applying..." : "Apply"}</Button>
</div></div>
{controlError && <p role="alert" className="text-sm text-red-400 sm:col-span-2">{controlError}</p>}
<div className="flex flex-col gap-1.5">
<Label htmlFor="ticket-status">Status</Label>
<div className="flex gap-2">
<Select
value={status}
onValueChange={(value) => {
if (isTicketStatus(value)) setStatus(value);
}}
disabled={controlBusy !== null}
>
<SelectTrigger id="ticket-status" className="min-w-0 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TICKET_STATUSES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
onClick={() => void applyControl("status")}
disabled={controlBusy !== null}
>
{controlBusy === "status" ? "Applying..." : "Apply"}
</Button>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ticket-assignee">Assignee</Label>
<div className="flex gap-2">
<Select
value={assignee}
onValueChange={setAssignee}
disabled={controlBusy !== null}
>
<SelectTrigger id="ticket-assignee" className="min-w-0 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="unassigned">Unassigned</SelectItem>
{assigneeOptions.map((option) => (
<SelectItem key={option.id} value={String(option.id)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
onClick={() => void applyControl("assignee")}
disabled={controlBusy !== null}
>
{controlBusy === "assignee" ? "Applying..." : "Apply"}
</Button>
</div>
</div>
{controlError && (
<p role="alert" className="text-sm text-red-400 sm:col-span-2">
{controlError}
</p>
)}
</ItemContent>
</Item>
)}
<section className="flex flex-col gap-3">
<div className="flex items-center gap-2"><HistoryIcon className="size-4 text-muted-foreground" /><h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Activity</h3><span className="text-xs text-muted-foreground">{ticket.activity?.length ?? 0} entries</span></div>
<Item variant="outline" className="bg-muted/20"><ItemContent className="w-full"><TicketTimeline ticket={ticket} currentUserId={currentUserId} /></ItemContent></Item>
<div className="flex items-center gap-2">
<HistoryIcon className="size-4 text-muted-foreground" />
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Activity
</h3>
<span className="text-xs text-muted-foreground">
{ticket.activity?.length ?? 0} entries
</span>
</div>
<Item variant="outline" className="bg-muted/20">
<ItemContent className="w-full">
<TicketTimeline ticket={ticket} currentUserId={currentUserId} />
</ItemContent>
</Item>
</section>
<Item variant="outline">
<ItemHeader><div className="flex items-center gap-2"><SendIcon className="size-4 text-muted-foreground" /><ItemTitle>Reply</ItemTitle></div><span className="text-xs text-muted-foreground">{isStaff ? "Reply as staff" : "Send reply"}</span></ItemHeader>
<ItemHeader>
<div className="flex items-center gap-2">
<SendIcon className="size-4 text-muted-foreground" />
<ItemTitle>Reply</ItemTitle>
</div>
<span className="text-xs text-muted-foreground">
{isStaff ? "Reply as staff" : "Send reply"}
</span>
</ItemHeader>
<ItemContent className="w-full gap-3">
<Textarea rows={4} value={reply} onChange={(event) => setReply(event.target.value)} placeholder={isStaff ? "Write an update for the reporter..." : "Add more information..."} disabled={replyBusy} />
<div className="flex flex-wrap items-center justify-between gap-2">{replyError && <p role="alert" className="text-sm text-red-400">{replyError}</p>}<Button className="ml-auto" onClick={() => void handleReply()} disabled={replyBusy}>{replyBusy ? "Sending..." : isStaff ? "Reply as staff" : "Send reply"}</Button></div>
<Textarea
rows={4}
value={reply}
onChange={(event) => setReply(event.target.value)}
placeholder={
isStaff ? "Write an update for the reporter..." : "Add more information..."
}
disabled={replyBusy}
/>
<div className="flex flex-wrap items-center justify-between gap-2">
{replyError && (
<p role="alert" className="text-sm text-red-400">
{replyError}
</p>
)}
<Button className="ml-auto" onClick={() => void handleReply()} disabled={replyBusy}>
{replyBusy ? "Sending..." : isStaff ? "Reply as staff" : "Send reply"}
</Button>
</div>
</ItemContent>
</Item>
{canCancel && <div className="flex justify-end"><Button variant="destructive" onClick={requestCancel} disabled={cancelBusy}>{cancelBusy ? "Canceling..." : confirmCancel ? "Confirm cancel?" : "Cancel ticket"}</Button></div>}
{canCancel && (
<div className="flex justify-end">
<Button variant="destructive" onClick={requestCancel} disabled={cancelBusy}>
{cancelBusy ? "Canceling..." : confirmCancel ? "Confirm cancel?" : "Cancel ticket"}
</Button>
</div>
)}
</div>
);
}

View file

@ -18,7 +18,7 @@ function resolveUser(relation: UserRelation): User | null {
}
function userId(relation: UserRelation): number | null {
return typeof relation === "object" && relation ? relation.id : relation ?? null;
return typeof relation === "object" && relation ? relation.id : (relation ?? null);
}
function displayName(relation: UserRelation, fallback: string): string {

View file

@ -5,8 +5,10 @@ import type { Campaign } from "@/payload-types";
import { cn } from "@/lib/utils";
import { ClockIcon, CrosshairIcon, UsersIcon } from "lucide-react";
interface CampaignCardCampaign
extends Pick<Campaign, "id" | "name" | "summary" | "status" | "startDate" | "endDate"> {
interface CampaignCardCampaign extends Pick<
Campaign,
"id" | "name" | "summary" | "status" | "startDate" | "endDate"
> {
readonly coverImageUrl: string | null;
readonly missionCount: number;
readonly avgPlayers: number;

View file

@ -76,9 +76,7 @@ export function FactionCard({ faction }: FactionCardProps) {
? convertLexicalToPlaintext({ data: faction.description })
: null;
const truncatedDescription =
description && description.length > 150
? description.slice(0, 150) + "…"
: description;
description && description.length > 150 ? description.slice(0, 150) + "…" : description;
const campaignCount = faction.associatedCampaigns?.length ?? 0;
const reputation = faction.reputation ?? 0;
@ -105,9 +103,7 @@ export function FactionCard({ faction }: FactionCardProps) {
<ItemContent>
<div className="flex flex-col gap-3 w-full">
{truncatedDescription && (
<p className="text-sm text-muted-foreground line-clamp-2">
{truncatedDescription}
</p>
<p className="text-sm text-muted-foreground line-clamp-2">{truncatedDescription}</p>
)}
<div className="flex items-center gap-2 flex-wrap">
{faction.playableFaction && (
@ -134,9 +130,7 @@ export function FactionCard({ faction }: FactionCardProps) {
</div>
</div>
</ItemContent>
<ItemDescription>
{truncatedDescription ?? "No description provided."}
</ItemDescription>
<ItemDescription>{truncatedDescription ?? "No description provided."}</ItemDescription>
</Item>
</Link>
);

View file

@ -111,25 +111,19 @@ export function MissionAttendance({
</div>
)}
{currentUserId !== null && attendanceClosed && (
<p className="text-xs text-muted-foreground">
Attendance is closed for this mission.
</p>
<p className="text-xs text-muted-foreground">Attendance is closed for this mission.</p>
)}
{error && <p className="text-xs text-red-400">{error}</p>}
{states.map((response) => {
const meta = stateMeta[response];
const Icon = meta.icon;
const people = groups[response]
.slice()
.sort((a, b) => a.name.localeCompare(b.name));
const people = groups[response].slice().sort((a, b) => a.name.localeCompare(b.name));
return (
<div key={response} className="flex flex-col gap-1">
<div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider">
<Icon className={cn("size-3.5", meta.color)} />
<span className={meta.color}>{meta.label}</span>
<span className="font-mono text-muted-foreground">
({people.length})
</span>
<span className="font-mono text-muted-foreground">({people.length})</span>
</div>
{people.length > 0 && (
<ul className="flex flex-wrap gap-x-3 gap-y-0.5">

View file

@ -36,9 +36,7 @@ export function MissionBriefingSection({ briefing }: MissionBriefingSectionProps
</div>
</ItemHeader>
<ItemContent>
<p className="text-sm text-muted-foreground italic">
No briefing available.
</p>
<p className="text-sm text-muted-foreground italic">No briefing available.</p>
</ItemContent>
</Item>
);

View file

@ -29,9 +29,7 @@ export function MissionFactions({ factionsInvolved }: MissionFactionsProps) {
</div>
</ItemHeader>
<ItemContent>
<p className="text-sm text-muted-foreground italic">
No factions assigned.
</p>
<p className="text-sm text-muted-foreground italic">No factions assigned.</p>
</ItemContent>
</Item>
);
@ -47,15 +45,12 @@ export function MissionFactions({ factionsInvolved }: MissionFactionsProps) {
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{factionsInvolved.map((entry, index) => {
const faction =
typeof entry.faction === "object" ? entry.faction : null;
const faction = typeof entry.faction === "object" ? entry.faction : null;
return (
<Item key={index} variant="outline">
<ItemHeader>
<ItemTitle>
{faction?.name ?? `Faction #${entry.faction}`}
</ItemTitle>
<ItemTitle>{faction?.name ?? `Faction #${entry.faction}`}</ItemTitle>
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${threatLevelColors[entry.threatLevel] ?? "bg-muted text-muted-foreground"}`}
>
@ -64,9 +59,7 @@ export function MissionFactions({ factionsInvolved }: MissionFactionsProps) {
</ItemHeader>
{faction && (
<ItemContent>
<span className="text-xs text-muted-foreground">
{faction.type}
</span>
<span className="text-xs text-muted-foreground">{faction.type}</span>
</ItemContent>
)}
</Item>

View file

@ -1,10 +1,8 @@
"use client";
import { useState, useMemo } from "react";
import { useState } from "react";
import type { Campaign } from "@/payload-types";
import { MissionCard } from "@/components/frontend/intelligence/MissionCard";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface MissionFiltersProps {
campaigns: Campaign[];
@ -21,20 +19,9 @@ const operationTypes = [
{ label: "External", value: "external" },
];
const statuses = [
"Concept",
"Planning",
"Ready",
"Scheduled",
"Active",
"Completed",
"Cancelled",
];
const statuses = ["Concept", "Planning", "Ready", "Scheduled", "Active", "Completed", "Cancelled"];
export function MissionFilters({
campaigns,
onFilterChange,
}: MissionFiltersProps) {
export function MissionFilters({ campaigns, onFilterChange }: MissionFiltersProps) {
const [campaignId, setCampaignId] = useState<number | null>(null);
const [operationType, setOperationType] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);

View file

@ -23,12 +23,7 @@ const operationTypeColors: Record<string, string> = {
external: "bg-purple-500/20 text-purple-400",
};
export function MissionHeader({
name,
codeName,
operationType,
status,
}: MissionHeaderProps) {
export function MissionHeader({ name, codeName, operationType, status }: MissionHeaderProps) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">

View file

@ -110,9 +110,7 @@ export function MissionLogistics({ logisticsAndSupport }: MissionLogisticsProps)
<tbody>
{assetsAvailable!.map((asset, index) => (
<tr key={index} className="border-b border-border/50">
<td className="px-4 py-2 font-medium">
{asset.assetType}
</td>
<td className="px-4 py-2 font-medium">{asset.assetType}</td>
<td className="px-4 py-2 text-muted-foreground">
{asset.assetCount ?? "—"}
</td>
@ -134,9 +132,7 @@ export function MissionLogistics({ logisticsAndSupport }: MissionLogisticsProps)
</div>
)}
{!hasInsertion && !hasExtraction && !hasAssets && (
<p className="text-sm text-muted-foreground italic">
No logistics details provided.
</p>
<p className="text-sm text-muted-foreground italic">No logistics details provided.</p>
)}
</ItemContent>
</Item>

View file

@ -35,9 +35,7 @@ export function MissionRoles({ roles, notes, maxPlayers }: MissionRolesProps) {
</div>
</ItemHeader>
<ItemContent>
<p className="text-sm text-muted-foreground italic">
No roles defined.
</p>
<p className="text-sm text-muted-foreground italic">No roles defined.</p>
</ItemContent>
</Item>
);
@ -51,8 +49,7 @@ export function MissionRoles({ roles, notes, maxPlayers }: MissionRolesProps) {
Roles
</h2>
<span className="ml-auto text-xs font-mono text-muted-foreground">
Max Players:{" "}
<span className="text-foreground font-semibold">{maxPlayers}</span>
Max Players: <span className="text-foreground font-semibold">{maxPlayers}</span>
</span>
</div>
<div className="border border-border rounded-lg overflow-hidden">
@ -90,8 +87,7 @@ export function MissionRoles({ roles, notes, maxPlayers }: MissionRolesProps) {
: "—"}
</td>
<td className="px-4 py-2 text-muted-foreground">
{typeof role.minimumRank === "object" &&
role.minimumRank !== null
{typeof role.minimumRank === "object" && role.minimumRank !== null
? role.minimumRank.name
: typeof role.minimumRank === "number"
? `#${role.minimumRank}`
@ -102,9 +98,7 @@ export function MissionRoles({ roles, notes, maxPlayers }: MissionRolesProps) {
</tbody>
</table>
</div>
{notes && (
<p className="text-sm text-muted-foreground italic">{notes}</p>
)}
{notes && <p className="text-sm text-muted-foreground italic">{notes}</p>}
</div>
);
}

View file

@ -1,7 +1,7 @@
"use client";
import { useState, useMemo } from "react";
import type { Mission, Campaign, Map } from "@/payload-types";
import { useMemo, useState } from "react";
import type { Campaign, Mission } from "@/payload-types";
import { MissionCard } from "@/components/frontend/intelligence/MissionCard";
import { MissionFilters } from "@/components/frontend/intelligence/MissionFilters";
@ -29,10 +29,7 @@ export function MissionsList({ missions, campaigns }: MissionsListProps) {
if (filters.operationType && m.operationType !== filters.operationType) {
return false;
}
if (
filters.status &&
m.ownershipAndStatus.status !== filters.status
) {
if (filters.status && m.ownershipAndStatus.status !== filters.status) {
return false;
}
return true;

View file

@ -81,9 +81,7 @@ export function AddLockerItemDialog({ assets }: AddLockerItemDialogProps) {
<DialogContent>
<DialogHeader>
<DialogTitle>Add Item to Locker</DialogTitle>
<DialogDescription>
Provision an asset into this member&apos;s locker.
</DialogDescription>
<DialogDescription>Provision an asset into this member&apos;s locker.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">

View file

@ -25,8 +25,8 @@ import {
getAssetTypeLabel,
getAttachmentOptionsForItem,
getSkinOptionsForItem,
resolveAssetId,
type LockerGridItem,
resolveAssetId,
} from "@/lib/locker";
import { getAssetIcon } from "./LockerGrid";
import {
@ -61,7 +61,7 @@ export function EquipmentEditorDialog({
}, [item, open]);
const resolvedItem = useMemo(
() => (item ? items.find((i) => i.id === item.id) ?? item : null),
() => (item ? (items.find((i) => i.id === item.id) ?? item) : null),
[item, items],
);
@ -167,18 +167,22 @@ export function EquipmentEditorDialog({
<DialogHeader>
<DialogTitle>Customize {displayName}</DialogTitle>
<DialogDescription>
Install attachments, apply a skin, and give your {getAssetTypeLabel(activeItem.asset.assetType).toLowerCase()} a name.
Installing consumes the item from your locker grid; removing returns it.
Install attachments, apply a skin, and give your{" "}
{getAssetTypeLabel(activeItem.asset.assetType).toLowerCase()} a name. Installing
consumes the item from your locker grid; removing returns it.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-5">
<div className="flex items-center gap-3 rounded-md border p-3">
<span className="text-amber-400">{getAssetIcon(activeItem.asset.assetType, "size-6")}</span>
<span className="text-amber-400">
{getAssetIcon(activeItem.asset.assetType, "size-6")}
</span>
<div className="min-w-0">
<div className="font-medium truncate">{activeItem.asset.name}</div>
<div className="text-xs text-muted-foreground">
{getAssetTypeLabel(activeItem.asset.assetType)} · {activeItem.quantity.toLocaleString()}×
{getAssetTypeLabel(activeItem.asset.assetType)} ·{" "}
{activeItem.quantity.toLocaleString()}×
</div>
</div>
</div>
@ -193,7 +197,10 @@ export function EquipmentEditorDialog({
? [{ assetId: installed.id, quantity: 0, asset: installed }, ...options]
: options;
return (
<div key={category.name} className="grid grid-cols-[100px_1fr] items-center gap-2">
<div
key={category.name}
className="grid grid-cols-[100px_1fr] items-center gap-2"
>
<span className="text-xs text-muted-foreground">{category.label}</span>
<Select
value={installed ? String(installed.id) : "none"}
@ -202,7 +209,11 @@ export function EquipmentEditorDialog({
>
<SelectTrigger>
<SelectValue
placeholder={categoryOptions.length === 0 ? "No compatible items" : "Choose an attachment"}
placeholder={
categoryOptions.length === 0
? "No compatible items"
: "Choose an attachment"
}
/>
</SelectTrigger>
<SelectContent>
@ -219,8 +230,8 @@ export function EquipmentEditorDialog({
);
})}
<p className="text-xs text-muted-foreground">
Compatible with this weapon based on its catalog entry. Replacing an attachment returns
the old one to your locker.
Compatible with this weapon based on its catalog entry. Replacing an attachment
returns the old one to your locker.
</p>
</div>
)}
@ -236,14 +247,17 @@ export function EquipmentEditorDialog({
>
<SelectTrigger>
<SelectValue
placeholder={mergedSkinOptions.length === 0 ? "No compatible skins" : "Choose a skin"}
placeholder={
mergedSkinOptions.length === 0 ? "No compatible skins" : "Choose a skin"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{mergedSkinOptions.map((o) => (
<SelectItem key={o.assetId} value={String(o.assetId)}>
{o.asset.name} · {o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "applied"}
{o.asset.name} ·{" "}
{o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "applied"}
</SelectItem>
))}
</SelectContent>
@ -251,7 +265,8 @@ export function EquipmentEditorDialog({
</div>
{installedSkin && (
<p className="text-xs text-muted-foreground">
Currently applied: {installedSkin.name}. Choosing another skin returns the old one to your locker.
Currently applied: {installedSkin.name}. Choosing another skin returns the old one
to your locker.
</p>
)}
</div>

View file

@ -4,7 +4,12 @@ import { CheckCircle2Icon, PencilIcon, TrashIcon } from "lucide-react";
import type { Asset, Loadout } from "@/payload-types";
import { Button } from "@/components/ui/button";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { getAssetTypeLabel, LOADOUT_SLOTS, type LockerGridItem, type LoadoutSlots } from "@/lib/locker";
import {
getAssetTypeLabel,
LOADOUT_SLOTS,
type LoadoutSlots,
type LockerGridItem,
} from "@/lib/locker";
function resolveSlotValue(value: LoadoutSlots[keyof LoadoutSlots]): Asset | null {
if (value == null) return null;
@ -48,11 +53,7 @@ export function LoadoutCard({ loadout, ownedItems, onEdit, onDelete, onEquip }:
<span className="text-xs text-muted-foreground">{loadout.description}</span>
)}
<div className="flex items-center gap-1">
<Button
variant={loadout.equipped ? "default" : "outline"}
size="sm"
onClick={onEquip}
>
<Button variant={loadout.equipped ? "default" : "outline"} size="sm" onClick={onEquip}>
{loadout.equipped ? "Unequip" : "Equip"}
</Button>
<Button variant="outline" size="sm" onClick={onEdit}>
@ -80,7 +81,9 @@ export function LoadoutCard({ loadout, ownedItems, onEdit, onDelete, onEquip }:
>
<span className="text-muted-foreground">{slot.label}:</span>
<span className="font-medium">{slotDisplayName(asset, ownedItems)}</span>
<span className="text-muted-foreground">({getAssetTypeLabel(asset.assetType)})</span>
<span className="text-muted-foreground">
({getAssetTypeLabel(asset.assetType)})
</span>
</span>
);
})}

View file

@ -22,16 +22,8 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
LOADOUT_SLOTS,
type LockerGridItem,
type LoadoutSlots,
} from "@/lib/locker";
import {
createLoadout,
setLoadoutSlot,
updateLoadout,
} from "@/app/(frontend)/locker/actions";
import { LOADOUT_SLOTS, type LoadoutSlots, type LockerGridItem } from "@/lib/locker";
import { createLoadout, setLoadoutSlot, updateLoadout } from "@/app/(frontend)/locker/actions";
interface LoadoutEditorProps {
mode: "create" | "edit";

View file

@ -3,18 +3,19 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
DndContext,
type DragEndEvent,
type DragMoveEvent,
DragOverlay,
type DragStartEvent,
PointerSensor,
useDraggable,
useSensor,
useSensors,
type DragStartEvent,
type DragMoveEvent,
type DragEndEvent,
} from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import {
Backpack,
Crosshair,
FlaskConical,
Gem,
Glasses,
@ -27,23 +28,22 @@ import {
Palette,
Shield,
Shirt,
Tag,
Target,
Wrench,
Crosshair,
Tag,
} from "lucide-react";
import type { Asset } from "@/payload-types";
import {
ATTACHMENT_CATEGORIES,
findLockerMergeTarget,
getAssetTypeColor,
getLockerItemFootprint,
getLockerGridOccupancy,
getLockerItemFootprint,
isAttachmentCompatible,
isLockerPlacementValid,
resolveAssetId,
type LockerGridItem,
type LockerItemEquipment,
resolveAssetId,
} from "@/lib/locker";
import { GridCell } from "@/components/frontend/storage/GridCell";
import type { ActionResult } from "@/app/(frontend)/locker/actions";
@ -99,7 +99,9 @@ function assetThumbnailUrl(asset: Asset): string | null {
function itemContainsCell(item: LockerGridItem, x: number, y: number): boolean {
const fp = getLockerItemFootprint(item.asset, item.rotated);
return x >= item.gridX && x < item.gridX + fp.width && y >= item.gridY && y < item.gridY + fp.height;
return (
x >= item.gridX && x < item.gridX + fp.width && y >= item.gridY && y < item.gridY + fp.height
);
}
export function LockerItemFace({
@ -159,7 +161,9 @@ export function LockerItemFace({
{thumb ? (
<img src={thumb} alt={asset.name} className="size-6 object-contain opacity-80" />
) : (
<span className="text-muted-foreground/70">{getAssetIcon(asset.assetType, "size-5")}</span>
<span className="text-muted-foreground/70">
{getAssetIcon(asset.assetType, "size-5")}
</span>
)}
<span className="text-[10px] leading-tight font-medium text-center text-foreground/90 line-clamp-2 px-0.5">
{displayName}
@ -467,11 +471,11 @@ export function LockerGrid({
}
if (onAttachItem) {
const isAttachment = ATTACHMENT_CATEGORIES.some((c) => c.assetType === item.asset.assetType);
const isAttachment = ATTACHMENT_CATEGORIES.some(
(c) => c.assetType === item.asset.assetType,
);
const targetItem =
items.find(
(it) => it.id !== item.id && itemContainsCell(it, targetX, targetY),
) ?? null;
items.find((it) => it.id !== item.id && itemContainsCell(it, targetX, targetY)) ?? null;
if (
isAttachment &&
targetItem?.asset.assetType === "weapon" &&
@ -508,7 +512,16 @@ export function LockerGrid({
await onMoveItem(item.id, targetX, targetY, pendingRotated);
}
},
[items, gridWidth, gridHeight, onMoveItem, onMergeStacks, onAttachItem, cellSize, pendingRotated],
[
items,
gridWidth,
gridHeight,
onMoveItem,
onMergeStacks,
onAttachItem,
cellSize,
pendingRotated,
],
);
const handleBackgroundClick = useCallback(() => {
@ -516,7 +529,12 @@ export function LockerGrid({
}, [onSelectItem]);
return (
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragMove={handleDragMove} onDragEnd={handleDragEnd}>
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
>
<div className="flex flex-col gap-3">
<div ref={containerRef} className="rounded-lg border bg-muted/20 p-2">
<div

View file

@ -2,22 +2,22 @@
import { Button } from "@/components/ui/button";
import {
ShuffleIcon,
RotateCwIcon,
TrashIcon,
ScissorsIcon,
LayoutGridIcon,
ListIcon,
RotateCwIcon,
ScissorsIcon,
ShuffleIcon,
SlidersHorizontalIcon,
TrashIcon,
} from "lucide-react";
import {
findLockerEmptySpot,
getAssetTypeLabel,
getLockerItemFootprint,
getLockerGridOccupancy,
getLockerItemFootprint,
type LockerGridItem,
lockerIsRotatable,
lockerIsStackable,
type LockerGridItem,
} from "@/lib/locker";
interface LockerGridToolbarProps {

View file

@ -16,8 +16,8 @@ import { LockerSearch } from "./LockerSearch";
import { getAssetTypeLabel, type LockerGridItem } from "@/lib/locker";
import type { LockerSearchEntry } from "@/lib/locker/search";
import {
autoArrangeLocker,
attachItemFromGrid,
autoArrangeLocker,
mergeLockerStacks,
moveLockerItem,
removeLockerItem,

View file

@ -14,11 +14,10 @@ import {
} from "lucide-react";
import type { Asset, Loadout } from "@/payload-types";
import {
WARDROBE_SLOT_LAYOUT,
getLoadoutSlot,
type WardrobeSlotIcon,
type LoadoutSlots,
type LockerGridItem,
WARDROBE_SLOT_LAYOUT,
type WardrobeSlotIcon,
} from "@/lib/locker";
import { cn } from "@/lib/utils";
import { getAssetIcon } from "./LockerGrid";
@ -121,9 +120,7 @@ export function Wardrobe({
>
<div className="flex flex-col gap-0.5">
<span className="font-semibold">{layout.label}</span>
<span className="text-muted-foreground">
{asset ? asset.name : "Empty"}
</span>
<span className="text-muted-foreground">{asset ? asset.name : "Empty"}</span>
</div>
</TooltipContent>
</Tooltip>

View file

@ -15,12 +15,12 @@ import {
import { Wardrobe } from "./Wardrobe";
import { WardrobeSlotPicker } from "./WardrobeSlotPicker";
import {
WARDROBE_SLOT_LAYOUT,
getAssetTypeLabel,
getWardrobeSlot,
getLoadoutSlot,
getWardrobeSlot,
type LoadoutSlots,
type LockerGridItem,
WARDROBE_SLOT_LAYOUT,
} from "@/lib/locker";
import { equipLoadout, setLoadoutSlot } from "@/app/(frontend)/locker/actions";
import { getAssetIcon } from "./LockerGrid";
@ -75,7 +75,7 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
const pickerLayout = pickerSlot ? getWardrobeSlot(pickerSlot) : null;
const pickerConfig = pickerSlot ? getLoadoutSlot(pickerSlot) : null;
const pickerValue = pickerSlot ? slots[pickerSlot as keyof LoadoutSlots] ?? null : null;
const pickerValue = pickerSlot ? (slots[pickerSlot as keyof LoadoutSlots] ?? null) : null;
const filled = equippedLoadout
? WARDROBE_SLOT_LAYOUT.filter((layout) => slots[layout.name] != null)
@ -89,9 +89,7 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
</ItemHeader>
<ItemContent>
<Wardrobe
loadout={
equippedLoadout ?? ({ name: "Empty", slots: {} } as unknown as Loadout)
}
loadout={equippedLoadout ?? ({ name: "Empty", slots: {} } as unknown as Loadout)}
items={items}
onSlotClick={(name) => setPickerSlot(name)}
/>
@ -124,7 +122,9 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
>
<SelectTrigger className="flex-1">
<SelectValue
placeholder={equippedLoadout ? equippedLoadout.name : "Select a loadout to equip"}
placeholder={
equippedLoadout ? equippedLoadout.name : "Select a loadout to equip"
}
/>
</SelectTrigger>
<SelectContent>
@ -149,7 +149,8 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
{!equippedLoadout && (
<p className="text-sm text-muted-foreground">
Choose a loadout above — it becomes the active loadout that the wardrobe reflects.
Choose a loadout above — it becomes the active loadout that the wardrobe
reflects.
</p>
)}
</div>
@ -178,7 +179,9 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
onClick={() => setPickerSlot(layout.name)}
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted transition-colors"
>
<span className="text-amber-400">{getAssetIcon(asset.assetType, "size-4")}</span>
<span className="text-amber-400">
{getAssetIcon(asset.assetType, "size-4")}
</span>
<span className="flex-1 min-w-0">
<span className="block text-xs text-muted-foreground">{layout.label}</span>
<span className="block font-medium truncate">

View file

@ -103,9 +103,7 @@ export function WardrobeSlotPicker({
onClick={() => handlePick(entry.assetId)}
className={cn(
"flex items-center gap-3 rounded-md border px-3 py-2 text-left text-sm transition-colors",
selected
? "border-primary bg-primary/10"
: "border-border hover:bg-muted",
selected ? "border-primary bg-primary/10" : "border-border hover:bg-muted",
)}
>
<span className="text-muted-foreground">{getAssetIcon(entry.asset.assetType)}</span>
@ -122,11 +120,7 @@ export function WardrobeSlotPicker({
</div>
<DialogFooter>
<Button
variant="outline"
disabled={busy || pending}
onClick={() => onOpenChange(false)}
>
<Button variant="outline" disabled={busy || pending} onClick={() => onOpenChange(false)}>
Cancel
</Button>
{currentId != null && (

View file

@ -86,15 +86,12 @@ const rarityLabels: Record<string, string> = {
};
export function ResourceCard({ resource }: ResourceCardProps) {
const icon =
resource.icon && typeof resource.icon === "object" ? resource.icon : null;
const icon = resource.icon && typeof resource.icon === "object" ? resource.icon : null;
const description = resource.description
? convertLexicalToPlaintext({ data: resource.description })
: null;
const truncatedDescription =
description && description.length > 150
? description.slice(0, 150) + "…"
: description;
description && description.length > 150 ? description.slice(0, 150) + "…" : description;
return (
<Link href={`/logistics/resources/${resource.id}`}>
@ -106,11 +103,7 @@ export function ResourceCard({ resource }: ResourceCardProps) {
<div className="flex items-center gap-2">
{icon ? (
<ItemMedia variant="image">
<img
src={icon.url}
alt={resource.name}
className="size-full object-cover"
/>
<img src={icon.url} alt={resource.name} className="size-full object-cover" />
</ItemMedia>
) : resource.color ? (
<ItemMedia variant="icon">
@ -123,9 +116,7 @@ export function ResourceCard({ resource }: ResourceCardProps) {
<ItemMedia variant="icon">{typeIcons[resource.type]}</ItemMedia>
)}
<ItemTitle className="flex flex-col">
<span className="font-mono text-xs text-muted-foreground">
{resource.codeName}
</span>
<span className="font-mono text-xs text-muted-foreground">{resource.codeName}</span>
<span>{resource.name}</span>
</ItemTitle>
</div>
@ -145,9 +136,7 @@ export function ResourceCard({ resource }: ResourceCardProps) {
<ItemContent>
<div className="flex flex-col gap-3 w-full">
{truncatedDescription && (
<p className="text-sm text-muted-foreground line-clamp-2">
{truncatedDescription}
</p>
<p className="text-sm text-muted-foreground line-clamp-2">{truncatedDescription}</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 font-medium text-foreground">

View file

@ -26,8 +26,7 @@ export function ShipmentActions({ shipment }: ShipmentActionsProps) {
const [error, setError] = useState<string | null>(null);
const [showCancelDialog, setShowCancelDialog] = useState(false);
const isActive =
shipment.status === "dispatched" || shipment.status === "in_transit";
const isActive = shipment.status === "dispatched" || shipment.status === "in_transit";
if (!isActive) return null;
@ -49,10 +48,7 @@ export function ShipmentActions({ shipment }: ShipmentActionsProps) {
setLoading(true);
setError(null);
const result = await toggleAutoReturn(
shipment.id,
!shipment.autoReturn,
);
const result = await toggleAutoReturn(shipment.id, !shipment.autoReturn);
if (result.success) {
router.refresh();
} else {
@ -63,15 +59,8 @@ export function ShipmentActions({ shipment }: ShipmentActionsProps) {
return (
<div className="flex items-center gap-2">
{error && (
<span className="text-xs text-red-400">{error}</span>
)}
<Button
variant="outline"
size="sm"
onClick={handleToggleAutoReturn}
disabled={loading}
>
{error && <span className="text-xs text-red-400">{error}</span>}
<Button variant="outline" size="sm" onClick={handleToggleAutoReturn} disabled={loading}>
<RotateCcwIcon className="size-3.5 mr-1.5" />
{shipment.autoReturn ? "Disable" : "Enable"} Auto-Return
</Button>
@ -86,23 +75,15 @@ export function ShipmentActions({ shipment }: ShipmentActionsProps) {
<DialogHeader>
<DialogTitle>Cancel Shipment</DialogTitle>
<DialogDescription>
Are you sure you want to cancel this shipment? All cargo will be
returned to the origin structure and the vehicle will be parked
at the origin.
Are you sure you want to cancel this shipment? All cargo will be returned to the
origin structure and the vehicle will be parked at the origin.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCancelDialog(false)}
>
<Button variant="outline" onClick={() => setShowCancelDialog(false)}>
Keep Shipment
</Button>
<Button
variant="destructive"
onClick={handleCancel}
disabled={loading}
>
<Button variant="destructive" onClick={handleCancel} disabled={loading}>
{loading ? "Cancelling..." : "Cancel Shipment"}
</Button>
</DialogFooter>

View file

@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import type { Shipment, GameStructure, GameVehicle, Vehicle } from "@/payload-types";
import type { GameStructure, GameVehicle, Shipment, Vehicle } from "@/payload-types";
import {
Item,
ItemContent,
@ -38,27 +38,19 @@ export function ShipmentCard({ shipment }: ShipmentCardProps) {
? (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 dispatchedAt = shipment.dispatchedAt ? new Date(shipment.dispatchedAt) : null;
const estimatedArrival = shipment.estimatedArrival ? new Date(shipment.estimatedArrival) : null;
const isActive =
shipment.status === "dispatched" || shipment.status === "in_transit";
const isActive = shipment.status === "dispatched" || shipment.status === "in_transit";
const cargoCount = shipment.cargo?.length ?? 0;
const totalItems =
shipment.cargo?.reduce((sum, c) => sum + (c.amount ?? 0), 0) ?? 0;
const totalItems = shipment.cargo?.reduce((sum, c) => sum + (c.amount ?? 0), 0) ?? 0;
return (
<Link href={`/logistics/shipments/${shipment.id}`}>
@ -83,16 +75,13 @@ export function ShipmentCard({ shipment }: ShipmentCardProps) {
>
{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>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-3 w-full">
<div className="flex items-center gap-2 text-sm">
<span className="truncate">
{origin?.name ?? `Structure #${shipment.origin}`}
</span>
<span className="truncate">{origin?.name ?? `Structure #${shipment.origin}`}</span>
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
<span className="truncate">
{destination?.name ?? `Structure #${shipment.destination}`}
@ -108,8 +97,7 @@ export function ShipmentCard({ shipment }: ShipmentCardProps) {
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{cargoCount} item type{cargoCount !== 1 ? "s" : ""} ({totalItems}{" "}
total)
{cargoCount} item type{cargoCount !== 1 ? "s" : ""} ({totalItems} total)
</span>
<span className="font-mono">
{shipment.distance?.toFixed(1)} units · {shipment.fuelCost} fuel
@ -122,7 +110,7 @@ export function ShipmentCard({ shipment }: ShipmentCardProps) {
? `Completed ${shipment.completedAt ? new Date(shipment.completedAt).toLocaleDateString() : ""}`
: shipment.status === "cancelled"
? "Cancelled"
: shipment.failureReason ?? (isActive ? "In transit" : shipment.status)}
: (shipment.failureReason ?? (isActive ? "In transit" : shipment.status))}
</ItemDescription>
<span className="text-xs text-muted-foreground group-hover:text-primary transition-colors flex items-center gap-1">
View details <ChevronRightIcon className="size-3" />

View file

@ -1,16 +1,13 @@
"use client";
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";
interface ShipmentProgressBarProps {
dispatchedAt: string;
estimatedArrival: string;
}
export function ShipmentProgressBar({
dispatchedAt,
estimatedArrival,
}: ShipmentProgressBarProps) {
export function ShipmentProgressBar({ dispatchedAt, estimatedArrival }: ShipmentProgressBarProps) {
const [progress, setProgress] = useState(0);
const [countdown, setCountdown] = useState("");
@ -52,9 +49,7 @@ export function ShipmentProgressBar({
style={{ width: `${Math.min(progress, 100)}%` }}
/>
</div>
<span className="text-xs font-mono text-muted-foreground">
{Math.floor(progress)}%
</span>
<span className="text-xs font-mono text-muted-foreground">{Math.floor(progress)}%</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="font-mono">{countdown} remaining</span>

View file

@ -3,7 +3,7 @@
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { ArrowRightIcon, ChevronDownIcon, ChevronUpIcon, TruckIcon } from "lucide-react";
import type { Shipment, GameStructure, GameVehicle, Vehicle } from "@/payload-types";
import type { GameStructure, GameVehicle, Shipment, Vehicle } from "@/payload-types";
import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar";
import { useGameTick } from "@/hooks/useGameTick";
@ -81,9 +81,7 @@ export function ShipmentToasts() {
<div className="flex items-center gap-2">
<TruckIcon className="size-4 text-primary" />
<span className="text-sm font-semibold">Active Shipments</span>
<span className="text-xs font-mono text-muted-foreground">
{count}
</span>
<span className="text-xs font-mono text-muted-foreground">{count}</span>
</div>
<div className="flex items-center gap-1">
<Link
@ -109,17 +107,14 @@ export function ShipmentToasts() {
? (shipment.origin as GameStructure)
: null;
const destination =
typeof shipment.destination === "object" &&
shipment.destination !== null
typeof shipment.destination === "object" && shipment.destination !== null
? (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;
return (
<Link
@ -149,9 +144,7 @@ export function ShipmentToasts() {
</span>
</div>
{vehicleType && (
<p className="mt-0.5 text-xs text-muted-foreground/80">
{vehicleType.name}
</p>
<p className="mt-0.5 text-xs text-muted-foreground/80">{vehicleType.name}</p>
)}
{shipment.dispatchedAt && shipment.estimatedArrival && (
<div className="mt-2">

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useMemo } from "react";
import { useMemo, useState } from "react";
import type { Shipment } from "@/payload-types";
import { ShipmentCard } from "@/components/frontend/logistics/ShipmentCard";
import { Button } from "@/components/ui/button";

View file

@ -10,12 +10,7 @@ import {
ItemTitle,
} from "@/components/ui/item";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import {
ChevronRightIcon,
HeartIcon,
TimerIcon,
TruckIcon,
} from "lucide-react";
import { ChevronRightIcon, HeartIcon, TimerIcon, TruckIcon } from "lucide-react";
interface VehicleCardProps {
vehicle: {
@ -67,15 +62,12 @@ const categoryLabels: Record<string, string> = {
};
export function VehicleCard({ vehicle }: VehicleCardProps) {
const image =
vehicle.image && typeof vehicle.image === "object" ? vehicle.image : null;
const image = vehicle.image && typeof vehicle.image === "object" ? vehicle.image : null;
const description = vehicle.description
? convertLexicalToPlaintext({ data: vehicle.description })
: null;
const truncatedDescription =
description && description.length > 150
? description.slice(0, 150) + "…"
: description;
description && description.length > 150 ? description.slice(0, 150) + "…" : description;
const materialCount = vehicle.construction?.materials?.length ?? 0;
const constructionTime = vehicle.construction?.constructionTime;
@ -92,11 +84,7 @@ export function VehicleCard({ vehicle }: VehicleCardProps) {
<div className="flex items-center gap-2">
{image ? (
<ItemMedia variant="image">
<img
src={image.url}
alt={vehicle.name}
className="size-full object-cover"
/>
<img src={image.url} alt={vehicle.name} className="size-full object-cover" />
</ItemMedia>
) : (
<ItemMedia variant="icon">
@ -105,9 +93,7 @@ export function VehicleCard({ vehicle }: VehicleCardProps) {
)}
<ItemTitle className="flex flex-col">
{vehicle.classname && (
<span className="font-mono text-xs text-muted-foreground">
{vehicle.classname}
</span>
<span className="font-mono text-xs text-muted-foreground">{vehicle.classname}</span>
)}
<span>{vehicle.name}</span>
</ItemTitle>
@ -126,9 +112,7 @@ export function VehicleCard({ vehicle }: VehicleCardProps) {
<ItemContent>
<div className="flex flex-col gap-3 w-full">
{truncatedDescription && (
<p className="text-sm text-muted-foreground line-clamp-2">
{truncatedDescription}
</p>
<p className="text-sm text-muted-foreground line-clamp-2">{truncatedDescription}</p>
)}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
{materialCount > 0 && (
@ -136,8 +120,7 @@ export function VehicleCard({ vehicle }: VehicleCardProps) {
{materialCount} material{materialCount !== 1 ? "s" : ""}
{constructionTime != null && (
<>
,{" "}
<TimerIcon className="size-3" />
, <TimerIcon className="size-3" />
{constructionTime} tick{constructionTime !== 1 ? "s" : ""}
</>
)}
@ -158,9 +141,7 @@ export function VehicleCard({ vehicle }: VehicleCardProps) {
</div>
</div>
</ItemContent>
<ItemDescription>
{truncatedDescription ?? vehicle.classname ?? "Vehicle"}
</ItemDescription>
<ItemDescription>{truncatedDescription ?? vehicle.classname ?? "Vehicle"}</ItemDescription>
<span className="text-xs text-muted-foreground group-hover:text-primary transition-colors flex items-center gap-1">
View details <ChevronRightIcon className="size-3" />
</span>

View file

@ -2,7 +2,6 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface VehicleFiltersProps {
categories: string[];

View file

@ -41,11 +41,7 @@ export function ChatBubbleList({
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{bubble.from === "buyer" ? buyerLabel : sellerLabel}
</span>
<span
className={cn(
bubble.from === "seller" ? "text-amber-100/90" : "text-blue-100/90",
)}
>
<span className={cn(bubble.from === "seller" ? "text-amber-100/90" : "text-blue-100/90")}>
{bubble.text}
</span>
</div>

View file

@ -74,9 +74,10 @@ export function CreateListingDialog({ locker, currencyLabel }: CreateListingDial
quantity,
tradeable: asset.tradeable === true,
isLive: asset.isLive === true,
basePrice: typeof asset.marketTrading?.perceivedValue?.baseBuyPrice === "number"
? asset.marketTrading.perceivedValue.baseBuyPrice
: null,
basePrice:
typeof asset.marketTrading?.perceivedValue?.baseBuyPrice === "number"
? asset.marketTrading.perceivedValue.baseBuyPrice
: null,
});
}
}
@ -164,14 +165,20 @@ export function CreateListingDialog({ locker, currencyLabel }: CreateListingDial
<DialogHeader>
<DialogTitle>Sell Item</DialogTitle>
<DialogDescription>
List items from your locker on the market. Listings last 30 days and stock is
returned if they expire.
List items from your locker on the market. Listings last 30 days and stock is returned
if they expire.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label>Item</Label>
<Select value={assetId} onValueChange={(v) => { setAssetId(v); setQuantity("1"); }}>
<Select
value={assetId}
onValueChange={(v) => {
setAssetId(v);
setQuantity("1");
}}
>
<SelectTrigger>
<SelectValue placeholder="Select an item from your locker" />
</SelectTrigger>
@ -216,7 +223,9 @@ export function CreateListingDialog({ locker, currencyLabel }: CreateListingDial
step="any"
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder={selected.basePrice != null ? `Suggested ~${selected.basePrice}` : "0"}
placeholder={
selected.basePrice != null ? `Suggested ~${selected.basePrice}` : "0"
}
/>
</div>
</div>
@ -255,7 +264,9 @@ export function CreateListingDialog({ locker, currencyLabel }: CreateListingDial
{selected && selected.basePrice != null && (
<p className="text-xs text-muted-foreground">
<Badge variant="outline" className="mr-1">Tip</Badge>
<Badge variant="outline" className="mr-1">
Tip
</Badge>
Resting valuation is {selected.basePrice.toLocaleString()} per unit.
</p>
)}

View file

@ -6,7 +6,16 @@ import type { Asset, MarketListing } from "@/payload-types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Item, ItemActions, ItemContent, ItemDescription, ItemFooter, ItemHeader, ItemMedia, ItemTitle } from "@/components/ui/item";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemFooter,
ItemHeader,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { getAssetTypeLabel } from "@/lib/locker";
import { getRarityLabel, isNegotiable } from "@/lib/market";
import { formatAmount } from "@/lib/banking/format";
@ -42,7 +51,12 @@ function sellerName(listing: MarketListing): string {
return npcLabel(listing);
}
export function ListingCard({ listing, currentUserId, currencyLabel, canManage }: ListingCardProps) {
export function ListingCard({
listing,
currentUserId,
currencyLabel,
canManage,
}: ListingCardProps) {
const router = useRouter();
const [busy, setBusy] = useState<"buy" | "cancel" | null>(null);
const [error, setError] = useState<string | null>(null);
@ -51,7 +65,8 @@ export function ListingCard({ listing, currentUserId, currencyLabel, canManage }
const asset = listing.asset as Asset;
const assetName = typeof asset === "object" && asset ? asset.name : `Asset #${listing.asset}`;
const rarity = typeof asset === "object" && asset ? getRarityLabel(asset) : null;
const assetType = typeof asset === "object" && asset ? getAssetTypeLabel(asset.assetType) : "Item";
const assetType =
typeof asset === "object" && asset ? getAssetTypeLabel(asset.assetType) : "Item";
const stock = listing.quantity ?? 1;
const negotiable = isNegotiable(listing);
const isOwn = (() => {
@ -170,7 +185,11 @@ export function ListingCard({ listing, currentUserId, currencyLabel, canManage }
{busy === "buy" ? "Buying..." : stock > 1 ? `Buy ${selectedQty}` : "Buy"}
</Button>
{negotiable && !isOwn && (
<MakeOfferDialog listing={listing} currentUserId={currentUserId} currencyLabel={currencyLabel} />
<MakeOfferDialog
listing={listing}
currentUserId={currentUserId}
currencyLabel={currencyLabel}
/>
)}
</div>
{canCancel && (

View file

@ -129,7 +129,10 @@ export function MakeOfferDialog({ listing, currentUserId, currencyLabel }: MakeO
}
}
async function run(action: () => Promise<{ success: boolean; error?: string; data?: unknown }>, key: string) {
async function run(
action: () => Promise<{ success: boolean; error?: string; data?: unknown }>,
key: string,
) {
setBusy(key);
setError(null);
const res = await action();
@ -278,9 +281,7 @@ export function MakeOfferDialog({ listing, currentUserId, currencyLabel }: MakeO
)}
<p className="text-xs text-muted-foreground">
{stock}× in stock · total{" "}
<span className="font-mono">
{fmt((Number(amount) || 0) * clampedQty)}
</span>
<span className="font-mono">{fmt((Number(amount) || 0) * clampedQty)}</span>
</p>
{error && <p className="text-sm text-red-400">{error}</p>}
</>
@ -289,8 +290,8 @@ export function MakeOfferDialog({ listing, currentUserId, currencyLabel }: MakeO
{outcome?.kind === "pending" && (
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">
Offer of <span className="font-mono">{fmt(negotiation?.amount ?? 0)}</span> each
for {negotiation?.quantity ?? stock}× sent. Waiting for the{" "}
Offer of <span className="font-mono">{fmt(negotiation?.amount ?? 0)}</span> each for{" "}
{negotiation?.quantity ?? stock}× sent. Waiting for the{" "}
{isVendor ? "vendor" : "seller"} to respond.
</p>
{error && <p className="text-sm text-red-400">{error}</p>}
@ -386,7 +387,9 @@ export function MakeOfferDialog({ listing, currentUserId, currencyLabel }: MakeO
</>
)}
{(outcome?.kind === "accepted" || outcome?.kind === "rejected" || outcome?.kind === "closed") && (
{(outcome?.kind === "accepted" ||
outcome?.kind === "rejected" ||
outcome?.kind === "closed") && (
<Button variant="outline" onClick={() => setOpen(false)} disabled={acceptLocked}>
{acceptLocked ? `Closing in ${acceptRemaining}s…` : "Close"}
</Button>

View file

@ -181,9 +181,7 @@ export function MarketView({
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
My listings
</h2>
<span className="text-xs text-muted-foreground">
{myListings.length} active
</span>
<span className="text-xs text-muted-foreground">{myListings.length} active</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-3">
{myListings.map((listing) => (

View file

@ -17,7 +17,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
import { formatAmount } from "@/lib/banking/format";
import { buildChatBubbles } from "@/lib/market/chatBubbles";
import { ChatBubbleList } from "@/components/frontend/market/ChatBubbleList";
import { HistoryIcon, ChevronDownIcon } from "lucide-react";
import { ChevronDownIcon, HistoryIcon } from "lucide-react";
import { cn } from "@/lib/utils";
const STATUS_LABELS: Record<string, string> = {

View file

@ -40,10 +40,7 @@ function assetName(listing: MarketListing | null): string {
return typeof asset === "object" && asset ? asset.name : `Asset #${listing.asset}`;
}
function counterpartyLabel(
negotiation: MarketNegotiation,
role: "buyer" | "seller",
): string {
function counterpartyLabel(negotiation: MarketNegotiation, role: "buyer" | "seller"): string {
if (role === "buyer") {
const listing = listingOf(negotiation);
if (listing?.isAutoGenerated) {
@ -55,9 +52,11 @@ function counterpartyLabel(
}
const seller = listing?.seller as unknown;
if (typeof seller === "object" && seller && "username" in (seller as object)) {
return (seller as { username?: string; displayName?: string }).displayName ||
return (
(seller as { username?: string; displayName?: string }).displayName ||
(seller as { username?: string }).username ||
"Seller";
"Seller"
);
}
return "Seller";
}
@ -128,7 +127,9 @@ function NegotiationRow({
{counterpartyLabel(negotiation, role)} · {listing?.quantity ?? "?"}× in stock
</span>
</div>
<Badge variant={isOpen ? "default" : "secondary"}>{STATUS_LABELS[negotiation.status] ?? negotiation.status}</Badge>
<Badge variant={isOpen ? "default" : "secondary"}>
{STATUS_LABELS[negotiation.status] ?? negotiation.status}
</Badge>
</div>
<div className="flex items-center gap-2 text-sm">
@ -223,7 +224,11 @@ interface NegotiationPanelProps {
currencyLabel: string | null;
}
export function NegotiationPanel({ negotiations, currentUserId, currencyLabel }: NegotiationPanelProps) {
export function NegotiationPanel({
negotiations,
currentUserId,
currencyLabel,
}: NegotiationPanelProps) {
const open = negotiations.filter((n) => n.status === "open");
const closed = negotiations.filter((n) => n.status !== "open");
@ -255,9 +260,8 @@ export function NegotiationPanel({ negotiations, currentUserId, currencyLabel }:
{open.map((negotiation) => {
const role: "buyer" | "seller" =
negotiation.buyer &&
(typeof negotiation.buyer === "object"
? negotiation.buyer.id
: negotiation.buyer) === currentUserId
(typeof negotiation.buyer === "object" ? negotiation.buyer.id : negotiation.buyer) ===
currentUserId
? "buyer"
: "seller";
return (
@ -276,7 +280,8 @@ export function NegotiationPanel({ negotiations, currentUserId, currencyLabel }:
<div className="flex flex-wrap gap-1.5">
{closed.slice(0, 6).map((negotiation) => (
<Badge key={negotiation.id} variant="secondary" className="font-normal">
{assetName(listingOf(negotiation))} · {STATUS_LABELS[negotiation.status] ?? negotiation.status}
{assetName(listingOf(negotiation))} ·{" "}
{STATUS_LABELS[negotiation.status] ?? negotiation.status}
</Badge>
))}
{closed.length > 6 && (

View file

@ -27,7 +27,12 @@ export function PatienceMeter({ patience, className }: PatienceMeterProps) {
<div className={cn("flex flex-col gap-1.5", className)}>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Vendor patience</span>
<span className={cn("font-mono text-xs", value >= 80 ? "text-red-400" : "text-muted-foreground")}>
<span
className={cn(
"font-mono text-xs",
value >= 80 ? "text-red-400" : "text-muted-foreground",
)}
>
{value}%
</span>
</div>

View file

@ -2,9 +2,9 @@
import Link from "next/link";
import { cn, pluralize } from "@/lib/utils";
import type { RosterMember, RosterAssignment } from "@/app/(frontend)/roster/page";
import type { RosterAssignment, RosterMember } from "@/app/(frontend)/roster/page";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { NetworkIcon, Building2Icon, ShieldIcon, ListIcon, CrownIcon } from "lucide-react";
import { Building2Icon, CrownIcon, ListIcon, NetworkIcon, ShieldIcon } from "lucide-react";
const TYPE_ORDER: Record<string, number> = {
division: 0,

View file

@ -42,9 +42,7 @@ export function CapacityBar({ utilization, currentMass, maxMass, className }: Ca
<div className={cn("flex flex-col gap-1", className)}>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Capacity</span>
<span className={cn("font-medium", colorClass)}>
{Math.round(utilization)}%
</span>
<span className={cn("font-medium", colorClass)}>{Math.round(utilization)}%</span>
</div>
<Progress value={utilization} className={cn("h-1.5", progressClass)} />
<div className="flex items-center justify-between text-xs text-muted-foreground">

View file

@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Item, ItemContent, ItemHeader, ItemTitle, ItemDescription } from "@/components/ui/item";
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
import { BookOpenTextIcon, ScrollTextIcon } from "lucide-react";
import { useGameTick } from "@/hooks/useGameTick";
import { formatType } from "@/utils/event-log/formatType";
@ -9,7 +9,6 @@ import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
@ -140,9 +139,7 @@ export function EventLedger({
<ItemContent>
{eventTypes.length > 1 && (
<DropdownMenu>
<span className="text-xs text-muted-foreground">
Filter event logs...
</span>
<span className="text-xs text-muted-foreground">Filter event logs...</span>
<DropdownMenuTrigger asChild>
<button
type="button"

View file

@ -1,6 +1,6 @@
"use client";
import { useRef, useCallback, useEffect } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useDraggable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import { Resource } from "@/payload-types";
@ -73,10 +73,7 @@ export function GridItemComponent({ item, cellSize, onSelect, selected }: GridIt
onClick={handleClick}
suppressHydrationWarning
>
<div
className="absolute inset-0 opacity-20"
style={{ backgroundColor: bgColor }}
/>
<div className="absolute inset-0 opacity-20" style={{ backgroundColor: bgColor }} />
<div className="relative flex flex-col items-center justify-center gap-0.5 p-1 w-full h-full">
{resource.icon && typeof resource.icon === "object" && "url" in resource.icon && (
<img

View file

@ -2,16 +2,16 @@
import { Button } from "@/components/ui/button";
import {
ShuffleIcon,
RotateCwIcon,
TrashIcon,
ScissorsIcon,
LayoutGridIcon,
ListIcon,
RotateCwIcon,
ScissorsIcon,
ShuffleIcon,
TrashIcon,
} from "lucide-react";
import { GridItem, getItemFootprint, getGridOccupancy, findEmptySpot } from "@/lib/logistics";
import { findEmptySpot, getGridOccupancy, getItemFootprint, GridItem } from "@/lib/logistics";
import { Resource } from "@/payload-types";
import { cn, pluralize } from "@/lib/utils";
import { pluralize } from "@/lib/utils";
interface GridToolbarProps {
gridWidth: number;
@ -41,9 +41,10 @@ export function GridToolbar({
const occupancy = getGridOccupancy(storedResources, gridWidth, gridHeight);
const res = selectedItem ? (selectedItem.resource as Resource) : null;
const fp = res ? getItemFootprint(res, selectedItem!.rotated) : null;
const canSplit = selectedItem && res && fp
? findEmptySpot(storedResources, fp.width, fp.height, gridWidth, gridHeight) !== null
: false;
const canSplit =
selectedItem && res && fp
? findEmptySpot(storedResources, fp.width, fp.height, gridWidth, gridHeight) !== null
: false;
return (
<div className="flex flex-col gap-3">
@ -78,18 +79,15 @@ export function GridToolbar({
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{res.name}</div>
<div className="text-muted-foreground">
{selectedItem.amount.toLocaleString()} {pluralize(selectedItem.amount, res.unitOfMeasure)}
{selectedItem.amount.toLocaleString()}{" "}
{pluralize(selectedItem.amount, res.unitOfMeasure)}
{fp && ` · ${fp.width}x${fp.height}`}
{selectedItem.rotated && " · rotated"}
</div>
</div>
<div className="flex items-center gap-1">
{res.rotatable !== false && (
<Button
variant="outline"
size="sm"
onClick={() => onRotate(selectedItem.id)}
>
<Button variant="outline" size="sm" onClick={() => onRotate(selectedItem.id)}>
<RotateCwIcon className="size-3.5" />
</Button>
)}
@ -103,11 +101,7 @@ export function GridToolbar({
<ScissorsIcon className="size-3.5" />
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => onRemove(selectedItem.id)}
>
<Button variant="outline" size="sm" onClick={() => onRemove(selectedItem.id)}>
<TrashIcon className="size-3.5" />
</Button>
</div>

View file

@ -1,27 +1,35 @@
"use client";
import { useState, useCallback, useMemo, useRef, useEffect } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
DndContext,
type DragEndEvent,
type DragMoveEvent,
DragOverlay,
type DragStartEvent,
PointerSensor,
useSensor,
useSensors,
type DragStartEvent,
type DragMoveEvent,
type DragEndEvent,
} from "@dnd-kit/core";
import { GameStructure, Structure, Resource } from "@/payload-types";
import { GameStructure, Resource, Structure } from "@/payload-types";
import {
getItemFootprint,
isValidPlacement,
getGridOccupancy,
getItemFootprint,
type GridItem,
isValidPlacement,
} from "@/lib/logistics";
import { GridItemComponent } from "./GridItem";
import { GridCell } from "./GridCell";
function DragOverlayContent({ item, cellSize, rotated }: { item: GridItem; cellSize: number; rotated?: boolean }) {
function DragOverlayContent({
item,
cellSize,
rotated,
}: {
item: GridItem;
cellSize: number;
rotated?: boolean;
}) {
const resource = item.resource as Resource;
const effective = rotated ?? item.rotated;
const { width, height } = getItemFootprint(resource, effective);
@ -60,7 +68,13 @@ function DragOverlayContent({ item, cellSize, rotated }: { item: GridItem; cellS
);
}
function findMergeTarget(items: GridItem[], dragged: GridItem, targetX: number, targetY: number, rotated?: boolean): GridItem | null {
function findMergeTarget(
items: GridItem[],
dragged: GridItem,
targetX: number,
targetY: number,
rotated?: boolean,
): GridItem | null {
const effective = rotated ?? dragged.rotated;
const fp = getItemFootprint(dragged.resource as Resource, effective);
const targetCells = new Set<string>();
@ -172,32 +186,37 @@ export function InventoryGrid({
cells.add(`${hoverPosition.x + dx},${hoverPosition.y + dy}`);
}
}
const mergeTarget = findMergeTarget(storedResources, activeItem, hoverPosition.x, hoverPosition.y, pendingRotated);
const valid = mergeTarget !== null || isValidPlacement(
const mergeTarget = findMergeTarget(
storedResources,
activeItem.id,
activeItem,
hoverPosition.x,
hoverPosition.y,
fp.width,
fp.height,
gridWidth,
gridHeight,
pendingRotated,
);
const valid =
mergeTarget !== null ||
isValidPlacement(
storedResources,
activeItem.id,
hoverPosition.x,
hoverPosition.y,
fp.width,
fp.height,
gridWidth,
gridHeight,
);
return { cells, valid };
}, [hoverPosition, activeItem, storedResources, gridWidth, gridHeight, pendingRotated]);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const data = event.active.data.current;
if (data?.item) {
const item = data.item as GridItem;
setActiveItem(item);
setPendingRotated(item.rotated ?? false);
}
setHoverPosition(null);
},
[],
);
const handleDragStart = useCallback((event: DragStartEvent) => {
const data = event.active.data.current;
if (data?.item) {
const item = data.item as GridItem;
setActiveItem(item);
setPendingRotated(item.rotated ?? false);
}
setHoverPosition(null);
}, []);
const handleDragMove = useCallback(
(event: DragMoveEvent) => {
@ -277,13 +296,13 @@ export function InventoryGrid({
}, [onSelectItem]);
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
>
<div className="flex flex-col gap-3">
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
>
<div className="flex flex-col gap-3">
<div ref={containerRef} className="rounded-lg border bg-muted/20 p-2">
<div
ref={gridRef}
@ -322,7 +341,7 @@ export function InventoryGrid({
/>
))}
</div>
</div>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>

View file

@ -18,11 +18,7 @@ export function ManageStorageButton({ structureId }: ManageStorageButtonProps) {
<SettingsIcon className="size-3.5 mr-1.5" />
Manage
</Button>
<ManageStorageDialog
structureId={structureId}
open={open}
onOpenChange={setOpen}
/>
<ManageStorageDialog structureId={structureId} open={open} onOpenChange={setOpen} />
</>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useTransition, useCallback } from "react";
import { useCallback, useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
Dialog,
@ -31,11 +31,11 @@ import {
isResourceProhibited,
isResourceWhitelisted,
} from "@/lib/storageRules";
import { GameStructure, Structure, Resource, GameVehicle, Vehicle } from "@/payload-types";
import { GameStructure, GameVehicle, Resource, Structure, Vehicle } from "@/payload-types";
import {
ArrowDownToLineIcon,
ArrowUpFromLineIcon,
ArrowRightLeftIcon,
ArrowUpFromLineIcon,
Loader2Icon,
TruckIcon,
} from "lucide-react";
@ -48,21 +48,13 @@ interface ManageStorageDialogProps {
onOpenChange: (open: boolean) => void;
}
export function ManageStorageDialog({
structureId,
open,
onOpenChange,
}: ManageStorageDialogProps) {
const [structure, setStructure] = useState<
(GameStructure & { type: Structure }) | null
>(null);
const [allStructures, setAllStructures] = useState<
(GameStructure & { type: Structure })[]
>([]);
export function ManageStorageDialog({ structureId, open, onOpenChange }: ManageStorageDialogProps) {
const [structure, setStructure] = useState<(GameStructure & { type: Structure }) | null>(null);
const [allStructures, setAllStructures] = useState<(GameStructure & { type: Structure })[]>([]);
const [allResources, setAllResources] = useState<Resource[]>([]);
const [availableVehicles, setAvailableVehicles] = useState<
(GameVehicle & { type: Vehicle })[]
>([]);
const [availableVehicles, setAvailableVehicles] = useState<(GameVehicle & { type: Vehicle })[]>(
[],
);
const [loading, setLoading] = useState(false);
const [isPending, startTransition] = useTransition();
const router = useRouter();
@ -91,7 +83,9 @@ export function ManageStorageDialog({
fetch(`/api/game-structures/${structureId}?depth=2`),
fetch(`/api/game-structures?depth=2&limit=500`),
fetch(`/api/resources?depth=0&limit=500`),
fetch(`/api/game-vehicles?depth=2&limit=500&where[deployedAt][equals]=${structureId}&where[status][equals]=idle`),
fetch(
`/api/game-vehicles?depth=2&limit=500&where[deployedAt][equals]=${structureId}&where[status][equals]=idle`,
),
]);
const anyUnauthorized = [structRes, allRes, resourcesRes, vehiclesRes].some(
@ -111,9 +105,7 @@ export function ManageStorageDialog({
const vehiclesData = await vehiclesRes.json();
setStructure(structData);
setAllStructures(
allData.docs.filter(
(s: GameStructure & { type: Structure }) => s.id !== structureId,
),
allData.docs.filter((s: GameStructure & { type: Structure }) => s.id !== structureId),
);
setAllResources(resourcesData.docs ?? []);
setAvailableVehicles(vehiclesData.docs ?? []);
@ -177,18 +169,12 @@ export function ManageStorageDialog({
): number => {
const grid = (s.storedResources ?? []).reduce((sum, e) => {
const res = e.resource;
const rid =
typeof res === "object" && res !== null && "id" in res
? res.id
: (res as number);
const rid = typeof res === "object" && res !== null && "id" in res ? res.id : (res as number);
return sum + (rid === resourceId ? e.amount : 0);
}, 0);
const voidAmount = (s.voidStorage ?? []).reduce((sum, e) => {
const res = e.resource;
const rid =
typeof res === "object" && res !== null && "id" in res
? res.id
: (res as number);
const rid = typeof res === "object" && res !== null && "id" in res ? res.id : (res as number);
return sum + (rid === resourceId ? e.amount : 0);
}, 0);
return grid + voidAmount;
@ -230,15 +216,11 @@ export function ManageStorageDialog({
reasons.push({ value: allowance, label: "storage allowance" });
}
if (reasons.length === 0) return undefined;
const binding = reasons.reduce(
(min, r) => (r.value < min.value ? r : min),
reasons[0],
);
const binding = reasons.reduce((min, r) => (r.value < min.value ? r : min), reasons[0]);
return { max: binding.value, reason: binding.label };
};
const getDepositMax = (): number | undefined =>
getDepositLimitInfoFor(depositResourceId)?.max;
const getDepositMax = (): number | undefined => getDepositLimitInfoFor(depositResourceId)?.max;
const getWithdrawMax = (): number | undefined => {
const stored = findStoredResource(withdrawResourceId);
@ -263,9 +245,7 @@ export function ManageStorageDialog({
if (destMaxMass != null) {
const massPerUnit = stored.resource.massPerUnit ?? 0;
if (massPerUnit > 0) {
caps.push(
Math.floor((destMaxMass - getMassOf(dest)) / massPerUnit),
);
caps.push(Math.floor((destMaxMass - getMassOf(dest)) / massPerUnit));
}
}
return Math.min(...caps);
@ -273,8 +253,7 @@ export function ManageStorageDialog({
return stored.amount;
};
const getTransferMax = (): number | undefined =>
getTransferMaxFor(transferToId);
const getTransferMax = (): number | undefined => getTransferMaxFor(transferToId);
const clampAmount = (value: string, max: number | undefined): string => {
const num = Number(value);
@ -333,13 +312,19 @@ export function ManageStorageDialog({
};
return (
<div className={cn(
"flex flex-col gap-1.5 p-3 rounded-md border",
isOverCapacity ? "border-red-500/30 bg-red-500/5" : "border-border bg-muted/30",
)}>
<div
className={cn(
"flex flex-col gap-1.5 p-3 rounded-md border",
isOverCapacity ? "border-red-500/30 bg-red-500/5" : "border-border bg-muted/30",
)}
>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{tab === "deposit" ? "After deposit" : tab === "withdraw" ? "After withdrawal" : "After transfer"}
{tab === "deposit"
? "After deposit"
: tab === "withdraw"
? "After withdrawal"
: "After transfer"}
</span>
<span className={cn("font-medium", getColor(previewPercent))}>
{Math.round(previewPercent)}%
@ -347,9 +332,12 @@ export function ManageStorageDialog({
</div>
<Progress value={previewPercent} className={cn("h-1.5", getBarColor(previewPercent))} />
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{currentMass.toLocaleString()} kg → {previewMass.toLocaleString()} kg</span>
<span>
{currentMass.toLocaleString()} kg → {previewMass.toLocaleString()} kg
</span>
<span className={cn(isOverCapacity && "text-red-400 font-medium")}>
{delta >= 0 ? "+" : ""}{delta.toLocaleString()} kg
{delta >= 0 ? "+" : ""}
{delta.toLocaleString()} kg
{isOverCapacity && " (over capacity!)"}
</span>
</div>
@ -474,9 +462,7 @@ export function ManageStorageDialog({
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Manage Storage</DialogTitle>
<DialogDescription>
{structure?.name ?? "Loading..."}
</DialogDescription>
<DialogDescription>{structure?.name ?? "Loading..."}</DialogDescription>
</DialogHeader>
{loading ? (
@ -505,7 +491,8 @@ export function ManageStorageDialog({
<div className="flex justify-between">
<span>Cargo</span>
<span className="font-medium text-foreground">
{shipmentAmount}× {allResources.find((r) => r.id === Number(shipmentResourceId))?.name ?? "Unknown"}
{shipmentAmount}×{" "}
{allResources.find((r) => r.id === Number(shipmentResourceId))?.name ?? "Unknown"}
</span>
</div>
</div>
@ -531,67 +518,81 @@ export function ManageStorageDialog({
) : (
<div className="flex items-center gap-2 p-3 rounded-md border border-amber-500/30 bg-amber-500/5 text-sm text-amber-400">
<TruckIcon className="size-4 shrink-0" />
<span>No idle vehicles stationed at this structure. Deploy a vehicle here first to create shipments.</span>
<span>
No idle vehicles stationed at this structure. Deploy a vehicle here first to
create shipments.
</span>
</div>
)}
</div>
{shipmentVehicleId && (() => {
const selectedVehicle = availableVehicles.find((v) => v.id === Number(shipmentVehicleId));
const vType = selectedVehicle && typeof selectedVehicle.type === "object" ? selectedVehicle.type : null;
if (!selectedVehicle || !vType) return null;
const destStructure = allStructures.find((s) => s.id === Number(shipmentDestId));
const originCoords = structure?.coordinates as [number, number] | null;
const destCoords = destStructure?.coordinates as [number, number] | null;
let distance = 0;
if (originCoords && destCoords) {
distance = Math.sqrt(
(destCoords[0] - originCoords[0]) ** 2 +
(destCoords[1] - originCoords[1]) ** 2,
{shipmentVehicleId &&
(() => {
const selectedVehicle = availableVehicles.find(
(v) => v.id === Number(shipmentVehicleId),
);
}
const vType =
selectedVehicle && typeof selectedVehicle.type === "object"
? selectedVehicle.type
: null;
if (!selectedVehicle || !vType) return null;
const consumptionRate = vType.fuel?.fuelConsumptionRate ?? 1;
const fuelCost = Math.ceil(distance * consumptionRate);
const speed = vType.maxSpeedOnRoad ?? vType.maxSpeedOffRoad ?? vType.maxAirspeed ?? 50;
const transitHours = distance / speed;
const canAfford = (selectedVehicle.currentFuel ?? 0) >= fuelCost;
const destStructure = allStructures.find((s) => s.id === Number(shipmentDestId));
const originCoords = structure?.coordinates as [number, number] | null;
const destCoords = destStructure?.coordinates as [number, number] | null;
let distance = 0;
if (originCoords && destCoords) {
distance = Math.sqrt(
(destCoords[0] - originCoords[0]) ** 2 + (destCoords[1] - originCoords[1]) ** 2,
);
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1.5 p-3 rounded-md border border-border bg-muted/30 text-xs">
<div className="flex justify-between text-muted-foreground">
<span>Distance</span>
<span className="font-mono">{distance.toFixed(1)} units</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Fuel Cost</span>
<span className={cn("font-mono", !canAfford && "text-red-400 font-medium")}>
{fuelCost} units
</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Transit Time</span>
<span className="font-mono">
{transitHours < 1
? `${Math.ceil(transitHours * 60)} min`
: `${transitHours.toFixed(1)} hr`}
</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Vehicle Fuel</span>
<span className="font-mono">{selectedVehicle.currentFuel?.toFixed(1)} / {vType.fuel?.fuelCapacity}</span>
const consumptionRate = vType.fuel?.fuelConsumptionRate ?? 1;
const fuelCost = Math.ceil(distance * consumptionRate);
const speed =
vType.maxSpeedOnRoad ?? vType.maxSpeedOffRoad ?? vType.maxAirspeed ?? 50;
const transitHours = distance / speed;
const canAfford = (selectedVehicle.currentFuel ?? 0) >= fuelCost;
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1.5 p-3 rounded-md border border-border bg-muted/30 text-xs">
<div className="flex justify-between text-muted-foreground">
<span>Distance</span>
<span className="font-mono">{distance.toFixed(1)} units</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Fuel Cost</span>
<span className={cn("font-mono", !canAfford && "text-red-400 font-medium")}>
{fuelCost} units
</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Transit Time</span>
<span className="font-mono">
{transitHours < 1
? `${Math.ceil(transitHours * 60)} min`
: `${transitHours.toFixed(1)} hr`}
</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Vehicle Fuel</span>
<span className="font-mono">
{selectedVehicle.currentFuel?.toFixed(1)} / {vType.fuel?.fuelCapacity}
</span>
</div>
</div>
{!canAfford && (
<div className="flex items-center gap-2 p-3 rounded-md border border-red-500/30 bg-red-500/5 text-sm text-red-400">
<span>
Vehicle does not have enough fuel for this trip. Need {fuelCost} units,
has {selectedVehicle.currentFuel?.toFixed(1)}.
</span>
</div>
)}
</div>
{!canAfford && (
<div className="flex items-center gap-2 p-3 rounded-md border border-red-500/30 bg-red-500/5 text-sm text-red-400">
<span>Vehicle does not have enough fuel for this trip. Need {fuelCost} units, has {selectedVehicle.currentFuel?.toFixed(1)}.</span>
</div>
)}
</div>
);
})()}
);
})()}
<div className="flex gap-2">
<Button
@ -606,7 +607,12 @@ export function ManageStorageDialog({
</Button>
<Button
onClick={handleCreateShipment}
disabled={isPending || !shipmentVehicleId || !shipmentDestId || availableVehicles.length === 0}
disabled={
isPending ||
!shipmentVehicleId ||
!shipmentDestId ||
availableVehicles.length === 0
}
className="flex-1"
>
{isPending ? (
@ -628,269 +634,273 @@ export function ManageStorageDialog({
{Math.round(Math.min((currentMass / maxMass) * 100, 100))}%
</span>
</div>
<Progress
value={Math.min((currentMass / maxMass) * 100, 100)}
className="h-1.5"
/>
<Progress value={Math.min((currentMass / maxMass) * 100, 100)} className="h-1.5" />
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{currentMass.toLocaleString()} kg used</span>
<span>{maxMass.toLocaleString()} kg max</span>
</div>
</div>
)}
<Tabs defaultValue="deposit" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="deposit" className="flex items-center gap-1.5">
<ArrowDownToLineIcon className="size-3.5" />
Deposit
</TabsTrigger>
<TabsTrigger value="withdraw" className="flex items-center gap-1.5">
<ArrowUpFromLineIcon className="size-3.5" />
Withdraw
</TabsTrigger>
<TabsTrigger value="transfer" className="flex items-center gap-1.5">
<ArrowRightLeftIcon className="size-3.5" />
Transfer
</TabsTrigger>
</TabsList>
<Tabs defaultValue="deposit" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="deposit" className="flex items-center gap-1.5">
<ArrowDownToLineIcon className="size-3.5" />
Deposit
</TabsTrigger>
<TabsTrigger value="withdraw" className="flex items-center gap-1.5">
<ArrowUpFromLineIcon className="size-3.5" />
Withdraw
</TabsTrigger>
<TabsTrigger value="transfer" className="flex items-center gap-1.5">
<ArrowRightLeftIcon className="size-3.5" />
Transfer
</TabsTrigger>
</TabsList>
<TabsContent value="deposit" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2">
<Label>Resource</Label>
<Select
value={depositResourceId}
onValueChange={(val) => {
setDepositResourceId(val);
setDepositAmount(
clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max),
);
}}
<TabsContent value="deposit" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2">
<Label>Resource</Label>
<Select
value={depositResourceId}
onValueChange={(val) => {
setDepositResourceId(val);
setDepositAmount(
clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max),
);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a resource..." />
</SelectTrigger>
<SelectContent>
{allResources
.filter((r) => isResourceAllowed(r.id))
.map((resource) => {
const cap = getAllowedDepositCap(resource.id);
return (
<SelectItem key={resource.id} value={String(resource.id)}>
{resource.name}
{cap !== undefined && (
<span className="text-muted-foreground">
{" "}
— {cap.toLocaleString()} max
</span>
)}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Amount</Label>
<Input
type="number"
min="1"
max={getDepositMax()}
value={depositAmount}
onChange={(e) => setDepositAmount(clampAmount(e.target.value, getDepositMax()))}
/>
{getDepositLimitInfoFor(depositResourceId) !== undefined && (
<span className="text-xs text-muted-foreground">
Max: {getDepositLimitInfoFor(depositResourceId)!.max.toLocaleString()}{" "}
(limited by {getDepositLimitInfoFor(depositResourceId)!.reason})
</span>
)}
</div>
{renderCapacityPreview("deposit")}
<Button
onClick={handleDeposit}
disabled={isPending || !depositResourceId}
className="w-full"
>
<SelectTrigger>
<SelectValue placeholder="Select a resource..." />
</SelectTrigger>
<SelectContent>
{allResources
.filter((r) => isResourceAllowed(r.id))
.map((resource) => {
const cap = getAllowedDepositCap(resource.id);
{isPending ? (
<Loader2Icon className="size-4 animate-spin mr-2" />
) : (
<ArrowDownToLineIcon className="size-4 mr-2" />
)}
Deposit
</Button>
</TabsContent>
<TabsContent value="withdraw" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2">
<Label>Resource</Label>
<Select
value={withdrawResourceId}
onValueChange={(val) => {
setWithdrawResourceId(val);
const stored = findStoredResource(val);
setWithdrawAmount(clampAmount(withdrawAmount, stored?.amount));
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a resource..." />
</SelectTrigger>
<SelectContent>
{storedResources.map((entry) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return null;
return (
<SelectItem key={resource.id} value={String(resource.id)}>
{resource.name}
{cap !== undefined && (
<span className="text-muted-foreground">
{" "}
— {cap.toLocaleString()} max
</span>
)}
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
{resource.name} ({entry.amount.toLocaleString()}{" "}
{pluralize(entry.amount, resource.unitOfMeasure)})
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Amount</Label>
<Input
type="number"
min="1"
max={getDepositMax()}
value={depositAmount}
onChange={(e) => setDepositAmount(clampAmount(e.target.value, getDepositMax()))}
/>
{getDepositLimitInfoFor(depositResourceId) !== undefined && (
<span className="text-xs text-muted-foreground">
Max:{" "}
{getDepositLimitInfoFor(depositResourceId)!.max.toLocaleString()}{" "}
(limited by{" "}
{getDepositLimitInfoFor(depositResourceId)!.reason})
</span>
)}
</div>
{renderCapacityPreview("deposit")}
<Button
onClick={handleDeposit}
disabled={isPending || !depositResourceId}
className="w-full"
>
{isPending ? (
<Loader2Icon className="size-4 animate-spin mr-2" />
) : (
<ArrowDownToLineIcon className="size-4 mr-2" />
)}
Deposit
</Button>
</TabsContent>
<TabsContent value="withdraw" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2">
<Label>Resource</Label>
<Select
value={withdrawResourceId}
onValueChange={(val) => {
setWithdrawResourceId(val);
const stored = findStoredResource(val);
setWithdrawAmount(clampAmount(withdrawAmount, stored?.amount));
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a resource..." />
</SelectTrigger>
<SelectContent>
{storedResources.map((entry) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return null;
return (
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
{resource.name} ({entry.amount.toLocaleString()} {pluralize(entry.amount, resource.unitOfMeasure)})
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Amount</Label>
<Input
type="number"
min="1"
max={getWithdrawMax()}
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(clampAmount(e.target.value, getWithdrawMax()))}
/>
{getWithdrawMax() !== undefined && (
<span className="text-xs text-muted-foreground">
Max: {getWithdrawMax()!.toLocaleString()} stored
</span>
)}
</div>
{renderCapacityPreview("withdraw")}
<Button
onClick={handleWithdraw}
disabled={isPending || !withdrawResourceId}
className="w-full"
variant="destructive"
>
{isPending ? (
<Loader2Icon className="size-4 animate-spin mr-2" />
) : (
<ArrowUpFromLineIcon className="size-4 mr-2" />
)}
Withdraw
</Button>
</TabsContent>
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2">
<Label>Destination</Label>
<Select
value={transferToId}
onValueChange={(val) => {
setTransferToId(val);
setTransferAmount(
clampAmount(transferAmount, getTransferMaxFor(val)),
);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a structure..." />
</SelectTrigger>
<SelectContent>
{allStructures.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Resource</Label>
<Select
value={transferResourceId}
onValueChange={(val) => {
setTransferResourceId(val);
const stored = findStoredResource(val);
setTransferAmount(clampAmount(transferAmount, stored?.amount));
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a resource..." />
</SelectTrigger>
<SelectContent>
{storedResources.map((entry) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return null;
return (
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
{resource.name} ({entry.amount.toLocaleString()} {pluralize(entry.amount, resource.unitOfMeasure)})
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Amount</Label>
<Input
type="number"
min="1"
max={getTransferMax()}
value={transferAmount}
onChange={(e) => setTransferAmount(clampAmount(e.target.value, getTransferMax()))}
/>
{getTransferMax() !== undefined && (
<span className="text-xs text-muted-foreground">
Max: {getTransferMax()!.toLocaleString()} stored
</span>
)}
{transferToId && transferResourceId && (() => {
const dest = allStructures.find((s) => s.id === Number(transferToId));
const destType = dest?.type;
if (!dest || !destType) return null;
const stored = findStoredResource(transferResourceId);
if (!stored) return null;
const resId = stored.resource.id;
if (isResourceProhibited(destType, resId)) {
return (
<span className="text-xs text-red-400">
Destination prohibits this resource.
</span>
);
}
if (!isResourceWhitelisted(destType, resId)) {
return (
<span className="text-xs text-amber-400">
Destination only accepts items on its allowed storage list — this resource is not permitted.
</span>
);
}
const cap = getResourceStorageCap(destType, resId);
const destStored = getStoredAmountOf(dest, resId);
return (
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Amount</Label>
<Input
type="number"
min="1"
max={getWithdrawMax()}
value={withdrawAmount}
onChange={(e) =>
setWithdrawAmount(clampAmount(e.target.value, getWithdrawMax()))
}
/>
{getWithdrawMax() !== undefined && (
<span className="text-xs text-muted-foreground">
{cap != null
? `Destination allowance: ${Math.max(0, cap - destStored).toLocaleString()} / ${cap.toLocaleString()}`
: "Destination has no per-item storage limit"}
Max: {getWithdrawMax()!.toLocaleString()} stored
</span>
);
})()}
</div>
{renderCapacityPreview("transfer")}
<Button
onClick={handleTransfer}
disabled={isPending || !transferToId || !transferResourceId || getTransferMax() === 0}
className="w-full"
>
{isPending ? (
<Loader2Icon className="size-4 animate-spin mr-2" />
) : (
<ArrowRightLeftIcon className="size-4 mr-2" />
)}
Transfer
</Button>
</TabsContent>
</Tabs>
)}
</div>
{renderCapacityPreview("withdraw")}
<Button
onClick={handleWithdraw}
disabled={isPending || !withdrawResourceId}
className="w-full"
variant="destructive"
>
{isPending ? (
<Loader2Icon className="size-4 animate-spin mr-2" />
) : (
<ArrowUpFromLineIcon className="size-4 mr-2" />
)}
Withdraw
</Button>
</TabsContent>
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2">
<Label>Destination</Label>
<Select
value={transferToId}
onValueChange={(val) => {
setTransferToId(val);
setTransferAmount(clampAmount(transferAmount, getTransferMaxFor(val)));
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a structure..." />
</SelectTrigger>
<SelectContent>
{allStructures.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Resource</Label>
<Select
value={transferResourceId}
onValueChange={(val) => {
setTransferResourceId(val);
const stored = findStoredResource(val);
setTransferAmount(clampAmount(transferAmount, stored?.amount));
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a resource..." />
</SelectTrigger>
<SelectContent>
{storedResources.map((entry) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return null;
return (
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
{resource.name} ({entry.amount.toLocaleString()}{" "}
{pluralize(entry.amount, resource.unitOfMeasure)})
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Amount</Label>
<Input
type="number"
min="1"
max={getTransferMax()}
value={transferAmount}
onChange={(e) =>
setTransferAmount(clampAmount(e.target.value, getTransferMax()))
}
/>
{getTransferMax() !== undefined && (
<span className="text-xs text-muted-foreground">
Max: {getTransferMax()!.toLocaleString()} stored
</span>
)}
{transferToId &&
transferResourceId &&
(() => {
const dest = allStructures.find((s) => s.id === Number(transferToId));
const destType = dest?.type;
if (!dest || !destType) return null;
const stored = findStoredResource(transferResourceId);
if (!stored) return null;
const resId = stored.resource.id;
if (isResourceProhibited(destType, resId)) {
return (
<span className="text-xs text-red-400">
Destination prohibits this resource.
</span>
);
}
if (!isResourceWhitelisted(destType, resId)) {
return (
<span className="text-xs text-amber-400">
Destination only accepts items on its allowed storage list — this
resource is not permitted.
</span>
);
}
const cap = getResourceStorageCap(destType, resId);
const destStored = getStoredAmountOf(dest, resId);
return (
<span className="text-xs text-muted-foreground">
{cap != null
? `Destination allowance: ${Math.max(0, cap - destStored).toLocaleString()} / ${cap.toLocaleString()}`
: "Destination has no per-item storage limit"}
</span>
);
})()}
</div>
{renderCapacityPreview("transfer")}
<Button
onClick={handleTransfer}
disabled={
isPending || !transferToId || !transferResourceId || getTransferMax() === 0
}
className="w-full"
>
{isPending ? (
<Loader2Icon className="size-4 animate-spin mr-2" />
) : (
<ArrowRightLeftIcon className="size-4 mr-2" />
)}
Transfer
</Button>
</TabsContent>
</Tabs>
</>
)}

View file

@ -1,4 +1,4 @@
import { Resource, GameStructure } from "@/payload-types";
import { GameStructure, Resource } from "@/payload-types";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { pluralize } from "@/lib/utils";
import {
@ -10,7 +10,7 @@ import {
ItemSeparator,
ItemTitle,
} from "@/components/ui/item";
import { PackageOpenIcon, DropletIcon, ZapIcon, BrainIcon } from "lucide-react";
import { BrainIcon, DropletIcon, PackageOpenIcon, ZapIcon } from "lucide-react";
interface ResourceListProps {
storedResources: GameStructure["storedResources"];
@ -77,7 +77,7 @@ export function ResourceList({ storedResources, maxCapacityMass }: ResourceListP
<ItemMedia variant="icon">
{resource.icon ? (
<img
src={typeof resource.icon === "object" ? resource.icon.url ?? "" : ""}
src={typeof resource.icon === "object" ? (resource.icon.url ?? "") : ""}
alt={resource.name}
className="size-4 object-contain"
/>
@ -93,18 +93,18 @@ export function ResourceList({ storedResources, maxCapacityMass }: ResourceListP
/>
<span>{resource.name}</span>
<span className="text-xs font-normal text-muted-foreground">
{entry.amount.toLocaleString()} {pluralize(entry.amount, resource.unitOfMeasure)}
{entry.amount.toLocaleString()}{" "}
{pluralize(entry.amount, resource.unitOfMeasure)}
</span>
</ItemTitle>
{description && (
<ItemDescription className="line-clamp-1">
{description}
</ItemDescription>
<ItemDescription className="line-clamp-1">{description}</ItemDescription>
)}
</ItemContent>
<div className="text-right shrink-0">
<div className="text-sm font-medium">
{entry.amount.toLocaleString()} {pluralize(entry.amount, resource.unitOfMeasure)}
{entry.amount.toLocaleString()}{" "}
{pluralize(entry.amount, resource.unitOfMeasure)}
</div>
<div className="text-xs text-muted-foreground">
{mass > 0 && `${mass.toLocaleString()} kg mass`}

View file

@ -8,11 +8,19 @@ import { getCategoryLabel } from "@/lib/logistics";
interface StorageFiltersProps {
categories: Structure["category"][];
factions: Faction[];
onFilterChange: (filters: { category: Structure["category"] | null; factionId: number | null }) => void;
onFilterChange: (filters: {
category: Structure["category"] | null;
factionId: number | null;
}) => void;
className?: string;
}
export function StorageFilters({ categories, factions, onFilterChange, className }: StorageFiltersProps) {
export function StorageFilters({
categories,
factions,
onFilterChange,
className,
}: StorageFiltersProps) {
const [selectedCategory, setSelectedCategory] = useState<Structure["category"] | null>(null);
const [selectedFaction, setSelectedFaction] = useState<number | null>(null);

View file

@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { GameStructure, Faction, Structure } from "@/payload-types";
import { Faction, GameStructure, Structure } from "@/payload-types";
import { StructureCard } from "@/components/frontend/storage/StructureCard";
import { StorageFilters } from "@/components/frontend/storage/StorageFilters";
@ -21,7 +21,11 @@ export function StorageGrid({ structures, categories, factions }: StorageGridPro
if (filters.category && s.type.category !== filters.category) return false;
if (filters.factionId) {
const factionId =
typeof s.faction === "object" && s.faction ? s.faction.id : typeof s.faction === "number" ? s.faction : null;
typeof s.faction === "object" && s.faction
? s.faction.id
: typeof s.faction === "number"
? s.faction
: null;
if (factionId !== filters.factionId) return false;
}
return true;
@ -29,11 +33,7 @@ export function StorageGrid({ structures, categories, factions }: StorageGridPro
return (
<div className="flex flex-col gap-4">
<StorageFilters
categories={categories}
factions={factions}
onFilterChange={setFilters}
/>
<StorageFilters categories={categories} factions={factions} onFilterChange={setFilters} />
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-sm">No structures found.</p>

View file

@ -1,16 +1,6 @@
import { Structure } from "@/payload-types";
import {
Item,
ItemContent,
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
import {
CheckCircleIcon,
XCircleIcon,
ShieldCheckIcon,
ListRestartIcon,
} from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { CheckCircleIcon, ListRestartIcon, ShieldCheckIcon, XCircleIcon } from "lucide-react";
interface StorageRulesProps {
allowedStorage: Structure["allowedStorage"];
@ -42,8 +32,8 @@ export function StorageRules({
<div className="flex items-center gap-2 p-2.5 rounded-md border border-amber-500/30 bg-amber-500/5 text-xs text-amber-400">
<ListRestartIcon className="size-3.5 shrink-0" />
<span>
Whitelist mode — only the items listed under "Allowed" can be
stored here. All other items are rejected.
Whitelist mode — only the items listed under "Allowed" can be stored here. All other
items are rejected.
</span>
</div>
)}
@ -68,9 +58,7 @@ export function StorageRules({
>
<CheckCircleIcon className="size-3" />
{name}
{rule.amount && (
<span className="text-green-400/60">×{rule.amount}</span>
)}
{rule.amount && <span className="text-green-400/60">×{rule.amount}</span>}
</span>
);
})}

View file

@ -11,21 +11,15 @@ import {
} from "@/components/ui/item";
import { CapacityBar } from "@/components/frontend/storage/CapacityBar";
import {
calculateStoredMass,
calculateUtilization,
getCategoryLabel,
getFunctionTypeLabel,
getGridSlots,
getResourceCount,
calculateStoredMass,
calculateUtilization,
} from "@/lib/logistics";
import { GameStructure, Faction, Structure } from "@/payload-types";
import {
BoxIcon,
Building2Icon,
ChevronRightIcon,
Grid3x3Icon,
MapPinIcon,
} from "lucide-react";
import { Faction, GameStructure, Structure } from "@/payload-types";
import { BoxIcon, Building2Icon, ChevronRightIcon, Grid3x3Icon, MapPinIcon } from "lucide-react";
interface StructureCardProps {
structure: GameStructure & {
@ -46,7 +40,10 @@ export function StructureCard({ structure }: StructureCardProps) {
return (
<Link href={`/logistics/structures/${structure.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)]">
<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)]"
>
<ItemHeader>
<div className="flex items-center gap-2">
<ItemMedia variant="icon">
@ -89,11 +86,7 @@ export function StructureCard({ structure }: StructureCardProps) {
{structureType.xSize}×{structureType.ySize} grid
</span>
)}
{functionLabel && (
<span className="flex items-center gap-1">
{functionLabel}
</span>
)}
{functionLabel && <span className="flex items-center gap-1">{functionLabel}</span>}
</div>
{structure.coordinates && (
<span className="flex items-center gap-1">

View file

@ -1,8 +1,8 @@
"use client";
import { useState, useCallback } from "react";
import { useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import { GameStructure, Structure, Resource } from "@/payload-types";
import { GameStructure, Resource, Structure } from "@/payload-types";
import { InventoryGrid } from "./InventoryGrid";
import { GridToolbar } from "./GridToolbar";
import { ResourceList } from "./ResourceList";
@ -13,27 +13,21 @@ import {
ItemContent,
ItemDescription,
ItemHeader,
ItemSeparator,
ItemMedia,
ItemSeparator,
ItemTitle,
} from "@/components/ui/item";
import {
PackageOpenIcon,
AlertTriangleIcon,
DropletIcon,
ZapIcon,
BrainIcon,
} from "lucide-react";
import { AlertTriangleIcon, BrainIcon, DropletIcon, PackageOpenIcon, ZapIcon } from "lucide-react";
import { EventLedger } from "./EventLedger";
import {
moveGridItem,
mergeGridStacks,
rotateGridItem,
removeGridItem,
splitGridStack,
autoArrangeGrid,
mergeGridStacks,
moveGridItem,
removeGridItem,
removeResource,
retrieveFromVoid,
rotateGridItem,
splitGridStack,
} from "@/app/(frontend)/logistics/structures/actions";
function getResourceIcon(type: Resource["type"]) {
@ -216,7 +210,8 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
</ItemTitle>
<ItemDescription className="flex items-center gap-2">
<span>
{voidStorageItems.length} item{voidStorageItems.length !== 1 ? "s" : ""} — these no longer fit in the grid.
{voidStorageItems.length} item{voidStorageItems.length !== 1 ? "s" : ""} — these no
longer fit in the grid.
</span>
<button
type="button"
@ -241,7 +236,7 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
<ItemMedia variant="icon">
{resource.icon ? (
<img
src={typeof resource.icon === "object" ? resource.icon.url ?? "" : ""}
src={typeof resource.icon === "object" ? (resource.icon.url ?? "") : ""}
alt={resource.name}
className="size-4 object-contain"
/>
@ -260,7 +255,8 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
</ItemContent>
<div className="text-right shrink-0">
<div className="text-sm font-medium">
{entry.amount.toLocaleString()} {pluralize(entry.amount, resource.unitOfMeasure)}
{entry.amount.toLocaleString()}{" "}
{pluralize(entry.amount, resource.unitOfMeasure)}
</div>
<div className="text-xs text-muted-foreground">
{mass > 0 && `${mass.toLocaleString()} kg`}