Noticias multiidioma (ES/EN) con fallback

home_noticia gana columnas titulo_en/contenido_en (sql/add_news_en.sql). getNoticias(locale)
usa la versión EN si existe, si no cae al español (título y contenido independientes); la fecha
también se formatea por idioma. El panel de admin permite escribir la traducción al crear y EDITAR
noticias existentes (nuevo PUT /api/admin/news/[id], updateNews). Claves Admin nuevas (título/
contenido EN, editar/guardar). Probado: /en/ muestra EN, /es/ ES; PUT guardado (403 sin sesión).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-14 19:14:07 +00:00
parent 81e1450e6b
commit 86c9b01c76
9 changed files with 162 additions and 39 deletions
+6 -6
View File
@@ -7,8 +7,8 @@ import { getRealmName } from '@/lib/realm'
// SSR por petición: los datos se leen DIRECTAMENTE de MySQL desde el servidor Next.
export const dynamic = 'force-dynamic'
async function getData(): Promise<{ noticias: Noticia[]; status: ServerStatus | null }> {
const [news, status] = await Promise.allSettled([getNoticias(), getServerStatus()])
async function getData(locale: string): Promise<{ noticias: Noticia[]; status: ServerStatus | null }> {
const [news, status] = await Promise.allSettled([getNoticias(locale), getServerStatus()])
return {
noticias: news.status === 'fulfilled' ? news.value : [],
status: status.status === 'fulfilled' ? status.value : null,
@@ -24,11 +24,11 @@ function brandify(html: string, realm: string): string {
return html.replace(/<[^>]*>|[^<]+/g, (seg) => (seg.startsWith('<') ? seg : seg.replace(re, repl)))
}
function formatFecha(iso: string | Date | null): string {
function formatFecha(iso: string | Date | null, locale: string): string {
if (!iso) return ''
const d = new Date(iso)
if (isNaN(d.getTime())) return String(iso)
return d.toLocaleDateString('es-ES', {
return d.toLocaleDateString(locale === 'en' ? 'en-US' : 'es-ES', {
day: 'numeric',
month: 'long',
year: 'numeric',
@@ -39,7 +39,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations('Misc')
const { noticias, status } = await getData()
const { noticias, status } = await getData(locale)
const realmName = await getRealmName()
const worldStatus = status ? status.world_status : 'Offline'
@@ -125,7 +125,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
{noticias.map((n) => (
<div key={n.id} className="noticia-item">
<h3>{brandify(n.titulo, realmName)}</h3>
<span className="date">{formatFecha(n.fecha)}</span>
<span className="date">{formatFecha(n.fecha, locale)}</span>
<p
className="noticia-contenido"
dangerouslySetInnerHTML={{ __html: brandify(n.contenido, realmName) }}
+17 -1
View File
@@ -1,6 +1,6 @@
import { getSession } from '@/lib/session'
import { isAdmin } from '@/lib/admin'
import { deleteNews } from '@/lib/admin-news'
import { deleteNews, updateNews } from '@/lib/admin-news'
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
@@ -9,3 +9,19 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{
await deleteNews(Number(id))
return Response.json({ success: true })
}
export async function PUT(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<string, string> = {}
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
const tituloEn = String(b.tituloEn ?? '').trim() || null
const contenidoEn = String(b.contenidoEn ?? '').trim() || null
if (!titulo || !contenido) return Response.json({ success: false, error: 'emptyError' })
await updateNews(Number(id), titulo, contenido, enlace, tituloEn, contenidoEn)
return Response.json({ success: true })
}
+3 -1
View File
@@ -10,7 +10,9 @@ export async function POST(request: Request) {
const titulo = String(b.titulo ?? '').trim()
const contenido = String(b.contenido ?? '').trim()
const enlace = String(b.enlace ?? '').trim() || null
const tituloEn = String(b.tituloEn ?? '').trim() || null
const contenidoEn = String(b.contenidoEn ?? '').trim() || null
if (!titulo || !contenido) return Response.json({ success: false, error: 'emptyError' })
const id = await createNews(titulo, contenido, enlace)
const id = await createNews(titulo, contenido, enlace, tituloEn, contenidoEn)
return Response.json({ success: true, id })
}
+76 -21
View File
@@ -4,19 +4,22 @@ import { useState } from 'react'
import { useTranslations, useLocale } from 'next-intl'
import type { NewsItem } from '@/lib/admin-news'
const EMPTY = { titulo: '', tituloEn: '', contenido: '', contenidoEn: '', enlace: '' }
type Draft = typeof EMPTY
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 [draft, setDraft] = useState<Draft>(EMPTY)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [editId, setEditId] = useState<number | null>(null)
const [edit, setEdit] = useState<Draft>(EMPTY)
async function create(e: React.FormEvent) {
e.preventDefault()
if (busy || !titulo.trim() || !contenido.trim()) return
if (busy || !draft.titulo.trim() || !draft.contenido.trim()) return
setBusy(true)
setError(null)
try {
@@ -24,14 +27,41 @@ export function AdminNewsManager({ initialNews }: { initialNews: NewsItem[] }) {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ titulo, contenido, enlace }),
body: JSON.stringify(draft),
})
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('')
setNews([{ id: data.id, ...draft, fecha: new Date().toISOString(), enlace: draft.enlace || null }, ...news])
setDraft(EMPTY)
} else setError(t('genericError'))
} catch {
setError(t('genericError'))
} finally {
setBusy(false)
}
}
function startEdit(n: NewsItem) {
setEditId(n.id)
setEdit({ titulo: n.titulo, tituloEn: n.tituloEn, contenido: n.contenido, contenidoEn: n.contenidoEn, enlace: n.enlace || '' })
setError(null)
}
async function save(id: number) {
if (busy || !edit.titulo.trim() || !edit.contenido.trim()) return
setBusy(true)
setError(null)
try {
const res = await fetch(`/api/admin/news/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(edit),
})
const data: { success?: boolean } = await res.json()
if (data.success) {
setNews(news.map((n) => (n.id === id ? { ...n, ...edit, enlace: edit.enlace || null } : n)))
setEditId(null)
} else setError(t('genericError'))
} catch {
setError(t('genericError'))
@@ -50,9 +80,12 @@ export function AdminNewsManager({ initialNews }: { initialNews: NewsItem[] }) {
return (
<div>
<form onSubmit={create} className="admin-form info-box-light separate2">
<input value={titulo} onChange={(e) => setTitulo(e.target.value)} placeholder={t('newsTitle')} maxLength={200} />
<textarea value={contenido} onChange={(e) => setContenido(e.target.value)} placeholder={t('newsContent')} rows={5} />
<input value={enlace} onChange={(e) => setEnlace(e.target.value)} placeholder={t('newsLink')} />
<input value={draft.titulo} onChange={(e) => setDraft({ ...draft, titulo: e.target.value })} placeholder={t('newsTitle')} maxLength={200} />
<textarea value={draft.contenido} onChange={(e) => setDraft({ ...draft, contenido: e.target.value })} placeholder={t('newsContent')} rows={5} />
<input value={draft.tituloEn} onChange={(e) => setDraft({ ...draft, tituloEn: e.target.value })} placeholder={t('newsTitleEn')} maxLength={200} />
<textarea value={draft.contenidoEn} onChange={(e) => setDraft({ ...draft, contenidoEn: e.target.value })} placeholder={t('newsContentEn')} rows={5} />
<input value={draft.enlace} onChange={(e) => setDraft({ ...draft, enlace: e.target.value })} placeholder={t('newsLink')} />
<p className="third-brown small-font">{t('newsEnHint')}</p>
<button type="submit" disabled={busy}>
{busy ? t('creating') : t('create')}
</button>
@@ -69,15 +102,37 @@ export function AdminNewsManager({ initialNews }: { initialNews: NewsItem[] }) {
<tbody>
{news.map((n) => (
<tr key={n.id} className="team-center-table-tr">
<td className="lefted separate">
<span className="yellow-info">{n.titulo}</span>
{n.fecha && <p className="third-brown small-font">{new Date(n.fecha).toLocaleString(locale)}</p>}
</td>
<td className="real-info-box">
<button onClick={() => remove(n.id)} className="nw-tool-btn red-info2">
{t('delete')}
</button>
</td>
{editId === n.id ? (
<td className="lefted separate" colSpan={2}>
<input value={edit.titulo} onChange={(e) => setEdit({ ...edit, titulo: e.target.value })} placeholder={t('newsTitle')} maxLength={200} />
<textarea value={edit.contenido} onChange={(e) => setEdit({ ...edit, contenido: e.target.value })} placeholder={t('newsContent')} rows={4} />
<input value={edit.tituloEn} onChange={(e) => setEdit({ ...edit, tituloEn: e.target.value })} placeholder={t('newsTitleEn')} maxLength={200} />
<textarea value={edit.contenidoEn} onChange={(e) => setEdit({ ...edit, contenidoEn: e.target.value })} placeholder={t('newsContentEn')} rows={4} />
<input value={edit.enlace} onChange={(e) => setEdit({ ...edit, enlace: e.target.value })} placeholder={t('newsLink')} />
<button onClick={() => save(n.id)} disabled={busy} className="nw-tool-btn">
{busy ? t('saving') : t('save')}
</button>{' '}
<button onClick={() => setEditId(null)} className="nw-tool-btn">
{t('cancel')}
</button>
</td>
) : (
<>
<td className="lefted separate">
<span className="yellow-info">{n.titulo}</span>
{n.tituloEn && <span className="third-brown small-font"> · EN: {n.tituloEn}</span>}
{n.fecha && <p className="third-brown small-font">{new Date(n.fecha).toLocaleString(locale)}</p>}
</td>
<td className="real-info-box">
<button onClick={() => startEdit(n)} className="nw-tool-btn">
{t('edit')}
</button>{' '}
<button onClick={() => remove(n.id)} className="nw-tool-btn red-info2">
{t('delete')}
</button>
</td>
</>
)}
</tr>
))}
</tbody>
+31 -4
View File
@@ -4,30 +4,57 @@ import { db, DB } from './db'
export interface NewsItem {
id: number
titulo: string
tituloEn: string
contenido: string
contenidoEn: string
fecha: string | null
enlace: string | null
}
export async function listNews(): Promise<NewsItem[]> {
const [rows] = await db(DB.default).query<RowDataPacket[]>(
'SELECT id, titulo, fecha_publicacion, enlace FROM home_noticia ORDER BY fecha_publicacion DESC',
'SELECT id, titulo, titulo_en, contenido, contenido_en, fecha_publicacion, enlace FROM home_noticia ORDER BY fecha_publicacion DESC',
)
return rows.map((r) => ({
id: r.id,
titulo: r.titulo,
tituloEn: r.titulo_en || '',
contenido: r.contenido,
contenidoEn: r.contenido_en || '',
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<number> {
export async function createNews(
titulo: string,
contenido: string,
enlace: string | null,
tituloEn: string | null,
contenidoEn: string | null,
): Promise<number> {
const [res] = await db(DB.default).query<ResultSetHeader>(
'INSERT INTO home_noticia (titulo, contenido, fecha_publicacion, enlace) VALUES (?, ?, NOW(), ?)',
[titulo, contenido, enlace || null],
'INSERT INTO home_noticia (titulo, titulo_en, contenido, contenido_en, fecha_publicacion, enlace) VALUES (?, ?, ?, ?, NOW(), ?)',
[titulo, tituloEn || null, contenido, contenidoEn || null, enlace || null],
)
return res.insertId
}
/** Actualiza una noticia (incluye la traducción al inglés). */
export async function updateNews(
id: number,
titulo: string,
contenido: string,
enlace: string | null,
tituloEn: string | null,
contenidoEn: string | null,
): Promise<void> {
await db(DB.default).query(
'UPDATE home_noticia SET titulo = ?, titulo_en = ?, contenido = ?, contenido_en = ?, enlace = ? WHERE id = ?',
[titulo, tituloEn || null, contenido, contenidoEn || null, enlace || null, id],
)
}
export async function deleteNews(id: number): Promise<void> {
await db(DB.default).query('DELETE FROM home_noticia WHERE id = ?', [id])
}
+10 -4
View File
@@ -31,16 +31,22 @@ export function expansionFromGamebuild(gamebuild: number): string {
return 'Unknown'
}
export async function getNoticias(limit = 50): Promise<Noticia[]> {
/**
* Noticias localizadas. Con `locale='en'` usa `titulo_en`/`contenido_en` si
* existen; si están vacías, cae a la versión en español (título y contenido de
* forma independiente). El texto original (titulo/contenido) siempre es ES.
*/
export async function getNoticias(locale = 'es', limit = 50): Promise<Noticia[]> {
const [rows] = await db(DB.default).query<RowDataPacket[]>(
'SELECT id, titulo, contenido, fecha_publicacion, enlace FROM home_noticia ORDER BY fecha_publicacion DESC LIMIT ?',
'SELECT id, titulo, titulo_en, contenido, contenido_en, fecha_publicacion, enlace FROM home_noticia ORDER BY fecha_publicacion DESC LIMIT ?',
[limit],
)
const en = locale === 'en'
return rows.map((r) => ({
id: r.id,
titulo: r.titulo,
titulo: en && r.titulo_en ? r.titulo_en : r.titulo,
fecha: r.fecha_publicacion ? new Date(r.fecha_publicacion).toISOString() : null,
contenido: r.contenido,
contenido: en && r.contenido_en ? r.contenido_en : r.contenido,
enlace: r.enlace || null,
}))
}
+7 -1
View File
@@ -566,7 +566,13 @@
"promoEmptyError": "Enter the code and at least PD or PV (with max uses ≥ 1).",
"forum": "Forums",
"search": "Search",
"promoConfirmDelete": "Delete this promo code?"
"promoConfirmDelete": "Delete this promo code?",
"newsTitleEn": "Title (English, optional)",
"newsContentEn": "Content (English, optional)",
"edit": "Edit",
"saving": "Saving…",
"cancel": "Cancel",
"newsEnHint": "Leave English empty to fall back to Spanish on the English site."
},
"Vote": {
"title": "Vote for us",
+7 -1
View File
@@ -566,7 +566,13 @@
"promoEmptyError": "Escribe el código y al menos PD o PV (con usos máx. ≥ 1).",
"forum": "Foros",
"search": "Buscar",
"promoConfirmDelete": "¿Borrar este código de promoción?"
"promoConfirmDelete": "¿Borrar este código de promoción?",
"newsTitleEn": "Título (inglés, opcional)",
"newsContentEn": "Contenido (inglés, opcional)",
"edit": "Editar",
"saving": "Guardando…",
"cancel": "Cancelar",
"newsEnHint": "Deja el inglés vacío para usar el español en la web en inglés."
},
"Vote": {
"title": "Votar por nosotros",
+5
View File
@@ -0,0 +1,5 @@
-- Noticias multiidioma: versión en inglés (opcional). Si están vacías, la home
-- en inglés cae a la versión en español. El texto original (titulo/contenido) es ES.
ALTER TABLE home_noticia
ADD COLUMN titulo_en VARCHAR(200) NULL AFTER titulo,
ADD COLUMN contenido_en LONGTEXT NULL AFTER contenido;