From 5109ac65f44c17169f9b66ae1f85cef51e56e615 Mon Sep 17 00:00:00 2001
From: trisua <tri@swmff.org>
Date: Thu, 10 Apr 2025 21:37:33 -0400
Subject: [PATCH] add: community search

---
 crates/app/src/assets.rs                      |  2 +
 crates/app/src/langs/en-US.toml               |  4 ++
 crates/app/src/public/css/style.css           |  4 ++
 .../app/src/public/html/communities/list.html | 26 ++++++++---
 .../src/public/html/communities/search.html   | 45 +++++++++++++++++++
 crates/app/src/public/html/components.html    | 12 +++--
 .../app/src/public/html/timelines/home.html   | 10 ++++-
 crates/app/src/routes/pages/communities.rs    | 40 ++++++++++++++++-
 crates/app/src/routes/pages/mod.rs            |  9 ++++
 crates/core/src/database/communities.rs       | 27 +++++++++++
 10 files changed, 167 insertions(+), 12 deletions(-)
 create mode 100644 crates/app/src/public/html/communities/search.html

diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs
index 43c8fd1..1b348b3 100644
--- a/crates/app/src/assets.rs
+++ b/crates/app/src/assets.rs
@@ -52,6 +52,7 @@ pub const COMMUNITIES_FEED: &str = include_str!("./public/html/communities/feed.
 pub const COMMUNITIES_POST: &str = include_str!("./public/html/communities/post.html");
 pub const COMMUNITIES_SETTINGS: &str = include_str!("./public/html/communities/settings.html");
 pub const COMMUNITIES_MEMBERS: &str = include_str!("./public/html/communities/members.html");
+pub const COMMUNITIES_SEARCH: &str = include_str!("./public/html/communities/search.html");
 
 pub const TIMELINES_HOME: &str = include_str!("./public/html/timelines/home.html");
 pub const TIMELINES_FOLLOWING: &str = include_str!("./public/html/timelines/following.html");
@@ -180,6 +181,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
     write_template!(html_path->"communities/post.html"(crate::assets::COMMUNITIES_POST) --config=config);
     write_template!(html_path->"communities/settings.html"(crate::assets::COMMUNITIES_SETTINGS) --config=config);
     write_template!(html_path->"communities/members.html"(crate::assets::COMMUNITIES_MEMBERS) --config=config);
+    write_template!(html_path->"communities/search.html"(crate::assets::COMMUNITIES_SEARCH) --config=config);
 
     write_template!(html_path->"timelines/home.html"(crate::assets::TIMELINES_HOME) -d "timelines" --config=config);
     write_template!(html_path->"timelines/following.html"(crate::assets::TIMELINES_FOLLOWING) --config=config);
diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml
index 839bf95..0713af7 100644
--- a/crates/app/src/langs/en-US.toml
+++ b/crates/app/src/langs/en-US.toml
@@ -80,6 +80,10 @@ version = "1.0.0"
 "communities:label.repost" = "Repost"
 "communities:label.quote_post" = "Quote post"
 "communities:label.expand_original" = "Expand original"
+"communities:label.search" = "Search"
+"communities:label.search_results" = "Search results"
+"communities:label.query" = "Query"
+"communities:label.join_new" = "Join new"
 
 "notifs:action.mark_as_read" = "Mark as read"
 "notifs:action.mark_as_unread" = "Mark as unread"
diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css
index 442b1f4..bf366ef 100644
--- a/crates/app/src/public/css/style.css
+++ b/crates/app/src/public/css/style.css
@@ -157,6 +157,10 @@ p {
     margin-bottom: 0;
 }
 
+.no_p_margin img {
+    display: block !important;
+}
+
 .name {
     max-width: 250px;
     overflow: hidden;
diff --git a/crates/app/src/public/html/communities/list.html b/crates/app/src/public/html/communities/list.html
index 8a6191b..31f0b66 100644
--- a/crates/app/src/public/html/communities/list.html
+++ b/crates/app/src/public/html/communities/list.html
@@ -33,9 +33,16 @@
     {% endif %}
 
     <div class="card-nest w-full">
-        <div class="card small flex items-center gap-2">
-            {{ icon "award" }}
-            <span>{{ text "communities:label.my_communities" }}</span>
+        <div class="card small flex items-center justify-between gap-2">
+            <div class="flex items-center gap-2">
+                {{ icon "award" }}
+                <span>{{ text "communities:label.my_communities" }}</span>
+            </div>
+
+            <a href="/communities/search" class="button quaternary small">
+                {{ icon "search" }}
+                <span>{{ text "communities:label.join_new" }}</span>
+            </a>
         </div>
 
         <div class="card flex flex-col gap-2">
@@ -45,9 +52,16 @@
     </div>
 
     <div class="card-nest w-full">
-        <div class="card small flex items-center gap-2">
-            {{ icon "trending-up" }}
-            <span>{{ text "communities:label.popular_communities" }}</span>
+        <div class="card small flex items-center justify-between gap-2">
+            <div class="flex items-center gap-2">
+                {{ icon "trending-up" }}
+                <span>{{ text "communities:label.popular_communities" }}</span>
+            </div>
+
+            <a href="/communities/search" class="button quaternary small">
+                {{ icon "search" }}
+                <span>{{ text "communities:label.search" }}</span>
+            </a>
         </div>
 
         <div class="card flex flex-col gap-2">
diff --git a/crates/app/src/public/html/communities/search.html b/crates/app/src/public/html/communities/search.html
new file mode 100644
index 0000000..d54cb15
--- /dev/null
+++ b/crates/app/src/public/html/communities/search.html
@@ -0,0 +1,45 @@
+{% extends "root.html" %} {% block head %}
+<title>Search communities - {{ config.name }}</title>
+{% endblock %} {% block body %} {{ macros::nav(selected="communities") }}
+<main class="flex flex-col gap-2">
+    <div class="card-nest">
+        <div class="card small flex items-center gap-2">
+            {{ icon "search" }}
+            <span>{{ text "communities:label.search" }}</span>
+        </div>
+
+        <form class="card flex flex-col gap-4">
+            <div class="flex flex-col gap-1">
+                <label for="text">{{ text "communities:label.query" }}</label>
+                <input
+                    type="text"
+                    name="text"
+                    id="text"
+                    placeholder="text"
+                    required
+                    maxlength="32"
+                    value="{{ text }}"
+                />
+            </div>
+
+            <button class="primary">{{ text "dialog:action.continue" }}</button>
+        </form>
+    </div>
+
+    <div class="card-nest">
+        <div class="card small flex items-center gap-2">
+            {{ icon "book-marked" }}
+            <span>{{ text "communities:label.search_results" }}</span>
+        </div>
+
+        <!-- prettier-ignore -->
+        <div class="card flex flex-col gap-4">
+            {% for item in list %}
+                {{ components::community_listing_card(community=item) }}
+            {% endfor %}
+
+            {{ components::pagination(page=page, items=list|length, key="&text=", value=text) }}
+        </div>
+    </div>
+</main>
+{% endblock %}
diff --git a/crates/app/src/public/html/components.html b/crates/app/src/public/html/components.html
index 093fd5e..4c70d79 100644
--- a/crates/app/src/public/html/components.html
+++ b/crates/app/src/public/html/components.html
@@ -392,17 +392,23 @@ show_community and post.community != config.town_square %}
         </div>
     </div>
 </a>
-{%- endmacro %} {% macro pagination(page=0, items=0) -%}
+{%- endmacro %} {% macro pagination(page=0, items=0, key="", value="") -%}
 <div class="flex justify-between gap-2 w-full">
     {% if page > 0 %}
-    <a class="button quaternary" href="?page={{ page - 1 }}">
+    <a
+        class="button quaternary"
+        href="?page={{ page - 1 }}{{ key }}{{ value }}"
+    >
         {{ icon "arrow-left" }}
         <span>{{ text "general:link.previous" }}</span>
     </a>
     {% else %}
     <div></div>
     {% endif %} {% if items != 0 %}
-    <a class="button quaternary" href="?page={{ page + 1 }}">
+    <a
+        class="button quaternary"
+        href="?page={{ page + 1 }}{{ key }}{{ value }}"
+    >
         <span>{{ text "general:link.next" }}</span>
         {{ icon "arrow-right"}}
     </a>
diff --git a/crates/app/src/public/html/timelines/home.html b/crates/app/src/public/html/timelines/home.html
index 2b89e6f..6a596a7 100644
--- a/crates/app/src/public/html/timelines/home.html
+++ b/crates/app/src/public/html/timelines/home.html
@@ -11,8 +11,14 @@
             <b>✨ Welcome to <i>{{ config.name }}</i>!</b>
         </div>
 
-        <div class="card">
-            Join some communities to populate your home timeline!
+        <div class="card no_p_margin">
+            <p>Join some communities to populate your home timeline!</p>
+            <p>
+                You can get started by
+                <a href="/communities/search"
+                    >searching for a community to join!</a
+                >
+            </p>
         </div>
     </div>
     {% else %}
diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs
index 1276d8f..b9eabcb 100644
--- a/crates/app/src/routes/pages/communities.rs
+++ b/crates/app/src/routes/pages/communities.rs
@@ -1,4 +1,4 @@
-use super::{PaginatedQuery, render_error};
+use super::{render_error, PaginatedQuery, SearchedQuery};
 use crate::{assets::initial_context, get_lang, get_user_from_token, sanitize::clean_context, State};
 use axum::{
     Extension,
@@ -166,6 +166,44 @@ pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) ->
     ))
 }
 
