Skip to content
Open
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
95 changes: 95 additions & 0 deletions components/contributors/BadgeDisplay.css
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,101 @@
align-items: stretch;
}

/* Overflow chip */
.badge-overflow-chip {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 12px;
border: 2px dashed var(--theme-popup-border);
background: var(--quote-bg, rgba(0, 0, 0, 0.05));
font-size: 0.6875rem;
font-weight: 700;
color: var(--fg);
opacity: 0.7;
cursor: default;
transition: opacity 0.2s ease, border-color 0.2s ease;
z-index: 1;
}

.badge-overflow-chip:hover {
opacity: 1;
border-color: var(--vocs-color_codeInlineBg);
z-index: 99999;
}

.badge-overflow-chip::before {
content: '';
position: absolute;
bottom: 100%;
left: -8px;
right: -8px;
height: 14px;
}

.badge-overflow-tooltip {
position: absolute;
bottom: calc(100% + 14px);
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, #1f2937 0%, #111827 100%);
padding: 0.625rem;
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.1);
z-index: 999999;
opacity: 0;
visibility: hidden;
}

.badge-overflow-tooltip { width: max-content; }

.badge-overflow-tooltip::before {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 7px solid transparent;
border-top-color: #111827;
}

.badge-overflow-chip:hover .badge-overflow-tooltip,
.badge-overflow-tooltip:hover {
opacity: 1;
visibility: visible;
}

.badge-overflow-tooltip-grid {
display: grid;
gap: 0.5rem;
justify-items: center;
}

.badge-overflow-tooltip-grid[data-cols="1"] { grid-template-columns: repeat(1, 48px); }
.badge-overflow-tooltip-grid[data-cols="2"] { grid-template-columns: repeat(2, 48px); }
.badge-overflow-tooltip-grid[data-cols="3"] { grid-template-columns: repeat(3, 48px); }
.badge-overflow-tooltip-grid[data-cols="4"] { grid-template-columns: repeat(4, 48px); }

.badge-overflow-tooltip .badge-wrapper {
z-index: 1;
}

.badge-overflow-tooltip .badge-wrapper:hover {
z-index: 99999;
}

:root:not(.dark) .badge-overflow-tooltip {
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12), 0 0 0 1px rgba(15, 23, 42, 0.08);
}

:root:not(.dark) .badge-overflow-tooltip::before {
border-top-color: #ffffff;
}

