From 8bb18838ff91430b65ebd8515b782ce8f4f40716 Mon Sep 17 00:00:00 2001 From: adevopg Date: Mon, 13 Jul 2026 01:49:52 +0000 Subject: [PATCH] =?UTF-8?q?Admin:=20gesti=C3=B3n=20de=20foros=20(categor?= =?UTF-8?q?=C3=ADas=20y=20foros)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/admin-forum.ts: listCategoriesWithForums, createCategory/deleteCategory (solo si vacía), createForum/deleteForum (borrado lógico), setForumVisibility. - API: /api/admin/forum(+/[id] DELETE/PATCH visibilidad) y /api/admin/forum/category(+/[id]) (403 sin admin). - UI: /admin/forum (AdminForumManager) — alta de categorías, alta/borrado de foros por categoría, mostrar/ocultar; tarjeta "Gestionar foros" en el índice del admin. - i18n es/en (Admin): 13 claves nuevas. Verificado: build OK, /admin/forum 307->login, APIs 403 sin admin. Co-Authored-By: Claude Opus 4.8 (1M context) --- web-next/app/[locale]/admin/forum/page.tsx | 24 ++++ web-next/app/[locale]/admin/page.tsx | 1 + web-next/app/api/admin/forum/[id]/route.ts | 21 +++ .../api/admin/forum/category/[id]/route.ts | 11 ++ .../app/api/admin/forum/category/route.ts | 15 ++ web-next/app/api/admin/forum/route.ts | 18 +++ web-next/components/AdminForumManager.tsx | 135 ++++++++++++++++++ web-next/lib/admin-forum.ts | 86 +++++++++++ web-next/messages/en.json | 15 +- web-next/messages/es.json | 15 +- 10 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 web-next/app/[locale]/admin/forum/page.tsx create mode 100644 web-next/app/api/admin/forum/[id]/route.ts create mode 100644 web-next/app/api/admin/forum/category/[id]/route.ts create mode 100644 web-next/app/api/admin/forum/category/route.ts create mode 100644 web-next/app/api/admin/forum/route.ts create mode 100644 web-next/components/AdminForumManager.tsx create mode 100644 web-next/lib/admin-forum.ts diff --git a/web-next/app/[locale]/admin/forum/page.tsx b/web-next/app/[locale]/admin/forum/page.tsx new file mode 100644 index 0000000..26a9bd2 --- /dev/null +++ b/web-next/app/[locale]/admin/forum/page.tsx @@ -0,0 +1,24 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server' +import { redirect } from '@/i18n/navigation' +import { getSession } from '@/lib/session' +import { isAdmin } from '@/lib/admin' +import { listCategoriesWithForums } from '@/lib/admin-forum' +import { AdminForumManager } from '@/components/AdminForumManager' + +export const dynamic = 'force-dynamic' + +export default async function AdminForumPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations('Admin') + const session = await getSession() + if (!session.bnetId) redirect({ href: '/login', locale }) + if (!(await isAdmin(session))) redirect({ href: '/', locale }) + const groups = await listCategoriesWithForums() + return ( +
+

{t('forum')}

