feat(awards): add AwardCard and RibbonRack components for award display
This commit is contained in:
parent
9e91203330
commit
b060b967c0
10 changed files with 264 additions and 76 deletions
1
bun.lock
1
bun.lock
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
BIN
public/images/ribbon-overlay.png
Normal file
BIN
public/images/ribbon-overlay.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
50
src/components/frontend/account/AwardCard.tsx
Normal file
50
src/components/frontend/account/AwardCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/components/frontend/account/RibbonRack.tsx
Normal file
36
src/components/frontend/account/RibbonRack.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
73
src/components/frontend/account/RibbonViewer.tsx
Normal file
73
src/components/frontend/account/RibbonViewer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
33
src/components/ui/popover.tsx
Normal file
33
src/components/ui/popover.tsx
Normal 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 }
|
||||
|
|
@ -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;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
|
||||
arrowClassName?: string;
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>(({ className, sideOffset = 4, arrowClassName, children, ...props }, ref) => (
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
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 }
|
||||
|
|
|
|||
Loading…
Reference in a new issue