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)
|
rssEnabled Boolean @default(true)
|
||||||
rssFeedCount Int @default(50)
|
rssFeedCount Int @default(50)
|
||||||
staleDays Int @default(0)
|
staleDays Int @default(0)
|
||||||
|
position Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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" } } },
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||||
async (_req, reply) => {
|
async (_req, reply) => {
|
||||||
const boards = await prisma.board.findMany({
|
const boards = await prisma.board.findMany({
|
||||||
orderBy: { createdAt: "asc" },
|
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||||
include: {
|
include: {
|
||||||
_count: { select: { posts: true } },
|
_count: { select: { posts: true } },
|
||||||
},
|
},
|
||||||
@@ -128,4 +128,17 @@ export default async function adminBoardRoutes(app: FastifyInstance) {
|
|||||||
reply.status(204).send();
|
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: {
|
include: {
|
||||||
_count: { select: { posts: true } },
|
_count: { select: { posts: true } },
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: "asc" },
|
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
// use aggregation queries instead of loading all posts into memory
|
// 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 { Link } from 'react-router-dom'
|
||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||||
@@ -25,6 +25,7 @@ interface Board {
|
|||||||
rssEnabled: boolean
|
rssEnabled: boolean
|
||||||
rssFeedCount: number
|
rssFeedCount: number
|
||||||
staleDays: number
|
staleDays: number
|
||||||
|
position: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminBoards() {
|
export default function AdminBoards() {
|
||||||
@@ -35,6 +36,8 @@ export default function AdminBoards() {
|
|||||||
const [boards, setBoards] = useState<Board[]>([])
|
const [boards, setBoards] = useState<Board[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [editBoard, setEditBoard] = useState<Board | null>(null)
|
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 [showCreate, setShowCreate] = useState(false)
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -62,6 +65,21 @@ export default function AdminBoards() {
|
|||||||
|
|
||||||
useEffect(() => { fetchBoards() }, [])
|
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 = () => {
|
const resetForm = () => {
|
||||||
setForm({ name: '', slug: '', description: '', iconName: null, iconColor: null, voteBudget: 10, voteBudgetReset: 'monthly', rssEnabled: true, rssFeedCount: 50, staleDays: 0 })
|
setForm({ name: '', slug: '', description: '', iconName: null, iconColor: null, voteBudget: 10, voteBudgetReset: 'monthly', rssEnabled: true, rssFeedCount: 50, staleDays: 0 })
|
||||||
setEditBoard(null)
|
setEditBoard(null)
|
||||||
@@ -158,13 +176,30 @@ export default function AdminBoards() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-1">
|
||||||
{boards.map((board) => (
|
{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
|
<div
|
||||||
key={board.id}
|
className="card p-4 flex items-center gap-3"
|
||||||
className="card p-4 flex items-center gap-4"
|
style={{ opacity: board.isArchived ? 0.5 : dragIdx === i ? 0.4 : 1 }}
|
||||||
style={{ opacity: board.isArchived ? 0.5 : 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} />
|
<BoardIcon name={board.name} iconName={board.iconName} iconColor={board.iconColor} size={40} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -203,6 +238,10 @@ export default function AdminBoards() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{boards.length === 0 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user