diff options
| author | metamuffin <metamuffin@disroot.org> | 2026-01-23 04:19:24 +0100 |
|---|---|---|
| committer | metamuffin <metamuffin@disroot.org> | 2026-01-23 04:19:24 +0100 |
| commit | 3671a4e07565c86f8071fb2309f463aeaf684ba3 (patch) | |
| tree | 9a9057c7dcc174ada17a45a195502ff94b2f2946 | |
| parent | 10cdaaa30a6b4a187797434dc8d959780f0e8fbf (diff) | |
| download | jellything-3671a4e07565c86f8071fb2309f463aeaf684ba3.tar jellything-3671a4e07565c86f8071fb2309f463aeaf684ba3.tar.bz2 jellything-3671a4e07565c86f8071fb2309f463aeaf684ba3.tar.zst | |
move ui code around
| -rw-r--r-- | Cargo.lock | 1 | ||||
| -rw-r--r-- | common/object/src/registry.rs | 6 | ||||
| -rw-r--r-- | common/src/api.rs | 17 | ||||
| -rw-r--r-- | common/src/routes.rs | 4 | ||||
| -rw-r--r-- | server/Cargo.toml | 2 | ||||
| -rw-r--r-- | server/src/compat/jellyfin/mod.rs | 6 | ||||
| -rw-r--r-- | server/src/main.rs | 4 | ||||
| -rw-r--r-- | ui/src/components/message.rs | 20 | ||||
| -rw-r--r-- | ui/src/components/mod.rs | 22 | ||||
| -rw-r--r-- | ui/src/components/node_list.rs | 0 | ||||
| -rw-r--r-- | ui/src/components/node_page.rs | 194 | ||||
| -rw-r--r-- | ui/src/components/props.rs (renamed from ui/src/props.rs) | 23 | ||||
| -rw-r--r-- | ui/src/components/stats.rs | 55 | ||||
| -rw-r--r-- | ui/src/error.rs | 27 | ||||
| -rw-r--r-- | ui/src/format.rs | 14 | ||||
| -rw-r--r-- | ui/src/lib.rs | 88 | ||||
| -rw-r--r-- | ui/src/locale.rs | 6 | ||||
| -rw-r--r-- | ui/src/node_page.rs | 218 | ||||
| -rw-r--r-- | ui/src/old/account/mod.rs (renamed from ui/src/account/mod.rs) | 0 | ||||
| -rw-r--r-- | ui/src/old/account/settings.rs (renamed from ui/src/account/settings.rs) | 0 | ||||
| -rw-r--r-- | ui/src/old/admin/import.rs (renamed from ui/src/admin/import.rs) | 0 | ||||
| -rw-r--r-- | ui/src/old/admin/log.rs (renamed from ui/src/admin/log.rs) | 0 | ||||
| -rw-r--r-- | ui/src/old/admin/mod.rs (renamed from ui/src/admin/mod.rs) | 0 | ||||
| -rw-r--r-- | ui/src/old/admin/user.rs (renamed from ui/src/admin/user.rs) | 0 | ||||
| -rw-r--r-- | ui/src/old/filter_sort.rs (renamed from ui/src/filter_sort.rs) | 0 | ||||
| -rw-r--r-- | ui/src/old/home.rs (renamed from ui/src/home.rs) | 0 | ||||
| -rw-r--r-- | ui/src/old/items.rs (renamed from ui/src/items.rs) | 0 | ||||
| -rw-r--r-- | ui/src/old/node_card.rs (renamed from ui/src/node_card.rs) | 0 | ||||
| -rw-r--r-- | ui/src/old/search.rs (renamed from ui/src/search.rs) | 0 | ||||
| -rw-r--r-- | ui/src/scaffold.rs | 30 | ||||
| -rw-r--r-- | ui/src/stats.rs | 79 |
31 files changed, 363 insertions, 453 deletions
@@ -1945,7 +1945,6 @@ dependencies = [ "jellyimport", "jellystream", "jellytranscoder", - "jellyui", "log", "rand 0.9.2", "rocket", diff --git a/common/object/src/registry.rs b/common/object/src/registry.rs index 2cd2a1f..d9da2fb 100644 --- a/common/object/src/registry.rs +++ b/common/object/src/registry.rs @@ -34,10 +34,10 @@ impl Registry { pub fn info(&self, tag: Tag) -> Option<&TagInfo> { self.tags.get(&tag) } - pub fn name(&self, tag: Tag) -> String { + pub fn name(&self, tag: Tag) -> &str { match self.tags.get(&tag) { - Some(inf) => inf.name.to_string(), - None => format!("unknown_tag_{:04x}", tag.0), + Some(inf) => inf.name, + None => "unknown", } } } diff --git a/common/src/api.rs b/common/src/api.rs index 2123477..ee26020 100644 --- a/common/src/api.rs +++ b/common/src/api.rs @@ -14,10 +14,12 @@ fields! { QUERY_SORT_ASCENDING: () = 2003 "sort_ascending"; VIEW_TITLE: &str = 2005 "title"; - VIEW_MESSAGE: &str = 2010 "message"; + VIEW_MESSAGE: Object = 2010 "message"; VIEW_NODE_PAGE: Object = 2011 "node_page"; VIEW_NODE_LIST: Object = 2012 "node_list"; // multi VIEW_PLAYER: u64 = 2028 "player"; + VIEW_STATGROUP: Object = 2041 "statgroup"; + VIEW_STATTEXT: Object = 2042 "stattext"; NKU_NODE: Object = 2025 "node"; NKU_UDATA: Object = 2026 "udata"; @@ -26,6 +28,19 @@ fields! { NODELIST_TITLE: &str = 2007 "title"; NODELIST_DISPLAYSTYLE: &str = 2008 "displaystyle"; NODELIST_ITEM: &str = 2009 "item"; + + MESSAGE_KIND: &str = 2029 "kind"; + MESSAGE_TEXT: &str = 2030 "text"; + + STATGROUP_TITLE: &str = 2039 "title"; + STATGROUP_BIN: Object = 2040 "bin"; + + STAT_NAME: &str = 2038 "name"; + STAT_COUNT: u64 = 2031 "count"; + STAT_TOTAL_SIZE: u64 = 2032 "total_size"; + STAT_TOTAL_DURATION: f64 = 2033 "total_duration"; + STAT_MAX_SIZE: u64 = 2036 "max_size"; + STAT_MAX_DURATION: f64 = 2037 "max_duration"; } enums! { diff --git a/common/src/routes.rs b/common/src/routes.rs index 48f975a..1d3a8da 100644 --- a/common/src/routes.rs +++ b/common/src/routes.rs @@ -18,8 +18,8 @@ pub fn u_node_slug_player(node: &str) -> String { pub fn u_node_slug_player_time(node: &str, time: f64) -> String { format!("/n/{node}/player?t={time}") } -pub fn u_node_image(node: &str, slot: &str, size: usize) -> String { - format!("/n/{node}/image/{slot}?size={size}") +pub fn u_image(path: &str, size: usize) -> String { + format!("/image/{path}?size={size}") } pub fn u_node_slug_watched(node: &str, state: &str) -> String { format!("/n/{node}/watched?state={state}") diff --git a/server/Cargo.toml b/server/Cargo.toml index 416ff8e..01e918e 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -9,7 +9,7 @@ jellystream = { path = "../stream" } jellytranscoder = { path = "../transcoder" } jellyimport = { path = "../import" } jellycache = { path = "../cache" } -jellyui = { path = "../ui" } +# jellyui = { path = "../ui" } anyhow = { workspace = true } async-recursion = "1.1.1" diff --git a/server/src/compat/jellyfin/mod.rs b/server/src/compat/jellyfin/mod.rs index 7978f3b..db60530 100644 --- a/server/src/compat/jellyfin/mod.rs +++ b/server/src/compat/jellyfin/mod.rs @@ -9,7 +9,7 @@ use crate::{helper::A, ui::error::MyResult}; use anyhow::anyhow; use jellycommon::{ api::{NodeFilterSort, SortOrder, SortProperty}, - routes::{u_asset, u_node_image}, + routes::{u_asset, u_image}, stream::{StreamContainer, StreamSpec}, user::{NodeUserData, WatchedState}, MediaInfo, Node, NodeID, NodeKind, PictureSlot, SourceTrack, SourceTrackKind, Visibility, @@ -169,7 +169,7 @@ pub fn r_jellyfin_items_image_primary( tag: String, ) -> Redirect { if tag == "poster" { - Redirect::permanent(u_node_image( + Redirect::permanent(u_image( id, PictureSlot::Cover, fillWidth.unwrap_or(1024), @@ -186,7 +186,7 @@ pub fn r_jellyfin_items_images_backdrop( id: &str, maxWidth: Option<usize>, ) -> Redirect { - Redirect::permanent(u_node_image( + Redirect::permanent(u_image( id, PictureSlot::Backdrop, maxWidth.unwrap_or(1024), diff --git a/server/src/main.rs b/server/src/main.rs index 058fdc3..8b0c128 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -8,7 +8,6 @@ #![recursion_limit = "4096"] use config::load_config; -use jellylogic::{admin::log::enable_logging, login::create_admin_account}; use log::{error, info, warn}; use routes::build_rocket; use serde::{Deserialize, Serialize}; @@ -23,12 +22,11 @@ pub mod logic; pub mod routes; pub mod ui; -#[rustfmt::skip] #[derive(Debug, Deserialize, Serialize, Default)] pub struct Config { asset_path: PathBuf, cookie_key: Option<String>, - tls:bool, + tls: bool, hostname: String, } diff --git a/ui/src/components/message.rs b/ui/src/components/message.rs new file mode 100644 index 0000000..a271d40 --- /dev/null +++ b/ui/src/components/message.rs @@ -0,0 +1,20 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ +use crate::RenderInfo; +use jellycommon::{MESSAGE_KIND, MESSAGE_TEXT, jellyobject::Object}; +use markup::define; + +define! { + Message<'a>(ri: &'a RenderInfo<'a>, message: Object<'a>) { + @let _ = ri; + @let text = message.get(MESSAGE_TEXT).unwrap_or_default(); + @match message.get(MESSAGE_KIND).unwrap_or("neutral") { + "success" => { section.message { p.success { @text } } } + "error" => { section.message { p.error { @text } } } + "neutral" | _ => { section.message { p { @text } } } + } + } +} diff --git a/ui/src/components/mod.rs b/ui/src/components/mod.rs new file mode 100644 index 0000000..4d2dd62 --- /dev/null +++ b/ui/src/components/mod.rs @@ -0,0 +1,22 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ + +pub mod message; +pub mod node_page; +pub mod props; +pub mod stats; + +use crate::{RenderInfo, components::message::Message}; +use jellycommon::{VIEW_MESSAGE, jellyobject::Object}; +use markup::define; + +define! { + View<'a>(ri: &'a RenderInfo<'a>, view: Object<'a>) { + @if let Some(message) = view.get(VIEW_MESSAGE) { + @Message { ri, message } + } + } +} diff --git a/ui/src/components/node_list.rs b/ui/src/components/node_list.rs new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/ui/src/components/node_list.rs diff --git a/ui/src/components/node_page.rs b/ui/src/components/node_page.rs new file mode 100644 index 0000000..4a594d6 --- /dev/null +++ b/ui/src/components/node_page.rs @@ -0,0 +1,194 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ + +use crate::{RenderInfo, components::props::Props, locale::tr}; +use jellycommon::{ + jellyobject::{Object, Tag, TypedTag}, + routes::{u_image, u_node_slug_player}, + *, +}; +use std::marker::PhantomData; + +markup::define! { + NodePage<'a>(ri: &'a RenderInfo<'a>, nku: Object<'a>) { + @let node = nku.get(NKU_NODE).unwrap_or_default(); + @let slug = node.get(NO_SLUG).unwrap_or_default(); + @if let Some(path) = node.get(NO_PICTURES).unwrap_or_default().get(PICT_BACKDROP) { + img.backdrop[src=u_image(path, 2048)]; + } + @if let Some(path) = node.get(NO_PICTURES).unwrap_or_default().get(PICT_COVER) { + @let cls = format!("bigposter {}", aspect_class(node)); + div[class=cls] { img[src=u_image(path, 2048), loading="lazy"]; } + } + .title { + h1 { @node.get(NO_TITLE).unwrap_or_default() } + // ul.parents { @for (node, _) in *parents { li { + // a.component[href=u_node_slug(&node.slug)] { @node.title } + // }}} + @if node.has(NO_TRACK.0) { + a.play[href=u_node_slug_player(slug)] { @tr(ri.lang, "node.player_link") } + // @if matches!(udata.watched, WatchedState::None | WatchedState::Pending | WatchedState::Progress(_)) { + // form.mark_watched[method="POST", action=u_node_slug_watched(&node.slug, ApiWatchedState::Watched)] { + // input[type="submit", value=trs(lang, "node.watched.set")]; + // } + // } + // @if matches!(udata.watched, WatchedState::Watched) { + // form.mark_unwatched[method="POST", action=u_node_slug_watched(&node.slug, ApiWatchedState::None)] { + // input[type="submit", value=trs(lang, "node.watched.unset")]; + // } + // } + // @if matches!(udata.watched, WatchedState::None) { + // form.mark_unwatched[method="POST", action=u_node_slug_watched(&node.slug, ApiWatchedState::Pending)] { + // input[type="submit", value=trs(lang, "node.watchlist.set")]; + // } + // } + // @if matches!(udata.watched, WatchedState::Pending) { + // form.mark_unwatched[method="POST", action=u_node_slug_watched(&node.slug, ApiWatchedState::None)] { + // input[type="submit", value=trs(lang, "node.watchlist.unset")]; + // } + // } + // form.rating[method="POST", action=u_node_slug_update_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 { ri, nku: *nku, full: true } + h3 { @node.get(NO_TAGLINE).unwrap_or_default() } + @if let Some(description) = &node.get(NO_DESCRIPTION) { + p { @for line in description.lines() { @line br; } } + } + // @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=u_node_slug_player_time(&node.slug, chap.time_start.unwrap_or(0.))] { + // img[src=u_node_slug_thumbnail(&node.slug, chapter_key_time(chap, media.duration), 1024), loading="lazy"]; + // } + // .cardhover { .props { p { @inl } } } + // } + // .title { span { @sub } } + // }} + // }} + // } + // @if !node.credits.is_empty() { + // h2 { @trs(lang, "node.people") } + // @for (group, people) in &node.credits { + // details[open=group==&CreditCategory::Cast] { + // summary { h3 { @format!("{}", group) } } + // ul.children.hlist { @for (i, pe) in people.iter().enumerate() { + // li { .card."aspect-port" { + // .poster { + // a[href="#"] { + // img[src=u_node_slug_person_asset(&node.slug, *group, i, 1024), loading="lazy"]; + // } + // } + // .title { + // // TODO 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 { @tr(ri.lang, "media.tracks") } + ol { @for track in node.iter(NO_TRACK) { + li { "track" @track.get(TR_NAME) } + }} + } + @if let Some(idents) = node.get(NO_IDENTIFIERS) { + details { + summary { @tr(ri.lang, "node.external_ids") } + table { + @for (key, value) in idents.entries::<&str>() { tr { + tr { + td { @tr(ri.lang, &format!("id.{}", TAGREG.name(key))) } + @if let Some(url) = external_id_url(key, value) { + td { a[href=url] { pre { @value } } } + } else { + td { pre { @value } } + } + } + }} + } + } + } + @if node.has(NO_TAG.0) { + details { + summary { @tr(ri.lang, "node.tags") } + ol { @for tag in node.iter(NO_TAG) { + 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: Object, dur: f64) -> f64 { + let start = c.get(CH_START).unwrap_or(0.); + let end = c.get(CH_END).unwrap_or(dur); + start * 0.8 + end * 0.2 +} + +pub fn aspect_class(node: Object<'_>) -> &'static str { + let kind = node.get(NO_KIND).unwrap_or(KIND_COLLECTION); + match kind { + KIND_VIDEO | KIND_EPISODE => "aspect-thumb", + KIND_COLLECTION => "aspect-land", + KIND_SEASON | KIND_SHOW | KIND_SERIES | KIND_MOVIE | KIND_SHORTFORMVIDEO => "aspect-port", + KIND_CHANNEL | KIND_MUSIC | _ => "aspect-square", + } +} + +fn external_id_url(key: Tag, value: &str) -> Option<String> { + Some(match TypedTag(key, PhantomData) { + IDENT_YOUTUBE_VIDEO => format!("https://youtube.com/watch?v={value}"), + IDENT_YOUTUBE_CHANNEL => format!("https://youtube.com/channel/{value}"), + IDENT_YOUTUBE_CHANNEL_HANDLE => format!("https://youtube.com/channel/@{value}"), + IDENT_MUSICBRAINZ_RELEASE => format!("https://musicbrainz.org/release/{value}"), + IDENT_MUSICBRAINZ_ARTIST => format!("https://musicbrainz.org/artist/{value}"), + IDENT_MUSICBRAINZ_RELEASE_GROUP => { + format!("https://musicbrainz.org/release-group/{value}") + } + IDENT_MUSICBRAINZ_RECORDING => { + format!("https://musicbrainz.org/recording/{value}") + } + _ => return None, + }) +} diff --git a/ui/src/props.rs b/ui/src/components/props.rs index fe7f419..f23d72e 100644 --- a/ui/src/props.rs +++ b/ui/src/components/props.rs @@ -5,41 +5,44 @@ */ use crate::{ + RenderInfo, format::{format_count, format_duration}, locale::tr, - node_page::NodeUdata, - scaffold::RenderInfo, }; use chrono::DateTime; -use jellycommon::{jellyobject::TypedTag, *}; +use jellycommon::{ + jellyobject::{Object, TypedTag}, + *, +}; use std::marker::PhantomData; markup::define! { - Props<'a>(ri: &'a RenderInfo<'a>, nodeu: NodeUdata<'a>, full: bool) { + Props<'a>(ri: &'a RenderInfo<'a>, nku: Object<'a>, full: bool) { + @let node = nku.get(NKU_NODE).unwrap_or_default(); .props { - @if let Some(dur) = nodeu.node.get(NO_DURATION) { + @if let Some(dur) = node.get(NO_DURATION) { p { @format_duration(dur) } } - // @if let Some(res) = nodeu.node.get(NO_TRACK) { + // @if let Some(res) = node.get(NO_TRACK) { // p { @m.resolution_name() } // } - @if let Some(d) = nodeu.node.get(NO_RELEASEDATE) { + @if let Some(d) = node.get(NO_RELEASEDATE) { 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 nodeu.node.get(NO_VISIBILITY).unwrap_or(VISI_VISIBLE) { - VISI_VISIBLE => {} + @match node.get(NO_VISIBILITY).unwrap_or(VISI_VISIBLE) { VISI_REDUCED => {p.visibility{@tr(ri.lang, "prop.vis.reduced")}} VISI_HIDDEN => {p.visibility{@tr(ri.lang, "prop.vis.hidden")}} + VISI_VISIBLE | _ => {} } // TODO // @if !node.children.is_empty() { // p { @format!("{} items", node.children.len()) } // } - @for (kind, value) in nodeu.node.get(NO_RATINGS).unwrap_or_default().entries::<f64>() { + @for (kind, value) in node.get(NO_RATINGS).unwrap_or_default().entries::<f64>() { @match TypedTag(kind, PhantomData) { RTYP_YOUTUBE_LIKES => {p.likes{ @format_count(value as usize) " Likes" }} RTYP_YOUTUBE_VIEWS => {p{ @format_count(value as usize) " Views" }} diff --git a/ui/src/components/stats.rs b/ui/src/components/stats.rs new file mode 100644 index 0000000..8dfb304 --- /dev/null +++ b/ui/src/components/stats.rs @@ -0,0 +1,55 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ + +use crate::{ + RenderInfo, + format::{format_duration, format_duration_long, format_size}, + locale::tr, +}; +use jellycommon::{jellyobject::Object, *}; +use markup::raw; + +markup::define! { + StatText<'a>(ri: &'a RenderInfo<'a>, stat: Object<'a>) { + h1 { @tr(ri.lang, "stats.title") } + p { @raw(tr(ri.lang, "stats.count") + .replace("{count}", &format!("<b>{}</b>", stat.get(STAT_COUNT).unwrap_or_default())) + )} + p { @raw(tr(ri.lang, "stats.runtime") + .replace("{dur}", &format!("<b>{}</b>", format_duration_long(ri.lang, stat.get(STAT_TOTAL_DURATION).unwrap_or_default()))) + .replace("{size}", &format!("<b>{}</b>", format_size(stat.get(STAT_TOTAL_SIZE).unwrap_or_default()))) + )} + p { @raw(tr(ri.lang, "stats.average") + .replace("{dur}", &format!("<b>{}</b>", format_duration(stat.get(STAT_TOTAL_DURATION).unwrap_or_default() / stat.get(STAT_COUNT).unwrap_or_default() as f64))) + .replace("{size}", &format!("<b>{}</b>", format_size(stat.get(STAT_TOTAL_SIZE).unwrap_or_default() / stat.get(STAT_COUNT).unwrap_or_default()))) + )} + } + StatGroup<'a>(ri: &'a RenderInfo<'a>, statgroup: Object<'a>) { + h2 { @tr(ri.lang, statgroup.get(STATGROUP_TITLE).unwrap_or_default()) } + table.striped { + tr { + th { @tr(ri.lang, "stats.by_kind.kind") } + th { @tr(ri.lang, "stats.by_kind.count") } + th { @tr(ri.lang, "stats.by_kind.total_size") } + th { @tr(ri.lang, "stats.by_kind.total_runtime") } + th { @tr(ri.lang, "stats.by_kind.average_size") } + th { @tr(ri.lang, "stats.by_kind.average_runtime") } + th { @tr(ri.lang, "stats.by_kind.max_size") } + th { @tr(ri.lang, "stats.by_kind.max_runtime") } + } + @for stat in statgroup.iter(STATGROUP_BIN) { tr { + td { @tr(ri.lang, stat.get(STAT_NAME).unwrap_or_default()) } + td { @stat.get(STAT_COUNT).unwrap_or_default() } + td { @format_size(stat.get(STAT_TOTAL_SIZE).unwrap_or_default()) } + td { @format_duration(stat.get(STAT_TOTAL_DURATION).unwrap_or_default()) } + td { @format_size(stat.get(STAT_TOTAL_SIZE).unwrap_or_default() / stat.get(STAT_COUNT).unwrap_or_default()) } + td { @format_duration(stat.get(STAT_TOTAL_DURATION).unwrap_or_default() / stat.get(STAT_COUNT).unwrap_or_default() as f64) } + td { @format_size(stat.get(STAT_MAX_SIZE).unwrap_or_default()) } + td { @format_duration(stat.get(STAT_MAX_DURATION).unwrap_or_default()) } + }} + } + } +} diff --git a/ui/src/error.rs b/ui/src/error.rs deleted file mode 100644 index 23d323a..0000000 --- a/ui/src/error.rs +++ /dev/null @@ -1,27 +0,0 @@ -/* - 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) 2026 metamuffin <metamuffin.org> -*/ - -use crate::Page; -use jellycommon::routes::u_account_login; - -impl Page for ErrorPage { - fn title(&self) -> String { - "Error".to_string() - } - fn to_render(&self) -> markup::DynRender<'_> { - markup::new!(@self) - } -} - -markup::define! { - ErrorPage(status: String) { - h2 { "Error" } - p { @status } - // @if status == Status::NotFound { - p { "You might need to " a[href=u_account_login()] { "log in" } ", to see this page" } - // } - } -} diff --git a/ui/src/format.rs b/ui/src/format.rs index 811211a..01982af 100644 --- a/ui/src/format.rs +++ b/ui/src/format.rs @@ -13,12 +13,12 @@ use crate::locale::tr; use std::fmt::Write; pub fn format_duration(d: f64) -> String { - format_duration_mode(d, false, LANG_ENG.0) + format_duration_mode(LANG_ENG.0, d, false) } -pub fn format_duration_long(d: f64, lang: Language) -> String { - format_duration_mode(d, true, lang) +pub fn format_duration_long(lang: Language, d: f64) -> String { + format_duration_mode(lang, d, true) } -fn format_duration_mode(mut d: f64, long_units: bool, lang: Language) -> String { +fn format_duration_mode(lang: Language, mut d: f64, long_units: bool) -> String { let mut s = String::new(); let sign = if d > 0. { "" } else { "-" }; d = d.abs(); @@ -58,15 +58,15 @@ fn test_duration_short() { #[test] fn test_duration_long() { assert_eq!( - format_duration_long(61., LANG_ENG.0).as_str(), + format_duration_long(LANG_ENG.0, 61.).as_str(), "1 minute and 1 second" ); assert_eq!( - format_duration_long(121., LANG_ENG.0).as_str(), + format_duration_long(LANG_ENG.0, 121.).as_str(), "2 minutes and 1 second" ); assert_eq!( - format_duration_long(3661., LANG_ENG.0).as_str(), + format_duration_long(LANG_ENG.0, 3661.).as_str(), "1 hour, 1 minute and 1 second" ); } diff --git a/ui/src/lib.rs b/ui/src/lib.rs index 6dbc837..72109d4 100644 --- a/ui/src/lib.rs +++ b/ui/src/lib.rs @@ -3,28 +3,17 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2026 metamuffin <metamuffin.org> */ -pub mod account; -pub mod admin; -pub mod error; -pub mod filter_sort; -pub mod format; -pub mod home; -pub mod items; -pub mod locale; -pub mod node_card; -pub mod node_page; -pub mod props; -pub mod scaffold; -pub mod search; -pub mod stats; +mod components; +pub(crate) mod format; +pub(crate) mod locale; +mod scaffold; -use markup::DynRender; -use scaffold::{RenderInfo, Scaffold}; -use serde::{Deserialize, Serialize}; -use std::{ - path::PathBuf, - sync::{LazyLock, Mutex}, +use crate::{components::View, scaffold::Scaffold}; +use jellycommon::{ + jellyobject::{Object, Tag}, + *, }; +use serde::{Deserialize, Serialize}; pub type FlashM = Option<(String, String)>; @@ -33,59 +22,22 @@ pub type FlashM = Option<(String, String)>; pub struct Config { brand: String, slogan: String, - asset_path: PathBuf, -} - -static CONF: LazyLock<Config> = LazyLock::new(|| { - CONF_PRELOAD - .lock() - .unwrap() - .take() - .expect("cache config not preloaded. logic error") -}); -pub static CONF_PRELOAD: Mutex<Option<Config>> = Mutex::new(None); - -pub fn get_brand() -> String { - CONF.brand.clone() -} -pub fn get_slogan() -> String { - CONF.slogan.clone() + logo: bool, } -/// render as supertrait would be possible but is not -/// dyn compatible and I really dont want to expose generics -/// that generate rendering code because of compile speed. -pub trait Page { - fn title(&self) -> String; - fn to_render(&self) -> DynRender<'_>; - fn class(&self) -> Option<&'static str> { - None - } +pub struct RenderInfo<'a> { + pub user: Option<Object<'a>>, + pub lang: Tag, + pub status_message: Option<&'a str>, + pub config: &'a Config, } -pub fn render_page(renderinfo: &RenderInfo<'_>, page: &dyn Page) -> String { +pub fn render_view(ri: RenderInfo<'_>, view: Object<'_>) -> String { Scaffold { - class: &format!( - "{} theme-{}", - page.class().unwrap_or("custom-page"), - "dark", // todo - ), - ri: renderinfo, - title: page.title(), - main: page.to_render(), + ri: &ri, + main: View { ri: &ri, view }, + title: view.get(VIEW_TITLE).unwrap_or_default(), + class: "", } .to_string() } - -pub struct CustomPage { - pub title: String, - pub body: String, -} -impl Page for CustomPage { - fn title(&self) -> String { - self.title.clone() - } - fn to_render(&self) -> DynRender<'_> { - markup::new!(@markup::raw(&self.body)) - } -} diff --git a/ui/src/locale.rs b/ui/src/locale.rs index a2fdce0..08c73c2 100644 --- a/ui/src/locale.rs +++ b/ui/src/locale.rs @@ -6,7 +6,7 @@ use jellycommon::*; use std::{collections::HashMap, sync::LazyLock}; -static LANG_TABLES: LazyLock<HashMap<Language, HashMap<&'static str, &'static str>>> = +pub static LANG_TABLES: LazyLock<HashMap<Language, HashMap<&'static str, &'static str>>> = LazyLock::new(|| { let mut k = HashMap::new(); for (lang, source) in [ @@ -31,10 +31,6 @@ pub fn tr(lang: Language, key: &str) -> &'static str { tr_map.get(key).copied().unwrap_or("MISSING TRANSLATION") } -pub fn get_translation_table(lang: &Language) -> &'static HashMap<&'static str, &'static str> { - LANG_TABLES.get(lang).unwrap() -} - pub fn escape(str: &str) -> String { let mut o = String::with_capacity(str.len()); let mut last = 0; diff --git a/ui/src/node_page.rs b/ui/src/node_page.rs deleted file mode 100644 index f52ea5b..0000000 --- a/ui/src/node_page.rs +++ /dev/null @@ -1,218 +0,0 @@ -/* - 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) 2026 metamuffin <metamuffin.org> -*/ - -use crate::{Page, scaffold::RenderInfo}; -use jellycommon::{ - jellyobject::{Object, Tag, TypedTag}, - *, -}; -use std::marker::PhantomData; - -impl Page for NodePage<'_> { - fn title(&self) -> String { - self.node.node.get(NO_TITLE).unwrap_or_default().to_string() - } - fn class(&self) -> Option<&'static str> { - Some("node-page") - } - fn to_render(&self) -> markup::DynRender<'_> { - markup::new!(@self) - } -} - -pub struct NodeUdata<'a> { - pub node: Object<'a>, - pub udata: Object<'a>, -} - -markup::define! { - NodePage<'a>( - ri: &'a RenderInfo<'a>, - node: NodeUdata<'a>, - children: &'a [NodeUdata<'a>], - parents: &'a [NodeUdata<'a>], - similar: &'a [NodeUdata<'a>], - ) { - // @if !matches!(node.kind, NodeKind::Collection) && !player { - // img.backdrop[src=u_node_image(&node.slug, PictureSlot::Backdrop, 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=u_node_image(&node.slug, PictureSlot::Cover, 2048), loading="lazy"]; } - // } - // .title { - // h1 { @node.title } - // ul.parents { @for (node, _) in *parents { li { - // a.component[href=u_node_slug(&node.slug)] { @node.title } - // }}} - // @if node.media.is_some() { - // a.play[href=u_node_slug_player(&node.slug)] { @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=u_node_slug_watched(&node.slug, ApiWatchedState::Watched)] { - // input[type="submit", value=trs(lang, "node.watched.set")]; - // } - // } - // @if matches!(udata.watched, WatchedState::Watched) { - // form.mark_unwatched[method="POST", action=u_node_slug_watched(&node.slug, ApiWatchedState::None)] { - // input[type="submit", value=trs(lang, "node.watched.unset")]; - // } - // } - // @if matches!(udata.watched, WatchedState::None) { - // form.mark_unwatched[method="POST", action=u_node_slug_watched(&node.slug, ApiWatchedState::Pending)] { - // input[type="submit", value=trs(lang, "node.watchlist.set")]; - // } - // } - // @if matches!(udata.watched, WatchedState::Pending) { - // form.mark_unwatched[method="POST", action=u_node_slug_watched(&node.slug, ApiWatchedState::None)] { - // input[type="submit", value=trs(lang, "node.watchlist.unset")]; - // } - // } - // form.rating[method="POST", action=u_node_slug_update_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=u_node_slug_player_time(&node.slug, chap.time_start.unwrap_or(0.))] { - // img[src=u_node_slug_thumbnail(&node.slug, chapter_key_time(chap, media.duration), 1024), loading="lazy"]; - // } - // .cardhover { .props { p { @inl } } } - // } - // .title { span { @sub } } - // }} - // }} - // } - // @if !node.credits.is_empty() { - // h2 { @trs(lang, "node.people") } - // @for (group, people) in &node.credits { - // details[open=group==&CreditCategory::Cast] { - // summary { h3 { @format!("{}", group) } } - // ul.children.hlist { @for (i, pe) in people.iter().enumerate() { - // li { .card."aspect-port" { - // .poster { - // a[href="#"] { - // img[src=u_node_slug_person_asset(&node.slug, *group, i, 1024), loading="lazy"]; - // } - // } - // .title { - // // TODO 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.identifiers.is_empty() { - // details { - // summary { @trs(lang, "node.external_ids") } - // table { - // @for (key, value) in &node.identifiers { tr { - // tr { - // td { @trs(lang, &format!("id.{}", 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: Object, dur: f64) -> f64 { - let start = c.get(CH_START).unwrap_or(0.); - let end = c.get(CH_END).unwrap_or(dur); - start * 0.8 + end * 0.2 -} - -pub fn aspect_class(kind: Tag) -> &'static str { - match kind { - KIND_VIDEO | KIND_EPISODE => "aspect-thumb", - KIND_COLLECTION => "aspect-land", - KIND_SEASON | KIND_SHOW | KIND_SERIES | KIND_MOVIE | KIND_SHORTFORMVIDEO => "aspect-port", - KIND_CHANNEL | KIND_MUSIC | _ => "aspect-square", - } -} - -fn external_id_url(key: Tag, value: &str) -> Option<String> { - Some(match TypedTag(key, PhantomData) { - IDENT_YOUTUBE_VIDEO => format!("https://youtube.com/watch?v={value}"), - IDENT_YOUTUBE_CHANNEL => format!("https://youtube.com/channel/{value}"), - IDENT_YOUTUBE_CHANNEL_HANDLE => format!("https://youtube.com/channel/@{value}"), - IDENT_MUSICBRAINZ_RELEASE => format!("https://musicbrainz.org/release/{value}"), - IDENT_MUSICBRAINZ_ARTIST => format!("https://musicbrainz.org/artist/{value}"), - IDENT_MUSICBRAINZ_RELEASE_GROUP => { - format!("https://musicbrainz.org/release-group/{value}") - } - IDENT_MUSICBRAINZ_RECORDING => { - format!("https://musicbrainz.org/recording/{value}") - } - _ => return None, - }) -} diff --git a/ui/src/account/mod.rs b/ui/src/old/account/mod.rs index e7da26f..e7da26f 100644 --- a/ui/src/account/mod.rs +++ b/ui/src/old/account/mod.rs diff --git a/ui/src/account/settings.rs b/ui/src/old/account/settings.rs index 83f72b0..83f72b0 100644 --- a/ui/src/account/settings.rs +++ b/ui/src/old/account/settings.rs diff --git a/ui/src/admin/import.rs b/ui/src/old/admin/import.rs index 805d787..805d787 100644 --- a/ui/src/admin/import.rs +++ b/ui/src/old/admin/import.rs diff --git a/ui/src/admin/log.rs b/ui/src/old/admin/log.rs index 637158f..637158f 100644 --- a/ui/src/admin/log.rs +++ b/ui/src/old/admin/log.rs diff --git a/ui/src/admin/mod.rs b/ui/src/old/admin/mod.rs index f42ba76..f42ba76 100644 --- a/ui/src/admin/mod.rs +++ b/ui/src/old/admin/mod.rs diff --git a/ui/src/admin/user.rs b/ui/src/old/admin/user.rs index e4a8975..e4a8975 100644 --- a/ui/src/admin/user.rs +++ b/ui/src/old/admin/user.rs diff --git a/ui/src/filter_sort.rs b/ui/src/old/filter_sort.rs index 55cb113..55cb113 100644 --- a/ui/src/filter_sort.rs +++ b/ui/src/old/filter_sort.rs diff --git a/ui/src/home.rs b/ui/src/old/home.rs index a3088c8..a3088c8 100644 --- a/ui/src/home.rs +++ b/ui/src/old/home.rs diff --git a/ui/src/items.rs b/ui/src/old/items.rs index 529a5d6..529a5d6 100644 --- a/ui/src/items.rs +++ b/ui/src/old/items.rs diff --git a/ui/src/node_card.rs b/ui/src/old/node_card.rs index f87f490..f87f490 100644 --- a/ui/src/node_card.rs +++ b/ui/src/old/node_card.rs diff --git a/ui/src/search.rs b/ui/src/old/search.rs index 0eb34b9..0eb34b9 100644 --- a/ui/src/search.rs +++ b/ui/src/old/search.rs diff --git a/ui/src/scaffold.rs b/ui/src/scaffold.rs index 82d6d5e..fee311a 100644 --- a/ui/src/scaffold.rs +++ b/ui/src/scaffold.rs @@ -5,11 +5,10 @@ */ use crate::{ - CONF, FlashM, + RenderInfo, locale::{escape, tr}, }; use jellycommon::{ - jellyobject::{Object, Tag}, routes::{ u_account_login, u_account_logout, u_account_register, u_account_settings, u_admin_dashboard, u_home, u_items, u_node_slug, u_search, u_stats, @@ -17,22 +16,13 @@ use jellycommon::{ user::{USER_ADMIN, USER_NAME}, }; use markup::{Render, raw}; -use std::sync::LazyLock; - -static LOGO_ENABLED: LazyLock<bool> = LazyLock::new(|| CONF.asset_path.join("logo.svg").exists()); - -pub struct RenderInfo<'a> { - pub user: Option<Object<'a>>, - pub lang: Tag, - pub status_message: Option<&'a str>, -} markup::define! { - Scaffold<'a, Main: Render>(ri: &'a RenderInfo<'a>, title: String, main: Main, class: &'a str) { + Scaffold<'a, Main: Render>(ri: &'a RenderInfo<'a>, title: &'a str, main: Main, class: &'a str) { @markup::doctype() html { head { - title { @title " - " @CONF.brand } + title { @title " - " @ri.config.brand } meta[name="viewport", content="width=device-width, initial-scale=1.0"]; link[rel="stylesheet", href="/assets/style.css"]; script[src="/assets/bundle.js"] {} @@ -41,7 +31,7 @@ markup::define! { @Navbar { ri } #main { @main } footer { - p { @CONF.brand " - " @CONF.slogan " | powered by " a[href="https://codeberg.org/metamuffin/jellything"]{"Jellything"} } + p { @ri.config.brand " - " @ri.config.slogan " | powered by " a[href="https://codeberg.org/metamuffin/jellything"]{"Jellything"} } } } } @@ -49,7 +39,7 @@ markup::define! { Navbar<'a>(ri: &'a RenderInfo<'a>) { nav { - h1 { a[href=if ri.user.is_some() {u_home()} else {"/".to_string()}] { @if *LOGO_ENABLED { img.logo[src="/assets/logo.svg"]; } else { @CONF.brand } } } " " + h1 { a[href=if ri.user.is_some() {u_home()} else {"/".to_string()}] { @if ri.config.logo { img.logo[src="/assets/logo.svg"]; } else { @ri.config.brand } } } " " @if ri.user.is_some() { a.library[href=u_node_slug("library")] { @tr(ri.lang, "nav.root") } " " a.library[href=u_items()] { @tr(ri.lang, "nav.all") } " " @@ -72,14 +62,4 @@ markup::define! { } } } - - FlashDisplay<'a>(flash: &'a FlashM) { - @if let Some((kind, message)) = &flash { - @match kind.as_str() { - "success" => { section.message { p.success { @message } } } - "error" => { section.message { p.error { @message } } } - _ => { section.message { p { @message } } } - } - } - } } diff --git a/ui/src/stats.rs b/ui/src/stats.rs deleted file mode 100644 index d69f07e..0000000 --- a/ui/src/stats.rs +++ /dev/null @@ -1,79 +0,0 @@ -/* - 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) 2026 metamuffin <metamuffin.org> -*/ - -use crate::{ - Page, - format::{format_duration, format_duration_long, format_kind, format_size}, - locale::tr, - scaffold::RenderInfo, -}; -use jellycommon::routes::u_node_slug; -use markup::raw; - -impl Page for StatsPage<'_> { - fn title(&self) -> String { - tr(self.ri.lang, "stats.title").to_string() - } - fn to_render(&self) -> markup::DynRender<'_> { - markup::new!(@self) - } -} - -markup::define! { - StatsPage<'a>(ri: &'a RenderInfo<'a>, r: ApiStatsResponse) { - .page.stats { - h1 { @tr(ri.lang, "stats.title") } - p { @raw(tr(ri.lang, "stats.count") - .replace("{count}", &format!("<b>{}</b>", r.total.count)) - )} - p { @raw(tr(ri.lang, "stats.runtime") - .replace("{dur}", &format!("<b>{}</b>", format_duration_long(r.total.runtime, ri.lang))) - .replace("{size}", &format!("<b>{}</b>", format_size(r.total.size))) - )} - p { @raw(tr(ri.lang, "stats.average") - .replace("{dur}", &format!("<b>{}</b>", format_duration(r.total.average_runtime()))) - .replace("{size}", &format!("<b>{}</b>", format_size(r.total.average_size() as u64))) - )} - - h2 { @tr(ri.lang, "stats.by_kind.title") } - table.striped { - tr { - th { @tr(ri.lang, "stats.by_kind.kind") } - th { @tr(ri.lang, "stats.by_kind.count") } - th { @tr(ri.lang, "stats.by_kind.total_size") } - th { @tr(ri.lang, "stats.by_kind.total_runtime") } - th { @tr(ri.lang, "stats.by_kind.average_size") } - th { @tr(ri.lang, "stats.by_kind.average_runtime") } - th { @tr(ri.lang, "stats.by_kind.max_size") } - th { @tr(ri.lang, "stats.by_kind.max_runtime") } - } - @for (k,b) in &r.kinds { tr { - td { @format_kind(*k, ri.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=u_node_slug(&b.max_size.1)]{ @format_size(b.max_size.0) }}} - td { @if b.max_runtime.0 > 0. { a[href=u_node_slug(&b.max_runtime.1)]{ @format_duration(b.max_runtime.0) }}} - }} - } - } - } -} - -trait BinExt { - fn average_runtime(&self) -> f64; - fn average_size(&self) -> f64; -} -impl BinExt for StatsBin { - fn average_runtime(&self) -> f64 { - self.runtime / self.count as f64 - } - fn average_size(&self) -> f64 { - self.size as f64 / self.count as f64 - } -} |