Files
echoboard/packages/web/src/pages/admin/AdminBoards.tsx

411 lines
16 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../../lib/api'
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
import { useFocusTrap } from '../../hooks/useFocusTrap'
import { useAdmin } from '../../hooks/useAdmin'
import { useConfirm } from '../../hooks/useConfirm'
import { useToast } from '../../hooks/useToast'
import Dropdown from '../../components/Dropdown'
import NumberInput from '../../components/NumberInput'
import IconPicker from '../../components/IconPicker'
import BoardIcon from '../../components/BoardIcon'
interface Board {
id: string
slug: string
name: string
description: string
_count?: { posts: number }
isArchived: boolean
iconName: string | null
iconColor: string | null
voteBudget: number
voteBudgetReset: string
rssEnabled: boolean
rssFeedCount: number
staleDays: number
position: number
}
export default function AdminBoards() {
useDocumentTitle('Manage Boards')
const { isSuperAdmin } = useAdmin()
const confirm = useConfirm()
const toast = useToast()
const [boards, setBoards] = useState<Board[]>([])
const [loading, setLoading] = useState(true)
const [editBoard, setEditBoard] = useState<Board | null>(null)
const [dragIdx, setDragIdx] = useState<number | null>(null)
const [insertAt, setInsertAt] = useState<number | null>(null)
const [showCreate, setShowCreate] = useState(false)
const [form, setForm] = useState({
name: '',
slug: '',
description: '',
iconName: null as string | null,
iconColor: null as string | null,
voteBudget: 10,
voteBudgetReset: 'monthly',
rssEnabled: true,
rssFeedCount: 50,
staleDays: 0,
})
const [saving, setSaving] = useState(false)
const boardTrapRef = useFocusTrap(showCreate)
const fetchBoards = async () => {
try {
const b = await api.get<Board[]>('/admin/boards')
setBoards(b)
} catch {} finally {
setLoading(false)
}
}
useEffect(() => { fetchBoards() }, [])
const handleDrop = useCallback(() => {
if (dragIdx === null || insertAt === null) return
setBoards((prev) => {
const next = [...prev]
const [moved] = next.splice(dragIdx, 1)
next.splice(insertAt > dragIdx ? insertAt - 1 : insertAt, 0, moved)
api.put('/admin/boards/reorder', { boardIds: next.map((b) => b.id) }).catch(() => {
toast.error('Failed to save order')
})
return next
})
setDragIdx(null)
setInsertAt(null)
}, [dragIdx, insertAt, toast])
const resetForm = () => {
setForm({ name: '', slug: '', description: '', iconName: null, iconColor: null, voteBudget: 10, voteBudgetReset: 'monthly', rssEnabled: true, rssFeedCount: 50, staleDays: 0 })
setEditBoard(null)
setShowCreate(false)
}
const openEdit = (b: Board) => {
setEditBoard(b)
setForm({
name: b.name,
slug: b.slug,
description: b.description,
iconName: b.iconName,
iconColor: b.iconColor,
voteBudget: b.voteBudget,
voteBudgetReset: b.voteBudgetReset,
rssEnabled: b.rssEnabled,
rssFeedCount: b.rssFeedCount,
staleDays: b.staleDays ?? 0,
})
setShowCreate(true)
}
const handleSave = async () => {
setSaving(true)
try {
if (editBoard) {
await api.put(`/admin/boards/${editBoard.id}`, form)
toast.success('Board updated')
} else {
await api.post('/admin/boards', form)
toast.success('Board created')
}
resetForm()
fetchBoards()
} catch {
toast.error('Failed to save board')
} finally {
setSaving(false)
}
}
const handleArchive = async (id: string, isArchived: boolean) => {
try {
await api.put(`/admin/boards/${id}`, { isArchived: !isArchived })
fetchBoards()
toast.info(isArchived ? 'Board restored' : 'Board archived')
} catch {
toast.error('Failed to update board')
}
}
const handleDelete = async (board: Board) => {
const ok = await confirm(
`Permanently delete "${board.name}" and all its posts, comments, and votes? This cannot be undone.`
)
if (!ok) return
try {
await api.delete(`/admin/boards/${board.id}`)
fetchBoards()
toast.success('Board deleted')
} catch {
toast.error('Failed to delete board')
}
}
const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
return (
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
<div className="flex items-center justify-between mb-6">
<h1
className="font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
>
Boards
</h1>
<div className="flex gap-2">
<Link to="/admin" className="btn btn-ghost text-sm">Back</Link>
<button onClick={() => { resetForm(); setShowCreate(true) }} className="btn btn-admin text-sm">
New Board
</button>
</div>
</div>
{loading ? (
<div>
<div className="progress-bar mb-4" />
{[0, 1, 2].map((i) => (
<div key={i} className="card p-4 flex items-center gap-4 mb-3" style={{ opacity: 1 - i * 0.2 }}>
<div className="skeleton w-10 h-10 rounded-lg" />
<div className="flex-1"><div className="skeleton h-4 mb-2" style={{ width: '40%' }} /><div className="skeleton h-3" style={{ width: '60%' }} /></div>
</div>
))}
</div>
) : (
<div className="flex flex-col gap-1">
{boards.map((board, i) => (
<div key={board.id}>
{dragIdx !== null && insertAt === i && dragIdx !== i && dragIdx !== i - 1 && (
<div style={{ height: 2, background: 'var(--admin-accent)', borderRadius: 1, margin: '0 12px' }} />
)}
<div
className="card p-4 flex items-center gap-3"
style={{ opacity: board.isArchived ? 0.5 : dragIdx === i ? 0.4 : 1 }}
draggable
onDragStart={() => setDragIdx(i)}
onDragOver={(e) => { e.preventDefault(); setInsertAt(i) }}
onDragEnd={handleDrop}
>
<div
style={{ cursor: 'grab', color: 'var(--text-tertiary)', padding: '4px 0', touchAction: 'none' }}
aria-label="Drag to reorder"
>
<svg width="10" height="16" viewBox="0 0 10 16" fill="currentColor">
<circle cx="2" cy="2" r="1.5" /><circle cx="8" cy="2" r="1.5" />
<circle cx="2" cy="8" r="1.5" /><circle cx="8" cy="8" r="1.5" />
<circle cx="2" cy="14" r="1.5" /><circle cx="8" cy="14" r="1.5" />
</svg>
</div>
<BoardIcon name={board.name} iconName={board.iconName} iconColor={board.iconColor} size={40} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-sm font-medium" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
{board.name}
</h2>
{board.isArchived && (
<span className="text-xs px-1.5 py-0.5 rounded" style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }}>
archived
</span>
)}
</div>
<p className="text-xs truncate" style={{ color: 'var(--text-tertiary)' }}>
/{board.slug} - {board._count?.posts ?? 0} posts - Budget: {board.voteBudget}/{board.voteBudgetReset}
</p>
</div>
<div className="flex gap-1 shrink-0">
<button onClick={() => openEdit(board)} className="btn btn-ghost text-xs px-2" style={{ minHeight: 44, color: 'var(--admin-accent)' }}>
Edit
</button>
<button
onClick={() => handleArchive(board.id, board.isArchived)}
className="btn btn-ghost text-xs px-2"
style={{ minHeight: 44, color: board.isArchived ? 'var(--success)' : 'var(--warning)' }}
>
{board.isArchived ? 'Restore' : 'Archive'}
</button>
{isSuperAdmin && (
<button
onClick={() => handleDelete(board)}
className="action-btn text-xs px-2"
style={{ minHeight: 44, color: 'var(--error)' }}
>
Delete
</button>
)}
</div>
</div>
{dragIdx !== null && insertAt === boards.length && i === boards.length - 1 && dragIdx !== i && (
<div style={{ height: 2, background: 'var(--admin-accent)', borderRadius: 1, margin: '0 12px' }} />
)}
</div>
))}
{boards.length === 0 && (
<div className="text-center py-12">
<p className="text-sm mb-4" style={{ color: 'var(--text-tertiary)' }}>No boards yet</p>
<button onClick={() => setShowCreate(true)} className="btn btn-admin">Create first board</button>
</div>
)}
</div>
)}
{/* Create/Edit modal */}
{showCreate && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center"
onClick={resetForm}
>
<div className="absolute inset-0 fade-in" style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }} />
<div
ref={boardTrapRef}
role="dialog"
aria-modal="true"
aria-labelledby="board-modal-title"
className="relative w-full max-w-md mx-4 fade-in"
style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-xl)', boxShadow: 'var(--shadow-xl)', padding: '24px' }}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.key === 'Escape' && resetForm()}
>
<h2 id="board-modal-title" className="text-base font-bold mb-4" style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}>
{editBoard ? 'Edit Board' : 'New Board'}
</h2>
<div className="flex flex-col gap-3">
<div>
<label htmlFor="board-name" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Name</label>
<input
id="board-name"
className="input"
value={form.name}
onChange={(e) => {
setForm((f) => ({
...f,
name: e.target.value,
slug: editBoard ? f.slug : slugify(e.target.value),
}))
}}
placeholder="Feature Requests"
/>
</div>
<div>
<label htmlFor="board-slug" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Slug</label>
<input
id="board-slug"
className="input"
value={form.slug}
onChange={(e) => setForm((f) => ({ ...f, slug: e.target.value }))}
placeholder="feature-requests"
/>
</div>
<div>
<label htmlFor="board-description" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Description</label>
<textarea
id="board-description"
className="input"
rows={2}
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder="What is this board for?"
style={{ resize: 'vertical' }}
/>
</div>
<div>
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Icon</label>
<IconPicker
value={form.iconName}
color={form.iconColor}
onChangeIcon={(v) => setForm((f) => ({ ...f, iconName: v }))}
onChangeColor={(v) => setForm((f) => ({ ...f, iconColor: v }))}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="board-vote-budget" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Vote Budget</label>
<NumberInput
value={form.voteBudget}
onChange={(v) => setForm((f) => ({ ...f, voteBudget: v }))}
min={1}
max={100}
/>
</div>
<div>
<label htmlFor="board-reset-schedule" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Reset Schedule</label>
<Dropdown
value={form.voteBudgetReset}
onChange={(v) => setForm((f) => ({ ...f, voteBudgetReset: v }))}
options={[
{ value: 'weekly', label: 'Weekly' },
{ value: 'biweekly', label: 'Biweekly' },
{ value: 'monthly', label: 'Monthly' },
{ value: 'quarterly', label: 'Quarterly' },
{ value: 'per_release', label: 'Per release' },
{ value: 'never', label: 'Never' },
]}
/>
</div>
</div>
<div className="col-span-2 flex items-center gap-3 mt-1">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.rssEnabled}
onChange={(e) => setForm((f) => ({ ...f, rssEnabled: e.target.checked }))}
style={{ accentColor: 'var(--admin-accent)' }}
/>
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>RSS Feed</span>
</label>
{form.rssEnabled && (
<div className="flex items-center gap-1">
<label className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Items:</label>
<NumberInput
value={form.rssFeedCount}
onChange={(v) => setForm((f) => ({ ...f, rssFeedCount: v }))}
min={1}
max={200}
style={{ minWidth: 120 }}
/>
</div>
)}
</div>
<div>
<label htmlFor="board-stale-days" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>
Stale after (days)
</label>
<div className="flex items-center gap-2">
<NumberInput
value={form.staleDays}
onChange={(v) => setForm((f) => ({ ...f, staleDays: v }))}
min={0}
max={365}
step={7}
style={{ width: 120 }}
/>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{form.staleDays === 0 ? 'Disabled' : `${form.staleDays} days`}
</span>
</div>
</div>
</div>
<div className="flex gap-3 mt-6">
<button onClick={resetForm} className="btn btn-secondary flex-1">Cancel</button>
<button
onClick={handleSave}
disabled={saving || !form.name.trim() || !form.slug.trim()}
className="btn btn-admin flex-1"
style={{ opacity: saving ? 0.6 : 1 }}
>
{saving ? 'Saving...' : editBoard ? 'Update' : 'Create'}
</button>
</div>
</div>
</div>
)}
</div>
)
}