Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat(web): Homepage + Category Pages (Phase 4 M4) (#5)

* feat(web): add homepage and category pages (Phase 4 M4)

Implement the first real forum pages with full component library:

- API client with TypeScript types matching barazo-api schemas
- MSW mock handlers for testing (Tier 3 hand-written)
- ForumLayout with header, sidebar, footer, skip links
- Pagination component (WCAG 2.2 AA, aria-current)
- Breadcrumbs with JSON-LD BreadcrumbList structured data
- TopicCard/TopicList components with reply/reaction counts
- CategoryNav with hierarchical tree display
- Homepage (/) with recent topics, category sidebar, JSON-LD WebSite
- Category page (/c/[slug]) with filtered topics, breadcrumbs
- FocusOnNavigate hook for client-side route transitions
- Switch from static export to standalone SSR output
- 62 tests passing across 10 test files with axe a11y checks

* fix(ci): update accessibility audit for standalone output

- Add try-catch error handling to homepage for API unavailability
- Update CI workflow to use standalone server instead of static serve
- Fix build artifact paths for standalone output
- Use polling wait instead of fixed sleep for server readiness

* fix(a11y): add underline to footer link for link-in-text-block rule

Links within text blocks must be visually distinguished from
surrounding text using more than just color (WCAG 2.2 AA).

authored by

Guido X Jansen and committed by
GitHub
56e91e1f bb913c28

+2018 -141
+18 -4
.github/workflows/ci.yml
··· 115 115 uses: actions/upload-artifact@v4 116 116 with: 117 117 name: build 118 - path: dist/ 118 + path: | 119 + .next/standalone/ 120 + .next/static/ 119 121 retention-days: 7 120 122 121 123 accessibility: ··· 143 145 - name: Build application 144 146 run: pnpm build 145 147 148 + - name: Prepare standalone server 149 + run: | 150 + cp -r .next/static .next/standalone/.next/static 151 + cp -r public .next/standalone/public 152 + 146 153 - name: Install axe-core CLI 147 154 run: pnpm add -g @axe-core/cli 148 155 149 156 - name: Sync Chrome and ChromeDriver versions 150 157 run: npx browser-driver-manager install chrome 151 158 152 - - name: Serve build 153 - run: npx serve dist -l 3000 & 159 + - name: Start standalone server 160 + run: node .next/standalone/server.js & 161 + env: 162 + PORT: '3000' 163 + HOSTNAME: '0.0.0.0' 154 164 155 165 - name: Wait for server 156 - run: sleep 5 166 + run: | 167 + for i in $(seq 1 30); do 168 + curl -s http://localhost:3000 > /dev/null 2>&1 && break 169 + sleep 1 170 + done 157 171 158 172 - name: Run axe accessibility check 159 173 run: axe http://localhost:3000 --exit
+14 -5
next.config.ts
··· 2 2 3 3 /** 4 4 * Next.js Configuration for Barazo Web 5 + * Uses standalone output for Docker deployment with SSR. 5 6 * @see https://nextjs.org/docs/api-reference/next.config.js/introduction 6 7 */ 7 8 const nextConfig: NextConfig = { 8 - // Static export for Docker deployment 9 - output: 'export', 10 - distDir: 'dist', 9 + // Standalone output for Docker (includes Node.js server) 10 + output: 'standalone', 11 11 12 - // Image optimization (static export requires unoptimized images) 12 + // Image optimization 13 13 images: { 14 - unoptimized: true, 14 + remotePatterns: [ 15 + { 16 + protocol: 'https', 17 + hostname: '*.bsky.social', 18 + }, 19 + { 20 + protocol: 'https', 21 + hostname: 'cdn.bsky.app', 22 + }, 23 + ], 15 24 }, 16 25 17 26 // Trailing slashes for SEO consistency
+101
src/app/c/[slug]/page.test.tsx
··· 1 + import { describe, it, expect, vi, beforeEach } from 'vitest' 2 + import { render, screen } from '@testing-library/react' 3 + 4 + // Mock API client 5 + vi.mock('@/lib/api/client', () => ({ 6 + getCategoryBySlug: vi.fn(), 7 + getCategories: vi.fn(), 8 + getTopics: vi.fn(), 9 + })) 10 + 11 + // Mock next-themes 12 + vi.mock('next-themes', () => ({ 13 + useTheme: () => ({ theme: 'dark', setTheme: vi.fn() }), 14 + ThemeProvider: ({ children }: { children: React.ReactNode }) => children, 15 + })) 16 + 17 + // Mock next/image 18 + vi.mock('next/image', () => ({ 19 + default: (props: Record<string, unknown>) => { 20 + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text 21 + return <img {...props} /> 22 + }, 23 + })) 24 + 25 + // Mock next/link 26 + vi.mock('next/link', () => ({ 27 + default: ({ 28 + children, 29 + href, 30 + ...props 31 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 32 + <a href={href} {...props}> 33 + {children} 34 + </a> 35 + ), 36 + })) 37 + 38 + import { getCategoryBySlug, getCategories, getTopics } from '@/lib/api/client' 39 + import { mockCategories, mockCategoryWithTopicCount, mockTopics } from '@/mocks/data' 40 + import CategoryPage from './page' 41 + 42 + const mockGetCategoryBySlug = vi.mocked(getCategoryBySlug) 43 + const mockGetCategories = vi.mocked(getCategories) 44 + const mockGetTopics = vi.mocked(getTopics) 45 + 46 + beforeEach(() => { 47 + mockGetCategoryBySlug.mockResolvedValue(mockCategoryWithTopicCount) 48 + mockGetCategories.mockResolvedValue({ categories: mockCategories }) 49 + mockGetTopics.mockResolvedValue({ 50 + topics: mockTopics.filter((t) => t.category === 'general'), 51 + cursor: null, 52 + }) 53 + }) 54 + 55 + const params = Promise.resolve({ slug: 'general' }) 56 + 57 + describe('CategoryPage', () => { 58 + it('renders category name as heading', async () => { 59 + const page = await CategoryPage({ params }) 60 + render(page) 61 + expect( 62 + screen.getByRole('heading', { level: 1, name: mockCategoryWithTopicCount.name }) 63 + ).toBeInTheDocument() 64 + }) 65 + 66 + it('renders category description', async () => { 67 + const page = await CategoryPage({ params }) 68 + render(page) 69 + expect(screen.getByText(mockCategoryWithTopicCount.description!)).toBeInTheDocument() 70 + }) 71 + 72 + it('renders breadcrumbs', async () => { 73 + const page = await CategoryPage({ params }) 74 + render(page) 75 + expect(screen.getByRole('navigation', { name: /breadcrumb/i })).toBeInTheDocument() 76 + }) 77 + 78 + it('renders topic list for category', async () => { 79 + const page = await CategoryPage({ params }) 80 + render(page) 81 + const articles = screen.getAllByRole('article') 82 + expect(articles.length).toBeGreaterThan(0) 83 + }) 84 + 85 + it('renders topic count', async () => { 86 + const page = await CategoryPage({ params }) 87 + render(page) 88 + expect(screen.getByText(`${mockCategoryWithTopicCount.topicCount} topics`)).toBeInTheDocument() 89 + }) 90 + 91 + it('includes JSON-LD BreadcrumbList', async () => { 92 + const page = await CategoryPage({ params }) 93 + const { container } = render(page) 94 + const scripts = container.querySelectorAll('script[type="application/ld+json"]') 95 + const breadcrumbScript = Array.from(scripts).find((s) => { 96 + const data = JSON.parse(s.textContent!) 97 + return data['@type'] === 'BreadcrumbList' 98 + }) 99 + expect(breadcrumbScript).toBeTruthy() 100 + }) 101 + })
+103
src/app/c/[slug]/page.tsx
··· 1 + /** 2 + * Category page - Shows topics for a specific category. 3 + * URL: /c/{slug} 4 + * Server-side rendered with SEO metadata and JSON-LD. 5 + * @see specs/prd-web.md Section 3.1 6 + */ 7 + 8 + import type { Metadata } from 'next' 9 + import { notFound } from 'next/navigation' 10 + import { getCategoryBySlug, getCategories, getTopics, ApiError } from '@/lib/api/client' 11 + 12 + export const dynamic = 'force-dynamic' 13 + import { ForumLayout } from '@/components/layout/forum-layout' 14 + import { TopicList } from '@/components/topic-list' 15 + import { CategoryNav } from '@/components/category-nav' 16 + import { Breadcrumbs } from '@/components/breadcrumbs' 17 + import { Pagination } from '@/components/pagination' 18 + 19 + interface CategoryPageProps { 20 + params: Promise<{ slug: string }> 21 + searchParams?: Promise<{ page?: string }> 22 + } 23 + 24 + export async function generateMetadata({ params }: CategoryPageProps): Promise<Metadata> { 25 + const { slug } = await params 26 + try { 27 + const category = await getCategoryBySlug(slug) 28 + return { 29 + title: category.name, 30 + description: category.description ?? `Topics in ${category.name}`, 31 + openGraph: { 32 + title: category.name, 33 + description: category.description ?? `Topics in ${category.name}`, 34 + type: 'website', 35 + }, 36 + } 37 + } catch { 38 + return { title: 'Category Not Found' } 39 + } 40 + } 41 + 42 + const TOPICS_PER_PAGE = 20 43 + 44 + export default async function CategoryPage({ params, searchParams }: CategoryPageProps) { 45 + const { slug } = await params 46 + const resolvedSearchParams = searchParams ? await searchParams : {} 47 + const page = Math.max(1, parseInt(resolvedSearchParams.page ?? '1', 10) || 1) 48 + 49 + let category 50 + try { 51 + category = await getCategoryBySlug(slug) 52 + } catch (error) { 53 + if (error instanceof ApiError && error.status === 404) { 54 + notFound() 55 + } 56 + throw error 57 + } 58 + 59 + const [categoriesResult, topicsResult] = await Promise.all([ 60 + getCategories(), 61 + getTopics({ 62 + category: slug, 63 + limit: TOPICS_PER_PAGE, 64 + }), 65 + ]) 66 + 67 + const totalPages = Math.max(1, Math.ceil(category.topicCount / TOPICS_PER_PAGE)) 68 + 69 + const breadcrumbItems = [ 70 + { label: 'Home', href: '/' }, 71 + { label: category.name, href: `/c/${slug}` }, 72 + ] 73 + 74 + return ( 75 + <ForumLayout 76 + sidebar={<CategoryNav categories={categoriesResult.categories} currentSlug={slug} />} 77 + > 78 + {/* Breadcrumbs (includes JSON-LD BreadcrumbList) */} 79 + <Breadcrumbs items={breadcrumbItems} /> 80 + 81 + {/* Category header */} 82 + <div className="mb-6"> 83 + <h1 className="text-2xl font-bold text-foreground">{category.name}</h1> 84 + {category.description && ( 85 + <p className="mt-1 text-muted-foreground">{category.description}</p> 86 + )} 87 + <p className="mt-2 text-sm text-muted-foreground"> 88 + {category.topicCount} {category.topicCount === 1 ? 'topic' : 'topics'} 89 + </p> 90 + </div> 91 + 92 + {/* Topic list */} 93 + <TopicList topics={topicsResult.topics} /> 94 + 95 + {/* Pagination */} 96 + {totalPages > 1 && ( 97 + <div className="mt-6"> 98 + <Pagination currentPage={page} totalPages={totalPages} baseUrl={`/c/${slug}`} /> 99 + </div> 100 + )} 101 + </ForumLayout> 102 + ) 103 + }
+86
src/app/page.test.tsx
··· 1 + import { describe, it, expect, vi, beforeEach } from 'vitest' 2 + import { render, screen } from '@testing-library/react' 3 + 4 + // Mock API client 5 + vi.mock('@/lib/api/client', () => ({ 6 + getCategories: vi.fn(), 7 + getTopics: vi.fn(), 8 + })) 9 + 10 + // Mock next-themes 11 + vi.mock('next-themes', () => ({ 12 + useTheme: () => ({ theme: 'dark', setTheme: vi.fn() }), 13 + ThemeProvider: ({ children }: { children: React.ReactNode }) => children, 14 + })) 15 + 16 + // Mock next/image 17 + vi.mock('next/image', () => ({ 18 + default: (props: Record<string, unknown>) => { 19 + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text 20 + return <img {...props} /> 21 + }, 22 + })) 23 + 24 + // Mock next/link 25 + vi.mock('next/link', () => ({ 26 + default: ({ 27 + children, 28 + href, 29 + ...props 30 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 31 + <a href={href} {...props}> 32 + {children} 33 + </a> 34 + ), 35 + })) 36 + 37 + import { getCategories, getTopics } from '@/lib/api/client' 38 + import { mockCategories, mockTopics } from '@/mocks/data' 39 + import HomePage from './page' 40 + 41 + const mockGetCategories = vi.mocked(getCategories) 42 + const mockGetTopics = vi.mocked(getTopics) 43 + 44 + beforeEach(() => { 45 + mockGetCategories.mockResolvedValue({ categories: mockCategories }) 46 + mockGetTopics.mockResolvedValue({ topics: mockTopics, cursor: null }) 47 + }) 48 + 49 + describe('HomePage', () => { 50 + it('renders page heading', async () => { 51 + const page = await HomePage() 52 + render(page) 53 + expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument() 54 + }) 55 + 56 + it('renders recent topics', async () => { 57 + const page = await HomePage() 58 + render(page) 59 + expect(screen.getByText('Welcome to Barazo Forums')).toBeInTheDocument() 60 + }) 61 + 62 + it('renders category navigation', async () => { 63 + const page = await HomePage() 64 + render(page) 65 + const navs = screen.getAllByRole('navigation', { name: /categories/i }) 66 + expect(navs.length).toBeGreaterThan(0) 67 + }) 68 + 69 + it('renders category links', async () => { 70 + const page = await HomePage() 71 + render(page) 72 + const generalLinks = screen.getAllByRole('link', { name: 'General Discussion' }) 73 + expect(generalLinks.length).toBeGreaterThan(0) 74 + const devLinks = screen.getAllByRole('link', { name: 'Development' }) 75 + expect(devLinks.length).toBeGreaterThan(0) 76 + }) 77 + 78 + it('renders JSON-LD structured data', async () => { 79 + const page = await HomePage() 80 + const { container } = render(page) 81 + const script = container.querySelector('script[type="application/ld+json"]') 82 + expect(script).toBeInTheDocument() 83 + const jsonLd = JSON.parse(script!.textContent!) 84 + expect(jsonLd['@type']).toBe('WebSite') 85 + }) 86 + })
+83 -131
src/app/page.tsx
··· 1 - import { SkipLinks } from '@/components/skip-links' 2 - import { ThemeToggle } from '@/components/theme-toggle' 3 - import Image from 'next/image' 1 + /** 2 + * Homepage - Forum landing page. 3 + * Shows recent topics, category sidebar, and community overview. 4 + * Server-side rendered for SEO. 5 + * @see specs/prd-web.md Section 3.1 6 + */ 4 7 5 - export default function Home() { 6 - return ( 7 - <div className="min-h-screen bg-background"> 8 - <SkipLinks /> 8 + import type { Metadata } from 'next' 9 + import { getCategories, getTopics } from '@/lib/api/client' 10 + import { ForumLayout } from '@/components/layout/forum-layout' 11 + import { TopicList } from '@/components/topic-list' 12 + import { CategoryNav } from '@/components/category-nav' 13 + import type { CategoriesResponse, TopicsResponse } from '@/lib/api/types' 9 14 10 - {/* Header */} 11 - <header className="sticky top-0 z-40 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> 12 - <div className="container flex h-14 items-center justify-between"> 13 - <div className="flex items-center gap-2"> 14 - <Image 15 - src="/barazo-logo-light.svg" 16 - alt="Barazo" 17 - width={120} 18 - height={32} 19 - className="h-8 w-auto dark:hidden" 20 - priority 21 - /> 22 - <Image 23 - src="/barazo-logo-dark.svg" 24 - alt="Barazo" 25 - width={120} 26 - height={32} 27 - className="hidden h-8 w-auto dark:block" 28 - priority 29 - /> 30 - </div> 31 - <div className="flex items-center gap-4"> 32 - <ThemeToggle /> 33 - </div> 34 - </div> 35 - </header> 15 + export const dynamic = 'force-dynamic' 36 16 37 - {/* Main Content */} 38 - <main id="main-content" className="container py-8" tabIndex={-1}> 39 - <section className="mx-auto max-w-3xl space-y-8"> 40 - <div className="space-y-4 text-center"> 41 - <h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl"> 42 - Community Forums on the <span className="text-primary">AT Protocol</span> 43 - </h1> 44 - <p className="mx-auto max-w-2xl text-lg text-muted-foreground"> 45 - Portable identity. User data ownership. Cross-community reputation. The forum platform 46 - built for the decentralized web. 47 - </p> 48 - </div> 17 + export const metadata: Metadata = { 18 + title: 'Barazo - Community Forums on the AT Protocol', 19 + description: 20 + 'Federated community forums with portable identity, user data ownership, and cross-community reputation.', 21 + } 49 22 50 - {/* Design System Demo */} 51 - <div className="grid gap-6 rounded-lg border border-border bg-card p-6"> 52 - <h2 className="text-2xl font-semibold text-card-foreground">Design System Active</h2> 23 + export default async function HomePage() { 24 + let categoriesResult: CategoriesResponse = { categories: [] } 25 + let topicsResult: TopicsResponse = { topics: [], cursor: null } 26 + let apiError = false 53 27 54 - {/* Color Palette Demo */} 55 - <div className="space-y-3"> 56 - <h3 className="text-sm font-medium text-muted-foreground"> 57 - Color Palette (Radix Colors + Flexoki) 58 - </h3> 59 - <div className="flex flex-wrap gap-2"> 60 - <div className="flex items-center gap-2"> 61 - <div className="h-8 w-8 rounded bg-primary" /> 62 - <span className="text-xs text-muted-foreground">Primary</span> 63 - </div> 64 - <div className="flex items-center gap-2"> 65 - <div className="h-8 w-8 rounded bg-secondary" /> 66 - <span className="text-xs text-muted-foreground">Secondary</span> 67 - </div> 68 - <div className="flex items-center gap-2"> 69 - <div className="h-8 w-8 rounded bg-success" /> 70 - <span className="text-xs text-muted-foreground">Success</span> 71 - </div> 72 - <div className="flex items-center gap-2"> 73 - <div className="h-8 w-8 rounded bg-warning" /> 74 - <span className="text-xs text-muted-foreground">Warning</span> 75 - </div> 76 - <div className="flex items-center gap-2"> 77 - <div className="h-8 w-8 rounded bg-destructive" /> 78 - <span className="text-xs text-muted-foreground">Error</span> 79 - </div> 80 - </div> 81 - </div> 28 + try { 29 + ;[categoriesResult, topicsResult] = await Promise.all([ 30 + getCategories(), 31 + getTopics({ limit: 20, sort: 'latest' }), 32 + ]) 33 + } catch { 34 + apiError = true 35 + } 82 36 83 - {/* Typography Demo */} 84 - <div className="space-y-3"> 85 - <h3 className="text-sm font-medium text-muted-foreground"> 86 - Typography (Source Sans 3) 87 - </h3> 88 - <div className="space-y-1"> 89 - <p className="text-2xl font-bold">Heading 2XL - Bold</p> 90 - <p className="text-xl font-semibold">Heading XL - Semibold</p> 91 - <p className="text-lg font-medium">Heading LG - Medium</p> 92 - <p className="text-base">Body text - Regular</p> 93 - <p className="text-sm text-muted-foreground">Small text - Muted</p> 94 - </div> 95 - </div> 37 + const jsonLd = { 38 + '@context': 'https://schema.org', 39 + '@type': 'WebSite', 40 + name: 'Barazo', 41 + url: 'https://barazo.forum', 42 + potentialAction: { 43 + '@type': 'SearchAction', 44 + target: { 45 + '@type': 'EntryPoint', 46 + urlTemplate: 'https://barazo.forum/search?q={search_term_string}', 47 + }, 48 + 'query-input': 'required name=search_term_string', 49 + }, 50 + } 96 51 97 - {/* Code Font Demo */} 98 - <div className="space-y-3"> 99 - <h3 className="text-sm font-medium text-muted-foreground"> 100 - Monospace (Source Code Pro) 101 - </h3> 102 - <code className="rounded bg-input px-2 py-1 font-mono text-sm"> 103 - const barazo = &quot;AT Protocol Forum&quot;; 104 - </code> 105 - </div> 52 + return ( 53 + <ForumLayout 54 + sidebar={ 55 + categoriesResult.categories.length > 0 ? ( 56 + <CategoryNav categories={categoriesResult.categories} /> 57 + ) : undefined 58 + } 59 + > 60 + {/* JSON-LD */} 61 + <script 62 + type="application/ld+json" 63 + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} 64 + /> 65 + 66 + {/* Welcome / Stats */} 67 + <div className="mb-6"> 68 + <h1 className="text-2xl font-bold text-foreground">Welcome to the Community</h1> 69 + <p className="mt-1 text-muted-foreground"> 70 + Discussions powered by the AT Protocol. Your identity, your data. 71 + </p> 72 + </div> 106 73 107 - {/* Button Variants */} 108 - <div className="space-y-3"> 109 - <h3 className="text-sm font-medium text-muted-foreground">Button Styles</h3> 110 - <div className="flex flex-wrap gap-3"> 111 - <button className="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary-hover focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"> 112 - Primary Button 113 - </button> 114 - <button className="inline-flex h-10 items-center justify-center rounded-md bg-secondary px-4 py-2 text-sm font-medium text-secondary-foreground shadow-sm transition-colors hover:bg-secondary-hover focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"> 115 - Secondary Button 116 - </button> 117 - <button className="inline-flex h-10 items-center justify-center rounded-md border border-border bg-card px-4 py-2 text-sm font-medium text-card-foreground shadow-sm transition-colors hover:bg-card-hover focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"> 118 - Outline Button 119 - </button> 120 - <button className="inline-flex h-10 items-center justify-center rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground shadow-sm transition-colors hover:bg-destructive-hover focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"> 121 - Destructive 122 - </button> 123 - </div> 74 + {apiError ? ( 75 + <div className="rounded-lg border border-border bg-card p-8 text-center"> 76 + <p className="text-muted-foreground"> 77 + Unable to connect to the forum API. Please try again later. 78 + </p> 79 + </div> 80 + ) : ( 81 + <> 82 + {/* Category cards (mobile) */} 83 + {categoriesResult.categories.length > 0 && ( 84 + <div className="mb-6 lg:hidden"> 85 + <CategoryNav categories={categoriesResult.categories} /> 124 86 </div> 125 - </div> 87 + )} 126 88 127 - {/* Footer */} 128 - <footer className="text-center text-sm text-muted-foreground"> 129 - <p> 130 - Powered by Barazo v0.1.0 |{' '} 131 - <a 132 - href="https://github.com/barazo-forum" 133 - className="text-primary underline hover:text-primary-hover" 134 - > 135 - GitHub 136 - </a> 137 - </p> 138 - </footer> 139 - </section> 140 - </main> 141 - </div> 89 + {/* Recent Topics */} 90 + <TopicList topics={topicsResult.topics} heading="Recent Topics" /> 91 + </> 92 + )} 93 + </ForumLayout> 142 94 ) 143 95 }
+57
src/components/breadcrumbs.test.tsx
··· 1 + import { describe, it, expect } from 'vitest' 2 + import { render, screen } from '@testing-library/react' 3 + import { axe } from 'vitest-axe' 4 + import { Breadcrumbs } from './breadcrumbs' 5 + 6 + describe('Breadcrumbs', () => { 7 + const items = [ 8 + { label: 'Home', href: '/' }, 9 + { label: 'Development', href: '/c/development' }, 10 + { label: 'Frontend', href: '/c/development/frontend' }, 11 + ] 12 + 13 + it('renders all breadcrumb items', () => { 14 + render(<Breadcrumbs items={items} />) 15 + expect(screen.getByText('Home')).toBeInTheDocument() 16 + expect(screen.getByText('Development')).toBeInTheDocument() 17 + expect(screen.getByText('Frontend')).toBeInTheDocument() 18 + }) 19 + 20 + it('renders links for non-current items', () => { 21 + render(<Breadcrumbs items={items} />) 22 + const homeLink = screen.getByRole('link', { name: 'Home' }) 23 + expect(homeLink).toHaveAttribute('href', '/') 24 + }) 25 + 26 + it('marks last item as current page', () => { 27 + render(<Breadcrumbs items={items} />) 28 + const current = screen.getByText('Frontend') 29 + expect(current).toHaveAttribute('aria-current', 'page') 30 + }) 31 + 32 + it('has accessible navigation landmark', () => { 33 + render(<Breadcrumbs items={items} />) 34 + expect(screen.getByRole('navigation', { name: /breadcrumb/i })).toBeInTheDocument() 35 + }) 36 + 37 + it('renders separator between items', () => { 38 + render(<Breadcrumbs items={items} />) 39 + const separators = screen.getAllByText('/') 40 + expect(separators).toHaveLength(2) 41 + }) 42 + 43 + it('includes JSON-LD structured data', () => { 44 + const { container } = render(<Breadcrumbs items={items} />) 45 + const script = container.querySelector('script[type="application/ld+json"]') 46 + expect(script).toBeInTheDocument() 47 + const jsonLd = JSON.parse(script!.textContent!) 48 + expect(jsonLd['@type']).toBe('BreadcrumbList') 49 + expect(jsonLd.itemListElement).toHaveLength(3) 50 + }) 51 + 52 + it('passes axe accessibility check', async () => { 53 + const { container } = render(<Breadcrumbs items={items} />) 54 + const results = await axe(container) 55 + expect(results).toHaveNoViolations() 56 + }) 57 + })
+66
src/components/breadcrumbs.tsx
··· 1 + /** 2 + * Breadcrumbs component with JSON-LD structured data. 3 + * WCAG 2.2 AA: nav landmark, aria-current, semantic list. 4 + * @see https://schema.org/BreadcrumbList 5 + */ 6 + 7 + import Link from 'next/link' 8 + 9 + export interface BreadcrumbItem { 10 + label: string 11 + href: string 12 + } 13 + 14 + interface BreadcrumbsProps { 15 + items: BreadcrumbItem[] 16 + } 17 + 18 + export function Breadcrumbs({ items }: BreadcrumbsProps) { 19 + if (items.length === 0) return null 20 + 21 + const jsonLd = { 22 + '@context': 'https://schema.org', 23 + '@type': 'BreadcrumbList', 24 + itemListElement: items.map((item, index) => ({ 25 + '@type': 'ListItem', 26 + position: index + 1, 27 + name: item.label, 28 + item: `https://barazo.forum${item.href}`, 29 + })), 30 + } 31 + 32 + return ( 33 + <nav aria-label="Breadcrumb" className="mb-4"> 34 + <script 35 + type="application/ld+json" 36 + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} 37 + /> 38 + <ol className="flex items-center gap-1 text-sm text-muted-foreground"> 39 + {items.map((item, index) => { 40 + const isLast = index === items.length - 1 41 + return ( 42 + <li key={item.href} className="flex items-center gap-1"> 43 + {index > 0 && ( 44 + <span aria-hidden="true" className="text-muted-foreground"> 45 + / 46 + </span> 47 + )} 48 + {isLast ? ( 49 + <span aria-current="page" className="font-medium text-foreground"> 50 + {item.label} 51 + </span> 52 + ) : ( 53 + <Link 54 + href={item.href} 55 + className="transition-colors hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded-sm px-1" 56 + > 57 + {item.label} 58 + </Link> 59 + )} 60 + </li> 61 + ) 62 + })} 63 + </ol> 64 + </nav> 65 + ) 66 + }
+43
src/components/category-nav.test.tsx
··· 1 + import { describe, it, expect } from 'vitest' 2 + import { render, screen } from '@testing-library/react' 3 + import { axe } from 'vitest-axe' 4 + import { CategoryNav } from './category-nav' 5 + import { mockCategories } from '@/mocks/data' 6 + 7 + describe('CategoryNav', () => { 8 + it('renders navigation landmark', () => { 9 + render(<CategoryNav categories={mockCategories} />) 10 + expect(screen.getByRole('navigation', { name: /categories/i })).toBeInTheDocument() 11 + }) 12 + 13 + it('renders top-level categories', () => { 14 + render(<CategoryNav categories={mockCategories} />) 15 + expect(screen.getByRole('link', { name: 'General Discussion' })).toBeInTheDocument() 16 + expect(screen.getByRole('link', { name: 'Development' })).toBeInTheDocument() 17 + expect(screen.getByRole('link', { name: /feedback/i })).toBeInTheDocument() 18 + }) 19 + 20 + it('renders subcategories', () => { 21 + render(<CategoryNav categories={mockCategories} />) 22 + expect(screen.getByRole('link', { name: 'Frontend' })).toBeInTheDocument() 23 + expect(screen.getByRole('link', { name: 'Backend' })).toBeInTheDocument() 24 + }) 25 + 26 + it('links categories to their slug URL', () => { 27 + render(<CategoryNav categories={mockCategories} />) 28 + const generalLink = screen.getByRole('link', { name: 'General Discussion' }) 29 + expect(generalLink).toHaveAttribute('href', '/c/general') 30 + }) 31 + 32 + it('links subcategories with parent path', () => { 33 + render(<CategoryNav categories={mockCategories} />) 34 + const frontendLink = screen.getByRole('link', { name: 'Frontend' }) 35 + expect(frontendLink).toHaveAttribute('href', '/c/development/frontend') 36 + }) 37 + 38 + it('passes axe accessibility check', async () => { 39 + const { container } = render(<CategoryNav categories={mockCategories} />) 40 + const results = await axe(container) 41 + expect(results).toHaveNoViolations() 42 + }) 43 + })
+82
src/components/category-nav.tsx
··· 1 + /** 2 + * CategoryNav - Hierarchical category navigation. 3 + * Renders category tree with proper accessibility. 4 + * Used in sidebar and as standalone navigation. 5 + * @see specs/prd-web.md Section 4 (Navigation) 6 + */ 7 + 8 + import Link from 'next/link' 9 + import { Folder, FolderOpen } from '@phosphor-icons/react/dist/ssr' 10 + import type { CategoryTreeNode } from '@/lib/api/types' 11 + import { cn } from '@/lib/utils' 12 + 13 + interface CategoryNavProps { 14 + categories: CategoryTreeNode[] 15 + currentSlug?: string 16 + className?: string 17 + } 18 + 19 + export function CategoryNav({ categories, currentSlug, className }: CategoryNavProps) { 20 + return ( 21 + <nav aria-label="Categories" className={className}> 22 + <h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground"> 23 + Categories 24 + </h2> 25 + <ul className="space-y-1"> 26 + {categories.map((category) => ( 27 + <CategoryItem key={category.id} category={category} currentSlug={currentSlug} depth={0} /> 28 + ))} 29 + </ul> 30 + </nav> 31 + ) 32 + } 33 + 34 + interface CategoryItemProps { 35 + category: CategoryTreeNode 36 + currentSlug?: string 37 + depth: number 38 + parentSlug?: string 39 + } 40 + 41 + function CategoryItem({ category, currentSlug, depth, parentSlug }: CategoryItemProps) { 42 + const isActive = currentSlug === category.slug 43 + const hasChildren = category.children.length > 0 44 + const categoryPath = parentSlug ? `/c/${parentSlug}/${category.slug}` : `/c/${category.slug}` 45 + 46 + return ( 47 + <li> 48 + <Link 49 + href={categoryPath} 50 + aria-current={isActive ? 'page' : undefined} 51 + className={cn( 52 + 'flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors', 53 + 'hover:bg-card-hover focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', 54 + isActive 55 + ? 'bg-primary-muted font-medium text-primary' 56 + : 'text-muted-foreground hover:text-foreground', 57 + depth > 0 && 'ml-4' 58 + )} 59 + > 60 + {hasChildren ? ( 61 + <FolderOpen className="h-4 w-4 shrink-0" weight="regular" aria-hidden="true" /> 62 + ) : ( 63 + <Folder className="h-4 w-4 shrink-0" weight="regular" aria-hidden="true" /> 64 + )} 65 + <span className="truncate">{category.name}</span> 66 + </Link> 67 + {hasChildren && ( 68 + <ul className="mt-0.5 space-y-0.5"> 69 + {category.children.map((child) => ( 70 + <CategoryItem 71 + key={child.id} 72 + category={child} 73 + currentSlug={currentSlug} 74 + depth={depth + 1} 75 + parentSlug={category.slug} 76 + /> 77 + ))} 78 + </ul> 79 + )} 80 + </li> 81 + ) 82 + }
+99
src/components/layout/forum-layout.test.tsx
··· 1 + import { describe, it, expect } from 'vitest' 2 + import { render, screen } from '@testing-library/react' 3 + import { axe } from 'vitest-axe' 4 + import { ForumLayout } from './forum-layout' 5 + 6 + // Mock next-themes 7 + vi.mock('next-themes', () => ({ 8 + useTheme: () => ({ theme: 'dark', setTheme: vi.fn() }), 9 + ThemeProvider: ({ children }: { children: React.ReactNode }) => children, 10 + })) 11 + 12 + // Mock next/image 13 + vi.mock('next/image', () => ({ 14 + default: (props: Record<string, unknown>) => { 15 + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text 16 + return <img {...props} /> 17 + }, 18 + })) 19 + 20 + // Mock next/link 21 + vi.mock('next/link', () => ({ 22 + default: ({ 23 + children, 24 + href, 25 + ...props 26 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 27 + <a href={href} {...props}> 28 + {children} 29 + </a> 30 + ), 31 + })) 32 + 33 + describe('ForumLayout', () => { 34 + it('renders header with logo', () => { 35 + render( 36 + <ForumLayout> 37 + <p>Content</p> 38 + </ForumLayout> 39 + ) 40 + const logos = screen.getAllByAltText('Barazo') 41 + expect(logos.length).toBeGreaterThan(0) 42 + }) 43 + 44 + it('renders main content area', () => { 45 + render( 46 + <ForumLayout> 47 + <p>Test content</p> 48 + </ForumLayout> 49 + ) 50 + expect(screen.getByText('Test content')).toBeInTheDocument() 51 + }) 52 + 53 + it('renders header landmark', () => { 54 + render( 55 + <ForumLayout> 56 + <p>Content</p> 57 + </ForumLayout> 58 + ) 59 + expect(screen.getByRole('banner')).toBeInTheDocument() 60 + }) 61 + 62 + it('renders main landmark', () => { 63 + render( 64 + <ForumLayout> 65 + <p>Content</p> 66 + </ForumLayout> 67 + ) 68 + expect(screen.getByRole('main')).toBeInTheDocument() 69 + }) 70 + 71 + it('renders footer', () => { 72 + render( 73 + <ForumLayout> 74 + <p>Content</p> 75 + </ForumLayout> 76 + ) 77 + expect(screen.getByRole('contentinfo')).toBeInTheDocument() 78 + }) 79 + 80 + it('renders skip links', () => { 81 + render( 82 + <ForumLayout> 83 + <p>Content</p> 84 + </ForumLayout> 85 + ) 86 + expect(screen.getByText('Skip to main content')).toBeInTheDocument() 87 + }) 88 + 89 + it('passes axe accessibility check', async () => { 90 + const { container } = render( 91 + <ForumLayout> 92 + <h1>Page Title</h1> 93 + <p>Content</p> 94 + </ForumLayout> 95 + ) 96 + const results = await axe(container) 97 + expect(results).toHaveNoViolations() 98 + }) 99 + })
+123
src/components/layout/forum-layout.tsx
··· 1 + /** 2 + * Forum Layout (CommunityLayout) 3 + * Wraps all forum pages with header, sidebar, main content area, and footer. 4 + * Header: logo, search placeholder, theme toggle, user menu placeholder. 5 + * @see specs/prd-web.md Section 4 (Layout Components) 6 + */ 7 + 8 + import Link from 'next/link' 9 + import Image from 'next/image' 10 + import { SkipLinks } from '@/components/skip-links' 11 + import { ThemeToggle } from '@/components/theme-toggle' 12 + import { MagnifyingGlass } from '@phosphor-icons/react/dist/ssr' 13 + 14 + interface ForumLayoutProps { 15 + children: React.ReactNode 16 + sidebar?: React.ReactNode 17 + } 18 + 19 + export function ForumLayout({ children, sidebar }: ForumLayoutProps) { 20 + return ( 21 + <div className="min-h-screen bg-background"> 22 + <SkipLinks /> 23 + 24 + {/* Header */} 25 + <header className="sticky top-0 z-40 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> 26 + <div className="container flex h-14 items-center justify-between gap-4"> 27 + {/* Logo */} 28 + <Link href="/" className="flex shrink-0 items-center gap-2"> 29 + <Image 30 + src="/barazo-logo-light.svg" 31 + alt="Barazo" 32 + width={120} 33 + height={32} 34 + className="h-8 w-auto dark:hidden" 35 + priority 36 + /> 37 + <Image 38 + src="/barazo-logo-dark.svg" 39 + alt="Barazo" 40 + width={120} 41 + height={32} 42 + className="hidden h-8 w-auto dark:block" 43 + priority 44 + /> 45 + </Link> 46 + 47 + {/* Search */} 48 + <div className="hidden flex-1 sm:flex sm:max-w-md"> 49 + <Link 50 + href="/search" 51 + className="flex h-9 w-full items-center gap-2 rounded-md border border-border bg-card px-3 text-sm text-muted-foreground transition-colors hover:bg-card-hover focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" 52 + > 53 + <MagnifyingGlass className="h-4 w-4" weight="regular" aria-hidden="true" /> 54 + <span>Search topics...</span> 55 + </Link> 56 + </div> 57 + 58 + {/* Actions */} 59 + <div className="flex items-center gap-2"> 60 + {/* Mobile search */} 61 + <Link 62 + href="/search" 63 + aria-label="Search" 64 + className="inline-flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-card-hover hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 sm:hidden" 65 + > 66 + <MagnifyingGlass className="h-5 w-5" weight="regular" aria-hidden="true" /> 67 + </Link> 68 + <ThemeToggle /> 69 + </div> 70 + </div> 71 + </header> 72 + 73 + {/* Main layout */} 74 + <div className="container flex gap-8 py-6"> 75 + {/* Main content */} 76 + <main id="main-content" className="min-w-0 flex-1" tabIndex={-1}> 77 + {children} 78 + </main> 79 + 80 + {/* Sidebar */} 81 + {sidebar && ( 82 + <aside className="hidden w-64 shrink-0 lg:block" aria-label="Sidebar"> 83 + {sidebar} 84 + </aside> 85 + )} 86 + </div> 87 + 88 + {/* Footer */} 89 + <footer className="border-t border-border bg-background"> 90 + <div className="container flex items-center justify-between py-6 text-sm text-muted-foreground"> 91 + <p> 92 + Powered by{' '} 93 + <Link 94 + href="https://barazo.forum" 95 + className="text-primary underline decoration-primary/50 hover:text-primary-hover hover:decoration-primary" 96 + > 97 + Barazo 98 + </Link> 99 + </p> 100 + <nav aria-label="Footer"> 101 + <ul className="flex gap-4"> 102 + <li> 103 + <Link href="/accessibility" className="transition-colors hover:text-foreground"> 104 + Accessibility 105 + </Link> 106 + </li> 107 + <li> 108 + <Link href="/legal/privacy" className="transition-colors hover:text-foreground"> 109 + Privacy 110 + </Link> 111 + </li> 112 + <li> 113 + <Link href="/legal/terms" className="transition-colors hover:text-foreground"> 114 + Terms 115 + </Link> 116 + </li> 117 + </ul> 118 + </nav> 119 + </div> 120 + </footer> 121 + </div> 122 + ) 123 + }
+52
src/components/pagination.test.tsx
··· 1 + import { describe, it, expect } from 'vitest' 2 + import { render, screen } from '@testing-library/react' 3 + import { axe } from 'vitest-axe' 4 + import { Pagination } from './pagination' 5 + 6 + describe('Pagination', () => { 7 + it('renders page numbers', () => { 8 + render(<Pagination currentPage={1} totalPages={5} baseUrl="/c/general" />) 9 + expect(screen.getByText('1')).toBeInTheDocument() 10 + expect(screen.getByText('5')).toBeInTheDocument() 11 + }) 12 + 13 + it('marks current page with aria-current', () => { 14 + render(<Pagination currentPage={3} totalPages={5} baseUrl="/c/general" />) 15 + const currentLink = screen.getByText('3') 16 + expect(currentLink).toHaveAttribute('aria-current', 'page') 17 + }) 18 + 19 + it('renders previous and next links', () => { 20 + render(<Pagination currentPage={2} totalPages={5} baseUrl="/c/general" />) 21 + expect(screen.getByLabelText('Previous page')).toBeInTheDocument() 22 + expect(screen.getByLabelText('Next page')).toBeInTheDocument() 23 + }) 24 + 25 + it('disables previous on first page', () => { 26 + render(<Pagination currentPage={1} totalPages={5} baseUrl="/c/general" />) 27 + const prev = screen.getByLabelText('Previous page') 28 + expect(prev).toHaveAttribute('aria-disabled', 'true') 29 + }) 30 + 31 + it('disables next on last page', () => { 32 + render(<Pagination currentPage={5} totalPages={5} baseUrl="/c/general" />) 33 + const next = screen.getByLabelText('Next page') 34 + expect(next).toHaveAttribute('aria-disabled', 'true') 35 + }) 36 + 37 + it('has accessible navigation landmark', () => { 38 + render(<Pagination currentPage={1} totalPages={5} baseUrl="/c/general" />) 39 + expect(screen.getByRole('navigation', { name: /pagination/i })).toBeInTheDocument() 40 + }) 41 + 42 + it('does not render when totalPages is 1', () => { 43 + const { container } = render(<Pagination currentPage={1} totalPages={1} baseUrl="/c/general" />) 44 + expect(container.firstChild).toBeNull() 45 + }) 46 + 47 + it('passes axe accessibility check', async () => { 48 + const { container } = render(<Pagination currentPage={2} totalPages={5} baseUrl="/c/general" />) 49 + const results = await axe(container) 50 + expect(results).toHaveNoViolations() 51 + }) 52 + })
+135
src/components/pagination.tsx
··· 1 + /** 2 + * Pagination component with WCAG 2.2 AA compliance. 3 + * Uses aria-current="page" and proper navigation landmark. 4 + */ 5 + 6 + import Link from 'next/link' 7 + import { CaretLeft, CaretRight } from '@phosphor-icons/react/dist/ssr' 8 + import { cn } from '@/lib/utils' 9 + 10 + interface PaginationProps { 11 + currentPage: number 12 + totalPages: number 13 + baseUrl: string 14 + } 15 + 16 + function pageUrl(baseUrl: string, page: number): string { 17 + if (page === 1) return baseUrl 18 + return `${baseUrl}?page=${page}` 19 + } 20 + 21 + export function Pagination({ currentPage, totalPages, baseUrl }: PaginationProps) { 22 + if (totalPages <= 1) return null 23 + 24 + const pages = getPageNumbers(currentPage, totalPages) 25 + 26 + return ( 27 + <nav aria-label="Pagination" className="flex items-center justify-center gap-1"> 28 + {/* Previous */} 29 + {currentPage <= 1 ? ( 30 + <span 31 + aria-label="Previous page" 32 + aria-disabled="true" 33 + className="inline-flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground opacity-50" 34 + > 35 + <CaretLeft className="h-4 w-4" weight="bold" aria-hidden="true" /> 36 + </span> 37 + ) : ( 38 + <Link 39 + href={pageUrl(baseUrl, currentPage - 1)} 40 + aria-label="Previous page" 41 + className={cn( 42 + 'inline-flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors', 43 + 'hover:bg-card-hover hover:text-foreground', 44 + 'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2' 45 + )} 46 + > 47 + <CaretLeft className="h-4 w-4" weight="bold" aria-hidden="true" /> 48 + </Link> 49 + )} 50 + 51 + {/* Page numbers */} 52 + {pages.map((page, index) => 53 + page === '...' ? ( 54 + <span 55 + key={`ellipsis-${index}`} 56 + className="inline-flex h-10 w-10 items-center justify-center text-sm text-muted-foreground" 57 + aria-hidden="true" 58 + > 59 + ... 60 + </span> 61 + ) : ( 62 + <Link 63 + key={page} 64 + href={pageUrl(baseUrl, page)} 65 + aria-current={page === currentPage ? 'page' : undefined} 66 + aria-label={`Page ${page}`} 67 + className={cn( 68 + 'inline-flex h-10 w-10 items-center justify-center rounded-md text-sm font-medium transition-colors', 69 + 'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', 70 + page === currentPage 71 + ? 'bg-primary text-primary-foreground' 72 + : 'text-muted-foreground hover:bg-card-hover hover:text-foreground' 73 + )} 74 + > 75 + {page} 76 + </Link> 77 + ) 78 + )} 79 + 80 + {/* Next */} 81 + {currentPage >= totalPages ? ( 82 + <span 83 + aria-label="Next page" 84 + aria-disabled="true" 85 + className="inline-flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground opacity-50" 86 + > 87 + <CaretRight className="h-4 w-4" weight="bold" aria-hidden="true" /> 88 + </span> 89 + ) : ( 90 + <Link 91 + href={pageUrl(baseUrl, currentPage + 1)} 92 + aria-label="Next page" 93 + className={cn( 94 + 'inline-flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors', 95 + 'hover:bg-card-hover hover:text-foreground', 96 + 'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2' 97 + )} 98 + > 99 + <CaretRight className="h-4 w-4" weight="bold" aria-hidden="true" /> 100 + </Link> 101 + )} 102 + </nav> 103 + ) 104 + } 105 + 106 + /** 107 + * Generates page numbers with ellipsis for large page counts. 108 + * Always shows first, last, and pages around current. 109 + */ 110 + function getPageNumbers(currentPage: number, totalPages: number): (number | '...')[] { 111 + if (totalPages <= 7) { 112 + return Array.from({ length: totalPages }, (_, i) => i + 1) 113 + } 114 + 115 + const pages: (number | '...')[] = [1] 116 + 117 + if (currentPage > 3) { 118 + pages.push('...') 119 + } 120 + 121 + const start = Math.max(2, currentPage - 1) 122 + const end = Math.min(totalPages - 1, currentPage + 1) 123 + 124 + for (let i = start; i <= end; i++) { 125 + pages.push(i) 126 + } 127 + 128 + if (currentPage < totalPages - 2) { 129 + pages.push('...') 130 + } 131 + 132 + pages.push(totalPages) 133 + 134 + return pages 135 + }
+46
src/components/topic-card.test.tsx
··· 1 + import { describe, it, expect } from 'vitest' 2 + import { render, screen } from '@testing-library/react' 3 + import { axe } from 'vitest-axe' 4 + import { TopicCard } from './topic-card' 5 + import { mockTopics } from '@/mocks/data' 6 + 7 + const topic = mockTopics[0]! 8 + 9 + describe('TopicCard', () => { 10 + it('renders topic title as a link', () => { 11 + render(<TopicCard topic={topic} />) 12 + const link = screen.getByRole('link', { name: topic.title }) 13 + expect(link).toBeInTheDocument() 14 + }) 15 + 16 + it('renders author handle', () => { 17 + render(<TopicCard topic={topic} />) 18 + expect(screen.getByText(topic.authorDid)).toBeInTheDocument() 19 + }) 20 + 21 + it('renders category', () => { 22 + render(<TopicCard topic={topic} />) 23 + expect(screen.getByText(topic.category)).toBeInTheDocument() 24 + }) 25 + 26 + it('renders reply count', () => { 27 + render(<TopicCard topic={topic} />) 28 + expect(screen.getByText(String(topic.replyCount))).toBeInTheDocument() 29 + }) 30 + 31 + it('renders reaction count', () => { 32 + render(<TopicCard topic={topic} />) 33 + expect(screen.getByText(String(topic.reactionCount))).toBeInTheDocument() 34 + }) 35 + 36 + it('renders as an article element', () => { 37 + render(<TopicCard topic={topic} />) 38 + expect(screen.getByRole('article')).toBeInTheDocument() 39 + }) 40 + 41 + it('passes axe accessibility check', async () => { 42 + const { container } = render(<TopicCard topic={topic} />) 43 + const results = await axe(container) 44 + expect(results).toHaveNoViolations() 45 + }) 46 + })
+87
src/components/topic-card.tsx
··· 1 + /** 2 + * TopicCard - Displays a single topic in a list view. 3 + * Shows title, author, category, reply/reaction counts, last activity. 4 + * @see specs/prd-web.md Section 4 (Topic Components) 5 + */ 6 + 7 + import Link from 'next/link' 8 + import { ChatCircle, Heart, Clock } from '@phosphor-icons/react/dist/ssr' 9 + import type { Topic } from '@/lib/api/types' 10 + import { cn } from '@/lib/utils' 11 + import { formatRelativeTime } from '@/lib/format' 12 + 13 + interface TopicCardProps { 14 + topic: Topic 15 + className?: string 16 + } 17 + 18 + export function TopicCard({ topic, className }: TopicCardProps) { 19 + const topicUrl = `/t/${topic.rkey}` 20 + 21 + return ( 22 + <article 23 + className={cn( 24 + 'flex items-start gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-card-hover', 25 + className 26 + )} 27 + aria-labelledby={`topic-title-${topic.rkey}`} 28 + > 29 + {/* Content */} 30 + <div className="min-w-0 flex-1"> 31 + {/* Title */} 32 + <h3 id={`topic-title-${topic.rkey}`} className="mb-1"> 33 + <Link 34 + href={topicUrl} 35 + className="text-base font-semibold text-foreground hover:text-primary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded-sm" 36 + > 37 + {topic.title} 38 + </Link> 39 + </h3> 40 + 41 + {/* Metadata */} 42 + <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground"> 43 + {/* Author */} 44 + <span>{topic.authorDid}</span> 45 + 46 + {/* Category */} 47 + <Link 48 + href={`/c/${topic.category}`} 49 + className="rounded-full bg-primary-muted px-2 py-0.5 text-xs font-medium text-primary transition-colors hover:bg-primary hover:text-primary-foreground" 50 + > 51 + {topic.category} 52 + </Link> 53 + 54 + {/* Tags */} 55 + {topic.tags?.map((tag) => ( 56 + <Link 57 + key={tag} 58 + href={`/tag/${tag}`} 59 + className="text-xs text-muted-foreground hover:text-foreground" 60 + > 61 + #{tag} 62 + </Link> 63 + ))} 64 + </div> 65 + </div> 66 + 67 + {/* Stats */} 68 + <div className="flex shrink-0 items-center gap-4 text-sm text-muted-foreground"> 69 + <span className="flex items-center gap-1" aria-label={`${topic.replyCount} replies`}> 70 + <ChatCircle className="h-4 w-4" weight="regular" aria-hidden="true" /> 71 + {topic.replyCount} 72 + </span> 73 + <span className="flex items-center gap-1" aria-label={`${topic.reactionCount} reactions`}> 74 + <Heart className="h-4 w-4" weight="regular" aria-hidden="true" /> 75 + {topic.reactionCount} 76 + </span> 77 + <span 78 + className="hidden items-center gap-1 sm:flex" 79 + aria-label={`Last activity ${formatRelativeTime(topic.lastActivityAt)}`} 80 + > 81 + <Clock className="h-4 w-4" weight="regular" aria-hidden="true" /> 82 + {formatRelativeTime(topic.lastActivityAt)} 83 + </span> 84 + </div> 85 + </article> 86 + ) 87 + }
+29
src/components/topic-list.test.tsx
··· 1 + import { describe, it, expect } from 'vitest' 2 + import { render, screen } from '@testing-library/react' 3 + import { TopicList } from './topic-list' 4 + import { mockTopics } from '@/mocks/data' 5 + 6 + describe('TopicList', () => { 7 + it('renders all topics', () => { 8 + render(<TopicList topics={mockTopics} />) 9 + const articles = screen.getAllByRole('article') 10 + expect(articles).toHaveLength(mockTopics.length) 11 + }) 12 + 13 + it('renders topic titles', () => { 14 + render(<TopicList topics={mockTopics} />) 15 + for (const topic of mockTopics) { 16 + expect(screen.getByRole('link', { name: topic.title })).toBeInTheDocument() 17 + } 18 + }) 19 + 20 + it('renders empty state when no topics', () => { 21 + render(<TopicList topics={[]} />) 22 + expect(screen.getByText(/no topics yet/i)).toBeInTheDocument() 23 + }) 24 + 25 + it('renders with heading', () => { 26 + render(<TopicList topics={mockTopics} heading="Recent Topics" />) 27 + expect(screen.getByRole('heading', { name: 'Recent Topics' })).toBeInTheDocument() 28 + }) 29 + })
+34
src/components/topic-list.tsx
··· 1 + /** 2 + * TopicList - Paginated list of TopicCard components. 3 + * Renders topics with optional heading and empty state. 4 + * @see specs/prd-web.md Section 4 (Topic Components) 5 + */ 6 + 7 + import type { Topic } from '@/lib/api/types' 8 + import { TopicCard } from './topic-card' 9 + 10 + interface TopicListProps { 11 + topics: Topic[] 12 + heading?: string 13 + } 14 + 15 + export function TopicList({ topics, heading }: TopicListProps) { 16 + return ( 17 + <section> 18 + {heading && <h2 className="mb-4 text-xl font-semibold text-foreground">{heading}</h2>} 19 + {topics.length === 0 ? ( 20 + <div className="rounded-lg border border-border bg-card p-8 text-center"> 21 + <p className="text-muted-foreground"> 22 + No topics yet. Be the first to start a discussion! 23 + </p> 24 + </div> 25 + ) : ( 26 + <div className="space-y-3"> 27 + {topics.map((topic) => ( 28 + <TopicCard key={topic.uri} topic={topic} /> 29 + ))} 30 + </div> 31 + )} 32 + </section> 33 + ) 34 + }
+28
src/hooks/use-focus-on-navigate.ts
··· 1 + /** 2 + * Focus management for client-side navigation. 3 + * Next.js does NOT move focus after route transitions. 4 + * This hook moves focus to the main content area after navigation. 5 + * @see WCAG 2.4.3 (Focus Order), standards/frontend.md 6 + */ 7 + 8 + 'use client' 9 + 10 + import { usePathname } from 'next/navigation' 11 + import { useEffect, useRef } from 'react' 12 + 13 + export function useFocusOnNavigate() { 14 + const pathname = usePathname() 15 + const previousPathname = useRef(pathname) 16 + 17 + useEffect(() => { 18 + if (previousPathname.current !== pathname) { 19 + previousPathname.current = pathname 20 + 21 + // Focus the main content area after navigation 22 + const main = document.getElementById('main-content') 23 + if (main) { 24 + main.focus({ preventScroll: false }) 25 + } 26 + } 27 + }, [pathname]) 28 + }
+83
src/lib/api/client.test.ts
··· 1 + import { describe, it, expect } from 'vitest' 2 + import { getCategories, getCategoryBySlug, getTopics, ApiError } from './client' 3 + import { http, HttpResponse } from 'msw' 4 + import { server } from '@/mocks/server' 5 + import { mockCategories } from '@/mocks/data' 6 + 7 + const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000' 8 + 9 + describe('API client', () => { 10 + describe('getCategories', () => { 11 + it('returns category tree', async () => { 12 + const result = await getCategories() 13 + expect(result.categories).toHaveLength(mockCategories.length) 14 + expect(result.categories[0]!.name).toBe('General Discussion') 15 + }) 16 + 17 + it('includes nested children', async () => { 18 + const result = await getCategories() 19 + const dev = result.categories.find((c) => c.slug === 'development') 20 + expect(dev?.children).toHaveLength(2) 21 + expect(dev?.children[0]!.name).toBe('Frontend') 22 + }) 23 + }) 24 + 25 + describe('getCategoryBySlug', () => { 26 + it('returns a single category with topic count', async () => { 27 + const result = await getCategoryBySlug('general') 28 + expect(result.name).toBe('General Discussion') 29 + expect(result.topicCount).toBeGreaterThan(0) 30 + }) 31 + 32 + it('throws ApiError for unknown slug', async () => { 33 + await expect(getCategoryBySlug('nonexistent')).rejects.toThrow(ApiError) 34 + }) 35 + }) 36 + 37 + describe('getTopics', () => { 38 + it('returns paginated topics', async () => { 39 + const result = await getTopics() 40 + expect(result.topics.length).toBeGreaterThan(0) 41 + expect(result.topics[0]).toHaveProperty('uri') 42 + expect(result.topics[0]).toHaveProperty('title') 43 + }) 44 + 45 + it('filters by category', async () => { 46 + const result = await getTopics({ category: 'general' }) 47 + for (const topic of result.topics) { 48 + expect(topic.category).toBe('general') 49 + } 50 + }) 51 + 52 + it('respects limit parameter', async () => { 53 + const result = await getTopics({ limit: 2 }) 54 + expect(result.topics.length).toBeLessThanOrEqual(2) 55 + }) 56 + }) 57 + 58 + describe('error handling', () => { 59 + it('throws ApiError on server error', async () => { 60 + server.use( 61 + http.get(`${API_URL}/api/categories`, () => { 62 + return HttpResponse.json({ error: 'Internal server error' }, { status: 500 }) 63 + }) 64 + ) 65 + await expect(getCategories()).rejects.toThrow(ApiError) 66 + }) 67 + 68 + it('includes status code in ApiError', async () => { 69 + server.use( 70 + http.get(`${API_URL}/api/categories`, () => { 71 + return HttpResponse.json({ error: 'Not found' }, { status: 404 }) 72 + }) 73 + ) 74 + try { 75 + await getCategories() 76 + expect.fail('Should have thrown') 77 + } catch (error) { 78 + expect(error).toBeInstanceOf(ApiError) 79 + expect((error as ApiError).status).toBe(404) 80 + } 81 + }) 82 + }) 83 + })
+132
src/lib/api/client.ts
··· 1 + /** 2 + * Type-safe API client for barazo-api. 3 + * Server-side: uses fetch directly with the internal API URL. 4 + * Client-side: uses fetch with the public API URL. 5 + */ 6 + 7 + import type { 8 + CategoriesResponse, 9 + CategoryWithTopicCount, 10 + CommunitySettings, 11 + CommunityStats, 12 + TopicsResponse, 13 + RepliesResponse, 14 + PaginationParams, 15 + } from './types' 16 + 17 + const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000' 18 + 19 + interface FetchOptions { 20 + headers?: Record<string, string> 21 + signal?: AbortSignal 22 + } 23 + 24 + class ApiError extends Error { 25 + constructor( 26 + public readonly status: number, 27 + message: string 28 + ) { 29 + super(message) 30 + this.name = 'ApiError' 31 + } 32 + } 33 + 34 + async function apiFetch<T>(path: string, options: FetchOptions = {}): Promise<T> { 35 + const url = `${API_URL}${path}` 36 + const response = await fetch(url, { 37 + headers: { 38 + 'Content-Type': 'application/json', 39 + ...options.headers, 40 + }, 41 + signal: options.signal, 42 + }) 43 + 44 + if (!response.ok) { 45 + const body = await response.text().catch(() => 'Unknown error') 46 + throw new ApiError(response.status, `API ${response.status}: ${body}`) 47 + } 48 + 49 + return response.json() as Promise<T> 50 + } 51 + 52 + function buildQuery(params: Record<string, string | number | undefined>): string { 53 + const entries = Object.entries(params).filter( 54 + (entry): entry is [string, string | number] => entry[1] !== undefined 55 + ) 56 + if (entries.length === 0) return '' 57 + return '?' + new URLSearchParams(entries.map(([k, v]) => [k, String(v)])).toString() 58 + } 59 + 60 + // --- Category endpoints --- 61 + 62 + export function getCategories(options?: FetchOptions): Promise<CategoriesResponse> { 63 + return apiFetch<CategoriesResponse>('/api/categories', options) 64 + } 65 + 66 + export function getCategoryBySlug( 67 + slug: string, 68 + options?: FetchOptions 69 + ): Promise<CategoryWithTopicCount> { 70 + return apiFetch<CategoryWithTopicCount>(`/api/categories/${encodeURIComponent(slug)}`, options) 71 + } 72 + 73 + // --- Topic endpoints --- 74 + 75 + export interface GetTopicsParams extends PaginationParams { 76 + category?: string 77 + sort?: 'latest' | 'popular' 78 + } 79 + 80 + export function getTopics( 81 + params: GetTopicsParams = {}, 82 + options?: FetchOptions 83 + ): Promise<TopicsResponse> { 84 + const query = buildQuery({ 85 + limit: params.limit, 86 + cursor: params.cursor, 87 + category: params.category, 88 + sort: params.sort, 89 + }) 90 + return apiFetch<TopicsResponse>(`/api/topics${query}`, options) 91 + } 92 + 93 + // --- Reply endpoints --- 94 + 95 + export function getReplies( 96 + topicUri: string, 97 + params: PaginationParams = {}, 98 + options?: FetchOptions 99 + ): Promise<RepliesResponse> { 100 + const query = buildQuery({ 101 + limit: params.limit, 102 + cursor: params.cursor, 103 + }) 104 + return apiFetch<RepliesResponse>( 105 + `/api/topics/${encodeURIComponent(topicUri)}/replies${query}`, 106 + options 107 + ) 108 + } 109 + 110 + // --- Community endpoints --- 111 + 112 + export function getCommunitySettings(options?: FetchOptions): Promise<CommunitySettings> { 113 + return apiFetch<CommunitySettings>('/api/admin/settings', { 114 + ...options, 115 + headers: { ...options?.headers }, 116 + }) 117 + } 118 + 119 + export function getCommunityStats( 120 + accessToken: string, 121 + options?: FetchOptions 122 + ): Promise<CommunityStats> { 123 + return apiFetch<CommunityStats>('/api/admin/stats', { 124 + ...options, 125 + headers: { 126 + ...options?.headers, 127 + Authorization: `Bearer ${accessToken}`, 128 + }, 129 + }) 130 + } 131 + 132 + export { ApiError }
+181
src/lib/api/types.ts
··· 1 + /** 2 + * API response types matching barazo-api schemas. 3 + * These mirror the Zod-validated responses from the API. 4 + * @see ~/Documents/Git/barazo-forum/barazo-api/src/routes/ 5 + */ 6 + 7 + // --- Categories --- 8 + 9 + export interface Category { 10 + id: string 11 + slug: string 12 + name: string 13 + description: string | null 14 + parentId: string | null 15 + sortOrder: number 16 + communityDid: string 17 + maturityRating: MaturityRating 18 + createdAt: string 19 + updatedAt: string 20 + } 21 + 22 + export interface CategoryTreeNode extends Category { 23 + children: CategoryTreeNode[] 24 + } 25 + 26 + export interface CategoryWithTopicCount extends Category { 27 + topicCount: number 28 + } 29 + 30 + export interface CategoriesResponse { 31 + categories: CategoryTreeNode[] 32 + } 33 + 34 + // --- Topics --- 35 + 36 + export interface Topic { 37 + uri: string 38 + rkey: string 39 + authorDid: string 40 + title: string 41 + content: string 42 + contentFormat: string | null 43 + category: string 44 + tags: string[] | null 45 + communityDid: string 46 + cid: string 47 + replyCount: number 48 + reactionCount: number 49 + lastActivityAt: string 50 + createdAt: string 51 + indexedAt: string 52 + } 53 + 54 + export interface TopicsResponse { 55 + topics: Topic[] 56 + cursor: string | null 57 + } 58 + 59 + // --- Replies --- 60 + 61 + export interface Reply { 62 + uri: string 63 + rkey: string 64 + authorDid: string 65 + content: string 66 + contentFormat: string | null 67 + rootUri: string 68 + rootCid: string 69 + parentUri: string 70 + parentCid: string 71 + communityDid: string 72 + cid: string 73 + depth: number 74 + reactionCount: number 75 + createdAt: string 76 + indexedAt: string 77 + } 78 + 79 + export interface RepliesResponse { 80 + replies: Reply[] 81 + cursor: string | null 82 + } 83 + 84 + // --- Reactions --- 85 + 86 + export interface Reaction { 87 + uri: string 88 + rkey: string 89 + authorDid: string 90 + subjectUri: string 91 + subjectCid: string 92 + type: string 93 + communityDid: string 94 + cid: string 95 + createdAt: string 96 + } 97 + 98 + export interface ReactionsResponse { 99 + reactions: Reaction[] 100 + cursor: string | null 101 + } 102 + 103 + // --- Search --- 104 + 105 + export interface SearchResult { 106 + type: 'topic' | 'reply' 107 + uri: string 108 + rkey: string 109 + authorDid: string 110 + title: string | null 111 + content: string 112 + category: string | null 113 + communityDid: string 114 + replyCount: number | null 115 + reactionCount: number 116 + createdAt: string 117 + rank: number 118 + rootUri: string | null 119 + rootTitle: string | null 120 + } 121 + 122 + export interface SearchResponse { 123 + results: SearchResult[] 124 + cursor: string | null 125 + total: number 126 + searchMode: 'fulltext' | 'hybrid' 127 + } 128 + 129 + // --- Community --- 130 + 131 + export interface CommunitySettings { 132 + id: string 133 + initialized: boolean 134 + communityDid: string | null 135 + adminDid: string | null 136 + communityName: string 137 + maturityRating: MaturityRating 138 + reactionSet: string[] 139 + communityDescription: string | null 140 + communityLogoUrl: string | null 141 + primaryColor: string | null 142 + accentColor: string | null 143 + createdAt: string 144 + updatedAt: string 145 + } 146 + 147 + export interface CommunityStats { 148 + topicCount: number 149 + replyCount: number 150 + userCount: number 151 + categoryCount: number 152 + reportCount: number 153 + recentTopics: number 154 + recentReplies: number 155 + recentUsers: number 156 + } 157 + 158 + // --- Auth --- 159 + 160 + export interface AuthSession { 161 + accessToken: string 162 + expiresAt: string 163 + did: string 164 + handle: string 165 + } 166 + 167 + export interface AuthUser { 168 + did: string 169 + handle: string 170 + } 171 + 172 + // --- Shared --- 173 + 174 + export type MaturityRating = 'safe' | 'mature' | 'adult' 175 + 176 + // --- Pagination --- 177 + 178 + export interface PaginationParams { 179 + limit?: number 180 + cursor?: string 181 + }
+39
src/lib/format.ts
··· 1 + /** 2 + * Formatting utilities for display values. 3 + */ 4 + 5 + /** 6 + * Formats an ISO date string as a relative time (e.g., "2h ago", "3d ago"). 7 + */ 8 + export function formatRelativeTime(isoDate: string): string { 9 + const date = new Date(isoDate) 10 + const now = new Date() 11 + const diffMs = now.getTime() - date.getTime() 12 + const diffSeconds = Math.floor(diffMs / 1000) 13 + 14 + if (diffSeconds < 60) return 'just now' 15 + 16 + const diffMinutes = Math.floor(diffSeconds / 60) 17 + if (diffMinutes < 60) return `${diffMinutes}m ago` 18 + 19 + const diffHours = Math.floor(diffMinutes / 60) 20 + if (diffHours < 24) return `${diffHours}h ago` 21 + 22 + const diffDays = Math.floor(diffHours / 24) 23 + if (diffDays < 30) return `${diffDays}d ago` 24 + 25 + const diffMonths = Math.floor(diffDays / 30) 26 + if (diffMonths < 12) return `${diffMonths}mo ago` 27 + 28 + const diffYears = Math.floor(diffMonths / 12) 29 + return `${diffYears}y ago` 30 + } 31 + 32 + /** 33 + * Formats a number with compact notation (e.g., 1.2k, 3.4M). 34 + */ 35 + export function formatCompactNumber(n: number): string { 36 + if (n < 1000) return String(n) 37 + if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k` 38 + return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M` 39 + }
+200
src/mocks/data.ts
··· 1 + /** 2 + * Mock data for testing. Realistic AT Protocol forum data 3 + * matching barazo-api response schemas. 4 + */ 5 + 6 + import type { CategoryTreeNode, CategoryWithTopicCount, Topic } from '@/lib/api/types' 7 + 8 + const COMMUNITY_DID = 'did:plc:test-community-123' 9 + const NOW = '2026-02-14T12:00:00.000Z' 10 + const YESTERDAY = '2026-02-13T12:00:00.000Z' 11 + const TWO_DAYS_AGO = '2026-02-12T12:00:00.000Z' 12 + 13 + // --- Users --- 14 + 15 + export const mockUsers = [ 16 + { did: 'did:plc:user-alice-001', handle: 'alice.bsky.social' }, 17 + { did: 'did:plc:user-bob-002', handle: 'bob.bsky.social' }, 18 + { did: 'did:plc:user-carol-003', handle: 'carol.example.com' }, 19 + { did: 'did:plc:user-dave-004', handle: 'dave.bsky.social' }, 20 + { did: 'did:plc:user-eve-005', handle: 'eve.forum.example' }, 21 + ] as const 22 + 23 + // --- Categories --- 24 + 25 + export const mockCategories: CategoryTreeNode[] = [ 26 + { 27 + id: 'cat-general', 28 + slug: 'general', 29 + name: 'General Discussion', 30 + description: 'Talk about anything related to the community', 31 + parentId: null, 32 + sortOrder: 0, 33 + communityDid: COMMUNITY_DID, 34 + maturityRating: 'safe', 35 + createdAt: TWO_DAYS_AGO, 36 + updatedAt: TWO_DAYS_AGO, 37 + children: [], 38 + }, 39 + { 40 + id: 'cat-dev', 41 + slug: 'development', 42 + name: 'Development', 43 + description: 'Technical discussions, code, and project updates', 44 + parentId: null, 45 + sortOrder: 1, 46 + communityDid: COMMUNITY_DID, 47 + maturityRating: 'safe', 48 + createdAt: TWO_DAYS_AGO, 49 + updatedAt: TWO_DAYS_AGO, 50 + children: [ 51 + { 52 + id: 'cat-frontend', 53 + slug: 'frontend', 54 + name: 'Frontend', 55 + description: 'UI, UX, and frontend frameworks', 56 + parentId: 'cat-dev', 57 + sortOrder: 0, 58 + communityDid: COMMUNITY_DID, 59 + maturityRating: 'safe', 60 + createdAt: TWO_DAYS_AGO, 61 + updatedAt: TWO_DAYS_AGO, 62 + children: [], 63 + }, 64 + { 65 + id: 'cat-backend', 66 + slug: 'backend', 67 + name: 'Backend', 68 + description: 'APIs, databases, and server infrastructure', 69 + parentId: 'cat-dev', 70 + sortOrder: 1, 71 + communityDid: COMMUNITY_DID, 72 + maturityRating: 'safe', 73 + createdAt: TWO_DAYS_AGO, 74 + updatedAt: TWO_DAYS_AGO, 75 + children: [], 76 + }, 77 + ], 78 + }, 79 + { 80 + id: 'cat-feedback', 81 + slug: 'feedback', 82 + name: 'Feedback & Ideas', 83 + description: 'Suggestions and feature requests', 84 + parentId: null, 85 + sortOrder: 2, 86 + communityDid: COMMUNITY_DID, 87 + maturityRating: 'safe', 88 + createdAt: TWO_DAYS_AGO, 89 + updatedAt: TWO_DAYS_AGO, 90 + children: [], 91 + }, 92 + { 93 + id: 'cat-meta', 94 + slug: 'meta', 95 + name: 'Meta', 96 + description: 'About this community', 97 + parentId: null, 98 + sortOrder: 3, 99 + communityDid: COMMUNITY_DID, 100 + maturityRating: 'safe', 101 + createdAt: TWO_DAYS_AGO, 102 + updatedAt: TWO_DAYS_AGO, 103 + children: [], 104 + }, 105 + ] 106 + 107 + export const mockCategoryWithTopicCount: CategoryWithTopicCount = { 108 + ...mockCategories[0]!, 109 + topicCount: 12, 110 + } 111 + 112 + // --- Topics --- 113 + 114 + export const mockTopics: Topic[] = [ 115 + { 116 + uri: `at://${mockUsers[0]!.did}/forum.barazo.topic.post/3kf1abc`, 117 + rkey: '3kf1abc', 118 + authorDid: mockUsers[0]!.did, 119 + title: 'Welcome to Barazo Forums', 120 + content: 'This is the first topic on our new federated forum platform.', 121 + contentFormat: null, 122 + category: 'general', 123 + tags: ['welcome', 'introduction'], 124 + communityDid: COMMUNITY_DID, 125 + cid: 'bafyreib1', 126 + replyCount: 5, 127 + reactionCount: 12, 128 + lastActivityAt: NOW, 129 + createdAt: TWO_DAYS_AGO, 130 + indexedAt: TWO_DAYS_AGO, 131 + }, 132 + { 133 + uri: `at://${mockUsers[1]!.did}/forum.barazo.topic.post/3kf2def`, 134 + rkey: '3kf2def', 135 + authorDid: mockUsers[1]!.did, 136 + title: 'Building with the AT Protocol', 137 + content: 'A deep dive into building applications on the AT Protocol.', 138 + contentFormat: null, 139 + category: 'development', 140 + tags: ['atproto', 'tutorial'], 141 + communityDid: COMMUNITY_DID, 142 + cid: 'bafyreib2', 143 + replyCount: 8, 144 + reactionCount: 23, 145 + lastActivityAt: YESTERDAY, 146 + createdAt: TWO_DAYS_AGO, 147 + indexedAt: TWO_DAYS_AGO, 148 + }, 149 + { 150 + uri: `at://${mockUsers[2]!.did}/forum.barazo.topic.post/3kf3ghi`, 151 + rkey: '3kf3ghi', 152 + authorDid: mockUsers[2]!.did, 153 + title: 'Feature Request: Dark Mode Improvements', 154 + content: 'I would love to see more theme customization options.', 155 + contentFormat: null, 156 + category: 'feedback', 157 + tags: ['feature-request', 'ui'], 158 + communityDid: COMMUNITY_DID, 159 + cid: 'bafyreib3', 160 + replyCount: 3, 161 + reactionCount: 7, 162 + lastActivityAt: YESTERDAY, 163 + createdAt: YESTERDAY, 164 + indexedAt: YESTERDAY, 165 + }, 166 + { 167 + uri: `at://${mockUsers[3]!.did}/forum.barazo.topic.post/3kf4jkl`, 168 + rkey: '3kf4jkl', 169 + authorDid: mockUsers[3]!.did, 170 + title: 'Understanding Portable Identity', 171 + content: 'How does identity work across federated services?', 172 + contentFormat: null, 173 + category: 'general', 174 + tags: ['identity', 'federation'], 175 + communityDid: COMMUNITY_DID, 176 + cid: 'bafyreib4', 177 + replyCount: 15, 178 + reactionCount: 31, 179 + lastActivityAt: NOW, 180 + createdAt: YESTERDAY, 181 + indexedAt: YESTERDAY, 182 + }, 183 + { 184 + uri: `at://${mockUsers[4]!.did}/forum.barazo.topic.post/3kf5mno`, 185 + rkey: '3kf5mno', 186 + authorDid: mockUsers[4]!.did, 187 + title: 'Self-Hosting Guide', 188 + content: 'Step-by-step guide to running your own Barazo instance.', 189 + contentFormat: null, 190 + category: 'meta', 191 + tags: ['self-hosting', 'guide'], 192 + communityDid: COMMUNITY_DID, 193 + cid: 'bafyreib5', 194 + replyCount: 2, 195 + reactionCount: 9, 196 + lastActivityAt: NOW, 197 + createdAt: NOW, 198 + indexedAt: NOW, 199 + }, 200 + ]
+68
src/mocks/handlers.ts
··· 1 + /** 2 + * MSW request handlers for API mocking during tests. 3 + * Tier 3: Hand-written handlers matching barazo-api response shapes. 4 + * @see plans/2026-02-09-mvp-implementation.md API Mock Strategy 5 + */ 6 + 7 + import { http, HttpResponse } from 'msw' 8 + import { mockCategories, mockCategoryWithTopicCount, mockTopics } from './data' 9 + 10 + const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000' 11 + 12 + export const handlers = [ 13 + // GET /api/categories 14 + http.get(`${API_URL}/api/categories`, () => { 15 + return HttpResponse.json({ categories: mockCategories }) 16 + }), 17 + 18 + // GET /api/categories/:slug 19 + http.get(`${API_URL}/api/categories/:slug`, ({ params }) => { 20 + const slug = params['slug'] as string 21 + const findCategory = ( 22 + nodes: typeof mockCategories 23 + ): (typeof mockCategories)[number] | undefined => { 24 + for (const node of nodes) { 25 + if (node.slug === slug) return node 26 + const found = findCategory(node.children) 27 + if (found) return found 28 + } 29 + return undefined 30 + } 31 + const category = findCategory(mockCategories) 32 + if (!category) { 33 + return HttpResponse.json({ error: 'Category not found' }, { status: 404 }) 34 + } 35 + return HttpResponse.json({ ...category, topicCount: mockCategoryWithTopicCount.topicCount }) 36 + }), 37 + 38 + // GET /api/topics 39 + http.get(`${API_URL}/api/topics`, ({ request }) => { 40 + const url = new URL(request.url) 41 + const category = url.searchParams.get('category') 42 + const limitParam = url.searchParams.get('limit') 43 + const limit = limitParam ? parseInt(limitParam, 10) : 20 44 + 45 + let topics = mockTopics 46 + if (category) { 47 + topics = topics.filter((t) => t.category === category) 48 + } 49 + 50 + const limited = topics.slice(0, limit) 51 + const hasMore = topics.length > limit 52 + 53 + return HttpResponse.json({ 54 + topics: limited, 55 + cursor: hasMore ? 'mock-cursor-next' : null, 56 + }) 57 + }), 58 + 59 + // GET /api/topics/:uri 60 + http.get(`${API_URL}/api/topics/:uri`, ({ params }) => { 61 + const uri = decodeURIComponent(params['uri'] as string) 62 + const topic = mockTopics.find((t) => t.uri === uri) 63 + if (!topic) { 64 + return HttpResponse.json({ error: 'Topic not found' }, { status: 404 }) 65 + } 66 + return HttpResponse.json(topic) 67 + }), 68 + ]
+8
src/mocks/server.ts
··· 1 + /** 2 + * MSW server setup for Node.js test environment. 3 + */ 4 + 5 + import { setupServer } from 'msw/node' 6 + import { handlers } from './handlers' 7 + 8 + export const server = setupServer(...handlers)
+11 -1
src/test/setup.ts
··· 1 - // Test setup file 1 + import { expect, afterAll, afterEach, beforeAll } from 'vitest' 2 + import * as matchers from '@testing-library/jest-dom/matchers' 3 + import * as axeMatchers from 'vitest-axe/matchers' 4 + import { server } from '@/mocks/server' 5 + 6 + expect.extend(matchers) 7 + expect.extend(axeMatchers) 8 + 9 + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) 10 + afterEach(() => server.resetHandlers()) 11 + afterAll(() => server.close())
+9
src/test/vitest-axe.d.ts
··· 1 + import 'vitest' 2 + import type { AxeMatchers } from 'vitest-axe/matchers' 3 + 4 + declare module 'vitest' { 5 + // eslint-disable-next-line @typescript-eslint/no-empty-object-type 6 + interface Assertion extends AxeMatchers {} 7 + // eslint-disable-next-line @typescript-eslint/no-empty-object-type 8 + interface AsymmetricMatchersContaining extends AxeMatchers {} 9 + }
+1
tsconfig.json
··· 13 13 "isolatedModules": true, 14 14 "jsx": "react-jsx", 15 15 "incremental": true, 16 + "types": ["vitest/globals", "@testing-library/jest-dom"], 16 17 "plugins": [ 17 18 { 18 19 "name": "next"