+ +
+ ) +} diff --git a/web-next/app/[locale]/admin/page.tsx b/web-next/app/[locale]/admin/page.tsx index 8220161..e910ce6 100644 --- a/web-next/app/[locale]/admin/page.tsx +++ b/web-next/app/[locale]/admin/page.tsx @@ -20,6 +20,7 @@ export default async function AdminPage({ params }: { params: Promise<{ locale: { href: '/admin/gold', label: t('manageGold'), icon: '🪙' }, { href: '/admin/users', label: t('manageUsers'), icon: '👤' }, { href: '/admin/recruit', label: t('manageRecruit'), icon: '🎁' }, + { href: '/admin/forum', label: t('manageForum'), icon: '💬' }, ] return ( diff --git a/web-next/app/api/admin/forum/[id]/route.ts b/web-next/app/api/admin/forum/[id]/route.ts new file mode 100644 index 0000000..412a724 --- /dev/null +++ b/web-next/app/api/admin/forum/[id]/route.ts @@ -0,0 +1,21 @@ +import { getSession } from '@/lib/session' +import { isAdmin } from '@/lib/admin' +import { deleteForum, setForumVisibility } from '@/lib/admin-forum' + +export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession() + if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 }) + const { id } = await params + await deleteForum(Number(id)) + return Response.json({ success: true }) +} + +export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession() + if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 }) + const { id } = await params + let b: Record = {} + try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) } + await setForumVisibility(Number(id), b.visibility !== false) + return Response.json({ success: true }) +} diff --git a/web-next/app/api/admin/forum/category/[id]/route.ts b/web-next/app/api/admin/forum/category/[id]/route.ts new file mode 100644 index 0000000..1ee229e --- /dev/null +++ b/web-next/app/api/admin/forum/category/[id]/route.ts @@ -0,0 +1,11 @@ +import { getSession } from '@/lib/session' +import { isAdmin } from '@/lib/admin' +import { deleteCategory } from '@/lib/admin-forum' + +export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession() + if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 }) + const { id } = await params + const ok = await deleteCategory(Number(id)) + return Response.json({ success: ok, error: ok ? undefined : 'categoryNotEmpty' }) +} diff --git a/web-next/app/api/admin/forum/category/route.ts b/web-next/app/api/admin/forum/category/route.ts new file mode 100644 index 0000000..4b5c3cb --- /dev/null +++ b/web-next/app/api/admin/forum/category/route.ts @@ -0,0 +1,15 @@ +import { getSession } from '@/lib/session' +import { isAdmin } from '@/lib/admin' +import { createCategory } from '@/lib/admin-forum' + +export async function POST(request: Request) { + const session = await getSession() + if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 }) + let b: Record = {} + try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) } + const name = String(b.name ?? '').trim() + const order = Number(b.order) || 0 + if (!name) return Response.json({ success: false, error: 'emptyError' }) + const id = await createCategory(name, order) + return Response.json({ success: true, id }) +} diff --git a/web-next/app/api/admin/forum/route.ts b/web-next/app/api/admin/forum/route.ts new file mode 100644 index 0000000..180df79 --- /dev/null +++ b/web-next/app/api/admin/forum/route.ts @@ -0,0 +1,18 @@ +import { getSession } from '@/lib/session' +import { isAdmin } from '@/lib/admin' +import { createForum } from '@/lib/admin-forum' + +export async function POST(request: Request) { + const session = await getSession() + if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 }) + let b: Record = {} + try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) } + const categoryId = Number(b.categoryId) + const name = String(b.name ?? '').trim() + const description = String(b.description ?? '').trim() + const order = Number(b.order) || 0 + const visibility = b.visibility === false ? 0 : 1 + if (!categoryId || !name) return Response.json({ success: false, error: 'emptyError' }) + const id = await createForum(categoryId, name, description, order, visibility) + return Response.json({ success: true, id }) +} diff --git a/web-next/components/AdminForumManager.tsx b/web-next/components/AdminForumManager.tsx new file mode 100644 index 0000000..598b8a5 --- /dev/null +++ b/web-next/components/AdminForumManager.tsx @@ -0,0 +1,135 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import { useRouter } from '@/i18n/navigation' +import type { AdminCategory, AdminForum } from '@/lib/admin-forum' + +type Group = { category: AdminCategory; forums: AdminForum[] } + +export function AdminForumManager({ initial }: { initial: Group[] }) { + const t = useTranslations('Admin') + const router = useRouter() + const [busy, setBusy] = useState(false) + const [catForm, setCatForm] = useState({ name: '', order: '0' }) + const [forumForm, setForumForm] = useState>({}) + + function ff(catId: number) { + return forumForm[catId] ?? { name: '', description: '', order: '0' } + } + function setFF(catId: number, patch: Partial<{ name: string; description: string; order: string }>) { + setForumForm((s) => ({ ...s, [catId]: { ...ff(catId), ...patch } })) + } + + async function call(url: string, method: string, body?: unknown): Promise { + setBusy(true) + try { + const res = await fetch(url, { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + credentials: 'same-origin', + body: body ? JSON.stringify(body) : undefined, + }) + const d = await res.json() + if (d.success) { + router.refresh() + return true + } + if (d.error === 'categoryNotEmpty') alert(t('categoryNotEmpty')) + return false + } finally { + setBusy(false) + } + } + + const field = 'nw-input' + return ( +
+ {/* Alta de categoría */} +
{ + e.preventDefault() + if (catForm.name.trim()) call('/api/admin/forum/category', 'POST', { name: catForm.name, order: Number(catForm.order) }).then((ok) => ok && setCatForm({ name: '', order: '0' })) + }} + className="flex flex-wrap items-end gap-2 nw-card" + > + + + +
+ + {initial.length === 0 &&

{t('noCategories')}

} + + {initial.map(({ category, forums }) => ( +
+
+

+ {category.name} #{category.order} +

+ +
+ + {/* Foros de la categoría */} +
    + {forums.length === 0 &&
  • {t('noForums')}
  • } + {forums.map((f) => ( +
  • +
    +

    + {f.name} {f.visibility === 0 && ({t('hidden')})} +

    + {f.description &&

    {f.description}

    } +
    +
    + + +
    +
  • + ))} +
+ + {/* Alta de foro en esta categoría */} +
{ + e.preventDefault() + const v = ff(category.id) + if (v.name.trim()) + call('/api/admin/forum', 'POST', { categoryId: category.id, name: v.name, description: v.description, order: Number(v.order) }).then( + (ok) => ok && setFF(category.id, { name: '', description: '', order: '0' }), + ) + }} + className="grid gap-2 rounded-lg border border-nw-border/50 bg-nw-panel-2/30 p-3 sm:grid-cols-[1fr_1fr_auto]" + > + setFF(category.id, { name: e.target.value })} placeholder={t('forumName')} maxLength={45} className={field} /> + setFF(category.id, { description: e.target.value })} placeholder={t('forumDescription')} className={field} /> + +
+
+ ))} +
+ ) +} diff --git a/web-next/lib/admin-forum.ts b/web-next/lib/admin-forum.ts new file mode 100644 index 0000000..6cbaf3c --- /dev/null +++ b/web-next/lib/admin-forum.ts @@ -0,0 +1,86 @@ +import type { RowDataPacket, ResultSetHeader } from 'mysql2' +import { db, DB } from './db' + +export interface AdminCategory { + id: number + name: string + order: number +} + +export interface AdminForum { + id: number + category_id: number + name: string + description: string + order: number + visibility: number +} + +/** Categorías con sus foros (incluye ocultos y sin borrar), para el admin. */ +export async function listCategoriesWithForums(): Promise< + { category: AdminCategory; forums: AdminForum[] }[] +> { + const [cats] = await db(DB.web).query( + 'SELECT id, name, `order` FROM forum_categories ORDER BY `order`, id', + ) + const [forums] = await db(DB.web).query( + 'SELECT id, category_id, name, description, `order`, visibility FROM forums WHERE deleted = 0 ORDER BY `order`, id', + ) + return cats.map((c) => ({ + category: { id: c.id, name: c.name, order: c.order }, + forums: forums + .filter((f) => f.category_id === c.id) + .map((f) => ({ + id: f.id, + category_id: f.category_id, + name: f.name, + description: f.description, + order: f.order, + visibility: f.visibility, + })), + })) +} + +export async function createCategory(name: string, order: number): Promise { + const [res] = await db(DB.web).query( + 'INSERT INTO forum_categories (name, `order`, created_at) VALUES (?, ?, NOW())', + [name, order], + ) + return res.insertId +} + +/** Borra una categoría solo si no tiene foros (no borrados). */ +export async function deleteCategory(id: number): Promise { + const [f] = await db(DB.web).query( + 'SELECT COUNT(*) AS n FROM forums WHERE category_id = ? AND deleted = 0', + [id], + ) + if (Number(f[0]?.n ?? 0) > 0) return false + await db(DB.web).query('DELETE FROM forum_categories WHERE id = ?', [id]) + return true +} + +export async function createForum( + categoryId: number, + name: string, + description: string, + order: number, + visibility: number, +): Promise { + const [res] = await db(DB.web).query( + 'INSERT INTO forums (category_id, name, description, `order`, type, visibility, deleted, created_at, updated_at) ' + + 'VALUES (?, ?, ?, ?, 0, ?, 0, NOW(), NOW())', + [categoryId, name, description, order, visibility ? 1 : 0], + ) + return res.insertId +} + +/** Borrado lógico de un foro (deleted = 1). */ +export async function deleteForum(id: number): Promise { + await db(DB.web).query('UPDATE forums SET deleted = 1 WHERE id = ?', [id]) +} + +/** Cambia la visibilidad de un foro. */ +export async function setForumVisibility(id: number, visible: boolean): Promise { + await db(DB.web).query('UPDATE forums SET visibility = ? WHERE id = ?', [visible ? 1 : 0, id]) +} diff --git a/web-next/messages/en.json b/web-next/messages/en.json index eeb30d2..afa450a 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -386,7 +386,20 @@ "itemLink": "Item link", "iconClass": "Icon class", "noRewards": "No rewards.", - "requiredFriendsShort": "{n} friend(s)" + "requiredFriendsShort": "{n} friend(s)", + "manageForum": "Manage forums", + "newCategory": "New category", + "categoryName": "Category name", + "order": "Order", + "noCategories": "No categories.", + "noForums": "No forums in this category.", + "forumName": "Forum name", + "forumDescription": "Description", + "addForum": "Add forum", + "hidden": "hidden", + "show": "Show", + "hide": "Hide", + "categoryNotEmpty": "Category has forums; delete them first." }, "Vote": { "title": "Vote for us", diff --git a/web-next/messages/es.json b/web-next/messages/es.json index 01e5e5e..3edea71 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -386,7 +386,20 @@ "itemLink": "Enlace del objeto", "iconClass": "Clase del icono", "noRewards": "No hay recompensas.", - "requiredFriendsShort": "{n} amigo(s)" + "requiredFriendsShort": "{n} amigo(s)", + "manageForum": "Gestionar foros", + "newCategory": "Nueva categoría", + "categoryName": "Nombre de la categoría", + "order": "Orden", + "noCategories": "No hay categorías.", + "noForums": "Sin foros en esta categoría.", + "forumName": "Nombre del foro", + "forumDescription": "Descripción", + "addForum": "Añadir foro", + "hidden": "oculto", + "show": "Mostrar", + "hide": "Ocultar", + "categoryNotEmpty": "La categoría tiene foros; bórralos primero." }, "Vote": { "title": "Votar por nosotros",