Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat(web): search input, results page, and header integration (M9) (#10)

Add full-text search with WAI-ARIA combobox pattern, search results
page with Suspense boundary, and SearchInput integrated into header.

authored by

Guido X Jansen and committed by
GitHub
c8ea09ee 4abf800e

+887 -11
+5
src/app/c/[slug]/page.test.tsx
··· 8 8 getTopics: vi.fn(), 9 9 })) 10 10 11 + // Mock next/navigation 12 + vi.mock('next/navigation', () => ({ 13 + useRouter: () => ({ push: vi.fn() }), 14 + })) 15 + 11 16 // Mock next-themes 12 17 vi.mock('next-themes', () => ({ 13 18 useTheme: () => ({ theme: 'dark', setTheme: vi.fn() }),
+5
src/app/page.test.tsx
··· 7 7 getTopics: vi.fn(), 8 8 })) 9 9 10 + // Mock next/navigation 11 + vi.mock('next/navigation', () => ({ 12 + useRouter: () => ({ push: vi.fn() }), 13 + })) 14 + 10 15 // Mock next-themes 11 16 vi.mock('next-themes', () => ({ 12 17 useTheme: () => ({ theme: 'dark', setTheme: vi.fn() }),
+181
src/app/search/page.test.tsx
··· 1 + /** 2 + * Tests for search results page. 3 + */ 4 + 5 + import { describe, it, expect, vi, beforeEach } from 'vitest' 6 + import { render, screen, act, cleanup } from '@testing-library/react' 7 + import { axe } from 'vitest-axe' 8 + import SearchPage from './page' 9 + 10 + // Mock next/navigation 11 + const mockPush = vi.fn() 12 + const mockSearchParams = new URLSearchParams() 13 + vi.mock('next/navigation', () => ({ 14 + useRouter: () => ({ push: mockPush }), 15 + useSearchParams: () => mockSearchParams, 16 + })) 17 + 18 + // Mock next-themes 19 + vi.mock('next-themes', () => ({ 20 + useTheme: () => ({ theme: 'dark', setTheme: vi.fn() }), 21 + ThemeProvider: ({ children }: { children: React.ReactNode }) => children, 22 + })) 23 + 24 + // Mock next/image 25 + vi.mock('next/image', () => ({ 26 + default: (props: Record<string, unknown>) => { 27 + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text 28 + return <img {...props} /> 29 + }, 30 + })) 31 + 32 + // Mock next/link 33 + vi.mock('next/link', () => ({ 34 + default: ({ 35 + children, 36 + href, 37 + ...props 38 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 39 + <a href={href} {...props}> 40 + {children} 41 + </a> 42 + ), 43 + })) 44 + 45 + // Mock API client 46 + vi.mock('@/lib/api/client', () => ({ 47 + searchContent: vi.fn(), 48 + })) 49 + 50 + import { searchContent } from '@/lib/api/client' 51 + 52 + const mockSearchContent = vi.mocked(searchContent) 53 + 54 + describe('SearchPage', () => { 55 + beforeEach(() => { 56 + vi.clearAllMocks() 57 + cleanup() 58 + // Reset search params to empty 59 + mockSearchParams.delete('q') 60 + }) 61 + 62 + it('renders search heading', () => { 63 + render(<SearchPage />) 64 + expect(screen.getByRole('heading', { level: 1, name: /search/i })).toBeInTheDocument() 65 + }) 66 + 67 + it('renders search input', () => { 68 + render(<SearchPage />) 69 + const comboboxes = screen.getAllByRole('combobox') 70 + // One in header, one on search page 71 + expect(comboboxes.length).toBeGreaterThanOrEqual(1) 72 + }) 73 + 74 + it('shows empty state when no query', () => { 75 + render(<SearchPage />) 76 + expect(screen.getByText(/enter a search term/i)).toBeInTheDocument() 77 + }) 78 + 79 + it('displays results from API', async () => { 80 + mockSearchParams.set('q', 'barazo') 81 + mockSearchContent.mockResolvedValue({ 82 + results: [ 83 + { 84 + type: 'topic', 85 + uri: 'at://did:plc:user/forum.barazo.topic.post/abc', 86 + rkey: 'abc', 87 + authorDid: 'did:plc:user', 88 + title: 'Welcome to Barazo', 89 + content: 'First topic on barazo forums.', 90 + category: 'general', 91 + communityDid: 'did:plc:community', 92 + replyCount: 5, 93 + reactionCount: 12, 94 + createdAt: '2026-02-14T00:00:00Z', 95 + rank: 0.95, 96 + rootUri: null, 97 + rootTitle: null, 98 + }, 99 + ], 100 + cursor: null, 101 + total: 1, 102 + searchMode: 'fulltext', 103 + }) 104 + 105 + render(<SearchPage />) 106 + 107 + await act(async () => { 108 + await new Promise((r) => setTimeout(r, 100)) 109 + }) 110 + 111 + expect(await screen.findByText('Welcome to Barazo')).toBeInTheDocument() 112 + expect(screen.getByText(/1 result/i)).toBeInTheDocument() 113 + }) 114 + 115 + it('shows no results message', async () => { 116 + mockSearchParams.set('q', 'nonexistent') 117 + mockSearchContent.mockResolvedValue({ 118 + results: [], 119 + cursor: null, 120 + total: 0, 121 + searchMode: 'fulltext', 122 + }) 123 + 124 + render(<SearchPage />) 125 + 126 + await act(async () => { 127 + await new Promise((r) => setTimeout(r, 100)) 128 + }) 129 + 130 + expect(await screen.findByText(/no results found/i)).toBeInTheDocument() 131 + }) 132 + 133 + it('displays reply results with root topic reference', async () => { 134 + mockSearchParams.set('q', 'reply') 135 + mockSearchContent.mockResolvedValue({ 136 + results: [ 137 + { 138 + type: 'reply', 139 + uri: 'at://did:plc:user/forum.barazo.reply.post/xyz', 140 + rkey: 'xyz', 141 + authorDid: 'did:plc:user', 142 + title: null, 143 + content: 'This is a reply about the topic.', 144 + category: null, 145 + communityDid: 'did:plc:community', 146 + replyCount: null, 147 + reactionCount: 4, 148 + createdAt: '2026-02-14T00:00:00Z', 149 + rank: 0.8, 150 + rootUri: 'at://did:plc:user/forum.barazo.topic.post/abc', 151 + rootTitle: 'Original Topic', 152 + }, 153 + ], 154 + cursor: null, 155 + total: 1, 156 + searchMode: 'fulltext', 157 + }) 158 + 159 + render(<SearchPage />) 160 + 161 + await act(async () => { 162 + await new Promise((r) => setTimeout(r, 100)) 163 + }) 164 + 165 + expect(await screen.findByText(/original topic/i)).toBeInTheDocument() 166 + // Verify the result type badge shows "reply" 167 + expect(screen.getByText('reply')).toBeInTheDocument() 168 + }) 169 + 170 + it('renders breadcrumbs', () => { 171 + render(<SearchPage />) 172 + const nav = screen.getByRole('navigation', { name: /breadcrumb/i }) 173 + expect(nav).toBeInTheDocument() 174 + }) 175 + 176 + it('passes axe accessibility check', async () => { 177 + const { container } = render(<SearchPage />) 178 + const results = await axe(container) 179 + expect(results).toHaveNoViolations() 180 + }) 181 + })
+195
src/app/search/page.tsx
··· 1 + /** 2 + * Search results page. 3 + * URL: /search?q={query} 4 + * Displays full-text search results with type indicators. 5 + * noindex per specs/prd-web.md robots.txt section. 6 + * @see specs/prd-web.md Section M9 7 + */ 8 + 9 + 'use client' 10 + 11 + import { Suspense, useState, useEffect, useCallback } from 'react' 12 + import { useSearchParams } from 'next/navigation' 13 + import Link from 'next/link' 14 + import { ChatCircle, Article, Heart } from '@phosphor-icons/react' 15 + import { ForumLayout } from '@/components/layout/forum-layout' 16 + import { Breadcrumbs } from '@/components/breadcrumbs' 17 + import { SearchInput } from '@/components/search-input' 18 + import { searchContent } from '@/lib/api/client' 19 + import type { SearchResult, SearchResponse } from '@/lib/api/types' 20 + 21 + export default function SearchPage() { 22 + return ( 23 + <ForumLayout> 24 + <div className="space-y-6"> 25 + <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Search' }]} /> 26 + 27 + <div className="space-y-4"> 28 + <h1 className="text-2xl font-bold text-foreground">Search</h1> 29 + 30 + <div className="max-w-lg"> 31 + <SearchInput placeholder="Search topics and replies..." /> 32 + </div> 33 + </div> 34 + 35 + <Suspense 36 + fallback={ 37 + <div className="animate-pulse space-y-4 py-4"> 38 + <div className="h-16 rounded bg-muted" /> 39 + <div className="h-16 rounded bg-muted" /> 40 + </div> 41 + } 42 + > 43 + <SearchResults /> 44 + </Suspense> 45 + </div> 46 + </ForumLayout> 47 + ) 48 + } 49 + 50 + function SearchResults() { 51 + const searchParams = useSearchParams() 52 + const initialQuery = searchParams.get('q') ?? '' 53 + 54 + const [results, setResults] = useState<SearchResult[]>([]) 55 + const [total, setTotal] = useState<number | null>(null) 56 + const [loading, setLoading] = useState(false) 57 + const [searched, setSearched] = useState(false) 58 + 59 + const performSearch = useCallback(async (q: string) => { 60 + if (!q) { 61 + setResults([]) 62 + setTotal(null) 63 + setSearched(false) 64 + return 65 + } 66 + 67 + setLoading(true) 68 + try { 69 + const response: SearchResponse = await searchContent({ q }) 70 + setResults(response.results) 71 + setTotal(response.total) 72 + setSearched(true) 73 + } catch { 74 + setResults([]) 75 + setTotal(0) 76 + setSearched(true) 77 + } finally { 78 + setLoading(false) 79 + } 80 + }, []) 81 + 82 + useEffect(() => { 83 + if (initialQuery) { 84 + void performSearch(initialQuery) 85 + } 86 + }, [initialQuery, performSearch]) 87 + 88 + const formatDate = (dateStr: string) => { 89 + return new Date(dateStr).toLocaleDateString('en-US', { 90 + year: 'numeric', 91 + month: 'short', 92 + day: 'numeric', 93 + }) 94 + } 95 + 96 + return ( 97 + <div aria-live="polite"> 98 + {loading && ( 99 + <div className="animate-pulse space-y-4 py-4"> 100 + <div className="h-16 rounded bg-muted" /> 101 + <div className="h-16 rounded bg-muted" /> 102 + </div> 103 + )} 104 + 105 + {!loading && !searched && !initialQuery && ( 106 + <p className="py-8 text-center text-muted-foreground"> 107 + Enter a search term to find topics and replies. 108 + </p> 109 + )} 110 + 111 + {!loading && searched && results.length === 0 && ( 112 + <p className="py-8 text-center text-muted-foreground"> 113 + No results found for &ldquo;{initialQuery}&rdquo;. Try a different search term. 114 + </p> 115 + )} 116 + 117 + {!loading && searched && results.length > 0 && ( 118 + <div className="space-y-4"> 119 + <p className="text-sm text-muted-foreground"> 120 + {total} result{total !== 1 ? 's' : ''} for &ldquo;{initialQuery}&rdquo; 121 + </p> 122 + 123 + <ul className="space-y-3"> 124 + {results.map((result) => ( 125 + <li key={result.uri}> 126 + <SearchResultCard result={result} formatDate={formatDate} /> 127 + </li> 128 + ))} 129 + </ul> 130 + </div> 131 + )} 132 + </div> 133 + ) 134 + } 135 + 136 + interface SearchResultCardProps { 137 + result: SearchResult 138 + formatDate: (dateStr: string) => string 139 + } 140 + 141 + function SearchResultCard({ result, formatDate }: SearchResultCardProps) { 142 + const isTopic = result.type === 'topic' 143 + const href = isTopic ? `/t/${result.category ?? '-'}/${result.rkey}` : `/t/-/${result.rkey}` 144 + 145 + return ( 146 + <article className="rounded-lg border border-border bg-card p-4 transition-colors hover:bg-card-hover"> 147 + <div className="flex items-start gap-3"> 148 + <div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted"> 149 + {isTopic ? ( 150 + <Article size={16} className="text-muted-foreground" aria-hidden="true" /> 151 + ) : ( 152 + <ChatCircle size={16} className="text-muted-foreground" aria-hidden="true" /> 153 + )} 154 + </div> 155 + <div className="min-w-0 flex-1"> 156 + <div className="flex items-center gap-2"> 157 + <span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground capitalize"> 158 + {result.type} 159 + </span> 160 + {result.category && ( 161 + <span className="text-xs text-muted-foreground">{result.category}</span> 162 + )} 163 + </div> 164 + 165 + <Link 166 + href={href} 167 + className="mt-1 block font-medium text-foreground hover:text-primary hover:underline" 168 + > 169 + {isTopic && result.title ? result.title : result.content.slice(0, 100)} 170 + </Link> 171 + 172 + {!isTopic && result.rootTitle && ( 173 + <p className="mt-1 text-xs text-muted-foreground"> 174 + In topic: <span className="font-medium">{result.rootTitle}</span> 175 + </p> 176 + )} 177 + 178 + <div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground"> 179 + <span>{formatDate(result.createdAt)}</span> 180 + <span className="flex items-center gap-1"> 181 + <Heart size={12} aria-hidden="true" /> 182 + {result.reactionCount} 183 + </span> 184 + {isTopic && result.replyCount !== null && ( 185 + <span className="flex items-center gap-1"> 186 + <ChatCircle size={12} aria-hidden="true" /> 187 + {result.replyCount} 188 + </span> 189 + )} 190 + </div> 191 + </div> 192 + </div> 193 + </article> 194 + ) 195 + }
+1
src/app/t/[slug]/[rkey]/page.test.tsx
··· 9 9 10 10 // Mock notFound 11 11 vi.mock('next/navigation', () => ({ 12 + useRouter: () => ({ push: vi.fn() }), 12 13 notFound: vi.fn(() => { 13 14 throw new Error('NEXT_NOT_FOUND') 14 15 }),
+15 -1
src/components/layout/forum-layout.test.tsx
··· 1 - import { describe, it, expect } from 'vitest' 1 + import { describe, it, expect, vi } from 'vitest' 2 2 import { render, screen } from '@testing-library/react' 3 3 import { axe } from 'vitest-axe' 4 4 import { ForumLayout } from './forum-layout' ··· 7 7 vi.mock('next-themes', () => ({ 8 8 useTheme: () => ({ theme: 'dark', setTheme: vi.fn() }), 9 9 ThemeProvider: ({ children }: { children: React.ReactNode }) => children, 10 + })) 11 + 12 + // Mock next/navigation 13 + vi.mock('next/navigation', () => ({ 14 + useRouter: () => ({ push: vi.fn() }), 10 15 })) 11 16 12 17 // Mock next/image ··· 84 89 </ForumLayout> 85 90 ) 86 91 expect(screen.getByText('Skip to main content')).toBeInTheDocument() 92 + }) 93 + 94 + it('renders search input in header', () => { 95 + render( 96 + <ForumLayout> 97 + <p>Content</p> 98 + </ForumLayout> 99 + ) 100 + expect(screen.getByRole('combobox')).toBeInTheDocument() 87 101 }) 88 102 89 103 it('passes axe accessibility check', async () => {
+3 -8
src/components/layout/forum-layout.tsx
··· 1 1 /** 2 2 * Forum Layout (CommunityLayout) 3 3 * Wraps all forum pages with header, sidebar, main content area, and footer. 4 - * Header: logo, search placeholder, theme toggle, user menu placeholder. 4 + * Header: logo, search, theme toggle, user menu placeholder. 5 5 * @see specs/prd-web.md Section 4 (Layout Components) 6 6 */ 7 7 ··· 9 9 import Image from 'next/image' 10 10 import { SkipLinks } from '@/components/skip-links' 11 11 import { ThemeToggle } from '@/components/theme-toggle' 12 + import { SearchInput } from '@/components/search-input' 12 13 import { MagnifyingGlass } from '@phosphor-icons/react/dist/ssr' 13 14 14 15 interface ForumLayoutProps { ··· 46 47 47 48 {/* Search */} 48 49 <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> 50 + <SearchInput className="w-full" /> 56 51 </div> 57 52 58 53 {/* Actions */}
+170
src/components/search-input.test.tsx
··· 1 + /** 2 + * Tests for SearchInput component. 3 + * WAI-ARIA Combobox pattern with result count via role="status". 4 + */ 5 + 6 + import { describe, it, expect, vi, beforeEach } from 'vitest' 7 + import { render, screen, act, cleanup } from '@testing-library/react' 8 + import userEvent from '@testing-library/user-event' 9 + import { axe } from 'vitest-axe' 10 + import { SearchInput } from './search-input' 11 + 12 + // Mock next/navigation 13 + const mockPush = vi.fn() 14 + vi.mock('next/navigation', () => ({ 15 + useRouter: () => ({ push: mockPush }), 16 + })) 17 + 18 + describe('SearchInput', () => { 19 + beforeEach(() => { 20 + vi.clearAllMocks() 21 + cleanup() 22 + }) 23 + 24 + it('renders with combobox role', () => { 25 + render(<SearchInput />) 26 + expect(screen.getByRole('combobox')).toBeInTheDocument() 27 + }) 28 + 29 + it('has correct aria attributes', () => { 30 + render(<SearchInput />) 31 + const input = screen.getByRole('combobox') 32 + expect(input).toHaveAttribute('aria-expanded', 'false') 33 + expect(input).toHaveAttribute('aria-autocomplete', 'list') 34 + expect(input).toHaveAttribute('aria-controls') 35 + }) 36 + 37 + it('shows listbox on typing when suggestions exist', async () => { 38 + const user = userEvent.setup() 39 + render( 40 + <SearchInput 41 + onSearch={vi.fn()} 42 + suggestions={[{ type: 'topic', title: 'Test result', rkey: '1' }]} 43 + /> 44 + ) 45 + const input = screen.getByRole('combobox') 46 + await user.type(input, 'test') 47 + 48 + // Wait for debounce 49 + await act(async () => { 50 + await new Promise((r) => setTimeout(r, 350)) 51 + }) 52 + 53 + expect(input).toHaveAttribute('aria-expanded', 'true') 54 + expect(screen.getByRole('listbox')).toBeInTheDocument() 55 + }) 56 + 57 + it('calls onSearch with query text', async () => { 58 + const onSearch = vi.fn() 59 + const user = userEvent.setup() 60 + render(<SearchInput onSearch={onSearch} />) 61 + const input = screen.getByRole('combobox') 62 + await user.type(input, 'barazo') 63 + 64 + // Wait for debounce 65 + await act(async () => { 66 + await new Promise((r) => setTimeout(r, 350)) 67 + }) 68 + 69 + expect(onSearch).toHaveBeenCalledWith('barazo') 70 + }) 71 + 72 + it('navigates to search page on Enter', async () => { 73 + const user = userEvent.setup() 74 + render(<SearchInput />) 75 + const input = screen.getByRole('combobox') 76 + await user.type(input, 'query') 77 + await user.keyboard('{Enter}') 78 + 79 + expect(mockPush).toHaveBeenCalledWith('/search?q=query') 80 + }) 81 + 82 + it('announces result count via role="status"', async () => { 83 + const user = userEvent.setup() 84 + render( 85 + <SearchInput 86 + onSearch={vi.fn()} 87 + suggestions={[ 88 + { type: 'topic', title: 'First result', rkey: '1' }, 89 + { type: 'topic', title: 'Second result', rkey: '2' }, 90 + ]} 91 + /> 92 + ) 93 + const input = screen.getByRole('combobox') 94 + await user.type(input, 'res') 95 + 96 + // Wait for debounce 97 + await act(async () => { 98 + await new Promise((r) => setTimeout(r, 350)) 99 + }) 100 + 101 + const status = screen.getByRole('status') 102 + expect(status).toHaveTextContent('2 results') 103 + }) 104 + 105 + it('closes suggestions on Escape', async () => { 106 + const user = userEvent.setup() 107 + render( 108 + <SearchInput 109 + onSearch={vi.fn()} 110 + suggestions={[{ type: 'topic', title: 'Result', rkey: '1' }]} 111 + /> 112 + ) 113 + const input = screen.getByRole('combobox') 114 + await user.type(input, 'test') 115 + 116 + await act(async () => { 117 + await new Promise((r) => setTimeout(r, 350)) 118 + }) 119 + 120 + expect(input).toHaveAttribute('aria-expanded', 'true') 121 + 122 + await user.keyboard('{Escape}') 123 + expect(input).toHaveAttribute('aria-expanded', 'false') 124 + }) 125 + 126 + it('navigates suggestions with arrow keys', async () => { 127 + const user = userEvent.setup() 128 + render( 129 + <SearchInput 130 + onSearch={vi.fn()} 131 + suggestions={[ 132 + { type: 'topic', title: 'First', rkey: '1' }, 133 + { type: 'topic', title: 'Second', rkey: '2' }, 134 + ]} 135 + /> 136 + ) 137 + const input = screen.getByRole('combobox') 138 + await user.type(input, 'test') 139 + 140 + await act(async () => { 141 + await new Promise((r) => setTimeout(r, 350)) 142 + }) 143 + 144 + await user.keyboard('{ArrowDown}') 145 + const options = screen.getAllByRole('option') 146 + expect(options[0]).toHaveAttribute('aria-selected', 'true') 147 + 148 + await user.keyboard('{ArrowDown}') 149 + expect(options[1]).toHaveAttribute('aria-selected', 'true') 150 + expect(options[0]).toHaveAttribute('aria-selected', 'false') 151 + }) 152 + 153 + it('clears input on clear button click', async () => { 154 + const user = userEvent.setup() 155 + render(<SearchInput />) 156 + const input = screen.getByRole('combobox') 157 + await user.type(input, 'query') 158 + expect(input).toHaveValue('query') 159 + 160 + const clearButton = screen.getByLabelText('Clear search') 161 + await user.click(clearButton) 162 + expect(input).toHaveValue('') 163 + }) 164 + 165 + it('passes axe accessibility check', async () => { 166 + const { container } = render(<SearchInput />) 167 + const results = await axe(container) 168 + expect(results).toHaveNoViolations() 169 + }) 170 + })
+198
src/components/search-input.tsx
··· 1 + /** 2 + * SearchInput - WAI-ARIA Combobox pattern with typeahead suggestions. 3 + * Result count announced via role="status". 4 + * @see specs/prd-web.md Section M9 (Search) 5 + */ 6 + 7 + 'use client' 8 + 9 + import { useState, useRef, useCallback, useEffect, useId } from 'react' 10 + import { useRouter } from 'next/navigation' 11 + import { MagnifyingGlass, X } from '@phosphor-icons/react' 12 + import { cn } from '@/lib/utils' 13 + 14 + export interface SearchSuggestion { 15 + type: 'topic' | 'reply' 16 + title: string 17 + rkey: string 18 + } 19 + 20 + interface SearchInputProps { 21 + onSearch?: (query: string) => void 22 + suggestions?: SearchSuggestion[] 23 + placeholder?: string 24 + className?: string 25 + } 26 + 27 + const DEBOUNCE_MS = 300 28 + 29 + export function SearchInput({ 30 + onSearch, 31 + suggestions = [], 32 + placeholder = 'Search topics...', 33 + className, 34 + }: SearchInputProps) { 35 + const router = useRouter() 36 + const id = useId() 37 + const listboxId = `${id}-listbox` 38 + const statusId = `${id}-status` 39 + 40 + const [query, setQuery] = useState('') 41 + const [isOpen, setIsOpen] = useState(false) 42 + const [activeIndex, setActiveIndex] = useState(-1) 43 + const inputRef = useRef<HTMLInputElement>(null) 44 + const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null) 45 + 46 + const hasQuery = query.length > 0 47 + const hasSuggestions = suggestions.length > 0 48 + const showListbox = isOpen && hasQuery && hasSuggestions 49 + 50 + useEffect(() => { 51 + return () => { 52 + if (debounceRef.current) clearTimeout(debounceRef.current) 53 + } 54 + }, []) 55 + 56 + const handleChange = useCallback( 57 + (value: string) => { 58 + setQuery(value) 59 + setActiveIndex(-1) 60 + 61 + if (debounceRef.current) clearTimeout(debounceRef.current) 62 + 63 + if (value.length > 0) { 64 + debounceRef.current = setTimeout(() => { 65 + setIsOpen(true) 66 + onSearch?.(value) 67 + }, DEBOUNCE_MS) 68 + } else { 69 + setIsOpen(false) 70 + } 71 + }, 72 + [onSearch] 73 + ) 74 + 75 + const handleKeyDown = useCallback( 76 + (e: React.KeyboardEvent) => { 77 + switch (e.key) { 78 + case 'Escape': 79 + setIsOpen(false) 80 + setActiveIndex(-1) 81 + break 82 + case 'Enter': 83 + if (activeIndex >= 0 && suggestions[activeIndex]) { 84 + router.push(`/t/-/${suggestions[activeIndex].rkey}`) 85 + } else if (query) { 86 + router.push(`/search?q=${encodeURIComponent(query)}`) 87 + } 88 + setIsOpen(false) 89 + break 90 + case 'ArrowDown': 91 + e.preventDefault() 92 + if (showListbox) { 93 + setActiveIndex((prev) => Math.min(prev + 1, suggestions.length - 1)) 94 + } 95 + break 96 + case 'ArrowUp': 97 + e.preventDefault() 98 + if (showListbox) { 99 + setActiveIndex((prev) => Math.max(prev - 1, 0)) 100 + } 101 + break 102 + } 103 + }, 104 + [activeIndex, suggestions, query, router, showListbox] 105 + ) 106 + 107 + const handleClear = useCallback(() => { 108 + setQuery('') 109 + setIsOpen(false) 110 + setActiveIndex(-1) 111 + inputRef.current?.focus() 112 + }, []) 113 + 114 + const activeDescendant = activeIndex >= 0 ? `${id}-option-${activeIndex}` : undefined 115 + 116 + return ( 117 + <div className={cn('relative', className)}> 118 + <div className="relative"> 119 + <MagnifyingGlass 120 + className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" 121 + weight="regular" 122 + aria-hidden="true" 123 + /> 124 + <input 125 + ref={inputRef} 126 + role="combobox" 127 + type="search" 128 + value={query} 129 + onChange={(e) => handleChange(e.target.value)} 130 + onKeyDown={handleKeyDown} 131 + onFocus={() => { 132 + if (hasQuery && hasSuggestions) setIsOpen(true) 133 + }} 134 + onBlur={() => { 135 + // Delay to allow click on suggestion 136 + setTimeout(() => setIsOpen(false), 150) 137 + }} 138 + placeholder={placeholder} 139 + aria-expanded={showListbox} 140 + aria-autocomplete="list" 141 + aria-controls={listboxId} 142 + aria-activedescendant={activeDescendant} 143 + aria-label="Search" 144 + className="h-9 w-full rounded-md border border-border bg-card pl-9 pr-8 text-sm text-foreground placeholder:text-muted-foreground transition-colors hover:bg-card-hover focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" 145 + /> 146 + {hasQuery && ( 147 + <button 148 + type="button" 149 + onClick={handleClear} 150 + aria-label="Clear search" 151 + className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring" 152 + > 153 + <X className="h-3.5 w-3.5" aria-hidden="true" /> 154 + </button> 155 + )} 156 + </div> 157 + 158 + {showListbox && ( 159 + <div 160 + id={listboxId} 161 + role="listbox" 162 + aria-label="Search suggestions" 163 + className="absolute left-0 right-0 top-full z-50 mt-1 max-h-60 overflow-auto rounded-md border border-border bg-card py-1 shadow-lg" 164 + > 165 + {suggestions.map((suggestion, index) => ( 166 + <div 167 + key={suggestion.rkey} 168 + id={`${id}-option-${index}`} 169 + role="option" 170 + tabIndex={-1} 171 + aria-selected={index === activeIndex} 172 + className={cn( 173 + 'cursor-pointer px-3 py-2 text-sm', 174 + index === activeIndex 175 + ? 'bg-primary/10 text-foreground' 176 + : 'text-foreground hover:bg-muted' 177 + )} 178 + onMouseDown={(e) => { 179 + e.preventDefault() 180 + router.push(`/t/-/${suggestion.rkey}`) 181 + setIsOpen(false) 182 + }} 183 + > 184 + <span className="font-medium">{suggestion.title}</span> 185 + <span className="ml-2 text-xs text-muted-foreground capitalize"> 186 + {suggestion.type} 187 + </span> 188 + </div> 189 + ))} 190 + </div> 191 + )} 192 + 193 + <div id={statusId} role="status" aria-live="polite" className="sr-only"> 194 + {showListbox ? `${suggestions.length} result${suggestions.length !== 1 ? 's' : ''}` : ''} 195 + </div> 196 + </div> 197 + ) 198 + }
+19
src/lib/api/client.ts
··· 14 14 TopicsResponse, 15 15 UpdateTopicInput, 16 16 RepliesResponse, 17 + SearchResponse, 17 18 PaginationParams, 18 19 } from './types' 19 20 ··· 149 150 `/api/topics/${encodeURIComponent(topicUri)}/replies${query}`, 150 151 options 151 152 ) 153 + } 154 + 155 + // --- Search endpoints --- 156 + 157 + export interface SearchParams extends PaginationParams { 158 + q: string 159 + } 160 + 161 + export function searchContent( 162 + params: SearchParams, 163 + options?: FetchOptions 164 + ): Promise<SearchResponse> { 165 + const query = buildQuery({ 166 + q: params.q, 167 + limit: params.limit, 168 + cursor: params.cursor, 169 + }) 170 + return apiFetch<SearchResponse>(`/api/search${query}`, options) 152 171 } 153 172 154 173 // --- Community endpoints ---
+62 -1
src/mocks/data.ts
··· 3 3 * matching barazo-api response schemas. 4 4 */ 5 5 6 - import type { CategoryTreeNode, CategoryWithTopicCount, Topic, Reply } from '@/lib/api/types' 6 + import type { 7 + CategoryTreeNode, 8 + CategoryWithTopicCount, 9 + Topic, 10 + Reply, 11 + SearchResult, 12 + } from '@/lib/api/types' 7 13 8 14 const COMMUNITY_DID = 'did:plc:test-community-123' 9 15 const NOW = '2026-02-14T12:00:00.000Z' ··· 203 209 204 210 const TOPIC_URI = mockTopics[0]!.uri 205 211 const TOPIC_CID = mockTopics[0]!.cid 212 + 213 + // --- Search Results --- 214 + 215 + export const mockSearchResults: SearchResult[] = [ 216 + { 217 + type: 'topic', 218 + uri: mockTopics[0]!.uri, 219 + rkey: mockTopics[0]!.rkey, 220 + authorDid: mockTopics[0]!.authorDid, 221 + title: 'Welcome to Barazo Forums', 222 + content: 'This is the first topic on our new federated forum platform.', 223 + category: 'general', 224 + communityDid: COMMUNITY_DID, 225 + replyCount: 5, 226 + reactionCount: 12, 227 + createdAt: TWO_DAYS_AGO, 228 + rank: 0.95, 229 + rootUri: null, 230 + rootTitle: null, 231 + }, 232 + { 233 + type: 'topic', 234 + uri: mockTopics[1]!.uri, 235 + rkey: mockTopics[1]!.rkey, 236 + authorDid: mockTopics[1]!.authorDid, 237 + title: 'Building with the AT Protocol', 238 + content: 'A deep dive into building applications on the AT Protocol.', 239 + category: 'development', 240 + communityDid: COMMUNITY_DID, 241 + replyCount: 8, 242 + reactionCount: 23, 243 + createdAt: TWO_DAYS_AGO, 244 + rank: 0.82, 245 + rootUri: null, 246 + rootTitle: null, 247 + }, 248 + { 249 + type: 'reply', 250 + uri: `at://${mockUsers[1]!.did}/forum.barazo.reply.post/3kf6aaa`, 251 + rkey: '3kf6aaa', 252 + authorDid: mockUsers[1]!.did, 253 + title: null, 254 + content: 'Welcome! Excited to see this forum take shape.', 255 + category: null, 256 + communityDid: COMMUNITY_DID, 257 + replyCount: null, 258 + reactionCount: 4, 259 + createdAt: TWO_DAYS_AGO, 260 + rank: 0.71, 261 + rootUri: mockTopics[0]!.uri, 262 + rootTitle: 'Welcome to Barazo Forums', 263 + }, 264 + ] 265 + 266 + // --- Replies --- 206 267 207 268 export const mockReplies: Reply[] = [ 208 269 {
+33 -1
src/mocks/handlers.ts
··· 5 5 */ 6 6 7 7 import { http, HttpResponse } from 'msw' 8 - import { mockCategories, mockCategoryWithTopicCount, mockTopics, mockReplies } from './data' 8 + import { 9 + mockCategories, 10 + mockCategoryWithTopicCount, 11 + mockTopics, 12 + mockReplies, 13 + mockSearchResults, 14 + } from './data' 9 15 10 16 const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000' 11 17 ··· 33 39 return HttpResponse.json({ error: 'Category not found' }, { status: 404 }) 34 40 } 35 41 return HttpResponse.json({ ...category, topicCount: mockCategoryWithTopicCount.topicCount }) 42 + }), 43 + 44 + // GET /api/search 45 + http.get(`${API_URL}/api/search`, ({ request }) => { 46 + const url = new URL(request.url) 47 + const q = url.searchParams.get('q') ?? '' 48 + const limitParam = url.searchParams.get('limit') 49 + const limit = limitParam ? parseInt(limitParam, 10) : 20 50 + 51 + const filtered = q 52 + ? mockSearchResults.filter( 53 + (r) => 54 + r.title?.toLowerCase().includes(q.toLowerCase()) || 55 + r.content.toLowerCase().includes(q.toLowerCase()) 56 + ) 57 + : [] 58 + 59 + const limited = filtered.slice(0, limit) 60 + const hasMore = filtered.length > limit 61 + 62 + return HttpResponse.json({ 63 + results: limited, 64 + cursor: hasMore ? 'mock-cursor-next' : null, 65 + total: filtered.length, 66 + searchMode: 'fulltext' as const, 67 + }) 36 68 }), 37 69 38 70 // GET /api/topics/by-rkey/:rkey (must be before :uri handler)