Keep search results with thumbnails until cleared in Settings; add stale-while-revalidate search and longer asset caching
This commit is contained in:
219
frontend/app.js
219
frontend/app.js
@@ -135,31 +135,89 @@ const RecentSearches = (() => {
|
||||
list.unshift(q);
|
||||
const trimmed = list.slice(0, MAX);
|
||||
save(trimmed);
|
||||
pruneResults(trimmed);
|
||||
},
|
||||
remove(q) {
|
||||
const list = load().filter((x) => x.toLowerCase() !== (q || '').toLowerCase());
|
||||
save(list);
|
||||
pruneResults(list);
|
||||
},
|
||||
clear() { save([]); saveResults({}); },
|
||||
clear() { save([]); }, // saved results stay in SearchLibrary (cleared only from Settings)
|
||||
// Cache the just-fetched result set for `q` (called on every successful search).
|
||||
cacheResults(q, results) {
|
||||
q = (q || '').trim();
|
||||
if (!q || !Array.isArray(results) || !results.length) return;
|
||||
const r = loadResults();
|
||||
r[norm(q)] = { query: q, results: results.slice(0, RESULTS_PER_QUERY_MAX), ts: Date.now() };
|
||||
saveResults(r);
|
||||
pruneResults(load());
|
||||
},
|
||||
cacheResults(q, results) { SearchLibrary.put(q, results); },
|
||||
// Cached {query, results, ts} for `q`, or null.
|
||||
getResults(q) {
|
||||
const hit = loadResults()[norm(q)];
|
||||
return hit && Array.isArray(hit.results) && hit.results.length ? hit : null;
|
||||
return SearchLibrary.get(q);
|
||||
},
|
||||
// Normalized queries that have a cached result set — one localStorage
|
||||
// read for the whole dropdown, rather than one per row.
|
||||
cachedQueries() { return new Set(Object.keys(loadResults())); },
|
||||
cachedQueries() { return new Set(SearchLibrary.list().map((x) => x.key)); },
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Search library — every result set (with thumbnail URLs) is kept on this device
|
||||
// ============================================================================
|
||||
// Clearing the search box or the recent-searches dropdown never deletes these;
|
||||
// they are removed only from Settings → Saved searches (all, or a selection).
|
||||
// The thumbnails themselves live in the service worker's `ytplayer-thumbs`
|
||||
// cache (warmed below), so a saved search renders with its pictures offline.
|
||||
const SearchLibrary = (() => {
|
||||
const KEY = 'ytpSearchLibrary';
|
||||
const LEGACY = 'ytpRecentSearchResults';
|
||||
const MAX_QUERIES = 150;
|
||||
const PER_QUERY = 50;
|
||||
const norm = (q) => String(q || '').trim().toLowerCase();
|
||||
let mem = null;
|
||||
function read() {
|
||||
if (mem) return mem;
|
||||
let o = {};
|
||||
try { o = JSON.parse(localStorage.getItem(KEY) || '{}') || {}; } catch { o = {}; }
|
||||
// One-time import of the old per-recent-search cache.
|
||||
try {
|
||||
const old = JSON.parse(localStorage.getItem(LEGACY) || 'null');
|
||||
if (old && typeof old === 'object') {
|
||||
for (const [k, v] of Object.entries(old)) if (v && Array.isArray(v.results) && !o[k]) o[k] = v;
|
||||
localStorage.removeItem(LEGACY);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
mem = o;
|
||||
return mem;
|
||||
}
|
||||
function write() {
|
||||
const o = read();
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
try { localStorage.setItem(KEY, JSON.stringify(o)); return true; } catch {
|
||||
// Quota: drop the oldest fifth and retry.
|
||||
const keys = Object.keys(o).sort((a, b) => (o[a].ts || 0) - (o[b].ts || 0));
|
||||
keys.slice(0, Math.max(1, Math.ceil(keys.length / 5))).forEach((k) => delete o[k]);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return {
|
||||
put(q, results) {
|
||||
q = String(q || '').trim();
|
||||
if (!q || !Array.isArray(results) || !results.length) return;
|
||||
const o = read();
|
||||
o[norm(q)] = { query: q, results: results.slice(0, PER_QUERY), ts: Date.now() };
|
||||
const keys = Object.keys(o);
|
||||
if (keys.length > MAX_QUERIES) {
|
||||
keys.sort((a, b) => (o[a].ts || 0) - (o[b].ts || 0)).slice(0, keys.length - MAX_QUERIES).forEach((k) => delete o[k]);
|
||||
}
|
||||
write();
|
||||
},
|
||||
get(q) { const h = read()[norm(q)]; return h && Array.isArray(h.results) && h.results.length ? h : null; },
|
||||
has(q) { return !!read()[norm(q)]; },
|
||||
// Newest first: [{ query, count, ts, thumb }]
|
||||
list() {
|
||||
const o = read();
|
||||
return Object.keys(o).map((k) => ({ key: k, query: o[k].query || k, count: (o[k].results || []).length, ts: o[k].ts || 0, thumb: ((o[k].results || [])[0] || {}).thumbnail || '' }))
|
||||
.sort((a, b) => b.ts - a.ts);
|
||||
},
|
||||
remove(keys) { const o = read(); (keys || []).forEach((k) => delete o[k]); write(); },
|
||||
clear() { mem = {}; try { localStorage.removeItem(KEY); } catch { /* blocked */ } },
|
||||
bytes() { try { return (localStorage.getItem(KEY) || '').length; } catch { return 0; } },
|
||||
count() { return Object.keys(read()).length; },
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -1242,6 +1300,14 @@ function startCacheResyncWatch() {
|
||||
// is only cached once something has actually requested it. Pull it through the
|
||||
// service worker (which caches thumbnail responses) so a video saved for
|
||||
// offline still has its art when the network is gone.
|
||||
// Pull a result set's artwork into the service worker's thumbnail cache, a few at
|
||||
// a time, so a saved search still has pictures offline.
|
||||
function warmSearchThumbs(results) {
|
||||
const urls = (results || []).map((r) => r && r.thumbnail).filter(Boolean).slice(0, 50);
|
||||
let i = 0;
|
||||
const next = () => { if (i >= urls.length) return; warmThumb(urls[i++]); setTimeout(next, 40); };
|
||||
next(); next(); next();
|
||||
}
|
||||
function warmThumb(url) {
|
||||
if (!WEB || !url || !/^https?:/i.test(url)) return;
|
||||
fetch(url, { mode: 'cors', credentials: 'omit' }).catch(() => {});
|
||||
@@ -4482,7 +4548,7 @@ const Remote = (() => {
|
||||
} catch { found = []; }
|
||||
results.innerHTML = found.length ? found.slice(0, 20).map((v, i) => `
|
||||
<button class="rv-add-item" type="button" data-k="${i}">
|
||||
<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" />
|
||||
<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" decoding="async" />
|
||||
<span><b>${escapeHtml(v.title || '')}</b><small>${escapeHtml(v.channel || '')}${v.duration ? ' · ' + clock(v.duration) : ''}</small></span>
|
||||
</button>`).join('') : '<div class="rv-empty">No results.</div>';
|
||||
});
|
||||
@@ -6905,7 +6971,7 @@ function renderUpNext() {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'upnext-item';
|
||||
item.innerHTML = `
|
||||
<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" />
|
||||
<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" decoding="async" />
|
||||
<div class="upnext-info">
|
||||
<div class="upnext-title"></div>
|
||||
<div class="upnext-channel"></div>
|
||||
@@ -7333,7 +7399,7 @@ function renderHomePlaylists() {
|
||||
card.type = 'button';
|
||||
card.innerHTML = `
|
||||
<span class="home-pl-thumb">
|
||||
${thumb ? `<img src="${escapeHtml(thumb)}" alt="" loading="lazy" />`
|
||||
${thumb ? `<img src="${escapeHtml(thumb)}" alt="" loading="lazy" decoding="async" />`
|
||||
: '<span class="home-pl-thumb-fallback">🎵</span>'}
|
||||
${offlineHtml}
|
||||
<span class="home-pl-play" title="Play all">▶</span>
|
||||
@@ -7414,6 +7480,7 @@ function renderList() {
|
||||
if (view.type === 'settings') { renderSettings(); return; }
|
||||
if (view.type === 'saved') { renderSaved(); return; }
|
||||
if (view.type === 'p2p') { renderP2pList(); return; }
|
||||
if (view.type === 'savedSearches') { renderSavedSearches(); return; }
|
||||
if (view.type === 'downloads') { renderDownloads(); return; }
|
||||
if (view.type === 'stats') { renderStats(); return; }
|
||||
if (view.type === 'notes') { renderNotesView(); return; }
|
||||
@@ -7776,6 +7843,47 @@ async function renderP2pStrip() {
|
||||
box.classList.remove('hidden');
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Saved searches — browse (read-only here; deleting happens in Settings)
|
||||
// ============================================================================
|
||||
function fmtAgo(ts) {
|
||||
const s = Math.max(0, (Date.now() - ts) / 1000);
|
||||
if (s < 90) return 'just now';
|
||||
if (s < 3600) return Math.round(s / 60) + ' min ago';
|
||||
if (s < 86400) return Math.round(s / 3600) + ' h ago';
|
||||
return Math.round(s / 86400) + ' d ago';
|
||||
}
|
||||
function renderSavedSearches() {
|
||||
els.listTitle.textContent = '📚 Saved searches';
|
||||
els.listActions.innerHTML = '';
|
||||
els.status.classList.add('hidden');
|
||||
const c = els.cards;
|
||||
c.innerHTML = '';
|
||||
const items = SearchLibrary.list();
|
||||
if (!items.length) {
|
||||
c.innerHTML = '<div class="empty-state"><div class="empty-icon">📚</div><h3 class="empty-title">No saved searches yet</h3><p class="empty-desc">Every search you run is kept here with its thumbnails, even after you clear the search box. You can remove them in Settings.</p></div>';
|
||||
return;
|
||||
}
|
||||
const sum = document.createElement('div');
|
||||
sum.className = 'saved-summary';
|
||||
sum.innerHTML = `<span class="saved-total">${items.length}</span><span class="saved-sub">saved search${items.length === 1 ? '' : 'es'} · ${fmtBytes(SearchLibrary.bytes())} · manage in Settings</span>`;
|
||||
c.appendChild(sum);
|
||||
items.forEach((it) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'card saved-card ss-card';
|
||||
row.innerHTML = `
|
||||
<div class="thumb"><img loading="lazy" decoding="async" src="${escapeHtml(it.thumb)}" alt="" onerror="this.classList.add('no-art')" /></div>
|
||||
<div class="card-info"><div class="card-title"></div><div class="card-channel">${it.count} result${it.count === 1 ? '' : 's'} · ${fmtAgo(it.ts)}</div></div>`;
|
||||
row.querySelector('.card-title').textContent = it.query;
|
||||
row.addEventListener('click', () => {
|
||||
els.searchInput.value = it.query;
|
||||
if (els.runSearch) els.runSearch(it.query, { instant: SearchLibrary.get(it.query) });
|
||||
});
|
||||
c.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function renderP2pList() {
|
||||
els.listTitle.textContent = '📡 On other devices';
|
||||
els.listActions.innerHTML = '';
|
||||
@@ -7822,7 +7930,7 @@ async function renderP2pList() {
|
||||
row.dataset.id = it.id;
|
||||
const state = it.online ? `<b class="p2p-on">● ${it.online} online</b>` : '<span class="p2p-off">○ none online</span>';
|
||||
row.innerHTML = `
|
||||
<div class="thumb"><img loading="lazy" src="${escapeHtml(v.thumbnail)}" alt="" onerror="this.classList.add('no-art')" /></div>
|
||||
<div class="thumb"><img loading="lazy" decoding="async" src="${escapeHtml(v.thumbnail)}" alt="" onerror="this.classList.add('no-art')" /></div>
|
||||
<div class="card-info">
|
||||
<div class="card-title"></div>
|
||||
<div class="card-channel">${state} · ${it.holders} device${it.holders === 1 ? '' : 's'} · ${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''}${it.stale ? ' · <i>not checked lately</i>' : ''}</div>
|
||||
@@ -7923,7 +8031,7 @@ async function renderSaved() {
|
||||
row.className = 'card saved-card' + (isCustom ? ' custom' : '');
|
||||
row.dataset.id = it.id;
|
||||
row.innerHTML = `
|
||||
<div class="thumb"><img loading="lazy" src="${escapeHtml(v.thumbnail || thumbUrlFor(v.id, null) || '')}" alt=""
|
||||
<div class="thumb"><img loading="lazy" decoding="async" src="${escapeHtml(v.thumbnail || thumbUrlFor(v.id, null) || '')}" alt=""
|
||||
onerror="this.classList.add('no-art')" />${isCustom ? '<span class="edit-badge" title="Edited copy">✂</span>' : ''}${isUploadId(v.id) ? `<span class="upload-badge" title="On this server">${v.kind === 'audio' ? '♪' : '📁'}</span>` : ''}</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title"></div>
|
||||
@@ -7990,7 +8098,7 @@ function renderDownloads() {
|
||||
row.dataset.id = v.id;
|
||||
row.innerHTML = `
|
||||
<div class="thumb">
|
||||
<img loading="lazy" src="${escapeHtml(v.thumbnail || '')}" alt="" />
|
||||
<img loading="lazy" decoding="async" src="${escapeHtml(v.thumbnail || '')}" alt="" />
|
||||
<div class="dl-progress"><div class="dl-bar"></div></div>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
@@ -8179,6 +8287,23 @@ async function renderSettings() {
|
||||
<div id="cacheList" class="cache-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-title">Saved searches</div>
|
||||
<div class="set-row">
|
||||
<span>
|
||||
Kept on this device
|
||||
<small>Search results and their thumbnails stay saved after you clear the search box. Only you can remove them, here.</small>
|
||||
</span>
|
||||
<span id="ssTotal" class="set-stat">…</span>
|
||||
</div>
|
||||
<div class="set-actions">
|
||||
<button id="ssSelectAll" class="btn">Select all</button>
|
||||
<button id="ssDeleteSel" class="btn danger" disabled>Delete selected</button>
|
||||
<button id="ssClearAll" class="btn danger">Clear all</button>
|
||||
</div>
|
||||
<div id="ssList" class="ss-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-title">Playlists</div>
|
||||
<div class="set-row" style="border:none;gap:8px;flex-wrap:wrap">
|
||||
@@ -8288,6 +8413,40 @@ async function renderSettings() {
|
||||
$('setRepeat').addEventListener('change', (e) => { data.settings.repeatMode = e.target.checked ? 'all' : 'off'; updateLoopRepeatButtons(); persist(); });
|
||||
$('setLoopOne').addEventListener('change', (e) => { data.settings.loopOne = e.target.checked; updateLoopRepeatButtons(); persist(); });
|
||||
|
||||
|
||||
// ---- Saved searches manager ----
|
||||
{
|
||||
const listEl = $('ssList');
|
||||
const sel = new Set();
|
||||
const refresh = () => {
|
||||
const items = SearchLibrary.list();
|
||||
for (const k of [...sel]) if (!items.some((i) => i.key === k)) sel.delete(k);
|
||||
$('ssTotal').textContent = items.length ? `${items.length} · ${fmtBytes(SearchLibrary.bytes())}` : 'None';
|
||||
listEl.innerHTML = items.length ? items.map((i) => `
|
||||
<label class="ss-row"><input type="checkbox" data-k="${escapeHtml(i.key)}" ${sel.has(i.key) ? 'checked' : ''} />
|
||||
<span class="ss-q">${escapeHtml(i.query)}</span><small>${i.count} · ${fmtAgo(i.ts)}</small></label>`).join('') : '<div class="muted" style="padding:8px 2px">No saved searches.</div>';
|
||||
$('ssDeleteSel').disabled = !sel.size;
|
||||
$('ssDeleteSel').textContent = sel.size ? `Delete selected (${sel.size})` : 'Delete selected';
|
||||
$('ssSelectAll').textContent = items.length && sel.size === items.length ? 'Select none' : 'Select all';
|
||||
$('ssClearAll').disabled = !items.length;
|
||||
};
|
||||
listEl.addEventListener('change', (e) => { const k = e.target.dataset && e.target.dataset.k; if (!k) return; e.target.checked ? sel.add(k) : sel.delete(k); refresh(); });
|
||||
$('ssSelectAll').addEventListener('click', () => {
|
||||
const items = SearchLibrary.list();
|
||||
if (sel.size === items.length) sel.clear(); else items.forEach((i) => sel.add(i.key));
|
||||
refresh();
|
||||
});
|
||||
$('ssDeleteSel').addEventListener('click', () => {
|
||||
if (!sel.size || !confirm(`Delete ${sel.size} saved search${sel.size === 1 ? '' : 'es'}? Their results and thumbnails are removed from this device.`)) return;
|
||||
SearchLibrary.remove([...sel]); sel.clear(); refresh(); toast('Saved searches deleted');
|
||||
});
|
||||
$('ssClearAll').addEventListener('click', () => {
|
||||
if (!confirm('Delete ALL saved searches and their thumbnails from this device?')) return;
|
||||
SearchLibrary.clear(); sel.clear(); refresh(); toast('All saved searches deleted');
|
||||
});
|
||||
refresh();
|
||||
}
|
||||
|
||||
// ---- Wire playback controls ----
|
||||
$('setQuality').addEventListener('change', (e) => {
|
||||
data.settings.quality = e.target.value;
|
||||
@@ -8520,7 +8679,7 @@ function renderCard(v, index, list) {
|
||||
card.innerHTML = `
|
||||
<div class="batch-check"></div>
|
||||
<div class="thumb">
|
||||
<img loading="lazy" src="${escapeHtml(v.thumbnail || '')}" alt="" />
|
||||
<img loading="lazy" decoding="async" src="${escapeHtml(v.thumbnail || '')}" alt="" />
|
||||
${v.duration ? `<span class="dur">${fmtTime(v.duration)}</span>` : ''}
|
||||
<span class="saved-badge" title="${isDownloading ? 'Downloading…' : 'Saved offline'}">${isDownloading ? '' : '⬇'}</span>
|
||||
${isDownloading ? '<div class="dl-progress"><div class="dl-bar"></div></div>' : ''}
|
||||
@@ -9404,6 +9563,7 @@ function wireUI() {
|
||||
searchAttempted = true;
|
||||
renderList();
|
||||
RecentSearches.cacheResults(q, results);
|
||||
warmSearchThumbs(results);
|
||||
warmStreams(results.slice(0, 3).map((r) => r.id));
|
||||
perfMeasure('ytp:search', 'ytp:search-start');
|
||||
} catch (err) {
|
||||
@@ -9415,6 +9575,7 @@ function wireUI() {
|
||||
}
|
||||
}
|
||||
|
||||
els.runSearch = runSearchQuery;
|
||||
els.searchForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
runSearchQuery(els.searchInput.value);
|
||||
@@ -9423,7 +9584,7 @@ function wireUI() {
|
||||
// ---- Recent searches dropdown (device-local; see RecentSearches above) ----
|
||||
function renderRecentSearches() {
|
||||
const list = RecentSearches.list();
|
||||
if (!list.length) { els.searchRecent.innerHTML = ''; return; }
|
||||
if (!list.length && !SearchLibrary.count()) { els.searchRecent.innerHTML = ''; return; }
|
||||
const cachedQueries = RecentSearches.cachedQueries();
|
||||
els.searchRecent.innerHTML = list.map((q) => {
|
||||
const isCached = cachedQueries.has(q.trim().toLowerCase());
|
||||
@@ -9432,11 +9593,12 @@ function wireUI() {
|
||||
<span class="sr-icon" title="${isCached ? 'Loads instantly, then refreshes' : 'Recent search'}">${isCached ? '⚡' : '🕐'}</span><span class="sr-text">${escapeHtml(q)}</span>
|
||||
<button type="button" class="sr-remove" data-remove="${escapeHtml(q)}" aria-label="Remove ‘${escapeHtml(q)}’ from recent searches">✕</button>
|
||||
</div>`;
|
||||
}).join('') + '<button type="button" class="search-recent-clear" data-clear-all>Clear recent searches</button>';
|
||||
}).join('') + `<button type="button" class="search-recent-clear" data-clear-all>Clear this list <small>(saved results are kept)</small></button>`
|
||||
+ (SearchLibrary.count() ? `<button type="button" class="search-recent-clear" data-open-saved>📚 Saved searches (${SearchLibrary.count()})</button>` : '');
|
||||
}
|
||||
function showRecentSearches() {
|
||||
renderRecentSearches();
|
||||
if (!RecentSearches.list().length) return;
|
||||
if (!RecentSearches.list().length && !SearchLibrary.count()) return;
|
||||
els.searchRecent.classList.remove('hidden');
|
||||
}
|
||||
function hideRecentSearches() { els.searchRecent.classList.add('hidden'); }
|
||||
@@ -9455,6 +9617,13 @@ function wireUI() {
|
||||
showRecentSearches();
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('[data-open-saved]')) {
|
||||
e.preventDefault();
|
||||
hideRecentSearches();
|
||||
view = { type: 'savedSearches' };
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('[data-clear-all]')) {
|
||||
e.preventDefault();
|
||||
RecentSearches.clear();
|
||||
@@ -10105,7 +10274,7 @@ function renderRelated() {
|
||||
relatedVideos.forEach((v) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'related-item';
|
||||
item.innerHTML = `<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" /><div class="ri-info"><div class="ri-title"></div><div class="ri-channel"></div></div>`;
|
||||
item.innerHTML = `<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" decoding="async" /><div class="ri-info"><div class="ri-title"></div><div class="ri-channel"></div></div>`;
|
||||
item.querySelector('.ri-title').textContent = v.title;
|
||||
const riChEl = item.querySelector('.ri-channel');
|
||||
riChEl.textContent = v.channel || '';
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<link rel="preload" href="fonts/BricolageGrotesque-latin.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<link rel="preload" href="fonts/HankenGrotesk-latin.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<link rel="stylesheet" href="fonts/fonts.css" />
|
||||
<link rel="preconnect" href="https://i.ytimg.com" crossorigin />
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<link rel="stylesheet" href="service-ui.css" />
|
||||
<link rel="stylesheet" href="refresh.css" />
|
||||
|
||||
@@ -535,3 +535,13 @@
|
||||
}
|
||||
@media (max-width: 380px) { #nowPlayingMeta .np-actions #addBtn, #nowPlayingMeta .np-actions #serviceBtn { flex-basis: calc(50% - 8px); } }
|
||||
.player-stage { flex-shrink: 0; }
|
||||
|
||||
/* Saved searches */
|
||||
.ss-list { max-height: 280px; overflow-y: auto; margin-top: 8px; border: 1px solid var(--line); border-radius: 14px; }
|
||||
.ss-row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--line-soft, var(--line)); cursor: pointer; }
|
||||
.ss-row:last-child { border-bottom: 0; }
|
||||
.ss-row input { width: 18px; height: 18px; accent-color: var(--accent); flex: none; }
|
||||
.ss-q { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ss-row small { color: var(--text-dim); flex: none; font-size: 11px; }
|
||||
.search-recent-clear small { opacity: 0.6; }
|
||||
.topbar { position: relative; z-index: 80; } /* keeps the recent-searches dropdown above the page content */
|
||||
|
||||
@@ -46,7 +46,7 @@ const THUMB_HOSTS = new Set([
|
||||
]);
|
||||
|
||||
// Thumbnails accumulate with every card ever scrolled past — keep it bounded.
|
||||
const THUMB_CACHE_MAX = 800;
|
||||
const THUMB_CACHE_MAX = 2500;
|
||||
function isVersionedShellCache(key) {
|
||||
return key.startsWith(SHELL_CACHE_PREFIX) && !UTILITY_CACHES.has(key);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user