diff options
author | metamuffin <metamuffin@disroot.org> | 2025-04-28 00:48:52 +0200 |
---|---|---|
committer | metamuffin <metamuffin@disroot.org> | 2025-04-28 00:48:52 +0200 |
commit | 80d28b764c95891551e28c395783f5ff9d065743 (patch) | |
tree | f25898b1c939a939c63236ca4e8e843e81069947 /ui | |
parent | 335ba978dbaf203f3603a815147fd75dbf205723 (diff) | |
download | jellything-80d28b764c95891551e28c395783f5ff9d065743.tar jellything-80d28b764c95891551e28c395783f5ff9d065743.tar.bz2 jellything-80d28b764c95891551e28c395783f5ff9d065743.tar.zst |
start with splitting server
Diffstat (limited to 'ui')
-rw-r--r-- | ui/Cargo.toml | 9 | ||||
-rw-r--r-- | ui/src/filter_sort.rs | 135 | ||||
-rw-r--r-- | ui/src/format.rs | 117 | ||||
-rw-r--r-- | ui/src/home.rs | 36 | ||||
-rw-r--r-- | ui/src/lib.rs | 15 | ||||
-rw-r--r-- | ui/src/locale.rs | 82 | ||||
-rw-r--r-- | ui/src/node_card.rs | 56 | ||||
-rw-r--r-- | ui/src/node_page.rs | 209 | ||||
-rw-r--r-- | ui/src/props.rs | 63 | ||||
-rw-r--r-- | ui/src/scaffold.rs | 81 | ||||
-rw-r--r-- | ui/src/search.rs | 31 | ||||
-rw-r--r-- | ui/src/stats.rs | 63 |
12 files changed, 897 insertions, 0 deletions
diff --git a/ui/Cargo.toml b/ui/Cargo.toml new file mode 100644 index 0000000..0e8f0fd --- /dev/null +++ b/ui/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "jellyui" +version = "0.1.0" +edition = "2024" + +[dependencies] +markup = "0.15.0" +jellycommon = { path = "../common", features = ["rocket"] } +humansize = "2.1.3" diff --git a/ui/src/filter_sort.rs b/ui/src/filter_sort.rs new file mode 100644 index 0000000..53d4ea3 --- /dev/null +++ b/ui/src/filter_sort.rs @@ -0,0 +1,135 @@ +/* + 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 jellycommon::api::{FilterProperty, NodeFilterSort, SortOrder, SortProperty}; + +use crate::locale::{Language, trs}; + +const SORT_CATS: &'static [(&'static str, &'static [(SortProperty, &'static str)])] = { + use SortProperty::*; + &[ + ( + "filter_sort.sort.general", + &[(Title, "node.title"), (ReleaseDate, "node.release_date")], + ), + ("filter_sort.sort.media", &[(Duration, "media.runtime")]), + ( + "filter_sort.sort.rating", + &[ + (RatingImdb, "rating.imdb"), + (RatingTmdb, "rating.tmdb"), + (RatingMetacritic, "rating.metacritic"), + (RatingRottenTomatoes, "rating.rotten_tomatoes"), + (RatingYoutubeFollowers, "rating.youtube_followers"), + (RatingYoutubeLikes, "rating.youtube_likes"), + (RatingYoutubeViews, "rating.youtube_views"), + (RatingUser, "filter_sort.sort.rating.user"), + ( + RatingLikesDivViews, + "filter_sort.sort.rating.likes_div_views", + ), + ], + ), + ] +}; +const FILTER_CATS: &'static [(&'static str, &'static [(FilterProperty, &'static str)])] = { + use FilterProperty::*; + &[ + ( + "filter_sort.filter.kind", + &[ + (KindMovie, "kind.movie"), + (KindVideo, "kind.video"), + (KindShortFormVideo, "kind.short_form_video"), + (KindMusic, "kind.music"), + (KindCollection, "kind.collection"), + (KindChannel, "kind.channel"), + (KindShow, "kind.show"), + (KindSeries, "kind.series"), + (KindSeason, "kind.season"), + (KindEpisode, "kind.episode"), + ], + ), + ( + "filter_sort.filter.federation", + &[ + (FederationLocal, "federation.local"), + (FederationRemote, "federation.remote"), + ], + ), + ( + "filter_sort.filter.watched", + &[ + (Watched, "watched.watched"), + (Unwatched, "watched.none"), + (WatchProgress, "watched.progress"), + ], + ), + ] +}; + +markup::define! { + NodeFilterSortForm<'a>(f: &'a NodeFilterSort, lang: &'a Language) { + details.filtersort[open=f.filter_kind.is_some() || f.sort_by.is_some()] { + summary { "Filter and Sort" } + form[method="GET", action=""] { + fieldset.filter { + legend { "Filter" } + .categories { + @for (cname, cat) in FilterProperty::CATS { + .category { + h3 { @trs(lang, cname) } + @for (value, label) in *cat { + label { input[type="checkbox", name="filter_kind", value=value, checked=f.filter_kind.as_ref().map(|k|k.contains(value)).unwrap_or(true)]; @trs(lang, label) } br; + } + } + } + } + } + fieldset.sortby { + legend { "Sort" } + .categories { + @for (cname, cat) in SortProperty::CATS { + .category { + h3 { @trs(lang, cname) } + @for (value, label) in *cat { + label { input[type="radio", name="sort_by", value=value, checked=Some(value)==f.sort_by.as_ref()]; @trs(lang, label) } br; + } + } + } + } + } + fieldset.sortorder { + legend { "Sort Order" } + @use SortOrder::*; + @for (value, label) in [(Ascending, "filter_sort.order.asc"), (Descending, "filter_sort.order.desc")] { + label { input[type="radio", name="sort_order", value=value, checked=Some(value)==f.sort_order]; @trs(lang, label) } br; + } + } + input[type="submit", value="Apply"]; a[href="?"] { "Clear" } + } + } + } +} + +impl markup::Render for SortProperty { + fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { + writer.write_fmt(format_args!("{}", self as &dyn UriDisplay<Query>)) + } +} +impl markup::Render for SortOrder { + fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { + writer.write_fmt(format_args!("{}", self as &dyn UriDisplay<Query>)) + } +} +impl markup::Render for FilterProperty { + fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { + writer.write_fmt(format_args!("{}", self as &dyn UriDisplay<Query>)) + } +} +impl RenderAttributeValue for SortOrder {} +impl RenderAttributeValue for FilterProperty {} +impl RenderAttributeValue for SortProperty {} diff --git a/ui/src/format.rs b/ui/src/format.rs new file mode 100644 index 0000000..a374850 --- /dev/null +++ b/ui/src/format.rs @@ -0,0 +1,117 @@ +/* + 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::locale::{Language, TrString, tr, trs}; +use jellycommon::{Chapter, MediaInfo, NodeKind, SourceTrackKind}; + +pub fn format_duration(d: f64) -> String { + format_duration_mode(d, false, Language::English) +} +pub fn format_duration_long(d: f64, lang: Language) -> String { + format_duration_mode(d, true, lang) +} +fn format_duration_mode(mut d: f64, long_units: bool, lang: Language) -> String { + let mut s = String::new(); + let sign = if d > 0. { "" } else { "-" }; + d = d.abs(); + for (short, long, long_pl, k) in [ + ("d", "time.day", "time.days", 60. * 60. * 24.), + ("h", "time.hour", "time.hours", 60. * 60.), + ("m", "time.minute", "time.minutes", 60.), + ("s", "time.second", "time.seconds", 1.), + ] { + let h = (d / k).floor(); + d -= h * k; + if h > 0. { + if long_units { + let long = tr(lang, if h != 1. { long_pl } else { long }); + let and = format!(" {} ", tr(lang, "time.and_join")); + // TODO breaks if seconds is zero + write!( + s, + "{}{h} {long}{}", + if k != 1. { "" } else { &and }, + if k > 60. { ", " } else { "" }, + ) + .unwrap(); + } else { + write!(s, "{h}{short} ").unwrap(); + } + } + } + format!("{sign}{}", s.trim()) +} +pub fn format_size(size: u64) -> String { + humansize::format_size(size, humansize::DECIMAL) +} +pub fn format_kind(k: NodeKind, lang: Language) -> TrString<'static> { + trs( + &lang, + match k { + NodeKind::Unknown => "kind.unknown", + NodeKind::Movie => "kind.movie", + NodeKind::Video => "kind.video", + NodeKind::Music => "kind.music", + NodeKind::ShortFormVideo => "kind.short_form_video", + NodeKind::Collection => "kind.collection", + NodeKind::Channel => "kind.channel", + NodeKind::Show => "kind.show", + NodeKind::Series => "kind.series", + NodeKind::Season => "kind.season", + NodeKind::Episode => "kind.episode", + }, + ) +} + +trait MediaInfoExt { + fn resolution_name(&self) -> &'static str; +} +impl MediaInfoExt for MediaInfo { + fn resolution_name(&self) -> &'static str { + let mut maxdim = 0; + for t in &self.tracks { + if let SourceTrackKind::Video { width, height, .. } = &t.kind { + maxdim = maxdim.max(*width.max(height)) + } + } + + match maxdim { + 30720.. => "32K", + 15360.. => "16K", + 7680.. => "8K UHD", + 5120.. => "5K UHD", + 3840.. => "4K UHD", + 2560.. => "QHD 1440p", + 1920.. => "FHD 1080p", + 1280.. => "HD 720p", + 854.. => "SD 480p", + _ => "Unkown", + } + } +} + +pub fn format_count(n: impl Into<usize>) -> String { + let n: usize = n.into(); + + if n >= 1_000_000 { + format!("{:.1}M", n as f32 / 1_000_000.) + } else if n >= 1_000 { + format!("{:.1}k", n as f32 / 1_000.) + } else { + format!("{n}") + } +} + +pub fn format_chapter(c: &Chapter) -> (String, String) { + ( + format!( + "{}-{}", + c.time_start.map(format_duration).unwrap_or_default(), + c.time_end.map(format_duration).unwrap_or_default(), + ), + c.labels.first().map(|l| l.1.clone()).unwrap_or_default(), + ) +} diff --git a/ui/src/home.rs b/ui/src/home.rs new file mode 100644 index 0000000..7b58179 --- /dev/null +++ b/ui/src/home.rs @@ -0,0 +1,36 @@ +/* + 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::{ + locale::{Language, tr, trs}, + node_card::NodeCard, + scaffold::LayoutPage, +}; + +markup::define! { + HomePage<'a>(lang: &'a Language) { + h2 { @tr(lang, "home.bin.root").replace("{title}", &CONF.brand) } + ul.children.hlist {@for (node, udata) in &toplevel { + li { @NodeCard { node, udata, lang: &lang } } + }} + @for (name, nodes) in &categories { + @if !nodes.is_empty() { + h2 { @trs(&lang, &name) } + ul.children.hlist {@for (node, udata) in nodes { + li { @NodeCard { node, udata, lang: &lang } } + }} + } + } + } +} + +pub fn home_page() { + LayoutPage { + title: tr(lang, "home").to_string(), + content: HomePage { lang: &lang }, + ..Default::default() + } +} diff --git a/ui/src/lib.rs b/ui/src/lib.rs new file mode 100644 index 0000000..40d43dd --- /dev/null +++ b/ui/src/lib.rs @@ -0,0 +1,15 @@ +/* + 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> +*/ +pub mod format; +pub mod locale; +pub mod node_page; +pub mod node_card; +pub mod scaffold; +pub mod props; +pub mod filter_sort; +pub mod search; +pub mod stats; +pub mod home; diff --git a/ui/src/locale.rs b/ui/src/locale.rs new file mode 100644 index 0000000..0179c66 --- /dev/null +++ b/ui/src/locale.rs @@ -0,0 +1,82 @@ +/* + 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 markup::{Render, RenderAttributeValue}; +use std::{borrow::Cow, collections::HashMap, sync::LazyLock}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Language { + English, + German, +} + +static LANG_TABLES: LazyLock<HashMap<Language, HashMap<&'static str, &'static str>>> = + LazyLock::new(|| { + let mut k = HashMap::new(); + for (lang, source) in [ + (Language::English, include_str!("../../locale/en.ini")), + (Language::German, include_str!("../../locale/de.ini")), + ] { + let tr_map = source + .lines() + .filter_map(|line| { + let (key, value) = line.split_once("=")?; + Some((key.trim(), value.trim())) + }) + .collect::<HashMap<&'static str, &'static str>>(); + k.insert(lang, tr_map); + } + k + }); + +pub fn tr(lang: Language, key: &str) -> Cow<'static, str> { + let tr_map = LANG_TABLES.get(&lang).unwrap(); + match tr_map.get(key) { + Some(value) => Cow::Borrowed(value), + None => Cow::Owned(format!("TR[{key}]")), + } +} + +pub struct TrString<'a>(Cow<'a, str>); +impl Render for TrString<'_> { + fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { + self.0.render(writer) + } +} +impl RenderAttributeValue for TrString<'_> { + fn is_none(&self) -> bool { + false + } + fn is_true(&self) -> bool { + false + } + fn is_false(&self) -> bool { + false + } +} + +pub fn escape(str: &str) -> String { + let mut o = String::with_capacity(str.len()); + let mut last = 0; + for (index, byte) in str.bytes().enumerate() { + if let Some(esc) = match byte { + b'<' => Some("<"), + b'>' => Some(">"), + b'&' => Some("&"), + b'"' => Some("""), + _ => None, + } { + o += &str[last..index]; + o += esc; + last = index + 1; + } + } + o += &str[last..]; + o +} + +pub fn trs<'a>(lang: &Language, key: &str) -> TrString<'a> { + TrString(tr(*lang, key)) +} diff --git a/ui/src/node_card.rs b/ui/src/node_card.rs new file mode 100644 index 0000000..cedb81e --- /dev/null +++ b/ui/src/node_card.rs @@ -0,0 +1,56 @@ +/* + 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::{locale::Language, node_page::aspect_class, props::Props}; +use jellycommon::{Node, user::NodeUserData}; + +markup::define! { + NodeCard<'a>(node: &'a Node, udata: &'a NodeUserData, lang: &'a Language) { + @let cls = format!("node card poster {}", aspect_class(node.kind)); + div[class=cls] { + .poster { + a[href=uri!(r_library_node(&node.slug))] { + img[src=uri!(r_item_poster(&node.slug, Some(1024))), loading="lazy"]; + } + .cardhover.item { + @if node.media.is_some() { + a.play.icon[href=&uri!(r_player(&node.slug, PlayerConfig::default()))] { "play_arrow" } + } + @Props { node, udata, full: false, lang } + } + } + div.title { + a[href=uri!(r_library_node(&node.slug))] { + @node.title + } + } + div.subtitle { + span { + @node.subtitle + } + } + } + } + NodeCardWide<'a>(node: &'a Node, udata: &'a NodeUserData, lang: &'a Language) { + div[class="node card widecard poster"] { + div[class=&format!("poster {}", aspect_class(node.kind))] { + a[href=uri!(r_library_node(&node.slug))] { + img[src=uri!(r_item_poster(&node.slug, Some(1024))), loading="lazy"]; + } + .cardhover.item { + @if node.media.is_some() { + a.play.icon[href=&uri!(r_player(&node.slug, PlayerConfig::default()))] { "play_arrow" } + } + } + } + div.details { + a.title[href=uri!(r_library_node(&node.slug))] { @node.title } + @Props { node, udata, full: false, lang } + span.overview { @node.description } + } + } + } +} 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, + }) +} diff --git a/ui/src/props.rs b/ui/src/props.rs new file mode 100644 index 0000000..7dbc0de --- /dev/null +++ b/ui/src/props.rs @@ -0,0 +1,63 @@ +/* + 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::{ + format::format_duration, + locale::{Language, trs}, +}; +use jellycommon::{ + Node, Rating, Visibility, + chrono::DateTime, + user::{NodeUserData, WatchedState}, +}; + +markup::define! { + Props<'a>(node: &'a Node, udata: &'a NodeUserData, full: bool, lang: &'a Language) { + .props { + @if let Some(m) = &node.media { + p { @format_duration(m.duration) } + p { @m.resolution_name() } + } + @if let Some(d) = &node.release_date { + p { @if *full { + @DateTime::from_timestamp_millis(*d).unwrap().naive_utc().to_string() + } else { + @DateTime::from_timestamp_millis(*d).unwrap().date_naive().to_string() + }} + } + @match node.visibility { + Visibility::Visible => {} + Visibility::Reduced => {p.visibility{@trs(lang, "prop.vis.reduced")}} + Visibility::Hidden => {p.visibility{@trs(lang, "prop.vis.hidden")}} + } + // TODO + // @if !node.children.is_empty() { + // p { @format!("{} items", node.children.len()) } + // } + @for (kind, value) in &node.ratings { + @match kind { + Rating::YoutubeLikes => {p.likes{ @format_count(*value as usize) " Likes" }} + Rating::YoutubeViews => {p{ @format_count(*value as usize) " Views" }} + Rating::YoutubeFollowers => {p{ @format_count(*value as usize) " Subscribers" }} + Rating::RottenTomatoes => {p.rating{ @value " Tomatoes" }} + Rating::Metacritic if *full => {p{ "Metacritic Score: " @value }} + Rating::Imdb => {p.rating{ "IMDb " @value }} + Rating::Tmdb => {p.rating{ "TMDB " @value }} + Rating::Trakt if *full => {p.rating{ "Trakt " @value }} + _ => {} + } + } + @if let Some(f) = &node.federated { + p.federation { @f } + } + @match udata.watched { + WatchedState::None => {} + WatchedState::Pending => { p.pending { @trs(lang, "prop.watched.pending") } } + WatchedState::Progress(x) => { p.progress { @tr(**lang, "prop.watched.progress").replace("{time}", &format_duration(x)) } } + WatchedState::Watched => { p.watched { @trs(lang, "prop.watched.watched") } } + } + } + } +} diff --git a/ui/src/scaffold.rs b/ui/src/scaffold.rs new file mode 100644 index 0000000..ffd5fdf --- /dev/null +++ b/ui/src/scaffold.rs @@ -0,0 +1,81 @@ +/* + 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::locale::{tr, trs, Language}; +use markup::{DynRender, Render}; +use std::sync::LazyLock; + +static LOGO_ENABLED: LazyLock<bool> = LazyLock::new(|| CONF.asset_path.join("logo.svg").exists()); + +markup::define! { + Scaffold<'a, Main: Render>(title: String, main: Main, class: &'a str, session: Option<Session>, lang: Language) { + @markup::doctype() + html { + head { + title { @title " - " @CONF.brand } + meta[name="viewport", content="width=device-width, initial-scale=1.0"]; + link[rel="stylesheet", href="/assets/style.css"]; + script[src="/assets/bundle.js"] {} + } + body[class=class] { + nav { + h1 { a[href=if session.is_some() {"/home"} else {"/"}] { @if *LOGO_ENABLED { img.logo[src="/assets/logo.svg"]; } else { @CONF.brand } } } " " + @if let Some(_) = session { + a.library[href=uri!(r_library_node("library"))] { @trs(lang, "nav.root") } " " + a.library[href=uri!(r_all_items())] { @trs(lang, "nav.all") } " " + a.library[href=uri!(r_search(None::<&'static str>, None::<usize>))] { @trs(lang, "nav.search") } " " + a.library[href=uri!(r_stats())] { @trs(lang, "nav.stats") } " " + } + @if is_importing() { span.warn { "Library database is updating..." } } + div.account { + @if let Some(session) = session { + span { @raw(tr(*lang, "nav.username").replace("{name}", &format!("<b class=\"username\">{}</b>", escape(&session.user.display_name)))) } " " + @if session.user.admin { + a.admin.hybrid_button[href=uri!(r_admin_dashboard())] { p {@trs(lang, "nav.admin")} } " " + } + a.settings.hybrid_button[href=uri!(r_account_settings())] { p {@trs(lang, "nav.settings")} } " " + a.logout.hybrid_button[href=uri!(r_account_logout())] { p {@trs(lang, "nav.logout")} } + } else { + a.register.hybrid_button[href=uri!(r_account_register())] { p {@trs(lang, "nav.register")} } " " + a.login.hybrid_button[href=uri!(r_account_login())] { p {@trs(lang, "nav.login")} } + } + } + } + #main { @main } + footer { + p { @CONF.brand " - " @CONF.slogan " | powered by " a[href="https://codeberg.org/metamuffin/jellything"]{"Jellything"} } + } + } + } + } + + FlashDisplay(flash: Option<Result<String, String>>) { + @if let Some(flash) = &flash { + @match flash { + Ok(mesg) => { section.message { p.success { @mesg } } } + Err(err) => { section.message { p.error { @err } } } + } + } + } +} + +pub type DynLayoutPage<'a> = LayoutPage<DynRender<'a>>; + +pub struct LayoutPage<T> { + pub title: String, + pub class: Option<&'static str>, + pub content: T, +} + +impl Default for LayoutPage<DynRender<'_>> { + fn default() -> Self { + Self { + class: None, + content: markup::new!(), + title: String::new(), + } + } +} diff --git a/ui/src/search.rs b/ui/src/search.rs new file mode 100644 index 0000000..092ad57 --- /dev/null +++ b/ui/src/search.rs @@ -0,0 +1,31 @@ +/* + 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> +*/ + +markup::define! { + SearchPage { + h1 { @trs(&lang, "search.title") } + form[action="", method="GET"] { + input[type="text", name="query", placeholder=&*tr(lang, "search.placeholder"), value=&query]; + input[type="submit", value="Search"]; + } + @if let Some((count, results, search_dur)) = &results { + h2 { @trs(&lang, "search.results.title") } + p.stats { @tr(lang, "search.results.stats").replace("{count}", &count.to_string()).replace("{dur}", &format!("{search_dur:?}")) } + ul.children {@for (node, udata) in results.iter() { + li { @NodeCard { node, udata, lang: &lang } } + }} + // TODO pagination + } + } +} + +pub fn search_page() { + LayoutPage { + title: tr(lang, "search.title").to_string(), + class: Some("search"), + content: SearchPage, + } +}
\ No newline at end of file diff --git a/ui/src/stats.rs b/ui/src/stats.rs new file mode 100644 index 0000000..b4a2e23 --- /dev/null +++ b/ui/src/stats.rs @@ -0,0 +1,63 @@ +/* + 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::{ + format::{format_duration, format_duration_long, format_kind, format_size}, + locale::{Language, tr, trs}, + scaffold::LayoutPage, +}; +use markup::raw; + +markup::define! { + StatsPage<'a>(lang: &'a Language) { + .page.stats { + h1 { @trs(&lang, "stats.title") } + p { @raw(tr(lang, "stats.count") + .replace("{count}", &format!("<b>{}</b>", all.count)) + )} + p { @raw(tr(lang, "stats.runtime") + .replace("{dur}", &format!("<b>{}</b>", format_duration_long(all.runtime, lang))) + .replace("{size}", &format!("<b>{}</b>", format_size(all.size))) + )} + p { @raw(tr(lang, "stats.average") + .replace("{dur}", &format!("<b>{}</b>", format_duration(all.average_runtime()))) + .replace("{size}", &format!("<b>{}</b>", format_size(all.average_size() as u64))) + )} + + h2 { @trs(&lang, "stats.by_kind.title") } + table.striped { + tr { + th { @trs(&lang, "stats.by_kind.kind") } + th { @trs(&lang, "stats.by_kind.count") } + th { @trs(&lang, "stats.by_kind.total_size") } + th { @trs(&lang, "stats.by_kind.total_runtime") } + th { @trs(&lang, "stats.by_kind.average_size") } + th { @trs(&lang, "stats.by_kind.average_runtime") } + th { @trs(&lang, "stats.by_kind.max_size") } + th { @trs(&lang, "stats.by_kind.max_runtime") } + } + @for (k,b) in &kinds { tr { + td { @format_kind(*k, lang) } + td { @b.count } + td { @format_size(b.size) } + td { @format_duration(b.runtime) } + td { @format_size(b.average_size() as u64) } + td { @format_duration(b.average_runtime()) } + td { @if b.max_size.0 > 0 { a[href=uri!(r_library_node(&b.max_size.1))]{ @format_size(b.max_size.0) }}} + td { @if b.max_runtime.0 > 0. { a[href=uri!(r_library_node(&b.max_runtime.1))]{ @format_duration(b.max_runtime.0) }}} + }} + } + } + } +} + +pub fn stats_page() { + LayoutPage { + title: tr(lang, "stats.title").to_string(), + content: StatsPage { lang: &lang }, + ..Default::default() + } +} |