From 56e91e1f7c2d46b4c8ef11705fed22e2734f641e Mon Sep 17 00:00:00 2001 From: Guido X Jansen Date: Sat, 14 Feb 2026 10:50:39 +0100 Subject: [PATCH] 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). --- .github/workflows/ci.yml | 22 +- next.config.ts | 19 +- src/app/c/[slug]/page.test.tsx | 101 +++++++++ src/app/c/[slug]/page.tsx | 103 ++++++++++ src/app/page.test.tsx | 86 ++++++++ src/app/page.tsx | 214 ++++++++------------ src/components/breadcrumbs.test.tsx | 57 ++++++ src/components/breadcrumbs.tsx | 66 ++++++ src/components/category-nav.test.tsx | 43 ++++ src/components/category-nav.tsx | 82 ++++++++ src/components/layout/forum-layout.test.tsx | 99 +++++++++ src/components/layout/forum-layout.tsx | 123 +++++++++++ src/components/pagination.test.tsx | 52 +++++ src/components/pagination.tsx | 135 ++++++++++++ src/components/topic-card.test.tsx | 46 +++++ src/components/topic-card.tsx | 87 ++++++++ src/components/topic-list.test.tsx | 29 +++ src/components/topic-list.tsx | 34 ++++ src/hooks/use-focus-on-navigate.ts | 28 +++ src/lib/api/client.test.ts | 83 ++++++++ src/lib/api/client.ts | 132 ++++++++++++ src/lib/api/types.ts | 181 +++++++++++++++++ src/lib/format.ts | 39 ++++ src/mocks/data.ts | 200 ++++++++++++++++++ src/mocks/handlers.ts | 68 +++++++ src/mocks/server.ts | 8 + src/test/setup.ts | 12 +- src/test/vitest-axe.d.ts | 9 + tsconfig.json | 1 + 29 files changed, 2018 insertions(+), 141 deletions(-) create mode 100644 src/app/c/[slug]/page.test.tsx create mode 100644 src/app/c/[slug]/page.tsx create mode 100644 src/app/page.test.tsx create mode 100644 src/components/breadcrumbs.test.tsx create mode 100644 src/components/breadcrumbs.tsx create mode 100644 src/components/category-nav.test.tsx create mode 100644 src/components/category-nav.tsx create mode 100644 src/components/layout/forum-layout.test.tsx create mode 100644 src/components/layout/forum-layout.tsx create mode 100644 src/components/pagination.test.tsx create mode 100644 src/components/pagination.tsx create mode 100644 src/components/topic-card.test.tsx create mode 100644 src/components/topic-card.tsx create mode 100644 src/components/topic-list.test.tsx create mode 100644 src/components/topic-list.tsx create mode 100644 src/hooks/use-focus-on-navigate.ts create mode 100644 src/lib/api/client.test.ts create mode 100644 src/lib/api/client.ts create mode 100644 src/lib/api/types.ts create mode 100644 src/lib/format.ts create mode 100644 src/mocks/data.ts create mode 100644 src/mocks/handlers.ts create mode 100644 src/mocks/server.ts create mode 100644 src/test/vitest-axe.d.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71026e9..9cfb1e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,7 +115,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: build - path: dist/ + path: | + .next/standalone/ + .next/static/ retention-days: 7 accessibility: @@ -143,17 +145,29 @@ jobs: - name: Build application run: pnpm build + - name: Prepare standalone server + run: | + cp -r .next/static .next/standalone/.next/static + cp -r public .next/standalone/public + - name: Install axe-core CLI run: pnpm add -g @axe-core/cli - name: Sync Chrome and ChromeDriver versions run: npx browser-driver-manager install chrome - - name: Serve build - run: npx serve dist -l 3000 & + - name: Start standalone server + run: node .next/standalone/server.js & + env: + PORT: '3000' + HOSTNAME: '0.0.0.0' - name: Wait for server - run: sleep 5 + run: | + for i in $(seq 1 30); do + curl -s http://localhost:3000 > /dev/null 2>&1 && break + sleep 1 + done - name: Run axe accessibility check run: axe http://localhost:3000 --exit diff --git a/next.config.ts b/next.config.ts index 404e929..0001eb1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,16 +2,25 @@ import type { NextConfig } from 'next' /** * Next.js Configuration for Barazo Web + * Uses standalone output for Docker deployment with SSR. * @see https://nextjs.org/docs/api-reference/next.config.js/introduction */ const nextConfig: NextConfig = { - // Static export for Docker deployment - output: 'export', - distDir: 'dist', + // Standalone output for Docker (includes Node.js server) + output: 'standalone', - // Image optimization (static export requires unoptimized images) + // Image optimization images: { - unoptimized: true, + remotePatterns: [ + { + protocol: 'https', + hostname: '*.bsky.social', + }, + { + protocol: 'https', + hostname: 'cdn.bsky.app', + }, + ], }, // Trailing slashes for SEO consistency diff --git a/src/app/c/[slug]/page.test.tsx b/src/app/c/[slug]/page.test.tsx new file mode 100644 index 0000000..3d63d9d --- /dev/null +++ b/src/app/c/[slug]/page.test.tsx @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' + +// Mock API client +vi.mock('@/lib/api/client', () => ({ + getCategoryBySlug: vi.fn(), + getCategories: vi.fn(), + getTopics: vi.fn(), +})) + +// Mock next-themes +vi.mock('next-themes', () => ({ + useTheme: () => ({ theme: 'dark', setTheme: vi.fn() }), + ThemeProvider: ({ children }: { children: React.ReactNode }) => children, +})) + +// Mock next/image +vi.mock('next/image', () => ({ + default: (props: Record) => { + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text + return + }, +})) + +// Mock next/link +vi.mock('next/link', () => ({ + default: ({ + children, + href, + ...props + }: { children: React.ReactNode; href: string } & Record) => ( + + {children} + + ), +})) + +import { getCategoryBySlug, getCategories, getTopics } from '@/lib/api/client' +import { mockCategories, mockCategoryWithTopicCount, mockTopics } from '@/mocks/data' +import CategoryPage from './page' + +const mockGetCategoryBySlug = vi.mocked(getCategoryBySlug) +const mockGetCategories = vi.mocked(getCategories) +const mockGetTopics = vi.mocked(getTopics) + +beforeEach(() => { + mockGetCategoryBySlug.mockResolvedValue(mockCategoryWithTopicCount) + mockGetCategories.mockResolvedValue({ categories: mockCategories }) + mockGetTopics.mockResolvedValue({ + topics: mockTopics.filter((t) => t.category === 'general'), + cursor: null, + }) +}) + +const params = Promise.resolve({ slug: 'general' }) + +describe('CategoryPage', () => { + it('renders category name as heading', async () => { + const page = await CategoryPage({ params }) + render(page) + expect( + screen.getByRole('heading', { level: 1, name: mockCategoryWithTopicCount.name }) + ).toBeInTheDocument() + }) + + it('renders category description', async () => { + const page = await CategoryPage({ params }) + render(page) + expect(screen.getByText(mockCategoryWithTopicCount.description!)).toBeInTheDocument() + }) + + it('renders breadcrumbs', async () => { + const page = await CategoryPage({ params }) + render(page) + expect(screen.getByRole('navigation', { name: /breadcrumb/i })).toBeInTheDocument() + }) + + it('renders topic list for category', async () => { + const page = await CategoryPage({ params }) + render(page) + const articles = screen.getAllByRole('article') + expect(articles.length).toBeGreaterThan(0) + }) + + it('renders topic count', async () => { + const page = await CategoryPage({ params }) + render(page) + expect(screen.getByText(`${mockCategoryWithTopicCount.topicCount} topics`)).toBeInTheDocument() + }) + + it('includes JSON-LD BreadcrumbList', async () => { + const page = await CategoryPage({ params }) + const { container } = render(page) + const scripts = container.querySelectorAll('script[type="application/ld+json"]') + const breadcrumbScript = Array.from(scripts).find((s) => { + const data = JSON.parse(s.textContent!) + return data['@type'] === 'BreadcrumbList' + }) + expect(breadcrumbScript).toBeTruthy() + }) +}) diff --git a/src/app/c/[slug]/page.tsx b/src/app/c/[slug]/page.tsx new file mode 100644 index 0000000..8947258 --- /dev/null +++ b/src/app/c/[slug]/page.tsx @@ -0,0 +1,103 @@ +/** + * Category page - Shows topics for a specific category. + * URL: /c/{slug} + * Server-side rendered with SEO metadata and JSON-LD. + * @see specs/prd-web.md Section 3.1 + */ + +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { getCategoryBySlug, getCategories, getTopics, ApiError } from '@/lib/api/client' + +export const dynamic = 'force-dynamic' +import { ForumLayout } from '@/components/layout/forum-layout' +import { TopicList } from '@/components/topic-list' +import { CategoryNav } from '@/components/category-nav' +import { Breadcrumbs } from '@/components/breadcrumbs' +import { Pagination } from '@/components/pagination' + +interface CategoryPageProps { + params: Promise<{ slug: string }> + searchParams?: Promise<{ page?: string }> +} + +export async function generateMetadata({ params }: CategoryPageProps): Promise { + const { slug } = await params + try { + const category = await getCategoryBySlug(slug) + return { + title: category.name, + description: category.description ?? `Topics in ${category.name}`, + openGraph: { + title: category.name, + description: category.description ?? `Topics in ${category.name}`, + type: 'website', + }, + } + } catch { + return { title: 'Category Not Found' } + } +} + +const TOPICS_PER_PAGE = 20 + +export default async function CategoryPage({ params, searchParams }: CategoryPageProps) { + const { slug } = await params + const resolvedSearchParams = searchParams ? await searchParams : {} + const page = Math.max(1, parseInt(resolvedSearchParams.page ?? '1', 10) || 1) + + let category + try { + category = await getCategoryBySlug(slug) + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + notFound() + } + throw error + } + + const [categoriesResult, topicsResult] = await Promise.all([ + getCategories(), + getTopics({ + category: slug, + limit: TOPICS_PER_PAGE, + }), + ]) + + const totalPages = Math.max(1, Math.ceil(category.topicCount / TOPICS_PER_PAGE)) + + const breadcrumbItems = [ + { label: 'Home', href: '/' }, + { label: category.name, href: `/c/${slug}` }, + ] + + return ( + } + > + {/* Breadcrumbs (includes JSON-LD BreadcrumbList) */} + + + {/* Category header */} +
+

