diff --git a/web-next/app/[locale]/admin/news/page.tsx b/web-next/app/[locale]/admin/news/page.tsx new file mode 100644 index 0000000..d824b23 --- /dev/null +++ b/web-next/app/[locale]/admin/news/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 { listNews } from '@/lib/admin-news' +import { AdminNewsManager } from '@/components/AdminNewsManager' + +export const dynamic = 'force-dynamic' + +export default async function AdminNewsPage({ 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 news = await listNews() + return ( + + {t('manageNews')} + + + ) +} diff --git a/web-next/app/[locale]/admin/page.tsx b/web-next/app/[locale]/admin/page.tsx new file mode 100644 index 0000000..393439b --- /dev/null +++ b/web-next/app/[locale]/admin/page.tsx @@ -0,0 +1,28 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server' +import { redirect, Link } from '@/i18n/navigation' +import { getSession } from '@/lib/session' +import { isAdmin } from '@/lib/admin' + +export const dynamic = 'force-dynamic' + +export default async function AdminPage({ 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 }) + + return ( + + {t('title')} + + + + {t('manageNews')} + + + + + ) +} diff --git a/web-next/app/api/admin/news/[id]/route.ts b/web-next/app/api/admin/news/[id]/route.ts new file mode 100644 index 0000000..c02681e --- /dev/null +++ b/web-next/app/api/admin/news/[id]/route.ts @@ -0,0 +1,11 @@ +import { getSession } from '@/lib/session' +import { isAdmin } from '@/lib/admin' +import { deleteNews } from '@/lib/admin-news' + +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 deleteNews(Number(id)) + return Response.json({ success: true }) +} diff --git a/web-next/app/api/admin/news/route.ts b/web-next/app/api/admin/news/route.ts new file mode 100644 index 0000000..03540bf --- /dev/null +++ b/web-next/app/api/admin/news/route.ts @@ -0,0 +1,16 @@ +import { getSession } from '@/lib/session' +import { isAdmin } from '@/lib/admin' +import { createNews } from '@/lib/admin-news' + +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 titulo = String(b.titulo ?? '').trim() + const contenido = String(b.contenido ?? '').trim() + const enlace = String(b.enlace ?? '').trim() || null + if (!titulo || !contenido) return Response.json({ success: false, error: 'emptyError' }) + const id = await createNews(titulo, contenido, enlace) + return Response.json({ success: true, id }) +} diff --git a/web-next/components/AdminNewsManager.tsx b/web-next/components/AdminNewsManager.tsx new file mode 100644 index 0000000..931be77 --- /dev/null +++ b/web-next/components/AdminNewsManager.tsx @@ -0,0 +1,83 @@ +'use client' + +import { useState } from 'react' +import { useTranslations, useLocale } from 'next-intl' +import type { NewsItem } from '@/lib/admin-news' + +export function AdminNewsManager({ initialNews }: { initialNews: NewsItem[] }) { + const t = useTranslations('Admin') + const locale = useLocale() + const [news, setNews] = useState(initialNews) + const [titulo, setTitulo] = useState('') + const [contenido, setContenido] = useState('') + const [enlace, setEnlace] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + async function create(e: React.FormEvent) { + e.preventDefault() + if (busy || !titulo.trim() || !contenido.trim()) return + setBusy(true) + setError(null) + try { + const res = await fetch('/api/admin/news', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ titulo, contenido, enlace }), + }) + const data: { success?: boolean; id?: number } = await res.json() + if (data.success && data.id) { + setNews([{ id: data.id, titulo, fecha: new Date().toISOString(), enlace: enlace || null }, ...news]) + setTitulo('') + setContenido('') + setEnlace('') + } else setError(t('genericError')) + } catch { + setError(t('genericError')) + } finally { + setBusy(false) + } + } + + async function remove(id: number) { + if (!confirm(t('confirmDelete'))) return + const res = await fetch(`/api/admin/news/${id}`, { method: 'DELETE', credentials: 'same-origin' }) + const data: { success?: boolean } = await res.json() + if (data.success) setNews(news.filter((n) => n.id !== id)) + } + + const field = 'w-full rounded border border-amber-900/60 bg-[#2c1e14] px-3 py-2' + + return ( + + + setTitulo(e.target.value)} placeholder={t('newsTitle')} maxLength={200} className={field} /> + setContenido(e.target.value)} placeholder={t('newsContent')} rows={5} className={field} /> + setEnlace(e.target.value)} placeholder={t('newsLink')} className={field} /> + + {busy ? t('creating') : t('create')} + + {error && {error}} + + + {news.length === 0 ? ( + {t('noNews')} + ) : ( + + {news.map((n) => ( + + + {n.titulo} + {n.fecha && {new Date(n.fecha).toLocaleString(locale)}} + + remove(n.id)} className="rounded border border-red-900/60 px-3 py-1 text-sm text-red-400 hover:bg-red-950/40"> + {t('delete')} + + + ))} + + )} + + ) +} diff --git a/web-next/lib/admin-news.ts b/web-next/lib/admin-news.ts new file mode 100644 index 0000000..19c0dfe --- /dev/null +++ b/web-next/lib/admin-news.ts @@ -0,0 +1,33 @@ +import type { RowDataPacket, ResultSetHeader } from 'mysql2' +import { db, DB } from './db' + +export interface NewsItem { + id: number + titulo: string + fecha: string | null + enlace: string | null +} + +export async function listNews(): Promise { + const [rows] = await db(DB.default).query( + 'SELECT id, titulo, fecha_publicacion, enlace FROM home_noticia ORDER BY fecha_publicacion DESC', + ) + return rows.map((r) => ({ + id: r.id, + titulo: r.titulo, + fecha: r.fecha_publicacion ? new Date(r.fecha_publicacion).toISOString() : null, + enlace: r.enlace || null, + })) +} + +export async function createNews(titulo: string, contenido: string, enlace: string | null): Promise { + const [res] = await db(DB.default).query( + 'INSERT INTO home_noticia (titulo, contenido, fecha_publicacion, enlace) VALUES (?, ?, NOW(), ?)', + [titulo, contenido, enlace || null], + ) + return res.insertId +} + +export async function deleteNews(id: number): Promise { + await db(DB.default).query('DELETE FROM home_noticia WHERE id = ?', [id]) +} diff --git a/web-next/lib/admin.ts b/web-next/lib/admin.ts new file mode 100644 index 0000000..f4f7552 --- /dev/null +++ b/web-next/lib/admin.ts @@ -0,0 +1,27 @@ +import type { RowDataPacket } from 'mysql2' +import { db, DB } from './db' +import { normalizeEmail } from './bnet' +import type { SessionData } from './session' + +/** ¿Es administrador? Por allowlist de email (ADMIN_EMAILS) o por gmlevel de AzerothCore. */ +export async function isAdmin(session: SessionData): Promise { + if (!session.bnetId) return false + + const allow = (process.env.ADMIN_EMAILS || '') + .split(',') + .map((e) => normalizeEmail(e)) + .filter(Boolean) + if (session.bnetEmail && allow.includes(normalizeEmail(session.bnetEmail))) return true + + try { + const level = Number(process.env.ADMIN_GMLEVEL || 3) + const [rows] = await db(DB.auth).query( + 'SELECT gmlevel FROM account_access WHERE id = ? ORDER BY gmlevel DESC LIMIT 1', + [session.accountId ?? 0], + ) + if (rows[0] && Number(rows[0].gmlevel) >= level) return true + } catch { + // account_access puede no existir (acore sin poblar) + } + return false +} diff --git a/web-next/messages/en.json b/web-next/messages/en.json index 6da753a..73fd56e 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -265,5 +265,22 @@ "loginToPost": "Sign in to participate.", "emptyError": "Fill in all fields.", "genericError": "Something went wrong. Please try again later." + }, + "Admin": { + "title": "Admin panel", + "dashboard": "Dashboard", + "news": "News", + "manageNews": "Manage news", + "newsTitle": "Title", + "newsContent": "Content (HTML)", + "newsLink": "Link (optional)", + "create": "Create news", + "creating": "Creating…", + "delete": "Delete", + "confirmDelete": "Delete this news item?", + "noNews": "No news.", + "forbidden": "You do not have permission to access here.", + "emptyError": "Fill in the title and content.", + "genericError": "Something went wrong." } } diff --git a/web-next/messages/es.json b/web-next/messages/es.json index a55ddf9..9007def 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -265,5 +265,22 @@ "loginToPost": "Inicia sesión para participar.", "emptyError": "Completa todos los campos.", "genericError": "Algo ha salido mal. Inténtalo más tarde." + }, + "Admin": { + "title": "Panel de administración", + "dashboard": "Panel", + "news": "Noticias", + "manageNews": "Gestionar noticias", + "newsTitle": "Título", + "newsContent": "Contenido (HTML)", + "newsLink": "Enlace (opcional)", + "create": "Crear noticia", + "creating": "Creando…", + "delete": "Borrar", + "confirmDelete": "¿Borrar esta noticia?", + "noNews": "No hay noticias.", + "forbidden": "No tienes permiso para acceder aquí.", + "emptyError": "Completa el título y el contenido.", + "genericError": "Algo ha salido mal." } }
{error}
{t('noNews')}
{n.titulo}
{new Date(n.fecha).toLocaleString(locale)}