Files
NightSpire/web-next/components/ArmoryActivity.tsx
T
Inna 3af7f5e804 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.
2026-07-16 21:07:39 +00:00

156 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
)}
</>
)
}