diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cfb1e2..d2a3eee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 with: - version: 9 + version: 10 - name: Setup Node.js uses: actions/setup-node@v4 @@ -48,7 +48,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 with: - version: 9 + version: 10 - name: Setup Node.js uses: actions/setup-node@v4 @@ -72,7 +72,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 with: - version: 9 + version: 10 - name: Setup Node.js uses: actions/setup-node@v4 @@ -97,7 +97,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 with: - version: 9 + version: 10 - name: Setup Node.js uses: actions/setup-node@v4 @@ -131,7 +131,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 with: - version: 9 + version: 10 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/package.json b/package.json index dd1819c..f27c6f5 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "isomorphic-dompurify": "^2.20.0", + "marked": "^17.0.2", "next": "16.1.6", "next-themes": "^0.4.4", "react": "19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6dfff26..fbb902b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,6 +103,9 @@ importers: isomorphic-dompurify: specifier: ^2.20.0 version: 2.36.0 + marked: + specifier: ^17.0.2 + version: 17.0.2 next: specifier: 16.1.6 version: 16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -5354,6 +5357,14 @@ packages: integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==, } + marked@17.0.2: + resolution: + { + integrity: sha512-s5HZGFQea7Huv5zZcAGhJLT3qLpAfnY7v7GWkICUr0+Wd5TFEtdlRR2XUL5Gg+RH7u2Df595ifrxR03mBaw7gA==, + } + engines: { node: '>= 20' } + hasBin: true + math-intrinsics@1.1.0: resolution: { @@ -10465,6 +10476,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + marked@17.0.2: {} + math-intrinsics@1.1.0: {} mdast-util-to-hast@13.2.1: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..581a9d5 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +ignoredBuiltDependencies: + - sharp + - unrs-resolver diff --git a/src/app/t/[slug]/[rkey]/page.test.tsx b/src/app/t/[slug]/[rkey]/page.test.tsx new file mode 100644 index 0000000..8416193 --- /dev/null +++ b/src/app/t/[slug]/[rkey]/page.test.tsx @@ -0,0 +1,70 @@ +/** + * Tests for topic detail page. + */ + +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import TopicPage from './page' +import { mockTopics, mockReplies, mockCategories } from '@/mocks/data' + +// Mock notFound +vi.mock('next/navigation', () => ({ + notFound: vi.fn(() => { + throw new Error('NEXT_NOT_FOUND') + }), +})) + +const topic = mockTopics[0]! + +describe('TopicPage', () => { + const defaultParams = Promise.resolve({ slug: 'welcome-to-barazo-forums', rkey: topic.rkey }) + const defaultSearchParams = Promise.resolve({}) + + it('renders topic title as h2', async () => { + const Page = await TopicPage({ params: defaultParams, searchParams: defaultSearchParams }) + render(Page) + expect(screen.getByRole('heading', { level: 2, name: topic.title })).toBeInTheDocument() + }) + + it('renders topic content', async () => { + const Page = await TopicPage({ params: defaultParams, searchParams: defaultSearchParams }) + render(Page) + expect(screen.getByText(topic.content)).toBeInTheDocument() + }) + + it('renders replies', async () => { + const Page = await TopicPage({ params: defaultParams, searchParams: defaultSearchParams }) + render(Page) + for (const reply of mockReplies) { + expect(screen.getByText(reply.content)).toBeInTheDocument() + } + }) + + it('renders breadcrumbs', async () => { + const Page = await TopicPage({ params: defaultParams, searchParams: defaultSearchParams }) + render(Page) + expect(screen.getByText('Home')).toBeInTheDocument() + // Category should appear in breadcrumbs + const categoryName = mockCategories.find((c) => c.slug === topic.category)?.name + if (categoryName) { + expect(screen.getAllByText(categoryName).length).toBeGreaterThan(0) + } + }) + + it('renders JSON-LD DiscussionForumPosting', async () => { + const Page = await TopicPage({ params: defaultParams, searchParams: defaultSearchParams }) + const { container } = render(Page) + const script = container.querySelector('script[type="application/ld+json"]') + expect(script).toBeInTheDocument() + const jsonLd = JSON.parse(script!.innerHTML) + expect(jsonLd['@type']).toBe('DiscussionForumPosting') + expect(jsonLd.headline).toBe(topic.title) + }) + + it('handles topic not found', async () => { + const params = Promise.resolve({ slug: 'nonexistent', rkey: 'notreal' }) + await expect(TopicPage({ params, searchParams: defaultSearchParams })).rejects.toThrow( + 'NEXT_NOT_FOUND' + ) + }) +}) diff --git a/src/app/t/[slug]/[rkey]/page.tsx b/src/app/t/[slug]/[rkey]/page.tsx new file mode 100644 index 0000000..5a377bc --- /dev/null +++ b/src/app/t/[slug]/[rkey]/page.tsx @@ -0,0 +1,143 @@ +/** + * Topic detail page - Shows topic post and threaded replies. + * URL: /t/{slug}/{rkey} + * Server-side rendered with JSON-LD DiscussionForumPosting. + * @see specs/prd-web.md Section 3.1, Section 5 + */ + +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { getTopicByRkey, getCategories, getReplies, ApiError } from '@/lib/api/client' +import { ForumLayout } from '@/components/layout/forum-layout' +import { CategoryNav } from '@/components/category-nav' +import { Breadcrumbs } from '@/components/breadcrumbs' +import { TopicView } from '@/components/topic-view' +import { ReplyThread } from '@/components/reply-thread' +import type { CategoriesResponse, RepliesResponse } from '@/lib/api/types' + +export const dynamic = 'force-dynamic' + +interface TopicPageProps { + params: Promise<{ slug: string; rkey: string }> + searchParams: Promise<{ page?: string }> +} + +export async function generateMetadata({ params }: TopicPageProps): Promise { + const { rkey } = await params + try { + const topic = await getTopicByRkey(rkey) + const description = + topic.content.length > 160 ? topic.content.slice(0, 157) + '...' : topic.content + return { + title: topic.title, + description, + openGraph: { + title: topic.title, + description, + type: 'article', + publishedTime: topic.createdAt, + }, + } + } catch { + return { title: 'Topic Not Found' } + } +} + +const REPLIES_PER_PAGE = 20 + +export default async function TopicPage({ params }: TopicPageProps) { + const { rkey } = await params + + let topic + try { + topic = await getTopicByRkey(rkey) + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + notFound() + } + throw error + } + + let categoriesResult: CategoriesResponse = { categories: [] } + let repliesResult: RepliesResponse = { replies: [], cursor: null } + + try { + ;[categoriesResult, repliesResult] = await Promise.all([ + getCategories(), + getReplies(topic.uri, { limit: REPLIES_PER_PAGE }), + ]) + } catch { + // Non-critical: page still renders with topic but without sidebar/replies + } + + // Find category name for breadcrumbs + const findCategoryName = ( + nodes: CategoriesResponse['categories'], + slug: string + ): string | undefined => { + for (const node of nodes) { + if (node.slug === slug) return node.name + const found = findCategoryName(node.children, slug) + if (found) return found + } + return undefined + } + + const categoryName = + findCategoryName(categoriesResult.categories, topic.category) ?? topic.category + + const breadcrumbItems = [ + { label: 'Home', href: '/' }, + { label: categoryName, href: `/c/${topic.category}` }, + { label: topic.title }, + ] + + const jsonLd = { + '@context': 'https://schema.org', + '@type': 'DiscussionForumPosting', + headline: topic.title, + text: topic.content, + author: { + '@type': 'Person', + identifier: topic.authorDid, + }, + datePublished: topic.createdAt, + dateModified: topic.lastActivityAt, + commentCount: topic.replyCount, + interactionStatistic: { + '@type': 'InteractionCounter', + interactionType: 'https://schema.org/LikeAction', + userInteractionCount: topic.reactionCount, + }, + url: `https://barazo.forum/t/${encodeURIComponent(topic.title)}/${topic.rkey}`, + } + + return ( + 0 ? ( + + ) : undefined + } + > + {/* JSON-LD */} +

