aboutsummaryrefslogtreecommitdiff
path: root/ui/src/node_page.rs
diff options
context:
space:
mode:
Diffstat (limited to 'ui/src/node_page.rs')
-rw-r--r--ui/src/node_page.rs209
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,
+ })
+}