diff --git a/Cargo.lock b/Cargo.lock index 5a57149..6c58c80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3155,7 +3155,7 @@ dependencies = [ [[package]] name = "tetratto" -version = "1.0.3" +version = "1.0.4" dependencies = [ "ammonia", "axum", @@ -3180,7 +3180,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "1.0.3" +version = "1.0.4" dependencies = [ "async-recursion", "bb8-postgres", @@ -3199,7 +3199,7 @@ dependencies = [ [[package]] name = "tetratto-l10n" -version = "1.0.3" +version = "1.0.4" dependencies = [ "pathbufd", "serde", @@ -3208,7 +3208,7 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "1.0.3" +version = "1.0.4" dependencies = [ "ammonia", "chrono", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 5217ce8..5593d98 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "1.0.3" +version = "1.0.4" edition = "2024" [features] diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 41333f7..a315804 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -64,6 +64,12 @@ pub const TIMELINES_HOME: &str = include_str!("./public/html/timelines/home.html pub const TIMELINES_POPULAR: &str = include_str!("./public/html/timelines/popular.html"); pub const TIMELINES_FOLLOWING: &str = include_str!("./public/html/timelines/following.html"); pub const TIMELINES_ALL: &str = include_str!("./public/html/timelines/all.html"); +pub const TIMELINES_HOME_QUESTIONS: &str = + include_str!("./public/html/timelines/home_questions.html"); +pub const TIMELINES_FOLLOWING_QUESTIONS: &str = + include_str!("./public/html/timelines/following_questions.html"); +pub const TIMELINES_ALL_QUESTIONS: &str = + include_str!("./public/html/timelines/all_questions.html"); pub const MOD_AUDIT_LOG: &str = include_str!("./public/html/mod/audit_log.html"); pub const MOD_REPORTS: &str = include_str!("./public/html/mod/reports.html"); @@ -200,6 +206,9 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"timelines/popular.html"(crate::assets::TIMELINES_POPULAR) --config=config); write_template!(html_path->"timelines/following.html"(crate::assets::TIMELINES_FOLLOWING) --config=config); write_template!(html_path->"timelines/all.html"(crate::assets::TIMELINES_ALL) --config=config); + write_template!(html_path->"timelines/home_questions.html"(crate::assets::TIMELINES_HOME_QUESTIONS) --config=config); + write_template!(html_path->"timelines/following_questions.html"(crate::assets::TIMELINES_FOLLOWING_QUESTIONS) --config=config); + write_template!(html_path->"timelines/all_questions.html"(crate::assets::TIMELINES_ALL_QUESTIONS) --config=config); write_template!(html_path->"mod/audit_log.html"(crate::assets::MOD_AUDIT_LOG) -d "mod" --config=config); write_template!(html_path->"mod/reports.html"(crate::assets::MOD_REPORTS) --config=config); diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 47e9636..dc17baa 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -14,7 +14,7 @@ use tera::{Tera, Value}; use tower_http::trace::{self, TraceLayer}; use tracing::{Level, info}; -use std::{collections::HashMap, env::var, sync::Arc}; +use std::{collections::HashMap, env::var, process::exit, sync::Arc}; use tokio::sync::RwLock; pub(crate) type State = Arc>; @@ -51,7 +51,14 @@ async fn main() { let database = DataManager::new(config.clone()).await.unwrap(); database.init().await.unwrap(); - let mut tera = Tera::new(&format!("{html_path}/**/*")).unwrap(); + let mut tera = match Tera::new(&format!("{html_path}/**/*")) { + Ok(t) => t, + Err(e) => { + println!("{e}"); + exit(1); + } + }; + tera.register_filter("markdown", render_markdown); tera.register_filter("color", color_escape); tera.register_filter("has_supporter", check_supporter); diff --git a/crates/app/src/public/html/communities/questions.html b/crates/app/src/public/html/communities/questions.html index 79cba8d..55bbe72 100644 --- a/crates/app/src/public/html/communities/questions.html +++ b/crates/app/src/public/html/communities/questions.html @@ -20,60 +20,11 @@
{% for question in feed %} -
- {{ components::question(question=question[0], owner=question[1], - show_community=false) }} + {{ components::global_question(question=question, can_manage_questions=can_manage_questions) }} + {% endfor %} -
- - {{ icon "external-link" }} {% if user %} - {{ text "requests:label.answer" }} - {% else %} - {{ text "general:action.open" }} - {% endif %} - - - {% if user %} {% if can_manage_questions or is_helper or - question[1].id == user.id %} - - {% endif %} {% endif %} -
-
- {% endfor %} {{ components::pagination(page=page, items=feed|length) - }} + {{ components::pagination(page=page, items=feed|length) }}
- - {% endblock %} diff --git a/crates/app/src/public/html/components.html b/crates/app/src/public/html/components.html index d4b4559..872fc19 100644 --- a/crates/app/src/public/html/components.html +++ b/crates/app/src/public/html/components.html @@ -698,4 +698,36 @@ is_global=false) -%} }); } +{%- endmacro %} {% macro global_question(question, can_manage_questions=false, +secondary=false) -%} +
+ {{ components::question(question=question[0], owner=question[1], + show_community=false) }} + +
+ + {{ icon "external-link" }} {% if user %} + {{ text "requests:label.answer" }} + {% else %} + {{ text "general:action.open" }} + {% endif %} + + + {% if user %} {% if can_manage_questions or is_helper or question[1].id + == user.id %} + + {% endif %} {% endif %} +
+
{%- endmacro %} diff --git a/crates/app/src/public/html/macros.html b/crates/app/src/public/html/macros.html index 550ce74..4b57266 100644 --- a/crates/app/src/public/html/macros.html +++ b/crates/app/src/public/html/macros.html @@ -185,8 +185,24 @@ {% endif %} -{%- endmacro %} {% macro community_nav(community, selected="") -%} {% if -community.context.enable_questions %} +{%- endmacro %} {% macro timelines_secondary_nav(posts="", questions="", +selected="posts") -%} {% if user %} +
+ + {{ icon "newspaper" }} + {{ text "communities:label.posts" }} + + + + {{ icon "message-circle-heart" }} + {{ text "communities:label.questions" }} + +
+{% endif %} {%- endmacro %} {% macro community_nav(community, selected="") -%} +{% if community.context.enable_questions %}
- +
@@ -121,27 +121,6 @@ }); } - async function remove_question(id) { - if ( - !(await trigger("atto::confirm", [ - "Are you sure you want to do this?", - ])) - ) { - return; - } - - fetch(`/api/v1/questions/${id}`, { - method: "DELETE", - }) - .then((res) => res.json()) - .then((res) => { - trigger("atto::toast", [ - res.ok ? "success" : "error", - res.message, - ]); - }); - } - async function answer_question_from_form(e, answering) { e.preventDefault(); await trigger("atto::debounce", ["posts::create"]); diff --git a/crates/app/src/public/html/timelines/all.html b/crates/app/src/public/html/timelines/all.html index 7e78de3..8d84ce2 100644 --- a/crates/app/src/public/html/timelines/all.html +++ b/crates/app/src/public/html/timelines/all.html @@ -2,7 +2,8 @@ Latest posts - {{ config.name }} {% endblock %} {% block body %} {{ macros::nav() }}
- {{ macros::timelines_nav(selected="all") }} + {{ macros::timelines_nav(selected="all") }} {{ + macros::timelines_secondary_nav(posts="/all", questions="/all/questions") }}
diff --git a/crates/app/src/public/html/timelines/all_questions.html b/crates/app/src/public/html/timelines/all_questions.html new file mode 100644 index 0000000..e95db77 --- /dev/null +++ b/crates/app/src/public/html/timelines/all_questions.html @@ -0,0 +1,18 @@ +{% extends "root.html" %} {% block head %} +Latest questions - {{ config.name }} +{% endblock %} {% block body %} {{ macros::nav() }} +
+ {{ macros::timelines_nav(selected="all") }} {{ + macros::timelines_secondary_nav(posts="/all", questions="/all/questions", + selected="questions") }} + + +
+ {% for question in list %} + {{ components::global_question(question=question, can_manage_questions=false, secondary=true) }} + {% endfor %} + + {{ components::pagination(page=page, items=list|length) }} +
+
+{% endblock %} diff --git a/crates/app/src/public/html/timelines/following.html b/crates/app/src/public/html/timelines/following.html index ddc3f96..893d597 100644 --- a/crates/app/src/public/html/timelines/following.html +++ b/crates/app/src/public/html/timelines/following.html @@ -2,7 +2,9 @@ Following - {{ config.name }} {% endblock %} {% block body %} {{ macros::nav() }}
- {{ macros::timelines_nav(selected="following") }} + {{ macros::timelines_nav(selected="following") }} {{ + macros::timelines_secondary_nav(posts="/following", + questions="/following/questions") }}
diff --git a/crates/app/src/public/html/timelines/following_questions.html b/crates/app/src/public/html/timelines/following_questions.html new file mode 100644 index 0000000..ff4e994 --- /dev/null +++ b/crates/app/src/public/html/timelines/following_questions.html @@ -0,0 +1,18 @@ +{% extends "root.html" %} {% block head %} +Following (questions) - {{ config.name }} +{% endblock %} {% block body %} {{ macros::nav() }} +
+ {{ macros::timelines_nav(selected="following") }} {{ + macros::timelines_secondary_nav(posts="/following", + questions="/following/questions", selected="questions") }} + + +
+ {% for question in list %} + {{ components::global_question(question=question, can_manage_questions=false, secondary=true) }} + {% endfor %} + + {{ components::pagination(page=page, items=list|length) }} +
+
+{% endblock %} diff --git a/crates/app/src/public/html/timelines/home.html b/crates/app/src/public/html/timelines/home.html index 3ac0785..b709d08 100644 --- a/crates/app/src/public/html/timelines/home.html +++ b/crates/app/src/public/html/timelines/home.html @@ -3,7 +3,8 @@ {% endblock %} {% block body %} {{ macros::nav(selected="home") }}
- {{ macros::timelines_nav(selected="home") }} + {{ macros::timelines_nav(selected="home") }} {{ + macros::timelines_secondary_nav(posts="/", questions="/questions") }} {% if list|length == 0 and page == 0 %}
diff --git a/crates/app/src/public/html/timelines/home_questions.html b/crates/app/src/public/html/timelines/home_questions.html new file mode 100644 index 0000000..0f5199d --- /dev/null +++ b/crates/app/src/public/html/timelines/home_questions.html @@ -0,0 +1,18 @@ +{% extends "root.html" %} {% block head %} +From my communities (questions) - {{ config.name }} +{% endblock %} {% block body %} {{ macros::nav() }} +
+ {{ macros::timelines_nav(selected="home") }} {{ + macros::timelines_secondary_nav(posts="/", questions="/questions", + selected="questions") }} + + +
+ {% for question in list %} + {{ components::global_question(question=question, can_manage_questions=false, secondary=true) }} + {% endfor %} + + {{ components::pagination(page=page, items=list|length) }} +
+
+{% endblock %} diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index 826dd8c..19105bf 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -193,6 +193,27 @@ }); }); + self.define("remove_question", async (_, id) => { + if ( + !(await trigger("atto::confirm", [ + "Are you sure you want to do this?", + ])) + ) { + return; + } + + fetch(`/api/v1/questions/${id}`, { + method: "DELETE", + }) + .then((res) => res.json()) + .then((res) => { + trigger("atto::toast", [ + res.ok ? "success" : "error", + res.message, + ]); + }); + }); + // token switcher self.define( "set_login_account_tokens", diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index 49112d3..8ea30f3 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -158,6 +158,113 @@ pub async fn all_request( Html(data.1.render("timelines/all.html", &context).unwrap()) } +/// `/questions` +pub async fn index_questions_request( + jar: CookieJar, + Extension(data): Extension, + Query(req): Query, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Html(render_error(Error::NotAllowed, &jar, &data, &None).await); + } + }; + + let list = match data + .0 + .get_questions_from_user_communities(user.id, 12, req.page) + .await + { + Ok(l) => match data.0.fill_questions(l).await { + Ok(l) => l, + Err(e) => return Html(render_error(e, &jar, &data, &Some(user)).await), + }, + Err(e) => return Html(render_error(e, &jar, &data, &Some(user)).await), + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0, lang, &Some(user)).await; + + context.insert("list", &list); + context.insert("page", &req.page); + Html( + data.1 + .render("timelines/home_questions.html", &context) + .unwrap(), + ) +} + +/// `/following/questions` +pub async fn following_questions_request( + jar: CookieJar, + Extension(data): Extension, + Query(req): Query, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + let list = match data + .0 + .get_questions_from_user_following(user.id, 12, req.page) + .await + { + Ok(l) => match data.0.fill_questions(l).await { + Ok(l) => l, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0, lang, &Some(user)).await; + + context.insert("list", &list); + context.insert("page", &req.page); + Ok(Html( + data.1 + .render("timelines/following_questions.html", &context) + .unwrap(), + )) +} + +/// `/all/questions` +pub async fn all_questions_request( + jar: CookieJar, + Extension(data): Extension, + Query(req): Query, +) -> impl IntoResponse { + let data = data.read().await; + let user = get_user_from_token!(jar, data.0); + + let list = match data.0.get_latest_global_questions(12, req.page).await { + Ok(l) => match data.0.fill_questions(l).await { + Ok(l) => l, + Err(e) => return Html(render_error(e, &jar, &data, &user).await), + }, + Err(e) => return Html(render_error(e, &jar, &data, &user).await), + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0, lang, &user).await; + + context.insert("list", &list); + context.insert("page", &req.page); + Html( + data.1 + .render("timelines/all_questions.html", &context) + .unwrap(), + ) +} + /// `/notifs` pub async fn notifications_request( jar: CookieJar, diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index cb9db32..66eb71a 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -16,11 +16,19 @@ use crate::{assets::initial_context, get_lang}; pub fn routes() -> Router { Router::new() - // misc + // timelines .route("/", get(misc::index_request)) .route("/popular", get(misc::popular_request)) .route("/following", get(misc::following_request)) .route("/all", get(misc::all_request)) + // question timelines + .route("/questions", get(misc::index_questions_request)) + .route( + "/following/questions", + get(misc::following_questions_request), + ) + .route("/all/questions", get(misc::all_questions_request)) + // misc .route("/notifs", get(misc::notifications_request)) .route("/requests", get(misc::requests_request)) .route("/doc/{*file_name}", get(misc::markdown_document_request)) diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 1c567bd..fb60c3d 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-core" -version = "1.0.3" +version = "1.0.4" edition = "2024" [features] diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index aacc5c3..cea5898 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -104,6 +104,7 @@ impl DataManager { /// Get the question of a given post. pub async fn get_post_question(&self, post: &Post) -> Result> { if post.context.answering != 0 { + dbg!(&post.context.answering); let question = self.get_question_by_id(post.context.answering).await?; let user = self.get_user_by_id_with_void(question.owner).await?; Ok(Some((question, user))) @@ -483,6 +484,7 @@ impl DataManager { query_string.push_str(&format!(" OR community = {}", membership.community)); } + // ... let conn = match self.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), @@ -530,6 +532,7 @@ impl DataManager { query_string.push_str(&format!(" OR owner = {}", user.receiver)); } + // ... let conn = match self.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs index 8a2b12f..0044caa 100644 --- a/crates/core/src/database/questions.rs +++ b/crates/core/src/database/questions.rs @@ -128,6 +128,121 @@ impl DataManager { Ok(res.unwrap()) } + /// Get all global questions by the given user's following. + pub async fn get_questions_from_user_following( + &self, + id: usize, + batch: usize, + page: usize, + ) -> Result> { + let following = self.get_userfollows_by_initiator_all(id).await?; + let mut following = following.iter(); + let first = match following.next() { + Some(f) => f, + None => return Ok(Vec::new()), + }; + + let mut query_string: String = String::new(); + + for user in following { + query_string.push_str(&format!(" OR owner = {}", user.receiver)); + } + + // ... + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + &format!( + "SELECT * FROM questions WHERE (owner = {} {query_string}) AND is_global = 1 ORDER BY created DESC LIMIT $1 OFFSET $2", + first.receiver + ), + &[&(batch as i64), &((page * batch) as i64)], + |x| { Self::get_question_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("question".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get all global questions posted in the given user's communities. + pub async fn get_questions_from_user_communities( + &self, + id: usize, + batch: usize, + page: usize, + ) -> Result> { + let memberships = self.get_memberships_by_owner(id).await?; + let mut memberships = memberships.iter(); + let first = match memberships.next() { + Some(f) => f, + None => return Ok(Vec::new()), + }; + + let mut query_string: String = String::new(); + + for membership in memberships { + query_string.push_str(&format!(" OR community = {}", membership.community)); + } + + // ... + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + &format!( + "SELECT * FROM questions WHERE (community = {} {query_string}) AND is_global = 1 ORDER BY created DESC LIMIT $1 OFFSET $2", + first.community + ), + &[&(batch as i64), &((page * batch) as i64)], + |x| { Self::get_question_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("question".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get global questions from all communities, sorted by creation. + /// + /// # Arguments + /// * `batch` - the limit of questions in each page + /// * `page` - the page number + pub async fn get_latest_global_questions( + &self, + batch: usize, + page: usize, + ) -> Result> { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM questions WHERE is_global = 1 ORDER BY created DESC LIMIT $1 OFFSET $2", + &[&(batch as i64), &((page * batch) as i64)], + |x| { Self::get_question_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("question".to_string())); + } + + Ok(res.unwrap()) + } + /// Create a new question in the database. /// /// # Arguments @@ -250,6 +365,17 @@ impl DataManager { self.delete_request(y.owner, y.id, user).await?; } + // delete all posts answering question + let res = execute!( + &conn, + "DELETE FROM posts WHERE context LIKE $1", + &[&format!("%\"answering\":{id}%")] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + // return Ok(()) } diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index 3728475..4a991d5 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-l10n" -version = "1.0.3" +version = "1.0.4" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 71bde60..7472401 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-shared" -version = "1.0.3" +version = "1.0.4" edition = "2024" authors.workspace = true repository.workspace = true