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:
parent
11afd905f5
commit
cce00861c2
1 changed files with 210 additions and 191 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue