ui: VOD list filter with persistence + Ctrl+F focus + Esc clear
Filter row above the VOD grid lets the user search the loaded archive by title. Concrete user pain: streamers commonly have hundreds of VODs and the current UI only supported scrolling. - vodFilterInput / vodFilterClearBtn / vodFilterCount in index.html - localized placeholder + clear-button title (DE + EN) - vodFilterQuery state persisted to localStorage as twitch-vod-manager:vod-filter so the search bar survives reloads - renderVODs split: it now caches lastLoadedVods + lastLoadedStreamer and delegates to renderVodGridFromCurrentState which applies filterVodsByQuery on every input event (no re-fetch) - empty-state DOM is now built with createElement + textContent (via setVodGridEmptyState) instead of an innerHTML template, even for locale-only strings — defence in depth - keyboard: Ctrl/Cmd+F focuses the filter when the VODs tab is active (Electron has no native find bar, so the default is suppressed). Esc clears the filter when the input has focus and content. Esc still closes modals first if any are open. docs/IMPROVEMENT_LOG.md: Cycle 3 dated section. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
31e6671e65
commit
23d0dd5829
@@ -239,6 +239,11 @@
|
||||
<div class="content">
|
||||
<!-- VODs Tab -->
|
||||
<div class="tab-content active" id="vodsTab">
|
||||
<div class="vod-filter-row" style="display:flex; align-items:center; gap:8px; margin-bottom:12px;">
|
||||
<input type="text" id="vodFilterInput" placeholder="Filter VODs..." oninput="onVodFilterInput()" style="flex:1; background: var(--bg-secondary,#222); border:1px solid var(--border-color,#444); border-radius:6px; padding:8px 12px; color: var(--text-primary,#fff); font-size:13px;">
|
||||
<button id="vodFilterClearBtn" onclick="clearVodFilter()" title="Clear filter" style="display:none; background:transparent; border:1px solid var(--border-color,#444); border-radius:6px; padding:8px 12px; color: var(--text-secondary,#888); cursor:pointer;">x</button>
|
||||
<span id="vodFilterCount" style="color: var(--text-secondary,#888); font-size:12px; min-width:80px;"></span>
|
||||
</div>
|
||||
<div class="vod-grid" id="vodGrid">
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
|
||||
|
||||
@@ -162,7 +162,12 @@ const UI_TEXT_DE = {
|
||||
noResultsText: 'Dieser Streamer hat keine VODs.',
|
||||
untitled: 'Unbenanntes VOD',
|
||||
views: 'Aufrufe',
|
||||
addQueue: '+ Warteschlange'
|
||||
addQueue: '+ Warteschlange',
|
||||
filterPlaceholder: 'Nach Titel filtern... (Strg+F)',
|
||||
filterClearTitle: 'Filter loeschen (Esc)',
|
||||
filterNoMatchTitle: 'Keine Treffer',
|
||||
filterNoMatchText: 'Keine VODs entsprechen dem aktuellen Filter.',
|
||||
filterMatchCount: '{shown} von {total} VODs'
|
||||
},
|
||||
clips: {
|
||||
dialogTitle: 'Clip zuschneiden',
|
||||
|
||||
@@ -162,7 +162,12 @@ const UI_TEXT_EN = {
|
||||
noResultsText: 'This streamer has no VODs.',
|
||||
untitled: 'Untitled VOD',
|
||||
views: 'views',
|
||||
addQueue: '+ Queue'
|
||||
addQueue: '+ Queue',
|
||||
filterPlaceholder: 'Filter by title... (Ctrl+F)',
|
||||
filterClearTitle: 'Clear filter (Esc)',
|
||||
filterNoMatchTitle: 'No matches',
|
||||
filterNoMatchText: 'No VODs match the current filter.',
|
||||
filterMatchCount: '{shown} of {total} VODs'
|
||||
},
|
||||
clips: {
|
||||
dialogTitle: 'Trim clip',
|
||||
|
||||
+115
-10
@@ -2,6 +2,79 @@ let selectStreamerRequestId = 0;
|
||||
let vodRenderTaskId = 0;
|
||||
const VOD_RENDER_CHUNK_SIZE = 64;
|
||||
|
||||
// VOD filter state — persists across renderer reloads via localStorage so the
|
||||
// user's search query survives an app restart. Cleared explicitly via Esc /
|
||||
// the clear button. Shared across streamers (acts like a search bar).
|
||||
let lastLoadedVods: VOD[] = [];
|
||||
let lastLoadedStreamer: string | null = null;
|
||||
let vodFilterQuery = '';
|
||||
const VOD_FILTER_STORAGE_KEY = 'twitch-vod-manager:vod-filter';
|
||||
|
||||
function loadPersistedVodFilter(): string {
|
||||
try {
|
||||
return localStorage.getItem(VOD_FILTER_STORAGE_KEY) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function persistVodFilter(query: string): void {
|
||||
try { localStorage.setItem(VOD_FILTER_STORAGE_KEY, query); } catch { /* localStorage may be unavailable */ }
|
||||
}
|
||||
|
||||
function filterVodsByQuery(vods: VOD[], query: string): VOD[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return vods;
|
||||
return vods.filter((vod) => (vod.title || '').toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
function updateVodFilterCount(filteredCount: number, totalCount: number): void {
|
||||
const node = document.getElementById('vodFilterCount');
|
||||
if (!node) return;
|
||||
if (!totalCount || !vodFilterQuery.trim()) {
|
||||
node.textContent = '';
|
||||
return;
|
||||
}
|
||||
node.textContent = UI_TEXT.vods.filterMatchCount
|
||||
.replace('{shown}', String(filteredCount))
|
||||
.replace('{total}', String(totalCount));
|
||||
}
|
||||
|
||||
function syncVodFilterClearButton(): void {
|
||||
const btn = document.getElementById('vodFilterClearBtn') as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.style.display = vodFilterQuery.trim() ? '' : 'none';
|
||||
}
|
||||
|
||||
function onVodFilterInput(): void {
|
||||
const input = byId<HTMLInputElement>('vodFilterInput');
|
||||
vodFilterQuery = input.value;
|
||||
persistVodFilter(vodFilterQuery);
|
||||
syncVodFilterClearButton();
|
||||
if (lastLoadedStreamer) {
|
||||
renderVodGridFromCurrentState();
|
||||
}
|
||||
}
|
||||
|
||||
function clearVodFilter(): void {
|
||||
vodFilterQuery = '';
|
||||
const input = byId<HTMLInputElement>('vodFilterInput');
|
||||
if (input) input.value = '';
|
||||
persistVodFilter('');
|
||||
syncVodFilterClearButton();
|
||||
if (lastLoadedStreamer) {
|
||||
renderVodGridFromCurrentState();
|
||||
}
|
||||
}
|
||||
|
||||
function focusVodFilter(): void {
|
||||
const input = document.getElementById('vodFilterInput') as HTMLInputElement | null;
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
}
|
||||
|
||||
function buildVodCardHtml(vod: VOD, streamer: string): string {
|
||||
const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180');
|
||||
const date = formatUiDate(vod.created_at);
|
||||
@@ -117,9 +190,48 @@ async function selectStreamer(name: string, forceRefresh = false): Promise<void>
|
||||
renderVODs(vods, name);
|
||||
}
|
||||
|
||||
function setVodGridEmptyState(grid: HTMLElement, title: string, text: string): void {
|
||||
// Build via DOM API so the (locale-only) strings can never escape into HTML.
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'empty-state';
|
||||
const h3 = document.createElement('h3');
|
||||
h3.textContent = title;
|
||||
const p = document.createElement('p');
|
||||
p.textContent = text;
|
||||
wrap.appendChild(h3);
|
||||
wrap.appendChild(p);
|
||||
grid.replaceChildren(wrap);
|
||||
}
|
||||
|
||||
function renderVODs(vods: VOD[] | null | undefined, streamer: string): void {
|
||||
lastLoadedVods = Array.isArray(vods) ? vods : [];
|
||||
lastLoadedStreamer = streamer;
|
||||
renderVodGridFromCurrentState();
|
||||
}
|
||||
|
||||
function renderVodGridFromCurrentState(): void {
|
||||
if (!lastLoadedStreamer) return;
|
||||
|
||||
const grid = byId('vodGrid');
|
||||
const renderTaskId = ++vodRenderTaskId;
|
||||
const total = lastLoadedVods.length;
|
||||
|
||||
if (total === 0) {
|
||||
setVodGridEmptyState(grid, UI_TEXT.vods.noResultsTitle, UI_TEXT.vods.noResultsText);
|
||||
updateVodFilterCount(0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = filterVodsByQuery(lastLoadedVods, vodFilterQuery);
|
||||
|
||||
if (filtered.length === 0 && vodFilterQuery.trim()) {
|
||||
setVodGridEmptyState(grid, UI_TEXT.vods.filterNoMatchTitle, UI_TEXT.vods.filterNoMatchText);
|
||||
updateVodFilterCount(0, total);
|
||||
return;
|
||||
}
|
||||
|
||||
grid.replaceChildren();
|
||||
updateVodFilterCount(filtered.length, total);
|
||||
|
||||
const scheduleNextChunk = (nextStartIndex: number): void => {
|
||||
const delayMs = document.hidden ? 16 : 0;
|
||||
@@ -128,26 +240,19 @@ function renderVODs(vods: VOD[] | null | undefined, streamer: string): void {
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
if (!vods || vods.length === 0) {
|
||||
grid.innerHTML = `<div class="empty-state"><h3>${UI_TEXT.vods.noResultsTitle}</h3><p>${UI_TEXT.vods.noResultsText}</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = '';
|
||||
|
||||
const renderChunk = (startIndex: number): void => {
|
||||
if (renderTaskId !== vodRenderTaskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chunk = vods.slice(startIndex, startIndex + VOD_RENDER_CHUNK_SIZE);
|
||||
const chunk = filtered.slice(startIndex, startIndex + VOD_RENDER_CHUNK_SIZE);
|
||||
if (!chunk.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
grid.insertAdjacentHTML('beforeend', chunk.map((vod) => buildVodCardHtml(vod, streamer)).join(''));
|
||||
grid.insertAdjacentHTML('beforeend', chunk.map((vod) => buildVodCardHtml(vod, lastLoadedStreamer || '')).join(''));
|
||||
|
||||
if (startIndex + chunk.length < vods.length) {
|
||||
if (startIndex + chunk.length < filtered.length) {
|
||||
scheduleNextChunk(startIndex + chunk.length);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -143,6 +143,8 @@ function applyLanguageToStaticUI(): void {
|
||||
setText('updateChangelogToggle', UI_TEXT.updates.showChangelog);
|
||||
setText('updateChangelogEmpty', UI_TEXT.updates.noChangelog);
|
||||
setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder);
|
||||
setPlaceholder('vodFilterInput', UI_TEXT.vods.filterPlaceholder);
|
||||
setTitle('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle);
|
||||
|
||||
const status = document.getElementById('statusText')?.textContent?.trim() || '';
|
||||
if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) {
|
||||
|
||||
@@ -45,6 +45,13 @@ async function init(): Promise<void> {
|
||||
initQueueDragDrop();
|
||||
updateDownloadButtonState();
|
||||
|
||||
// Restore persisted VOD filter into the input — the filter itself only
|
||||
// takes effect once VODs load (renderVODs reads vodFilterQuery).
|
||||
vodFilterQuery = loadPersistedVodFilter();
|
||||
const vodFilterInput = document.getElementById('vodFilterInput') as HTMLInputElement | null;
|
||||
if (vodFilterInput) vodFilterInput.value = vodFilterQuery;
|
||||
syncVodFilterClearButton();
|
||||
|
||||
// Restore last active tab from previous session (default 'vods')
|
||||
showTab(loadPersistedActiveTab());
|
||||
|
||||
@@ -136,6 +143,28 @@ async function init(): Promise<void> {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// No modal open: if the VOD filter has focus or content, clear it.
|
||||
// Otherwise let Esc bubble (e.g. blur).
|
||||
if (e.target instanceof HTMLInputElement && e.target.id === 'vodFilterInput') {
|
||||
if (vodFilterQuery) {
|
||||
clearVodFilter();
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+F (or Cmd+F): focus the VOD filter — only when on the VODs tab.
|
||||
// Browser's default Ctrl+F is suppressed because Electron's renderer
|
||||
// doesn't have a native find bar anyway.
|
||||
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'f' || e.key === 'F')) {
|
||||
const onVodsTab = document.getElementById('vodsTab')?.classList.contains('active');
|
||||
if (onVodsTab) {
|
||||
e.preventDefault();
|
||||
focusVodFilter();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip rest if user is typing in an input field
|
||||
|
||||
Reference in New Issue
Block a user