Switch to a nested search model, exclude search feature on single video sites

This commit is contained in:
Simon Repp
2025-08-01 15:26:08 +02:00
parent eae09a97df
commit 71a649eca1
7 changed files with 297 additions and 136 deletions
+137 -16
View File
@@ -18,26 +18,95 @@ let searchInitialized = false;
let delayedUpdateInterval = null;
function initializeResults() {
for (const item of SITE_ITEMS) {
// Create the search result element for the video inside the DOM, store a
// reference to it in the siteContent tree
function initializeVideo(video) {
let image;
if (item.image) {
if (video.image) {
image = document.createElement('img');
image.src = rootPrefix + item.url + item.image;
image.src = rootPrefix + video.sitePath + video.image;
} else {
image = document.createElement('span');
image.classList.add('placeholder');
}
const spanText = document.createElement('span');
spanText.dataset.searchable = 'true';
spanText.textContent = item.title;
spanText.textContent = video.title;
const aRow = document.createElement('a');
aRow.href = rootPrefix + item.url + indexSuffix;
aRow.appendChild(image);
aRow.appendChild(spanText);
video.element = document.createElement('a');
video.element.classList.add('video');
video.element.href = rootPrefix + video.sitePath + indexSuffix;
video.element.appendChild(image);
video.element.appendChild(spanText);
searchResults.appendChild(aRow);
searchResults.appendChild(video.element);
}
// Create the search result element for the playlist inside the DOM, store
// a reference to it in the siteContent tree
function initializePlaylist(playlist, ancestors = []) {
const traversal = [...ancestors, playlist];
const spanText = document.createElement('span');
spanText.textContent = traversal.map(ancestor => ancestor.title).join(' > ');
playlist.element = document.createElement('a');
playlist.element.classList.add('playlist');
playlist.element.href = rootPrefix + playlist.sitePath + indexSuffix;
playlist.element.appendChild(spanText);
searchResults.appendChild(playlist.element);
for (const video of playlist.videos) {
initializeVideo(video);
}
}
// Create the search result element for the collection inside the DOM,
// store a reference to it in the siteContent tree
function initializeCollection(collection, ancestors = []) {
const traversal = [...ancestors, collection];
const spanText = document.createElement('span');
spanText.textContent = traversal.map(ancestor => ancestor.title).join(' > ');
collection.element = document.createElement('a');
collection.element.classList.add('collection');
collection.element.href = rootPrefix + collection.sitePath + indexSuffix;
collection.element.appendChild(spanText);
searchResults.appendChild(collection.element);
for (const video of collection.videos) {
initializeVideo(video);
}
for (const playlist of collection.playlists) {
initializePlaylist(playlist, traversal);
}
for (const subcollection of collection.subcollections) {
initializeCollection(subcollection, traversal);
}
}
if (siteContent.collection) {
for (const video of siteContent.collection.videos) {
initializeVideo(video);
}
for (const playlist of siteContent.collection.playlists) {
initializePlaylist(playlist);
}
for (const subcollection of siteContent.collection.subcollections) {
initializeCollection(subcollection);
}
} else /* if (siteContent.playlist) */ {
for (const video of siteContent.playlist.videos) {
initializeVideo(video);
}
}
searchInitialized = true;
@@ -58,17 +127,69 @@ function updateResults() {
const regexp = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
let shown = 0;
for (const row of searchResults.children) {
const title = row.querySelector('[data-searchable]').textContent;
const display = regexp.test(title);
row.style.setProperty('display', display ? null : 'none');
function updateVideo(video) {
const display = regexp.test(video.title);
video.element.classList.toggle('visible', display);
if (display) { shown += 1; }
return display;
}
function updatePlaylist(playlist) {
let display = regexp.test(playlist.title);
if (display) { shown += 1; }
for (const video of playlist.videos) {
display = updateVideo(video) || display;
}
playlist.element.classList.toggle('visible', display);
return display;
}
function updateCollection(collection) {
let display = regexp.test(collection.title);
if (display) { shown += 1; }
for (const video of collection.videos) {
display = updateVideo(video) || display;
}
for (const playlist of collection.playlists) {
updatePlaylist(playlist);
}
for (const subcollection of collection.subcollections) {
updateCollection(subcollection);
}
collection.element.classList.toggle('visible', display);
return display;
}
if (siteContent.collection) {
for (const video of siteContent.collection.videos) {
updateVideo(video);
}
for (const playlist of siteContent.collection.playlists) {
updatePlaylist(playlist);
}
for (const subcollection of siteContent.collection.subcollections) {
updateCollection(subcollection);
}
} else {
for (const video of siteContent.playlist.videos) {
updateVideo(video);
}
}
if (shown === 0) {
searchResults.style.setProperty('display', 'none');
statusField.removeAttribute('aria-label');
statusField.textContent = SEARCH_JS_T.nothingFoundForXxx(query);
} else {
searchResults.style.setProperty('display', null);
statusField.setAttribute('aria-label', SEARCH_JS_T.showingXxxResultsForXxx(shown, query));
statusField.textContent = '';
}
@@ -109,7 +230,7 @@ searchInput.addEventListener('input', () => {
searchContainer.addEventListener('keydown', event => {
if (event.key === 'ArrowUp') {
const visibleResults = [...searchResults.children].filter(row => row.style.display !== 'none');
const visibleResults = [...searchResults.querySelectorAll('a.visible')];
if (visibleResults.length) {
const targetRowIndex = visibleResults.indexOf(event.target);
@@ -124,7 +245,7 @@ searchContainer.addEventListener('keydown', event => {
}
event.preventDefault();
} else if (event.key === 'ArrowDown') {
const visibleResults = [...searchResults.children].filter(row => row.style.display !== 'none');
const visibleResults = [...searchResults.querySelectorAll('a.visible')];
if (visibleResults.length) {
const targetRowIndex = visibleResults.indexOf(event.target);
+32 -37
View File
@@ -331,27 +331,6 @@ img {
@media (max-aspect-ratio: 2/3) {
.player_wrapper:not(:fullscreen) video { max-height: 76dvh; }
}
.playlist {
display: flex;
flex-direction: column;
}
.playlist > a > div { flex-shrink: 0; }
.playlist > * {
align-items: center;
border-radius: .5rem;
column-gap: 1rem;
display: flex;
outline: none;
padding: .5rem;
}
.playlist > *:focus-visible,
.playlist > *:hover { background: var(--bg-2); }
.playlist .description {
align-items: center;
color: var(--fg-1);
display: flex;
flex-grow: 1;
}
.playlist_context {
border: 1px solid var(--bg-2);
border-radius: .5rem;
@@ -431,6 +410,27 @@ img {
color: var(--fg-1);
display: block;
}
.playlist_page {
display: flex;
flex-direction: column;
}
.playlist_page > a > div { flex-shrink: 0; }
.playlist_page > * {
align-items: center;
border-radius: .5rem;
column-gap: 1rem;
display: flex;
outline: none;
padding: .5rem;
}
.playlist_page > *:focus-visible,
.playlist_page > *:hover { background: var(--bg-2); }
.playlist_page .description {
align-items: center;
color: var(--fg-1);
display: flex;
flex-grow: 1;
}
.reading_width { max-width: 32rem; }
.release_date {
color: var(--fg-3);
@@ -471,6 +471,7 @@ img {
max-height: 80dvh;
min-width: 20rem;
overflow-y: auto;
padding: 1rem;
position: absolute;
right: 0;
z-index: 1;
@@ -480,25 +481,24 @@ img {
flex-direction: column;
width: 100%;
}
.search .results > a {
display: flex;
column-gap: .5rem;
}
.search .results a {
align-items: center;
column-gap: .7rem;
display: flex;
padding: .5rem;
}
.search .results > a:focus-visible,
.search .results > a:hover {
.search .results a:focus-visible,
.search .results a:hover {
background: var(--bg-2);
color: var(--fg-1);
outline: none;
}
.search .results a > :nth-child(1) {
.search .results a:not(.visible) { display: none; }
.search .results .video > :nth-child(1) {
aspect-ratio: var(--poster-aspect);
height: 3rem;
}
.search .results a > :nth-child(2) { flex: 1; }
.search .results .video > :nth-child(2) { flex: 1; }
.search .results .placeholder { background: var(--bg-3); }
.search:not(.open) button,
.search:not(.open) .pane {
@@ -635,11 +635,6 @@ video {
height: 100%;
width: 100%;
}
.video {
color: var(--fg-2);
display: flex;
flex-direction: column;
}
.video_title {
color: var(--fg-1);
margin: 0;
@@ -668,11 +663,11 @@ video {
.videos_grid .thumbnail { height: auto; }
@media (max-width: 33.999rem) {
.playlist > a > div { width: 50%; }
.playlist .thumbnail { height: auto; }
.playlist_page > a > div { width: 50%; }
.playlist_page .thumbnail { height: auto; }
}
@media (min-width: 34rem) {
.playlist .thumbnail {
.playlist_page .thumbnail {
height: 8rem;
width: auto;
}
+1 -1
View File
@@ -428,7 +428,7 @@ video.addEventListener('ended', () => {
video.addEventListener('click', togglePlayback);
window.addEventListener('keydown', event => {
if (searchContainer.contains(event.target)) return;
if (searchContainer?.contains(event.target)) return;
if (event.key === ' ') {
togglePlayback();
+26 -16
View File
@@ -100,7 +100,30 @@ impl Layout {
String::new()
};
let search_js_asset_hash = asset_hashes.search_js.as_ref().unwrap();
let search;
let search_script;
if let Some(search_js_asset_hash) = &asset_hashes.search_js {
let search_icon = icons::search(translations.search);
let close_icon = icons::failure(translations.close);
let t_search = translations.search;
search = formatdoc!(r#"
<div class="search" data-root-prefix="{root_prefix}">
<div class="icon">{search_icon}</div>
<input autocomplete="off" placeholder="{t_search} (s)" type="search">
<button>
{close_icon}
</button>
<div class="pane">
<div role="status"></div>
<div class="results"></div>
</div>
</div>
"#);
search_script = format!(r#"<script defer src="{root_prefix}search.js?{search_js_asset_hash}"></script>"#);
} else {
search = String::new();
search_script = String::new();
};
let head = formatdoc!(r#"
<head>
@@ -113,7 +136,7 @@ impl Layout {
{adaptive_theme_script}
{clipboard_script}
{video_script}
<script defer src="{root_prefix}search.js?{search_js_asset_hash}"></script>
{search_script}
</head>
"#);
@@ -152,9 +175,6 @@ impl Layout {
templates.push_str(&player_icon_templates(translations));
}
let search_icon = icons::search(translations.search);
let close_icon = icons::failure(translations.close);
let t_search = translations.search;
let body = formatdoc!(r##"
<body>
<div class="layout">
@@ -178,17 +198,7 @@ impl Layout {
Hyper 8 Video System {version_display}
</a>
</div>
<div class="search" data-root-prefix="{root_prefix}">
<div class="icon">{search_icon}</div>
<input autocomplete="off" placeholder="{t_search} (s)" type="search">
<button>
{close_icon}
</button>
<div class="pane">
<div role="status"></div>
<div class="results"></div>
</div>
</div>
{search}
</footer>
</div>
{templates}
+1 -1
View File
@@ -112,7 +112,7 @@ pub fn playlist_html(
.join("\n");
let videos_rendered = formatdoc!(r#"
<div class="playlist">
<div class="playlist_page">
{videos}
</div>
"#);
+99 -64
View File
@@ -5,7 +5,15 @@ use std::fs;
use indoc::formatdoc;
use crate::{Context, Site, SiteItem};
use crate::{
Collection,
Context,
Playlist,
Site,
SiteContent,
Translations,
Video
};
use crate::util::url_safe_hash_base64;
use super::AssetHashes;
@@ -38,82 +46,109 @@ pub fn generate_search_js(
) {
let translations = &site.language.translations;
let mut items = Vec::new();
let mut visit = |item: SiteItem| {
let image;
let kind;
let title;
let site_path;
match item {
SiteItem::Collection(collection) => {
image = None;
kind = "collection";
title = collection.title_or_slug_or_generic_label(&translations);
site_path = collection.site_path.normalized();
}
SiteItem::Playlist(playlist) => {
image = None;
kind = "playlist";
title = playlist.title_or_slug_or_generic_label(&translations);
site_path = playlist.site_path.normalized();
}
SiteItem::Video(video) => {
image = video.poster
.as_ref()
.and_then(|poster| poster.assets.as_ref())
.map(|assets| assets.max_160.target_filename("poster"));
kind = "video";
title = video.title_or_slug_or_generic_label(&translations);
site_path = video.site_path.normalized();
}
}
fn video_js(translations: &Translations, video: &Video) -> String {
let image = video.poster
.as_ref()
.and_then(|poster| poster.assets.as_ref())
.map(|assets| assets.max_160.target_filename("poster"));
let title = video.title_or_slug_or_generic_label(&translations);
let site_path = video.site_path.normalized();
let r_image = if let Some(file_name) = image {
format!("image: '{file_name}',")
format!("image:'{file_name}',")
} else {
String::new()
};
let title_escaped = js_escape_inside_single_quoted_string(title);
let r_item = formatdoc!(r#"
{{
kind: '{kind}',
{r_image}
title: '{title_escaped}',
url: '{site_path}'
}}
"#);
items.push(r_item);
format!("{{{r_image}sitePath:'{site_path}',title:'{title_escaped}'}}")
}
fn playlist_js(playlist: &Playlist, translations: &Translations) -> String {
let title = playlist.title_or_slug_or_generic_label(&translations);
let site_path = playlist.site_path.normalized();
let title_escaped = js_escape_inside_single_quoted_string(title);
let videos = playlist.public_videos_in_playlist_order()
.iter()
.map(|video| video_js(translations, &video))
.collect::<Vec<String>>()
.join(", ");
format!("{{sitePath:'{site_path}',title:'{title_escaped}',videos:[{videos}]}}")
}
fn collection_js(collection: &Collection, translations: &Translations) -> String {
let title = collection.title_or_slug_or_generic_label(&translations);
let site_path = collection.site_path.normalized();
let title_escaped = js_escape_inside_single_quoted_string(title);
let playlists = collection.public_playlists_desc_by_release_date()
.iter()
.map(|playlist| playlist_js(&playlist, translations))
.collect::<Vec<String>>()
.join(", ");
let subcollections = collection.public_subcollections_desc_by_release_date()
.iter()
.map(|subcollection| collection_js(&subcollection, translations))
.collect::<Vec<String>>()
.join(", ");
let videos = collection.public_videos_in_collection_video_order()
.iter()
.map(|video| video_js(translations, &video))
.collect::<Vec<String>>()
.join(", ");
format!("{{\
playlists:[{playlists}],\
sitePath:'{site_path}',\
subcollections:[{subcollections}],\
title:'{title_escaped}',\
videos:[{videos}]\
}}")
}
let site_content = match &site.content {
SiteContent::Collection(collection) => {
let r_collection = collection_js(collection, translations);
let site_content = format!("{{collection:{r_collection}}}");
Some(site_content)
}
SiteContent::Empty => None,
SiteContent::Invalid { .. } => None,
SiteContent::Playlist(playlist) => {
let r_playlist = playlist_js(playlist, translations);
let site_content = format!("{{playlist:{r_playlist}}}");
Some(site_content)
}
SiteContent::Video(_) => None
};
site.walk_recursive(&mut visit);
if let Some(site_content) = site_content {
let t_nothing_found_for_xxx = js_escape_inside_single_quoted_string(translations.nothing_found_for_xxx);
let t_showing_xxx_results_for_xxx = js_escape_inside_single_quoted_string(translations.showing_xxx_results_for_xxx);
let mut js = formatdoc!(r#"
const SEARCH_JS_T = {{
nothingFoundForXxx: query => '{t_nothing_found_for_xxx}'.replace('{{query}}', query),
showingXxxResultsForXxx: (count, query) => '{t_showing_xxx_results_for_xxx}'.replace('{{count}}', count).replace('{{query}}', query)
}};
const siteContent = {site_content};
"#);
let r_items = items.join(",\n");
js.push_str(include_str!("assets/search.js"));
let t_nothing_found_for_xxx = js_escape_inside_single_quoted_string(translations.nothing_found_for_xxx);
let t_showing_xxx_results_for_xxx = js_escape_inside_single_quoted_string(translations.showing_xxx_results_for_xxx);
let mut js = formatdoc!(r#"
const SEARCH_JS_T = {{
nothingFoundForXxx: query => '{t_nothing_found_for_xxx}'.replace('{{query}}', query),
showingXxxResultsForXxx: (count, query) => '{t_showing_xxx_results_for_xxx}'.replace('{{count}}', count).replace('{{query}}', query)
}};
asset_hashes.search_js = Some(url_safe_hash_base64(&js));
const SITE_ITEMS = [
{r_items}
];
"#);
js.push_str(include_str!("assets/search.js"));
asset_hashes.search_js = Some(url_safe_hash_base64(&js));
fs::write(
context.build_dir.join("search.js"),
js
).unwrap();
fs::write(
context.build_dir.join("search.js"),
js
).unwrap();
}
}
pub fn generate_video_js(context: &Context) {
+1 -1
View File
@@ -151,4 +151,4 @@ impl Display for Input<'_> {
write!(formatter, "{html}")
}
}
}