security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states
This commit is contained in:
@@ -1,19 +1,31 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../../lib/api'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import { useFocusTrap } from '../../hooks/useFocusTrap'
|
||||
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
|
||||
postCount: number
|
||||
archived: boolean
|
||||
_count?: { posts: number }
|
||||
isArchived: boolean
|
||||
iconName: string | null
|
||||
iconColor: string | null
|
||||
voteBudget: number
|
||||
voteResetSchedule: string
|
||||
voteBudgetReset: string
|
||||
rssEnabled: boolean
|
||||
rssFeedCount: number
|
||||
staleDays: number
|
||||
}
|
||||
|
||||
export default function AdminBoards() {
|
||||
useDocumentTitle('Manage Boards')
|
||||
const [boards, setBoards] = useState<Board[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editBoard, setEditBoard] = useState<Board | null>(null)
|
||||
@@ -22,10 +34,16 @@ export default function AdminBoards() {
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
iconName: null as string | null,
|
||||
iconColor: null as string | null,
|
||||
voteBudget: 10,
|
||||
voteResetSchedule: 'monthly',
|
||||
voteBudgetReset: 'monthly',
|
||||
rssEnabled: true,
|
||||
rssFeedCount: 50,
|
||||
staleDays: 0,
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const boardTrapRef = useFocusTrap(showCreate)
|
||||
|
||||
const fetchBoards = async () => {
|
||||
try {
|
||||
@@ -39,7 +57,7 @@ export default function AdminBoards() {
|
||||
useEffect(() => { fetchBoards() }, [])
|
||||
|
||||
const resetForm = () => {
|
||||
setForm({ name: '', slug: '', description: '', voteBudget: 10, voteResetSchedule: 'monthly' })
|
||||
setForm({ name: '', slug: '', description: '', iconName: null, iconColor: null, voteBudget: 10, voteBudgetReset: 'monthly', rssEnabled: true, rssFeedCount: 50, staleDays: 0 })
|
||||
setEditBoard(null)
|
||||
setShowCreate(false)
|
||||
}
|
||||
@@ -50,8 +68,13 @@ export default function AdminBoards() {
|
||||
name: b.name,
|
||||
slug: b.slug,
|
||||
description: b.description,
|
||||
iconName: b.iconName,
|
||||
iconColor: b.iconColor,
|
||||
voteBudget: b.voteBudget,
|
||||
voteResetSchedule: b.voteResetSchedule,
|
||||
voteBudgetReset: b.voteBudgetReset,
|
||||
rssEnabled: b.rssEnabled,
|
||||
rssFeedCount: b.rssFeedCount,
|
||||
staleDays: b.staleDays ?? 0,
|
||||
})
|
||||
setShowCreate(true)
|
||||
}
|
||||
@@ -71,9 +94,9 @@ export default function AdminBoards() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleArchive = async (id: string, archived: boolean) => {
|
||||
const handleArchive = async (id: string, isArchived: boolean) => {
|
||||
try {
|
||||
await api.patch(`/admin/boards/${id}`, { archived: !archived })
|
||||
await api.put(`/admin/boards/${id}`, { isArchived: !isArchived })
|
||||
fetchBoards()
|
||||
} catch {}
|
||||
}
|
||||
@@ -81,11 +104,11 @@ export default function AdminBoards() {
|
||||
const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1
|
||||
className="text-2xl font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
|
||||
className="font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
|
||||
>
|
||||
Boards
|
||||
</h1>
|
||||
@@ -98,11 +121,14 @@ export default function AdminBoards() {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full"
|
||||
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--admin-accent)', animation: 'spin 0.6s linear infinite' }}
|
||||
/>
|
||||
<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-3">
|
||||
@@ -110,43 +136,34 @@ export default function AdminBoards() {
|
||||
<div
|
||||
key={board.id}
|
||||
className="card p-4 flex items-center gap-4"
|
||||
style={{ opacity: board.archived ? 0.5 : 1 }}
|
||||
style={{ opacity: board.isArchived ? 0.5 : 1 }}
|
||||
>
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center text-sm font-bold shrink-0"
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
background: 'var(--admin-subtle)',
|
||||
color: 'var(--admin-accent)',
|
||||
}}
|
||||
>
|
||||
{board.name.charAt(0)}
|
||||
</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">
|
||||
<h3 className="text-sm font-medium" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
|
||||
<h2 className="text-sm font-medium" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
|
||||
{board.name}
|
||||
</h3>
|
||||
{board.archived && (
|
||||
</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.postCount} posts - Budget: {board.voteBudget}/{board.voteResetSchedule}
|
||||
/{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 py-1" style={{ color: 'var(--admin-accent)' }}>
|
||||
<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.archived)}
|
||||
className="btn btn-ghost text-xs px-2 py-1"
|
||||
style={{ color: board.archived ? 'var(--success)' : 'var(--warning)' }}
|
||||
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.archived ? 'Restore' : 'Archive'}
|
||||
{board.isArchived ? 'Restore' : 'Archive'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,18 +186,24 @@ export default function AdminBoards() {
|
||||
>
|
||||
<div className="absolute inset-0 fade-in" style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }} />
|
||||
<div
|
||||
className="relative w-full max-w-md mx-4 p-6 rounded-xl shadow-2xl slide-up"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
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()}
|
||||
>
|
||||
<h3 className="text-base font-bold mb-4" style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}>
|
||||
<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'}
|
||||
</h3>
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Name</label>
|
||||
<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) => {
|
||||
@@ -194,8 +217,9 @@ export default function AdminBoards() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Slug</label>
|
||||
<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 }))}
|
||||
@@ -203,8 +227,9 @@ export default function AdminBoards() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Description</label>
|
||||
<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}
|
||||
@@ -213,30 +238,80 @@ export default function AdminBoards() {
|
||||
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 className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Vote Budget</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
<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}
|
||||
value={form.voteBudget}
|
||||
onChange={(e) => setForm((f) => ({ ...f, voteBudget: parseInt(e.target.value) || 10 }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Reset Schedule</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.voteResetSchedule}
|
||||
onChange={(e) => setForm((f) => ({ ...f, voteResetSchedule: e.target.value }))}
|
||||
>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="never">Never</option>
|
||||
</select>
|
||||
<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={{ width: 100 }}
|
||||
/>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user