Web UI: named filter presets (1.3+)
The chip filters (watch/date/size/subs/chapters) were session-only. Add saveable named presets, finishing the last small Phase-1 line. - ★ Save preset button (shown when any filter is active) → names the current chip set via prompt and stores it. - Saved presets render as chips in the filter row; click to apply, the inline ✕ deletes. The preset matching the current filters is highlighted. - Persisted under their own `filter-presets` localStorage key so they survive clearFilters() and aren't entangled with view state. Handlers are index-based (not name-based) so preset names containing quotes/apostrophes can't break the inline onclick — the list re-renders after every change so indices always track the DOM. Same-name save overwrites rather than duplicating. Web-only: the desktop UI has no chip filters. Verified the save/apply/ delete/match/overwrite logic in isolation; JS syntax-checked; builds. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
b8a742535b
commit
3cd4827ff0
1 changed files with 60 additions and 3 deletions
|
|
@ -571,19 +571,75 @@ function renderFilters(){
|
||||||
return `<div class="filter-grp"><span class="filter-grp-label">${label}</span>${chips}</div>`;
|
return `<div class="filter-grp"><span class="filter-grp-label">${label}</span>${chips}</div>`;
|
||||||
};
|
};
|
||||||
const toggle=(label,key,title)=>`<button class="chip-toggle${filter[key]?' active':''}" title="${esc(title)}" onclick="toggleFilter('${key}')">${label}</button>`;
|
const toggle=(label,key,title)=>`<button class="chip-toggle${filter[key]?' active':''}" title="${esc(title)}" onclick="toggleFilter('${key}')">${label}</button>`;
|
||||||
const anyActive=filter.watch!=='all'||filter.date!=='all'||filter.size!=='all'||filter.hasSubs||filter.hasChapters;
|
const anyActive=!filtersAreDefault(filter);
|
||||||
const clear=anyActive?`<button class="chip-clear" onclick="clearFilters()">✕ Clear filters</button>`:'';
|
const clear=anyActive?`<button class="chip-clear" onclick="clearFilters()">✕ Clear filters</button>`:'';
|
||||||
|
const save=anyActive?`<button class="chip-clear" onclick="saveCurrentFilterPreset()" title="Save these filters as a named preset">★ Save preset</button>`:'';
|
||||||
|
// Saved presets: click name to apply, ✕ to delete. Active preset (one
|
||||||
|
// whose filters match the current set) is highlighted.
|
||||||
|
const presets=filterPresets.length
|
||||||
|
? `<div class="filter-grp"><span class="filter-grp-label">★ Presets</span>`
|
||||||
|
+ filterPresets.map((p,i)=>
|
||||||
|
`<button class="chip${presetMatchesCurrent(p)?' active':''}" onclick="applyFilterPreset(${i})" title="Apply preset">`
|
||||||
|
+ `${esc(p.name)}<span onclick="event.stopPropagation();deleteFilterPreset(${i})" style="margin-left:6px;opacity:.55;font-size:10px" title="Delete preset">✕</span>`
|
||||||
|
+ `</button>`).join('')
|
||||||
|
+ `</div>`
|
||||||
|
: '';
|
||||||
el.innerHTML=
|
el.innerHTML=
|
||||||
grp('Watch','watch',[['all','All'],['unwatched','Unwatched'],['in-progress','In progress'],['watched','Watched']])+
|
grp('Watch','watch',[['all','All'],['unwatched','Unwatched'],['in-progress','In progress'],['watched','Watched']])+
|
||||||
grp('Date','date',[['all','All'],['today','Today'],['week','Week'],['month','Month'],['year','Year'],['older','Older']])+
|
grp('Date','date',[['all','All'],['today','Today'],['week','Week'],['month','Month'],['year','Year'],['older','Older']])+
|
||||||
grp('Size','size',[['all','All'],['small','< 100 MB'],['med','100 MB – 1 GB'],['large','> 1 GB']])+
|
grp('Size','size',[['all','All'],['small','< 100 MB'],['med','100 MB – 1 GB'],['large','> 1 GB']])+
|
||||||
toggle('💬 Subs','hasSubs','Only videos with subtitle tracks')+
|
toggle('💬 Subs','hasSubs','Only videos with subtitle tracks')+
|
||||||
toggle('🔖 Chapters','hasChapters','Only videos with chapter markers')+
|
toggle('🔖 Chapters','hasChapters','Only videos with chapter markers')+
|
||||||
clear;
|
presets+save+clear;
|
||||||
}
|
}
|
||||||
function setFilter(key,val){filter[key]=val;saveUiState();renderFilters();renderGrid()}
|
function setFilter(key,val){filter[key]=val;saveUiState();renderFilters();renderGrid()}
|
||||||
function toggleFilter(key){filter[key]=!filter[key];saveUiState();renderFilters();renderGrid()}
|
function toggleFilter(key){filter[key]=!filter[key];saveUiState();renderFilters();renderGrid()}
|
||||||
function clearFilters(){filter={watch:'all',date:'all',size:'all',hasSubs:false,hasChapters:false};saveUiState();renderFilters();renderGrid()}
|
const DEFAULT_FILTER={watch:'all',date:'all',size:'all',hasSubs:false,hasChapters:false};
|
||||||
|
function clearFilters(){filter={...DEFAULT_FILTER};saveUiState();renderFilters();renderGrid()}
|
||||||
|
|
||||||
|
/* ── Named filter presets ───────────────────────────────────────── */
|
||||||
|
// Saved chip-filter sets, persisted under their own localStorage key so
|
||||||
|
// they outlive a clearFilters() and aren't tangled with view state.
|
||||||
|
let filterPresets=[];
|
||||||
|
function loadFilterPresets(){
|
||||||
|
try{filterPresets=JSON.parse(localStorage.getItem('filter-presets')||'[]')}catch{filterPresets=[]}
|
||||||
|
if(!Array.isArray(filterPresets))filterPresets=[];
|
||||||
|
}
|
||||||
|
function persistFilterPresets(){
|
||||||
|
try{localStorage.setItem('filter-presets',JSON.stringify(filterPresets))}catch{}
|
||||||
|
}
|
||||||
|
function filtersAreDefault(f){
|
||||||
|
return f.watch==='all'&&f.date==='all'&&f.size==='all'&&!f.hasSubs&&!f.hasChapters;
|
||||||
|
}
|
||||||
|
function presetMatchesCurrent(p){
|
||||||
|
const f=p.filter||{};
|
||||||
|
return f.watch===filter.watch&&f.date===filter.date&&f.size===filter.size
|
||||||
|
&&!!f.hasSubs===!!filter.hasSubs&&!!f.hasChapters===!!filter.hasChapters;
|
||||||
|
}
|
||||||
|
function saveCurrentFilterPreset(){
|
||||||
|
if(filtersAreDefault(filter)){setStatus('No active filters to save');return}
|
||||||
|
const name=(prompt('Name this filter preset:')||'').trim();
|
||||||
|
if(!name)return;
|
||||||
|
// Overwrite a same-named preset rather than duplicating.
|
||||||
|
filterPresets=filterPresets.filter(p=>p.name!==name);
|
||||||
|
filterPresets.push({name,filter:{...filter}});
|
||||||
|
persistFilterPresets();
|
||||||
|
renderFilters();
|
||||||
|
setStatus('Saved preset “'+name+'”');
|
||||||
|
}
|
||||||
|
// Index-based so preset names with quotes/apostrophes can't break the
|
||||||
|
// inline onclick handlers; the array is re-rendered after every change so
|
||||||
|
// indices always match what's on screen.
|
||||||
|
function applyFilterPreset(i){
|
||||||
|
const p=filterPresets[i]; if(!p)return;
|
||||||
|
filter={...DEFAULT_FILTER,...p.filter};
|
||||||
|
saveUiState();renderFilters();renderGrid();
|
||||||
|
}
|
||||||
|
function deleteFilterPreset(i){
|
||||||
|
filterPresets.splice(i,1);
|
||||||
|
persistFilterPresets();
|
||||||
|
renderFilters();
|
||||||
|
}
|
||||||
function renderGrid(){
|
function renderGrid(){
|
||||||
renderFilters();
|
renderFilters();
|
||||||
if(showChannels){renderChannelGrid();return}
|
if(showChannels){renderChannelGrid();return}
|
||||||
|
|
@ -2202,6 +2258,7 @@ applyTheme(localStorage.getItem('theme')||'dark');
|
||||||
// immediately. View selectors need the library array to be populated
|
// immediately. View selectors need the library array to be populated
|
||||||
// before we can resolve `channel:<name>` back to an index — do that
|
// before we can resolve `channel:<name>` back to an index — do that
|
||||||
// after loadLibrary() resolves.
|
// after loadLibrary() resolves.
|
||||||
|
loadFilterPresets();
|
||||||
(async()=>{
|
(async()=>{
|
||||||
await Promise.all([loadLibrary(), loadNotes()]);
|
await Promise.all([loadLibrary(), loadNotes()]);
|
||||||
restoreUiState();
|
restoreUiState();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue