Barazo default frontend barazo.forum

feat(web): user profiles, reputation badges, and settings page (M8) (#9)

Add ReputationBadge (score + level display), UserProfileCard (hover/focus card
with keyboard dismissal), user profile page at /u/[handle], and settings page
with content safety, cross-posting defaults, and notification preferences.

authored by

Guido X Jansen and committed by
GitHub
4abf800e 8284c5b7

+662
+63
src/app/settings/page.test.tsx
··· 1 + /** 2 + * Tests for settings page. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import { axe } from 'vitest-axe' 8 + import SettingsPage from './page' 9 + 10 + vi.mock('next/navigation', () => ({ 11 + useRouter: () => ({ 12 + push: vi.fn(), 13 + replace: vi.fn(), 14 + back: vi.fn(), 15 + }), 16 + redirect: vi.fn(), 17 + })) 18 + 19 + describe('SettingsPage', () => { 20 + it('renders settings heading', () => { 21 + render(<SettingsPage />) 22 + expect(screen.getByRole('heading', { name: /settings/i })).toBeInTheDocument() 23 + }) 24 + 25 + it('renders content safety section', () => { 26 + render(<SettingsPage />) 27 + expect(screen.getByText(/content safety/i)).toBeInTheDocument() 28 + expect(screen.getByLabelText(/maturity level/i)).toBeInTheDocument() 29 + }) 30 + 31 + it('renders muted words input', () => { 32 + render(<SettingsPage />) 33 + expect(screen.getByLabelText(/muted words/i)).toBeInTheDocument() 34 + }) 35 + 36 + it('renders cross-posting section', () => { 37 + render(<SettingsPage />) 38 + expect(screen.getByText(/cross-posting/i)).toBeInTheDocument() 39 + expect(screen.getByLabelText(/bluesky/i)).toBeInTheDocument() 40 + expect(screen.getByLabelText(/frontpage/i)).toBeInTheDocument() 41 + }) 42 + 43 + it('renders notification preferences section', () => { 44 + render(<SettingsPage />) 45 + expect(screen.getByText(/notifications/i)).toBeInTheDocument() 46 + }) 47 + 48 + it('renders save button', () => { 49 + render(<SettingsPage />) 50 + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument() 51 + }) 52 + 53 + it('renders breadcrumbs', () => { 54 + render(<SettingsPage />) 55 + expect(screen.getByText('Home')).toBeInTheDocument() 56 + }) 57 + 58 + it('passes axe accessibility check', async () => { 59 + const { container } = render(<SettingsPage />) 60 + const results = await axe(container) 61 + expect(results).toHaveNoViolations() 62 + }) 63 + })
+184
src/app/settings/page.tsx
··· 1 + /** 2 + * User settings page. 3 + * URL: /settings 4 + * Content safety, cross-posting defaults, notification preferences. 5 + * Client component (form state). 6 + * @see specs/prd-web.md Section M8 (Settings page) 7 + */ 8 + 9 + 'use client' 10 + 11 + import { useState } from 'react' 12 + import { ForumLayout } from '@/components/layout/forum-layout' 13 + import { Breadcrumbs } from '@/components/breadcrumbs' 14 + import { cn } from '@/lib/utils' 15 + 16 + type MaturityLevel = 'sfw' | 'sfw-mature' 17 + 18 + interface SettingsValues { 19 + maturityLevel: MaturityLevel 20 + mutedWords: string 21 + crossPostBluesky: boolean 22 + crossPostFrontpage: boolean 23 + notifyReplies: boolean 24 + notifyMentions: boolean 25 + notifyReactions: boolean 26 + } 27 + 28 + export default function SettingsPage() { 29 + const [values, setValues] = useState<SettingsValues>({ 30 + maturityLevel: 'sfw', 31 + mutedWords: '', 32 + crossPostBluesky: true, 33 + crossPostFrontpage: false, 34 + notifyReplies: true, 35 + notifyMentions: true, 36 + notifyReactions: false, 37 + }) 38 + const [saving, setSaving] = useState(false) 39 + 40 + const handleSave = (e: React.FormEvent) => { 41 + e.preventDefault() 42 + setSaving(true) 43 + // TODO: Save settings via API 44 + setTimeout(() => setSaving(false), 500) 45 + } 46 + 47 + return ( 48 + <ForumLayout> 49 + <div className="space-y-6"> 50 + <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Settings' }]} /> 51 + 52 + <h1 className="text-2xl font-bold text-foreground">Settings</h1> 53 + 54 + <form onSubmit={handleSave} className="max-w-2xl space-y-8" noValidate> 55 + {/* Content Safety */} 56 + <fieldset className="space-y-4 rounded-lg border border-border p-4"> 57 + <legend className="px-2 text-sm font-semibold text-foreground">Content Safety</legend> 58 + 59 + <div className="space-y-1"> 60 + <label htmlFor="maturity-level" className="block text-sm font-medium text-foreground"> 61 + Maturity level 62 + </label> 63 + <select 64 + id="maturity-level" 65 + value={values.maturityLevel} 66 + onChange={(e) => 67 + setValues({ ...values, maturityLevel: e.target.value as MaturityLevel }) 68 + } 69 + className={cn( 70 + 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground', 71 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 72 + )} 73 + > 74 + <option value="sfw">SFW only</option> 75 + <option value="sfw-mature">SFW + Mature</option> 76 + </select> 77 + <p className="text-xs text-muted-foreground"> 78 + Controls which content you can see. Mature content requires age confirmation. 79 + </p> 80 + </div> 81 + 82 + <div className="space-y-1"> 83 + <label htmlFor="muted-words" className="block text-sm font-medium text-foreground"> 84 + Muted words 85 + </label> 86 + <textarea 87 + id="muted-words" 88 + value={values.mutedWords} 89 + onChange={(e) => setValues({ ...values, mutedWords: e.target.value })} 90 + placeholder="Enter words separated by commas" 91 + rows={3} 92 + className={cn( 93 + 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 94 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 95 + )} 96 + /> 97 + <p className="text-xs text-muted-foreground"> 98 + Posts containing these words will be collapsed. Comma-separated. 99 + </p> 100 + </div> 101 + </fieldset> 102 + 103 + {/* Cross-Posting */} 104 + <fieldset className="space-y-4 rounded-lg border border-border p-4"> 105 + <legend className="px-2 text-sm font-semibold text-foreground">Cross-Posting</legend> 106 + <div className="space-y-3"> 107 + <label className="flex items-center gap-2"> 108 + <input 109 + type="checkbox" 110 + checked={values.crossPostBluesky} 111 + onChange={(e) => setValues({ ...values, crossPostBluesky: e.target.checked })} 112 + className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 113 + /> 114 + <span className="text-sm text-foreground"> 115 + Share new topics on Bluesky by default 116 + </span> 117 + </label> 118 + <label className="flex items-center gap-2"> 119 + <input 120 + type="checkbox" 121 + checked={values.crossPostFrontpage} 122 + onChange={(e) => setValues({ ...values, crossPostFrontpage: e.target.checked })} 123 + className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 124 + /> 125 + <span className="text-sm text-foreground"> 126 + Share new topics on Frontpage by default 127 + </span> 128 + </label> 129 + </div> 130 + </fieldset> 131 + 132 + {/* Notifications */} 133 + <fieldset className="space-y-4 rounded-lg border border-border p-4"> 134 + <legend className="px-2 text-sm font-semibold text-foreground">Notifications</legend> 135 + <div className="space-y-3"> 136 + <label className="flex items-center gap-2"> 137 + <input 138 + type="checkbox" 139 + checked={values.notifyReplies} 140 + onChange={(e) => setValues({ ...values, notifyReplies: e.target.checked })} 141 + className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 142 + /> 143 + <span className="text-sm text-foreground">Replies to my posts</span> 144 + </label> 145 + <label className="flex items-center gap-2"> 146 + <input 147 + type="checkbox" 148 + checked={values.notifyMentions} 149 + onChange={(e) => setValues({ ...values, notifyMentions: e.target.checked })} 150 + className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 151 + /> 152 + <span className="text-sm text-foreground">Mentions of my handle</span> 153 + </label> 154 + <label className="flex items-center gap-2"> 155 + <input 156 + type="checkbox" 157 + checked={values.notifyReactions} 158 + onChange={(e) => setValues({ ...values, notifyReactions: e.target.checked })} 159 + className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 160 + /> 161 + <span className="text-sm text-foreground">Reactions on my posts</span> 162 + </label> 163 + </div> 164 + </fieldset> 165 + 166 + {/* Save */} 167 + <div className="flex justify-end"> 168 + <button 169 + type="submit" 170 + disabled={saving} 171 + className={cn( 172 + 'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors', 173 + 'hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', 174 + 'disabled:cursor-not-allowed disabled:opacity-50' 175 + )} 176 + > 177 + {saving ? 'Saving...' : 'Save Settings'} 178 + </button> 179 + </div> 180 + </form> 181 + </div> 182 + </ForumLayout> 183 + ) 184 + }
+37
src/app/u/[handle]/page.test.tsx
··· 1 + /** 2 + * Tests for user profile page. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import UserProfilePage from './page' 8 + 9 + // Mock next/navigation 10 + vi.mock('next/navigation', () => ({ 11 + useRouter: () => ({ 12 + push: vi.fn(), 13 + replace: vi.fn(), 14 + back: vi.fn(), 15 + }), 16 + redirect: vi.fn(), 17 + })) 18 + 19 + describe('UserProfilePage', () => { 20 + it('renders user handle in heading', () => { 21 + render(<UserProfilePage params={{ handle: 'alice.bsky.social' }} />) 22 + expect(screen.getByRole('heading', { name: /alice\.bsky\.social/i })).toBeInTheDocument() 23 + }) 24 + 25 + it('renders breadcrumbs', () => { 26 + render(<UserProfilePage params={{ handle: 'alice.bsky.social' }} />) 27 + expect(screen.getByText('Home')).toBeInTheDocument() 28 + // Handle appears in both breadcrumbs and heading; check breadcrumb specifically 29 + const breadcrumb = screen.getByRole('navigation', { name: /breadcrumb/i }) 30 + expect(breadcrumb).toBeInTheDocument() 31 + }) 32 + 33 + it('renders profile sections', () => { 34 + render(<UserProfilePage params={{ handle: 'alice.bsky.social' }} />) 35 + expect(screen.getByText(/recent activity/i)).toBeInTheDocument() 36 + }) 37 + })
+109
src/app/u/[handle]/page.tsx
··· 1 + /** 2 + * User profile page. 3 + * URL: /u/[handle] 4 + * Displays user info, reputation, recent posts. 5 + * Client component (needs param resolution + dynamic data). 6 + * @see specs/prd-web.md Section M8 7 + */ 8 + 9 + 'use client' 10 + 11 + import { useState, useEffect } from 'react' 12 + import { User, CalendarBlank, ChatCircle } from '@phosphor-icons/react' 13 + import { ForumLayout } from '@/components/layout/forum-layout' 14 + import { Breadcrumbs } from '@/components/breadcrumbs' 15 + import { ReputationBadge } from '@/components/reputation-badge' 16 + import { BanIndicator } from '@/components/ban-indicator' 17 + 18 + interface UserProfilePageProps { 19 + params: Promise<{ handle: string }> | { handle: string } 20 + } 21 + 22 + export default function UserProfilePage({ params }: UserProfilePageProps) { 23 + const [handle, setHandle] = useState<string | null>(null) 24 + 25 + useEffect(() => { 26 + async function resolveParams() { 27 + const resolved = params instanceof Promise ? await params : params 28 + setHandle(resolved.handle) 29 + } 30 + void resolveParams() 31 + }, [params]) 32 + 33 + if (!handle) { 34 + return ( 35 + <ForumLayout> 36 + <div className="animate-pulse space-y-4 py-8"> 37 + <div className="h-8 w-48 rounded bg-muted" /> 38 + <div className="h-32 rounded bg-muted" /> 39 + </div> 40 + </ForumLayout> 41 + ) 42 + } 43 + 44 + // TODO: Fetch user profile from API when endpoint is available 45 + const mockProfile = { 46 + did: `did:plc:mock-${handle}`, 47 + handle, 48 + displayName: handle.split('.')[0], 49 + reputation: 42, 50 + postCount: 15, 51 + joinedAt: '2025-06-15T00:00:00Z', 52 + isBanned: false, 53 + } 54 + 55 + const joinDate = new Date(mockProfile.joinedAt).toLocaleDateString('en-US', { 56 + year: 'numeric', 57 + month: 'long', 58 + day: 'numeric', 59 + }) 60 + 61 + return ( 62 + <ForumLayout> 63 + <div className="space-y-6"> 64 + <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: handle }]} /> 65 + 66 + {/* Profile header */} 67 + <div className="rounded-lg border border-border bg-card p-6"> 68 + <div className="flex items-start gap-4"> 69 + <div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted"> 70 + <User size={32} className="text-muted-foreground" aria-hidden="true" /> 71 + </div> 72 + <div className="min-w-0 flex-1"> 73 + <h1 className="text-2xl font-bold text-foreground">{handle}</h1> 74 + {mockProfile.displayName && ( 75 + <p className="text-lg text-muted-foreground">{mockProfile.displayName}</p> 76 + )} 77 + 78 + <div className="mt-3 flex flex-wrap items-center gap-4 text-sm text-muted-foreground"> 79 + <ReputationBadge score={mockProfile.reputation} /> 80 + <span className="flex items-center gap-1"> 81 + <ChatCircle size={16} aria-hidden="true" /> 82 + {mockProfile.postCount} posts 83 + </span> 84 + <span className="flex items-center gap-1"> 85 + <CalendarBlank size={16} aria-hidden="true" /> 86 + Joined {joinDate} 87 + </span> 88 + </div> 89 + 90 + {mockProfile.isBanned && ( 91 + <div className="mt-3"> 92 + <BanIndicator isBanned={true} /> 93 + </div> 94 + )} 95 + </div> 96 + </div> 97 + </div> 98 + 99 + {/* Recent activity */} 100 + <section> 101 + <h2 className="text-lg font-semibold text-foreground">Recent Activity</h2> 102 + <p className="mt-2 text-sm text-muted-foreground"> 103 + Recent posts and replies will appear here once the API is connected. 104 + </p> 105 + </section> 106 + </div> 107 + </ForumLayout> 108 + ) 109 + }
+36
src/components/reputation-badge.test.tsx
··· 1 + /** 2 + * Tests for ReputationBadge component. 3 + */ 4 + 5 + import { describe, it, expect } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import { axe } from 'vitest-axe' 8 + import { ReputationBadge } from './reputation-badge' 9 + 10 + describe('ReputationBadge', () => { 11 + it('renders reputation score', () => { 12 + render(<ReputationBadge score={42} />) 13 + expect(screen.getByText('42')).toBeInTheDocument() 14 + }) 15 + 16 + it('renders with accessible label', () => { 17 + render(<ReputationBadge score={42} />) 18 + expect(screen.getByLabelText(/reputation.*42/i)).toBeInTheDocument() 19 + }) 20 + 21 + it('renders level label when provided', () => { 22 + render(<ReputationBadge score={100} level="Trusted" />) 23 + expect(screen.getByText('Trusted')).toBeInTheDocument() 24 + }) 25 + 26 + it('renders without level label when not provided', () => { 27 + render(<ReputationBadge score={10} />) 28 + expect(screen.queryByText('Trusted')).not.toBeInTheDocument() 29 + }) 30 + 31 + it('passes axe accessibility check', async () => { 32 + const { container } = render(<ReputationBadge score={42} level="Member" />) 33 + const results = await axe(container) 34 + expect(results).toHaveNoViolations() 35 + }) 36 + })
+26
src/components/reputation-badge.tsx
··· 1 + /** 2 + * ReputationBadge - Displays user reputation score and optional level. 3 + * @see specs/prd-web.md Section M8 (ReputationBadge) 4 + */ 5 + 6 + import { Star } from '@phosphor-icons/react/dist/ssr' 7 + import { cn } from '@/lib/utils' 8 + 9 + interface ReputationBadgeProps { 10 + score: number 11 + level?: string 12 + className?: string 13 + } 14 + 15 + export function ReputationBadge({ score, level, className }: ReputationBadgeProps) { 16 + return ( 17 + <span 18 + className={cn('inline-flex items-center gap-1 text-sm', className)} 19 + aria-label={`Reputation: ${score}`} 20 + > 21 + <Star className="h-3.5 w-3.5 text-amber-500" weight="fill" aria-hidden="true" /> 22 + <span className="font-medium text-foreground">{score}</span> 23 + {level && <span className="text-muted-foreground">{level}</span>} 24 + </span> 25 + ) 26 + }
+87
src/components/user-profile-card.test.tsx
··· 1 + /** 2 + * Tests for UserProfileCard component. 3 + */ 4 + 5 + import { describe, it, expect } from 'vitest' 6 + import { render, screen, act } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 8 + import { axe } from 'vitest-axe' 9 + import { UserProfileCard } from './user-profile-card' 10 + 11 + const mockUser = { 12 + did: 'did:plc:test-user', 13 + handle: 'alice.bsky.social', 14 + displayName: 'Alice', 15 + reputation: 42, 16 + postCount: 15, 17 + joinedAt: '2025-01-01T00:00:00Z', 18 + } 19 + 20 + describe('UserProfileCard', () => { 21 + it('renders trigger with user handle', () => { 22 + render(<UserProfileCard user={mockUser} />) 23 + expect(screen.getByText('alice.bsky.social')).toBeInTheDocument() 24 + }) 25 + 26 + it('shows card on hover', async () => { 27 + const user = userEvent.setup() 28 + render(<UserProfileCard user={mockUser} />) 29 + await user.hover(screen.getByText('alice.bsky.social')) 30 + 31 + // Wait for hover delay 32 + await act(async () => { 33 + await new Promise((r) => setTimeout(r, 250)) 34 + }) 35 + 36 + expect(screen.getByText('Alice')).toBeInTheDocument() 37 + expect(screen.getByLabelText(/reputation/i)).toBeInTheDocument() 38 + }) 39 + 40 + it('shows card on keyboard focus', async () => { 41 + const user = userEvent.setup() 42 + render(<UserProfileCard user={mockUser} />) 43 + await user.tab() 44 + 45 + // Wait for focus delay 46 + await act(async () => { 47 + await new Promise((r) => setTimeout(r, 250)) 48 + }) 49 + 50 + expect(screen.getByText('Alice')).toBeInTheDocument() 51 + }) 52 + 53 + it('hides card on Escape', async () => { 54 + const user = userEvent.setup() 55 + render(<UserProfileCard user={mockUser} />) 56 + await user.hover(screen.getByText('alice.bsky.social')) 57 + await act(async () => { 58 + await new Promise((r) => setTimeout(r, 250)) 59 + }) 60 + expect(screen.getByText('Alice')).toBeInTheDocument() 61 + 62 + await user.keyboard('{Escape}') 63 + expect(screen.queryByText('Alice')).not.toBeInTheDocument() 64 + }) 65 + 66 + it('shows post count', async () => { 67 + const user = userEvent.setup() 68 + render(<UserProfileCard user={mockUser} />) 69 + await user.hover(screen.getByText('alice.bsky.social')) 70 + await act(async () => { 71 + await new Promise((r) => setTimeout(r, 250)) 72 + }) 73 + expect(screen.getByText(/15/)).toBeInTheDocument() 74 + }) 75 + 76 + it('renders as link to user profile', () => { 77 + render(<UserProfileCard user={mockUser} />) 78 + const link = screen.getByRole('link', { name: /alice\.bsky\.social/i }) 79 + expect(link).toHaveAttribute('href', '/u/alice.bsky.social') 80 + }) 81 + 82 + it('passes axe accessibility check', async () => { 83 + const { container } = render(<UserProfileCard user={mockUser} />) 84 + const results = await axe(container) 85 + expect(results).toHaveNoViolations() 86 + }) 87 + })
+120
src/components/user-profile-card.tsx
··· 1 + /** 2 + * UserProfileCard - Hover/focus card showing user info, reputation, and stats. 3 + * Keyboard-triggerable via focus, Escape-dismissible. 4 + * @see specs/prd-web.md Section M8 (UserProfileCard) 5 + */ 6 + 7 + 'use client' 8 + 9 + import { useState, useRef, useCallback, useEffect } from 'react' 10 + import Link from 'next/link' 11 + import { User, CalendarBlank, ChatCircle } from '@phosphor-icons/react' 12 + import { cn } from '@/lib/utils' 13 + import { ReputationBadge } from './reputation-badge' 14 + 15 + interface UserProfileData { 16 + did: string 17 + handle: string 18 + displayName?: string 19 + reputation: number 20 + postCount: number 21 + joinedAt: string 22 + } 23 + 24 + interface UserProfileCardProps { 25 + user: UserProfileData 26 + className?: string 27 + } 28 + 29 + const SHOW_DELAY = 200 30 + 31 + export function UserProfileCard({ user, className }: UserProfileCardProps) { 32 + const [visible, setVisible] = useState(false) 33 + const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null) 34 + const containerRef = useRef<HTMLSpanElement>(null) 35 + 36 + const show = useCallback(() => { 37 + timerRef.current = setTimeout(() => setVisible(true), SHOW_DELAY) 38 + }, []) 39 + 40 + const hide = useCallback(() => { 41 + if (timerRef.current) { 42 + clearTimeout(timerRef.current) 43 + timerRef.current = null 44 + } 45 + setVisible(false) 46 + }, []) 47 + 48 + useEffect(() => { 49 + if (!visible) return 50 + const handleKeyDown = (e: KeyboardEvent) => { 51 + if (e.key === 'Escape') { 52 + hide() 53 + } 54 + } 55 + document.addEventListener('keydown', handleKeyDown) 56 + return () => document.removeEventListener('keydown', handleKeyDown) 57 + }, [visible, hide]) 58 + 59 + useEffect(() => { 60 + return () => { 61 + if (timerRef.current) clearTimeout(timerRef.current) 62 + } 63 + }, []) 64 + 65 + const joinDate = new Date(user.joinedAt).toLocaleDateString('en-US', { 66 + year: 'numeric', 67 + month: 'short', 68 + }) 69 + 70 + return ( 71 + // eslint-disable-next-line jsx-a11y/no-static-element-interactions 72 + <span 73 + ref={containerRef} 74 + className={cn('relative inline-block', className)} 75 + onMouseEnter={show} 76 + onMouseLeave={hide} 77 + onFocus={show} 78 + onBlur={hide} 79 + > 80 + <Link 81 + href={`/u/${user.handle}`} 82 + className="font-medium text-foreground hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:rounded-sm" 83 + > 84 + {user.handle} 85 + </Link> 86 + 87 + {visible && ( 88 + <div 89 + className="absolute left-0 top-full z-40 mt-1 w-64 rounded-lg border border-border bg-card p-4 shadow-lg" 90 + role="tooltip" 91 + > 92 + <div className="flex items-start gap-3"> 93 + <div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted"> 94 + <User size={20} className="text-muted-foreground" aria-hidden="true" /> 95 + </div> 96 + <div className="min-w-0 flex-1"> 97 + {user.displayName && ( 98 + <p className="truncate font-semibold text-foreground">{user.displayName}</p> 99 + )} 100 + <p className="truncate text-sm text-muted-foreground">@{user.handle}</p> 101 + </div> 102 + </div> 103 + 104 + <div className="mt-3 flex items-center gap-4 text-xs text-muted-foreground"> 105 + <ReputationBadge score={user.reputation} /> 106 + <span className="flex items-center gap-1"> 107 + <ChatCircle size={12} aria-hidden="true" /> 108 + {user.postCount} posts 109 + </span> 110 + </div> 111 + 112 + <div className="mt-2 flex items-center gap-1 text-xs text-muted-foreground"> 113 + <CalendarBlank size={12} aria-hidden="true" /> 114 + Joined {joinDate} 115 + </div> 116 + </div> 117 + )} 118 + </span> 119 + ) 120 + }