aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2026-02-27 14:40:15 +0100
committermetamuffin <metamuffin@disroot.org>2026-02-27 14:40:15 +0100
commitc05bfcc2775f0e11db6e856bfcf06d0419c35d54 (patch)
treeffd0e9fcf6b476a6198287085a514cfa7940c200
parent4ba86694e393c61107e27c4127efc0455b329524 (diff)
downloadjellything-c05bfcc2775f0e11db6e856bfcf06d0419c35d54.tar
jellything-c05bfcc2775f0e11db6e856bfcf06d0419c35d54.tar.bz2
jellything-c05bfcc2775f0e11db6e856bfcf06d0419c35d54.tar.zst
ui changed before object slices
-rw-r--r--common/src/api.rs127
-rw-r--r--common/src/user.rs4
-rw-r--r--database/src/helper.rs23
-rw-r--r--database/src/lib.rs1
-rw-r--r--server/src/main.rs2
-rw-r--r--server/src/request_info.rs7
-rw-r--r--server/src/ui/account/mod.rs37
-rw-r--r--server/src/ui/account/settings.rs17
-rw-r--r--server/src/ui/admin/import.rs28
-rw-r--r--server/src/ui/admin/log.rs19
-rw-r--r--server/src/ui/admin/mod.rs37
-rw-r--r--server/src/ui/admin/users.rs46
-rw-r--r--server/src/ui/home.rs97
-rw-r--r--server/src/ui/items.rs45
-rw-r--r--server/src/ui/player.rs90
-rw-r--r--server/src/ui_responder.rs56
-rw-r--r--ui/client-scripts/src/main.ts1
-rw-r--r--ui/client-scripts/src/pagination.ts18
-rw-r--r--ui/src/components/admin.rs37
-rw-r--r--ui/src/components/admin_log.rs6
-rw-r--r--ui/src/components/home.rs38
-rw-r--r--ui/src/components/items.rs22
-rw-r--r--ui/src/components/login.rs15
-rw-r--r--ui/src/components/message.rs6
-rw-r--r--ui/src/components/mod.rs61
-rw-r--r--ui/src/components/node_card.rs18
-rw-r--r--ui/src/components/node_list.rs47
-rw-r--r--ui/src/components/node_page.rs29
-rw-r--r--ui/src/components/props.rs9
-rw-r--r--ui/src/components/stats.rs36
-rw-r--r--ui/src/components/user.rs7
-rw-r--r--ui/src/lib.rs54
-rw-r--r--ui/src/scaffold.rs26
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")} }
}
}
}