1 line
13 KiB
JavaScript
1 line
13 KiB
JavaScript
class TorrentDashboard{constructor(){this.state={torrents:[],selectedTorrents:new Set,categories:new Set,filteredTorrents:[],selectedCategory:"",selectedState:"",sortBy:"added_on",itemsPerPage:20,currentPage:1,selectedTorrentContextMenu:null},this.refs={torrentsList:document.getElementById("torrentsList"),categoryFilter:document.getElementById("categoryFilter"),stateFilter:document.getElementById("stateFilter"),sortSelector:document.getElementById("sortSelector"),selectAll:document.getElementById("selectAll"),batchDeleteBtn:document.getElementById("batchDeleteBtn"),batchDeleteDebridBtn:document.getElementById("batchDeleteDebridBtn"),refreshBtn:document.getElementById("refreshBtn"),torrentContextMenu:document.getElementById("torrentContextMenu"),paginationControls:document.getElementById("paginationControls"),paginationInfo:document.getElementById("paginationInfo"),emptyState:document.getElementById("emptyState")},this.init()}init(){this.bindEvents(),this.loadTorrents(),this.startAutoRefresh()}bindEvents(){this.refs.refreshBtn.addEventListener("click",()=>this.loadTorrents()),this.refs.batchDeleteBtn.addEventListener("click",()=>this.deleteSelectedTorrents()),this.refs.batchDeleteDebridBtn.addEventListener("click",()=>this.deleteSelectedTorrents(!0)),this.refs.selectAll.addEventListener("change",t=>this.toggleSelectAll(t.target.checked)),this.refs.categoryFilter.addEventListener("change",t=>this.setFilter("category",t.target.value)),this.refs.stateFilter.addEventListener("change",t=>this.setFilter("state",t.target.value)),this.refs.sortSelector.addEventListener("change",t=>this.setSort(t.target.value)),this.bindContextMenu(),this.refs.torrentsList.addEventListener("change",t=>{t.target.classList.contains("torrent-select")&&this.toggleTorrentSelection(t.target.dataset.hash,t.target.checked)})}bindContextMenu(){this.refs.torrentsList.addEventListener("contextmenu",t=>{const e=t.target.closest("tr[data-hash]");e&&(t.preventDefault(),this.showContextMenu(t,e))}),document.addEventListener("click",t=>{this.refs.torrentContextMenu.contains(t.target)||this.hideContextMenu()}),this.refs.torrentContextMenu.addEventListener("click",t=>{const e=t.target.closest("[data-action]")?.dataset.action;e&&(this.handleContextAction(e),this.hideContextMenu())})}showContextMenu(t,e){this.state.selectedTorrentContextMenu={hash:e.dataset.hash,name:e.dataset.name,category:e.dataset.category||""},this.refs.torrentContextMenu.querySelector(".torrent-name").textContent=this.state.selectedTorrentContextMenu.name;const{pageX:s,pageY:r}=t,{clientWidth:n,clientHeight:a}=document.documentElement,o=this.refs.torrentContextMenu;o.style.left=`${Math.min(s,n-200)}px`,o.style.top=`${Math.min(r,a-150)}px`,o.classList.remove("hidden")}hideContextMenu(){this.refs.torrentContextMenu.classList.add("hidden"),this.state.selectedTorrentContextMenu=null}async handleContextAction(t){const e=this.state.selectedTorrentContextMenu;if(!e)return;const s={"copy-magnet":async()=>{try{await navigator.clipboard.writeText(`magnet:?xt=urn:btih:${e.hash}`),window.decypharrUtils.createToast("Magnet link copied to clipboard")}catch(t){window.decypharrUtils.createToast("Failed to copy magnet link","error")}},"copy-name":async()=>{try{await navigator.clipboard.writeText(e.name),window.decypharrUtils.createToast("Torrent name copied to clipboard")}catch(t){window.decypharrUtils.createToast("Failed to copy torrent name","error")}},delete:async()=>{await this.deleteTorrent(e.hash,e.category,!1)}};s[t]&&await s[t]()}async loadTorrents(){try{this.refs.refreshBtn.disabled=!0,this.refs.paginationInfo.textContent="Loading torrents...";const t=await window.decypharrUtils.fetcher("/api/torrents");if(!t.ok)throw new Error("Failed to fetch torrents");const e=await t.json();this.state.torrents=e,this.state.categories=new Set(e.map(t=>t.category).filter(Boolean)),this.updateUI()}catch(t){console.error("Error loading torrents:",t),window.decypharrUtils.createToast(`Error loading torrents: ${t.message}`,"error")}finally{this.refs.refreshBtn.disabled=!1}}updateUI(){this.filterTorrents(),this.updateCategoryFilter(),this.renderTorrents(),this.updatePagination(),this.updateSelectionUI(),this.toggleEmptyState()}filterTorrents(){let t=[...this.state.torrents];this.state.selectedCategory&&(t=t.filter(t=>t.category===this.state.selectedCategory)),this.state.selectedState&&(t=t.filter(t=>t.state?.toLowerCase()===this.state.selectedState.toLowerCase())),t=this.sortTorrents(t),this.state.filteredTorrents=t}sortTorrents(t){const[e,s]=this.state.sortBy.includes("_asc")||this.state.sortBy.includes("_desc")?[this.state.sortBy.split("_").slice(0,-1).join("_"),this.state.sortBy.endsWith("_asc")?"asc":"desc"]:[this.state.sortBy,"desc"];return t.sort((t,r)=>{let n,a;switch(e){case"name":n=t.name?.toLowerCase()||"",a=r.name?.toLowerCase()||"";break;case"size":n=t.size||0,a=r.size||0;break;case"progress":n=t.progress||0,a=r.progress||0;break;case"added_on":n=t.added_on||0,a=r.added_on||0;break;default:n=t[e]||0,a=r[e]||0}return"string"==typeof n?"asc"===s?n.localeCompare(a):a.localeCompare(n):"asc"===s?n-a:a-n})}renderTorrents(){const t=(this.state.currentPage-1)*this.state.itemsPerPage,e=Math.min(t+this.state.itemsPerPage,this.state.filteredTorrents.length),s=this.state.filteredTorrents.slice(t,e);this.refs.torrentsList.innerHTML=s.map(t=>this.torrentRowTemplate(t)).join("")}torrentRowTemplate(t){const e=(100*t.progress).toFixed(1),s=this.state.selectedTorrents.has(t.hash);new Date(t.added_on).toLocaleString();return`\n <tr data-hash="${t.hash}" \n data-name="${this.escapeHtml(t.name)}" \n data-category="${t.category||""}"\n class="hover:bg-base-200 transition-colors">\n <td>\n <label class="cursor-pointer">\n <input type="checkbox" \n class="checkbox checkbox-sm torrent-select" \n data-hash="${t.hash}" \n ${s?"checked":""}>\n </label>\n </td>\n <td class="max-w-xs">\n <div class="truncate font-medium" title="${this.escapeHtml(t.name)}">\n ${this.escapeHtml(t.name)}\n </div>\n </td>\n <td class="text-nowrap font-mono text-sm">\n ${window.decypharrUtils.formatBytes(t.size)}\n </td>\n <td class="min-w-36">\n <div class="flex items-center gap-3">\n <progress class="progress progress-primary w-20 h-2" \n value="${e}" \n max="100"></progress>\n <span class="text-sm font-medium min-w-12">${e}%</span>\n </div>\n </td>\n <td class="text-nowrap font-mono text-sm">\n ${window.decypharrUtils.formatSpeed(t.dlspeed)}\n </td>\n <td>\n ${t.category?`<div class="badge badge-secondary badge-sm">${this.escapeHtml(t.category)}</div>`:'<span class="text-base-content/50">None</span>'}\n </td>\n <td>\n ${t.debrid?`<div class="badge badge-accent badge-sm">${this.escapeHtml(t.debrid)}</div>`:'<span class="text-base-content/50">None</span>'}\n </td>\n <td class="text-nowrap font-mono text-sm">\n ${t.num_seeds||0}\n </td>\n <td>\n <div class="badge ${this.getStateColor(t.state)} badge-sm">\n ${this.escapeHtml(t.state)}\n </div>\n </td>\n <td>\n <div class="flex gap-1">\n <button class="btn btn-error btn-outline btn-xs tooltip" \n onclick="dashboard.deleteTorrent('${t.hash}', '${t.category||""}', false);"\n data-tip="Delete from local">\n <i class="bi bi-trash"></i>\n </button>\n ${t.debrid&&t.id?`\n <button class="btn btn-error btn-outline btn-xs tooltip" \n onclick="dashboard.deleteTorrent('${t.hash}', '${t.category||""}', true);"\n data-tip="Remove from ${t.debrid}">\n <i class="bi bi-cloud-slash"></i>\n </button>\n `:""}\n </div>\n </td>\n </tr>\n `}getStateColor(t){return{downloading:"badge-primary",pausedup:"badge-success",error:"badge-error",completed:"badge-success"}[t?.toLowerCase()]||"badge-ghost"}updateCategoryFilter(){const t=Array.from(this.state.categories).sort(),e=['<option value="">All Categories</option>'].concat(t.map(t=>`<option value="${this.escapeHtml(t)}" ${t===this.state.selectedCategory?"selected":""}>\n ${this.escapeHtml(t)}\n </option>`));this.refs.categoryFilter.innerHTML=e.join("")}updatePagination(){const t=Math.ceil(this.state.filteredTorrents.length/this.state.itemsPerPage),e=(this.state.currentPage-1)*this.state.itemsPerPage,s=Math.min(e+this.state.itemsPerPage,this.state.filteredTorrents.length);if(this.refs.paginationInfo.textContent=`Showing ${this.state.filteredTorrents.length>0?e+1:0}-${s} of ${this.state.filteredTorrents.length} torrents`,this.refs.paginationControls.innerHTML="",t<=1)return;const r=this.createPaginationButton("❮",this.state.currentPage-1,1===this.state.currentPage);this.refs.paginationControls.appendChild(r);let n=Math.max(1,this.state.currentPage-Math.floor(2.5)),a=Math.min(t,n+5-1);a-n+1<5&&(n=Math.max(1,a-5+1));for(let t=n;t<=a;t++){const e=this.createPaginationButton(t,t,!1,t===this.state.currentPage);this.refs.paginationControls.appendChild(e)}const o=this.createPaginationButton("❯",this.state.currentPage+1,this.state.currentPage===t);this.refs.paginationControls.appendChild(o)}createPaginationButton(t,e,s=!1,r=!1){const n=document.createElement("button");return n.className=`join-item btn btn-sm ${r?"btn-active":""} ${s?"btn-disabled":""}`,n.textContent=t,n.disabled=s,s||n.addEventListener("click",()=>{this.state.currentPage=e,this.updateUI()}),n}updateSelectionUI(){const t=new Set(this.state.filteredTorrents.map(t=>t.hash));this.state.selectedTorrents.forEach(e=>{t.has(e)||this.state.selectedTorrents.delete(e)}),this.refs.batchDeleteBtn.classList.toggle("hidden",0===this.state.selectedTorrents.size),this.refs.batchDeleteDebridBtn.classList.toggle("hidden",0===this.state.selectedTorrents.size);const e=this.state.filteredTorrents.slice((this.state.currentPage-1)*this.state.itemsPerPage,this.state.currentPage*this.state.itemsPerPage);this.refs.selectAll.checked=e.length>0&&e.every(t=>this.state.selectedTorrents.has(t.hash)),this.refs.selectAll.indeterminate=e.some(t=>this.state.selectedTorrents.has(t.hash))&&!e.every(t=>this.state.selectedTorrents.has(t.hash))}toggleEmptyState(){const t=0===this.state.torrents.length;this.refs.emptyState.classList.toggle("hidden",!t),document.querySelector(".card:has(#torrentsList)").classList.toggle("hidden",t)}setFilter(t,e){"category"===t?this.state.selectedCategory=e:"state"===t&&(this.state.selectedState=e),this.state.currentPage=1,this.updateUI()}setSort(t){this.state.sortBy=t,this.state.currentPage=1,this.updateUI()}toggleSelectAll(t){this.state.filteredTorrents.slice((this.state.currentPage-1)*this.state.itemsPerPage,this.state.currentPage*this.state.itemsPerPage).forEach(e=>{t?this.state.selectedTorrents.add(e.hash):this.state.selectedTorrents.delete(e.hash)}),this.updateUI()}toggleTorrentSelection(t,e){e?this.state.selectedTorrents.add(t):this.state.selectedTorrents.delete(t),this.updateSelectionUI()}async deleteTorrent(t,e,s=!1){if(confirm(`Are you sure you want to delete this torrent${s?" from "+e:""}?`))try{const r=`/api/torrents/${encodeURIComponent(e)}/${t}?removeFromDebrid=${s}`,n=await window.decypharrUtils.fetcher(r,{method:"DELETE"});if(!n.ok)throw new Error(await n.text());window.decypharrUtils.createToast("Torrent deleted successfully"),await this.loadTorrents()}catch(t){console.error("Error deleting torrent:",t),window.decypharrUtils.createToast(`Failed to delete torrent: ${t.message}`,"error")}}async deleteSelectedTorrents(t=!1){const e=this.state.selectedTorrents.size;if(0!==e){if(confirm(`Are you sure you want to delete ${e} torrent${e>1?"s":""}${t?" from debrid":""}?`))try{const s=Array.from(this.state.selectedTorrents).join(","),r=await window.decypharrUtils.fetcher(`/api/torrents/?hashes=${encodeURIComponent(s)}&removeFromDebrid=${t}`,{method:"DELETE"});if(!r.ok)throw new Error(await r.text());window.decypharrUtils.createToast(`${e} torrent${e>1?"s":""} deleted successfully`),this.state.selectedTorrents.clear(),await this.loadTorrents()}catch(t){console.error("Error deleting torrents:",t),window.decypharrUtils.createToast(`Failed to delete some torrents: ${t.message}`,"error")}}else window.decypharrUtils.createToast("No torrents selected for deletion","warning")}startAutoRefresh(){this.refreshInterval=setInterval(()=>{this.loadTorrents()},5e3),window.addEventListener("beforeunload",()=>{this.refreshInterval&&clearInterval(this.refreshInterval)})}escapeHtml(t){const e={"&":"&","<":"<",">":">",'"':""","'":"'"};return t?t.replace(/[&<>"']/g,t=>e[t]):""}} |