Safe content

' /> + ) + expect(container.querySelector('script')).not.toBeInTheDocument() + expect(screen.getByText('Safe content')).toBeInTheDocument() + }) + + it('strips event handler attributes', () => { + const { container } = render( + + ) + const img = container.querySelector('img') + if (img) { + expect(img.getAttribute('onerror')).toBeNull() + } + }) + + it('applies prose styling class', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toContain('prose') + }) + + it('passes axe accessibility check', async () => { + const { container } = render( + + ) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/src/components/markdown-content.tsx b/src/components/markdown-content.tsx new file mode 100644 index 0000000..759ad2c --- /dev/null +++ b/src/components/markdown-content.tsx @@ -0,0 +1,87 @@ +/** + * MarkdownContent - Renders markdown content with DOMPurify sanitization. + * Used for topic and reply content display. + * @see specs/prd-web.md Section 4 (Topic Components) + */ + +import DOMPurify from 'isomorphic-dompurify' +import { marked } from 'marked' +import { cn } from '@/lib/utils' + +interface MarkdownContentProps { + content: string + className?: string +} + +// Configure marked for safe defaults +marked.setOptions({ + breaks: true, + gfm: true, +}) + +// Configure marked renderer for links +const renderer = new marked.Renderer() +renderer.link = ({ href, text }: { href: string; text: string }) => { + return `${text}` +} + +marked.use({ renderer }) + +/** + * Renders markdown content, sanitized against XSS. + * Supports: headings, bold, italic, links, code blocks, lists, blockquotes. + */ +export function MarkdownContent({ content, className }: MarkdownContentProps) { + const rawHtml = marked.parse(content, { async: false }) as string + + const cleanHtml = DOMPurify.sanitize(rawHtml, { + ALLOWED_TAGS: [ + 'p', + 'br', + 'strong', + 'em', + 'a', + 'code', + 'pre', + 'blockquote', + 'ul', + 'ol', + 'li', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'hr', + 'img', + 'table', + 'thead', + 'tbody', + 'tr', + 'th', + 'td', + 'del', + 'sup', + 'sub', + 'span', + ], + ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'rel', 'target'], + ALLOW_DATA_ATTR: false, + }) + + return ( +
+ ) +} diff --git a/src/components/reply-card.test.tsx b/src/components/reply-card.test.tsx new file mode 100644 index 0000000..3b55f0b --- /dev/null +++ b/src/components/reply-card.test.tsx @@ -0,0 +1,67 @@ +/** + * Tests for ReplyCard component. + */ + +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { axe } from 'vitest-axe' +import { ReplyCard } from './reply-card' +import { mockReplies } from '@/mocks/data' + +const reply = mockReplies[0]! +const nestedReply = mockReplies[1]! // depth 1 + +describe('ReplyCard', () => { + it('renders reply content', () => { + render() + expect(screen.getByText(reply.content)).toBeInTheDocument() + }) + + it('renders author handle', () => { + render() + expect(screen.getByText(reply.authorDid)).toBeInTheDocument() + }) + + it('renders as article with aria-labelledby', () => { + const { container } = render() + const article = container.querySelector('article') + expect(article).toBeInTheDocument() + expect(article).toHaveAttribute('aria-labelledby') + }) + + it('renders anchor id for post number', () => { + const { container } = render() + const article = container.querySelector('article') + expect(article).toHaveAttribute('id', 'post-2') + }) + + it('renders post number link', () => { + render() + const link = screen.getByRole('link', { name: 'Link to post #2' }) + expect(link).toHaveAttribute('href', '#post-2') + }) + + it('renders reaction count', () => { + render() + expect(screen.getByText(`${reply.reactionCount}`)).toBeInTheDocument() + }) + + it('applies depth indentation for nested replies', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + // Depth 1 should have margin-left + expect(wrapper.className).toContain('ml-') + }) + + it('does not indent top-level replies', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).not.toContain('ml-') + }) + + it('passes axe accessibility check', async () => { + const { container } = render() + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/src/components/reply-card.tsx b/src/components/reply-card.tsx new file mode 100644 index 0000000..c79ba92 --- /dev/null +++ b/src/components/reply-card.tsx @@ -0,0 +1,88 @@ +/** + * ReplyCard - Displays a single reply with depth indication. + * Depth is shown via left margin indentation. + * @see specs/prd-web.md Section 4 (Topic Components) + */ + +import { Heart, Clock, Link as LinkIcon } from '@phosphor-icons/react/dist/ssr' +import type { Reply } from '@/lib/api/types' +import { cn } from '@/lib/utils' +import { formatRelativeTime, formatCompactNumber } from '@/lib/format' +import { MarkdownContent } from './markdown-content' + +interface ReplyCardProps { + reply: Reply + postNumber: number + className?: string +} + +const DEPTH_INDENT: Record = { + 0: '', + 1: 'ml-6 sm:ml-8', + 2: 'ml-12 sm:ml-16', + 3: 'ml-16 sm:ml-20', +} + +export function ReplyCard({ reply, postNumber, className }: ReplyCardProps) { + const headingId = `reply-heading-${reply.rkey}` + const indent = DEPTH_INDENT[Math.min(reply.depth, 3)] ?? DEPTH_INDENT[3] + + return ( +
+
+ {/* Header */} +
+
+