+/// `/communities/search`
+pub async fn search_request(
+    jar: CookieJar,
+    Extension(data): Extension<State>,
+    Query(req): Query<SearchedQuery>,
+) -> 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 communities = match data
+        .0
+        .get_communities_searched(&req.text, 12, req.page)
+        .await
+    {
+        Ok(p) => p,
+        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", &communities);
+    context.insert("page", &req.page);
+    context.insert("text", &req.text);
+
+    // return
+    Ok(Html(
+        data.1.render("communities/search.html", &context).unwrap(),
+    ))
+}
+
 pub fn community_context(
     context: &mut Context,
     community: &Community,
diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs
index 52eb6a1..43cac0e 100644
--- a/crates/app/src/routes/pages/mod.rs
+++ b/crates/app/src/routes/pages/mod.rs
@@ -45,6 +45,7 @@ pub fn routes() -> Router {
         .route("/@{username}/followers", get(profile::followers_request))
         // communities
         .route("/communities", get(communities::list_request))
+        .route("/communities/search", get(communities::search_request))
         .route("/community/{title}", get(communities::feed_request))
         .route(
             "/community/{title}/manage",
@@ -74,3 +75,11 @@ pub struct PaginatedQuery {
     #[serde(default)]
     pub page: usize,
 }
+
+#[derive(Deserialize)]
+pub struct SearchedQuery {
+    #[serde(default)]
+    pub text: String,
+    #[serde(default)]
+    pub page: usize,
+}
diff --git a/crates/core/src/database/communities.rs b/crates/core/src/database/communities.rs
index 1d11c26..3d50d85 100644
--- a/crates/core/src/database/communities.rs
+++ b/crates/core/src/database/communities.rs
@@ -145,6 +145,33 @@ impl DataManager {
         Ok(res.unwrap())
     }
 
+    /// Get all communities, filtering their title.
+    /// Communities are sorted by popularity first, creation date second.
+    pub async fn get_communities_searched(
+        &self,
+        query: &str,
+        batch: usize,
+        page: usize,
+    ) -> Result<Vec<Community>> {
+        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 communities WHERE title LIKE $1 ORDER BY member_count DESC, created DESC LIMIT $2 OFFSET $3",
+            params![&format!("%{query}%"), &(batch as i64), &(page as i64)],
+            |x| { Self::get_community_from_row(x) }
+        );
+
+        if res.is_err() {
+            return Err(Error::GeneralNotFound("communities".to_string()));
+        }
+
+        Ok(res.unwrap())
+    }
+
     /// Create a new community in the database.
     ///
     /// # Arguments