From 110112e9b45d7f5b32f20e1afde9cd1ceae031f6 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Wed, 12 Aug 2026 16:21:13 -0400 Subject: [PATCH] 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 --- .../intelligence/factions/[id]/page.tsx | 464 ++++++++++++++++++ .../frontend/intelligence/FactionCard.tsx | 119 +++-- 2 files changed, 540 insertions(+), 43 deletions(-) create mode 100644 src/app/(frontend)/intelligence/factions/[id]/page.tsx diff --git a/src/app/(frontend)/intelligence/factions/[id]/page.tsx b/src/app/(frontend)/intelligence/factions/[id]/page.tsx new file mode 100644 index 0000000..5006eb9 --- /dev/null +++ b/src/app/(frontend)/intelligence/factions/[id]/page.tsx @@ -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 = { + 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 = { + playerFaction: "Player Faction", + majorFaction: "Major Faction", + minorFaction: "Minor Faction", + corporation: "Corporation", + special: "Special", +}; + +const missionStatusColors: Record = { + 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 = { + 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 ( +
+
+
+ + + Back to Factions + +
+ {coverImageUrl ? ( + + ) : ( +
+ )} +
+
+ +
+ + {typeLabels[faction.type] ?? faction.type} + + + {reputationLabel(reputation)} · {reputation >= 0 ? `+${reputation}` : reputation} + +
+ +
+

+ {faction.name} +

+

{heroSubtitle}

+
+ + {typeLabels[faction.type] ?? faction.type} + {faction.playableFaction && ( + <> + · + Playable + + )} + {faction.startingFaction && ( + <> + · + Starting + + )} +
+
+
+ +
+
+ {/* ── Main Content (2/3) ──────────────────────────── */} +
+ {faction.description && ( + + + Description + + + + + + )} + + {relationships.length > 0 && ( + + +
+ + Relationships +
+
+ +
+ {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 ( + +
+ + + {target.name} + + + {value >= 0 ? `+${value}` : value} · {tone.label} + +
+
+
+
+
+ {r.notes && ( +

{r.notes}

+ )} + + ); + })} +
+ + + )} + + {missions.length > 0 && ( +
+
+

+ Missions +

+ + {missions.length} mission{missions.length !== 1 ? "s" : ""} + +
+
+ {missions.map((mission) => ( + + + +
+ + {mission.codeName} + + {mission.name} +
+ + {mission.ownershipAndStatus.status} + +
+ +

+ {mission.summary} +

+
+
+ + ))} +
+
+ )} +
+ + {/* ── Right Sidebar (1/3) ─────────────────────────── */} +
+ + +
+ + Standing +
+
+ +
+
+ Reputation + + {reputation >= 0 ? `+${reputation}` : reputation} + +
+
+
+
+
+

+ {reputationLabel(reputation)} standing in the world. +

+
+ + + + {bankAccount && ( + + +
+ + Bank Balance +
+ + View + +
+ +
+ + {formatAmount(bankAccount.balance ?? 0, bankCurrency)} + + {bankAccount.name} + {bankAccount.status !== "open" && ( + + {bankAccount.status} + + )} +
+
+
+ )} + + {campaigns.length > 0 && ( + + +
+ + Associated Campaigns +
+
+ +
+ {campaigns.map((c) => { + const campaign = c as { + id: number; + name: string; + status?: string; + summary?: string; + }; + return ( + + +
+ + {campaign.name} + + {campaign.summary && ( + + {campaign.summary} + + )} +
+ {campaign.status && ( + + {campaign.status === "inProgress" + ? "In Progress" + : campaign.status.charAt(0).toUpperCase() + + campaign.status.slice(1)} + + )} + + + ); + })} +
+
+
+ )} +
+
+
+
+ ); +} diff --git a/src/components/frontend/intelligence/FactionCard.tsx b/src/components/frontend/intelligence/FactionCard.tsx index 6b273f3..ac0ddf4 100644 --- a/src/components/frontend/intelligence/FactionCard.tsx +++ b/src/components/frontend/intelligence/FactionCard.tsx @@ -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 = { + 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 = { }; const typeLabels: Record = { + 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 ( - - -
- - - - {faction.name} -
- - {typeLabels[faction.type] ?? faction.type} - -
- -
- {truncatedDescription && ( -

{truncatedDescription}

- )} -
- {faction.playableFaction && ( - - Playable - - )} - {faction.startingFaction && ( - - Starting - - )} - {campaignCount > 0 && ( - - - {campaignCount} campaign{campaignCount !== 1 ? "s" : ""} - - )} + + + +
+ + + + {faction.name}
-
- - - {truncatedDescription ?? "No description provided."} - - + + {typeLabels[faction.type] ?? faction.type} + + + +
+ {truncatedDescription && ( +

+ {truncatedDescription} +

+ )} +
+ {faction.playableFaction && ( + + Playable + + )} + {faction.startingFaction && ( + + Starting + + )} + + {reputationLabel(reputation)} ({reputation >= 0 ? `+${reputation}` : reputation}) + + {campaignCount > 0 && ( + + + {campaignCount} campaign{campaignCount !== 1 ? "s" : ""} + + )} +
+
+
+ + {truncatedDescription ?? "No description provided."} + + + ); }