Barazo default frontend barazo.forum
2
fork

Configure Feed

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

Merge pull request #48 from barazo-forum/feat/profile-sync-community-overrides

feat: P2.6 community profile settings + real avatar display

authored by

Guido X Jansen and committed by
GitHub
c9ca21e2 f60cab51

+1090 -79
+4
src/app/settings/page.tsx
··· 14 14 import { ForumLayout } from '@/components/layout/forum-layout' 15 15 import { Breadcrumbs } from '@/components/breadcrumbs' 16 16 import { AgeGateDialog } from '@/components/age-gate-dialog' 17 + import { CommunityProfileSettings } from '@/components/community-profile-settings' 17 18 import { cn } from '@/lib/utils' 18 19 import { 19 20 getPreferences, ··· 209 210 Settings saved successfully. 210 211 </p> 211 212 )} 213 + 214 + {/* Community Profile (separate save, independent section) */} 215 + <CommunityProfileSettings /> 212 216 213 217 {/* Content Safety */} 214 218 <fieldset className="space-y-4 rounded-lg border border-border p-4">
+31 -13
src/app/u/[handle]/page.test.tsx
··· 1 1 /** 2 2 * Tests for user profile page. 3 + * The page fetches profile data via getUserProfile() (MSW-intercepted). 3 4 */ 4 5 5 6 import { describe, it, expect, vi } from 'vitest' 6 - import { render, screen } from '@testing-library/react' 7 + import { render, screen, waitFor } from '@testing-library/react' 7 8 import UserProfilePage from './page' 8 9 9 10 // Mock useAuth hook ··· 31 32 })) 32 33 33 34 describe('UserProfilePage', () => { 34 - it('renders user handle in heading', () => { 35 + it('renders user display name in heading', async () => { 35 36 render(<UserProfilePage params={{ handle: 'alice.bsky.social' }} />) 36 - expect(screen.getByRole('heading', { name: /alice\.bsky\.social/i })).toBeInTheDocument() 37 + await waitFor(() => { 38 + expect(screen.getByRole('heading', { name: /alice/i })).toBeInTheDocument() 39 + }) 37 40 }) 38 41 39 - it('renders breadcrumbs', () => { 42 + it('renders breadcrumbs', async () => { 40 43 render(<UserProfilePage params={{ handle: 'alice.bsky.social' }} />) 41 - expect(screen.getByText('Home')).toBeInTheDocument() 42 - // Handle appears in both breadcrumbs and heading; check breadcrumb specifically 44 + await waitFor(() => { 45 + expect(screen.getByText('Home')).toBeInTheDocument() 46 + }) 43 47 const breadcrumb = screen.getByRole('navigation', { name: /breadcrumb/i }) 44 48 expect(breadcrumb).toBeInTheDocument() 45 49 }) 46 50 47 - it('renders profile sections', () => { 51 + it('renders profile sections', async () => { 48 52 render(<UserProfilePage params={{ handle: 'alice.bsky.social' }} />) 49 - expect(screen.getByText(/recent activity/i)).toBeInTheDocument() 53 + await waitFor(() => { 54 + expect(screen.getByText(/recent activity/i)).toBeInTheDocument() 55 + }) 50 56 }) 51 57 52 - it('shows cross-community ban warning when user is banned elsewhere', () => { 53 - render(<UserProfilePage params={{ handle: 'dave.bsky.social' }} />) 54 - expect(screen.getByText(/banned from.*other communit/i)).toBeInTheDocument() 58 + it('renders user bio when available', async () => { 59 + render(<UserProfilePage params={{ handle: 'alice.bsky.social' }} />) 60 + await waitFor(() => { 61 + expect(screen.getByText(/community admin and at protocol enthusiast/i)).toBeInTheDocument() 62 + }) 55 63 }) 56 64 57 - it('does not show ban warning for users with no cross-community bans', () => { 65 + it('renders post count from activity data', async () => { 58 66 render(<UserProfilePage params={{ handle: 'alice.bsky.social' }} />) 59 - expect(screen.queryByText(/banned from.*other communit/i)).not.toBeInTheDocument() 67 + // alice has topicCount 15 + replyCount 42 = 57 posts 68 + await waitFor(() => { 69 + expect(screen.getByText(/57 posts/i)).toBeInTheDocument() 70 + }) 71 + }) 72 + 73 + it('shows error for unknown handle', async () => { 74 + render(<UserProfilePage params={{ handle: 'unknown.user.social' }} />) 75 + await waitFor(() => { 76 + expect(screen.getByText(/api 404/i)).toBeInTheDocument() 77 + }) 60 78 }) 61 79 })
+137 -64
src/app/u/[handle]/page.tsx
··· 9 9 'use client' 10 10 11 11 import { useState, useEffect } from 'react' 12 - import { User, CalendarBlank, ChatCircle, Prohibit } from '@phosphor-icons/react' 12 + import { User, CalendarBlank, ChatCircle } from '@phosphor-icons/react' 13 13 import { ForumLayout } from '@/components/layout/forum-layout' 14 14 import { Breadcrumbs } from '@/components/breadcrumbs' 15 15 import { ReputationBadge } from '@/components/reputation-badge' 16 - import { BanIndicator } from '@/components/ban-indicator' 17 16 import { BlockMuteButton } from '@/components/block-mute-button' 17 + import { getUserProfile } from '@/lib/api/client' 18 + import type { UserProfile } from '@/lib/api/types' 18 19 19 20 interface UserProfilePageProps { 20 21 params: Promise<{ handle: string }> | { handle: string } 21 22 } 22 23 24 + /** Compute reputation from activity counts using the backend formula. */ 25 + function computeReputation(activity: UserProfile['activity']): number { 26 + return activity.topicCount * 5 + activity.replyCount * 2 + activity.reactionsReceived * 1 27 + } 28 + 23 29 export default function UserProfilePage({ params }: UserProfilePageProps) { 24 30 const [handle, setHandle] = useState<string | null>(null) 31 + const [profile, setProfile] = useState<UserProfile | null>(null) 32 + const [loading, setLoading] = useState(true) 33 + const [error, setError] = useState<string | null>(null) 25 34 const [isBlocked, setIsBlocked] = useState(false) 26 35 const [isMuted, setIsMuted] = useState(false) 27 36 37 + // Resolve Next.js async params 28 38 useEffect(() => { 29 39 async function resolveParams() { 30 40 const resolved = params instanceof Promise ? await params : params ··· 33 43 void resolveParams() 34 44 }, [params]) 35 45 36 - if (!handle) { 46 + // Fetch profile when handle is available 47 + useEffect(() => { 48 + if (!handle) return 49 + 50 + let cancelled = false 51 + const controller = new AbortController() 52 + 53 + async function fetchProfile() { 54 + setLoading(true) 55 + setError(null) 56 + try { 57 + const data = await getUserProfile(handle!, undefined, { 58 + signal: controller.signal, 59 + }) 60 + if (!cancelled) { 61 + setProfile(data) 62 + } 63 + } catch (err) { 64 + if (!cancelled) { 65 + const message = err instanceof Error ? err.message : 'Failed to load profile' 66 + setError(message) 67 + } 68 + } finally { 69 + if (!cancelled) { 70 + setLoading(false) 71 + } 72 + } 73 + } 74 + 75 + void fetchProfile() 76 + 77 + return () => { 78 + cancelled = true 79 + controller.abort() 80 + } 81 + }, [handle]) 82 + 83 + // Loading state: show skeleton while params resolve or data loads 84 + if (!handle || loading) { 37 85 return ( 38 86 <ForumLayout> 39 87 <div className="animate-pulse space-y-4 py-8"> 40 - <div className="h-8 w-48 rounded bg-muted" /> 41 - <div className="h-32 rounded bg-muted" /> 88 + <div className="h-48 rounded-t-lg bg-muted" /> 89 + <div className="flex items-start gap-4 p-6"> 90 + <div className="h-16 w-16 rounded-full bg-muted" /> 91 + <div className="space-y-2"> 92 + <div className="h-6 w-32 rounded bg-muted" /> 93 + <div className="h-4 w-48 rounded bg-muted" /> 94 + </div> 95 + </div> 42 96 </div> 43 97 </ForumLayout> 44 98 ) 45 99 } 46 100 47 - // TODO: Fetch user profile from API when endpoint is available 48 - // Mock data: dave.bsky.social simulates a user banned from other communities 49 - const bannedFromOther = handle === 'dave.bsky.social' ? 2 : 0 50 - const mockProfile = { 51 - did: `did:plc:mock-${handle}`, 52 - handle, 53 - displayName: handle.split('.')[0], 54 - reputation: 42, 55 - postCount: 15, 56 - joinedAt: '2025-06-15T00:00:00Z', 57 - isBanned: false, 58 - bannedFromOtherCommunities: bannedFromOther, 101 + // Error state 102 + if (error) { 103 + return ( 104 + <ForumLayout> 105 + <div className="rounded-lg border border-destructive/20 bg-destructive/5 p-6 text-center"> 106 + <p className="text-sm text-destructive">{error}</p> 107 + </div> 108 + </ForumLayout> 109 + ) 59 110 } 60 111 61 - const joinDate = new Date(mockProfile.joinedAt).toLocaleDateString('en-US', { 112 + // No profile loaded (shouldn't happen if no error, but guard) 113 + if (!profile) { 114 + return ( 115 + <ForumLayout> 116 + <div className="rounded-lg border border-destructive/20 bg-destructive/5 p-6 text-center"> 117 + <p className="text-sm text-destructive">Profile not found.</p> 118 + </div> 119 + </ForumLayout> 120 + ) 121 + } 122 + 123 + const reputationScore = computeReputation(profile.activity) 124 + const postCount = profile.activity.topicCount + profile.activity.replyCount 125 + 126 + const joinDate = new Date(profile.firstSeenAt).toLocaleDateString('en-US', { 62 127 year: 'numeric', 63 128 month: 'long', 64 129 day: 'numeric', ··· 70 135 <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: handle }]} /> 71 136 72 137 {/* Profile header */} 73 - <div className="rounded-lg border border-border bg-card p-6"> 74 - <div className="flex items-start gap-4"> 75 - <div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted"> 76 - <User size={32} className="text-muted-foreground" aria-hidden="true" /> 138 + <div className="overflow-hidden rounded-lg border border-border bg-card"> 139 + {/* Banner */} 140 + {profile.bannerUrl && ( 141 + <div className="relative h-48 overflow-hidden"> 142 + <img src={profile.bannerUrl} alt="" className="h-full w-full object-cover" /> 77 143 </div> 78 - <div className="min-w-0 flex-1"> 79 - <h1 className="text-2xl font-bold text-foreground">{handle}</h1> 80 - {mockProfile.displayName && ( 81 - <p className="text-lg text-muted-foreground">{mockProfile.displayName}</p> 82 - )} 83 - 84 - <div className="mt-3 flex flex-wrap items-center gap-4 text-sm text-muted-foreground"> 85 - <ReputationBadge score={mockProfile.reputation} /> 86 - <span className="flex items-center gap-1"> 87 - <ChatCircle size={16} aria-hidden="true" /> 88 - {mockProfile.postCount} posts 89 - </span> 90 - <span className="flex items-center gap-1"> 91 - <CalendarBlank size={16} aria-hidden="true" /> 92 - Joined {joinDate} 93 - </span> 94 - </div> 144 + )} 95 145 96 - {mockProfile.isBanned && ( 97 - <div className="mt-3"> 98 - <BanIndicator isBanned={true} /> 146 + <div className="p-6"> 147 + <div className="flex items-start gap-4"> 148 + {/* Avatar */} 149 + {profile.avatarUrl ? ( 150 + <img 151 + src={profile.avatarUrl} 152 + alt={`${profile.displayName ?? profile.handle}'s avatar`} 153 + className="h-16 w-16 rounded-full object-cover" 154 + /> 155 + ) : ( 156 + <div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted"> 157 + <User size={32} className="text-muted-foreground" aria-hidden="true" /> 99 158 </div> 100 159 )} 101 160 102 - {mockProfile.bannedFromOtherCommunities > 0 && ( 103 - <p className="mt-2 inline-flex items-center gap-1 rounded-md bg-destructive/10 px-2 py-1 text-xs font-medium text-destructive"> 104 - <Prohibit size={14} aria-hidden="true" /> 105 - Banned from {mockProfile.bannedFromOtherCommunities} other{' '} 106 - {mockProfile.bannedFromOtherCommunities === 1 ? 'community' : 'communities'} 107 - </p> 108 - )} 161 + <div className="min-w-0 flex-1"> 162 + <h1 className="text-2xl font-bold text-foreground"> 163 + {profile.displayName ?? handle} 164 + </h1> 165 + {profile.displayName && <p className="text-lg text-muted-foreground">@{handle}</p>} 166 + 167 + {/* Bio */} 168 + {profile.bio && <p className="mt-2 text-sm text-muted-foreground">{profile.bio}</p>} 109 169 110 - {/* Block/Mute actions */} 111 - <div className="mt-3 flex gap-2"> 112 - <BlockMuteButton 113 - targetDid={mockProfile.did} 114 - action="block" 115 - isActive={isBlocked} 116 - onToggle={setIsBlocked} 117 - /> 118 - <BlockMuteButton 119 - targetDid={mockProfile.did} 120 - action="mute" 121 - isActive={isMuted} 122 - onToggle={setIsMuted} 123 - /> 170 + <div className="mt-3 flex flex-wrap items-center gap-4 text-sm text-muted-foreground"> 171 + <ReputationBadge score={reputationScore} /> 172 + <span className="flex items-center gap-1"> 173 + <ChatCircle size={16} aria-hidden="true" /> 174 + {postCount} {postCount === 1 ? 'post' : 'posts'} 175 + </span> 176 + <span className="flex items-center gap-1"> 177 + <CalendarBlank size={16} aria-hidden="true" /> 178 + Joined {joinDate} 179 + </span> 180 + </div> 181 + 182 + {/* Block/Mute actions */} 183 + <div className="mt-3 flex gap-2"> 184 + <BlockMuteButton 185 + targetDid={profile.did} 186 + action="block" 187 + isActive={isBlocked} 188 + onToggle={setIsBlocked} 189 + /> 190 + <BlockMuteButton 191 + targetDid={profile.did} 192 + action="mute" 193 + isActive={isMuted} 194 + onToggle={setIsMuted} 195 + /> 196 + </div> 124 197 </div> 125 198 </div> 126 199 </div> ··· 130 203 <section> 131 204 <h2 className="text-lg font-semibold text-foreground">Recent Activity</h2> 132 205 <p className="mt-2 text-sm text-muted-foreground"> 133 - Recent posts and replies will appear here once the API is connected. 206 + Recent posts and replies will appear here once the activity feed is implemented. 134 207 </p> 135 208 </section> 136 209 </div>
+444
src/components/community-profile-settings.tsx
··· 1 + /** 2 + * CommunityProfileSettings - Section for customizing per-community profile. 3 + * Displays source AT Protocol profile as reference, override form for 4 + * display name / bio / avatar / banner, and reset functionality. 5 + * @see specs/prd-web.md Section M8 (Settings / Community Profile) 6 + */ 7 + 8 + 'use client' 9 + 10 + import { useState, useEffect, useCallback } from 'react' 11 + import { cn } from '@/lib/utils' 12 + import { ImageUpload } from '@/components/image-upload' 13 + import { ConfirmDialog } from '@/components/confirm-dialog' 14 + import { useAuth } from '@/hooks/use-auth' 15 + import { 16 + getPublicSettings, 17 + getCommunityProfile, 18 + updateCommunityProfile, 19 + resetCommunityProfile, 20 + uploadCommunityAvatar, 21 + uploadCommunityBanner, 22 + } from '@/lib/api/client' 23 + import type { CommunityProfile } from '@/lib/api/types' 24 + import { ArrowCounterClockwise } from '@phosphor-icons/react' 25 + 26 + const DISPLAY_NAME_MAX = 256 27 + const BIO_MAX = 2048 28 + 29 + export function CommunityProfileSettings() { 30 + const { getAccessToken, isAuthenticated } = useAuth() 31 + 32 + const [communityDid, setCommunityDid] = useState<string | null>(null) 33 + const [profile, setProfile] = useState<CommunityProfile | null>(null) 34 + const [loading, setLoading] = useState(true) 35 + const [saving, setSaving] = useState(false) 36 + const [error, setError] = useState<string | null>(null) 37 + const [success, setSuccess] = useState(false) 38 + const [showResetConfirm, setShowResetConfirm] = useState(false) 39 + 40 + // Form state for text overrides 41 + const [displayName, setDisplayName] = useState('') 42 + const [bio, setBio] = useState('') 43 + 44 + // Load community DID from public settings, then load profile 45 + useEffect(() => { 46 + if (!isAuthenticated) { 47 + setLoading(false) 48 + return 49 + } 50 + 51 + const token = getAccessToken() 52 + if (!token) { 53 + setLoading(false) 54 + return 55 + } 56 + 57 + let cancelled = false 58 + 59 + async function loadProfile() { 60 + try { 61 + const publicSettings = await getPublicSettings() 62 + const did = publicSettings.communityDid 63 + if (!did) { 64 + // Community not initialized yet 65 + if (!cancelled) { 66 + setLoading(false) 67 + } 68 + return 69 + } 70 + 71 + if (!cancelled) { 72 + setCommunityDid(did) 73 + } 74 + 75 + const currentToken = token 76 + if (!currentToken) return 77 + 78 + const communityProfile = await getCommunityProfile(did, currentToken) 79 + if (!cancelled) { 80 + setProfile(communityProfile) 81 + // Initialize form with current override values (empty string means "use source") 82 + setDisplayName(communityProfile.hasOverride ? (communityProfile.displayName ?? '') : '') 83 + setBio(communityProfile.hasOverride ? (communityProfile.bio ?? '') : '') 84 + } 85 + } catch { 86 + if (!cancelled) { 87 + setError('Failed to load community profile.') 88 + } 89 + } finally { 90 + if (!cancelled) { 91 + setLoading(false) 92 + } 93 + } 94 + } 95 + 96 + void loadProfile() 97 + return () => { 98 + cancelled = true 99 + } 100 + }, [getAccessToken, isAuthenticated]) 101 + 102 + const handleSave = useCallback( 103 + async (e: React.FormEvent) => { 104 + e.preventDefault() 105 + if (!communityDid) return 106 + 107 + const token = getAccessToken() 108 + if (!token) { 109 + setError('Not authenticated') 110 + return 111 + } 112 + 113 + setSaving(true) 114 + setError(null) 115 + setSuccess(false) 116 + 117 + try { 118 + await updateCommunityProfile( 119 + communityDid, 120 + { 121 + displayName: displayName.trim() || null, 122 + bio: bio.trim() || null, 123 + }, 124 + token 125 + ) 126 + 127 + // Reload profile to get fresh state 128 + const updatedProfile = await getCommunityProfile(communityDid, token) 129 + setProfile(updatedProfile) 130 + setSuccess(true) 131 + } catch { 132 + setError('Failed to save community profile.') 133 + } finally { 134 + setSaving(false) 135 + } 136 + }, 137 + [communityDid, displayName, bio, getAccessToken] 138 + ) 139 + 140 + const handleReset = useCallback(async () => { 141 + if (!communityDid) return 142 + 143 + const token = getAccessToken() 144 + if (!token) { 145 + setError('Not authenticated') 146 + return 147 + } 148 + 149 + setShowResetConfirm(false) 150 + setSaving(true) 151 + setError(null) 152 + setSuccess(false) 153 + 154 + try { 155 + await resetCommunityProfile(communityDid, token) 156 + 157 + // Reload profile after reset 158 + const updatedProfile = await getCommunityProfile(communityDid, token) 159 + setProfile(updatedProfile) 160 + setDisplayName('') 161 + setBio('') 162 + setSuccess(true) 163 + } catch { 164 + setError('Failed to reset community profile.') 165 + } finally { 166 + setSaving(false) 167 + } 168 + }, [communityDid, getAccessToken]) 169 + 170 + const handleAvatarUpload = useCallback( 171 + async (file: File): Promise<{ url: string }> => { 172 + if (!communityDid) throw new Error('No community DID') 173 + const token = getAccessToken() 174 + if (!token) throw new Error('Not authenticated') 175 + 176 + const result = await uploadCommunityAvatar(communityDid, file, token) 177 + 178 + // Reload profile to reflect new avatar 179 + const updatedProfile = await getCommunityProfile(communityDid, token) 180 + setProfile(updatedProfile) 181 + 182 + return result 183 + }, 184 + [communityDid, getAccessToken] 185 + ) 186 + 187 + const handleBannerUpload = useCallback( 188 + async (file: File): Promise<{ url: string }> => { 189 + if (!communityDid) throw new Error('No community DID') 190 + const token = getAccessToken() 191 + if (!token) throw new Error('Not authenticated') 192 + 193 + const result = await uploadCommunityBanner(communityDid, file, token) 194 + 195 + // Reload profile to reflect new banner 196 + const updatedProfile = await getCommunityProfile(communityDid, token) 197 + setProfile(updatedProfile) 198 + 199 + return result 200 + }, 201 + [communityDid, getAccessToken] 202 + ) 203 + 204 + const handleAvatarRemove = useCallback(async () => { 205 + if (!communityDid) return 206 + const token = getAccessToken() 207 + if (!token) return 208 + 209 + try { 210 + // Update profile with null avatar by saving without avatar override 211 + // The API interprets a PUT without avatar fields as keeping current state, 212 + // so we reset the full profile and re-save text fields 213 + await updateCommunityProfile( 214 + communityDid, 215 + { 216 + displayName: displayName.trim() || null, 217 + bio: bio.trim() || null, 218 + }, 219 + token 220 + ) 221 + const updatedProfile = await getCommunityProfile(communityDid, token) 222 + setProfile(updatedProfile) 223 + } catch { 224 + setError('Failed to remove avatar.') 225 + } 226 + }, [communityDid, displayName, bio, getAccessToken]) 227 + 228 + const handleBannerRemove = useCallback(async () => { 229 + if (!communityDid) return 230 + const token = getAccessToken() 231 + if (!token) return 232 + 233 + try { 234 + await updateCommunityProfile( 235 + communityDid, 236 + { 237 + displayName: displayName.trim() || null, 238 + bio: bio.trim() || null, 239 + }, 240 + token 241 + ) 242 + const updatedProfile = await getCommunityProfile(communityDid, token) 243 + setProfile(updatedProfile) 244 + } catch { 245 + setError('Failed to remove banner.') 246 + } 247 + }, [communityDid, displayName, bio, getAccessToken]) 248 + 249 + if (!isAuthenticated) return null 250 + 251 + if (loading) { 252 + return ( 253 + <fieldset className="space-y-4 rounded-lg border border-border p-4"> 254 + <legend className="px-2 text-sm font-semibold text-foreground">Community Profile</legend> 255 + <div className="animate-pulse space-y-3"> 256 + <div className="h-24 w-24 rounded-full bg-muted" /> 257 + <div className="h-10 rounded-md bg-muted" /> 258 + <div className="h-20 rounded-md bg-muted" /> 259 + </div> 260 + </fieldset> 261 + ) 262 + } 263 + 264 + if (!communityDid) { 265 + return ( 266 + <fieldset className="space-y-4 rounded-lg border border-border p-4"> 267 + <legend className="px-2 text-sm font-semibold text-foreground">Community Profile</legend> 268 + <p className="text-sm text-muted-foreground"> 269 + Community has not been initialized yet. Profile customization will be available once the 270 + community is set up. 271 + </p> 272 + </fieldset> 273 + ) 274 + } 275 + 276 + return ( 277 + <> 278 + <fieldset className="space-y-6 rounded-lg border border-border p-4"> 279 + <legend className="px-2 text-sm font-semibold text-foreground">Community Profile</legend> 280 + 281 + <p className="text-sm text-muted-foreground"> 282 + Customize how you appear in this community. Leave fields empty to use your AT Protocol 283 + profile. 284 + </p> 285 + 286 + {error && ( 287 + <p 288 + className="rounded-md bg-destructive/10 px-4 py-2 text-sm text-destructive" 289 + role="alert" 290 + > 291 + {error} 292 + </p> 293 + )} 294 + 295 + {success && ( 296 + <p 297 + className="rounded-md bg-green-500/10 px-4 py-2 text-sm text-green-700 dark:text-green-400" 298 + role="status" 299 + > 300 + Community profile updated. 301 + </p> 302 + )} 303 + 304 + {/* Source profile preview (read-only) */} 305 + {profile?.source && ( 306 + <div className="space-y-2 rounded-md border border-dashed border-border bg-muted/30 p-3"> 307 + <p className="text-xs font-medium text-muted-foreground"> 308 + Your AT Protocol profile (source) 309 + </p> 310 + <div className="flex items-center gap-3"> 311 + {profile.source.avatarUrl ? ( 312 + // eslint-disable-next-line @next/next/no-img-element 313 + <img 314 + src={profile.source.avatarUrl} 315 + alt="Source avatar" 316 + className="h-10 w-10 rounded-full object-cover" 317 + /> 318 + ) : ( 319 + <div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-muted-foreground"> 320 + <span className="text-xs">--</span> 321 + </div> 322 + )} 323 + <div className="min-w-0"> 324 + <p className="truncate text-sm text-muted-foreground"> 325 + {profile.source.displayName || '(no display name)'} 326 + </p> 327 + <p className="truncate text-xs text-muted-foreground/70"> 328 + {profile.source.bio || '(no bio)'} 329 + </p> 330 + </div> 331 + </div> 332 + </div> 333 + )} 334 + 335 + {/* Avatar upload */} 336 + <ImageUpload 337 + currentUrl={profile?.avatarUrl ?? null} 338 + onUpload={handleAvatarUpload} 339 + onRemove={profile?.hasOverride && profile.avatarUrl ? handleAvatarRemove : undefined} 340 + label="Avatar" 341 + aspectRatio="1/1" 342 + /> 343 + 344 + {/* Banner upload */} 345 + <ImageUpload 346 + currentUrl={profile?.bannerUrl ?? null} 347 + onUpload={handleBannerUpload} 348 + onRemove={profile?.hasOverride && profile.bannerUrl ? handleBannerRemove : undefined} 349 + label="Banner" 350 + aspectRatio="3/1" 351 + /> 352 + 353 + {/* Display name */} 354 + <div className="space-y-1"> 355 + <label 356 + htmlFor="community-display-name" 357 + className="block text-sm font-medium text-foreground" 358 + > 359 + Display name 360 + </label> 361 + <input 362 + id="community-display-name" 363 + type="text" 364 + value={displayName} 365 + onChange={(e) => setDisplayName(e.target.value)} 366 + placeholder={profile?.source.displayName ?? 'Display name'} 367 + maxLength={DISPLAY_NAME_MAX} 368 + className={cn( 369 + 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 370 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 371 + )} 372 + /> 373 + <p className="text-xs text-muted-foreground"> 374 + {displayName.length}/{DISPLAY_NAME_MAX} characters. Leave empty to use your AT Protocol 375 + display name. 376 + </p> 377 + </div> 378 + 379 + {/* Bio */} 380 + <div className="space-y-1"> 381 + <label htmlFor="community-bio" className="block text-sm font-medium text-foreground"> 382 + Bio 383 + </label> 384 + <textarea 385 + id="community-bio" 386 + value={bio} 387 + onChange={(e) => setBio(e.target.value)} 388 + placeholder={profile?.source.bio ?? 'Tell the community about yourself'} 389 + maxLength={BIO_MAX} 390 + rows={4} 391 + className={cn( 392 + 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 393 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 394 + )} 395 + /> 396 + <p className="text-xs text-muted-foreground"> 397 + {bio.length}/{BIO_MAX} characters. Leave empty to use your AT Protocol bio. 398 + </p> 399 + </div> 400 + 401 + {/* Action buttons */} 402 + <div className="flex items-center justify-between gap-3"> 403 + <button 404 + type="button" 405 + onClick={() => setShowResetConfirm(true)} 406 + disabled={saving || !profile?.hasOverride} 407 + className={cn( 408 + 'inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-2 text-sm font-medium text-foreground transition-colors', 409 + 'hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 410 + 'disabled:cursor-not-allowed disabled:opacity-50' 411 + )} 412 + > 413 + <ArrowCounterClockwise size={16} weight="bold" aria-hidden="true" /> 414 + Reset to AT Protocol Profile 415 + </button> 416 + 417 + <button 418 + type="button" 419 + onClick={handleSave} 420 + disabled={saving} 421 + className={cn( 422 + 'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors', 423 + 'hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', 424 + 'disabled:cursor-not-allowed disabled:opacity-50' 425 + )} 426 + > 427 + {saving ? 'Saving...' : 'Save Profile'} 428 + </button> 429 + </div> 430 + </fieldset> 431 + 432 + <ConfirmDialog 433 + open={showResetConfirm} 434 + title="Reset Community Profile" 435 + description="This will remove all community-specific overrides (display name, bio, avatar, banner) and revert to your AT Protocol profile. This action cannot be undone." 436 + confirmLabel="Reset Profile" 437 + cancelLabel="Keep Overrides" 438 + variant="destructive" 439 + onConfirm={handleReset} 440 + onCancel={() => setShowResetConfirm(false)} 441 + /> 442 + </> 443 + ) 444 + }
+177
src/components/image-upload.tsx
··· 1 + /** 2 + * ImageUpload - Reusable image upload component for avatars and banners. 3 + * Supports client-side validation (file type, max size), upload progress, 4 + * and remove functionality. 5 + * @see specs/prd-web.md Section M8 (Settings / Community Profile) 6 + */ 7 + 8 + 'use client' 9 + 10 + import { useRef, useState, useCallback } from 'react' 11 + import { cn } from '@/lib/utils' 12 + import { UploadSimple, TrashSimple, User, Image as ImageIcon } from '@phosphor-icons/react' 13 + 14 + const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] 15 + const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 // 5 MB 16 + 17 + interface ImageUploadProps { 18 + /** URL of the currently displayed image, or null for placeholder */ 19 + currentUrl: string | null 20 + /** Upload handler -- receives the File and returns { url } on success */ 21 + onUpload: (file: File) => Promise<{ url: string }> 22 + /** Optional remove handler -- called when the user clicks "Remove" */ 23 + onRemove?: () => void 24 + /** Accessible label for the upload area */ 25 + label: string 26 + /** CSS aspect-ratio value, e.g. "1/1" for avatar, "3/1" for banner */ 27 + aspectRatio?: string 28 + /** Additional CSS classes for the outer container */ 29 + className?: string 30 + } 31 + 32 + export function ImageUpload({ 33 + currentUrl, 34 + onUpload, 35 + onRemove, 36 + label, 37 + aspectRatio = '1/1', 38 + className, 39 + }: ImageUploadProps) { 40 + const fileInputRef = useRef<HTMLInputElement>(null) 41 + const [uploading, setUploading] = useState(false) 42 + const [error, setError] = useState<string | null>(null) 43 + 44 + const isAvatar = aspectRatio === '1/1' 45 + 46 + const handleFileChange = useCallback( 47 + async (e: React.ChangeEvent<HTMLInputElement>) => { 48 + const file = e.target.files?.[0] 49 + if (!file) return 50 + 51 + // Reset the input so re-selecting the same file triggers onChange 52 + e.target.value = '' 53 + 54 + // Client-side validation: file type 55 + if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { 56 + setError('Please select a JPEG, PNG, WebP, or GIF image.') 57 + return 58 + } 59 + 60 + // Client-side validation: file size 61 + if (file.size > MAX_FILE_SIZE_BYTES) { 62 + setError('Image must be smaller than 5 MB.') 63 + return 64 + } 65 + 66 + setError(null) 67 + setUploading(true) 68 + 69 + try { 70 + await onUpload(file) 71 + } catch { 72 + setError('Upload failed. Please try again.') 73 + } finally { 74 + setUploading(false) 75 + } 76 + }, 77 + [onUpload] 78 + ) 79 + 80 + const handleUploadClick = useCallback(() => { 81 + fileInputRef.current?.click() 82 + }, []) 83 + 84 + const handleRemoveClick = useCallback(() => { 85 + setError(null) 86 + onRemove?.() 87 + }, [onRemove]) 88 + 89 + return ( 90 + <div className={cn('space-y-2', className)}> 91 + <p className="text-sm font-medium text-foreground">{label}</p> 92 + 93 + {/* Image preview area */} 94 + <div 95 + className={cn( 96 + 'relative overflow-hidden border border-border bg-muted', 97 + isAvatar ? 'h-24 w-24 rounded-full' : 'w-full max-w-md rounded-lg' 98 + )} 99 + style={{ aspectRatio }} 100 + > 101 + {currentUrl ? ( 102 + // eslint-disable-next-line @next/next/no-img-element 103 + <img src={currentUrl} alt={label} className="h-full w-full object-cover" /> 104 + ) : ( 105 + <div className="flex h-full w-full items-center justify-center text-muted-foreground"> 106 + {isAvatar ? ( 107 + <User size={32} weight="regular" aria-hidden="true" /> 108 + ) : ( 109 + <ImageIcon size={32} weight="regular" aria-hidden="true" /> 110 + )} 111 + </div> 112 + )} 113 + 114 + {/* Loading overlay */} 115 + {uploading && ( 116 + <div className="absolute inset-0 flex items-center justify-center bg-black/40"> 117 + <div 118 + className="h-6 w-6 animate-spin rounded-full border-2 border-white border-t-transparent" 119 + role="status" 120 + aria-label="Uploading image" 121 + /> 122 + </div> 123 + )} 124 + </div> 125 + 126 + {/* Hidden file input */} 127 + <input 128 + ref={fileInputRef} 129 + type="file" 130 + accept={ACCEPTED_IMAGE_TYPES.join(',')} 131 + onChange={handleFileChange} 132 + className="sr-only" 133 + aria-label={`Upload ${label}`} 134 + /> 135 + 136 + {/* Action buttons */} 137 + <div className="flex gap-2"> 138 + <button 139 + type="button" 140 + onClick={handleUploadClick} 141 + disabled={uploading} 142 + className={cn( 143 + 'inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground transition-colors', 144 + 'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 145 + 'disabled:cursor-not-allowed disabled:opacity-50' 146 + )} 147 + > 148 + <UploadSimple size={16} weight="bold" aria-hidden="true" /> 149 + {uploading ? 'Uploading...' : 'Upload'} 150 + </button> 151 + 152 + {onRemove && currentUrl && ( 153 + <button 154 + type="button" 155 + onClick={handleRemoveClick} 156 + disabled={uploading} 157 + className={cn( 158 + 'inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground transition-colors', 159 + 'hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 160 + 'disabled:cursor-not-allowed disabled:opacity-50' 161 + )} 162 + > 163 + <TrashSimple size={16} weight="bold" aria-hidden="true" /> 164 + Remove 165 + </button> 166 + )} 167 + </div> 168 + 169 + {/* Error message */} 170 + {error && ( 171 + <p className="text-sm text-destructive" role="alert"> 172 + {error} 173 + </p> 174 + )} 175 + </div> 176 + ) 177 + }
+8 -2
src/components/user-profile-card.tsx
··· 16 16 did: string 17 17 handle: string 18 18 displayName?: string 19 + avatarUrl?: string | null 19 20 reputation: number 20 21 postCount: number 21 22 joinedAt: string ··· 90 91 role="tooltip" 91 92 > 92 93 <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" /> 94 + <div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted"> 95 + {user.avatarUrl ? ( 96 + // eslint-disable-next-line @next/next/no-img-element 97 + <img src={user.avatarUrl} alt="" className="h-full w-full object-cover" /> 98 + ) : ( 99 + <User size={20} className="text-muted-foreground" aria-hidden="true" /> 100 + )} 95 101 </div> 96 102 <div className="min-w-0 flex-1"> 97 103 {user.displayName && (
+99
src/lib/api/client.ts
··· 43 43 SubmitOnboardingInput, 44 44 MyReport, 45 45 MyReportsResponse, 46 + UserProfile, 47 + CommunityProfile, 48 + UpdateCommunityProfileInput, 49 + UploadResponse, 46 50 } from './types' 47 51 48 52 const API_URL = ··· 794 798 body: { reason }, 795 799 } 796 800 ) 801 + } 802 + 803 + // --- User Profile endpoints --- 804 + 805 + export function getUserProfile( 806 + handle: string, 807 + communityDid?: string, 808 + options?: FetchOptions 809 + ): Promise<UserProfile> { 810 + const query = buildQuery({ communityDid }) 811 + return apiFetch<UserProfile>(`/api/users/${encodeURIComponent(handle)}${query}`, options) 812 + } 813 + 814 + // --- Community Profile endpoints --- 815 + 816 + export function getCommunityProfile( 817 + communityDid: string, 818 + accessToken: string, 819 + options?: FetchOptions 820 + ): Promise<CommunityProfile> { 821 + return apiFetch<CommunityProfile>( 822 + `/api/communities/${encodeURIComponent(communityDid)}/profile`, 823 + { ...options, headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` } } 824 + ) 825 + } 826 + 827 + export function updateCommunityProfile( 828 + communityDid: string, 829 + input: UpdateCommunityProfileInput, 830 + accessToken: string, 831 + options?: FetchOptions 832 + ): Promise<{ success: boolean }> { 833 + return apiFetch<{ success: boolean }>( 834 + `/api/communities/${encodeURIComponent(communityDid)}/profile`, 835 + { 836 + ...options, 837 + method: 'PUT', 838 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 839 + body: input, 840 + } 841 + ) 842 + } 843 + 844 + export function resetCommunityProfile( 845 + communityDid: string, 846 + accessToken: string, 847 + options?: FetchOptions 848 + ): Promise<void> { 849 + return apiFetch<void>(`/api/communities/${encodeURIComponent(communityDid)}/profile`, { 850 + ...options, 851 + method: 'DELETE', 852 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 853 + }) 854 + } 855 + 856 + // --- Upload endpoints (use FormData, not JSON) --- 857 + 858 + export async function uploadCommunityAvatar( 859 + communityDid: string, 860 + file: File, 861 + accessToken: string 862 + ): Promise<UploadResponse> { 863 + const form = new FormData() 864 + form.append('file', file) 865 + const url = `${API_URL}/api/communities/${encodeURIComponent(communityDid)}/profile/avatar` 866 + const response = await fetch(url, { 867 + method: 'POST', 868 + headers: { Authorization: `Bearer ${accessToken}` }, 869 + body: form, 870 + }) 871 + if (!response.ok) { 872 + const body = await response.text().catch(() => 'Unknown error') 873 + throw new ApiError(response.status, `API ${response.status}: ${body}`) 874 + } 875 + return response.json() as Promise<UploadResponse> 876 + } 877 + 878 + export async function uploadCommunityBanner( 879 + communityDid: string, 880 + file: File, 881 + accessToken: string 882 + ): Promise<UploadResponse> { 883 + const form = new FormData() 884 + form.append('file', file) 885 + const url = `${API_URL}/api/communities/${encodeURIComponent(communityDid)}/profile/banner` 886 + const response = await fetch(url, { 887 + method: 'POST', 888 + headers: { Authorization: `Bearer ${accessToken}` }, 889 + body: form, 890 + }) 891 + if (!response.ok) { 892 + const body = await response.text().catch(() => 'Unknown error') 893 + throw new ApiError(response.status, `API ${response.status}: ${body}`) 894 + } 895 + return response.json() as Promise<UploadResponse> 797 896 } 798 897 799 898 export { ApiError }
+48
src/lib/api/types.ts
··· 183 183 } 184 184 185 185 export interface PublicSettings { 186 + communityDid: string | null 186 187 communityName: string 187 188 maturityRating: MaturityRating 188 189 communityDescription: string | null ··· 541 542 542 543 export interface SubmitOnboardingInput { 543 544 responses: Array<{ fieldId: string; response: unknown }> 545 + } 546 + 547 + // --- User Profile (public, with community resolution) --- 548 + 549 + export interface UserProfile { 550 + did: string 551 + handle: string 552 + displayName: string | null 553 + avatarUrl: string | null 554 + bannerUrl: string | null 555 + bio: string | null 556 + role: string 557 + firstSeenAt: string 558 + lastActiveAt: string 559 + activity: { 560 + topicCount: number 561 + replyCount: number 562 + reactionsReceived: number 563 + } 564 + } 565 + 566 + // --- Community Profile (own profile in a community) --- 567 + 568 + export interface CommunityProfile { 569 + did: string 570 + handle: string 571 + displayName: string | null 572 + avatarUrl: string | null 573 + bannerUrl: string | null 574 + bio: string | null 575 + communityDid: string 576 + hasOverride: boolean 577 + source: { 578 + displayName: string | null 579 + avatarUrl: string | null 580 + bannerUrl: string | null 581 + bio: string | null 582 + } 583 + } 584 + 585 + export interface UpdateCommunityProfileInput { 586 + displayName?: string | null 587 + bio?: string | null 588 + } 589 + 590 + export interface UploadResponse { 591 + url: string 544 592 } 545 593 546 594 // --- Shared ---
+69
src/mocks/data.ts
··· 9 9 CategoryTreeNode, 10 10 CategoryWithTopicCount, 11 11 CommunityPreferenceOverride, 12 + CommunityProfile, 12 13 Topic, 13 14 Reply, 14 15 Notification, ··· 22 23 CommunitySettings, 23 24 CommunityStats, 24 25 Plugin, 26 + PublicSettings, 25 27 UserPreferences, 28 + UserProfile, 26 29 OnboardingField, 27 30 MyReport, 28 31 } from '@/lib/api/types' ··· 987 990 createdAt: LAST_WEEK, 988 991 }, 989 992 ] 993 + 994 + // --- User Profiles (public, keyed by handle) --- 995 + 996 + export const mockUserProfiles: Record<string, UserProfile> = { 997 + 'alice.bsky.social': { 998 + did: 'did:plc:user-alice-001', 999 + handle: 'alice.bsky.social', 1000 + displayName: 'Alice', 1001 + avatarUrl: 'https://cdn.bsky.social/avatar/alice.jpg', 1002 + bannerUrl: null, 1003 + bio: 'Community admin and AT Protocol enthusiast.', 1004 + role: 'admin', 1005 + firstSeenAt: TWO_DAYS_AGO, 1006 + lastActiveAt: NOW, 1007 + activity: { 1008 + topicCount: 15, 1009 + replyCount: 42, 1010 + reactionsReceived: 89, 1011 + }, 1012 + }, 1013 + 'bob.bsky.social': { 1014 + did: 'did:plc:user-bob-002', 1015 + handle: 'bob.bsky.social', 1016 + displayName: 'Bob', 1017 + avatarUrl: null, 1018 + bannerUrl: null, 1019 + bio: null, 1020 + role: 'moderator', 1021 + firstSeenAt: TWO_DAYS_AGO, 1022 + lastActiveAt: YESTERDAY, 1023 + activity: { 1024 + topicCount: 8, 1025 + replyCount: 31, 1026 + reactionsReceived: 45, 1027 + }, 1028 + }, 1029 + } 1030 + 1031 + // --- Public Settings --- 1032 + 1033 + export const mockPublicSettings: PublicSettings = { 1034 + communityDid: COMMUNITY_DID, 1035 + communityName: 'Barazo Test Community', 1036 + maturityRating: 'safe', 1037 + communityDescription: 'A test community for development', 1038 + communityLogoUrl: null, 1039 + } 1040 + 1041 + // --- Community Profile (own profile in a community) --- 1042 + 1043 + export const mockCommunityProfile: CommunityProfile = { 1044 + did: 'did:plc:user-alice-001', 1045 + handle: 'alice.bsky.social', 1046 + displayName: 'Alice', 1047 + avatarUrl: 'https://cdn.bsky.social/avatar/alice.jpg', 1048 + bannerUrl: null, 1049 + bio: 'Community admin and AT Protocol enthusiast.', 1050 + communityDid: COMMUNITY_DID, 1051 + hasOverride: false, 1052 + source: { 1053 + displayName: 'Alice', 1054 + avatarUrl: 'https://cdn.bsky.social/avatar/alice.jpg', 1055 + bannerUrl: null, 1056 + bio: 'Community admin and AT Protocol enthusiast.', 1057 + }, 1058 + }
+73
src/mocks/handlers.ts
··· 27 27 mockCommunityPreferences, 28 28 mockOnboardingFields, 29 29 mockMyReports, 30 + mockUserProfiles, 31 + mockPublicSettings, 32 + mockCommunityProfile, 30 33 } from './data' 31 34 32 35 const API_URL = '' ··· 693 696 reports: mockMyReports, 694 697 cursor: null, 695 698 }) 699 + }), 700 + 701 + // --- User Profile endpoints --- 702 + 703 + // GET /api/users/:handle (public profile) 704 + http.get(`${API_URL}/api/users/:handle`, ({ params }) => { 705 + const handle = decodeURIComponent(params['handle'] as string) 706 + // Skip /api/users/me/* paths (handled by specific handlers above) 707 + if (handle === 'me') { 708 + return 709 + } 710 + const profile = mockUserProfiles[handle] 711 + if (!profile) { 712 + return HttpResponse.json({ error: 'User not found' }, { status: 404 }) 713 + } 714 + return HttpResponse.json(profile) 715 + }), 716 + 717 + // --- Public Settings endpoint --- 718 + 719 + // GET /api/settings/public 720 + http.get(`${API_URL}/api/settings/public`, () => { 721 + return HttpResponse.json(mockPublicSettings) 722 + }), 723 + 724 + // --- Community Profile endpoints --- 725 + 726 + // GET /api/communities/:communityDid/profile 727 + http.get(`${API_URL}/api/communities/:communityDid/profile`, ({ request }) => { 728 + const auth = request.headers.get('Authorization') 729 + if (!auth?.startsWith('Bearer ')) { 730 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 731 + } 732 + return HttpResponse.json(mockCommunityProfile) 733 + }), 734 + 735 + // PUT /api/communities/:communityDid/profile 736 + http.put(`${API_URL}/api/communities/:communityDid/profile`, async ({ request }) => { 737 + const auth = request.headers.get('Authorization') 738 + if (!auth?.startsWith('Bearer ')) { 739 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 740 + } 741 + return HttpResponse.json({ success: true }) 742 + }), 743 + 744 + // DELETE /api/communities/:communityDid/profile 745 + http.delete(`${API_URL}/api/communities/:communityDid/profile`, ({ request }) => { 746 + const auth = request.headers.get('Authorization') 747 + if (!auth?.startsWith('Bearer ')) { 748 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 749 + } 750 + return new HttpResponse(null, { status: 204 }) 751 + }), 752 + 753 + // POST /api/communities/:communityDid/profile/avatar 754 + http.post(`${API_URL}/api/communities/:communityDid/profile/avatar`, ({ request }) => { 755 + const auth = request.headers.get('Authorization') 756 + if (!auth?.startsWith('Bearer ')) { 757 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 758 + } 759 + return HttpResponse.json({ url: 'https://cdn.example.com/avatar/uploaded.jpg' }) 760 + }), 761 + 762 + // POST /api/communities/:communityDid/profile/banner 763 + http.post(`${API_URL}/api/communities/:communityDid/profile/banner`, ({ request }) => { 764 + const auth = request.headers.get('Authorization') 765 + if (!auth?.startsWith('Bearer ')) { 766 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 767 + } 768 + return HttpResponse.json({ url: 'https://cdn.example.com/banner/uploaded.jpg' }) 696 769 }), 697 770 698 771 // POST /api/moderation/reports/:id/appeal