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}
{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}
{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};