Drop Media/Badges/Likes tabs, redesign profile tab bar

Tabs:
  - Activity, Campaigns, Pledges, Posts. That's it.
  - Media / Badges / Likes removed along with their renderers,
    ProfileBadgesTab function (152 LoC), useProfileMedia + useProfileLikes
    hooks, mediaEvents / likedItems / mediaFeedItems / likedFeedItems
    memos, the sidebarMediaUrl state and its callbacks, and the
    profile-media / profile-likes-infinite cache invalidations.
  - The 'isCoreProfileTab' fallthrough now only handles posts/replies.

Visuals:
  - New ProfileTabs component replaces the global SubHeaderBar +
    TabButton pair on this page. It's a clean column-local sticky bar
    with backdrop-blurred translucent background, hairline bottom
    border, and an animated underline that slides between active tabs.
    No arc decoration, no hover-slice tracking — just tabs.
  - Sticks to top-mobile-bar on small screens (clears mobile chrome)
    and top-0 from sidebar breakpoint up. Active tab auto-scrolls into
    view when it overflows the column.

Net: -289 LoC in ProfilePage.tsx; +118 LoC for the new ProfileTabs.
This commit is contained in:
mkfain
2026-05-21 23:50:35 -05:00
parent 6e7fcb8732
commit 121991f3e5
2 changed files with 156 additions and 315 deletions
+130
View File
@@ -0,0 +1,130 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
export interface ProfileTabsProps {
tabs: Array<{ id: string; label: string }>;
activeTab: string;
onChange: (id: string) => void;
}
/**
* Profile-local tab bar.
*
* A focused alternative to the global `SubHeaderBar` — no arc decoration,
* no hover slice tracking, no FAB-aware spacing. Just a clean horizontal
* row with an animated underline marking the active tab. Designed for the
* 4-tab profile case (Activity / Campaigns / Pledges / Posts).
*
* Behavior:
* - Sticks to the top of its containing scroll context. The parent column
* can place it inside any scroll region; the bar uses `position: sticky`.
* - Underline animates between active tabs via a single absolute-positioned
* indicator measured from the active tab's offset/width.
* - Horizontally scrolls when overflowing; auto-scrolls the active tab into
* view on selection (matches the previous TabButton behavior).
*/
export function ProfileTabs({ tabs, activeTab, onChange }: ProfileTabsProps) {
const trackRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const [indicator, setIndicator] = useState<{ left: number; width: number } | null>(null);
// Measure the active tab and update the underline indicator.
useLayoutEffect(() => {
const btn = tabRefs.current.get(activeTab);
if (!btn) {
setIndicator(null);
return;
}
setIndicator({ left: btn.offsetLeft, width: btn.offsetWidth });
}, [activeTab, tabs]);
// Recompute on resize (label-width changes between breakpoints).
useEffect(() => {
const onResize = () => {
const btn = tabRefs.current.get(activeTab);
if (!btn) return;
setIndicator({ left: btn.offsetLeft, width: btn.offsetWidth });
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [activeTab]);
// Scroll the active tab into view when activated (overflow scroll case).
useLayoutEffect(() => {
const btn = tabRefs.current.get(activeTab);
const track = trackRef.current;
if (!btn || !track) return;
const left = btn.offsetLeft;
const right = left + btn.offsetWidth;
const viewLeft = track.scrollLeft;
const viewRight = viewLeft + track.clientWidth;
if (left < viewLeft) {
track.scrollTo({ left: left - 8, behavior: 'smooth' });
} else if (right > viewRight) {
track.scrollTo({ left: right - track.clientWidth + 8, behavior: 'smooth' });
}
}, [activeTab]);
return (
<div
className={cn(
// Stickiness — sits at the top of the column scroll. `top-mobile-bar`
// matches the existing app convention so it sits flush with the
// mobile top nav. On desktop the chrome shifts and we use top-0.
'sticky top-mobile-bar sidebar:top-0 z-10',
// Visual separation — translucent backdrop so feed content doesn't
// bleed through, with a single hairline border below.
'bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60',
'border-b border-border/60',
'-mx-4 sm:-mx-6 lg:mx-0',
)}
>
<div
ref={trackRef}
className="relative flex overflow-x-auto scrollbar-none px-4 sm:px-6 lg:px-0"
>
{tabs.map((tab) => {
const active = tab.id === activeTab;
return (
<button
key={tab.id}
ref={(el) => {
if (el) tabRefs.current.set(tab.id, el);
else tabRefs.current.delete(tab.id);
}}
onClick={() => {
if (active) {
window.scrollTo({ top: 0, behavior: 'smooth' });
} else {
window.scrollTo({ top: 0 });
onChange(tab.id);
}
}}
className={cn(
'relative shrink-0 px-4 py-3.5 text-sm font-medium whitespace-nowrap',
'transition-colors duration-150',
'focus:outline-none focus-visible:bg-secondary/40 rounded-sm',
active
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
aria-selected={active}
role="tab"
>
{tab.label}
</button>
);
})}
{/* Active-tab underline indicator. Animates between tab positions. */}
{indicator && (
<span
aria-hidden
className="absolute bottom-0 h-0.5 bg-primary rounded-full transition-all duration-200 ease-out"
style={{ left: indicator.left, width: indicator.width }}
/>
)}
</div>
</div>
);
}
+26 -315
View File
@@ -28,10 +28,8 @@ import { usePinnedNotes } from '@/hooks/usePinnedNotes';
import { useFollowList, useFollowActions } from '@/hooks/useFollowActions';
import { useMuteList } from '@/hooks/useMuteList';
import { isEventMuted } from '@/lib/muteHelpers';
import { useProfileFeed, useProfileLikes as useProfileLikesInfinite, filterByTab } from '@/hooks/useProfileFeed';
import { useProfileFeed, filterByTab } from '@/hooks/useProfileFeed';
import type { ProfileTab as CoreProfileTab } from '@/hooks/useProfileFeed';
import { useProfileMedia } from '@/hooks/useProfileMedia';
import { MediaCollage, MediaCollageSkeleton } from '@/components/MediaCollage';
import { useProfileSupplementary } from '@/hooks/useProfileData';
import { useNip05Resolve } from '@/hooks/useNip05Resolve';
import { genUserName } from '@/lib/genUserName';
@@ -63,9 +61,8 @@ import { ProfileIdentityRail } from '@/components/profile/ProfileIdentityRail';
import { ProfileCampaignsTab } from '@/components/profile/ProfileCampaignsTab';
import { ProfilePledgesTab } from '@/components/profile/ProfilePledgesTab';
import { ProfileActivityTab } from '@/components/profile/ProfileActivityTab';
import { ProfileTabs } from '@/components/profile/ProfileTabs';
import type { ParsedCampaign } from '@/lib/campaign';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import type { AddrCoords } from '@/hooks/useEvent';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { impactMedium } from '@/lib/haptics';
@@ -866,13 +863,12 @@ function ProfileBannerImage({ src, onClick }: { src: string; onClick: () => void
// ----- Main Component -----
const DEFAULT_TAB_LABELS = ['Activity', 'Campaigns', 'Pledges', 'Posts', 'Media', 'Badges', 'Likes'];
const DEFAULT_TAB_LABELS = ['Activity', 'Campaigns', 'Pledges', 'Posts'];
// Map from display label → internal tab id for core tabs
const CORE_TAB_IDS: Record<string, string> = {
'Activity': 'activity', 'Campaigns': 'campaigns', 'Pledges': 'pledges',
'Posts': 'posts', 'Posts & replies': 'replies',
'Media': 'media', 'Badges': 'badges', 'Likes': 'likes',
};
export function ProfilePage() {
@@ -886,7 +882,6 @@ export function ProfilePage() {
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<CoreProfileTab | string>('activity');
const [sidebarMediaUrl, setSidebarMediaUrl] = useState<string | null>(null);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [followQROpen, setFollowQROpen] = useState(false);
const [followingModalOpen, setFollowingModalOpen] = useState(false);
@@ -1089,7 +1084,7 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe
// Keep the active tab in sync if it ever falls out of the recognized set
// (e.g. on first mount, or if a user navigates with a stale tab id).
useEffect(() => {
const isCoreTab = ['campaigns', 'pledges', 'activity', 'posts', 'replies', 'media', 'badges', 'likes'].includes(activeTab);
const isCoreTab = ['campaigns', 'pledges', 'activity', 'posts', 'replies'].includes(activeTab);
if (!isCoreTab) {
setActiveTab(firstTabId);
}
@@ -1107,7 +1102,7 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe
isFetchingNextPage: isFetchingNextFeedPage,
} = useProfileFeed(
pubkey,
(['posts', 'replies', 'media', 'likes', 'badges'].includes(activeTab) ? activeTab : 'posts') as CoreProfileTab,
(['posts', 'replies'].includes(activeTab) ? activeTab : 'posts') as CoreProfileTab,
true,
);
@@ -1140,24 +1135,6 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe
description: metadata?.about || 'Nostr profile',
});
// Profile media — NIP-50 `media:true` search via the configured read pool.
const {
data: mediaData,
isPending: mediaPending,
fetchNextPage: fetchNextMediaPage,
hasNextPage: hasNextMediaPage,
isFetchingNextPage: isFetchingNextMediaPage,
} = useProfileMedia(pubkey, true);
// Infinite-scroll likes
const {
data: likesData,
isPending: likesPending,
fetchNextPage: fetchNextLikesPage,
hasNextPage: hasNextLikesPage,
isFetchingNextPage: isFetchingNextLikesPage,
} = useProfileLikesInfinite(pubkey, activeTab === 'likes');
// Follow list (cached, for display checks only)
const { data: followData } = useFollowList();
@@ -1256,41 +1233,6 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe
return items;
}, [feedData?.pages, muteItems]);
// Flatten media pages for the sidebar and media tab
const mediaEvents = useMemo(() => {
if (!mediaData?.pages) return [];
const seen = new Set<string>();
const events: NostrEvent[] = [];
for (const page of mediaData.pages) {
for (const event of page.events) {
if (!seen.has(event.id)) {
seen.add(event.id);
events.push(event);
}
}
}
return events;
}, [mediaData?.pages]);
// Profile badges are queried inside ProfileIdentityRail; the page no
// longer needs to fetch them at this level.
// Flatten likes pages and deduplicate
const likedItems = useMemo(() => {
if (!likesData?.pages) return [];
const seen = new Set<string>();
const items: NostrEvent[] = [];
for (const page of likesData.pages) {
for (const event of page.events) {
if (!seen.has(event.id)) {
seen.add(event.id);
items.push(event);
}
}
}
return items;
}, [likesData?.pages]);
// Infinite scroll sentinel
const { ref: scrollRef, inView } = useInView({
threshold: 0,
@@ -1298,45 +1240,21 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe
useEffect(() => {
if (!inView) return;
if (activeTab === 'likes') {
if (hasNextLikesPage && !isFetchingNextLikesPage) {
fetchNextLikesPage();
}
} else if (activeTab === 'media') {
if (hasNextMediaPage && !isFetchingNextMediaPage) {
fetchNextMediaPage();
}
} else {
if (hasNextFeedPage && !isFetchingNextFeedPage) {
fetchNextFeedPage();
}
if (hasNextFeedPage && !isFetchingNextFeedPage) {
fetchNextFeedPage();
}
}, [inView, activeTab, hasNextFeedPage, isFetchingNextFeedPage, fetchNextFeedPage, hasNextLikesPage, isFetchingNextLikesPage, fetchNextLikesPage, hasNextMediaPage, isFetchingNextMediaPage, fetchNextMediaPage]);
}, [inView, hasNextFeedPage, isFetchingNextFeedPage, fetchNextFeedPage]);
const authorEvent = metadataEvent;
// For likes, convert NostrEvents to FeedItems
const likedFeedItems: FeedItem[] = useMemo(() =>
likedItems.map(event => ({ event, sortTimestamp: event.created_at })),
[likedItems]
);
// For media, convert media events to FeedItems
const mediaFeedItems: FeedItem[] = useMemo(() =>
mediaEvents.map(event => ({ event, sortTimestamp: event.created_at })),
[mediaEvents]
);
// Whether the active tab is one of the legacy feed-driven core tabs that
// pulls items out of useProfileFeed / useProfileLikes / useProfileMedia /
// useWallComments. The new Agora-native core tabs (overview / campaigns /
// pledges / activity) have their own renderers below and intentionally
// bypass this fallthrough.
const isCoreProfileTab = activeTab === 'posts' || activeTab === 'replies' || activeTab === 'media' || activeTab === 'likes' || activeTab === 'badges';
const currentItems = activeTab === 'likes' ? likedFeedItems : activeTab === 'media' ? mediaFeedItems : filterByTab(feedItems, isCoreProfileTab ? (activeTab as CoreProfileTab) : 'posts');
const currentLoading = activeTab === 'likes' ? likesPending : activeTab === 'media' ? mediaPending : feedPending;
const hasMore = activeTab === 'likes' ? hasNextLikesPage : activeTab === 'media' ? hasNextMediaPage : hasNextFeedPage;
const isFetchingMore = activeTab === 'likes' ? isFetchingNextLikesPage : activeTab === 'media' ? isFetchingNextMediaPage : isFetchingNextFeedPage;
// The Posts and Posts & replies tabs are the only feed-driven core tabs
// remaining; the Agora-native tabs (Activity / Campaigns / Pledges) have
// their own renderers below and bypass this fallthrough.
const isCoreProfileTab = activeTab === 'posts' || activeTab === 'replies';
const currentItems = filterByTab(feedItems, isCoreProfileTab ? (activeTab as CoreProfileTab) : 'posts');
const currentLoading = feedPending;
const hasMore = hasNextFeedPage;
const isFetchingMore = isFetchingNextFeedPage;
// Auto-fetch next page when client-side filtering (e.g. removing replies
// from the "posts" tab) leaves fewer visible items than the page size.
@@ -1344,11 +1262,10 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe
const MIN_VISIBLE_ITEMS = 5;
useEffect(() => {
if (currentLoading || isFetchingMore) return;
if (activeTab === 'likes' || activeTab === 'media') return;
if (currentItems.length < MIN_VISIBLE_ITEMS && hasNextFeedPage && !isFetchingNextFeedPage) {
fetchNextFeedPage();
}
}, [currentItems.length, currentLoading, isFetchingMore, activeTab, hasNextFeedPage, isFetchingNextFeedPage, fetchNextFeedPage]);
}, [currentItems.length, currentLoading, isFetchingMore, hasNextFeedPage, isFetchingNextFeedPage, fetchNextFeedPage]);
const handleRefresh = useCallback(async () => {
if (!pubkey) return;
@@ -1361,8 +1278,6 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe
(tag === 'author' && key[1] === pubkey) ||
(tag === 'profile-supplementary' && key[1] === pubkey) ||
(tag === 'profile-feed' && key[1] === pubkey) ||
(tag === 'profile-media' && key[1] === pubkey) ||
(tag === 'profile-likes-infinite' && key[1] === pubkey) ||
(tag === 'profile-pinned-events' && key[1] === pubkey)
);
},
@@ -1474,10 +1389,7 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe
onMoreMenuOpen={() => setMoreMenuOpen(true)}
onFollowQROpen={() => setFollowQROpen(true)}
onToggleFollow={handleToggleFollow}
onTabChange={(id) => {
setActiveTab(id);
if (id === 'media') setSidebarMediaUrl(null);
}}
onTabChange={(id) => setActiveTab(id)}
onDonate={openDonateForCampaign}
canFollow={!!user}
authorEvent={authorEvent}
@@ -1489,26 +1401,12 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe
`min-w-0` is critical inside a grid track so long unbroken
text doesn't push the column wider than its fraction. */}
<section className="min-w-0">
{/* Tabs — fixed Agora-first set with an overflow `⋯` for the
legacy social tabs. Identical for owner and visitor.
Sticks to the top of this column as the user scrolls. */}
<SubHeaderBar pinned>
{viewTabs.map((tab) => {
const tabId = CORE_TAB_IDS[tab.label] ?? tab.label;
return (
<TabButton
key={tab.label}
label={tab.label}
active={activeTab === tabId}
onClick={() => {
setActiveTab(tabId);
if (tab.label === 'Media') setSidebarMediaUrl(null);
}}
className="flex-initial shrink-0 px-4"
/>
);
})}
</SubHeaderBar>
{/* Profile tabs — sticky at top of this column. */}
<ProfileTabs
tabs={viewTabs.map((t) => ({ id: CORE_TAB_IDS[t.label] ?? t.label, label: t.label }))}
activeTab={activeTab}
onChange={setActiveTab}
/>
{/* Campaigns tab. */}
{activeTab === 'campaigns' && pubkey && (
@@ -1571,38 +1469,8 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe
</div>
)}
{/* Media tab. */}
{activeTab === 'media' && (
<div>
{mediaPending ? (
<MediaCollageSkeleton count={15} />
) : mediaEvents.length > 0 ? (
<>
<MediaCollage
events={mediaEvents}
initialOpenUrl={sidebarMediaUrl ?? undefined}
onInitialOpenConsumed={() => setSidebarMediaUrl(null)}
hasNextPage={hasNextMediaPage}
isFetchingNextPage={isFetchingNextMediaPage}
onNearEnd={() => { if (hasNextMediaPage && !isFetchingNextMediaPage) fetchNextMediaPage(); }}
/>
{hasNextMediaPage && (
<div ref={scrollRef} className="h-px" />
)}
</>
) : (
<div className="py-12 text-center text-muted-foreground">No media posts yet.</div>
)}
</div>
)}
{/* Badges tab. */}
{activeTab === 'badges' && pubkey && (
<ProfileBadgesTab pubkey={pubkey} displayName={displayName} />
)}
{/* Posts / Replies / Likes — generic feed renderer. */}
{isCoreProfileTab && activeTab !== 'media' && activeTab !== 'badges' && (
{/* Posts / Replies — generic feed renderer. */}
{isCoreProfileTab && (
<div>
{currentLoading ? (
<div className="space-y-0">
@@ -1640,7 +1508,6 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe
<div className="py-12 text-center text-muted-foreground">
{activeTab === 'posts' && 'No posts yet.'}
{activeTab === 'replies' && 'No posts or replies yet.'}
{activeTab === 'likes' && 'No likes yet.'}
</div>
)}
</div>
@@ -1717,159 +1584,3 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe
);
}
// ─── Profile Badges Tab ───────────────────────────────────────────────────────
function ProfileBadgesTab({ pubkey, displayName }: { pubkey: string; displayName: string }) {
const { nostr } = useNostr();
// Fetch the user's profile badges event (both new kind 10008 and legacy 30008)
const profileBadgesQuery = useQuery({
queryKey: ['profile-badges', pubkey],
queryFn: async () => {
const events = await nostr.query([
{ kinds: [10008], authors: [pubkey], limit: 1 },
{ kinds: [30008], authors: [pubkey], '#d': ['profile_badges'], limit: 1 },
]);
if (events.length === 0) return null;
// Pick the most recent event across both kinds
return events.reduce((latest, current) =>
current.created_at > latest.created_at ? current : latest,
);
},
staleTime: 2 * 60_000,
});
// Parse badge references from the profile badges event
const badgeRefs = useMemo(() => {
if (!profileBadgesQuery.data) return [];
const tags = profileBadgesQuery.data.tags;
const refs: Array<{ aTag: string; eTag?: string; pubkey: string; identifier: string }> = [];
for (let i = 0; i < tags.length; i++) {
if (tags[i][0] === 'a' && tags[i][1]) {
const aTag = tags[i][1];
const parts = aTag.split(':');
if (parts.length < 3 || parts[0] !== '30009') continue;
const bPubkey = parts[1];
const identifier = parts.slice(2).join(':');
let eTag: string | undefined;
if (i + 1 < tags.length && tags[i + 1][0] === 'e') {
eTag = tags[i + 1][1];
}
refs.push({ aTag, eTag, pubkey: bPubkey, identifier });
}
}
// Deduplicate by aTag — keep first occurrence only
const seen = new Set<string>();
return refs.filter((r) => {
if (seen.has(r.aTag)) return false;
seen.add(r.aTag);
return true;
});
}, [profileBadgesQuery.data]);
// Fetch all referenced badge definitions
const badgeDefsQuery = useQuery({
queryKey: ['badge-definitions-profile', pubkey, badgeRefs.map((r) => r.aTag).join(',')],
queryFn: async () => {
if (badgeRefs.length === 0) return [];
const filters = badgeRefs.map((ref) => ({
kinds: [30009 as const],
authors: [ref.pubkey],
'#d': [ref.identifier],
limit: 1,
}));
return nostr.query(filters);
},
enabled: badgeRefs.length > 0,
staleTime: 5 * 60_000,
});
// Build a lookup map from a-tag to parsed badge data
const badgeMap = useMemo(() => {
const map = new Map<string, { name: string; image?: string; description?: string }>();
if (!badgeDefsQuery.data) return map;
for (const event of badgeDefsQuery.data) {
const d = event.tags.find(([n]) => n === 'd')?.[1];
if (!d) continue;
const aTag = `30009:${event.pubkey}:${d}`;
const name = event.tags.find(([n]) => n === 'name')?.[1] || d;
const thumbTag = event.tags.find(([n]) => n === 'thumb');
const imageTag = event.tags.find(([n]) => n === 'image');
const image = thumbTag?.[1] ?? imageTag?.[1];
const description = event.tags.find(([n]) => n === 'description')?.[1];
map.set(aTag, { name, image, description });
}
return map;
}, [badgeDefsQuery.data]);
if (profileBadgesQuery.isLoading) {
return (
<div className="p-6">
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex flex-col items-center gap-2">
<Skeleton className="size-16 rounded-xl" />
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
</div>
);
}
if (badgeRefs.length === 0) {
return (
<div className="py-12 text-center text-muted-foreground">
<Award className="size-12 mx-auto mb-4 opacity-30" />
<p className="text-lg font-medium mb-2">No badges yet</p>
<p className="text-sm">{displayName} hasn't accepted any badges.</p>
</div>
);
}
return (
<div className="p-4">
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-4">
{badgeRefs.map((ref, idx) => {
const badge = badgeMap.get(ref.aTag);
const isLoading = badgeDefsQuery.isLoading;
const badgeUrl = `/${nip19.naddrEncode({ kind: 30009, pubkey: ref.pubkey, identifier: ref.identifier })}`;
return (
<Link
key={`${ref.aTag}-${idx}`}
to={badgeUrl}
className="flex flex-col items-center gap-2 group"
title={badge?.description || badge?.name || ref.identifier}
onClick={(e) => e.stopPropagation()}
>
{isLoading ? (
<Skeleton className="size-16 rounded-xl" />
) : badge?.image ? (
<img
src={badge.image}
alt={badge.name}
className="size-16 rounded-xl object-cover border border-border bg-secondary/30 transition-transform group-hover:scale-105"
loading="lazy"
decoding="async"
/>
) : (
<div className="size-16 rounded-xl border border-border bg-secondary/30 flex items-center justify-center transition-transform group-hover:scale-105">
<Award className="size-7 text-muted-foreground" />
</div>
)}
<span className="text-xs text-muted-foreground text-center leading-tight line-clamp-2 max-w-[5rem] group-hover:text-foreground transition-colors">
{isLoading ? <Skeleton className="h-3 w-14" /> : (badge?.name || ref.identifier)}
</span>
</Link>
);
})}
</div>
</div>
);
}