diff --git a/src/app/globals.css b/src/app/globals.css --- a/src/app/globals.css +++ b/src/app/globals.css @@ -270,3 +270,16 @@ background-color: var(--color-primary-muted); color: var(--color-primary); } + +/* Component classes */ +@layer components { + .prose-barazo a { + color: var(--cyan-11); + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--cyan-11) 40%, transparent); + text-underline-offset: 2px; + } + .prose-barazo a:hover { + text-decoration-color: var(--cyan-11); + } +} diff --git a/src/lib/format-bio.test.ts b/src/lib/format-bio.test.ts --- a/src/lib/format-bio.test.ts +++ b/src/lib/format-bio.test.ts @@ -41,4 +41,26 @@ const result = formatBio('Not a link: example.com') expect(result).not.toContain(' { + const result = formatBio('Visit https://example.com/path/') + expect(result).toContain('example.com/path') + }) + + it('strips trailing slash from display text', () => { + const result = formatBio('Visit https://example.com/') + expect(result).toContain('>example.com') + }) + + it('keeps full URL in href', () => { + const result = formatBio('Visit https://example.com/path/') + expect(result).toContain('href="https://example.com/path/"') + }) + + it('strips http:// from display text', () => { + const result = formatBio('Visit http://example.com/page') + expect(result).toContain('example.com/page') + }) }) diff --git a/src/lib/format-bio.ts b/src/lib/format-bio.ts --- a/src/lib/format-bio.ts +++ b/src/lib/format-bio.ts @@ -16,10 +16,11 @@ .replace(/'/g, ''') // Step 2: Autolink URLs (only http:// and https://) - result = result.replace( - /https?:\/\/[^\s<]+/g, - (url) => `${url}` - ) + // Display text strips protocol prefix and trailing slash for cleaner appearance. + result = result.replace(/https?:\/\/[^\s<]+/g, (url) => { + const display = url.replace(/^https?:\/\//, '').replace(/\/$/, '') + return `${display}` + }) // Step 3: Convert newlines to
result = result.replace(/\n/g, '
') diff --git a/src/lib/format-count.test.ts b/src/lib/format-count.test.ts new file mode 100644 --- /dev/null +++ b/src/lib/format-count.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import { formatCount } from './format-count' + +describe('formatCount', () => { + it('returns "0" for zero', () => { + expect(formatCount(0)).toBe('0') + }) + + it('returns number as-is below 1000', () => { + expect(formatCount(999)).toBe('999') + }) + + it('formats exact thousands as whole K', () => { + expect(formatCount(1000)).toBe('1K') + }) + + it('formats non-exact thousands with one decimal', () => { + expect(formatCount(1500)).toBe('1.5K') + }) + + it('formats 1700 as 1.7K', () => { + expect(formatCount(1700)).toBe('1.7K') + }) + + it('formats 14100 as 14.1K', () => { + expect(formatCount(14100)).toBe('14.1K') + }) + + it('formats exact millions as whole M', () => { + expect(formatCount(1000000)).toBe('1M') + }) + + it('formats non-exact millions with one decimal', () => { + expect(formatCount(2300000)).toBe('2.3M') + }) +}) diff --git a/src/lib/format-count.ts b/src/lib/format-count.ts new file mode 100644 --- /dev/null +++ b/src/lib/format-count.ts @@ -0,0 +1,13 @@ +/** + * Abbreviates large numbers for display (e.g., 1500 -> "1.5K"). + * Full number should be shown via title attribute for accessibility. + */ +export function formatCount(n: number): string { + if (n < 1000) return n.toString() + if (n < 1_000_000) { + const k = n / 1000 + return k % 1 === 0 ? `${k}K` : `${k.toFixed(1)}K` + } + const m = n / 1_000_000 + return m % 1 === 0 ? `${m}M` : `${m.toFixed(1)}M` +} diff --git a/src/components/profile/profile-header.test.tsx b/src/components/profile/profile-header.test.tsx --- a/src/components/profile/profile-header.test.tsx +++ b/src/components/profile/profile-header.test.tsx @@ -87,7 +87,7 @@ expect(screen.getByRole('heading', { name: /test user/i })).toBeInTheDocument() }) - it('renders handle', () => { + it('renders handle inline with display name', () => { render() expect(screen.getByText(/@test\.bsky\.social/)).toBeInTheDocument() }) @@ -96,19 +96,18 @@ const { container } = render( ) - // The bio div uses dangerouslySetInnerHTML, select by div.mt-2.text-muted-foreground - const bioDiv = container.querySelector('div.mt-2.text-sm.text-muted-foreground') + const bioDiv = container.querySelector('div.prose-barazo') expect(bioDiv?.innerHTML).toContain('
') }) - it('renders bio with autolinked URLs', () => { + it('renders bio with autolinked URLs (stripped display text)', () => { render( ) - const link = screen.getByRole('link', { name: /https:\/\/example\.com/i }) + const link = screen.getByRole('link', { name: /example\.com/i }) expect(link).toHaveAttribute('href', 'https://example.com') }) @@ -133,44 +132,23 @@ expect(screen.queryByAltText("Test User's avatar")).not.toBeInTheDocument() }) - it('renders followers and following counts', () => { - render( - - ) - expect(screen.getByText(/150 followers/i)).toBeInTheDocument() - expect(screen.getByText(/75 following/i)).toBeInTheDocument() + it('renders an hr separator', () => { + const { container } = render() + expect(container.querySelector('hr')).toBeInTheDocument() }) - it('renders Bluesky link when hasBlueskyProfile is true', () => { - render() - const link = screen.getByRole('link', { name: /view on bluesky/i }) - expect(link).toHaveAttribute('href', 'https://bsky.app/profile/test.bsky.social') + // Section label tests + it('renders "This forum" section label', () => { + render() + expect(screen.getByText('This forum')).toBeInTheDocument() }) - it('does not render Bluesky link when hasBlueskyProfile is false', () => { - render( - - ) - expect(screen.queryByRole('link', { name: /view on bluesky/i })).not.toBeInTheDocument() + it('renders "AT Protocol" section label', () => { + render() + expect(screen.getByText('AT Protocol')).toBeInTheDocument() }) - it('renders votesReceived in stats', () => { - render( - - ) - expect(screen.getByText(/15 votes/i)).toBeInTheDocument() - }) - - it('renders globalActivity section when present', () => { + it('renders "Barazo-wide" section label when globalActivity present', () => { render( ) - expect(screen.getByText(/activity across all communities/i)).toBeInTheDocument() - expect(screen.getByText(/25 topics/i)).toBeInTheDocument() + expect(screen.getByText('Barazo-wide')).toBeInTheDocument() }) - it('does not render globalActivity section when absent', () => { + it('does not render "Barazo-wide" section label when globalActivity absent', () => { render() - expect(screen.queryByText(/activity across all communities/i)).not.toBeInTheDocument() + expect(screen.queryByText('Barazo-wide')).not.toBeInTheDocument() + }) + + // Stats value tests + it('renders followers and following counts', () => { + render( + + ) + expect(screen.getByText(/150 followers/i)).toBeInTheDocument() + expect(screen.getByText(/75 following/i)).toBeInTheDocument() + }) + + it('renders Bluesky link with stripped URL display when hasBlueskyProfile is true', () => { + render() + const link = screen.getByRole('link', { name: /bsky\.app\/profile\/test\.bsky\.social/i }) + expect(link).toHaveAttribute('href', 'https://bsky.app/profile/test.bsky.social') + }) + + it('does not render Bluesky link when hasBlueskyProfile is false', () => { + render( + + ) + expect(screen.queryByRole('link', { name: /bsky\.app\/profile/i })).not.toBeInTheDocument() + }) + + it('renders votesReceived in "This forum" stats', () => { + render( + + ) + expect(screen.getByText(/15 votes/i)).toBeInTheDocument() + }) + + it('renders globalActivity stats when present', () => { + render( + + ) + expect(screen.getByText(/25 topics/i)).toBeInTheDocument() + expect(screen.getByText(/60 replies/i)).toBeInTheDocument() + }) + + it('shows full number in title attribute for large counts', () => { + render( + + ) + expect(screen.getByTitle('14,100')).toBeInTheDocument() + expect(screen.getByTitle('1,500')).toBeInTheDocument() + }) + + it('abbreviates large numbers with formatCount', () => { + render( + + ) + expect(screen.getByText(/14\.1K followers/i)).toBeInTheDocument() + expect(screen.getByText(/1\.5K posts/i)).toBeInTheDocument() }) describe('edit profile button', () => { diff --git a/src/components/profile/profile-header.tsx b/src/components/profile/profile-header.tsx --- a/src/components/profile/profile-header.tsx +++ b/src/components/profile/profile-header.tsx @@ -8,11 +8,22 @@ import Link from 'next/link' import Image from 'next/image' -import { User, CalendarBlank, ChatCircle, ArrowUp, PencilSimple } from '@phosphor-icons/react' +import { + User, + CalendarBlank, + ChatCircle, + ArrowUp, + PencilSimple, + HouseSimple, + At, + Globe, + Heart, +} from '@phosphor-icons/react' import { ReputationBadge } from '@/components/reputation-badge' import { BlockMuteButton } from '@/components/block-mute-button' import { ProfileStats } from '@/components/profile/profile-stats' import { formatBio } from '@/lib/format-bio' +import { formatCount } from '@/lib/format-count' import type { UserProfile } from '@/lib/api/types' interface ProfileHeaderProps { @@ -70,10 +81,14 @@ )}
-
+ {/* Display name + handle inline + edit button */} +

{profile.displayName ?? handle}

+ {profile.displayName && ( + @{handle} + )} {isOwnProfile && ( )}
- {profile.displayName &&

@{handle}

} {/* Bio */} {profile.bio && (
)} -
- - - - - - - -
+
- + {/* Labeled stats sections */} +
+ {/* This forum */} +
+

+

+
+ + + + + + + + + +
+
+ + {/* AT Protocol */} +
+

+

+ +
+ + {/* Barazo-wide (conditional) */} + {profile.globalActivity && ( +
+

+

+
+ + {formatCount(profile.globalActivity.topicCount)} topics + + + {formatCount(profile.globalActivity.replyCount)} replies + + + {formatCount(profile.globalActivity.reactionsReceived)} reactions + + + {formatCount(profile.globalActivity.votesReceived)} votes + +
+
+ )} +
{/* Block/Mute actions (hidden on own profile) */} {!isOwnProfile && ( @@ -128,20 +196,6 @@ isActive={isMuted} onToggle={onMuteToggle} /> -
- )} - - {profile.globalActivity && ( -
-

- Activity across all communities -

-
- {profile.globalActivity.topicCount} topics - {profile.globalActivity.replyCount} replies - {profile.globalActivity.reactionsReceived} reactions - {profile.globalActivity.votesReceived} votes -
)}
diff --git a/src/components/profile/profile-stats.tsx b/src/components/profile/profile-stats.tsx --- a/src/components/profile/profile-stats.tsx +++ b/src/components/profile/profile-stats.tsx @@ -1,10 +1,11 @@ /** * ProfileStats - Renders AT Protocol stats (followers, following, posts) and Bluesky link. - * Extracted from ProfileHeader to keep components under ~150 lines. + * Used as a sub-component within the "AT Protocol" stats section of ProfileHeader. * @see specs/prd-web.md Section M8 */ import { Users, ArrowSquareOut } from '@phosphor-icons/react' +import { formatCount } from '@/lib/format-count' import type { UserProfile } from '@/lib/api/types' interface ProfileStatsProps { @@ -14,16 +15,20 @@ export function ProfileStats({ profile, handle }: ProfileStatsProps) { return ( - <> - {/* AT Protocol stats */} -
- - + + {formatCount(profile.followsCount)} following + + + {formatCount(profile.atprotoPostsCount)} AT Proto posts + {/* Bluesky link */} {profile.hasBlueskyProfile && ( @@ -31,12 +36,12 @@ href={`https://bsky.app/profile/${handle}`} target="_blank" rel="noopener noreferrer" - className="mt-2 inline-flex items-center gap-1 text-sm text-primary hover:underline" + className="mt-1 inline-flex items-center gap-1 text-sm text-primary hover:underline" > - View on Bluesky + bsky.app/profile/{handle}
) } diff --git a/src/app/u/[handle]/page.test.tsx b/src/app/u/[handle]/page.test.tsx --- a/src/app/u/[handle]/page.test.tsx +++ b/src/app/u/[handle]/page.test.tsx @@ -89,7 +89,7 @@ render() // Alice's mock data has globalActivity (communityCount: 2) await waitFor(() => { - expect(screen.getByText(/activity across all communities/i)).toBeInTheDocument() + expect(screen.getByText('Barazo-wide')).toBeInTheDocument() }) }) @@ -103,7 +103,7 @@ it('renders Bluesky link', async () => { render() await waitFor(() => { - const link = screen.getByRole('link', { name: /view on bluesky/i }) + const link = screen.getByRole('link', { name: /bsky\.app\/profile\/alice\.bsky\.social/i }) expect(link).toHaveAttribute('href', 'https://bsky.app/profile/alice.bsky.social') }) })