1
0
Fork 0

feat(campaigns): redesign campaign detail page with cover hero and mission thumbnails

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:06 -04:00
parent 11afd905f5
commit cce00861c2

View file

@ -2,7 +2,7 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import type { Campaign, Faction, Mission } from "@/payload-types"; import type { Campaign, Faction, Media, Mission } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react"; import { RichText } from "@payloadcms/richtext-lexical/react";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
@ -13,13 +13,7 @@ import {
TargetIcon, TargetIcon,
UsersIcon, UsersIcon,
} from "lucide-react"; } from "lucide-react";
import { import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
interface CampaignDetailPageProps { interface CampaignDetailPageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@ -48,9 +42,7 @@ const missionStatusColors: Record<string, string> = {
Cancelled: "bg-red-500/20 text-red-400", Cancelled: "bg-red-500/20 text-red-400",
}; };
export default async function CampaignDetailPage({ export default async function CampaignDetailPage({ params }: CampaignDetailPageProps) {
params,
}: CampaignDetailPageProps) {
const { id } = await params; const { id } = await params;
const payload = await getPayload({ config }); const payload = await getPayload({ config });
@ -58,7 +50,7 @@ export default async function CampaignDetailPage({
.findByID({ .findByID({
collection: "campaigns", collection: "campaigns",
id: Number(id), id: Number(id),
depth: 0, depth: 1,
}) })
.catch(() => null); .catch(() => null);
@ -66,6 +58,12 @@ export default async function CampaignDetailPage({
notFound(); notFound();
} }
const coverImage =
typeof campaign.coverImage === "object" && campaign.coverImage !== null
? (campaign.coverImage as Media)
: null;
const coverImageUrl = coverImage?.url ?? null;
const [missionsRes, factionsRes] = await Promise.all([ const [missionsRes, factionsRes] = await Promise.all([
payload payload
.find({ .find({
@ -82,6 +80,7 @@ export default async function CampaignDetailPage({
classification: true, classification: true,
ownershipAndStatus: true, ownershipAndStatus: true,
missionRoles: true, missionRoles: true,
coverImage: true,
}, },
}) })
.catch(() => ({ docs: [] })), .catch(() => ({ docs: [] })),
@ -120,200 +119,220 @@ export default async function CampaignDetailPage({
} }
return ( return (
<div className="p-5 flex flex-col gap-6 max-w-7xl"> <div className="flex flex-col">
<Link <header className="relative min-h-[500px] w-full overflow-hidden bg-background">
href="/intelligence/campaigns" <div className="absolute top-0 left-0 px-5 pt-5 pb-3 z-10">
className="text-sm text-muted-foreground hover:text-primary transition-colors flex items-center gap-1 w-fit" <Link
> href="/intelligence/campaigns"
<ArrowLeftIcon className="size-3" /> className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary"
Back to Campaigns >
</Link> <ArrowLeftIcon className="size-3" />
Back to Campaigns
<div className="flex items-start justify-between gap-4"> </Link>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<ShieldIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">{campaign.name}</h1>
</div>
<div className="flex items-center gap-2">
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[campaign.status] ?? "bg-muted text-muted-foreground"}`}
>
{campaign.status === "inProgress"
? "In Progress"
: campaign.status.charAt(0).toUpperCase() +
campaign.status.slice(1)}
</span>
{startDate && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<CalendarDaysIcon className="size-3" />
{startDate}
{endDate ? ` — ${endDate}` : " — Present"}
</span>
)}
</div>
</div> </div>
</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" />
<p className="text-sm text-muted-foreground">{campaign.summary}</p> <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 ${statusColors[campaign.status] ?? "bg-muted text-muted-foreground"}`}
>
{campaign.status === "inProgress"
? "In Progress"
: campaign.status.charAt(0).toUpperCase() + campaign.status.slice(1)}
</span>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6"> <div className="absolute inset-0 flex flex-col items-center justify-center p-6 md:p-10 lg:p-12 text-center">
{/* ── Main Content (2/3) ──────────────────────────── */} <h1
<div className="xl:col-span-2 flex flex-col gap-6"> id="campaign-title"
{campaign.description && ( className="text-5xl font-bold tracking-tight text-white drop-shadow-2xl md:text-6xl"
<Item variant="outline"> >
<ItemHeader> {campaign.name}
<ItemTitle>Description</ItemTitle> </h1>
</ItemHeader> <p className="mt-3 max-w-3xl text-lg leading-relaxed text-white/80">{campaign.summary}</p>
<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"> {(startDate || endDate) && (
<RichText data={campaign.description} /> <div className="mt-6 flex items-center gap-2 text-sm text-white/70">
</ItemContent> <CalendarDaysIcon className="size-4 text-white/60" />
</Item> {startDate && <span>{startDate}</span>}
)} {startDate && endDate && <span>—</span>}
{endDate ? <span>{endDate}</span> : startDate ? <span>Present</span> : null}
<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>
{missions.length > 0 ? ( )}
<div className="flex flex-col gap-2"> </div>
{missions.map((mission) => ( </header>
<Link
key={mission.id} <div className="p-5 flex flex-col gap-6 max-w-7xl mx-auto w-full">
href={`/intelligence/missions/${mission.id}`} <div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
> {/* ── Main Content (2/3) ──────────────────────────── */}
<Item <div className="xl:col-span-2 flex flex-col gap-6">
variant="outline" {campaign.description && (
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01]" <Item variant="outline">
> <ItemHeader>
<ItemHeader> <ItemTitle>Description</ItemTitle>
<div className="flex flex-col gap-0.5"> </ItemHeader>
<span className="font-mono text-xs text-muted-foreground"> <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">
{mission.codeName} <RichText data={campaign.description} />
</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>
<ItemDescription className="line-clamp-1">
{mission.summary}
</ItemDescription>
</ItemContent>
</Item>
</Link>
))}
</div>
) : (
<Item variant="muted">
<ItemContent>
<p className="text-sm text-muted-foreground italic">
No missions in this campaign yet.
</p>
</ItemContent> </ItemContent>
</Item> </Item>
)} )}
</section>
</div>
{/* ── Right Sidebar (1/3) ─────────────────────────── */} <section className="flex flex-col gap-3">
<div className="flex flex-col gap-4"> <div className="flex items-center justify-between">
{/* Campaign Stats */} <h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
<Item variant="outline"> Missions
<ItemHeader> </h2>
<div className="flex items-center gap-2"> <span className="text-xs text-muted-foreground">
<TargetIcon className="size-4 text-muted-foreground" /> {missions.length} mission{missions.length !== 1 ? "s" : ""}
<ItemTitle>Campaign Stats</ItemTitle> </span>
</div> </div>
</ItemHeader> {missions.length > 0 ? (
<ItemContent>
<div className="flex flex-col gap-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total Missions</span>
<span className="font-semibold">{missions.length}</span>
</div>
{Object.entries(statusCounts).map(([status, count]) => (
<div key={status} className="flex justify-between text-sm">
<span className="flex items-center gap-1.5">
<span
className={`size-2 rounded-full ${missionStatusColors[status]?.split(" ")[0] ?? "bg-muted"}`}
/>
<span className="text-muted-foreground">{status}</span>
</span>
<span className="font-mono">{count}</span>
</div>
))}
{campaign.startDate && (
<div className="flex justify-between text-sm pt-1 border-t border-border/50">
<span className="text-muted-foreground">Duration</span>
<span>
{startDate}
{endDate ? ` — ${endDate}` : " — Present"}
</span>
</div>
)}
</div>
</ItemContent>
</Item>
{/* Faction Roster */}
{factions.length > 0 && (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<UsersIcon className="size-4 text-muted-foreground" />
<ItemTitle>Faction Roster</ItemTitle>
</div>
<Link
href="/intelligence/factions"
className="text-xs text-muted-foreground hover:text-primary transition-colors"
>
View all
</Link>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{factions.map((faction) => ( {missions.map((mission) => (
<Link <Link key={mission.id} href={`/intelligence/missions/${mission.id}`}>
key={faction.id} <Item
href="/intelligence/factions" variant="outline"
className="flex items-center gap-3 p-1.5 rounded-md hover:bg-muted/50 transition-colors group" className="cursor-pointer group transition-all duration-200 hover:scale-[1.01] group relative border border-transparent hover:border-white/75"
> >
<ShieldIcon className="size-4 text-muted-foreground shrink-0" /> <div className="absolute inset-0 pointer-events-none opacity-25 overflow-hidden after:bg-linear-0 after:bg-linear-180 after:from-transparent after:to-black/42 after:inset-0 after:z-10 after:absolute group-hover:after:opacity-0 after:transition-opacity">
<div className="flex flex-col gap-0.5 min-w-0 flex-1"> <img
<span className="text-sm font-medium truncate group-hover:text-primary transition-colors"> src={(mission.coverImage as Media)?.url ?? undefined}
{faction.name} className="w-full h-full aspect-video object-cover object-center"
</span> />
<span </div>
className={`text-[10px] font-semibold uppercase tracking-wider px-1 py-0.5 rounded w-fit ${factionTypeColors[faction.type] ?? "bg-muted text-muted-foreground"}`} <ItemHeader>
> <div className="flex flex-col gap-0.5">
{faction.type === "majorFaction" <span className="font-mono text-xs text-muted-foreground">
? "Major" {mission.codeName}
: faction.type === "minorFaction" </span>
? "Minor" <ItemTitle>{mission.name}</ItemTitle>
: faction.type} </div>
</span> <span
</div> className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${
<ChevronRightIcon className="size-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" /> missionStatusColors[mission.ownershipAndStatus.status] ??
"bg-muted text-muted-foreground"
}`}
>
{mission.ownershipAndStatus.status}
</span>
</ItemHeader>
<ItemContent>
<ItemDescription className="line-clamp-1">
{mission.summary}
</ItemDescription>
</ItemContent>
</Item>
</Link> </Link>
))} ))}
</div> </div>
) : (
<Item variant="muted">
<ItemContent>
<p className="text-sm text-muted-foreground italic">
No missions in this campaign yet.
</p>
</ItemContent>
</Item>
)}
</section>
</div>
{/* ── Right Sidebar (1/3) ─────────────────────────── */}
<div className="flex flex-col gap-4">
{/* Campaign Stats */}
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<TargetIcon className="size-4 text-muted-foreground" />
<ItemTitle>Campaign Stats</ItemTitle>
</div>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total Missions</span>
<span className="font-semibold">{missions.length}</span>
</div>
{Object.entries(statusCounts).map(([status, count]) => (
<div key={status} className="flex justify-between text-sm">
<span className="flex items-center gap-1.5">
<span
className={`size-2 rounded-full ${missionStatusColors[status]?.split(" ")[0] ?? "bg-muted"}`}
/>
<span className="text-muted-foreground">{status}</span>
</span>
<span className="font-mono">{count}</span>
</div>
))}
{campaign.startDate && (
<div className="flex justify-between text-sm pt-1 border-t border-border/50">
<span className="text-muted-foreground">Duration</span>
<span>
{startDate}
{endDate ? ` — ${endDate}` : " — Present"}
</span>
</div>
)}
</div>
</ItemContent> </ItemContent>
</Item> </Item>
)}
{/* Faction Roster */}
{factions.length > 0 && (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<UsersIcon className="size-4 text-muted-foreground" />
<ItemTitle>Faction Roster</ItemTitle>
</div>
<Link
href="/intelligence/factions"
className="text-xs text-muted-foreground hover:text-primary transition-colors"
>
View all
</Link>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-2">
{factions.map((faction) => (
<Link
key={faction.id}
href="/intelligence/factions"
className="flex items-center gap-3 p-1.5 rounded-md hover:bg-muted/50 transition-colors group"
>
<ShieldIcon 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">
{faction.name}
</span>
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1 py-0.5 rounded w-fit ${factionTypeColors[faction.type] ?? "bg-muted text-muted-foreground"}`}
>
{faction.type === "majorFaction"
? "Major"
: faction.type === "minorFaction"
? "Minor"
: faction.type}
</span>
</div>
<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> </div>
</div> </div>