ui: VOD sort dropdown with persisted key + locale labels
Adds a sort selector next to the existing filter input. Five modes: newest first (default), oldest first, most viewed, longest first, shortest first. Concrete user pain — long archives previously had no way to find the longest stream, the most-watched, or to scroll back to the start chronologically. - vodSortKey state persisted to localStorage as twitch-vod-manager:vod-sort and validated against an enum on load, so an unknown stored value falls back to date_desc - renderVodGridFromCurrentState now applies sortVods before filterVodsByQuery so the filter sees the sort and the match counter is consistent - sortVods uses created_at timestamps for date sorts, view_count for views, and a tiny vodDurationToSeconds parser (XhYmZs) for duration - DE + EN labels for both the "Sort:" prefix and the five option texts; refreshVodSortSelectLabels re-runs on language switch - syncVodSortSelect on init preselects the persisted value before any VOD load so the dropdown reflects state immediately Browser-default keyboard nav (arrows, type-ahead) covers keyboard access for the select. docs/IMPROVEMENT_LOG.md: Cycle 4 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
020f3dacf1
commit
832b606701
+10
-2
@@ -239,9 +239,17 @@
|
||||
<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;">
|
||||
<div class="vod-filter-row" style="display:flex; align-items:center; gap:8px; margin-bottom:12px; flex-wrap:wrap;">
|
||||
<input type="text" id="vodFilterInput" placeholder="Filter VODs..." oninput="onVodFilterInput()" style="flex:1; min-width:180px; 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>
|
||||
<label id="vodSortLabel" for="vodSortSelect" style="color: var(--text-secondary,#888); font-size:12px; margin-left:8px;">Sort:</label>
|
||||
<select id="vodSortSelect" onchange="onVodSortChange()" style="background: var(--bg-secondary,#222); border:1px solid var(--border-color,#444); border-radius:6px; padding:7px 10px; color: var(--text-primary,#fff); font-size:13px;">
|
||||
<option value="date_desc">Newest first</option>
|
||||
<option value="date_asc">Oldest first</option>
|
||||
<option value="views_desc">Most viewed</option>
|
||||
<option value="duration_desc">Longest first</option>
|
||||
<option value="duration_asc">Shortest first</option>
|
||||
</select>
|
||||
<span id="vodFilterCount" style="color: var(--text-secondary,#888); font-size:12px; min-width:80px;"></span>
|
||||
</div>
|
||||
<div class="vod-grid" id="vodGrid">
|
||||
|
||||
@@ -167,7 +167,13 @@ const UI_TEXT_DE = {
|
||||
filterClearTitle: 'Filter loeschen (Esc)',
|
||||
filterNoMatchTitle: 'Keine Treffer',
|
||||
filterNoMatchText: 'Keine VODs entsprechen dem aktuellen Filter.',
|
||||
filterMatchCount: '{shown} von {total} VODs'
|
||||
filterMatchCount: '{shown} von {total} VODs',
|
||||
sortLabel: 'Sortierung:',
|
||||
sortDateDesc: 'Neueste zuerst',
|
||||
sortDateAsc: 'Aelteste zuerst',
|
||||
sortViewsDesc: 'Meiste Aufrufe',
|
||||
sortDurationDesc: 'Laengste zuerst',
|
||||
sortDurationAsc: 'Kuerzeste zuerst'
|
||||
},
|
||||
clips: {
|
||||
dialogTitle: 'Clip zuschneiden',
|
||||
|
||||
@@ -167,7 +167,13 @@ const UI_TEXT_EN = {
|
||||
filterClearTitle: 'Clear filter (Esc)',
|
||||
filterNoMatchTitle: 'No matches',
|
||||
filterNoMatchText: 'No VODs match the current filter.',
|
||||
filterMatchCount: '{shown} of {total} VODs'
|
||||
filterMatchCount: '{shown} of {total} VODs',
|
||||
sortLabel: 'Sort:',
|
||||
sortDateDesc: 'Newest first',
|
||||
sortDateAsc: 'Oldest first',
|
||||
sortViewsDesc: 'Most viewed',
|
||||
sortDurationDesc: 'Longest first',
|
||||
sortDurationAsc: 'Shortest first'
|
||||
},
|
||||
clips: {
|
||||
dialogTitle: 'Trim clip',
|
||||
|
||||
@@ -10,6 +10,95 @@ let lastLoadedStreamer: string | null = null;
|
||||
let vodFilterQuery = '';
|
||||
const VOD_FILTER_STORAGE_KEY = 'twitch-vod-manager:vod-filter';
|
||||
|
||||
type VodSortKey = 'date_desc' | 'date_asc' | 'views_desc' | 'duration_desc' | 'duration_asc';
|
||||
const VALID_VOD_SORTS: ReadonlyArray<VodSortKey> = ['date_desc', 'date_asc', 'views_desc', 'duration_desc', 'duration_asc'];
|
||||
const VOD_SORT_STORAGE_KEY = 'twitch-vod-manager:vod-sort';
|
||||
let vodSortKey: VodSortKey = 'date_desc';
|
||||
|
||||
function loadPersistedVodSort(): VodSortKey {
|
||||
try {
|
||||
const stored = localStorage.getItem(VOD_SORT_STORAGE_KEY);
|
||||
if (stored && (VALID_VOD_SORTS as readonly string[]).includes(stored)) {
|
||||
return stored as VodSortKey;
|
||||
}
|
||||
} catch { /* localStorage may be unavailable */ }
|
||||
return 'date_desc';
|
||||
}
|
||||
|
||||
function persistVodSort(key: VodSortKey): void {
|
||||
try { localStorage.setItem(VOD_SORT_STORAGE_KEY, key); } catch { /* localStorage may be unavailable */ }
|
||||
}
|
||||
|
||||
function vodDurationToSeconds(durationStr: string): number {
|
||||
let total = 0;
|
||||
const h = durationStr.match(/(\d+)h/);
|
||||
const m = durationStr.match(/(\d+)m/);
|
||||
const s = durationStr.match(/(\d+)s/);
|
||||
if (h) total += parseInt(h[1], 10) * 3600;
|
||||
if (m) total += parseInt(m[1], 10) * 60;
|
||||
if (s) total += parseInt(s[1], 10);
|
||||
return total;
|
||||
}
|
||||
|
||||
function sortVods(vods: VOD[], key: VodSortKey): VOD[] {
|
||||
const sorted = [...vods];
|
||||
const ts = (s: string): number => {
|
||||
const n = new Date(s).getTime();
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
switch (key) {
|
||||
case 'date_desc':
|
||||
sorted.sort((a, b) => ts(b.created_at) - ts(a.created_at));
|
||||
break;
|
||||
case 'date_asc':
|
||||
sorted.sort((a, b) => ts(a.created_at) - ts(b.created_at));
|
||||
break;
|
||||
case 'views_desc':
|
||||
sorted.sort((a, b) => (b.view_count || 0) - (a.view_count || 0));
|
||||
break;
|
||||
case 'duration_desc':
|
||||
sorted.sort((a, b) => vodDurationToSeconds(b.duration) - vodDurationToSeconds(a.duration));
|
||||
break;
|
||||
case 'duration_asc':
|
||||
sorted.sort((a, b) => vodDurationToSeconds(a.duration) - vodDurationToSeconds(b.duration));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function onVodSortChange(): void {
|
||||
const select = byId<HTMLSelectElement>('vodSortSelect');
|
||||
const value = select.value;
|
||||
if ((VALID_VOD_SORTS as readonly string[]).includes(value)) {
|
||||
vodSortKey = value as VodSortKey;
|
||||
persistVodSort(vodSortKey);
|
||||
if (lastLoadedStreamer) {
|
||||
renderVodGridFromCurrentState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncVodSortSelect(): void {
|
||||
const select = document.getElementById('vodSortSelect') as HTMLSelectElement | null;
|
||||
if (select) select.value = vodSortKey;
|
||||
}
|
||||
|
||||
function refreshVodSortSelectLabels(): void {
|
||||
const select = document.getElementById('vodSortSelect') as HTMLSelectElement | null;
|
||||
if (!select) return;
|
||||
const labels: Record<VodSortKey, string> = {
|
||||
date_desc: UI_TEXT.vods.sortDateDesc,
|
||||
date_asc: UI_TEXT.vods.sortDateAsc,
|
||||
views_desc: UI_TEXT.vods.sortViewsDesc,
|
||||
duration_desc: UI_TEXT.vods.sortDurationDesc,
|
||||
duration_asc: UI_TEXT.vods.sortDurationAsc
|
||||
};
|
||||
for (const opt of Array.from(select.options)) {
|
||||
const k = opt.value as VodSortKey;
|
||||
if (labels[k]) opt.textContent = labels[k];
|
||||
}
|
||||
}
|
||||
|
||||
function loadPersistedVodFilter(): string {
|
||||
try {
|
||||
return localStorage.getItem(VOD_FILTER_STORAGE_KEY) ?? '';
|
||||
@@ -222,7 +311,8 @@ function renderVodGridFromCurrentState(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = filterVodsByQuery(lastLoadedVods, vodFilterQuery);
|
||||
const sorted = sortVods(lastLoadedVods, vodSortKey);
|
||||
const filtered = filterVodsByQuery(sorted, vodFilterQuery);
|
||||
|
||||
if (filtered.length === 0 && vodFilterQuery.trim()) {
|
||||
setVodGridEmptyState(grid, UI_TEXT.vods.filterNoMatchTitle, UI_TEXT.vods.filterNoMatchText);
|
||||
|
||||
@@ -145,6 +145,10 @@ function applyLanguageToStaticUI(): void {
|
||||
setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder);
|
||||
setPlaceholder('vodFilterInput', UI_TEXT.vods.filterPlaceholder);
|
||||
setTitle('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle);
|
||||
setText('vodSortLabel', UI_TEXT.vods.sortLabel);
|
||||
if (typeof refreshVodSortSelectLabels === 'function') {
|
||||
refreshVodSortSelectLabels();
|
||||
}
|
||||
|
||||
const status = document.getElementById('statusText')?.textContent?.trim() || '';
|
||||
if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) {
|
||||
|
||||
@@ -52,6 +52,13 @@ async function init(): Promise<void> {
|
||||
if (vodFilterInput) vodFilterInput.value = vodFilterQuery;
|
||||
syncVodFilterClearButton();
|
||||
|
||||
// Restore persisted VOD sort key. Apply localized labels to <option>s
|
||||
// before syncing the select value so the right option is preselected
|
||||
// even on first load before any language change fires.
|
||||
vodSortKey = loadPersistedVodSort();
|
||||
refreshVodSortSelectLabels();
|
||||
syncVodSortSelect();
|
||||
|
||||
// Restore last active tab from previous session (default 'vods')
|
||||
showTab(loadPersistedActiveTab());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user