{category.name}

+ {category.description && ( +

{category.description}

+ )} +

+ {category.topicCount} {category.topicCount === 1 ? 'topic' : 'topics'} +

+
+ + {/* Topic list */} + + + {/* Pagination */} + {totalPages > 1 && ( +
+ +
+ )} +
+ ) +} diff --git a/src/app/page.test.tsx b/src/app/page.test.tsx new file mode 100644 index 0000000..048372b --- /dev/null +++ b/src/app/page.test.tsx @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' + +// Mock API client +vi.mock('@/lib/api/client', () => ({ + getCategories: vi.fn(), + getTopics: vi.fn(), +})) + +// Mock next-themes +vi.mock('next-themes', () => ({ + useTheme: () => ({ theme: 'dark', setTheme: vi.fn() }), + ThemeProvider: ({ children }: { children: React.ReactNode }) => children, +})) + +// Mock next/image +vi.mock('next/image', () => ({ + default: (props: Record) => { + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text + return + }, +})) + +// Mock next/link +vi.mock('next/link', () => ({ + default: ({ + children, + href, + ...props + }: { children: React.ReactNode; href: string } & Record) => ( + + {children} + + ), +})) + +import { getCategories, getTopics } from '@/lib/api/client' +import { mockCategories, mockTopics } from '@/mocks/data' +import HomePage from './page' + +const mockGetCategories = vi.mocked(getCategories) +const mockGetTopics = vi.mocked(getTopics) + +beforeEach(() => { + mockGetCategories.mockResolvedValue({ categories: mockCategories }) + mockGetTopics.mockResolvedValue({ topics: mockTopics, cursor: null }) +}) + +describe('HomePage', () => { + it('renders page heading', async () => { + const page = await HomePage() + render(page) + expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument() + }) + + it('renders recent topics', async () => { + const page = await HomePage() + render(page) + expect(screen.getByText('Welcome to Barazo Forums')).toBeInTheDocument() + }) + + it('renders category navigation', async () => { + const page = await HomePage() + render(page) + const navs = screen.getAllByRole('navigation', { name: /categories/i }) + expect(navs.length).toBeGreaterThan(0) + }) + + it('renders category links', async () => { + const page = await HomePage() + render(page) + const generalLinks = screen.getAllByRole('link', { name: 'General Discussion' }) + expect(generalLinks.length).toBeGreaterThan(0) + const devLinks = screen.getAllByRole('link', { name: 'Development' }) + expect(devLinks.length).toBeGreaterThan(0) + }) + + it('renders JSON-LD structured data', async () => { + const page = await HomePage() + const { container } = render(page) + const script = container.querySelector('script[type="application/ld+json"]') + expect(script).toBeInTheDocument() + const jsonLd = JSON.parse(script!.textContent!) + expect(jsonLd['@type']).toBe('WebSite') + }) +}) diff --git a/src/app/page.tsx b/src/app/page.tsx index 9795e85..192554e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,143 +1,95 @@ -import { SkipLinks } from '@/components/skip-links' -import { ThemeToggle } from '@/components/theme-toggle' -import Image from 'next/image' +/** + * Homepage - Forum landing page. + * Shows recent topics, category sidebar, and community overview. + * Server-side rendered for SEO. + * @see specs/prd-web.md Section 3.1 + */ -export default function Home() { - return ( -
- +import type { Metadata } from 'next' +import { getCategories, getTopics } from '@/lib/api/client' +import { ForumLayout } from '@/components/layout/forum-layout' +import { TopicList } from '@/components/topic-list' +import { CategoryNav } from '@/components/category-nav' +import type { CategoriesResponse, TopicsResponse } from '@/lib/api/types' - {/* Header */} -
-
-
- Barazo - Barazo -
-
- -
-
-
+export const dynamic = 'force-dynamic' - {/* Main Content */} -
-
-
-

