Files
share/static/index.html
T
eberkheevandClaude Opus 4.6 ebe5c6ec90 Refactor: fix socket leak, deduplicate stat/path logic, simplify config
- get_local_ip: use context manager to prevent socket leak
- _route_files: single stat() per file instead of two
- _safe_path helper: DRY path resolution for download/delete
- load_config: loop over vars(args) instead of 6 repetitive blocks
- Remove stale comment from index.html

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 20:38:53 +04:00

533 lines
18 KiB
HTML

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Share</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='20' fill='%230a0a0a'/><path d='M50 20v35M50 55l-18-18M50 55l18-18' stroke='%234fc3f7' stroke-width='8' stroke-linecap='round' stroke-linejoin='round' fill='none'/><line x1='25' y1='75' x2='75' y2='75' stroke='%234fc3f7' stroke-width='8' stroke-linecap='round'/></svg>">
<style>
:root {
--bg: #0a0a0a;
--bg-card: #141414;
--bg-card-hover: #1a1a1a;
--text: #e0e0e0;
--text-dim: #888;
--text-muted: #555;
--text-empty: #444;
--accent: #4fc3f7;
--danger: #ef5350;
--warn: #ff9800;
}
* { box-sizing: border-box; margin: 0; padding: 0 }
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg); color: var(--text);
min-height: 100vh; display: flex; flex-direction: column;
align-items: center; padding: 20px;
}
h1 { font-size: 1.4rem; margin-bottom: 16px; color: #fff }
#drop {
width: 100%; max-width: 600px;
border: 2px dashed var(--text-empty); border-radius: 12px;
padding: 48px 24px; text-align: center;
cursor: pointer; transition: all .2s;
margin-bottom: 20px; position: relative;
}
#drop.over { border-color: var(--accent); background: rgba(79,195,247,.08) }
#drop.uploading { border-color: var(--warn); background: rgba(255,152,0,.06) }
#drop p { color: var(--text-dim); font-size: .95rem; pointer-events: none }
#drop .hint { font-size: .8rem; color: var(--text-muted); margin-top: 8px }
#fileinput { display: none }
#progress { display: none; margin-top: 12px; width: 100%; max-width: 600px }
#progress .bar { height: 4px; background: #222; border-radius: 2px; overflow: hidden }
#progress .fill { height: 100%; background: var(--accent); width: 0; transition: width .15s }
#progress .text { font-size: .75rem; color: var(--text-dim); margin-top: 4px; text-align: center }
#files { width: 100%; max-width: 600px; margin-top: 4px }
#files-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px }
#files h2 { font-size: 1rem; color: #aaa; margin-bottom: 0 }
.file {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; background: var(--bg-card); border-radius: 8px;
margin-bottom: 6px; transition: background .15s;
}
.file:hover { background: var(--bg-card-hover) }
.file .fname {
color: var(--accent); text-decoration: none; font-size: .9rem;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
flex: 1; margin-right: 12px; cursor: pointer;
}
.file .fname:hover { text-decoration: underline }
.file .meta {
font-size: .75rem; color: var(--text-muted); white-space: nowrap;
display: flex; gap: 4px; align-items: center;
}
.file .fsize { margin-right: 6px }
.file .btn {
cursor: pointer; padding: 4px 5px; border-radius: 4px;
transition: all .15s; line-height: 1;
display: inline-flex; align-items: center;
}
.file .dl { color: var(--text-muted); font-size: .85rem }
.file .dl:hover { color: var(--accent) }
.file .del { color: var(--text-muted); font-size: .85rem; padding-right: 0 }
.file .del:hover { color: var(--danger) }
.empty { color: var(--text-empty); font-size: .85rem; text-align: center; padding: 24px }
.pagination {
display: flex; align-items: center; justify-content: center;
gap: 4px; margin-top: 12px; flex-wrap: wrap;
}
.pagination button {
background: none; color: var(--text-muted); border: none;
padding: 4px 8px; font-size: .8rem;
cursor: pointer; transition: color .15s;
display: inline-flex; align-items: center; justify-content: center;
}
.pagination button:hover:not(:disabled) { color: #fff }
.pagination button.active { color: var(--accent); font-weight: 600 }
.pagination button:disabled { opacity: .3; cursor: default }
.pagination button.nav { padding: 4px }
.pagination button.dots { padding: 4px 2px }
.per-page {
display: flex; align-items: center; gap: 4px;
font-size: .7rem; color: var(--text-muted);
}
.per-page span.lbl { margin-right: 2px }
.per-page button {
background: none; color: var(--text-muted); border: none;
padding: 2px 4px; font-size: .7rem;
cursor: pointer; transition: color .15s;
}
.per-page button:hover { color: #fff }
.per-page button.active { color: var(--accent) }
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: #222; color: #fff; padding: 10px 20px; border-radius: 8px;
font-size: .85rem; opacity: 0; transition: opacity .3s;
pointer-events: none; z-index: 99;
}
.toast.show { opacity: 1 }
#header {
width: 100%; max-width: 600px;
display: flex; align-items: center; justify-content: center;
margin-bottom: 16px; position: relative;
}
#header h1 { margin-bottom: 0 }
#lang-sw {
position: absolute; right: 0;
display: flex; background: var(--bg-card-hover); border-radius: 6px;
padding: 2px; gap: 2px; font-size: .7rem;
}
#lang-sw span {
padding: 3px 8px; border-radius: 4px; cursor: pointer;
color: var(--text-muted); transition: all .2s; user-select: none;
}
#lang-sw span.active { background: #333; color: var(--text) }
</style>
</head>
<body>
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<symbol id="icon-dl" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="12" y1="5" x2="12" y2="15"/><line x1="7" y1="11" x2="12" y2="16"/>
<line x1="17" y1="11" x2="12" y2="16"/><line x1="5" y1="20" x2="19" y2="20"/>
</symbol>
<symbol id="icon-del" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/>
</symbol>
<symbol id="icon-prev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 6 9 12 15 18"/>
</symbol>
<symbol id="icon-next" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 6 15 12 9 18"/>
</symbol>
</svg>
<div id="header">
<h1 id="title"></h1>
<div id="lang-sw"><span data-l="ru">RU</span><span data-l="en">EN</span></div>
</div>
<div id="drop" tabindex="0">
<p id="drop-text"></p>
<div class="hint" id="drop-hint"></div>
</div>
<input type="file" id="fileinput" multiple>
<div id="progress">
<div class="bar"><div class="fill" id="pfill"></div></div>
<div class="text" id="ptext"></div>
</div>
<div id="files">
<div id="files-header">
<h2 id="files-title"></h2>
<div class="per-page" id="per-page"></div>
</div>
<div id="flist"></div>
<div class="pagination" id="pager"></div>
</div>
<div class="toast" id="toast"></div>
<!--CFG-->
<script>
const $ = id => document.getElementById(id);
const drop = $('drop'), fi = $('fileinput'), flist = $('flist'),
pfill = $('pfill'), ptext = $('ptext'), toastEl = $('toast');
/* ── Config (from server or defaults) ── */
const _cfg = typeof CFG !== 'undefined' ? CFG : {};
const defaultLang = _cfg.lang || 'ru';
const REFRESH_SEC = (_cfg.refresh || 5) * 1000;
const PER_PAGE_OPTS = _cfg.perPage || [10, 25, 50, 100];
/* ── i18n ── */
const I18N = {
ru: {
title: 'Поделиться',
drop: 'Перетащите файлы или нажмите для выбора',
hint: 'Ctrl+V — вставить изображения / файлы / текст',
files: 'Файлы',
empty: 'Файлов пока нет',
copy: 'Копировать',
download: 'Скачать',
delete: 'Удалить',
copied: 'Скопировано',
linkCopied: 'Ссылка скопирована',
imgCopied: 'Изображение скопировано',
copyFail: 'Не удалось скопировать',
uploaded: n => 'Загружено: ' + n,
uploadFail: 'Ошибка загрузки',
uploadError: 'Ошибка соединения',
deleted: 'Удалено',
perPage: 'На странице:',
sz: [' Б', ' КБ', ' МБ', ' ГБ'],
},
en: {
title: 'Share',
drop: 'Drop files here or click to select',
hint: 'Ctrl+V to paste images / files / text',
files: 'Files',
empty: 'No files yet',
copy: 'Copy to clipboard',
download: 'Download',
delete: 'Delete',
copied: 'Copied',
linkCopied: 'Copied link',
imgCopied: 'Copied image',
copyFail: 'Copy failed',
uploaded: n => 'Uploaded ' + n + ' file(s)',
uploadFail: 'Upload failed',
uploadError: 'Upload error',
deleted: 'Deleted',
perPage: 'Per page:',
sz: [' B', ' KB', ' MB', ' GB'],
}
};
let lang = localStorage.getItem('share-lang') || defaultLang;
function t() { return I18N[lang] }
function setLang(l) {
lang = l;
localStorage.setItem('share-lang', l);
document.documentElement.lang = l;
$('lang-sw').querySelectorAll('span').forEach(s => s.classList.toggle('active', s.dataset.l === l));
$('title').textContent = t().title;
$('drop-text').textContent = t().drop;
$('drop-hint').textContent = t().hint;
$('files-title').textContent = t().files;
loadFiles();
}
$('lang-sw').addEventListener('click', e => {
if (e.target.dataset.l) setLang(e.target.dataset.l);
});
/* ── Helpers ── */
let toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 2500);
}
function fmtSize(b) {
const s = t().sz;
if (b < 1024) return b + s[0];
if (b < 1048576) return (b / 1024).toFixed(1) + s[1];
if (b < 1073741824) return (b / 1048576).toFixed(1) + s[2];
return (b / 1073741824).toFixed(2) + s[3];
}
function esc(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
/* ── Clipboard ── */
function fallbackCopy(text) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
}
function copyText(text, msg) {
msg = msg || t().copied;
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text)
.then(() => toast(msg))
.catch(() => { fallbackCopy(text) ? toast(msg) : toast(t().copyFail) });
} else {
fallbackCopy(text) ? toast(msg) : toast(t().copyFail);
}
}
function toPngBlob(blob) {
if (blob.type === 'image/png') return Promise.resolve(blob);
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const c = document.createElement('canvas');
c.width = img.naturalWidth;
c.height = img.naturalHeight;
c.getContext('2d').drawImage(img, 0, 0);
c.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
URL.revokeObjectURL(img.src);
};
img.onerror = () => { URL.revokeObjectURL(img.src); reject(new Error('img load failed')); };
img.src = URL.createObjectURL(blob);
});
}
function copyFile(name, type) {
const url = '/dl/' + encodeURIComponent(name);
if (type === 'text') {
fetch(url).then(r => r.text()).then(text => copyText(text))
.catch(() => toast(t().copyFail));
} else if (type === 'image') {
const link = location.origin + url;
if (!navigator.clipboard?.write) {
copyText(link, t().linkCopied);
return;
}
const pngBlob = fetch(url).then(r => r.blob()).then(toPngBlob);
navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
.then(() => toast(t().imgCopied))
.catch(() => copyText(link, t().linkCopied));
} else {
copyText(location.origin + url, t().linkCopied);
}
}
/* ── Upload ── */
function upload(fileList) {
if (!fileList.length) return;
const fd = new FormData();
for (const f of fileList) fd.append('files', f);
const xhr = new XMLHttpRequest();
$('progress').style.display = 'block';
drop.classList.add('uploading');
xhr.upload.onprogress = e => {
if (!e.lengthComputable) return;
const pct = Math.round(e.loaded / e.total * 100);
pfill.style.width = pct + '%';
ptext.textContent = fmtSize(e.loaded) + ' / ' + fmtSize(e.total) + ' (' + pct + '%)';
};
xhr.onload = () => {
drop.classList.remove('uploading');
pfill.style.width = '100%';
setTimeout(() => { $('progress').style.display = 'none'; pfill.style.width = '0' }, 1200);
if (xhr.status === 200) {
const r = JSON.parse(xhr.responseText);
toast(t().uploaded(r.count));
loadFiles();
} else { toast(t().uploadFail) }
};
xhr.onerror = () => { drop.classList.remove('uploading'); toast(t().uploadError) };
xhr.open('POST', '/upload');
xhr.send(fd);
}
/* ── Pagination ── */
let perPage = parseInt(localStorage.getItem('share-perpage')) || PER_PAGE_OPTS[0];
let curPage = 1;
let allFiles = [];
function setPerPage(n) {
perPage = n;
localStorage.setItem('share-perpage', n);
curPage = 1;
renderFiles();
}
function setPage(p) {
curPage = p;
renderFiles();
}
function renderPager(total) {
const pages = Math.ceil(total / perPage);
const pager = $('pager');
const pp = $('per-page');
if (pages <= 1 && total <= PER_PAGE_OPTS[0]) { pager.innerHTML = ''; pp.innerHTML = ''; return }
/* per-page selector */
pp.innerHTML = '<span class="lbl">' + t().perPage + '</span>' +
PER_PAGE_OPTS.map(n =>
`<button class="${n === perPage ? 'active' : ''}" data-action="pp" data-pp="${n}">${n}</button>`
).join('');
if (pages <= 1) { pager.innerHTML = ''; return }
const icon = id => `<svg width="16" height="16"><use href="#icon-${id}"/></svg>`;
let html = `<button class="nav" data-action="page" data-page="${curPage - 1}" ${curPage === 1 ? 'disabled' : ''}>${icon('prev')}</button>`;
const show = new Set();
for (let i = 1; i <= pages; i++) {
if (i <= 2 || i > pages - 2 || Math.abs(i - curPage) <= 1) show.add(i);
}
let prev = 0;
for (const i of [...show].sort((a, b) => a - b)) {
if (prev && i - prev > 1) html += '<button class="dots" disabled>&hellip;</button>';
html += `<button class="${i === curPage ? 'active' : ''}" data-action="page" data-page="${i}">${i}</button>`;
prev = i;
}
html += `<button class="nav" data-action="page" data-page="${curPage + 1}" ${curPage === pages ? 'disabled' : ''}>${icon('next')}</button>`;
pager.innerHTML = html;
}
/* ── File list ── */
function renderFiles() {
if (!allFiles.length) {
flist.innerHTML = '<div class="empty">' + t().empty + '</div>';
$('pager').innerHTML = '';
$('per-page').innerHTML = '';
return;
}
const pages = Math.ceil(allFiles.length / perPage);
if (curPage > pages) curPage = pages;
const start = (curPage - 1) * perPage;
const slice = allFiles.slice(start, start + perPage);
flist.innerHTML = slice.map(f => `
<div class="file">
<span class="fname" data-action="copy" data-name="${esc(f.name)}" data-type="${f.type}" title="${t().copy}">${esc(f.name)}</span>
<div class="meta">
<span class="fsize">${fmtSize(f.size)}</span>
<a class="btn dl" href="/dl/${encodeURIComponent(f.name)}" title="${t().download}"><svg width="14" height="14"><use href="#icon-dl"/></svg></a>
<span class="btn del" data-action="del" data-name="${esc(f.name)}" title="${t().delete}"><svg width="14" height="14"><use href="#icon-del"/></svg></span>
</div>
</div>`).join('');
renderPager(allFiles.length);
}
function loadFiles() {
fetch('/files').then(r => r.json()).then(files => {
allFiles = files;
renderFiles();
});
}
/* ── Event delegation ── */
flist.addEventListener('click', e => {
const el = e.target.closest('[data-action]');
if (!el) return;
const action = el.dataset.action;
const name = el.dataset.name;
if (action === 'copy') copyFile(name, el.dataset.type);
else if (action === 'del') {
fetch('/del/' + encodeURIComponent(name), { method: 'DELETE' })
.then(() => { toast(t().deleted); loadFiles() });
}
});
$('pager').addEventListener('click', e => {
const el = e.target.closest('[data-action="page"]');
if (!el || el.disabled) return;
setPage(parseInt(el.dataset.page));
});
$('per-page').addEventListener('click', e => {
const el = e.target.closest('[data-action="pp"]');
if (!el) return;
setPerPage(parseInt(el.dataset.pp));
});
/* ── Events ── */
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('over') });
drop.addEventListener('dragleave', () => drop.classList.remove('over'));
drop.addEventListener('drop', e => { e.preventDefault(); drop.classList.remove('over'); upload(e.dataTransfer.files) });
drop.addEventListener('click', () => fi.click());
fi.addEventListener('change', () => { if (fi.files.length) upload(fi.files); fi.value = '' });
document.addEventListener('paste', e => {
const items = e.clipboardData?.items;
if (!items) return;
const files = [];
let textItem = null;
for (const item of items) {
if (item.kind === 'file') {
const f = item.getAsFile();
if (f) files.push(f);
} else if (item.kind === 'string' && item.type === 'text/plain' && !textItem) {
textItem = item;
}
}
if (files.length) { e.preventDefault(); upload(files) }
else if (textItem) {
e.preventDefault();
textItem.getAsString(text => {
if (!text.trim()) return;
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
upload([new File([text], 'paste_' + ts + '.txt', { type: 'text/plain' })]);
});
}
});
setLang(lang);
setInterval(loadFiles, REFRESH_SEC);
</script>
</body>
</html>