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 /> <AppLogo />
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Classified operations portal for Polaris Task Force, an Arma 3 unit. Classified operations portal for Polaris Task Force, an Arma 3 unit. Log in to access the
Log in to access the operations dashboard, intelligence, and operations dashboard, intelligence, and logistics.
logistics.
</p> </p>
<Button asChild className="mt-2"> <Button asChild className="mt-2">
<Link href="/login">Log in to access the portal</Link> <Link href="/login">Log in to access the portal</Link>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,17 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { Bolt, Send, Shield, Users, UserCog, Warehouse, Crosshair, Backpack, LifeBuoy } from "lucide-react"; import {
Backpack,
Bolt,
Crosshair,
LifeBuoy,
Send,
Shield,
UserCog,
Users,
Warehouse,
} from "lucide-react";
import { NavCore } from "@/components/frontend/blocks/NavCore"; import { NavCore } from "@/components/frontend/blocks/NavCore";
import { NavSecondary } from "@/components/frontend/blocks/NavSecondary"; import { NavSecondary } from "@/components/frontend/blocks/NavSecondary";

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -9,10 +9,7 @@ import {
ItemHeader, ItemHeader,
ItemTitle, ItemTitle,
} from "@/components/ui/item"; } from "@/components/ui/item";
import { import { TicketBadges, TicketCategoryBadge } from "@/components/frontend/helpdesk/TicketBadges";
TicketBadges,
TicketCategoryBadge,
} from "@/components/frontend/helpdesk/TicketBadges";
interface TicketCardProps { interface TicketCardProps {
readonly ticket: Ticket; readonly ticket: Ticket;
@ -50,7 +47,10 @@ export function TicketCard({ ticket }: TicketCardProps) {
return ( return (
<Link href={`/helpdesk/${ticket.id}`} className="group block h-full"> <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"> <ItemHeader className="items-start">
<TicketCategoryBadge category={ticket.category} /> <TicketCategoryBadge category={ticket.category} />
<span className="font-mono text-[10px] text-muted-foreground">#{ticket.id}</span> <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"> <ItemTitle className="line-clamp-2 min-w-0 max-w-full text-base font-semibold">
{ticket.title} {ticket.title}
</ItemTitle> </ItemTitle>
<ItemDescription> <ItemDescription>{description || "No description provided."}</ItemDescription>
{description || "No description provided."}
</ItemDescription>
<TicketBadges ticket={ticket} /> <TicketBadges ticket={ticket} />
</ItemContent> </ItemContent>
<ItemFooter className="w-full flex-wrap items-end"> <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 { Button } from "@/components/ui/button";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { Label } from "@/components/ui/label"; 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 { Textarea } from "@/components/ui/textarea";
import { TicketBadges, TicketCategoryBadge } from "@/components/frontend/helpdesk/TicketBadges"; import { TicketBadges, TicketCategoryBadge } from "@/components/frontend/helpdesk/TicketBadges";
import { TicketTimeline } from "@/components/frontend/helpdesk/TicketTimeline"; import { TicketTimeline } from "@/components/frontend/helpdesk/TicketTimeline";
import { replyToTicket, updateTicketStatus, assignTicket } from "@/app/(frontend)/helpdesk/actions"; import { assignTicket, replyToTicket, updateTicketStatus } from "@/app/(frontend)/helpdesk/actions";
import { import { CLOSING_STATUSES, TICKET_STATUSES, type TicketStatus } from "@/lib/tickets/ticketMeta";
CLOSING_STATUSES,
TICKET_STATUSES,
type TicketStatus,
} from "@/lib/tickets/ticketMeta";
import { CalendarClockIcon, HistoryIcon, SendIcon, Settings2Icon } from "lucide-react"; import { CalendarClockIcon, HistoryIcon, SendIcon, Settings2Icon } from "lucide-react";
interface AssigneeOption { interface AssigneeOption {
@ -37,7 +39,7 @@ type UserRelation = number | User | null | undefined;
type ControlKind = "status" | "assignee"; type ControlKind = "status" | "assignee";
function relationId(relation: UserRelation): number | null { 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 { 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 }) { function MetaItem({ label, value }: { readonly label: string; readonly value: string }) {
return ( return (
<div className="flex min-w-0 flex-col gap-1"> <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> <span className="truncate text-sm">{value}</span>
</div> </div>
); );
@ -89,13 +93,17 @@ export function TicketDetail({
const [cancelBusy, setCancelBusy] = useState(false); const [cancelBusy, setCancelBusy] = useState(false);
const cancelTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const cancelTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => { useEffect(
() => () => {
if (cancelTimerRef.current) clearTimeout(cancelTimerRef.current); if (cancelTimerRef.current) clearTimeout(cancelTimerRef.current);
}, []); },
[],
);
const reporterName = displayName(ticket.reporter, "Unknown reporter"); const reporterName = displayName(ticket.reporter, "Unknown reporter");
const assigneeName = displayName(ticket.assignee, "Unassigned"); 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 }); const descriptionText = convertLexicalToPlaintext({ data: ticket.description });
async function applyControl(kind: ControlKind) { async function applyControl(kind: ControlKind) {
@ -170,58 +178,169 @@ export function TicketDetail({
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<div className="flex flex-wrap items-start justify-between gap-3"> <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> <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>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 rounded-md border bg-muted/20 p-4 sm:grid-cols-4"> <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="Reporter" value={reporterName} />
<MetaItem label="Created" value={formatDate(ticket.createdAt)} /><MetaItem label="Updated" value={formatDate(ticket.updatedAt)} /> <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)} /> <MetaItem label="Closed" value={formatDate(ticket.closedAt)} />
</div> </div>
<div className="flex flex-col gap-2"> <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"> <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>
</div> </div>
</section> </section>
{isStaff && ( {isStaff && (
<Item variant="outline" className="bg-muted/20"> <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"> <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"> <div className="flex flex-col gap-1.5">
<Select value={status} onValueChange={(value) => { if (isTicketStatus(value)) setStatus(value); }} disabled={controlBusy !== null}> <Label htmlFor="ticket-status">Status</Label>
<SelectTrigger id="ticket-status" className="min-w-0 flex-1"><SelectValue /></SelectTrigger> <div className="flex gap-2">
<SelectContent>{TICKET_STATUSES.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}</SelectContent> <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> </Select>
<Button size="sm" onClick={() => void applyControl("status")} disabled={controlBusy !== null}>{controlBusy === "status" ? "Applying..." : "Apply"}</Button> <Button
</div></div> size="sm"
<div className="flex flex-col gap-1.5"><Label htmlFor="ticket-assignee">Assignee</Label><div className="flex gap-2"> onClick={() => void applyControl("status")}
<Select value={assignee} onValueChange={setAssignee} disabled={controlBusy !== null}> 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> {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> </Select>
<Button size="sm" onClick={() => void applyControl("assignee")} disabled={controlBusy !== null}>{controlBusy === "assignee" ? "Applying..." : "Apply"}</Button> <Button
</div></div> size="sm"
{controlError && <p role="alert" className="text-sm text-red-400 sm:col-span-2">{controlError}</p>} 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> </ItemContent>
</Item> </Item>
)} )}
<section className="flex flex-col gap-3"> <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> <div className="flex items-center gap-2">
<Item variant="outline" className="bg-muted/20"><ItemContent className="w-full"><TicketTimeline ticket={ticket} currentUserId={currentUserId} /></ItemContent></Item> <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> </section>
<Item variant="outline"> <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"> <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} /> <Textarea
<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> 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> </ItemContent>
</Item> </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> </div>
); );
} }

View file

@ -18,7 +18,7 @@ function resolveUser(relation: UserRelation): User | null {
} }
function userId(relation: UserRelation): number | 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 { 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 { cn } from "@/lib/utils";
import { ClockIcon, CrosshairIcon, UsersIcon } from "lucide-react"; import { ClockIcon, CrosshairIcon, UsersIcon } from "lucide-react";
interface CampaignCardCampaign interface CampaignCardCampaign extends Pick<
extends Pick<Campaign, "id" | "name" | "summary" | "status" | "startDate" | "endDate"> { Campaign,
"id" | "name" | "summary" | "status" | "startDate" | "endDate"
> {
readonly coverImageUrl: string | null; readonly coverImageUrl: string | null;
readonly missionCount: number; readonly missionCount: number;
readonly avgPlayers: number; readonly avgPlayers: number;

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +1,8 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState } from "react";
import type { Campaign } from "@/payload-types"; import type { Campaign } from "@/payload-types";
import { MissionCard } from "@/components/frontend/intelligence/MissionCard";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface MissionFiltersProps { interface MissionFiltersProps {
campaigns: Campaign[]; campaigns: Campaign[];
@ -21,20 +19,9 @@ const operationTypes = [
{ label: "External", value: "external" }, { label: "External", value: "external" },
]; ];
const statuses = [ const statuses = ["Concept", "Planning", "Ready", "Scheduled", "Active", "Completed", "Cancelled"];
"Concept",
"Planning",
"Ready",
"Scheduled",
"Active",
"Completed",
"Cancelled",
];
export function MissionFilters({ export function MissionFilters({ campaigns, onFilterChange }: MissionFiltersProps) {
campaigns,
onFilterChange,
}: MissionFiltersProps) {
const [campaignId, setCampaignId] = useState<number | null>(null); const [campaignId, setCampaignId] = useState<number | null>(null);
const [operationType, setOperationType] = useState<string | null>(null); const [operationType, setOperationType] = useState<string | null>(null);
const [status, setStatus] = 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", external: "bg-purple-500/20 text-purple-400",
}; };
export function MissionHeader({ export function MissionHeader({ name, codeName, operationType, status }: MissionHeaderProps) {
name,
codeName,
operationType,
status,
}: MissionHeaderProps) {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,12 +15,12 @@ import {
import { Wardrobe } from "./Wardrobe"; import { Wardrobe } from "./Wardrobe";
import { WardrobeSlotPicker } from "./WardrobeSlotPicker"; import { WardrobeSlotPicker } from "./WardrobeSlotPicker";
import { import {
WARDROBE_SLOT_LAYOUT,
getAssetTypeLabel, getAssetTypeLabel,
getWardrobeSlot,
getLoadoutSlot, getLoadoutSlot,
getWardrobeSlot,
type LoadoutSlots, type LoadoutSlots,
type LockerGridItem, type LockerGridItem,
WARDROBE_SLOT_LAYOUT,
} from "@/lib/locker"; } from "@/lib/locker";
import { equipLoadout, setLoadoutSlot } from "@/app/(frontend)/locker/actions"; import { equipLoadout, setLoadoutSlot } from "@/app/(frontend)/locker/actions";
import { getAssetIcon } from "./LockerGrid"; import { getAssetIcon } from "./LockerGrid";
@ -75,7 +75,7 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
const pickerLayout = pickerSlot ? getWardrobeSlot(pickerSlot) : null; const pickerLayout = pickerSlot ? getWardrobeSlot(pickerSlot) : null;
const pickerConfig = pickerSlot ? getLoadoutSlot(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 const filled = equippedLoadout
? WARDROBE_SLOT_LAYOUT.filter((layout) => slots[layout.name] != null) ? WARDROBE_SLOT_LAYOUT.filter((layout) => slots[layout.name] != null)
@ -89,9 +89,7 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<Wardrobe <Wardrobe
loadout={ loadout={equippedLoadout ?? ({ name: "Empty", slots: {} } as unknown as Loadout)}
equippedLoadout ?? ({ name: "Empty", slots: {} } as unknown as Loadout)
}
items={items} items={items}
onSlotClick={(name) => setPickerSlot(name)} onSlotClick={(name) => setPickerSlot(name)}
/> />
@ -124,7 +122,9 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
> >
<SelectTrigger className="flex-1"> <SelectTrigger className="flex-1">
<SelectValue <SelectValue
placeholder={equippedLoadout ? equippedLoadout.name : "Select a loadout to equip"} placeholder={
equippedLoadout ? equippedLoadout.name : "Select a loadout to equip"
}
/> />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -149,7 +149,8 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
{!equippedLoadout && ( {!equippedLoadout && (
<p className="text-sm text-muted-foreground"> <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> </p>
)} )}
</div> </div>
@ -178,7 +179,9 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
onClick={() => setPickerSlot(layout.name)} 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" 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="flex-1 min-w-0">
<span className="block text-xs text-muted-foreground">{layout.label}</span> <span className="block text-xs text-muted-foreground">{layout.label}</span>
<span className="block font-medium truncate"> <span className="block font-medium truncate">

View file

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

View file

@ -86,15 +86,12 @@ const rarityLabels: Record<string, string> = {
}; };
export function ResourceCard({ resource }: ResourceCardProps) { export function ResourceCard({ resource }: ResourceCardProps) {
const icon = const icon = resource.icon && typeof resource.icon === "object" ? resource.icon : null;
resource.icon && typeof resource.icon === "object" ? resource.icon : null;
const description = resource.description const description = resource.description
? convertLexicalToPlaintext({ data: resource.description }) ? convertLexicalToPlaintext({ data: resource.description })
: null; : null;
const truncatedDescription = const truncatedDescription =
description && description.length > 150 description && description.length > 150 ? description.slice(0, 150) + "…" : description;
? description.slice(0, 150) + "…"
: description;
return ( return (
<Link href={`/logistics/resources/${resource.id}`}> <Link href={`/logistics/resources/${resource.id}`}>
@ -106,11 +103,7 @@ export function ResourceCard({ resource }: ResourceCardProps) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{icon ? ( {icon ? (
<ItemMedia variant="image"> <ItemMedia variant="image">
<img <img src={icon.url} alt={resource.name} className="size-full object-cover" />
src={icon.url}
alt={resource.name}
className="size-full object-cover"
/>
</ItemMedia> </ItemMedia>
) : resource.color ? ( ) : resource.color ? (
<ItemMedia variant="icon"> <ItemMedia variant="icon">
@ -123,9 +116,7 @@ export function ResourceCard({ resource }: ResourceCardProps) {
<ItemMedia variant="icon">{typeIcons[resource.type]}</ItemMedia> <ItemMedia variant="icon">{typeIcons[resource.type]}</ItemMedia>
)} )}
<ItemTitle className="flex flex-col"> <ItemTitle className="flex flex-col">
<span className="font-mono text-xs text-muted-foreground"> <span className="font-mono text-xs text-muted-foreground">{resource.codeName}</span>
{resource.codeName}
</span>
<span>{resource.name}</span> <span>{resource.name}</span>
</ItemTitle> </ItemTitle>
</div> </div>
@ -145,9 +136,7 @@ export function ResourceCard({ resource }: ResourceCardProps) {
<ItemContent> <ItemContent>
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
{truncatedDescription && ( {truncatedDescription && (
<p className="text-sm text-muted-foreground line-clamp-2"> <p className="text-sm text-muted-foreground line-clamp-2">{truncatedDescription}</p>
{truncatedDescription}
</p>
)} )}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
<span className="flex items-center gap-1 font-medium text-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 [error, setError] = useState<string | null>(null);
const [showCancelDialog, setShowCancelDialog] = useState(false); const [showCancelDialog, setShowCancelDialog] = useState(false);
const isActive = const isActive = shipment.status === "dispatched" || shipment.status === "in_transit";
shipment.status === "dispatched" || shipment.status === "in_transit";
if (!isActive) return null; if (!isActive) return null;
@ -49,10 +48,7 @@ export function ShipmentActions({ shipment }: ShipmentActionsProps) {
setLoading(true); setLoading(true);
setError(null); setError(null);
const result = await toggleAutoReturn( const result = await toggleAutoReturn(shipment.id, !shipment.autoReturn);
shipment.id,
!shipment.autoReturn,
);
if (result.success) { if (result.success) {
router.refresh(); router.refresh();
} else { } else {
@ -63,15 +59,8 @@ export function ShipmentActions({ shipment }: ShipmentActionsProps) {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{error && ( {error && <span className="text-xs text-red-400">{error}</span>}
<span className="text-xs text-red-400">{error}</span> <Button variant="outline" size="sm" onClick={handleToggleAutoReturn} disabled={loading}>
)}
<Button
variant="outline"
size="sm"
onClick={handleToggleAutoReturn}
disabled={loading}
>
<RotateCcwIcon className="size-3.5 mr-1.5" /> <RotateCcwIcon className="size-3.5 mr-1.5" />
{shipment.autoReturn ? "Disable" : "Enable"} Auto-Return {shipment.autoReturn ? "Disable" : "Enable"} Auto-Return
</Button> </Button>
@ -86,23 +75,15 @@ export function ShipmentActions({ shipment }: ShipmentActionsProps) {
<DialogHeader> <DialogHeader>
<DialogTitle>Cancel Shipment</DialogTitle> <DialogTitle>Cancel Shipment</DialogTitle>
<DialogDescription> <DialogDescription>
Are you sure you want to cancel this shipment? All cargo will be Are you sure you want to cancel this shipment? All cargo will be returned to the
returned to the origin structure and the vehicle will be parked origin structure and the vehicle will be parked at the origin.
at the origin.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button <Button variant="outline" onClick={() => setShowCancelDialog(false)}>
variant="outline"
onClick={() => setShowCancelDialog(false)}
>
Keep Shipment Keep Shipment
</Button> </Button>
<Button <Button variant="destructive" onClick={handleCancel} disabled={loading}>
variant="destructive"
onClick={handleCancel}
disabled={loading}
>
{loading ? "Cancelling..." : "Cancel Shipment"} {loading ? "Cancelling..." : "Cancel Shipment"}
</Button> </Button>
</DialogFooter> </DialogFooter>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,7 +6,16 @@ import type { Asset, MarketListing } from "@/payload-types";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; 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 { getAssetTypeLabel } from "@/lib/locker";
import { getRarityLabel, isNegotiable } from "@/lib/market"; import { getRarityLabel, isNegotiable } from "@/lib/market";
import { formatAmount } from "@/lib/banking/format"; import { formatAmount } from "@/lib/banking/format";
@ -42,7 +51,12 @@ function sellerName(listing: MarketListing): string {
return npcLabel(listing); return npcLabel(listing);
} }
export function ListingCard({ listing, currentUserId, currencyLabel, canManage }: ListingCardProps) { export function ListingCard({
listing,
currentUserId,
currencyLabel,
canManage,
}: ListingCardProps) {
const router = useRouter(); const router = useRouter();
const [busy, setBusy] = useState<"buy" | "cancel" | null>(null); const [busy, setBusy] = useState<"buy" | "cancel" | null>(null);
const [error, setError] = useState<string | 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 asset = listing.asset as Asset;
const assetName = typeof asset === "object" && asset ? asset.name : `Asset #${listing.asset}`; const assetName = typeof asset === "object" && asset ? asset.name : `Asset #${listing.asset}`;
const rarity = typeof asset === "object" && asset ? getRarityLabel(asset) : null; 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 stock = listing.quantity ?? 1;
const negotiable = isNegotiable(listing); const negotiable = isNegotiable(listing);
const isOwn = (() => { const isOwn = (() => {
@ -170,7 +185,11 @@ export function ListingCard({ listing, currentUserId, currencyLabel, canManage }
{busy === "buy" ? "Buying..." : stock > 1 ? `Buy ${selectedQty}` : "Buy"} {busy === "buy" ? "Buying..." : stock > 1 ? `Buy ${selectedQty}` : "Buy"}
</Button> </Button>
{negotiable && !isOwn && ( {negotiable && !isOwn && (
<MakeOfferDialog listing={listing} currentUserId={currentUserId} currencyLabel={currencyLabel} /> <MakeOfferDialog
listing={listing}
currentUserId={currentUserId}
currencyLabel={currencyLabel}
/>
)} )}
</div> </div>
{canCancel && ( {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); setBusy(key);
setError(null); setError(null);
const res = await action(); const res = await action();
@ -278,9 +281,7 @@ export function MakeOfferDialog({ listing, currentUserId, currencyLabel }: MakeO
)} )}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{stock}× in stock · total{" "} {stock}× in stock · total{" "}
<span className="font-mono"> <span className="font-mono">{fmt((Number(amount) || 0) * clampedQty)}</span>
{fmt((Number(amount) || 0) * clampedQty)}
</span>
</p> </p>
{error && <p className="text-sm text-red-400">{error}</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" && ( {outcome?.kind === "pending" && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Offer of <span className="font-mono">{fmt(negotiation?.amount ?? 0)}</span> each Offer of <span className="font-mono">{fmt(negotiation?.amount ?? 0)}</span> each for{" "}
for {negotiation?.quantity ?? stock}× sent. Waiting for the{" "} {negotiation?.quantity ?? stock}× sent. Waiting for the{" "}
{isVendor ? "vendor" : "seller"} to respond. {isVendor ? "vendor" : "seller"} to respond.
</p> </p>
{error && <p className="text-sm text-red-400">{error}</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}> <Button variant="outline" onClick={() => setOpen(false)} disabled={acceptLocked}>
{acceptLocked ? `Closing in ${acceptRemaining}s…` : "Close"} {acceptLocked ? `Closing in ${acceptRemaining}s…` : "Close"}
</Button> </Button>

View file

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

View file

@ -17,7 +17,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
import { formatAmount } from "@/lib/banking/format"; import { formatAmount } from "@/lib/banking/format";
import { buildChatBubbles } from "@/lib/market/chatBubbles"; import { buildChatBubbles } from "@/lib/market/chatBubbles";
import { ChatBubbleList } from "@/components/frontend/market/ChatBubbleList"; import { ChatBubbleList } from "@/components/frontend/market/ChatBubbleList";
import { HistoryIcon, ChevronDownIcon } from "lucide-react"; import { ChevronDownIcon, HistoryIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const STATUS_LABELS: Record<string, string> = { 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}`; return typeof asset === "object" && asset ? asset.name : `Asset #${listing.asset}`;
} }
function counterpartyLabel( function counterpartyLabel(negotiation: MarketNegotiation, role: "buyer" | "seller"): string {
negotiation: MarketNegotiation,
role: "buyer" | "seller",
): string {
if (role === "buyer") { if (role === "buyer") {
const listing = listingOf(negotiation); const listing = listingOf(negotiation);
if (listing?.isAutoGenerated) { if (listing?.isAutoGenerated) {
@ -55,9 +52,11 @@ function counterpartyLabel(
} }
const seller = listing?.seller as unknown; const seller = listing?.seller as unknown;
if (typeof seller === "object" && seller && "username" in (seller as object)) { 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 as { username?: string }).username ||
"Seller"; "Seller"
);
} }
return "Seller"; return "Seller";
} }
@ -128,7 +127,9 @@ function NegotiationRow({
{counterpartyLabel(negotiation, role)} · {listing?.quantity ?? "?"}× in stock {counterpartyLabel(negotiation, role)} · {listing?.quantity ?? "?"}× in stock
</span> </span>
</div> </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>
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
@ -223,7 +224,11 @@ interface NegotiationPanelProps {
currencyLabel: string | null; 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 open = negotiations.filter((n) => n.status === "open");
const closed = 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) => { {open.map((negotiation) => {
const role: "buyer" | "seller" = const role: "buyer" | "seller" =
negotiation.buyer && negotiation.buyer &&
(typeof negotiation.buyer === "object" (typeof negotiation.buyer === "object" ? negotiation.buyer.id : negotiation.buyer) ===
? negotiation.buyer.id currentUserId
: negotiation.buyer) === currentUserId
? "buyer" ? "buyer"
: "seller"; : "seller";
return ( return (
@ -276,7 +280,8 @@ export function NegotiationPanel({ negotiations, currentUserId, currencyLabel }:
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{closed.slice(0, 6).map((negotiation) => ( {closed.slice(0, 6).map((negotiation) => (
<Badge key={negotiation.id} variant="secondary" className="font-normal"> <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> </Badge>
))} ))}
{closed.length > 6 && ( {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={cn("flex flex-col gap-1.5", className)}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Vendor patience</span> <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}% {value}%
</span> </span>
</div> </div>

View file

@ -2,9 +2,9 @@
import Link from "next/link"; import Link from "next/link";
import { cn, pluralize } from "@/lib/utils"; 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 { 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> = { const TYPE_ORDER: Record<string, number> = {
division: 0, 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={cn("flex flex-col gap-1", className)}>
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Capacity</span> <span className="text-muted-foreground">Capacity</span>
<span className={cn("font-medium", colorClass)}> <span className={cn("font-medium", colorClass)}>{Math.round(utilization)}%</span>
{Math.round(utilization)}%
</span>
</div> </div>
<Progress value={utilization} className={cn("h-1.5", progressClass)} /> <Progress value={utilization} className={cn("h-1.5", progressClass)} />
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="flex items-center justify-between text-xs text-muted-foreground">

View file

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

View file

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

View file

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

View file

@ -1,27 +1,35 @@
"use client"; "use client";
import { useState, useCallback, useMemo, useRef, useEffect } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
DndContext, DndContext,
type DragEndEvent,
type DragMoveEvent,
DragOverlay, DragOverlay,
type DragStartEvent,
PointerSensor, PointerSensor,
useSensor, useSensor,
useSensors, useSensors,
type DragStartEvent,
type DragMoveEvent,
type DragEndEvent,
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { GameStructure, Structure, Resource } from "@/payload-types"; import { GameStructure, Resource, Structure } from "@/payload-types";
import { import {
getItemFootprint,
isValidPlacement,
getGridOccupancy, getGridOccupancy,
getItemFootprint,
type GridItem, type GridItem,
isValidPlacement,
} from "@/lib/logistics"; } from "@/lib/logistics";
import { GridItemComponent } from "./GridItem"; import { GridItemComponent } from "./GridItem";
import { GridCell } from "./GridCell"; 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 resource = item.resource as Resource;
const effective = rotated ?? item.rotated; const effective = rotated ?? item.rotated;
const { width, height } = getItemFootprint(resource, effective); 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 effective = rotated ?? dragged.rotated;
const fp = getItemFootprint(dragged.resource as Resource, effective); const fp = getItemFootprint(dragged.resource as Resource, effective);
const targetCells = new Set<string>(); const targetCells = new Set<string>();
@ -172,8 +186,16 @@ export function InventoryGrid({
cells.add(`${hoverPosition.x + dx},${hoverPosition.y + dy}`); cells.add(`${hoverPosition.x + dx},${hoverPosition.y + dy}`);
} }
} }
const mergeTarget = findMergeTarget(storedResources, activeItem, hoverPosition.x, hoverPosition.y, pendingRotated); const mergeTarget = findMergeTarget(
const valid = mergeTarget !== null || isValidPlacement( storedResources,
activeItem,
hoverPosition.x,
hoverPosition.y,
pendingRotated,
);
const valid =
mergeTarget !== null ||
isValidPlacement(
storedResources, storedResources,
activeItem.id, activeItem.id,
hoverPosition.x, hoverPosition.x,
@ -186,8 +208,7 @@ export function InventoryGrid({
return { cells, valid }; return { cells, valid };
}, [hoverPosition, activeItem, storedResources, gridWidth, gridHeight, pendingRotated]); }, [hoverPosition, activeItem, storedResources, gridWidth, gridHeight, pendingRotated]);
const handleDragStart = useCallback( const handleDragStart = useCallback((event: DragStartEvent) => {
(event: DragStartEvent) => {
const data = event.active.data.current; const data = event.active.data.current;
if (data?.item) { if (data?.item) {
const item = data.item as GridItem; const item = data.item as GridItem;
@ -195,9 +216,7 @@ export function InventoryGrid({
setPendingRotated(item.rotated ?? false); setPendingRotated(item.rotated ?? false);
} }
setHoverPosition(null); setHoverPosition(null);
}, }, []);
[],
);
const handleDragMove = useCallback( const handleDragMove = useCallback(
(event: DragMoveEvent) => { (event: DragMoveEvent) => {

View file

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

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useTransition, useCallback } from "react"; import { useCallback, useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
Dialog, Dialog,
@ -31,11 +31,11 @@ import {
isResourceProhibited, isResourceProhibited,
isResourceWhitelisted, isResourceWhitelisted,
} from "@/lib/storageRules"; } from "@/lib/storageRules";
import { GameStructure, Structure, Resource, GameVehicle, Vehicle } from "@/payload-types"; import { GameStructure, GameVehicle, Resource, Structure, Vehicle } from "@/payload-types";
import { import {
ArrowDownToLineIcon, ArrowDownToLineIcon,
ArrowUpFromLineIcon,
ArrowRightLeftIcon, ArrowRightLeftIcon,
ArrowUpFromLineIcon,
Loader2Icon, Loader2Icon,
TruckIcon, TruckIcon,
} from "lucide-react"; } from "lucide-react";
@ -48,21 +48,13 @@ interface ManageStorageDialogProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
export function ManageStorageDialog({ export function ManageStorageDialog({ structureId, open, onOpenChange }: ManageStorageDialogProps) {
structureId, const [structure, setStructure] = useState<(GameStructure & { type: Structure }) | null>(null);
open, const [allStructures, setAllStructures] = useState<(GameStructure & { type: Structure })[]>([]);
onOpenChange,
}: ManageStorageDialogProps) {
const [structure, setStructure] = useState<
(GameStructure & { type: Structure }) | null
>(null);
const [allStructures, setAllStructures] = useState<
(GameStructure & { type: Structure })[]
>([]);
const [allResources, setAllResources] = useState<Resource[]>([]); const [allResources, setAllResources] = useState<Resource[]>([]);
const [availableVehicles, setAvailableVehicles] = useState< const [availableVehicles, setAvailableVehicles] = useState<(GameVehicle & { type: Vehicle })[]>(
(GameVehicle & { type: Vehicle })[] [],
>([]); );
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const router = useRouter(); const router = useRouter();
@ -91,7 +83,9 @@ export function ManageStorageDialog({
fetch(`/api/game-structures/${structureId}?depth=2`), fetch(`/api/game-structures/${structureId}?depth=2`),
fetch(`/api/game-structures?depth=2&limit=500`), fetch(`/api/game-structures?depth=2&limit=500`),
fetch(`/api/resources?depth=0&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( const anyUnauthorized = [structRes, allRes, resourcesRes, vehiclesRes].some(
@ -111,9 +105,7 @@ export function ManageStorageDialog({
const vehiclesData = await vehiclesRes.json(); const vehiclesData = await vehiclesRes.json();
setStructure(structData); setStructure(structData);
setAllStructures( setAllStructures(
allData.docs.filter( allData.docs.filter((s: GameStructure & { type: Structure }) => s.id !== structureId),
(s: GameStructure & { type: Structure }) => s.id !== structureId,
),
); );
setAllResources(resourcesData.docs ?? []); setAllResources(resourcesData.docs ?? []);
setAvailableVehicles(vehiclesData.docs ?? []); setAvailableVehicles(vehiclesData.docs ?? []);
@ -177,18 +169,12 @@ export function ManageStorageDialog({
): number => { ): number => {
const grid = (s.storedResources ?? []).reduce((sum, e) => { const grid = (s.storedResources ?? []).reduce((sum, e) => {
const res = e.resource; const res = e.resource;
const rid = const rid = typeof res === "object" && res !== null && "id" in res ? res.id : (res as number);
typeof res === "object" && res !== null && "id" in res
? res.id
: (res as number);
return sum + (rid === resourceId ? e.amount : 0); return sum + (rid === resourceId ? e.amount : 0);
}, 0); }, 0);
const voidAmount = (s.voidStorage ?? []).reduce((sum, e) => { const voidAmount = (s.voidStorage ?? []).reduce((sum, e) => {
const res = e.resource; const res = e.resource;
const rid = const rid = typeof res === "object" && res !== null && "id" in res ? res.id : (res as number);
typeof res === "object" && res !== null && "id" in res
? res.id
: (res as number);
return sum + (rid === resourceId ? e.amount : 0); return sum + (rid === resourceId ? e.amount : 0);
}, 0); }, 0);
return grid + voidAmount; return grid + voidAmount;
@ -230,15 +216,11 @@ export function ManageStorageDialog({
reasons.push({ value: allowance, label: "storage allowance" }); reasons.push({ value: allowance, label: "storage allowance" });
} }
if (reasons.length === 0) return undefined; if (reasons.length === 0) return undefined;
const binding = reasons.reduce( const binding = reasons.reduce((min, r) => (r.value < min.value ? r : min), reasons[0]);
(min, r) => (r.value < min.value ? r : min),
reasons[0],
);
return { max: binding.value, reason: binding.label }; return { max: binding.value, reason: binding.label };
}; };
const getDepositMax = (): number | undefined => const getDepositMax = (): number | undefined => getDepositLimitInfoFor(depositResourceId)?.max;
getDepositLimitInfoFor(depositResourceId)?.max;
const getWithdrawMax = (): number | undefined => { const getWithdrawMax = (): number | undefined => {
const stored = findStoredResource(withdrawResourceId); const stored = findStoredResource(withdrawResourceId);
@ -263,9 +245,7 @@ export function ManageStorageDialog({
if (destMaxMass != null) { if (destMaxMass != null) {
const massPerUnit = stored.resource.massPerUnit ?? 0; const massPerUnit = stored.resource.massPerUnit ?? 0;
if (massPerUnit > 0) { if (massPerUnit > 0) {
caps.push( caps.push(Math.floor((destMaxMass - getMassOf(dest)) / massPerUnit));
Math.floor((destMaxMass - getMassOf(dest)) / massPerUnit),
);
} }
} }
return Math.min(...caps); return Math.min(...caps);
@ -273,8 +253,7 @@ export function ManageStorageDialog({
return stored.amount; return stored.amount;
}; };
const getTransferMax = (): number | undefined => const getTransferMax = (): number | undefined => getTransferMaxFor(transferToId);
getTransferMaxFor(transferToId);
const clampAmount = (value: string, max: number | undefined): string => { const clampAmount = (value: string, max: number | undefined): string => {
const num = Number(value); const num = Number(value);
@ -333,13 +312,19 @@ export function ManageStorageDialog({
}; };
return ( return (
<div className={cn( <div
className={cn(
"flex flex-col gap-1.5 p-3 rounded-md border", "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", isOverCapacity ? "border-red-500/30 bg-red-500/5" : "border-border bg-muted/30",
)}> )}
>
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground"> <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>
<span className={cn("font-medium", getColor(previewPercent))}> <span className={cn("font-medium", getColor(previewPercent))}>
{Math.round(previewPercent)}% {Math.round(previewPercent)}%
@ -347,9 +332,12 @@ export function ManageStorageDialog({
</div> </div>
<Progress value={previewPercent} className={cn("h-1.5", getBarColor(previewPercent))} /> <Progress value={previewPercent} className={cn("h-1.5", getBarColor(previewPercent))} />
<div className="flex items-center justify-between text-xs text-muted-foreground"> <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")}> <span className={cn(isOverCapacity && "text-red-400 font-medium")}>
{delta >= 0 ? "+" : ""}{delta.toLocaleString()} kg {delta >= 0 ? "+" : ""}
{delta.toLocaleString()} kg
{isOverCapacity && " (over capacity!)"} {isOverCapacity && " (over capacity!)"}
</span> </span>
</div> </div>
@ -474,9 +462,7 @@ export function ManageStorageDialog({
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Manage Storage</DialogTitle> <DialogTitle>Manage Storage</DialogTitle>
<DialogDescription> <DialogDescription>{structure?.name ?? "Loading..."}</DialogDescription>
{structure?.name ?? "Loading..."}
</DialogDescription>
</DialogHeader> </DialogHeader>
{loading ? ( {loading ? (
@ -505,7 +491,8 @@ export function ManageStorageDialog({
<div className="flex justify-between"> <div className="flex justify-between">
<span>Cargo</span> <span>Cargo</span>
<span className="font-medium text-foreground"> <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> </span>
</div> </div>
</div> </div>
@ -531,14 +518,23 @@ 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"> <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" /> <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>
)} )}
</div> </div>
{shipmentVehicleId && (() => { {shipmentVehicleId &&
const selectedVehicle = availableVehicles.find((v) => v.id === Number(shipmentVehicleId)); (() => {
const vType = selectedVehicle && typeof selectedVehicle.type === "object" ? selectedVehicle.type : null; const selectedVehicle = availableVehicles.find(
(v) => v.id === Number(shipmentVehicleId),
);
const vType =
selectedVehicle && typeof selectedVehicle.type === "object"
? selectedVehicle.type
: null;
if (!selectedVehicle || !vType) return null; if (!selectedVehicle || !vType) return null;
const destStructure = allStructures.find((s) => s.id === Number(shipmentDestId)); const destStructure = allStructures.find((s) => s.id === Number(shipmentDestId));
@ -547,14 +543,14 @@ export function ManageStorageDialog({
let distance = 0; let distance = 0;
if (originCoords && destCoords) { if (originCoords && destCoords) {
distance = Math.sqrt( distance = Math.sqrt(
(destCoords[0] - originCoords[0]) ** 2 + (destCoords[0] - originCoords[0]) ** 2 + (destCoords[1] - originCoords[1]) ** 2,
(destCoords[1] - originCoords[1]) ** 2,
); );
} }
const consumptionRate = vType.fuel?.fuelConsumptionRate ?? 1; const consumptionRate = vType.fuel?.fuelConsumptionRate ?? 1;
const fuelCost = Math.ceil(distance * consumptionRate); const fuelCost = Math.ceil(distance * consumptionRate);
const speed = vType.maxSpeedOnRoad ?? vType.maxSpeedOffRoad ?? vType.maxAirspeed ?? 50; const speed =
vType.maxSpeedOnRoad ?? vType.maxSpeedOffRoad ?? vType.maxAirspeed ?? 50;
const transitHours = distance / speed; const transitHours = distance / speed;
const canAfford = (selectedVehicle.currentFuel ?? 0) >= fuelCost; const canAfford = (selectedVehicle.currentFuel ?? 0) >= fuelCost;
@ -581,12 +577,17 @@ export function ManageStorageDialog({
</div> </div>
<div className="flex justify-between text-muted-foreground"> <div className="flex justify-between text-muted-foreground">
<span>Vehicle Fuel</span> <span>Vehicle Fuel</span>
<span className="font-mono">{selectedVehicle.currentFuel?.toFixed(1)} / {vType.fuel?.fuelCapacity}</span> <span className="font-mono">
{selectedVehicle.currentFuel?.toFixed(1)} / {vType.fuel?.fuelCapacity}
</span>
</div> </div>
</div> </div>
{!canAfford && ( {!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"> <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> <span>
Vehicle does not have enough fuel for this trip. Need {fuelCost} units,
has {selectedVehicle.currentFuel?.toFixed(1)}.
</span>
</div> </div>
)} )}
</div> </div>
@ -606,7 +607,12 @@ export function ManageStorageDialog({
</Button> </Button>
<Button <Button
onClick={handleCreateShipment} onClick={handleCreateShipment}
disabled={isPending || !shipmentVehicleId || !shipmentDestId || availableVehicles.length === 0} disabled={
isPending ||
!shipmentVehicleId ||
!shipmentDestId ||
availableVehicles.length === 0
}
className="flex-1" className="flex-1"
> >
{isPending ? ( {isPending ? (
@ -628,10 +634,7 @@ export function ManageStorageDialog({
{Math.round(Math.min((currentMass / maxMass) * 100, 100))}% {Math.round(Math.min((currentMass / maxMass) * 100, 100))}%
</span> </span>
</div> </div>
<Progress <Progress value={Math.min((currentMass / maxMass) * 100, 100)} className="h-1.5" />
value={Math.min((currentMass / maxMass) * 100, 100)}
className="h-1.5"
/>
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{currentMass.toLocaleString()} kg used</span> <span>{currentMass.toLocaleString()} kg used</span>
<span>{maxMass.toLocaleString()} kg max</span> <span>{maxMass.toLocaleString()} kg max</span>
@ -700,10 +703,8 @@ export function ManageStorageDialog({
/> />
{getDepositLimitInfoFor(depositResourceId) !== undefined && ( {getDepositLimitInfoFor(depositResourceId) !== undefined && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Max:{" "} Max: {getDepositLimitInfoFor(depositResourceId)!.max.toLocaleString()}{" "}
{getDepositLimitInfoFor(depositResourceId)!.max.toLocaleString()}{" "} (limited by {getDepositLimitInfoFor(depositResourceId)!.reason})
(limited by{" "}
{getDepositLimitInfoFor(depositResourceId)!.reason})
</span> </span>
)} )}
</div> </div>
@ -742,7 +743,8 @@ export function ManageStorageDialog({
if (!resource || typeof resource === "number") return null; if (!resource || typeof resource === "number") return null;
return ( return (
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}> <SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
{resource.name} ({entry.amount.toLocaleString()} {pluralize(entry.amount, resource.unitOfMeasure)}) {resource.name} ({entry.amount.toLocaleString()}{" "}
{pluralize(entry.amount, resource.unitOfMeasure)})
</SelectItem> </SelectItem>
); );
})} })}
@ -756,7 +758,9 @@ export function ManageStorageDialog({
min="1" min="1"
max={getWithdrawMax()} max={getWithdrawMax()}
value={withdrawAmount} value={withdrawAmount}
onChange={(e) => setWithdrawAmount(clampAmount(e.target.value, getWithdrawMax()))} onChange={(e) =>
setWithdrawAmount(clampAmount(e.target.value, getWithdrawMax()))
}
/> />
{getWithdrawMax() !== undefined && ( {getWithdrawMax() !== undefined && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
@ -787,9 +791,7 @@ export function ManageStorageDialog({
value={transferToId} value={transferToId}
onValueChange={(val) => { onValueChange={(val) => {
setTransferToId(val); setTransferToId(val);
setTransferAmount( setTransferAmount(clampAmount(transferAmount, getTransferMaxFor(val)));
clampAmount(transferAmount, getTransferMaxFor(val)),
);
}} }}
> >
<SelectTrigger> <SelectTrigger>
@ -823,7 +825,8 @@ export function ManageStorageDialog({
if (!resource || typeof resource === "number") return null; if (!resource || typeof resource === "number") return null;
return ( return (
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}> <SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
{resource.name} ({entry.amount.toLocaleString()} {pluralize(entry.amount, resource.unitOfMeasure)}) {resource.name} ({entry.amount.toLocaleString()}{" "}
{pluralize(entry.amount, resource.unitOfMeasure)})
</SelectItem> </SelectItem>
); );
})} })}
@ -837,14 +840,18 @@ export function ManageStorageDialog({
min="1" min="1"
max={getTransferMax()} max={getTransferMax()}
value={transferAmount} value={transferAmount}
onChange={(e) => setTransferAmount(clampAmount(e.target.value, getTransferMax()))} onChange={(e) =>
setTransferAmount(clampAmount(e.target.value, getTransferMax()))
}
/> />
{getTransferMax() !== undefined && ( {getTransferMax() !== undefined && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Max: {getTransferMax()!.toLocaleString()} stored Max: {getTransferMax()!.toLocaleString()} stored
</span> </span>
)} )}
{transferToId && transferResourceId && (() => { {transferToId &&
transferResourceId &&
(() => {
const dest = allStructures.find((s) => s.id === Number(transferToId)); const dest = allStructures.find((s) => s.id === Number(transferToId));
const destType = dest?.type; const destType = dest?.type;
if (!dest || !destType) return null; if (!dest || !destType) return null;
@ -861,7 +868,8 @@ export function ManageStorageDialog({
if (!isResourceWhitelisted(destType, resId)) { if (!isResourceWhitelisted(destType, resId)) {
return ( return (
<span className="text-xs text-amber-400"> <span className="text-xs text-amber-400">
Destination only accepts items on its allowed storage list — this resource is not permitted. Destination only accepts items on its allowed storage list — this
resource is not permitted.
</span> </span>
); );
} }
@ -879,7 +887,9 @@ export function ManageStorageDialog({
{renderCapacityPreview("transfer")} {renderCapacityPreview("transfer")}
<Button <Button
onClick={handleTransfer} onClick={handleTransfer}
disabled={isPending || !transferToId || !transferResourceId || getTransferMax() === 0} disabled={
isPending || !transferToId || !transferResourceId || getTransferMax() === 0
}
className="w-full" className="w-full"
> >
{isPending ? ( {isPending ? (

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

View file

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

View file

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

View file

@ -1,16 +1,6 @@
import { Structure } from "@/payload-types"; import { Structure } from "@/payload-types";
import { import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
Item, import { CheckCircleIcon, ListRestartIcon, ShieldCheckIcon, XCircleIcon } from "lucide-react";
ItemContent,
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
import {
CheckCircleIcon,
XCircleIcon,
ShieldCheckIcon,
ListRestartIcon,
} from "lucide-react";
interface StorageRulesProps { interface StorageRulesProps {
allowedStorage: Structure["allowedStorage"]; 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"> <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" /> <ListRestartIcon className="size-3.5 shrink-0" />
<span> <span>
Whitelist mode — only the items listed under "Allowed" can be Whitelist mode — only the items listed under "Allowed" can be stored here. All other
stored here. All other items are rejected. items are rejected.
</span> </span>
</div> </div>
)} )}
@ -68,9 +58,7 @@ export function StorageRules({
> >
<CheckCircleIcon className="size-3" /> <CheckCircleIcon className="size-3" />
{name} {name}
{rule.amount && ( {rule.amount && <span className="text-green-400/60">×{rule.amount}</span>}
<span className="text-green-400/60">×{rule.amount}</span>
)}
</span> </span>
); );
})} })}

View file

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

View file

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