Offer the searches from before while you type
Searches are kept in local storage and the five that best fit what is in
the box are offered under it: the ones it starts, then the ones it starts
a word of, then the ones it appears in anywhere, most recently searched
first. Walk them with the arrows, take one with enter or a tap, dismiss
with escape. The part that matched is picked out, and rows are built from
text nodes -- these strings are the reader's own and they are going back
on screen.
The box searches as you type, so there is no submit to record and storing
every pause would fill the list with the way to a query rather than the
query: a new entry swallows the ones it grew out of ("hot", "hote",
"hotel" leaves "hotel"), and going back to something shorter adds
nothing. Enter records straight away, since that is a reader saying they
meant it.
That turned up a bug the suggestions would have run into constantly: a
search started while the box's own last search was still out was dropped
-- loadVideos saw a load running and returned -- leaving the grid on the
previous query. Changing the result set now stops the load that is
running before starting the next one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
This commit is contained in:
@@ -191,6 +191,53 @@ body.theme-light .top-bar {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Recent searches, under the box. The drawers' colours, but laid over the page
|
||||
background rather than letting it through: this one hangs over thumbnails,
|
||||
and a title showing through a suggestion is a title you try to read. */
|
||||
.search-suggest {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 120;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(var(--bg-secondary), var(--bg-secondary)), var(--bg-primary);
|
||||
box-shadow: 0 18px 40px var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-suggest-row {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.search-suggest-row:hover,
|
||||
.search-suggest-row.is-active {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* The part that matched what is typed, so the eye can see why a row is here. */
|
||||
.search-suggest-hit {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@@ -15,8 +15,11 @@
|
||||
<header class="top-bar">
|
||||
<div class="logo">Jacuzzi</div>
|
||||
<div class="search-container">
|
||||
<input type="text" id="search-input" placeholder="Search videos...">
|
||||
<input type="text" id="search-input" placeholder="Search videos..."
|
||||
autocomplete="off" role="combobox" aria-expanded="false"
|
||||
aria-controls="search-suggest" aria-autocomplete="list">
|
||||
<button class="search-clear-btn" id="search-clear-btn" type="button" aria-label="Clear search" title="Clear search">✕</button>
|
||||
<div id="search-suggest" class="search-suggest" role="listbox" aria-label="Recent searches" hidden></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="icon-btn reload-toggle" id="reload-channel-btn" title="Reload Channel">
|
||||
|
||||
@@ -24,7 +24,8 @@ App.constants = {
|
||||
FAVORITES_VISIBILITY_KEY: 'favoritesVisible',
|
||||
FAVORITES_SORT_KEY: 'favoritesSort',
|
||||
PREFERRED_QUALITY_KEY: 'preferredQuality',
|
||||
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior'
|
||||
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior',
|
||||
SEARCH_HISTORY_KEY: 'searchHistory'
|
||||
};
|
||||
|
||||
// Lazily injects hls.js the first time a stream actually needs it. Sessions
|
||||
|
||||
@@ -48,6 +48,73 @@ App.session = App.session || {};
|
||||
localStorage.setItem(FEED_END_BEHAVIOR_KEY, nextBehavior === 'scroll' ? 'scroll' : 'loop');
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Searches
|
||||
//
|
||||
// What was searched for before, newest first. The grid searches as you
|
||||
// type, so the box never sees a "submit" to record: every pause in the
|
||||
// typing is a search of its own and storing each one would fill the list
|
||||
// with the way to a query rather than the query. So a new entry swallows
|
||||
// the ones it grew out of -- typing "hot", "hote", "hotel" leaves "hotel"
|
||||
// -- and going back to something shorter than what is already stored adds
|
||||
// nothing, since that is a step back through the same typing.
|
||||
// ---------------------------------------------------------------------
|
||||
const SEARCH_HISTORY_MAX = 50;
|
||||
|
||||
App.storage.getSearches = function() {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(App.constants.SEARCH_HISTORY_KEY));
|
||||
return Array.isArray(stored) ? stored.filter((q) => typeof q === 'string' && q.trim()) : [];
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
App.storage.rememberSearch = function(query) {
|
||||
const next = (query || '').trim();
|
||||
if (!next) return App.storage.getSearches();
|
||||
const lower = next.toLowerCase();
|
||||
const stored = App.storage.getSearches();
|
||||
// Already on the way to something longer: nothing new to record.
|
||||
if (stored.some((q) => q.toLowerCase() !== lower && q.toLowerCase().startsWith(lower))) {
|
||||
return stored;
|
||||
}
|
||||
const kept = stored.filter((q) => !lower.startsWith(q.toLowerCase()));
|
||||
kept.unshift(next);
|
||||
const capped = kept.slice(0, SEARCH_HISTORY_MAX);
|
||||
try {
|
||||
localStorage.setItem(App.constants.SEARCH_HISTORY_KEY, JSON.stringify(capped));
|
||||
} catch (err) { /* a full or blocked store is not worth failing a search over */ }
|
||||
return capped;
|
||||
};
|
||||
|
||||
App.storage.forgetSearches = function() {
|
||||
localStorage.removeItem(App.constants.SEARCH_HISTORY_KEY);
|
||||
};
|
||||
|
||||
// The past searches worth offering for what is being typed, best first.
|
||||
// Best means where the typing lands in them -- at the front, at the start
|
||||
// of a word, anywhere at all -- and then which was searched for most
|
||||
// recently. What is already typed in full is not a suggestion.
|
||||
App.storage.suggestSearches = function(query, limit) {
|
||||
const want = Math.max(1, limit || 5);
|
||||
const typed = (query || '').trim().toLowerCase();
|
||||
const scored = [];
|
||||
App.storage.getSearches().forEach((entry, age) => {
|
||||
const candidate = entry.toLowerCase();
|
||||
if (candidate === typed) return;
|
||||
let rank = 0;
|
||||
if (typed) {
|
||||
const at = candidate.indexOf(typed);
|
||||
if (at < 0) return;
|
||||
rank = at === 0 ? 3 : (candidate[at - 1] === ' ' ? 2 : 1);
|
||||
}
|
||||
scored.push({ entry: entry, rank: rank, age: age });
|
||||
});
|
||||
scored.sort((a, b) => (b.rank - a.rank) || (a.age - b.age));
|
||||
return scored.slice(0, want).map((s) => s.entry);
|
||||
};
|
||||
|
||||
// Grid density: 'comfortable' (default) or 'compact' (more, smaller columns).
|
||||
App.storage.getDensity = function() {
|
||||
return localStorage.getItem('density') === 'compact' ? 'compact' : 'comfortable';
|
||||
|
||||
@@ -297,6 +297,181 @@ App.ui = App.ui || {};
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Recent searches
|
||||
//
|
||||
// The searches from before, offered under the box as you type. Which ones
|
||||
// and in what order is App.storage's business (suggestSearches); this is
|
||||
// the list itself: when it shows, how it is walked, and what taking one
|
||||
// does. Five at a time, because a list you have to read is not a shortcut.
|
||||
// ---------------------------------------------------------------------
|
||||
App.ui.searchHistory = (function() {
|
||||
const SHOWN = 5;
|
||||
let offered = [];
|
||||
let activeIndex = -1;
|
||||
let bound = false;
|
||||
|
||||
const input = () => document.getElementById('search-input');
|
||||
const list = () => document.getElementById('search-suggest');
|
||||
|
||||
const isOpen = function() {
|
||||
const el = list();
|
||||
return !!el && !el.hidden;
|
||||
};
|
||||
|
||||
const close = function() {
|
||||
const el = list();
|
||||
if (el) {
|
||||
el.hidden = true;
|
||||
el.textContent = ''; // no rows left behind for anything to find
|
||||
}
|
||||
offered = [];
|
||||
activeIndex = -1;
|
||||
const box = input();
|
||||
if (box) {
|
||||
box.setAttribute('aria-expanded', 'false');
|
||||
box.removeAttribute('aria-activedescendant');
|
||||
}
|
||||
};
|
||||
|
||||
// The typed part, picked out of the suggestion it matched. Built as
|
||||
// text nodes rather than markup: these strings are the reader's own,
|
||||
// and they are going back on screen.
|
||||
const rowLabel = function(entry, typed) {
|
||||
const label = document.createElement('span');
|
||||
label.className = 'search-suggest-label';
|
||||
const at = typed ? entry.toLowerCase().indexOf(typed.toLowerCase()) : -1;
|
||||
if (at < 0) {
|
||||
label.textContent = entry;
|
||||
return label;
|
||||
}
|
||||
label.appendChild(document.createTextNode(entry.slice(0, at)));
|
||||
const hit = document.createElement('span');
|
||||
hit.className = 'search-suggest-hit';
|
||||
hit.textContent = entry.slice(at, at + typed.length);
|
||||
label.appendChild(hit);
|
||||
label.appendChild(document.createTextNode(entry.slice(at + typed.length)));
|
||||
return label;
|
||||
};
|
||||
|
||||
const highlight = function(next) {
|
||||
const el = list();
|
||||
if (!el) return;
|
||||
const rows = Array.from(el.querySelectorAll('.search-suggest-row'));
|
||||
activeIndex = next;
|
||||
let activeId = '';
|
||||
rows.forEach((row, i) => {
|
||||
const on = i === activeIndex;
|
||||
row.classList.toggle('is-active', on);
|
||||
row.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
if (on) activeId = row.id;
|
||||
});
|
||||
const box = input();
|
||||
if (!box) return;
|
||||
if (activeId) box.setAttribute('aria-activedescendant', activeId);
|
||||
else box.removeAttribute('aria-activedescendant');
|
||||
};
|
||||
|
||||
const choose = function(query) {
|
||||
const box = input();
|
||||
close();
|
||||
if (!query) return;
|
||||
if (box) {
|
||||
box.value = query;
|
||||
box.blur(); // the list has served its purpose; give the page back
|
||||
}
|
||||
App.videos.handleSearch(query);
|
||||
};
|
||||
|
||||
// Shows what the box's current contents suggest, or hides the list when
|
||||
// nothing does.
|
||||
const refresh = function() {
|
||||
const box = input();
|
||||
const el = list();
|
||||
if (!box || !el) return;
|
||||
const typed = box.value.trim();
|
||||
offered = App.storage.suggestSearches(typed, SHOWN);
|
||||
if (!offered.length) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
el.textContent = '';
|
||||
offered.forEach((entry, i) => {
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'search-suggest-row';
|
||||
row.id = 'search-suggest-' + i;
|
||||
row.setAttribute('role', 'option');
|
||||
row.setAttribute('aria-selected', 'false');
|
||||
row.dataset.query = entry;
|
||||
row.appendChild(rowLabel(entry, typed));
|
||||
// Chosen on pointerdown, before the box loses focus: a click
|
||||
// would arrive after the blur that closes the list.
|
||||
row.addEventListener('pointerdown', (event) => {
|
||||
event.preventDefault();
|
||||
choose(entry);
|
||||
});
|
||||
row.addEventListener('mousemove', () => highlight(i));
|
||||
el.appendChild(row);
|
||||
});
|
||||
el.hidden = false;
|
||||
box.setAttribute('aria-expanded', 'true');
|
||||
highlight(-1);
|
||||
};
|
||||
|
||||
const onKeyDown = function(event) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
if (!isOpen()) {
|
||||
refresh();
|
||||
if (!isOpen()) return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const step = event.key === 'ArrowDown' ? 1 : -1;
|
||||
const count = offered.length;
|
||||
highlight(((activeIndex + step) % count + count) % count);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
const box = input();
|
||||
if (isOpen() && activeIndex >= 0) {
|
||||
event.preventDefault();
|
||||
choose(offered[activeIndex]);
|
||||
return;
|
||||
}
|
||||
// Typed and entered: a search the reader meant, so it is kept
|
||||
// now rather than waiting for the typing to settle.
|
||||
close();
|
||||
if (box) {
|
||||
App.storage.rememberSearch(box.value);
|
||||
App.videos.handleSearch(box.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape' && isOpen()) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation(); // Escape closes drawers too
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const bind = function() {
|
||||
const box = input();
|
||||
if (bound || !box) return;
|
||||
bound = true;
|
||||
box.addEventListener('focus', refresh);
|
||||
box.addEventListener('input', refresh);
|
||||
box.addEventListener('keydown', onKeyDown);
|
||||
box.addEventListener('blur', () => { window.setTimeout(close, 0); });
|
||||
document.addEventListener('pointerdown', (event) => {
|
||||
if (!isOpen()) return;
|
||||
if (event.target.closest('.search-container')) return;
|
||||
close();
|
||||
});
|
||||
};
|
||||
|
||||
return { bind: bind, refresh: refresh, close: close, isOpen: isOpen, shown: () => offered.slice() };
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Channel picker
|
||||
//
|
||||
@@ -1125,6 +1300,7 @@ App.ui = App.ui || {};
|
||||
// Update the clear button immediately for snappy feedback, but
|
||||
// debounce the actual reload so typing doesn't wipe the grid and
|
||||
// fire a backend request on every keystroke.
|
||||
App.ui.searchHistory.bind();
|
||||
searchInput.addEventListener('input', updateClearVisibility);
|
||||
searchInput.addEventListener('input', () => {
|
||||
if (searchDebounce) clearTimeout(searchDebounce);
|
||||
|
||||
@@ -922,6 +922,22 @@ App.videos = App.videos || {};
|
||||
// until the tab stops responding.
|
||||
let loadRunning = false;
|
||||
|
||||
// Stops whatever load is running and releases the guard in the same breath,
|
||||
// so the caller can start the next one straight away. Without this a search
|
||||
// typed while the box's own last search was still out was simply dropped --
|
||||
// loadVideos saw a load running and returned -- and the reader was left
|
||||
// looking at results for what they had typed a moment before. The aborted
|
||||
// call unwinds on its own: its fetch rejects, it commits nothing, and its
|
||||
// `finally` clears these again.
|
||||
const abortLoad = function() {
|
||||
if (state.currentLoadController) {
|
||||
state.currentLoadController.abort();
|
||||
state.currentLoadController = null;
|
||||
}
|
||||
loadRunning = false;
|
||||
state.isLoading = false;
|
||||
};
|
||||
|
||||
// Renders the next page: the prefetched one when it's ready (the common
|
||||
// case), otherwise fetched here and rendered as it lands. `opts.force` is for
|
||||
// callers that know they need content and can't be judged by where the grid
|
||||
@@ -1321,6 +1337,13 @@ App.videos = App.videos || {};
|
||||
clearBtn.disabled = !hasValue;
|
||||
}
|
||||
}
|
||||
// Every search worth running is a search worth remembering: this is
|
||||
// the one place they all come through -- the box, a tag, the command
|
||||
// palette. Storage decides what is worth keeping (see rememberSearch).
|
||||
const searched = document.getElementById('search-input');
|
||||
if (App.storage && typeof App.storage.rememberSearch === 'function') {
|
||||
App.storage.rememberSearch(searched ? searched.value : value);
|
||||
}
|
||||
// A search is a new result set, so it leaves the favorites grid.
|
||||
if (App.favoritesView && App.favoritesView.isActive()) {
|
||||
App.favoritesView.close({ silent: true });
|
||||
@@ -1334,6 +1357,7 @@ App.videos = App.videos || {};
|
||||
App.videos.resetGrid = function() {
|
||||
// The held/in-flight page belongs to the old result set.
|
||||
App.videos.resetPrefetch();
|
||||
abortLoad();
|
||||
state.currentPage = 1;
|
||||
state.hasNextPage = true;
|
||||
state.renderedVideoIds.clear();
|
||||
|
||||
Reference in New Issue
Block a user