Files
pyTorrent/pytorrent/static/js/modals.js
2026-05-19 13:43:37 +00:00

2 lines
20 KiB
JavaScript

export const modalsSource = " function copyText(text){\n text=String(text ?? '');\n if(navigator.clipboard && window.isSecureContext){\n return navigator.clipboard.writeText(text);\n }\n return new Promise((resolve,reject)=>{\n const ta=document.createElement('textarea');\n ta.value=text; ta.setAttribute('readonly','');\n ta.style.position='fixed'; ta.style.left='-9999px'; ta.style.top='0';\n document.body.appendChild(ta); ta.focus(); ta.select();\n try{ document.execCommand('copy') ? resolve() : reject(new Error('copy command failed')); }\n catch(e){ reject(e); }\n finally{ ta.remove(); }\n });\n }\n function copySelected(field){\n const t=torrents.get(selectedHash);\n if(!t) return toast('No torrent selected','warning');\n const value=String(t[field] ?? '');\n if(!value) return toast(`No ${field} to copy`,'warning');\n copyText(value).then(()=>toast(`Copied ${field}`,'success')).catch(()=>toast('Copy failed','danger'));\n }\n\n async function getDefaultDownloadPath(){ if(defaultDownloadPath) return defaultDownloadPath; try{ const j=await (await fetch('/api/path/default')).json(); if(j.ok && j.path) defaultDownloadPath=j.path; }catch(e){} return defaultDownloadPath || '/'; }\n async function applyDefaultDownloadPath(force=false){ const p=await getDefaultDownloadPath(); ['addPath','rssPath','autoEffectPath'].forEach(id=>{ const el=$(id); if(el && (force || !el.value)) el.value=p; }); return p; }\n async function openPathPicker(target){\n pathTarget=target;\n const modal=$('pathModal');\n if(!modal) return toast('Path picker is unavailable','danger');\n const def=await getDefaultDownloadPath();\n const initial=def || ($(target)?.value||'/');\n // Note: The same modal is used for Move and simple path selection; only Move shows extra options.\n $('moveOptions')?.classList.toggle('d-none', target!=='move');\n if($('moveDataPhysical')) $('moveDataPhysical').checked=true;\n if($('moveRecheck')) $('moveRecheck').checked=true;\n // Note: The path picker can be opened from Add/Create modals, so it must sit above the parent modal.\n modal.classList.toggle('path-picker-stacked', document.querySelectorAll('.modal.show').length > 0);\n new bootstrap.Modal(modal).show();\n browsePath(initial);\n }\n function pathInfoHtml(j){\n // Note: Move modal shows remote-side capacity and entry counts before queuing a move.\n const meta=[];\n if(j.free_h) meta.push(`<span><i class=\"fa-solid fa-hard-drive\"></i> Free ${esc(j.free_h)}</span>`);\n if(j.used_percent!==undefined) meta.push(`<span>${esc(j.used_percent)}% used</span>`);\n if(j.dir_count!==undefined) meta.push(`<span>${esc(j.dir_count)} dirs</span>`);\n if(j.file_count!==undefined) meta.push(`<span>${esc(j.file_count)} files</span>`);\n return meta.length ? `<div class=\"path-info-strip\">${meta.join('')}</div>` : '';\n }\n async function browsePath(path){\n const list=$('pathList');\n const current=$('pathCurrent');\n if(!list || !current) return;\n list.innerHTML='<span class=\"spinner-border spinner-border-sm\"></span> Loading...';\n try{\n const res=await fetch(`/api/path/browse?path=${encodeURIComponent(path||'/')}`);\n const j=await res.json();\n if(!j.ok) throw new Error(j.error);\n current.value=j.path;\n lastPathParent=j.parent;\n const rows=j.dirs.map(d=>`<div class=\"path-row\" data-path=\"${esc(d.path)}\"><i class=\"fa-solid fa-folder\"></i><span>${esc(d.name)}</span></div>`).join('')||'<div class=\"p-3 text-muted\">No directories.</div>';\n list.innerHTML=pathInfoHtml(j)+rows;\n }catch(e){\n list.innerHTML=`<div class=\"text-danger p-2\">${esc(e.message)}</div>`;\n }\n }\n $('pathList')?.addEventListener('click',e=>{const r=e.target.closest('.path-row'); if(r) browsePath(r.dataset.path);});\n $('pathGoBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathUpBtn')?.addEventListener('click',()=>browsePath(lastPathParent));\n $('pathReloadBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathSelectBtn')?.addEventListener('click',async()=>{\n const p=($('pathCurrent')?.value||'').trim();\n if(!p) return toast('Path is empty','warning');\n if(pathTarget==='move'){\n const hashes=selectedHashes();\n if(!hashes.length) return toast('No torrents selected','warning');\n const j=await post('/api/torrents/move',{hashes,path:p,move_data:!!($('moveDataPhysical')?.checked),recheck:!!($('moveRecheck')?.checked)});\n markQueuedJobs(j,hashes,'move');\n const parts=Number(j.bulk_parts||1);\n toast(parts>1?`move queued in ${parts} bulk parts`:$('moveDataPhysical')?.checked?'physical move queued':'move queued','success');\n } else if($(pathTarget)) {\n $(pathTarget).value=p;\n }\n bootstrap.Modal.getInstance($('pathModal'))?.hide();\n });\n document.querySelectorAll('.browse-path').forEach(b=>b.addEventListener('click',()=>openPathPicker(b.dataset.target)));\n\n function columnPrefsPayload(){\n return JSON.stringify({hidden:cleanColumnPrefsHidden(hiddenColumns), shown:cleanColumnPrefsHidden(DEFAULT_HIDDEN_COLUMNS).filter(key => !hiddenColumns.has(key)), mobile:mobileColumns, mobileSmartFiltersEnabled, widths:columnWidths});\n }\n function parseTableColumnsPreference(value){\n if(!value) return {};\n if(typeof value === 'object') return value;\n try{ return JSON.parse(value); }catch(e){ return {}; }\n }\n function applyTableColumnsPreference(value){\n const prefs = parseTableColumnsPreference(value);\n const explicitlyShown = new Set(prefs.shown || []);\n hiddenColumns = new Set([...(prefs.hidden || []), ...[...DEFAULT_HIDDEN_COLUMNS].filter(key => !explicitlyShown.has(key))]);\n mobileColumns = normalizeMobileColumns(prefs.mobile || {});\n mobileSmartFiltersEnabled = prefs.mobileSmartFiltersEnabled ?? true;\n columnWidths = normalizeColumnWidths(prefs.widths || {});\n saveBrowserViewPrefs({mobileColumns, mobileSmartFiltersEnabled, columnWidths});\n }\n function renderColumnCards(defs, values, inputClass, dataAttr, icon){\n return defs.map(([key,label,hiddenByDefault])=>{\n const active = !!values[key];\n return `<label class=\"column-card form-check form-switch ${active?'active':''}\"><input class=\"form-check-input ${inputClass}\" type=\"checkbox\" ${dataAttr}=\"${esc(key)}\" ${active?'checked':''}><span class=\"form-check-label\"><i class=\"fa-solid ${icon}\"></i> ${esc(label)}</span></label>`;\n }).join('');\n }\n function renderColumnManager(){\n const box=$('columnManager');\n if(!box) return;\n const desktopValues = Object.fromEntries(COLUMN_DEFS.map(([key])=>[key, !hiddenColumns.has(key)]));\n const desktop = renderColumnCards(COLUMN_DEFS, desktopValues, 'column-toggle', 'data-col-key', 'fa-table-columns');\n const mobile = renderColumnCards(MOBILE_COLUMN_DEFS, mobileColumns, 'mobile-column-toggle', 'data-mobile-col-key', 'fa-mobile-screen');\n const smart = `<label class=\"column-card form-check form-switch ${mobileSmartFiltersEnabled?'active':''}\"><input id=\"mobileSmartFiltersToggle\" class=\"form-check-input\" type=\"checkbox\" ${mobileSmartFiltersEnabled?'checked':''}><span class=\"form-check-label\"><i class=\"fa-solid fa-wand-magic-sparkles\"></i> Smart filters</span></label>`;\n box.innerHTML=`<div class=\"column-manager-tabs nav nav-pills\" role=\"tablist\"><button class=\"nav-link active\" data-bs-toggle=\"pill\" data-bs-target=\"#desktopColumnPane\" type=\"button\" role=\"tab\"><i class=\"fa-solid fa-desktop\"></i> Desktop</button><button class=\"nav-link\" data-bs-toggle=\"pill\" data-bs-target=\"#mobileColumnPane\" type=\"button\" role=\"tab\"><i class=\"fa-solid fa-mobile-screen\"></i> Mobile</button></div><div class=\"column-manager-pane tab-content\"><div id=\"desktopColumnPane\" class=\"tab-pane fade show active\" role=\"tabpanel\"><div class=\"column-grid\">${desktop}</div></div><div id=\"mobileColumnPane\" class=\"tab-pane fade\" role=\"tabpanel\"><div class=\"column-grid\">${mobile}${smart}</div></div></div>`;\n }\n $('saveColumnsBtn')?.addEventListener('click',async()=>{ document.querySelectorAll('.column-toggle').forEach(cb=>cb.checked?hiddenColumns.delete(cb.dataset.colKey):hiddenColumns.add(cb.dataset.colKey)); document.querySelectorAll('.mobile-column-toggle').forEach(cb=>mobileColumns[cb.dataset.mobileColKey]=cb.checked); mobileSmartFiltersEnabled = $('mobileSmartFiltersToggle')?.checked ?? true; saveBrowserViewPrefs({mobileColumns, mobileSmartFiltersEnabled, columnWidths}); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:columnPrefsPayload()}).catch(e=>toast(e.message,'danger')); toast('Columns saved','success'); });\n $('resetColumnsBtn')?.addEventListener('click',async()=>{ hiddenColumns = new Set(DEFAULT_HIDDEN_COLUMNS); mobileColumns = normalizeMobileColumns(); mobileSmartFiltersEnabled = true; columnWidths = normalizeColumnWidths(); saveBrowserViewPrefs({mobileColumns, mobileSmartFiltersEnabled, columnWidths}); renderColumnManager(); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:columnPrefsPayload()}).catch(()=>{}); });\n $('recommendedColumnsBtn')?.addEventListener('click',async()=>{\n try{\n // Note: The recommended layout is applied by the backend and includes desktop, mobile and widths.\n const j = await post('/api/preferences/table-columns/recommended', {});\n applyTableColumnsPreference(j.preferences?.table_columns_json);\n renderColumnManager();\n applyColumnVisibility();\n scheduleRender(true);\n toast('Recommended columns applied','success');\n }catch(e){\n toast(e.message,'danger');\n }\n });\n\n function jobActions(r){ const id=esc(r.id); const status=String(r.status||''); const actions=[]; if(status==='failed'||status==='cancelled') actions.push(`<button class=\"btn btn-xs btn-outline-primary job-retry\" data-id=\"${id}\"><i class=\"fa-solid fa-rotate-left\"></i> retry</button>`); if(status==='pending') actions.push(`<button class=\"btn btn-xs btn-outline-warning job-force\" data-id=\"${id}\" title=\"Run now in a separate worker\"><i class=\"fa-solid fa-bolt\"></i> force</button>`); if(status==='pending'||status==='running') actions.push(`<button class=\"btn btn-xs btn-outline-danger job-cancel\" data-id=\"${id}\" data-status=\"${esc(status)}\"><i class=\"fa-solid fa-triangle-exclamation\"></i> emergency cancel</button>`); return actions.join(' ') || '<span class=\"text-muted\">-</span>'; }\n function jobStatusBadgeClass(status){\n // Note: Running means active work, so it uses primary instead of danger; danger stays reserved for failed.\n const classes={done:'success',failed:'danger',running:'primary',cancelled:'secondary',pending:'warning'};\n return classes[String(status||'')] || 'warning';\n }\n async function loadJobs(page=jobsPage){\n const box=$('jobsTable');\n // Note: Finished shows only a real finished_at value; running/pending do not receive a date from updated_at.\n if(!box) return;\n jobsPage=Math.max(0,page|0);\n box.innerHTML='<span class=\"spinner-border spinner-border-sm\"></span> Loading jobs...';\n const offset=jobsPage*jobsLimit;\n const j=await (await fetch(`/api/jobs?limit=${jobsLimit}&offset=${offset}`)).json();\n const rows=j.jobs||[];\n jobsTotal=Number(j.total||rows.length);\n const details=r=>{\n const count=Number(r.hash_count||0);\n if(r.is_bulk || count>1) return `<span class=\"badge text-bg-info\">bulk</span><br><span class=\"text-muted\">${esc(count)} torrent(s), details hidden</span>`;\n const bits=[];\n if(count) bits.push(`${esc(count)} torrent`);\n if(r.summary) bits.push(esc(r.summary));\n return bits.join('<br>') || '-';\n };\n box.innerHTML=responsiveTable(\n ['Status','Source','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],\n rows.map(r=>[\n `<span class=\"badge text-bg-${jobStatusBadgeClass(r.status)}\">${esc(r.status)}</span>`,\n r.source==='automation'?`<span class=\"badge text-bg-info\" title=\"${esc(r.source_label||'automation')}\"><i class=\"fa-solid fa-wand-magic-sparkles\"></i> automation</span>`:(r.is_forced?'<span class=\"badge text-bg-warning\"><i class=\"fa-solid fa-bolt\"></i> forced</span>':'<span class=\"badge text-bg-secondary\">user</span>'),\n esc(r.action),\n esc(r.profile_id),\n esc(r.hash_count||0),\n details(r),\n esc(r.attempts||0),\n humanDateCell(r.started_at||r.created_at),\n humanDateCell(r.finished_at),\n compactCell(r.error||'',140),\n jobActions(r),\n ]),\n 'jobs-table'\n );\n renderJobsPager();\n }\n function renderJobsPager(){ const p=$('jobsPager'); if(!p)return; const pages=Math.max(1,Math.ceil(jobsTotal/jobsLimit)); p.innerHTML=`<div class=\"d-flex align-items-center gap-2 flex-wrap\"><button class=\"btn btn-sm btn-outline-secondary\" id=\"jobsPrev\" ${jobsPage<=0?'disabled':''}><i class=\"fa-solid fa-chevron-left\"></i> Prev</button><span class=\"small text-muted\">Page ${jobsPage+1} / ${pages} · ${jobsTotal} jobs</span><button class=\"btn btn-sm btn-outline-secondary\" id=\"jobsNext\" ${jobsPage>=pages-1?'disabled':''}>Next <i class=\"fa-solid fa-chevron-right\"></i></button></div>`; $('jobsPrev')?.addEventListener('click',()=>loadJobs(jobsPage-1)); $('jobsNext')?.addEventListener('click',()=>loadJobs(jobsPage+1)); }\n // Note: Job log buttons are separated so normal cleanup cannot accidentally trigger emergency cleanup.\n $('jobsModal')?.addEventListener('show.bs.modal',loadJobs); $('refreshJobsBtn')?.addEventListener('click',loadJobs); $('jobsTable')?.addEventListener('click',async e=>{ const btn=e.target.closest('.job-retry,.job-cancel,.job-force'); if(!btn)return; const id=btn.dataset.id; if(!id)return; if(btn.classList.contains('job-retry')) await post(`/api/jobs/${id}/retry`,{}).catch(x=>toast(x.message,'danger')); if(btn.classList.contains('job-force')){ if(!confirm('Force this pending job now in a separate worker? This can break normal queue ordering.')) return; await post(`/api/jobs/${id}/force`,{}).catch(x=>toast(x.message,'danger')); } if(btn.classList.contains('job-cancel')){ const st=btn.dataset.status||''; if((st==='pending'||st==='running') && !confirm('Emergency cancel this unfinished job?')) return; await post(`/api/jobs/${id}/cancel`,{}).catch(x=>toast(x.message,'danger')); } loadJobs(); });\n $('clearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Clear finished job logs? Pending and running jobs will stay.')) return; try{ const j=await post('/api/jobs/clear',{}); toast(`Cleared ${j.deleted||0} finished job log(s)`,'success'); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } });\n $('emergencyClearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Emergency clean ALL job logs, including unfinished jobs?')) return; try{ const j=await post('/api/jobs/clear?force=1',{}); toast(`Emergency cleared ${j.deleted||0} job log(s)`,'success'); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } });\n\n async function loadLabels(){ const j=await (await fetch('/api/labels')).json(); const labels=j.labels||[]; knownLabels=labels; renderLabelFilters(); renderLabelChooser(); if($('labelsManager')) $('labelsManager').innerHTML=labels.length?labels.map(l=>`<div class=\"label-manager-row\"><span class=\"chip\"><i class=\"fa-solid fa-tag\"></i> ${esc(l.name)}</span><button class=\"btn btn-xs btn-outline-danger delete-label\" data-id=\"${esc(l.id)}\" title=\"Delete label\"><i class=\"fa-solid fa-trash-can\"></i> Remove</button></div>`).join(''):'<div class=\"empty-state\"><i class=\"fa-solid fa-tags\"></i><b>No labels.</b><span>Add first label above.</span></div>'; }\n function renderLabelChooser(){ if($('selectedLabelList')) $('selectedLabelList').innerHTML=[...modalLabels].map(l=>`<button class=\"chip label-selected\" data-label=\"${esc(l)}\" title=\"Remove\"><i class=\"fa-solid fa-tag\"></i> ${esc(l)} <i class=\"fa-solid fa-xmark ms-1\"></i></button>`).join('') || '<span class=\"text-muted small\">No labels selected.</span>'; if($('labelList')) $('labelList').innerHTML=knownLabels.map(l=>`<button class=\"chip label-chip ${modalLabels.has(l.name)?'active':''}\" data-label=\"${esc(l.name)}\"><i class=\"fa-solid fa-tag\"></i> ${esc(l.name)}</button>`).join('') || '<span class=\"text-muted small\">No saved labels.</span>'; }\n async function saveKnownLabel(name){ name=String(name||'').trim(); if(!name) return; await post('/api/labels',{name}); await loadLabels(); }\n async function loadRatios(){ const j=await (await fetch('/api/ratio-groups')).json(); const groups=j.groups||[], history=j.history||[]; if($('ratioAssignSelect')) $('ratioAssignSelect').innerHTML=groups.map(g=>`<option value=\"${esc(g.name)}\">${esc(g.name)} (${esc(g.min_ratio)}-${esc(g.max_ratio)})</option>`).join(''); if($('ratioManager')) $('ratioManager').innerHTML=`<h6>Groups</h6>${table(['Name','Min','Max','Seed min','Action','Move path','Set label','Enabled'],groups.map(g=>[esc(g.name),esc(g.min_ratio),esc(g.max_ratio),esc(g.seed_time_minutes||g.min_seed_time_minutes||0),esc(g.action),esc(g.move_path||''),esc(g.set_label||''),g.enabled?'yes':'no']))}<h6 class=\"mt-3\">Applied history</h6>${table(['Time','Torrent','Group','Action','Status','Reason'],history.map(h=>[humanDateCell(h.created_at),esc(h.torrent_name||h.torrent_hash),esc(h.group_name||''),esc(h.action),esc(h.status),esc(h.reason||'')]))}`; }\n $('labelModal')?.addEventListener('show.bs.modal',async()=>{ modalLabels=new Set(selectedHashes().flatMap(h=>labelNames(torrents.get(h)?.label))); if($('labelInput')) $('labelInput').value=''; await loadLabels(); renderLabelChooser(); });\n $('saveLabelBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } await runAction('set_label',{label:labelValue([...modalLabels])}); bootstrap.Modal.getInstance($('labelModal'))?.hide(); });\n $('addLabelToSelectionBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } if($('labelInput')) $('labelInput').value=''; renderLabelChooser(); });\n $('clearLabelsBtn')?.addEventListener('click',()=>{ modalLabels.clear(); renderLabelChooser(); });\n $('labelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-chip'); if(!chip) return; const v=chip.dataset.label||''; modalLabels.has(v)?modalLabels.delete(v):modalLabels.add(v); renderLabelChooser(); });\n $('selectedLabelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-selected'); if(!chip) return; modalLabels.delete(chip.dataset.label||''); renderLabelChooser(); });\n $('newLabelBtn')?.addEventListener('click',async()=>{ await saveKnownLabel($('newLabelName')?.value||''); if($('newLabelName')) $('newLabelName').value=''; });\n $('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios); $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); }); $('ratioSaveBtn')?.addEventListener('click',async()=>{ await post('/api/ratio-groups',{name:$('ratioName').value,min_ratio:$('ratioMin').value,max_ratio:$('ratioMax').value,seed_time_minutes:$('ratioSeed').value,action:$('ratioAction').value,move_path:$('ratioMovePath')?.value||'',set_label:$('ratioSetLabel')?.value||'',ignore_private:$('ratioIgnorePrivate')?.checked!==false,ignore_active_upload:$('ratioIgnoreUpload')?.checked!==false}); loadRatios(); }); $('ratioCheckBtn')?.addEventListener('click',async()=>{ const j=await post('/api/ratio-groups/check',{}); toast(`Ratio applied ${j.result?.applied||0} torrent(s)`,'success'); loadRatios(); });\n";