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:
@@ -139,7 +139,8 @@ body.theme-light .top-bar {
|
|||||||
|
|
||||||
.search-container input {
|
.search-container input {
|
||||||
width: 100%;
|
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);
|
background: var(--bg-tertiary);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -162,7 +163,7 @@ body.theme-light .top-bar {
|
|||||||
.search-clear-btn {
|
.search-clear-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
right: 12px;
|
right: 44px;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 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))) {
|
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||||
.privacy-veil { background: var(--bg-primary); }
|
.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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,12 +19,26 @@
|
|||||||
autocomplete="off" role="combobox" aria-expanded="false"
|
autocomplete="off" role="combobox" aria-expanded="false"
|
||||||
aria-controls="search-suggest" aria-autocomplete="list">
|
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>
|
<button class="search-clear-btn" id="search-clear-btn" type="button" aria-label="Clear search" title="Clear search">✕</button>
|
||||||
|
<button class="search-save-btn" id="search-save-btn" type="button" aria-pressed="false"
|
||||||
|
aria-label="Save this search" title="Save this search">
|
||||||
|
<svg class="icon-line" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M6.5 4.75h11a.75.75 0 0 1 .75.75v13.3l-6.25-4.1-6.25 4.1V5.5a.75.75 0 0 1 .75-.75z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<div id="search-suggest" class="search-suggest" role="listbox" aria-label="Recent searches" hidden></div>
|
<div id="search-suggest" class="search-suggest" role="listbox" aria-label="Recent searches" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="icon-btn reload-toggle" id="reload-channel-btn" title="Reload Channel">
|
<button class="icon-btn reload-toggle" id="reload-channel-btn" title="Reload Channel">
|
||||||
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/arrow-path.svg" alt="Reload">
|
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/arrow-path.svg" alt="Reload">
|
||||||
</button>
|
</button>
|
||||||
|
<button class="icon-btn saved-toggle" id="saved-searches-btn" type="button"
|
||||||
|
title="Saved searches" aria-label="Saved searches" aria-haspopup="dialog" aria-expanded="false">
|
||||||
|
<svg class="icon-line" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M8 3.75h8a1 1 0 0 1 1 1V17l-5-3.2L7 17V4.75a1 1 0 0 1 1-1z"/>
|
||||||
|
<path d="M18.5 6.5h.5a1 1 0 0 1 1 1v12.75l-3-2" stroke-opacity="0.55"/>
|
||||||
|
</svg>
|
||||||
|
<span class="saved-badge" id="saved-searches-badge" aria-hidden="true" hidden></span>
|
||||||
|
</button>
|
||||||
<button class="icon-btn menu-toggle" onclick="toggleDrawer('menu')" title="Menu">
|
<button class="icon-btn menu-toggle" onclick="toggleDrawer('menu')" title="Menu">
|
||||||
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/bars-3.svg" alt="Menu">
|
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/bars-3.svg" alt="Menu">
|
||||||
</button>
|
</button>
|
||||||
@@ -229,6 +243,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="saved-panel" class="saved-panel" aria-hidden="true">
|
||||||
|
<div class="saved-box" role="dialog" aria-modal="true" aria-labelledby="saved-panel-title">
|
||||||
|
<div class="saved-head">
|
||||||
|
<div class="saved-head-text">
|
||||||
|
<h3 id="saved-panel-title">Saved searches</h3>
|
||||||
|
<p class="saved-head-note" id="saved-head-note"></p>
|
||||||
|
</div>
|
||||||
|
<div class="saved-head-actions">
|
||||||
|
<button id="saved-check-btn" class="btn-link" type="button">Check now</button>
|
||||||
|
<button id="saved-close-btn" class="close-btn" type="button" aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="saved-list" class="saved-list"></div>
|
||||||
|
<div id="saved-empty" class="saved-empty">
|
||||||
|
Nothing saved yet. Pick a channel, set your filters, type a search —
|
||||||
|
then press the bookmark in the search bar to keep the whole thing.
|
||||||
|
</div>
|
||||||
|
<div class="saved-foot">
|
||||||
|
<button id="saved-add-btn" class="btn-secondary saved-add" type="button">Save the current search</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="privacy-veil" class="privacy-veil" aria-hidden="true"></div>
|
<div id="privacy-veil" class="privacy-veil" aria-hidden="true"></div>
|
||||||
|
|
||||||
<script src="static/js/state.js"></script>
|
<script src="static/js/state.js"></script>
|
||||||
@@ -241,6 +278,7 @@
|
|||||||
<script src="static/js/sqlite.js"></script>
|
<script src="static/js/sqlite.js"></script>
|
||||||
<script src="static/js/hottubBackup.js"></script>
|
<script src="static/js/hottubBackup.js"></script>
|
||||||
<script src="static/js/videos.js"></script>
|
<script src="static/js/videos.js"></script>
|
||||||
|
<script src="static/js/savedSearches.js"></script>
|
||||||
<script src="static/js/feed.js"></script>
|
<script src="static/js/feed.js"></script>
|
||||||
<script src="static/js/ui.js"></script>
|
<script src="static/js/ui.js"></script>
|
||||||
<script src="static/js/version.js"></script>
|
<script src="static/js/version.js"></script>
|
||||||
|
|||||||
@@ -185,6 +185,26 @@ App.enhance = App.enhance || {};
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Saved searches: the list itself, the one-press save, and every
|
||||||
|
// saved search as a row of its own so ⌘K can jump straight to one.
|
||||||
|
if (App.savedSearches && App.ui && App.ui.savedSearches) {
|
||||||
|
const saved = App.savedSearches.getAll();
|
||||||
|
out.push({ label: 'Saved searches', hint: saved.length ? `${saved.length} saved` : 'Nothing saved yet', run: () => App.ui.savedSearches.open() });
|
||||||
|
const match = App.savedSearches.findMatching();
|
||||||
|
out.push({
|
||||||
|
label: match ? `Remove saved search: ${match.name}` : 'Save the current search',
|
||||||
|
hint: 'Source · channel · filters · query',
|
||||||
|
run: () => App.ui.savedSearches.toggleCurrent()
|
||||||
|
});
|
||||||
|
saved.forEach((entry) => {
|
||||||
|
const label = App.savedSearches.badgeLabel(entry.newCount);
|
||||||
|
out.push({
|
||||||
|
label: entry.name,
|
||||||
|
hint: label ? `Saved search · ${label} new` : 'Saved search',
|
||||||
|
run: () => App.savedSearches.apply(entry.id)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
out.push({ label: 'Reload channel', hint: 'Refresh the current feed', run: () => { if (App.videos) App.videos.resetAndReload(); } });
|
out.push({ label: 'Reload channel', hint: 'Refresh the current feed', run: () => { if (App.videos) App.videos.resetAndReload(); } });
|
||||||
out.push({ label: 'Open Menu', hint: 'Source · channel · filters', run: () => { if (App.ui) App.ui.toggleDrawer('menu'); } });
|
out.push({ label: 'Open Menu', hint: 'Source · channel · filters', run: () => { if (App.ui) App.ui.toggleDrawer('menu'); } });
|
||||||
out.push({ label: 'Open Settings', hint: 'Preferences', run: () => { if (App.ui) App.ui.toggleDrawer('settings'); } });
|
out.push({ label: 'Open Settings', hint: 'Preferences', run: () => { if (App.ui) App.ui.toggleDrawer('settings'); } });
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ window.App = window.App || {};
|
|||||||
await App.videos.loadVideos();
|
await App.videos.loadVideos();
|
||||||
App.favorites.syncButtons();
|
App.favorites.syncButtons();
|
||||||
|
|
||||||
|
// The grid is showing something now. If that something is one of the
|
||||||
|
// saved searches, the reader is looking at it -- so it stops being
|
||||||
|
// "new" from here, and the once-a-day check starts running.
|
||||||
|
App.savedSearches.syncViewed();
|
||||||
|
App.savedSearches.start();
|
||||||
|
|
||||||
// The UI above is rendered entirely from the last known status cached in
|
// The UI above is rendered entirely from the last known status cached in
|
||||||
// localStorage, so startup never blocks on (or breaks because of) a slow
|
// localStorage, so startup never blocks on (or breaks because of) a slow
|
||||||
// or failing status endpoint. Now fetch fresh status in the background and
|
// or failing status endpoint. Now fetch fresh status in the background and
|
||||||
|
|||||||
425
frontend/js/savedSearches.js
Normal file
425
frontend/js/savedSearches.js
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
window.App = window.App || {};
|
||||||
|
App.savedSearches = App.savedSearches || {};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Saved searches
|
||||||
|
//
|
||||||
|
// 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. Set all
|
||||||
|
// that up once -- "uncensored JAV, newest first, 1080p and up" -- and this
|
||||||
|
// keeps it, so getting back to it is one tap instead of five.
|
||||||
|
//
|
||||||
|
// Each saved search also remembers what was on screen the last time it was
|
||||||
|
// opened (the ids of the newest videos it showed). Once a day the client asks
|
||||||
|
// each one's first page again and counts how many of those videos it has never
|
||||||
|
// shown the reader, which is the number on the badge. Nothing is counted
|
||||||
|
// server-side and nothing leaves the device but the same listing request the
|
||||||
|
// grid would have made anyway.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
(function() {
|
||||||
|
const KEY = App.constants.SAVED_SEARCHES_KEY;
|
||||||
|
|
||||||
|
// How many of the newest ids are kept as the "already seen" mark. Twice
|
||||||
|
// the probe below, so a listing that shifts by a page between visits is
|
||||||
|
// still measured against something.
|
||||||
|
const SEEN_KEEP = 24;
|
||||||
|
// One page is enough to answer "is there anything new, and roughly how
|
||||||
|
// much": the badge stops counting at 9+ anyway.
|
||||||
|
const PROBE_PER_PAGE = 12;
|
||||||
|
// A group asks each of its channels, so each one asks for less.
|
||||||
|
const GROUP_PROBE_PER_PAGE = 4;
|
||||||
|
// Above this the badge says "9+" rather than a number.
|
||||||
|
const NEW_CAP = 9;
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
// How often a tab left open gets round to asking again. The check itself
|
||||||
|
// is still once a day per search; this is just how often we look.
|
||||||
|
const HEARTBEAT_MS = 30 * 60 * 1000;
|
||||||
|
// Checks run together, but not all of them: a reader with twenty saved
|
||||||
|
// searches should not open twenty listing requests on startup.
|
||||||
|
const CHECK_CONCURRENCY = 3;
|
||||||
|
const PROBE_TIMEOUT_MS = 12000;
|
||||||
|
|
||||||
|
App.savedSearches.NEW_CAP = NEW_CAP;
|
||||||
|
|
||||||
|
const now = () => Date.now();
|
||||||
|
|
||||||
|
// ---- store ----------------------------------------------------------
|
||||||
|
|
||||||
|
const clean = function(entry) {
|
||||||
|
if (!entry || typeof entry !== 'object') return null;
|
||||||
|
if (!entry.id || !entry.server || !entry.channelId) return null;
|
||||||
|
return {
|
||||||
|
id: String(entry.id),
|
||||||
|
name: typeof entry.name === 'string' && entry.name.trim() ? entry.name : 'Saved search',
|
||||||
|
server: String(entry.server),
|
||||||
|
channelId: String(entry.channelId),
|
||||||
|
channelName: typeof entry.channelName === 'string' ? entry.channelName : '',
|
||||||
|
query: typeof entry.query === 'string' ? entry.query : '',
|
||||||
|
options: entry.options && typeof entry.options === 'object' ? entry.options : {},
|
||||||
|
optionLabels: Array.isArray(entry.optionLabels) ? entry.optionLabels.map(String) : [],
|
||||||
|
createdAt: Number(entry.createdAt) || 0,
|
||||||
|
seenIds: Array.isArray(entry.seenIds) ? entry.seenIds.map(String).slice(0, SEEN_KEEP) : [],
|
||||||
|
lastViewedAt: Number(entry.lastViewedAt) || 0,
|
||||||
|
lastCheckedAt: Number(entry.lastCheckedAt) || 0,
|
||||||
|
newCount: Math.max(0, Number(entry.newCount) || 0),
|
||||||
|
unreachable: !!entry.unreachable
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
App.savedSearches.getAll = function() {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(localStorage.getItem(KEY));
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed.map(clean).filter(Boolean);
|
||||||
|
} catch (err) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAll = function(entries) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(entries));
|
||||||
|
} catch (err) { /* a full store is not worth failing the click over */ }
|
||||||
|
return entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
App.savedSearches.get = function(id) {
|
||||||
|
return App.savedSearches.getAll().find((entry) => entry.id === id) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Every write goes through here: the list is read again first, so a check
|
||||||
|
// that lands while the reader is renaming or deleting can't write back a
|
||||||
|
// copy of the list from before they did it.
|
||||||
|
const patch = function(id, changes) {
|
||||||
|
const entries = App.savedSearches.getAll();
|
||||||
|
const found = entries.find((entry) => entry.id === id);
|
||||||
|
if (!found) return null;
|
||||||
|
Object.assign(found, changes);
|
||||||
|
setAll(entries);
|
||||||
|
return found;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- what is on screen right now -------------------------------------
|
||||||
|
|
||||||
|
const optionLabelsOf = function(options) {
|
||||||
|
const labels = [];
|
||||||
|
Object.values(options || {}).forEach((value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
// A multi-select that is fully on says nothing worth printing;
|
||||||
|
// the interesting case is a narrowed one.
|
||||||
|
if (value.length && value.length <= 3) {
|
||||||
|
value.forEach((opt) => labels.push(opt.title || opt.id));
|
||||||
|
} else if (value.length) {
|
||||||
|
labels.push(`${value.length} selected`);
|
||||||
|
}
|
||||||
|
} else if (value && (value.title || value.id)) {
|
||||||
|
labels.push(value.title || value.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return labels;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The search the app is showing, in the shape a saved one is stored in.
|
||||||
|
App.savedSearches.current = function() {
|
||||||
|
const session = App.storage.getSession();
|
||||||
|
if (!session || !session.channel) return null;
|
||||||
|
const box = document.getElementById('search-input');
|
||||||
|
return {
|
||||||
|
server: session.server,
|
||||||
|
channelId: session.channel.id,
|
||||||
|
channelName: session.channel.name || session.channel.id,
|
||||||
|
query: (box ? box.value : '').trim(),
|
||||||
|
options: App.session.serializeOptions(session.options),
|
||||||
|
optionLabels: optionLabelsOf(session.options)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Two searches are the same search when they would send the same request.
|
||||||
|
App.savedSearches.signature = function(desc) {
|
||||||
|
if (!desc) return '';
|
||||||
|
const options = desc.options || {};
|
||||||
|
const parts = Object.keys(options).sort().map((key) => {
|
||||||
|
const value = options[key];
|
||||||
|
const flat = Array.isArray(value) ? value.slice().sort().join(',') : String(value);
|
||||||
|
return `${key}=${flat}`;
|
||||||
|
});
|
||||||
|
return [desc.server, desc.channelId, (desc.query || '').toLowerCase().trim()]
|
||||||
|
.concat(parts).join('||');
|
||||||
|
};
|
||||||
|
|
||||||
|
// The saved search the app is currently showing, if any -- what makes the
|
||||||
|
// bookmark in the search bar light up.
|
||||||
|
App.savedSearches.findMatching = function() {
|
||||||
|
const desc = App.savedSearches.current();
|
||||||
|
if (!desc) return null;
|
||||||
|
const wanted = App.savedSearches.signature(desc);
|
||||||
|
return App.savedSearches.getAll()
|
||||||
|
.find((entry) => App.savedSearches.signature(entry) === wanted) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// What to call a search nobody has named: the words searched for, or the
|
||||||
|
// channel when there were none.
|
||||||
|
const autoName = function(desc) {
|
||||||
|
if (desc.query) return desc.query;
|
||||||
|
const channel = desc.channelName || desc.channelId;
|
||||||
|
const narrowed = desc.optionLabels.slice(0, 2).join(' · ');
|
||||||
|
return narrowed ? `${channel} · ${narrowed}` : channel;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The ids of what the grid is showing, newest first. Saving straight after
|
||||||
|
// a load therefore starts at "nothing new", which is the truth: the reader
|
||||||
|
// is looking at it.
|
||||||
|
const visibleIds = function() {
|
||||||
|
return (App.state.loadedVideos || [])
|
||||||
|
.slice(0, SEEN_KEEP)
|
||||||
|
.map((video) => String(video && (video.id || video.url) || ''))
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
App.savedSearches.saveCurrent = function() {
|
||||||
|
const desc = App.savedSearches.current();
|
||||||
|
if (!desc) return null;
|
||||||
|
const already = App.savedSearches.findMatching();
|
||||||
|
if (already) return already;
|
||||||
|
const entry = clean(Object.assign({}, desc, {
|
||||||
|
id: `ss_${now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
||||||
|
name: autoName(desc),
|
||||||
|
createdAt: now(),
|
||||||
|
seenIds: visibleIds(),
|
||||||
|
lastViewedAt: now(),
|
||||||
|
lastCheckedAt: now(),
|
||||||
|
newCount: 0
|
||||||
|
}));
|
||||||
|
const entries = App.savedSearches.getAll();
|
||||||
|
entries.unshift(entry);
|
||||||
|
setAll(entries);
|
||||||
|
changed();
|
||||||
|
return entry;
|
||||||
|
};
|
||||||
|
|
||||||
|
App.savedSearches.remove = function(id) {
|
||||||
|
setAll(App.savedSearches.getAll().filter((entry) => entry.id !== id));
|
||||||
|
changed();
|
||||||
|
};
|
||||||
|
|
||||||
|
App.savedSearches.rename = function(id, name) {
|
||||||
|
const next = (name || '').trim();
|
||||||
|
if (!next) return null;
|
||||||
|
const entry = patch(id, { name: next.slice(0, 80) });
|
||||||
|
changed();
|
||||||
|
return entry;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Opened, so whatever it shows now counts as seen and the badge goes out.
|
||||||
|
App.savedSearches.markViewed = function(id) {
|
||||||
|
const seen = visibleIds();
|
||||||
|
const entry = patch(id, Object.assign({
|
||||||
|
lastViewedAt: now(),
|
||||||
|
lastCheckedAt: now(),
|
||||||
|
newCount: 0,
|
||||||
|
unreachable: false
|
||||||
|
}, seen.length ? { seenIds: seen } : {}));
|
||||||
|
changed();
|
||||||
|
return entry;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Being on a saved search is viewing it, however the reader got there --
|
||||||
|
// by tapping it here, or by setting the same channel and filters by hand.
|
||||||
|
App.savedSearches.syncViewed = function() {
|
||||||
|
const match = App.savedSearches.findMatching();
|
||||||
|
if (match) App.savedSearches.markViewed(match.id);
|
||||||
|
return match;
|
||||||
|
};
|
||||||
|
|
||||||
|
App.savedSearches.totalNew = function() {
|
||||||
|
return App.savedSearches.getAll()
|
||||||
|
.reduce((sum, entry) => sum + (entry.newCount || 0), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// What a count looks like on a badge: nothing, a number, or "9+".
|
||||||
|
App.savedSearches.badgeLabel = function(count) {
|
||||||
|
if (!count) return '';
|
||||||
|
return count > NEW_CAP ? `${NEW_CAP}+` : String(count);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changed = function() {
|
||||||
|
if (App.ui && App.ui.savedSearches && typeof App.ui.savedSearches.sync === 'function') {
|
||||||
|
App.ui.savedSearches.sync();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- the daily check -------------------------------------------------
|
||||||
|
|
||||||
|
// The ids on the first page of a saved search, as the server orders them.
|
||||||
|
// Not the grid's request: the grid's paging state belongs to what the
|
||||||
|
// reader is looking at, and a check must not disturb it.
|
||||||
|
const probeIds = async function(entry, signal) {
|
||||||
|
const serverEntry = App.storage.getServerEntries()
|
||||||
|
.find((candidate) => candidate.url === entry.server);
|
||||||
|
const serverData = serverEntry ? serverEntry.data : null;
|
||||||
|
const channel = serverData ? App.session.resolveChannelById(serverData, entry.channelId) : null;
|
||||||
|
if (!channel) throw new Error('channel is no longer listed');
|
||||||
|
|
||||||
|
const ask = async function(channelId, perPage, extra) {
|
||||||
|
const body = Object.assign({
|
||||||
|
channel: channelId,
|
||||||
|
query: entry.query || '',
|
||||||
|
page: 1,
|
||||||
|
perPage: perPage,
|
||||||
|
server: entry.server
|
||||||
|
}, extra || {});
|
||||||
|
const response = await fetch('/api/videos', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: signal
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`listing request failed: ${response.status}`);
|
||||||
|
const data = await response.json();
|
||||||
|
const items = data && Array.isArray(data.items) ? data.items : [];
|
||||||
|
return items.map((item) => String(item && (item.id || item.url) || '')).filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (channel.isGroup) {
|
||||||
|
const selected = entry.options ? entry.options[App.session.GROUP_CHANNELS_OPTION_ID] : null;
|
||||||
|
const enabled = (Array.isArray(selected) && selected.length ? selected : channel.channelIds)
|
||||||
|
.filter((id) => channel.channelIds.includes(id));
|
||||||
|
if (!enabled.length) return [];
|
||||||
|
const perChannel = await Promise.all(enabled.map((id) => ask(id, GROUP_PROBE_PER_PAGE)));
|
||||||
|
// Interleaved, so "the newest" means the same thing it does in the
|
||||||
|
// grid: one from each channel in turn.
|
||||||
|
const merged = [];
|
||||||
|
const longest = perChannel.reduce((max, ids) => Math.max(max, ids.length), 0);
|
||||||
|
for (let i = 0; i < longest; i++) {
|
||||||
|
perChannel.forEach((ids) => { if (ids[i]) merged.push(ids[i]); });
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extra = {};
|
||||||
|
Object.entries(entry.options || {}).forEach(([key, value]) => {
|
||||||
|
extra[key] = Array.isArray(value) ? value.join(', ') : value;
|
||||||
|
});
|
||||||
|
return ask(entry.channelId, PROBE_PER_PAGE, extra);
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkOne = async function(entry) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = window.setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const ids = await probeIds(entry, controller.signal);
|
||||||
|
const seen = new Set(entry.seenIds || []);
|
||||||
|
// A search nobody has ever opened has nothing to compare against,
|
||||||
|
// so the whole first page is its mark rather than its news.
|
||||||
|
if (!seen.size) {
|
||||||
|
patch(entry.id, { seenIds: ids.slice(0, SEEN_KEEP), lastCheckedAt: now(), newCount: 0, unreachable: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fresh = ids.filter((id) => !seen.has(id)).length;
|
||||||
|
patch(entry.id, { lastCheckedAt: now(), newCount: fresh, unreachable: false });
|
||||||
|
} catch (err) {
|
||||||
|
// A source that is down must not look like a search with nothing
|
||||||
|
// new in it, and must not reset the clock either: it is tried
|
||||||
|
// again on the next look.
|
||||||
|
patch(entry.id, { unreachable: true });
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let running = null;
|
||||||
|
|
||||||
|
// Checks every saved search that is due (or all of them, with `force`).
|
||||||
|
// Resolves when they have all been written back; safe to call at any time,
|
||||||
|
// and calling it while a run is in flight joins that run.
|
||||||
|
App.savedSearches.check = function(opts) {
|
||||||
|
const force = !!(opts && opts.force);
|
||||||
|
if (running) return running;
|
||||||
|
const due = App.savedSearches.getAll().filter((entry) =>
|
||||||
|
force || !entry.lastCheckedAt || (now() - entry.lastCheckedAt) >= DAY_MS);
|
||||||
|
if (!due.length) return Promise.resolve(0);
|
||||||
|
|
||||||
|
const queue = due.slice();
|
||||||
|
const worker = async function() {
|
||||||
|
while (queue.length) {
|
||||||
|
await checkOne(queue.shift());
|
||||||
|
changed();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
running = Promise.all(
|
||||||
|
Array.from({ length: Math.min(CHECK_CONCURRENCY, queue.length) }, worker)
|
||||||
|
).then(() => {
|
||||||
|
running = null;
|
||||||
|
changed();
|
||||||
|
return due.length;
|
||||||
|
}).catch(() => {
|
||||||
|
running = null;
|
||||||
|
changed();
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
return running;
|
||||||
|
};
|
||||||
|
|
||||||
|
App.savedSearches.isChecking = function() { return !!running; };
|
||||||
|
|
||||||
|
// ---- opening one -----------------------------------------------------
|
||||||
|
|
||||||
|
// Puts the whole saved search back: source, channel, filters, query. The
|
||||||
|
// grid is reloaded here rather than left to the caller, because "applied"
|
||||||
|
// means the reader is looking at it -- which is also when it stops being
|
||||||
|
// new (see markViewed).
|
||||||
|
App.savedSearches.apply = async function(id) {
|
||||||
|
const entry = App.savedSearches.get(id);
|
||||||
|
if (!entry) return false;
|
||||||
|
|
||||||
|
const serverEntry = App.storage.getServerEntries()
|
||||||
|
.find((candidate) => candidate.url === entry.server);
|
||||||
|
if (!serverEntry || !serverEntry.data) {
|
||||||
|
App.ui.showError(`${entry.name}: the source ${entry.server} isn't set up on this device any more.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const channel = App.session.resolveChannelById(serverEntry.data, entry.channelId);
|
||||||
|
if (!channel) {
|
||||||
|
App.ui.showError(`${entry.name}: ${entry.server} no longer lists that channel.`);
|
||||||
|
patch(entry.id, { unreachable: true });
|
||||||
|
changed();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = {
|
||||||
|
server: entry.server,
|
||||||
|
channel: channel,
|
||||||
|
options: App.session.hydrateOptions(channel, entry.options)
|
||||||
|
};
|
||||||
|
App.storage.setSession(session);
|
||||||
|
App.session.savePreference(session);
|
||||||
|
|
||||||
|
App.videos.setSearchBox(entry.query || '');
|
||||||
|
if (App.favoritesView && App.favoritesView.isActive()) {
|
||||||
|
App.favoritesView.close({ silent: true });
|
||||||
|
}
|
||||||
|
App.ui.renderMenu();
|
||||||
|
App.videos.resetGrid();
|
||||||
|
await App.videos.loadVideos();
|
||||||
|
App.savedSearches.markViewed(entry.id);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- lifecycle -------------------------------------------------------
|
||||||
|
|
||||||
|
let started = false;
|
||||||
|
|
||||||
|
App.savedSearches.start = function() {
|
||||||
|
if (started) return;
|
||||||
|
started = true;
|
||||||
|
changed();
|
||||||
|
App.savedSearches.check();
|
||||||
|
// A tab left open for days still gets round to it.
|
||||||
|
window.setInterval(() => App.savedSearches.check(), HEARTBEAT_MS);
|
||||||
|
// And a phone that had the app in the background for a day checks as
|
||||||
|
// soon as it is looked at again, rather than at the next reload.
|
||||||
|
document.addEventListener('visibilitychange', function() {
|
||||||
|
if (document.visibilityState === 'visible') App.savedSearches.check();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -25,7 +25,8 @@ App.constants = {
|
|||||||
FAVORITES_SORT_KEY: 'favoritesSort',
|
FAVORITES_SORT_KEY: 'favoritesSort',
|
||||||
PREFERRED_QUALITY_KEY: 'preferredQuality',
|
PREFERRED_QUALITY_KEY: 'preferredQuality',
|
||||||
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior',
|
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior',
|
||||||
SEARCH_HISTORY_KEY: 'searchHistory'
|
SEARCH_HISTORY_KEY: 'searchHistory',
|
||||||
|
SAVED_SEARCHES_KEY: 'savedSearches'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Lazily injects hls.js the first time a stream actually needs it. Sessions
|
// Lazily injects hls.js the first time a stream actually needs it. Sessions
|
||||||
|
|||||||
@@ -472,6 +472,386 @@ App.ui = App.ui || {};
|
|||||||
return { bind: bind, refresh: refresh, close: close, isOpen: isOpen, shown: () => offered.slice() };
|
return { bind: bind, refresh: refresh, close: close, isOpen: isOpen, shown: () => offered.slice() };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Saved searches
|
||||||
|
//
|
||||||
|
// The bookmark in the search bar keeps whatever is on screen -- source,
|
||||||
|
// channel, filters, query -- and the bookmark in the header opens the
|
||||||
|
// list of them. Each row carries what is new in it since it was last
|
||||||
|
// opened (App.savedSearches does the counting), and tapping a row puts
|
||||||
|
// that whole search back.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
App.ui.savedSearches = (function() {
|
||||||
|
let bound = false;
|
||||||
|
// A row with its rename field open, or its "remove?" question showing.
|
||||||
|
// Held here so a check landing mid-edit redraws around it rather than
|
||||||
|
// through it.
|
||||||
|
let pending = null;
|
||||||
|
|
||||||
|
const el = (id) => document.getElementById(id);
|
||||||
|
const panel = () => el('saved-panel');
|
||||||
|
const isOpen = () => !!panel() && panel().classList.contains('open');
|
||||||
|
|
||||||
|
const ago = function(stamp) {
|
||||||
|
if (!stamp) return 'never';
|
||||||
|
const seconds = Math.max(0, Math.round((Date.now() - stamp) / 1000));
|
||||||
|
if (seconds < 90) return 'just now';
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes} min ago`;
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours} h ago`;
|
||||||
|
const days = Math.round(hours / 24);
|
||||||
|
return days === 1 ? 'yesterday' : `${days} days ago`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Where the search points, in one line: the channel, what the filters
|
||||||
|
// narrow it to, and the words themselves.
|
||||||
|
const metaLine = function(entry) {
|
||||||
|
const parts = [entry.channelName || entry.channelId];
|
||||||
|
if (entry.optionLabels && entry.optionLabels.length) {
|
||||||
|
parts.push(entry.optionLabels.slice(0, 3).join(' · '));
|
||||||
|
}
|
||||||
|
parts.push(entry.query ? `“${entry.query}”` : 'no search term');
|
||||||
|
return parts.join(' · ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const stampLine = function(entry) {
|
||||||
|
if (entry.unreachable) return 'Could not be checked — the source did not answer.';
|
||||||
|
const checked = `Checked ${ago(entry.lastCheckedAt)}`;
|
||||||
|
if (!entry.newCount) return `${checked} · nothing new`;
|
||||||
|
const since = entry.lastViewedAt ? ` since you opened it ${ago(entry.lastViewedAt)}` : '';
|
||||||
|
const label = App.savedSearches.badgeLabel(entry.newCount);
|
||||||
|
return `${label} new${since} · ${checked.toLowerCase()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const svgIcon = function(paths, extraClass) {
|
||||||
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
|
svg.setAttribute('viewBox', '0 0 24 24');
|
||||||
|
svg.setAttribute('aria-hidden', 'true');
|
||||||
|
svg.setAttribute('class', 'icon-line' + (extraClass ? ' ' + extraClass : ''));
|
||||||
|
paths.forEach((d) => {
|
||||||
|
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||||
|
path.setAttribute('d', d);
|
||||||
|
svg.appendChild(path);
|
||||||
|
});
|
||||||
|
return svg;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RENAME_ICON = ['M4.5 19.5h3L19 8a2.1 2.1 0 0 0-3-3L4.5 16.5v3z'];
|
||||||
|
const DELETE_ICON = ['M5 7.5h14', 'M9.5 7.5V5.75h5V7.5', 'M7 7.5l.8 11.2h8.4L17 7.5'];
|
||||||
|
|
||||||
|
const toolButton = function(act, label, paths) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'saved-tool';
|
||||||
|
button.dataset.act = act;
|
||||||
|
button.title = label;
|
||||||
|
button.setAttribute('aria-label', label);
|
||||||
|
button.appendChild(svgIcon(paths));
|
||||||
|
return button;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A row mid-rename: the name becomes a field, and leaving it (Enter or
|
||||||
|
// blur) keeps what was typed. Escape puts the old name back -- the
|
||||||
|
// normal way round for an edit in place.
|
||||||
|
const renameField = function(entry, row) {
|
||||||
|
const field = document.createElement('input');
|
||||||
|
field.type = 'text';
|
||||||
|
field.className = 'saved-rename';
|
||||||
|
field.value = entry.name;
|
||||||
|
field.maxLength = 80;
|
||||||
|
field.setAttribute('aria-label', 'Name for this saved search');
|
||||||
|
let settled = false;
|
||||||
|
const commit = function(keep) {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
pending = null;
|
||||||
|
if (keep) App.savedSearches.rename(entry.id, field.value);
|
||||||
|
else sync();
|
||||||
|
};
|
||||||
|
field.addEventListener('keydown', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (event.key === 'Enter') { event.preventDefault(); commit(true); }
|
||||||
|
else if (event.key === 'Escape') { event.preventDefault(); commit(false); }
|
||||||
|
});
|
||||||
|
field.addEventListener('blur', () => commit(true));
|
||||||
|
field.addEventListener('click', (event) => event.stopPropagation());
|
||||||
|
row.classList.add('is-renaming');
|
||||||
|
return field;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmStrip = function(entry) {
|
||||||
|
const strip = document.createElement('div');
|
||||||
|
strip.className = 'saved-confirm';
|
||||||
|
|
||||||
|
const question = document.createElement('span');
|
||||||
|
question.className = 'saved-confirm-text';
|
||||||
|
question.textContent = 'Remove this saved search?';
|
||||||
|
|
||||||
|
const remove = document.createElement('button');
|
||||||
|
remove.type = 'button';
|
||||||
|
remove.className = 'saved-confirm-btn is-danger';
|
||||||
|
remove.textContent = 'Remove';
|
||||||
|
remove.addEventListener('click', () => {
|
||||||
|
pending = null;
|
||||||
|
App.savedSearches.remove(entry.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const keep = document.createElement('button');
|
||||||
|
keep.type = 'button';
|
||||||
|
keep.className = 'saved-confirm-btn';
|
||||||
|
keep.textContent = 'Keep';
|
||||||
|
keep.addEventListener('click', () => { pending = null; sync(); });
|
||||||
|
|
||||||
|
strip.appendChild(question);
|
||||||
|
strip.appendChild(keep);
|
||||||
|
strip.appendChild(remove);
|
||||||
|
return strip;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildRow = function(entry, index) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'saved-row';
|
||||||
|
row.dataset.id = entry.id;
|
||||||
|
row.style.setProperty('--i', String(index));
|
||||||
|
if (entry.unreachable) row.classList.add('is-unreachable');
|
||||||
|
|
||||||
|
const main = document.createElement('button');
|
||||||
|
main.type = 'button';
|
||||||
|
main.className = 'saved-row-main';
|
||||||
|
|
||||||
|
const count = document.createElement('span');
|
||||||
|
const label = App.savedSearches.badgeLabel(entry.newCount);
|
||||||
|
count.className = 'saved-count' + (label ? '' : ' is-quiet');
|
||||||
|
// The quiet state is a mark, not a number: a row with nothing new
|
||||||
|
// should not look like a row with something in it.
|
||||||
|
count.textContent = label || '·';
|
||||||
|
if (label) count.title = `${entry.newCount} new since you last opened this`;
|
||||||
|
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.className = 'saved-row-text';
|
||||||
|
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'saved-row-name';
|
||||||
|
name.textContent = entry.name;
|
||||||
|
|
||||||
|
const meta = document.createElement('span');
|
||||||
|
meta.className = 'saved-row-meta';
|
||||||
|
meta.textContent = metaLine(entry);
|
||||||
|
|
||||||
|
const stamp = document.createElement('span');
|
||||||
|
stamp.className = 'saved-row-stamp';
|
||||||
|
stamp.textContent = stampLine(entry);
|
||||||
|
|
||||||
|
text.appendChild(name);
|
||||||
|
text.appendChild(meta);
|
||||||
|
text.appendChild(stamp);
|
||||||
|
main.appendChild(count);
|
||||||
|
main.appendChild(text);
|
||||||
|
main.addEventListener('click', () => choose(entry.id));
|
||||||
|
row.appendChild(main);
|
||||||
|
|
||||||
|
if (pending && pending.id === entry.id && pending.mode === 'rename') {
|
||||||
|
const field = renameField(entry, row);
|
||||||
|
name.replaceWith(field);
|
||||||
|
main.disabled = true;
|
||||||
|
requestAnimationFrame(() => { field.focus(); field.select(); });
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending && pending.id === entry.id && pending.mode === 'delete') {
|
||||||
|
row.classList.add('is-confirming');
|
||||||
|
row.appendChild(confirmStrip(entry));
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tools = document.createElement('div');
|
||||||
|
tools.className = 'saved-row-tools';
|
||||||
|
const rename = toolButton('rename', `Rename ${entry.name}`, RENAME_ICON);
|
||||||
|
rename.addEventListener('click', () => { pending = { id: entry.id, mode: 'rename' }; render(); });
|
||||||
|
const remove = toolButton('delete', `Remove ${entry.name}`, DELETE_ICON);
|
||||||
|
remove.addEventListener('click', () => { pending = { id: entry.id, mode: 'delete' }; render(); });
|
||||||
|
tools.appendChild(rename);
|
||||||
|
tools.appendChild(remove);
|
||||||
|
row.appendChild(tools);
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
|
||||||
|
const render = function() {
|
||||||
|
const list = el('saved-list');
|
||||||
|
const empty = el('saved-empty');
|
||||||
|
const note = el('saved-head-note');
|
||||||
|
const add = el('saved-add-btn');
|
||||||
|
const check = el('saved-check-btn');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
const entries = App.savedSearches.getAll();
|
||||||
|
list.textContent = '';
|
||||||
|
entries.forEach((entry, index) => list.appendChild(buildRow(entry, index)));
|
||||||
|
if (empty) empty.hidden = entries.length > 0;
|
||||||
|
|
||||||
|
if (note) {
|
||||||
|
const total = App.savedSearches.totalNew();
|
||||||
|
note.textContent = !entries.length ?
|
||||||
|
'Keep a search once, come back to it in one tap.' :
|
||||||
|
(total ?
|
||||||
|
`${App.savedSearches.badgeLabel(total)} new since you last looked.` :
|
||||||
|
'Checked once a day. Nothing new right now.');
|
||||||
|
}
|
||||||
|
if (check) {
|
||||||
|
check.disabled = !entries.length || App.savedSearches.isChecking();
|
||||||
|
check.textContent = App.savedSearches.isChecking() ? 'Checking…' : 'Check now';
|
||||||
|
}
|
||||||
|
if (add) {
|
||||||
|
const match = App.savedSearches.findMatching();
|
||||||
|
add.disabled = !!match || !App.savedSearches.current();
|
||||||
|
add.textContent = match ? `Already saved as “${match.name}”` : 'Save the current search';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const choose = function(id) {
|
||||||
|
close();
|
||||||
|
App.savedSearches.apply(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = function() {
|
||||||
|
const box = panel();
|
||||||
|
if (!box) return;
|
||||||
|
pending = null;
|
||||||
|
render();
|
||||||
|
box.classList.add('open');
|
||||||
|
box.setAttribute('aria-hidden', 'false');
|
||||||
|
const trigger = el('saved-searches-btn');
|
||||||
|
if (trigger) trigger.setAttribute('aria-expanded', 'true');
|
||||||
|
// Opening the list is as good a moment as any to find out whether
|
||||||
|
// anything is due -- the daily check may have been missed by a tab
|
||||||
|
// that was never left open that long.
|
||||||
|
App.savedSearches.check();
|
||||||
|
const closeBtn = el('saved-close-btn');
|
||||||
|
if (closeBtn) requestAnimationFrame(() => closeBtn.focus());
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = function() {
|
||||||
|
const box = panel();
|
||||||
|
// Where the keyboard goes next: back to the button that opened
|
||||||
|
// this, not to the top of the page behind it.
|
||||||
|
const returnFocus = !!(box && box.contains(document.activeElement));
|
||||||
|
if (box) {
|
||||||
|
box.classList.remove('open');
|
||||||
|
box.setAttribute('aria-hidden', 'true');
|
||||||
|
}
|
||||||
|
pending = null;
|
||||||
|
const trigger = el('saved-searches-btn');
|
||||||
|
if (trigger) {
|
||||||
|
trigger.setAttribute('aria-expanded', 'false');
|
||||||
|
if (returnFocus) trigger.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The bookmark in the search bar: lit when what is on screen is
|
||||||
|
// already saved, and pressing it then takes it off the list again.
|
||||||
|
const toggleCurrent = function() {
|
||||||
|
const match = App.savedSearches.findMatching();
|
||||||
|
if (match) {
|
||||||
|
App.savedSearches.remove(match.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const saved = App.savedSearches.saveCurrent();
|
||||||
|
if (!saved) return;
|
||||||
|
const button = el('search-save-btn');
|
||||||
|
if (button) {
|
||||||
|
button.classList.remove('is-just-saved');
|
||||||
|
// Restarted rather than left to run: saving twice in a row
|
||||||
|
// should flash twice.
|
||||||
|
void button.offsetWidth;
|
||||||
|
button.classList.add('is-just-saved');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keeps every piece of the feature showing the same thing: the
|
||||||
|
// bookmark's state, the header's count, and the list if it is open.
|
||||||
|
const sync = function() {
|
||||||
|
const button = el('search-save-btn');
|
||||||
|
if (button) {
|
||||||
|
const match = App.savedSearches.findMatching();
|
||||||
|
const available = !!App.savedSearches.current();
|
||||||
|
button.classList.toggle('is-saved', !!match);
|
||||||
|
button.setAttribute('aria-pressed', match ? 'true' : 'false');
|
||||||
|
button.disabled = !available;
|
||||||
|
const title = match ? `Saved as “${match.name}” — tap to remove` : 'Save this search';
|
||||||
|
button.title = title;
|
||||||
|
button.setAttribute('aria-label', title);
|
||||||
|
}
|
||||||
|
|
||||||
|
const badge = el('saved-searches-badge');
|
||||||
|
const trigger = el('saved-searches-btn');
|
||||||
|
if (badge) {
|
||||||
|
const total = App.savedSearches.totalNew();
|
||||||
|
const label = App.savedSearches.badgeLabel(total);
|
||||||
|
badge.textContent = label;
|
||||||
|
badge.hidden = !label;
|
||||||
|
if (trigger) {
|
||||||
|
const count = App.savedSearches.getAll().length;
|
||||||
|
trigger.setAttribute('aria-label', label ?
|
||||||
|
`Saved searches, ${total > App.savedSearches.NEW_CAP ? 'more than ' + App.savedSearches.NEW_CAP : total} new` :
|
||||||
|
(count ? `Saved searches (${count})` : 'Saved searches'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rename in progress is the reader's text, not ours to redraw.
|
||||||
|
if (isOpen() && !(pending && pending.mode === 'rename')) render();
|
||||||
|
};
|
||||||
|
|
||||||
|
const bind = function() {
|
||||||
|
if (bound) return;
|
||||||
|
const box = panel();
|
||||||
|
const trigger = el('saved-searches-btn');
|
||||||
|
if (!box || !trigger) return;
|
||||||
|
bound = true;
|
||||||
|
|
||||||
|
trigger.addEventListener('click', () => { if (isOpen()) close(); else open(); });
|
||||||
|
const closeBtn = el('saved-close-btn');
|
||||||
|
if (closeBtn) closeBtn.addEventListener('click', close);
|
||||||
|
box.addEventListener('click', (event) => { if (event.target === box) close(); });
|
||||||
|
|
||||||
|
const saveBtn = el('search-save-btn');
|
||||||
|
if (saveBtn) saveBtn.addEventListener('click', toggleCurrent);
|
||||||
|
|
||||||
|
const add = el('saved-add-btn');
|
||||||
|
if (add) {
|
||||||
|
add.addEventListener('click', () => {
|
||||||
|
const saved = App.savedSearches.saveCurrent();
|
||||||
|
if (saved) pending = { id: saved.id, mode: 'rename' };
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const check = el('saved-check-btn');
|
||||||
|
if (check) {
|
||||||
|
check.addEventListener('click', () => {
|
||||||
|
App.savedSearches.check({ force: true });
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape' && isOpen()) {
|
||||||
|
event.stopPropagation();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
bind: bind,
|
||||||
|
sync: sync,
|
||||||
|
open: open,
|
||||||
|
close: close,
|
||||||
|
isOpen: isOpen,
|
||||||
|
toggleCurrent: toggleCurrent
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Channel picker
|
// Channel picker
|
||||||
//
|
//
|
||||||
@@ -1043,6 +1423,10 @@ App.ui = App.ui || {};
|
|||||||
App.videos.resetAndReload();
|
App.videos.resetAndReload();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Source, channel and filters are all part of what a saved search is,
|
||||||
|
// so whenever the menu redraws, so does the bookmark.
|
||||||
|
if (App.ui.savedSearches) App.ui.savedSearches.sync();
|
||||||
};
|
};
|
||||||
|
|
||||||
App.ui.renderFilters = function(container, session) {
|
App.ui.renderFilters = function(container, session) {
|
||||||
@@ -1301,7 +1685,11 @@ App.ui = App.ui || {};
|
|||||||
// debounce the actual reload so typing doesn't wipe the grid and
|
// debounce the actual reload so typing doesn't wipe the grid and
|
||||||
// fire a backend request on every keystroke.
|
// fire a backend request on every keystroke.
|
||||||
App.ui.searchHistory.bind();
|
App.ui.searchHistory.bind();
|
||||||
|
App.ui.savedSearches.bind();
|
||||||
searchInput.addEventListener('input', updateClearVisibility);
|
searchInput.addEventListener('input', updateClearVisibility);
|
||||||
|
// The bookmark is about this search, so it follows the words as
|
||||||
|
// they are typed, not just the channel and filters.
|
||||||
|
searchInput.addEventListener('input', () => App.ui.savedSearches.sync());
|
||||||
searchInput.addEventListener('input', () => {
|
searchInput.addEventListener('input', () => {
|
||||||
if (searchDebounce) clearTimeout(searchDebounce);
|
if (searchDebounce) clearTimeout(searchDebounce);
|
||||||
searchDebounce = setTimeout(() => {
|
searchDebounce = setTimeout(() => {
|
||||||
|
|||||||
@@ -1322,20 +1322,24 @@ App.videos = App.videos || {};
|
|||||||
return (best || cards[0]).dataset.videoId || null;
|
return (best || cards[0]).dataset.videoId || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Puts a query in the box without searching for it. The clear button is
|
||||||
|
// kept in sync here rather than by re-dispatching an `input` event, which
|
||||||
|
// would re-trigger the debounced reload listener behind it.
|
||||||
|
App.videos.setSearchBox = function(value) {
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
if (!searchInput) return;
|
||||||
|
if (searchInput.value !== value) searchInput.value = value;
|
||||||
|
const clearBtn = document.getElementById('search-clear-btn');
|
||||||
|
if (clearBtn) {
|
||||||
|
const hasValue = searchInput.value.trim().length > 0;
|
||||||
|
clearBtn.classList.toggle('is-visible', hasValue);
|
||||||
|
clearBtn.disabled = !hasValue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
App.videos.handleSearch = function(value) {
|
App.videos.handleSearch = function(value) {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
const searchInput = document.getElementById('search-input');
|
App.videos.setSearchBox(value);
|
||||||
if (searchInput && searchInput.value !== value) {
|
|
||||||
searchInput.value = value;
|
|
||||||
}
|
|
||||||
// Keep the clear button in sync without re-dispatching an `input`
|
|
||||||
// event (which would re-trigger the debounced reload listener).
|
|
||||||
const clearBtn = document.getElementById('search-clear-btn');
|
|
||||||
if (searchInput && clearBtn) {
|
|
||||||
const hasValue = searchInput.value.trim().length > 0;
|
|
||||||
clearBtn.classList.toggle('is-visible', hasValue);
|
|
||||||
clearBtn.disabled = !hasValue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Every search worth running is a search worth remembering: this is
|
// Every search worth running is a search worth remembering: this is
|
||||||
// the one place they all come through -- the box, a tag, the command
|
// the one place they all come through -- the box, a tag, the command
|
||||||
|
|||||||
323
tests/smoke_saved_searches.py
Normal file
323
tests/smoke_saved_searches.py
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""A whole search -- source, channel, filters, query -- can be kept and come back to.
|
||||||
|
|
||||||
|
Run against a locally running backend:
|
||||||
|
|
||||||
|
backend/main.py &
|
||||||
|
.venv/bin/python tests/smoke_saved_searches.py
|
||||||
|
|
||||||
|
Both the status and the listing are fixtures, so the test owns what "new"
|
||||||
|
means: it changes the ids the listing hands back and then asks the client how
|
||||||
|
many of them it has never shown.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
BASE = "http://127.0.0.1:5000/"
|
||||||
|
SERVER = "https://fixture.test"
|
||||||
|
CHANNEL = "clips"
|
||||||
|
|
||||||
|
STATUS = {
|
||||||
|
"online": True,
|
||||||
|
"channels": [{
|
||||||
|
"id": CHANNEL,
|
||||||
|
"name": "Fixture Clips",
|
||||||
|
"description": "A channel that only exists for this test.",
|
||||||
|
"options": [{
|
||||||
|
"id": "sort",
|
||||||
|
"title": "Sort",
|
||||||
|
"options": [
|
||||||
|
{"id": "latest", "title": "Latest"},
|
||||||
|
{"id": "views", "title": "Most viewed"},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
SEED = """([server, channel]) => {
|
||||||
|
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||||
|
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||||
|
localStorage.removeItem('session');
|
||||||
|
localStorage.removeItem('searchHistory');
|
||||||
|
localStorage.removeItem('savedSearches');
|
||||||
|
}"""
|
||||||
|
|
||||||
|
SAVED = """() => JSON.parse(localStorage.getItem('savedSearches') || '[]')"""
|
||||||
|
|
||||||
|
BADGE = """() => {
|
||||||
|
const badge = document.getElementById('saved-searches-badge');
|
||||||
|
return { hidden: badge.hidden, text: badge.textContent };
|
||||||
|
}"""
|
||||||
|
|
||||||
|
ROWS = """() => Array.from(document.querySelectorAll('#saved-panel .saved-row')).map((row) => ({
|
||||||
|
name: row.querySelector('.saved-row-name') ? row.querySelector('.saved-row-name').textContent : '',
|
||||||
|
count: row.querySelector('.saved-count').textContent,
|
||||||
|
quiet: row.querySelector('.saved-count').classList.contains('is-quiet'),
|
||||||
|
meta: row.querySelector('.saved-row-meta').textContent,
|
||||||
|
stamp: row.querySelector('.saved-row-stamp').textContent
|
||||||
|
}))"""
|
||||||
|
|
||||||
|
IS_SAVED = """() => document.getElementById('search-save-btn').classList.contains('is-saved')"""
|
||||||
|
PANEL_OPEN = """() => document.getElementById('saved-panel').classList.contains('open')"""
|
||||||
|
SESSION = """() => {
|
||||||
|
const s = JSON.parse(localStorage.getItem('session'));
|
||||||
|
return { channel: s.channel.id, sort: s.options.sort ? s.options.sort.id : null };
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
class Checks:
|
||||||
|
def __init__(self):
|
||||||
|
self.failed = 0
|
||||||
|
|
||||||
|
def ok(self, label, condition, detail=""):
|
||||||
|
mark = "PASS" if condition else "FAIL"
|
||||||
|
if not condition:
|
||||||
|
self.failed += 1
|
||||||
|
print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else ""))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
c = Checks()
|
||||||
|
asked = []
|
||||||
|
# Which ids the listing hands back. Shifting this window is how the test
|
||||||
|
# says "three new videos were uploaded".
|
||||||
|
offset = {"at": 0, "fail": False}
|
||||||
|
|
||||||
|
def items(query):
|
||||||
|
start = offset["at"]
|
||||||
|
return [{"id": f"vid-{i}", "title": f"{query or 'anything'} {i}",
|
||||||
|
"url": f"https://fixture.test/v{i}.mp4", "thumb": "", "channel": CHANNEL,
|
||||||
|
"duration": 1, "isLive": False, "tags": []}
|
||||||
|
for i in range(start, start + 12)]
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(args=[
|
||||||
|
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
|
||||||
|
"--renderer-process-limit=1", "--js-flags=--max-old-space-size=512",
|
||||||
|
])
|
||||||
|
page = browser.new_page(viewport={"width": 1180, "height": 920})
|
||||||
|
crashes = []
|
||||||
|
page.on("pageerror", lambda e: crashes.append(str(e)))
|
||||||
|
|
||||||
|
def status(route):
|
||||||
|
route.fulfill(status=200, content_type="application/json", body=json.dumps(STATUS))
|
||||||
|
|
||||||
|
def listing(route):
|
||||||
|
try:
|
||||||
|
body = json.loads(route.request.post_data or "{}")
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
asked.append(body)
|
||||||
|
if offset["fail"]:
|
||||||
|
route.fulfill(status=500, content_type="application/json", body="{}")
|
||||||
|
return
|
||||||
|
route.fulfill(status=200, content_type="application/json", body=json.dumps(
|
||||||
|
{"items": items(body.get("query", "")), "pageInfo": {"hasNextPage": False}}))
|
||||||
|
|
||||||
|
page.route("**/api/status", status)
|
||||||
|
page.route("**/api/videos", listing)
|
||||||
|
page.goto(BASE, wait_until="domcontentloaded")
|
||||||
|
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||||
|
page.goto(BASE, wait_until="load")
|
||||||
|
page.wait_for_selector(".video-card", timeout=30000)
|
||||||
|
page.wait_for_timeout(400)
|
||||||
|
|
||||||
|
print("\nthe feature is there at all")
|
||||||
|
c.ok("the module loaded", page.evaluate("() => typeof App.savedSearches.saveCurrent") == 'function')
|
||||||
|
c.ok("with nothing saved, the header count is out", page.evaluate(BADGE)["hidden"] is True)
|
||||||
|
c.ok("and the bookmark is unlit", page.evaluate(IS_SAVED) is False)
|
||||||
|
|
||||||
|
print("\nkeeping the search that is on screen")
|
||||||
|
page.fill("#search-input", "")
|
||||||
|
page.type("#search-input", "sauna", delay=30)
|
||||||
|
page.wait_for_timeout(900)
|
||||||
|
page.click("#search-save-btn")
|
||||||
|
page.wait_for_timeout(200)
|
||||||
|
saved = page.evaluate(SAVED)
|
||||||
|
c.ok("one search was kept", len(saved) == 1, str(saved))
|
||||||
|
entry = saved[0] if saved else {}
|
||||||
|
c.ok("with the words in it", entry.get("query") == "sauna", str(entry.get("query")))
|
||||||
|
c.ok("the channel it was run against", entry.get("channelId") == CHANNEL, str(entry.get("channelId")))
|
||||||
|
c.ok("the source", entry.get("server") == SERVER, str(entry.get("server")))
|
||||||
|
c.ok("and the filters as they stood", entry.get("options") == {"sort": "latest"}, str(entry.get("options")))
|
||||||
|
c.ok("it is named after the search", entry.get("name") == "sauna", str(entry.get("name")))
|
||||||
|
c.ok("what is on screen counts as seen", len(entry.get("seenIds") or []) == 12, str(len(entry.get("seenIds") or [])))
|
||||||
|
c.ok("so nothing is new yet", entry.get("newCount") == 0 and page.evaluate(BADGE)["hidden"] is True)
|
||||||
|
c.ok("and the bookmark is lit", page.evaluate(IS_SAVED) is True)
|
||||||
|
|
||||||
|
print("\nthe bookmark is about this search, not just this channel")
|
||||||
|
page.fill("#search-input", "")
|
||||||
|
page.type("#search-input", "pool", delay=30)
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
c.ok("typing something else puts it out", page.evaluate(IS_SAVED) is False)
|
||||||
|
page.fill("#search-input", "")
|
||||||
|
page.type("#search-input", "sauna", delay=30)
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
c.ok("coming back to it lights it again", page.evaluate(IS_SAVED) is True)
|
||||||
|
|
||||||
|
print("\nand a filter is part of it too")
|
||||||
|
page.evaluate("""() => {
|
||||||
|
const s = JSON.parse(localStorage.getItem('session'));
|
||||||
|
s.options.sort = { id: 'views', title: 'Most viewed' };
|
||||||
|
localStorage.setItem('session', JSON.stringify(s));
|
||||||
|
App.ui.renderMenu();
|
||||||
|
}""")
|
||||||
|
page.wait_for_timeout(200)
|
||||||
|
c.ok("a different sort is a different search", page.evaluate(IS_SAVED) is False)
|
||||||
|
|
||||||
|
print("\nthree videos turn up while nobody is looking")
|
||||||
|
offset["at"] = 3
|
||||||
|
asked.clear()
|
||||||
|
page.evaluate("() => App.savedSearches.check({ force: true })")
|
||||||
|
page.wait_for_timeout(900)
|
||||||
|
saved = page.evaluate(SAVED)
|
||||||
|
c.ok("the check asked for the saved search, not what is on screen",
|
||||||
|
any(b.get("query") == "sauna" and b.get("sort") == "latest" for b in asked), str(asked))
|
||||||
|
c.ok("three of them are new", saved[0].get("newCount") == 3, str(saved[0].get("newCount")))
|
||||||
|
badge = page.evaluate(BADGE)
|
||||||
|
c.ok("the header says so", badge["hidden"] is False and badge["text"] == "3", str(badge))
|
||||||
|
|
||||||
|
print("\na busy day")
|
||||||
|
offset["at"] = 40
|
||||||
|
page.evaluate("() => App.savedSearches.check({ force: true })")
|
||||||
|
page.wait_for_timeout(900)
|
||||||
|
saved = page.evaluate(SAVED)
|
||||||
|
badge = page.evaluate(BADGE)
|
||||||
|
c.ok("every one of them is new", saved[0].get("newCount") == 12, str(saved[0].get("newCount")))
|
||||||
|
c.ok("the badge stops counting at 9+", badge["text"] == "9+", str(badge))
|
||||||
|
|
||||||
|
print("\nonce a day, not once a page view")
|
||||||
|
asked.clear()
|
||||||
|
page.evaluate("() => App.savedSearches.check()")
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
c.ok("a search checked a moment ago is left alone", asked == [], str(asked))
|
||||||
|
page.evaluate("""() => {
|
||||||
|
const all = JSON.parse(localStorage.getItem('savedSearches'));
|
||||||
|
all[0].lastCheckedAt = Date.now() - 2 * 24 * 60 * 60 * 1000;
|
||||||
|
localStorage.setItem('savedSearches', JSON.stringify(all));
|
||||||
|
}""")
|
||||||
|
page.evaluate("() => App.savedSearches.check()")
|
||||||
|
page.wait_for_timeout(900)
|
||||||
|
c.ok("one last checked two days ago is asked about",
|
||||||
|
any(b.get("query") == "sauna" for b in asked), str(asked))
|
||||||
|
|
||||||
|
print("\nthe list")
|
||||||
|
page.click("#saved-searches-btn")
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
rows = page.evaluate(ROWS)
|
||||||
|
c.ok("it opens", page.evaluate(PANEL_OPEN) is True)
|
||||||
|
c.ok("with the saved search in it", len(rows) == 1 and rows[0]["name"] == "sauna", str(rows))
|
||||||
|
c.ok("carrying its count", rows[0]["count"] == "9+" and rows[0]["quiet"] is False, str(rows[0]))
|
||||||
|
c.ok("and saying where it points",
|
||||||
|
"Fixture Clips" in rows[0]["meta"] and "Latest" in rows[0]["meta"] and "sauna" in rows[0]["meta"],
|
||||||
|
rows[0]["meta"])
|
||||||
|
|
||||||
|
print("\ntapping it puts the whole search back")
|
||||||
|
asked.clear()
|
||||||
|
page.click("#saved-panel .saved-row-main")
|
||||||
|
page.wait_for_timeout(1200)
|
||||||
|
c.ok("the list closes", page.evaluate(PANEL_OPEN) is False)
|
||||||
|
c.ok("the words are back in the box",
|
||||||
|
page.evaluate("() => document.getElementById('search-input').value") == "sauna")
|
||||||
|
session = page.evaluate(SESSION)
|
||||||
|
c.ok("the filter is back as it was", session == {"channel": CHANNEL, "sort": "latest"}, str(session))
|
||||||
|
c.ok("and that is what was fetched",
|
||||||
|
any(b.get("query") == "sauna" and b.get("sort") == "latest" for b in asked), str(asked))
|
||||||
|
saved = page.evaluate(SAVED)
|
||||||
|
c.ok("opening it is viewing it, so the count goes out",
|
||||||
|
saved[0].get("newCount") == 0 and page.evaluate(BADGE)["hidden"] is True, str(saved[0]))
|
||||||
|
c.ok("and what it showed is the new mark",
|
||||||
|
"vid-40" in (saved[0].get("seenIds") or []), str((saved[0].get("seenIds") or [])[:3]))
|
||||||
|
c.ok("so the bookmark is lit again", page.evaluate(IS_SAVED) is True)
|
||||||
|
|
||||||
|
print("\nnothing new, said quietly")
|
||||||
|
page.click("#saved-searches-btn")
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
rows = page.evaluate(ROWS)
|
||||||
|
c.ok("no number where there is no news",
|
||||||
|
rows[0]["quiet"] is True and "nothing new" in rows[0]["stamp"].lower(), str(rows[0]))
|
||||||
|
|
||||||
|
print("\nrenaming one")
|
||||||
|
page.click("#saved-panel .saved-tool[data-act='rename']")
|
||||||
|
page.wait_for_timeout(250)
|
||||||
|
page.fill("#saved-panel .saved-rename", "Sauna, newest first")
|
||||||
|
page.keyboard.press("Enter")
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
saved = page.evaluate(SAVED)
|
||||||
|
c.ok("the new name is kept", saved[0].get("name") == "Sauna, newest first", str(saved[0].get("name")))
|
||||||
|
c.ok("and shown", page.evaluate(ROWS)[0]["name"] == "Sauna, newest first")
|
||||||
|
|
||||||
|
print("\nsaving a second one from the list itself")
|
||||||
|
page.evaluate("() => App.ui.savedSearches.close()")
|
||||||
|
page.fill("#search-input", "")
|
||||||
|
page.type("#search-input", "steam", delay=30)
|
||||||
|
page.wait_for_timeout(900)
|
||||||
|
page.click("#saved-searches-btn")
|
||||||
|
page.wait_for_timeout(250)
|
||||||
|
page.click("#saved-add-btn")
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
saved = page.evaluate(SAVED)
|
||||||
|
c.ok("there are two now", len(saved) == 2, str([s.get("name") for s in saved]))
|
||||||
|
c.ok("the new one is first and named after its query",
|
||||||
|
saved[0].get("query") == "steam", str(saved[0].get("name")))
|
||||||
|
c.ok("and it opens ready to be named",
|
||||||
|
page.evaluate("() => !!document.querySelector('#saved-panel .saved-rename')"))
|
||||||
|
page.keyboard.press("Escape")
|
||||||
|
page.wait_for_timeout(200)
|
||||||
|
|
||||||
|
print("\nremoving one asks first")
|
||||||
|
page.click("#saved-panel .saved-row:nth-of-type(1) .saved-tool[data-act='delete']")
|
||||||
|
page.wait_for_timeout(250)
|
||||||
|
c.ok("the row asks", page.evaluate(
|
||||||
|
"() => !!document.querySelector('#saved-panel .saved-confirm')"))
|
||||||
|
page.click("#saved-panel .saved-confirm-btn:not(.is-danger)")
|
||||||
|
page.wait_for_timeout(250)
|
||||||
|
c.ok("keeping keeps it", len(page.evaluate(SAVED)) == 2)
|
||||||
|
page.click("#saved-panel .saved-row:nth-of-type(1) .saved-tool[data-act='delete']")
|
||||||
|
page.wait_for_timeout(250)
|
||||||
|
page.click("#saved-panel .saved-confirm-btn.is-danger")
|
||||||
|
page.wait_for_timeout(250)
|
||||||
|
saved = page.evaluate(SAVED)
|
||||||
|
c.ok("removing removes it", len(saved) == 1 and saved[0].get("query") == "sauna", str(saved))
|
||||||
|
|
||||||
|
print("\na source that does not answer")
|
||||||
|
page.evaluate("""() => {
|
||||||
|
const all = JSON.parse(localStorage.getItem('savedSearches'));
|
||||||
|
all[0].newCount = 0;
|
||||||
|
localStorage.setItem('savedSearches', JSON.stringify(all));
|
||||||
|
}""")
|
||||||
|
offset["fail"] = True
|
||||||
|
page.evaluate("() => App.savedSearches.check({ force: true })")
|
||||||
|
page.wait_for_timeout(1200)
|
||||||
|
saved = page.evaluate(SAVED)
|
||||||
|
c.ok("is not a search with nothing new in it",
|
||||||
|
saved[0].get("unreachable") is True and saved[0].get("newCount") == 0, str(saved[0]))
|
||||||
|
rows = page.evaluate(ROWS)
|
||||||
|
c.ok("and the row says as much", "could not be checked" in rows[0]["stamp"].lower(), str(rows[0]["stamp"]))
|
||||||
|
offset["fail"] = False
|
||||||
|
|
||||||
|
print("\nacross a reload")
|
||||||
|
page.evaluate("""() => {
|
||||||
|
const all = JSON.parse(localStorage.getItem('savedSearches'));
|
||||||
|
all[0].newCount = 4;
|
||||||
|
all[0].unreachable = false;
|
||||||
|
all[0].lastCheckedAt = Date.now();
|
||||||
|
localStorage.setItem('savedSearches', JSON.stringify(all));
|
||||||
|
}""")
|
||||||
|
page.goto(BASE, wait_until="load")
|
||||||
|
page.wait_for_selector(".video-card", timeout=30000)
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
badge = page.evaluate(BADGE)
|
||||||
|
c.ok("the count is on screen straight from storage",
|
||||||
|
badge["hidden"] is False and badge["text"] == "4", str(badge))
|
||||||
|
c.ok("the search survived too", len(page.evaluate(SAVED)) == 1)
|
||||||
|
|
||||||
|
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
print(f"\n{'OK' if not c.failed else 'FAILED'}: {c.failed} check(s) failed")
|
||||||
|
return 1 if c.failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user