Barazo default frontend barazo.forum

feat(design): add header logo and show community name toggle (#154)

* feat(design): add header logo upload and show community name toggle

- Add headerLogoUrl and showCommunityName fields to CommunitySettings
and PublicSettings types
- Add uploadHeaderLogo API client function
- Update ForumLayout to accept publicSettings prop instead of
communityName, with three-tier header logo priority: custom header
logo > community logo > default Barazo SVGs
- Add header logo upload, remove, and show community name toggle to
the admin design page (DesignImagesSection)
- Update all 15 pages using ForumLayout to pass publicSettings
- Remove inline styles from logo images (use Tailwind w-auto)
- Add mock handler for POST /api/admin/design/header-logo
- Update tests for ForumLayout and admin design page

* fix(design): resolve rebase conflicts with main (pages, maxReplyDepth)

authored by

Guido X Jansen and committed by
GitHub
8077101e bd47ba70

+285 -80
+3 -4
src/app/accessibility/page.tsx
··· 20 20 } 21 21 22 22 export default async function AccessibilityPage() { 23 - let communityName = '' 23 + let publicSettings = null 24 24 try { 25 - const settings = await getPublicSettings() 26 - communityName = settings.communityName 25 + publicSettings = await getPublicSettings() 27 26 } catch { 28 27 // silently degrade 29 28 } 30 29 31 30 return ( 32 - <ForumLayout communityName={communityName}> 31 + <ForumLayout publicSettings={publicSettings}> 33 32 <div className="mx-auto max-w-2xl space-y-8"> 34 33 <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Accessibility' }]} /> 35 34
+17
src/app/admin/design/page.test.tsx
··· 56 56 expect(screen.getByRole('heading', { name: /design/i })).toBeInTheDocument() 57 57 }) 58 58 59 + it('renders header logo upload with help text', async () => { 60 + render(<AdminDesignPage />) 61 + await waitFor(() => { 62 + expect(screen.getByText('Header Logo')).toBeInTheDocument() 63 + }) 64 + expect(screen.getByText(/Wide logo or wordmark for the forum header/)).toBeInTheDocument() 65 + }) 66 + 59 67 it('renders logo upload section with help text', async () => { 60 68 render(<AdminDesignPage />) 61 69 await waitFor(() => { ··· 70 78 expect(screen.getByText('Favicon')).toBeInTheDocument() 71 79 }) 72 80 expect(screen.getByText(/256×256px.*JPEG, PNG, WebP, GIF/)).toBeInTheDocument() 81 + }) 82 + 83 + it('renders "Show community name" toggle, defaults to checked', async () => { 84 + render(<AdminDesignPage />) 85 + await waitFor(() => { 86 + const toggle = screen.getByRole('checkbox', { name: /show community name/i }) 87 + expect(toggle).toBeInTheDocument() 88 + expect(toggle).toBeChecked() 89 + }) 73 90 }) 74 91 75 92 it('renders primary color input', async () => {
+38 -1
src/app/admin/design/page.tsx
··· 1 1 /** 2 2 * Admin design page. 3 3 * URL: /admin/design 4 - * Logo upload, favicon upload, primary/accent color configuration. 4 + * Header logo upload, community logo upload, favicon upload, primary/accent color configuration. 5 5 */ 6 6 7 7 'use client' ··· 16 16 updateCommunitySettings, 17 17 uploadCommunityLogo, 18 18 uploadCommunityFavicon, 19 + uploadHeaderLogo, 19 20 } from '@/lib/api/client' 20 21 import type { CommunitySettings } from '@/lib/api/types' 21 22 import { useAuth } from '@/hooks/use-auth' ··· 44 45 void fetchSettings() 45 46 }, [fetchSettings]) 46 47 48 + const handleHeaderLogoUpload = useCallback( 49 + async (file: File) => { 50 + const result = await uploadHeaderLogo(file, getAccessToken() ?? '') 51 + setSettings((prev) => (prev ? { ...prev, headerLogoUrl: result.url } : prev)) 52 + return result 53 + }, 54 + [getAccessToken] 55 + ) 56 + 57 + const handleHeaderLogoRemove = useCallback(async () => { 58 + try { 59 + const updated = await updateCommunitySettings({ headerLogoUrl: null }, getAccessToken() ?? '') 60 + setSettings(updated) 61 + } catch { 62 + setSaveError('Failed to remove header logo.') 63 + } 64 + }, [getAccessToken]) 65 + 66 + const handleShowCommunityNameChange = useCallback( 67 + async (value: boolean) => { 68 + try { 69 + const updated = await updateCommunitySettings( 70 + { showCommunityName: value }, 71 + getAccessToken() ?? '' 72 + ) 73 + setSettings(updated) 74 + } catch { 75 + setSaveError('Failed to update community name visibility.') 76 + } 77 + }, 78 + [getAccessToken] 79 + ) 80 + 47 81 const handleLogoUpload = useCallback( 48 82 async (file: File) => { 49 83 const result = await uploadCommunityLogo(file, getAccessToken() ?? '') ··· 118 152 <div className="max-w-lg space-y-8"> 119 153 <DesignImagesSection 120 154 settings={settings} 155 + onHeaderLogoUpload={handleHeaderLogoUpload} 156 + onHeaderLogoRemove={() => void handleHeaderLogoRemove()} 157 + onShowCommunityNameChange={(v) => void handleShowCommunityNameChange(v)} 121 158 onLogoUpload={handleLogoUpload} 122 159 onLogoRemove={() => void handleLogoRemove()} 123 160 onFaviconUpload={handleFaviconUpload}
+1 -3
src/app/c/[slug]/page.tsx
··· 91 91 getPublicSettings().catch(() => null), 92 92 ]) 93 93 94 - const communityName = publicSettings?.communityName ?? '' 95 - 96 94 const totalPages = Math.max(1, Math.ceil(category.topicCount / TOPICS_PER_PAGE)) 97 95 98 96 const breadcrumbItems = [ ··· 102 100 103 101 return ( 104 102 <ForumLayout 105 - communityName={communityName} 103 + publicSettings={publicSettings} 106 104 sidebar={<CategoryNav categories={categoriesResult.categories} currentSlug={slug} />} 107 105 > 108 106 {/* Breadcrumbs (includes JSON-LD BreadcrumbList) */}
+4 -4
src/app/new/page.tsx
··· 9 9 10 10 import { useState, useEffect } from 'react' 11 11 import { useRouter, useSearchParams } from 'next/navigation' 12 - import type { CreateTopicInput } from '@/lib/api/types' 12 + import type { CreateTopicInput, PublicSettings } from '@/lib/api/types' 13 13 import { createTopic, getPublicSettings } from '@/lib/api/client' 14 14 import { getTopicUrl } from '@/lib/format' 15 15 import { ForumLayout } from '@/components/layout/forum-layout' ··· 26 26 const { ensureOnboarded } = useOnboardingContext() 27 27 const [submitting, setSubmitting] = useState(false) 28 28 const [error, setError] = useState<string | null>(null) 29 - const [communityName, setCommunityName] = useState('') 29 + const [publicSettings, setPublicSettings] = useState<PublicSettings | null>(null) 30 30 31 31 useEffect(() => { 32 32 getPublicSettings() 33 - .then((settings) => setCommunityName(settings.communityName)) 33 + .then((settings) => setPublicSettings(settings)) 34 34 .catch(() => {}) 35 35 }, []) 36 36 ··· 51 51 } 52 52 53 53 return ( 54 - <ForumLayout communityName={communityName}> 54 + <ForumLayout publicSettings={publicSettings}> 55 55 <div className="space-y-6"> 56 56 <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'New topic' }]} /> 57 57
+4 -4
src/app/notifications/page.tsx
··· 15 15 import { ErrorAlert } from '@/components/error-alert' 16 16 import { getNotifications, markNotificationsRead, getPublicSettings } from '@/lib/api/client' 17 17 import { cn } from '@/lib/utils' 18 - import type { Notification, NotificationType } from '@/lib/api/types' 18 + import type { Notification, NotificationType, PublicSettings } from '@/lib/api/types' 19 19 import { useAuth } from '@/hooks/use-auth' 20 20 import { ProtectedRoute } from '@/components/auth/protected-route' 21 21 ··· 40 40 const [loading, setLoading] = useState(true) 41 41 const [loadError, setLoadError] = useState<string | null>(null) 42 42 const [actionError, setActionError] = useState<string | null>(null) 43 - const [communityName, setCommunityName] = useState('') 43 + const [publicSettings, setPublicSettings] = useState<PublicSettings | null>(null) 44 44 45 45 useEffect(() => { 46 46 getPublicSettings() 47 - .then((settings) => setCommunityName(settings.communityName)) 47 + .then((settings) => setPublicSettings(settings)) 48 48 .catch(() => {}) 49 49 }, []) 50 50 ··· 91 91 const hasUnread = notifications.some((n) => !n.read) 92 92 93 93 return ( 94 - <ForumLayout communityName={communityName}> 94 + <ForumLayout publicSettings={publicSettings}> 95 95 <div className="space-y-6"> 96 96 <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Notifications' }]} /> 97 97
+3 -4
src/app/p/[slug]/page.tsx
··· 60 60 throw error 61 61 } 62 62 63 - let communityName = '' 63 + let publicSettings = null 64 64 try { 65 - const settings = await getPublicSettings() 66 - communityName = settings.communityName 65 + publicSettings = await getPublicSettings() 67 66 } catch { 68 67 // silently degrade 69 68 } ··· 78 77 } 79 78 80 79 return ( 81 - <ForumLayout communityName={communityName}> 80 + <ForumLayout publicSettings={publicSettings}> 82 81 <script 83 82 type="application/ld+json" 84 83 dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
+1 -1
src/app/page.tsx
··· 61 61 62 62 return ( 63 63 <ForumLayout 64 - communityName={communityName} 64 + publicSettings={publicSettings} 65 65 sidebar={ 66 66 categoriesResult.categories.length > 0 ? ( 67 67 <CategoryNav categories={categoriesResult.categories} />
+4 -3
src/app/search/page.tsx
··· 14 14 import { Breadcrumbs } from '@/components/breadcrumbs' 15 15 import { SearchInput } from '@/components/search-input' 16 16 import { SearchResults } from '@/components/search-results' 17 + import type { PublicSettings } from '@/lib/api/types' 17 18 18 19 export default function SearchPage() { 19 - const [communityName, setCommunityName] = useState('') 20 + const [publicSettings, setPublicSettings] = useState<PublicSettings | null>(null) 20 21 21 22 useEffect(() => { 22 23 getPublicSettings() 23 - .then((settings) => setCommunityName(settings.communityName)) 24 + .then((settings) => setPublicSettings(settings)) 24 25 .catch(() => {}) 25 26 }, []) 26 27 27 28 return ( 28 - <ForumLayout communityName={communityName}> 29 + <ForumLayout publicSettings={publicSettings}> 29 30 <div className="space-y-6"> 30 31 <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Search' }]} /> 31 32
+5 -3
src/app/settings/page.tsx
··· 12 12 import { useState, useEffect } from 'react' 13 13 import Link from 'next/link' 14 14 import { getPublicSettings } from '@/lib/api/client' 15 + import type { PublicSettings } from '@/lib/api/types' 15 16 import { ForumLayout } from '@/components/layout/forum-layout' 16 17 import { Breadcrumbs } from '@/components/breadcrumbs' 17 18 import { AgeGateDialog } from '@/components/age-gate-dialog' ··· 26 27 import { useSettingsForm } from '@/hooks/use-settings-form' 27 28 28 29 export default function SettingsPage() { 29 - const [communityName, setCommunityName] = useState('') 30 + const [publicSettings, setPublicSettings] = useState<PublicSettings | null>(null) 30 31 31 32 useEffect(() => { 32 33 getPublicSettings() 33 - .then((settings) => setCommunityName(settings.communityName)) 34 + .then((settings) => setPublicSettings(settings)) 34 35 .catch(() => {}) 35 36 }, []) 36 37 ··· 59 60 handleCrossPostAuthorize, 60 61 } = useSettingsForm() 61 62 63 + const communityName = publicSettings?.communityName ?? '' 62 64 const displayName = communityName || 'This Community' 63 65 64 66 return ( 65 - <ForumLayout communityName={communityName}> 67 + <ForumLayout publicSettings={publicSettings}> 66 68 <div className="space-y-6"> 67 69 <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Account settings' }]} /> 68 70
+4 -4
src/app/settings/reports/page.tsx
··· 15 15 import { ErrorAlert } from '@/components/error-alert' 16 16 import { ReportCard } from '@/components/settings/report-card' 17 17 import { getMyReports, submitAppeal, getPublicSettings } from '@/lib/api/client' 18 - import type { MyReport } from '@/lib/api/types' 18 + import type { MyReport, PublicSettings } from '@/lib/api/types' 19 19 import { useAuth } from '@/hooks/use-auth' 20 20 21 21 export default function MyReportsPage() { ··· 23 23 const [reports, setReports] = useState<MyReport[]>([]) 24 24 const [loading, setLoading] = useState(true) 25 25 const [error, setError] = useState<string | null>(null) 26 - const [communityName, setCommunityName] = useState('') 26 + const [publicSettings, setPublicSettings] = useState<PublicSettings | null>(null) 27 27 28 28 useEffect(() => { 29 29 getPublicSettings() 30 - .then((settings) => setCommunityName(settings.communityName)) 30 + .then((settings) => setPublicSettings(settings)) 31 31 .catch(() => {}) 32 32 }, []) 33 33 const [appealingId, setAppealingId] = useState<number | null>(null) ··· 103 103 ) 104 104 105 105 return ( 106 - <ForumLayout communityName={communityName}> 106 + <ForumLayout publicSettings={publicSettings}> 107 107 <div className="space-y-6"> 108 108 <Breadcrumbs 109 109 items={[
+7 -7
src/app/t/[slug]/[rkey]/edit/page.tsx
··· 10 10 import { useState, useEffect } from 'react' 11 11 import { useRouter } from 'next/navigation' 12 12 import Link from 'next/link' 13 - import type { CreateTopicInput, Topic } from '@/lib/api/types' 13 + import type { CreateTopicInput, PublicSettings, Topic } from '@/lib/api/types' 14 14 import { getTopicByRkey, updateTopic, getPublicSettings } from '@/lib/api/client' 15 15 import { getTopicUrl } from '@/lib/format' 16 16 import { useAuth } from '@/hooks/use-auth' ··· 30 30 const [loading, setLoading] = useState(true) 31 31 const [submitting, setSubmitting] = useState(false) 32 32 const [error, setError] = useState<string | null>(null) 33 - const [communityName, setCommunityName] = useState('') 33 + const [publicSettings, setPublicSettings] = useState<PublicSettings | null>(null) 34 34 35 35 useEffect(() => { 36 36 getPublicSettings() 37 - .then((settings) => setCommunityName(settings.communityName)) 37 + .then((settings) => setPublicSettings(settings)) 38 38 .catch(() => {}) 39 39 }, []) 40 40 ··· 98 98 99 99 if (loading || authLoading) { 100 100 return ( 101 - <ForumLayout communityName={communityName}> 101 + <ForumLayout publicSettings={publicSettings}> 102 102 <div className="space-y-6"> 103 103 <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Loading...' }]} /> 104 104 <p className="text-muted-foreground" aria-busy="true"> ··· 111 111 112 112 if (!topic) { 113 113 return ( 114 - <ForumLayout communityName={communityName}> 114 + <ForumLayout publicSettings={publicSettings}> 115 115 <div className="space-y-6"> 116 116 <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Error' }]} /> 117 117 <p className="text-destructive" role="alert"> ··· 124 124 125 125 if (!authLoading && (!user || user.did !== topic.authorDid)) { 126 126 return ( 127 - <ForumLayout communityName={communityName}> 127 + <ForumLayout publicSettings={publicSettings}> 128 128 <div className="space-y-6"> 129 129 <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Edit' }]} /> 130 130 <div className="py-8 text-center"> ··· 139 139 } 140 140 141 141 return ( 142 - <ForumLayout communityName={communityName}> 142 + <ForumLayout publicSettings={publicSettings}> 143 143 <div className="space-y-6"> 144 144 <Breadcrumbs 145 145 items={[
+2 -3
src/app/t/[slug]/[rkey]/page.tsx
··· 102 102 103 103 // Fetch community settings for maturity context 104 104 const publicSettings = await getPublicSettings().catch(() => null) 105 - const communityName = publicSettings?.communityName ?? '' 106 105 107 106 // Deleted topics: minimal page with tombstone, no replies/JSON-LD 108 107 if (isDeleted) { ··· 115 114 116 115 return ( 117 116 <ForumLayout 118 - communityName={communityName} 117 + publicSettings={publicSettings} 119 118 sidebar={ 120 119 categoriesResult.categories.length > 0 ? ( 121 120 <CategoryNav categories={categoriesResult.categories} /> ··· 189 188 190 189 return ( 191 190 <ForumLayout 192 - communityName={communityName} 191 + publicSettings={publicSettings} 193 192 sidebar={ 194 193 categoriesResult.categories.length > 0 ? ( 195 194 <CategoryNav categories={categoriesResult.categories} />
+7 -6
src/app/u/[handle]/edit/page.tsx
··· 11 11 import Link from 'next/link' 12 12 import { useRouter } from 'next/navigation' 13 13 import { getPublicSettings } from '@/lib/api/client' 14 + import type { PublicSettings } from '@/lib/api/types' 14 15 import { ArrowCounterClockwise } from '@phosphor-icons/react' 15 16 import { cn } from '@/lib/utils' 16 17 import { ForumLayout } from '@/components/layout/forum-layout' ··· 41 42 handleSave, 42 43 } = useCommunityProfile() 43 44 44 - const [communityName, setCommunityName] = useState('') 45 + const [publicSettings, setPublicSettings] = useState<PublicSettings | null>(null) 45 46 const [handle, setHandle] = useState<string | null>(null) 46 47 47 48 // Resolve Next.js async params ··· 55 56 56 57 useEffect(() => { 57 58 getPublicSettings() 58 - .then((settings) => setCommunityName(settings.communityName)) 59 + .then((settings) => setPublicSettings(settings)) 59 60 .catch(() => {}) 60 61 }, []) 61 62 ··· 78 79 // Don't render until we know the user is authenticated and it's their profile 79 80 if (authLoading || !user || !handle) { 80 81 return ( 81 - <ForumLayout communityName={communityName}> 82 + <ForumLayout publicSettings={publicSettings}> 82 83 <p className="text-sm text-muted-foreground">Loading...</p> 83 84 </ForumLayout> 84 85 ) ··· 90 91 91 92 if (loading) { 92 93 return ( 93 - <ForumLayout communityName={communityName}> 94 + <ForumLayout publicSettings={publicSettings}> 94 95 <p className="text-sm text-muted-foreground">Loading...</p> 95 96 </ForumLayout> 96 97 ) ··· 98 99 99 100 if (error) { 100 101 return ( 101 - <ForumLayout communityName={communityName}> 102 + <ForumLayout publicSettings={publicSettings}> 102 103 <div className="rounded-lg border border-destructive/20 bg-destructive/5 p-6 text-center"> 103 104 <p className="text-sm text-destructive">{error}</p> 104 105 </div> ··· 119 120 ) 120 121 121 122 return ( 122 - <ForumLayout communityName={communityName}> 123 + <ForumLayout publicSettings={publicSettings}> 123 124 <div className="mx-auto max-w-2xl space-y-6"> 124 125 <Breadcrumbs 125 126 items={[
+8 -8
src/app/u/[handle]/page.tsx
··· 16 16 import { getUserProfile, getPublicSettings } from '@/lib/api/client' 17 17 import { useAuth } from '@/hooks/use-auth' 18 18 import { formatDateLong } from '@/lib/format' 19 - import type { UserProfile } from '@/lib/api/types' 19 + import type { PublicSettings, UserProfile } from '@/lib/api/types' 20 20 21 21 interface UserProfilePageProps { 22 22 params: Promise<{ handle: string }> | { handle: string } ··· 34 34 const [error, setError] = useState<string | null>(null) 35 35 const [isBlocked, setIsBlocked] = useState(false) 36 36 const [isMuted, setIsMuted] = useState(false) 37 - const [communityName, setCommunityName] = useState('') 37 + const [publicSettings, setPublicSettings] = useState<PublicSettings | null>(null) 38 38 const { user } = useAuth() 39 39 40 40 // Resolve Next.js async params ··· 60 60 const publicSettings = await getPublicSettings({ 61 61 signal: controller.signal, 62 62 }).catch(() => null) 63 - if (!cancelled && publicSettings?.communityName) { 64 - setCommunityName(publicSettings.communityName) 63 + if (!cancelled && publicSettings) { 64 + setPublicSettings(publicSettings) 65 65 } 66 66 const communityDid = publicSettings?.communityDid ?? undefined 67 67 const data = await getUserProfile(handle!, communityDid, { ··· 93 93 // Loading state: show skeleton while params resolve or data loads 94 94 if (!handle || loading) { 95 95 return ( 96 - <ForumLayout communityName={communityName}> 96 + <ForumLayout publicSettings={publicSettings}> 97 97 <ProfileSkeleton /> 98 98 </ForumLayout> 99 99 ) ··· 102 102 // Error state 103 103 if (error) { 104 104 return ( 105 - <ForumLayout communityName={communityName}> 105 + <ForumLayout publicSettings={publicSettings}> 106 106 <div className="rounded-lg border border-destructive/20 bg-destructive/5 p-6 text-center"> 107 107 <p className="text-sm text-destructive">{error}</p> 108 108 </div> ··· 113 113 // No profile loaded (shouldn't happen if no error, but guard) 114 114 if (!profile) { 115 115 return ( 116 - <ForumLayout communityName={communityName}> 116 + <ForumLayout publicSettings={publicSettings}> 117 117 <div className="rounded-lg border border-destructive/20 bg-destructive/5 p-6 text-center"> 118 118 <p className="text-sm text-destructive">Profile not found.</p> 119 119 </div> ··· 127 127 const joinDate = formatDateLong(profile.firstSeenAt) 128 128 129 129 return ( 130 - <ForumLayout> 130 + <ForumLayout publicSettings={publicSettings}> 131 131 <div className="space-y-6"> 132 132 <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: handle }]} /> 133 133
+39 -2
src/components/admin/design/design-images-section.tsx
··· 1 1 /** 2 - * DesignImagesSection - Logo and favicon upload section for the design page. 2 + * DesignImagesSection - Branding section: header logo, community logo, and favicon upload. 3 3 */ 4 4 5 5 'use client' ··· 9 9 10 10 interface DesignImagesSectionProps { 11 11 settings: CommunitySettings 12 + onHeaderLogoUpload: (file: File) => Promise<{ url: string }> 13 + onHeaderLogoRemove: () => void 14 + onShowCommunityNameChange: (value: boolean) => void 12 15 onLogoUpload: (file: File) => Promise<{ url: string }> 13 16 onLogoRemove: () => void 14 17 onFaviconUpload: (file: File) => Promise<{ url: string }> ··· 17 20 18 21 export function DesignImagesSection({ 19 22 settings, 23 + onHeaderLogoUpload, 24 + onHeaderLogoRemove, 25 + onShowCommunityNameChange, 20 26 onLogoUpload, 21 27 onLogoRemove, 22 28 onFaviconUpload, ··· 24 30 }: DesignImagesSectionProps) { 25 31 return ( 26 32 <fieldset className="space-y-6"> 27 - <legend className="text-sm font-medium text-foreground">Images</legend> 33 + <legend className="text-sm font-medium text-foreground">Branding</legend> 34 + 35 + <div className="space-y-4"> 36 + <ImageUpload 37 + currentUrl={settings.headerLogoUrl} 38 + onUpload={onHeaderLogoUpload} 39 + onRemove={onHeaderLogoRemove} 40 + label="Header Logo" 41 + aspectRatio="5/1" 42 + className="w-full max-w-sm" 43 + /> 44 + <p className="text-xs text-muted-foreground"> 45 + Wide logo or wordmark for the forum header. Max 600 x 120 px. Accepted formats: JPEG, PNG, 46 + WebP, GIF. 47 + </p> 48 + 49 + <div className="flex items-center gap-3"> 50 + <input 51 + type="checkbox" 52 + id="show-community-name" 53 + checked={settings.showCommunityName} 54 + onChange={(e) => onShowCommunityNameChange(e.target.checked)} 55 + className="h-4 w-4 rounded border-border text-primary focus:ring-2 focus:ring-ring" 56 + /> 57 + <label htmlFor="show-community-name" className="text-sm font-medium text-foreground"> 58 + Show community name in header 59 + </label> 60 + </div> 61 + <p className="text-xs text-muted-foreground"> 62 + Disable when your header logo already includes the community name. 63 + </p> 64 + </div> 28 65 29 66 <div> 30 67 <ImageUpload
+56 -2
src/components/layout/forum-layout.test.tsx
··· 2 2 import { render, screen } from '@testing-library/react' 3 3 import { axe } from 'vitest-axe' 4 4 import { ForumLayout } from './forum-layout' 5 + import type { PublicSettings } from '@/lib/api/types' 5 6 6 7 // Mock next-themes 7 8 vi.mock('next-themes', () => ({ ··· 53 54 }), 54 55 })) 55 56 57 + const baseSettings: PublicSettings = { 58 + communityDid: 'did:plc:test', 59 + communityName: 'Test Community', 60 + maturityRating: 'safe', 61 + maxReplyDepth: 9999, 62 + communityDescription: null, 63 + communityLogoUrl: null, 64 + faviconUrl: null, 65 + headerLogoUrl: null, 66 + showCommunityName: true, 67 + } 68 + 56 69 describe('ForumLayout', () => { 57 70 it('renders header with logo', () => { 58 71 render(<ForumLayout>Content</ForumLayout>) ··· 99 112 expect(screen.getByRole('combobox')).toBeInTheDocument() 100 113 }) 101 114 102 - it('renders community name next to logo', () => { 103 - render(<ForumLayout communityName="Test Community">Content</ForumLayout>) 115 + it('shows default Barazo logo when no custom logos (publicSettings null)', () => { 116 + render(<ForumLayout publicSettings={null}>Content</ForumLayout>) 117 + const logos = screen.getAllByAltText('Barazo') 118 + expect(logos.length).toBeGreaterThan(0) 119 + }) 120 + 121 + it('shows header logo when headerLogoUrl provided via publicSettings', () => { 122 + const settings: PublicSettings = { 123 + ...baseSettings, 124 + headerLogoUrl: 'https://cdn.example.com/header-logo.webp', 125 + } 126 + render(<ForumLayout publicSettings={settings}>Content</ForumLayout>) 127 + const headerLogo = screen.getByAltText('Test Community') 128 + expect(headerLogo).toBeInTheDocument() 129 + expect(headerLogo).toHaveAttribute('src', 'https://cdn.example.com/header-logo.webp') 130 + }) 131 + 132 + it('shows square community logo when only communityLogoUrl provided', () => { 133 + const settings: PublicSettings = { 134 + ...baseSettings, 135 + communityLogoUrl: 'https://cdn.example.com/logo.webp', 136 + } 137 + render(<ForumLayout publicSettings={settings}>Content</ForumLayout>) 138 + const logo = screen.getByAltText('Test Community') 139 + expect(logo).toBeInTheDocument() 140 + expect(logo).toHaveAttribute('src', 'https://cdn.example.com/logo.webp') 141 + }) 142 + 143 + it('hides community name when showCommunityName is false', () => { 144 + const settings: PublicSettings = { 145 + ...baseSettings, 146 + showCommunityName: false, 147 + } 148 + render(<ForumLayout publicSettings={settings}>Content</ForumLayout>) 149 + expect(screen.queryByText('Test Community')).not.toBeInTheDocument() 150 + }) 151 + 152 + it('shows community name when showCommunityName is true', () => { 153 + const settings: PublicSettings = { 154 + ...baseSettings, 155 + showCommunityName: true, 156 + } 157 + render(<ForumLayout publicSettings={settings}>Content</ForumLayout>) 104 158 expect(screen.getByText('Test Community')).toBeInTheDocument() 105 159 }) 106 160
+47 -21
src/components/layout/forum-layout.tsx
··· 14 14 import { UserMenu } from '@/components/auth/user-menu' 15 15 import { MagnifyingGlass } from '@phosphor-icons/react/dist/ssr' 16 16 import { NewTopicButton } from '@/components/new-topic-button' 17 + import type { PublicSettings } from '@/lib/api/types' 17 18 18 19 interface ForumLayoutProps { 19 20 children: React.ReactNode 20 21 sidebar?: React.ReactNode 21 - communityName?: string 22 + publicSettings?: PublicSettings | null 22 23 } 23 24 24 - export function ForumLayout({ children, sidebar, communityName = '' }: ForumLayoutProps) { 25 + export function ForumLayout({ children, sidebar, publicSettings }: ForumLayoutProps) { 26 + const communityName = publicSettings?.communityName ?? '' 27 + const headerLogoUrl = publicSettings?.headerLogoUrl ?? null 28 + const communityLogoUrl = publicSettings?.communityLogoUrl ?? null 29 + const showCommunityName = publicSettings?.showCommunityName ?? true 30 + 25 31 return ( 26 32 <div className="min-h-screen overflow-x-hidden bg-background"> 27 33 <SkipLinks /> ··· 31 37 <div className="container flex h-14 items-center justify-between gap-2 sm:gap-4"> 32 38 {/* Logo */} 33 39 <Link href="/" className="flex items-center gap-2"> 34 - <Image 35 - src="/barazo-logo-light.svg" 36 - alt="Barazo" 37 - width={120} 38 - height={32} 39 - className="h-8 dark:hidden" 40 - style={{ width: 'auto' }} 41 - priority 42 - /> 43 - <Image 44 - src="/barazo-logo-dark.svg" 45 - alt="Barazo" 46 - width={120} 47 - height={32} 48 - className="hidden h-8 dark:block" 49 - style={{ width: 'auto' }} 50 - priority 51 - /> 52 - {communityName && ( 40 + {headerLogoUrl ? ( 41 + <Image 42 + src={headerLogoUrl} 43 + alt={communityName || 'Community'} 44 + width={200} 45 + height={32} 46 + className="h-8 w-auto" 47 + priority 48 + /> 49 + ) : communityLogoUrl ? ( 50 + <Image 51 + src={communityLogoUrl} 52 + alt={communityName || 'Community'} 53 + width={32} 54 + height={32} 55 + className="h-8 w-8 rounded" 56 + priority 57 + /> 58 + ) : ( 59 + <> 60 + <Image 61 + src="/barazo-logo-light.svg" 62 + alt="Barazo" 63 + width={120} 64 + height={32} 65 + className="h-8 w-auto dark:hidden" 66 + priority 67 + /> 68 + <Image 69 + src="/barazo-logo-dark.svg" 70 + alt="Barazo" 71 + width={120} 72 + height={32} 73 + className="hidden h-8 w-auto dark:block" 74 + priority 75 + /> 76 + </> 77 + )} 78 + {showCommunityName && communityName && ( 53 79 <span className="hidden text-lg font-semibold text-foreground sm:inline"> 54 80 {communityName} 55 81 </span>
+16
src/lib/api/client.ts
··· 1010 1010 return response.json() as Promise<UploadResponse> 1011 1011 } 1012 1012 1013 + export async function uploadHeaderLogo(file: File, accessToken: string): Promise<UploadResponse> { 1014 + const form = new FormData() 1015 + form.append('file', file) 1016 + const url = `${API_URL}/api/admin/design/header-logo` 1017 + const response = await fetch(url, { 1018 + method: 'POST', 1019 + headers: { Authorization: `Bearer ${accessToken}` }, 1020 + body: form, 1021 + }) 1022 + if (!response.ok) { 1023 + const body = await response.text().catch(() => 'Unknown error') 1024 + throw new ApiError(response.status, `API ${response.status}: ${body}`) 1025 + } 1026 + return response.json() as Promise<UploadResponse> 1027 + } 1028 + 1013 1029 export async function uploadCommunityFavicon( 1014 1030 file: File, 1015 1031 accessToken: string
+4
src/lib/api/types.ts
··· 247 247 communityDescription: string | null 248 248 communityLogoUrl: string | null 249 249 faviconUrl: string | null 250 + headerLogoUrl: string | null 251 + showCommunityName: boolean 250 252 primaryColor: string | null 251 253 accentColor: string | null 252 254 jurisdictionCountry: string | null ··· 265 267 communityDescription: string | null 266 268 communityLogoUrl: string | null 267 269 faviconUrl: string | null 270 + headerLogoUrl: string | null 271 + showCommunityName: boolean 268 272 } 269 273 270 274 export interface CommunityStats {
+4
src/mocks/data.ts
··· 604 604 communityDescription: 'A test community for development', 605 605 communityLogoUrl: null, 606 606 faviconUrl: null, 607 + headerLogoUrl: null, 608 + showCommunityName: true, 607 609 primaryColor: '#31748f', 608 610 accentColor: '#c4a7e7', 609 611 jurisdictionCountry: null, ··· 1200 1202 communityDescription: 'A test community for development', 1201 1203 communityLogoUrl: null, 1202 1204 faviconUrl: null, 1205 + headerLogoUrl: null, 1206 + showCommunityName: true, 1203 1207 } 1204 1208 1205 1209 // --- Community Profile (own profile in a community) ---
+11
src/mocks/handlers.ts
··· 338 338 return HttpResponse.json({ url: 'http://localhost:3000/uploads/logos/mock-logo.webp' }) 339 339 }), 340 340 341 + // POST /api/admin/design/header-logo 342 + http.post(`${API_URL}/api/admin/design/header-logo`, ({ request }) => { 343 + const auth = request.headers.get('Authorization') 344 + if (!auth?.startsWith('Bearer ')) { 345 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 346 + } 347 + return HttpResponse.json({ 348 + url: 'http://localhost:3000/uploads/header-logos/mock-header-logo.webp', 349 + }) 350 + }), 351 + 341 352 // POST /api/admin/design/favicon 342 353 http.post(`${API_URL}/api/admin/design/favicon`, ({ request }) => { 343 354 const auth = request.headers.get('Authorization')