This commit is contained in:
Mateusz Gruszczyński
2026-06-20 22:36:38 +02:00
parent b7d268dd77
commit 6ad0102280
4 changed files with 60 additions and 96 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
export const torrentDetailsLoaderSource = " async function loadDetails(tab, options={}){\n const hash=selectedHash;\n const t=torrents.get(hash);\n const silent = !!options.silent;\n if(tab !== 'files') clearFilesAutoRefresh();\n if(tab !== 'peers') clearReverseDnsPeerRefresh();\n if($('peersRefreshBox')) $('peersRefreshBox').classList.toggle('d-none', tab!=='peers');\n setupPeersRefresh(tab);\n if(!t) return;\n if(tab==='general') return renderGeneral();\n if(tab==='log'){\n $('detailPane').innerHTML=`<pre class=\"torrent-log-message\">${esc(t.message||'No logs')}</pre>`;\n return;\n }\n const pane=$('detailPane');\n if(!silent) pane.innerHTML=`<div class=\"loading-line\"><span class=\"spinner-border spinner-border-sm\"></span> Loading ${esc(tab)}...</div>`;\n try{\n // Note: Background peer refresh keeps the current table visible and only swaps in fresh rows after a successful response.\n const detailUrl = tab==='chunks' ? `/api/torrents/${encodeURIComponent(hash)}/chunks?max_cells=${chunkMaxCellsForDensity()}` : `/api/torrents/${encodeURIComponent(hash)}/${tab}`;\n const res=await fetch(detailUrl,{headers:{'Accept':'application/json'}});\n const text=await res.text();\n let json;\n try{\n json=JSON.parse(text);\n }catch(parseErr){\n throw new Error(`Invalid API response for ${tab}. HTTP ${res.status}`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`HTTP ${res.status}`);\n if(tab!==activeTab() || selectedHash!==hash) return;\n if(tab==='files') renderFiles(json.files||[]);\n if(tab==='chunks') renderChunks(json.chunks||{});\n if(tab==='peers') renderPeers(json.peers||[]);\n if(tab==='trackers') renderTrackers(json.trackers||[]);\n }catch(e){\n if(!silent) pane.innerHTML=`<div class=\"text-danger\">${esc(e.message)}</div>`;\n }\n }\n";
export const torrentDetailsLoaderSource = " async function loadDetails(tab, options={}){\n const hash=selectedHash;\n const t=torrents.get(hash);\n const silent = !!options.silent;\n if(tab !== 'files') clearFilesAutoRefresh();\n if(tab !== 'peers') clearReverseDnsPeerRefresh();\n if($('peersRefreshBox')) $('peersRefreshBox').classList.toggle('d-none', tab!=='peers');\n setupPeersRefresh(tab);\n if(!t) return;\n if(tab==='general') return renderGeneral();\n if(tab==='log'){\n // Note: The Log tab uses the same torrent message field as General and renders it as normal readable text.\n $('detailPane').innerHTML=`<div class=\"torrent-log-message\">${esc(t.message||'No logs')}</div>`;\n return;\n }\n const pane=$('detailPane');\n if(!silent) pane.innerHTML=`<div class=\"loading-line\"><span class=\"spinner-border spinner-border-sm\"></span> Loading ${esc(tab)}...</div>`;\n try{\n // Note: Background peer refresh keeps the current table visible and only swaps in fresh rows after a successful response.\n const detailUrl = tab==='chunks' ? `/api/torrents/${encodeURIComponent(hash)}/chunks?max_cells=${chunkMaxCellsForDensity()}` : `/api/torrents/${encodeURIComponent(hash)}/${tab}`;\n const res=await fetch(detailUrl,{headers:{'Accept':'application/json'}});\n const text=await res.text();\n let json;\n try{\n json=JSON.parse(text);\n }catch(parseErr){\n throw new Error(`Invalid API response for ${tab}. HTTP ${res.status}`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`HTTP ${res.status}`);\n if(tab!==activeTab() || selectedHash!==hash) return;\n if(tab==='files') renderFiles(json.files||[]);\n if(tab==='chunks') renderChunks(json.chunks||{});\n if(tab==='peers') renderPeers(json.peers||[]);\n if(tab==='trackers') renderTrackers(json.trackers||[]);\n }catch(e){\n if(!silent) pane.innerHTML=`<div class=\"text-danger\">${esc(e.message)}</div>`;\n }\n }\n";
+1 -1
View File
@@ -1 +1 @@
export const torrentGeneralDetailsSource = " function formatDateTime(seconds){ const n=Number(seconds||0); if(!n) return '-'; try{ return new Date(n*1000).toLocaleString(); }catch(e){ return '-'; } }\n function joinRemotePath(base,name){\n const b=String(base||'').trim();\n const n=String(name||'').trim();\n if(!b && !n) return '-';\n if(!n) return b || '-';\n if(!b) return n;\n return `${b.replace(/\\/+$/,'')}/${n.replace(/^\\/+/,'')}`;\n }\n function renderGeneral(){\n const t=torrents.get(selectedHash);\n if(!t){ $('detailPane').innerHTML='Select a torrent.'; return; }\n const labels=labelNames(t.label).map(l=>`<span class=\"chip label-mini\"><i class=\"fa-solid fa-tag\"></i> ${esc(l)}</span>`).join(' ') || '<span class=\"text-muted\">-</span>';\n const ratioGroup=t.ratio_group ? `<span class=\"badge text-bg-info\">${esc(t.ratio_group)}</span>` : '<span class=\"text-muted\">Not assigned</span>';\n const statusClass=t.status==='Seeding'?'success':t.status==='Downloading'?'primary':t.status==='Checking'?'warning':t.status==='Paused'?'secondary':t.status==='Stopped'?'dark':'secondary';\n const fullPath=joinRemotePath(t.path,t.name);\n const cards=[\n ['Size', esc(t.size_h||'-')],\n ['Downloaded', esc(t.down_total_h||'-')],\n ['Uploaded', esc(t.up_total_h||'-')],\n ['Ratio', esc(t.ratio??'-')],\n ['Download speed', esc(t.down_rate_h||'-')],\n ['Upload speed', esc(t.up_rate_h||'-')],\n ['Seeds / Peers', `${esc(t.seeds??0)} / ${esc(t.peers??0)}`],\n ['ETA', esc(t.eta_h||'-')],\n ['Created', esc(formatDateTime(t.created))],\n ['Last activity', esc(formatDateTime(t.last_activity))],\n ['Priority', esc(t.priority??'-')],\n ].map(([label,value])=>`<div class=\"general-stat\"><b>${label}</b><span>${value}</span></div>`).join('');\n $('detailPane').innerHTML=`\n <section class=\"general-summary\">\n <div class=\"general-summary-main\">\n <div class=\"general-title-row\"><h6>${esc(t.name||'-')}</h6><span class=\"badge text-bg-${statusClass}\">${esc(t.status||'-')}</span></div>\n <div class=\"general-path\"><b>Directory</b><span>${esc(t.path||'-')}</span></div>\n <div class=\"general-path\"><b>Full data path</b><span>${esc(fullPath)}</span></div>\n </div>\n <div class=\"general-summary-side\"><b>Hash</b><code>${esc(t.hash||'-')}</code></div>\n </section>\n <div class=\"general-grid\">${cards}</div>\n <div class=\"general-meta\"><div><b>Labels</b><span>${labels}</span></div><div><b>Ratio rule</b><span>${ratioGroup}</span></div><div><b>Message</b><span>${esc(t.message||'-')}</span></div></div>`;\n }\n";
export const torrentGeneralDetailsSource = " function formatDateTime(seconds){ const n=Number(seconds||0); if(!n) return '-'; try{ return new Date(n*1000).toLocaleString(); }catch(e){ return '-'; } }\n function joinRemotePath(base,name){\n const b=String(base||'').trim();\n const n=String(name||'').trim();\n if(!b && !n) return '-';\n if(!n) return b || '-';\n if(!b) return n;\n return `${b.replace(/\\/+$/,'')}/${n.replace(/^\\/+/,'')}`;\n }\n function generalInfoItem(label, value, className=''){\n const modifier = className ? ` ${className}` : '';\n return `<div class=\"general-info-item${modifier}\"><b>${esc(label)}</b><span>${value}</span></div>`;\n }\n function generalCodeValue(value){\n return `<code>${esc(value || '-')}</code>`;\n }\n function renderGeneral(){\n const t=torrents.get(selectedHash);\n if(!t){ $('detailPane').innerHTML='Select a torrent.'; return; }\n const labels=labelNames(t.label).map(l=>`<span class=\"chip label-mini\"><i class=\"fa-solid fa-tag\"></i> ${esc(l)}</span>`).join(' ') || '<span class=\"text-muted\">-</span>';\n const ratioGroup=t.ratio_group ? `<span class=\"badge text-bg-info\">${esc(t.ratio_group)}</span>` : '<span class=\"text-muted\">Not assigned</span>';\n const statusClass=t.status==='Seeding'?'success':t.status==='Downloading'?'primary':t.status==='Checking'?'warning':t.status==='Paused'?'secondary':t.status==='Stopped'?'dark':'secondary';\n const fullPath=joinRemotePath(t.path,t.name);\n const stats=[\n ['Size', esc(t.size_h||'-')],\n ['Done', `${esc(t.progress??0)}%`],\n ['Downloaded', esc(t.down_total_h||'-')],\n ['Uploaded', esc(t.up_total_h||'-')],\n ['Ratio', esc(t.ratio??'-')],\n ['DL / UL', `${esc(t.down_rate_h||'-')} / ${esc(t.up_rate_h||'-')}`],\n ['Seeds / Peers', `${esc(t.seeds??0)} / ${esc(t.peers??0)}`],\n ['ETA', esc(t.eta_h||'-')],\n ['Priority', esc(t.priority??'-')],\n ['Created', esc(formatDateTime(t.created))],\n ['Last activity', esc(formatDateTime(t.last_activity))],\n ].map(([label,value])=>generalInfoItem(label,value)).join('');\n const meta=[\n generalInfoItem('Directory', generalCodeValue(t.path)),\n generalInfoItem('Full data path', generalCodeValue(fullPath)),\n generalInfoItem('Hash', generalCodeValue(t.hash)),\n generalInfoItem('Labels', labels),\n generalInfoItem('Ratio rule', ratioGroup),\n ].join('');\n // Note: General details keep path-like values in code blocks for easier copying and omit tracker message because it is shown in Log.\n $('detailPane').innerHTML=`\n <section class=\"general-panel\">\n <div class=\"general-header\">\n <h6>${esc(t.name||'-')}</h6>\n <span class=\"badge text-bg-${statusClass}\">${esc(t.status||'-')}</span>\n </div>\n <div class=\"general-stats\">${stats}</div>\n <div class=\"general-info\">${meta}</div>\n </section>`;\n }\n";
+57 -93
View File
@@ -652,128 +652,97 @@ body.resizing-details {
}
.torrent-log-message {
background: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: 0.65rem;
font-size: 1rem;
line-height: 1.6;
color: var(--bs-body-color);
font: inherit;
line-height: 1.45;
margin: 0;
min-height: 4.25rem;
padding: 1rem 1.1rem;
overflow-wrap: anywhere;
padding: 0.35rem 0.15rem;
white-space: pre-wrap;
}
.detail-table {
white-space: nowrap;
}
.responsive-table-wrap {
max-width: 100%;
overflow-x: auto;
border: 1px solid var(--bs-border-color);
border-radius: 0.6rem;
-webkit-overflow-scrolling: touch;
}
.responsive-table-wrap .detail-table {
margin-bottom: 0;
}
.smart-exclusions-table {
min-width: 680px;
}
.smart-history-table {
min-width: 920px;
table-layout: fixed;
}
.smart-history-table th,
.smart-history-table td {
overflow-wrap: anywhere;
white-space: normal;
}
.general-summary,
.general-grid,
.general-meta {
display: grid;
gap: 0.75rem;
}
.general-summary {
grid-template-columns: minmax(0, 2fr) minmax(16rem, 1fr);
margin-bottom: 0.75rem;
}
.general-summary-main,
.general-summary-side,
.general-stat,
.general-meta > div {
.general-panel {
background: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-radius: 0.75rem;
min-width: 0;
padding: 0.75rem;
border-radius: 0.65rem;
display: grid;
gap: 0.55rem;
padding: 0.65rem;
}
.general-title-row {
.general-header {
align-items: flex-start;
border-bottom: 1px solid var(--bs-border-color);
display: flex;
gap: 0.75rem;
justify-content: space-between;
min-width: 0;
padding-bottom: 0.5rem;
}
.general-title-row h6 {
font-size: 1rem;
line-height: 1.35;
.general-header h6 {
font-size: 0.95rem;
line-height: 1.3;
margin: 0;
overflow-wrap: anywhere;
}
.general-path {
.general-stats,
.general-info {
display: grid;
gap: 0.15rem;
margin-top: 0.5rem;
overflow-wrap: anywhere;
gap: 0.35rem;
}
.general-path b {
.general-stats {
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
}
.general-info {
grid-template-columns: repeat(auto-fit, minmax(10.5rem, 1fr));
}
.general-info-item {
align-items: baseline;
background: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: 0.45rem;
display: grid;
gap: 0.2rem;
min-width: 0;
padding: 0.38rem 0.5rem;
}
.general-info-item b {
color: var(--bs-secondary-color);
font-size: 0.72rem;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.general-path span {
.general-info-item span,
.general-info-item code {
display: block;
font-size: 0.82rem;
min-width: 0;
overflow-wrap: anywhere;
}
.general-summary-side code {
display: block;
font-size: 0.78rem;
overflow-wrap: anywhere;
.general-info-item span {
white-space: normal;
}
.general-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
.general-info-item code {
background: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-radius: 0.35rem;
color: var(--bs-body-color);
line-height: 1.35;
padding: 0.18rem 0.3rem;
user-select: all;
white-space: pre-wrap;
}
.general-meta {
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 0.75rem;
}
.general-stat b,
.general-meta b,
.general-summary-side b {
color: var(--bs-secondary-color);
display: block;
font-size: 0.72rem;
letter-spacing: 0.03em;
margin-bottom: 0.25rem;
text-transform: uppercase;
}
.general-stat span,
.general-meta span {
display: block;
overflow-wrap: anywhere;
}
.statusbar {
display: flex;
align-items: center;
@@ -992,11 +961,6 @@ body.resizing-details {
.sidebar {
display: none;
}
.general-summary,
.general-grid,
.general-meta {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
:root {