Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat(editor): add topic creation and edit pages (M6) (#7)

- MarkdownEditor with WAI-ARIA toolbar (bold, italic, link, code, quote, list)
with roving tabindex keyboard navigation
- MarkdownPreview with live sanitized preview
- TopicForm with title, category select, tag input, cross-post checkboxes
(Bluesky default ON, Frontpage default OFF), client-side validation
- New topic page at /new with breadcrumbs
- Edit topic page at /t/[slug]/[rkey]/edit with pre-populated form
- API client functions: createTopic, updateTopic with auth headers
- MSW handlers for POST /api/topics and PUT /api/topics/:rkey
- 39 new tests (157 total passing)

authored by

Guido X Jansen and committed by
GitHub
b74b093d c3bf145d

+1336
+1
package.json
··· 76 76 "@tailwindcss/postcss": "^4.0.0", 77 77 "@testing-library/jest-dom": "^6.6.3", 78 78 "@testing-library/react": "^16.1.0", 79 + "@testing-library/user-event": "^14.6.1", 79 80 "@types/node": "^22", 80 81 "@types/react": "^19", 81 82 "@types/react-dom": "^19",
+16
pnpm-lock.yaml
··· 152 152 '@testing-library/react': 153 153 specifier: ^16.1.0 154 154 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 155 + '@testing-library/user-event': 156 + specifier: ^14.6.1 157 + version: 14.6.1(@testing-library/dom@10.4.1) 155 158 '@types/node': 156 159 specifier: ^22 157 160 version: 22.19.11 ··· 2750 2753 optional: true 2751 2754 '@types/react-dom': 2752 2755 optional: true 2756 + 2757 + '@testing-library/user-event@14.6.1': 2758 + resolution: 2759 + { 2760 + integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==, 2761 + } 2762 + engines: { node: '>=12', npm: '>=6' } 2763 + peerDependencies: 2764 + '@testing-library/dom': '>=7.21.4' 2753 2765 2754 2766 '@tybys/wasm-util@0.10.1': 2755 2767 resolution: ··· 8762 8774 optionalDependencies: 8763 8775 '@types/react': 19.2.14 8764 8776 '@types/react-dom': 19.2.3(@types/react@19.2.14) 8777 + 8778 + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': 8779 + dependencies: 8780 + '@testing-library/dom': 10.4.1 8765 8781 8766 8782 '@tybys/wasm-util@0.10.1': 8767 8783 dependencies:
+47
src/app/new/page.test.tsx
··· 1 + /** 2 + * Tests for new topic page. 3 + */ 4 + 5 + import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import { setupServer } from 'msw/node' 8 + import { handlers } from '@/mocks/handlers' 9 + 10 + const server = setupServer(...handlers) 11 + 12 + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) 13 + afterEach(() => server.resetHandlers()) 14 + afterAll(() => server.close()) 15 + 16 + // Mock next/navigation 17 + vi.mock('next/navigation', () => ({ 18 + useRouter: () => ({ 19 + push: vi.fn(), 20 + replace: vi.fn(), 21 + back: vi.fn(), 22 + }), 23 + redirect: vi.fn(), 24 + })) 25 + 26 + describe('NewTopicPage', () => { 27 + it('renders create topic heading', async () => { 28 + const { default: NewTopicPage } = await import('./page') 29 + render(<NewTopicPage />) 30 + expect(screen.getByRole('heading', { name: 'Create New Topic' })).toBeInTheDocument() 31 + }) 32 + 33 + it('renders topic form', async () => { 34 + const { default: NewTopicPage } = await import('./page') 35 + render(<NewTopicPage />) 36 + expect(screen.getByLabelText('Title')).toBeInTheDocument() 37 + expect(screen.getByLabelText('Content')).toBeInTheDocument() 38 + expect(screen.getByRole('button', { name: 'Create Topic' })).toBeInTheDocument() 39 + }) 40 + 41 + it('renders breadcrumbs', async () => { 42 + const { default: NewTopicPage } = await import('./page') 43 + render(<NewTopicPage />) 44 + expect(screen.getByText('Home')).toBeInTheDocument() 45 + expect(screen.getByText('New Topic')).toBeInTheDocument() 46 + }) 47 + })
+59
src/app/new/page.tsx
··· 1 + /** 2 + * New topic page - Create a new forum topic. 3 + * URL: /new 4 + * Client component (requires auth context + form state). 5 + * @see specs/prd-web.md Section 3.2 6 + */ 7 + 8 + 'use client' 9 + 10 + import { useState } from 'react' 11 + import { useRouter } from 'next/navigation' 12 + import type { CreateTopicInput } from '@/lib/api/types' 13 + import { createTopic } from '@/lib/api/client' 14 + import { getTopicUrl } from '@/lib/format' 15 + import { ForumLayout } from '@/components/layout/forum-layout' 16 + import { Breadcrumbs } from '@/components/breadcrumbs' 17 + import { TopicForm } from '@/components/topic-form' 18 + 19 + export default function NewTopicPage() { 20 + const router = useRouter() 21 + const [submitting, setSubmitting] = useState(false) 22 + const [error, setError] = useState<string | null>(null) 23 + 24 + const handleSubmit = async (values: CreateTopicInput) => { 25 + setSubmitting(true) 26 + setError(null) 27 + 28 + try { 29 + // TODO: Get access token from auth context when auth is implemented 30 + const accessToken = '' 31 + const topic = await createTopic(values, accessToken) 32 + router.push(getTopicUrl(topic)) 33 + } catch (err) { 34 + setError(err instanceof Error ? err.message : 'Failed to create topic') 35 + setSubmitting(false) 36 + } 37 + } 38 + 39 + return ( 40 + <ForumLayout> 41 + <div className="space-y-6"> 42 + <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'New Topic' }]} /> 43 + 44 + <h1 className="text-2xl font-bold text-foreground">Create New Topic</h1> 45 + 46 + {error && ( 47 + <div 48 + className="rounded-md border border-destructive/50 bg-destructive/10 p-4" 49 + role="alert" 50 + > 51 + <p className="text-sm text-destructive">{error}</p> 52 + </div> 53 + )} 54 + 55 + <TopicForm onSubmit={handleSubmit} submitting={submitting} /> 56 + </div> 57 + </ForumLayout> 58 + ) 59 + }
+43
src/app/t/[slug]/[rkey]/edit/page.test.tsx
··· 1 + /** 2 + * Tests for edit topic page. 3 + */ 4 + 5 + import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import { setupServer } from 'msw/node' 8 + import { handlers } from '@/mocks/handlers' 9 + import EditTopicPage from './page' 10 + 11 + const server = setupServer(...handlers) 12 + 13 + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) 14 + afterEach(() => server.resetHandlers()) 15 + afterAll(() => server.close()) 16 + 17 + // Mock next/navigation 18 + vi.mock('next/navigation', () => ({ 19 + useRouter: () => ({ 20 + push: vi.fn(), 21 + replace: vi.fn(), 22 + back: vi.fn(), 23 + }), 24 + notFound: vi.fn(), 25 + redirect: vi.fn(), 26 + })) 27 + 28 + describe('EditTopicPage', () => { 29 + it('renders edit topic heading', async () => { 30 + render(<EditTopicPage params={{ slug: 'welcome-to-barazo-forums', rkey: '3kf1abc' }} />) 31 + expect(await screen.findByRole('heading', { name: 'Edit Topic' })).toBeInTheDocument() 32 + }) 33 + 34 + it('pre-populates form with topic data', async () => { 35 + render(<EditTopicPage params={{ slug: 'welcome-to-barazo-forums', rkey: '3kf1abc' }} />) 36 + expect(await screen.findByDisplayValue('Welcome to Barazo Forums')).toBeInTheDocument() 37 + }) 38 + 39 + it('shows save button', async () => { 40 + render(<EditTopicPage params={{ slug: 'welcome-to-barazo-forums', rkey: '3kf1abc' }} />) 41 + expect(await screen.findByRole('button', { name: 'Save Changes' })).toBeInTheDocument() 42 + }) 43 + })
+153
src/app/t/[slug]/[rkey]/edit/page.tsx
··· 1 + /** 2 + * Edit topic page - Edit an existing forum topic. 3 + * URL: /t/{slug}/{rkey}/edit 4 + * Client component (requires auth context + form state). 5 + * @see specs/prd-web.md Section 3.2 6 + */ 7 + 8 + 'use client' 9 + 10 + import { useState, useEffect } from 'react' 11 + import { useRouter } from 'next/navigation' 12 + import type { CreateTopicInput, Topic } from '@/lib/api/types' 13 + import { getTopicByRkey, updateTopic } from '@/lib/api/client' 14 + import { getTopicUrl } from '@/lib/format' 15 + import { ForumLayout } from '@/components/layout/forum-layout' 16 + import { Breadcrumbs } from '@/components/breadcrumbs' 17 + import { TopicForm } from '@/components/topic-form' 18 + 19 + interface EditTopicPageProps { 20 + params: Promise<{ slug: string; rkey: string }> | { slug: string; rkey: string } 21 + } 22 + 23 + export default function EditTopicPage({ params }: EditTopicPageProps) { 24 + const router = useRouter() 25 + const [rkey, setRkey] = useState<string | null>(null) 26 + const [topic, setTopic] = useState<Topic | null>(null) 27 + const [loading, setLoading] = useState(true) 28 + const [submitting, setSubmitting] = useState(false) 29 + const [error, setError] = useState<string | null>(null) 30 + 31 + // Resolve params (handles both Promise and plain object) 32 + useEffect(() => { 33 + async function resolveParams() { 34 + const resolved = params instanceof Promise ? await params : params 35 + setRkey(resolved.rkey) 36 + } 37 + void resolveParams() 38 + }, [params]) 39 + 40 + // Load topic once rkey is available 41 + useEffect(() => { 42 + if (!rkey) return 43 + 44 + let cancelled = false 45 + async function loadTopic() { 46 + try { 47 + const loaded = await getTopicByRkey(rkey!) 48 + if (!cancelled) { 49 + setTopic(loaded) 50 + setLoading(false) 51 + } 52 + } catch (err) { 53 + if (!cancelled) { 54 + setError(err instanceof Error ? err.message : 'Failed to load topic') 55 + setLoading(false) 56 + } 57 + } 58 + } 59 + void loadTopic() 60 + return () => { 61 + cancelled = true 62 + } 63 + }, [rkey]) 64 + 65 + const handleSubmit = async (values: CreateTopicInput) => { 66 + if (!rkey) return 67 + setSubmitting(true) 68 + setError(null) 69 + 70 + try { 71 + // TODO: Get access token from auth context when auth is implemented 72 + const accessToken = '' 73 + const updated = await updateTopic( 74 + rkey, 75 + { 76 + title: values.title, 77 + content: values.content, 78 + category: values.category, 79 + tags: values.tags, 80 + }, 81 + accessToken 82 + ) 83 + router.push(getTopicUrl(updated)) 84 + } catch (err) { 85 + setError(err instanceof Error ? err.message : 'Failed to update topic') 86 + setSubmitting(false) 87 + } 88 + } 89 + 90 + if (loading) { 91 + return ( 92 + <ForumLayout> 93 + <div className="space-y-6"> 94 + <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Loading...' }]} /> 95 + <p className="text-muted-foreground" aria-busy="true"> 96 + Loading topic... 97 + </p> 98 + </div> 99 + </ForumLayout> 100 + ) 101 + } 102 + 103 + if (!topic) { 104 + return ( 105 + <ForumLayout> 106 + <div className="space-y-6"> 107 + <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Error' }]} /> 108 + <p className="text-destructive" role="alert"> 109 + {error ?? 'Topic not found'} 110 + </p> 111 + </div> 112 + </ForumLayout> 113 + ) 114 + } 115 + 116 + return ( 117 + <ForumLayout> 118 + <div className="space-y-6"> 119 + <Breadcrumbs 120 + items={[ 121 + { label: 'Home', href: '/' }, 122 + { label: topic.category, href: `/c/${topic.category}` }, 123 + { label: topic.title, href: getTopicUrl(topic) }, 124 + { label: 'Edit' }, 125 + ]} 126 + /> 127 + 128 + <h1 className="text-2xl font-bold text-foreground">Edit Topic</h1> 129 + 130 + {error && ( 131 + <div 132 + className="rounded-md border border-destructive/50 bg-destructive/10 p-4" 133 + role="alert" 134 + > 135 + <p className="text-sm text-destructive">{error}</p> 136 + </div> 137 + )} 138 + 139 + <TopicForm 140 + onSubmit={handleSubmit} 141 + submitting={submitting} 142 + mode="edit" 143 + initialValues={{ 144 + title: topic.title, 145 + content: topic.content, 146 + category: topic.category, 147 + tags: topic.tags ?? undefined, 148 + }} 149 + /> 150 + </div> 151 + </ForumLayout> 152 + ) 153 + }
+142
src/components/markdown-editor.test.tsx
··· 1 + /** 2 + * Tests for MarkdownEditor component. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 8 + import { axe } from 'vitest-axe' 9 + import { MarkdownEditor } from './markdown-editor' 10 + 11 + describe('MarkdownEditor', () => { 12 + it('renders a labeled textarea', () => { 13 + render(<MarkdownEditor value="" onChange={vi.fn()} id="content" label="Content" />) 14 + expect(screen.getByRole('textbox', { name: 'Content' })).toBeInTheDocument() 15 + }) 16 + 17 + it('calls onChange when typing', async () => { 18 + const user = userEvent.setup() 19 + const onChange = vi.fn() 20 + render(<MarkdownEditor value="" onChange={onChange} id="content" label="Content" />) 21 + await user.type(screen.getByRole('textbox', { name: 'Content' }), 'Hello') 22 + expect(onChange).toHaveBeenCalled() 23 + }) 24 + 25 + it('renders toolbar with formatting buttons', () => { 26 + render(<MarkdownEditor value="" onChange={vi.fn()} id="content" label="Content" />) 27 + const toolbar = screen.getByRole('toolbar', { name: 'Formatting' }) 28 + expect(toolbar).toBeInTheDocument() 29 + expect(screen.getByRole('button', { name: 'Bold' })).toBeInTheDocument() 30 + expect(screen.getByRole('button', { name: 'Italic' })).toBeInTheDocument() 31 + expect(screen.getByRole('button', { name: 'Link' })).toBeInTheDocument() 32 + expect(screen.getByRole('button', { name: 'Code' })).toBeInTheDocument() 33 + expect(screen.getByRole('button', { name: 'Quote' })).toBeInTheDocument() 34 + expect(screen.getByRole('button', { name: 'List' })).toBeInTheDocument() 35 + }) 36 + 37 + it('wraps selected text with bold markers', async () => { 38 + const user = userEvent.setup() 39 + const onChange = vi.fn() 40 + render(<MarkdownEditor value="hello world" onChange={onChange} id="content" label="Content" />) 41 + const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement 42 + 43 + // Select "world" 44 + textarea.setSelectionRange(6, 11) 45 + await user.click(screen.getByRole('button', { name: 'Bold' })) 46 + expect(onChange).toHaveBeenCalledWith('hello **world**') 47 + }) 48 + 49 + it('wraps selected text with italic markers', async () => { 50 + const user = userEvent.setup() 51 + const onChange = vi.fn() 52 + render(<MarkdownEditor value="hello world" onChange={onChange} id="content" label="Content" />) 53 + const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement 54 + 55 + textarea.setSelectionRange(6, 11) 56 + await user.click(screen.getByRole('button', { name: 'Italic' })) 57 + expect(onChange).toHaveBeenCalledWith('hello *world*') 58 + }) 59 + 60 + it('inserts link template when no selection', async () => { 61 + const user = userEvent.setup() 62 + const onChange = vi.fn() 63 + render(<MarkdownEditor value="hello " onChange={onChange} id="content" label="Content" />) 64 + const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement 65 + 66 + textarea.setSelectionRange(6, 6) 67 + await user.click(screen.getByRole('button', { name: 'Link' })) 68 + expect(onChange).toHaveBeenCalledWith('hello [text](url)') 69 + }) 70 + 71 + it('wraps selected text with code markers', async () => { 72 + const user = userEvent.setup() 73 + const onChange = vi.fn() 74 + render(<MarkdownEditor value="hello code" onChange={onChange} id="content" label="Content" />) 75 + const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement 76 + 77 + textarea.setSelectionRange(6, 10) 78 + await user.click(screen.getByRole('button', { name: 'Code' })) 79 + expect(onChange).toHaveBeenCalledWith('hello `code`') 80 + }) 81 + 82 + it('prefixes line with quote marker', async () => { 83 + const user = userEvent.setup() 84 + const onChange = vi.fn() 85 + render(<MarkdownEditor value="hello" onChange={onChange} id="content" label="Content" />) 86 + const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement 87 + 88 + textarea.setSelectionRange(0, 5) 89 + await user.click(screen.getByRole('button', { name: 'Quote' })) 90 + expect(onChange).toHaveBeenCalledWith('> hello') 91 + }) 92 + 93 + it('prefixes line with list marker', async () => { 94 + const user = userEvent.setup() 95 + const onChange = vi.fn() 96 + render(<MarkdownEditor value="hello" onChange={onChange} id="content" label="Content" />) 97 + const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement 98 + 99 + textarea.setSelectionRange(0, 5) 100 + await user.click(screen.getByRole('button', { name: 'List' })) 101 + expect(onChange).toHaveBeenCalledWith('- hello') 102 + }) 103 + 104 + it('supports roving tabindex on toolbar buttons', async () => { 105 + const user = userEvent.setup() 106 + render(<MarkdownEditor value="" onChange={vi.fn()} id="content" label="Content" />) 107 + 108 + const boldBtn = screen.getByRole('button', { name: 'Bold' }) 109 + const italicBtn = screen.getByRole('button', { name: 'Italic' }) 110 + 111 + // First button is tabbable, others have tabindex -1 112 + expect(boldBtn).toHaveAttribute('tabindex', '0') 113 + expect(italicBtn).toHaveAttribute('tabindex', '-1') 114 + 115 + // Focus first button, arrow right to move focus 116 + boldBtn.focus() 117 + await user.keyboard('{ArrowRight}') 118 + expect(italicBtn).toHaveFocus() 119 + }) 120 + 121 + it('shows error message when provided', () => { 122 + render( 123 + <MarkdownEditor 124 + value="" 125 + onChange={vi.fn()} 126 + id="content" 127 + label="Content" 128 + error="Content is required" 129 + /> 130 + ) 131 + expect(screen.getByText('Content is required')).toBeInTheDocument() 132 + expect(screen.getByRole('textbox', { name: 'Content' })).toHaveAttribute('aria-invalid', 'true') 133 + }) 134 + 135 + it('passes axe accessibility check', async () => { 136 + const { container } = render( 137 + <MarkdownEditor value="Some content" onChange={vi.fn()} id="content" label="Content" /> 138 + ) 139 + const results = await axe(container) 140 + expect(results).toHaveNoViolations() 141 + }) 142 + })
+227
src/components/markdown-editor.tsx
··· 1 + /** 2 + * MarkdownEditor - Textarea with WAI-ARIA Toolbar for markdown formatting. 3 + * Supports bold, italic, link, code, quote, and list formatting. 4 + * Implements roving tabindex for toolbar keyboard navigation. 5 + * @see specs/prd-web.md Section 4 (Editor Components) 6 + */ 7 + 8 + 'use client' 9 + 10 + import { useRef, useState, useCallback } from 'react' 11 + import { TextB, TextItalic, Link as LinkIcon, Code, Quotes, List } from '@phosphor-icons/react' 12 + import { cn } from '@/lib/utils' 13 + 14 + interface ToolbarAction { 15 + label: string 16 + icon: typeof TextB 17 + apply: (value: string, start: number, end: number) => { result: string; cursor: number } 18 + } 19 + 20 + const TOOLBAR_ACTIONS: ToolbarAction[] = [ 21 + { 22 + label: 'Bold', 23 + icon: TextB, 24 + apply: (value, start, end) => { 25 + const selected = value.slice(start, end) 26 + const replacement = selected ? `**${selected}**` : '**text**' 27 + return { 28 + result: value.slice(0, start) + replacement + value.slice(end), 29 + cursor: selected ? start + replacement.length : start + 2, 30 + } 31 + }, 32 + }, 33 + { 34 + label: 'Italic', 35 + icon: TextItalic, 36 + apply: (value, start, end) => { 37 + const selected = value.slice(start, end) 38 + const replacement = selected ? `*${selected}*` : '*text*' 39 + return { 40 + result: value.slice(0, start) + replacement + value.slice(end), 41 + cursor: selected ? start + replacement.length : start + 1, 42 + } 43 + }, 44 + }, 45 + { 46 + label: 'Link', 47 + icon: LinkIcon, 48 + apply: (value, start, end) => { 49 + const selected = value.slice(start, end) 50 + const replacement = selected ? `[${selected}](url)` : '[text](url)' 51 + return { 52 + result: value.slice(0, start) + replacement + value.slice(end), 53 + cursor: selected ? start + selected.length + 3 : start + 1, 54 + } 55 + }, 56 + }, 57 + { 58 + label: 'Code', 59 + icon: Code, 60 + apply: (value, start, end) => { 61 + const selected = value.slice(start, end) 62 + const replacement = selected ? `\`${selected}\`` : '`code`' 63 + return { 64 + result: value.slice(0, start) + replacement + value.slice(end), 65 + cursor: selected ? start + replacement.length : start + 1, 66 + } 67 + }, 68 + }, 69 + { 70 + label: 'Quote', 71 + icon: Quotes, 72 + apply: (value, start, end) => { 73 + const selected = value.slice(start, end) 74 + const replacement = `> ${selected || 'quote'}` 75 + return { 76 + result: value.slice(0, start) + replacement + value.slice(end), 77 + cursor: start + replacement.length, 78 + } 79 + }, 80 + }, 81 + { 82 + label: 'List', 83 + icon: List, 84 + apply: (value, start, end) => { 85 + const selected = value.slice(start, end) 86 + const replacement = `- ${selected || 'item'}` 87 + return { 88 + result: value.slice(0, start) + replacement + value.slice(end), 89 + cursor: start + replacement.length, 90 + } 91 + }, 92 + }, 93 + ] 94 + 95 + interface MarkdownEditorProps { 96 + value: string 97 + onChange: (value: string) => void 98 + id: string 99 + label: string 100 + error?: string 101 + className?: string 102 + placeholder?: string 103 + } 104 + 105 + export function MarkdownEditor({ 106 + value, 107 + onChange, 108 + id, 109 + label, 110 + error, 111 + className, 112 + placeholder, 113 + }: MarkdownEditorProps) { 114 + const textareaRef = useRef<HTMLTextAreaElement>(null) 115 + const toolbarRef = useRef<HTMLDivElement>(null) 116 + const [focusedIndex, setFocusedIndex] = useState(0) 117 + 118 + const handleAction = useCallback( 119 + (action: ToolbarAction) => { 120 + const textarea = textareaRef.current 121 + if (!textarea) return 122 + 123 + const start = textarea.selectionStart 124 + const end = textarea.selectionEnd 125 + const { result, cursor } = action.apply(value, start, end) 126 + 127 + onChange(result) 128 + 129 + // Restore focus and cursor position after React re-render 130 + requestAnimationFrame(() => { 131 + textarea.focus() 132 + textarea.setSelectionRange(cursor, cursor) 133 + }) 134 + }, 135 + [value, onChange] 136 + ) 137 + 138 + const handleToolbarKeyDown = useCallback( 139 + (e: React.KeyboardEvent<HTMLDivElement>) => { 140 + const buttons = toolbarRef.current?.querySelectorAll<HTMLButtonElement>('button') 141 + if (!buttons?.length) return 142 + 143 + let newIndex = focusedIndex 144 + 145 + if (e.key === 'ArrowRight') { 146 + e.preventDefault() 147 + newIndex = (focusedIndex + 1) % buttons.length 148 + } else if (e.key === 'ArrowLeft') { 149 + e.preventDefault() 150 + newIndex = (focusedIndex - 1 + buttons.length) % buttons.length 151 + } else if (e.key === 'Home') { 152 + e.preventDefault() 153 + newIndex = 0 154 + } else if (e.key === 'End') { 155 + e.preventDefault() 156 + newIndex = buttons.length - 1 157 + } else { 158 + return 159 + } 160 + 161 + setFocusedIndex(newIndex) 162 + buttons[newIndex]?.focus() 163 + }, 164 + [focusedIndex] 165 + ) 166 + 167 + const errorId = error ? `${id}-error` : undefined 168 + 169 + return ( 170 + <div className={cn('space-y-1', className)}> 171 + <label htmlFor={id} className="block text-sm font-medium text-foreground"> 172 + {label} 173 + </label> 174 + 175 + {/* Toolbar */} 176 + <div 177 + ref={toolbarRef} 178 + role="toolbar" 179 + aria-label="Formatting" 180 + aria-controls={id} 181 + onKeyDown={handleToolbarKeyDown} 182 + className="flex items-center gap-0.5 rounded-t-md border border-b-0 border-border bg-muted/50 px-1 py-1" 183 + > 184 + {TOOLBAR_ACTIONS.map((action, index) => { 185 + const Icon = action.icon 186 + return ( 187 + <button 188 + key={action.label} 189 + type="button" 190 + aria-label={action.label} 191 + tabIndex={index === focusedIndex ? 0 : -1} 192 + onClick={() => handleAction(action)} 193 + onFocus={() => setFocusedIndex(index)} 194 + className="rounded p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" 195 + > 196 + <Icon className="h-4 w-4" weight="bold" aria-hidden="true" /> 197 + </button> 198 + ) 199 + })} 200 + </div> 201 + 202 + {/* Textarea */} 203 + <textarea 204 + ref={textareaRef} 205 + id={id} 206 + value={value} 207 + onChange={(e) => onChange(e.target.value)} 208 + placeholder={placeholder ?? 'Write your content using Markdown...'} 209 + aria-invalid={error ? 'true' : undefined} 210 + aria-describedby={errorId} 211 + className={cn( 212 + 'block w-full rounded-b-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 213 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 214 + 'min-h-[200px] resize-y font-mono', 215 + error && 'border-destructive' 216 + )} 217 + /> 218 + 219 + {/* Error message */} 220 + {error && ( 221 + <p id={errorId} className="text-sm text-destructive" role="alert"> 222 + {error} 223 + </p> 224 + )} 225 + </div> 226 + ) 227 + }
+37
src/components/markdown-preview.test.tsx
··· 1 + /** 2 + * Tests for MarkdownPreview component. 3 + */ 4 + 5 + import { describe, it, expect } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import { axe } from 'vitest-axe' 8 + import { MarkdownPreview } from './markdown-preview' 9 + 10 + describe('MarkdownPreview', () => { 11 + it('renders markdown content', () => { 12 + render(<MarkdownPreview content="**bold text**" />) 13 + expect(screen.getByText('bold text')).toBeInTheDocument() 14 + }) 15 + 16 + it('renders empty state when no content', () => { 17 + render(<MarkdownPreview content="" />) 18 + expect(screen.getByText('Nothing to preview')).toBeInTheDocument() 19 + }) 20 + 21 + it('has preview label', () => { 22 + render(<MarkdownPreview content="Hello" />) 23 + expect(screen.getByText('Preview')).toBeInTheDocument() 24 + }) 25 + 26 + it('renders links correctly', () => { 27 + render(<MarkdownPreview content="[Barazo](https://barazo.forum)" />) 28 + const link = screen.getByRole('link', { name: 'Barazo' }) 29 + expect(link).toHaveAttribute('href', 'https://barazo.forum') 30 + }) 31 + 32 + it('passes axe accessibility check', async () => { 33 + const { container } = render(<MarkdownPreview content="# Heading\n\nSome **bold** text." />) 34 + const results = await axe(container) 35 + expect(results).toHaveNoViolations() 36 + }) 37 + })
+28
src/components/markdown-preview.tsx
··· 1 + /** 2 + * MarkdownPreview - Live preview of markdown content. 3 + * Uses the shared MarkdownContent component for rendering. 4 + * @see specs/prd-web.md Section 4 (Editor Components) 5 + */ 6 + 7 + import { cn } from '@/lib/utils' 8 + import { MarkdownContent } from './markdown-content' 9 + 10 + interface MarkdownPreviewProps { 11 + content: string 12 + className?: string 13 + } 14 + 15 + export function MarkdownPreview({ content, className }: MarkdownPreviewProps) { 16 + return ( 17 + <div className={cn('space-y-1', className)}> 18 + <p className="block text-sm font-medium text-foreground">Preview</p> 19 + <div className="min-h-[200px] rounded-md border border-border bg-card p-4"> 20 + {content ? ( 21 + <MarkdownContent content={content} /> 22 + ) : ( 23 + <p className="text-sm text-muted-foreground">Nothing to preview</p> 24 + )} 25 + </div> 26 + </div> 27 + ) 28 + }
+153
src/components/topic-form.test.tsx
··· 1 + /** 2 + * Tests for TopicForm component. 3 + */ 4 + 5 + import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 8 + import { axe } from 'vitest-axe' 9 + import { setupServer } from 'msw/node' 10 + import { handlers } from '@/mocks/handlers' 11 + import { TopicForm } from './topic-form' 12 + 13 + const server = setupServer(...handlers) 14 + 15 + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) 16 + afterEach(() => server.resetHandlers()) 17 + afterAll(() => server.close()) 18 + 19 + describe('TopicForm', () => { 20 + it('renders title input', () => { 21 + render(<TopicForm onSubmit={vi.fn()} />) 22 + expect(screen.getByLabelText('Title')).toBeInTheDocument() 23 + }) 24 + 25 + it('renders category select', () => { 26 + render(<TopicForm onSubmit={vi.fn()} />) 27 + expect(screen.getByLabelText('Category')).toBeInTheDocument() 28 + }) 29 + 30 + it('renders tag input', () => { 31 + render(<TopicForm onSubmit={vi.fn()} />) 32 + expect(screen.getByLabelText('Tags')).toBeInTheDocument() 33 + }) 34 + 35 + it('renders content editor', () => { 36 + render(<TopicForm onSubmit={vi.fn()} />) 37 + expect(screen.getByLabelText('Content')).toBeInTheDocument() 38 + }) 39 + 40 + it('renders cross-post checkboxes', () => { 41 + render(<TopicForm onSubmit={vi.fn()} />) 42 + expect(screen.getByLabelText('Share on Bluesky')).toBeInTheDocument() 43 + expect(screen.getByLabelText('Share on Frontpage')).toBeInTheDocument() 44 + }) 45 + 46 + it('defaults Bluesky cross-post to checked', () => { 47 + render(<TopicForm onSubmit={vi.fn()} />) 48 + expect(screen.getByLabelText('Share on Bluesky')).toBeChecked() 49 + }) 50 + 51 + it('defaults Frontpage cross-post to unchecked', () => { 52 + render(<TopicForm onSubmit={vi.fn()} />) 53 + expect(screen.getByLabelText('Share on Frontpage')).not.toBeChecked() 54 + }) 55 + 56 + it('renders submit button', () => { 57 + render(<TopicForm onSubmit={vi.fn()} />) 58 + expect(screen.getByRole('button', { name: 'Create Topic' })).toBeInTheDocument() 59 + }) 60 + 61 + it('shows edit mode submit button text', () => { 62 + render( 63 + <TopicForm 64 + onSubmit={vi.fn()} 65 + initialValues={{ 66 + title: 'Test', 67 + content: 'Content', 68 + category: 'general', 69 + }} 70 + mode="edit" 71 + /> 72 + ) 73 + expect(screen.getByRole('button', { name: 'Save Changes' })).toBeInTheDocument() 74 + }) 75 + 76 + it('validates required title', async () => { 77 + const user = userEvent.setup() 78 + const onSubmit = vi.fn() 79 + render(<TopicForm onSubmit={onSubmit} />) 80 + 81 + await user.click(screen.getByRole('button', { name: 'Create Topic' })) 82 + expect(screen.getByText('Title is required')).toBeInTheDocument() 83 + expect(onSubmit).not.toHaveBeenCalled() 84 + }) 85 + 86 + it('validates required content', async () => { 87 + const user = userEvent.setup() 88 + const onSubmit = vi.fn() 89 + render(<TopicForm onSubmit={onSubmit} />) 90 + 91 + await user.type(screen.getByLabelText('Title'), 'Test Title') 92 + await user.click(screen.getByRole('button', { name: 'Create Topic' })) 93 + expect(screen.getByText('Content is required')).toBeInTheDocument() 94 + expect(onSubmit).not.toHaveBeenCalled() 95 + }) 96 + 97 + it('validates required category', async () => { 98 + const user = userEvent.setup() 99 + const onSubmit = vi.fn() 100 + render(<TopicForm onSubmit={onSubmit} />) 101 + 102 + await user.type(screen.getByLabelText('Title'), 'Test Title') 103 + await user.type(screen.getByLabelText('Content'), 'Test content') 104 + await user.click(screen.getByRole('button', { name: 'Create Topic' })) 105 + expect(screen.getByText('Category is required')).toBeInTheDocument() 106 + expect(onSubmit).not.toHaveBeenCalled() 107 + }) 108 + 109 + it('validates title length', async () => { 110 + const user = userEvent.setup() 111 + const onSubmit = vi.fn() 112 + render(<TopicForm onSubmit={onSubmit} />) 113 + 114 + await user.type(screen.getByLabelText('Title'), 'AB') 115 + await user.click(screen.getByRole('button', { name: 'Create Topic' })) 116 + expect(screen.getByText('Title must be at least 3 characters')).toBeInTheDocument() 117 + expect(onSubmit).not.toHaveBeenCalled() 118 + }) 119 + 120 + it('renders preview tab', async () => { 121 + const user = userEvent.setup() 122 + render(<TopicForm onSubmit={vi.fn()} />) 123 + 124 + const previewTab = screen.getByRole('tab', { name: 'Preview' }) 125 + expect(previewTab).toBeInTheDocument() 126 + 127 + await user.click(previewTab) 128 + expect(screen.getByText('Nothing to preview')).toBeInTheDocument() 129 + }) 130 + 131 + it('pre-populates form in edit mode', () => { 132 + render( 133 + <TopicForm 134 + onSubmit={vi.fn()} 135 + initialValues={{ 136 + title: 'Existing Topic', 137 + content: 'Existing content', 138 + category: 'general', 139 + tags: ['test', 'edit'], 140 + }} 141 + mode="edit" 142 + /> 143 + ) 144 + expect(screen.getByLabelText('Title')).toHaveValue('Existing Topic') 145 + expect(screen.getByLabelText('Content')).toHaveValue('Existing content') 146 + }) 147 + 148 + it('passes axe accessibility check', async () => { 149 + const { container } = render(<TopicForm onSubmit={vi.fn()} />) 150 + const results = await axe(container) 151 + expect(results).toHaveNoViolations() 152 + }) 153 + })
+320
src/components/topic-form.tsx
··· 1 + /** 2 + * TopicForm - Complete topic creation/edit form. 3 + * Title, category, tags, markdown editor with preview, cross-post options. 4 + * Client-side validation matching API Zod schemas. 5 + * @see specs/prd-web.md Section 4 (Editor Components) 6 + */ 7 + 8 + 'use client' 9 + 10 + import { useState, useCallback } from 'react' 11 + import type { CreateTopicInput } from '@/lib/api/types' 12 + import { cn } from '@/lib/utils' 13 + import { MarkdownEditor } from './markdown-editor' 14 + import { MarkdownPreview } from './markdown-preview' 15 + 16 + interface TopicFormValues { 17 + title: string 18 + content: string 19 + category: string 20 + tags?: string[] 21 + crossPostBluesky?: boolean 22 + crossPostFrontpage?: boolean 23 + } 24 + 25 + interface FormErrors { 26 + title?: string 27 + content?: string 28 + category?: string 29 + } 30 + 31 + interface TopicFormProps { 32 + onSubmit: (values: CreateTopicInput) => void | Promise<void> 33 + initialValues?: Partial<TopicFormValues> 34 + mode?: 'create' | 'edit' 35 + categories?: Array<{ slug: string; name: string }> 36 + submitting?: boolean 37 + className?: string 38 + } 39 + 40 + const CATEGORIES_FALLBACK = [ 41 + { slug: 'general', name: 'General Discussion' }, 42 + { slug: 'development', name: 'Development' }, 43 + { slug: 'frontend', name: 'Frontend' }, 44 + { slug: 'backend', name: 'Backend' }, 45 + { slug: 'feedback', name: 'Feedback & Ideas' }, 46 + { slug: 'meta', name: 'Meta' }, 47 + ] 48 + 49 + function validate(values: TopicFormValues): FormErrors { 50 + const errors: FormErrors = {} 51 + 52 + if (!values.title.trim()) { 53 + errors.title = 'Title is required' 54 + } else if (values.title.trim().length < 3) { 55 + errors.title = 'Title must be at least 3 characters' 56 + } else if (values.title.trim().length > 200) { 57 + errors.title = 'Title must be at most 200 characters' 58 + } 59 + 60 + if (!values.content.trim()) { 61 + errors.content = 'Content is required' 62 + } else if (values.content.trim().length < 10) { 63 + errors.content = 'Content must be at least 10 characters' 64 + } 65 + 66 + if (!values.category) { 67 + errors.category = 'Category is required' 68 + } 69 + 70 + return errors 71 + } 72 + 73 + export function TopicForm({ 74 + onSubmit, 75 + initialValues, 76 + mode = 'create', 77 + categories = CATEGORIES_FALLBACK, 78 + submitting = false, 79 + className, 80 + }: TopicFormProps) { 81 + const [title, setTitle] = useState(initialValues?.title ?? '') 82 + const [content, setContent] = useState(initialValues?.content ?? '') 83 + const [category, setCategory] = useState(initialValues?.category ?? '') 84 + const [tagInput, setTagInput] = useState(initialValues?.tags?.join(', ') ?? '') 85 + const [crossPostBluesky, setCrossPostBluesky] = useState(initialValues?.crossPostBluesky ?? true) 86 + const [crossPostFrontpage, setCrossPostFrontpage] = useState( 87 + initialValues?.crossPostFrontpage ?? false 88 + ) 89 + const [errors, setErrors] = useState<FormErrors>({}) 90 + const [activeTab, setActiveTab] = useState<'write' | 'preview'>('write') 91 + 92 + const handleSubmit = useCallback( 93 + (e: React.FormEvent) => { 94 + e.preventDefault() 95 + 96 + const values: TopicFormValues = { 97 + title, 98 + content, 99 + category, 100 + tags: tagInput 101 + .split(',') 102 + .map((t) => t.trim()) 103 + .filter(Boolean), 104 + crossPostBluesky, 105 + crossPostFrontpage, 106 + } 107 + 108 + const validationErrors = validate(values) 109 + setErrors(validationErrors) 110 + 111 + if (Object.keys(validationErrors).length > 0) { 112 + return 113 + } 114 + 115 + onSubmit({ 116 + title: values.title.trim(), 117 + content: values.content.trim(), 118 + category: values.category, 119 + tags: values.tags?.length ? values.tags : undefined, 120 + crossPostBluesky: values.crossPostBluesky, 121 + crossPostFrontpage: values.crossPostFrontpage, 122 + }) 123 + }, 124 + [title, content, category, tagInput, crossPostBluesky, crossPostFrontpage, onSubmit] 125 + ) 126 + 127 + return ( 128 + <form 129 + onSubmit={handleSubmit} 130 + className={cn('space-y-6', className)} 131 + noValidate 132 + aria-label={mode === 'create' ? 'Create new topic' : 'Edit topic'} 133 + > 134 + {/* Title */} 135 + <div className="space-y-1"> 136 + <label htmlFor="topic-title" className="block text-sm font-medium text-foreground"> 137 + Title 138 + </label> 139 + <input 140 + id="topic-title" 141 + type="text" 142 + value={title} 143 + onChange={(e) => setTitle(e.target.value)} 144 + placeholder="Enter a descriptive title" 145 + aria-invalid={errors.title ? 'true' : undefined} 146 + aria-describedby={errors.title ? 'topic-title-error' : undefined} 147 + className={cn( 148 + 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 149 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 150 + errors.title && 'border-destructive' 151 + )} 152 + /> 153 + {errors.title && ( 154 + <p id="topic-title-error" className="text-sm text-destructive" role="alert"> 155 + {errors.title} 156 + </p> 157 + )} 158 + </div> 159 + 160 + {/* Category */} 161 + <div className="space-y-1"> 162 + <label htmlFor="topic-category" className="block text-sm font-medium text-foreground"> 163 + Category 164 + </label> 165 + <select 166 + id="topic-category" 167 + value={category} 168 + onChange={(e) => setCategory(e.target.value)} 169 + aria-invalid={errors.category ? 'true' : undefined} 170 + aria-describedby={errors.category ? 'topic-category-error' : undefined} 171 + className={cn( 172 + 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground', 173 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 174 + errors.category && 'border-destructive' 175 + )} 176 + > 177 + <option value="">Select a category</option> 178 + {categories.map((cat) => ( 179 + <option key={cat.slug} value={cat.slug}> 180 + {cat.name} 181 + </option> 182 + ))} 183 + </select> 184 + {errors.category && ( 185 + <p id="topic-category-error" className="text-sm text-destructive" role="alert"> 186 + {errors.category} 187 + </p> 188 + )} 189 + </div> 190 + 191 + {/* Tags */} 192 + <div className="space-y-1"> 193 + <label htmlFor="topic-tags" className="block text-sm font-medium text-foreground"> 194 + Tags 195 + </label> 196 + <input 197 + id="topic-tags" 198 + type="text" 199 + value={tagInput} 200 + onChange={(e) => setTagInput(e.target.value)} 201 + placeholder="Comma-separated tags (e.g., discussion, help)" 202 + className={cn( 203 + 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 204 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 205 + )} 206 + /> 207 + </div> 208 + 209 + {/* Content - Write/Preview tabs */} 210 + <div className="space-y-1"> 211 + <div role="tablist" aria-label="Editor mode" className="flex gap-1 border-b border-border"> 212 + <button 213 + type="button" 214 + role="tab" 215 + id="tab-write" 216 + aria-selected={activeTab === 'write'} 217 + aria-controls="tabpanel-write" 218 + onClick={() => setActiveTab('write')} 219 + className={cn( 220 + 'px-3 py-1.5 text-sm font-medium transition-colors', 221 + activeTab === 'write' 222 + ? 'border-b-2 border-primary text-foreground' 223 + : 'text-muted-foreground hover:text-foreground' 224 + )} 225 + > 226 + Write 227 + </button> 228 + <button 229 + type="button" 230 + role="tab" 231 + id="tab-preview" 232 + aria-selected={activeTab === 'preview'} 233 + aria-controls="tabpanel-preview" 234 + onClick={() => setActiveTab('preview')} 235 + className={cn( 236 + 'px-3 py-1.5 text-sm font-medium transition-colors', 237 + activeTab === 'preview' 238 + ? 'border-b-2 border-primary text-foreground' 239 + : 'text-muted-foreground hover:text-foreground' 240 + )} 241 + > 242 + Preview 243 + </button> 244 + </div> 245 + 246 + <div 247 + id="tabpanel-write" 248 + role="tabpanel" 249 + aria-labelledby="tab-write" 250 + hidden={activeTab !== 'write'} 251 + > 252 + <MarkdownEditor 253 + value={content} 254 + onChange={setContent} 255 + id="topic-content" 256 + label="Content" 257 + error={errors.content} 258 + /> 259 + </div> 260 + 261 + <div 262 + id="tabpanel-preview" 263 + role="tabpanel" 264 + aria-labelledby="tab-preview" 265 + hidden={activeTab !== 'preview'} 266 + > 267 + <MarkdownPreview content={content} /> 268 + </div> 269 + </div> 270 + 271 + {/* Cross-post options */} 272 + {mode === 'create' && ( 273 + <fieldset className="space-y-3"> 274 + <legend className="text-sm font-medium text-foreground">Cross-post</legend> 275 + <div className="flex flex-col gap-2"> 276 + <label className="flex items-center gap-2"> 277 + <input 278 + type="checkbox" 279 + checked={crossPostBluesky} 280 + onChange={(e) => setCrossPostBluesky(e.target.checked)} 281 + className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 282 + /> 283 + <span className="text-sm text-foreground">Share on Bluesky</span> 284 + </label> 285 + <label className="flex items-center gap-2"> 286 + <input 287 + type="checkbox" 288 + checked={crossPostFrontpage} 289 + onChange={(e) => setCrossPostFrontpage(e.target.checked)} 290 + className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 291 + /> 292 + <span className="text-sm text-foreground">Share on Frontpage</span> 293 + </label> 294 + </div> 295 + </fieldset> 296 + )} 297 + 298 + {/* Submit */} 299 + <div className="flex justify-end"> 300 + <button 301 + type="submit" 302 + disabled={submitting} 303 + className={cn( 304 + 'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors', 305 + 'hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', 306 + 'disabled:cursor-not-allowed disabled:opacity-50' 307 + )} 308 + > 309 + {submitting 310 + ? mode === 'create' 311 + ? 'Creating...' 312 + : 'Saving...' 313 + : mode === 'create' 314 + ? 'Create Topic' 315 + : 'Save Changes'} 316 + </button> 317 + </div> 318 + </form> 319 + ) 320 + }
+39
src/lib/api/client.ts
··· 9 9 CategoryWithTopicCount, 10 10 CommunitySettings, 11 11 CommunityStats, 12 + CreateTopicInput, 12 13 Topic, 13 14 TopicsResponse, 15 + UpdateTopicInput, 14 16 RepliesResponse, 15 17 PaginationParams, 16 18 } from './types' ··· 20 22 interface FetchOptions { 21 23 headers?: Record<string, string> 22 24 signal?: AbortSignal 25 + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' 26 + body?: unknown 23 27 } 24 28 25 29 class ApiError extends Error { ··· 35 39 async function apiFetch<T>(path: string, options: FetchOptions = {}): Promise<T> { 36 40 const url = `${API_URL}${path}` 37 41 const response = await fetch(url, { 42 + method: options.method ?? 'GET', 38 43 headers: { 39 44 'Content-Type': 'application/json', 40 45 ...options.headers, 41 46 }, 42 47 signal: options.signal, 48 + ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}), 43 49 }) 44 50 45 51 if (!response.ok) { ··· 93 99 94 100 export function getTopicByRkey(rkey: string, options?: FetchOptions): Promise<Topic> { 95 101 return apiFetch<Topic>(`/api/topics/by-rkey/${encodeURIComponent(rkey)}`, options) 102 + } 103 + 104 + export function createTopic( 105 + input: CreateTopicInput, 106 + accessToken: string, 107 + options?: FetchOptions 108 + ): Promise<Topic> { 109 + return apiFetch<Topic>('/api/topics', { 110 + ...options, 111 + method: 'POST', 112 + headers: { 113 + ...options?.headers, 114 + Authorization: `Bearer ${accessToken}`, 115 + }, 116 + body: input, 117 + }) 118 + } 119 + 120 + export function updateTopic( 121 + rkey: string, 122 + input: UpdateTopicInput, 123 + accessToken: string, 124 + options?: FetchOptions 125 + ): Promise<Topic> { 126 + return apiFetch<Topic>(`/api/topics/${encodeURIComponent(rkey)}`, { 127 + ...options, 128 + method: 'PUT', 129 + headers: { 130 + ...options?.headers, 131 + Authorization: `Bearer ${accessToken}`, 132 + }, 133 + body: input, 134 + }) 96 135 } 97 136 98 137 // --- Reply endpoints ---
+16
src/lib/api/types.ts
··· 56 56 cursor: string | null 57 57 } 58 58 59 + export interface CreateTopicInput { 60 + title: string 61 + content: string 62 + category: string 63 + tags?: string[] 64 + crossPostBluesky?: boolean 65 + crossPostFrontpage?: boolean 66 + } 67 + 68 + export interface UpdateTopicInput { 69 + title?: string 70 + content?: string 71 + category?: string 72 + tags?: string[] 73 + } 74 + 59 75 // --- Replies --- 60 76 61 77 export interface Reply {
+55
src/mocks/handlers.ts
··· 83 83 }) 84 84 }), 85 85 86 + // POST /api/topics (create) 87 + http.post(`${API_URL}/api/topics`, async ({ request }) => { 88 + const auth = request.headers.get('Authorization') 89 + if (!auth?.startsWith('Bearer ')) { 90 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 91 + } 92 + 93 + const body = (await request.json()) as { 94 + title?: string 95 + content?: string 96 + category?: string 97 + } 98 + if (!body.title || !body.content || !body.category) { 99 + return HttpResponse.json({ error: 'Missing required fields' }, { status: 400 }) 100 + } 101 + 102 + const rkey = `3kf${Date.now().toString(36)}` 103 + const now = new Date().toISOString() 104 + const newTopic = { 105 + uri: `at://did:plc:mock-user/forum.barazo.topic.post/${rkey}`, 106 + rkey, 107 + authorDid: 'did:plc:mock-user', 108 + title: body.title, 109 + content: body.content, 110 + contentFormat: null, 111 + category: body.category, 112 + tags: [], 113 + communityDid: 'did:plc:test-community-123', 114 + cid: `bafyreib-${rkey}`, 115 + replyCount: 0, 116 + reactionCount: 0, 117 + lastActivityAt: now, 118 + createdAt: now, 119 + indexedAt: now, 120 + } 121 + return HttpResponse.json(newTopic, { status: 201 }) 122 + }), 123 + 124 + // PUT /api/topics/:rkey (update) 125 + http.put(`${API_URL}/api/topics/:rkey`, async ({ request, params }) => { 126 + const auth = request.headers.get('Authorization') 127 + if (!auth?.startsWith('Bearer ')) { 128 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 129 + } 130 + 131 + const rkey = params['rkey'] as string 132 + const topic = mockTopics.find((t) => t.rkey === rkey) 133 + if (!topic) { 134 + return HttpResponse.json({ error: 'Topic not found' }, { status: 404 }) 135 + } 136 + 137 + const body = (await request.json()) as Record<string, unknown> 138 + return HttpResponse.json({ ...topic, ...body }) 139 + }), 140 + 86 141 // GET /api/topics/:uri (single topic by AT URI) 87 142 http.get(`${API_URL}/api/topics/:uri`, ({ params }) => { 88 143 const uri = decodeURIComponent(params['uri'] as string)