@@ -34,7 +34,31 @@
// Note: Keeps live filter tooltips stable while the pointer is over a filter button.
// Note: Keeps live filter tooltips stable while the pointer is over a filter button.
const filterTooltipState = new WeakMap ( ) ;
const filterTooltipState = new WeakMap ( ) ;
function toast ( msg , type = "secondary" ) { const h = $ ( 'toastHost' ) ; if ( ! h ) return ; const el = document . createElement ( 'div' ) ; el . className = ` toast-item text-bg- ${ type } ` ; el . innerHTML = esc ( msg ) ; h . appendChild ( el ) ; setTimeout ( ( ) => el . remove ( ) , 3500 ) ; }
const toastGroups = new Map ( ) ;
function toastKey ( msg , type ) { return ` ${ type } :: ${ String ( msg ? ? '' ) } ` ; }
function toast ( msg , type = "secondary" ) {
// Note: Groups identical toasts fired together, so repeated automation/action events do not flood the UI.
const h = $ ( 'toastHost' ) ;
if ( ! h ) return ;
const text = String ( msg ? ? '' ) ;
const key = toastKey ( text , type ) ;
const existing = toastGroups . get ( key ) ;
if ( existing ) {
existing . count += 1 ;
const badge = existing . el . querySelector ( '.toast-count' ) ;
if ( badge ) { badge . textContent = ` × ${ existing . count } ` ; badge . classList . remove ( 'd-none' ) ; }
clearTimeout ( existing . timer ) ;
existing . timer = setTimeout ( ( ) => { existing . el . remove ( ) ; toastGroups . delete ( key ) ; } , 3500 ) ;
return ;
}
const el = document . createElement ( 'div' ) ;
el . className = ` toast-item text-bg- ${ type } ` ;
el . innerHTML = ` <span class="toast-message"> ${ esc ( text ) } </span><span class="toast-count d-none">× 1</span> ` ;
h . appendChild ( el ) ;
const entry = { el , count : 1 , timer : null } ;
entry . timer = setTimeout ( ( ) => { el . remove ( ) ; toastGroups . delete ( key ) ; } , 3500 ) ;
toastGroups . set ( key , entry ) ;
}
function setBusy ( on ) { pendingBusy += on ? 1 : - 1 ; if ( pendingBusy < 0 ) pendingBusy = 0 ; $ ( 'globalLoader' ) ? . classList . toggle ( 'd-none' , pendingBusy === 0 ) ; $ ( 'busyBadge' ) ? . classList . toggle ( 'd-none' , pendingBusy === 0 ) ; }
function setBusy ( on ) { pendingBusy += on ? 1 : - 1 ; if ( pendingBusy < 0 ) pendingBusy = 0 ; $ ( 'globalLoader' ) ? . classList . toggle ( 'd-none' , pendingBusy === 0 ) ; $ ( 'busyBadge' ) ? . classList . toggle ( 'd-none' , pendingBusy === 0 ) ; }
function setInitialLoader ( title , text ) { if ( initialLoaderDone ) return ; if ( $ ( 'initialLoaderTitle' ) && title ) $ ( 'initialLoaderTitle' ) . textContent = title ; if ( $ ( 'initialLoaderText' ) && text ) $ ( 'initialLoaderText' ) . textContent = text ; }
function setInitialLoader ( title , text ) { if ( initialLoaderDone ) return ; if ( $ ( 'initialLoaderTitle' ) && title ) $ ( 'initialLoaderTitle' ) . textContent = title ; if ( $ ( 'initialLoaderText' ) && text ) $ ( 'initialLoaderText' ) . textContent = text ; }
function hideInitialLoader ( ) { if ( initialLoaderDone ) return ; initialLoaderDone = true ; $ ( 'initialLoader' ) ? . classList . add ( 'is-hidden' ) ; }
function hideInitialLoader ( ) { if ( initialLoaderDone ) return ; initialLoaderDone = true ; $ ( 'initialLoader' ) ? . classList . add ( 'is-hidden' ) ; }
@@ -274,14 +298,17 @@
function fmtTs ( value ) { const n = Number ( value || 0 ) ; if ( ! n ) return '-' ; try { return new Date ( n * 1000 ) . toLocaleString ( ) ; } catch ( e ) { return String ( n ) ; } }
function fmtTs ( value ) { const n = Number ( value || 0 ) ; if ( ! n ) return '-' ; try { return new Date ( n * 1000 ) . toLocaleString ( ) ; } catch ( e ) { return String ( n ) ; } }
function trackerSeedsPeers ( t ) { const hasScrape = t . seeds !== null || t . peers !== null ; return hasScrape ? ` ${ t . seeds ? ? "-" } / ${ t . peers ? ? "-" } ` : "-" ; }
function trackerSeedsPeers ( t ) { const hasScrape = t . seeds !== null || t . peers !== null ; return hasScrape ? ` ${ t . seeds ? ? "-" } / ${ t . peers ? ? "-" } ` : "-" ; }
function renderTrackers ( trackers ) {
function renderTrackers ( trackers ) {
// Note: Tracker URL editing is intentionally replaced by safe deletion; adding trackers remains unchanged.
const pane = $ ( 'detailPane' ) ;
const pane = $ ( 'detailPane' ) ;
const rows = ( trackers || [ ] ) . map ( t => {
const list = trackers || [ ] ;
const canDelete = list . length > 1 ;
const rows = list . map ( t => {
const idx = esc ( t . index ) , url = esc ( t . url ) ;
const idx = esc ( t . index ) , url = esc ( t . url ) ;
return [ ` <span class="text-muted"># ${ idx } </span> ` , ` <div class="tracker-url-view" data-tracker-index=" ${ idx } "><span class="tracker-url-text"> ${ url || '<span class="text-muted">-</span>' } </span></div><div class="tracker-url-edit d-none" data-tracker-index=" ${ idx } "><input class="form-control form-control-sm tracker-url" data-tracker-index=" ${ idx } " value=" ${ url } "></div> ` , t . enabled ? 'yes' : 'no' , esc ( trackerSeedsPeers ( t ) ) , esc ( t . downloaded ? ? '-' ) , fmtTs ( t . last _announce ) , ` <div class="tracker-actions"><button class="btn btn-xs btn-outline-secondary tracker-edit-start" data-index=" ${ idx } "><i class="fa-solid fa-pen"></i> Edit</button><button class="btn btn-xs btn-outline-primary tracker-edit-save d-none" data-index=" ${ idx } "><i class="fa-solid fa-floppy-disk"></i> Save</button><button class="btn btn-xs btn-outline-secondary tracker-edit-cancel d-none" data-index=" ${ idx } "><i class="fa-solid fa-xmark"></i> Cancel</button></div> ` ] ;
const deleteDisabled = canDelete ? '' : ' disabled title="At least one tracker must remain"' ;
return [ ` <span class="text-muted"># ${ idx } </span> ` , ` <span class="tracker-url-text"> ${ url || '<span class="text-muted">-</span>' } </span> ` , t . enabled ? 'yes' : 'no' , esc ( trackerSeedsPeers ( t ) ) , esc ( t . downloaded ? ? '-' ) , fmtTs ( t . last _announce ) , ` <div class="tracker-actions"><button class="btn btn-xs btn-outline-danger tracker-delete" data-index=" ${ idx } " ${ deleteDisabled } ><i class="fa-solid fa-trash"></i> Delete</button></div> ` ] ;
} ) ;
} ) ;
pane . innerHTML = ` <div class="tracker-toolbar"><div class="input-group input-group-sm"><input id="trackerAddUrl" class="form-control" placeholder="https://tracker.example/announce"><button id="trackerAddBtn" class="btn btn-outline-primary"><i class="fa-solid fa-plus"></i> Add tracker</button></div><button id="trackerReannounceBtn" class="btn btn-sm btn-outline-primary"><i class="fa-solid fa-bullhorn"></i> Reannounce</button></div> ${ table ( [ '#' , 'URL' , 'On' , 'Seeds / Peers' , 'Done' , 'Last announce' , 'Actions' ] , rows . length ? rows : [ [ '<span class="text-muted">-</span>' , '<span class="text-muted">No trackers.</span>' , '' , '' , '' , '' , '' ] ] ) } ` ;
pane . innerHTML = ` <div class="tracker-toolbar"><div class="input-group input-group-sm"><input id="trackerAddUrl" class="form-control tracker-add-input " placeholder="https://tracker.example/announce"><button id="trackerAddBtn" class="btn btn-outline-primary"><i class="fa-solid fa-plus"></i> Add tracker</button></div><button id="trackerReannounceBtn" class="btn btn-sm btn-outline-primary"><i class="fa-solid fa-bullhorn"></i> Reannounce</button></div> ${ table ( [ '#' , 'URL' , 'On' , 'Seeds / Peers' , 'Done' , 'Last announce' , 'Actions' ] , rows . length ? rows : [ [ '<span class="text-muted">-</span>' , '<span class="text-muted">No trackers.</span>' , '' , '' , '' , '' , '' ] ] ) } ` ;
}
}
function setTrackerEdit ( index , on ) { const sel = String ( index ) ; document . querySelector ( ` .tracker-url-view[data-tracker-index=" ${ CSS . escape ( sel ) } "] ` ) ? . classList . toggle ( 'd-none' , on ) ; document . querySelector ( ` .tracker-url-edit[data-tracker-index=" ${ CSS . escape ( sel ) } "] ` ) ? . classList . toggle ( 'd-none' , ! on ) ; document . querySelector ( ` .tracker-edit-start[data-index=" ${ CSS . escape ( sel ) } "] ` ) ? . classList . toggle ( 'd-none' , on ) ; document . querySelector ( ` .tracker-edit-save[data-index=" ${ CSS . escape ( sel ) } "] ` ) ? . classList . toggle ( 'd-none' , ! on ) ; document . querySelector ( ` .tracker-edit-cancel[data-index=" ${ CSS . escape ( sel ) } "] ` ) ? . classList . toggle ( 'd-none' , ! on ) ; }
async function trackerAction ( action , payload = { } ) {
async function trackerAction ( action , payload = { } ) {
if ( ! selectedHash ) return toast ( 'No torrent selected' , 'warning' ) ;
if ( ! selectedHash ) return toast ( 'No torrent selected' , 'warning' ) ;
setBusy ( true ) ;
setBusy ( true ) ;
@@ -870,7 +897,7 @@
function awaitMaybeRun ( action ) { runAction ( action ) . catch ? . ( ( ) => { } ) ; }
function awaitMaybeRun ( action ) { runAction ( action ) . catch ? . ( ( ) => { } ) ; }
document . addEventListener ( 'click' , e => { const ctx = $ ( 'ctxMenu' ) ; if ( ! e . target . closest ( '#ctxMenu' ) ) ctx . style . display = 'none' ; const mobileFilter = e . target . closest ( '#mobileFilterBar .mobile-filter' ) ; if ( mobileFilter ) { document . querySelectorAll ( '.filter' ) . forEach ( x => x . classList . remove ( 'active' ) ) ; document . querySelectorAll ( '.filter' ) . forEach ( x => { if ( x . dataset . filter === mobileFilter . dataset . filter ) x . classList . add ( 'active' ) ; } ) ; activeFilter = mobileFilter . dataset . filter ; if ( $ ( 'tableWrap' ) ) $ ( 'tableWrap' ) . scrollTop = 0 ; if ( $ ( 'mobileList' ) ) $ ( 'mobileList' ) . scrollTop = 0 ; scheduleRender ( true ) ; return ; } const mobileSelectAll = e . target . closest ( '#mobileSelectAll' ) ; if ( mobileSelectAll ) { const all = visibleRows . length > 0 && visibleRows . every ( t => selected . has ( t . hash ) ) ; if ( all ) visibleRows . forEach ( t => selected . delete ( t . hash ) ) ; else visibleRows . forEach ( t => selected . add ( t . hash ) ) ; if ( selected . size === 0 ) { selectedHash = null ; lastSelectedHash = null ; } else { selectedHash = [ ... selected ] [ selected . size - 1 ] ; lastSelectedHash = selectedHash ; } scheduleRender ( true ) ; return ; } const mobileClear = e . target . closest ( '#mobileClearSelection' ) ; if ( mobileClear ) { selected . clear ( ) ; selectedHash = null ; lastSelectedHash = null ; scheduleRender ( true ) ; return ; } const mobileAct = e . target . closest ( '.mobile-card [data-action]' ) ; if ( mobileAct ) { const card0 = mobileAct . closest ( '.mobile-card' ) ; selected . clear ( ) ; selected . add ( card0 . dataset . hash ) ; selectedHash = card0 . dataset . hash ; awaitMaybeRun ( mobileAct . dataset . action ) ; scheduleRender ( true ) ; return ; } const card = e . target . closest ( '.mobile-card' ) ; const tr = e . target . closest ( 'tr[data-hash]' ) ; const row = tr || card ; if ( row ) { const h = row . dataset . hash ; const additive = e . ctrlKey || e . metaKey ; if ( e . shiftKey ) { setSelectionRange ( h , additive ) ; } else if ( e . target . classList . contains ( 'row-check' ) ) { e . target . checked ? selected . add ( h ) : selected . delete ( h ) ; lastSelectedHash = h ; selectedHash = h ; } else { selectedHash = h ; if ( ! additive ) selected . clear ( ) ; selected . add ( h ) ; lastSelectedHash = h ; loadDetails ( activeTab ( ) ) ; } scheduleRender ( true ) ; } const copy = e . target . closest ( '[data-copy]' ) ; if ( copy ) copySelected ( copy . dataset . copy ) ; const smartEx = e . target . closest ( '#smartExcludeCtx' ) ; if ( smartEx ) { selectedHashes ( ) . forEach ( h => post ( '/api/smart-queue/exclusion' , { hash : h , excluded : true , reason : 'manual' } ) . catch ( ( ) => { } ) ) ; toast ( 'Smart Queue exception saved' , 'success' ) ; loadSmartQueue ( ) . catch ( ( ) => { } ) ; } const act = e . target . closest ( '.torrent-action,[data-action]' ) ; if ( act && act . dataset . action && ! act . closest ( '#detailTabs' ) && ! act . closest ( '.mobile-card' ) ) runAction ( act . dataset . action ) ; } ) ;
document . addEventListener ( 'click' , e => { const ctx = $ ( 'ctxMenu' ) ; if ( ! e . target . closest ( '#ctxMenu' ) ) ctx . style . display = 'none' ; const mobileFilter = e . target . closest ( '#mobileFilterBar .mobile-filter' ) ; if ( mobileFilter ) { document . querySelectorAll ( '.filter' ) . forEach ( x => x . classList . remove ( 'active' ) ) ; document . querySelectorAll ( '.filter' ) . forEach ( x => { if ( x . dataset . filter === mobileFilter . dataset . filter ) x . classList . add ( 'active' ) ; } ) ; activeFilter = mobileFilter . dataset . filter ; if ( $ ( 'tableWrap' ) ) $ ( 'tableWrap' ) . scrollTop = 0 ; if ( $ ( 'mobileList' ) ) $ ( 'mobileList' ) . scrollTop = 0 ; scheduleRender ( true ) ; return ; } const mobileSelectAll = e . target . closest ( '#mobileSelectAll' ) ; if ( mobileSelectAll ) { const all = visibleRows . length > 0 && visibleRows . every ( t => selected . has ( t . hash ) ) ; if ( all ) visibleRows . forEach ( t => selected . delete ( t . hash ) ) ; else visibleRows . forEach ( t => selected . add ( t . hash ) ) ; if ( selected . size === 0 ) { selectedHash = null ; lastSelectedHash = null ; } else { selectedHash = [ ... selected ] [ selected . size - 1 ] ; lastSelectedHash = selectedHash ; } scheduleRender ( true ) ; return ; } const mobileClear = e . target . closest ( '#mobileClearSelection' ) ; if ( mobileClear ) { selected . clear ( ) ; selectedHash = null ; lastSelectedHash = null ; scheduleRender ( true ) ; return ; } const mobileAct = e . target . closest ( '.mobile-card [data-action]' ) ; if ( mobileAct ) { const card0 = mobileAct . closest ( '.mobile-card' ) ; selected . clear ( ) ; selected . add ( card0 . dataset . hash ) ; selectedHash = card0 . dataset . hash ; awaitMaybeRun ( mobileAct . dataset . action ) ; scheduleRender ( true ) ; return ; } const card = e . target . closest ( '.mobile-card' ) ; const tr = e . target . closest ( 'tr[data-hash]' ) ; const row = tr || card ; if ( row ) { const h = row . dataset . hash ; const additive = e . ctrlKey || e . metaKey ; if ( e . shiftKey ) { setSelectionRange ( h , additive ) ; } else if ( e . target . classList . contains ( 'row-check' ) ) { e . target . checked ? selected . add ( h ) : selected . delete ( h ) ; lastSelectedHash = h ; selectedHash = h ; } else { selectedHash = h ; if ( ! additive ) selected . clear ( ) ; selected . add ( h ) ; lastSelectedHash = h ; loadDetails ( activeTab ( ) ) ; } scheduleRender ( true ) ; } const copy = e . target . closest ( '[data-copy]' ) ; if ( copy ) copySelected ( copy . dataset . copy ) ; const smartEx = e . target . closest ( '#smartExcludeCtx' ) ; if ( smartEx ) { selectedHashes ( ) . forEach ( h => post ( '/api/smart-queue/exclusion' , { hash : h , excluded : true , reason : 'manual' } ) . catch ( ( ) => { } ) ) ; toast ( 'Smart Queue exception saved' , 'success' ) ; loadSmartQueue ( ) . catch ( ( ) => { } ) ; } const act = e . target . closest ( '.torrent-action,[data-action]' ) ; if ( act && act . dataset . action && ! act . closest ( '#detailTabs' ) && ! act . closest ( '.mobile-card' ) ) runAction ( act . dataset . action ) ; } ) ;
document . addEventListener ( 'contextmenu' , e => { const tr = e . target . closest ( 'tr[data-hash],.mobile-card' ) ; if ( ! tr ) return ; e . preventDefault ( ) ; selectedHash = tr . dataset . hash ; if ( ! selected . has ( selectedHash ) ) { selected . clear ( ) ; selected . add ( selectedHash ) ; scheduleRender ( true ) ; } const m = $ ( 'ctxMenu' ) ; m . style . left = ` ${ e . pageX } px ` ; m . style . top = ` ${ e . pageY } px ` ; m . style . display = 'block' ; } ) ;
document . addEventListener ( 'contextmenu' , e => { const tr = e . target . closest ( 'tr[data-hash],.mobile-card' ) ; if ( ! tr ) return ; e . preventDefault ( ) ; selectedHash = tr . dataset . hash ; if ( ! selected . has ( selectedHash ) ) { selected . clear ( ) ; selected . add ( selectedHash ) ; scheduleRender ( true ) ; } const m = $ ( 'ctxMenu' ) ; m . style . left = ` ${ e . pageX } px ` ; m . style . top = ` ${ e . pageY } px ` ; m . style . display = 'block' ; } ) ;
document . querySelectorAll ( '.torrent-table thead th[data-sort]' ) . forEach ( th => th . addEventListener ( 'click' , ( ) => { const key = th . dataset . sort ; if ( sortState . key === key ) sortState . dir *= - 1 ; else sortState = { key , dir : 1 } ; scheduleRender ( true ) ; } ) ) ; $ ( 'tableWrap' ) ? . addEventListener ( 'scroll' , ( ) => scheduleRender ( false ) , { passive : true } ) ; $ ( 'selectAll' ) ? . addEventListener ( 'change' , e => { selected . clear ( ) ; if ( e . target . checked ) visibleRows . forEach ( t => selected . add ( t . hash ) ) ; scheduleRender ( true ) ; } ) ; $ ( 'searchBox' ) ? . addEventListener ( 'input' , ( ) => { if ( $ ( 'tableWrap' ) ) $ ( 'tableWrap' ) . scrollTop = 0 ; scheduleRender ( true ) ; } ) ; document . querySelectorAll ( '.filter' ) . forEach ( b => b . addEventListener ( 'click' , ( ) => { document . querySelectorAll ( '.filter' ) . forEach ( x => x . classList . remove ( 'active' ) ) ; b . classList . add ( 'active' ) ; activeFilter = b . dataset . filter ; if ( $ ( 'tableWrap' ) ) $ ( 'tableWrap' ) . scrollTop = 0 ; scheduleRender ( true ) ; } ) ) ; document . querySelectorAll ( '#detailTabs .nav-link' ) . forEach ( b => b . addEventListener ( 'click' , ( ) => { document . querySelectorAll ( '#detailTabs .nav-link' ) . forEach ( x => x . classList . remove ( 'active' ) ) ; b . classList . add ( 'active' ) ; loadDetails ( b . dataset . tab ) ; } ) ) ; document . addEventListener ( 'change' , e => { const sel = e . target . closest ( '.file-priority' ) ; if ( sel ) { setFilePriorities ( [ { index : Number ( sel . dataset . index ) , priority : Number ( sel . value ) } ] ) ; return ; } if ( e . target && e . target . id === 'fileSelectAll' ) { document . querySelectorAll ( '#detailPane .file-check' ) . forEach ( cb => cb . checked = e . target . checked ) ; } } ) ; document . addEventListener ( 'click' , e => { const bulk = e . target . closest ( '.file-priority-bulk' ) ; if ( ! bulk ) return ; const priority = Number ( bulk . dataset . priority ) ; const checked = [ ... document . querySelectorAll ( '#detailPane .file-check:checked' ) ] . map ( cb => ( { index : Number ( cb . dataset . index ) , priority } ) ) ; if ( ! checked . length ) return toast ( 'No files selected' , 'warning' ) ; setFilePriorities ( checked ) ; } ) ; document . addEventListener ( 'click' , e => { const add = e . target . closest ( '#trackerAddBtn' ) ; if ( add ) { const url = $ ( 'trackerAddUrl' ) ? . value || '' ; trackerAction ( 'add' , { url } ) ; return ; } const editStart = e . target . closest ( '.tracker-edit-start' ) ; if ( editStart ) { setTrackerEdit ( editStart . dataset . index , true ) ; return ; } const cancel = e . target . closest ( '.tracker-edit-cancel' ) ; if ( cancel ) { setTrackerEdit ( cancel . dataset . index , false ) ; return ; } const save = e . target . closest ( '.tracker-edit-save' ) ; if ( save ) { const input = document . querySelector ( ` .tracker-url[data-tracker-index=" ${ CSS . escape ( String ( save . dataset . index ) ) } "] ` ) ; trackerAction ( 'edit ' , { index : Number ( save . dataset . index ) , url : input ? . value || '' }) ; return ; } const rea = e . target . closest ( '#trackerReannounceBtn' ) ; if ( rea ) trackerAction ( 'reannounce' , { } ) ; } ) ; $ ( 'appStatusRefreshBtn' ) ? . addEventListener ( 'click' , loadAppStatus ) ; $ ( 'portCheckEnabled' ) ? . addEventListener ( 'change' , savePortCheckPref ) ; $ ( 'portCheckNowBtn' ) ? . addEventListener ( 'click' , ( ) => loadPortCheck ( true ) ) ; $ ( 'bootstrapThemeSelect' ) ? . addEventListener ( 'change' , saveAppearancePreferences ) ; $ ( 'fontFamilySelect' ) ? . addEventListener ( 'change' , saveAppearancePreferences ) ; $ ( 'saveFooterPrefsBtn' ) ? . addEventListener ( 'click' , saveFooterPreferences ) ;
document . querySelectorAll ( '.torrent-table thead th[data-sort]' ) . forEach ( th => th . addEventListener ( 'click' , ( ) => { const key = th . dataset . sort ; if ( sortState . key === key ) sortState . dir *= - 1 ; else sortState = { key , dir : 1 } ; scheduleRender ( true ) ; } ) ) ; $ ( 'tableWrap' ) ? . addEventListener ( 'scroll' , ( ) => scheduleRender ( false ) , { passive : true } ) ; $ ( 'selectAll' ) ? . addEventListener ( 'change' , e => { selected . clear ( ) ; if ( e . target . checked ) visibleRows . forEach ( t => selected . add ( t . hash ) ) ; scheduleRender ( true ) ; } ) ; $ ( 'searchBox' ) ? . addEventListener ( 'input' , ( ) => { if ( $ ( 'tableWrap' ) ) $ ( 'tableWrap' ) . scrollTop = 0 ; scheduleRender ( true ) ; } ) ; document . querySelectorAll ( '.filter' ) . forEach ( b => b . addEventListener ( 'click' , ( ) => { document . querySelectorAll ( '.filter' ) . forEach ( x => x . classList . remove ( 'active' ) ) ; b . classList . add ( 'active' ) ; activeFilter = b . dataset . filter ; if ( $ ( 'tableWrap' ) ) $ ( 'tableWrap' ) . scrollTop = 0 ; scheduleRender ( true ) ; } ) ) ; document . querySelectorAll ( '#detailTabs .nav-link' ) . forEach ( b => b . addEventListener ( 'click' , ( ) => { document . querySelectorAll ( '#detailTabs .nav-link' ) . forEach ( x => x . classList . remove ( 'active' ) ) ; b . classList . add ( 'active' ) ; loadDetails ( b . dataset . tab ) ; } ) ) ; document . addEventListener ( 'change' , e => { const sel = e . target . closest ( '.file-priority' ) ; if ( sel ) { setFilePriorities ( [ { index : Number ( sel . dataset . index ) , priority : Number ( sel . value ) } ] ) ; return ; } if ( e . target && e . target . id === 'fileSelectAll' ) { document . querySelectorAll ( '#detailPane .file-check' ) . forEach ( cb => cb . checked = e . target . checked ) ; } } ) ; document . addEventListener ( 'click' , e => { const bulk = e . target . closest ( '.file-priority-bulk' ) ; if ( ! bulk ) return ; const priority = Number ( bulk . dataset . priority ) ; const checked = [ ... document . querySelectorAll ( '#detailPane .file-check:checked' ) ] . map ( cb => ( { index : Number ( cb . dataset . index ) , priority } ) ) ; if ( ! checked . length ) return toast ( 'No files selected' , 'warning' ) ; setFilePriorities ( checked ) ; } ) ; document . addEventListener ( 'click' , e => { const add = e . target . closest ( '#trackerAddBtn' ) ; if ( add ) { const url = $ ( 'trackerAddUrl' ) ? . value || '' ; trackerAction ( 'add' , { url } ) ; return ; } const del = e . target . closest ( '.tracker-delete' ) ; if ( del && ! del . disabled ) { trackerAction ( 'delete ' , { index : Number ( del . dataset . index ) } ) ; return ; } const rea = e . target . closest ( '#trackerReannounceBtn' ) ; if ( rea ) trackerAction ( 'reannounce' , { } ) ; } ) ; $ ( 'appStatusRefreshBtn' ) ? . addEventListener ( 'click' , loadAppStatus ) ; $ ( 'portCheckEnabled' ) ? . addEventListener ( 'change' , savePortCheckPref ) ; $ ( 'portCheckNowBtn' ) ? . addEventListener ( 'click' , ( ) => loadPortCheck ( true ) ) ; $ ( 'bootstrapThemeSelect' ) ? . addEventListener ( 'change' , saveAppearancePreferences ) ; $ ( 'fontFamilySelect' ) ? . addEventListener ( 'change' , saveAppearancePreferences ) ; $ ( 'saveFooterPrefsBtn' ) ? . addEventListener ( 'click' , saveFooterPreferences ) ;
document . addEventListener ( 'keydown' , e => { const tag = ( e . target ? . tagName || '' ) . toLowerCase ( ) ; const editable = tag === 'input' || tag === 'textarea' || tag === 'select' || e . target ? . isContentEditable ; if ( editable ) { if ( e . key === 'Enter' && e . target ? . id === 'labelInput' ) { e . preventDefault ( ) ; $ ( 'addLabelToSelectionBtn' ) ? . click ( ) ; } return ; } if ( ( e . ctrlKey || e . metaKey ) && e . key . toLowerCase ( ) === 'a' ) { e . preventDefault ( ) ; selected . clear ( ) ; visibleRows . forEach ( t => selected . add ( t . hash ) ) ; scheduleRender ( true ) ; } if ( ( e . ctrlKey || e . metaKey ) && e . key . toLowerCase ( ) === 'i' ) { e . preventDefault ( ) ; visibleRows . forEach ( t => selected . has ( t . hash ) ? selected . delete ( t . hash ) : selected . add ( t . hash ) ) ; scheduleRender ( true ) ; } if ( ( e . ctrlKey || e . metaKey ) && e . key . toLowerCase ( ) === 'o' ) { e . preventDefault ( ) ; new bootstrap . Modal ( $ ( 'addModal' ) ) . show ( ) ; } if ( e . key === 'Escape' ) { selected . clear ( ) ; scheduleRender ( true ) ; } if ( e . key === 'Delete' ) new bootstrap . Modal ( $ ( 'removeModal' ) ) . show ( ) ; if ( e . key === ' ' ) { e . preventDefault ( ) ; runAction ( 'start' ) ; } if ( e . key . toLowerCase ( ) === 'p' ) runAction ( 'pause' ) ; if ( e . key . toLowerCase ( ) === 's' ) runAction ( 'stop' ) ; if ( e . key . toLowerCase ( ) === 'r' ) runAction ( 'resume' ) ; if ( e . key . toLowerCase ( ) === 'm' ) runAction ( 'move' ) ; } ) ;
document . addEventListener ( 'keydown' , e => { const tag = ( e . target ? . tagName || '' ) . toLowerCase ( ) ; const editable = tag === 'input' || tag === 'textarea' || tag === 'select' || e . target ? . isContentEditable ; if ( editable ) { if ( e . key === 'Enter' && e . target ? . id === 'labelInput' ) { e . preventDefault ( ) ; $ ( 'addLabelToSelectionBtn' ) ? . click ( ) ; } return ; } if ( ( e . ctrlKey || e . metaKey ) && e . key . toLowerCase ( ) === 'a' ) { e . preventDefault ( ) ; selected . clear ( ) ; visibleRows . forEach ( t => selected . add ( t . hash ) ) ; scheduleRender ( true ) ; } if ( ( e . ctrlKey || e . metaKey ) && e . key . toLowerCase ( ) === 'i' ) { e . preventDefault ( ) ; visibleRows . forEach ( t => selected . has ( t . hash ) ? selected . delete ( t . hash ) : selected . add ( t . hash ) ) ; scheduleRender ( true ) ; } if ( ( e . ctrlKey || e . metaKey ) && e . key . toLowerCase ( ) === 'o' ) { e . preventDefault ( ) ; new bootstrap . Modal ( $ ( 'addModal' ) ) . show ( ) ; } if ( e . key === 'Escape' ) { selected . clear ( ) ; scheduleRender ( true ) ; } if ( e . key === 'Delete' ) new bootstrap . Modal ( $ ( 'removeModal' ) ) . show ( ) ; if ( e . key === ' ' ) { e . preventDefault ( ) ; runAction ( 'start' ) ; } if ( e . key . toLowerCase ( ) === 'p' ) runAction ( 'pause' ) ; if ( e . key . toLowerCase ( ) === 's' ) runAction ( 'stop' ) ; if ( e . key . toLowerCase ( ) === 'r' ) runAction ( 'resume' ) ; if ( e . key . toLowerCase ( ) === 'm' ) runAction ( 'move' ) ; } ) ;
$ ( 'removeModal' ) ? . addEventListener ( 'show.bs.modal' , ( ) => { $ ( 'removeCount' ) . textContent = selected . size ; $ ( 'removeData' ) . checked = true ; } ) ; $ ( 'confirmRemoveBtn' ) ? . addEventListener ( 'click' , async ( ) => { await runAction ( 'remove' , { remove _data : $ ( 'removeData' ) . checked } ) ; bootstrap . Modal . getInstance ( $ ( 'removeModal' ) ) ? . hide ( ) ; } ) ;
$ ( 'removeModal' ) ? . addEventListener ( 'show.bs.modal' , ( ) => { $ ( 'removeCount' ) . textContent = selected . size ; $ ( 'removeData' ) . checked = true ; } ) ; $ ( 'confirmRemoveBtn' ) ? . addEventListener ( 'click' , async ( ) => { await runAction ( 'remove' , { remove _data : $ ( 'removeData' ) . checked } ) ; bootstrap . Modal . getInstance ( $ ( 'removeModal' ) ) ? . hide ( ) ; } ) ;
$ ( 'addModal' ) ? . addEventListener ( 'show.bs.modal' , ( ) => applyDefaultDownloadPath ( true ) ) ;
$ ( 'addModal' ) ? . addEventListener ( 'show.bs.modal' , ( ) => applyDefaultDownloadPath ( true ) ) ;