1
0
Fork 0

feat(factions): redesign faction card and add faction detail page

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-08-12 16:21:13 -04:00
parent ab0ff6b96e
commit 110112e9b4
2 changed files with 540 additions and 43 deletions

View file

@ -0,0 +1,464 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { notFound } from "next/navigation";
import Link from "next/link";
import type { Faction, Media, Mission, BankAccount, Resource, Campaign } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import {
ArrowLeftIcon,
ChevronRightIcon,
FlagIcon,
LandmarkIcon,
ShieldIcon,
ScaleIcon,
} from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { formatAmount } from "@/lib/banking/format";
interface FactionDetailPageProps {
params: Promise<{ id: string }>;
}
const typeColors: Record<string, string> = {
playerFaction: "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30",
majorFaction: "bg-blue-500/20 text-blue-400",
minorFaction: "bg-muted text-muted-foreground",
corporation: "bg-amber-500/20 text-amber-400",
special: "bg-purple-500/20 text-purple-400",
};
const typeLabels: Record<string, string> = {
playerFaction: "Player Faction",
majorFaction: "Major Faction",
minorFaction: "Minor Faction",
corporation: "Corporation",
special: "Special",
};
const missionStatusColors: Record<string, string> = {
Concept: "bg-muted text-muted-foreground",
Planning: "bg-muted text-muted-foreground",
Ready: "bg-blue-500/20 text-blue-400",
Scheduled: "bg-amber-500/20 text-amber-400",
Active: "bg-green-500/20 text-green-400",
Completed: "bg-emerald-500/20 text-emerald-400",
Cancelled: "bg-red-500/20 text-red-400",
};
const campaignStatusColors: Record<string, string> = {
concept: "bg-muted text-muted-foreground",
inProgress: "bg-blue-500/20 text-blue-400",
completed: "bg-emerald-500/20 text-emerald-400",
};
function reputationTone(rep: number): string {
if (rep >= 50) return "bg-emerald-500/20 text-emerald-400";
if (rep >= 20) return "bg-green-500/20 text-green-400";
if (rep > -20) return "bg-muted text-muted-foreground";
if (rep > -50) return "bg-amber-500/20 text-amber-400";
return "bg-red-500/20 text-red-400";
}
function reputationLabel(rep: number): string {
if (rep >= 50) return "Revered";
if (rep >= 20) return "Respected";
if (rep > -20) return "Neutral";
if (rep > -50) return "Disliked";
return "Reviled";
}
function relationshipTone(value: number): { bar: string; text: string; label: string } {
// 50..100 allied (green), 20..49 friendly (green-soft), -19..20 neutral, -20..-49 tense, -50..-100 hostile
if (value >= 50) return { bar: "bg-emerald-500", text: "text-emerald-400", label: "Allied" };
if (value >= 20) return { bar: "bg-green-500", text: "text-green-400", label: "Friendly" };
if (value > -20)
return { bar: "bg-muted-foreground", text: "text-muted-foreground", label: "Neutral" };
if (value > -50) return { bar: "bg-amber-500", text: "text-amber-400", label: "Tense" };
return { bar: "bg-red-500", text: "text-red-400", label: "Hostile" };
}
export default async function FactionDetailPage({ params }: FactionDetailPageProps) {
const { id } = await params;
const payload = await getPayload({ config });
const faction = await payload
.findByID({
collection: "factions",
id: Number(id),
depth: 2,
})
.catch(() => null);
if (!faction) {
notFound();
}
const coverImage =
typeof faction.coverImage === "object" && faction.coverImage !== null
? (faction.coverImage as Media)
: null;
const coverImageUrl = coverImage?.url ?? null;
const descriptionText = faction.description
? convertLexicalToPlaintext({ data: faction.description })
: null;
const heroSubtitle =
descriptionText && descriptionText.length > 240
? descriptionText.slice(0, 240) + "…"
: (descriptionText ?? faction.name);
const reputation = faction.reputation ?? 0;
// Bank account for this faction
const bankRes = await payload
.find({
collection: "bank-accounts",
where: {
and: [{ ownerFaction: { equals: faction.id } }, { accountType: { equals: "faction" } }],
},
depth: 1,
limit: 1,
})
.catch(() => ({ docs: [] }));
const bankAccount = (bankRes.docs[0] as BankAccount | undefined) ?? null;
const bankCurrency =
bankAccount && typeof bankAccount.currency === "object"
? (bankAccount.currency as Resource)
: null;
// Missions involving this faction
const missionsRes = await payload
.find({
collection: "missions",
where: { "factionsInvolved.faction": { equals: faction.id } },
sort: "-classification.startDateTime",
limit: 100,
depth: 0,
select: {
name: true,
codeName: true,
summary: true,
ownershipAndStatus: true,
classification: true,
},
})
.catch(() => ({ docs: [] as Mission[] }));
const missions = missionsRes.docs as Mission[];
// Associated campaigns (faction.associatedCampaigns is populated at depth 2)
const campaigns =
faction.associatedCampaigns?.filter(
(c): c is Campaign => typeof c === "object" && c !== null,
) ?? [];
// Relationships (targetFaction populated at depth 2)
const relationships = (faction.relationships ?? []).filter(
(r) => typeof r.targetFaction === "object" && r.targetFaction !== null,
);
return (
<div className="flex flex-col">
<header className="relative min-h-[500px] w-full overflow-hidden bg-background">
<div className="absolute top-0 left-0 px-5 pt-5 pb-3 z-10">
<Link
href="/intelligence/factions"
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary"
>
<ArrowLeftIcon className="size-3" />
Back to Factions
</Link>
</div>
{coverImageUrl ? (
<img
src={coverImageUrl}
alt=""
fetchPriority="high"
className="absolute inset-0 size-full object-cover opacity-70"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-b from-muted/40 to-background" />
)}
<div className="absolute inset-0 bg-gradient-to-b from-black/50 via-black/40 to-background" />
<div className="absolute inset-0 bg-gradient-to-r from-black/30 via-transparent to-black/40" />
<div className="absolute top-4 right-4 z-20 flex gap-2">
<span
className={`rounded-md border border-white/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider backdrop-blur-sm ${typeColors[faction.type] ?? "bg-muted text-muted-foreground"}`}
>
{typeLabels[faction.type] ?? faction.type}
</span>
<span
className={`rounded-md border border-white/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider backdrop-blur-sm ${reputationTone(reputation)}`}
>
{reputationLabel(reputation)} · {reputation >= 0 ? `+${reputation}` : reputation}
</span>
</div>
<div className="absolute inset-0 flex flex-col items-center justify-center p-6 md:p-10 lg:p-12 text-center">
<h1
id="faction-title"
className="text-5xl font-bold tracking-tight text-white drop-shadow-2xl md:text-6xl"
>
{faction.name}
</h1>
<p className="mt-3 max-w-3xl text-lg leading-relaxed text-white/80">{heroSubtitle}</p>
<div className="mt-6 flex items-center gap-2 text-sm text-white/70">
<ShieldIcon className="size-4 text-white/60" />
<span>{typeLabels[faction.type] ?? faction.type}</span>
{faction.playableFaction && (
<>
<span className="text-white/30">·</span>
<span>Playable</span>
</>
)}
{faction.startingFaction && (
<>
<span className="text-white/30">·</span>
<span>Starting</span>
</>
)}
</div>
</div>
</header>
<div className="p-5 flex flex-col gap-6 max-w-7xl mx-auto w-full">
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* ── Main Content (2/3) ──────────────────────────── */}
<div className="xl:col-span-2 flex flex-col gap-6">
{faction.description && (
<Item variant="outline">
<ItemHeader>
<ItemTitle>Description</ItemTitle>
</ItemHeader>
<ItemContent className="prose-invert prose-sm max-w-none [&_p]:mb-2 [&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4 [&_strong]:text-foreground [&_em]:italic">
<RichText data={faction.description} />
</ItemContent>
</Item>
)}
{relationships.length > 0 && (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<ScaleIcon className="size-4 text-muted-foreground" />
<ItemTitle>Relationships</ItemTitle>
</div>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-3">
{relationships.map((r) => {
const target = r.targetFaction as Faction;
const value = r.value;
const tone = relationshipTone(value);
const barWidth = Math.min(Math.abs(value), 100);
return (
<Link
key={r.id ?? target.id}
href={`/intelligence/factions/${target.id}`}
className="flex flex-col gap-1 rounded-md border border-border/50 p-3 transition-colors hover:border-primary/40 hover:bg-muted/30"
>
<div className="flex items-center justify-between gap-2">
<Link
href={`/intelligence/factions/${target.id}`}
className="flex items-center gap-2 text-sm font-medium hover:text-primary transition-colors"
>
<ShieldIcon className="size-4 text-muted-foreground" />
<span>{target.name}</span>
</Link>
<span className={`text-xs font-mono ${tone.text}`}>
{value >= 0 ? `+${value}` : value} · {tone.label}
</span>
</div>
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={`absolute top-0 h-full ${tone.bar}`}
style={{
left: value < 0 ? "50%" : `${50 - barWidth / 2}%`,
width: `${barWidth / 2}%`,
}}
/>
<div className="absolute left-1/2 top-0 h-full w-px bg-border" />
</div>
{r.notes && (
<p className="text-xs text-muted-foreground mt-1">{r.notes}</p>
)}
</Link>
);
})}
</div>
</ItemContent>
</Item>
)}
{missions.length > 0 && (
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Missions
</h2>
<span className="text-xs text-muted-foreground">
{missions.length} mission{missions.length !== 1 ? "s" : ""}
</span>
</div>
<div className="flex flex-col gap-2">
{missions.map((mission) => (
<Link key={mission.id} href={`/intelligence/missions/${mission.id}`}>
<Item
variant="outline"
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01]"
>
<ItemHeader>
<div className="flex flex-col gap-0.5">
<span className="font-mono text-xs text-muted-foreground">
{mission.codeName}
</span>
<ItemTitle>{mission.name}</ItemTitle>
</div>
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${
missionStatusColors[mission.ownershipAndStatus.status] ??
"bg-muted text-muted-foreground"
}`}
>
{mission.ownershipAndStatus.status}
</span>
</ItemHeader>
<ItemContent>
<p className="text-sm text-muted-foreground line-clamp-1">
{mission.summary}
</p>
</ItemContent>
</Item>
</Link>
))}
</div>
</section>
)}
</div>
{/* ── Right Sidebar (1/3) ─────────────────────────── */}
<div className="flex flex-col gap-4">
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<ScaleIcon className="size-4 text-muted-foreground" />
<ItemTitle>Standing</ItemTitle>
</div>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Reputation</span>
<span className={`font-mono ${reputationTone(reputation).split(" ")[1]}`}>
{reputation >= 0 ? `+${reputation}` : reputation}
</span>
</div>
<div className="relative h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className={`absolute top-0 h-full ${reputationTone(reputation).split(" ")[0]}`}
style={{
left:
reputation < 0
? "50%"
: `${50 - Math.min(Math.abs(reputation), 100) / 2}%`,
width: `${Math.min(Math.abs(reputation), 100) / 2}%`,
}}
/>
<div className="absolute left-1/2 top-0 h-full w-px bg-border" />
</div>
<p className="text-xs text-muted-foreground pt-1">
{reputationLabel(reputation)} standing in the world.
</p>
</div>
</ItemContent>
</Item>
{bankAccount && (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<LandmarkIcon className="size-4 text-muted-foreground" />
<ItemTitle>Bank Balance</ItemTitle>
</div>
<Link
href={`/logistics/banking/${bankAccount.id}`}
className="text-xs text-muted-foreground hover:text-primary transition-colors"
>
View
</Link>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-1">
<span className="text-lg font-semibold font-mono">
{formatAmount(bankAccount.balance ?? 0, bankCurrency)}
</span>
<span className="text-xs text-muted-foreground">{bankAccount.name}</span>
{bankAccount.status !== "open" && (
<span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded w-fit bg-amber-500/20 text-amber-400">
{bankAccount.status}
</span>
)}
</div>
</ItemContent>
</Item>
)}
{campaigns.length > 0 && (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<FlagIcon className="size-4 text-muted-foreground" />
<ItemTitle>Associated Campaigns</ItemTitle>
</div>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-1.5">
{campaigns.map((c) => {
const campaign = c as {
id: number;
name: string;
status?: string;
summary?: string;
};
return (
<Link
key={campaign.id}
href={`/intelligence/campaigns/${campaign.id}`}
className="flex items-center gap-3 p-1.5 rounded-md hover:bg-muted/50 transition-colors group"
>
<FlagIcon className="size-4 text-muted-foreground shrink-0" />
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="text-sm font-medium truncate group-hover:text-primary transition-colors">
{campaign.name}
</span>
{campaign.summary && (
<span className="text-xs text-muted-foreground line-clamp-1">
{campaign.summary}
</span>
)}
</div>
{campaign.status && (
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1 py-0.5 rounded shrink-0 ${campaignStatusColors[campaign.status] ?? "bg-muted text-muted-foreground"}`}
>
{campaign.status === "inProgress"
? "In Progress"
: campaign.status.charAt(0).toUpperCase() +
campaign.status.slice(1)}
</span>
)}
<ChevronRightIcon className="size-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</Link>
);
})}
</div>
</ItemContent>
</Item>
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import {
Item,
ItemContent,
@ -15,7 +16,7 @@ interface FactionCardProps {
faction: {
id: number;
name: string;
type: "majorFaction" | "minorFaction" | "corporation" | "special";
type: "playerFaction" | "majorFaction" | "minorFaction" | "corporation" | "special";
description?: {
root: {
type: string;
@ -34,10 +35,12 @@ interface FactionCardProps {
playableFaction?: boolean | null;
startingFaction?: boolean | null;
associatedCampaigns?: (number | { id: number; name: string })[] | null;
reputation?: number | null;
};
}
const typeColors: Record<string, string> = {
playerFaction: "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30",
majorFaction: "bg-blue-500/20 text-blue-400",
minorFaction: "bg-muted text-muted-foreground",
corporation: "bg-amber-500/20 text-amber-400",
@ -45,12 +48,29 @@ const typeColors: Record<string, string> = {
};
const typeLabels: Record<string, string> = {
playerFaction: "Player Faction",
majorFaction: "Major Faction",
minorFaction: "Minor Faction",
corporation: "Corporation",
special: "Special",
};
function reputationTone(rep: number): string {
if (rep >= 50) return "bg-emerald-500/20 text-emerald-400";
if (rep >= 20) return "bg-green-500/20 text-green-400";
if (rep > -20) return "bg-muted text-muted-foreground";
if (rep > -50) return "bg-amber-500/20 text-amber-400";
return "bg-red-500/20 text-red-400";
}
function reputationLabel(rep: number): string {
if (rep >= 50) return "Revered";
if (rep >= 20) return "Respected";
if (rep > -20) return "Neutral";
if (rep > -50) return "Disliked";
return "Reviled";
}
export function FactionCard({ faction }: FactionCardProps) {
const description = faction.description
? convertLexicalToPlaintext({ data: faction.description })
@ -61,50 +81,63 @@ export function FactionCard({ faction }: FactionCardProps) {
: description;
const campaignCount = faction.associatedCampaigns?.length ?? 0;
const reputation = faction.reputation ?? 0;
return (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<ItemMedia variant="icon">
<ShieldIcon />
</ItemMedia>
<ItemTitle>{faction.name}</ItemTitle>
</div>
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${typeColors[faction.type] ?? "bg-muted text-muted-foreground"}`}
>
{typeLabels[faction.type] ?? faction.type}
</span>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-3 w-full">
{truncatedDescription && (
<p className="text-sm text-muted-foreground">{truncatedDescription}</p>
)}
<div className="flex items-center gap-2 flex-wrap">
{faction.playableFaction && (
<span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">
Playable
</span>
)}
{faction.startingFaction && (
<span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400">
Starting
</span>
)}
{campaignCount > 0 && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<UsersIcon className="size-3" />
{campaignCount} campaign{campaignCount !== 1 ? "s" : ""}
</span>
)}
<Link href={`/intelligence/factions/${faction.id}`} className="block">
<Item
variant="outline"
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01] hover:[box-shadow:inset_0_2px_8px_rgba(255,255,255,0.33)]"
>
<ItemHeader>
<div className="flex items-center gap-2">
<ItemMedia variant="icon">
<ShieldIcon />
</ItemMedia>
<ItemTitle>{faction.name}</ItemTitle>
</div>
</div>
</ItemContent>
<ItemDescription>
{truncatedDescription ?? "No description provided."}
</ItemDescription>
</Item>
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${typeColors[faction.type] ?? "bg-muted text-muted-foreground"}`}
>
{typeLabels[faction.type] ?? faction.type}
</span>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-3 w-full">
{truncatedDescription && (
<p className="text-sm text-muted-foreground line-clamp-2">
{truncatedDescription}
</p>
)}
<div className="flex items-center gap-2 flex-wrap">
{faction.playableFaction && (
<span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">
Playable
</span>
)}
{faction.startingFaction && (
<span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400">
Starting
</span>
)}
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${reputationTone(reputation)}`}
>
{reputationLabel(reputation)} ({reputation >= 0 ? `+${reputation}` : reputation})
</span>
{campaignCount > 0 && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<UsersIcon className="size-3" />
{campaignCount} campaign{campaignCount !== 1 ? "s" : ""}
</span>
)}
</div>
</div>
</ItemContent>
<ItemDescription>
{truncatedDescription ?? "No description provided."}
</ItemDescription>
</Item>
</Link>
);
}