drag reorder boards in admin, position respected on public site

This commit is contained in:
2026-03-22 07:55:08 +02:00
parent 393001c07c
commit a530ce67b0
4 changed files with 61 additions and 8 deletions

View File

@@ -33,6 +33,7 @@ model Board {
rssEnabled Boolean @default(true)
rssFeedCount Int @default(50)
staleDays Int @default(0)
position Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -44,7 +44,7 @@ export default async function adminBoardRoutes(app: FastifyInstance) {
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (_req, reply) => {
const boards = await prisma.board.findMany({
orderBy: { createdAt: "asc" },
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
include: {
_count: { select: { posts: true } },
},
@@ -128,4 +128,17 @@ export default async function adminBoardRoutes(app: FastifyInstance) {
reply.status(204).send();
}
);
// reorder boards
app.put(
"/admin/boards/reorder",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = z.object({ boardIds: z.array(z.string().min(1)).min(1) }).parse(req.body);
await prisma.$transaction(
body.boardIds.map((id, i) => prisma.board.update({ where: { id }, data: { position: i } }))
);
reply.send({ ok: true });
}
);
}

View File

@@ -55,7 +55,7 @@ export default async function boardRoutes(app: FastifyInstance) {
include: {
_count: { select: { posts: true } },
},
orderBy: { createdAt: "asc" },
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
});
// use aggregation queries instead of loading all posts into memory

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../../lib/api'
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
@@ -25,6 +25,7 @@ interface Board {
rssEnabled: boolean
rssFeedCount: number
staleDays: number
position: number
}
export default function AdminBoards() {
@@ -35,6 +36,8 @@ export default function AdminBoards() {
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: '',
@@ -62,6 +65,21 @@ export default function AdminBoards() {
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)
@@ -158,13 +176,30 @@ export default function AdminBoards() {
))}
</div>
) : (
<div className="flex flex-col gap-3">
{boards.map((board) => (
<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
key={board.id}
className="card p-4 flex items-center gap-4"
style={{ opacity: board.isArchived ? 0.5 : 1 }}
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">
@@ -203,6 +238,10 @@ export default function AdminBoards() {
)}
</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 && (