1
0
Fork 0

feat(awards): add AwardCard and RibbonRack components for award display

This commit is contained in:
Jason Fraley 2026-08-21 12:46:44 -04:00
parent 9e91203330
commit b060b967c0
10 changed files with 264 additions and 76 deletions

View file

@ -23,6 +23,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-separator": "^1.1.8",

View file

@ -45,6 +45,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-separator": "^1.1.8",

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -4,7 +4,6 @@ import { notFound } from "next/navigation";
import Link from "next/link";
import type { Assignment, Award, Media, Qualification, Rank, User } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { resolveLevel } from "@/utils/xp/resolveLevel";
import {
ArrowLeftIcon,
@ -32,6 +31,8 @@ import {
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import AwardCard from "@/components/frontend/account/AwardCard";
import { RibbonRack } from "@/components/frontend/account/RibbonRack";
interface ProfilePageProps {
params: Promise<{ username: string }>;
@ -151,6 +152,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
<span className="text-border">|</span>
<span className="font-mono text-xs">@{user.username}</span>
</div>
<RibbonRack awards={awards} />
<div className="flex items-center gap-2 mt-1">
<div className="flex-1 h-2 bg-border rounded-full overflow-hidden max-w-xs">
<div
@ -392,39 +394,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
{awards.map((award) => {
const awardImage =
award.image && typeof award.image === "object" ? (award.image as Media) : null;
return (
<Item key={award.id} variant="outline">
<ItemHeader>
<div className="flex items-center gap-2.5">
{awardImage?.url && (
<img
src={awardImage.url}
alt={award.name}
className="size-8 object-contain"
/>
)}
<div className="flex flex-col">
<ItemTitle>{award.name}</ItemTitle>
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-mono">
{award.type ?? "commendation"}
</span>
</div>
</div>
</ItemHeader>
<ItemContent>
<ItemDescription className="line-clamp-3">
{award.description
? convertLexicalToPlaintext({ data: award.description }).slice(0, 120)
: "No description."}
</ItemDescription>
{award.experiencePoints != null && (
<span className="text-xs font-mono text-muted-foreground mt-1">
+{award.experiencePoints} XP
</span>
)}
</ItemContent>
</Item>
);
return <AwardCard key={award.id} awardImage={awardImage} award={award} />;
})}
</div>
) : (

View file

@ -19,7 +19,7 @@ type Stripe = {
width: number;
};
type RibbonDesign = {
export type RibbonDesign = {
stripes: Stripe[];
devices: Device[];
mirrored: boolean;
@ -27,7 +27,7 @@ type RibbonDesign = {
// ─── Constants ──────────────────────────────────────────────────────
const DEFAULT_RIBBON: RibbonDesign = {
export const DEFAULT_RIBBON: RibbonDesign = {
stripes: [
{ color: "#b91c1c", width: 30 },
{ color: "#f5f5f4", width: 5 },
@ -62,16 +62,20 @@ const MILITARY_COLORS: { name: string; hex: string }[] = [
{ name: "Black", hex: "#18181b" },
];
const DEVICE_INFO: Record<DeviceType, { label: string; symbol: string }> = {
export const RIBBON_DEVICE_INFO: Record<DeviceType, { label: string; symbol: string }> = {
star: { label: "Star", symbol: "\u2605" },
oakLeaf: { label: "Oak Leaf", symbol: "\u{1F333}" },
arrowUp: { label: "Arrow", symbol: "\u2191" },
campaignClasp: { label: "Clasp", symbol: "\u2015" },
};
const RIBBON_ASPECT = 3.667;
const RIBBON_WIDTH = 366;
const PREVIEW_HEIGHT = 80;
export const RIBBON_CONSTANTS = {
RIBBON_ASPECT: 3.667,
RIBBON_WIDTH: 366,
PREVIEW_HEIGHT: 80,
};
const { RIBBON_ASPECT, RIBBON_WIDTH, PREVIEW_HEIGHT } = RIBBON_CONSTANTS;
// ─── Preview ────────────────────────────────────────────────────────
@ -103,7 +107,7 @@ function RibbonPreview({ design }: { design: RibbonDesign }) {
})()}
{devices.map((device) => {
const info = DEVICE_INFO[device.type];
const info = RIBBON_DEVICE_INFO[device.type];
const cx = (device.position / 100) * viewWidth;
const cy = (device.positionY / 100) * viewHeight;
const fontSize = Math.min(viewHeight * 0.45, 24);
@ -218,8 +222,8 @@ function DeviceEditor({
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-zinc-400 uppercase tracking-wider">Devices</span>
<div className="flex flex-wrap gap-1.5">
{(Object.keys(DEVICE_INFO) as DeviceType[]).map((type) => {
const info = DEVICE_INFO[type];
{(Object.keys(RIBBON_DEVICE_INFO) as DeviceType[]).map((type) => {
const info = RIBBON_DEVICE_INFO[type];
return (
<button
key={type}
@ -235,7 +239,7 @@ function DeviceEditor({
{devices.length > 0 && (
<div className="flex flex-col gap-1.5">
{devices.map((device) => {
const info = DEVICE_INFO[device.type];
const info = RIBBON_DEVICE_INFO[device.type];
return (
<div
key={device.id}

View file

@ -0,0 +1,50 @@
import { Award, Media } from "@/payload-types";
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
import { RibbonDesign } from "@/components/admin/awards/RibbonDesigner";
export default function AwardCard(props: {
awardImage: Media | null;
award: Award;
}) {
return (
<Item variant="outline">
<ItemHeader className="flex flex-col items-center text-center">
{props.award.ribbonDesign && (
<RibbonViewer
ribbonDesign={props.award.ribbonDesign as RibbonDesign}
showRibbonOnly={true}
/>
)}
<div className="flex items-center gap-2.5">
{props.awardImage?.url && (
<img
src={props.awardImage.url}
alt={props.award.name}
className="size-8 object-contain"
/>
)}
<div className="flex flex-col">
<ItemTitle>{props.award.name}</ItemTitle>
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-mono">
{props.award.type ?? "commendation"}
</span>
</div>
</div>
</ItemHeader>
<ItemContent>
<ItemDescription className="line-clamp-3 text-center">
{props.award.description
? convertLexicalToPlaintext({ data: props.award.description }).slice(0, 120)
: "No description."}
</ItemDescription>
{props.award.experiencePoints != null && (
<span className="text-xs font-mono text-muted-foreground mt-1">
+{props.award.experiencePoints} XP
</span>
)}
</ItemContent>
</Item>
);
}

View file

@ -0,0 +1,36 @@
import { RibbonDesign } from "@/components/admin/awards/RibbonDesigner";
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Award } from "@/payload-types";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
export const RibbonRack = ({ awards }: { awards: Award[] }) => {
return (
<div className="flex flex-row w-52 gap-1">
{awards
.filter((award) => award.ribbonDesign !== null)
.sort((a, b) => (b.experiencePoints ?? 0) - (a.experiencePoints ?? 0))
.map((award, key) => {
const ribbon = award.ribbonDesign as RibbonDesign;
return (
<Tooltip key={key}>
<TooltipTrigger className="w-full hover:scale-110 transition-transform">
<RibbonViewer ribbonDesign={ribbon} showRibbonOnly />
</TooltipTrigger>
<TooltipContent>
<div className="flex flex-col gap-1">
<div className="flex flex-row items-center justify-between gap-3">
<span className="font-bold text-lg">{award.name}</span>
<span className="text-amber-400/75">{award.experiencePoints} XP</span>
</div>
<p className="opacity-75">
{convertLexicalToPlaintext({ data: award.description })}
</p>
</div>
</TooltipContent>
</Tooltip>
);
})}
</div>
);
};

View file

@ -0,0 +1,73 @@
"use client";
import {
RIBBON_DEVICE_INFO,
RIBBON_CONSTANTS,
RibbonDesign,
DEFAULT_RIBBON,
} from "@/components/admin/awards/RibbonDesigner";
import { useState } from "react";
import { cn } from "@/lib/utils";
export const RibbonViewer = ({ ribbonDesign, showRibbonOnly }: { ribbonDesign: RibbonDesign, showRibbonOnly?: boolean }) => {
const initial: RibbonDesign =
ribbonDesign && typeof ribbonDesign === "object" && Array.isArray((ribbonDesign as RibbonDesign).stripes)
? (ribbonDesign as RibbonDesign)
: DEFAULT_RIBBON;
const [design] = useState<RibbonDesign>(initial);
const { stripes, devices, mirrored } = design;
if (stripes.length === 0) return null;
const displayStripes = mirrored ? [...stripes, ...stripes.slice(0, -1).reverse()] : stripes;
const totalWeight = displayStripes.reduce((sum, s) => sum + s.width, 0);
const viewWidth = RIBBON_CONSTANTS.RIBBON_WIDTH;
const viewHeight = RIBBON_CONSTANTS.PREVIEW_HEIGHT;
return (
<svg
viewBox={`0 0 ${viewWidth} ${viewHeight}`}
className={cn("h-auto max-h-20", {
"w-full rounded border border-zinc-700 bg-zinc-950": !showRibbonOnly
} )}
>
<rect x={0} y={0} width={viewWidth} height={viewHeight} rx={2} fill="#18181b" />
{(() => {
let x = 0;
return displayStripes.map((stripe, i) => {
const w = (stripe.width / totalWeight) * viewWidth;
const el = <rect key={i} x={x} y={0} width={w} height={viewHeight} fill={stripe.color} />;
x += w;
return el;
});
})()}
<image href="/images/ribbon-overlay.png" className="mix-blend-multiply w-full" />
{devices.map((device) => {
const info = RIBBON_DEVICE_INFO[device.type];
const cx = (device.position / 100) * viewWidth;
const cy = (device.positionY / 100) * viewHeight;
const fontSize = Math.min(viewHeight * 0.45, 24);
return (
<text
key={device.id}
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="central"
fill="#facc15"
fontSize={fontSize}
fontFamily="serif"
style={{ pointerEvents: "none" }}
className="text-shadow-sm text-shadow-black"
>
{info.symbol}
</text>
);
})}
</svg>
);
};

View file

@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]: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 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View file

@ -1,37 +1,57 @@
"use client";
"use client"
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider;
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
const Tooltip = TooltipPrimitive.Root;
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
const TooltipTrigger = TooltipPrimitive.Trigger;
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
arrowClassName?: string;
}
>(({ className, sideOffset = 4, arrowClassName, children, ...props }, ref) => (
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 origin-[--radix-tooltip-content-transform-origin]",
className,
"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",
className
)}
{...props}
>
{arrowClassName && <TooltipPrimitive.Arrow className={arrowClassName} />}
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-background fill-background" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }