Files
fileshare-app/src/static/app.js
T
2026-10-23 21:34:20 +03:00

328 lines
13 KiB
JavaScript
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 strict';
const $ = (id) => document.getElementById(id);
let statusTimer = null;
let lastData = null;
let knownPeersKey = '';
let rangeInitialized = false;
let pendingTrustId = null;
function esc(v) {
return String(v).replace(/[&<>"']/g, (s) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[s]));
}
function showStatus(msg, isError = false) {
const el = $('status');
clearTimeout(statusTimer);
el.style.display = 'block';
el.style.background = isError ? 'rgba(239,68,68,.2)' : 'rgba(16,185,129,.2)';
el.style.border = `1px solid ${isError ? '#ef4444' : '#10b981'}`;
el.textContent = msg;
statusTimer = setTimeout(() => { el.style.display = 'none'; }, 4500);
}
function formatBytes(b) {
if (b < 1024) return `${b} Б`;
if (b < 1024 ** 2) return `${(b / 1024).toFixed(1)} КБ`;
if (b < 1024 ** 3) return `${(b / 1024 ** 2).toFixed(2)} МБ`;
return `${(b / 1024 ** 3).toFixed(2)} ГБ`;
}
// Обновляем DOM только если разметка реально изменилась (не сбрасывает скролл/hover/картинки)
function setHtml(el, html) {
if (el._h !== html) { el._h = html; el.innerHTML = html; }
}
async function api(path, opts = {}) {
const res = await fetch(path, { credentials: 'same-origin', ...opts });
if (!res.ok) throw new Error((await res.text()) || `Ошибка ${res.status}`);
return res;
}
function postJson(path, body) {
return api(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
// ───────────── рендер ─────────────
function renderHeader(d) {
$('my-name').textContent = `Устройство: ${d.device_name}`;
$('my-ip').textContent = `Наш адрес: ${d.my_ip}:${d.p2p_port} | Отпечаток: ${d.my_fingerprint.slice(0, 16)}…`;
$('my-pin').textContent = d.my_pin;
$('media-dir').textContent = d.media_dir;
if (!rangeInitialized && d.my_ip && d.my_ip.includes('.') && !d.my_ip.startsWith('127.')) {
const p = d.my_ip.split('.');
$('scan-range-input').value = `${p[0]}.${p[1]}.${p[2]}.1-254`;
rangeInitialized = true;
}
}
function renderScan(scan) {
const box = $('scan-progress-box');
if (scan && (scan.active || scan.total > 0)) {
box.style.display = 'block';
const pct = scan.total > 0 ? Math.round((scan.scanned / scan.total) * 100) : 0;
$('scan-label').textContent = scan.active
? `Сканирование ${scan.range}… (найдено: ${scan.found})`
: `Сканирование ${scan.range} завершено (найдено: ${scan.found})`;
$('scan-numbers').textContent = `${scan.scanned} / ${scan.total} (${pct}%)`;
$('scan-bar-fill').style.width = `${pct}%`;
$('btn-scan-range').disabled = scan.active;
$('btn-scan-all').disabled = scan.active;
}
}
function renderTransfers(list) {
setHtml($('transfers-list'), (list || []).map((t) => {
const dir = t.direction === 'out' ? '⬆️ Отправка на' : '⬇️ Приём от';
const status = t.error ? '✗ Ошибка' : t.done ? '✓ Завершено' : `${t.percent}%`;
const note = t.error
? `<span style="color:#f87171;">${esc(t.error)}</span>`
: (t.done ? 'Готово' : 'Передача по TLS…');
return `
<div class="progress-wrap">
<div style="display:flex;justify-content:space-between;font-size:.83rem;font-weight:600;gap:8px;">
<span>${dir} ${esc(t.peer_name)}: <span style="color:#60a5fa;">${esc(t.filename)}</span></span>
<span>${esc(status)}</span>
</div>
<div class="progress-bar-bg"><div class="progress-bar-fill" style="width:${Number(t.percent)}%;"></div></div>
<div class="small muted" style="display:flex;justify-content:space-between;gap:8px;">
<span>${formatBytes(t.transferred_bytes)} из ${formatBytes(t.total_bytes)}</span>
<span>${note}</span>
</div>
</div>`;
}).join(''));
}
function renderPeers(peers) {
const box = $('peers-list');
if (!peers.length) {
setHtml(box, '<div class="muted" style="font-size:.9rem;">Устройства пока не найдены. Нажмите «Вся подсеть» или введите IP вручную.</div>');
} else {
setHtml(box, peers.map((p) => `
<div class="peer-item">
<div style="min-width:0;">
<div style="font-weight:600;">
<span class="dot ${p.online ? 'dot-on' : 'dot-off'}"></span>${esc(p.name)}
<span class="muted small">(${esc(p.ips.join(', ') || 'адрес неизвестен')}:${esc(p.port)})</span>
</div>
<div class="small muted mono" style="margin-top:3px;word-break:break-all;">
Код сверки: <b style="color:#fff;">${esc(p.pin_code)}</b><br>
Статус: ${p.verified ? '<span style="color:#34d399;">Заверен</span>' : '<span style="color:#f87171;">Не заверен</span>'}
</div>
</div>
<button class="btn-sm ${p.verified ? 'btn-red' : 'btn-green'}"
data-action="trust" data-id="${esc(p.id)}" data-trust="${p.verified ? '0' : '1'}">
${p.verified ? 'Отозвать' : 'Заверить'}
</button>
</div>`).join(''));
}
const key = peers.map((p) => `${p.id}:${p.verified}:${p.ips[0] || ''}:${p.name}`).join('|');
if (key !== knownPeersKey) {
knownPeersKey = key;
const sel = $('target-peer');
const prev = sel.value;
sel.innerHTML = '<option value="">-- Выберите устройство --</option>' + peers.map((p) =>
`<option value="${esc(p.id)}">${esc(p.name)} (${esc(p.ips[0] || '—')}) ${p.verified ? '✓' : '🔒 не заверен'}</option>`).join('');
if (prev) sel.value = prev;
}
}
function renderTexts(texts) {
const box = $('ram-texts');
if (!texts.length) return setHtml(box, '<div class="muted" style="font-size:.9rem;">Буфер ОЗУ пуст</div>');
setHtml(box, texts.map((t, i) => `
<div class="msg-item">
<div class="msg-meta">
<span>От: <b>${esc(t.sender)}</b></span>
<button class="btn-sm" data-action="copy" data-idx="${i}">Скопировать</button>
</div>
<div class="msg-text">${esc(t.text)}</div>
</div>`).join(''));
}
function renderMedia(media) {
const box = $('disk-media');
if (!media.length) return setHtml(box, '<div class="muted" style="font-size:.9rem;">Нет сохранённых файлов</div>');
setHtml(box, media.map((m) => {
const url = `/ui/files/${encodeURIComponent(m.filename)}`;
return `
<div class="msg-item">
<div class="msg-meta"><span>От: <b>${esc(m.sender)}</b></span><span>${formatBytes(m.size)}</span></div>
<div style="font-weight:600;margin:2px 0;word-break:break-all;">
<a href="${url}" target="_blank" rel="noopener" style="color:#60a5fa;text-decoration:none;">${esc(m.filename)} ↗</a>
</div>
${m.risky ? '<div class="warn">⚠ Потенциально опасный тип файла — не открывайте, если не ждали его.</div>' : ''}
${m.previewable ? `<img src="${url}" class="preview-img" alt="">` : ''}
</div>`;
}).join(''));
}
async function fetchState() {
const d = await (await api('/ui/state')).json();
lastData = d;
renderHeader(d);
renderScan(d.scan);
renderTransfers(d.transfers);
renderPeers(d.peers);
renderTexts(d.texts);
renderMedia(d.media);
}
// ───────────── действия ─────────────
async function guarded(fn) {
try { await fn(); } catch (e) { showStatus(e.message || String(e), true); }
}
function scanIpRange(full) {
return guarded(async () => {
const range = full ? 'all' : $('scan-range-input').value.trim();
if (!full && !range) return showStatus('Введите диапазон IP (например 192.168.1.1-254)', true);
await postJson('/ui/scan-range', { range });
showStatus('Сканирование запущено…');
fetchState().catch(() => {});
});
}
function addManualPeer() {
return guarded(async () => {
const raw = $('manual-ip-input').value.trim();
if (!raw) return showStatus('Введите IP-адрес устройства', true);
let ip = raw, port = null;
const i = raw.lastIndexOf(':');
if (i > 0) { ip = raw.slice(0, i); port = parseInt(raw.slice(i + 1), 10) || null; }
showStatus(`Связь с узлом ${ip}…`);
await postJson('/ui/manual-peer', port ? { ip, port } : { ip });
$('manual-ip-input').value = '';
showStatus(`Узел ${ip} найден. Сверьте код и заверьте устройство.`);
fetchState().catch(() => {});
});
}
function pingPeers() {
return guarded(async () => {
await api('/ui/ping', { method: 'POST' });
showStatus('Запущен опрос известных узлов');
setTimeout(() => fetchState().catch(() => {}), 800);
});
}
function setTrust(id, trust) {
return guarded(async () => {
await postJson('/ui/trust', { peer_id: id, trust });
showStatus(trust ? 'Устройство заверено' : 'Доверие отозвано');
fetchState().catch(() => {});
});
}
function sendText() {
return guarded(async () => {
const peer_id = $('target-peer').value;
const input = $('text-input');
const text = input.value.trim();
if (!peer_id) return showStatus('Выберите устройство-получатель!', true);
if (!text) return showStatus('Введите текст для отправки!', true);
if (new TextEncoder().encode(text).length > 64 * 1024) return showStatus('Текст больше 64 КБ', true);
await postJson('/ui/send-text', { peer_id, text });
input.value = '';
showStatus('Текст доставлен в ОЗУ получателя');
});
}
async function sendMedia() {
const peer_id = $('target-peer').value;
const fileInput = $('media-input');
const btn = $('send-media-btn');
if (!peer_id) return showStatus('Выберите устройство-получатель!', true);
if (!fileInput.files.length) return showStatus('Выберите файл!', true);
const file = fileInput.files[0];
btn.disabled = true;
try {
await api('/ui/send-media', {
method: 'POST',
headers: {
'X-Target-Id': peer_id,
'X-Filename': encodeURIComponent(file.name),
'Content-Type': 'application/octet-stream',
},
body: file,
});
fileInput.value = '';
showStatus(`Файл «${file.name}» передан`);
} catch (e) {
showStatus(e.message || String(e), true);
} finally {
btn.disabled = false;
fetchState().catch(() => {});
}
}
function clearRam() {
return guarded(async () => {
await api('/ui/clear-ram', { method: 'POST' });
showStatus('Буфер текстов в ОЗУ очищен');
fetchState().catch(() => {});
});
}
// ───────────── события (без inline-обработчиков: CSP script-src 'self') ─────────────
$('btn-ping').addEventListener('click', pingPeers);
$('btn-manual').addEventListener('click', addManualPeer);
$('btn-scan-range').addEventListener('click', () => scanIpRange(false));
$('btn-scan-all').addEventListener('click', () => scanIpRange(true));
$('btn-send-text').addEventListener('click', sendText);
$('send-media-btn').addEventListener('click', sendMedia);
$('btn-clear-ram').addEventListener('click', clearRam);
$('text-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendText(); }
});
$('peers-list').addEventListener('click', (e) => {
const btn = e.target.closest('button[data-action="trust"]');
if (!btn || !lastData) return;
const id = btn.dataset.id;
if (btn.dataset.trust === '0') return setTrust(id, false);
const peer = lastData.peers.find((p) => p.id === id);
if (!peer) return;
pendingTrustId = id;
$('td-name').textContent = peer.name;
$('td-code').textContent = peer.pin_code;
$('trust-dialog').showModal();
});
$('td-cancel').addEventListener('click', () => { pendingTrustId = null; $('trust-dialog').close(); });
$('td-ok').addEventListener('click', () => {
const id = pendingTrustId;
pendingTrustId = null;
$('trust-dialog').close();
if (id) setTrust(id, true);
});
$('ram-texts').addEventListener('click', (e) => {
const btn = e.target.closest('button[data-action="copy"]');
if (!btn || !lastData) return;
const item = lastData.texts[Number(btn.dataset.idx)];
if (!item) return;
navigator.clipboard.writeText(item.text).then(
() => showStatus('Текст скопирован в буфер обмена'),
() => showStatus('Не удалось скопировать', true),
);
});
// ───────────── опрос состояния: следующий запрос только после завершения предыдущего ─────────────
async function poll() {
try {
if (!document.hidden) await fetchState();
$('offline').style.display = 'none';
} catch (_) {
$('offline').style.display = 'block';
}
setTimeout(poll, document.hidden ? 2500 : 700);
}
poll();