Foro: mover tema entre foros (moderadores)

- lib/forum-write.ts: moveTopic (forum_id + moved=1). lib/forum.ts: listForumsForMove.
- API /api/forum/moderate: acción 'move' con forumId, validando foro destino visible
  y distinto del actual.
- TopicModBar: selector «Mover a» con los foros visibles (excl. el actual); tras mover,
  redirige al foro destino.
- i18n es/en: Forum.moveTopic.

Verificado: build OK, move 401 sin sesión, /forum 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 01:01:48 +00:00
parent 0247c6cc38
commit 7c1cd195ff
7 changed files with 69 additions and 10 deletions
@@ -1,7 +1,7 @@
import { notFound } from 'next/navigation'
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { Link } from '@/i18n/navigation'
import { getTopic, getPosts, countPosts } from '@/lib/forum'
import { getTopic, getPosts, countPosts, listForumsForMove } from '@/lib/forum'
import { getSession } from '@/lib/session'
import { forumIsModerator, canEditPost } from '@/lib/forum-perm'
import { ReplyForm } from '@/components/ReplyForm'
@@ -33,6 +33,7 @@ export default async function TopicPage({
const posts = await getPosts(id, (page - 1) * PER_PAGE, PER_PAGE)
const session = await getSession()
const isMod = await forumIsModerator(session)
const moveForums = isMod ? (await listForumsForMove()).filter((f) => f.id !== topic!.forum_id) : []
const canReply = Boolean(session.username) && (!topic!.locked || isMod)
return (
@@ -48,7 +49,9 @@ export default async function TopicPage({
{topic!.name}
</h1>
{isMod && <TopicModBar topicId={id} locked={topic!.locked} sticky={topic!.sticky} />}
{isMod && (
<TopicModBar topicId={id} locked={topic!.locked} sticky={topic!.sticky} moveForums={moveForums} />
)}
<div className="space-y-4">
{posts.map((post) => (
+14 -3
View File
@@ -1,9 +1,9 @@
import { getSession } from '@/lib/session'
import { getTopic } from '@/lib/forum'
import { setTopicFlag } from '@/lib/forum-write'
import { getTopic, getForum } from '@/lib/forum'
import { setTopicFlag, moveTopic } from '@/lib/forum-write'
import { forumIsModerator } from '@/lib/forum-perm'
type Action = 'lock' | 'unlock' | 'sticky' | 'unsticky' | 'delete'
type Action = 'lock' | 'unlock' | 'sticky' | 'unsticky' | 'delete' | 'move'
/** Moderación de temas: bloquear, fijar, borrar. Solo moderadores. */
export async function POST(request: Request) {
@@ -36,6 +36,17 @@ export async function POST(request: Request) {
case 'delete':
await setTopicFlag(topicId, 'deleted', true)
return Response.json({ success: true, forumId: topic.forum_id })
case 'move': {
const newForumId = Number(b.forumId)
if (!newForumId || newForumId === topic.forum_id) {
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
}
if (!(await getForum(newForumId))) {
return Response.json({ success: false, error: 'invalidForum' }, { status: 400 })
}
await moveTopic(topicId, newForumId)
return Response.json({ success: true })
}
default:
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
}
+29 -3
View File
@@ -8,16 +8,18 @@ export function TopicModBar({
topicId,
locked,
sticky,
moveForums = [],
}: {
topicId: number
locked: boolean
sticky: boolean
moveForums?: { id: number; name: string }[]
}) {
const t = useTranslations('Forum')
const router = useRouter()
const [busy, setBusy] = useState(false)
async function act(action: string, confirmMsg?: string) {
async function act(action: string, extra?: Record<string, unknown>, confirmMsg?: string) {
if (busy) return
if (confirmMsg && !confirm(confirmMsg)) return
setBusy(true)
@@ -26,11 +28,13 @@ export function TopicModBar({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ topicId, action }),
body: JSON.stringify({ topicId, action, ...extra }),
})
const data: { success?: boolean; forumId?: number } = await res.json()
if (data.success && action === 'delete' && data.forumId) {
router.push(`/forum/${data.forumId}`)
} else if (data.success && action === 'move') {
router.push(`/forum/${extra?.forumId}`)
} else {
router.refresh()
}
@@ -58,9 +62,31 @@ export function TopicModBar({
>
{sticky ? t('unpin') : t('pin')}
</button>
{moveForums.length > 0 && (
<select
defaultValue=""
disabled={busy}
onChange={(e) => {
const forumId = Number(e.target.value)
e.target.value = ''
if (forumId) act('move', { forumId })
}}
className="nw-input inline-block w-auto py-1 text-xs disabled:opacity-60"
aria-label={t('moveTopic')}
>
<option value="" disabled>
{t('moveTopic')}
</option>
{moveForums.map((f) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
)}
<button
type="button"
onClick={() => act('delete', t('confirmDeleteTopic'))}
onClick={() => act('delete', undefined, t('confirmDeleteTopic'))}
disabled={busy}
className="text-xs text-red-400 hover:text-red-300 disabled:opacity-60"
>
+5
View File
@@ -71,6 +71,11 @@ export async function setTopicFlag(
await db(DB.web).query(`UPDATE forum_topics SET ${field} = ? WHERE id = ?`, [value ? 1 : 0, topicId])
}
/** Mueve un tema a otro foro (marca moved=1). Solo moderación. */
export async function moveTopic(topicId: number, newForumId: number): Promise<void> {
await db(DB.web).query('UPDATE forum_topics SET forum_id = ?, moved = 1 WHERE id = ?', [newForumId, topicId])
}
/** Comprueba que un foro existe y es visible (para crear tema). */
export async function forumIsPostable(forumId: number): Promise<boolean> {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
+12
View File
@@ -92,6 +92,18 @@ export async function getForumIndex(): Promise<Category[]> {
}
}
/** Lista de foros visibles para el desplegable de «mover tema». */
export async function listForumsForMove(): Promise<{ id: number; name: string }[]> {
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
'SELECT id, name FROM forums WHERE visibility = 1 AND deleted = 0 ORDER BY `order`, id',
)
return rows.map((r) => ({ id: r.id, name: r.name }))
} catch {
return []
}
}
export async function getForum(id: number): Promise<{ name: string; description: string } | null> {
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
+2 -1
View File
@@ -293,7 +293,8 @@
"noResults": "No results for “{query}”.",
"resultsCount": "{count} result(s) for “{query}”.",
"in": "in",
"replyOnLastPage": "Go to the last page to reply"
"replyOnLastPage": "Go to the last page to reply",
"moveTopic": "Move to"
},
"Admin": {
"title": "Admin panel",
+2 -1
View File
@@ -293,7 +293,8 @@
"noResults": "No hay resultados para «{query}».",
"resultsCount": "{count} resultado(s) para «{query}».",
"in": "en",
"replyOnLastPage": "Ir a la última página para responder"
"replyOnLastPage": "Ir a la última página para responder",
"moveTopic": "Mover a"
},
"Admin": {
"title": "Panel de administración",