aboutsummaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2025-04-28 00:48:52 +0200
committermetamuffin <metamuffin@disroot.org>2025-04-28 00:48:52 +0200
commit80d28b764c95891551e28c395783f5ff9d065743 (patch)
treef25898b1c939a939c63236ca4e8e843e81069947 /ui
parent335ba978dbaf203f3603a815147fd75dbf205723 (diff)
downloadjellything-80d28b764c95891551e28c395783f5ff9d065743.tar
jellything-80d28b764c95891551e28c395783f5ff9d065743.tar.bz2
jellything-80d28b764c95891551e28c395783f5ff9d065743.tar.zst
start with splitting server
Diffstat (limited to 'ui')
-rw-r--r--ui/Cargo.toml9
-rw-r--r--ui/src/filter_sort.rs135
-rw-r--r--ui/src/format.rs117
-rw-r--r--ui/src/home.rs36
-rw-r--r--ui/src/lib.rs15
-rw-r--r--ui/src/locale.rs82
-rw-r--r--ui/src/node_card.rs56
-rw-r--r--ui/src/node_page.rs209
-rw-r--r--ui/src/props.rs63
-rw-r--r--ui/src/scaffold.rs81
-rw-r--r--ui/src/search.rs31
-rw-r--r--ui/src/stats.rs63
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("&lt;"),
+ b'>' => Some("&gt;"),
+ b'&' => Some("&amp;"),
+ b'"' => Some("&quot;"),
+ _ => 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()
+ }
+}