From 3cd4827ff0791a35473f00223ba4d734f0afa2f1 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 5 Jun 2026 11:36:06 -0700 Subject: [PATCH] Web UI: named filter presets (1.3+) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/web_ui/index.html | 63 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/src/web_ui/index.html b/src/web_ui/index.html index 5a404f6..c91814e 100644 --- a/src/web_ui/index.html +++ b/src/web_ui/index.html @@ -571,19 +571,75 @@ function renderFilters(){ return `
${label}${chips}
`; }; const toggle=(label,key,title)=>``; - const anyActive=filter.watch!=='all'||filter.date!=='all'||filter.size!=='all'||filter.hasSubs||filter.hasChapters; + const anyActive=!filtersAreDefault(filter); const clear=anyActive?``:''; + const save=anyActive?``:''; + // Saved presets: click name to apply, ✕ to delete. Active preset (one + // whose filters match the current set) is highlighted. + const presets=filterPresets.length + ? `
★ Presets` + + filterPresets.map((p,i)=> + ``).join('') + + `
` + : ''; el.innerHTML= 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('Size','size',[['all','All'],['small','< 100 MB'],['med','100 MB – 1 GB'],['large','> 1 GB']])+ toggle('💬 Subs','hasSubs','Only videos with subtitle tracks')+ toggle('🔖 Chapters','hasChapters','Only videos with chapter markers')+ - clear; + presets+save+clear; } function setFilter(key,val){filter[key]=val;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(){ renderFilters(); if(showChannels){renderChannelGrid();return} @@ -2202,6 +2258,7 @@ applyTheme(localStorage.getItem('theme')||'dark'); // immediately. View selectors need the library array to be populated // before we can resolve `channel:` back to an index — do that // after loadLibrary() resolves. +loadFilterPresets(); (async()=>{ await Promise.all([loadLibrary(), loadNotes()]); restoreUiState();