65a97aab2d
Se añade una vista previa DEBAJO del editor que renderiza el texto actual y se ve igual que el post publicado: los ítems de wowhead con icono, nombre y color de calidad. Vive en el documento principal (no en el iframe del editor), así que la procesa el tooltips.js global sin ensuciar lo que se guarda (el editor sigue enviando el enlace plano). Un efecto refresca los enlaces de la preview al cambiar el texto (con debounce). forum.css: caja .forum-preview + la regla de «sin subrayado» de wowhead se generaliza a cualquier .post_container (post y preview). Verificado: el CSS de la preview se sirve y el componente queda compilado. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
182 lines
7.6 KiB
TypeScript
182 lines
7.6 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
}
|
|
|
|
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,
|
|
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> `,
|
|
)
|
|
},
|
|
})
|
|
},
|
|
})
|
|
},
|
|
}}
|
|
/>
|
|
{value.trim() && (
|
|
<div className="forum-preview">
|
|
<div className="forum-preview-label">{L.preview}</div>
|
|
<div className="post_container" dangerouslySetInnerHTML={{ __html: value }} />
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|