Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import type { ReactElement } from 'react';
import React from 'react';
import React, { useCallback } from 'react';
import classNames from 'classnames';
import type { CommonLeaderboardProps } from './LeaderboardList';
import { LeaderboardList } from './LeaderboardList';
import { LeaderboardListItem } from './LeaderboardListItem';
import type { Company } from '../../../lib/userCompany';
import { ProfileImageSize, ProfilePicture } from '../../ProfilePicture';
import { indexToEmoji } from './common';
import {
runIconPopAnimation,
runSparkAnimation,
TOP_RANK_STYLES,
} from './common';
import { TopRankBadge } from './TopRankBadge';

export interface CompanyLeaderboard {
score: number;
Expand All @@ -16,15 +22,37 @@ export function CompanyTopList({
items,
...props
}: CommonLeaderboardProps<CompanyLeaderboard[]>): ReactElement {
const createRowMouseEnter = useCallback(
(rankIndex: number) => (e: React.MouseEvent<HTMLLIElement>) => {
const rankStyle = TOP_RANK_STYLES[rankIndex];
if (!rankStyle) {
return;
}

runIconPopAnimation(e.currentTarget, 'leaderboard-medal-wrapper');
runSparkAnimation(
e.currentTarget,
'.leaderboard-medal-spark',
rankStyle.glowColor,
16,
);
},
[],
);

return (
<LeaderboardList {...props}>
{items?.map((item, i) => (
<LeaderboardListItem
key={item.company.name}
index={i + 1}
className="flex w-full flex-row items-center rounded-8 px-2 hover:bg-accent-pepper-subtler"
className={classNames(
'group flex w-full flex-row items-center rounded-8 px-2 hover:bg-accent-pepper-subtler',
TOP_RANK_STYLES[i]?.hoverClass,
)}
onMouseEnter={TOP_RANK_STYLES[i] ? createRowMouseEnter(i) : undefined}
>
<span className="pl-1">{indexToEmoji(i)}</span>
<TopRankBadge rankIndex={i} />
<div className="relative flex min-w-0 flex-shrink flex-row items-center gap-4 p-2">
<ProfilePicture
size={ProfileImageSize.Medium}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface LeaderboardListItemProps {
children: ReactNode;
className?: string;
concatScore?: boolean;
onMouseEnter?: React.MouseEventHandler<HTMLLIElement>;
}

export function LeaderboardListItem({
Expand All @@ -19,14 +20,15 @@ export function LeaderboardListItem({
children,
className,
concatScore = true,
onMouseEnter,
}: LeaderboardListItemProps): ReactElement {
const formattedNumber = concatScore ? largeNumberFormat(index) : index;
const shouldShowTooltip =
concatScore && typeof index === 'number' && index >= 1000;
const actualNumber = index.toLocaleString();

return (
<li className={className}>
<li className={className} onMouseEnter={onMouseEnter}>
<ConditionalWrapper
condition={!!href}
wrapper={(child) => (
Expand All @@ -38,7 +40,7 @@ export function LeaderboardListItem({
)}
>
<Tooltip content={actualNumber} visible={shouldShowTooltip}>
<span className="inline-flex min-w-14 justify-center text-text-quaternary">
<span className="inline-flex w-14 shrink-0 justify-center tabular-nums text-text-quaternary">
{formattedNumber}
</span>
</Tooltip>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ export function SourceTopList({
return (
<LeaderboardList {...props}>
{items?.map((item, i) => (
<LeaderboardListItem key={item.id} index={i + 1} href={item.permalink}>
<LeaderboardListItem
key={item.id}
index={i + 1}
className="flex w-full flex-row items-center rounded-8 px-2 hover:bg-accent-pepper-subtler"
>
<UserHighlight
{...item}
userType={UserType.Source}
Expand Down
66 changes: 66 additions & 0 deletions packages/shared/src/components/cards/Leaderboard/TopRankBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { ReactElement } from 'react';
import React, { useCallback } from 'react';
import { MedalBadgeIcon } from '../../icons/MedalBadge';
import { IconSize } from '../../Icon';
import {
runIconPopAnimation,
runSparkAnimation,
TOP_RANK_STYLES,
} from './common';

const SPARK_COUNT = 5;

interface TopRankBadgeProps {
rankIndex: number;
}

export function TopRankBadge({ rankIndex }: TopRankBadgeProps): ReactElement {
const rankStyle = TOP_RANK_STYLES[rankIndex];

const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLSpanElement>) => {
if (!rankStyle) {
return;
}
runIconPopAnimation(e.currentTarget, 'leaderboard-medal-wrapper');
runSparkAnimation(
e.currentTarget,
'.leaderboard-medal-spark',
rankStyle.glowColor,
16,
);
},
[rankStyle],
);

if (!rankStyle) {
return <span className="w-8 shrink-0" />;
}

return (
<span
className="relative flex w-8 shrink-0 items-center justify-center pl-1"
onMouseEnter={handleMouseEnter}
>
<span
className="pointer-events-none absolute left-1/2 top-1/2 h-8 w-8 -translate-x-1/2 -translate-y-1/2 rounded-full opacity-0 blur-md transition-opacity duration-300 group-hover:opacity-40"
style={{ backgroundColor: rankStyle.glowColor }}
/>
<span className="leaderboard-medal-wrapper relative transition-transform duration-300">
<MedalBadgeIcon
size={IconSize.Small}
secondary
className={rankStyle.iconColor}
/>
<span className="pointer-events-none absolute inset-0">
{Array.from({ length: SPARK_COUNT }, (_, i) => (
<span
key={i}
className="leaderboard-medal-spark absolute left-1/2 top-1/2 h-1 w-1 rounded-full"
/>
))}
</span>
</span>
</span>
);
}
36 changes: 32 additions & 4 deletions packages/shared/src/components/cards/Leaderboard/UserTopList.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import type { ReactElement } from 'react';
import React from 'react';
import React, { useCallback } from 'react';
import classNames from 'classnames';
import type { CommonLeaderboardProps } from './LeaderboardList';
import { LeaderboardList } from './LeaderboardList';
import { LeaderboardListItem } from './LeaderboardListItem';
import { UserHighlight } from '../../widgets/PostUsersHighlights';
import type { LoggedUser } from '../../../lib/user';
import { indexToEmoji } from './common';
import {
runIconPopAnimation,
runSparkAnimation,
TOP_RANK_STYLES,
} from './common';
import { TopRankBadge } from './TopRankBadge';

export interface UserLeaderboard {
score: number;
Expand All @@ -17,16 +23,38 @@ export function UserTopList({
concatScore = true,
...props
}: CommonLeaderboardProps<UserLeaderboard[]>): ReactElement {
const createRowMouseEnter = useCallback(
(rankIndex: number) => (e: React.MouseEvent<HTMLLIElement>) => {
const rankStyle = TOP_RANK_STYLES[rankIndex];
if (!rankStyle) {
return;
}

runIconPopAnimation(e.currentTarget, 'leaderboard-medal-wrapper');
runSparkAnimation(
e.currentTarget,
'.leaderboard-medal-spark',
rankStyle.glowColor,
16,
);
},
[],
);

return (
<LeaderboardList {...props}>
{items?.map((item, i) => (
<LeaderboardListItem
key={item.user.id}
index={item.score}
href={item.user.permalink}
concatScore={concatScore}
className={classNames(
'group flex w-full flex-row items-center rounded-8 px-2 hover:bg-accent-pepper-subtler',
TOP_RANK_STYLES[i]?.hoverClass,
)}
onMouseEnter={TOP_RANK_STYLES[i] ? createRowMouseEnter(i) : undefined}
>
<span className="min-w-8 pl-1">{indexToEmoji(i)}</span>
<TopRankBadge rankIndex={i} />
<UserHighlight
{...item.user}
showReputation
Expand Down
76 changes: 66 additions & 10 deletions packages/shared/src/components/cards/Leaderboard/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,71 @@ export const LeaderboardCard = classed(
'flex flex-col border-b border-border-subtlest-tertiary p-4 tablet:rounded-12 tablet:border tablet:bg-surface-float',
);

export const indexToEmoji = (index: number): string => {
switch (index) {
case 0:
return '🏆';
case 1:
return '🥈';
case 2:
return '🥉';
default:
return '';
export interface TopRankStyle {
iconColor: string;
glowColor: string;
hoverClass: string;
}

export const TOP_RANK_STYLES: TopRankStyle[] = [
{
iconColor: 'text-accent-cheese-default',
glowColor: 'var(--theme-accent-cheese-default)',
hoverClass: 'leaderboard-rank-gold',
},
{
iconColor: 'text-text-tertiary',
glowColor: 'var(--theme-text-tertiary)',
hoverClass: 'leaderboard-rank-silver',
},
{
iconColor: 'text-accent-bacon-default',
glowColor: 'var(--theme-accent-bacon-default)',
hoverClass: 'leaderboard-rank-bronze',
},
];

const forceReflow = (element: Element): void => {
element.getBoundingClientRect();
};

export const runSparkAnimation = (
container: HTMLElement,
selector: string,
glowColor: string,
radius: number,
): void => {
const sparks = container.querySelectorAll<HTMLElement>(selector);
sparks.forEach((el, i) => {
const { style } = el;
const slot = 360 / Math.max(sparks.length, 1);
const jitter = (Math.random() - 0.5) * slot * 0.4;
const angle = i * slot + jitter;
const rad = (angle * Math.PI) / 180;
const r = radius + Math.random() * (radius * 0.4);

style.setProperty('--spark-fx', `${Math.round(Math.sin(rad) * r)}px`);
style.setProperty('--spark-fy', `${Math.round(-Math.cos(rad) * r)}px`);
style.backgroundColor = glowColor;

style.animation = 'none';
forceReflow(el);
style.animation = `leaderboard-medal-spark 0.7s ease-out ${(
Math.random() * 0.06
).toFixed(2)}s forwards`;
});
};

export const runIconPopAnimation = (
container: HTMLElement,
wrapperClass: string,
): void => {
const iconWrapper = container.querySelector<HTMLElement>(`.${wrapperClass}`);
if (!iconWrapper) {
return;
}

iconWrapper.style.animation = 'none';
forceReflow(iconWrapper);
iconWrapper.style.animation = 'crown-icon-pop 0.8s ease-out';
};
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,7 @@ export const UserHighlight = (props: UserHighlightProps): ReactElement => {
</ProfileTooltip>
)}
>
<ProfileLink href={permalink}>
<Image {...props} className={className?.image} />
</ProfileLink>
<Image {...props} className={className?.image} />
</ConditionalWrapper>
{userType && Icon && (
<Icon
Expand Down
47 changes: 47 additions & 0 deletions packages/shared/src/styles/utilities.css
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,53 @@
opacity: 0;
}

@keyframes leaderboard-medal-spark {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 0;
}

15% {
opacity: 0.9;
}

100% {
transform: translate(
calc(-50% + var(--spark-fx, 0px)),
calc(-50% + var(--spark-fy, 0px))
)
scale(0);
opacity: 0;
}
}

.leaderboard-medal-spark {
opacity: 0;
}

.leaderboard-rank-gold:hover {
box-shadow: 0 0 24px
color-mix(
in srgb,
var(--theme-accent-cheese-default) 18%,
transparent
);
}

.leaderboard-rank-silver:hover {
box-shadow: 0 0 24px
color-mix(in srgb, var(--theme-text-tertiary) 14%, transparent);
}

.leaderboard-rank-bronze:hover {
box-shadow: 0 0 24px
color-mix(
in srgb,
var(--theme-accent-bacon-default) 18%,
transparent
);
}

@keyframes crown-icon-bloom-pulse {
0%,
60%,
Expand Down