diff --git a/pytorrent/static/js/mobileTorrentDetails.js b/pytorrent/static/js/mobileTorrentDetails.js index f046fd4..bdb4987 100644 --- a/pytorrent/static/js/mobileTorrentDetails.js +++ b/pytorrent/static/js/mobileTorrentDetails.js @@ -1 +1 @@ -export const mobileTorrentDetailsSource = " function mobileDetailValue(value, fallback='-'){\n const text = value === null || value === undefined || value === '' ? fallback : String(value);\n return esc(text);\n }\n function mobileDetailsStatCards(t){\n const stats = [\n ['Status', t.status || '-'],\n ['Progress', `${Number(t.progress || 0)}%`],\n ['Size', t.size_h || '-'],\n ['Downloaded', t.down_total_h || '-'],\n ['Uploaded', t.up_total_h || '-'],\n ['DL / UL', `${t.down_rate_h || '-'} / ${t.up_rate_h || '-'}`],\n ['Seeds / Peers', `${t.seeds ?? 0} / ${t.peers ?? 0}`],\n ['Ratio', t.ratio ?? '-'],\n ['ETA', t.eta_h || '-'],\n ['Added', formatDateTime(t.created)],\n ];\n return stats.map(([label,value]) => `
${esc(label)}${mobileDetailValue(value)}
`).join('');\n }\n function mobileDetailsPeerRows(peers){\n // Note: Mobile peers use the same responsive table wrapper as desktop details for consistent spacing and scrolling.\n return (peers || []).slice(0, 40).map(p => {\n const location = [p.country, p.city].filter(Boolean).join(', ') || '-';\n const ip = `${esc(p.ip || '-')}`;\n const row = [flag(p.country_iso), ip];\n if(reverseDnsEnabled) row.push(peerHostCell(p));\n row.push(esc(location), esc(p.client || '-'), progressBar(p.completed || 0, 'peer-progress peer-progress-wide'), esc(p.down_rate_h || '-'), esc(p.up_rate_h || '-'), esc(p.port || '-'), peerBadges(p));\n return row;\n });\n }\n function scheduleMobileReverseDnsRefresh(hash, torrent, payload){\n // Note: Mobile details refresh only the peers payload while preserving already-loaded files and trackers.\n clearTimeout(mobileReverseDnsRefreshTimer);\n if(!hasPendingReverseDns(payload.peers?.value?.peers || [])){ mobileReverseDnsRefreshAttempts=0; return; }\n if(!hash || mobileReverseDnsRefreshAttempts>=REVERSE_DNS_REFRESH_MAX_ATTEMPTS) return;\n mobileReverseDnsRefreshAttempts+=1;\n mobileReverseDnsRefreshTimer=setTimeout(async()=>{\n if(selectedHash!==hash || !$('mobileDetailsModal')?.classList.contains('show')) return;\n try{\n const peersJson=await fetchMobileDetailsJson(hash, 'peers');\n const nextPayload={...payload, peers:{status:'fulfilled', value:peersJson}};\n const body=$('mobileDetailsBody');\n if(body && selectedHash===hash){\n body.innerHTML=renderMobileDetailsContent(torrent, nextPayload);\n scheduleMobileReverseDnsRefresh(hash, torrent, nextPayload);\n }\n }catch(_){ }\n }, REVERSE_DNS_REFRESH_SECONDS*1000);\n }\n function mobileDetailsPeerTable(peers){\n const headers = ['Flag', 'IP'];\n if(reverseDnsEnabled) headers.push('Host');\n headers.push('Location', 'Client', '%', 'DL', 'UL', 'Port', 'Flags');\n const rows = mobileDetailsPeerRows(peers);\n if(!rows.length) return '
No peers returned by rTorrent.
';\n return responsiveTable(headers, rows, reverseDnsEnabled ? 'peers-table mobile-details-peers-table peers-table-hosts' : 'peers-table mobile-details-peers-table');\n }\n function mobileDetailsFileTable(files){\n const rows = (files || []).map(file => {\n const priority = FILE_PRIORITY_LABELS[Number(file.priority || 0)] || file.priority || '-';\n const actions = `
${renderFileInfoButton(file)}
`;\n return [\n `${esc(file.path || file.name || '-')}`,\n esc(file.size_h || '-'),\n progressBar(file.progress ?? 0, 'file-progress'),\n `${esc(priority)}`,\n renderFilePrioritySelect(file),\n actions,\n ];\n });\n // Note: Mobile files now reuse the same compact table pattern as peers, with per-file priority, state, info and download actions.\n if(!rows.length) return '
No files returned by rTorrent.
';\n return responsiveTable(['Path', 'Size', 'Done', 'Priority', 'Set priority', 'Actions'], rows, 'file-priority-table mobile-details-files-table');\n }\n function mobileDetailsTrackerRows(trackers){\n // Note: Mobile trackers mirror the desktop tracker table instead of using a separate list layout.\n return (trackers || []).slice(0, 12).map(t => [\n `#${esc(t.index)}`,\n trackerUrlCell(t),\n trackerEnabledCell(t.enabled),\n esc(trackerSeedsPeers(t)),\n esc(t.downloaded ?? '-'),\n fmtTs(t.last_announce),\n ]);\n }\n function mobileDetailsTrackerTable(trackers){\n const rows = mobileDetailsTrackerRows(trackers);\n if(!rows.length) return '
No trackers returned by rTorrent.
';\n return responsiveTable(['#', 'URL', 'On', 'Seeds / Peers', 'Done', 'Last announce'], rows, 'tracker-table mobile-details-trackers-table');\n }\n function mobileDetailsSection(title, icon, body, meta='', options={}){\n const collapsed = !!options.collapsed;\n const titleMarkup = `
${esc(title)}${meta ? `${esc(meta)}` : ''}
`;\n if(collapsed){\n // Note: Heavy mobile sections start collapsed to keep torrent details quick to scan on phones.\n return `
${titleMarkup}${body}
`;\n }\n return `
${titleMarkup}${body}
`;\n }\n function ensureMobileDetailsModal(){\n let modal = $('mobileDetailsModal');\n if(modal) return modal;\n // Note: Mobile torrent details are lazy-created so the desktop details pane and existing tabs stay unchanged.\n modal = document.createElement('div');\n modal.id = 'mobileDetailsModal';\n modal.className = 'modal fade mobile-details-modal';\n modal.tabIndex = -1;\n modal.innerHTML = `
Torrent details
Loading torrent details...
`;\n document.body.appendChild(modal);\n return modal;\n }\n function renderMobileDetailsContent(t, payload){\n const peers = payload.peers?.status === 'fulfilled' ? (payload.peers.value.peers || []) : [];\n const files = payload.files?.status === 'fulfilled' ? (payload.files.value.files || []) : [];\n const trackers = payload.trackers?.status === 'fulfilled' ? (payload.trackers.value.trackers || []) : [];\n const failures = ['peers','files','trackers'].filter(key => payload[key]?.status === 'rejected').map(key => `${key}: ${payload[key].reason?.message || 'failed'}`);\n const fullPath = joinRemotePath(t.path, t.name);\n const peerTable = mobileDetailsPeerTable(peers);\n const fileTable = mobileDetailsFileTable(files);\n const trackerTable = mobileDetailsTrackerTable(trackers);\n const generalBody = `
${esc(t.name || '-')}
Path${esc(fullPath)}
Hash${esc(t.hash || '-')}
${mobileDetailsStatCards(t)}
`;\n const messageBody = `
${esc(t.message || 'No message.')}
`;\n const errorBox = failures.length ? `
Partial details loaded
${esc(failures.join(' | '))}
` : '';\n // Note: General and heavy lists start collapsed on mobile so the modal opens cleanly and the user expands only the section needed.\n return `${errorBox}${mobileDetailsSection('General', 'fa-circle-info', generalBody, '', {collapsed:true})}${mobileDetailsSection('Peers', 'fa-users', peerTable, peers.length > 40 ? `showing 40 of ${peers.length}` : `${peers.length} total`, {collapsed:true})}${mobileDetailsSection('Files', 'fa-folder-tree', fileTable, files.length ? `${files.length} total` : '', {collapsed:true})}${mobileDetailsSection('Trackers', 'fa-bullhorn', trackerTable, trackers.length > 12 ? `showing 12 of ${trackers.length}` : `${trackers.length} total`, {collapsed:true})}${mobileDetailsSection('Message', 'fa-message', messageBody, '', {collapsed:true})}`;\n }\n async function fetchMobileDetailsJson(hash, tab){\n const res = await fetch(`/api/torrents/${encodeURIComponent(hash)}/${tab}`, {headers:{'Accept':'application/json'}});\n const json = await res.json().catch(() => ({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`);\n return json;\n }\n async function openMobileDetails(hash){\n const t = torrents.get(hash);\n if(!t) return toast('Torrent is no longer available.','warning');\n selectedHash = hash;\n lastSelectedHash = hash;\n const modal = ensureMobileDetailsModal();\n const title = $('mobileDetailsTitle');\n const subtitle = $('mobileDetailsSubtitle');\n const body = $('mobileDetailsBody');\n if(title) title.innerHTML = ' Torrent details';\n if(subtitle) subtitle.textContent = t.name || hash;\n if(body) body.innerHTML = '
Loading peers, files and trackers...
';\n new bootstrap.Modal(modal).show();\n try{\n // Note: The mobile modal reads existing lightweight detail endpoints without changing the desktop details tabs.\n const [peers, files, trackers] = await Promise.allSettled([\n fetchMobileDetailsJson(hash, 'peers'),\n fetchMobileDetailsJson(hash, 'files'),\n fetchMobileDetailsJson(hash, 'trackers'),\n ]);\n if(body) body.innerHTML = renderMobileDetailsContent(t, {peers, files, trackers});\n mobileReverseDnsRefreshAttempts=0;\n scheduleMobileReverseDnsRefresh(hash, t, {peers, files, trackers});\n }catch(e){\n if(body) body.innerHTML = `
Details failed
${esc(e.message)}
`;\n }\n }\n\n"; +export const mobileTorrentDetailsSource = " function mobileDetailValue(value, fallback='-'){\n const text = value === null || value === undefined || value === '' ? fallback : String(value);\n return esc(text);\n }\n function mobileDetailsStatCards(t){\n const stats = [\n ['Status', t.status || '-'],\n ['Progress', `${Number(t.progress || 0)}%`],\n ['Size', t.size_h || '-'],\n ['Downloaded', t.down_total_h || '-'],\n ['Uploaded', t.up_total_h || '-'],\n ['DL / UL', `${t.down_rate_h || '-'} / ${t.up_rate_h || '-'}`],\n ['Seeds / Peers', `${t.seeds ?? 0} / ${t.peers ?? 0}`],\n ['Ratio', t.ratio ?? '-'],\n ['ETA', t.eta_h || '-'],\n ['Added', formatDateTime(t.created)],\n ];\n return stats.map(([label,value]) => `
${esc(label)}${mobileDetailValue(value)}
`).join('');\n }\n function mobileDetailsPeerRows(peers){\n // Note: Mobile peers use the same responsive table wrapper as desktop details for consistent spacing and scrolling.\n return (peers || []).slice(0, 40).map(p => {\n const location = [p.country, p.city].filter(Boolean).join(', ') || '-';\n const ip = `${esc(p.ip || '-')}`;\n const row = [flag(p.country_iso), ip];\n if(reverseDnsEnabled) row.push(peerHostCell(p));\n row.push(esc(location), esc(p.client || '-'), progressBar(p.completed || 0, 'peer-progress peer-progress-wide'), esc(p.down_rate_h || '-'), esc(p.up_rate_h || '-'), esc(p.port || '-'), peerBadges(p));\n return row;\n });\n }\n function scheduleMobileReverseDnsRefresh(hash, torrent, payload){\n // Note: Mobile details refresh only the peers payload while preserving already-loaded files and trackers.\n clearTimeout(mobileReverseDnsRefreshTimer);\n if(!hasPendingReverseDns(payload.peers?.value?.peers || [])){ mobileReverseDnsRefreshAttempts=0; return; }\n if(!hash || mobileReverseDnsRefreshAttempts>=REVERSE_DNS_REFRESH_MAX_ATTEMPTS) return;\n mobileReverseDnsRefreshAttempts+=1;\n mobileReverseDnsRefreshTimer=setTimeout(async()=>{\n if(selectedHash!==hash || !$('mobileDetailsModal')?.classList.contains('show')) return;\n try{\n const peersJson=await fetchMobileDetailsJson(hash, 'peers');\n const nextPayload={...payload, peers:{status:'fulfilled', value:peersJson}};\n const body=$('mobileDetailsBody');\n if(body && selectedHash===hash){\n body.innerHTML=renderMobileDetailsContent(torrent, nextPayload);\n scheduleMobileReverseDnsRefresh(hash, torrent, nextPayload);\n }\n }catch(_){ }\n }, REVERSE_DNS_REFRESH_SECONDS*1000);\n }\n function mobileDetailsPeerTable(peers){\n const headers = ['Flag', 'IP'];\n if(reverseDnsEnabled) headers.push('Host');\n headers.push('Location', 'Client', '%', 'DL', 'UL', 'Port', 'Flags');\n const rows = mobileDetailsPeerRows(peers);\n if(!rows.length) return '
No peers returned by rTorrent.
';\n return responsiveTable(headers, rows, reverseDnsEnabled ? 'peers-table peers-table-hosts' : 'peers-table');\n }\n function mobileDetailsFileTable(files){\n const rows = (files || []).map(file => {\n const priority = FILE_PRIORITY_LABELS[Number(file.priority || 0)] || file.priority || '-';\n const actions = `
${renderFileInfoButton(file)}
`;\n return [\n `${esc(file.path || file.name || '-')}`,\n esc(file.size_h || '-'),\n progressBar(file.progress ?? 0, 'file-progress'),\n `${esc(priority)}`,\n renderFilePrioritySelect(file),\n actions,\n ];\n });\n // Note: Mobile files now reuse the same compact table pattern as peers, with per-file priority, state, info and download actions.\n if(!rows.length) return '
No files returned by rTorrent.
';\n return responsiveTable(['Path', 'Size', 'Done', 'Priority', 'Set priority', 'Actions'], rows, 'file-priority-table');\n }\n function mobileDetailsTrackerRows(trackers){\n // Note: Mobile trackers mirror the desktop tracker table instead of using a separate list layout.\n return (trackers || []).slice(0, 12).map(t => [\n `#${esc(t.index)}`,\n trackerUrlCell(t),\n trackerEnabledCell(t.enabled),\n esc(trackerSeedsPeers(t)),\n esc(t.downloaded ?? '-'),\n fmtTs(t.last_announce),\n ]);\n }\n function mobileDetailsTrackerTable(trackers){\n const rows = mobileDetailsTrackerRows(trackers);\n if(!rows.length) return '
No trackers returned by rTorrent.
';\n return responsiveTable(['#', 'URL', 'On', 'Seeds / Peers', 'Done', 'Last announce'], rows, 'tracker-table');\n }\n function mobileDetailsSection(title, icon, body, meta='', options={}){\n const collapsed = !!options.collapsed;\n const titleMarkup = `
${esc(title)}${meta ? `${esc(meta)}` : ''}
`;\n if(collapsed){\n // Note: Heavy mobile sections start collapsed to keep torrent details quick to scan on phones.\n return `
${titleMarkup}${body}
`;\n }\n return `
${titleMarkup}${body}
`;\n }\n function ensureMobileDetailsModal(){\n let modal = $('mobileDetailsModal');\n if(modal) return modal;\n // Note: Mobile torrent details are lazy-created so the desktop details pane and existing tabs stay unchanged.\n modal = document.createElement('div');\n modal.id = 'mobileDetailsModal';\n modal.className = 'modal fade mobile-details-modal';\n modal.tabIndex = -1;\n modal.innerHTML = `
Torrent details
Loading torrent details...
`;\n document.body.appendChild(modal);\n return modal;\n }\n function renderMobileDetailsContent(t, payload){\n const peers = payload.peers?.status === 'fulfilled' ? (payload.peers.value.peers || []) : [];\n const files = payload.files?.status === 'fulfilled' ? (payload.files.value.files || []) : [];\n const trackers = payload.trackers?.status === 'fulfilled' ? (payload.trackers.value.trackers || []) : [];\n const failures = ['peers','files','trackers'].filter(key => payload[key]?.status === 'rejected').map(key => `${key}: ${payload[key].reason?.message || 'failed'}`);\n const fullPath = joinRemotePath(t.path, t.name);\n const peerTable = mobileDetailsPeerTable(peers);\n const fileTable = mobileDetailsFileTable(files);\n const trackerTable = mobileDetailsTrackerTable(trackers);\n const generalBody = `
${esc(t.name || '-')}
Path${esc(fullPath)}
Hash${esc(t.hash || '-')}
${mobileDetailsStatCards(t)}
`;\n const messageBody = `
${esc(t.message || 'No message.')}
`;\n const errorBox = failures.length ? `
Partial details loaded
${esc(failures.join(' | '))}
` : '';\n // Note: General and heavy lists start collapsed on mobile so the modal opens cleanly and the user expands only the section needed.\n return `${errorBox}${mobileDetailsSection('General', 'fa-circle-info', generalBody, '', {collapsed:true})}${mobileDetailsSection('Peers', 'fa-users', peerTable, peers.length > 40 ? `showing 40 of ${peers.length}` : `${peers.length} total`, {collapsed:true})}${mobileDetailsSection('Files', 'fa-folder-tree', fileTable, files.length ? `${files.length} total` : '', {collapsed:true})}${mobileDetailsSection('Trackers', 'fa-bullhorn', trackerTable, trackers.length > 12 ? `showing 12 of ${trackers.length}` : `${trackers.length} total`, {collapsed:true})}${mobileDetailsSection('Message', 'fa-message', messageBody, '', {collapsed:true})}`;\n }\n async function fetchMobileDetailsJson(hash, tab){\n const res = await fetch(`/api/torrents/${encodeURIComponent(hash)}/${tab}`, {headers:{'Accept':'application/json'}});\n const json = await res.json().catch(() => ({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`);\n return json;\n }\n async function openMobileDetails(hash){\n const t = torrents.get(hash);\n if(!t) return toast('Torrent is no longer available.','warning');\n selectedHash = hash;\n lastSelectedHash = hash;\n const modal = ensureMobileDetailsModal();\n const title = $('mobileDetailsTitle');\n const subtitle = $('mobileDetailsSubtitle');\n const body = $('mobileDetailsBody');\n if(title) title.innerHTML = ' Torrent details';\n if(subtitle) subtitle.textContent = t.name || hash;\n if(body) body.innerHTML = '
Loading peers, files and trackers...
';\n new bootstrap.Modal(modal).show();\n try{\n // Note: The mobile modal reads existing lightweight detail endpoints without changing the desktop details tabs.\n const [peers, files, trackers] = await Promise.allSettled([\n fetchMobileDetailsJson(hash, 'peers'),\n fetchMobileDetailsJson(hash, 'files'),\n fetchMobileDetailsJson(hash, 'trackers'),\n ]);\n if(body) body.innerHTML = renderMobileDetailsContent(t, {peers, files, trackers});\n mobileReverseDnsRefreshAttempts=0;\n scheduleMobileReverseDnsRefresh(hash, t, {peers, files, trackers});\n }catch(e){\n if(body) body.innerHTML = `
Details failed
${esc(e.message)}
`;\n }\n }\n\n"; diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index e81a616..a285e90 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -4991,72 +4991,72 @@ body, width: 100%; } -.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) th:nth-child(1), -.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) td:nth-child(1), -.peers-table:not(.mobile-details-peers-table).peers-table-hosts th:nth-child(1), -.peers-table:not(.mobile-details-peers-table).peers-table-hosts td:nth-child(1) { +.peers-table:not(.peers-table-hosts) th:nth-child(1), +.peers-table:not(.peers-table-hosts) td:nth-child(1), +.peers-table.peers-table-hosts th:nth-child(1), +.peers-table.peers-table-hosts td:nth-child(1) { width: 4%; } -.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) th:nth-child(2), -.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) td:nth-child(2), -.peers-table:not(.mobile-details-peers-table).peers-table-hosts th:nth-child(2), -.peers-table:not(.mobile-details-peers-table).peers-table-hosts td:nth-child(2) { +.peers-table:not(.peers-table-hosts) th:nth-child(2), +.peers-table:not(.peers-table-hosts) td:nth-child(2), +.peers-table.peers-table-hosts th:nth-child(2), +.peers-table.peers-table-hosts td:nth-child(2) { width: 13%; } -.peers-table:not(.mobile-details-peers-table).peers-table-hosts th:nth-child(3), -.peers-table:not(.mobile-details-peers-table).peers-table-hosts td:nth-child(3) { +.peers-table.peers-table-hosts th:nth-child(3), +.peers-table.peers-table-hosts td:nth-child(3) { width: 15%; } -.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) th:nth-child(3), -.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) td:nth-child(3), -.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) th:nth-child(4), -.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) td:nth-child(4), -.peers-table:not(.mobile-details-peers-table).peers-table-hosts th:nth-child(4), -.peers-table:not(.mobile-details-peers-table).peers-table-hosts td:nth-child(4), -.peers-table:not(.mobile-details-peers-table).peers-table-hosts th:nth-child(5), -.peers-table:not(.mobile-details-peers-table).peers-table-hosts td:nth-child(5) { +.peers-table:not(.peers-table-hosts) th:nth-child(3), +.peers-table:not(.peers-table-hosts) td:nth-child(3), +.peers-table:not(.peers-table-hosts) th:nth-child(4), +.peers-table:not(.peers-table-hosts) td:nth-child(4), +.peers-table.peers-table-hosts th:nth-child(4), +.peers-table.peers-table-hosts td:nth-child(4), +.peers-table.peers-table-hosts th:nth-child(5), +.peers-table.peers-table-hosts td:nth-child(5) { width: 8%; } -.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) th:nth-child(5), -.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) td:nth-child(5), -.peers-table:not(.mobile-details-peers-table).peers-table-hosts th:nth-child(6), -.peers-table:not(.mobile-details-peers-table).peers-table-hosts td:nth-child(6) { +.peers-table:not(.peers-table-hosts) th:nth-child(5), +.peers-table:not(.peers-table-hosts) td:nth-child(5), +.peers-table.peers-table-hosts th:nth-child(6), +.peers-table.peers-table-hosts td:nth-child(6) { width: 15%; } -.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) th:nth-child(6), -.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) td:nth-child(6), -.peers-table:not(.mobile-details-peers-table).peers-table-hosts th:nth-child(7), -.peers-table:not(.mobile-details-peers-table).peers-table-hosts td:nth-child(7) { +.peers-table:not(.peers-table-hosts) th:nth-child(6), +.peers-table:not(.peers-table-hosts) td:nth-child(6), +.peers-table.peers-table-hosts th:nth-child(7), +.peers-table.peers-table-hosts td:nth-child(7) { width: 8rem; } -.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) th:nth-child(7), -.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) td:nth-child(7), -.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) th:nth-child(8), -.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) td:nth-child(8), -.peers-table:not(.mobile-details-peers-table).peers-table-hosts th:nth-child(8), -.peers-table:not(.mobile-details-peers-table).peers-table-hosts td:nth-child(8), -.peers-table:not(.mobile-details-peers-table).peers-table-hosts th:nth-child(9), -.peers-table:not(.mobile-details-peers-table).peers-table-hosts td:nth-child(9) { +.peers-table:not(.peers-table-hosts) th:nth-child(7), +.peers-table:not(.peers-table-hosts) td:nth-child(7), +.peers-table:not(.peers-table-hosts) th:nth-child(8), +.peers-table:not(.peers-table-hosts) td:nth-child(8), +.peers-table.peers-table-hosts th:nth-child(8), +.peers-table.peers-table-hosts td:nth-child(8), +.peers-table.peers-table-hosts th:nth-child(9), +.peers-table.peers-table-hosts td:nth-child(9) { width: 6%; } -.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) th:nth-child(9), -.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) td:nth-child(9), -.peers-table:not(.mobile-details-peers-table).peers-table-hosts th:nth-child(10), -.peers-table:not(.mobile-details-peers-table).peers-table-hosts td:nth-child(10) { +.peers-table:not(.peers-table-hosts) th:nth-child(9), +.peers-table:not(.peers-table-hosts) td:nth-child(9), +.peers-table.peers-table-hosts th:nth-child(10), +.peers-table.peers-table-hosts td:nth-child(10) { width: 5%; } -.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) th:nth-child(10), -.peers-table:not(.mobile-details-peers-table):not(.peers-table-hosts) td:nth-child(10), -.peers-table:not(.mobile-details-peers-table).peers-table-hosts th:nth-child(11), -.peers-table:not(.mobile-details-peers-table).peers-table-hosts td:nth-child(11) { +.peers-table:not(.peers-table-hosts) th:nth-child(10), +.peers-table:not(.peers-table-hosts) td:nth-child(10), +.peers-table.peers-table-hosts th:nth-child(11), +.peers-table.peers-table-hosts td:nth-child(11) { width: 10%; } @@ -5069,85 +5069,30 @@ body, white-space: nowrap; } -/* Note: Mobile torrent details use a stable table so progress bars always render on a 0-100% track. */ +/* Note: Responsive table wrappers keep detail tables scrollable while preserving the rounded app table frame. */ +.responsive-table-wrap { + -webkit-overflow-scrolling: touch; + background: var(--bs-body-bg); + border: 1px solid var(--bs-border-color); + border-radius: 0.55rem; + max-width: 100%; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + scrollbar-width: thin; + touch-action: pan-x pan-y; + width: 100%; +} + +.responsive-table-wrap > .detail-table { + margin-bottom: 0; +} + .mobile-details-modal .modal-body { overflow-x: hidden; } -.mobile-details-modal .responsive-table-wrap { - max-width: 100%; -} - -.mobile-details-peers-table { - margin-bottom: 0; - min-width: 780px; -} - -.mobile-details-peers-table:not(.peers-table-hosts) th:nth-child(1), -.mobile-details-peers-table:not(.peers-table-hosts) td:nth-child(1), -.mobile-details-peers-table.peers-table-hosts th:nth-child(1), -.mobile-details-peers-table.peers-table-hosts td:nth-child(1) { - width: 5%; -} - -.mobile-details-peers-table:not(.peers-table-hosts) th:nth-child(2), -.mobile-details-peers-table:not(.peers-table-hosts) td:nth-child(2), -.mobile-details-peers-table.peers-table-hosts th:nth-child(2), -.mobile-details-peers-table.peers-table-hosts td:nth-child(2) { - width: 14%; -} - -.mobile-details-peers-table.peers-table-hosts th:nth-child(3), -.mobile-details-peers-table.peers-table-hosts td:nth-child(3) { - width: 16%; -} - -.mobile-details-peers-table:not(.peers-table-hosts) th:nth-child(3), -.mobile-details-peers-table:not(.peers-table-hosts) td:nth-child(3), -.mobile-details-peers-table.peers-table-hosts th:nth-child(4), -.mobile-details-peers-table.peers-table-hosts td:nth-child(4) { - width: 16%; -} - -.mobile-details-peers-table:not(.peers-table-hosts) th:nth-child(4), -.mobile-details-peers-table:not(.peers-table-hosts) td:nth-child(4), -.mobile-details-peers-table.peers-table-hosts th:nth-child(5), -.mobile-details-peers-table.peers-table-hosts td:nth-child(5) { - width: 15%; -} - -.mobile-details-peers-table:not(.peers-table-hosts) th:nth-child(5), -.mobile-details-peers-table:not(.peers-table-hosts) td:nth-child(5), -.mobile-details-peers-table.peers-table-hosts th:nth-child(6), -.mobile-details-peers-table.peers-table-hosts td:nth-child(6) { - width: 8rem; -} - -.mobile-details-peers-table:not(.peers-table-hosts) th:nth-child(6), -.mobile-details-peers-table:not(.peers-table-hosts) td:nth-child(6), -.mobile-details-peers-table:not(.peers-table-hosts) th:nth-child(7), -.mobile-details-peers-table:not(.peers-table-hosts) td:nth-child(7), -.mobile-details-peers-table.peers-table-hosts th:nth-child(7), -.mobile-details-peers-table.peers-table-hosts td:nth-child(7), -.mobile-details-peers-table.peers-table-hosts th:nth-child(8), -.mobile-details-peers-table.peers-table-hosts td:nth-child(8) { - width: 7%; -} - -.mobile-details-peers-table:not(.peers-table-hosts) th:nth-child(8), -.mobile-details-peers-table:not(.peers-table-hosts) td:nth-child(8), -.mobile-details-peers-table.peers-table-hosts th:nth-child(9), -.mobile-details-peers-table.peers-table-hosts td:nth-child(9) { - width: 6%; -} - -.mobile-details-peers-table:not(.peers-table-hosts) th:nth-child(9), -.mobile-details-peers-table:not(.peers-table-hosts) td:nth-child(9), -.mobile-details-peers-table.peers-table-hosts th:nth-child(10), -.mobile-details-peers-table.peers-table-hosts td:nth-child(10) { - width: 8%; -} - /* App modal widths stay consistent while Bootstrap still handles full-screen mobile breakpoints. */ .app-modal-dialog, .modal-dialog.modal-xl { @@ -5612,23 +5557,6 @@ body, overflow-wrap: anywhere; } -.mobile-details-files-table { - margin-bottom: 0; - min-width: 760px; -} - -.mobile-details-trackers-table { - margin-bottom: 0; -} - -.mobile-details-files-table .file-progress { - min-width: 7.5rem; -} - -.mobile-details-files-table .file-priority { - min-width: 6.5rem; -} - .mobile-details-file-path { display: inline-block; max-width: 24rem;