import { api } from '../socket.js'; const PAGE_SIZE = 25; export function renderPlayers(root) { root.innerHTML = `

Jogadores do Mundo

0 online

Allowlist

Membros 0
`; let capabilities = new Set(); let currentWhitelistEnabled = false; let page = 1; let query = ''; const action = (act, body) => api(`/api/players/${act}`, { method: 'POST', body }).then(() => { refresh(); loadDirectory(); }); const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); // --- Directory (server-side paginated) --- // Per-player actions instead of type-a-name forms: primary buttons inline, // privilege actions (op/deop/ban) behind a native
kebab menu. function rowActions(e) { const iconBtn = (data, title, cls, path) => ` `; const detail = e.xuid ? `` : ''; const online = e.online ? iconBtn(`data-tp="${esc(e.name)}"`, 'Teleportar', 'bg-blue-600/10 hover:bg-blue-600 border-blue-500/20 hover:border-blue-500 text-blue-400 hover:text-slate-950', 'M13 10V3L4 14h7v7l9-11h-7z') + iconBtn(`data-kick="${esc(e.name)}"`, 'Expulsar', 'bg-red-600/10 hover:bg-red-600 border-red-500/20 hover:border-red-600 text-red-400 hover:text-white', 'M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1') : ''; const menuItem = (act, label, danger = false) => capabilities.has(act) ? `` : ''; const items = menuItem('op', 'Tornar OP') + menuItem('deop', 'Remover OP') + menuItem('ban', 'Banir jogador', true); const menu = items ? `
${items}
` : ''; return detail + online + menu; } async function loadDirectory() { const data = await api(`/api/players/directory?page=${page}&pageSize=${PAGE_SIZE}&q=${encodeURIComponent(query)}`); if (!data) return; const list = root.querySelector('#directory'); // Don't re-render under an open kebab menu (the 5s poll would close it). if (list.querySelector('[data-kebab][open]')) return; const items = data.items || []; list.innerHTML = items.length ? items.map((e) => { const initials = esc(e.name.substring(0, 2).toUpperCase()); const dot = e.online ? `` : ''; const avatar = e.online ? 'bg-emerald-500/10 text-emerald-400' : 'bg-slate-800/40 text-slate-500'; const nameCls = e.online ? 'text-slate-200' : 'text-slate-400'; const statusLine = `${e.online ? 'Online' : 'Offline'}${e.xuid ? ` · ${esc(e.xuid)}` : ''}`; return `
  • ${initials}${dot}
    ${esc(e.name)} ${statusLine}
    ${rowActions(e)}
  • `; }).join('') : `
    ${query ? 'Nenhum jogador corresponde ao filtro' : 'Nenhum jogador encontrado'}
    `; // Pagination controls const totalPages = Math.max(1, Math.ceil((data.total || 0) / PAGE_SIZE)); const pag = root.querySelector('#pagination'); pag.innerHTML = ` Página ${data.page} de ${totalPages} · ${data.total || 0} jogador${(data.total || 0) === 1 ? '' : 'es'} `; pag.querySelector('[data-page="prev"]').onclick = () => { if (page > 1) { page--; loadDirectory(); } }; pag.querySelector('[data-page="next"]').onclick = () => { if (page < totalPages) { page++; loadDirectory(); } }; list.querySelectorAll('[data-kick]').forEach((b) => { b.onclick = () => action('kick', { name: b.dataset.kick }); }); list.querySelectorAll('[data-tp]').forEach((b) => { b.onclick = () => { const t = prompt('Teleportar para (jogador ou coordenadas x y z):'); if (t) action('teleport', { name: b.dataset.tp, target: t }); }; }); list.querySelectorAll('[data-detail]').forEach((b) => { b.onclick = () => openDetail(b.dataset.detail, b.dataset.name); }); list.querySelectorAll('[data-menu-act]').forEach((b) => { b.onclick = () => { const { menuAct, menuName } = b.dataset; b.closest('details')?.removeAttribute('open'); if (menuAct === 'ban' && !confirm(`Banir ${menuName} permanentemente do servidor?`)) return; action(menuAct, { name: menuName }); }; }); // Only one kebab menu open at a time; close on outside click. list.querySelectorAll('[data-kebab]').forEach((d) => { d.addEventListener('toggle', () => { if (d.open) list.querySelectorAll('[data-kebab][open]').forEach((o) => { if (o !== d) o.removeAttribute('open'); }); }); }); } root.addEventListener('click', (ev) => { if (!ev.target.closest('[data-kebab]')) root.querySelectorAll('[data-kebab][open]').forEach((o) => o.removeAttribute('open')); }); // --- Detail modal --- let modalTimer = null; function closeModal() { if (modalTimer) { clearInterval(modalTimer); modalTimer = null; } root.querySelector('#detail-modal').innerHTML = ''; document.removeEventListener('keydown', onEsc); } function onEsc(e) { if (e.key === 'Escape') closeModal(); } function bar(label, value, max, colorClass) { const pct = Math.max(0, Math.min(100, (value / max) * 100)); return `
    ${label}${value} / ${max}
    `; } // --- Minecraft-style inventory rendering --- // Slot layout follows the game: 0-8 hotbar, 9-35 main grid (3 rows of 9). const ICON_BASE = '/assets/mc/items'; const shortName = (n) => String(n || '').replace(/^minecraft:/, ''); const prettyName = (n) => shortName(n).replace(/_/g, ' '); function slotHtml(it, { placeholder = '' } = {}) { const inner = it && it.name ? ` ${it.count > 1 ? `${it.count}` : ''}` : placeholder; const title = it && it.name ? ` title="${esc(prettyName(it.name))}${it.count > 1 ? ` ×${it.count}` : ''}"` : ''; return `
    ${inner}
    `; } function inventoryHtml(d) { const bySlot = new Map((d.inventory || []).filter((i) => i && i.name).map((i) => [i.slot, i])); const row = (from, to) => { let cells = ''; for (let s = from; s <= to; s++) cells += slotHtml(bySlot.get(s)); return `
    ${cells}
    `; }; const armor = d.armor || {}; const armorIcons = { head: '🪖', chest: '🦺', legs: '👖', feet: '🥾' }; const equipment = ['head', 'chest', 'legs', 'feet'] .map((k) => slotHtml(armor[k], { placeholder: `${armorIcons[k]}` })) .join(''); return `
    ${equipment}
    Escudo ${slotHtml(d.offhand, { placeholder: '🛡️' })}
    ${row(9, 17)}${row(18, 26)}${row(27, 35)}
    ${row(0, 8)}
    `; } function renderModalBody(d, name) { if (d.needsBridge) { return `

    Abra este jogador uma vez enquanto ele está online para vincular os dados do save (o Bedrock não liga o nome ao arquivo até o primeiro acesso observado).

    `; } if (d.empty || !d.position) { return `

    Sem dados salvos para este jogador ainda.

    `; } const dimLabel = { overworld: 'Overworld', nether: 'Nether', end: 'End' }[d.dimension] || d.dimension; const p = d.position; const invGrid = inventoryHtml(d); return `
    Posição
    ${Math.round(p.x)}, ${Math.round(p.y)}, ${Math.round(p.z)}
    ${dimLabel}
    ${d.health ? bar('Vida', Math.round(d.health.current * 10) / 10, d.health.max, 'bg-red-500') : ''} ${typeof d.food === 'number' ? bar('Fome', d.food, 20, 'bg-amber-500') : ''}
    Inventário${d.xp ? ` · Nível ${d.xp.level}` : ''}${d.gamemode ? ` · ${esc(d.gamemode)}` : ''}
    ${invGrid}
    `; } async function openDetail(xuid, name) { const host = root.querySelector('#detail-modal'); const shell = (body, footer = '') => `
    ${esc(name.substring(0, 2).toUpperCase())}
    ${esc(name)}
    ${esc(xuid)}
    ${body}
    ${footer}
    `; host.innerHTML = shell('

    Carregando dados do save...

    '); document.addEventListener('keydown', onEsc); const wire = () => { host.querySelector('[data-close]').onclick = closeModal; host.querySelector('[data-backdrop]').onclick = (ev) => { if (ev.target === ev.currentTarget) closeModal(); }; const rb = host.querySelector('[data-refresh]'); if (rb) rb.onclick = () => load(); }; wire(); async function load() { const d = await api(`/api/players/detail?xuid=${encodeURIComponent(xuid)}`); if (!d) { host.innerHTML = shell('

    Falha ao ler os dados do jogador.

    ', footerHtml('')); wire(); return; } host.innerHTML = shell(renderModalBody(d, name), footerHtml(d)); wire(); } function footerHtml(d) { const online = d && d.online ? '● Online' : 'Offline'; const saved = d && d.savedAt ? `atualizado às ${new Date(d.savedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : ''; return `${online}${saved}`; } await load(); } // --- Allowlist card (single home for toggle + add + members) --- function renderAllowlist(data) { currentWhitelistEnabled = data.whitelistEnabled; const toggle = root.querySelector('#allowlist-toggle'); const knob = root.querySelector('#allowlist-knob'); const hint = root.querySelector('#allowlist-hint'); toggle.classList.toggle('bg-emerald-600', currentWhitelistEnabled); toggle.classList.toggle('bg-slate-700', !currentWhitelistEnabled); knob.style.transform = currentWhitelistEnabled ? 'translateX(1.5rem)' : 'translateX(0.25rem)'; hint.textContent = currentWhitelistEnabled ? 'Ativa — apenas os membros abaixo podem entrar.' : 'Desativada — qualquer jogador pode entrar no servidor.'; toggle.onclick = () => action(currentWhitelistEnabled ? 'whitelistOff' : 'whitelistOn', {}); const addForm = root.querySelector('#allowlist-add'); addForm.classList.toggle('hidden', !capabilities.has('whitelistAdd')); addForm.classList.toggle('flex', capabilities.has('whitelistAdd')); addForm.onsubmit = (e) => { e.preventDefault(); const input = addForm.querySelector('input[name="name"]'); if (input.value.trim()) action('whitelistAdd', { name: input.value.trim() }); input.value = ''; }; const list = root.querySelector('#allowlist-list'); const members = data.whitelist || []; root.querySelector('#allowlist-count').textContent = members.length; list.innerHTML = members.length ? members.map((name) => `
  • ${esc(name.substring(0, 2).toUpperCase())}
    ${esc(name)} ${capabilities.has('whitelistRemove') ? `` : ''}
  • `).join('') : '
  • Nenhum jogador na allowlist.
  • '; list.querySelectorAll('[data-wl-remove]').forEach((b) => { b.onclick = () => action('whitelistRemove', { name: b.dataset.wlRemove }); }); } async function refresh() { const data = await api('/api/players'); if (!data) return; capabilities = new Set(data.capabilities); root.querySelector('#player-count').textContent = `${data.players?.online || 0}/${data.players?.max || 20} online`; renderAllowlist(data); } // Search → server-side query, debounced, resets to page 1. let searchTimer = null; root.querySelector('#player-search').addEventListener('input', (e) => { clearTimeout(searchTimer); searchTimer = setTimeout(() => { query = e.target.value.trim(); page = 1; loadDirectory(); }, 300); }); refresh(); loadDirectory(); const timer = setInterval(() => { refresh(); loadDirectory(); }, 5000); return () => { clearInterval(timer); closeModal(); }; }