Armería: muñeco de papel, talentos, actividad y caché del visor 3D

La ficha de personaje pasa a mostrar el equipo sobre el paperdoll (con
encantamientos y gemas en los tooltips de wowhead), el árbol de talentos y
la actividad reciente del personaje.

El proxy de /modelviewer/* deja de ser un rewrite de Next y pasa a un route
handler que cachea en disco los assets de zamimg, para no repetir la descarga
en cada visita.
This commit is contained in:
2026-07-16 21:07:39 +00:00
parent 65f6776969
commit 3af7f5e804
18 changed files with 1788 additions and 105 deletions
+155
View File
@@ -0,0 +1,155 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { WowheadRefresh } from './WowheadRefresh'
import { wowheadIcon, wowheadUrl, wowheadData } from '@/lib/wowhead'
export type ActivityEntry = { id: number; name: string; icon: string; date: number }
/**
* Navegador de logros del personaje: búsqueda en tiempo real (por nombre o ID) y
* paginación (anterior/siguiente + números). Los datos se piden a /api/armory/activity;
* la primera página llega ya renderizada del servidor.
*/
export function ArmoryActivity({
guid,
locale,
initial,
total: initialTotal,
pageSize,
labels,
}: {
guid: number
locale: string
initial: ActivityEntry[]
total: number
pageSize: number
labels: { earned: string; search: string; empty: string }
}) {
const [entries, setEntries] = useState<ActivityEntry[]>(initial)
const [total, setTotal] = useState(initialTotal)
const [page, setPage] = useState(1)
const [q, setQ] = useState('')
const [loading, setLoading] = useState(false)
const pages = Math.max(1, Math.ceil(total / pageSize))
const load = async (p: number, query: string) => {
setLoading(true)
try {
const res = await fetch(
`/api/armory/activity?guid=${guid}&page=${p}&q=${encodeURIComponent(query)}&locale=${locale}`,
)
const data = await res.json()
setEntries(Array.isArray(data.entries) ? data.entries : [])
setTotal(Number(data.total) || 0)
} catch {
setEntries([])
} finally {
setLoading(false)
}
}
// Búsqueda en tiempo real (con retardo para no consultar en cada tecla).
const skip = useRef(true)
useEffect(() => {
if (skip.current) {
skip.current = false
return
}
const t = setTimeout(() => {
setPage(1)
load(1, q)
}, 300)
return () => clearTimeout(t)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q])
const goto = (p: number) => {
if (p < 1 || p > pages || p === page || loading) return
setPage(p)
load(p, q)
}
const fmt = (ts: number) => {
const d = new Date(ts * 1000)
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`
}
const win = 2
const from = Math.max(1, page - win)
const to = Math.min(pages, page + win)
const nums: number[] = []
for (let p = from; p <= to; p++) nums.push(p)
return (
<>
<WowheadRefresh dep={`${page}-${q}-${entries.length}`} />
<input
className="armory-ach-search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={labels.search}
aria-label={labels.search}
/>
<div className={`armory-activity${loading ? ' loading' : ''}`}>
{entries.length === 0 ? (
<p className="armory-hint">{labels.empty}</p>
) : (
entries.map((a, i) => (
<div className="armory-activity-row" key={`${a.id}-${a.date}-${i}`}>
<span
className="armory-activity-icon"
style={{ backgroundImage: `url(${wowheadIcon(a.icon, 'small')})` }}
/>
<span className="armory-activity-text">
{labels.earned}{' '}
<a
className="armory-activity-link"
href={wowheadUrl('achievement', a.id, locale)}
data-wowhead={wowheadData('achievement', a.id, locale)}
target="_blank"
rel="noopener noreferrer"
>
[{a.name}]
</a>
.
</span>
<span className="armory-activity-date">{fmt(a.date)}</span>
</div>
))
)}
</div>
{pages > 1 && (
<div className="armory-pager">
<button onClick={() => goto(page - 1)} disabled={page <= 1} type="button" aria-label="Anterior">
</button>
{from > 1 && (
<>
<button onClick={() => goto(1)} type="button">
1
</button>
{from > 2 && <span className="armory-pager-dots"></span>}
</>
)}
{nums.map((p) => (
<button key={p} className={p === page ? 'active' : ''} onClick={() => goto(p)} type="button">
{p}
</button>
))}
{to < pages && (
<>
{to < pages - 1 && <span className="armory-pager-dots"></span>}
<button onClick={() => goto(pages)} type="button">
{pages}
</button>
</>
)}
<button onClick={() => goto(page + 1)} disabled={page >= pages} type="button" aria-label="Siguiente">
</button>
</div>
)}
</>
)
}