a.0.0.5
This commit is contained in:
1 parent
e88dc07696
commit
5d01dfb63a
12 files changed
+2438
-1891
No files matched your search
@@ -0,0 +1,327 @@
|
||||
'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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
|
||||
+68
-362
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LAN Share — Постоянный PIN | Текст в ОЗУ | Медиа на Диск</title>
|
||||
<title>LAN Share</title>
|
||||
<style>
|
||||
:root { --bg: #0f172a; --card: #1e293b; --border: #334155; --accent: #3b82f6; --green: #10b981; --red: #ef4444; --text: #f8fafc; --muted: #94a3b8; }
|
||||
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
|
||||
@@ -12,428 +12,134 @@
|
||||
@media (max-width: 800px) { .container { grid-template-columns: 1fr; } }
|
||||
.header { grid-column: 1 / -1; background: var(--card); border: 1px solid var(--border); padding: 16px 20px; border-radius: 12px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||
.pin-badge { background: #1d4ed8; padding: 6px 14px; border-radius: 8px; font-weight: 700; letter-spacing: 1px; font-family: monospace; font-size: 1.15rem; }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 18px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 18px; display: flex; flex-direction: column; gap: 14px; min-width: 0; }
|
||||
h2 { margin: 0; font-size: 1.08rem; display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
||||
.tag { font-size: 0.75rem; padding: 3px 8px; border-radius: 6px; font-weight: 600; }
|
||||
.tag-ram { background: rgba(16, 185, 129, 0.2); color: #34d399; }
|
||||
.tag-disk { background: rgba(59, 130, 246, 0.2); color: #60a5fa; }
|
||||
input[type="text"], select, textarea, input[type="file"] { width: 100%; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 10px; font-size: 0.92rem; }
|
||||
.tag-ram { background: rgba(16,185,129,.2); color: #34d399; }
|
||||
.tag-disk { background: rgba(59,130,246,.2); color: #60a5fa; }
|
||||
input[type="text"], select, textarea, input[type="file"] { width: 100%; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 10px; font-size: .92rem; }
|
||||
textarea { resize: vertical; min-height: 80px; }
|
||||
button { background: var(--accent); color: white; border: none; border-radius: 8px; padding: 10px 16px; font-weight: 600; cursor: pointer; transition: opacity 0.15s; }
|
||||
button:hover { opacity: 0.9; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
button.btn-green { background: var(--green); }
|
||||
button.btn-red { background: var(--red); }
|
||||
button.btn-sm { padding: 7px 12px; font-size: 0.82rem; }
|
||||
button { background: var(--accent); color: #fff; border: none; border-radius: 8px; padding: 10px 16px; font-weight: 600; cursor: pointer; transition: opacity .15s; }
|
||||
button:hover { opacity: .9; }
|
||||
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn-green { background: var(--green); }
|
||||
.btn-red { background: var(--red); }
|
||||
.btn-gray { background: #475569; }
|
||||
.btn-sm { padding: 7px 12px; font-size: .82rem; }
|
||||
.peer-item, .msg-item { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 12px; display: flex; justify-content: space-between; align-items: center; gap: 10px; }
|
||||
.msg-item { flex-direction: column; align-items: flex-start; }
|
||||
.msg-meta { width: 100%; display: flex; justify-content: space-between; font-size: 0.8rem; color: var(--muted); }
|
||||
.msg-text { font-size: 0.95rem; white-space: pre-wrap; word-break: break-word; width: 100%; margin: 4px 0; }
|
||||
.path-box { font-family: monospace; font-size: 0.78rem; color: #93c5fd; background: rgba(59,130,246,0.1); padding: 4px 8px; border-radius: 4px; word-break: break-all; }
|
||||
.msg-meta { width: 100%; display: flex; justify-content: space-between; font-size: .8rem; color: var(--muted); }
|
||||
.msg-text { font-size: .95rem; white-space: pre-wrap; word-break: break-word; width: 100%; margin: 4px 0; }
|
||||
.path-box { font-family: monospace; font-size: .78rem; color: #93c5fd; background: rgba(59,130,246,.1); padding: 4px 8px; border-radius: 4px; word-break: break-all; }
|
||||
.preview-img { max-width: 100%; max-height: 180px; border-radius: 6px; margin-top: 6px; }
|
||||
.warn { color: #fbbf24; font-size: .8rem; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: .8rem; }
|
||||
.mono { font-family: monospace; }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
|
||||
.dot-on { background: #10b981; }
|
||||
.dot-off { background: #64748b; }
|
||||
.progress-wrap { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.progress-bar-bg { width: 100%; height: 10px; background: #0b1120; border-radius: 6px; overflow: hidden; border: 1px solid var(--border); }
|
||||
.progress-bar-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #10b981); transition: width 0.2s ease; }
|
||||
.sub-box { background: rgba(15, 23, 42, 0.55); border: 1px solid var(--border); border-radius: 8px; padding: 10px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.progress-bar-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #10b981); transition: width .2s ease; }
|
||||
.sub-box { background: rgba(15,23,42,.55); border: 1px solid var(--border); border-radius: 8px; padding: 10px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.col { display: flex; flex-direction: column; gap: 8px; }
|
||||
#status { grid-column: 1 / -1; padding: 10px 16px; border-radius: 8px; display: none; font-weight: 500; }
|
||||
#offline { grid-column: 1 / -1; padding: 10px 16px; border-radius: 8px; display: none; background: rgba(239,68,68,.25); border: 1px solid var(--red); }
|
||||
dialog { background: var(--card); color: var(--text); border: 1px solid var(--border); border-radius: 12px; padding: 22px; max-width: 460px; }
|
||||
dialog::backdrop { background: rgba(0,0,0,.65); }
|
||||
.sas { font-family: monospace; font-size: 1.5rem; font-weight: 700; letter-spacing: 2px; background: #1d4ed8; border-radius: 8px; padding: 12px; text-align: center; margin: 14px 0; word-break: break-all; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div>
|
||||
<div style="font-size: 1.2rem; font-weight: 700;" id="my-name">Загрузка...</div>
|
||||
<div style="font-size: 0.85rem; color: var(--muted);" id="my-ip"></div>
|
||||
<div style="font-size:1.2rem;font-weight:700;" id="my-name">Загрузка...</div>
|
||||
<div class="small muted" id="my-ip"></div>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size: 0.85rem; color: var(--muted); margin-right: 8px;">Постоянный PIN устройства:</span>
|
||||
<span class="small muted" style="margin-right:8px;">ID устройства:</span>
|
||||
<span class="pin-badge" id="my-pin">--------</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="offline">Нет связи с приложением. Проверьте, что оно запущено.</div>
|
||||
<div id="status"></div>
|
||||
|
||||
<!-- 1. Устройства в сети и сканер диапазона IP -->
|
||||
<div class="card">
|
||||
<h2>
|
||||
<span>1. Устройства в сети</span>
|
||||
<button class="btn-sm" onclick="pingPeers()">Опросить известных</button>
|
||||
<button class="btn-sm" id="btn-ping">Опросить известных</button>
|
||||
</h2>
|
||||
|
||||
<!-- Одиночное добавление по IP -->
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<input type="text" id="manual-ip-input" placeholder="Один IP:порт (напр. 192.168.1.55 или 192.168.1.55:8443)">
|
||||
<button class="btn-sm" style="white-space: nowrap;" onclick="addManualPeer()">Найти IP</button>
|
||||
<div class="row" style="flex-wrap:nowrap;">
|
||||
<input type="text" id="manual-ip-input" placeholder="Один IP[:порт] (напр. 192.168.1.55 или 192.168.1.55:8443)">
|
||||
<button class="btn-sm" style="white-space:nowrap;" id="btn-manual">Найти IP</button>
|
||||
</div>
|
||||
|
||||
<!-- Сканер диапазона IP -->
|
||||
<div class="sub-box">
|
||||
<div style="font-size: 0.82rem; color: var(--muted); font-weight: 600;">Сканер диапазона IP-адресов:</div>
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
<input type="text" id="scan-range-input" style="flex: 1; min-width: 190px;" placeholder="Напр. 192.168.1.1-254 или 192.168.1.10-50">
|
||||
<button class="btn-sm" id="btn-scan-range" onclick="scanIpRange(false)">Сканировать диапазон</button>
|
||||
<button class="btn-sm btn-green" id="btn-scan-all" onclick="scanIpRange(true)">Вся подсеть (.1–254)</button>
|
||||
<div class="small muted" style="font-weight:600;">Сканер диапазона IP-адресов (только локальные сети):</div>
|
||||
<div class="row">
|
||||
<input type="text" id="scan-range-input" style="flex:1;min-width:190px;" placeholder="Напр. 192.168.1.1-254, 10.0.0.0/24">
|
||||
<button class="btn-sm" id="btn-scan-range">Сканировать диапазон</button>
|
||||
<button class="btn-sm btn-green" id="btn-scan-all">Вся подсеть (.1–254)</button>
|
||||
</div>
|
||||
<div id="scan-progress-box" style="display: none;">
|
||||
<div style="display:flex; justify-content:space-between; font-size:0.78rem; color:var(--muted); margin-bottom:4px;">
|
||||
<div id="scan-progress-box" style="display:none;">
|
||||
<div class="small muted" style="display:flex;justify-content:space-between;margin-bottom:4px;">
|
||||
<span id="scan-label">Сканирование...</span>
|
||||
<span id="scan-numbers">0 / 0</span>
|
||||
</div>
|
||||
<div class="progress-bar-bg">
|
||||
<div class="progress-bar-fill" id="scan-bar-fill" style="width: 0%;"></div>
|
||||
</div>
|
||||
<div class="progress-bar-bg"><div class="progress-bar-fill" id="scan-bar-fill" style="width:0%;"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="peers-list" style="display:flex; flex-direction:column; gap:8px;">
|
||||
<div style="color:var(--muted); font-size:0.9rem;">Поиск устройств в локальной сети...</div>
|
||||
</div>
|
||||
<div id="peers-list" class="col"></div>
|
||||
<div class="small muted">Доверие должно быть взаимным: заверьте устройство на обеих сторонах после сверки кода.</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Отправка данных и прогресс-бар передачи -->
|
||||
<div class="card">
|
||||
<h2>2. Отправка данных (по нажатию)</h2>
|
||||
<label style="font-size:0.85rem; color:var(--muted);">Получатель (должен быть заверен):</label>
|
||||
<h2>2. Отправка данных</h2>
|
||||
<label class="small muted">Получатель (должен быть заверен):</label>
|
||||
<select id="target-peer"><option value="">-- Выберите устройство --</option></select>
|
||||
|
||||
<textarea id="text-input" placeholder="Введите текст (Enter — отправить, Shift+Enter — новая строка)..."></textarea>
|
||||
<button onclick="sendText()">Отправить текст в ОЗУ</button>
|
||||
<textarea id="text-input" placeholder="Введите текст (Enter — отправить, Shift+Enter — новая строка, до 64 КБ)..."></textarea>
|
||||
<button id="btn-send-text">Отправить текст в ОЗУ</button>
|
||||
|
||||
<hr style="border:0; border-top:1px solid var(--border); width:100%; margin: 4px 0;">
|
||||
<hr style="border:0;border-top:1px solid var(--border);width:100%;margin:4px 0;">
|
||||
<input type="file" id="media-input">
|
||||
<button class="btn-green" id="send-media-btn" onclick="sendMedia()">Отправить медиа на диск получателя</button>
|
||||
<button class="btn-green" id="send-media-btn">Отправить файл на диск получателя</button>
|
||||
|
||||
<!-- Контейнер прогресс-баров активных передач (исходящих и входящих) -->
|
||||
<div id="transfers-list" style="display:flex; flex-direction:column; gap:8px;"></div>
|
||||
<div id="transfers-list" class="col"></div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Принятый текст в ОЗУ -->
|
||||
<div class="card">
|
||||
<h2>
|
||||
<span>Входящий текст <span class="tag tag-ram">ТОЛЬКО В ОЗУ</span></span>
|
||||
<button class="btn-red btn-sm" onclick="clearRam()">Очистить ОЗУ</button>
|
||||
<button class="btn-red btn-sm" id="btn-clear-ram">Очистить ОЗУ</button>
|
||||
</h2>
|
||||
<div id="ram-texts" style="display:flex; flex-direction:column; gap:10px; max-height: 400px; overflow-y: auto;"></div>
|
||||
<div id="ram-texts" class="col" style="max-height:400px;overflow-y:auto;"></div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Принятые медиафайлы на диске -->
|
||||
<div class="card">
|
||||
<h2>Входящие медиафайлы <span class="tag tag-disk">НА ДИСКЕ (./received_media)</span></h2>
|
||||
<div id="disk-media" style="display:flex; flex-direction:column; gap:10px; max-height: 400px; overflow-y: auto;"></div>
|
||||
<h2>Входящие файлы <span class="tag tag-disk">НА ДИСКЕ</span></h2>
|
||||
<div class="small muted mono" id="media-dir"></div>
|
||||
<div id="disk-media" class="col" style="max-height:400px;overflow-y:auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let knownPeersKey = "";
|
||||
let defaultRangeInitialized = false;
|
||||
<dialog id="trust-dialog">
|
||||
<h3 style="margin:0 0 8px;">Заверить устройство?</h3>
|
||||
<div>«<b id="td-name"></b>»</div>
|
||||
<div class="sas" id="td-code"></div>
|
||||
<div class="small muted">Код должен <b>полностью совпадать</b> с кодом, который это устройство показывает у вас в списке. Если коды различаются, вас перехватывают — не заверяйте.</div>
|
||||
<div class="row" style="margin-top:16px;justify-content:flex-end;">
|
||||
<button class="btn-gray" id="td-cancel">Отмена</button>
|
||||
<button class="btn-green" id="td-ok">Коды совпадают — заверить</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
function showStatus(msg, isError = false) {
|
||||
const el = document.getElementById('status');
|
||||
el.style.display = 'block';
|
||||
el.style.background = isError ? 'rgba(239, 68, 68, 0.2)' : 'rgba(16, 185, 129, 0.2)';
|
||||
el.style.border = `1px solid ${isError ? '#ef4444' : '#10b981'}`;
|
||||
el.textContent = msg;
|
||||
setTimeout(() => { el.style.display = 'none'; }, 4000);
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024) return `${bytes} Б`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} МБ`;
|
||||
}
|
||||
|
||||
async function fetchState() {
|
||||
try {
|
||||
const res = await fetch('/ui/state');
|
||||
const data = await res.json();
|
||||
|
||||
document.getElementById('my-name').textContent = `Устройство: ${data.device_name}`;
|
||||
document.getElementById('my-ip').textContent = `Наш адрес: ${data.my_ip}:${data.p2p_port} | ID: ${data.my_fingerprint.slice(0, 20)}...`;
|
||||
document.getElementById('my-pin').textContent = data.my_pin;
|
||||
|
||||
// Автоподстановка подсети в поле диапазона при первой загрузке
|
||||
if (!defaultRangeInitialized && data.my_ip && data.my_ip.includes('.')) {
|
||||
const parts = data.my_ip.split('.');
|
||||
if (parts.length === 4 && parts[0] !== '127') {
|
||||
document.getElementById('scan-range-input').value = `${parts[0]}.${parts[1]}.${parts[2]}.1-254`;
|
||||
defaultRangeInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Отображение статуса сканирования IP
|
||||
const scanBox = document.getElementById('scan-progress-box');
|
||||
const btnScanRange = document.getElementById('btn-scan-range');
|
||||
const btnScanAll = document.getElementById('btn-scan-all');
|
||||
if (data.scan && (data.scan.active || data.scan.total > 0)) {
|
||||
scanBox.style.display = 'block';
|
||||
const pct = data.scan.total > 0 ? Math.round((data.scan.scanned / data.scan.total) * 100) : 0;
|
||||
document.getElementById('scan-label').textContent = data.scan.active
|
||||
? `Сканирование ${data.scan.range}... (найдено: ${data.scan.found})`
|
||||
: `Сканирование ${data.scan.range} завершено (найдено: ${data.scan.found})`;
|
||||
document.getElementById('scan-numbers').textContent = `${data.scan.scanned} / ${data.scan.total} (${pct}%)`;
|
||||
document.getElementById('scan-bar-fill').style.width = `${pct}%`;
|
||||
btnScanRange.disabled = data.scan.active;
|
||||
btnScanAll.disabled = data.scan.active;
|
||||
}
|
||||
|
||||
// Отображение прогресс-баров передачи файлов
|
||||
const transfersContainer = document.getElementById('transfers-list');
|
||||
if (!data.transfers || data.transfers.length === 0) {
|
||||
transfersContainer.innerHTML = '';
|
||||
} else {
|
||||
transfersContainer.innerHTML = data.transfers.map(tr => {
|
||||
const dirBadge = tr.direction === 'out' ? '⬆️ Отправка на' : '⬇️ Прием от';
|
||||
const statusText = tr.done ? '✓ Завершено' : `${tr.percent}%`;
|
||||
return `
|
||||
<div class="progress-wrap">
|
||||
<div style="display:flex; justify-content:space-between; font-size:0.83rem; font-weight:600;">
|
||||
<span>${dirBadge} ${escapeHtml(tr.peer_name)}: <span style="color:#60a5fa;">${escapeHtml(tr.filename)}</span></span>
|
||||
<span>${statusText}</span>
|
||||
</div>
|
||||
<div class="progress-bar-bg">
|
||||
<div class="progress-bar-fill" style="width: ${tr.percent}%;"></div>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:space-between; font-size:0.76rem; color:var(--muted);">
|
||||
<span>${formatBytes(tr.transferred_bytes)} из ${formatBytes(tr.total_bytes)}</span>
|
||||
<span>${tr.done ? 'Сохранено на диске' : 'Передача по TLS...'}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Список устройств
|
||||
const peersContainer = document.getElementById('peers-list');
|
||||
if (data.peers.length === 0) {
|
||||
peersContainer.innerHTML = '<div style="color:var(--muted); font-size:0.9rem;">Устройства пока не найдены. Нажмите «Вся подсеть (.1–254)» или введите IP вручную.</div>';
|
||||
} else {
|
||||
peersContainer.innerHTML = data.peers.map(p => {
|
||||
const ipStr = p.ips.join(', ');
|
||||
const dotClass = p.online ? 'dot-on' : 'dot-off';
|
||||
return `
|
||||
<div class="peer-item">
|
||||
<div>
|
||||
<div style="font-weight:600;">
|
||||
<span class="dot ${dotClass}"></span>${escapeHtml(p.name)}
|
||||
<span style="color:var(--muted); font-size:0.85rem;">(${escapeHtml(ipStr)}:${p.port})</span>
|
||||
</div>
|
||||
<div style="font-size:0.8rem; color:var(--muted); font-family:monospace; margin-top:3px;">
|
||||
PIN: <b style="color:#fff;">[${p.pin_code}]</b> |
|
||||
Статус: ${p.verified ? '<span style="color:#34d399;">Заверен</span>' : '<span style="color:#f87171;">Не заверен</span>'}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-sm ${p.verified ? 'btn-red' : 'btn-green'}" onclick="toggleTrust('${p.id}', ${!p.verified})">
|
||||
${p.verified ? 'Отозвать' : 'Заверить'}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const currentSelectKey = data.peers.map(p => `${p.id}:${p.verified}:${p.ips[0]}`).join('|');
|
||||
if (currentSelectKey !== knownPeersKey) {
|
||||
knownPeersKey = currentSelectKey;
|
||||
const select = document.getElementById('target-peer');
|
||||
const prevVal = select.value;
|
||||
select.innerHTML = '<option value="">-- Выберите устройство --</option>' +
|
||||
data.peers.map(p => `<option value="${p.id}">${escapeHtml(p.name)} [${p.pin_code}] (${p.ips[0] || ''}) ${p.verified ? '✓' : '🔒 [Не заверен]'}</option>`).join('');
|
||||
if (prevVal) select.value = prevVal;
|
||||
}
|
||||
|
||||
const textsContainer = document.getElementById('ram-texts');
|
||||
if (data.texts.length === 0) {
|
||||
textsContainer.innerHTML = '<div style="color:var(--muted); font-size:0.9rem;">Буфер ОЗУ пуст</div>';
|
||||
} else {
|
||||
textsContainer.innerHTML = data.texts.map(t => `
|
||||
<div class="msg-item">
|
||||
<div class="msg-meta">
|
||||
<span>От: <b>${escapeHtml(t.sender)}</b></span>
|
||||
<button class="btn-sm" onclick="copyText(${JSON.stringify(t.text).replace(/"/g, '"')})">Скопировать</button>
|
||||
</div>
|
||||
<div class="msg-text">${escapeHtml(t.text)}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
const mediaContainer = document.getElementById('disk-media');
|
||||
if (data.media.length === 0) {
|
||||
mediaContainer.innerHTML = '<div style="color:var(--muted); font-size:0.9rem;">Нет сохраненных медиафайлов</div>';
|
||||
} else {
|
||||
mediaContainer.innerHTML = data.media.map(m => {
|
||||
const isImg = m.mime.startsWith('image/');
|
||||
const fileUrl = `/ui/files/${encodeURIComponent(m.filename)}`;
|
||||
return `
|
||||
<div class="msg-item">
|
||||
<div class="msg-meta">
|
||||
<span>От: <b>${escapeHtml(m.sender)}</b></span>
|
||||
<span>${formatBytes(m.size)}</span>
|
||||
</div>
|
||||
<div style="font-weight:600; margin:2px 0;">
|
||||
<a href="${fileUrl}" target="_blank" style="color:#60a5fa; text-decoration:none;">${escapeHtml(m.filename)} ↗</a>
|
||||
</div>
|
||||
<div class="path-box">Диск: ${escapeHtml(m.saved_path)}</div>
|
||||
${isImg ? `<img src="${fileUrl}" class="preview-img">` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function scanIpRange(fullSubnet = false) {
|
||||
const input = document.getElementById('scan-range-input');
|
||||
const range = fullSubnet ? 'all' : input.value.trim();
|
||||
if (!fullSubnet && !range) {
|
||||
return showStatus('Введите диапазон IP (например 192.168.1.1-254 или 192.168.1.10-50)', true);
|
||||
}
|
||||
|
||||
const res = await fetch('/ui/scan-range', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ range })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showStatus('Сканирование диапазона запущено...');
|
||||
fetchState();
|
||||
} else {
|
||||
const err = await res.text();
|
||||
showStatus(err, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function addManualPeer() {
|
||||
const input = document.getElementById('manual-ip-input');
|
||||
const raw = input.value.trim();
|
||||
if (!raw) return showStatus('Введите IP-адрес устройства', true);
|
||||
|
||||
let ip = raw;
|
||||
let port = 8443;
|
||||
if (raw.includes(':')) {
|
||||
const parts = raw.split(':');
|
||||
ip = parts[0];
|
||||
port = parseInt(parts[1], 10) || 8443;
|
||||
}
|
||||
|
||||
showStatus(`Связь с узлом ${ip}:${port}...`);
|
||||
const res = await fetch('/ui/manual-peer', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ip, port })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
input.value = '';
|
||||
showStatus(`Узел ${ip} подключен (оба устройства обменялись PIN-кодами)!`);
|
||||
fetchState();
|
||||
} else {
|
||||
const err = await res.text();
|
||||
showStatus(err, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function pingPeers() {
|
||||
await fetch('/ui/ping', { method: 'POST' });
|
||||
showStatus('Запущен опрос известных узлов');
|
||||
setTimeout(fetchState, 600);
|
||||
}
|
||||
|
||||
async function toggleTrust(peer_id, trust) {
|
||||
const res = await fetch('/ui/trust', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ peer_id, trust })
|
||||
});
|
||||
if (res.ok) {
|
||||
showStatus(trust ? 'Сертификат устройства заверен' : 'Доверие устройству отозвано');
|
||||
fetchState();
|
||||
}
|
||||
}
|
||||
|
||||
async function sendText() {
|
||||
const peer_id = document.getElementById('target-peer').value;
|
||||
const input = document.getElementById('text-input');
|
||||
const text = input.value.trim();
|
||||
if (!peer_id) return showStatus('Выберите устройство-получатель!', true);
|
||||
if (!text) return showStatus('Введите текст для отправки!', true);
|
||||
|
||||
const res = await fetch('/ui/send-text', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ peer_id, text })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
input.value = '';
|
||||
showStatus('Текст успешно доставлен в ОЗУ получателя');
|
||||
} else {
|
||||
const err = await res.text();
|
||||
showStatus(err, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMedia() {
|
||||
const peer_id = document.getElementById('target-peer').value;
|
||||
const fileInput = document.getElementById('media-input');
|
||||
const sendBtn = document.getElementById('send-media-btn');
|
||||
if (!peer_id) return showStatus('Выберите устройство-получатель!', true);
|
||||
if (!fileInput.files.length) return showStatus('Выберите медиафайл!', true);
|
||||
|
||||
const file = fileInput.files[0];
|
||||
sendBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/ui/send-media', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Target-Id': peer_id,
|
||||
'X-Filename': encodeURIComponent(file.name),
|
||||
'Content-Type': file.type || 'application/octet-stream'
|
||||
},
|
||||
body: file
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
fileInput.value = '';
|
||||
showStatus(`Файл "${file.name}" успешно передан на диск получателя`);
|
||||
} else {
|
||||
const err = await res.text();
|
||||
showStatus(err, true);
|
||||
}
|
||||
} finally {
|
||||
sendBtn.disabled = false;
|
||||
fetchState();
|
||||
}
|
||||
}
|
||||
|
||||
async function clearRam() {
|
||||
await fetch('/ui/clear-ram', { method: 'POST' });
|
||||
fetchState();
|
||||
showStatus('Буфер текстовых сообщений в ОЗУ очищен');
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
navigator.clipboard.writeText(text);
|
||||
showStatus('Текст скопирован в буфер обмена');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str).replace(/[&<>"']/g, s => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[s]));
|
||||
}
|
||||
|
||||
document.getElementById('text-input').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendText();
|
||||
}
|
||||
});
|
||||
|
||||
// Опрос состояния каждые 500 мс для плавного отображения прогресс-баров
|
||||
setInterval(fetchState, 500);
|
||||
fetchState();
|
||||
</script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user