Initial commit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 20:23:55 +04:00
co-authored by Claude Opus 4.6
commit eba5ece147
5 changed files with 906 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
.certs/
__pycache__/
*.pyc
+45
View File
@@ -0,0 +1,45 @@
# Share
Local network file sharing via web page. Drag & drop, Ctrl+V paste, download.
No dependencies — Python 3 stdlib only.
## Quick start
```
python3 share.py
```
Open `http://localhost:8888` in a browser. The network address is printed at startup.
## Options
```
python3 share.py -p 3001 -d ~/Downloads/shared --ttl 7
```
| Flag | Default | Description |
|------|---------|-------------|
| `-p`, `--port` | `8888` | Port |
| `-d`, `--dir` | `~/Downloads/shared` | Upload directory |
| `--ttl` | `7` | File lifetime in days (0 = keep forever) |
## systemd (Arch)
Service file: `~/.config/systemd/user/share.service`
```
systemctl --user enable --now share # start + autostart
systemctl --user restart share # restart
systemctl --user stop share # stop
journalctl --user -u share -f # logs
```
## Features
- Drag & drop files onto the page
- Ctrl+V to paste images / files from clipboard
- Click the drop zone to open file picker
- Upload progress bar
- File list with download and delete
- Auto-cleanup of old files (configurable via `--ttl`)
+8
View File
@@ -0,0 +1,8 @@
[share]
port = 3001
dir = ~/Downloads/shared
ttl = 7
san = 192.168.0.1
lang = ru
refresh = 5
per_page = 10,25,50,100
Executable
+308
View File
@@ -0,0 +1,308 @@
#!/usr/bin/env python3
"""Share — local file sharing server (HTTPS)."""
import argparse
import configparser
import email.parser
import http.server
import json
import mimetypes
import socket
import ssl
import subprocess
import threading
import time
import urllib.parse
from datetime import datetime
from pathlib import Path
# ── Cleanup ──
def cleanup_loop(upload_dir: Path, max_age: int):
if max_age <= 0:
return
while True:
time.sleep(3600)
now = time.time()
try:
for f in upload_dir.iterdir():
if f.is_file() and now - f.stat().st_mtime > max_age:
f.unlink()
print(f" - expired: {f.name}")
except Exception:
pass
# ── SSL ──
def get_local_ip():
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return "127.0.0.1"
def ensure_cert(cert_dir: Path, extra_ips: list[str] | None = None) -> tuple[Path, Path]:
"""Generate self-signed cert if missing. Returns (certfile, keyfile)."""
cert_dir.mkdir(parents=True, exist_ok=True)
cert = cert_dir / "cert.pem"
key = cert_dir / "key.pem"
if cert.is_file() and key.is_file():
return cert, key
ips = {"127.0.0.1", get_local_ip()}
if extra_ips:
ips.update(extra_ips)
san = ",".join([f"IP:{ip}" for ip in sorted(ips)] + ["DNS:localhost"])
subprocess.run([
"openssl", "req", "-x509", "-newkey", "rsa:2048",
"-keyout", str(key), "-out", str(cert),
"-days", "3650", "-nodes",
"-subj", "/CN=Share",
"-addext", f"subjectAltName={san}",
], check=True, capture_output=True)
print(f" Generated self-signed cert in {cert_dir}")
return cert, key
BASE_DIR = Path(__file__).resolve().parent
# ── HTTP Handler ──
class ShareServer(http.server.ThreadingHTTPServer):
def __init__(self, addr, handler, *, upload_dir: Path, html: bytes, ssl_ctx: ssl.SSLContext | None = None):
super().__init__(addr, handler)
self.upload_dir = upload_dir
self.html = html
self.ssl_ctx = ssl_ctx
def get_request(self):
client, addr = self.socket.accept()
if self.ssl_ctx:
client.settimeout(5)
try:
client = self.ssl_ctx.wrap_socket(client, server_side=True)
except (ssl.SSLError, OSError):
client.close()
raise
client.settimeout(None)
return client, addr
class Handler(http.server.BaseHTTPRequestHandler):
server: ShareServer
def finish(self):
try:
super().finish()
except (ssl.SSLError, BrokenPipeError, ConnectionResetError, OSError):
pass
def log_message(self, fmt, *args):
print(f" [{datetime.now():%H:%M:%S}] {args[0]}")
def _json(self, data):
self._respond(json.dumps(data).encode(), "application/json")
def _respond(self, data, ct):
self.send_response(200)
self.send_header("Content-Type", ct)
self.send_header("Connection", "close")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(data if isinstance(data, bytes) else data.encode())
# ── Routes ──
ROUTES = {
"GET": [
("/", "_route_index"),
("/index.html","_route_index"),
("/files", "_route_files"),
("/dl/", "_route_download"),
],
"POST": [("/upload", "_route_upload")],
"DELETE": [("/del/", "_route_delete")],
}
def _dispatch(self):
for prefix, method in self.ROUTES.get(self.command, []):
if self.path == prefix or (len(prefix) > 1 and prefix.endswith("/") and self.path.startswith(prefix)):
return getattr(self, method)()
self.send_error(404)
do_GET = do_POST = do_DELETE = _dispatch
def _route_index(self):
self._respond(self.server.html, "text/html; charset=utf-8")
def _route_files(self):
files = []
for p in sorted(self.server.upload_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True):
if p.is_file():
files.append({"name": p.name, "size": p.stat().st_size})
self._json(files)
def _route_download(self):
name = urllib.parse.unquote(self.path[4:])
safe = self.server.upload_dir / Path(name).name
if not safe.is_file():
self.send_error(404)
return
ct = mimetypes.guess_type(safe.name)[0] or "application/octet-stream"
self.send_response(200)
self.send_header("Content-Type", ct)
self.send_header("Content-Disposition", f'attachment; filename="{safe.name}"')
self.send_header("Content-Length", str(safe.stat().st_size))
self.end_headers()
with open(safe, "rb") as f:
while chunk := f.read(65536):
self.wfile.write(chunk)
def _route_upload(self):
ct = self.headers.get("Content-Type", "")
if "multipart/form-data" not in ct:
self.send_error(400)
return
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
count = self._save_parts(ct, body)
self._json({"ok": True, "count": count})
def _route_delete(self):
name = urllib.parse.unquote(self.path[5:])
safe = self.server.upload_dir / Path(name).name
if safe.is_file():
safe.unlink()
self._json({"ok": True})
# ── Helpers ──
def _save_parts(self, content_type: str, body: bytes) -> int:
header = f"Content-Type: {content_type}\r\n\r\n".encode()
msg = email.parser.BytesParser().parsebytes(header + body)
count = 0
for part in msg.walk():
fname = part.get_filename()
if not fname:
continue
fname = Path(fname).name or f"paste_{datetime.now():%H%M%S}"
data = part.get_payload(decode=True)
if not data:
continue
dest = self._unique_path(fname)
dest.write_bytes(data)
count += 1
print(f" + {dest.name} ({len(data)} bytes)")
return count
def _unique_path(self, filename: str) -> Path:
dest = self.server.upload_dir / filename
if not dest.exists():
return dest
stem, suffix = dest.stem, dest.suffix
i = 1
while dest.exists():
dest = self.server.upload_dir / f"{stem}_{i}{suffix}"
i += 1
return dest
# ── Main ──
DEFAULTS = {
"port": "3001",
"dir": str(Path.home() / "Downloads" / "shared"),
"ttl": "7",
"san": "",
"lang": "ru",
"refresh": "5",
"per_page": "10,25,50,100",
}
def load_config() -> dict:
"""Load config: defaults ← config file ← CLI args."""
cfg_path = BASE_DIR / "share.conf"
cp = configparser.ConfigParser()
cp.read_dict({"share": DEFAULTS})
cp.read(cfg_path)
conf = dict(cp["share"])
p = argparse.ArgumentParser(description="Share — local file sharing server")
p.add_argument("-p", "--port", type=int)
p.add_argument("-d", "--dir", type=str)
p.add_argument("--ttl", type=int)
p.add_argument("--san", nargs="*")
p.add_argument("--lang", type=str)
p.add_argument("--refresh", type=int)
args = p.parse_args()
if args.port is not None:
conf["port"] = str(args.port)
if args.dir is not None:
conf["dir"] = args.dir
if args.ttl is not None:
conf["ttl"] = str(args.ttl)
if args.san is not None:
conf["san"] = " ".join(args.san)
if args.lang is not None:
conf["lang"] = args.lang
if args.refresh is not None:
conf["refresh"] = str(args.refresh)
return conf
def main():
conf = load_config()
port = int(conf["port"])
upload_dir = Path(conf["dir"]).expanduser().resolve()
max_age = int(conf["ttl"]) * 86400
san_list = conf["san"].split() if conf["san"] else None
upload_dir.mkdir(parents=True, exist_ok=True)
threading.Thread(target=cleanup_loop, args=(upload_dir, max_age), daemon=True).start()
ip = get_local_ip()
cfg_json = json.dumps({
"lang": conf["lang"],
"refresh": int(conf["refresh"]),
"perPage": [int(x) for x in conf["per_page"].split(",")],
})
cfg_tag = f"<script>const CFG={cfg_json}</script>".encode()
html = (BASE_DIR / "static" / "index.html").read_bytes()
html = html.replace(b"<!--CFG-->", cfg_tag)
cert_dir = BASE_DIR / ".certs"
cert, key = ensure_cert(cert_dir, extra_ips=san_list)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(cert, key)
server = ShareServer(("0.0.0.0", port), Handler, upload_dir=upload_dir, html=html, ssl_ctx=ctx)
print(f"\n Share running")
print(f" Local: https://localhost:{port}")
print(f" Network: https://{ip}:{port}")
print(f" Files: {upload_dir}\n")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n Stopped.")
server.server_close()
if __name__ == "__main__":
main()
+542
View File
@@ -0,0 +1,542 @@
<!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);
});
const TEXT_EXT = new Set(
'txt,log,json,xml,csv,html,css,js,ts,py,md,sh,yml,yaml,toml,ini,cfg,conf,env,sql,rs,go,java,c,h,cpp,rb,php'.split(',')
);
const IMG_EXT = new Set('png,jpg,jpeg,gif,webp,bmp,ico,svg'.split(','));
/* ── 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;');
}
function fileExt(name) {
return (name.split('.').pop() || '').toLowerCase();
}
/* ── 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) {
const url = '/dl/' + encodeURIComponent(name);
const ext = fileExt(name);
if (TEXT_EXT.has(ext)) {
fetch(url).then(r => r.text()).then(text => copyText(text))
.catch(() => toast(t().copyFail));
} else if (IMG_EXT.has(ext)) {
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)}" 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);
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>