1
0
Fork 0

feat(awards): render backgroundImage as faded backdrop on medal cards

This commit is contained in:
Jason Fraley 2026-09-02 16:14:03 -04:00
parent d8867a643a
commit d952a33ee7
6 changed files with 119 additions and 29 deletions

View file

@ -73,8 +73,17 @@ export default async function AwardsPage() {
{section.items.map((award) => {
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} />
<AwardWithGrantButton
key={award.id}
award={award}
awardImage={awardImage}
backgroundImage={backgroundImage}
/>
);
})}
</div>

View file

@ -623,7 +623,18 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
.map((award) => {
const awardImage =
award.image && typeof award.image === "object" ? (award.image as Media) : null;
return <AwardCard key={award.id} awardImage={awardImage} award={award} />;
const backgroundImage =
award.backgroundImage && typeof award.backgroundImage === "object"
? (award.backgroundImage as Media)
: null;
return (
<AwardCard
key={award.id}
awardImage={awardImage}
backgroundImage={backgroundImage}
award={award}
/>
);
})}
</div>
) : (

View file

@ -406,38 +406,69 @@
.medal-card::after {
content: "";
z-index: 2;
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;
pointer-events: none;
}
.medal-card > .medal-card-bg,
.medal-card > .medal-card-scrim {
position: absolute !important;
inset: 0;
z-index: 0 !important;
}
.medal-card > .medal-card-bg {
width: 100%;
height: 100%;
border-radius: inherit;
}
.medal-card > .medal-card-scrim {
border-radius: inherit;
background-color: rgb(0 0 0 / 0.42);
box-shadow: inset 0 0 2.25rem rgb(0 0 0 / 0.38);
}
.medal-card.medal-card-overflow {
overflow: visible;
}
.award-xp-high {
background: linear-gradient(110deg, rgb(245 158 11), rgb(254 249 195), rgb(250 204 21), rgb(245 158 11));
background-size: 200% auto;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
filter: drop-shadow(0 0 0.3rem rgb(250 204 21 / 0.35));
animation: award-xp-shimmer 2.5s linear infinite;
background: linear-gradient(
110deg,
rgb(245 158 11),
rgb(254 249 195),
rgb(250 204 21),
rgb(245 158 11)
);
background-size: 200% auto;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
filter: drop-shadow(0 0 0.3rem rgb(250 204 21 / 0.35));
animation: award-xp-shimmer 2.5s linear infinite;
}
@keyframes award-xp-shimmer {
to {
background-position: 200% center;
}
to {
background-position: 200% center;
}
}
@media (prefers-reduced-motion: reduce) {
.award-xp-high {
animation: none;
}
.award-xp-high {
animation: none;
}
}
.commendation-card {
align-self: start;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
align-self: start;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
}
/* Scrollbars — styled to match the dark frontend theme */

View file

@ -13,7 +13,11 @@ const getExperiencePointsClass = (experiencePoints: number) => {
return "text-muted-foreground";
};
export default function AwardCard(props: { awardImage: Media | null; award: Award }) {
export default function AwardCard(props: {
awardImage: Media | null;
award: Award;
backgroundImage?: Media | null;
}) {
const isCommendation = props.award.type === "commendation" || !props.award.type;
const commendationColor =
isCommendation &&
@ -22,12 +26,14 @@ export default function AwardCard(props: { awardImage: Media | null; award: Awar
? props.award.commendationColor
: null;
const bgUrl = props.backgroundImage?.url;
return (
<Item
variant="outline"
size={isCommendation ? "sm" : "default"}
className={cn("text-center", {
"medal-card": props.award.type === "medal",
className={cn("relative overflow-hidden text-center", {
"medal-card medal-card-overflow": props.award.type === "medal",
"commendation-card self-start h-fit": isCommendation,
})}
style={
@ -39,7 +45,21 @@ export default function AwardCard(props: { awardImage: Media | null; award: Awar
: undefined
}
>
<ItemHeader className="flex flex-col items-center">
{bgUrl && (
<>
<img
src={bgUrl}
alt=""
aria-hidden
className="medal-card-bg absolute inset-0 w-full h-full object-cover pointer-events-none select-none"
/>
<div
aria-hidden
className="medal-card-scrim absolute inset-0 pointer-events-none bg-gradient-to-b from-background/70 via-background/60 to-background/80"
/>
</>
)}
<ItemHeader className="relative z-10 flex flex-col items-center">
{props.award.ribbonDesign && (
<RibbonViewer
ribbonDesign={props.award.ribbonDesign as RibbonDesign}
@ -69,7 +89,7 @@ export default function AwardCard(props: { awardImage: Media | null; award: Awar
</div>
</div>
</ItemHeader>
<ItemContent>
<ItemContent className="relative z-10">
<ItemDescription
className={cn("text-center", isCommendation ? "line-clamp-none text-xs" : "line-clamp-3")}
>

View file

@ -20,6 +20,7 @@ import { AwardIcon, LockIcon } from "lucide-react";
interface AwardListingCardProps {
award: Award;
awardImage?: Media | null;
backgroundImage?: Media | null;
showGrantButton?: boolean;
onGrantClick?: () => void;
canGrant?: boolean | null;
@ -29,6 +30,7 @@ interface AwardListingCardProps {
export function AwardListingCard({
award,
awardImage,
backgroundImage,
showGrantButton = false,
onGrantClick,
canGrant,
@ -42,16 +44,31 @@ export function AwardListingCard({
typeof award.ribbonDesign === "object" &&
Array.isArray((award.ribbonDesign as RibbonDesign).stripes);
const hasArt = hasRibbonArt || Boolean(awardImage?.url);
const bgUrl = backgroundImage?.url;
return (
<Item
variant="outline"
className={cn(
"min-w-0 h-full flex flex-col flex-nowrap text-center",
isMedal && "justify-center medal-card",
"relative overflow-hidden min-w-0 h-full flex flex-col flex-nowrap text-center",
isMedal && "justify-center medal-card medal-card-overflow",
)}
>
<ItemHeader className={cn("flex flex-col items-center gap-2", isMedal && "justify-center")}>
{bgUrl && (
<>
<img
src={bgUrl}
alt=""
aria-hidden
className="medal-card-bg absolute inset-0 w-full h-full object-cover pointer-events-none select-none"
/>
<div
aria-hidden
className="medal-card-scrim absolute inset-0 pointer-events-none bg-gradient-to-b from-background/70 via-background/60 to-background/80"
/>
</>
)}
<ItemHeader className={cn("relative z-10 flex flex-col items-center gap-2", isMedal && "justify-center")}>
{hasArt && (
<div
className={cn(
@ -80,7 +97,7 @@ export function AwardListingCard({
</span>
</ItemHeader>
<ItemContent className="flex flex-col gap-2">
<ItemContent className="relative z-10 flex flex-col gap-2">
<ItemDescription className="min-w-0 break-words text-center text-sm leading-relaxed">
{award.description
? convertLexicalToPlaintext({ data: award.description })
@ -94,7 +111,7 @@ export function AwardListingCard({
</ItemContent>
{showGrantButton && (
<div className="mt-auto w-full">
<div className="relative z-10 mt-auto w-full">
{canGrant === false ? (
<Button
variant="outline"

View file

@ -11,9 +11,10 @@ import { getGrantStats } from "@/app/(frontend)/awards/grant-context";
interface AwardWithGrantButtonProps {
award: Award;
awardImage?: Media | null;
backgroundImage?: Media | null;
}
export function AwardWithGrantButton({ award, awardImage }: AwardWithGrantButtonProps) {
export function AwardWithGrantButton({ award, awardImage, backgroundImage }: AwardWithGrantButtonProps) {
const [open, setOpen] = useState(false);
const [canGrant, setCanGrant] = useState<boolean | null>(null);
const [remainingGrants, setRemainingGrants] = useState<number | null | undefined>(undefined);
@ -40,6 +41,7 @@ export function AwardWithGrantButton({ award, awardImage }: AwardWithGrantButton
<AwardListingCard
award={award}
awardImage={awardImage}
backgroundImage={backgroundImage}
showGrantButton
onGrantClick={() => setOpen(true)}
canGrant={canGrant}