1
0
Fork 0

feat(awards): finish award profile presentation

This commit is contained in:
Jason Fraley 2026-08-21 15:40:33 -04:00
parent 91d1214a7b
commit 432929cc29
9 changed files with 148 additions and 43 deletions

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 { Assignment, Award, Media, Qualification, Rank, User } from "@/payload-types"; import type { Assignment, Media, Qualification, Rank, User } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react"; import { RichText } from "@payloadcms/richtext-lexical/react";
import { resolveLevel } from "@/utils/xp/resolveLevel"; import { resolveLevel } from "@/utils/xp/resolveLevel";
import { import {
@ -13,6 +13,7 @@ import {
CrosshairIcon, CrosshairIcon,
FileTextIcon, FileTextIcon,
HashIcon, HashIcon,
HourglassIcon,
PlaneIcon, PlaneIcon,
ShieldCheck, ShieldCheck,
ShieldIcon, ShieldIcon,
@ -70,9 +71,12 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
const profile = profileRes.docs[0]; const profile = profileRes.docs[0];
const user = profile.user as User; const user = profile.user as User;
const rank = profile.rank as Rank; const rank = profile.rank as Rank;
const awards = (profile.progression?.awards ?? []).filter( const awards = (profile.progression?.awards ?? []).flatMap((entry) => {
(a): a is Award => typeof a === "object" && a !== null, if (typeof entry !== "object" || entry === null) return [];
);
const award = entry.award;
return typeof award === "object" && award !== null ? [award] : [];
});
const qualifications = (profile.progression?.qualifications ?? []).filter( const qualifications = (profile.progression?.qualifications ?? []).filter(
(q): q is Qualification => typeof q === "object" && q !== null, (q): q is Qualification => typeof q === "object" && q !== null,
); );
@ -252,9 +256,16 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
<Item variant="outline"> <Item variant="outline">
<ItemHeader> <ItemHeader>
<ItemTitle>Intelligence Record</ItemTitle> <ItemTitle>Intelligence Record</ItemTitle>
<div className="inline-flex flex-row items-center gap-1 text-xs text-amber-300"> {profile.dossier?.intelExcerpt ? (
<span>Verified by the Intelligence Division</span> <ShieldCheck className="w-4" /> <div className="inline-flex flex-row items-center gap-1 text-xs text-amber-300">
</div> <span>Verified by the Intelligence Division</span> <ShieldCheck className="w-4" />
</div>
) : (
<div className="inline-flex flex-row items-center gap-1 text-xs opacity-25">
<span>Undergoing review by the Intelligence Division</span>{" "}
<HourglassIcon className="w-4" />
</div>
)}
</ItemHeader> </ItemHeader>
<ItemContent className="flex flex-col justify-start items-start text-sm text-muted-foreground leading-relaxed 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"> <ItemContent className="flex flex-col justify-start items-start text-sm text-muted-foreground leading-relaxed 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">
{profile.dossier?.intelExcerpt ? ( {profile.dossier?.intelExcerpt ? (

View file

@ -299,20 +299,20 @@
} }
@keyframes xp-gain { @keyframes xp-gain {
0% { 0% {
opacity: 0; opacity: 0;
transform: translateX(0.35rem); transform: translateX(0.35rem);
} }
15% { 15% {
opacity: 1; opacity: 1;
} }
75% { 75% {
opacity: 1; opacity: 1;
} }
100% { 100% {
opacity: 0; opacity: 0;
transform: translateX(-1.1rem); transform: translateX(-1.1rem);
} }
} }
.locker-search-highlight { .locker-search-highlight {
@ -320,6 +320,62 @@
box-shadow: 0 0 0 3px rgb(251 191 36 / 0.9); box-shadow: 0 0 0 3px rgb(251 191 36 / 0.9);
} }
@property --medal-border-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@keyframes border-spin-animate {
from {
--medal-border-angle: 0deg;
}
to {
--medal-border-angle: 360deg;
}
}
.medal-card {
--medal-border-angle: 0deg;
background: rgb(193 161 79 / 0.28);
position: relative;
isolation: isolate;
}
.medal-card::before {
content: '';
position: absolute;
inset: -0.125rem;
z-index: 0;
border-radius: inherit;
border: 2px solid transparent;
background: linear-gradient(var(--background), var(--background)) padding-box, repeating-conic-gradient(from var(--medal-border-angle), transparent 0deg 150deg, peru 159deg, rgb(253 224 71) 168deg, white 178deg, transparent 178deg) border-box;
animation: border-spin-animate 10s linear infinite;
pointer-events: none;
}
@keyframes border-spin-animate {
from {
--medal-border-angle: 0deg;
}
to {
--medal-border-angle: 360deg;
}
}
.medal-card * {
position: relative;
z-index: 1;
}
.medal-card::after {
content: '';
background: radial-gradient(circle, transparent, rgb(253 224 71 / 0.1));
position: absolute;
inset: 0;
animation: pulse 5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Scrollbars — styled to match the dark frontend theme */ /* Scrollbars — styled to match the dark frontend theme */
* { * {
scrollbar-width: thin; scrollbar-width: thin;

View file

@ -3,14 +3,17 @@ import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/com
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer"; import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
import { RibbonDesign } from "@/components/admin/awards/RibbonDesigner"; import { RibbonDesign } from "@/components/admin/awards/RibbonDesigner";
import { cn } from "@/lib/utils";
export default function AwardCard(props: { export default function AwardCard(props: { awardImage: Media | null; award: Award }) {
awardImage: Media | null;
award: Award;
}) {
return ( return (
<Item variant="outline"> <Item
<ItemHeader className="flex flex-col items-center text-center"> variant="outline"
className={cn("text-center", {
"medal-card": props.award.type === "medal",
})}
>
<ItemHeader className="flex flex-col items-center">
{props.award.ribbonDesign && ( {props.award.ribbonDesign && (
<RibbonViewer <RibbonViewer
ribbonDesign={props.award.ribbonDesign as RibbonDesign} ribbonDesign={props.award.ribbonDesign as RibbonDesign}
@ -26,7 +29,9 @@ export default function AwardCard(props: {
/> />
)} )}
<div className="flex flex-col"> <div className="flex flex-col">
<ItemTitle>{props.award.name}</ItemTitle> <ItemTitle className={cn({ "text-xl": props.award.type === "medal" })}>
{props.award.name}
</ItemTitle>
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-mono"> <span className="text-[10px] uppercase tracking-wider text-muted-foreground font-mono">
{props.award.type ?? "commendation"} {props.award.type ?? "commendation"}
</span> </span>

View file

@ -8,13 +8,13 @@ export const RibbonRack = ({ awards }: { awards: Award[] }) => {
return ( return (
<div className="flex flex-row w-52 gap-1"> <div className="flex flex-row w-52 gap-1">
{awards {awards
.filter((award) => award.ribbonDesign !== null) .filter((award) => award.type === "ribbon" && award.ribbonDesign != null)
.sort((a, b) => (b.experiencePoints ?? 0) - (a.experiencePoints ?? 0)) .sort((a, b) => (b.experiencePoints ?? 0) - (a.experiencePoints ?? 0))
.map((award, key) => { .map((award, key) => {
const ribbon = award.ribbonDesign as RibbonDesign; const ribbon = award.ribbonDesign as RibbonDesign;
return ( return (
<Tooltip key={key}> <Tooltip key={key}>
<TooltipTrigger className="w-full hover:scale-110 transition-transform"> <TooltipTrigger className="w-full hover:scale-110 shadow-black shadow-xs hover:shadow-xl transition-transform">
<RibbonViewer ribbonDesign={ribbon} showRibbonOnly /> <RibbonViewer ribbonDesign={ribbon} showRibbonOnly />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>

View file

@ -115,8 +115,7 @@ export function Wardrobe({
<TooltipContent <TooltipContent
side="right" side="right"
sideOffset={8} sideOffset={8}
className="bg-popover text-popover-foreground border border-border text-sm px-3.5 py-2" className="text-sm px-3.5 py-2"
arrowClassName="bg-popover fill-popover"
> >
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="font-semibold">{layout.label}</span> <span className="font-semibold">{layout.label}</span>

View file

@ -2,9 +2,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { VersionInfo } from "@/lib/versionInfo"; import type { VersionInfo } from "@/lib/versionInfo";
import { getPayload } from "payload";
import config from "@payload-config";
import { User } from "@/payload-types";
const EMPTY: VersionInfo = { const EMPTY: VersionInfo = {
version: null, version: null,

View file

@ -34,24 +34,29 @@ function TooltipContent({
className, className,
sideOffset = 0, sideOffset = 0,
children, children,
arrowClassName,
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) { }: React.ComponentProps<typeof TooltipPrimitive.Content> & { arrowClassName?: string }) {
return ( return (
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipPrimitive.Content <TooltipPrimitive.Content
data-slot="tooltip-content" data-slot="tooltip-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-background px-3 py-1.5 text-xs text-balance text-foreground fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95", "z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-popover border border-border px-3 py-1.5 text-xs text-balance text-foreground fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-background fill-background" /> <TooltipPrimitive.Arrow
className={
cn("z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-popover fill-popover", arrowClassName)
}
/>
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) );
} }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View file

@ -1610,7 +1610,24 @@ export interface Profile {
}; };
progression?: { progression?: {
xp?: number | null; xp?: number | null;
awards?: (number | Award)[] | null; awards?:
| {
award: number | Award;
/**
* Who issued the award. If no one, the award will be attributed as "on behalf of the unit."
*/
awardedBy?: (number | null) | Profile;
/**
* The issuing date of the award.
*/
awardedAt: string;
/**
* An optional reason for the award.
*/
awardReason?: string | null;
id?: string | null;
}[]
| null;
qualifications?: (number | Qualification)[] | null; qualifications?: (number | Qualification)[] | null;
scoreboard?: { scoreboard?: {
infantryKills?: number | null; infantryKills?: number | null;
@ -3239,7 +3256,15 @@ export interface ProfilesSelect<T extends boolean = true> {
| T | T
| { | {
xp?: T; xp?: T;
awards?: T; awards?:
| T
| {
award?: T;
awardedBy?: T;
awardedAt?: T;
awardReason?: T;
id?: T;
};
qualifications?: T; qualifications?: T;
scoreboard?: scoreboard?:
| T | T

View file

@ -33,8 +33,15 @@ export async function hasIntelligenceQualification(
const profile = (await payload.find({ const profile = (await payload.find({
collection: "profiles", collection: "profiles",
where: { user: { equals: user.id as number } }, where: { user: { equals: user.id as number } },
depth: 4, // Only qualifications are needed here. A deeper read recursively
// hydrates Profiles through progression.awards.awardedBy.
depth: 1,
limit: 1, limit: 1,
select: {
progression: {
qualifications: true,
},
},
})) as unknown as { docs: Array<any> }; })) as unknown as { docs: Array<any> };
const userProfile = profile.docs[0]; const userProfile = profile.docs[0];