Keep a whole search, and say what is new in it

A search here is more than the words in the box: it is the source, the
channel, every filter that channel offers, and the query together. The
bookmark in the search bar keeps all of it, the one in the header opens
the list, and tapping a row puts the whole thing back.

Each saved search remembers the ids of the newest videos it showed the
last time it was opened. Once a day -- at startup, when the tab is looked
at again, and on a slow heartbeat for tabs left open -- the client asks
each one's first page again and counts how many of those videos it has
never shown. That count is the badge, on the row and summed in the
header, and it stops at 9+. Opening a saved search is viewing it, so the
count goes out and the mark moves to what is on screen now.

Nothing new leaves the device: the check is the same listing request the
grid would have made, and everything else is localStorage.

Also: handleSearch's "put this in the box without searching for it" half
is now App.videos.setSearchBox, which is what applying a saved search
needs.

tests/smoke_saved_searches.py covers it end to end against a fixture
listing whose ids the test shifts to mean "new uploads": 46 checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
This commit is contained in:
Simon
2026-10-17 05:59:19 +00:00
parent f977852253
commit bc7c0047b7
9 changed files with 1581 additions and 15 deletions

View File

@@ -139,7 +139,8 @@ body.theme-light .top-bar {
.search-container input {
width: 100%;
padding: 10px 44px 10px 16px;
/* Room on the right for the clear button and the bookmark beside it. */
padding: 10px 78px 10px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 999px;
@@ -162,7 +163,7 @@ body.theme-light .top-bar {
.search-clear-btn {
position: absolute;
top: 50%;
right: 12px;
right: 44px;
transform: translateY(-50%);
width: 26px;
height: 26px;
@@ -3092,3 +3093,363 @@ body.theme-light .video-card img:not(.is-loaded) {
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.privacy-veil { background: var(--bg-primary); }
}
/* =====================================================================
Saved searches (see savedSearches.js + App.ui.savedSearches)
One hairline per row and no boxes inside boxes: the list is read as a
document, with the brass reserved for the one thing that is news -- how
many videos a saved search has picked up since it was last opened.
===================================================================== */
/* Line icons drawn in place rather than fetched: these sit inside buttons
that change colour with state, and a tinted <img> cannot follow. */
.icon-line {
width: 22px;
height: 22px;
display: block;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
/* --- The bookmark in the search bar ---------------------------------- */
.search-save-btn {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
width: 30px;
height: 30px;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s ease, background 0.2s ease, transform 0.2s ease;
}
.search-save-btn:hover { color: var(--text-primary); background: var(--bg-tertiary); }
.search-save-btn:active { transform: translateY(-50%) scale(0.94); }
.search-save-btn:disabled { opacity: 0.4; cursor: default; }
/* Saved: the glyph fills in, which is the whole state in one look. */
.search-save-btn.is-saved { color: var(--accent); }
.search-save-btn.is-saved .icon-line { fill: currentColor; stroke: currentColor; }
@keyframes saved-flash {
0% { transform: translateY(-50%) scale(1); }
35% { transform: translateY(-50%) scale(1.22); }
100% { transform: translateY(-50%) scale(1); }
}
.search-save-btn.is-just-saved { animation: saved-flash 0.42s cubic-bezier(0.2, 0.7, 0.2, 1); }
/* --- The header button and its count --------------------------------- */
.saved-toggle { position: relative; }
.saved-badge {
position: absolute;
top: 6px;
right: 5px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: var(--accent);
color: var(--bg-primary);
font-size: 9.5px;
font-weight: 600;
letter-spacing: 0.02em;
font-variant-numeric: tabular-nums;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 0 2px rgba(20, 17, 13, 0.9);
}
body.theme-light .saved-badge { box-shadow: 0 0 0 2px rgba(246, 241, 231, 0.95); }
/* --- The list ---------------------------------------------------------- */
.saved-panel {
position: fixed;
inset: 0;
z-index: 4200;
display: none;
align-items: flex-start;
justify-content: center;
padding: 8vh 12px 12px 12px;
background: rgba(10, 8, 4, 0.55);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
}
.saved-panel.open { display: flex; }
.saved-box {
display: flex;
flex-direction: column;
width: min(620px, 100%);
max-height: min(78vh, 760px);
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 30px 70px var(--shadow);
overflow: hidden;
animation: card-rise 0.2s ease both;
}
.saved-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 20px 20px 14px 20px;
}
.saved-head h3 {
font-family: var(--font-display);
font-size: 19px;
font-weight: 600;
letter-spacing: -0.015em;
}
.saved-head-note {
margin-top: 4px;
max-width: 44ch;
font-size: 12px;
line-height: 1.5;
color: var(--text-secondary);
}
.saved-head-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.saved-list {
flex: 1;
overflow-y: auto;
padding: 0 10px;
overscroll-behavior: contain;
}
@keyframes saved-row-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
.saved-row {
position: relative;
display: flex;
align-items: center;
gap: 4px;
border-bottom: 1px solid var(--border);
animation: saved-row-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
animation-delay: calc(var(--i, 0) * 40ms);
}
.saved-list .saved-row:last-child { border-bottom: none; }
.saved-row-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 14px;
padding: 14px 10px;
border: none;
border-radius: var(--radius-md);
background: none;
color: var(--text-primary);
font-family: inherit;
text-align: left;
cursor: pointer;
transition: background 0.18s ease;
}
.saved-row-main:hover { background: var(--bg-tertiary); }
.saved-row-main:disabled { cursor: default; }
/* The count. Brass and filled when there is something new; a hairline ring
and a single dot when there isn't, so an up-to-date row reads as quiet
rather than as a zero. */
.saved-count {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 999px;
border: 1px solid var(--border-hover);
background: rgba(201, 165, 103, 0.16);
color: var(--accent);
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
display: flex;
align-items: center;
justify-content: center;
}
.saved-count.is-quiet {
border-color: var(--border);
background: transparent;
color: var(--text-secondary);
}
.saved-row.is-unreachable .saved-count { border-style: dashed; }
.saved-row-text {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.saved-row-name {
font-family: var(--font-display);
font-size: 15px;
font-weight: 500;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.saved-row-meta {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.saved-row-stamp {
font-size: 10px;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--text-secondary);
opacity: 0.8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.saved-row-tools {
display: flex;
gap: 2px;
flex-shrink: 0;
padding-right: 6px;
opacity: 0;
transition: opacity 0.18s ease;
}
.saved-row:hover .saved-row-tools,
.saved-row:focus-within .saved-row-tools { opacity: 1; }
.saved-tool {
width: 32px;
height: 32px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: none;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.18s ease, background 0.18s ease, border-color 0.18s ease;
}
.saved-tool .icon-line { width: 18px; height: 18px; }
.saved-tool:hover { color: var(--accent); background: var(--bg-tertiary); border-color: var(--border); }
.saved-rename {
width: 100%;
padding: 5px 9px;
background: var(--bg-tertiary);
border: 1px solid var(--border-hover);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-display);
font-size: 15px;
}
.saved-rename:focus { outline: none; box-shadow: 0 0 0 3px var(--focus-ring); }
/* Removing asks first, in the row itself -- no dialog on top of a dialog. */
.saved-confirm {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding-right: 8px;
}
.saved-confirm-text {
font-size: 12px;
color: var(--text-secondary);
}
.saved-confirm-btn {
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: none;
color: var(--text-primary);
font-family: inherit;
font-size: 12px;
cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
}
.saved-confirm-btn:hover { background: var(--bg-tertiary); border-color: var(--border-hover); }
.saved-confirm-btn.is-danger { color: #e2a2a2; }
body.theme-light .saved-confirm-btn.is-danger { color: #9f2f2d; }
.saved-confirm-btn.is-danger:hover { border-color: currentColor; }
.saved-row.is-confirming .saved-row-main { opacity: 0.5; }
.saved-empty {
padding: 4px 20px 20px 20px;
max-width: 52ch;
font-size: 13px;
line-height: 1.65;
color: var(--text-secondary);
}
.saved-foot {
padding: 14px 20px 18px 20px;
border-top: 1px solid var(--border);
}
.saved-add { width: 100%; }
.saved-add:disabled { opacity: 0.55; cursor: default; }
@media (max-width: 720px) {
.saved-panel { padding: 0; }
.saved-box {
width: 100%;
height: 100%;
max-height: 100%;
border: none;
border-radius: 0;
}
/* Nothing to hover with, so the row's tools are simply there. */
.saved-row-tools { opacity: 1; }
.saved-row-stamp { white-space: normal; }
}
@media (pointer: coarse) {
.saved-row-tools { opacity: 1; }
.search-save-btn { width: 34px; height: 34px; }
}