diff --git a/src/app/new/page.test.tsx b/src/app/new/page.test.tsx index b560a45..9ee33aa 100644 --- a/src/app/new/page.test.tsx +++ b/src/app/new/page.test.tsx @@ -3,14 +3,18 @@ */ import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest' -import { render, screen } from '@testing-library/react' +import { render, screen, cleanup } from '@testing-library/react' import { setupServer } from 'msw/node' import { handlers } from '@/mocks/handlers' +import NewTopicPage from './page' const server = setupServer(...handlers) beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) -afterEach(() => server.resetHandlers()) +afterEach(() => { + cleanup() + server.resetHandlers() +}) afterAll(() => server.close()) // Mock next/navigation @@ -24,22 +28,19 @@ vi.mock('next/navigation', () => ({ })) describe('NewTopicPage', () => { - it('renders create topic heading', async () => { - const { default: NewTopicPage } = await import('./page') + it('renders create topic heading', () => { render() expect(screen.getByRole('heading', { name: 'Create New Topic' })).toBeInTheDocument() }) - it('renders topic form', async () => { - const { default: NewTopicPage } = await import('./page') + it('renders topic form', () => { render() expect(screen.getByLabelText('Title')).toBeInTheDocument() expect(screen.getByLabelText('Content')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Create Topic' })).toBeInTheDocument() }) - it('renders breadcrumbs', async () => { - const { default: NewTopicPage } = await import('./page') + it('renders breadcrumbs', () => { render() expect(screen.getByText('Home')).toBeInTheDocument() expect(screen.getByText('New Topic')).toBeInTheDocument() diff --git a/src/components/ban-indicator.test.tsx b/src/components/ban-indicator.test.tsx new file mode 100644 index 0000000..bdfc44f --- /dev/null +++ b/src/components/ban-indicator.test.tsx @@ -0,0 +1,41 @@ +/** + * Tests for BanIndicator component. + */ + +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { axe } from 'vitest-axe' +import { BanIndicator } from './ban-indicator' + +describe('BanIndicator', () => { + it('renders nothing when user is not banned', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('shows banned status', () => { + render() + expect(screen.getByText(/banned/i)).toBeInTheDocument() + }) + + it('shows ban reason when provided', () => { + render() + expect(screen.getByText(/spam/i)).toBeInTheDocument() + }) + + it('shows ban expiry when provided', () => { + render() + expect(screen.getByText(/expires/i)).toBeInTheDocument() + }) + + it('shows permanent ban when no expiry', () => { + render() + expect(screen.getByText(/permanent/i)).toBeInTheDocument() + }) + + it('passes axe accessibility check', async () => { + const { container } = render() + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/src/components/ban-indicator.tsx b/src/components/ban-indicator.tsx new file mode 100644 index 0000000..40c134b --- /dev/null +++ b/src/components/ban-indicator.tsx @@ -0,0 +1,38 @@ +/** + * BanIndicator - Shows ban status on user profiles. + * Displays ban reason and expiry if available. + * @see specs/prd-web.md Section M7 (Ban indicator on user profiles) + */ + +import { Prohibit } from '@phosphor-icons/react' +import { cn } from '@/lib/utils' + +interface BanIndicatorProps { + isBanned: boolean + reason?: string + expiresAt?: string + className?: string +} + +export function BanIndicator({ isBanned, reason, expiresAt, className }: BanIndicatorProps) { + if (!isBanned) return null + + const expiryText = expiresAt + ? `Expires ${new Date(expiresAt).toLocaleDateString()}` + : 'Permanent ban' + + return ( +
+
+ ) +} diff --git a/src/components/confirm-dialog.test.tsx b/src/components/confirm-dialog.test.tsx new file mode 100644 index 0000000..aa4234c --- /dev/null +++ b/src/components/confirm-dialog.test.tsx @@ -0,0 +1,130 @@ +/** + * Tests for ConfirmDialog component. + */ + +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { axe } from 'vitest-axe' +import { ConfirmDialog } from './confirm-dialog' + +describe('ConfirmDialog', () => { + it('renders nothing when closed', () => { + render( + + ) + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + }) + + it('renders dialog when open', () => { + render( + + ) + expect(screen.getByRole('alertdialog')).toBeInTheDocument() + expect(screen.getByText('Delete Topic')).toBeInTheDocument() + expect(screen.getByText('This action cannot be undone.')).toBeInTheDocument() + }) + + it('calls onConfirm when confirm button is clicked', async () => { + const user = userEvent.setup() + const onConfirm = vi.fn() + render( + + ) + await user.click(screen.getByRole('button', { name: /confirm/i })) + expect(onConfirm).toHaveBeenCalledOnce() + }) + + it('calls onCancel when cancel button is clicked', async () => { + const user = userEvent.setup() + const onCancel = vi.fn() + render( + + ) + await user.click(screen.getByRole('button', { name: /cancel/i })) + expect(onCancel).toHaveBeenCalledOnce() + }) + + it('closes on Escape key', async () => { + const user = userEvent.setup() + const onCancel = vi.fn() + render( + + ) + await user.keyboard('{Escape}') + expect(onCancel).toHaveBeenCalledOnce() + }) + + it('uses custom confirm label', () => { + render( + + ) + expect(screen.getByRole('button', { name: 'Delete Forever' })).toBeInTheDocument() + }) + + it('shows destructive styling for variant', () => { + render( + + ) + const confirmBtn = screen.getByRole('button', { name: /confirm/i }) + expect(confirmBtn.className).toContain('destructive') + }) + + it('passes axe accessibility check', async () => { + const { container } = render( + + ) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/src/components/confirm-dialog.tsx b/src/components/confirm-dialog.tsx new file mode 100644 index 0000000..e6d45bb --- /dev/null +++ b/src/components/confirm-dialog.tsx @@ -0,0 +1,103 @@ +/** + * ConfirmDialog - Accessible confirmation dialog for destructive actions. + * Uses alertdialog role, focus trapping, and Escape key to dismiss. + * @see specs/prd-web.md Section M7 (Mod action confirmation dialogs) + */ + +'use client' + +import { useEffect, useRef, useCallback } from 'react' +import { cn } from '@/lib/utils' + +interface ConfirmDialogProps { + open: boolean + title: string + description: string + confirmLabel?: string + cancelLabel?: string + variant?: 'default' | 'destructive' + onConfirm: () => void + onCancel: () => void +} + +export function ConfirmDialog({ + open, + title, + description, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + variant = 'default', + onConfirm, + onCancel, +}: ConfirmDialogProps) { + const dialogRef = useRef(null) + const cancelRef = useRef(null) + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onCancel() + } + }, + [onCancel] + ) + + useEffect(() => { + if (open) { + document.addEventListener('keydown', handleKeyDown) + cancelRef.current?.focus() + } + return () => { + document.removeEventListener('keydown', handleKeyDown) + } + }, [open, handleKeyDown]) + + if (!open) return null + + return ( +
+ + ) +} diff --git a/src/components/moderation-controls.test.tsx b/src/components/moderation-controls.test.tsx new file mode 100644 index 0000000..e5e54d4 --- /dev/null +++ b/src/components/moderation-controls.test.tsx @@ -0,0 +1,66 @@ +/** + * Tests for ModerationControls component. + */ + +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { axe } from 'vitest-axe' +import { ModerationControls } from './moderation-controls' + +describe('ModerationControls', () => { + it('renders nothing when user is not a moderator', () => { + render() + expect(screen.queryByRole('group')).not.toBeInTheDocument() + }) + + it('renders moderation actions for moderators', () => { + render() + expect(screen.getByRole('group', { name: /moderation/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /lock/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /pin/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument() + }) + + it('shows confirmation dialog before executing destructive action', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: /delete/i })) + expect(screen.getByRole('alertdialog')).toBeInTheDocument() + }) + + it('executes action after confirmation', async () => { + const user = userEvent.setup() + const onAction = vi.fn() + render() + await user.click(screen.getByRole('button', { name: /delete/i })) + await user.click(screen.getByRole('button', { name: /confirm/i })) + expect(onAction).toHaveBeenCalledWith('delete') + }) + + it('cancels action when dialog is dismissed', async () => { + const user = userEvent.setup() + const onAction = vi.fn() + render() + await user.click(screen.getByRole('button', { name: /delete/i })) + await user.click(screen.getByRole('button', { name: /cancel/i })) + expect(onAction).not.toHaveBeenCalled() + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + }) + + it('reflects locked state', () => { + render() + expect(screen.getByRole('button', { name: /unlock/i })).toBeInTheDocument() + }) + + it('reflects pinned state', () => { + render() + expect(screen.getByRole('button', { name: /unpin/i })).toBeInTheDocument() + }) + + it('passes axe accessibility check', async () => { + const { container } = render() + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/src/components/moderation-controls.tsx b/src/components/moderation-controls.tsx new file mode 100644 index 0000000..880ce25 --- /dev/null +++ b/src/components/moderation-controls.tsx @@ -0,0 +1,137 @@ +/** + * ModerationControls - Lock, pin, delete actions for moderators/admins. + * Only renders when user has moderator privileges. + * Uses ConfirmDialog for destructive actions. + * @see specs/prd-web.md Section M7 (Moderation controls) + */ + +'use client' + +import { useState } from 'react' +import { Lock, LockOpen, PushPin, Trash } from '@phosphor-icons/react' +import { cn } from '@/lib/utils' +import { ConfirmDialog } from './confirm-dialog' + +export type ModerationAction = 'lock' | 'unlock' | 'pin' | 'unpin' | 'delete' + +interface ModerationControlsProps { + isModerator: boolean + isLocked?: boolean + isPinned?: boolean + onAction: (action: ModerationAction) => void + className?: string +} + +const ACTION_CONFIRMATIONS: Record = { + delete: { + title: 'Delete Topic', + description: + 'This will permanently delete this topic and all its replies. This action cannot be undone.', + }, + lock: { + title: 'Lock Topic', + description: 'Locking this topic will prevent new replies from being posted.', + }, + unlock: { + title: 'Unlock Topic', + description: 'Unlocking this topic will allow new replies again.', + }, + pin: { + title: 'Pin Topic', + description: 'This topic will be pinned to the top of the category.', + }, + unpin: { + title: 'Unpin Topic', + description: 'This topic will no longer be pinned to the top.', + }, +} + +export function ModerationControls({ + isModerator, + isLocked = false, + isPinned = false, + onAction, + className, +}: ModerationControlsProps) { + const [pendingAction, setPendingAction] = useState(null) + + if (!isModerator) return null + + const handleAction = (action: ModerationAction) => { + setPendingAction(action) + } + + const handleConfirm = () => { + if (pendingAction) { + onAction(pendingAction) + setPendingAction(null) + } + } + + const handleCancel = () => { + setPendingAction(null) + } + + const lockAction = isLocked ? 'unlock' : 'lock' + const pinAction = isPinned ? 'unpin' : 'pin' + + return ( + <> +
+ + + + + +
+ + {pendingAction && ( + + )} + + ) +} diff --git a/src/components/reaction-bar.test.tsx b/src/components/reaction-bar.test.tsx new file mode 100644 index 0000000..98be3d5 --- /dev/null +++ b/src/components/reaction-bar.test.tsx @@ -0,0 +1,65 @@ +/** + * Tests for ReactionBar component. + */ + +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { axe } from 'vitest-axe' +import { ReactionBar } from './reaction-bar' + +const defaultReactions = [ + { type: 'like', count: 5, reacted: false }, + { type: 'celebrate', count: 2, reacted: true }, +] + +describe('ReactionBar', () => { + it('renders reaction buttons', () => { + render() + expect(screen.getByRole('button', { name: /like/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /celebrate/i })).toBeInTheDocument() + }) + + it('shows reaction counts', () => { + render() + expect(screen.getByRole('button', { name: /like.*5/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /celebrate.*2/i })).toBeInTheDocument() + }) + + it('marks reacted buttons with aria-pressed', () => { + render() + expect(screen.getByRole('button', { name: /like/i })).toHaveAttribute('aria-pressed', 'false') + expect(screen.getByRole('button', { name: /celebrate/i })).toHaveAttribute( + 'aria-pressed', + 'true' + ) + }) + + it('calls onToggle when clicking a reaction', async () => { + const user = userEvent.setup() + const onToggle = vi.fn() + render() + + await user.click(screen.getByRole('button', { name: /like/i })) + expect(onToggle).toHaveBeenCalledWith('like') + }) + + it('renders empty state with no reactions', () => { + render() + // Should still render the container but be empty + const group = screen.getByRole('group', { name: 'Reactions' }) + expect(group).toBeInTheDocument() + }) + + it('disables buttons when disabled prop is true', () => { + render() + expect(screen.getByRole('button', { name: /like/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /celebrate/i })).toBeDisabled() + }) + + it('passes axe accessibility check', async () => { + const { container } = render() + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/src/components/reaction-bar.tsx b/src/components/reaction-bar.tsx new file mode 100644 index 0000000..958e557 --- /dev/null +++ b/src/components/reaction-bar.tsx @@ -0,0 +1,54 @@ +/** + * ReactionBar - Displays reaction buttons with counts and toggle state. + * Uses aria-pressed for toggle buttons and role="group" for the container. + * @see specs/prd-web.md Section M7 (Reactions + Moderation UI) + */ + +'use client' + +import { cn } from '@/lib/utils' + +interface ReactionData { + type: string + count: number + reacted: boolean +} + +interface ReactionBarProps { + reactions: ReactionData[] + onToggle: (type: string) => void + disabled?: boolean + className?: string +} + +export function ReactionBar({ + reactions, + onToggle, + disabled = false, + className, +}: ReactionBarProps) { + return ( +
+ {reactions.map((reaction) => ( + + ))} +
+ ) +} diff --git a/src/components/reply-card.test.tsx b/src/components/reply-card.test.tsx index 3b55f0b..bcc55fc 100644 --- a/src/components/reply-card.test.tsx +++ b/src/components/reply-card.test.tsx @@ -2,8 +2,9 @@ * Tests for ReplyCard component. */ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { axe } from 'vitest-axe' import { ReplyCard } from './reply-card' import { mockReplies } from '@/mocks/data' @@ -11,6 +12,8 @@ import { mockReplies } from '@/mocks/data' const reply = mockReplies[0]! const nestedReply = mockReplies[1]! // depth 1 +const mockReactions = [{ type: 'like', count: 3, reacted: true }] + describe('ReplyCard', () => { it('renders reply content', () => { render() @@ -64,4 +67,41 @@ describe('ReplyCard', () => { const results = await axe(container) expect(results).toHaveNoViolations() }) + + it('renders reaction bar when reactions are provided', () => { + render( + + ) + expect(screen.getByRole('group', { name: 'Reactions' })).toBeInTheDocument() + }) + + it('renders report button when canReport is true', () => { + render() + expect(screen.getByRole('button', { name: /report/i })).toBeInTheDocument() + }) + + it('renders self-label indicator when selfLabels are provided', () => { + render() + expect(screen.getByText(/content warning/i)).toBeInTheDocument() + }) + + it('calls onReactionToggle when reaction is clicked', async () => { + const user = userEvent.setup() + const onToggle = vi.fn() + render( + + ) + await user.click(screen.getByRole('button', { name: /like/i })) + expect(onToggle).toHaveBeenCalledWith('like') + }) }) diff --git a/src/components/reply-card.tsx b/src/components/reply-card.tsx index c79ba92..45562a0 100644 --- a/src/components/reply-card.tsx +++ b/src/components/reply-card.tsx @@ -1,5 +1,6 @@ /** * ReplyCard - Displays a single reply with depth indication. + * Includes reactions and report button. * Depth is shown via left margin indentation. * @see specs/prd-web.md Section 4 (Topic Components) */ @@ -9,10 +10,24 @@ import type { Reply } from '@/lib/api/types' import { cn } from '@/lib/utils' import { formatRelativeTime, formatCompactNumber } from '@/lib/format' import { MarkdownContent } from './markdown-content' +import { ReactionBar } from './reaction-bar' +import { ReportDialog, type ReportSubmission } from './report-dialog' +import { SelfLabelIndicator } from './self-label-indicator' + +interface ReactionData { + type: string + count: number + reacted: boolean +} interface ReplyCardProps { reply: Reply postNumber: number + reactions?: ReactionData[] + onReactionToggle?: (type: string) => void + canReport?: boolean + onReport?: (report: ReportSubmission) => void + selfLabels?: string[] className?: string } @@ -23,7 +38,16 @@ const DEPTH_INDENT: Record = { 3: 'ml-16 sm:ml-20', } -export function ReplyCard({ reply, postNumber, className }: ReplyCardProps) { +export function ReplyCard({ + reply, + postNumber, + reactions, + onReactionToggle, + canReport, + onReport, + selfLabels, + className, +}: ReplyCardProps) { const headingId = `reply-heading-${reply.rkey}` const indent = DEPTH_INDENT[Math.min(reply.depth, 3)] ?? DEPTH_INDENT[3] @@ -58,11 +82,20 @@ export function ReplyCard({ reply, postNumber, className }: ReplyCardProps) { {/* Content */}
- + {selfLabels && selfLabels.length > 0 ? ( + + + + ) : ( + + )}
{/* Footer */}
+ {reactions && onReactionToggle && ( + + )}
diff --git a/src/components/report-dialog.test.tsx b/src/components/report-dialog.test.tsx new file mode 100644 index 0000000..e1f3b00 --- /dev/null +++ b/src/components/report-dialog.test.tsx @@ -0,0 +1,82 @@ +/** + * Tests for ReportDialog component. + */ + +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { axe } from 'vitest-axe' +import { ReportDialog } from './report-dialog' + +describe('ReportDialog', () => { + it('renders report button', () => { + render() + expect(screen.getByRole('button', { name: /report/i })).toBeInTheDocument() + }) + + it('opens dialog on button click', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: /report/i })) + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('shows reason categories matching AT Protocol types', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: /report/i })) + expect(screen.getByLabelText(/spam/i)).toBeInTheDocument() + expect(screen.getByLabelText(/sexual content/i)).toBeInTheDocument() + expect(screen.getByLabelText(/harassment/i)).toBeInTheDocument() + expect(screen.getByLabelText(/rule violation/i)).toBeInTheDocument() + expect(screen.getByLabelText(/misleading/i)).toBeInTheDocument() + expect(screen.getByLabelText(/other/i)).toBeInTheDocument() + }) + + it('requires reason selection before submit', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn() + render() + await user.click(screen.getByRole('button', { name: /report/i })) + await user.click(screen.getByRole('button', { name: /submit report/i })) + expect(onSubmit).not.toHaveBeenCalled() + expect(screen.getByText(/select a reason/i)).toBeInTheDocument() + }) + + it('submits report with selected reason and optional text', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn() + render() + await user.click(screen.getByRole('button', { name: /report/i })) + await user.click(screen.getByLabelText(/spam/i)) + await user.type(screen.getByLabelText(/additional details/i), 'This is spam content') + await user.click(screen.getByRole('button', { name: /submit report/i })) + expect(onSubmit).toHaveBeenCalledWith({ + subjectUri: 'at://test', + reason: 'spam', + details: 'This is spam content', + }) + }) + + it('closes dialog on cancel', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: /report/i })) + expect(screen.getByRole('dialog')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: /cancel/i })) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('hides report button when disabled', () => { + render() + expect(screen.queryByRole('button', { name: /report/i })).not.toBeInTheDocument() + }) + + it('passes axe accessibility check when dialog is open', async () => { + const user = userEvent.setup() + const { container } = render() + await user.click(screen.getByRole('button', { name: /report/i })) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/src/components/report-dialog.tsx b/src/components/report-dialog.tsx new file mode 100644 index 0000000..21b44fa --- /dev/null +++ b/src/components/report-dialog.tsx @@ -0,0 +1,192 @@ +/** + * ReportDialog - Report content with AT Protocol reason categories. + * Button + accessible dialog with reason selection and optional details. + * Follows com.atproto.moderation.defs reason types. + * @see specs/prd-web.md Section M7 (Report button) + * @see decisions/content-moderation.md + */ + +'use client' + +import { useState, useEffect, useRef, useCallback } from 'react' +import { Flag } from '@phosphor-icons/react' +import { cn } from '@/lib/utils' + +export interface ReportSubmission { + subjectUri: string + reason: string + details: string +} + +interface ReportDialogProps { + subjectUri: string + onSubmit: (report: ReportSubmission) => void + disabled?: boolean + className?: string +} + +const REPORT_REASONS = [ + { value: 'spam', label: 'Spam' }, + { value: 'sexual', label: 'Sexual content' }, + { value: 'harassment', label: 'Harassment' }, + { value: 'violation', label: 'Rule violation' }, + { value: 'misleading', label: 'Misleading' }, + { value: 'other', label: 'Other' }, +] as const + +export function ReportDialog({ + subjectUri, + onSubmit, + disabled = false, + className, +}: ReportDialogProps) { + const [open, setOpen] = useState(false) + const [reason, setReason] = useState('') + const [details, setDetails] = useState('') + const [error, setError] = useState('') + const dialogRef = useRef(null) + + const handleClose = useCallback(() => { + setOpen(false) + setReason('') + setDetails('') + setError('') + }, []) + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + handleClose() + } + }, + [handleClose] + ) + + useEffect(() => { + if (open) { + document.addEventListener('keydown', handleKeyDown) + } + return () => { + document.removeEventListener('keydown', handleKeyDown) + } + }, [open, handleKeyDown]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!reason) { + setError('Please select a reason') + return + } + onSubmit({ subjectUri, reason, details }) + handleClose() + } + + if (disabled) return null + + return ( + <> + + + {open && ( +
+