From 8ebbaeab1c8f157fc670d7275791adb0bd25436f Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 16:18:02 +0000 Subject: [PATCH] better multiselect --- frontend/app.js | 101 ++++++++++++++++++++++++++++++++++----------- frontend/style.css | 20 +++++++++ 2 files changed, 96 insertions(+), 25 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index de40d4a..0718874 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -5,6 +5,7 @@ const renderedVideoIds = new Set(); let hasNextPage = true; let isLoading = false; let hlsPlayer = null; +let currentLoadController = null; // 2. Observer Definition (Must be defined before initApp uses it) const observer = new IntersectionObserver((entries) => { @@ -115,12 +116,14 @@ async function loadVideos() { try { isLoading = true; updateLoadMoreState(); + currentLoadController = new AbortController(); const response = await fetch('/api/videos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) + body: JSON.stringify(body), + signal: currentLoadController.signal }); const videos = await response.json(); renderVideos(videos); @@ -128,9 +131,12 @@ async function loadVideos() { currentPage++; ensureViewportFilled(); } catch (err) { - console.error("Failed to load videos:", err); + if (err.name !== 'AbortError') { + console.error("Failed to load videos:", err); + } } finally { isLoading = false; + currentLoadController = null; updateLoadMoreState(); } } @@ -156,6 +162,8 @@ function renderVideos(videos) { grid.appendChild(card); renderedVideoIds.add(v.id); }); + + ensureViewportFilled(); } function formatDuration(seconds) { @@ -190,6 +198,10 @@ async function initApp() { }; } + window.addEventListener('resize', () => { + ensureViewportFilled(); + }); + await loadVideos(); } @@ -378,6 +390,11 @@ function buildDefaultOptions(channel) { } function resetAndReload() { + if (currentLoadController) { + currentLoadController.abort(); + currentLoadController = null; + isLoading = false; + } currentPage = 1; hasNextPage = true; renderedVideoIds.clear(); @@ -391,8 +408,8 @@ function ensureViewportFilled() { if (!hasNextPage || isLoading) return; const grid = document.getElementById('video-grid'); if (!grid) return; - const contentHeight = grid.getBoundingClientRect().bottom; - if (contentHeight < window.innerHeight + 120) { + const docHeight = document.documentElement.scrollHeight; + if (docHeight <= window.innerHeight + 120) { window.setTimeout(() => loadVideos(), 0); } } @@ -621,42 +638,76 @@ function renderFilters(container, session) { const label = document.createElement('label'); label.textContent = optionGroup.title || optionGroup.id; + const options = optionGroup.options || []; + const currentSelection = session.options ? session.options[optionGroup.id] : null; + + if (optionGroup.multiSelect) { + const list = document.createElement('div'); + list.className = 'multi-select'; + + const selectedIds = new Set( + Array.isArray(currentSelection) + ? currentSelection.map((item) => item.id) + : [] + ); + + options.forEach((opt) => { + const item = document.createElement('label'); + item.className = 'multi-select-item'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.value = opt.id; + checkbox.checked = selectedIds.has(opt.id); + + const text = document.createElement('span'); + text.textContent = opt.title || opt.id; + + checkbox.onchange = () => { + const nextSession = getSession(); + if (!nextSession || !nextSession.channel) return; + const selected = []; + list.querySelectorAll('input[type="checkbox"]').forEach((cb) => { + if (cb.checked) { + const found = options.find((item) => item.id === cb.value); + if (found) selected.push(found); + } + }); + nextSession.options[optionGroup.id] = selected; + setSession(nextSession); + savePreference(nextSession); + resetAndReload(); + }; + + item.appendChild(checkbox); + item.appendChild(text); + list.appendChild(item); + }); + + wrapper.appendChild(label); + wrapper.appendChild(list); + container.appendChild(wrapper); + return; + } const select = document.createElement('select'); - select.multiple = Boolean(optionGroup.multiSelect); - - (optionGroup.options || []).forEach((opt) => { + options.forEach((opt) => { const option = document.createElement('option'); option.value = opt.id; option.textContent = opt.title || opt.id; select.appendChild(option); }); - const currentSelection = session.options ? session.options[optionGroup.id] : null; - if (Array.isArray(currentSelection)) { - const ids = new Set(currentSelection.map((item) => item.id)); - Array.from(select.options).forEach((opt) => { - opt.selected = ids.has(opt.value); - }); - } else if (currentSelection && currentSelection.id) { + if (currentSelection && currentSelection.id) { select.value = currentSelection.id; } select.onchange = () => { const nextSession = getSession(); if (!nextSession || !nextSession.channel) return; - - const selectedOptions = optionGroup.options || []; - if (optionGroup.multiSelect) { - const selected = Array.from(select.selectedOptions).map((opt) => - selectedOptions.find((item) => item.id === opt.value) - ).filter(Boolean); + const selected = options.find((item) => item.id === select.value); + if (selected) { nextSession.options[optionGroup.id] = selected; - } else { - const selected = selectedOptions.find((item) => item.id === select.value); - if (selected) { - nextSession.options[optionGroup.id] = selected; - } } setSession(nextSession); diff --git a/frontend/style.css b/frontend/style.css index 6dfb194..cc52209 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -404,6 +404,26 @@ body.theme-light .input-row input:focus { background-repeat: no-repeat; } +.multi-select { + display: flex; + flex-direction: column; + gap: 8px; +} + +.multi-select-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-primary); +} + +.multi-select-item input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); +} + .setting-item select:focus { outline: none; border-color: var(--accent);