Awards can now upgrade through a series: collection fields and migration, grant flow support, profile and account UI updates, and regression coverage. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
219 lines
7.7 KiB
TypeScript
219 lines
7.7 KiB
TypeScript
import config from "@payload-config";
|
|
import { getPayload } from "payload";
|
|
import Link from "next/link";
|
|
import { headers as nextHeaders } from "next/headers";
|
|
import type { Award, Media } from "@/payload-types";
|
|
import { AwardWithGrantButton } from "@/components/frontend/account/AwardWithGrantButton";
|
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
|
import { Button } from "@/components/ui/button";
|
|
import { MedalIcon } from "lucide-react";
|
|
import hasRoles from "@/utils/access-control/hasRoles";
|
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
|
|
|
export const metadata = {
|
|
title: "Awards",
|
|
description:
|
|
"Browse every medal and ribbon that can be granted to a member of Polaris Task Force.",
|
|
};
|
|
|
|
const TYPE_LABEL: Record<"ribbon" | "medal", string> = {
|
|
ribbon: "Ribbons",
|
|
medal: "Medals",
|
|
};
|
|
|
|
type AwardCatalogItem =
|
|
| { readonly kind: "award"; readonly award: Award }
|
|
| {
|
|
readonly kind: "advancement";
|
|
readonly seriesKey: string;
|
|
readonly familyName: string;
|
|
};
|
|
|
|
function buildRibbonCatalogItems(ribbons: Award[]): AwardCatalogItem[] {
|
|
const awardsBySeries = new Map<string, Award[]>();
|
|
for (const award of ribbons) {
|
|
const seriesKey = award.advancement?.seriesKey;
|
|
if (seriesKey == null) continue;
|
|
|
|
const seriesAwards = awardsBySeries.get(seriesKey);
|
|
if (seriesAwards) seriesAwards.push(award);
|
|
else awardsBySeries.set(seriesKey, [award]);
|
|
}
|
|
|
|
const groupedSeriesKeys = new Set(
|
|
[...awardsBySeries.entries()]
|
|
.filter(([, seriesAwards]) => seriesAwards.length > 1)
|
|
.map(([seriesKey]) => seriesKey),
|
|
);
|
|
const emittedSeriesKeys = new Set<string>();
|
|
const items: AwardCatalogItem[] = [];
|
|
|
|
for (const award of ribbons) {
|
|
const seriesKey = award.advancement?.seriesKey;
|
|
if (seriesKey == null || !groupedSeriesKeys.has(seriesKey)) {
|
|
items.push({ kind: "award", award });
|
|
continue;
|
|
}
|
|
if (emittedSeriesKeys.has(seriesKey)) continue;
|
|
|
|
const seriesAwards = [...(awardsBySeries.get(seriesKey) ?? [])].sort((first, second) => {
|
|
const tierDifference =
|
|
(first.advancement?.tier ?? Number.POSITIVE_INFINITY) -
|
|
(second.advancement?.tier ?? Number.POSITIVE_INFINITY);
|
|
if (tierDifference !== 0) return tierDifference;
|
|
if (first.name !== second.name) return first.name < second.name ? -1 : 1;
|
|
return first.id - second.id;
|
|
});
|
|
const familyAward =
|
|
seriesAwards.find((seriesAward) => seriesAward.advancement?.tier === 1) ?? seriesAwards[0];
|
|
if (!familyAward) continue;
|
|
|
|
items.push({
|
|
kind: "advancement",
|
|
seriesKey,
|
|
familyName: familyAward.name,
|
|
});
|
|
for (const seriesAward of seriesAwards) {
|
|
items.push({ kind: "award", award: seriesAward });
|
|
}
|
|
emittedSeriesKeys.add(seriesKey);
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
export default async function AwardsPage() {
|
|
const headers = await nextHeaders();
|
|
const payloadConfig = await config;
|
|
const payload = await getPayload({ config: payloadConfig });
|
|
const { user: authUser } = await payload.auth({ headers, canSetHeaders: false });
|
|
const user = isPayloadUser(authUser) ? authUser : null;
|
|
const canReview = hasRoles(["admin", "developer"], user);
|
|
|
|
const res = await payload.find({
|
|
collection: "awards",
|
|
where: {
|
|
type: { in: ["ribbon", "medal"] },
|
|
},
|
|
sort: "name",
|
|
limit: 100,
|
|
depth: 1,
|
|
overrideAccess: true,
|
|
});
|
|
|
|
const pendingCount = canReview
|
|
? (
|
|
await payload.count({
|
|
collection: "ribbon-submissions",
|
|
where: { status: { equals: "pending" } },
|
|
overrideAccess: true,
|
|
})
|
|
).totalDocs
|
|
: 0;
|
|
|
|
const awards: Award[] = res.docs;
|
|
const ribbons = awards.filter((a) => a.type === "ribbon");
|
|
const medals = awards.filter((a) => a.type === "medal");
|
|
|
|
const catalogSections: { title: string; items: AwardCatalogItem[] }[] = [];
|
|
if (medals.length > 0) {
|
|
catalogSections.push({
|
|
title: TYPE_LABEL.medal,
|
|
items: medals.map((award) => ({ kind: "award", award })),
|
|
});
|
|
}
|
|
if (ribbons.length > 0) {
|
|
catalogSections.push({
|
|
title: TYPE_LABEL.ribbon,
|
|
items: buildRibbonCatalogItems(ribbons),
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="p-5 flex flex-col gap-6 max-w-7xl">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<MedalIcon className="size-4 text-muted-foreground" />
|
|
<h1 className="text-2xl font-bold tracking-tight uppercase">Awards</h1>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground max-w-2xl">
|
|
A reference of every medal and ribbon that can be earned by a member of Polaris Task
|
|
Force. Review the requirements for each award and its artwork. Commendations are granted
|
|
on the fly and are not catalogued here.
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button asChild size="sm">
|
|
<Link href="/awards/design">Design a ribbon</Link>
|
|
</Button>
|
|
{canReview && (
|
|
<Button asChild size="sm" variant="outline">
|
|
<Link href="/awards/review">Review queue ({pendingCount})</Link>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{awards.length === 0 ? (
|
|
<Item variant="muted">
|
|
<ItemHeader>
|
|
<ItemTitle>No awards defined</ItemTitle>
|
|
</ItemHeader>
|
|
<ItemContent>
|
|
<p className="text-sm text-muted-foreground italic">
|
|
No medals or ribbons have been catalogued yet. Check back soon.
|
|
</p>
|
|
</ItemContent>
|
|
</Item>
|
|
) : (
|
|
catalogSections.map((section) => (
|
|
<section key={section.title} className="flex flex-col gap-3">
|
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
|
{section.title}
|
|
</h2>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{section.items.map((item) => {
|
|
switch (item.kind) {
|
|
case "advancement":
|
|
return (
|
|
<div
|
|
key={`advancement-${item.seriesKey}`}
|
|
className="col-span-full flex items-center gap-2 border-b border-border pb-1 text-xs font-medium text-muted-foreground"
|
|
>
|
|
<span className="shrink-0">Advancement: {item.familyName}</span>
|
|
<div className="h-px flex-1 bg-border" aria-hidden />
|
|
</div>
|
|
);
|
|
case "award": {
|
|
const { award } = item;
|
|
const awardImage =
|
|
typeof award.image === "object" && award.image
|
|
? (award.image as Media)
|
|
: null;
|
|
const backgroundImage =
|
|
typeof award.backgroundImage === "object" && award.backgroundImage
|
|
? (award.backgroundImage as Media)
|
|
: null;
|
|
return (
|
|
<AwardWithGrantButton
|
|
key={award.id}
|
|
award={award}
|
|
awardImage={awardImage}
|
|
backgroundImage={backgroundImage}
|
|
/>
|
|
);
|
|
}
|
|
default: {
|
|
const exhaustiveItem: never = item;
|
|
return exhaustiveItem;
|
|
}
|
|
}
|
|
})}
|
|
</div>
|
|
</section>
|
|
))
|
|
)}
|
|
</div>
|
|
);
|
|
}
|