diff --git a/packages/api/prisma/schema.prisma b/packages/api/prisma/schema.prisma index 92189ea..1f38a01 100644 --- a/packages/api/prisma/schema.prisma +++ b/packages/api/prisma/schema.prisma @@ -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 diff --git a/packages/api/src/routes/admin/boards.ts b/packages/api/src/routes/admin/boards.ts index 4251a63..e47f087 100644 --- a/packages/api/src/routes/admin/boards.ts +++ b/packages/api/src/routes/admin/boards.ts @@ -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 }); + } + ); } diff --git a/packages/api/src/routes/boards.ts b/packages/api/src/routes/boards.ts index a7a0128..21fb4c2 100644 --- a/packages/api/src/routes/boards.ts +++ b/packages/api/src/routes/boards.ts @@ -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 diff --git a/packages/web/src/pages/admin/AdminBoards.tsx b/packages/web/src/pages/admin/AdminBoards.tsx index 1e70404..696a62a 100644 --- a/packages/web/src/pages/admin/AdminBoards.tsx +++ b/packages/web/src/pages/admin/AdminBoards.tsx @@ -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([]) const [loading, setLoading] = useState(true) const [editBoard, setEditBoard] = useState(null) + const [dragIdx, setDragIdx] = useState(null) + const [insertAt, setInsertAt] = useState(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() { ))} ) : ( -
- {boards.map((board) => ( +
+ {boards.map((board, i) => ( +
+ {dragIdx !== null && insertAt === i && dragIdx !== i && dragIdx !== i - 1 && ( +
+ )}
setDragIdx(i)} + onDragOver={(e) => { e.preventDefault(); setInsertAt(i) }} + onDragEnd={handleDrop} > +
+ + + + + +
@@ -203,6 +238,10 @@ export default function AdminBoards() { )}
+ {dragIdx !== null && insertAt === boards.length && i === boards.length - 1 && dragIdx !== i && ( +
+ )} +
))} {boards.length === 0 && (