Files
NightSpire/web-next/components/ForumEditor.tsx
T
Inna e65e102244 Foro: interfaz de TinyMCE en el idioma de la web
El editor salía siempre en inglés. Ahora su interfaz (barra, menús, diálogos) usa
el locale de la web: inglés por defecto (idioma nativo de TinyMCE) y español desde
public/tinymce-langs/es.js (localización oficial de TinyMCE, versionada porque
public/tinymce se regenera en postinstall y no la incluye).

Los enlaces de wowhead del foro ya seguían el locale (subdominio del idioma en la
URL y domain=es.wotlk en data-wowhead), así que el tooltip sale en español en /es y
en inglés en /en.

Verificado: /tinymce-langs/es.js se sirve y language_url queda compilado.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 13:58:01 +00:00

187 lines
8.0 KiB
TypeScript

'use client'
import { useEffect } from 'react'
import { Editor } from '@tinymce/tinymce-react'
import { useLocale } from 'next-intl'
import { wowheadUrl, wowheadData, type WowheadType } from '@/lib/wowhead'
/**
* Editor TinyMCE del foro (community, self-hosted desde /tinymce — ver
* scripts/copy-tinymce.mjs). Mismo editor que el foro original de FusionCMS, con su
* skin oscura y su lista de plugins, adaptado a React como componente controlado:
* el HTML vive en el estado del padre (value/onChange) y se envía por fetch, en vez
* del `tinyMCE.triggerSave()` + jQuery del forum.js original.
*
* `licenseKey: 'gpl'` deja claro que es la edición community (GPL), no la de nube.
* El HTML que produce se sanea SIEMPRE en el servidor con nh3 (lib/forum-sanitize),
* nunca se confía en el cliente.
*
* Botón «Wowhead»: abre un diálogo (tipo + ID + texto) e inserta un enlace de
* wowhead con su data-wowhead en el idioma de la web, para que salga el tooltip,
* el color por calidad y el icono (lo pinta el script global tooltips.js).
*/
const WOWHEAD_TYPES: { value: WowheadType; es: string; en: string }[] = [
{ value: 'item', es: 'Ítem', en: 'Item' },
{ value: 'spell', es: 'Hechizo', en: 'Spell' },
{ value: 'quest', es: 'Misión', en: 'Quest' },
{ value: 'npc', es: 'PNJ', en: 'NPC' },
{ value: 'achievement', es: 'Logro', en: 'Achievement' },
{ value: 'object', es: 'Objeto', en: 'Object' },
]
function esc(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
export function ForumEditor({
value,
onChange,
height = 400,
}: {
value: string
onChange: (html: string) => void
height?: number
}) {
const locale = useLocale()
const es = locale !== 'en'
const L = {
title: 'Wowhead',
tooltip: es ? 'Insertar enlace de Wowhead' : 'Insert a Wowhead link',
type: es ? 'Tipo' : 'Type',
id: 'ID',
text: es ? 'Texto (vacío = nombre automático)' : 'Text (empty = auto name)',
insert: es ? 'Insertar' : 'Insert',
cancel: es ? 'Cancelar' : 'Cancel',
preview: es ? 'Vista previa' : 'Preview',
}
// Reprocesa los enlaces de wowhead de la VISTA PREVIA (que vive en el documento
// principal, no en el iframe del editor) cada vez que cambia el texto. Así el
// preview se ve como el post publicado sin ensuciar lo que se guarda.
useEffect(() => {
const t = setTimeout(() => {
try {
;(window as unknown as { $WowheadPower?: { refreshLinks?: () => void } }).$WowheadPower?.refreshLinks?.()
} catch {
/* no crítico */
}
}, 300)
return () => clearTimeout(t)
}, [value])
return (
<>
<Editor
tinymceScriptSrc="/tinymce/tinymce.min.js"
licenseKey="gpl"
value={value}
onEditorChange={(content) => onChange(content)}
init={{
height,
menubar: false,
statusbar: false,
// Idioma de la interfaz del editor según el locale de la web. El inglés es el
// idioma por defecto de TinyMCE (no necesita paquete); el español se carga
// desde public/tinymce-langs/es.js (localización oficial de TinyMCE).
language: es ? 'es' : 'en',
...(es ? { language_url: '/tinymce-langs/es.js' } : {}),
skin: 'oxide-dark',
content_css: 'dark',
// colores de calidad de WoW dentro del editor (para el preview de wowhead)
content_style:
'a{text-decoration:none}' +
'.q0{color:#9d9d9d}.q1{color:#fff}.q2{color:#1eff00}.q3{color:#0070dd}' +
'.q4{color:#a335ee}.q5{color:#ff8000}.q6{color:#e6cc80}.q7{color:#00ccff}',
branding: false,
promotion: false,
mobile: { menubar: true },
plugins:
'preview searchreplace autolink directionality visualblocks visualchars fullscreen image link media codesample table charmap pagebreak nonbreaking anchor insertdatetime advlist lists wordcount emoticons',
toolbar:
'bold italic strikethrough forecolor backcolor emoticons | link wowhead image media | alignleft aligncenter alignright alignjustify | numlist bullist outdent indent | removeformat',
image_advtab: true,
setup: (editor) => {
// OJO: no se ejecuta wowhead DENTRO del editor a propósito. Si se hace,
// wowhead muta el DOM editable (icono, clase icontinyl y una tabla enorme
// oculta con el tooltip) y TinyMCE lo serializa al publicar, ensuciando el
// post. El editor muestra el enlace con su nombre; el icono y el color de
// calidad los pone wowhead en la página publicada (como recruit).
editor.ui.registry.addButton('wowhead', {
text: 'Wowhead',
tooltip: L.tooltip,
onAction: () => {
editor.windowManager.open({
title: L.title,
body: {
type: 'panel',
items: [
{
type: 'listbox',
name: 'wtype',
label: L.type,
items: WOWHEAD_TYPES.map((t) => ({ value: t.value, text: es ? t.es : t.en })),
},
{ type: 'input', name: 'wid', label: L.id },
{ type: 'input', name: 'wtext', label: L.text },
],
},
initialData: { wtype: 'item', wid: '', wtext: '' },
buttons: [
{ type: 'cancel', text: L.cancel },
{ type: 'submit', text: L.insert, primary: true },
],
onSubmit: async (api) => {
const data = api.getData() as { wtype: WowheadType; wid: string; wtext: string }
const id = String(data.wid).trim().replace(/\D/g, '')
if (!id) {
api.close()
return
}
const type = (data.wtype || 'item') as WowheadType
const href = wowheadUrl(type, id, locale)
const wh = wowheadData(type, id, locale)
const custom = data.wtext.trim()
api.close()
// Resolver nombre + calidad en el servidor para insertar el enlace
// YA con su nombre real y su color (clase qN), sin el parpadeo de
// «item=xxx». Si falla, se cae al modo data-wh-rename-link.
// Igual que recruit: se inserta un enlace PLANO con el nombre real
// como texto (resuelto en el servidor, sin el parpadeo de item=xxx),
// y wowhead le añade el icono (como fondo, icontinyl) y el color de
// calidad al vuelo. Nada de <img> ni clases horneadas: así no sale
// doble ni descolocado. Si no hay texto y no se resuelve el nombre,
// se cae a data-wh-rename-link para que wowhead lo nombre.
let name = custom
if (!name) {
try {
const res = await fetch(`/api/forum/wowhead?type=${type}&id=${id}&locale=${locale}`)
const info = (await res.json()) as { name: string | null }
if (info.name) name = info.name
} catch {
/* wowhead no disponible: fallback abajo */
}
}
const rename = name ? '' : ' data-wh-rename-link="true"'
const label = esc(name || `${type}=${id}`)
editor.insertContent(
`<a href="${href}" data-wowhead="${esc(wh)}"${rename} target="_blank" rel="noopener noreferrer">${label}</a>&nbsp;`,
)
},
})
},
})
},
}}
/>
{value.trim() && (
<div className="forum-preview">
<div className="forum-preview-label">{L.preview}</div>
<div className="post_container" dangerouslySetInnerHTML={{ __html: value }} />
</div>
)}
</>
)
}