- Community Forums on the AT Protocol -

-

- Portable identity. User data ownership. Cross-community reputation. The forum platform - built for the decentralized web. -

-
+export const metadata: Metadata = { + title: 'Barazo - Community Forums on the AT Protocol', + description: + 'Federated community forums with portable identity, user data ownership, and cross-community reputation.', +} - {/* Design System Demo */} -
-

Design System Active

+export default async function HomePage() { + let categoriesResult: CategoriesResponse = { categories: [] } + let topicsResult: TopicsResponse = { topics: [], cursor: null } + let apiError = false - {/* Color Palette Demo */} -
-

- Color Palette (Radix Colors + Flexoki) -

-
-
-
- Primary -
-
-
- Secondary -
-
-
- Success -
-
-
- Warning -
-
-
- Error -
-
-
+ try { + ;[categoriesResult, topicsResult] = await Promise.all([ + getCategories(), + getTopics({ limit: 20, sort: 'latest' }), + ]) + } catch { + apiError = true + } - {/* Typography Demo */} -
-

- Typography (Source Sans 3) -

-
-

Heading 2XL - Bold

-

Heading XL - Semibold

-

Heading LG - Medium

-

Body text - Regular

-

Small text - Muted

-
-
+ const jsonLd = { + '@context': 'https://schema.org', + '@type': 'WebSite', + name: 'Barazo', + url: 'https://barazo.forum', + potentialAction: { + '@type': 'SearchAction', + target: { + '@type': 'EntryPoint', + urlTemplate: 'https://barazo.forum/search?q={search_term_string}', + }, + 'query-input': 'required name=search_term_string', + }, + } - {/* Code Font Demo */} -
-

- Monospace (Source Code Pro) -

- - const barazo = "AT Protocol Forum"; - -
+ return ( + 0 ? ( + + ) : undefined + } + > + {/* JSON-LD */} +