Iterate on design, displayed details, ordering, search and responsive behavior

This commit is contained in:
Simon Repp
2025-08-07 15:53:05 +02:00
parent 2c4bd010f1
commit d1948651fe
8 changed files with 222 additions and 109 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ body:not(.light) #toggle-dark {
background: none;
border: none;
border-radius: .9rem;
color: var(--fg-mg);
color: var(--fg-3);
cursor: pointer;
display: inline-flex;
height: 1.8rem;
+25 -4
View File
@@ -82,14 +82,29 @@ function initializeSiteTree() {
image.classList.add('placeholder');
}
const spanText = document.createElement('span');
spanText.textContent = video.title;
const divTitleDetails = document.createElement('div');
const spanTitle = document.createElement('span');
spanTitle.textContent = video.title;
divTitleDetails.appendChild(spanTitle);
const spanDetails = document.createElement('span');
if (video.date) {
if (video.duration) {
spanDetails.textContent = `${video.date}, ${video.duration}`;
} else {
spanDetails.textContent = video.date;
}
} else if (video.duration) {
spanDetails.textContent = video.duration;
}
divTitleDetails.appendChild(spanDetails);
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);
video.element.appendChild(divTitleDetails);
if (video.sitePath === sitePath) {
video.element.classList.add('current');
@@ -296,10 +311,16 @@ function updateSearch() {
if (query.length) {
const regexp = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
// If the query is four digits we additionally try to match it against
// video dates.
const yearQuery = /^\d{4}$/.test(query);
let shown = 0;
function updateVideo(video) {
const display = regexp.test(video.title);
const display = regexp.test(video.title) ||
!!(yearQuery && video.date && video.date.includes(query));
video.element.classList.toggle('visible', display);
if (display) { shown += 1; }
return display;
+79 -58
View File
@@ -13,7 +13,8 @@ a:hover { color: var(--fg-3); }
column-gap: .3rem;
cursor: pointer;
display: flex;
padding: .3rem .6rem .3rem .5rem;
font-size: .9rem;
padding: .3rem .4rem;
white-space: nowrap;
}
.actions a:focus-visible,
@@ -111,6 +112,7 @@ button {
footer {
align-items: center;
background: var(--bg-1);
box-sizing: border-box;
display: flex;
font-size: .9rem;
flex-wrap: wrap;
@@ -178,6 +180,7 @@ img {
gap: 1rem;
}
.links {
font-size: .9rem;
margin-bottom: 1rem;
margin-top: 1rem;
}
@@ -188,7 +191,7 @@ img {
color: var(--fg-1);
column-gap: .3rem;
display: flex;
padding: .3rem .6rem .3rem .5rem;
padding: .3rem .4rem;
width: max-content;
}
.links > a:not(:first-child) { margin-top: .5rem; }
@@ -203,25 +206,23 @@ img {
padding: .5rem;
}
.medium_font { font-size: 1.2rem; }
.outline {
flex-shrink: 0;
min-width: 20rem;
}
.parent_context {
display: flex;
flex-wrap: wrap;
font-size: .9rem;
gap: .3rem;
margin-bottom: .2rem;
}
.parent_context a:not(.context_dot) {
align-items: center;
column-gap: .3rem;
display: flex;
}
.parent_context > div {
align-items: flex-end;
display: flex;
flex-direction: column;
gap: .3rem;
font-size: .9rem;
gap: .5rem;
margin-bottom: .2rem;
}
.parent_context > div > div:nth-child(2) { display: flex; }
.parent_context.inline {
align-items: center;
flex-direction: row-reverse;
}
.parent_context > div:nth-child(2) { display: flex; }
.parents {
font-size: .9rem;
margin-top: .2rem;
@@ -349,28 +350,26 @@ img {
@media (max-aspect-ratio: 2/3) {
.player_wrapper:not(:fullscreen) video { max-height: 76dvh; }
}
.playlist_context { margin-top: 6.1rem; }
.playlist_context > a {
align-items: center;
border-radius: .3rem;
display: flex;
outline: none;
position: relative;
}
.playlist_context > a:first-child {
border-top-left-radius: .4rem;
border-top-right-radius: .4rem;
}
.playlist_context > a:first-child img { border-top-left-radius: .4rem }
.playlist_context > a:last-child {
border-bottom-left-radius: .4rem;
border-bottom-right-radius: .4rem;
}
.playlist_context > a:last-child img { border-bottom-left-radius: .4rem }
.playlist_context > a:focus-visible,
.playlist_context > a:hover { background: var(--bg-2); }
.playlist_context > a:not(:last-child) {
margin-bottom: .5rem;
}
.playlist_context .current { background: var(--bg-3); }
.playlist_context .current::after {
background: var(--action);
border-radius: .1rem;
content: '';
height: 3rem;
margin-left: -.2rem;
width: .2rem;
}
.playlist_context .description {
align-items: flex-start;
color: var(--fg-1);
@@ -380,35 +379,26 @@ img {
justify-content: center;
padding: .2rem .8rem;
}
.playlist_context .details { font-size: .8rem; }
.playlist_context .duration { color: var(--fg-3); }
.playlist_context .label {
font-size: .9rem;
max-width: 15rem; /* TODO: Resolve this hack, need dynamic fitting into max-width */
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist_context .more {
align-items: center;
display: flex;
height: 4rem;
justify-content: center;
}
.playlist_context .number {
background: var(--bg-1);
border-bottom-left-radius: .2rem;
.playlist_context .details {
color: var(--fg-3);
font-size: .8rem;
padding: 0 .4rem 0 .5rem;
position: absolute;
right: 0;
top: 0;
}
.playlist_context .label {
font-size: .9rem;
max-width: 15rem;
}
.playlist_context .number_context {
background: var(--fg-3);
border-radius: .2rem;
color: var(--bg-1);
font-size: .8rem;
margin-bottom: .2rem;
padding: .15rem .36rem;
}
.playlist_context img {
aspect-ratio: var(--poster-aspect);
background: var(--d-1);
border-radius: 0;
border-radius: .3rem;
display: block;
flex-shrink: 0;
height: 4rem;
@@ -495,6 +485,7 @@ img {
background: var(--bg-1);
border: 1px solid var(--bg-3);
border-radius: .5em;
box-shadow: 0 0 2rem var(--d-1-15);
box-sizing: border-box;
display: flex;
max-height: 80dvh;
@@ -502,7 +493,7 @@ img {
overflow-y: auto;
padding: 1rem;
position: absolute;
top: 3.5rem;
top: 3.2rem;
z-index: 2;
}
.site_tree [role="status"]:not(:empty) {
@@ -520,13 +511,13 @@ img {
display: flex;
}
.site_tree .elements a.current::before {
width: 0.2rem;
margin-left: -0.2rem;
right: 0;
height: 1rem;
background: var(--action);
border-radius: .1rem;
content: '';
border-radius: 0.1rem;
height: 1rem;
margin-left: -.2rem;
right: 0;
width: .2rem;
}
.site_tree .elements a:focus-visible,
.site_tree .elements a:hover {
@@ -551,6 +542,10 @@ img {
font-size: .6rem;
padding: .2rem;
}
.site_tree .elements .collection:hover > :nth-child(1) svg,
.site_tree .elements .playlist:hover > :nth-child(1) svg {
background: var(--bg-1);
}
.site_tree .elements .collection > :nth-child(1):hover svg,
.site_tree .elements .playlist > :nth-child(1):hover svg {
background: var(--fg-3);
@@ -585,8 +580,15 @@ img {
margin-left: 1.5rem;
}
.site_tree .elements .video > :nth-child(2) {
display: flex;
flex-direction: column;
flex: 1;
margin-left: .7rem;
row-gap: .1rem;
}
.site_tree .elements .video > :nth-child(2) > :nth-child(2) {
color: var(--fg-3);
font-size: .8rem;
}
.site_tree .elements .placeholder { background: var(--bg-3); }
.site_tree:not(.open) { display: none; }
@@ -714,10 +716,19 @@ video {
width: 100%;
}
.video_info { margin-bottom: 1.5rem; }
.video_info.top {
align-items: center;
column-gap: 3rem;
display: flex;
margin-top: 2rem;
display: flex;
justify-content: space-between;
}
.video_title {
color: var(--fg-1);
font-size: 1.4rem;
margin: 0;
margin-bottom: .2em;
margin-bottom: .3em;
}
.videos {
column-gap: 1rem;
@@ -756,6 +767,14 @@ video {
@media (min-width: 30rem) {
.videos_grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 40rem) {
.parent_context { align-items: flex-start; }
.video_info.top {
align-items: flex-start;
flex-direction: column;
}
.video_title { font-size: 1.2rem; }
}
@media (min-width: 44rem) {
.videos_grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@@ -764,6 +783,7 @@ video {
background: linear-gradient(var(--bg-1), var(--bg-1-85));
border: none;
bottom: 0;
box-shadow: unset;
left: 0;
max-height: unset;
padding: .5rem;
@@ -777,6 +797,7 @@ video {
@media (min-width: 55rem) {
.layout_split { flex-direction: row; }
.layout_split > :nth-child(1) { flex-grow: 1; }
.playlist_context { margin-top: 6.3rem; }
}
@media (min-width: 58rem) {
.videos_grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+42 -18
View File
@@ -16,6 +16,7 @@ use crate::build::widgets::{banner_html, poster_html};
use crate::util::{
casual_duration,
html_escape_outside_attribute,
localized_date_d_mmm_yyyy,
localized_date_mmm_yyyy
};
@@ -457,7 +458,8 @@ impl TraversalStep<'_> {
let label = collection.title_or_slug_or_generic_label(translations);
let label_escaped = html_escape_outside_attribute(label);
let video_bubbles = collection.public_videos()
let video_bubbles = collection.public_videos_in_collection_video_order()
.iter()
.map(|video| {
if let Some(poster_meta) = &video.poster.as_ref().and_then(|poster| poster.poster_meta.as_ref()) {
let background = poster_meta.lqip.gradient_radial();
@@ -471,10 +473,11 @@ impl TraversalStep<'_> {
.join("");
let count = collection.public_videos().count();
let inline = if count > 3 { "" } else { "inline" };
format!(r#"
<div>
<div><a href="{prefix}{index_suffix_file_only}">{label_escaped}</a></div>
<div class="parent_context {inline}">
<a href="{prefix}{index_suffix_file_only}">{label_escaped}</a>
<div style="--count: {count}">{video_bubbles}</div>
</div>
"#)
@@ -483,7 +486,8 @@ impl TraversalStep<'_> {
let label = playlist.title_or_slug_or_generic_label(translations);
let label_escaped = html_escape_outside_attribute(label);
let video_bubbles = playlist.public_videos()
let video_bubbles = playlist.public_videos_in_playlist_order()
.iter()
.map(|video| {
if let Some(poster_meta) = &video.poster.as_ref().and_then(|poster| poster.poster_meta.as_ref()) {
let background = poster_meta.lqip.gradient_radial();
@@ -497,10 +501,11 @@ impl TraversalStep<'_> {
.join("");
let count = playlist.public_videos().count();
let inline = if count > 3 { "" } else { "inline" };
format!(r#"
<div>
<div><a href="{prefix}{index_suffix_file_only}">{label_escaped}</a></div>
<div class="parent_context {inline}">
<a href="{prefix}{index_suffix_file_only}">{label_escaped}</a>
<div style="--count: {count}">{video_bubbles}</div>
</div>
"#)
@@ -757,28 +762,47 @@ impl TraversalStep<'_> {
format!("../{slug}/")
};
let thumbnail_rendered = thumbnail(&site.language.translations, video_iterated, &video_prefix, false);
let thumbnail_rendered = thumbnail(translations, video_iterated, &video_prefix, false);
let (current, href) = if video_iterated.site_path == video.site_path {
("current", String::new())
let current;
let href;
let number_context;
if video_iterated.site_path == video.site_path {
current = "current";
href = String::new();
let count = videos.len();
let number = index + 1;
let playlist_label = playlist.title_or_slug_or_generic_label(translations);
let t_video_xxx_of_xxx = translations.video_xxx_of_xxx(number, count);
number_context = format!(r#"<span class="number_context">{playlist_label} {t_video_xxx_of_xxx}</span>"#);
} else {
("", format!("../{slug}{index_suffix}"))
};
current = "";
href = format!("../{slug}{index_suffix}");
number_context = String::new();
}
let duration = match &video_iterated.highest_quality_version().and_then(|version| version.video_meta.as_ref()) {
Some(video_meta) => casual_duration(video_meta.duration, &site.language.translations),
None => String::new()
};
let mut details = Vec::new();
if let Some(release_date) = video.release_date {
let date = localized_date_d_mmm_yyyy(release_date, site.language.chrono_locale);
details.push(date);
}
if let Some(video_meta) = &video_iterated.highest_quality_version().and_then(|version| version.video_meta.as_ref()) {
let duration = casual_duration(video_meta.duration, translations);
details.push(duration);
}
let details = details.join(", ");
let number = index + 1;
formatdoc!(r#"
<a class="{current}" href="{href}">
<div class="number">{number}</div>
{thumbnail_rendered}
<div class="description">
{number_context}
<span class="label">{label}</span>
<span class="details">
<span class="duration">{duration}</span>
{details}
</span>
</div>
</a>
+37 -16
View File
@@ -8,13 +8,17 @@ use indoc::formatdoc;
use crate::{
Collection,
Context,
Language,
Playlist,
Site,
SiteContent,
Translations,
Video
};
use crate::util::url_safe_hash_base64;
use crate::util::{
casual_duration,
localized_date_d_mmm_yyyy,
url_safe_hash_base64
};
use super::AssetHashes;
@@ -44,9 +48,10 @@ pub fn generate_navigation_js(
context: &Context,
site: &Site
) {
let translations = &site.language.translations;
let language = &site.language;
let translations = &language.translations;
fn video_js(translations: &Translations, video: &Video) -> String {
fn video_js(language: &Language, video: &Video) -> String {
let image;
let lqip;
match &video.poster {
@@ -70,49 +75,65 @@ pub fn generate_navigation_js(
}
}
let date = match video.release_date {
Some(release_date) => {
let date = localized_date_d_mmm_yyyy(release_date, language.chrono_locale);
format!("date:'{date}',")
}
None => String::new()
};
let duration = match &video.highest_quality_version().and_then(|version| version.video_meta.as_ref()) {
Some(video_meta) => {
let duration = casual_duration(video_meta.duration, &language.translations);
format!("duration:'{duration}',")
}
None => String::new()
};
let site_path = video.site_path.normalized();
let title = video.title_or_slug_or_generic_label(&translations);
let title = video.title_or_slug_or_generic_label(&language.translations);
let title_escaped = js_escape_inside_single_quoted_string(title);
format!("{{{lqip}{image}sitePath:'{site_path}',title:'{title_escaped}'}}")
format!("{{{date}{duration}{lqip}{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);
fn playlist_js(language: &Language, playlist: &Playlist) -> String {
let title = playlist.title_or_slug_or_generic_label(&language.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))
.map(|video| video_js(language, &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);
fn collection_js(collection: &Collection, language: &Language) -> String {
let title = collection.title_or_slug_or_generic_label(&language.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))
.map(|playlist| playlist_js(language, &playlist))
.collect::<Vec<String>>()
.join(", ");
let subcollections = collection.public_subcollections_desc_by_release_date()
.iter()
.map(|subcollection| collection_js(&subcollection, translations))
.map(|subcollection| collection_js(&subcollection, language))
.collect::<Vec<String>>()
.join(", ");
let videos = collection.public_videos_in_collection_video_order()
.iter()
.map(|video| video_js(translations, &video))
.map(|video| video_js(language, &video))
.collect::<Vec<String>>()
.join(", ");
@@ -127,14 +148,14 @@ pub fn generate_navigation_js(
let site_content = match &site.content {
SiteContent::Collection(collection) => {
let r_collection = collection_js(collection, translations);
let r_collection = collection_js(collection, language);
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 r_playlist = playlist_js(language, playlist);
let site_content = format!("{{playlist:{r_playlist}}}");
Some(site_content)
}
+4 -12
View File
@@ -87,13 +87,7 @@ pub fn video_html(
let parent_context = if traversal.steps.len() > 1 {
let parent_step = &traversal.steps[traversal.steps.len() - 2];
let context_with_bubbles = parent_step.context_with_bubbles("../", site);
formatdoc!(r#"
<div class="parent_context">
{context_with_bubbles}
</div>
"#)
parent_step.context_with_bubbles("../", site)
} else {
String::new()
};
@@ -148,11 +142,9 @@ pub fn video_html(
let video_content = formatdoc!(r#"
<div>
<div class="video_info">
<div class="align apart flex space wrap" style="margin-top: 2rem;">
<h1 class="video_title">{e_title} {r_unlisted_badge}</h1>
{parent_context}
</div>
<div class="video_info top">
<h1 class="video_title">{e_title} {r_unlisted_badge}</h1>
{parent_context}
</div>
{player_rendered}
<div class="video_info">
+15
View File
@@ -274,6 +274,7 @@ pub struct Translations {
pub video_order: &'static str,
pub video_player_widget_for_xxx: &'static str,
pub video_settings: &'static str,
video_xxx_of_xxx: &'static str,
pub videos: &'static str,
pub vp9_missing_browser_support_not_recommended: &'static str,
pub warm: &'static str,
@@ -568,6 +569,7 @@ impl Translations {
video_order: "Video Reihenfolge",
video_player_widget_for_xxx: r#"Videoplayer-Widget für "{title}""#,
video_settings: "Video Einstellungen",
video_xxx_of_xxx: "Video {current} von {total}",
videos: "Videos",
vp9_missing_browser_support_not_recommended: "VP9 Fehlende Browserunterstützung (Nicht empfohlen)",
warm: "Warm",
@@ -863,6 +865,7 @@ impl Translations {
video_order: "Video Order",
video_player_widget_for_xxx: r#"Video player widget for "{title}""#,
video_settings: "Video settings",
video_xxx_of_xxx: "Video {current} of {total}",
videos: "Videos",
vp9_missing_browser_support_not_recommended: "VP9 Missing browser support (Not recommended)",
warm: "Warm",
@@ -965,6 +968,16 @@ impl Translations {
self.video_player_widget_for_xxx.replace("{title}", title)
}
pub fn video_xxx_of_xxx(
&self,
current: usize,
total: usize
) -> String {
self.video_xxx_of_xxx
.replace("{current}", &current.to_string())
.replace("{total}", &total.to_string())
}
pub fn xxx_characters_maximum(&self, maximum: usize) -> String {
self.xxx_characters_maximum.replace("{maximum}", &maximum.to_string())
}
@@ -1000,6 +1013,8 @@ fn check_translations() {
assert!(&translations.updating_video_from_editor_currently_not_allowed.contains("{video_title}"));
assert!(&translations.updating_video_from_editor_currently_not_allowed.contains("{manifest_path}"));
assert!(&translations.video_player_widget_for_xxx.contains("{title}"));
assert!(&translations.video_xxx_of_xxx.contains("{current}"));
assert!(&translations.video_xxx_of_xxx.contains("{total}"));
assert!(&translations.xxx_characters_maximum.contains("{maximum}"));
let disallowed_char = |c: char| !c.is_ascii_alphanumeric() && c != '-';
+19
View File
@@ -196,6 +196,24 @@ pub fn human_size(bytes: u64) -> String {
}
}
/// E.g. 1 Jan 2000
pub fn localized_date_d_mmm_yyyy(date: NaiveDate, chrono_locale: ChronoLocale) -> String {
use chrono::format::{Fixed, Item, Numeric, Pad};
let format = [
Item::Numeric(Numeric::Day, Pad::None),
Item::Space(" "),
Item::Fixed(Fixed::ShortMonthName),
Item::Space(" "),
Item::Numeric(Numeric::Year, Pad::None)
];
date
.format_localized_with_items(format.iter(), chrono_locale)
.to_string()
}
/// E.g. 1 January 2000
pub fn localized_date_d_mmmm_yyyy(date: NaiveDate, chrono_locale: ChronoLocale) -> String {
use chrono::format::{Fixed, Item, Numeric, Pad};
@@ -212,6 +230,7 @@ pub fn localized_date_d_mmmm_yyyy(date: NaiveDate, chrono_locale: ChronoLocale)
.to_string()
}
/// E.g. Jan 2000
pub fn localized_date_mmm_yyyy(date: NaiveDate, chrono_locale: ChronoLocale) -> String {
use chrono::format::{Fixed, Item, Numeric, Pad};