Torrent2Space / static /index.html
alisaadhq's picture
Upload 19 files
d286c92 verified
Raw
History Blame Contribute Delete
18.3 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Torrent2Space</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',system-ui,sans-serif;background:#0f1117;color:#e2e8f0;min-height:100vh}
header{background:#1a1f2e;border-bottom:1px solid #2d3748;padding:16px 24px;display:flex;align-items:center;gap:12px}
header h1{font-size:1.3rem;font-weight:700;color:#7c3aed}
header span{font-size:.8rem;color:#64748b;background:#1e293b;padding:2px 8px;border-radius:999px}
.container{max-width:900px;margin:32px auto;padding:0 16px}
/* Add torrent card */
.card{background:#1a1f2e;border:1px solid #2d3748;border-radius:12px;padding:24px;margin-bottom:20px}
.card h2{font-size:.95rem;font-weight:600;color:#94a3b8;margin-bottom:16px;text-transform:uppercase;letter-spacing:.05em}
.input-row{display:flex;gap:8px}
.input-row input{flex:1;background:#0f1117;border:1px solid #374151;border-radius:8px;padding:10px 14px;color:#e2e8f0;font-size:.95rem;outline:none;transition:border .2s}
.input-row input:focus{border-color:#7c3aed}
.btn{padding:10px 20px;border:none;border-radius:8px;font-size:.9rem;font-weight:600;cursor:pointer;transition:all .2s}
.btn-primary{background:#7c3aed;color:#fff}
.btn-primary:hover{background:#6d28d9}
.btn-primary:disabled{background:#374151;color:#64748b;cursor:not-allowed}
.btn-danger{background:#dc2626;color:#fff}
.btn-danger:hover{background:#b91c1c}
.btn-sm{padding:6px 12px;font-size:.8rem}
.btn-ghost{background:transparent;border:1px solid #374151;color:#94a3b8}
.btn-ghost:hover{border-color:#7c3aed;color:#7c3aed}
.or-divider{text-align:center;color:#4b5563;font-size:.85rem;margin:12px 0;position:relative}
.or-divider::before,.or-divider::after{content:'';position:absolute;top:50%;width:45%;height:1px;background:#2d3748}
.or-divider::before{left:0}.or-divider::after{right:0}
.file-upload-area{border:2px dashed #374151;border-radius:8px;padding:20px;text-align:center;cursor:pointer;transition:all .2s;color:#64748b}
.file-upload-area:hover,.file-upload-area.drag{border-color:#7c3aed;color:#a78bfa}
.file-upload-area input{display:none}
/* File selector */
#file-list-section{display:none}
.torrent-info{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:gap}
.torrent-name{font-weight:600;color:#e2e8f0;font-size:1rem}
.torrent-meta{font-size:.8rem;color:#64748b;margin-top:2px}
.file-table{width:100%;border-collapse:collapse}
.file-table th{text-align:left;font-size:.75rem;color:#64748b;font-weight:600;padding:8px 12px;text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid #2d3748}
.file-table td{padding:10px 12px;border-bottom:1px solid #1e293b;font-size:.875rem}
.file-table tr:last-child td{border-bottom:none}
.file-table tr:hover td{background:#1e293b}
.file-check{width:16px;height:16px;accent-color:#7c3aed;cursor:pointer}
.file-name{color:#cbd5e1}
.file-size{color:#64748b;text-align:right;white-space:nowrap}
.select-row{display:flex;gap:8px;margin-bottom:12px;align-items:center}
.select-row label{font-size:.8rem;color:#64748b}
/* Progress */
#progress-section{display:none}
.progress-wrap{background:#0f1117;border-radius:999px;height:8px;overflow:hidden;margin:12px 0}
.progress-bar{height:100%;background:linear-gradient(90deg,#7c3aed,#a855f7);border-radius:999px;transition:width .5s}
.progress-stats{display:flex;justify-content:space-between;font-size:.8rem;color:#64748b}
.status-badge{display:inline-flex;align-items:center;gap:6px;font-size:.8rem;font-weight:600;padding:4px 10px;border-radius:999px}
.badge-downloading{background:#1e3a5f;color:#60a5fa}
.badge-done{background:#14532d;color:#4ade80}
.badge-idle{background:#1e293b;color:#64748b}
.badge-processing{background:#3b1f5e;color:#c084fc}
.dot{width:6px;height:6px;border-radius:50%;background:currentColor;animation:pulse 1.2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
.ctrl-row{display:flex;gap:8px;margin-top:12px}
/* My Files */
#my-files-section .empty-state{text-align:center;padding:40px;color:#4b5563}
.dl-table{width:100%;border-collapse:collapse}
.dl-table th{text-align:left;font-size:.75rem;color:#64748b;padding:8px 12px;text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid #2d3748}
.dl-table td{padding:10px 12px;border-bottom:1px solid #1e293b;font-size:.875rem;vertical-align:middle}
.dl-table tr:last-child td{border-bottom:none}
.dl-table tr:hover td{background:#1e293b}
.dl-filename{color:#cbd5e1;max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dl-size{color:#64748b;text-align:right}
.dl-actions{display:flex;gap:6px;justify-content:flex-end}
.refresh-btn{background:none;border:none;color:#64748b;cursor:pointer;font-size:1rem;padding:4px;transition:color .2s}
.refresh-btn:hover{color:#7c3aed}
.section-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}
.alert{padding:12px 16px;border-radius:8px;font-size:.875rem;margin-bottom:16px}
.alert-error{background:#450a0a;border:1px solid #dc2626;color:#fca5a5}
.alert-success{background:#052e16;border:1px solid #16a34a;color:#86efac}
</style>
</head>
<body>
<header>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#7c3aed" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<h1>Torrent2Space</h1>
<span>Seedr-style on HF Space</span>
</header>
<div class="container">
<!-- Add Torrent -->
<div class="card">
<h2>Add Torrent</h2>
<div id="alert-box"></div>
<div class="input-row">
<input id="magnet-input" type="text" placeholder="Paste magnet link (magnet:?xt=urn:btih:...)" />
<button class="btn btn-primary" onclick="loadMagnet()">Load</button>
</div>
<div class="or-divider">or</div>
<div class="file-upload-area" id="drop-zone" onclick="document.getElementById('torrent-file').click()"
ondragover="event.preventDefault();this.classList.add('drag')"
ondragleave="this.classList.remove('drag')"
ondrop="handleDrop(event)">
<input type="file" id="torrent-file" accept=".torrent" onchange="loadTorrentFile(this)"/>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-bottom:8px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<div>Drop .torrent file here or click to browse</div>
</div>
</div>
<!-- File selector -->
<div class="card" id="file-list-section">
<div class="torrent-info">
<div>
<div class="torrent-name" id="torrent-name"></div>
<div class="torrent-meta" id="torrent-meta"></div>
</div>
</div>
<div class="select-row">
<button class="btn btn-ghost btn-sm" onclick="selectAll(true)">Select All</button>
<button class="btn btn-ghost btn-sm" onclick="selectAll(false)">Deselect All</button>
<label id="selected-label"></label>
</div>
<table class="file-table">
<thead><tr><th style="width:30px"></th><th>Name</th><th style="text-align:right">Size</th></tr></thead>
<tbody id="file-tbody"></tbody>
</table>
<div style="margin-top:16px;display:flex;gap:8px">
<button class="btn btn-primary" id="start-btn" onclick="startDownload()">⬇ Start Download</button>
<button class="btn btn-ghost" onclick="resetUI()">Cancel</button>
</div>
</div>
<!-- Progress -->
<div class="card" id="progress-section">
<div class="section-header">
<h2>Download Progress</h2>
<span class="status-badge badge-idle" id="status-badge"><span class="dot"></span><span id="badge-text">Idle</span></span>
</div>
<div class="progress-wrap"><div class="progress-bar" id="progress-bar" style="width:0%"></div></div>
<div class="progress-stats">
<span id="stat-data"></span>
<span id="stat-pct">0%</span>
<span id="stat-speed"></span>
<span id="stat-eta">ETA: —</span>
<span id="stat-peers"></span>
</div>
<div class="ctrl-row">
<button class="btn btn-ghost btn-sm" onclick="apiCall('/api/pause')">⏸ Pause</button>
<button class="btn btn-ghost btn-sm" onclick="apiCall('/api/resume')">▶ Resume</button>
<button class="btn btn-danger btn-sm" onclick="stopAndReset()">✕ Stop</button>
</div>
</div>
<!-- My Files -->
<div class="card" id="my-files-section">
<div class="section-header">
<h2>My Files</h2>
<button class="refresh-btn" onclick="loadFiles()" title="Refresh"></button>
</div>
<div id="files-container"><div class="empty-state">No files yet. Start a download above.</div></div>
</div>
</div>
<script>
let pollTimer = null;
let torrentFiles = [];
// ─── Alert ──────────────────────────────────────────────────────────────────
function showAlert(msg, type='error'){
const box = document.getElementById('alert-box');
box.innerHTML = `<div class="alert alert-${type}">${msg}</div>`;
setTimeout(()=>box.innerHTML='', 5000);
}
// ─── Load Magnet ─────────────────────────────────────────────────────────────
async function loadMagnet(){
const magnet = document.getElementById('magnet-input').value.trim();
if(!magnet) return showAlert('Paste a magnet link first');
showAlert('Loading metadata… this may take a minute.','success');
const res = await fetch('/api/load_magnet',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({magnet})});
const data = await res.json();
if(data.error) return showAlert(data.error);
renderFileSelector(data);
}
// ─── Load .torrent ────────────────────────────────────────────────────────────
async function loadTorrentFile(input){
const file = input.files[0];
if(!file) return;
const reader = new FileReader();
reader.onload = async e => {
const b64 = btoa(String.fromCharCode(...new Uint8Array(e.target.result)));
const res = await fetch('/api/load_torrent',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({data:b64})});
const data = await res.json();
if(data.error) return showAlert(data.error);
renderFileSelector(data);
};
reader.readAsArrayBuffer(file);
}
function handleDrop(e){
e.preventDefault();
document.getElementById('drop-zone').classList.remove('drag');
const file = e.dataTransfer.files[0];
if(file && file.name.endsWith('.torrent')){
const fakeInput = {files:[file]};
loadTorrentFile(fakeInput);
}
}
// ─── File Selector ────────────────────────────────────────────────────────────
function renderFileSelector(data){
torrentFiles = data.files;
document.getElementById('torrent-name').textContent = data.name;
document.getElementById('torrent-meta').textContent = `${data.num_files} file(s) · ${data.total_size}`;
const tbody = document.getElementById('file-tbody');
tbody.innerHTML = '';
data.files.forEach(f=>{
const tr = document.createElement('tr');
tr.innerHTML = `
<td><input type="checkbox" class="file-check" checked data-idx="${f.index}" onchange="updateSelectedLabel()"/></td>
<td class="file-name">${f.name}</td>
<td class="file-size">${f.size}</td>
`;
tbody.appendChild(tr);
});
updateSelectedLabel();
document.getElementById('file-list-section').style.display='block';
document.getElementById('alert-box').innerHTML='';
}
function selectAll(v){
document.querySelectorAll('.file-check').forEach(c=>c.checked=v);
updateSelectedLabel();
}
function updateSelectedLabel(){
const checks = document.querySelectorAll('.file-check');
const sel = [...checks].filter(c=>c.checked).length;
document.getElementById('selected-label').textContent = `${sel} of ${checks.length} selected`;
}
// ─── Start Download ───────────────────────────────────────────────────────────
async function startDownload(){
const selected = [...document.querySelectorAll('.file-check:checked')].map(c=>parseInt(c.dataset.idx));
if(!selected.length) return showAlert('Select at least one file');
const res = await fetch('/api/start',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({selected})});
const data = await res.json();
if(data.error) return showAlert(data.error);
document.getElementById('file-list-section').style.display='none';
document.getElementById('progress-section').style.display='block';
startPolling();
}
// ─── Status Polling ───────────────────────────────────────────────────────────
function startPolling(){
if(pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(pollStatus, 2000);
pollStatus();
}
async function pollStatus(){
try{
const res = await fetch('/api/status');
const d = await res.json();
updateProgress(d);
if(d.state==='done'){
clearInterval(pollTimer);
pollTimer=null;
loadFiles();
}
}catch(e){console.error(e)}
}
function updateProgress(d){
const pct = d.ovr_pct || d.cur_pct || 0;
document.getElementById('progress-bar').style.width = pct+'%';
document.getElementById('stat-pct').textContent = pct.toFixed(1)+'%';
document.getElementById('stat-data').textContent = d.data || '—';
document.getElementById('stat-speed').textContent = d.speed || '—';
document.getElementById('stat-eta').textContent = 'ETA: '+(d.eta||'—');
document.getElementById('stat-peers').textContent = d.peers || '—';
const badge = document.getElementById('status-badge');
const badgeText = document.getElementById('badge-text');
badge.className = 'status-badge ';
if(d.state==='downloading'){badge.className+='badge-downloading';badgeText.textContent=d.torrent_state||'Downloading';}
else if(d.state==='done'){badge.className+='badge-done';badgeText.textContent='Complete ✓';}
else if(d.state==='processing'){badge.className+='badge-processing';badgeText.textContent='Processing';}
else{badge.className+='badge-idle';badgeText.textContent='Idle';}
if(d.transfer_status) document.getElementById('stat-data').textContent = d.transfer_status;
}
// ─── My Files ─────────────────────────────────────────────────────────────────
async function loadFiles(){
const res = await fetch('/api/files');
const data = await res.json();
const container = document.getElementById('files-container');
if(!data.files || data.files.length===0){
container.innerHTML='<div class="empty-state">No files yet. Start a download above.</div>';
return;
}
let html = `<div style="font-size:.8rem;color:#64748b;margin-bottom:12px">${data.count} file(s) · ${data.total_size}</div>`;
html += '<table class="dl-table"><thead><tr><th>Name</th><th style="text-align:right">Size</th><th></th></tr></thead><tbody>';
data.files.forEach(f=>{
const encoded = encodeURIComponent(f.path).replace(/%2F/g,'/');
html += `<tr>
<td><div class="dl-filename" title="${f.path}">${f.name}</div></td>
<td class="dl-size">${f.size}</td>
<td class="dl-actions">
<a href="/api/download/${encoded}" download="${f.name}" class="btn btn-primary btn-sm">⬇ Download</a>
<button class="btn btn-ghost btn-sm" onclick="deleteFile('${encoded}','${f.name}')">🗑</button>
</td>
</tr>`;
});
html += '</tbody></table>';
if(data.count>0){
html+=`<div style="margin-top:12px;text-align:right"><button class="btn btn-danger btn-sm" onclick="deleteAll()">Delete All Files</button></div>`;
}
container.innerHTML=html;
}
async function deleteFile(path, name){
if(!confirm(`Delete "${name}"?`)) return;
await fetch('/api/files/'+path,{method:'DELETE'});
loadFiles();
}
async function deleteAll(){
if(!confirm('Delete ALL downloaded files?')) return;
await fetch('/api/files',{method:'DELETE'});
loadFiles();
}
// ─── Control ──────────────────────────────────────────────────────────────────
async function apiCall(endpoint){
await fetch(endpoint,{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
}
async function stopAndReset(){
await apiCall('/api/stop');
clearInterval(pollTimer);
pollTimer=null;
resetUI();
}
function resetUI(){
document.getElementById('file-list-section').style.display='none';
document.getElementById('progress-section').style.display='none';
document.getElementById('magnet-input').value='';
document.getElementById('torrent-file').value='';
}
// ─── Init ─────────────────────────────────────────────────────────────────────
loadFiles();
// Resume polling if download was already running
(async()=>{
const res = await fetch('/api/status');
const d = await res.json();
if(d.state==='downloading'){
document.getElementById('progress-section').style.display='block';
startPolling();
} else if(d.state==='done'){
loadFiles();
}
})();
</script>
</body>
</html>