diff options
Diffstat (limited to 'ui/src/node_page.rs')
-rw-r--r-- | ui/src/node_page.rs | 209 |
1 files changed, 209 insertions, 0 deletions
diff --git a/ui/src/node_page.rs b/ui/src/node_page.rs new file mode 100644 index 0000000..8848202 --- /dev/null +++ b/ui/src/node_page.rs @@ -0,0 +1,209 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2025 metamuffin <metamuffin.org> +*/ + +use crate::{ + filter_sort::NodeFilterSortForm, + format::format_chapter, + locale::{Language, trs}, + node_card::{NodeCard, NodeCardWide}, + props::Props, +}; +use jellycommon::{ + Chapter, Node, NodeKind, PeopleGroup, + api::NodeFilterSort, + user::{NodeUserData, WatchedState}, +}; +use std::sync::Arc; + +markup::define! { + NodePage<'a>( + node: &'a Node, + udata: &'a NodeUserData, + children: &'a [(Arc<Node>, NodeUserData)], + parents: &'a [(Arc<Node>, NodeUserData)], + similar: &'a [(Arc<Node>, NodeUserData)], + filter: &'a NodeFilterSort, + lang: &'a Language, + player: bool, + ) { + @if !matches!(node.kind, NodeKind::Collection) && !player { + img.backdrop[src=uri!(r_item_backdrop(&node.slug, Some(2048))), loading="lazy"]; + } + .page.node { + @if !matches!(node.kind, NodeKind::Collection) && !player { + @let cls = format!("bigposter {}", aspect_class(node.kind)); + div[class=cls] { img[src=uri!(r_item_poster(&node.slug, Some(2048))), loading="lazy"]; } + } + .title { + h1 { @node.title } + ul.parents { @for (node, _) in *parents { li { + a.component[href=uri!(r_library_node(&node.slug))] { @node.title } + }}} + @if node.media.is_some() { + a.play[href=&uri!(r_player(&node.slug, PlayerConfig::default()))] { @trs(lang, "node.player_link") } + } + @if !matches!(node.kind, NodeKind::Collection | NodeKind::Channel) { + @if matches!(udata.watched, WatchedState::None | WatchedState::Pending | WatchedState::Progress(_)) { + form.mark_watched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::Watched))] { + input[type="submit", value=trs(lang, "node.watched.set")]; + } + } + @if matches!(udata.watched, WatchedState::Watched) { + form.mark_unwatched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::None))] { + input[type="submit", value=trs(lang, "node.watched.unset")]; + } + } + @if matches!(udata.watched, WatchedState::None) { + form.mark_unwatched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::Pending))] { + input[type="submit", value=trs(lang, "node.watchlist.set")]; + } + } + @if matches!(udata.watched, WatchedState::Pending) { + form.mark_unwatched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::None))] { + input[type="submit", value=trs(lang, "node.watchlist.unset")]; + } + } + form.rating[method="POST", action=uri!(r_node_userdata_rating(&node.slug))] { + input[type="range", name="rating", min=-10, max=10, step=1, value=udata.rating]; + input[type="submit", value=trs(lang, "node.update_rating")]; + } + } + } + .details { + @Props { node, udata, full: true, lang } + h3 { @node.tagline } + @if let Some(description) = &node.description { + p { @for line in description.lines() { @line br; } } + } + @if let Some(media) = &node.media { + @if !media.chapters.is_empty() { + h2 { @trs(lang, "node.chapters") } + ul.children.hlist { @for chap in &media.chapters { + @let (inl, sub) = format_chapter(chap); + li { .card."aspect-thumb" { + .poster { + a[href=&uri!(r_player(&node.slug, PlayerConfig::seek(chap.time_start.unwrap_or(0.))))] { + img[src=&uri!(r_node_thumbnail(&node.slug, chapter_key_time(chap, media.duration), Some(1024))), loading="lazy"]; + } + .cardhover { .props { p { @inl } } } + } + .title { span { @sub } } + }} + }} + } + @if !node.people.is_empty() { + h2 { @trs(lang, "node.people") } + @for (group, people) in &node.people { + details[open=group==&PeopleGroup::Cast] { + summary { h3 { @format!("{}", group) } } + ul.children.hlist { @for (i, pe) in people.iter().enumerate() { + li { .card."aspect-port" { + .poster { + a[href="#"] { + img[src=&uri!(r_person_asset(&node.slug, i, group.to_string(), Some(1024))), loading="lazy"]; + } + } + .title { + span { @pe.person.name } br; + @if let Some(c) = pe.characters.first() { + span.subtitle { @c } + } + @if let Some(c) = pe.jobs.first() { + span.subtitle { @c } + } + } + }} + }} + } + } + } + details { + summary { @trs(lang, "media.tracks") } + ol { @for track in &media.tracks { + li { @format!("{track}") } + }} + } + } + @if !node.external_ids.is_empty() { + details { + summary { @trs(lang, "node.external_ids") } + table { + @for (key, value) in &node.external_ids { tr { + tr { + td { @trs(lang, &format!("eid.{}", key)) } + @if let Some(url) = external_id_url(key, value) { + td { a[href=url] { pre { @value } } } + } else { + td { pre { @value } } + } + } + }} + } + } + } + @if !node.tags.is_empty() { + details { + summary { @trs(lang, "node.tags") } + ol { @for tag in &node.tags { + li { @tag } + }} + } + } + } + @if matches!(node.kind, NodeKind::Collection | NodeKind::Channel) { + @NodeFilterSortForm { f: filter, lang } + } + @if !similar.is_empty() { + h2 { @trs(lang, "node.similar") } + ul.children.hlist {@for (node, udata) in similar.iter() { + li { @NodeCard { node, udata, lang } } + }} + } + @match node.kind { + NodeKind::Show | NodeKind::Series | NodeKind::Season => { + ol { @for (node, udata) in children.iter() { + li { @NodeCardWide { node, udata, lang } } + }} + } + NodeKind::Collection | NodeKind::Channel | _ => { + ul.children {@for (node, udata) in children.iter() { + li { @NodeCard { node, udata, lang } } + }} + } + } + } + } +} + +fn chapter_key_time(c: &Chapter, dur: f64) -> f64 { + let start = c.time_start.unwrap_or(0.); + let end = c.time_end.unwrap_or(dur); + start * 0.8 + end * 0.2 +} + +pub fn aspect_class(kind: NodeKind) -> &'static str { + use NodeKind::*; + match kind { + Video | Episode => "aspect-thumb", + Collection => "aspect-land", + Season | Show | Series | Movie | ShortFormVideo => "aspect-port", + Channel | Music | Unknown => "aspect-square", + } +} + +fn external_id_url(key: &str, value: &str) -> Option<String> { + Some(match key { + "youtube.video" => format!("https://youtube.com/watch?v={value}"), + "youtube.channel" => format!("https://youtube.com/channel/{value}"), + "youtube.channelname" => format!("https://youtube.com/channel/@{value}"), + "musicbrainz.release" => format!("https://musicbrainz.org/release/{value}"), + "musicbrainz.albumartist" => format!("https://musicbrainz.org/artist/{value}"), + "musicbrainz.artist" => format!("https://musicbrainz.org/artist/{value}"), + "musicbrainz.releasegroup" => format!("https://musicbrainz.org/release-group/{value}"), + "musicbrainz.recording" => format!("https://musicbrainz.org/recording/{value}"), + _ => return None, + }) +} |