diff options
| author | metamuffin <metamuffin@disroot.org> | 2026-02-27 14:40:15 +0100 |
|---|---|---|
| committer | metamuffin <metamuffin@disroot.org> | 2026-02-27 14:40:15 +0100 |
| commit | c05bfcc2775f0e11db6e856bfcf06d0419c35d54 (patch) | |
| tree | ffd0e9fcf6b476a6198287085a514cfa7940c200 | |
| parent | 4ba86694e393c61107e27c4127efc0455b329524 (diff) | |
| download | jellything-c05bfcc2775f0e11db6e856bfcf06d0419c35d54.tar jellything-c05bfcc2775f0e11db6e856bfcf06d0419c35d54.tar.bz2 jellything-c05bfcc2775f0e11db6e856bfcf06d0419c35d54.tar.zst | |
ui changed before object slices
33 files changed, 422 insertions, 644 deletions
diff --git a/common/src/api.rs b/common/src/api.rs index 89cfe16..d9f27c5 100644 --- a/common/src/api.rs +++ b/common/src/api.rs @@ -4,116 +4,27 @@ Copyright (C) 2026 metamuffin <metamuffin.org> */ -use jellyobject::{Object, Tag, enums, fields}; +use jellyobject::{Object, Tag}; +use std::collections::BTreeMap; -fields! { - QUERY_PARENT: u64 = b"prnt"; - QUERY_SEARCH: &str = b"sear"; - QUERY_KIND: Tag = b"kind"; // multi - QUERY_SORT: Tag = b"sort"; // one of RTYP_*, NU_RATING, NO_DURATION, NO_NAME - QUERY_SORT_ASCENDING: () = b"sasc"; - - VIEW_TITLE: &str = b"titl"; - VIEW_MESSAGE: Object = b"mesg"; - VIEW_NODE_PAGE: Object = b"npag"; - VIEW_NODE_LIST: Object = b"nlis"; // multi - VIEW_PLAYER: Object = b"play"; - VIEW_STATGROUP: Object = b"stag"; - VIEW_STATTEXT: Object = b"stat"; - VIEW_ACCOUNT_LOGIN: () = b"acli"; - VIEW_ACCOUNT_LOGOUT: () = b"aclo"; - VIEW_ACCOUNT_SET_PASSWORD: Object = b"acsp"; - VIEW_ADMIN_DASHBOARD: () = b"adda"; - VIEW_ADMIN_IMPORT: Object = b"adim"; - VIEW_ADMIN_INFO: Object = b"adin"; - VIEW_ADMIN_LOG: Object = b"adlo"; - VIEW_ADMIN_USER_LIST: Object = b"adul"; - VIEW_ADMIN_USER: Object = b"adus"; - VIEW_USER_SETTINGS: Object = b"uset"; - - ADMIN_IMPORT_BUSY: () = b"busy"; - ADMIN_IMPORT_ERROR: &str = b"erro"; // multi - ADMIN_INFO_TITLE: &str = b"aiti"; - ADMIN_INFO_TEXT: &str = b"aite"; - ADMIN_LOG_MESSAGE: &str = b"aite"; - - SETPW_USERNAME: &str = b"spwu"; - SETPW_PASSWORD: &str = b"spwp"; - - ADMIN_USER_LIST_ITEM: Object = b"item"; - - NKU_NODE: Object = b"node"; - NKU_UDATA: Object = b"udat"; - NKU_ROLE: &str = b"role"; - - NODELIST_TITLE: &str = b"titl"; - NODELIST_DISPLAYSTYLE: Tag = b"dsty"; - NODELIST_ITEM: Object = b"item"; // multi - NODELIST_CONTINUATION: &str = b"cont"; - - MESSAGE_KIND: &str = b"kind"; - MESSAGE_TEXT: &str = b"text"; - - STATGROUP_TITLE: &str = b"titl"; - STATGROUP_BIN: Object = b"bin1"; - - STAT_NAME: &str = b"name"; - STAT_COUNT: u64 = b"cont"; - STAT_TOTAL_SIZE: u64 = b"tlsz"; - STAT_TOTAL_DURATION: f64 = b"tldu"; - STAT_MAX_SIZE: u64 = b"mxsz"; - STAT_MAX_DURATION: f64 = b"mxdu"; +#[derive(Debug, Clone, Copy)] +pub struct Nku<'a> { + pub node: Object<'a>, + pub userdata: Object<'a>, + pub role: Option<&'a str>, } -enums! { - NLSTYLE_GRID = b"grid"; - NLSTYLE_INLINE = b"inli"; - NLSTYLE_LIST = b"list"; - NLSTYLE_HIGHLIGHT = b"hglt"; +pub struct Stats { + pub all: StatsBin, + pub by_kind: BTreeMap<Tag, StatsBin>, } -// use crate::user::{NodeUserData, User}; -// use std::{collections::BTreeMap, sync::Arc, time::Duration}; -// pub struct ApiNodeResponse { -// pub parents: NodesWithUdata, -// pub children: NodesWithUdata, -// pub node: Arc<Node>, -// pub userdata: NodeUserData, -// } - -// pub struct ApiSearchResponse { -// pub count: usize, -// pub results: NodesWithUdata, -// pub duration: Duration, -// } - -// pub struct ApiItemsResponse { -// pub count: usize, -// pub pages: usize, -// pub items: NodesWithUdata, -// } - -// pub struct ApiHomeResponse { -// pub toplevel: NodesWithUdata, -// pub categories: Vec<(String, NodesWithUdata)>, -// } - -// pub struct ApiStatsResponse { -// pub kinds: BTreeMap<NodeKind, StatsBin>, -// pub total: StatsBin, -// } - -// pub struct ApiAdminUsersResponse { -// pub users: Vec<User>, -// } - -// #[derive(Default, Serialize, Deserialize)] -// pub struct StatsBin { -// pub runtime: f64, -// pub size: u64, -// pub count: usize, -// pub max_runtime: f64, -// pub max_runtime_node: String, -// pub max_size: u64, -// pub max_size_node: String, -// } +pub struct StatsBin { + pub count: usize, + pub sum_duration: f64, + pub max_duration: f64, + pub max_duration_node: String, + pub sum_size: u64, + pub max_size: u64, + pub max_size_node: String, +} diff --git a/common/src/user.rs b/common/src/user.rs index 636046f..6511b10 100644 --- a/common/src/user.rs +++ b/common/src/user.rs @@ -4,7 +4,9 @@ Copyright (C) 2026 metamuffin <metamuffin.org> */ -use jellyobject::{Tag, enums, fields}; +use jellyobject::{Object, Tag, enums, fields}; + +pub type User<'a> = Object<'a>; fields! { USER_LOGIN: &str = b"Ulgn"; diff --git a/database/src/helper.rs b/database/src/helper.rs new file mode 100644 index 0000000..28ab1fa --- /dev/null +++ b/database/src/helper.rs @@ -0,0 +1,23 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2026 metamuffin <metamuffin.org> +*/ + +use crate::{Database, Transaction}; +use anyhow::Result; + +pub trait DatabaseReturnExt: Database { + fn transaction_ret<T>( + &self, + mut t: impl FnMut(&mut dyn Transaction) -> Result<T>, + ) -> Result<T> { + let mut ret = None; + self.transaction(&mut |x| { + ret = Some(t(x)?); + Ok(()) + })?; + Ok(ret.unwrap()) + } +} +impl<D: Database> DatabaseReturnExt for D {} diff --git a/database/src/lib.rs b/database/src/lib.rs index 13e4a0d..b8a67c9 100644 --- a/database/src/lib.rs +++ b/database/src/lib.rs @@ -8,6 +8,7 @@ pub mod kv; pub mod query_syntax; #[cfg(test)] pub mod test_shared; +pub mod helper; use anyhow::Result; use jellyobject::{ObjectBuffer, Path, Value}; diff --git a/server/src/main.rs b/server/src/main.rs index f3eabcf..8209879 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -32,7 +32,6 @@ pub mod request_info; pub mod responders; pub mod routes; pub mod ui; -pub mod ui_responder; #[rocket::main] async fn main() { @@ -84,7 +83,6 @@ pub fn create_state() -> Result<Arc<State>> { let cache_storage = jellykv::rocksdb::new(&config.cache_path)?; let db_storage = jellykv::rocksdb::new(&config.database_path)?; - // let db_storage = jellykv::memory::new(); let state = Arc::new(State { cache: Cache::new(Box::new(cache_storage), config.max_memory_cache_size).into(), diff --git a/server/src/request_info.rs b/server/src/request_info.rs index 4a0a781..442c7fb 100644 --- a/server/src/request_info.rs +++ b/server/src/request_info.rs @@ -14,11 +14,12 @@ use jellycommon::{ USER_ADMIN, jellyobject::{Object, ObjectBuffer}, }; -use jellyui::RenderInfo; +use jellyui::{Page, RenderInfo, Scaffold}; use rocket::{ Request, async_trait, http::{MediaType, Status}, request::{FlashMessage, FromRequest, Outcome}, + response::content::RawHtml, }; use std::sync::Arc; @@ -77,8 +78,12 @@ impl<'a> RequestInfo<'a> { status_message: None, user: self.user.as_ref().map(|u| u.as_object()), config: &self.state.config.ui, + message: self.flash.as_ref().map(|f| (f.kind(), f.message())), } } + pub fn respond_ui(&self, page: &dyn Page) -> RawHtml<String> { + RawHtml(Scaffold { page }.to_string()) + } } #[derive(Debug, Default)] diff --git a/server/src/ui/account/mod.rs b/server/src/ui/account/mod.rs index ab5093d..b106fc7 100644 --- a/server/src/ui/account/mod.rs +++ b/server/src/ui/account/mod.rs @@ -12,33 +12,37 @@ use crate::{ auth::{hash_password, login}, request_info::RequestInfo, ui::error::MyResult, - ui_responder::UiResponse, }; use anyhow::anyhow; use jellycommon::{ - jellyobject::{OBB, Path}, + jellyobject::Path, routes::{u_account_login, u_home}, *, }; use jellydb::{Filter, Query}; +use jellyui::components::login::{AccountLogin, AccountLogout, AccountSetPassword}; use rocket::{ Either, FromForm, form::{Contextual, Form}, get, http::{Cookie, CookieJar}, post, - response::{Flash, Redirect}, + response::{Flash, Redirect, content::RawHtml}, }; use serde::{Deserialize, Serialize}; #[get("/account/login")] -pub async fn r_account_login(ri: RequestInfo<'_>) -> UiResponse { - ri.respond_ui(OBB::new().with(VIEW_ACCOUNT_LOGIN, ())) +pub async fn r_account_login(ri: RequestInfo<'_>) -> RawHtml<String> { + ri.respond_ui(&AccountLogin { + ri: &ri.render_info(), + }) } #[get("/account/logout")] -pub fn r_account_logout(ri: RequestInfo<'_>) -> UiResponse { - ri.respond_ui(OBB::new().with(VIEW_ACCOUNT_LOGOUT, ())) +pub fn r_account_logout(ri: RequestInfo<'_>) -> RawHtml<String> { + ri.respond_ui(&AccountLogout { + ri: &ri.render_info(), + }) } #[derive(FromForm, Serialize, Deserialize)] @@ -60,7 +64,7 @@ pub fn r_account_login_post( ri: RequestInfo<'_>, jar: &CookieJar, form: Form<Contextual<LoginForm>>, -) -> MyResult<Either<Redirect, UiResponse>> { +) -> MyResult<Either<Redirect, RawHtml<String>>> { let form = match &form.value { Some(v) => v, None => return Err(MyError(anyhow!(format_form_error(form)))), @@ -88,18 +92,11 @@ pub fn r_account_login_post( Ok(()) })?; } else { - return Ok(Either::Right( - ri.respond_ui( - OBB::new().with( - VIEW_ACCOUNT_SET_PASSWORD, - OBB::new() - .with(SETPW_USERNAME, &form.username) - .with(SETPW_PASSWORD, &form.password) - .finish() - .as_object(), - ), - ), - )); + return Ok(Either::Right(ri.respond_ui(&AccountSetPassword { + ri: &ri.render_info(), + password: &form.password, + username: &form.username, + }))); } } diff --git a/server/src/ui/account/settings.rs b/server/src/ui/account/settings.rs index c1068f6..9a98c09 100644 --- a/server/src/ui/account/settings.rs +++ b/server/src/ui/account/settings.rs @@ -4,22 +4,20 @@ Copyright (C) 2026 metamuffin <metamuffin.org> */ use super::format_form_error; -use crate::{ - auth::hash_password, request_info::RequestInfo, ui::error::MyResult, ui_responder::UiResponse, -}; +use crate::{auth::hash_password, request_info::RequestInfo, ui::error::MyResult}; use anyhow::anyhow; use jellycommon::{ - jellyobject::{OBB, Object, ObjectBuffer, Path, Tag}, + jellyobject::{Object, ObjectBuffer, Path, Tag}, routes::u_account_settings, *, }; use jellydb::{Filter, Query}; -use jellyui::tr; +use jellyui::{components::user::UserSettings, tr}; use rocket::{ FromForm, form::{self, Contextual, Form, validate::len}, get, post, - response::{Flash, Redirect}, + response::{Flash, Redirect, content::RawHtml}, }; use std::ops::Range; @@ -38,9 +36,12 @@ fn option_len<'v>(value: &Option<String>, range: Range<usize>) -> form::Result<' } #[get("/account/settings")] -pub fn r_account_settings(ri: RequestInfo) -> MyResult<UiResponse> { +pub fn r_account_settings(ri: RequestInfo) -> MyResult<RawHtml<String>> { let user = ri.require_user()?; - Ok(ri.respond_ui(OBB::new().with(VIEW_USER_SETTINGS, user))) + Ok(ri.respond_ui(&UserSettings { + ri: &ri.render_info(), + user, + })) } #[post("/account/settings", data = "<form>")] diff --git a/server/src/ui/admin/import.rs b/server/src/ui/admin/import.rs index 78db4a4..e199de4 100644 --- a/server/src/ui/admin/import.rs +++ b/server/src/ui/admin/import.rs @@ -4,27 +4,23 @@ Copyright (C) 2026 metamuffin <metamuffin.org> */ -use crate::{request_info::RequestInfo, ui::error::MyResult, ui_responder::UiResponse}; -use jellycommon::{ - jellyobject::{OBB, ObjectBuffer}, - routes::u_admin_import, - *, -}; +use crate::{request_info::RequestInfo, ui::error::MyResult}; +use jellycommon::routes::u_admin_import; use jellyimport::{ ImportConfig, import_wrap, is_importing, reporting::{IMPORT_ERRORS, IMPORT_PROGRESS}, }; -use jellyui::tr; +use jellyui::{components::admin::AdminImport, tr}; use rocket::{ get, post, - response::{Flash, Redirect}, + response::{Flash, Redirect, content::RawHtml}, }; use rocket_ws::{Message, Stream, WebSocket}; use std::time::Duration; use tokio::{spawn, time::sleep}; #[get("/admin/import", rank = 2)] -pub async fn r_admin_import(ri: RequestInfo<'_>) -> MyResult<UiResponse> { +pub async fn r_admin_import(ri: RequestInfo<'_>) -> MyResult<RawHtml<String>> { ri.require_admin()?; let last_import_err = IMPORT_ERRORS.read().await.clone(); @@ -33,15 +29,11 @@ pub async fn r_admin_import(ri: RequestInfo<'_>) -> MyResult<UiResponse> { .map(|e| e.as_str()) .collect::<Vec<_>>(); - let mut data = ObjectBuffer::empty(); - if is_importing() { - data = data.as_object().insert(ADMIN_IMPORT_BUSY, ()); - } - data = data - .as_object() - .insert_multi(ADMIN_IMPORT_ERROR, &last_import_err); - - Ok(ri.respond_ui(OBB::new().with(VIEW_ADMIN_IMPORT, data.as_object()))) + Ok(ri.respond_ui(&AdminImport { + busy: is_importing(), + errors: &last_import_err, + ri: &ri.render_info(), + })) } #[post("/admin/import?<incremental>")] diff --git a/server/src/ui/admin/log.rs b/server/src/ui/admin/log.rs index 0965a25..168ec6a 100644 --- a/server/src/ui/admin/log.rs +++ b/server/src/ui/admin/log.rs @@ -8,7 +8,7 @@ use crate::{ request_info::RequestInfo, ui::error::MyResult, }; -use jellyui::{Scaffold, ServerLogPage, render_log_line}; +use jellyui::components::admin_log::{ServerLogPage, render_log_line}; use rocket::{get, response::content::RawHtml}; use rocket_ws::{Message, Stream, WebSocket}; use serde_json::json; @@ -21,18 +21,11 @@ pub fn r_admin_log(ri: RequestInfo, warnonly: bool) -> MyResult<RawHtml<String>> .map(|l| render_log_line(&l)) .collect::<Vec<_>>(); - Ok(RawHtml( - Scaffold { - class: "theme-purple", - main: ServerLogPage { - messages: &messages, - warnonly, - }, - ri: &ri.render_info(), - title: "Admin Log", - } - .to_string(), - )) + Ok(ri.respond_ui(&ServerLogPage { + ri: &ri.render_info(), + messages: &messages, + warnonly, + })) } #[get("/admin/log?stream&<warnonly>&<html>", rank = 1)] diff --git a/server/src/ui/admin/mod.rs b/server/src/ui/admin/mod.rs index 10037b5..6119b74 100644 --- a/server/src/ui/admin/mod.rs +++ b/server/src/ui/admin/mod.rs @@ -9,34 +9,21 @@ pub mod log; pub mod users; use super::error::MyResult; -use crate::{request_info::RequestInfo, ui_responder::UiResponse}; -use jellycommon::{ - jellyobject::{OBB, ObjectBuffer}, - *, -}; -use jellyui::tr; -use rocket::get; +use crate::request_info::RequestInfo; +use jellyui::components::admin::AdminDashboard; +use rocket::{get, response::content::RawHtml}; #[get("/admin/dashboard")] -pub async fn r_admin_dashboard(ri: RequestInfo<'_>) -> MyResult<UiResponse> { +pub async fn r_admin_dashboard(ri: RequestInfo<'_>) -> MyResult<RawHtml<String>> { ri.require_admin()?; - let mut db_debug = String::new(); - ri.state.database.transaction(&mut |txn| { - db_debug = txn.debug_info()?; - Ok(()) - })?; + // let mut db_debug = String::new(); + // ri.state.database.transaction(&mut |txn| { + // db_debug = txn.debug_info()?; + // Ok(()) + // })?; - let mut page = OBB::new(); - page.push(VIEW_TITLE, &*tr(ri.lang, "admin.dashboard.title")); - page.push(VIEW_ADMIN_DASHBOARD, ()); - page.push( - VIEW_ADMIN_INFO, - ObjectBuffer::new(&mut [ - (ADMIN_INFO_TITLE.0, &"Database Debug"), - (ADMIN_INFO_TEXT.0, &db_debug.as_str()), - ]) - .as_object(), - ); - Ok(ri.respond_ui(page)) + Ok(ri.respond_ui(&AdminDashboard { + ri: &ri.render_info(), + })) } diff --git a/server/src/ui/admin/users.rs b/server/src/ui/admin/users.rs index 172facc..85f241b 100644 --- a/server/src/ui/admin/users.rs +++ b/server/src/ui/admin/users.rs @@ -6,28 +6,29 @@ use std::str::FromStr; +use crate::{auth::hash_password, request_info::RequestInfo, ui::error::MyResult}; +use anyhow::anyhow; use base64::{Engine, prelude::BASE64_URL_SAFE}; use jellycommon::{ - jellyobject::{OBB, ObjectBufferBuilder, Path}, + jellyobject::{ObjectBufferBuilder, Path}, routes::u_admin_users, *, }; use jellydb::{Filter, Query}; -use jellyui::tr; +use jellyui::{ + components::admin::{AdminUser, AdminUserList}, + tr, +}; use rand::random; use rocket::{ FromForm, form::Form, get, post, - response::{Flash, Redirect}, -}; - -use crate::{ - auth::hash_password, request_info::RequestInfo, ui::error::MyResult, ui_responder::UiResponse, + response::{Flash, Redirect, content::RawHtml}, }; #[get("/admin/users")] -pub fn r_admin_users(ri: RequestInfo) -> MyResult<UiResponse> { +pub fn r_admin_users(ri: RequestInfo) -> MyResult<RawHtml<String>> { ri.require_admin()?; let mut users = Vec::new(); @@ -43,15 +44,10 @@ pub fn r_admin_users(ri: RequestInfo) -> MyResult<UiResponse> { Ok(()) })?; - let mut list = ObjectBufferBuilder::default(); - for u in users { - list.push(ADMIN_USER_LIST_ITEM, u.as_object()); - } - - let mut page = ObjectBufferBuilder::default(); - page.push(VIEW_TITLE, &*tr(ri.lang, "admin.users")); - page.push(VIEW_ADMIN_USER_LIST, list.finish().as_object()); - Ok(ri.respond_ui(page)) + Ok(ri.respond_ui(&AdminUserList { + ri: &ri.render_info(), + users: &users.iter().map(|u| u.as_object()).collect::<Vec<_>>(), + })) } #[derive(FromForm)] @@ -83,22 +79,26 @@ pub fn r_admin_new_user(ri: RequestInfo, form: Form<NewUser>) -> MyResult<Flash< } #[get("/admin/user/<name>")] -pub fn r_admin_user(ri: RequestInfo<'_>, name: &str) -> MyResult<UiResponse> { +pub fn r_admin_user(ri: RequestInfo<'_>, name: &str) -> MyResult<RawHtml<String>> { ri.require_admin()?; - let mut page = OBB::new(); + let mut user = None; ri.state.database.transaction(&mut |txn| { if let Some(row) = txn.query_single(Query { filter: Filter::Match(Path(vec![USER_LOGIN.0]), name.into()), ..Default::default() })? { - let user = txn.get(row)?.unwrap(); - page = OBB::new(); - page.push(VIEW_ADMIN_USER, user.as_object()); + user = Some(txn.get(row)?.unwrap()); } Ok(()) })?; + let Some(user) = user else { + Err(anyhow!("no such user"))? + }; - Ok(ri.respond_ui(page)) + Ok(ri.respond_ui(&AdminUser { + ri: &ri.render_info(), + user: user.as_object(), + })) } #[post("/admin/user/<name>/remove")] diff --git a/server/src/ui/home.rs b/server/src/ui/home.rs index 1a7da36..6cb6a77 100644 --- a/server/src/ui/home.rs +++ b/server/src/ui/home.rs @@ -4,77 +4,50 @@ Copyright (C) 2026 metamuffin <metamuffin.org> */ -use std::str::FromStr; - use super::error::MyResult; -use crate::{request_info::RequestInfo, ui_responder::UiResponse}; +use crate::request_info::RequestInfo; use anyhow::{Context, Result}; -use jellycommon::{ - jellyobject::{Object, ObjectBuffer, ObjectBufferBuilder}, - *, -}; +use jellycommon::jellyobject::{Object, ObjectBuffer}; use jellydb::Query; -use jellyui::tr; -use rocket::get; +use jellyui::components::home::HomeRow; +use rocket::{get, response::content::RawHtml}; +use std::str::FromStr; #[get("/home")] -pub fn r_home(ri: RequestInfo<'_>) -> MyResult<UiResponse> { +pub fn r_home(ri: RequestInfo<'_>) -> MyResult<RawHtml<String>> { ri.require_user()?; - let mut page = ObjectBufferBuilder::default(); - - page.push(VIEW_TITLE, &&*tr(ri.lang, "home")); - - page.push( - VIEW_NODE_LIST, - home_row( - &ri, - "home.bin.latest_video", - "FILTER (visi = visi AND kind = vide) SORT DESCENDING BY FIRST rldt", - )? - .as_object(), - ); - page.push( - VIEW_NODE_LIST, - home_row( - &ri, - "home.bin.latest_music", - "FILTER (visi = visi AND kind = musi) SORT DESCENDING BY FIRST rldt", - )? - .as_object(), - ); - page.push( - VIEW_NODE_LIST, - home_row_highlight( - &ri, - "home.bin.daily_random", - "FILTER (visi = visi AND kind = movi) SORT RANDOM", - )? - .as_object(), - ); - page.push( - VIEW_NODE_LIST, - home_row( - &ri, - "home.bin.max_rating", - "SORT DESCENDING BY FIRST rtng.imdb", - )? - .as_object(), - ); - page.push( - VIEW_NODE_LIST, - home_row_highlight( - &ri, - "home.bin.daily_random", - "FILTER (visi = visi AND kind = show) SORT RANDOM", - )? - .as_object(), - ); + let mut rows = Vec::new(); + rows.push(home_row( + &ri, + "home.bin.latest_video", + "FILTER (visi = visi AND kind = vide) SORT DESCENDING BY FIRST rldt", + )?); + rows.push(home_row( + &ri, + "home.bin.latest_music", + "FILTER (visi = visi AND kind = musi) SORT DESCENDING BY FIRST rldt", + )?); + rows.push(home_row_highlight( + &ri, + "home.bin.daily_random", + "FILTER (visi = visi AND kind = movi) SORT RANDOM", + )?); + rows.push(home_row( + &ri, + "home.bin.max_rating", + "SORT DESCENDING BY FIRST rtng.imdb", + )?); + rows.push(home_row_highlight( + &ri, + "home.bin.daily_random", + "FILTER (visi = visi AND kind = show) SORT RANDOM", + )?); - Ok(ri.respond_ui(page)) + Ok(ri.respond_ui(rows)) } -fn home_row(ri: &RequestInfo<'_>, title: &str, query: &str) -> Result<ObjectBuffer> { +fn home_row(ri: &RequestInfo<'_>, title: &str, query: &str) -> Result<HomeRow> { let q = Query::from_str(query).context("parse query")?; let mut res = ObjectBuffer::empty(); ri.state.database.transaction(&mut |txn| { @@ -96,7 +69,7 @@ fn home_row(ri: &RequestInfo<'_>, title: &str, query: &str) -> Result<ObjectBuff Ok(res) } -fn home_row_highlight(ri: &RequestInfo<'_>, title: &str, query: &str) -> Result<ObjectBuffer> { +fn home_row_highlight(ri: &RequestInfo<'_>, title: &str, query: &str) -> Result<HomeRow> { let q = Query::from_str(query).context("parse query")?; let mut res = ObjectBuffer::empty(); ri.state.database.transaction(&mut |txn| { diff --git a/server/src/ui/items.rs b/server/src/ui/items.rs index 286fc01..b800914 100644 --- a/server/src/ui/items.rs +++ b/server/src/ui/items.rs @@ -4,49 +4,56 @@ Copyright (C) 2026 metamuffin <metamuffin.org> */ -use crate::{request_info::RequestInfo, ui::error::MyResult, ui_responder::UiResponse}; +use crate::{request_info::RequestInfo, ui::error::MyResult}; use anyhow::anyhow; use base64::{Engine, prelude::BASE64_URL_SAFE}; use jellycommon::{ - jellyobject::{OBB, Path}, + jellyobject::{Object, Path}, *, }; use jellydb::{Filter, Query}; -use rocket::get; +use jellyui::components::items::Items; +use rocket::{get, response::content::RawHtml}; #[get("/items?<cont>")] -pub fn r_items(ri: RequestInfo, cont: Option<&str>) -> MyResult<UiResponse> { - let cont = cont +pub fn r_items(ri: RequestInfo, cont: Option<&str>) -> MyResult<RawHtml<String>> { + let cont_in = cont .map(|s| BASE64_URL_SAFE.decode(s)) .transpose() .map_err(|_| anyhow!("invalid contination token"))?; - let mut page = OBB::new(); + let mut items = Vec::new(); + let mut cont_out = None; ri.state.database.transaction(&mut |txn| { let rows = txn .query(Query { filter: Filter::Match(Path(vec![NO_KIND.0]), KIND_CHANNEL.into()), - continuation: cont.clone(), + continuation: cont_in.clone(), ..Default::default() })? .take(64) .collect::<Result<Vec<_>, _>>()?; - let mut list = OBB::new().with(NODELIST_DISPLAYSTYLE, NLSTYLE_GRID); - - let mut iterstate = Vec::new(); + items.clear(); + cont_out = None; for (r, is) in rows { let node = txn.get(r)?.unwrap(); - let nku = OBB::new().with(NKU_NODE, node.as_object()).finish(); - list.push(NODELIST_ITEM, nku.as_object()); - iterstate = is; + items.push(node); + cont_out = Some(is) } - list.push(NODELIST_CONTINUATION, &BASE64_URL_SAFE.encode(iterstate)); - - page = OBB::new(); - page.push(VIEW_NODE_LIST, list.finish().as_object()); - Ok(()) })?; - Ok(ri.respond_ui(page)) + + Ok(ri.respond_ui(&Items { + ri: &ri.render_info(), + items: &items + .iter() + .map(|node| Nku { + node: node.as_object(), + userdata: Object::EMPTY, + role: None, + }) + .collect::<Vec<_>>(), + cont: cont_out.map(|x| BASE64_URL_SAFE.encode(x)), + })) } diff --git a/server/src/ui/player.rs b/server/src/ui/player.rs index f0d6dea..1050abb 100644 --- a/server/src/ui/player.rs +++ b/server/src/ui/player.rs @@ -4,13 +4,14 @@ Copyright (C) 2026 metamuffin <metamuffin.org> */ use super::error::MyResult; -use crate::{request_info::RequestInfo, ui_responder::UiResponse}; +use crate::request_info::RequestInfo; use jellycommon::{ - jellyobject::{OBB, Object, Path}, + jellyobject::{Object, ObjectBuffer, Path}, *, }; use jellydb::{Filter, Query}; -use rocket::get; +use jellyui::components::node_page::Player; +use rocket::{get, response::content::RawHtml}; // fn jellynative_url(action: &str, seek: f64, secret: &str, node: &str, session: &str) -> String { // let protocol = if CONF.tls { "https" } else { "http" }; @@ -26,88 +27,27 @@ use rocket::get; // } #[get("/n/<slug>/player?<t>", rank = 4)] -pub fn r_player(ri: RequestInfo<'_>, t: Option<f64>, slug: &str) -> MyResult<UiResponse> { +pub fn r_player(ri: RequestInfo<'_>, t: Option<f64>, slug: &str) -> MyResult<RawHtml<String>> { ri.require_user()?; let _ = t; - let mut page = OBB::new(); + let mut node = ObjectBuffer::empty(); ri.state.database.transaction(&mut |txn| { if let Some(row) = txn.query_single(Query { filter: Filter::Match(Path(vec![NO_SLUG.0]), slug.into()), ..Default::default() })? { - let n = txn.get(row)?.unwrap(); - let nku = Object::EMPTY.insert(NKU_NODE, n.as_object()); - - page = OBB::new(); - let title = nku - .as_object() - .get(NKU_NODE) - .unwrap_or_default() - .get(NO_TITLE) - .unwrap_or_default(); - page.push(VIEW_TITLE, title); - page.push(VIEW_PLAYER, nku.as_object()); + node = txn.get(row)?.unwrap(); } Ok(()) })?; - Ok(ri.respond_ui(page)) + Ok(ri.respond_ui(&Player { + ri: &ri.render_info(), + nku: Nku { + node: node.as_object(), + userdata: Object::EMPTY, + role: None, + }, + })) } - -// pub fn player_conf<'a>(item: Arc<Node>, playing: bool) -> anyhow::Result<DynRender<'a>> { -// let mut audio_tracks = vec![]; -// let mut video_tracks = vec![]; -// let mut sub_tracks = vec![]; -// let tracks = item -// .media -// .clone() -// .ok_or(anyhow!("node does not have media"))? -// .tracks -// .clone(); -// for (tid, track) in tracks.into_iter().enumerate() { -// match &track.kind { -// SourceTrackKind::Audio { .. } => audio_tracks.push((tid, track)), -// SourceTrackKind::Video { .. } => video_tracks.push((tid, track)), -// SourceTrackKind::Subtitles => sub_tracks.push((tid, track)), -// } -// } - -// Ok(markup::new! { -// form.playerconf[method = "GET", action = ""] { -// h2 { "Select tracks for " @item.title } - -// fieldset.video { -// legend { "Video" } -// @for (i, (tid, track)) in video_tracks.iter().enumerate() { -// input[type="radio", id=tid, name="v", value=tid, checked=i==0]; -// label[for=tid] { @format!("{track}") } br; -// } -// input[type="radio", id="v-none", name="v", value=""]; -// label[for="v-none"] { "No video" } -// } - -// fieldset.audio { -// legend { "Audio" } -// @for (i, (tid, track)) in audio_tracks.iter().enumerate() { -// input[type="radio", id=tid, name="a", value=tid, checked=i==0]; -// label[for=tid] { @format!("{track}") } br; -// } -// input[type="radio", id="a-none", name="a", value=""]; -// label[for="a-none"] { "No audio" } -// } - -// fieldset.subtitles { -// legend { "Subtitles" } -// @for (_i, (tid, track)) in sub_tracks.iter().enumerate() { -// input[type="radio", id=tid, name="s", value=tid]; -// label[for=tid] { @format!("{track}") } br; -// } -// input[type="radio", id="s-none", name="s", value="", checked=true]; -// label[for="s-none"] { "No subtitles" } -// } - -// input[type="submit", value=if playing { "Change tracks" } else { "Start playback" }]; -// } -// }) -// } diff --git a/server/src/ui_responder.rs b/server/src/ui_responder.rs deleted file mode 100644 index 2c4adea..0000000 --- a/server/src/ui_responder.rs +++ /dev/null @@ -1,56 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2026 metamuffin <metamuffin.org> -*/ - -use crate::request_info::RequestInfo; -use jellycommon::{ - jellyobject::{ObjectBuffer, ObjectBufferBuilder, json::object_to_json}, - *, -}; -use jellyui::render_view; -use rocket::response::{ - Responder, - content::{RawHtml, RawJson}, -}; - -pub enum UiResponse { - Html(String), - Json(String), - Object(ObjectBuffer), -} - -impl RequestInfo<'_> { - pub fn respond_ui(&self, mut view: ObjectBufferBuilder) -> UiResponse { - if let Some(flash) = &self.flash { - view.push( - VIEW_MESSAGE, - ObjectBuffer::new(&mut [ - (MESSAGE_KIND.0, &flash.kind()), - (MESSAGE_TEXT.0, &flash.message()), - ]) - .as_object(), - ); - } - let view = view.finish(); - if self.accept.is_json() || self.debug == "json" { - let value = object_to_json(view.as_object()); - UiResponse::Json(serde_json::to_string(&value).unwrap()) - } else if self.debug == "raw" { - UiResponse::Object(view) - } else { - UiResponse::Html(render_view(self.render_info(), view.as_object())) - } - } -} - -impl<'r, 'o: 'r> Responder<'r, 'o> for UiResponse { - fn respond_to(self, request: &'r rocket::Request<'_>) -> rocket::response::Result<'o> { - match self { - UiResponse::Html(x) => RawHtml(x).respond_to(request), - UiResponse::Json(x) => RawJson(x).respond_to(request), - UiResponse::Object(x) => x.to_bytes().respond_to(request), - } - } -} diff --git a/ui/client-scripts/src/main.ts b/ui/client-scripts/src/main.ts index 303ac71..ba60646 100644 --- a/ui/client-scripts/src/main.ts +++ b/ui/client-scripts/src/main.ts @@ -10,3 +10,4 @@ import "./backbutton.ts" import "./dangerbutton.ts" import "./log_live.ts" import "./import_live.ts" +import "./pagination.ts" diff --git a/ui/client-scripts/src/pagination.ts b/ui/client-scripts/src/pagination.ts new file mode 100644 index 0000000..380b20e --- /dev/null +++ b/ui/client-scripts/src/pagination.ts @@ -0,0 +1,18 @@ + +globalThis.addEventListener("DOMContentLoaded", () => { + const el = document.querySelector(".next_page") as HTMLElement + if (!el) return + const cont = document.body.parentElement! + console.log(cont); + + cont.addEventListener("scroll", () => { + const end = cont.scrollTop + cont.clientHeight + console.log(end, cont.scrollHeight); + + if (end + 1000 > el.scrollHeight) { + el.textContent = "Loading more..." + el.click() + } + + }) +}) diff --git a/ui/src/components/admin.rs b/ui/src/components/admin.rs index 3cb45d6..76dfe6a 100644 --- a/ui/src/components/admin.rs +++ b/ui/src/components/admin.rs @@ -4,9 +4,8 @@ Copyright (C) 2026 metamuffin <metamuffin.org> */ -use crate::RenderInfo; +use crate::{RenderInfo, page}; use jellycommon::{ - jellyobject::Object, routes::{ u_admin_import, u_admin_import_post, u_admin_log, u_admin_new_user, u_admin_user, u_admin_user_remove, u_admin_users, @@ -15,6 +14,16 @@ use jellycommon::{ }; use jellyui_locale::tr; +page!(AdminDashboard<'_>, |x| tr(x.ri.lang, "admin.dashboard")); +page!(AdminImport<'_>, |x| tr(x.ri.lang, "admin.import")); +page!(AdminUserList<'_>, |x| tr(x.ri.lang, "admin.users")); +page!(AdminUser<'_>, |x| x + .user + .get(USER_NAME) + .unwrap_or("nameless user") + .to_string() + .into()); + markup::define!( AdminDashboard<'a>(ri: &'a RenderInfo<'a>) { h1 { @tr(ri.lang, "admin.dashboard") } @@ -22,23 +31,22 @@ markup::define!( 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") }} a[href=u_admin_users()] { h2 { @tr(ri.lang, "admin.users") }} } - AdminImport<'a>(ri: &'a RenderInfo<'a>, data: Object<'a>) { - @if data.has(ADMIN_IMPORT_BUSY.0) { + AdminImport<'a>(ri: &'a RenderInfo<'a>, errors: &'a [&'a str], busy: bool) { + @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") } - @if data.has(ADMIN_IMPORT_ERROR.0) { + @if !errors.is_empty() { section.message.error { details { - summary { p.error { @tr(ri.lang, "admin.import_errors").replace("{n}", &data.iter(ADMIN_IMPORT_ERROR).count().to_string()) } } - ol { @for e in data.iter(ADMIN_IMPORT_ERROR) { + summary { p.error { @tr(ri.lang, "admin.import_errors").replace("{n}", &errors.len().to_string()) } } + ol { @for e in *errors { li.error { pre.error { @e } } }} } @@ -53,24 +61,19 @@ markup::define!( } } - AdminInfo<'a>(ri: &'a RenderInfo<'a>, data: Object<'a>) { - @let _ = ri; - h2 { @data.get(ADMIN_INFO_TITLE) } - pre { @data.get(ADMIN_INFO_TEXT) } - } - - AdminUserList<'a>(ri: &'a RenderInfo<'a>, data: Object<'a>) { + AdminUserList<'a>(ri: &'a RenderInfo<'a>, users: &'a [User<'a>]) { h1 { @tr(ri.lang, "admin.users") } form[method="POST", action=u_admin_new_user()] { label[for="login"] { "Login: " } input[type="text", id="login", name="login"]; br; input[type="submit", value="Create new user"]; } - ul { @for u in data.iter(ADMIN_USER_LIST_ITEM) { + ul { @for u in *users { li { a[href=u_admin_user(u.get(USER_LOGIN).unwrap_or_default())] { @u.get(USER_LOGIN) } } }} } - AdminUser<'a>(ri: &'a RenderInfo<'a>, user: Object<'a>) { + + AdminUser<'a>(ri: &'a RenderInfo<'a>, user: User<'a>) { h2 { @user.get(USER_NAME).unwrap_or("nameless user") } p { @tr(ri.lang, "tag.Ulgn") ": " @user.get(USER_LOGIN) } form[method="POST", action=u_admin_user_remove(user.get(USER_LOGIN).unwrap())] { diff --git a/ui/src/components/admin_log.rs b/ui/src/components/admin_log.rs index abec7fe..f521aae 100644 --- a/ui/src/components/admin_log.rs +++ b/ui/src/components/admin_log.rs @@ -4,6 +4,7 @@ Copyright (C) 2026 metamuffin <metamuffin.org> */ +use crate::{RenderInfo, page}; use jellycommon::{ internal::{LogLevel, LogLine}, routes::u_admin_log, @@ -11,8 +12,11 @@ use jellycommon::{ use markup::raw; use std::fmt::Write; +page!(ServerLogPage<'_>, |_| "Server Log".into()); + markup::define! { - ServerLogPage<'a>(warnonly: bool, messages: &'a [String]) { + ServerLogPage<'a>(ri: &'a RenderInfo<'a>, warnonly: bool, messages: &'a [String]) { + @let _ = ri; h1 { "Server Log" } a[href=u_admin_log(!warnonly)] { @if *warnonly { "Show everything" } else { "Show only warnings" }} code.log[id="log"] { diff --git a/ui/src/components/home.rs b/ui/src/components/home.rs new file mode 100644 index 0000000..5ae6a66 --- /dev/null +++ b/ui/src/components/home.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 <metamuffin.org> +*/ + +use crate::{ + RenderInfo, + components::node_card::{NodeCard, NodeCardHightlight}, + page, +}; +use jellycommon::Nku; +use jellyui_locale::tr; + +pub enum HomeRow { + Inline(Vec<Nku<'a>>), + Highlight(Nku<'a>), +} + +page!(Home<'_>, |x| tr(x.ri.lang, "home")); + +markup::define! { + Home<'a>(ri: &'a RenderInfo<'a>, rows: &'a [(&'a str, HomeRow<'a>)]) { + @for (title, row) in *rows { + h2 { @tr(ri.lang, title) } + @match row { + HomeRow::Inline(nkus) => { + ul.nl.inline { @for nku in nkus { + li { @NodeCard { ri, nku } } + }} + } + HomeRow::Highlight(nku) => { + @NodeCardHightlight { ri, nku } + } + } + } + } +} diff --git a/ui/src/components/items.rs b/ui/src/components/items.rs new file mode 100644 index 0000000..c9e0d41 --- /dev/null +++ b/ui/src/components/items.rs @@ -0,0 +1,22 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2026 metamuffin <metamuffin.org> +*/ + +use crate::{RenderInfo, components::node_card::NodeCard, page}; +use jellycommon::{Nku, routes::u_items_cont}; +use jellyui_locale::tr; + +page!(Items<'_>, |x| tr(x.ri.lang, "items")); + +markup::define! { + Items<'a>(ri: &'a RenderInfo<'a>, items: &'a [Nku<'a>], cont: Option<String>) { + ul.nl.grid { @for nku in *items { + li { @NodeCard { ri, nku } } + }} + @if let Some(cont) = cont { + a.next_page[href=u_items_cont(cont)] { button { "Show more" } } + } + } +} diff --git a/ui/src/components/login.rs b/ui/src/components/login.rs index d291165..7d19a38 100644 --- a/ui/src/components/login.rs +++ b/ui/src/components/login.rs @@ -4,19 +4,24 @@ Copyright (C) 2026 metamuffin <metamuffin.org> */ -use jellycommon::{SETPW_PASSWORD, SETPW_USERNAME, jellyobject::Object}; +use crate::{RenderInfo, page}; use jellyui_locale::tr; -use crate::RenderInfo; +page!(AccountLogin<'_>, |x| tr(x.ri.lang, "account.login")); +page!(AccountLogout<'_>, |x| tr(x.ri.lang, "account.logout")); +page!(AccountSetPassword<'_>, |x| tr( + x.ri.lang, + "account.login.set_password" +)); markup::define! { - AccountSetPassword<'a>(ri: &'a RenderInfo<'a>, data: Object<'a>) { + AccountSetPassword<'a>(ri: &'a RenderInfo<'a>, username: &'a str, password: &'a str) { form.account[method="POST", action=""] { h1 { @tr(ri.lang, "account.login.set_password") } p { @tr(ri.lang, "account.login.set_password.par") } - input[type="hidden", name="username", value=data.get(SETPW_USERNAME)]; - input[type="hidden", name="password", value=data.get(SETPW_PASSWORD)]; + input[type="hidden", name="username", value=username]; + input[type="hidden", name="password", value=password]; label[for="password"] { @tr(ri.lang, "account.new_password") } input[type="password", id="password", name="new_password"]; br; diff --git a/ui/src/components/message.rs b/ui/src/components/message.rs index a271d40..b4499ed 100644 --- a/ui/src/components/message.rs +++ b/ui/src/components/message.rs @@ -4,14 +4,12 @@ Copyright (C) 2026 metamuffin <metamuffin.org> */ use crate::RenderInfo; -use jellycommon::{MESSAGE_KIND, MESSAGE_TEXT, jellyobject::Object}; use markup::define; define! { - Message<'a>(ri: &'a RenderInfo<'a>, message: Object<'a>) { + Message<'a>(ri: &'a RenderInfo<'a>, kind: &'a str, text: &'a str) { @let _ = ri; - @let text = message.get(MESSAGE_TEXT).unwrap_or_default(); - @match message.get(MESSAGE_KIND).unwrap_or("neutral") { + @match *kind { "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 index d87efab..6c4cc9d 100644 --- a/ui/src/components/mod.rs +++ b/ui/src/components/mod.rs @@ -9,66 +9,9 @@ pub mod admin_log; pub mod login; pub mod message; pub mod node_card; -pub mod node_list; pub mod node_page; pub mod props; pub mod stats; pub mod user; - -use crate::{ - RenderInfo, - components::{ - admin::{AdminDashboard, AdminImport, AdminInfo, AdminUser, AdminUserList}, - login::{AccountLogin, AccountLogout, AccountSetPassword}, - message::Message, - node_list::NodeList, - node_page::{NodePage, Player}, - user::UserSettings, - }, -}; -use jellycommon::{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 } - } - @if let Some(nku) = view.get(VIEW_NODE_PAGE) { - @NodePage { ri, nku } - } - @if let Some(nku) = view.get(VIEW_PLAYER) { - @Player { ri, nku } - } - @for nl in view.iter(VIEW_NODE_LIST) { - @NodeList { ri, nl } - } - @if let Some(()) = view.get(VIEW_ACCOUNT_LOGIN) { - @AccountLogin { ri } - } - @if let Some(()) = view.get(VIEW_ACCOUNT_LOGOUT) { - @AccountLogout{ ri } - } - @if let Some(data) = view.get(VIEW_ACCOUNT_SET_PASSWORD) { - @AccountSetPassword { ri, data } - } - @if let Some(()) = view.get(VIEW_ADMIN_DASHBOARD) { - @AdminDashboard { ri } - } - @if let Some(data) = view.get(VIEW_ADMIN_IMPORT) { - @AdminImport { ri, data } - } - @if let Some(data) = view.get(VIEW_ADMIN_INFO) { - @AdminInfo { ri, data } - } - @if let Some(user) = view.get(VIEW_USER_SETTINGS) { - @UserSettings { ri, user } - } - @if let Some(data) = view.get(VIEW_ADMIN_USER_LIST) { - @AdminUserList { ri, data } - } - @if let Some(user) = view.get(VIEW_ADMIN_USER) { - @AdminUser { ri, user } - } - } -} +pub mod items; +pub mod home; diff --git a/ui/src/components/node_card.rs b/ui/src/components/node_card.rs index d93825b..e1baec1 100644 --- a/ui/src/components/node_card.rs +++ b/ui/src/components/node_card.rs @@ -15,8 +15,8 @@ use jellycommon::{ }; markup::define! { - NodeCard<'a>(ri: &'a RenderInfo<'a>, nku: Object<'a>) { - @let node = nku.get(NKU_NODE).unwrap_or_default(); + NodeCard<'a>(ri: &'a RenderInfo<'a>, nku: &'a Nku<'a>) { + @let node = nku.node; @let slug = node.get(NO_SLUG).unwrap_or_default(); div[class=&format!("card {}", aspect_class(node))] { .poster { @@ -27,7 +27,7 @@ markup::define! { @if node.has(NO_TRACK.0) { a.play.icon[href=u_node_slug_player(&slug)] { "play_arrow" } } - @Props { ri, nku: *nku, full: false } + @Props { ri, nku, full: false } } } div.title { @@ -37,14 +37,14 @@ markup::define! { } div.subtitle { span { - @nku.get(NKU_ROLE).or(node.get(NO_SUBTITLE)) + @nku.role.or(node.get(NO_SUBTITLE)) } } } } - NodeCardWide<'a>(ri: &'a RenderInfo<'a>, nku: Object<'a>) { - @let node = nku.get(NKU_NODE).unwrap_or_default(); + NodeCardWide<'a>(ri: &'a RenderInfo<'a>, nku: Nku<'a>) { + @let node = nku.node; @let slug = node.get(NO_SLUG).unwrap_or_default(); div[class="card wide"] { div[class=&format!("poster {}", aspect_class(node))] { @@ -59,14 +59,14 @@ markup::define! { } div.details { a.title[href=u_node_slug(&slug)] { @node.get(NO_TITLE) } - @Props { ri, nku: *nku ,full: false } + @Props { ri, nku ,full: false } span.overview { @node.get(NO_DESCRIPTION) } } } } - NodeCardHightlight<'a>(ri: &'a RenderInfo<'a>, nku: Object<'a>) { - @let node = nku.get(NKU_NODE).unwrap_or_default(); + NodeCardHightlight<'a>(ri: &'a RenderInfo<'a>, nku: &'a Nku<'a>) { + @let node = nku.node; @let slug = node.get(NO_SLUG).unwrap_or_default(); @let backdrop = u_image(node.get(NO_PICTURES).unwrap_or_default().get(PICT_BACKDROP).unwrap_or_default(), 2048); div[class="card highlight", style=format!("background-image: url(\"{backdrop}\")")] { diff --git a/ui/src/components/node_list.rs b/ui/src/components/node_list.rs deleted file mode 100644 index df405ea..0000000 --- a/ui/src/components/node_list.rs +++ /dev/null @@ -1,47 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2026 metamuffin <metamuffin.org> -*/ - -use crate::{ - RenderInfo, - components::node_card::{NodeCard, NodeCardHightlight, NodeCardWide}, -}; -use jellycommon::{jellyobject::Object, routes::u_items_cont, *}; -use jellyui_locale::tr; - -markup::define! { - NodeList<'a>(ri: &'a RenderInfo<'a>, nl: Object<'a>) { - @let ds = nl.get(NODELIST_DISPLAYSTYLE).unwrap_or(NLSTYLE_GRID); - @if let Some(title) = nl.get(NODELIST_TITLE) { - h2 { @tr(ri.lang, title) } - } - @match ds { - NLSTYLE_GRID => { - ul.nl.grid { @for nku in nl.iter(NODELIST_ITEM) { - li { @NodeCard { ri, nku } } - }} - } - NLSTYLE_INLINE => { - ul.nl.inline { @for nku in nl.iter(NODELIST_ITEM) { - li { @NodeCard { ri, nku } } - }} - } - NLSTYLE_LIST => { - ol.nl.list { @for nku in nl.iter(NODELIST_ITEM) { - li { @NodeCardWide { ri, nku } } - }} - } - NLSTYLE_HIGHLIGHT => { - @if let Some(nku) = nl.get(NODELIST_ITEM) { - @NodeCardHightlight { ri, nku } - } - } - _ => {} - } - @if let Some(cont) = nl.get(NODELIST_CONTINUATION) { - a[href=u_items_cont(cont)] { button { "Show more" } } - } - } -} diff --git a/ui/src/components/node_page.rs b/ui/src/components/node_page.rs index f40aa73..5cd71ce 100644 --- a/ui/src/components/node_page.rs +++ b/ui/src/components/node_page.rs @@ -4,7 +4,7 @@ Copyright (C) 2026 metamuffin <metamuffin.org> */ -use crate::{RenderInfo, components::props::Props}; +use crate::{RenderInfo, components::props::Props, page}; use jellycommon::{ jellyobject::{Object, Tag, TypedTag}, routes::{u_image, u_node_slug_player}, @@ -13,9 +13,24 @@ use jellycommon::{ use jellyui_locale::tr; use std::marker::PhantomData; +page!(NodePage<'_>, |x| x + .nku + .node + .get(NO_TITLE) + .unwrap_or_default() + .to_string() + .into()); +page!(Player<'_>, |x| x + .nku + .node + .get(NO_TITLE) + .unwrap_or_default() + .to_string() + .into()); + markup::define! { - NodePage<'a>(ri: &'a RenderInfo<'a>, nku: Object<'a>) { - @let node = nku.get(NKU_NODE).unwrap_or_default(); + NodePage<'a>(ri: &'a RenderInfo<'a>, nku: Nku<'a>) { + @let node = nku.node; @let slug = node.get(NO_SLUG).unwrap_or_default(); @let pics = node.get(NO_PICTURES).unwrap_or_default(); @if let Some(path) = pics.get(PICT_BACKDROP) { @@ -59,7 +74,7 @@ markup::define! { } } .details { - @Props { ri, nku: *nku, full: true } + @Props { ri, 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; } } @@ -141,13 +156,11 @@ markup::define! { // } } - Player<'a>(ri: &'a RenderInfo<'a>, nku: Object<'a>) { + Player<'a>(ri: &'a RenderInfo<'a>, nku: Nku<'a>) { @let _ = ri; - @let node = nku.get(NKU_NODE).unwrap_or_default(); - @let pics = node.get(NO_PICTURES).unwrap_or_default(); + @let pics = nku.node.get(NO_PICTURES).unwrap_or_default(); video[id="player", poster=pics.get(PICT_COVER).map(|p| u_image(p, 2048))] {} } - } // fn chapter_key_time(c: Object, dur: f64) -> f64 { diff --git a/ui/src/components/props.rs b/ui/src/components/props.rs index 5fa9d3e..e672919 100644 --- a/ui/src/components/props.rs +++ b/ui/src/components/props.rs @@ -9,16 +9,13 @@ use crate::{ format::{format_count, format_duration}, }; use chrono::DateTime; -use jellycommon::{ - jellyobject::{Object, TypedTag}, - *, -}; +use jellycommon::{jellyobject::TypedTag, *}; use jellyui_locale::tr; 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<'a>(ri: &'a RenderInfo<'a>, nku: &'a Nku<'a>, full: bool) { + @let node = nku.node; .props { @if let Some(dur) = node.get(NO_DURATION) { p { @format_duration(dur) } diff --git a/ui/src/components/stats.rs b/ui/src/components/stats.rs index 698430b..ce06843 100644 --- a/ui/src/components/stats.rs +++ b/ui/src/components/stats.rs @@ -8,27 +8,25 @@ use crate::{ RenderInfo, format::{format_duration, format_duration_long, format_size}, }; -use jellycommon::{jellyobject::Object, *}; +use jellycommon::*; use jellyui_locale::tr; use markup::raw; markup::define! { - StatText<'a>(ri: &'a RenderInfo<'a>, stat: Object<'a>) { + StatText<'a>(ri: &'a RenderInfo<'a>, stats: &'a Stats) { h1 { @tr(ri.lang, "stats.title") } p { @raw(tr(ri.lang, "stats.count") - .replace("{count}", &format!("<b>{}</b>", stat.get(STAT_COUNT).unwrap_or_default())) + .replace("{count}", &format!("<b>{}</b>", stats.all.count)) )} p { @raw(tr(ri.lang, "stats.runtime") - .replace("{dur}", &format!("<b>{}</b>", format_duration_long(ri.lang, stat.get(STAT_TOTAL_DURATION).unwrap_or_default()))) - .replace("{size}", &format!("<b>{}</b>", format_size(stat.get(STAT_TOTAL_SIZE).unwrap_or_default()))) + .replace("{dur}", &format!("<b>{}</b>", format_duration_long(ri.lang, stats.all.sum_duration))) + .replace("{size}", &format!("<b>{}</b>", format_size(stats.all.sum_size))) )} p { @raw(tr(ri.lang, "stats.average") - .replace("{dur}", &format!("<b>{}</b>", format_duration(stat.get(STAT_TOTAL_DURATION).unwrap_or_default() / stat.get(STAT_COUNT).unwrap_or_default() as f64))) - .replace("{size}", &format!("<b>{}</b>", format_size(stat.get(STAT_TOTAL_SIZE).unwrap_or_default() / stat.get(STAT_COUNT).unwrap_or_default()))) + .replace("{dur}", &format!("<b>{}</b>", format_duration(stats.all.sum_duration / stats.all.count as f64))) + .replace("{size}", &format!("<b>{}</b>", format_size(stats.all.sum_size / stats.all.count as u64))) )} - } - StatGroup<'a>(ri: &'a RenderInfo<'a>, statgroup: Object<'a>) { - h2 { @tr(ri.lang, statgroup.get(STATGROUP_TITLE).unwrap_or_default()) } + h2 { @tr(ri.lang, "stats.by_kind") } table.striped { tr { th { @tr(ri.lang, "stats.by_kind.kind") } @@ -40,15 +38,15 @@ markup::define! { 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()) } + @for (kind, stat) in &stats.by_kind { tr { + td { @tr(ri.lang, &format!("tag.kind.{kind}")) } + td { @stat.count } + td { @format_size(stat.sum_size) } + td { @format_duration(stat.sum_duration) } + td { @format_size(stat.sum_size / stat.count as u64) } + td { @format_duration(stat.sum_duration / stat.count as f64) } + td { @format_size(stat.max_size) } + td { @format_duration(stat.max_duration) } }} } } diff --git a/ui/src/components/user.rs b/ui/src/components/user.rs index 22b296e..9dabffc 100644 --- a/ui/src/components/user.rs +++ b/ui/src/components/user.rs @@ -4,16 +4,17 @@ Copyright (C) 2026 metamuffin <metamuffin.org> */ -use crate::RenderInfo; +use crate::{RenderInfo, page}; use jellycommon::{ - jellyobject::Object, routes::{u_account_login, u_account_logout, u_account_settings}, *, }; use jellyui_locale::tr; +page!(UserSettings<'_>, |x| tr(x.ri.lang, "settings")); + markup::define! { - UserSettings<'a>(ri: &'a RenderInfo<'a>, user: Object<'a>) { + UserSettings<'a>(ri: &'a RenderInfo<'a>, user: User<'a>) { h1 { @tr(ri.lang, "settings") } h2 { @tr(ri.lang, "settings.account") } diff --git a/ui/src/lib.rs b/ui/src/lib.rs index d74df51..93dd38a 100644 --- a/ui/src/lib.rs +++ b/ui/src/lib.rs @@ -3,20 +3,19 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2026 metamuffin <metamuffin.org> */ -mod components; +pub mod components; pub(crate) mod format; mod scaffold; +use std::borrow::Cow; + pub use jellyui_client_scripts::*; pub use jellyui_client_style::*; - -pub use components::admin_log::ServerLogPage; -pub use components::admin_log::render_log_line; pub use jellyui_locale::tr; -pub use scaffold::Scaffold; -use crate::components::View; -use jellycommon::{jellyobject::Object, *}; +use jellycommon::jellyobject::Object; +use markup::DynRender; +pub use scaffold::Scaffold; use serde::{Deserialize, Serialize}; #[rustfmt::skip] @@ -27,29 +26,34 @@ pub struct Config { pub logo: bool, } +pub trait Page { + fn title(&self) -> Cow<'static, str>; + fn ri(&self) -> &RenderInfo<'_>; + fn render(&self) -> DynRender<'_>; +} + pub struct RenderInfo<'a> { pub user: Option<Object<'a>>, + pub message: Option<(&'a str, &'a str)>, pub lang: &'a str, pub status_message: Option<&'a str>, pub config: &'a Config, } -pub fn render_view(ri: RenderInfo<'_>, view: Object<'_>) -> String { - let theme = ri - .user - .and_then(|u| u.get(USER_THEME_PRESET)) - .unwrap_or(THEME_DARK); - Scaffold { - ri: &ri, - main: View { ri: &ri, view }, - title: view.get(VIEW_TITLE).unwrap_or_default(), - class: match (theme, view.has(VIEW_PLAYER.0)) { - (THEME_DARK, true) => "theme-dark player", - (THEME_DARK, false) => "theme-dark", - (THEME_LIGHT, true) => "theme-light player", - (THEME_LIGHT, false) => "theme-light", - _ => "theme-dark", - }, - } - .to_string() +#[macro_export] +macro_rules! page { + ($t:ty, $title:stmt) => { + impl $crate::Page for $t { + fn title(&self) -> std::borrow::Cow<'static, str> { + let x: fn(&$t) -> std::borrow::Cow<'static, str> = {$title}; + x(self) + } + fn ri(&self) -> &$crate::RenderInfo<'_> { + self.ri + } + fn render(&self) -> markup::DynRender<'_> { + markup::new!(@self) + } + } + }; } diff --git a/ui/src/scaffold.rs b/ui/src/scaffold.rs index c563ee4..61e497a 100644 --- a/ui/src/scaffold.rs +++ b/ui/src/scaffold.rs @@ -4,9 +4,9 @@ Copyright (C) 2026 metamuffin <metamuffin.org> */ -use crate::RenderInfo; +use crate::{Page, RenderInfo, components::message::Message}; use jellycommon::{ - USER_THEME_ACCENT, + THEME_DARK, USER_THEME_ACCENT, USER_THEME_PRESET, routes::{ u_account_login, u_account_settings, u_admin_dashboard, u_home, u_items, u_node_slug, u_search, u_stats, @@ -14,21 +14,27 @@ use jellycommon::{ user::{USER_ADMIN, USER_NAME}, }; use jellyui_locale::{escape, tr}; -use markup::{Render, raw}; +use markup::raw; markup::define! { - Scaffold<'a, Main: Render>(ri: &'a RenderInfo<'a>, title: &'a str, main: Main, class: &'a str) { + Scaffold<'a>(page: &'a dyn Page) { + @let ri = page.ri(); @markup::doctype() html { head { - title { @title " - " @ri.config.brand } + title { @page.title() " - " @ri.config.brand } meta[name="viewport", content="width=device-width, initial-scale=1.0"]; link[rel="stylesheet", href="/assets/bundle.css"]; script[src="/assets/bundle.js"] {} } - body[class=class, style=format!("--accent-hue: {}", ri.user.and_then(|u|u.get(USER_THEME_ACCENT)).unwrap_or(277))] { + @let theme = ri.user.and_then(|u| u.get(USER_THEME_PRESET)).unwrap_or(THEME_DARK); + @let hue = ri.user.and_then(|u| u.get(USER_THEME_ACCENT)).unwrap_or(277); + body[class=format!("theme-{theme}"), style=format!("--accent-hue: {hue}")] { @Navbar { ri } - #main { @main } + @if let Some((kind, text)) = ri.message { + @Message { ri, kind, text } + } + #main { @page.render() } footer { p { @ri.config.brand " - " @ri.config.slogan " | powered by " a[href="https://codeberg.org/metamuffin/jellything"]{"Jellything"} } } @@ -50,11 +56,11 @@ markup::define! { @if let Some(user) = &ri.user { span { @raw(tr(ri.lang, "nav.username").replace("{name}", &format!("<b class=\"username\">{}</b>", escape(user.get(USER_NAME).unwrap_or("nameless user"))))) } " " @if user.has(USER_ADMIN.0) { - a.admin.hybrid_button[href=u_admin_dashboard()] { p {@tr(ri.lang, "nav.admin")} } " " + a.admin[href=u_admin_dashboard()] { p {@tr(ri.lang, "nav.admin")} } " " } - a.settings.hybrid_button[href=u_account_settings()] { p {@tr(ri.lang, "nav.settings")} } " " + a.settings[href=u_account_settings()] { p {@tr(ri.lang, "nav.settings")} } " " } else { - a.login.hybrid_button[href=u_account_login()] { p {@tr(ri.lang, "nav.login")} } + a.login[href=u_account_login()] { p {@tr(ri.lang, "nav.login")} } } } } |