Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat(admin): add /admin/design page (#144)

* feat(admin): add /admin/design page with logo, favicon, and color settings

Introduce a dedicated Design page under the admin panel for managing
community branding. Logo and favicon uploads use new API endpoints with
sharp-based image processing. Color settings (primary/accent) are moved
here from the settings page to reduce clutter. The layout now serves a
dynamic favicon from community settings with a local fallback.

* style(admin): fix prettier formatting in design page

* fix(a11y): use static metadata with client-side dynamic favicon

Revert generateMetadata() in root layout back to static metadata
export to ensure the <title> tag is always rendered reliably. The
dynamic favicon is now handled by a client component that fetches
the community favicon URL and injects a <link> override at runtime.

authored by

Guido X Jansen and committed by
GitHub
1f2ba19b b584a3fd

+468 -34
+112
src/app/admin/design/page.test.tsx
··· 1 + /** 2 + * Tests for admin design page. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen, waitFor } from '@testing-library/react' 7 + import { axe } from 'vitest-axe' 8 + import AdminDesignPage from './page' 9 + 10 + vi.mock('next/navigation', () => ({ 11 + useRouter: () => ({ push: vi.fn() }), 12 + usePathname: () => '/admin/design', 13 + })) 14 + 15 + vi.mock('next/link', () => ({ 16 + default: ({ 17 + children, 18 + href, 19 + ...props 20 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 21 + <a href={href} {...props}> 22 + {children} 23 + </a> 24 + ), 25 + })) 26 + 27 + vi.mock('next/image', () => ({ 28 + default: (props: Record<string, unknown>) => { 29 + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text 30 + return <img {...props} /> 31 + }, 32 + })) 33 + 34 + vi.mock('@/hooks/use-auth', () => { 35 + const mockAuth = { 36 + user: { 37 + did: 'did:plc:user-jay-001', 38 + handle: 'jay.bsky.team', 39 + displayName: 'Jay', 40 + avatarUrl: null, 41 + }, 42 + isAuthenticated: true, 43 + isLoading: false, 44 + getAccessToken: () => 'mock-access-token', 45 + login: vi.fn(), 46 + logout: vi.fn(), 47 + setSessionFromCallback: vi.fn(), 48 + authFetch: vi.fn(), 49 + } 50 + return { useAuth: () => mockAuth } 51 + }) 52 + 53 + describe('AdminDesignPage', () => { 54 + it('renders design heading', () => { 55 + render(<AdminDesignPage />) 56 + expect(screen.getByRole('heading', { name: /design/i })).toBeInTheDocument() 57 + }) 58 + 59 + it('renders logo upload section', async () => { 60 + render(<AdminDesignPage />) 61 + await waitFor(() => { 62 + expect(screen.getByText('Community Logo')).toBeInTheDocument() 63 + }) 64 + }) 65 + 66 + it('renders favicon upload section', async () => { 67 + render(<AdminDesignPage />) 68 + await waitFor(() => { 69 + expect(screen.getByText('Favicon')).toBeInTheDocument() 70 + }) 71 + }) 72 + 73 + it('renders primary color input', async () => { 74 + render(<AdminDesignPage />) 75 + await waitFor(() => { 76 + expect(screen.getByLabelText(/primary color/i)).toBeInTheDocument() 77 + }) 78 + }) 79 + 80 + it('renders accent color input', async () => { 81 + render(<AdminDesignPage />) 82 + await waitFor(() => { 83 + expect(screen.getByLabelText(/accent color/i)).toBeInTheDocument() 84 + }) 85 + }) 86 + 87 + it('renders save colors button', async () => { 88 + render(<AdminDesignPage />) 89 + await waitFor(() => { 90 + expect(screen.getByRole('button', { name: /save colors/i })).toBeInTheDocument() 91 + }) 92 + }) 93 + 94 + it('loads color values from settings', async () => { 95 + render(<AdminDesignPage />) 96 + await waitFor(() => { 97 + const primaryInput = screen.getByLabelText(/primary color/i) as HTMLInputElement 98 + expect(primaryInput.value).toBe('#31748f') 99 + }) 100 + const accentInput = screen.getByLabelText(/accent color/i) as HTMLInputElement 101 + expect(accentInput.value).toBe('#c4a7e7') 102 + }) 103 + 104 + it('passes axe accessibility check', async () => { 105 + const { container } = render(<AdminDesignPage />) 106 + await waitFor(() => { 107 + expect(screen.getByLabelText(/primary color/i)).toBeInTheDocument() 108 + }) 109 + const results = await axe(container) 110 + expect(results).toHaveNoViolations() 111 + }) 112 + })
+146
src/app/admin/design/page.tsx
··· 1 + /** 2 + * Admin design page. 3 + * URL: /admin/design 4 + * Logo upload, favicon upload, primary/accent color configuration. 5 + */ 6 + 7 + 'use client' 8 + 9 + import { useState, useEffect, useCallback } from 'react' 10 + import { AdminLayout } from '@/components/admin/admin-layout' 11 + import { ErrorAlert } from '@/components/error-alert' 12 + import { DesignImagesSection } from '@/components/admin/design/design-images-section' 13 + import { DesignColorsSection } from '@/components/admin/design/design-colors-section' 14 + import { 15 + getCommunitySettings, 16 + updateCommunitySettings, 17 + uploadCommunityLogo, 18 + uploadCommunityFavicon, 19 + } from '@/lib/api/client' 20 + import type { CommunitySettings } from '@/lib/api/types' 21 + import { useAuth } from '@/hooks/use-auth' 22 + 23 + export default function AdminDesignPage() { 24 + const { getAccessToken } = useAuth() 25 + const [settings, setSettings] = useState<CommunitySettings | null>(null) 26 + const [loading, setLoading] = useState(true) 27 + const [saving, setSaving] = useState(false) 28 + const [loadError, setLoadError] = useState<string | null>(null) 29 + const [saveError, setSaveError] = useState<string | null>(null) 30 + 31 + const fetchSettings = useCallback(async () => { 32 + setLoadError(null) 33 + try { 34 + const data = await getCommunitySettings(getAccessToken() ?? '') 35 + setSettings(data) 36 + } catch { 37 + setLoadError('Failed to load design settings. The API may be unreachable.') 38 + } finally { 39 + setLoading(false) 40 + } 41 + }, [getAccessToken]) 42 + 43 + useEffect(() => { 44 + void fetchSettings() 45 + }, [fetchSettings]) 46 + 47 + const handleLogoUpload = useCallback( 48 + async (file: File) => { 49 + const result = await uploadCommunityLogo(file, getAccessToken() ?? '') 50 + setSettings((prev) => (prev ? { ...prev, communityLogoUrl: result.url } : prev)) 51 + return result 52 + }, 53 + [getAccessToken] 54 + ) 55 + 56 + const handleLogoRemove = useCallback(async () => { 57 + try { 58 + const updated = await updateCommunitySettings( 59 + { communityLogoUrl: null }, 60 + getAccessToken() ?? '' 61 + ) 62 + setSettings(updated) 63 + } catch { 64 + setSaveError('Failed to remove logo.') 65 + } 66 + }, [getAccessToken]) 67 + 68 + const handleFaviconUpload = useCallback( 69 + async (file: File) => { 70 + const result = await uploadCommunityFavicon(file, getAccessToken() ?? '') 71 + setSettings((prev) => (prev ? { ...prev, faviconUrl: result.url } : prev)) 72 + return result 73 + }, 74 + [getAccessToken] 75 + ) 76 + 77 + const handleFaviconRemove = useCallback(async () => { 78 + try { 79 + const updated = await updateCommunitySettings({ faviconUrl: null }, getAccessToken() ?? '') 80 + setSettings(updated) 81 + } catch { 82 + setSaveError('Failed to remove favicon.') 83 + } 84 + }, [getAccessToken]) 85 + 86 + const handleColorsSave = async () => { 87 + if (!settings) return 88 + setSaving(true) 89 + setSaveError(null) 90 + try { 91 + const updated = await updateCommunitySettings( 92 + { 93 + primaryColor: settings.primaryColor, 94 + accentColor: settings.accentColor, 95 + }, 96 + getAccessToken() ?? '' 97 + ) 98 + setSettings(updated) 99 + } catch { 100 + setSaveError('Failed to save colors. Please try again.') 101 + } finally { 102 + setSaving(false) 103 + } 104 + } 105 + 106 + return ( 107 + <AdminLayout> 108 + <div className="space-y-6"> 109 + <h1 className="text-2xl font-bold text-foreground">Design</h1> 110 + 111 + {loading && <p className="text-sm text-muted-foreground">Loading design settings...</p>} 112 + 113 + {loadError && ( 114 + <ErrorAlert message={loadError} variant="page" onRetry={() => void fetchSettings()} /> 115 + )} 116 + 117 + {settings && ( 118 + <div className="max-w-lg space-y-8"> 119 + <DesignImagesSection 120 + settings={settings} 121 + onLogoUpload={handleLogoUpload} 122 + onLogoRemove={() => void handleLogoRemove()} 123 + onFaviconUpload={handleFaviconUpload} 124 + onFaviconRemove={() => void handleFaviconRemove()} 125 + /> 126 + 127 + <hr className="border-border" /> 128 + 129 + <DesignColorsSection settings={settings} onChange={setSettings} /> 130 + 131 + {saveError && <ErrorAlert message={saveError} onDismiss={() => setSaveError(null)} />} 132 + 133 + <button 134 + type="button" 135 + onClick={() => void handleColorsSave()} 136 + disabled={saving} 137 + className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50" 138 + > 139 + {saving ? 'Saving...' : 'Save Colors'} 140 + </button> 141 + </div> 142 + )} 143 + </div> 144 + </AdminLayout> 145 + ) 146 + }
+9
src/app/admin/settings/page.test.tsx
··· 205 205 }) 206 206 }) 207 207 208 + it('does not render color fields (moved to /admin/design)', async () => { 209 + render(<AdminSettingsPage />) 210 + await waitFor(() => { 211 + expect(screen.getByLabelText(/community name/i)).toBeInTheDocument() 212 + }) 213 + expect(screen.queryByLabelText(/primary color/i)).not.toBeInTheDocument() 214 + expect(screen.queryByLabelText(/accent color/i)).not.toBeInTheDocument() 215 + }) 216 + 208 217 it('PDS Provider Trust section passes axe accessibility check', async () => { 209 218 const { container } = render(<AdminSettingsPage />) 210 219 await waitFor(() => {
+1 -3
src/app/admin/settings/page.tsx
··· 1 1 /** 2 2 * Admin community settings page. 3 3 * URL: /admin/settings 4 - * Community name, description, branding, reaction config, maturity rating. 4 + * Community name, description, reaction config, maturity rating. 5 5 * @see specs/prd-web.md Section M11 6 6 */ 7 7 ··· 64 64 communityDescription: settings.communityDescription, 65 65 maturityRating: settings.maturityRating, 66 66 reactionSet: settings.reactionSet, 67 - primaryColor: settings.primaryColor, 68 - accentColor: settings.accentColor, 69 67 }, 70 68 getAccessToken() ?? '' 71 69 )
+4
src/app/layout.tsx
··· 6 6 import { AppToastProvider } from '@/context/toast-context' 7 7 import { OnboardingProvider } from '@/context/onboarding-context' 8 8 import { SetupGuard } from '@/components/setup-guard' 9 + import { DynamicFavicon } from '@/components/dynamic-favicon' 9 10 10 11 const sourceCodePro = Source_Code_Pro({ 11 12 subsets: ['latin'], ··· 47 48 }>) { 48 49 return ( 49 50 <html lang="en" className={sourceCodePro.variable} suppressHydrationWarning> 51 + <head> 52 + <DynamicFavicon /> 53 + </head> 50 54 <body className="min-h-screen font-sans antialiased"> 51 55 <ThemeProvider 52 56 attribute="class"
+2
src/components/admin/admin-layout.tsx
··· 13 13 FolderSimple, 14 14 ShieldCheck, 15 15 Gear, 16 + PaintBrush, 16 17 Tag, 17 18 Users, 18 19 PuzzlePiece, ··· 34 35 { href: '/admin/sybil-detection', label: 'Sybil Detection', icon: ShieldWarning }, 35 36 { href: '/admin/trust-seeds', label: 'Trust Seeds', icon: SealCheck }, 36 37 { href: '/admin/settings', label: 'Settings', icon: Gear }, 38 + { href: '/admin/design', label: 'Design', icon: PaintBrush }, 37 39 { href: '/admin/content-ratings', label: 'Content Ratings', icon: Tag }, 38 40 { href: '/admin/onboarding', label: 'Onboarding', icon: ClipboardText }, 39 41 { href: '/admin/users', label: 'Users', icon: Users },
+48
src/components/admin/design/design-colors-section.tsx
··· 1 + /** 2 + * DesignColorsSection - Primary and accent color inputs for the design page. 3 + */ 4 + 5 + 'use client' 6 + 7 + import type { CommunitySettings } from '@/lib/api/types' 8 + 9 + interface DesignColorsSectionProps { 10 + settings: CommunitySettings 11 + onChange: (updated: CommunitySettings) => void 12 + } 13 + 14 + export function DesignColorsSection({ settings, onChange }: DesignColorsSectionProps) { 15 + return ( 16 + <fieldset className="space-y-4"> 17 + <legend className="text-sm font-medium text-foreground">Colors</legend> 18 + 19 + <div> 20 + <label htmlFor="design-primary-color" className="block text-sm text-muted-foreground"> 21 + Primary Color 22 + </label> 23 + <input 24 + id="design-primary-color" 25 + type="text" 26 + value={settings.primaryColor ?? ''} 27 + onChange={(e) => onChange({ ...settings, primaryColor: e.target.value || null })} 28 + placeholder="#31748f" 29 + className="mt-1 w-full max-w-xs rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 30 + /> 31 + </div> 32 + 33 + <div> 34 + <label htmlFor="design-accent-color" className="block text-sm text-muted-foreground"> 35 + Accent Color 36 + </label> 37 + <input 38 + id="design-accent-color" 39 + type="text" 40 + value={settings.accentColor ?? ''} 41 + onChange={(e) => onChange({ ...settings, accentColor: e.target.value || null })} 42 + placeholder="#c4a7e7" 43 + className="mt-1 w-full max-w-xs rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 44 + /> 45 + </div> 46 + </fieldset> 47 + ) 48 + }
+48
src/components/admin/design/design-images-section.tsx
··· 1 + /** 2 + * DesignImagesSection - Logo and favicon upload section for the design page. 3 + */ 4 + 5 + 'use client' 6 + 7 + import { ImageUpload } from '@/components/image-upload' 8 + import type { CommunitySettings } from '@/lib/api/types' 9 + 10 + interface DesignImagesSectionProps { 11 + settings: CommunitySettings 12 + onLogoUpload: (file: File) => Promise<{ url: string }> 13 + onLogoRemove: () => void 14 + onFaviconUpload: (file: File) => Promise<{ url: string }> 15 + onFaviconRemove: () => void 16 + } 17 + 18 + export function DesignImagesSection({ 19 + settings, 20 + onLogoUpload, 21 + onLogoRemove, 22 + onFaviconUpload, 23 + onFaviconRemove, 24 + }: DesignImagesSectionProps) { 25 + return ( 26 + <fieldset className="space-y-6"> 27 + <legend className="text-sm font-medium text-foreground">Images</legend> 28 + 29 + <ImageUpload 30 + currentUrl={settings.communityLogoUrl} 31 + onUpload={onLogoUpload} 32 + onRemove={onLogoRemove} 33 + label="Community Logo" 34 + aspectRatio="1/1" 35 + className="w-40" 36 + /> 37 + 38 + <ImageUpload 39 + currentUrl={settings.faviconUrl} 40 + onUpload={onFaviconUpload} 41 + onRemove={onFaviconRemove} 42 + label="Favicon" 43 + aspectRatio="1/1" 44 + className="w-16" 45 + /> 46 + </fieldset> 47 + ) 48 + }
+1 -31
src/components/admin/settings/community-settings-form.tsx
··· 1 1 /** 2 - * CommunitySettingsForm - Form for community name, description, maturity, reactions, branding. 2 + * CommunitySettingsForm - Form for community name, description, maturity, and reactions. 3 3 * @see specs/prd-web.md Section M11 4 4 */ 5 5 ··· 97 97 Comma-separated list of reaction types available in your community. 98 98 </p> 99 99 </div> 100 - 101 - <fieldset className="space-y-4"> 102 - <legend className="text-sm font-medium text-foreground">Branding</legend> 103 - <div> 104 - <label htmlFor="settings-primary-color" className="block text-sm text-muted-foreground"> 105 - Primary Color 106 - </label> 107 - <input 108 - id="settings-primary-color" 109 - type="text" 110 - value={settings.primaryColor ?? ''} 111 - onChange={(e) => onChange({ ...settings, primaryColor: e.target.value || null })} 112 - placeholder="#31748f" 113 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 114 - /> 115 - </div> 116 - <div> 117 - <label htmlFor="settings-accent-color" className="block text-sm text-muted-foreground"> 118 - Accent Color 119 - </label> 120 - <input 121 - id="settings-accent-color" 122 - type="text" 123 - value={settings.accentColor ?? ''} 124 - onChange={(e) => onChange({ ...settings, accentColor: e.target.value || null })} 125 - placeholder="#c4a7e7" 126 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 127 - /> 128 - </div> 129 - </fieldset> 130 100 131 101 {saveError && <ErrorAlert message={saveError} onDismiss={onDismissError} />} 132 102
+35
src/components/dynamic-favicon.tsx
··· 1 + 'use client' 2 + 3 + import { useEffect, useState } from 'react' 4 + import { getPublicSettings } from '@/lib/api/client' 5 + 6 + /** 7 + * Client component that replaces the default favicon with a community-uploaded 8 + * one when available. Renders nothing visible -- only updates <link rel="icon">. 9 + */ 10 + export function DynamicFavicon() { 11 + const [faviconUrl, setFaviconUrl] = useState<string | null>(null) 12 + 13 + useEffect(() => { 14 + let cancelled = false 15 + getPublicSettings() 16 + .then((settings) => { 17 + if (!cancelled && settings.faviconUrl) { 18 + setFaviconUrl(settings.faviconUrl) 19 + } 20 + }) 21 + .catch(() => { 22 + // Ignore -- default /favicon.ico handled by static metadata 23 + }) 24 + return () => { 25 + cancelled = true 26 + } 27 + }, []) 28 + 29 + if (!faviconUrl) return null 30 + 31 + return ( 32 + // eslint-disable-next-line @next/next/no-head-element -- needed to override static favicon at runtime 33 + <link rel="icon" href={faviconUrl} /> 34 + ) 35 + }
+40
src/lib/api/client.ts
··· 983 983 return response.json() as Promise<UploadResponse> 984 984 } 985 985 986 + // --- Admin design upload endpoints (use FormData, not JSON) --- 987 + 988 + export async function uploadCommunityLogo( 989 + file: File, 990 + accessToken: string 991 + ): Promise<UploadResponse> { 992 + const form = new FormData() 993 + form.append('file', file) 994 + const url = `${API_URL}/api/admin/design/logo` 995 + const response = await fetch(url, { 996 + method: 'POST', 997 + headers: { Authorization: `Bearer ${accessToken}` }, 998 + body: form, 999 + }) 1000 + if (!response.ok) { 1001 + const body = await response.text().catch(() => 'Unknown error') 1002 + throw new ApiError(response.status, `API ${response.status}: ${body}`) 1003 + } 1004 + return response.json() as Promise<UploadResponse> 1005 + } 1006 + 1007 + export async function uploadCommunityFavicon( 1008 + file: File, 1009 + accessToken: string 1010 + ): Promise<UploadResponse> { 1011 + const form = new FormData() 1012 + form.append('file', file) 1013 + const url = `${API_URL}/api/admin/design/favicon` 1014 + const response = await fetch(url, { 1015 + method: 'POST', 1016 + headers: { Authorization: `Bearer ${accessToken}` }, 1017 + body: form, 1018 + }) 1019 + if (!response.ok) { 1020 + const body = await response.text().catch(() => 'Unknown error') 1021 + throw new ApiError(response.status, `API ${response.status}: ${body}`) 1022 + } 1023 + return response.json() as Promise<UploadResponse> 1024 + } 1025 + 986 1026 // --- Sybil Detection endpoints --- 987 1027 988 1028 export function getSybilClusters(
+2
src/lib/api/types.ts
··· 199 199 reactionSet: string[] 200 200 communityDescription: string | null 201 201 communityLogoUrl: string | null 202 + faviconUrl: string | null 202 203 primaryColor: string | null 203 204 accentColor: string | null 204 205 jurisdictionCountry: string | null ··· 214 215 maturityRating: MaturityRating 215 216 communityDescription: string | null 216 217 communityLogoUrl: string | null 218 + faviconUrl: string | null 217 219 } 218 220 219 221 export interface CommunityStats {
+2
src/mocks/data.ts
··· 602 602 reactionSet: ['like', 'love', 'laugh', 'surprise', 'sad'], 603 603 communityDescription: 'A test community for development', 604 604 communityLogoUrl: null, 605 + faviconUrl: null, 605 606 primaryColor: '#31748f', 606 607 accentColor: '#c4a7e7', 607 608 jurisdictionCountry: null, ··· 1179 1180 maturityRating: 'safe', 1180 1181 communityDescription: 'A test community for development', 1181 1182 communityLogoUrl: null, 1183 + faviconUrl: null, 1182 1184 } 1183 1185 1184 1186 // --- Community Profile (own profile in a community) ---
+18
src/mocks/handlers.ts
··· 328 328 return HttpResponse.json({ ...mockCommunitySettings, ...body }) 329 329 }), 330 330 331 + // POST /api/admin/design/logo 332 + http.post(`${API_URL}/api/admin/design/logo`, ({ request }) => { 333 + const auth = request.headers.get('Authorization') 334 + if (!auth?.startsWith('Bearer ')) { 335 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 336 + } 337 + return HttpResponse.json({ url: 'http://localhost:3000/uploads/logos/mock-logo.webp' }) 338 + }), 339 + 340 + // POST /api/admin/design/favicon 341 + http.post(`${API_URL}/api/admin/design/favicon`, ({ request }) => { 342 + const auth = request.headers.get('Authorization') 343 + if (!auth?.startsWith('Bearer ')) { 344 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 345 + } 346 + return HttpResponse.json({ url: 'http://localhost:3000/uploads/favicons/mock-favicon.webp' }) 347 + }), 348 + 331 349 // GET /api/admin/stats 332 350 http.get(`${API_URL}/api/admin/stats`, ({ request }) => { 333 351 const auth = request.headers.get('Authorization')