Spaces:
Sleeping
Sleeping
| <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> | |