1
0
Fork 0

feat(missions): show op host on side op cards and mission pages

Side ops now credit their creator: mission cards get a 'Hosted by X' meta entry and the mission detail page gets a Host chip. Display name preferred over username; only shown for side ops with a populated createdBy.
This commit is contained in:
Jason Fraley 2026-09-07 05:07:43 -04:00
parent 3af17928e8
commit ffb2c2c5d9
2 changed files with 37 additions and 4 deletions

View file

@ -3,7 +3,7 @@ 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 { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import type { Campaign, Map, Media, Mission } from "@/payload-types"; import type { Campaign, Map, Media, Mission, User } from "@/payload-types";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
CalendarDaysIcon, CalendarDaysIcon,
@ -13,6 +13,7 @@ import {
FlagIcon, FlagIcon,
MapPinIcon, MapPinIcon,
ServerIcon, ServerIcon,
UserIcon,
} from "lucide-react"; } from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { ClientDate } from "@/components/frontend/ClientDate"; import { ClientDate } from "@/components/frontend/ClientDate";
@ -94,6 +95,13 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
? (missionWithCoverImage.coverImage as Media) ? (missionWithCoverImage.coverImage as Media)
: null; : null;
const coverImageUrl = coverImage?.url ?? null; const coverImageUrl = coverImage?.url ?? null;
const host =
mission.operationType === "side" &&
typeof mission.createdBy === "object" &&
mission.createdBy !== null
? (mission.createdBy as User)
: null;
const hostLabel = host ? (host.displayName || host.username) : null;
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;
@ -328,6 +336,17 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
</span> </span>
</div> </div>
)} )}
{hostLabel && (
<div className="flex items-center gap-2 px-3 py-2">
<UserIcon className="size-4 text-white/60" />
<span className="flex flex-col">
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/50">
Host
</span>
<span className="max-w-56 truncate text-sm text-white">{hostLabel}</span>
</span>
</div>
)}
{campaign && ( {campaign && (
<Link <Link
href={`/intelligence/campaigns/${campaign.id}`} href={`/intelligence/campaigns/${campaign.id}`}

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import type { Campaign, Map, Media } from "@/payload-types"; import type { Campaign, Map, Media, User } from "@/payload-types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
operationTypeColors, operationTypeColors,
@ -22,6 +22,7 @@ import {
ClockIcon, ClockIcon,
CrosshairIcon, CrosshairIcon,
MapPinIcon, MapPinIcon,
UserIcon,
UsersIcon, UsersIcon,
} from "lucide-react"; } from "lucide-react";
@ -52,6 +53,7 @@ export interface MissionCardProps {
readonly missionRoles?: { readonly missionRoles?: {
readonly maxPlayers: number; readonly maxPlayers: number;
} | null; } | null;
readonly createdBy?: number | User | null;
readonly coverImage?: number | Media | null; readonly coverImage?: number | Media | null;
}; };
readonly variant?: "default" | "image"; readonly variant?: "default" | "image";
@ -77,6 +79,12 @@ export function MissionCard({
typeof mission.classification.campaign === "object" && mission.classification.campaign !== null typeof mission.classification.campaign === "object" && mission.classification.campaign !== null
? mission.classification.campaign ? mission.classification.campaign
: null; : null;
const createdByUser =
typeof mission.createdBy === "object" && mission.createdBy !== null
? mission.createdBy
: null;
const hostLabel = createdByUser ? (createdByUser.displayName || createdByUser.username) : null;
const showHost = mission.operationType === "side" && hostLabel !== null;
const startDate = new Date(mission.classification.startDateTime); const startDate = new Date(mission.classification.startDateTime);
const dateStr = startDate.toLocaleDateString("en-US", { const dateStr = startDate.toLocaleDateString("en-US", {
@ -122,7 +130,7 @@ export function MissionCard({
<ItemMedia variant="icon"> <ItemMedia variant="icon">
<CrosshairIcon /> <CrosshairIcon />
</ItemMedia> </ItemMedia>
<ItemTitle className="flex flex-col"> <ItemTitle className="flex flex-col items-start text-start">
<span className="font-mono text-xs text-muted-foreground">{mission.codeName}</span> <span className="font-mono text-xs text-muted-foreground">{mission.codeName}</span>
<span>{mission.name}</span> <span>{mission.name}</span>
</ItemTitle> </ItemTitle>
@ -131,7 +139,7 @@ export function MissionCard({
<span <span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${operationTypeColors[mission.operationType] ?? "bg-muted text-muted-foreground"}`} className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${operationTypeColors[mission.operationType] ?? "bg-muted text-muted-foreground"}`}
> >
{operationTypeLabels[mission.operationType] ?? mission.operationType} {operationTypeLabels[mission.operationType] ?? mission.operationType} Operation
</span> </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"}`}
@ -167,6 +175,12 @@ export function MissionCard({
{mission.missionRoles.maxPlayers} slots {mission.missionRoles.maxPlayers} slots
</span> </span>
)} )}
{showHost && (
<span className="flex items-center gap-1">
<UserIcon className="size-3" />
Hosted by {hostLabel}
</span>
)}
{campaign && <span className="text-primary">{campaign.name}</span>} {campaign && <span className="text-primary">{campaign.name}</span>}
</div> </div>
</ItemContent> </ItemContent>