/* Badge Wrapper */
.badge-wrapper {
position: relative;
Expand Down
156 changes: 93 additions & 63 deletions components/contributors/BadgeDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ function getBadgeDateLabel(badge: Badge): 'Earned' | 'Last active' {
return badge.lastActive ? 'Last active' : 'Earned';
}

const COMPACT_MAX_VISIBLE = 5;

interface BadgeDisplayProps {
contributorSlug?: string;
badges?: Badge[];
Expand All @@ -79,6 +81,66 @@ interface BadgeDisplayProps {
layout?: 'grid' | 'stack';
}

function BadgeCard({ badge, index, compact }: { badge: Badge; index: number; compact: boolean }) {
const config = getBadgeConfig(badge.name);
const effectiveDate = getBadgeDate(badge);
const dateLabel = getBadgeDateLabel(badge);
const isNew = isNewlyEarned(effectiveDate);
const badgeDate = formatDate(effectiveDate);
const badgeKey = `${badge.name}-${badge.framework || ''}-${index}`;
const badgeLabel = badge.name === 'Framework-Steward' ? 'Steward' : config.label;
const badgeDescription = badge.framework && badge.name === 'Framework-Steward'
? `Steward of the ${badge.framework} framework`
: config.description;

return (
<div
key={badgeKey}
className={`badge-wrapper tier-${config.tier} ${isNew ? 'newly-earned' : ''} ${config.category}`}
style={{
'--delay': `${index * 0.08}s`,
'--badge-color': config.color,
'--tier-glow': `${config.color}33`
} as React.CSSProperties}
title={`${badgeLabel} - ${badgeDescription}`}
>
<div className="badge-card">
<BadgeIcon name={badge.name} isNew={isNew} />
{isNew && (
<div className="new-indicator">
<span className="pulse-dot"></span>
</div>
)}
{!compact && (
<div className="badge-tier-indicator">
{config.tier === 'legendary' && '👑'}
{config.tier === 'epic' && '💎'}
{config.tier === 'rare' && '⭐'}
</div>
)}
</div>

<div className="badge-tooltip">
<div className="tooltip-header">
<strong>{badgeLabel}</strong>
<span className={`tier-badge tier-${config.tier}`}>
{config.tier?.toUpperCase()}
</span>
</div>
<p className="tooltip-description">{badgeDescription}</p>
{badgeDate && (
<div className="tooltip-footer">
<span className="tooltip-date">
{isNew && <span className="new-badge-text">✨ NEW</span>}
{dateLabel} {badgeDate}
</span>
</div>
)}
</div>
</div>
);
}

export function BadgeDisplay({
contributorSlug,
badges,
Expand All @@ -93,7 +155,7 @@ export function BadgeDisplay({
if (badges) {
displayBadges = badges.filter(b => b.name && b.name.trim() !== '');
} else if (contributorSlug) {
const contributors = contributorsData as Record<string, Contributor>;
const contributors = contributorsData as unknown as Record<string, Contributor>;
const contributor = contributors[contributorSlug];
if (contributor?.badges) {
displayBadges = contributor.badges.filter(b => b.name && b.name.trim() !== '');
Expand All @@ -102,12 +164,22 @@ export function BadgeDisplay({

if (displayBadges.length === 0) return null;

// Sort badges chronologically by date (newest first)
const sortedBadges = [...displayBadges].sort((a, b) => {
const dateA = new Date(getBadgeDate(a) || '1970-01-01').getTime();
const dateB = new Date(getBadgeDate(b) || '1970-01-01').getTime();
return dateB - dateA;
});
const byDateDesc = (a: Badge, b: Badge) =>
new Date(getBadgeDate(b) || '1970-01-01').getTime() - new Date(getBadgeDate(a) || '1970-01-01').getTime();

let visibleBadges: Badge[];
let hiddenBadges: Badge[];

if (compact) {
const roleBadges = displayBadges.filter(b => getBadgeConfig(b.name).category === 'role').sort(byDateDesc);
const nonRoleBadges = displayBadges.filter(b => getBadgeConfig(b.name).category !== 'role').sort(byDateDesc);
const nonRoleSlots = Math.max(0, COMPACT_MAX_VISIBLE - roleBadges.length);
visibleBadges = [...nonRoleBadges.slice(0, nonRoleSlots), ...roleBadges];
hiddenBadges = nonRoleBadges.slice(nonRoleSlots);
} else {
visibleBadges = [...displayBadges].sort(byDateDesc);
hiddenBadges = [];
}

return (
<div
Expand All @@ -122,67 +194,25 @@ export function BadgeDisplay({
)}

<div className={`badges-container ${layout}`}>
{sortedBadges.map((badge, index) => {
const config = getBadgeConfig(badge.name);
const effectiveDate = getBadgeDate(badge);
const dateLabel = getBadgeDateLabel(badge);
const isNew = isNewlyEarned(effectiveDate);
const badgeDate = formatDate(effectiveDate);
const badgeKey = `${badge.name}-${badge.framework || ''}-${index}`;
const badgeLabel = badge.name === 'Framework-Steward'
? 'Steward'
: config.label;
const badgeDescription = badge.framework && badge.name === 'Framework-Steward'
? `Steward of the ${badge.framework} framework`
: config.description;

{visibleBadges.map((badge, index) => (
<BadgeCard key={`${badge.name}-${badge.framework || ''}-${index}`} badge={badge} index={index} compact={compact} />
))}
{hiddenBadges.length > 0 && (() => {
const count = hiddenBadges.length;
const cols = count <= 3 ? count : count === 4 ? 2 : 4;
return (
<div
key={badgeKey}
className={`badge-wrapper tier-${config.tier} ${isNew ? 'newly-earned' : ''} ${config.category}`}
style={{
'--delay': `${index * 0.08}s`,
'--badge-color': config.color,
'--tier-glow': `${config.color}33`
} as React.CSSProperties}
title={`${badgeLabel} - ${badgeDescription}`}
>
<div className="badge-card">
<BadgeIcon name={badge.name} isNew={isNew} />
{isNew && (
<div className="new-indicator">
<span className="pulse-dot"></span>
</div>
)}
{!compact && (
<div className="badge-tier-indicator">
{config.tier === 'legendary' && '👑'}
{config.tier === 'epic' && '💎'}
{config.tier === 'rare' && '⭐'}
</div>
)}
</div>

<div className="badge-tooltip">
<div className="tooltip-header">
<strong>{badgeLabel}</strong>
<span className={`tier-badge tier-${config.tier}`}>
{config.tier?.toUpperCase()}
</span>
<div className="badge-overflow-chip">
+{count}
<div className="badge-overflow-tooltip" data-cols={cols}>
<div className="badge-overflow-tooltip-grid" data-cols={cols}>
{hiddenBadges.map((badge, index) => (
<BadgeCard key={`overflow-${badge.name}-${badge.framework || ''}-${index}`} badge={badge} index={index} compact={true} />
))}
</div>
<p className="tooltip-description">{badgeDescription}</p>
{badgeDate && (
<div className="tooltip-footer">
<span className="tooltip-date">
{isNew && <span className="new-badge-text">✨ NEW</span>}
{dateLabel} {badgeDate}
</span>
</div>
)}
</div>
</div>
);
})}
})()}
</div>
</div>
);
Expand Down
Loading
Loading