From 3671a4e07565c86f8071fb2309f463aeaf684ba3 Mon Sep 17 00:00:00 2001 From: metamuffin Date: Fri, 23 Jan 2026 04:19:24 +0100 Subject: move ui code around --- Cargo.lock | 1 - common/object/src/registry.rs | 6 +- common/src/api.rs | 17 ++- common/src/routes.rs | 4 +- server/Cargo.toml | 2 +- server/src/compat/jellyfin/mod.rs | 6 +- server/src/main.rs | 4 +- ui/src/account/mod.rs | 103 ------------------ ui/src/account/settings.rs | 84 --------------- ui/src/admin/import.rs | 46 -------- ui/src/admin/log.rs | 127 ---------------------- ui/src/admin/mod.rs | 64 ----------- ui/src/admin/user.rs | 81 -------------- ui/src/components/message.rs | 20 ++++ ui/src/components/mod.rs | 22 ++++ ui/src/components/node_list.rs | 0 ui/src/components/node_page.rs | 194 +++++++++++++++++++++++++++++++++ ui/src/components/props.rs | 66 ++++++++++++ ui/src/components/stats.rs | 55 ++++++++++ ui/src/error.rs | 27 ----- ui/src/filter_sort.rs | 111 ------------------- ui/src/format.rs | 14 +-- ui/src/home.rs | 33 ------ ui/src/items.rs | 37 ------- ui/src/lib.rs | 90 ++++------------ ui/src/locale.rs | 6 +- ui/src/node_card.rs | 59 ----------- ui/src/node_page.rs | 218 -------------------------------------- ui/src/old/account/mod.rs | 103 ++++++++++++++++++ ui/src/old/account/settings.rs | 84 +++++++++++++++ ui/src/old/admin/import.rs | 46 ++++++++ ui/src/old/admin/log.rs | 127 ++++++++++++++++++++++ ui/src/old/admin/mod.rs | 64 +++++++++++ ui/src/old/admin/user.rs | 81 ++++++++++++++ ui/src/old/filter_sort.rs | 111 +++++++++++++++++++ ui/src/old/home.rs | 33 ++++++ ui/src/old/items.rs | 37 +++++++ ui/src/old/node_card.rs | 59 +++++++++++ ui/src/old/search.rs | 38 +++++++ ui/src/props.rs | 63 ----------- ui/src/scaffold.rs | 30 +----- ui/src/search.rs | 38 ------- ui/src/stats.rs | 79 -------------- 43 files changed, 1200 insertions(+), 1290 deletions(-) delete mode 100644 ui/src/account/mod.rs delete mode 100644 ui/src/account/settings.rs delete mode 100644 ui/src/admin/import.rs delete mode 100644 ui/src/admin/log.rs delete mode 100644 ui/src/admin/mod.rs delete mode 100644 ui/src/admin/user.rs create mode 100644 ui/src/components/message.rs create mode 100644 ui/src/components/mod.rs create mode 100644 ui/src/components/node_list.rs create mode 100644 ui/src/components/node_page.rs create mode 100644 ui/src/components/props.rs create mode 100644 ui/src/components/stats.rs delete mode 100644 ui/src/error.rs delete mode 100644 ui/src/filter_sort.rs delete mode 100644 ui/src/home.rs delete mode 100644 ui/src/items.rs delete mode 100644 ui/src/node_card.rs delete mode 100644 ui/src/node_page.rs create mode 100644 ui/src/old/account/mod.rs create mode 100644 ui/src/old/account/settings.rs create mode 100644 ui/src/old/admin/import.rs create mode 100644 ui/src/old/admin/log.rs create mode 100644 ui/src/old/admin/mod.rs create mode 100644 ui/src/old/admin/user.rs create mode 100644 ui/src/old/filter_sort.rs create mode 100644 ui/src/old/home.rs create mode 100644 ui/src/old/items.rs create mode 100644 ui/src/old/node_card.rs create mode 100644 ui/src/old/search.rs delete mode 100644 ui/src/props.rs delete mode 100644 ui/src/search.rs delete mode 100644 ui/src/stats.rs diff --git a/Cargo.lock b/Cargo.lock index 413071f..246f3a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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, ) -> 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, - tls:bool, + tls: bool, hostname: String, } diff --git a/ui/src/account/mod.rs b/ui/src/account/mod.rs deleted file mode 100644 index e7da26f..0000000 --- a/ui/src/account/mod.rs +++ /dev/null @@ -1,103 +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 -*/ -pub mod settings; - -use crate::{Page, locale::tr, scaffold::RenderInfo}; -use jellycommon::routes::{u_account_login, u_account_register}; - -impl Page for AccountLogin<'_> { - fn title(&self) -> String { - tr( - self.ri.lang, - if self.logged_in { - "account.login.switch" - } else { - "account.login" - }, - ) - .to_string() - } - - fn to_render(&self) -> markup::DynRender<'_> { - markup::new!(@self) - } -} -impl Page for AccountRegister<'_> { - fn title(&self) -> String { - tr(self.ri.lang, "account.register").to_string() - } - fn to_render(&self) -> markup::DynRender<'_> { - markup::new!(@self) - } -} -impl Page for AccountRegisterSuccess<'_> { - fn title(&self) -> String { - tr(self.ri.lang, "account.register").to_string() - } - fn to_render(&self) -> markup::DynRender<'_> { - markup::new!(@self) - } -} -impl Page for AccountLogout<'_> { - fn title(&self) -> String { - tr(self.ri.lang, "account.logout").to_string() - } - fn to_render(&self) -> markup::DynRender<'_> { - markup::new!(@self) - } -} - -markup::define! { - AccountRegister<'a>(ri: &'a RenderInfo<'a>) { - form.account[method="POST", action=""] { - h1 { @tr(ri.lang, "account.register") } - - label[for="inp-invitation"] { @tr(ri.lang, "account.register.invitation") } - input[type="text", id="inp-invitation", name="invitation"]; br; - - label[for="inp-username"] { @tr(ri.lang, "account.username") } - input[type="text", id="inp-username", name="username"]; br; - label[for="inp-password"] { @tr(ri.lang, "account.password") } - input[type="password", id="inp-password", name="password"]; br; - - input[type="submit", value=tr(ri.lang, "account.register.submit")]; - - p { @tr(ri.lang, "account.register.login") " " a[href=u_account_login()] { @tr(ri.lang, "account.register.login_here") } } - } - } - AccountRegisterSuccess<'a>(ri: &'a RenderInfo<'a>, logged_in: bool) { - h1 { @tr(ri.lang, if *logged_in { - "account.register.success.switch" - } else { - "account.register.success" - })} - } - AccountLogin<'a>(ri: &'a RenderInfo<'a>, logged_in: bool) { - form.account[method="POST", action=""] { - h1 { @self.title() } - - label[for="inp-username"] { @tr(ri.lang, "account.username") } - input[type="text", id="inp-username", name="username"]; br; - label[for="inp-password"] { @tr(ri.lang, "account.password") } - input[type="password", id="inp-password", name="password"]; br; - - input[type="submit", value=tr(ri.lang, if *logged_in { "account.login.submit.switch" } else { "account.login.submit" })]; - - @if *logged_in { - p { @tr(ri.lang, "account.login.register.switch") " " a[href=u_account_register()] { @tr(ri.lang, "account.login.register_here") } } - } else { - p { @tr(ri.lang, "account.login.cookie_note") } - p { @tr(ri.lang, "account.login.register") " " a[href=u_account_register()] { @tr(ri.lang, "account.login.register_here") } } - } - } - } - AccountLogout<'a>(ri: &'a RenderInfo<'a>) { - form.account[method="POST", action=""] { - h1 { @tr(ri.lang, "account.logout") } - input[type="submit", value=tr(ri.lang, "account.logout.submit")]; - } - } -} diff --git a/ui/src/account/settings.rs b/ui/src/account/settings.rs deleted file mode 100644 index 83f72b0..0000000 --- a/ui/src/account/settings.rs +++ /dev/null @@ -1,84 +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 -*/ -use crate::{ - FlashM, Page, - locale::tr, - scaffold::{FlashDisplay, RenderInfo, SessionInfo}, -}; -use jellycommon::routes::{u_account_login, u_account_settings}; -use markup::RenderAttributeValue; - -impl Page for SettingsPage<'_> { - fn title(&self) -> String { - "Settings".to_string() - } - fn to_render(&self) -> markup::DynRender<'_> { - markup::new!(@self) - } -} - -markup::define! { - SettingsPage<'a>(ri: &'a RenderInfo<'a>, session: &'a SessionInfo, flash: &'a FlashM) { - h1 { "Settings" } - @FlashDisplay {flash} - h2 { @tr(ri.lang, "account") } - a.switch_account[href=u_account_login()] { "Switch Account" } - form[method="POST", action=u_account_settings()] { - label[for="username"] { @tr(ri.lang, "account.username") } - input[type="text", id="username", disabled, value=&session.user.name]; - input[type="submit", disabled, value=tr(ri.lang, "settings.immutable")]; - } - form[method="POST", action=u_account_settings()] { - label[for="display_name"] { @tr(ri.lang, "account.display_name") } - input[type="text", id="display_name", name="display_name", value=&session.user.display_name]; - input[type="submit", value=tr(ri.lang, "settings.update")]; - } - form[method="POST", action=u_account_settings()] { - label[for="password"] { @tr(ri.lang, "account.password") } - input[type="password", id="password", name="password"]; - input[type="submit", value=tr(ri.lang, "settings.update")]; - } - h2 { @tr(ri.lang, "settings.appearance") } - form[method="POST", action=u_account_settings()] { - fieldset { - legend { @tr(ri.lang, "settings.appearance.theme") } - @for theme in Theme::ALL { - label { input[type="radio", name="theme", value=A(*theme), checked=session.user.theme==*theme]; @tr(ri.lang, &format!("theme.{theme}")) } br; - } - } - input[type="submit", value=tr(ri.lang, "settings.apply")]; - } - form[method="POST", action=u_account_settings()] { - fieldset { - legend { @tr(ri.lang, "settings.player_preference") } - @for kind in PlayerKind::ALL { - label { input[type="radio", name="player_preference", value=A(*kind), checked=session.user.player_preference==*kind]; @tr(ri.lang, &format!("player_kind.{kind}")) } br; - } - } - input[type="submit", value=tr(ri.lang, "settings.apply")]; - } - form[method="POST", action=u_account_settings()] { - label[for="native_secret"] { "Native Secret" } - input[type="password", id="native_secret", name="native_secret"]; - input[type="submit", value=tr(ri.lang, "settings.update")]; - p { "The secret can be found in " code{"$XDG_CONFIG_HOME/jellynative_secret"} " or by clicking " a.button[href="jellynative://show-secret-v1"] { "Show Secret" } "." } - } - } -} - -struct A(pub T); -impl markup::Render for A { - fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { - writer.write_str(self.0.to_str()) - } -} -impl markup::Render for A { - fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { - writer.write_str(self.0.to_str()) - } -} -impl RenderAttributeValue for A {} -impl RenderAttributeValue for A {} diff --git a/ui/src/admin/import.rs b/ui/src/admin/import.rs deleted file mode 100644 index 805d787..0000000 --- a/ui/src/admin/import.rs +++ /dev/null @@ -1,46 +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 -*/ - -use crate::{FlashM, Page, locale::tr, scaffold::{FlashDisplay, RenderInfo}}; -use jellycommon::routes::u_admin_import_post; - -impl Page for AdminImportPage<'_> { - fn title(&self) -> String { - "Import".to_string() - } - fn to_render(&self) -> markup::DynRender<'_> { - markup::new!(@self) - } -} - -markup::define!( - AdminImportPage<'a>(ri: &'a RenderInfo<'a>, busy: bool, last_import_err: &'a [String], flash: &'a FlashM) { - @FlashDisplay { flash } - @if *busy { - h1 { @tr(ri.lang, "admin.import.running") } - noscript { "Live import progress needs javascript." } - div[id="admin_import"] {} - } else { - h1 { @tr(ri.lang, "admin.import.title") } - @if !last_import_err.is_empty() { - section.message.error { - details { - summary { p.error { @tr(ri.lang, "admin.import_errors").replace("{n}", &last_import_err.len().to_string()) } } - ol { @for e in *last_import_err { - li.error { pre.error { @e } } - }} - } - } - } - form[method="POST", action=u_admin_import_post(true)] { - input[type="submit", value=tr(ri.lang, "admin.dashboard.import.inc").to_string()]; - } - form[method="POST", action=u_admin_import_post(false)] { - input[type="submit", value=tr(ri.lang, "admin.dashboard.import.full").to_string()]; - } - } - } -); diff --git a/ui/src/admin/log.rs b/ui/src/admin/log.rs deleted file mode 100644 index 637158f..0000000 --- a/ui/src/admin/log.rs +++ /dev/null @@ -1,127 +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 -*/ - -use crate::Page; -use jellycommon::routes::u_admin_log; -use markup::raw; -use std::fmt::Write; - -impl Page for ServerLogPage<'_> { - fn title(&self) -> String { - "Server Log".to_string() - } - fn class(&self) -> Option<&'static str> { - Some("admin_log") - } - fn to_render(&self) -> markup::DynRender<'_> { - markup::new!(@self) - } -} - -markup::define! { - ServerLogPage<'a>(warnonly: bool, messages: &'a [String]) { - h1 { "Server Log" } - a[href=u_admin_log(!warnonly)] { @if *warnonly { "Show everything" } else { "Show only warnings" }} - code.log[id="log"] { - table { @for e in *messages { - @raw(e) - }} - } - } - ServerLogLine<'a>(e: &'a LogLine) { - tr[class=format!("level-{}", e.level).to_ascii_lowercase()] { - td.time { @e.time.to_rfc3339() } - td.loglevel { @format_level(e.level) } - td.module { @e.module } - td { @markup::raw(vt100_to_html(&e.message)) } - } - } -} - -pub fn render_log_line(line: &LogLine) -> String { - ServerLogLine { e: line }.to_string() -} - -fn vt100_to_html(s: &str) -> String { - let mut out = HtmlOut::default(); - let mut st = vte::Parser::new(); - st.advance(&mut out, s.as_bytes()); - out.s -} - -fn format_level(level: LogLevel) -> impl markup::Render { - let (s, c) = match level { - LogLevel::Debug => ("DEBUG", "blue"), - LogLevel::Error => ("ERROR", "red"), - LogLevel::Warn => ("WARN", "yellow"), - LogLevel::Info => ("INFO", "green"), - LogLevel::Trace => ("TRACE", "lightblue"), - }; - markup::new! { span[style=format!("color:{c}")] {@s} } -} - -#[derive(Default)] -pub struct HtmlOut { - s: String, - color: bool, -} -impl HtmlOut { - pub fn set_color(&mut self, [r, g, b]: [u8; 3]) { - self.reset_color(); - self.color = true; - write!(self.s, "", r, g, b).unwrap() - } - pub fn reset_color(&mut self) { - if self.color { - write!(self.s, "").unwrap(); - self.color = false; - } - } -} -impl vte::Perform for HtmlOut { - fn print(&mut self, c: char) { - match c { - 'a'..='z' | 'A'..='Z' | '0'..='9' | ' ' => self.s.push(c), - x => write!(self.s, "&#{};", x as u32).unwrap(), - } - } - fn execute(&mut self, _byte: u8) {} - fn hook(&mut self, _params: &vte::Params, _i: &[u8], _ignore: bool, _a: char) {} - fn put(&mut self, _byte: u8) {} - fn unhook(&mut self) {} - fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {} - fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {} - fn csi_dispatch( - &mut self, - params: &vte::Params, - _intermediates: &[u8], - _ignore: bool, - action: char, - ) { - let mut k = params.iter(); - #[allow(clippy::single_match)] - match action { - 'm' => match k.next().unwrap_or(&[0]).first().unwrap_or(&0) { - c @ (30..=37 | 40..=47) => { - let c = if *c >= 40 { *c - 10 } else { *c }; - self.set_color(match c { - 30 => [0, 0, 0], - 31 => [255, 0, 0], - 32 => [0, 255, 0], - 33 => [255, 255, 0], - 34 => [0, 0, 255], - 35 => [255, 0, 255], - 36 => [0, 255, 255], - 37 => [255, 255, 255], - _ => unreachable!(), - }); - } - _ => (), - }, - _ => (), - } - } -} diff --git a/ui/src/admin/mod.rs b/ui/src/admin/mod.rs deleted file mode 100644 index f42ba76..0000000 --- a/ui/src/admin/mod.rs +++ /dev/null @@ -1,64 +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 -*/ - -pub mod import; -pub mod log; -pub mod user; - -use crate::{FlashM, Page, locale::tr, scaffold::{FlashDisplay, RenderInfo}}; -use jellycommon::routes::{ - u_admin_import, u_admin_invite_create, u_admin_invite_remove, u_admin_log, - u_admin_update_search, u_admin_users, -}; - -impl Page for AdminDashboardPage<'_> { - fn title(&self) -> String { - "Admin Dashboard".to_string() - } - fn to_render(&self) -> markup::DynRender<'_> { - markup::new!(@self) - } -} - -markup::define!( - AdminDashboardPage<'a>(ri: &'a RenderInfo<'a>, busy: Option<&'static str>, flash: &'a FlashM, invites: &'a [String]) { - h1 { @tr(ri.lang, "admin.dashboard.title") } - @FlashDisplay { flash } - ul { - li{a[href=u_admin_log(true)] { @tr(ri.lang, "admin.log.warnonly") }} - li{a[href=u_admin_log(false)] { @tr(ri.lang, "admin.log.full") }} - } - - a[href=u_admin_import()] { h2 { @tr(ri.lang, "admin.import.title") }} - @if let Some(text) = busy { - section.message { p.warn { @text } } - } - form[method="POST", action=u_admin_update_search()] { - input[type="submit", value=tr(ri.lang, "admin.dashboard.update_search").to_string()]; - } - h2 { @tr(ri.lang, "admin.dashboard.users") } - p { a[href=u_admin_users()] { @tr(ri.lang, "admin.dashboard.manage_users") } } - h2 { @tr(ri.lang, "admin.dashboard.invites") } - form[method="POST", action=u_admin_invite_create()] { - input[type="submit", value=tr(ri.lang, "admin.dashboard.create_invite").to_string()]; - } - ul { @for t in *invites { - li { - form[method="POST", action=u_admin_invite_remove()] { - span { @t } - input[type="text", name="invite", value=&t, hidden]; - input[type="submit", value=tr(ri.lang, "admin.dashboard.create_invite").to_string()]; - } - } - }} - - // h2 { "Database" } - // @match db_stats(&database) { - // Ok(s) => { @s } - // Err(e) => { pre.error { @format!("{e:?}") } } - // } - } -); diff --git a/ui/src/admin/user.rs b/ui/src/admin/user.rs deleted file mode 100644 index e4a8975..0000000 --- a/ui/src/admin/user.rs +++ /dev/null @@ -1,81 +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 -*/ - -use crate::{FlashM, Page, scaffold::FlashDisplay}; -use jellycommon::routes::{ - u_admin_user, u_admin_user_permission, u_admin_user_remove, u_admin_users, -}; - -impl Page for AdminUserPage<'_> { - fn title(&self) -> String { - "User Management".to_string() - } - fn to_render(&self) -> markup::DynRender<'_> { - markup::new!(@self) - } -} -impl Page for AdminUsersPage<'_> { - fn title(&self) -> String { - "User Management".to_string() - } - fn to_render(&self) -> markup::DynRender<'_> { - markup::new!(@self) - } -} - -markup::define! { - AdminUsersPage<'a>(lang: &'a Language, users: &'a [User], flash: &'a FlashM) { - h1 { @trs(lang, "admin.users.title") } - @FlashDisplay { flash } - h2 { @trs(lang, "admin.users.user_list") } - ul { @for u in *users { - li { - a[href=u_admin_user(&u.name)] { @format!("{:?}", u.display_name) " (" @u.name ")" } - } - }} - } - AdminUserPage<'a>(lang: &'a Language, user: &'a User, flash: &'a FlashM) { - h1 { @format!("{:?}", user.display_name) " (" @user.name ")" } - a[href=u_admin_users()] { @trs(lang, "admin.users.return_to_list") } - @FlashDisplay { flash } - form[method="POST", action=u_admin_user_remove(&user.name)] { - // input[type="text", name="name", value=&user.name, hidden]; - input.danger[type="submit", value="Remove user(!)"]; - } - - h2 { "Permissions" } - @PermissionDisplay { perms: &user.permissions } - - form[method="POST", action=u_admin_user_permission(&user.name)] { - // input[type="text", name="name", value=&user.name, hidden]; - fieldset.perms { - legend { "Permission" } - @for p in UserPermission::ALL_ENUMERABLE { - label { - input[type="radio", name="permission", value=serde_json::to_string(p).unwrap()]; - @format!("{p}") - } br; - } - } - fieldset.perms { - legend { "State" } - label { input[type="radio", name="action", value="unset"]; "Unset" } br; - label { input[type="radio", name="action", value="grant"]; "Grant" } br; - label { input[type="radio", name="action", value="revoke"]; "Revoke" } br; - } - input[type="submit", value="Update"]; - } - } - PermissionDisplay<'a>(perms: &'a PermissionSet) { - ul { @for (perm,grant) in &perms.0 { - @if *grant { - li[class="perm-grant"] { @format!("Allow {}", perm) } - } else { - li[class="perm-revoke"] { @format!("Deny {}", perm) } - } - }} - } -} 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 +*/ +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 +*/ + +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 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 +*/ + +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 { + 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/components/props.rs b/ui/src/components/props.rs new file mode 100644 index 0000000..f23d72e --- /dev/null +++ b/ui/src/components/props.rs @@ -0,0 +1,66 @@ +/* + 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 +*/ + +use crate::{ + RenderInfo, + format::{format_count, format_duration}, + locale::tr, +}; +use chrono::DateTime; +use jellycommon::{ + jellyobject::{Object, TypedTag}, + *, +}; +use std::marker::PhantomData; + +markup::define! { + 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) = node.get(NO_DURATION) { + p { @format_duration(dur) } + } + // @if let Some(res) = node.get(NO_TRACK) { + // p { @m.resolution_name() } + // } + @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 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 node.get(NO_RATINGS).unwrap_or_default().entries::() { + @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" }} + RTYP_YOUTUBE_FOLLOWERS => {p{ @format_count(value as usize) " Subscribers" }} + RTYP_ROTTEN_TOMATOES => {p.rating{ @value " Tomatoes" }} + RTYP_METACRITIC if *full => {p{ "Metacritic Score: " @value }} + RTYP_IMDB => {p.rating{ "IMDb " @value }} + RTYP_TMDB => {p.rating{ "TMDB " @format!("{:.01}", value) }} + RTYP_TRAKT => {p.rating{ "Trakt " @format!("{:.01}", value) }} + _ => {} + } + } + // @match nodeu.udata.watched { + // WatchedState::None => {} + // WatchedState::Pending => { p.pending { @tr(ri.lang, "prop.watched.pending") } } + // WatchedState::Progress(x) => { p.progress { @tr(ri.lang, "prop.watched.progress").replace("{time}", &format_duration(x)) } } + // WatchedState::Watched => { p.watched { @tr(ri.lang, "prop.watched.watched") } } + // } + } + } +} 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 +*/ + +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!("{}", stat.get(STAT_COUNT).unwrap_or_default())) + )} + p { @raw(tr(ri.lang, "stats.runtime") + .replace("{dur}", &format!("{}", format_duration_long(ri.lang, stat.get(STAT_TOTAL_DURATION).unwrap_or_default()))) + .replace("{size}", &format!("{}", format_size(stat.get(STAT_TOTAL_SIZE).unwrap_or_default()))) + )} + p { @raw(tr(ri.lang, "stats.average") + .replace("{dur}", &format!("{}", format_duration(stat.get(STAT_TOTAL_DURATION).unwrap_or_default() / stat.get(STAT_COUNT).unwrap_or_default() as f64))) + .replace("{size}", &format!("{}", 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 -*/ - -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/filter_sort.rs b/ui/src/filter_sort.rs deleted file mode 100644 index 55cb113..0000000 --- a/ui/src/filter_sort.rs +++ /dev/null @@ -1,111 +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 -*/ - -use crate::scaffold::RenderInfo; - -// const SORT_CATS: &[(&str, &[(SortProperty, &str)])] = { -// &[ -// ( -// "filter_sort.sort.general", -// &[(Title, "node.title"), (ReleaseDate, "node.release_date")], -// ), -// ("filter_sort.sort.media", &[(Duration, "media.duration")]), -// ( -// "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: &[(&str, &[(FilterProperty, &str)])] = { -// &[ -// ( -// "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>(ri: &'a RenderInfo<'a>, f: &'a NodeFilterSort) { - 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 FILTER_CATS { - // .category { - // h3 { @trs(lang, cname) } - // @for (value, label) in *cat { - // label { input[type="checkbox", name="filter_kind", value=A(*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 SORT_CATS { - // .category { - // h3 { @trs(lang, cname) } - // @for (value, label) in *cat { - // label { input[type="radio", name="sort_by", value=A(*value), checked=Some(value)==f.sort_by.as_ref()]; @trs(lang, label) } br; - // } - // } - // } - // } - } - fieldset.sortorder { - legend { "Sort Order" } - // @for (value, label) in [(Ascending, "filter_sort.order.asc"), (Descending, "filter_sort.order.desc")] { - // label { input[type="radio", name="sort_order", value=A(value), checked=Some(value)==f.sort_order]; @trs(lang, label) } br; - // } - } - input[type="submit", value="Apply"]; a[href="?"] { "Clear" } - } - } - } -} 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/home.rs b/ui/src/home.rs deleted file mode 100644 index a3088c8..0000000 --- a/ui/src/home.rs +++ /dev/null @@ -1,33 +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 -*/ -use crate::{CONF, Page, locale::tr, node_card::NodeCard, scaffold::RenderInfo}; -use markup::DynRender; - -markup::define! { - HomePage<'a>(ri: RenderInfo<'a>, r: ApiHomeResponse) { - h2 { @tr(ri.lang, "home.bin.root").replace("{title}", &CONF.brand) } - ul.children.hlist {@for nodeu in &r.toplevel { - li { @NodeCard { ri, nodeu } } - }} - @for (name, nodes) in &r.categories { - // @if !nodes.is_empty() { - // h2 { @trs(lang, name) } - // ul.children.hlist {@for (node, udata) in nodes { - // li { @NodeCard { node, udata, lang } } - // }} - // } - } - } -} - -impl Page for HomePage<'_> { - fn title(&self) -> String { - tr(self.ri.lang, "home").to_string() - } - fn to_render(&self) -> DynRender<'_> { - markup::new!(@self) - } -} diff --git a/ui/src/items.rs b/ui/src/items.rs deleted file mode 100644 index 529a5d6..0000000 --- a/ui/src/items.rs +++ /dev/null @@ -1,37 +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 -*/ -use crate::{Page, locale::tr, scaffold::RenderInfo}; -use markup::DynRender; - -markup::define! { - ItemsPage<'a>(ri: &'a RenderInfo<'a>, r: ApiItemsResponse, filter: &'a NodeFilterSort, page: usize) { - .page.dir { - h1 { "All Items" } - // @NodeFilterSortForm { f: filter, lang } - // ul.children { @for (node, udata) in &r.items { - // li {@NodeCard { node, udata, lang }} - // }} - // p.pagecontrols { - // span.current { @tr(**lang, "page.curr").replace("{cur}", &(page + 1).to_string()).replace("{max}", &r.pages.to_string()) " " } - // @if *page > 0 { - // a.prev[href=u_items_filter(page - 1, filter)] { @trs(lang, "page.prev") } " " - // } - // @if page + 1 < r.pages { - // a.next[href=u_items_filter(page + 1, filter)] { @trs(lang, "page.next") } - // } - // } - } - } -} - -impl Page for ItemsPage<'_> { - fn title(&self) -> String { - tr(self.ri.lang, "home").to_string() - } - fn to_render(&self) -> DynRender<'_> { - markup::new!(@self) - } -} 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 */ -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; - -use markup::DynRender; -use scaffold::{RenderInfo, Scaffold}; -use serde::{Deserialize, Serialize}; -use std::{ - path::PathBuf, - sync::{LazyLock, Mutex}, +mod components; +pub(crate) mod format; +pub(crate) mod locale; +mod scaffold; + +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, + logo: bool, } -static CONF: LazyLock = LazyLock::new(|| { - CONF_PRELOAD - .lock() - .unwrap() - .take() - .expect("cache config not preloaded. logic error") -}); -pub static CONF_PRELOAD: Mutex> = Mutex::new(None); - -pub fn get_brand() -> String { - CONF.brand.clone() -} -pub fn get_slogan() -> String { - CONF.slogan.clone() +pub struct RenderInfo<'a> { + pub user: Option>, + pub lang: Tag, + pub status_message: Option<&'a str>, + pub config: &'a Config, } -/// 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 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>> = +pub static LANG_TABLES: LazyLock>> = 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_card.rs b/ui/src/node_card.rs deleted file mode 100644 index f87f490..0000000 --- a/ui/src/node_card.rs +++ /dev/null @@ -1,59 +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 -*/ - -use crate::{ - node_page::{NodeUdata, aspect_class}, - scaffold::RenderInfo, -}; -use jellycommon::*; - -markup::define! { - NodeCard<'a>(ri: &'a RenderInfo<'a>, nodeu: NodeUdata<'a>) { - @let cls = format!("node card poster {}", aspect_class(nodeu.node.get(NO_KIND).unwrap_or(KIND_COLLECTION))); - div[class=cls] { - // .poster { - // a[href=u_node_slug(&node.slug)] { - // img[src=u_node_image(&node.slug, PictureSlot::Cover, 512), loading="lazy"]; - // } - // .cardhover.item { - // @if node.media.is_some() { - // a.play.icon[href=u_node_slug_player(&node.slug)] { "play_arrow" } - // } - // @Props { node, udata, full: false, lang } - // } - // } - // div.title { - // a[href=u_node_slug(&node.slug)] { - // @node.title - // } - // } - // div.subtitle { - // span { - // @node.subtitle - // } - // } - } - } - NodeCardWide<'a>(ri: &'a RenderInfo<'a>, nodeu: NodeUdata<'a>) { - div[class="node card widecard poster"] { - // div[class=&format!("poster {}", aspect_class(node.kind))] { - // a[href=u_node_slug(&node.slug)] { - // img[src=u_node_image(&node.slug, PictureSlot::Cover, 512), loading="lazy"]; - // } - // .cardhover.item { - // @if node.media.is_some() { - // a.play.icon[href=u_node_slug_player(&node.slug)] { "play_arrow" } - // } - // } - // } - // div.details { - // a.title[href=u_node_slug(&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 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 -*/ - -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 { - 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/old/account/mod.rs b/ui/src/old/account/mod.rs new file mode 100644 index 0000000..e7da26f --- /dev/null +++ b/ui/src/old/account/mod.rs @@ -0,0 +1,103 @@ +/* + 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 +*/ +pub mod settings; + +use crate::{Page, locale::tr, scaffold::RenderInfo}; +use jellycommon::routes::{u_account_login, u_account_register}; + +impl Page for AccountLogin<'_> { + fn title(&self) -> String { + tr( + self.ri.lang, + if self.logged_in { + "account.login.switch" + } else { + "account.login" + }, + ) + .to_string() + } + + fn to_render(&self) -> markup::DynRender<'_> { + markup::new!(@self) + } +} +impl Page for AccountRegister<'_> { + fn title(&self) -> String { + tr(self.ri.lang, "account.register").to_string() + } + fn to_render(&self) -> markup::DynRender<'_> { + markup::new!(@self) + } +} +impl Page for AccountRegisterSuccess<'_> { + fn title(&self) -> String { + tr(self.ri.lang, "account.register").to_string() + } + fn to_render(&self) -> markup::DynRender<'_> { + markup::new!(@self) + } +} +impl Page for AccountLogout<'_> { + fn title(&self) -> String { + tr(self.ri.lang, "account.logout").to_string() + } + fn to_render(&self) -> markup::DynRender<'_> { + markup::new!(@self) + } +} + +markup::define! { + AccountRegister<'a>(ri: &'a RenderInfo<'a>) { + form.account[method="POST", action=""] { + h1 { @tr(ri.lang, "account.register") } + + label[for="inp-invitation"] { @tr(ri.lang, "account.register.invitation") } + input[type="text", id="inp-invitation", name="invitation"]; br; + + label[for="inp-username"] { @tr(ri.lang, "account.username") } + input[type="text", id="inp-username", name="username"]; br; + label[for="inp-password"] { @tr(ri.lang, "account.password") } + input[type="password", id="inp-password", name="password"]; br; + + input[type="submit", value=tr(ri.lang, "account.register.submit")]; + + p { @tr(ri.lang, "account.register.login") " " a[href=u_account_login()] { @tr(ri.lang, "account.register.login_here") } } + } + } + AccountRegisterSuccess<'a>(ri: &'a RenderInfo<'a>, logged_in: bool) { + h1 { @tr(ri.lang, if *logged_in { + "account.register.success.switch" + } else { + "account.register.success" + })} + } + AccountLogin<'a>(ri: &'a RenderInfo<'a>, logged_in: bool) { + form.account[method="POST", action=""] { + h1 { @self.title() } + + label[for="inp-username"] { @tr(ri.lang, "account.username") } + input[type="text", id="inp-username", name="username"]; br; + label[for="inp-password"] { @tr(ri.lang, "account.password") } + input[type="password", id="inp-password", name="password"]; br; + + input[type="submit", value=tr(ri.lang, if *logged_in { "account.login.submit.switch" } else { "account.login.submit" })]; + + @if *logged_in { + p { @tr(ri.lang, "account.login.register.switch") " " a[href=u_account_register()] { @tr(ri.lang, "account.login.register_here") } } + } else { + p { @tr(ri.lang, "account.login.cookie_note") } + p { @tr(ri.lang, "account.login.register") " " a[href=u_account_register()] { @tr(ri.lang, "account.login.register_here") } } + } + } + } + AccountLogout<'a>(ri: &'a RenderInfo<'a>) { + form.account[method="POST", action=""] { + h1 { @tr(ri.lang, "account.logout") } + input[type="submit", value=tr(ri.lang, "account.logout.submit")]; + } + } +} diff --git a/ui/src/old/account/settings.rs b/ui/src/old/account/settings.rs new file mode 100644 index 0000000..83f72b0 --- /dev/null +++ b/ui/src/old/account/settings.rs @@ -0,0 +1,84 @@ +/* + 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 +*/ +use crate::{ + FlashM, Page, + locale::tr, + scaffold::{FlashDisplay, RenderInfo, SessionInfo}, +}; +use jellycommon::routes::{u_account_login, u_account_settings}; +use markup::RenderAttributeValue; + +impl Page for SettingsPage<'_> { + fn title(&self) -> String { + "Settings".to_string() + } + fn to_render(&self) -> markup::DynRender<'_> { + markup::new!(@self) + } +} + +markup::define! { + SettingsPage<'a>(ri: &'a RenderInfo<'a>, session: &'a SessionInfo, flash: &'a FlashM) { + h1 { "Settings" } + @FlashDisplay {flash} + h2 { @tr(ri.lang, "account") } + a.switch_account[href=u_account_login()] { "Switch Account" } + form[method="POST", action=u_account_settings()] { + label[for="username"] { @tr(ri.lang, "account.username") } + input[type="text", id="username", disabled, value=&session.user.name]; + input[type="submit", disabled, value=tr(ri.lang, "settings.immutable")]; + } + form[method="POST", action=u_account_settings()] { + label[for="display_name"] { @tr(ri.lang, "account.display_name") } + input[type="text", id="display_name", name="display_name", value=&session.user.display_name]; + input[type="submit", value=tr(ri.lang, "settings.update")]; + } + form[method="POST", action=u_account_settings()] { + label[for="password"] { @tr(ri.lang, "account.password") } + input[type="password", id="password", name="password"]; + input[type="submit", value=tr(ri.lang, "settings.update")]; + } + h2 { @tr(ri.lang, "settings.appearance") } + form[method="POST", action=u_account_settings()] { + fieldset { + legend { @tr(ri.lang, "settings.appearance.theme") } + @for theme in Theme::ALL { + label { input[type="radio", name="theme", value=A(*theme), checked=session.user.theme==*theme]; @tr(ri.lang, &format!("theme.{theme}")) } br; + } + } + input[type="submit", value=tr(ri.lang, "settings.apply")]; + } + form[method="POST", action=u_account_settings()] { + fieldset { + legend { @tr(ri.lang, "settings.player_preference") } + @for kind in PlayerKind::ALL { + label { input[type="radio", name="player_preference", value=A(*kind), checked=session.user.player_preference==*kind]; @tr(ri.lang, &format!("player_kind.{kind}")) } br; + } + } + input[type="submit", value=tr(ri.lang, "settings.apply")]; + } + form[method="POST", action=u_account_settings()] { + label[for="native_secret"] { "Native Secret" } + input[type="password", id="native_secret", name="native_secret"]; + input[type="submit", value=tr(ri.lang, "settings.update")]; + p { "The secret can be found in " code{"$XDG_CONFIG_HOME/jellynative_secret"} " or by clicking " a.button[href="jellynative://show-secret-v1"] { "Show Secret" } "." } + } + } +} + +struct A(pub T); +impl markup::Render for A { + fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { + writer.write_str(self.0.to_str()) + } +} +impl markup::Render for A { + fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { + writer.write_str(self.0.to_str()) + } +} +impl RenderAttributeValue for A {} +impl RenderAttributeValue for A {} diff --git a/ui/src/old/admin/import.rs b/ui/src/old/admin/import.rs new file mode 100644 index 0000000..805d787 --- /dev/null +++ b/ui/src/old/admin/import.rs @@ -0,0 +1,46 @@ +/* + 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 +*/ + +use crate::{FlashM, Page, locale::tr, scaffold::{FlashDisplay, RenderInfo}}; +use jellycommon::routes::u_admin_import_post; + +impl Page for AdminImportPage<'_> { + fn title(&self) -> String { + "Import".to_string() + } + fn to_render(&self) -> markup::DynRender<'_> { + markup::new!(@self) + } +} + +markup::define!( + AdminImportPage<'a>(ri: &'a RenderInfo<'a>, busy: bool, last_import_err: &'a [String], flash: &'a FlashM) { + @FlashDisplay { flash } + @if *busy { + h1 { @tr(ri.lang, "admin.import.running") } + noscript { "Live import progress needs javascript." } + div[id="admin_import"] {} + } else { + h1 { @tr(ri.lang, "admin.import.title") } + @if !last_import_err.is_empty() { + section.message.error { + details { + summary { p.error { @tr(ri.lang, "admin.import_errors").replace("{n}", &last_import_err.len().to_string()) } } + ol { @for e in *last_import_err { + li.error { pre.error { @e } } + }} + } + } + } + form[method="POST", action=u_admin_import_post(true)] { + input[type="submit", value=tr(ri.lang, "admin.dashboard.import.inc").to_string()]; + } + form[method="POST", action=u_admin_import_post(false)] { + input[type="submit", value=tr(ri.lang, "admin.dashboard.import.full").to_string()]; + } + } + } +); diff --git a/ui/src/old/admin/log.rs b/ui/src/old/admin/log.rs new file mode 100644 index 0000000..637158f --- /dev/null +++ b/ui/src/old/admin/log.rs @@ -0,0 +1,127 @@ +/* + 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 +*/ + +use crate::Page; +use jellycommon::routes::u_admin_log; +use markup::raw; +use std::fmt::Write; + +impl Page for ServerLogPage<'_> { + fn title(&self) -> String { + "Server Log".to_string() + } + fn class(&self) -> Option<&'static str> { + Some("admin_log") + } + fn to_render(&self) -> markup::DynRender<'_> { + markup::new!(@self) + } +} + +markup::define! { + ServerLogPage<'a>(warnonly: bool, messages: &'a [String]) { + h1 { "Server Log" } + a[href=u_admin_log(!warnonly)] { @if *warnonly { "Show everything" } else { "Show only warnings" }} + code.log[id="log"] { + table { @for e in *messages { + @raw(e) + }} + } + } + ServerLogLine<'a>(e: &'a LogLine) { + tr[class=format!("level-{}", e.level).to_ascii_lowercase()] { + td.time { @e.time.to_rfc3339() } + td.loglevel { @format_level(e.level) } + td.module { @e.module } + td { @markup::raw(vt100_to_html(&e.message)) } + } + } +} + +pub fn render_log_line(line: &LogLine) -> String { + ServerLogLine { e: line }.to_string() +} + +fn vt100_to_html(s: &str) -> String { + let mut out = HtmlOut::default(); + let mut st = vte::Parser::new(); + st.advance(&mut out, s.as_bytes()); + out.s +} + +fn format_level(level: LogLevel) -> impl markup::Render { + let (s, c) = match level { + LogLevel::Debug => ("DEBUG", "blue"), + LogLevel::Error => ("ERROR", "red"), + LogLevel::Warn => ("WARN", "yellow"), + LogLevel::Info => ("INFO", "green"), + LogLevel::Trace => ("TRACE", "lightblue"), + }; + markup::new! { span[style=format!("color:{c}")] {@s} } +} + +#[derive(Default)] +pub struct HtmlOut { + s: String, + color: bool, +} +impl HtmlOut { + pub fn set_color(&mut self, [r, g, b]: [u8; 3]) { + self.reset_color(); + self.color = true; + write!(self.s, "", r, g, b).unwrap() + } + pub fn reset_color(&mut self) { + if self.color { + write!(self.s, "").unwrap(); + self.color = false; + } + } +} +impl vte::Perform for HtmlOut { + fn print(&mut self, c: char) { + match c { + 'a'..='z' | 'A'..='Z' | '0'..='9' | ' ' => self.s.push(c), + x => write!(self.s, "&#{};", x as u32).unwrap(), + } + } + fn execute(&mut self, _byte: u8) {} + fn hook(&mut self, _params: &vte::Params, _i: &[u8], _ignore: bool, _a: char) {} + fn put(&mut self, _byte: u8) {} + fn unhook(&mut self) {} + fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {} + fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {} + fn csi_dispatch( + &mut self, + params: &vte::Params, + _intermediates: &[u8], + _ignore: bool, + action: char, + ) { + let mut k = params.iter(); + #[allow(clippy::single_match)] + match action { + 'm' => match k.next().unwrap_or(&[0]).first().unwrap_or(&0) { + c @ (30..=37 | 40..=47) => { + let c = if *c >= 40 { *c - 10 } else { *c }; + self.set_color(match c { + 30 => [0, 0, 0], + 31 => [255, 0, 0], + 32 => [0, 255, 0], + 33 => [255, 255, 0], + 34 => [0, 0, 255], + 35 => [255, 0, 255], + 36 => [0, 255, 255], + 37 => [255, 255, 255], + _ => unreachable!(), + }); + } + _ => (), + }, + _ => (), + } + } +} diff --git a/ui/src/old/admin/mod.rs b/ui/src/old/admin/mod.rs new file mode 100644 index 0000000..f42ba76 --- /dev/null +++ b/ui/src/old/admin/mod.rs @@ -0,0 +1,64 @@ +/* + 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 +*/ + +pub mod import; +pub mod log; +pub mod user; + +use crate::{FlashM, Page, locale::tr, scaffold::{FlashDisplay, RenderInfo}}; +use jellycommon::routes::{ + u_admin_import, u_admin_invite_create, u_admin_invite_remove, u_admin_log, + u_admin_update_search, u_admin_users, +}; + +impl Page for AdminDashboardPage<'_> { + fn title(&self) -> String { + "Admin Dashboard".to_string() + } + fn to_render(&self) -> markup::DynRender<'_> { + markup::new!(@self) + } +} + +markup::define!( + AdminDashboardPage<'a>(ri: &'a RenderInfo<'a>, busy: Option<&'static str>, flash: &'a FlashM, invites: &'a [String]) { + h1 { @tr(ri.lang, "admin.dashboard.title") } + @FlashDisplay { flash } + ul { + li{a[href=u_admin_log(true)] { @tr(ri.lang, "admin.log.warnonly") }} + li{a[href=u_admin_log(false)] { @tr(ri.lang, "admin.log.full") }} + } + + a[href=u_admin_import()] { h2 { @tr(ri.lang, "admin.import.title") }} + @if let Some(text) = busy { + section.message { p.warn { @text } } + } + form[method="POST", action=u_admin_update_search()] { + input[type="submit", value=tr(ri.lang, "admin.dashboard.update_search").to_string()]; + } + h2 { @tr(ri.lang, "admin.dashboard.users") } + p { a[href=u_admin_users()] { @tr(ri.lang, "admin.dashboard.manage_users") } } + h2 { @tr(ri.lang, "admin.dashboard.invites") } + form[method="POST", action=u_admin_invite_create()] { + input[type="submit", value=tr(ri.lang, "admin.dashboard.create_invite").to_string()]; + } + ul { @for t in *invites { + li { + form[method="POST", action=u_admin_invite_remove()] { + span { @t } + input[type="text", name="invite", value=&t, hidden]; + input[type="submit", value=tr(ri.lang, "admin.dashboard.create_invite").to_string()]; + } + } + }} + + // h2 { "Database" } + // @match db_stats(&database) { + // Ok(s) => { @s } + // Err(e) => { pre.error { @format!("{e:?}") } } + // } + } +); diff --git a/ui/src/old/admin/user.rs b/ui/src/old/admin/user.rs new file mode 100644 index 0000000..e4a8975 --- /dev/null +++ b/ui/src/old/admin/user.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) 2026 metamuffin +*/ + +use crate::{FlashM, Page, scaffold::FlashDisplay}; +use jellycommon::routes::{ + u_admin_user, u_admin_user_permission, u_admin_user_remove, u_admin_users, +}; + +impl Page for AdminUserPage<'_> { + fn title(&self) -> String { + "User Management".to_string() + } + fn to_render(&self) -> markup::DynRender<'_> { + markup::new!(@self) + } +} +impl Page for AdminUsersPage<'_> { + fn title(&self) -> String { + "User Management".to_string() + } + fn to_render(&self) -> markup::DynRender<'_> { + markup::new!(@self) + } +} + +markup::define! { + AdminUsersPage<'a>(lang: &'a Language, users: &'a [User], flash: &'a FlashM) { + h1 { @trs(lang, "admin.users.title") } + @FlashDisplay { flash } + h2 { @trs(lang, "admin.users.user_list") } + ul { @for u in *users { + li { + a[href=u_admin_user(&u.name)] { @format!("{:?}", u.display_name) " (" @u.name ")" } + } + }} + } + AdminUserPage<'a>(lang: &'a Language, user: &'a User, flash: &'a FlashM) { + h1 { @format!("{:?}", user.display_name) " (" @user.name ")" } + a[href=u_admin_users()] { @trs(lang, "admin.users.return_to_list") } + @FlashDisplay { flash } + form[method="POST", action=u_admin_user_remove(&user.name)] { + // input[type="text", name="name", value=&user.name, hidden]; + input.danger[type="submit", value="Remove user(!)"]; + } + + h2 { "Permissions" } + @PermissionDisplay { perms: &user.permissions } + + form[method="POST", action=u_admin_user_permission(&user.name)] { + // input[type="text", name="name", value=&user.name, hidden]; + fieldset.perms { + legend { "Permission" } + @for p in UserPermission::ALL_ENUMERABLE { + label { + input[type="radio", name="permission", value=serde_json::to_string(p).unwrap()]; + @format!("{p}") + } br; + } + } + fieldset.perms { + legend { "State" } + label { input[type="radio", name="action", value="unset"]; "Unset" } br; + label { input[type="radio", name="action", value="grant"]; "Grant" } br; + label { input[type="radio", name="action", value="revoke"]; "Revoke" } br; + } + input[type="submit", value="Update"]; + } + } + PermissionDisplay<'a>(perms: &'a PermissionSet) { + ul { @for (perm,grant) in &perms.0 { + @if *grant { + li[class="perm-grant"] { @format!("Allow {}", perm) } + } else { + li[class="perm-revoke"] { @format!("Deny {}", perm) } + } + }} + } +} diff --git a/ui/src/old/filter_sort.rs b/ui/src/old/filter_sort.rs new file mode 100644 index 0000000..55cb113 --- /dev/null +++ b/ui/src/old/filter_sort.rs @@ -0,0 +1,111 @@ +/* + 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 +*/ + +use crate::scaffold::RenderInfo; + +// const SORT_CATS: &[(&str, &[(SortProperty, &str)])] = { +// &[ +// ( +// "filter_sort.sort.general", +// &[(Title, "node.title"), (ReleaseDate, "node.release_date")], +// ), +// ("filter_sort.sort.media", &[(Duration, "media.duration")]), +// ( +// "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: &[(&str, &[(FilterProperty, &str)])] = { +// &[ +// ( +// "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>(ri: &'a RenderInfo<'a>, f: &'a NodeFilterSort) { + 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 FILTER_CATS { + // .category { + // h3 { @trs(lang, cname) } + // @for (value, label) in *cat { + // label { input[type="checkbox", name="filter_kind", value=A(*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 SORT_CATS { + // .category { + // h3 { @trs(lang, cname) } + // @for (value, label) in *cat { + // label { input[type="radio", name="sort_by", value=A(*value), checked=Some(value)==f.sort_by.as_ref()]; @trs(lang, label) } br; + // } + // } + // } + // } + } + fieldset.sortorder { + legend { "Sort Order" } + // @for (value, label) in [(Ascending, "filter_sort.order.asc"), (Descending, "filter_sort.order.desc")] { + // label { input[type="radio", name="sort_order", value=A(value), checked=Some(value)==f.sort_order]; @trs(lang, label) } br; + // } + } + input[type="submit", value="Apply"]; a[href="?"] { "Clear" } + } + } + } +} diff --git a/ui/src/old/home.rs b/ui/src/old/home.rs new file mode 100644 index 0000000..a3088c8 --- /dev/null +++ b/ui/src/old/home.rs @@ -0,0 +1,33 @@ +/* + 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 +*/ +use crate::{CONF, Page, locale::tr, node_card::NodeCard, scaffold::RenderInfo}; +use markup::DynRender; + +markup::define! { + HomePage<'a>(ri: RenderInfo<'a>, r: ApiHomeResponse) { + h2 { @tr(ri.lang, "home.bin.root").replace("{title}", &CONF.brand) } + ul.children.hlist {@for nodeu in &r.toplevel { + li { @NodeCard { ri, nodeu } } + }} + @for (name, nodes) in &r.categories { + // @if !nodes.is_empty() { + // h2 { @trs(lang, name) } + // ul.children.hlist {@for (node, udata) in nodes { + // li { @NodeCard { node, udata, lang } } + // }} + // } + } + } +} + +impl Page for HomePage<'_> { + fn title(&self) -> String { + tr(self.ri.lang, "home").to_string() + } + fn to_render(&self) -> DynRender<'_> { + markup::new!(@self) + } +} diff --git a/ui/src/old/items.rs b/ui/src/old/items.rs new file mode 100644 index 0000000..529a5d6 --- /dev/null +++ b/ui/src/old/items.rs @@ -0,0 +1,37 @@ +/* + 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 +*/ +use crate::{Page, locale::tr, scaffold::RenderInfo}; +use markup::DynRender; + +markup::define! { + ItemsPage<'a>(ri: &'a RenderInfo<'a>, r: ApiItemsResponse, filter: &'a NodeFilterSort, page: usize) { + .page.dir { + h1 { "All Items" } + // @NodeFilterSortForm { f: filter, lang } + // ul.children { @for (node, udata) in &r.items { + // li {@NodeCard { node, udata, lang }} + // }} + // p.pagecontrols { + // span.current { @tr(**lang, "page.curr").replace("{cur}", &(page + 1).to_string()).replace("{max}", &r.pages.to_string()) " " } + // @if *page > 0 { + // a.prev[href=u_items_filter(page - 1, filter)] { @trs(lang, "page.prev") } " " + // } + // @if page + 1 < r.pages { + // a.next[href=u_items_filter(page + 1, filter)] { @trs(lang, "page.next") } + // } + // } + } + } +} + +impl Page for ItemsPage<'_> { + fn title(&self) -> String { + tr(self.ri.lang, "home").to_string() + } + fn to_render(&self) -> DynRender<'_> { + markup::new!(@self) + } +} diff --git a/ui/src/old/node_card.rs b/ui/src/old/node_card.rs new file mode 100644 index 0000000..f87f490 --- /dev/null +++ b/ui/src/old/node_card.rs @@ -0,0 +1,59 @@ +/* + 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 +*/ + +use crate::{ + node_page::{NodeUdata, aspect_class}, + scaffold::RenderInfo, +}; +use jellycommon::*; + +markup::define! { + NodeCard<'a>(ri: &'a RenderInfo<'a>, nodeu: NodeUdata<'a>) { + @let cls = format!("node card poster {}", aspect_class(nodeu.node.get(NO_KIND).unwrap_or(KIND_COLLECTION))); + div[class=cls] { + // .poster { + // a[href=u_node_slug(&node.slug)] { + // img[src=u_node_image(&node.slug, PictureSlot::Cover, 512), loading="lazy"]; + // } + // .cardhover.item { + // @if node.media.is_some() { + // a.play.icon[href=u_node_slug_player(&node.slug)] { "play_arrow" } + // } + // @Props { node, udata, full: false, lang } + // } + // } + // div.title { + // a[href=u_node_slug(&node.slug)] { + // @node.title + // } + // } + // div.subtitle { + // span { + // @node.subtitle + // } + // } + } + } + NodeCardWide<'a>(ri: &'a RenderInfo<'a>, nodeu: NodeUdata<'a>) { + div[class="node card widecard poster"] { + // div[class=&format!("poster {}", aspect_class(node.kind))] { + // a[href=u_node_slug(&node.slug)] { + // img[src=u_node_image(&node.slug, PictureSlot::Cover, 512), loading="lazy"]; + // } + // .cardhover.item { + // @if node.media.is_some() { + // a.play.icon[href=u_node_slug_player(&node.slug)] { "play_arrow" } + // } + // } + // } + // div.details { + // a.title[href=u_node_slug(&node.slug)] { @node.title } + // @Props { node, udata, full: false, lang } + // span.overview { @node.description } + // } + } + } +} diff --git a/ui/src/old/search.rs b/ui/src/old/search.rs new file mode 100644 index 0000000..0eb34b9 --- /dev/null +++ b/ui/src/old/search.rs @@ -0,0 +1,38 @@ +/* + 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 +*/ + +use crate::{Page, locale::tr, node_card::NodeCard, scaffold::RenderInfo}; +use markup::DynRender; + +impl Page for SearchPage<'_> { + fn title(&self) -> String { + tr(self.ri.lang, "search.title").to_string() + } + fn class(&self) -> Option<&'static str> { + Some("search") + } + fn to_render(&self) -> DynRender<'_> { + markup::new!(@self) + } +} + +markup::define! { + SearchPage<'a>(ri: &'a RenderInfo<'a>, r: Option, query: &'a Option) { + h1 { @tr(ri.lang, "search.title") } + form[action="", method="GET"] { + input[type="text", name="query", placeholder=tr(ri.lang, "search.placeholder"), value=&query]; + input[type="submit", value="Search"]; + } + @if let Some(r) = &r { + h2 { @tr(ri.lang, "search.results.title") } + p.stats { @tr(ri.lang, "search.results.stats").replace("{count}", &r.count.to_string()).replace("{dur}", &format!("{:?}", r.duration)) } + ul.children {@for nodeu in r.results.iter() { + li { @NodeCard { ri, nodeu } } + }} + // TODO pagination + } + } +} diff --git a/ui/src/props.rs b/ui/src/props.rs deleted file mode 100644 index fe7f419..0000000 --- a/ui/src/props.rs +++ /dev/null @@ -1,63 +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 -*/ - -use crate::{ - format::{format_count, format_duration}, - locale::tr, - node_page::NodeUdata, - scaffold::RenderInfo, -}; -use chrono::DateTime; -use jellycommon::{jellyobject::TypedTag, *}; -use std::marker::PhantomData; - -markup::define! { - Props<'a>(ri: &'a RenderInfo<'a>, nodeu: NodeUdata<'a>, full: bool) { - .props { - @if let Some(dur) = nodeu.node.get(NO_DURATION) { - p { @format_duration(dur) } - } - // @if let Some(res) = nodeu.node.get(NO_TRACK) { - // p { @m.resolution_name() } - // } - @if let Some(d) = nodeu.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 => {} - VISI_REDUCED => {p.visibility{@tr(ri.lang, "prop.vis.reduced")}} - VISI_HIDDEN => {p.visibility{@tr(ri.lang, "prop.vis.hidden")}} - } - // 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::() { - @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" }} - RTYP_YOUTUBE_FOLLOWERS => {p{ @format_count(value as usize) " Subscribers" }} - RTYP_ROTTEN_TOMATOES => {p.rating{ @value " Tomatoes" }} - RTYP_METACRITIC if *full => {p{ "Metacritic Score: " @value }} - RTYP_IMDB => {p.rating{ "IMDb " @value }} - RTYP_TMDB => {p.rating{ "TMDB " @format!("{:.01}", value) }} - RTYP_TRAKT => {p.rating{ "Trakt " @format!("{:.01}", value) }} - _ => {} - } - } - // @match nodeu.udata.watched { - // WatchedState::None => {} - // WatchedState::Pending => { p.pending { @tr(ri.lang, "prop.watched.pending") } } - // WatchedState::Progress(x) => { p.progress { @tr(ri.lang, "prop.watched.progress").replace("{time}", &format_duration(x)) } } - // WatchedState::Watched => { p.watched { @tr(ri.lang, "prop.watched.watched") } } - // } - } - } -} 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 = LazyLock::new(|| CONF.asset_path.join("logo.svg").exists()); - -pub struct RenderInfo<'a> { - pub user: Option>, - 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/search.rs b/ui/src/search.rs deleted file mode 100644 index 0eb34b9..0000000 --- a/ui/src/search.rs +++ /dev/null @@ -1,38 +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 -*/ - -use crate::{Page, locale::tr, node_card::NodeCard, scaffold::RenderInfo}; -use markup::DynRender; - -impl Page for SearchPage<'_> { - fn title(&self) -> String { - tr(self.ri.lang, "search.title").to_string() - } - fn class(&self) -> Option<&'static str> { - Some("search") - } - fn to_render(&self) -> DynRender<'_> { - markup::new!(@self) - } -} - -markup::define! { - SearchPage<'a>(ri: &'a RenderInfo<'a>, r: Option, query: &'a Option) { - h1 { @tr(ri.lang, "search.title") } - form[action="", method="GET"] { - input[type="text", name="query", placeholder=tr(ri.lang, "search.placeholder"), value=&query]; - input[type="submit", value="Search"]; - } - @if let Some(r) = &r { - h2 { @tr(ri.lang, "search.results.title") } - p.stats { @tr(ri.lang, "search.results.stats").replace("{count}", &r.count.to_string()).replace("{dur}", &format!("{:?}", r.duration)) } - ul.children {@for nodeu in r.results.iter() { - li { @NodeCard { ri, nodeu } } - }} - // TODO pagination - } - } -} 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 -*/ - -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!("{}", r.total.count)) - )} - p { @raw(tr(ri.lang, "stats.runtime") - .replace("{dur}", &format!("{}", format_duration_long(r.total.runtime, ri.lang))) - .replace("{size}", &format!("{}", format_size(r.total.size))) - )} - p { @raw(tr(ri.lang, "stats.average") - .replace("{dur}", &format!("{}", format_duration(r.total.average_runtime()))) - .replace("{size}", &format!("{}", 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 - } -} -- cgit v1.3