drag reorder boards in admin, position respected on public site
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user