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:
@@ -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) }}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user