Barazo default frontend barazo.forum
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(profile): improve profile card UX and stats layout (#97)

Restructure profile page with 6 visual improvements:
- Bio links styled with prose-barazo class (cyan underline)
- Display name + handle on same line (flex wrap)
- HR separator between bio and stats
- Stats grouped by scope: This forum / AT Protocol / Barazo-wide
- Large numbers abbreviated (1.5K, 2.3M) with full tooltip
- Bluesky link shows URL instead of "View on Bluesky"

authored by

Guido X Jansen and committed by
GitHub
2b0035ab b848d872

+269 -68
+13
src/app/globals.css
··· 270 270 background-color: var(--color-primary-muted); 271 271 color: var(--color-primary); 272 272 } 273 + 274 + /* Component classes */ 275 + @layer components { 276 + .prose-barazo a { 277 + color: var(--cyan-11); 278 + text-decoration: underline; 279 + text-decoration-color: color-mix(in srgb, var(--cyan-11) 40%, transparent); 280 + text-underline-offset: 2px; 281 + } 282 + .prose-barazo a:hover { 283 + text-decoration-color: var(--cyan-11); 284 + } 285 + }
+2 -2
src/app/u/[handle]/page.test.tsx
··· 89 89 render(<UserProfilePage params={{ handle: 'alice.bsky.social' }} />) 90 90 // Alice's mock data has globalActivity (communityCount: 2) 91 91 await waitFor(() => { 92 - expect(screen.getByText(/activity across all communities/i)).toBeInTheDocument() 92 + expect(screen.getByText('Barazo-wide')).toBeInTheDocument() 93 93 }) 94 94 }) 95 95 ··· 103 103 it('renders Bluesky link', async () => { 104 104 render(<UserProfilePage params={{ handle: 'alice.bsky.social' }} />) 105 105 await waitFor(() => { 106 - const link = screen.getByRole('link', { name: /view on bluesky/i }) 106 + const link = screen.getByRole('link', { name: /bsky\.app\/profile\/alice\.bsky\.social/i }) 107 107 expect(link).toHaveAttribute('href', 'https://bsky.app/profile/alice.bsky.social') 108 108 }) 109 109 })
+71 -14
src/components/profile/profile-header.test.tsx
··· 87 87 expect(screen.getByRole('heading', { name: /test user/i })).toBeInTheDocument() 88 88 }) 89 89 90 - it('renders handle', () => { 90 + it('renders handle inline with display name', () => { 91 91 render(<ProfileHeader profile={createProfile()} {...defaultProps} />) 92 92 expect(screen.getByText(/@test\.bsky\.social/)).toBeInTheDocument() 93 93 }) ··· 96 96 const { container } = render( 97 97 <ProfileHeader profile={createProfile({ bio: 'Line 1\nLine 2' })} {...defaultProps} /> 98 98 ) 99 - // The bio div uses dangerouslySetInnerHTML, select by div.mt-2.text-muted-foreground 100 - const bioDiv = container.querySelector('div.mt-2.text-sm.text-muted-foreground') 99 + const bioDiv = container.querySelector('div.prose-barazo') 101 100 expect(bioDiv?.innerHTML).toContain('<br>') 102 101 }) 103 102 104 - it('renders bio with autolinked URLs', () => { 103 + it('renders bio with autolinked URLs (stripped display text)', () => { 105 104 render( 106 105 <ProfileHeader 107 106 profile={createProfile({ bio: 'Visit https://example.com' })} 108 107 {...defaultProps} 109 108 /> 110 109 ) 111 - const link = screen.getByRole('link', { name: /https:\/\/example\.com/i }) 110 + const link = screen.getByRole('link', { name: /example\.com/i }) 112 111 expect(link).toHaveAttribute('href', 'https://example.com') 113 112 }) 114 113 ··· 133 132 expect(screen.queryByAltText("Test User's avatar")).not.toBeInTheDocument() 134 133 }) 135 134 135 + it('renders an hr separator', () => { 136 + const { container } = render(<ProfileHeader profile={createProfile()} {...defaultProps} />) 137 + expect(container.querySelector('hr')).toBeInTheDocument() 138 + }) 139 + 140 + // Section label tests 141 + it('renders "This forum" section label', () => { 142 + render(<ProfileHeader profile={createProfile()} {...defaultProps} />) 143 + expect(screen.getByText('This forum')).toBeInTheDocument() 144 + }) 145 + 146 + it('renders "AT Protocol" section label', () => { 147 + render(<ProfileHeader profile={createProfile()} {...defaultProps} />) 148 + expect(screen.getByText('AT Protocol')).toBeInTheDocument() 149 + }) 150 + 151 + it('renders "Barazo-wide" section label when globalActivity present', () => { 152 + render( 153 + <ProfileHeader 154 + profile={createProfile({ 155 + globalActivity: { 156 + topicCount: 25, 157 + replyCount: 60, 158 + reactionsReceived: 120, 159 + votesReceived: 55, 160 + }, 161 + })} 162 + {...defaultProps} 163 + /> 164 + ) 165 + expect(screen.getByText('Barazo-wide')).toBeInTheDocument() 166 + }) 167 + 168 + it('does not render "Barazo-wide" section label when globalActivity absent', () => { 169 + render(<ProfileHeader profile={createProfile()} {...defaultProps} />) 170 + expect(screen.queryByText('Barazo-wide')).not.toBeInTheDocument() 171 + }) 172 + 173 + // Stats value tests 136 174 it('renders followers and following counts', () => { 137 175 render( 138 176 <ProfileHeader ··· 144 182 expect(screen.getByText(/75 following/i)).toBeInTheDocument() 145 183 }) 146 184 147 - it('renders Bluesky link when hasBlueskyProfile is true', () => { 185 + it('renders Bluesky link with stripped URL display when hasBlueskyProfile is true', () => { 148 186 render(<ProfileHeader profile={createProfile({ hasBlueskyProfile: true })} {...defaultProps} />) 149 - const link = screen.getByRole('link', { name: /view on bluesky/i }) 187 + const link = screen.getByRole('link', { name: /bsky\.app\/profile\/test\.bsky\.social/i }) 150 188 expect(link).toHaveAttribute('href', 'https://bsky.app/profile/test.bsky.social') 151 189 }) 152 190 ··· 154 192 render( 155 193 <ProfileHeader profile={createProfile({ hasBlueskyProfile: false })} {...defaultProps} /> 156 194 ) 157 - expect(screen.queryByRole('link', { name: /view on bluesky/i })).not.toBeInTheDocument() 195 + expect(screen.queryByRole('link', { name: /bsky\.app\/profile/i })).not.toBeInTheDocument() 158 196 }) 159 197 160 - it('renders votesReceived in stats', () => { 198 + it('renders votesReceived in "This forum" stats', () => { 161 199 render( 162 200 <ProfileHeader 163 201 profile={createProfile({ ··· 170 208 expect(screen.getByText(/15 votes/i)).toBeInTheDocument() 171 209 }) 172 210 173 - it('renders globalActivity section when present', () => { 211 + it('renders globalActivity stats when present', () => { 174 212 render( 175 213 <ProfileHeader 176 214 profile={createProfile({ ··· 184 222 {...defaultProps} 185 223 /> 186 224 ) 187 - expect(screen.getByText(/activity across all communities/i)).toBeInTheDocument() 188 225 expect(screen.getByText(/25 topics/i)).toBeInTheDocument() 226 + expect(screen.getByText(/60 replies/i)).toBeInTheDocument() 189 227 }) 190 228 191 - it('does not render globalActivity section when absent', () => { 192 - render(<ProfileHeader profile={createProfile()} {...defaultProps} />) 193 - expect(screen.queryByText(/activity across all communities/i)).not.toBeInTheDocument() 229 + it('shows full number in title attribute for large counts', () => { 230 + render( 231 + <ProfileHeader 232 + profile={createProfile({ followersCount: 14100, followsCount: 2300 })} 233 + {...defaultProps} 234 + postCount={1500} 235 + /> 236 + ) 237 + expect(screen.getByTitle('14,100')).toBeInTheDocument() 238 + expect(screen.getByTitle('1,500')).toBeInTheDocument() 239 + }) 240 + 241 + it('abbreviates large numbers with formatCount', () => { 242 + render( 243 + <ProfileHeader 244 + profile={createProfile({ followersCount: 14100 })} 245 + {...defaultProps} 246 + postCount={1500} 247 + /> 248 + ) 249 + expect(screen.getByText(/14\.1K followers/i)).toBeInTheDocument() 250 + expect(screen.getByText(/1\.5K posts/i)).toBeInTheDocument() 194 251 }) 195 252 196 253 describe('edit profile button', () => {
+89 -35
src/components/profile/profile-header.tsx
··· 8 8 9 9 import Link from 'next/link' 10 10 import Image from 'next/image' 11 - import { User, CalendarBlank, ChatCircle, ArrowUp, PencilSimple } from '@phosphor-icons/react' 11 + import { 12 + User, 13 + CalendarBlank, 14 + ChatCircle, 15 + ArrowUp, 16 + PencilSimple, 17 + HouseSimple, 18 + At, 19 + Globe, 20 + Heart, 21 + } from '@phosphor-icons/react' 12 22 import { ReputationBadge } from '@/components/reputation-badge' 13 23 import { BlockMuteButton } from '@/components/block-mute-button' 14 24 import { ProfileStats } from '@/components/profile/profile-stats' 15 25 import { formatBio } from '@/lib/format-bio' 26 + import { formatCount } from '@/lib/format-count' 16 27 import type { UserProfile } from '@/lib/api/types' 17 28 18 29 interface ProfileHeaderProps { ··· 70 81 )} 71 82 72 83 <div className="min-w-0 flex-1"> 73 - <div className="flex items-center gap-2"> 84 + {/* Display name + handle inline + edit button */} 85 + <div className="flex flex-wrap items-baseline gap-x-2"> 74 86 <h1 className="text-2xl font-bold text-foreground"> 75 87 {profile.displayName ?? handle} 76 88 </h1> 89 + {profile.displayName && ( 90 + <span className="text-lg text-muted-foreground">@{handle}</span> 91 + )} 77 92 {isOwnProfile && ( 78 93 <Link 79 94 href={`/u/${handle}/edit`} ··· 84 99 </Link> 85 100 )} 86 101 </div> 87 - {profile.displayName && <p className="text-lg text-muted-foreground">@{handle}</p>} 88 102 89 103 {/* Bio */} 90 104 {profile.bio && ( 91 105 <div 92 - className="mt-2 text-sm text-muted-foreground" 106 + className="prose-barazo mt-2 text-sm text-muted-foreground" 93 107 dangerouslySetInnerHTML={{ __html: formatBio(profile.bio) }} 94 108 /> 95 109 )} 96 110 97 - <div className="mt-3 flex flex-wrap items-center gap-4 text-sm text-muted-foreground"> 98 - <ReputationBadge score={reputationScore} /> 99 - <span className="flex items-center gap-1"> 100 - <ChatCircle size={16} aria-hidden="true" /> 101 - {postCount} {postCount === 1 ? 'post' : 'posts'} 102 - </span> 103 - <span className="flex items-center gap-1"> 104 - <ArrowUp size={16} aria-hidden="true" /> 105 - {profile.activity.votesReceived}{' '} 106 - {profile.activity.votesReceived === 1 ? 'vote' : 'votes'} 107 - </span> 108 - <span className="flex items-center gap-1"> 109 - <CalendarBlank size={16} aria-hidden="true" /> 110 - Joined {joinDate} 111 - </span> 112 - </div> 111 + <hr className="mt-4 border-border/50" /> 113 112 114 - <ProfileStats profile={profile} handle={handle} /> 113 + {/* Labeled stats sections */} 114 + <div className="mt-4 flex flex-wrap gap-6"> 115 + {/* This forum */} 116 + <div className="min-w-[140px] flex-1"> 117 + <p className="mb-1 flex items-center gap-1 text-xs font-medium text-muted-foreground"> 118 + <HouseSimple size={14} aria-hidden="true" /> 119 + This forum 120 + </p> 121 + <div className="flex flex-col gap-1 text-sm text-muted-foreground"> 122 + <span className="flex items-center gap-1"> 123 + <ChatCircle size={16} aria-hidden="true" /> 124 + <span title={postCount.toLocaleString()}> 125 + {formatCount(postCount)} {postCount === 1 ? 'post' : 'posts'} 126 + </span> 127 + </span> 128 + <span className="flex items-center gap-1"> 129 + <Heart size={16} aria-hidden="true" /> 130 + <span title={profile.activity.reactionsReceived.toLocaleString()}> 131 + {formatCount(profile.activity.reactionsReceived)}{' '} 132 + {profile.activity.reactionsReceived === 1 ? 'reaction' : 'reactions'} 133 + </span> 134 + </span> 135 + <span className="flex items-center gap-1"> 136 + <ArrowUp size={16} aria-hidden="true" /> 137 + <span title={profile.activity.votesReceived.toLocaleString()}> 138 + {formatCount(profile.activity.votesReceived)}{' '} 139 + {profile.activity.votesReceived === 1 ? 'vote' : 'votes'} 140 + </span> 141 + </span> 142 + <ReputationBadge score={reputationScore} /> 143 + <span className="flex items-center gap-1"> 144 + <CalendarBlank size={16} aria-hidden="true" /> 145 + Joined {joinDate} 146 + </span> 147 + </div> 148 + </div> 149 + 150 + {/* AT Protocol */} 151 + <div className="min-w-[140px] flex-1"> 152 + <p className="mb-1 flex items-center gap-1 text-xs font-medium text-muted-foreground"> 153 + <At size={14} aria-hidden="true" /> 154 + AT Protocol 155 + </p> 156 + <ProfileStats profile={profile} handle={handle} /> 157 + </div> 158 + 159 + {/* Barazo-wide (conditional) */} 160 + {profile.globalActivity && ( 161 + <div className="min-w-[140px] flex-1"> 162 + <p className="mb-1 flex items-center gap-1 text-xs font-medium text-muted-foreground"> 163 + <Globe size={14} aria-hidden="true" /> 164 + Barazo-wide 165 + </p> 166 + <div className="flex flex-col gap-1 text-sm text-muted-foreground"> 167 + <span title={profile.globalActivity.topicCount.toLocaleString()}> 168 + {formatCount(profile.globalActivity.topicCount)} topics 169 + </span> 170 + <span title={profile.globalActivity.replyCount.toLocaleString()}> 171 + {formatCount(profile.globalActivity.replyCount)} replies 172 + </span> 173 + <span title={profile.globalActivity.reactionsReceived.toLocaleString()}> 174 + {formatCount(profile.globalActivity.reactionsReceived)} reactions 175 + </span> 176 + <span title={profile.globalActivity.votesReceived.toLocaleString()}> 177 + {formatCount(profile.globalActivity.votesReceived)} votes 178 + </span> 179 + </div> 180 + </div> 181 + )} 182 + </div> 115 183 116 184 {/* Block/Mute actions (hidden on own profile) */} 117 185 {!isOwnProfile && ( ··· 128 196 isActive={isMuted} 129 197 onToggle={onMuteToggle} 130 198 /> 131 - </div> 132 - )} 133 - 134 - {profile.globalActivity && ( 135 - <div className="mt-4 rounded-md bg-muted/50 p-3"> 136 - <p className="text-xs font-medium text-muted-foreground"> 137 - Activity across all communities 138 - </p> 139 - <div className="mt-1 flex flex-wrap gap-3 text-sm text-muted-foreground"> 140 - <span>{profile.globalActivity.topicCount} topics</span> 141 - <span>{profile.globalActivity.replyCount} replies</span> 142 - <span>{profile.globalActivity.reactionsReceived} reactions</span> 143 - <span>{profile.globalActivity.votesReceived} votes</span> 144 - </div> 145 199 </div> 146 200 )} 147 201 </div>
+18 -13
src/components/profile/profile-stats.tsx
··· 1 1 /** 2 2 * ProfileStats - Renders AT Protocol stats (followers, following, posts) and Bluesky link. 3 - * Extracted from ProfileHeader to keep components under ~150 lines. 3 + * Used as a sub-component within the "AT Protocol" stats section of ProfileHeader. 4 4 * @see specs/prd-web.md Section M8 5 5 */ 6 6 7 7 import { Users, ArrowSquareOut } from '@phosphor-icons/react' 8 + import { formatCount } from '@/lib/format-count' 8 9 import type { UserProfile } from '@/lib/api/types' 9 10 10 11 interface ProfileStatsProps { ··· 14 15 15 16 export function ProfileStats({ profile, handle }: ProfileStatsProps) { 16 17 return ( 17 - <> 18 - {/* AT Protocol stats */} 19 - <div className="mt-2 flex flex-wrap items-center gap-4 text-sm text-muted-foreground"> 20 - <span className="flex items-center gap-1"> 21 - <Users size={16} aria-hidden="true" /> 22 - {profile.followersCount} {profile.followersCount === 1 ? 'follower' : 'followers'} 18 + <div className="flex flex-col gap-1 text-sm text-muted-foreground"> 19 + <span className="flex items-center gap-1"> 20 + <Users size={16} aria-hidden="true" /> 21 + <span title={profile.followersCount.toLocaleString()}> 22 + {formatCount(profile.followersCount)}{' '} 23 + {profile.followersCount === 1 ? 'follower' : 'followers'} 23 24 </span> 24 - <span>{profile.followsCount} following</span> 25 - <span>{profile.atprotoPostsCount} AT Proto posts</span> 26 - </div> 25 + </span> 26 + <span title={profile.followsCount.toLocaleString()}> 27 + {formatCount(profile.followsCount)} following 28 + </span> 29 + <span title={profile.atprotoPostsCount.toLocaleString()}> 30 + {formatCount(profile.atprotoPostsCount)} AT Proto posts 31 + </span> 27 32 28 33 {/* Bluesky link */} 29 34 {profile.hasBlueskyProfile && ( ··· 31 36 href={`https://bsky.app/profile/${handle}`} 32 37 target="_blank" 33 38 rel="noopener noreferrer" 34 - className="mt-2 inline-flex items-center gap-1 text-sm text-primary hover:underline" 39 + className="mt-1 inline-flex items-center gap-1 text-sm text-primary hover:underline" 35 40 > 36 - View on Bluesky 41 + bsky.app/profile/{handle} 37 42 <ArrowSquareOut size={14} aria-hidden="true" /> 38 43 </a> 39 44 )} 40 - </> 45 + </div> 41 46 ) 42 47 }
+22
src/lib/format-bio.test.ts
··· 41 41 const result = formatBio('Not a link: example.com') 42 42 expect(result).not.toContain('<a') 43 43 }) 44 + 45 + it('strips https:// from display text of autolinked URLs', () => { 46 + const result = formatBio('Visit https://example.com/path/') 47 + expect(result).toContain('<a href="https://example.com/path/"') 48 + expect(result).toContain('>example.com/path</a>') 49 + }) 50 + 51 + it('strips trailing slash from display text', () => { 52 + const result = formatBio('Visit https://example.com/') 53 + expect(result).toContain('>example.com</a>') 54 + }) 55 + 56 + it('keeps full URL in href', () => { 57 + const result = formatBio('Visit https://example.com/path/') 58 + expect(result).toContain('href="https://example.com/path/"') 59 + }) 60 + 61 + it('strips http:// from display text', () => { 62 + const result = formatBio('Visit http://example.com/page') 63 + expect(result).toContain('<a href="http://example.com/page"') 64 + expect(result).toContain('>example.com/page</a>') 65 + }) 44 66 })
+5 -4
src/lib/format-bio.ts
··· 16 16 .replace(/'/g, '&#39;') 17 17 18 18 // Step 2: Autolink URLs (only http:// and https://) 19 - result = result.replace( 20 - /https?:\/\/[^\s<]+/g, 21 - (url) => `<a href="${url}" rel="noopener noreferrer">${url}</a>` 22 - ) 19 + // Display text strips protocol prefix and trailing slash for cleaner appearance. 20 + result = result.replace(/https?:\/\/[^\s<]+/g, (url) => { 21 + const display = url.replace(/^https?:\/\//, '').replace(/\/$/, '') 22 + return `<a href="${url}" rel="noopener noreferrer">${display}</a>` 23 + }) 23 24 24 25 // Step 3: Convert newlines to <br> 25 26 result = result.replace(/\n/g, '<br>')
+36
src/lib/format-count.test.ts
··· 1 + import { describe, it, expect } from 'vitest' 2 + import { formatCount } from './format-count' 3 + 4 + describe('formatCount', () => { 5 + it('returns "0" for zero', () => { 6 + expect(formatCount(0)).toBe('0') 7 + }) 8 + 9 + it('returns number as-is below 1000', () => { 10 + expect(formatCount(999)).toBe('999') 11 + }) 12 + 13 + it('formats exact thousands as whole K', () => { 14 + expect(formatCount(1000)).toBe('1K') 15 + }) 16 + 17 + it('formats non-exact thousands with one decimal', () => { 18 + expect(formatCount(1500)).toBe('1.5K') 19 + }) 20 + 21 + it('formats 1700 as 1.7K', () => { 22 + expect(formatCount(1700)).toBe('1.7K') 23 + }) 24 + 25 + it('formats 14100 as 14.1K', () => { 26 + expect(formatCount(14100)).toBe('14.1K') 27 + }) 28 + 29 + it('formats exact millions as whole M', () => { 30 + expect(formatCount(1000000)).toBe('1M') 31 + }) 32 + 33 + it('formats non-exact millions with one decimal', () => { 34 + expect(formatCount(2300000)).toBe('2.3M') 35 + }) 36 + })
+13
src/lib/format-count.ts
··· 1 + /** 2 + * Abbreviates large numbers for display (e.g., 1500 -> "1.5K"). 3 + * Full number should be shown via title attribute for accessibility. 4 + */ 5 + export function formatCount(n: number): string { 6 + if (n < 1000) return n.toString() 7 + if (n < 1_000_000) { 8 + const k = n / 1000 9 + return k % 1 === 0 ? `${k}K` : `${k.toFixed(1)}K` 10 + } 11 + const m = n / 1_000_000 12 + return m % 1 === 0 ? `${m}M` : `${m.toFixed(1)}M` 13 + }