From 02e70194b0532161a1a9bdfedef78d48c5de1dce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 4 Jun 2026 15:29:17 +0200 Subject: [PATCH] changes_5 --- pytorrent/static/js/sharedUi.js | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/pytorrent/static/js/sharedUi.js b/pytorrent/static/js/sharedUi.js index b44d220..8deafe1 100644 --- a/pytorrent/static/js/sharedUi.js +++ b/pytorrent/static/js/sharedUi.js @@ -1,14 +1 @@ -export const sharedUiSource = " function clampNumber(value, min, max, fallback){\n const num = Number(value);\n if(!Number.isFinite(num)) return fallback;\n return Math.max(min, Math.min(max, Math.round(num)));\n }\n function debounce(fn, delay=250){\n let timer = null;\n return (...args) => {\n clearTimeout(timer);\n timer = setTimeout(() => fn(...args), delay);\n };\n }\n function savePreferencePatch(payload, delay=350){\n const key = Object.keys(payload).sort().join('|');\n clearTimeout(preferenceSaveTimers.get(key));\n preferenceSaveTimers.set(key, setTimeout(async()=>{\n try{ await post('/api/preferences', payload); }catch(e){ console.warn('Preference save failed', e); }\n finally{ preferenceSaveTimers.delete(key); }\n }, delay));\n }\n function currentActiveFilterPreference(){\n return activeTrackerFilter ? `tracker:${activeTrackerFilter}` : activeFilter;\n }\n function saveTorrentSortPreference(){\n // Note: Sorting is persisted together with the current filter so mobile tracker scope cannot fall back to All trackers after a quick sort change.\n saveBrowserViewPrefs();\n savePreferencePatch({torrent_sort_json:{key:sortState.key, dir:sortState.dir}, active_filter:currentActiveFilterPreference()}, 200);\n }\n function saveBrowserViewPrefs(extra={}){\n try{\n const prev=JSON.parse(localStorage.getItem('pyTorrent.mobileViewPrefs')||'{}')||{};\n localStorage.setItem('pyTorrent.mobileViewPrefs', JSON.stringify({...prev, activeFilter:currentActiveFilterPreference(), mobileFilterKey:mobileActiveFilterKey, sortState, mobileColumns, columnWidths, ...extra}));\n }catch(e){}\n }\n function saveActiveFilterPreference(){\n saveBrowserViewPrefs();\n savePreferencePatch({active_filter:currentActiveFilterPreference()}, 250);\n }\n function cleanColumnPrefsHidden(values){ return [...values].filter(key => key !== \"progressbar\"); }\n async function resetViewPreferences(){\n activeFilter = \"all\";\n activeTrackerFilter = \"\";\n mobileActiveFilterKey = \"all\";\n sortState = {key:\"name\", dir:1};\n mobileColumns = normalizeMobileColumns();\n hiddenColumns = new Set(DEFAULT_HIDDEN_COLUMNS);\n columnWidths = normalizeColumnWidths();\n const height = applyDetailPanelHeight(255);\n renderColumnManager();\n document.querySelectorAll('.filter').forEach(x=>x.classList.toggle('active', x.dataset.filter === 'all'));\n if($('tableWrap')) $('tableWrap').scrollTop = 0;\n if($('mobileList')) $('mobileList').scrollTop = 0;\n try{\n await post('/api/preferences', {active_filter:\"all\", torrent_sort_json:{key:\"name\", dir:1}, detail_panel_height:height, table_columns_json:JSON.stringify({hidden:cleanColumnPrefsHidden(DEFAULT_HIDDEN_COLUMNS), shown:[], mobile:mobileColumns, mobileSmartFiltersEnabled:true, widths:columnWidths})});\n toast('View preferences reset','success');\n }catch(e){ toast(e.message,'danger'); }\n scheduleRender(true);\n }\n function applyDetailPanelHeight(height){\n const safeHeight = clampNumber(height, 160, 720, 255);\n document.documentElement.style.setProperty('--detail-panel-height', `${safeHeight}px`);\n const handle = $('detailResizeHandle');\n if(handle) handle.setAttribute('aria-valuenow', String(safeHeight));\n return safeHeight;\n }\n function saveDetailPanelHeight(height){\n const safeHeight = applyDetailPanelHeight(height);\n savePreferencePatch({detail_panel_height:safeHeight}, 250);\n }\n function setupDetailResizer(){\n const handle = $('detailResizeHandle');\n const content = document.querySelector('.content');\n if(!handle || !content) return;\n applyDetailPanelHeight(window.PYTORRENT?.detailPanelHeight || 255);\n let startY = 0, startHeight = 0;\n const onMove = (event) => {\n const pointerY = event.clientY ?? event.touches?.[0]?.clientY ?? startY;\n applyDetailPanelHeight(startHeight - (pointerY - startY));\n scheduleRender(false);\n };\n const onUp = () => {\n document.body.classList.remove('resizing-details');\n document.removeEventListener('pointermove', onMove);\n document.removeEventListener('pointerup', onUp);\n const value = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--detail-panel-height'), 10);\n saveDetailPanelHeight(value);\n };\n handle.addEventListener('pointerdown', (event) => {\n event.preventDefault();\n startY = event.clientY;\n startHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--detail-panel-height'), 10) || 255;\n document.body.classList.add('resizing-details');\n document.addEventListener('pointermove', onMove);\n document.addEventListener('pointerup', onUp);\n });\n }\n function toastKey(msg, type){ return `${type}::${String(msg ?? '')}`; }\n function isAutomationEvent(msg){ return msg?.automation === true || msg?.source === 'automation'; }\n function shouldShowOperationToast(msg){\n // Note: Automation-created operation toasts follow the Automation toasts preference.\n return !isAutomationEvent(msg) || automationToastsEnabled;\n }\n function toast(msg, type=\"secondary\") {\n // Note: Groups identical toasts fired together, so repeated automation/action events do not flood the UI.\n const h=$('toastHost');\n if(!h) return;\n const text=String(msg ?? '');\n const key=toastKey(text,type);\n const existing=toastGroups.get(key);\n if(existing){\n existing.count += 1;\n const badge=existing.el.querySelector('.toast-count');\n if(badge){ badge.innerHTML=`${esc(existing.count)}`; badge.classList.remove('d-none'); }\n clearTimeout(existing.timer);\n existing.timer=setTimeout(()=>{ existing.el.remove(); toastGroups.delete(key); },3500);\n return;\n }\n const el=document.createElement('div');\n el.className=`toast-item text-bg-${type}`;\n el.innerHTML=`${esc(text)}1`;\n h.appendChild(el);\n const entry={el,count:1,timer:null};\n entry.timer=setTimeout(()=>{ el.remove(); toastGroups.delete(key); },3500);\n toastGroups.set(key,entry);\n }\n function setBusy(on, label='Working...'){ pendingBusy += on ? 1 : -1; if(pendingBusy<0) pendingBusy=0; const loader=$('globalLoader'); if(loader){ loader.classList.toggle('d-none', pendingBusy===0); const span=loader.querySelector('span:last-child'); if(span) span.textContent=label; } $('busyBadge')?.classList.toggle('d-none', pendingBusy===0); }\n function isEasterEggReady(kind='click'){\n if(!easterEggEnabled) return false;\n return kind === 'loading' ? !!easterEggLoadingImageUrl : !!easterEggClickImageUrl;\n }\n function applyInitialLoaderEasterEgg(){\n const box = $('initialLoaderSpinner');\n if(!box) return;\n const fallback = '';\n box.classList.remove('initial-loader-prank');\n if(!isEasterEggReady('loading')){\n if(!box.querySelector('.spinner-border')) box.innerHTML = fallback;\n return;\n }\n const img = new Image();\n img.className = 'initial-loader-easter-egg-image';\n img.alt = 'Loading';\n img.loading = 'eager';\n img.onload = () => { box.classList.add('initial-loader-prank'); box.replaceChildren(img); };\n img.onerror = () => { box.classList.remove('initial-loader-prank'); box.innerHTML = fallback; };\n img.src = easterEggLoadingImageUrl;\n }\n function showPrankClickImage(event){\n const target = event.target?.closest?.('button, .btn, [role=button]');\n if(!target || target.disabled || event.defaultPrevented || event.button !== 0) return;\n if(!isEasterEggReady('click')) return;\n if(Math.random() > 0.14) return;\n const img = document.createElement('img');\n img.className = 'prank-click-image';\n img.src = easterEggClickImageUrl;\n img.alt = '';\n img.setAttribute('aria-hidden', 'true');\n const rect = target.getBoundingClientRect();\n const x = event.clientX || (rect.left + rect.width / 2);\n const y = event.clientY || (rect.top + rect.height / 2);\n img.style.left = `${Math.max(90, Math.min(window.innerWidth - 90, x))}px`;\n img.style.top = `${Math.max(90, Math.min(window.innerHeight - 90, y))}px`;\n document.body.appendChild(img);\n setTimeout(() => img.remove(), 1300);\n }\n document.addEventListener('click', showPrankClickImage, true);\n applyInitialLoaderEasterEgg();\n function setInitialLoader(title, text){ if(initialLoaderDone) return; if($('initialLoaderTitle') && title) $('initialLoaderTitle').textContent=title; if($('initialLoaderText') && text) $('initialLoaderText').textContent=text; }\n function hideInitialLoader(){ if(initialLoaderDone) return; initialLoaderDone=true; $('initialLoader')?.classList.add('is-hidden'); }\n function buttonBusy(btn,on){ if(!btn)return; btn.disabled=on; const label=btn.querySelector('.btn-label'); if(label){ if(!label.dataset.orig) label.dataset.orig=label.innerHTML; label.innerHTML=on?`Working...`:label.dataset.orig; }}\n function activeTab(){ return document.querySelector('#detailTabs .nav-link.active')?.dataset.tab || 'general'; }\n function loadingMarkup(label='Loading data...'){ return `
${esc(label)}
`; }\n // Note: Keeps empty-state colspans aligned with the desktop torrent table column count.\n function torrentColumnSpan(){ return 26; }\n function loadingTableRow(label='Loading torrents...'){ return `${loadingMarkup(label)}`; }\n // Note: Handles fresh installations with no configured rTorrent profile, so the UI does not wait forever for a snapshot.\n function renderNoProfileState(){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $('torrentBody');\n if(body){\n body.innerHTML = `
No rTorrent profile configured.Add the first rTorrent profile to start loading torrents.
`;\n }\n if($('detailPane')) $('detailPane').innerHTML = 'Add rTorrent profile first.';\n }\n function clearRtorrentStartingState(){\n rtorrentStartingMessage='';\n rtorrentStartingSince=0;\n if(rtorrentStartingTimer){ clearTimeout(rtorrentStartingTimer); rtorrentStartingTimer=null; }\n }\n function rtorrentStartingHtml(error=''){\n const details=error ? `${esc(error)}` : 'Port can already be open while XML-RPC/SCGI is still warming up. The list will load automatically after rTorrent answers.';\n return `
rTorrent is starting or not responding yet.Waiting for torrent data from the active profile.${details}
`;\n }\n function scheduleRtorrentStartingState(error=''){\n rtorrentStartingMessage = String(error || 'rTorrent is starting or not responding yet.');\n if(!(hasTorrentSnapshot && torrents.size)){\n renderRtorrentStartingState(rtorrentStartingMessage, true);\n return;\n }\n if(!rtorrentStartingSince) rtorrentStartingSince = Date.now();\n if(rtorrentStartingTimer) return;\n rtorrentStartingTimer = setTimeout(() => {\n rtorrentStartingTimer = null;\n if(rtorrentStartingMessage) renderRtorrentStartingState(rtorrentStartingMessage, true);\n }, RTORRENT_STALE_GRACE_MS);\n }\n function renderRtorrentStartingState(error='', force=false){\n rtorrentStartingMessage = String(error || 'rTorrent is starting or not responding yet.');\n if(hasTorrentSnapshot && torrents.size && !force) return;\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body=$('torrentBody');\n if(body) body.innerHTML = `${rtorrentStartingHtml(rtorrentStartingMessage)}`;\n const list=$('mobileList');\n if(list) list.innerHTML = `
${rtorrentStartingHtml(rtorrentStartingMessage)}
`;\n if($('detailPane')) $('detailPane').innerHTML = 'rTorrent is starting. Details will appear after the first successful response.';\n }\n function parseDate(value){ const raw=String(value||'').trim(); if(!raw) return null; const d=new Date(raw); return Number.isNaN(d.getTime()) ? null : {raw,d}; }\n function formatDate(value, mode='short'){\n const parsed=parseDate(value);\n if(!parsed) return String(value||'');\n const opts=mode==='full'\n ? {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'}\n : {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'};\n return new Intl.DateTimeFormat('pl-PL', opts).format(parsed.d).replace(',', '');\n }\n function dateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); return `${esc(formatDate(value))}`; }\n // Note: Human-readable date cells keep full timestamps visible without squeezing table columns.\n function humanDateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); const full=formatDate(value,'full'); return `${esc(full)}`; }\n function compactCell(value, max=120){ const text=String(value||\"\"); if(!text) return \"\"; const short=text.length>max ? `${text.slice(0, Math.floor(max*0.62))}...${text.slice(-Math.floor(max*0.28))}` : text; return `${esc(short)}`; }\n function incompleteProgressColor(pct){ - if(pct <= 0) return 'transparent'; - const safePct = Math.max(0, Math.min(99.99, Number(pct || 0))); - const rootStyle = getComputedStyle(document.documentElement); - const hue = Math.round((safePct / 100) * 120); - const saturation = Number.parseFloat(rootStyle.getPropertyValue('--torrent-progress-scale-saturation')) || 42; - const baseLight = Number.parseFloat(rootStyle.getPropertyValue('--torrent-progress-scale-lightness-base')) || 29; - const rangeLight = Number.parseFloat(rootStyle.getPropertyValue('--torrent-progress-scale-lightness-range')) || 4; - const light = baseLight + Math.round((safePct / 100) * rangeLight); - const base = `hsl(${hue} ${saturation}% ${light}%)`; - const mix = Math.round(42 + (safePct / 100) * 38); - // Note: Incomplete progress uses theme-aware saturation/lightness so light themes stay a bit more muted without changing the hue scale. - return `color-mix(in srgb, ${base} ${mix}%, var(--bs-secondary-bg))`; - }\n function progressBar(value, extraClass=''){\n const pct=Math.max(0,Math.min(100,Number(value||0)));\n const isComplete=pct>=100;\n const bg=isComplete?'var(--torrent-progress-complete)':incompleteProgressColor(pct);\n const done=isComplete?' is-complete':'';\n const cls=extraClass?` ${extraClass}`:'';\n return `
${esc(pct)}%
`;\n }\n function progress(t){ return progressBar(t.progress); }\n"; +export const sharedUiSource = " function clampNumber(value, min, max, fallback){\n const num = Number(value);\n if(!Number.isFinite(num)) return fallback;\n return Math.max(min, Math.min(max, Math.round(num)));\n }\n function debounce(fn, delay=250){\n let timer = null;\n return (...args) => {\n clearTimeout(timer);\n timer = setTimeout(() => fn(...args), delay);\n };\n }\n function savePreferencePatch(payload, delay=350){\n const key = Object.keys(payload).sort().join('|');\n clearTimeout(preferenceSaveTimers.get(key));\n preferenceSaveTimers.set(key, setTimeout(async()=>{\n try{ await post('/api/preferences', payload); }catch(e){ console.warn('Preference save failed', e); }\n finally{ preferenceSaveTimers.delete(key); }\n }, delay));\n }\n function currentActiveFilterPreference(){\n return activeTrackerFilter ? `tracker:${activeTrackerFilter}` : activeFilter;\n }\n function saveTorrentSortPreference(){\n // Note: Sorting is persisted together with the current filter so mobile tracker scope cannot fall back to All trackers after a quick sort change.\n saveBrowserViewPrefs();\n savePreferencePatch({torrent_sort_json:{key:sortState.key, dir:sortState.dir}, active_filter:currentActiveFilterPreference()}, 200);\n }\n function saveBrowserViewPrefs(extra={}){\n try{\n const prev=JSON.parse(localStorage.getItem('pyTorrent.mobileViewPrefs')||'{}')||{};\n localStorage.setItem('pyTorrent.mobileViewPrefs', JSON.stringify({...prev, activeFilter:currentActiveFilterPreference(), mobileFilterKey:mobileActiveFilterKey, sortState, mobileColumns, columnWidths, ...extra}));\n }catch(e){}\n }\n function saveActiveFilterPreference(){\n saveBrowserViewPrefs();\n savePreferencePatch({active_filter:currentActiveFilterPreference()}, 250);\n }\n function cleanColumnPrefsHidden(values){ return [...values].filter(key => key !== \"progressbar\"); }\n async function resetViewPreferences(){\n activeFilter = \"all\";\n activeTrackerFilter = \"\";\n mobileActiveFilterKey = \"all\";\n sortState = {key:\"name\", dir:1};\n mobileColumns = normalizeMobileColumns();\n hiddenColumns = new Set(DEFAULT_HIDDEN_COLUMNS);\n columnWidths = normalizeColumnWidths();\n const height = applyDetailPanelHeight(255);\n renderColumnManager();\n document.querySelectorAll('.filter').forEach(x=>x.classList.toggle('active', x.dataset.filter === 'all'));\n if($('tableWrap')) $('tableWrap').scrollTop = 0;\n if($('mobileList')) $('mobileList').scrollTop = 0;\n try{\n await post('/api/preferences', {active_filter:\"all\", torrent_sort_json:{key:\"name\", dir:1}, detail_panel_height:height, table_columns_json:JSON.stringify({hidden:cleanColumnPrefsHidden(DEFAULT_HIDDEN_COLUMNS), shown:[], mobile:mobileColumns, mobileSmartFiltersEnabled:true, widths:columnWidths})});\n toast('View preferences reset','success');\n }catch(e){ toast(e.message,'danger'); }\n scheduleRender(true);\n }\n function applyDetailPanelHeight(height){\n const safeHeight = clampNumber(height, 160, 720, 255);\n document.documentElement.style.setProperty('--detail-panel-height', `${safeHeight}px`);\n const handle = $('detailResizeHandle');\n if(handle) handle.setAttribute('aria-valuenow', String(safeHeight));\n return safeHeight;\n }\n function saveDetailPanelHeight(height){\n const safeHeight = applyDetailPanelHeight(height);\n savePreferencePatch({detail_panel_height:safeHeight}, 250);\n }\n function setupDetailResizer(){\n const handle = $('detailResizeHandle');\n const content = document.querySelector('.content');\n if(!handle || !content) return;\n applyDetailPanelHeight(window.PYTORRENT?.detailPanelHeight || 255);\n let startY = 0, startHeight = 0;\n const onMove = (event) => {\n const pointerY = event.clientY ?? event.touches?.[0]?.clientY ?? startY;\n applyDetailPanelHeight(startHeight - (pointerY - startY));\n scheduleRender(false);\n };\n const onUp = () => {\n document.body.classList.remove('resizing-details');\n document.removeEventListener('pointermove', onMove);\n document.removeEventListener('pointerup', onUp);\n const value = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--detail-panel-height'), 10);\n saveDetailPanelHeight(value);\n };\n handle.addEventListener('pointerdown', (event) => {\n event.preventDefault();\n startY = event.clientY;\n startHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--detail-panel-height'), 10) || 255;\n document.body.classList.add('resizing-details');\n document.addEventListener('pointermove', onMove);\n document.addEventListener('pointerup', onUp);\n });\n }\n function toastKey(msg, type){ return `${type}::${String(msg ?? '')}`; }\n function isAutomationEvent(msg){ return msg?.automation === true || msg?.source === 'automation'; }\n function shouldShowOperationToast(msg){\n // Note: Automation-created operation toasts follow the Automation toasts preference.\n return !isAutomationEvent(msg) || automationToastsEnabled;\n }\n function toast(msg, type=\"secondary\") {\n // Note: Groups identical toasts fired together, so repeated automation/action events do not flood the UI.\n const h=$('toastHost');\n if(!h) return;\n const text=String(msg ?? '');\n const key=toastKey(text,type);\n const existing=toastGroups.get(key);\n if(existing){\n existing.count += 1;\n const badge=existing.el.querySelector('.toast-count');\n if(badge){ badge.innerHTML=`${esc(existing.count)}`; badge.classList.remove('d-none'); }\n clearTimeout(existing.timer);\n existing.timer=setTimeout(()=>{ existing.el.remove(); toastGroups.delete(key); },3500);\n return;\n }\n const el=document.createElement('div');\n el.className=`toast-item text-bg-${type}`;\n el.innerHTML=`${esc(text)}1`;\n h.appendChild(el);\n const entry={el,count:1,timer:null};\n entry.timer=setTimeout(()=>{ el.remove(); toastGroups.delete(key); },3500);\n toastGroups.set(key,entry);\n }\n function setBusy(on, label='Working...'){ pendingBusy += on ? 1 : -1; if(pendingBusy<0) pendingBusy=0; const loader=$('globalLoader'); if(loader){ loader.classList.toggle('d-none', pendingBusy===0); const span=loader.querySelector('span:last-child'); if(span) span.textContent=label; } $('busyBadge')?.classList.toggle('d-none', pendingBusy===0); }\n function isEasterEggReady(kind='click'){\n if(!easterEggEnabled) return false;\n return kind === 'loading' ? !!easterEggLoadingImageUrl : !!easterEggClickImageUrl;\n }\n function applyInitialLoaderEasterEgg(){\n const box = $('initialLoaderSpinner');\n if(!box) return;\n const fallback = '';\n box.classList.remove('initial-loader-prank');\n if(!isEasterEggReady('loading')){\n if(!box.querySelector('.spinner-border')) box.innerHTML = fallback;\n return;\n }\n const img = new Image();\n img.className = 'initial-loader-easter-egg-image';\n img.alt = 'Loading';\n img.loading = 'eager';\n img.onload = () => { box.classList.add('initial-loader-prank'); box.replaceChildren(img); };\n img.onerror = () => { box.classList.remove('initial-loader-prank'); box.innerHTML = fallback; };\n img.src = easterEggLoadingImageUrl;\n }\n function showPrankClickImage(event){\n const target = event.target?.closest?.('button, .btn, [role=button]');\n if(!target || target.disabled || event.defaultPrevented || event.button !== 0) return;\n if(!isEasterEggReady('click')) return;\n if(Math.random() > 0.14) return;\n const img = document.createElement('img');\n img.className = 'prank-click-image';\n img.src = easterEggClickImageUrl;\n img.alt = '';\n img.setAttribute('aria-hidden', 'true');\n const rect = target.getBoundingClientRect();\n const x = event.clientX || (rect.left + rect.width / 2);\n const y = event.clientY || (rect.top + rect.height / 2);\n img.style.left = `${Math.max(90, Math.min(window.innerWidth - 90, x))}px`;\n img.style.top = `${Math.max(90, Math.min(window.innerHeight - 90, y))}px`;\n document.body.appendChild(img);\n setTimeout(() => img.remove(), 1300);\n }\n document.addEventListener('click', showPrankClickImage, true);\n applyInitialLoaderEasterEgg();\n function setInitialLoader(title, text){ if(initialLoaderDone) return; if($('initialLoaderTitle') && title) $('initialLoaderTitle').textContent=title; if($('initialLoaderText') && text) $('initialLoaderText').textContent=text; }\n function hideInitialLoader(){ if(initialLoaderDone) return; initialLoaderDone=true; $('initialLoader')?.classList.add('is-hidden'); }\n function buttonBusy(btn,on){ if(!btn)return; btn.disabled=on; const label=btn.querySelector('.btn-label'); if(label){ if(!label.dataset.orig) label.dataset.orig=label.innerHTML; label.innerHTML=on?`Working...`:label.dataset.orig; }}\n function activeTab(){ return document.querySelector('#detailTabs .nav-link.active')?.dataset.tab || 'general'; }\n function loadingMarkup(label='Loading data...'){ return `
${esc(label)}
`; }\n // Note: Keeps empty-state colspans aligned with the desktop torrent table column count.\n function torrentColumnSpan(){ return 26; }\n function loadingTableRow(label='Loading torrents...'){ return `${loadingMarkup(label)}`; }\n // Note: Handles fresh installations with no configured rTorrent profile, so the UI does not wait forever for a snapshot.\n function renderNoProfileState(){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $('torrentBody');\n if(body){\n body.innerHTML = `
No rTorrent profile configured.Add the first rTorrent profile to start loading torrents.
`;\n }\n if($('detailPane')) $('detailPane').innerHTML = 'Add rTorrent profile first.';\n }\n function clearRtorrentStartingState(){\n rtorrentStartingMessage='';\n rtorrentStartingSince=0;\n if(rtorrentStartingTimer){ clearTimeout(rtorrentStartingTimer); rtorrentStartingTimer=null; }\n }\n function rtorrentStartingHtml(error=''){\n const details=error ? `${esc(error)}` : 'Port can already be open while XML-RPC/SCGI is still warming up. The list will load automatically after rTorrent answers.';\n return `
rTorrent is starting or not responding yet.Waiting for torrent data from the active profile.${details}
`;\n }\n function scheduleRtorrentStartingState(error=''){\n rtorrentStartingMessage = String(error || 'rTorrent is starting or not responding yet.');\n if(!(hasTorrentSnapshot && torrents.size)){\n renderRtorrentStartingState(rtorrentStartingMessage, true);\n return;\n }\n if(!rtorrentStartingSince) rtorrentStartingSince = Date.now();\n if(rtorrentStartingTimer) return;\n rtorrentStartingTimer = setTimeout(() => {\n rtorrentStartingTimer = null;\n if(rtorrentStartingMessage) renderRtorrentStartingState(rtorrentStartingMessage, true);\n }, RTORRENT_STALE_GRACE_MS);\n }\n function renderRtorrentStartingState(error='', force=false){\n rtorrentStartingMessage = String(error || 'rTorrent is starting or not responding yet.');\n if(hasTorrentSnapshot && torrents.size && !force) return;\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body=$('torrentBody');\n if(body) body.innerHTML = `${rtorrentStartingHtml(rtorrentStartingMessage)}`;\n const list=$('mobileList');\n if(list) list.innerHTML = `
${rtorrentStartingHtml(rtorrentStartingMessage)}
`;\n if($('detailPane')) $('detailPane').innerHTML = 'rTorrent is starting. Details will appear after the first successful response.';\n }\n function parseDate(value){ const raw=String(value||'').trim(); if(!raw) return null; const d=new Date(raw); return Number.isNaN(d.getTime()) ? null : {raw,d}; }\n function formatDate(value, mode='short'){\n const parsed=parseDate(value);\n if(!parsed) return String(value||'');\n const opts=mode==='full'\n ? {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'}\n : {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'};\n return new Intl.DateTimeFormat('pl-PL', opts).format(parsed.d).replace(',', '');\n }\n function dateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); return `${esc(formatDate(value))}`; }\n // Note: Human-readable date cells keep full timestamps visible without squeezing table columns.\n function humanDateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); const full=formatDate(value,'full'); return `${esc(full)}`; }\n function compactCell(value, max=120){ const text=String(value||\"\"); if(!text) return \"\"; const short=text.length>max ? `${text.slice(0, Math.floor(max*0.62))}...${text.slice(-Math.floor(max*0.28))}` : text; return `${esc(short)}`; }\n function incompleteProgressColor(pct){\n if(pct <= 0) return 'transparent';\n const safePct = Math.max(0, Math.min(99.99, Number(pct || 0)));\n const rootStyle = getComputedStyle(document.documentElement);\n const hue = Math.round((safePct / 100) * 120);\n const saturation = Number.parseFloat(rootStyle.getPropertyValue('--torrent-progress-scale-saturation')) || 42;\n const baseLight = Number.parseFloat(rootStyle.getPropertyValue('--torrent-progress-scale-lightness-base')) || 29;\n const rangeLight = Number.parseFloat(rootStyle.getPropertyValue('--torrent-progress-scale-lightness-range')) || 4;\n const light = baseLight + Math.round((safePct / 100) * rangeLight);\n const base = `hsl(${hue} ${saturation}% ${light}%)`;\n const mix = Math.round(42 + (safePct / 100) * 38);\n // Note: Incomplete progress uses theme-aware saturation/lightness so light themes stay a bit more muted without changing the hue scale.\n return `color-mix(in srgb, ${base} ${mix}%, var(--bs-secondary-bg))`;\n }\n function progressBar(value, extraClass=''){\n const pct=Math.max(0,Math.min(100,Number(value||0)));\n const isComplete=pct>=100;\n const bg=isComplete?'var(--torrent-progress-complete)':incompleteProgressColor(pct);\n const done=isComplete?' is-complete':'';\n const cls=extraClass?` ${extraClass}`:'';\n return `
${esc(pct)}%
`;\n }\n function progress(t){ return progressBar(t.progress); }\n";