web-next: selector de personaje coloreado + rediseño unstuck + fixes vote/account-info

- CharacterSelect: componente reutilizable que colorea las opciones por clase
  (class="priest big-font"…) y el <select> con la clase seleccionada. Usado en
  unstuck, revive, gold, transfer, trade-points, servicios de pago y recruit.
  getGameCharacters ahora devuelve classCss.
- /unstuck (+revive): diseño del original (info + NOTA, prompt, opciones coloreadas,
  botón "Desbloqueado"/"Revivido" con refresh).
- /vote-points: seed de los 4 sitios con logos y URLs (sql/seed_votesites.sql);
  botón voted-button para sitios en cooldown; etiqueta "Nota:" separada.
- account-info.png (imagen nueva) y CSS de los paneles de cuenta: se añade
  background-blend-mode: saturation y background-size en #account-settings y
  .account-fieldset para que la imagen salga como el original.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-14 10:55:43 +00:00
parent b9803adeb9
commit 6ca94c2803
22 changed files with 227 additions and 86 deletions
+4 -2
View File
@@ -1,19 +1,21 @@
import type { RowDataPacket } from 'mysql2'
import { db, DB } from './db'
import { getClassCss } from './character-info'
export interface GameCharacter {
name: string
online: boolean
classCss: string // clase CSS de color por clase (priest, warrior…), para el <select>
}
/** Personajes de una cuenta de juego (acore_characters). Vacío si no está poblada. */
export async function getGameCharacters(accountId: number): Promise<GameCharacter[]> {
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT name, online FROM characters WHERE account = ?',
'SELECT name, online, class FROM characters WHERE account = ?',
[accountId],
)
return rows.map((r) => ({ name: r.name, online: r.online === 1 }))
return rows.map((r) => ({ name: r.name, online: r.online === 1, classCss: getClassCss(r.class) }))
} catch {
return []
}
+9 -3
View File
@@ -33,12 +33,13 @@ export async function getVoteSites(): Promise<VoteSite[]> {
export interface VoteSiteStatus extends VoteSite {
lastVoteAt: string | null // ISO del último voto de la cuenta en este sitio, o null
onCooldown: boolean // true si aún no puede volver a votar (dentro de las 12h30m)
}
/** Sitios de votación con la fecha del último voto de la cuenta (para el recuadro). */
export async function getVoteSitesForAccount(accountId: number): Promise<VoteSiteStatus[]> {
const sites = await getVoteSites()
if (!accountId) return sites.map((s) => ({ ...s, lastVoteAt: null }))
if (!accountId) return sites.map((s) => ({ ...s, lastVoteAt: null, onCooldown: false }))
try {
const [rows] = await db(DB.default).query<RowDataPacket[]>(
'SELECT vote_site_id, MAX(created_at) AS last FROM home_votelog WHERE account_id = ? GROUP BY vote_site_id',
@@ -47,10 +48,15 @@ export async function getVoteSitesForAccount(accountId: number): Promise<VoteSit
const map = new Map(rows.map((r) => [Number(r.vote_site_id), r.last as Date]))
return sites.map((s) => {
const last = map.get(s.id)
return { ...s, lastVoteAt: last ? new Date(last).toISOString() : null }
const lastMs = last ? new Date(last).getTime() : 0
return {
...s,
lastVoteAt: last ? new Date(last).toISOString() : null,
onCooldown: lastMs > 0 && Date.now() - lastMs < COOLDOWN_MS,
}
})
} catch {
return sites.map((s) => ({ ...s, lastVoteAt: null }))
return sites.map((s) => ({ ...s, lastVoteAt: null, onCooldown: false }))
}
}