957a9f57b1
Al hornear la clase de calidad (qN), el script de wowhead daba el enlace por procesado y dejaba de añadir el icono, así que el icono desapareció. Ahora el icono se hornea junto al enlace: el endpoint /api/forum/wowhead ya devuelve el nombre del icono, y el botón inserta <img class="wh-icon" src="…zamimg…/small/<icono>.jpg"> antes del enlace. Así el ítem queda con nombre, color de calidad e icono TODO guardado, sin parpadeo y sin depender del script (que solo aporta ya el tooltip al pasar el ratón). El saneador permite class en img; forum.css estiliza .wh-icon. Verificado en producción: el post guarda la img del icono (clase wh-icon + URL de zamimg) junto al nombre y la clase q2, y el CSS del icono se sirve. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
191 lines
8.2 KiB
TypeScript
191 lines
8.2 KiB
TypeScript
'use client'
|
|
|
|
import { Editor } from '@tinymce/tinymce-react'
|
|
import { useLocale } from 'next-intl'
|
|
import { wowheadUrl, wowheadData, wowheadIcon, 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',
|
|
}
|
|
|
|
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) => {
|
|
// Vista previa: carga el script de tooltips de wowhead DENTRO del iframe
|
|
// del editor para que el enlace insertado se vea con icono, nombre y color
|
|
// de calidad, igual que al postear. No afecta a lo que se guarda: los
|
|
// formularios envían el HTML del estado de React (lo insertado, limpio),
|
|
// no la mutación visual que wowhead hace en el iframe.
|
|
const refreshEditorWowhead = () => {
|
|
try {
|
|
const wp = (editor.getWin() as unknown as { $WowheadPower?: { refreshLinks?: () => void } })
|
|
.$WowheadPower
|
|
wp?.refreshLinks?.()
|
|
} catch {
|
|
/* preview no crítico */
|
|
}
|
|
}
|
|
editor.on('init', () => {
|
|
try {
|
|
const doc = editor.getDoc()
|
|
const win = editor.getWin() as unknown as { whTooltips?: unknown }
|
|
win.whTooltips = { colorLinks: true, iconizeLinks: true, renameLinks: true }
|
|
if (!doc.getElementById('wh-tooltips-editor')) {
|
|
const s = doc.createElement('script')
|
|
s.id = 'wh-tooltips-editor'
|
|
s.src = 'https://wow.zamimg.com/js/tooltips.js'
|
|
doc.head.appendChild(s)
|
|
}
|
|
// procesa el contenido inicial (posts en edición) cuando el script cargue
|
|
setTimeout(refreshEditorWowhead, 400)
|
|
setTimeout(refreshEditorWowhead, 1200)
|
|
} catch {
|
|
/* preview no crítico */
|
|
}
|
|
})
|
|
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.
|
|
let name = custom
|
|
let qClass = ''
|
|
let iconHtml = ''
|
|
try {
|
|
const res = await fetch(`/api/forum/wowhead?type=${type}&id=${id}&locale=${locale}`)
|
|
const info = (await res.json()) as {
|
|
name: string | null
|
|
quality: number | null
|
|
icon: string | null
|
|
}
|
|
if (!name && info.name) name = info.name
|
|
if (typeof info.quality === 'number' && info.quality >= 0) qClass = ` q${info.quality}`
|
|
// Icono horneado: así no depende del script (que, al ver la clase
|
|
// de calidad, da el enlace por procesado y no añadía icono).
|
|
if (info.icon) {
|
|
iconHtml = `<img class="wh-icon" src="${esc(wowheadIcon(info.icon, 'small'))}" width="18" height="18" alt="" />`
|
|
}
|
|
} catch {
|
|
/* wowhead no disponible: se usa el fallback */
|
|
}
|
|
const rename = name ? '' : ' data-wh-rename-link="true"'
|
|
const label = esc(name || `${type}=${id}`)
|
|
editor.insertContent(
|
|
`${iconHtml}<a href="${href}" data-wowhead="${esc(wh)}" class="wowhead-link${qClass}"${rename} target="_blank" rel="noopener noreferrer">${label}</a> `,
|
|
)
|
|
// icono en el editor (el nombre y el color ya van puestos)
|
|
setTimeout(refreshEditorWowhead, 60)
|
|
},
|
|
})
|
|
},
|
|
})
|
|
},
|
|
}}
|
|
/>
|
|
)
|
|
}
|