feat(server): UI funcional com busca, filtros, detalhes e announce
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Ares Next — Tracker P2P</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0e1117; --panel: #161b24; --border: #262d3a;
|
||||
--text: #e8ecf3; --muted: #8b95a7; --accent: #4f8cff;
|
||||
--accent2: #4ade80; --danger: #f87171;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
||||
header { border-bottom: 1px solid var(--border); background: var(--panel); position: sticky; top: 0; z-index: 10; }
|
||||
.head-inner { max-width: 1000px; margin: 0 auto; padding: 14px 20px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.logo { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 18px; }
|
||||
.logo .dot { width: 12px; height: 12px; border-radius: 50%; background: var(--accent2); box-shadow: 0 0 12px var(--accent2); }
|
||||
.stats { margin-left: auto; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.stat { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 4px 10px; font-size: 12px; color: var(--muted); }
|
||||
.stat b { color: var(--text); font-size: 14px; }
|
||||
main { max-width: 1000px; margin: 0 auto; padding: 24px 20px 80px; }
|
||||
.searchbar { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 20px; }
|
||||
input[type=text], select { background: var(--panel); border: 1px solid var(--border); color: var(--text); border-radius: 8px; padding: 10px 14px; font-size: 14px; outline: none; }
|
||||
input[type=text]:focus, select:focus { border-color: var(--accent); }
|
||||
.searchbar input[type=text] { flex: 1; min-width: 220px; }
|
||||
button { background: var(--accent); color: white; border: none; border-radius: 8px; padding: 10px 18px; font-size: 14px; font-weight: 600; cursor: pointer; }
|
||||
button:hover { filter: brightness(1.1); }
|
||||
button.ghost { background: transparent; border: 1px solid var(--border); color: var(--muted); }
|
||||
button.ghost:hover { color: var(--text); border-color: var(--muted); }
|
||||
.filters { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.filters select { flex: 1; min-width: 140px; }
|
||||
.count { color: var(--muted); font-size: 13px; margin: 4px 0 12px; }
|
||||
.list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.file { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; cursor: pointer; transition: border-color .15s, transform .05s; }
|
||||
.file:hover { border-color: var(--accent); }
|
||||
.file .top { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.file .name { font-weight: 600; font-size: 15px; word-break: break-all; }
|
||||
.badge { font-size: 11px; padding: 2px 8px; border-radius: 99px; background: #22304a; color: var(--accent); text-transform: uppercase; letter-spacing: .03em; }
|
||||
.badge.size { background: #123c2a; color: var(--accent2); }
|
||||
.file .desc { color: var(--muted); font-size: 13px; margin-top: 6px; }
|
||||
.file .meta { color: var(--muted); font-size: 12px; margin-top: 6px; font-family: ui-monospace, monospace; word-break: break-all; }
|
||||
.empty { text-align: center; color: var(--muted); padding: 60px 0; }
|
||||
.empty b { display: block; font-size: 18px; margin-bottom: 6px; color: var(--text); }
|
||||
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 12px 20px; font-size: 14px; display: none; z-index: 100; max-width: 90vw; }
|
||||
.toast.show { display: block; }
|
||||
.toast.ok { border-color: var(--accent2); }
|
||||
.toast.err { border-color: var(--danger); }
|
||||
dialog { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; color: var(--text); padding: 24px; max-width: 560px; width: 90%; }
|
||||
dialog::backdrop { background: rgba(0,0,0,.6); }
|
||||
dialog h2 { margin: 0 0 16px; font-size: 18px; word-break: break-word; }
|
||||
dialog .row { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
dialog .row span { color: var(--muted); flex-shrink: 0; }
|
||||
dialog .row code { font-family: ui-monospace, monospace; word-break: break-all; text-align: right; }
|
||||
dialog .actions { display: flex; gap: 10px; margin-top: 18px; flex-wrap: wrap; }
|
||||
.peer { display: inline-block; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px; font-size: 12px; font-family: ui-monospace, monospace; margin: 2px 4px 2px 0; }
|
||||
.peers { margin-top: 14px; }
|
||||
.peers .title { font-size: 13px; color: var(--muted); margin-bottom: 6px; }
|
||||
dialog .actions .copy { background: transparent; border: 1px solid var(--accent); color: var(--accent); }
|
||||
dialog .actions .copy:hover { color: white; background: var(--accent); }
|
||||
details { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; margin-top: 24px; padding: 14px 16px; }
|
||||
details summary { cursor: pointer; font-weight: 600; font-size: 14px; }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 14px; }
|
||||
.form-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
|
||||
.form-grid label.full { grid-column: 1 / -1; }
|
||||
.form-grid input, .form-grid select { background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 8px 10px; font-size: 13px; outline: none; font-family: inherit; }
|
||||
.form-grid input:focus { border-color: var(--accent); }
|
||||
.form-actions { margin-top: 14px; display: flex; justify-content: flex-end; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="head-inner">
|
||||
<div class="logo"><span class="dot"></span> Ares Next</div>
|
||||
<div class="stats">
|
||||
<div class="stat">Arquivos <b id="st-files">–</b></div>
|
||||
<div class="stat">Peers online <b id="st-peers">–</b></div>
|
||||
<div class="stat">Total <b id="st-bytes">–</b></div>
|
||||
<div class="stat">v<span id="st-ver">–</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<div class="searchbar">
|
||||
<input type="text" id="q" placeholder="Buscar por nome, descrição, hash..." autofocus>
|
||||
<button id="btn-search">Buscar</button>
|
||||
<button class="ghost" id="btn-clear">Limpar</button>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<select id="ftype">
|
||||
<option value="">Todos os tipos</option>
|
||||
<option value="audio">Áudio</option>
|
||||
<option value="video">Vídeo</option>
|
||||
<option value="document">Documento</option>
|
||||
<option value="software">Software</option>
|
||||
<option value="other">Outro</option>
|
||||
</select>
|
||||
<select id="sort">
|
||||
<option value="relevance">Mais relevantes</option>
|
||||
<option value="newest">Mais recentes</option>
|
||||
<option value="oldest">Mais antigos</option>
|
||||
<option value="size_desc">Maior tamanho</option>
|
||||
<option value="size_asc">Menor tamanho</option>
|
||||
</select>
|
||||
<select id="limit">
|
||||
<option value="10">10 resultados</option>
|
||||
<option value="25" selected>25 resultados</option>
|
||||
<option value="50">50 resultados</option>
|
||||
<option value="100">100 resultados</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="count" id="count"></div>
|
||||
<div class="list" id="list">
|
||||
<div class="empty"><b>Ainda não há arquivos</b>Busque acima ou anuncie um arquivo para começar.</div>
|
||||
</div>
|
||||
<details>
|
||||
<summary>Anunciar arquivo (POST /api/v1/announce)</summary>
|
||||
<div class="form-grid">
|
||||
<label class="full">Nome *<input id="f-name" type="text" placeholder="ubuntu-24.04.iso"></label>
|
||||
<label class="full">Descrição<input id="f-desc" type="text" placeholder="O que é este arquivo?"></label>
|
||||
<label>Tipo
|
||||
<select id="f-type">
|
||||
<option value="software">Software</option>
|
||||
<option value="audio">Áudio</option>
|
||||
<option value="video">Vídeo</option>
|
||||
<option value="document">Documento</option>
|
||||
<option value="other">Outro</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Extensão<input id="f-ext" type="text" placeholder="iso"></label>
|
||||
<label>Peer ID *<input id="f-peer" type="text" placeholder="meu-peer-id"></label>
|
||||
<label>Tamanho (bytes) *<input id="f-size" type="number" min="0" placeholder="734003200"></label>
|
||||
<label class="full">Hash SHA-256 / BLAKE3 (64 hex) *<input id="f-hash" type="text" maxlength="64" placeholder="0123...abc"></label>
|
||||
<label class="full">Licença<input id="f-license" type="text" placeholder="MIT"></label>
|
||||
</div>
|
||||
<div class="form-actions"><button id="btn-announce">Anunciar arquivo</button></div>
|
||||
</details>
|
||||
</main>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<dialog id="dlg">
|
||||
<h2 id="dlg-name"></h2>
|
||||
<div id="dlg-body"></div>
|
||||
<div class="actions">
|
||||
<button id="dlg-close">Fechar</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
const fmtSize = b => {
|
||||
if (!b) return "0 B";
|
||||
const u = ["B","KB","MB","GB","TB"]; let i = 0;
|
||||
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
|
||||
return b.toFixed(i ? 1 : 0) + " " + u[i];
|
||||
};
|
||||
const fmtDate = ts => new Date(ts * 1000).toLocaleString("pt-BR");
|
||||
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
let toastTimer;
|
||||
function toast(msg, ok) {
|
||||
const t = $("toast");
|
||||
t.textContent = msg;
|
||||
t.className = "toast show " + (ok ? "ok" : "err");
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => t.className = "toast", 4000);
|
||||
}
|
||||
const api = async (path, opts) => {
|
||||
const r = await fetch(path, opts);
|
||||
if (!r.ok) {
|
||||
let msg = "HTTP " + r.status;
|
||||
try { const j = await r.json(); if (j.error) msg = j.error; else if (j.message) msg = j.message; } catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
return r.json();
|
||||
};
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const s = await api("/api/v1/stats");
|
||||
$("st-files").textContent = s.files;
|
||||
$("st-peers").textContent = s.online_peers;
|
||||
$("st-bytes").textContent = fmtSize(s.total_bytes);
|
||||
$("st-ver").textContent = s.version;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function search() {
|
||||
const q = $("q").value.trim();
|
||||
const params = new URLSearchParams();
|
||||
if (q) params.set("q", q);
|
||||
if ($("ftype").value) params.set("ftype", $("ftype").value);
|
||||
if ($("sort").value) params.set("sort", $("sort").value);
|
||||
params.set("limit", $("limit").value);
|
||||
const list = $("list");
|
||||
list.innerHTML = '<div class="empty"><b>Buscando...</b></div>';
|
||||
try {
|
||||
const rows = await api("/api/v1/search?" + params.toString());
|
||||
$("count").textContent = rows.length ? rows.length + " resultado(s)" : "";
|
||||
if (!rows.length) {
|
||||
list.innerHTML = '<div class="empty"><b>Nenhum resultado</b>Tente outros termos de busca.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = rows.map(f => `
|
||||
<div class="file" data-id="${esc(f.id)}">
|
||||
<div class="top">
|
||||
<span class="name">${esc(f.name)}</span>
|
||||
<span class="badge">${esc(f.ftype)}</span>
|
||||
${f.extension ? '<span class="badge size">.' + esc(f.extension) + "</span>" : ""}
|
||||
<span class="badge size">${fmtSize(f.size_bytes)}</span>
|
||||
</div>
|
||||
${f.description ? `<div class="desc">${esc(f.description)}</div>` : ""}
|
||||
<div class="meta">${esc(f.id)} · ${fmtDate(f.created_at)}</div>
|
||||
</div>`).join("");
|
||||
document.querySelectorAll(".file").forEach(el => {
|
||||
el.addEventListener("click", () => openFile(el.dataset.id));
|
||||
});
|
||||
} catch (e) {
|
||||
list.innerHTML = '<div class="empty"><b>Erro na busca</b>' + esc(e.message) + "</div>";
|
||||
}
|
||||
}
|
||||
|
||||
async function openFile(id) {
|
||||
const dlg = $("dlg");
|
||||
$("dlg-name").textContent = id;
|
||||
$("dlg-body").innerHTML = '<div class="empty">Carregando...</div>';
|
||||
dlg.showModal();
|
||||
try {
|
||||
const f = await api("/api/v1/files/" + encodeURIComponent(id));
|
||||
$("dlg-name").textContent = f.name;
|
||||
let peers = [];
|
||||
try { peers = (await api("/api/v1/peers/" + encodeURIComponent(id))).peers; } catch {}
|
||||
const row = (k, v) => `<div class="row"><span>${k}</span><code>${v}</code></div>`;
|
||||
$("dlg-body").innerHTML =
|
||||
(f.description ? `<div style="font-size:13px;color:var(--muted);margin-bottom:10px">${esc(f.description)}</div>` : "") +
|
||||
row("Peer", esc(f.peer_id)) +
|
||||
row("Tipo", esc(f.ftype) + (f.extension ? " · ." + esc(f.extension) : "")) +
|
||||
row("Tamanho", fmtSize(f.size_bytes) + " (" + f.size_bytes + " bytes)") +
|
||||
row("Licença", esc(f.license || "—")) +
|
||||
row("Tags", esc((f.tags || []).join(", ") || "—")) +
|
||||
row("Criado em", fmtDate(f.created_at)) +
|
||||
row("Hash", esc(f.hash)) +
|
||||
`<div class="peers"><div class="title">Peers online (${peers.length})</div>` +
|
||||
(peers.length ? peers.map(p => `<span class="peer">${esc(p)}</span>`).join("") : "Nenhum peer online") +
|
||||
"</div>";
|
||||
const actions = $("dlg").querySelector(".actions");
|
||||
const oldCopy = actions.querySelectorAll(".copy");
|
||||
oldCopy.forEach(b => b.remove());
|
||||
const copy = document.createElement("button");
|
||||
copy.className = "copy";
|
||||
copy.textContent = "Copiar hash";
|
||||
copy.addEventListener("click", () => {
|
||||
navigator.clipboard.writeText(f.hash).then(() => toast("Hash copiado", true)).catch(() => toast("Falha ao copiar", false));
|
||||
});
|
||||
actions.insertBefore(copy, $("dlg-close"));
|
||||
} catch (e) {
|
||||
$("dlg-body").innerHTML = '<div class="empty">' + esc(e.message) + "</div>";
|
||||
}
|
||||
}
|
||||
|
||||
async function announce() {
|
||||
const body = {
|
||||
peer_id: $("f-peer").value.trim(),
|
||||
name: $("f-name").value.trim(),
|
||||
description: $("f-desc").value.trim(),
|
||||
type: $("f-type").value,
|
||||
extension: $("f-ext").value.trim(),
|
||||
size_bytes: Number($("f-size").value),
|
||||
hash: $("f-hash").value.trim(),
|
||||
license: $("f-license").value.trim(),
|
||||
tags: []
|
||||
};
|
||||
if (!body.peer_id || !body.name || !body.size_bytes || !body.hash) {
|
||||
toast("Preencha peer_id, nome, tamanho e hash", false);
|
||||
return;
|
||||
}
|
||||
const btn = $("btn-announce");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await api("/api/v1/announce", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
toast("Anunciado: " + r.file_id, true);
|
||||
loadStats();
|
||||
search();
|
||||
$("f-name").value = $("f-desc").value = $("f-peer").value = $("f-size").value = $("f-hash").value = $("f-license").value = "";
|
||||
} catch (e) {
|
||||
toast(e.message, false);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
$("btn-search").addEventListener("click", search);
|
||||
$("q").addEventListener("keydown", e => { if (e.key === "Enter") search(); });
|
||||
$("btn-clear").addEventListener("click", () => { $("q").value = ""; $("ftype").value = ""; $("sort").value = "relevance"; search(); });
|
||||
["ftype", "sort", "limit"].forEach(id => $(id).addEventListener("change", search));
|
||||
$("btn-announce").addEventListener("click", announce);
|
||||
$("dlg-close").addEventListener("click", () => $("dlg").close());
|
||||
|
||||
loadStats();
|
||||
search();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user