diff --git a/pytorrent/static/js/torrentRowRenderer.js b/pytorrent/static/js/torrentRowRenderer.js
index 6eba085..c4cf432 100644
--- a/pytorrent/static/js/torrentRowRenderer.js
+++ b/pytorrent/static/js/torrentRowRenderer.js
@@ -1 +1 @@
-export const torrentRowRendererSource = " function statusMeta(t){\n const op=activeOperationFor(t);\n if(op) return {cls:'text-bg-info operation-status-badge', icon:actionIcon(op.action), color:'text-info', label:op.label};\n const status=String(t.status||'').toLowerCase();\n if(t.paused || status==='paused') return {cls:'text-bg-warning', icon:'fa-pause', color:'text-warning'};\n if(status==='checking' || Number(t.hashing||0)>0) return {cls:'text-bg-info', icon:'fa-rotate', color:'text-info'};\n if(status==='post-check' || t.post_check) return {cls:'text-bg-dark', icon:'fa-clipboard-check', color:'text-secondary', label:'Post-check'};\n if(status==='seeding') return {cls:'text-bg-success', icon:'fa-seedling', color:'text-success'};\n if(status==='downloading') return {cls:'text-bg-primary', icon:'fa-download', color:'text-primary'};\n if(status==='stopped') return {cls:'text-bg-secondary', icon:'fa-stop', color:'text-secondary'};\n return t.state ? {cls:'text-bg-success', icon:'fa-play', color:'text-success'} : {cls:'text-bg-secondary', icon:'fa-circle', color:'text-secondary'};\n }\n function statusBadge(t){ const m=statusMeta(t); return `${esc(m.label || t.status)}`; }\n function torrentWarning(t){ const msg=String(t.message||'').trim(); if(!msg) return null; const l=msg.toLowerCase(); const patterns=['error','failed','failure','timeout','timed out','tracker','could not','cannot','refused','unreachable','denied']; return patterns.some(p=>l.includes(p)) ? msg : null; }\n function torrentNameIcon(t){ const m=statusMeta(t); return ``; }\n function boolCell(value){ return Number(value||0) ? 'yes' : 'no'; }\n function renderRow(t){\n const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' ');\n const warn=torrentWarning(t);\n const op=activeOperationFor(t);\n const classes=[selected.has(t.hash)?'selected':'', t.paused?'torrent-paused':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' ');\n const title=[t.name,warn,op?op.label:''].filter(Boolean).join('\\n');\n return `
`+\n ` | `+\n `${warn?' ':''}${torrentNameIcon(t)} ${esc(t.name)} | `+\n `${statusBadge(t)} | `+\n `${esc(t.size_h)} | `+\n `${progress(t)} | `+\n `${esc(t.down_rate_h)} | `+\n `${esc(t.up_rate_h)} | `+\n `${esc(t.eta_h||\"-\")} | `+\n `${esc(t.seeds)} | `+\n `${esc(t.peers)} | `+\n `${esc(t.ratio)} | `+\n `${esc(t.path)} | `+\n `${labels||'-'} | `+\n `${esc(t.ratio_group||'')} | `+\n `${esc(t.down_total_h||'-')} | `+\n `${esc(t.to_download_h||'-')} | `+\n `${esc(t.up_total_h||'-')} | `+\n `${esc(formatDateTime(t.created))} | `+\n `${esc(formatDateTime(t.last_activity))} | `+\n `${esc(t.priority ?? '-')} | `+\n `${boolCell(t.state)} | `+\n `${boolCell(t.active)} | `+\n `${boolCell(t.complete)} | `+\n `${esc(t.hashing ?? 0)} | `+\n `${compactCell(t.message||'', 80)} | `+\n `${esc(t.hash||'')} | `+\n `
`;\n }\n\n\n\n\n";
+export const torrentRowRendererSource = " function statusMeta(t){\n const op=activeOperationFor(t);\n if(op) return {cls:'text-bg-info operation-status-badge', icon:actionIcon(op.action), color:'text-info', label:op.label};\n const status=String(t.status||'').toLowerCase();\n if(t.paused || status==='paused') return {cls:'text-bg-warning', icon:'fa-pause', color:'text-warning'};\n if(status==='checking' || Number(t.hashing||0)>0) return {cls:'text-bg-info', icon:'fa-rotate', color:'text-info'};\n if(status==='post-check' || t.post_check) return {cls:'text-bg-dark', icon:'fa-clipboard-check', color:'text-secondary', label:'Post-check'};\n if(status==='seeding') return {cls:'text-bg-success', icon:'fa-seedling', color:'text-success'};\n if(status==='downloading') return {cls:'text-bg-primary', icon:'fa-download', color:'text-primary'};\n if(status==='stopped') return {cls:'text-bg-secondary', icon:'fa-stop', color:'text-secondary'};\n return t.state ? {cls:'text-bg-success', icon:'fa-play', color:'text-success'} : {cls:'text-bg-secondary', icon:'fa-circle', color:'text-secondary'};\n }\n function statusBadge(t){ const m=statusMeta(t); return `${esc(m.label || t.status)}`; }\n function torrentErrorLog(t){\n // Note: The name-column status icon is useful only when the torrent has an error log to show in the tooltip.\n const status=String(t.status||'').trim().toLowerCase();\n const msg=String(t.message||'').trim();\n if(status==='error') return msg || 'Torrent reported an error.';\n if(!msg) return null;\n const patterns=['error','failed','failure','timeout','timed out','tracker','could not','cannot','refused','unreachable','denied'];\n return patterns.some(p=>msg.toLowerCase().includes(p)) ? msg : null;\n }\n function torrentNameStatusIcon(t){\n // Note: Non-error torrents keep the name cell clean; the Status column still shows their normal state.\n const errorLog=torrentErrorLog(t);\n return errorLog ? ` ` : '';\n }\n function boolCell(value){ return Number(value||0) ? 'yes' : 'no'; }\n function renderRow(t){\n const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' ');\n const errorLog=torrentErrorLog(t);\n const op=activeOperationFor(t);\n const classes=[selected.has(t.hash)?'selected':'', t.paused?'torrent-paused':'', op?'torrent-operating':'', errorLog?'torrent-warning':''].filter(Boolean).join(' ');\n const title=[t.name,errorLog,op?op.label:''].filter(Boolean).join('\\n');\n return ``+\n ` | `+\n `${torrentNameStatusIcon(t)}${esc(t.name)} | `+\n `${statusBadge(t)} | `+\n `${esc(t.size_h)} | `+\n `${progress(t)} | `+\n `${esc(t.down_rate_h)} | `+\n `${esc(t.up_rate_h)} | `+\n `${esc(t.eta_h||\"-\")} | `+\n `${esc(t.seeds)} | `+\n `${esc(t.peers)} | `+\n `${esc(t.ratio)} | `+\n `${esc(t.path)} | `+\n `${labels||'-'} | `+\n `${esc(t.ratio_group||'')} | `+\n `${esc(t.down_total_h||'-')} | `+\n `${esc(t.to_download_h||'-')} | `+\n `${esc(t.up_total_h||'-')} | `+\n `${esc(formatDateTime(t.created))} | `+\n `${esc(formatDateTime(t.last_activity))} | `+\n `${esc(t.priority ?? '-')} | `+\n `${boolCell(t.state)} | `+\n `${boolCell(t.active)} | `+\n `${boolCell(t.complete)} | `+\n `${esc(t.hashing ?? 0)} | `+\n `${compactCell(t.message||'', 80)} | `+\n `${esc(t.hash||'')} | `+\n `
`;\n }\n\n\n\n\n";
diff --git a/pytorrent/static/js/torrentTableRenderer.js b/pytorrent/static/js/torrentTableRenderer.js
index a98712a..6516730 100644
--- a/pytorrent/static/js/torrentTableRenderer.js
+++ b/pytorrent/static/js/torrentTableRenderer.js
@@ -1 +1 @@
-export const torrentTableRendererSource = " function renderMobile(){\n const list=$('mobileList');\n if(!list) return;\n const src=mobileVisibleRows();\n const rows=src.slice(0,250);\n renderMobileFilters(src);\n list.innerHTML=rows.map(t=>{\n const warn=torrentWarning(t);\n const op=activeOperationFor(t);\n const classes=[selected.has(t.hash)?'selected':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' ');\n const lines=mobileInfoLines(t);\n // Note: Mobile details use a separate corner button so user-configurable action buttons keep their current order.\n return `${lines.primary?`
${lines.primary}
`:''}${lines.secondary?`
${lines.secondary}
`:''}${mobileColumns.path?`
${esc(t.path)}
`:''}
${mobileColumns.progress?`
${progress(t)}
`:''}
`;\n }).join('') || (hasTorrentSnapshot ? `No torrents.
` : loadingMarkup('Loading torrents...'));\n }\n function renderTable(){ updateBulkBar(); syncActiveFilterSelection(); renderCounts(); renderLabelFilters(); if(typeof renderHealthDashboard==='function') renderHealthDashboard(); if(typeof renderSmartViewsManager==='function') renderSmartViewsManager(); updateSortHeaders(); buildVisibleRows(); renderMobile(); const body=$('torrentBody'); if(!visibleRows.length){ body.innerHTML=hasTorrentSnapshot?`| No torrents for this filter. |
`:loadingTableRow('Loading torrents...'); return; } const wrap=$('tableWrap'); const rowHeight=torrentRowHeight(); const start=Math.max(0,Math.floor((wrap?.scrollTop||0)/rowHeight)-OVERSCAN); const count=Math.ceil((wrap?.clientHeight||500)/rowHeight)+OVERSCAN*2; const end=Math.min(visibleRows.length,start+count); const sig=`${renderVersion}:${start}:${end}:${visibleRows.length}:${sortState.key}:${sortState.dir}:${selected.size}:${activeFilter}:${activeTrackerFilter}:${compactTorrentListEnabled?1:0}:${$('searchBox')?.value||''}:${[...selected].slice(0,30).join(',')}`; if(sig===lastRenderSignature) return; lastRenderSignature=sig; const top=start*rowHeight,bottom=Math.max(0,(visibleRows.length-end)*rowHeight); body.innerHTML=(top?` |
`:'')+visibleRows.slice(start,end).map(renderRow).join('')+(bottom?` |
`:''); applyColumnVisibility(); }\n function scheduleRender(force=false){ if(force){lastRenderSignature='';renderVersion++;} if(renderPending)return; renderPending=true; requestAnimationFrame(()=>{renderPending=false;renderTable();}); }\n function patchRows(msg){ if(msg.summary) torrentSummary=msg.summary; (msg.removed||[]).forEach(h=>{torrents.delete(h);selected.delete(h);activeOperations.delete(h);if(selectedHash===h)selectedHash=null;}); (msg.added||[]).forEach(t=>torrents.set(t.hash,t)); (msg.updated||[]).forEach(p=>torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p})); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function applyLiveTorrentStats(msg){ (msg.updated||[]).forEach(p=>{ if(torrents.has(p.hash)) torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p}); }); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function selectedHashes(){ return [...selected]; }\n function updateBulkBar(){\n const bar=$(\"bulkBar\");\n if(!bar) return;\n // Note: The desktop bulk toolbar is hidden in mobile mode; mobile has its own compact actions in the filter bar.\n const isMobileMode = document.body.classList.contains('mobile-mode');\n const show = selected.size > 1 && !isMobileMode;\n bar.classList.toggle(\"d-none\", !show);\n bar.setAttribute('aria-hidden', show ? 'false' : 'true');\n const c=$(\"bulkSelectedCount\");\n if(c) c.textContent=selected.size;\n }\n function setSelectionRange(hash, keepExisting=false){ const current=visibleRows.findIndex(t=>t.hash===hash); const last=visibleRows.findIndex(t=>t.hash===lastSelectedHash); if(current<0 || last<0){ selected.add(hash); lastSelectedHash=hash; return; } if(!keepExisting) selected.clear(); const a=Math.min(current,last), b=Math.max(current,last); visibleRows.slice(a,b+1).forEach(t=>selected.add(t.hash)); selectedHash=hash; }\n";
+export const torrentTableRendererSource = " function renderMobile(){\n const list=$('mobileList');\n if(!list) return;\n const src=mobileVisibleRows();\n const rows=src.slice(0,250);\n renderMobileFilters(src);\n list.innerHTML=rows.map(t=>{\n const errorLog=torrentErrorLog(t);\n const op=activeOperationFor(t);\n const classes=[selected.has(t.hash)?'selected':'', op?'torrent-operating':'', errorLog?'torrent-warning':''].filter(Boolean).join(' ');\n const lines=mobileInfoLines(t);\n // Note: Mobile details use a separate corner button so user-configurable action buttons keep their current order.\n return `${lines.primary?`
${lines.primary}
`:''}${lines.secondary?`
${lines.secondary}
`:''}${mobileColumns.path?`
${esc(t.path)}
`:''}
${mobileColumns.progress?`
${progress(t)}
`:''}
`;\n }).join('') || (hasTorrentSnapshot ? `No torrents.
` : loadingMarkup('Loading torrents...'));\n }\n function renderTable(){ updateBulkBar(); syncActiveFilterSelection(); renderCounts(); renderLabelFilters(); if(typeof renderHealthDashboard==='function') renderHealthDashboard(); if(typeof renderSmartViewsManager==='function') renderSmartViewsManager(); updateSortHeaders(); buildVisibleRows(); renderMobile(); const body=$('torrentBody'); if(!visibleRows.length){ body.innerHTML=hasTorrentSnapshot?`| No torrents for this filter. |
`:loadingTableRow('Loading torrents...'); return; } const wrap=$('tableWrap'); const rowHeight=torrentRowHeight(); const start=Math.max(0,Math.floor((wrap?.scrollTop||0)/rowHeight)-OVERSCAN); const count=Math.ceil((wrap?.clientHeight||500)/rowHeight)+OVERSCAN*2; const end=Math.min(visibleRows.length,start+count); const sig=`${renderVersion}:${start}:${end}:${visibleRows.length}:${sortState.key}:${sortState.dir}:${selected.size}:${activeFilter}:${activeTrackerFilter}:${compactTorrentListEnabled?1:0}:${$('searchBox')?.value||''}:${[...selected].slice(0,30).join(',')}`; if(sig===lastRenderSignature) return; lastRenderSignature=sig; const top=start*rowHeight,bottom=Math.max(0,(visibleRows.length-end)*rowHeight); body.innerHTML=(top?` |
`:'')+visibleRows.slice(start,end).map(renderRow).join('')+(bottom?` |
`:''); applyColumnVisibility(); }\n function scheduleRender(force=false){ if(force){lastRenderSignature='';renderVersion++;} if(renderPending)return; renderPending=true; requestAnimationFrame(()=>{renderPending=false;renderTable();}); }\n function patchRows(msg){ if(msg.summary) torrentSummary=msg.summary; (msg.removed||[]).forEach(h=>{torrents.delete(h);selected.delete(h);activeOperations.delete(h);if(selectedHash===h)selectedHash=null;}); (msg.added||[]).forEach(t=>torrents.set(t.hash,t)); (msg.updated||[]).forEach(p=>torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p})); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function applyLiveTorrentStats(msg){ (msg.updated||[]).forEach(p=>{ if(torrents.has(p.hash)) torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p}); }); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function selectedHashes(){ return [...selected]; }\n function updateBulkBar(){\n const bar=$(\"bulkBar\");\n if(!bar) return;\n // Note: The desktop bulk toolbar is hidden in mobile mode; mobile has its own compact actions in the filter bar.\n const isMobileMode = document.body.classList.contains('mobile-mode');\n const show = selected.size > 1 && !isMobileMode;\n bar.classList.toggle(\"d-none\", !show);\n bar.setAttribute('aria-hidden', show ? 'false' : 'true');\n const c=$(\"bulkSelectedCount\");\n if(c) c.textContent=selected.size;\n }\n function setSelectionRange(hash, keepExisting=false){ const current=visibleRows.findIndex(t=>t.hash===hash); const last=visibleRows.findIndex(t=>t.hash===lastSelectedHash); if(current<0 || last<0){ selected.add(hash); lastSelectedHash=hash; return; } if(!keepExisting) selected.clear(); const a=Math.min(current,last), b=Math.max(current,last); visibleRows.slice(a,b+1).forEach(t=>selected.add(t.hash)); selectedHash=hash; }\n";
diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css
index c8a6d18..8587950 100644
--- a/pytorrent/static/styles.css
+++ b/pytorrent/static/styles.css
@@ -1924,6 +1924,7 @@ body.mobile-mode .mobile-card {
}
.torrent-warning-icon {
color: var(--bs-warning);
+ cursor: help;
margin-right: 0.2rem;
}
.mobile-filter-bar {