+ {reply.authorDid} +

+ + +
+ + #{postNumber} + +
+ + {/* Content */} +
+ +
+ + {/* Footer */} +
+ + + + + + +
+
+
+ ) +} diff --git a/src/components/reply-thread.test.tsx b/src/components/reply-thread.test.tsx new file mode 100644 index 0000000..717b7b0 --- /dev/null +++ b/src/components/reply-thread.test.tsx @@ -0,0 +1,51 @@ +/** + * Tests for ReplyThread component. + */ + +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { axe } from 'vitest-axe' +import { ReplyThread } from './reply-thread' +import { mockReplies } from '@/mocks/data' + +describe('ReplyThread', () => { + it('renders all replies', () => { + render() + for (const reply of mockReplies) { + expect(screen.getByText(reply.content)).toBeInTheDocument() + } + }) + + it('renders heading with reply count', () => { + render() + const heading = screen.getByRole('heading', { + level: 2, + name: `${mockReplies.length} Replies`, + }) + expect(heading).toBeInTheDocument() + }) + + it('renders empty state when no replies', () => { + render() + expect(screen.getByText(/no replies yet/i)).toBeInTheDocument() + }) + + it('assigns sequential post numbers starting from 2', () => { + const { container } = render() + const articles = container.querySelectorAll('article') + expect(articles[0]).toHaveAttribute('id', 'post-2') + expect(articles[1]).toHaveAttribute('id', 'post-3') + expect(articles[2]).toHaveAttribute('id', 'post-4') + }) + + it('uses singular heading for 1 reply', () => { + render() + expect(screen.getByRole('heading', { level: 2, name: '1 Reply' })).toBeInTheDocument() + }) + + it('passes axe accessibility check', async () => { + const { container } = render() + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/src/components/reply-thread.tsx b/src/components/reply-thread.tsx new file mode 100644 index 0000000..3896454 --- /dev/null +++ b/src/components/reply-thread.tsx @@ -0,0 +1,38 @@ +/** + * ReplyThread - Displays a paginated list of replies with depth indicators. + * Post numbers start at 2 (post #1 is the topic itself). + * @see specs/prd-web.md Section 4 (Topic Components) + */ + +import type { Reply } from '@/lib/api/types' +import { cn } from '@/lib/utils' +import { ReplyCard } from './reply-card' + +interface ReplyThreadProps { + replies: Reply[] + className?: string +} + +export function ReplyThread({ replies, className }: ReplyThreadProps) { + const replyCount = replies.length + const heading = + replyCount === 0 ? 'Replies' : replyCount === 1 ? '1 Reply' : `${replyCount} Replies` + + return ( +
+

{heading}

+ + {replyCount === 0 ? ( +
+

No replies yet. Be the first to respond!

+
+ ) : ( +
+ {replies.map((reply, index) => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/components/topic-card.tsx b/src/components/topic-card.tsx index 62b89e5..311ab4d 100644 --- a/src/components/topic-card.tsx +++ b/src/components/topic-card.tsx @@ -8,7 +8,7 @@ import Link from 'next/link' import { ChatCircle, Heart, Clock } from '@phosphor-icons/react/dist/ssr' import type { Topic } from '@/lib/api/types' import { cn } from '@/lib/utils' -import { formatRelativeTime } from '@/lib/format' +import { formatRelativeTime, getTopicUrl } from '@/lib/format' interface TopicCardProps { topic: Topic @@ -16,7 +16,7 @@ interface TopicCardProps { } export function TopicCard({ topic, className }: TopicCardProps) { - const topicUrl = `/t/${topic.rkey}` + const topicUrl = getTopicUrl(topic) return (
{ + it('renders topic title as h2', () => { + render() + const heading = screen.getByRole('heading', { level: 2, name: topic.title }) + expect(heading).toBeInTheDocument() + }) + + it('renders topic content via markdown', () => { + render() + expect(screen.getByText(topic.content)).toBeInTheDocument() + }) + + it('renders author handle', () => { + render() + expect(screen.getByText(mockUsers[0]!.did)).toBeInTheDocument() + }) + + it('renders category link', () => { + render() + const link = screen.getByRole('link', { name: topic.category }) + expect(link).toHaveAttribute('href', `/c/${topic.category}`) + }) + + it('renders tags', () => { + render() + for (const tag of topic.tags ?? []) { + expect(screen.getByText(`#${tag}`)).toBeInTheDocument() + } + }) + + it('renders reply count', () => { + render() + expect(screen.getByText(`${topic.replyCount}`, { exact: false })).toBeInTheDocument() + }) + + it('renders reaction count', () => { + render() + expect(screen.getByText(`${topic.reactionCount}`, { exact: false })).toBeInTheDocument() + }) + + it('uses article element with aria-labelledby', () => { + const { container } = render() + const article = container.querySelector('article') + expect(article).toBeInTheDocument() + expect(article).toHaveAttribute('aria-labelledby') + }) + + it('includes anchor link for post', () => { + const { container } = render() + const article = container.querySelector('article') + expect(article).toHaveAttribute('id', 'post-1') + }) + + it('passes axe accessibility check', async () => { + const { container } = render() + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/src/components/topic-view.tsx b/src/components/topic-view.tsx new file mode 100644 index 0000000..ac514bc --- /dev/null +++ b/src/components/topic-view.tsx @@ -0,0 +1,89 @@ +/** + * TopicView - Displays a full topic post with content and metadata. + * Used on the topic detail page. + * @see specs/prd-web.md Section 4 (Topic Components) + */ + +import Link from 'next/link' +import { ChatCircle, Heart, Clock, Tag } from '@phosphor-icons/react/dist/ssr' +import type { Topic } from '@/lib/api/types' +import { cn } from '@/lib/utils' +import { formatRelativeTime, formatCompactNumber } from '@/lib/format' +import { MarkdownContent } from './markdown-content' + +interface TopicViewProps { + topic: Topic + className?: string +} + +export function TopicView({ topic, className }: TopicViewProps) { + const headingId = `topic-heading-${topic.rkey}` + + return ( +
+ {/* Header */} +
+

+ {topic.title} +

+ + {/* Author + timestamp */} +
+ {topic.authorDid} + + +
+ + {/* Category + Tags */} +
+ + {topic.category} + + {topic.tags?.map((tag) => ( + +
+
+ + {/* Content */} +
+ +
+ + {/* Footer stats */} +
+ + + + + + +
+
+ ) +} diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 7f18f4f..8445375 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -9,6 +9,7 @@ import type { CategoryWithTopicCount, CommunitySettings, CommunityStats, + Topic, TopicsResponse, RepliesResponse, PaginationParams, @@ -90,6 +91,10 @@ export function getTopics( return apiFetch(`/api/topics${query}`, options) } +export function getTopicByRkey(rkey: string, options?: FetchOptions): Promise { + return apiFetch(`/api/topics/by-rkey/${encodeURIComponent(rkey)}`, options) +} + // --- Reply endpoints --- export function getReplies( diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts new file mode 100644 index 0000000..f31c1f4 --- /dev/null +++ b/src/lib/format.test.ts @@ -0,0 +1,83 @@ +/** + * Tests for formatting utilities. + */ + +import { describe, it, expect } from 'vitest' +import { formatRelativeTime, formatCompactNumber, slugify, getTopicUrl } from './format' + +describe('formatRelativeTime', () => { + it('returns "just now" for recent timestamps', () => { + const now = new Date() + expect(formatRelativeTime(now.toISOString())).toBe('just now') + }) + + it('returns minutes ago', () => { + const date = new Date(Date.now() - 5 * 60 * 1000) + expect(formatRelativeTime(date.toISOString())).toBe('5m ago') + }) + + it('returns hours ago', () => { + const date = new Date(Date.now() - 3 * 60 * 60 * 1000) + expect(formatRelativeTime(date.toISOString())).toBe('3h ago') + }) +}) + +describe('formatCompactNumber', () => { + it('returns number as-is below 1000', () => { + expect(formatCompactNumber(42)).toBe('42') + }) + + it('formats thousands with k suffix', () => { + expect(formatCompactNumber(1200)).toBe('1.2k') + }) + + it('formats millions with M suffix', () => { + expect(formatCompactNumber(3400000)).toBe('3.4M') + }) +}) + +describe('slugify', () => { + it('converts title to lowercase slug', () => { + expect(slugify('Hello World')).toBe('hello-world') + }) + + it('removes special characters', () => { + expect(slugify('Building with the AT Protocol!')).toBe('building-with-the-at-protocol') + }) + + it('collapses multiple hyphens', () => { + expect(slugify('Hello --- World')).toBe('hello-world') + }) + + it('trims leading/trailing hyphens', () => { + expect(slugify('---Hello World---')).toBe('hello-world') + }) + + it('handles empty string', () => { + expect(slugify('')).toBe('untitled') + }) + + it('truncates long slugs', () => { + const longTitle = 'A'.repeat(200) + const slug = slugify(longTitle) + expect(slug.length).toBeLessThanOrEqual(80) + }) +}) + +describe('getTopicUrl', () => { + it('generates correct URL from topic', () => { + const topic = { + title: 'Welcome to Barazo Forums', + rkey: '3kf1abc', + } + expect(getTopicUrl(topic)).toBe('/t/welcome-to-barazo-forums/3kf1abc') + }) + + it('handles special characters in title', () => { + const topic = { + title: 'Feature Request: Dark Mode!', + rkey: '3kf3ghi', + } + expect(getTopicUrl(topic)).toBe('/t/feature-request-dark-mode/3kf3ghi') + }) +}) diff --git a/src/lib/format.ts b/src/lib/format.ts index 91119fb..c4286d9 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -37,3 +37,25 @@ export function formatCompactNumber(n: number): string { if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k` return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M` } + +/** + * Converts a string to a URL-safe slug. + */ +export function slugify(text: string): string { + const slug = text + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/[\s-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80) + .replace(/-+$/, '') + + return slug || 'untitled' +} + +/** + * Generates a topic URL from a topic's title and rkey. + */ +export function getTopicUrl(topic: { title: string; rkey: string }): string { + return `/t/${slugify(topic.title)}/${topic.rkey}` +} diff --git a/src/mocks/data.ts b/src/mocks/data.ts index 2cfec07..6a1b8b3 100644 --- a/src/mocks/data.ts +++ b/src/mocks/data.ts @@ -3,7 +3,7 @@ * matching barazo-api response schemas. */ -import type { CategoryTreeNode, CategoryWithTopicCount, Topic } from '@/lib/api/types' +import type { CategoryTreeNode, CategoryWithTopicCount, Topic, Reply } from '@/lib/api/types' const COMMUNITY_DID = 'did:plc:test-community-123' const NOW = '2026-02-14T12:00:00.000Z' @@ -198,3 +198,98 @@ export const mockTopics: Topic[] = [ indexedAt: NOW, }, ] + +// --- Replies --- + +const TOPIC_URI = mockTopics[0]!.uri +const TOPIC_CID = mockTopics[0]!.cid + +export const mockReplies: Reply[] = [ + { + uri: `at://${mockUsers[1]!.did}/forum.barazo.reply.post/3kf6aaa`, + rkey: '3kf6aaa', + authorDid: mockUsers[1]!.did, + content: 'Welcome! Excited to see this forum take shape.', + contentFormat: null, + rootUri: TOPIC_URI, + rootCid: TOPIC_CID, + parentUri: TOPIC_URI, + parentCid: TOPIC_CID, + communityDid: COMMUNITY_DID, + cid: 'bafyreir1', + depth: 0, + reactionCount: 4, + createdAt: TWO_DAYS_AGO, + indexedAt: TWO_DAYS_AGO, + }, + { + uri: `at://${mockUsers[2]!.did}/forum.barazo.reply.post/3kf6bbb`, + rkey: '3kf6bbb', + authorDid: mockUsers[2]!.did, + content: + 'Thanks for starting this community! The AT Protocol integration is really interesting.', + contentFormat: null, + rootUri: TOPIC_URI, + rootCid: TOPIC_CID, + parentUri: `at://${mockUsers[1]!.did}/forum.barazo.reply.post/3kf6aaa`, + parentCid: 'bafyreir1', + communityDid: COMMUNITY_DID, + cid: 'bafyreir2', + depth: 1, + reactionCount: 2, + createdAt: YESTERDAY, + indexedAt: YESTERDAY, + }, + { + uri: `at://${mockUsers[0]!.did}/forum.barazo.reply.post/3kf6ccc`, + rkey: '3kf6ccc', + authorDid: mockUsers[0]!.did, + content: 'Agreed! Portable identity changes everything.', + contentFormat: null, + rootUri: TOPIC_URI, + rootCid: TOPIC_CID, + parentUri: `at://${mockUsers[2]!.did}/forum.barazo.reply.post/3kf6bbb`, + parentCid: 'bafyreir2', + communityDid: COMMUNITY_DID, + cid: 'bafyreir3', + depth: 2, + reactionCount: 1, + createdAt: YESTERDAY, + indexedAt: YESTERDAY, + }, + { + uri: `at://${mockUsers[3]!.did}/forum.barazo.reply.post/3kf6ddd`, + rkey: '3kf6ddd', + authorDid: mockUsers[3]!.did, + content: 'One question: how does content moderation work across federated instances?', + contentFormat: null, + rootUri: TOPIC_URI, + rootCid: TOPIC_CID, + parentUri: TOPIC_URI, + parentCid: TOPIC_CID, + communityDid: COMMUNITY_DID, + cid: 'bafyreir4', + depth: 0, + reactionCount: 6, + createdAt: NOW, + indexedAt: NOW, + }, + { + uri: `at://${mockUsers[4]!.did}/forum.barazo.reply.post/3kf6eee`, + rkey: '3kf6eee', + authorDid: mockUsers[4]!.did, + content: + 'Great question! Each community has its own moderation policies, but the AT Protocol labeling system allows cross-community signals.', + contentFormat: null, + rootUri: TOPIC_URI, + rootCid: TOPIC_CID, + parentUri: `at://${mockUsers[3]!.did}/forum.barazo.reply.post/3kf6ddd`, + parentCid: 'bafyreir4', + communityDid: COMMUNITY_DID, + cid: 'bafyreir5', + depth: 1, + reactionCount: 8, + createdAt: NOW, + indexedAt: NOW, + }, +] diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 3591e77..40ea3a8 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -5,7 +5,7 @@ */ import { http, HttpResponse } from 'msw' -import { mockCategories, mockCategoryWithTopicCount, mockTopics } from './data' +import { mockCategories, mockCategoryWithTopicCount, mockTopics, mockReplies } from './data' const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000' @@ -35,7 +35,34 @@ export const handlers = [ return HttpResponse.json({ ...category, topicCount: mockCategoryWithTopicCount.topicCount }) }), - // GET /api/topics + // GET /api/topics/by-rkey/:rkey (must be before :uri handler) + http.get(`${API_URL}/api/topics/by-rkey/:rkey`, ({ params }) => { + const rkey = params['rkey'] as string + const topic = mockTopics.find((t) => t.rkey === rkey) + if (!topic) { + return HttpResponse.json({ error: 'Topic not found' }, { status: 404 }) + } + return HttpResponse.json(topic) + }), + + // GET /api/topics/:topicUri/replies + http.get(`${API_URL}/api/topics/:topicUri/replies`, ({ request, params }) => { + const topicUri = decodeURIComponent(params['topicUri'] as string) + const url = new URL(request.url) + const limitParam = url.searchParams.get('limit') + const limit = limitParam ? parseInt(limitParam, 10) : 20 + + const replies = mockReplies.filter((r) => r.rootUri === topicUri) + const limited = replies.slice(0, limit) + const hasMore = replies.length > limit + + return HttpResponse.json({ + replies: limited, + cursor: hasMore ? 'mock-cursor-next' : null, + }) + }), + + // GET /api/topics (list) http.get(`${API_URL}/api/topics`, ({ request }) => { const url = new URL(request.url) const category = url.searchParams.get('category') @@ -56,7 +83,7 @@ export const handlers = [ }) }), - // GET /api/topics/:uri + // GET /api/topics/:uri (single topic by AT URI) http.get(`${API_URL}/api/topics/:uri`, ({ params }) => { const uri = decodeURIComponent(params['uri'] as string) const topic = mockTopics.find((t) => t.uri === uri)