+ void handleMove(id, newParentId, newSortOrder)
+ }
+ onEdit={handleEdit}
+ onDelete={(id) => void handleDelete(id)}
+ />
)}
diff --git a/src/components/admin/categories/category-form.tsx b/src/components/admin/categories/category-form.tsx
index 34f8611..15996dc 100644
--- a/src/components/admin/categories/category-form.tsx
+++ b/src/components/admin/categories/category-form.tsx
@@ -3,9 +3,10 @@
* @see specs/prd-web.md Section M11
*/
-import type { MaturityRating } from '@/lib/api/types'
+import type { CategoryTreeNode, MaturityRating } from '@/lib/api/types'
import { SaveButton } from '@/components/admin/save-button'
import { FormLabel } from '@/components/ui/form-label'
+import { flattenCategoryTree } from '@/lib/flatten-category-tree'
import type { SaveStatus } from '@/hooks/use-save-state'
export interface EditingCategory {
@@ -19,6 +20,7 @@ export interface EditingCategory {
interface CategoryFormProps {
editing: EditingCategory
+ categories: CategoryTreeNode[]
onChange: (cat: EditingCategory) => void
onSave: () => void
onCancel: () => void
@@ -27,6 +29,7 @@ interface CategoryFormProps {
export function CategoryForm({
editing,
+ categories,
onChange,
onSave,
onCancel,
@@ -76,6 +79,27 @@ export function CategoryForm({
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground"
/>
+
+
+ Parent Category
+
+
+
Maturity Rating
diff --git a/src/components/admin/categories/category-row.tsx b/src/components/admin/categories/category-row.tsx
index 626cc99..4e20015 100644
--- a/src/components/admin/categories/category-row.tsx
+++ b/src/components/admin/categories/category-row.tsx
@@ -3,7 +3,9 @@
* @see specs/prd-web.md Section M11
*/
-import { PencilSimple, TrashSimple } from '@phosphor-icons/react'
+import { useSortable } from '@dnd-kit/sortable'
+import { CSS } from '@dnd-kit/utilities'
+import { DotsSixVertical, PencilSimple, TrashSimple } from '@phosphor-icons/react'
import { cn } from '@/lib/utils'
import type { CategoryTreeNode, MaturityRating } from '@/lib/api/types'
@@ -83,3 +85,82 @@ export function CategoryRow({ category, depth, onEdit, onDelete }: CategoryRowPr
>
)
}
+
+export function DraggableCategoryRow({ category, depth, onEdit, onDelete }: CategoryRowProps) {
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
+ id: category.id,
+ })
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ }
+
+ return (
+ <>
+ 0 && 'ml-6',
+ isDragging && 'opacity-50'
+ )}
+ >
+
+
+
+
{category.name}
+ {category.description && (
+
{category.description}
+ )}
+
+
+
+
+ {MATURITY_LABELS[category.maturityRating]}
+
+
+
+
+
+ {category.children.map((child) => (
+
+ ))}
+ >
+ )
+}
diff --git a/src/components/admin/categories/category-tree-dnd.tsx b/src/components/admin/categories/category-tree-dnd.tsx
new file mode 100644
index 0000000..59506d7
--- /dev/null
+++ b/src/components/admin/categories/category-tree-dnd.tsx
@@ -0,0 +1,97 @@
+/**
+ * CategoryTreeDnD - DnD wrapper for the admin category tree.
+ * Handles drag-and-drop reordering and reparenting of categories.
+ */
+
+'use client'
+
+import { useState, useCallback } from 'react'
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+ DragOverlay,
+ type DragStartEvent,
+} from '@dnd-kit/core'
+import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
+import type { CategoryTreeNode } from '@/lib/api/types'
+import { flattenCategoryTree } from '@/lib/flatten-category-tree'
+import { DraggableCategoryRow } from './category-row'
+
+interface CategoryTreeDnDProps {
+ categories: CategoryTreeNode[]
+ onMove: (categoryId: string, newParentId: string | null, newSortOrder: number) => void
+ onEdit: (cat: CategoryTreeNode) => void
+ onDelete: (id: string) => void
+}
+
+export function CategoryTreeDnD({ categories, onMove, onEdit, onDelete }: CategoryTreeDnDProps) {
+ const [activeId, setActiveId] = useState(null)
+ const flatItems = flattenCategoryTree(categories)
+ const itemIds = flatItems.map((item) => item.category.id)
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
+ useSensor(KeyboardSensor)
+ )
+
+ const handleDragStart = useCallback((event: DragStartEvent) => {
+ setActiveId(String(event.active.id))
+ }, [])
+
+ const handleDragEnd = useCallback(
+ (event: DragEndEvent) => {
+ setActiveId(null)
+ const { active, over } = event
+ if (!over || active.id === over.id) return
+
+ const activeItem = flatItems.find((i) => i.category.id === active.id)
+ const overItem = flatItems.find((i) => i.category.id === over.id)
+ if (!activeItem || !overItem) return
+
+ const newParentId = overItem.category.parentId
+ const siblingsAtLevel = flatItems.filter((i) => i.category.parentId === newParentId)
+ const overIndex = siblingsAtLevel.findIndex((i) => i.category.id === over.id)
+ const newSortOrder = Math.max(0, overIndex)
+
+ onMove(String(active.id), newParentId, newSortOrder)
+ },
+ [flatItems, onMove]
+ )
+
+ const activeItem = activeId ? flatItems.find((i) => i.category.id === activeId) : null
+
+ return (
+
+
+
+ {categories.map((category) => (
+
+ ))}
+
+
+
+ {activeItem ? (
+
+
{activeItem.category.name}
+
+ ) : null}
+
+
+ )
+}
diff --git a/src/components/topic-form.tsx b/src/components/topic-form.tsx
index 7ddd8ee..954d87c 100644
--- a/src/components/topic-form.tsx
+++ b/src/components/topic-form.tsx
@@ -8,7 +8,7 @@
'use client'
import { useState, useCallback } from 'react'
-import type { CreateTopicInput } from '@/lib/api/types'
+import type { CreateTopicInput, CategoryTreeNode } from '@/lib/api/types'
import { cn } from '@/lib/utils'
import { TopicMetaFields } from '@/components/topic-meta-fields'
import { TopicContentEditor } from '@/components/topic-content-editor'
@@ -22,18 +22,25 @@ interface TopicFormProps {
onSubmit: (values: CreateTopicInput) => void | Promise
initialValues?: Partial
mode?: 'create' | 'edit'
- categories?: Array<{ slug: string; name: string }>
+ categories?: CategoryTreeNode[]
submitting?: boolean
className?: string
}
-const CATEGORIES_FALLBACK = [
- { slug: 'general', name: 'General Discussion' },
- { slug: 'development', name: 'Development' },
- { slug: 'frontend', name: 'Frontend' },
- { slug: 'backend', name: 'Backend' },
- { slug: 'feedback', name: 'Feedback & Ideas' },
- { slug: 'meta', name: 'Meta' },
+const CATEGORIES_FALLBACK: CategoryTreeNode[] = [
+ {
+ id: 'fallback-general',
+ slug: 'general',
+ name: 'General Discussion',
+ description: null,
+ parentId: null,
+ sortOrder: 0,
+ communityDid: '',
+ maturityRating: 'safe',
+ createdAt: '',
+ updatedAt: '',
+ children: [],
+ },
]
export function TopicForm({
diff --git a/src/components/topic-meta-fields.test.tsx b/src/components/topic-meta-fields.test.tsx
new file mode 100644
index 0000000..4e98aa1
--- /dev/null
+++ b/src/components/topic-meta-fields.test.tsx
@@ -0,0 +1,86 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { axe } from 'vitest-axe'
+import { TopicMetaFields } from './topic-meta-fields'
+import type { CategoryTreeNode } from '@/lib/api/types'
+
+const COMMUNITY_DID = 'did:plc:test'
+const NOW = '2026-01-01T00:00:00.000Z'
+
+const treeCategories: CategoryTreeNode[] = [
+ {
+ id: 'cat-1',
+ slug: 'general',
+ name: 'General',
+ description: null,
+ parentId: null,
+ sortOrder: 0,
+ communityDid: COMMUNITY_DID,
+ maturityRating: 'safe',
+ createdAt: NOW,
+ updatedAt: NOW,
+ children: [],
+ },
+ {
+ id: 'cat-2',
+ slug: 'dev',
+ name: 'Development',
+ description: null,
+ parentId: null,
+ sortOrder: 1,
+ communityDid: COMMUNITY_DID,
+ maturityRating: 'safe',
+ createdAt: NOW,
+ updatedAt: NOW,
+ children: [
+ {
+ id: 'cat-3',
+ slug: 'frontend',
+ name: 'Frontend',
+ description: null,
+ parentId: 'cat-2',
+ sortOrder: 0,
+ communityDid: COMMUNITY_DID,
+ maturityRating: 'safe',
+ createdAt: NOW,
+ updatedAt: NOW,
+ children: [],
+ },
+ ],
+ },
+]
+
+describe('TopicMetaFields', () => {
+ const defaultProps = {
+ title: '',
+ category: '',
+ tagInput: '',
+ categories: treeCategories,
+ errors: {},
+ onTitleChange: vi.fn(),
+ onCategoryChange: vi.fn(),
+ onTagInputChange: vi.fn(),
+ }
+
+ it('renders category options with hierarchy indentation', () => {
+ render()
+ const select = screen.getByLabelText(/category/i) as HTMLSelectElement
+ const options = Array.from(select.options)
+ // First is placeholder
+ expect(options[0]).toHaveTextContent('Select a category')
+ // "General" at root
+ expect(options[1]).toHaveTextContent('General')
+ // "Development" at root
+ expect(options[2]).toHaveTextContent('Development')
+ // "Frontend" indented under Development (contains non-breaking spaces)
+ const frontendOption = options[3]
+ expect(frontendOption?.textContent).toContain('Frontend')
+ expect(frontendOption?.value).toBe('frontend')
+ })
+
+ it('passes axe accessibility check', async () => {
+ const { container } = render()
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/src/components/topic-meta-fields.tsx b/src/components/topic-meta-fields.tsx
index 966eea6..8ed55c3 100644
--- a/src/components/topic-meta-fields.tsx
+++ b/src/components/topic-meta-fields.tsx
@@ -5,12 +5,14 @@
import { cn } from '@/lib/utils'
import { FormLabel } from '@/components/ui/form-label'
+import type { CategoryTreeNode } from '@/lib/api/types'
+import { flattenCategoryTree } from '@/lib/flatten-category-tree'
interface TopicMetaFieldsProps {
title: string
category: string
tagInput: string
- categories: Array<{ slug: string; name: string }>
+ categories: CategoryTreeNode[]
errors: { title?: string; category?: string }
onTitleChange: (title: string) => void
onCategoryChange: (category: string) => void
@@ -73,8 +75,9 @@ export function TopicMetaFields({
)}
>
- {categories.map((cat) => (
+ {flattenCategoryTree(categories).map(({ category: cat, depth }) => (
))}
diff --git a/src/lib/flatten-category-tree.test.ts b/src/lib/flatten-category-tree.test.ts
new file mode 100644
index 0000000..b2ffc43
--- /dev/null
+++ b/src/lib/flatten-category-tree.test.ts
@@ -0,0 +1,75 @@
+import { describe, it, expect } from 'vitest'
+import { flattenCategoryTree } from './flatten-category-tree'
+import type { CategoryTreeNode } from '@/lib/api/types'
+
+const COMMUNITY_DID = 'did:plc:test'
+const NOW = '2026-01-01T00:00:00.000Z'
+
+function makeCategory(
+ overrides: Partial & { id: string; name: string }
+): CategoryTreeNode {
+ return {
+ slug: overrides.id,
+ description: null,
+ parentId: null,
+ sortOrder: 0,
+ communityDid: COMMUNITY_DID,
+ maturityRating: 'safe',
+ createdAt: NOW,
+ updatedAt: NOW,
+ children: [],
+ ...overrides,
+ }
+}
+
+describe('flattenCategoryTree', () => {
+ it('returns empty array for empty input', () => {
+ expect(flattenCategoryTree([])).toEqual([])
+ })
+
+ it('flattens a single root category', () => {
+ const tree = [makeCategory({ id: 'cat-1', name: 'General' })]
+ const result = flattenCategoryTree(tree)
+ expect(result).toEqual([{ category: tree[0], depth: 0 }])
+ })
+
+ it('flattens nested categories in depth-first order', () => {
+ const tree: CategoryTreeNode[] = [
+ makeCategory({
+ id: 'cat-1',
+ name: 'Dev',
+ children: [
+ makeCategory({ id: 'cat-2', name: 'Frontend', parentId: 'cat-1' }),
+ makeCategory({
+ id: 'cat-3',
+ name: 'Backend',
+ parentId: 'cat-1',
+ children: [makeCategory({ id: 'cat-4', name: 'Databases', parentId: 'cat-3' })],
+ }),
+ ],
+ }),
+ makeCategory({ id: 'cat-5', name: 'Meta' }),
+ ]
+ const result = flattenCategoryTree(tree)
+ expect(result.map((r) => ({ id: r.category.id, depth: r.depth }))).toEqual([
+ { id: 'cat-1', depth: 0 },
+ { id: 'cat-2', depth: 1 },
+ { id: 'cat-3', depth: 1 },
+ { id: 'cat-4', depth: 2 },
+ { id: 'cat-5', depth: 0 },
+ ])
+ })
+
+ it('excludes a category and all its descendants', () => {
+ const tree: CategoryTreeNode[] = [
+ makeCategory({
+ id: 'cat-1',
+ name: 'Dev',
+ children: [makeCategory({ id: 'cat-2', name: 'Frontend', parentId: 'cat-1' })],
+ }),
+ makeCategory({ id: 'cat-3', name: 'Meta' }),
+ ]
+ const result = flattenCategoryTree(tree, { excludeId: 'cat-1' })
+ expect(result).toEqual([{ category: tree[1], depth: 0 }])
+ })
+})
diff --git a/src/lib/flatten-category-tree.ts b/src/lib/flatten-category-tree.ts
new file mode 100644
index 0000000..2ec8d33
--- /dev/null
+++ b/src/lib/flatten-category-tree.ts
@@ -0,0 +1,29 @@
+import type { CategoryTreeNode } from '@/lib/api/types'
+
+export interface FlatCategory {
+ category: CategoryTreeNode
+ depth: number
+}
+
+interface FlattenOptions {
+ /** Exclude this category ID and all its descendants. */
+ excludeId?: string
+}
+
+export function flattenCategoryTree(
+ tree: CategoryTreeNode[],
+ options: FlattenOptions = {}
+): FlatCategory[] {
+ const result: FlatCategory[] = []
+
+ function walk(nodes: CategoryTreeNode[], depth: number): void {
+ for (const node of nodes) {
+ if (options.excludeId && node.id === options.excludeId) continue
+ result.push({ category: node, depth })
+ walk(node.children, depth + 1)
+ }
+ }
+
+ walk(tree, 0)
+ return result
+}