diff --git a/src/build/assets/adaptive_theme.css b/src/build/assets/adaptive_theme.css index 5c1d3c2..70aa762 100644 --- a/src/build/assets/adaptive_theme.css +++ b/src/build/assets/adaptive_theme.css @@ -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; diff --git a/src/build/assets/navigation.js b/src/build/assets/navigation.js index e658ee8..9304f19 100644 --- a/src/build/assets/navigation.js +++ b/src/build/assets/navigation.js @@ -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; diff --git a/src/build/assets/site.css b/src/build/assets/site.css index 8416007..895c95a 100644 --- a/src/build/assets/site.css +++ b/src/build/assets/site.css @@ -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)); } diff --git a/src/build/outline.rs b/src/build/outline.rs index 0aa6253..bc3e63f 100644 --- a/src/build/outline.rs +++ b/src/build/outline.rs @@ -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#" -
-
{label_escaped}
+
+ {label_escaped}
{video_bubbles}
"#) @@ -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#" -
-
{label_escaped}
+
+ {label_escaped}
{video_bubbles}
"#) @@ -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#"{playlist_label} – {t_video_xxx_of_xxx}"#); } 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#" -
{number}
{thumbnail_rendered}
+ {number_context} {label} - {duration} + {details}
diff --git a/src/build/scripts.rs b/src/build/scripts.rs index 20e9044..1dde3b9 100644 --- a/src/build/scripts.rs +++ b/src/build/scripts.rs @@ -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::>() .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::>() .join(", "); let subcollections = collection.public_subcollections_desc_by_release_date() .iter() - .map(|subcollection| collection_js(&subcollection, translations)) + .map(|subcollection| collection_js(&subcollection, language)) .collect::>() .join(", "); let videos = collection.public_videos_in_collection_video_order() .iter() - .map(|video| video_js(translations, &video)) + .map(|video| video_js(language, &video)) .collect::>() .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) } diff --git a/src/build/video.rs b/src/build/video.rs index 164030e..65a6de7 100644 --- a/src/build/video.rs +++ b/src/build/video.rs @@ -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#" -
- {context_with_bubbles} -
- "#) + parent_step.context_with_bubbles("../", site) } else { String::new() }; @@ -148,11 +142,9 @@ pub fn video_html( let video_content = formatdoc!(r#"
-
-
-

{e_title} {r_unlisted_badge}

- {parent_context} -
+
+

{e_title} {r_unlisted_badge}

+ {parent_context}
{player_rendered}
diff --git a/src/translations.rs b/src/translations.rs index f2c7ed0..7284c72 100644 --- a/src/translations.rs +++ b/src/translations.rs @@ -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}", ¤t.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 != '-'; diff --git a/src/util.rs b/src/util.rs index 5662617..c2ab19b 100644 --- a/src/util.rs +++ b/src/util.rs @@ -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};