diff --git a/src/web_ui/index.html b/src/web_ui/index.html index c40f6f8..dcb97bf 100644 --- a/src/web_ui/index.html +++ b/src/web_ui/index.html @@ -38,6 +38,19 @@ .ch-sub.active{color:var(--text)} section#content{flex:1;overflow-y:auto;padding:10px;min-width:0} .toolbar{display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap} + /* Filter chip row above the grid. Each chip is a button that toggles + a single filter dimension; the active state inverts colors so it's + obvious what's currently narrowing the view. */ + .filters{display:flex;align-items:center;gap:4px;margin-bottom:8px;flex-wrap:wrap;font-size:11px;color:var(--muted)} + .filter-grp{display:flex;align-items:center;gap:2px;padding:2px;background:var(--bg);border:1px solid var(--border);border-radius:4px} + .filter-grp-label{padding:0 4px;font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted)} + .chip{background:transparent;color:var(--muted);border:none;border-radius:3px;padding:3px 8px;font-size:11px;cursor:pointer;line-height:1.3} + .chip:hover{color:var(--text)} + .chip.active{background:var(--accent);color:#000;font-weight:600} + .chip-toggle{background:var(--bg);color:var(--muted);border:1px solid var(--border);border-radius:4px;padding:3px 8px;font-size:11px;cursor:pointer} + .chip-toggle.active{background:var(--accent);color:#000;border-color:var(--accent);font-weight:600} + .chip-clear{background:transparent;color:var(--muted);border:1px dashed var(--border);border-radius:4px;padding:3px 8px;font-size:11px;cursor:pointer} + .chip-clear:hover{color:var(--text);border-color:var(--muted)} .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px} .ch-card{background:var(--card);border-radius:8px;overflow:hidden;border:2px solid transparent;transition:border-color .15s;cursor:pointer;display:flex;flex-direction:column} .ch-card:hover{border-color:var(--accent)} @@ -193,6 +206,7 @@ +
@@ -222,6 +236,13 @@ let library=[], channelUrls=[], activeChannelIdx=null, activePlaylistIdx=null, s let librarySnapshotFolders=[]; // Smart-folder view selectors — at most one is true at a time. let showFavourites=false, showBookmarks=false, showWaiting=false; +// Filter chips above the grid. Each is independent; the grid filters by +// the AND of all active chips. Values: +// watch: 'all' | 'unwatched' | 'in-progress' | 'watched' +// date: 'all' | 'today' | 'week' | 'month' | 'year' | 'older' +// size: 'all' | 'small' (<100MB) | 'med' (100MB-1GB) | 'large' (>1GB) +// hasSubs / hasChapters: boolean toggles +let filter={watch:'all',date:'all',size:'all',hasSubs:false,hasChapters:false}; let musicTracks=[]; let bulkMode=false, selected=new Set(), selectedId=null; let currentPlayingId=null, saveTimer=null; @@ -370,6 +391,7 @@ function saveUiState(){ view:currentViewToken(), search:document.getElementById('search')?.value||'', sort:document.getElementById('sort')?.value||'date-desc', + filter, })); }catch{} } @@ -378,6 +400,9 @@ function restoreUiState(){ if(!raw)return; const s=document.getElementById('search'); if(s&&raw.search)s.value=raw.search; const sortEl=document.getElementById('sort'); if(sortEl&&raw.sort)sortEl.value=raw.sort; + // Restore filter chips. Defensive merge so an older localStorage payload + // missing a new field doesn't break the predicate. + if(raw.filter&&typeof raw.filter==='object')filter={...filter,...raw.filter}; // Map the persisted view back to the matching show* boolean. Channels // are stored by name (not by index) so a library re-order doesn't land // the user on the wrong channel. @@ -414,6 +439,42 @@ function setView(ci,pi){resetViewSelectors();activeChannelIdx=ci;activePlaylistI function setMusic(){resetViewSelectors();showMusic=true;selected.clear();saveUiState();closeSidebar();renderSidebar();renderGrid()} /* ── Grid ───────────────────────────────────────────────────────── */ +// Filter chip predicate. Returns true if a video passes every active chip. +// Date cutoffs use the upload_date (YYYYMMDD); videos with no date pass the +// 'all' bucket but get dropped by any specific date filter — same as +// Tartube's behavior. +function passesFilter(v){ + // Watch state. + if(filter.watch==='unwatched'&&v.watched)return false; + if(filter.watch==='watched'&&!v.watched)return false; + if(filter.watch==='in-progress'&&!(v.resume_pos&&v.resume_pos>5&&!v.watched))return false; + // Date (compares upload_date YYYYMMDD lexically against cutoff strings). + if(filter.date!=='all'){ + if(!v.upload_date)return false; + const now=new Date(); + const ymd=d=>d.getFullYear().toString()+String(d.getMonth()+1).padStart(2,'0')+String(d.getDate()).padStart(2,'0'); + const ago=(days)=>{const d=new Date(now);d.setDate(d.getDate()-days);return ymd(d)}; + const cutoff=filter.date==='today'?ymd(now): + filter.date==='week'?ago(7): + filter.date==='month'?ago(30): + filter.date==='year'?ago(365):null; + if(filter.date==='older'){ + // "Older" = anything more than a year old. + if(v.upload_date>=ago(365))return false; + } else if(cutoff && v.upload_date