diff --git a/pytorrent/routes/api.py b/pytorrent/routes/api.py index 66d59fc..4782f05 100644 --- a/pytorrent/routes/api.py +++ b/pytorrent/routes/api.py @@ -62,16 +62,53 @@ def _public_ip(profile: dict | None = None, force: bool = False) -> str: return res.read(64).decode("utf-8", "replace").strip() -def _incoming_port(profile: dict) -> int | None: +MAX_PORT_CHECK_CANDIDATES = 256 + + +def _parse_port_candidates(value: str, limit: int = MAX_PORT_CHECK_CANDIDATES) -> tuple[list[int], bool]: + """Return valid incoming port candidates from rTorrent network.port_range. + + Note: rTorrent may keep a range/list and pick a random port on start. + The old checker used only the first number, which produced false "closed" + results when another configured port was actually active. + """ + ports: list[int] = [] + seen: set[int] = set() + truncated = False + + def add(port: int) -> None: + nonlocal truncated + if not 1 <= port <= 65535 or port in seen: + return + if len(ports) >= limit: + truncated = True + return + seen.add(port) + ports.append(port) + + for start, end in re.findall(r"(\d{1,5})\s*-\s*(\d{1,5})", value or ""): + a, b = int(start), int(end) + if a > b: + a, b = b, a + for port in range(a, b + 1): + add(port) + if truncated: + break + + without_ranges = re.sub(r"\d{1,5}\s*-\s*\d{1,5}", " ", value or "") + for item in re.findall(r"\d{1,5}", without_ranges): + add(int(item)) + + return ports, truncated + + +def _incoming_ports(profile: dict) -> dict: try: - value = str(rtorrent.client_for(profile).call("network.port_range") or "") + raw_value = str(rtorrent.client_for(profile).call("network.port_range") or "") except Exception: - value = "" - match = re.search(r"(\d{2,5})", value) - if not match: - return None - port = int(match.group(1)) - return port if 1 <= port <= 65535 else None + raw_value = "" + ports, truncated = _parse_port_candidates(raw_value) + return {"ports": ports, "raw": raw_value, "truncated": truncated} def _yougetsignal_check(public_ip: str, port: int) -> dict: @@ -104,16 +141,40 @@ def _local_port_fallback(public_ip: str, port: int) -> dict: return {"status": "unknown", "source": "local-fallback", "error": f"Local fallback inconclusive: {exc}"} +def _check_ports(public_ip: str, ports: list[int], checker) -> dict: + checked: list[int] = [] + first_closed: dict | None = None + last_result: dict = {"status": "unknown"} + + for port in ports: + checked.append(port) + current = checker(public_ip, port) + last_result = current + if current.get("status") == "open": + current.update({"port": port, "open_port": port, "checked_ports": checked}) + return current + if current.get("status") == "closed" and first_closed is None: + first_closed = current + + result = first_closed or last_result + result.update({"port": ports[0] if ports else None, "open_port": None, "checked_ports": checked}) + return result + + def port_check_status(force: bool = False) -> dict: profile = preferences.active_profile() prefs = preferences.get_preferences() enabled = bool((prefs or {}).get("port_check_enabled")) if not profile: return {"status": "unknown", "enabled": enabled, "error": "No profile"} - port = _incoming_port(profile) - if not port: + + port_info = _incoming_ports(profile) + ports = port_info["ports"] + if not ports: return {"status": "unknown", "enabled": enabled, "error": "Cannot read rTorrent network.port_range"} - cache_key = f"port_check:{profile['id']}:{port}" + + ports_key = ",".join(str(port) for port in ports) + cache_key = f"port_check:{profile['id']}:{ports_key}:{int(bool(port_info['truncated']))}" if not force: cached = _app_setting_get(cache_key) if cached: @@ -127,20 +188,31 @@ def port_check_status(force: bool = False) -> dict: return data except Exception: pass + checked_at_epoch = time.time() - result = {"status": "unknown", "enabled": enabled, "port": port, "checked_at_epoch": checked_at_epoch, "checked_at": _iso_from_epoch(checked_at_epoch), "cached": False} + result = { + "status": "unknown", + "enabled": enabled, + "port": ports[0], + "ports": ports, + "port_range": port_info["raw"], + "ports_truncated": port_info["truncated"], + "checked_at_epoch": checked_at_epoch, + "checked_at": _iso_from_epoch(checked_at_epoch), + "cached": False, + } try: public_ip = _public_ip(profile, force=force) result["public_ip"] = public_ip result["remote"] = bool(profile.get("is_remote")) - result.update(_yougetsignal_check(public_ip, port)) + result.update(_check_ports(public_ip, ports, _yougetsignal_check)) except Exception as exc: result["error"] = f"YouGetSignal failed: {exc}" try: public_ip = result.get("public_ip") or _public_ip(profile, force=force) result["public_ip"] = public_ip result["remote"] = bool(profile.get("is_remote")) - result.update(_local_port_fallback(public_ip, port)) + result.update(_check_ports(public_ip, ports, _local_port_fallback)) except Exception as fallback_exc: result["fallback_error"] = str(fallback_exc) result["source"] = "none" diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js index 4256bd0..67d6840 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -299,7 +299,7 @@ async function browsePath(path){ $('pathList').innerHTML=' Loading...'; try{ const res=await fetch(`/api/path/browse?path=${encodeURIComponent(path||'/')}`); const j=await res.json(); if(!j.ok) throw new Error(j.error); $('pathCurrent').value=j.path; lastPathParent=j.parent; $('pathList').innerHTML=j.dirs.map(d=>`
${esc(d.name)}
`).join('')||'
No directories.
'; }catch(e){$('pathList').innerHTML=`
${esc(e.message)}
`;} } $('pathList')?.addEventListener('click',e=>{const r=e.target.closest('.path-row'); if(r) browsePath(r.dataset.path);}); $('pathGoBtn')?.addEventListener('click',()=>browsePath($('pathCurrent').value)); $('pathUpBtn')?.addEventListener('click',()=>browsePath(lastPathParent)); $('pathReloadBtn')?.addEventListener('click',()=>browsePath($('pathCurrent').value)); $('pathSelectBtn')?.addEventListener('click',async()=>{const p=$('pathCurrent').value; if(pathTarget==='move'){ const hashes=selectedHashes(); const j=await post('/api/torrents/move',{hashes,path:p,move_data:!!($('moveDataPhysical')?.checked),recheck:!!($('moveRecheck')?.checked)}); markTorrentOperation(hashes,'move',j.job_id,'queued'); toast($('moveDataPhysical')?.checked?'physical move queued':'move queued','success'); } else if($(pathTarget)) $(pathTarget).value=p; bootstrap.Modal.getInstance($('pathModal'))?.hide();}); document.querySelectorAll('.browse-path').forEach(b=>b.addEventListener('click',()=>openPathPicker(b.dataset.target))); - function renderColumnManager(){ const box=$('columnManager'); if(!box) return; box.innerHTML=COLUMN_DEFS.map(([key,label])=>``).join(''); } + function renderColumnManager(){ const box=$('columnManager'); if(!box) return; box.innerHTML=COLUMN_DEFS.map(([key,label])=>``).join(''); } $('saveColumnsBtn')?.addEventListener('click',async()=>{ document.querySelectorAll('.column-toggle').forEach(cb=>cb.checked?hiddenColumns.delete(cb.dataset.colKey):hiddenColumns.add(cb.dataset.colKey)); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:JSON.stringify({hidden:[...hiddenColumns]})}).catch(e=>toast(e.message,'danger')); toast('Columns saved','success'); }); $('resetColumnsBtn')?.addEventListener('click',async()=>{ hiddenColumns.clear(); renderColumnManager(); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:JSON.stringify({hidden:[]})}).catch(()=>{}); }); @@ -336,7 +336,9 @@ return raw; } function rtConfigInputValue(input){ - return normalizeRtConfigValue(input.value, input.dataset.type || rtConfigFieldTypes.get(input.dataset.key) || 'text'); + const type=input.dataset.type || rtConfigFieldTypes.get(input.dataset.key) || 'text'; + const value=type==='bool' && input.type==='checkbox' ? (input.checked?'1':'0') : input.value; + return normalizeRtConfigValue(value, type); } function rtConfigOriginalValue(input){ const key=input.dataset.key; @@ -402,7 +404,7 @@ const valueNote=f.saved?`Reference: ${esc(originalValue)} → saved: ${esc(displayValue)}`:''; const originalAttr=esc(originalValue); const input=type==='bool' - ? `` + ? `${displayValue==='1'?'On':'Off'}` : ``; return `${head}`; }).join(''); @@ -491,9 +493,9 @@ function portStatusLabel(st){ return st==='open'?'open':st==='closed'?'closed':st==='disabled'?'disabled':st==='error'?'error':'unknown'; } function portStatusClass(st){ return st==='open'?'port-ok':st==='closed'?'port-bad':'port-secondary'; } function portStatusIcon(st){ return st==='open'?'fa-circle-check':st==='closed'?'fa-circle-xmark':'fa-circle-question'; } - function portStatusBadge(data={},attrs='',withPort=false){ const st=portStatusLabel(data.status); const port=data.port?String(data.port):'-'; const label=withPort?`Port ${port} ${st}`:st; return ` ${esc(label)}`; } + function portStatusBadge(data={},attrs='',withPort=false){ const st=portStatusLabel(data.status); const active=data.open_port||data.port; const port=active?String(active):'-'; const label=withPort?`Port ${port} ${st}`:st; return ` ${esc(label)}`; } function portCheckedAt(data={}){ if(data.checked_at) return String(data.checked_at).replace('T',' ').replace(/\+00:00$/,' UTC'); if(data.checked_at_epoch) return new Date(Number(data.checked_at_epoch)*1000).toLocaleString(); return ''; } - function portCheckDetails(data={}){ const bits=[]; if(data.port) bits.push(`Port: ${data.port}`); if(data.public_ip) bits.push(`Public IP: ${data.public_ip}`); if(data.remote) bits.push('Remote profile'); if(data.source) bits.push(`Source: ${data.source}`); const checked=portCheckedAt(data); if(checked) bits.push(`Last check: ${checked}`); if(data.cached) bits.push('Cached result'); if(data.error) bits.push(data.error); if(data.fallback_error) bits.push(data.fallback_error); return bits; } + function portCheckDetails(data={}){ const bits=[]; if(data.open_port) bits.push(`Open port: ${data.open_port}`); else if(data.port) bits.push(`First port: ${data.port}`); if(Array.isArray(data.ports)&&data.ports.length>1) bits.push(`Candidates: ${data.ports.join(', ')}`); if(Array.isArray(data.checked_ports)&&data.checked_ports.length) bits.push(`Checked: ${data.checked_ports.join(', ')}`); if(data.ports_truncated) bits.push('Port list truncated to safety limit'); if(data.public_ip) bits.push(`Public IP: ${data.public_ip}`); if(data.remote) bits.push('Remote profile'); if(data.source) bits.push(`Source: ${data.source}`); const checked=portCheckedAt(data); if(checked) bits.push(`Last check: ${checked}`); if(data.cached) bits.push('Cached result'); if(data.error) bits.push(data.error); if(data.fallback_error) bits.push(data.fallback_error); return bits; } function renderPortCheck(data={}){ if($('portCheckEnabled')) $('portCheckEnabled').checked=!!data.enabled; const details=portCheckDetails(data); @@ -531,7 +533,7 @@ }catch(e){ box.innerHTML=`
${esc(e.message)}
`; } } - $('toolsModal')?.addEventListener('show.bs.modal',()=>{refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadAppStatus();loadPreferences();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',preferences:'toolPreferences',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',appstatus:'toolAppstatus'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='preferences') loadPreferences();}; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); $('rssFeedBtn')?.addEventListener('click',async()=>{await post('/api/rss/feeds',{name:$('rssName').value,url:$('rssUrl').value}); loadRss();}); $('rssRuleBtn')?.addEventListener('click',async()=>{await post('/api/rss/rules',{name:$('rssRuleName').value,pattern:$('rssPattern').value,save_path:$('rssPath').value,label:$('rssLabel').value}); loadRss();}); $('rssCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/rss/check',{}); toast(`RSS queued ${j.queued} item(s)`,'success');}catch(e){toast(e.message,'danger');} finally{setBusy(false);}}); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); const r=j.result||{}; toast(`Smart Queue: paused ${r.paused?.length||0}, resumed ${r.resumed?.length||0}`,'success'); await loadSmartQueue();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('smartManager')?.addEventListener('click',async e=>{const h=e.target.closest('.smart-unexclude')?.dataset.hash; if(!h)return; await post('/api/smart-queue/exclusion',{hash:h,excluded:false}); await loadSmartQueue();}); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job and Smart Queue logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); $('peersRefreshSelect')?.addEventListener('change',async e=>{peersRefreshSeconds=Number(e.target.value||0); await post('/api/preferences',{peers_refresh_seconds:peersRefreshSeconds}).catch(()=>{}); setupPeersRefresh(activeTab()); toast('Peers refresh preference saved','success');}); + $('toolsModal')?.addEventListener('show.bs.modal',()=>{refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadAppStatus();loadPreferences();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',preferences:'toolPreferences',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',appstatus:'toolAppstatus'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='preferences') loadPreferences();}; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); $('rssFeedBtn')?.addEventListener('click',async()=>{await post('/api/rss/feeds',{name:$('rssName').value,url:$('rssUrl').value}); loadRss();}); $('rssRuleBtn')?.addEventListener('click',async()=>{await post('/api/rss/rules',{name:$('rssRuleName').value,pattern:$('rssPattern').value,save_path:$('rssPath').value,label:$('rssLabel').value}); loadRss();}); $('rssCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/rss/check',{}); toast(`RSS queued ${j.queued} item(s)`,'success');}catch(e){toast(e.message,'danger');} finally{setBusy(false);}}); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); const r=j.result||{}; toast(`Smart Queue: paused ${r.paused?.length||0}, resumed ${r.resumed?.length||0}`,'success'); await loadSmartQueue();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('smartManager')?.addEventListener('click',async e=>{const h=e.target.closest('.smart-unexclude')?.dataset.hash; if(!h)return; await post('/api/smart-queue/exclusion',{hash:h,excluded:false}); await loadSmartQueue();}); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job and Smart Queue logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')){ const label=e.target.closest('.rt-config-switch')?.querySelector('.form-check-label'); if(label) label.textContent=e.target.checked?'On':'Off'; updateRtConfigDirty(); } }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); $('peersRefreshSelect')?.addEventListener('change',async e=>{peersRefreshSeconds=Number(e.target.value||0); await post('/api/preferences',{peers_refresh_seconds:peersRefreshSeconds}).catch(()=>{}); setupPeersRefresh(activeTab()); toast('Peers refresh preference saved','success');}); $('autoConditionType')?.addEventListener('change',updateAutomationForm); $('autoEffectType')?.addEventListener('change',updateAutomationForm); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); toast(`Automations applied ${j.result?.applied?.length||0} item(s)`,'success'); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const id=e.target.closest('.automation-delete')?.dataset.id;if(!id)return;if(!confirm('Delete this automation rule?'))return;const r=await fetch('/api/automations/'+id,{method:'DELETE'});const j=await r.json();if(!j.ok)toast(j.error||'Delete failed','danger');await loadAutomations();}); document.addEventListener('click',async e=>{ const btn=e.target.closest('.delete-label'); if(!btn)return; if(!confirm('Delete this label?')) return; setBusy(true); try{ const r=await fetch('/api/labels/'+btn.dataset.id,{method:'DELETE'}); const j=await r.json(); if(!j.ok) throw new Error(j.error||'Delete failed'); await loadLabels(); toast('Label deleted','success'); }catch(err){toast(err.message,'danger');} finally{setBusy(false);} }); $('bulkClearBtn')?.addEventListener('click',()=>{selected.clear(); selectedHash=null; lastSelectedHash=null; updateBulkBar(); if($('selectAll')) $('selectAll').checked=false; if($('detailPane')) $('detailPane').innerHTML='Select a torrent.'; setupPeersRefresh('general'); scheduleRender(true);}); diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index c7a017c..9ef82e6 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -389,11 +389,53 @@ body.mobile-mode .torrent-table { display: none; } .column-check{padding:.35rem .5rem;border:1px solid var(--bs-border-color);border-radius:.5rem;background:var(--bs-body-bg)} .label-filters .label-filter{font-size:.82rem;padding:.34rem .5rem;margin-bottom:.15rem} .label-filters .label-filter i{opacity:.75;margin-right:.25rem} -.column-manager{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:.55rem} -.column-card{display:flex;align-items:center;gap:.5rem;padding:.55rem .65rem;border:1px solid var(--bs-border-color);border-radius:.7rem;background:rgba(var(--bs-secondary-bg-rgb),.45);cursor:pointer;user-select:none;transition:background .15s,border-color .15s,transform .15s} -.column-card:hover{border-color:var(--bs-primary);background:var(--bs-primary-bg-subtle)} -.column-card.active{border-color:rgba(var(--bs-primary-rgb),.55);background:var(--bs-primary-bg-subtle)} -.column-card input{margin:0}.column-card span{display:flex;gap:.45rem;align-items:center;font-weight:600}.column-card i{opacity:.72} +.column-manager { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + gap: .55rem; +} + +.column-card { + display: flex; + align-items: center; + gap: .55rem; + margin: 0; + padding: .55rem .65rem; + border: 1px solid var(--bs-border-color); + border-radius: .7rem; + background: rgba(var(--bs-secondary-bg-rgb), .45); + cursor: pointer; + user-select: none; + transition: background .15s, border-color .15s, transform .15s; +} + +.column-card:hover, +.column-card.active { + background: var(--bs-primary-bg-subtle); +} + +.column-card:hover { + border-color: var(--bs-primary); +} + +.column-card.active { + border-color: rgba(var(--bs-primary-rgb), .55); +} + +.column-card .form-check-input { + margin: 0; +} + +.column-card .form-check-label { + display: flex; + align-items: center; + gap: .45rem; + font-weight: 600; +} + +.column-card i { + opacity: .72; +} .path-row::before{content:'\f07b';font-family:'Font Awesome 6 Free';font-weight:900;color:var(--bs-warning)} body.mobile-mode #mobileList{min-height:0;height:100%;overflow:auto;display:block!important} body.mobile-mode .mobile-card{display:block}.mobile-card .mobile-actions button{min-width:34px} @@ -643,6 +685,22 @@ body.mobile-mode #mobileList { padding-top: 5.2rem !important; } background: rgba(var(--bs-secondary-bg-rgb), .35); } +.rt-config-switch { + justify-self: end; + margin: 0; +} + +.rt-config-switch .form-check-input { + margin-top: 0; +} + +.rt-config-switch .form-check-label { + min-width: 2rem; + color: var(--bs-secondary-color); + font-size: .78rem; + font-weight: 700; +} + .rt-config-row b { font-size: .88rem; } diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index 11ba711..7e818fd 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -131,21 +131,21 @@ - + - + - + - +