diff --git a/examples/static.rs b/examples/static.rs index 8b89946..d4b73bc 100644 --- a/examples/static.rs +++ b/examples/static.rs @@ -9,7 +9,7 @@ use rocket::routes; pub async fn index_page<'r>(ctx: RequestContext) -> impl Responder<'r, 'static> { based::request::assets::DataResponse::new( include_bytes!("../Cargo.toml").to_vec(), - "text/toml", + "text/toml".to_string(), Some(60 * 60 * 3), ) } diff --git a/src/auth/user.rs b/src/auth/user.rs index dabe42c..9821374 100644 --- a/src/auth/user.rs +++ b/src/auth/user.rs @@ -81,7 +81,6 @@ impl User { /// Change the password of a User /// /// Returns a Result indicating whether the password change was successful or not - #[must_use] pub async fn passwd(self, old: &str, new: &str) -> Result<(), ()> { if self.verify_pw(old) { sqlx::query("UPDATE users SET \"password\" = $1 WHERE username = $2;") diff --git a/src/page/mod.rs b/src/page/mod.rs index 45e216f..f9091ef 100644 --- a/src/page/mod.rs +++ b/src/page/mod.rs @@ -1,5 +1,7 @@ use maud::{PreEscaped, html}; +pub mod search; + use crate::request::{RequestContext, StringResponse}; use rocket::http::{ContentType, Status}; diff --git a/src/page/search.rs b/src/page/search.rs new file mode 100644 index 0000000..9179406 --- /dev/null +++ b/src/page/search.rs @@ -0,0 +1,160 @@ +use maud::{PreEscaped, html}; + +use crate::request::{RequestContext, api::Pager}; + +/// Represents a search form with configurable options such as heading, placeholder, and CSS class. +pub struct Search { + post_url: String, + heading: Option>, + placeholder: Option, + search_class: Option, +} + +impl Search { + /// Creates a new `Search` instance with the specified post URL. + /// + /// # Arguments + /// * `post_url` - The URL where the search form will send the POST request. + /// + /// # Returns + /// A new `Search` instance with default settings. + #[must_use] + pub const fn new(post_url: String) -> Self { + Self { + heading: None, + placeholder: None, + post_url, + search_class: None, + } + } + + /// Sets the placeholder text for the search input field. + /// + /// # Arguments + /// * `placeholder` - The placeholder text to display in the search input. + /// + /// # Returns + /// The updated `Search` instance. + #[must_use] + pub fn placeholder(mut self, placeholder: String) -> Self { + self.placeholder = Some(placeholder); + self + } + + /// Sets the heading to be displayed above the search form. + /// + /// # Arguments + /// * `heading` - The heading element to display. + /// + /// # Returns + /// The updated `Search` instance. + #[must_use] + pub fn heading(mut self, heading: PreEscaped) -> Self { + self.heading = Some(heading); + self + } + + /// Sets the CSS class for the search input field. + /// + /// # Arguments + /// * `class` - The CSS class to apply to the search input. + /// + /// # Returns + /// The updated `Search` instance. + #[must_use] + pub fn search_class(mut self, class: String) -> Self { + self.search_class = Some(class); + self + } + + /// Builds the HTML for search results based on the current page and query. + /// + /// # Arguments + /// * `pager` - The `Pager` instance to paginate results. + /// * `page` - The current page number. + /// * `query` - The search query string. + /// * `result_ui` - A function that transforms each result into HTML. + /// + /// # Returns + /// The HTML string containing the search results. + pub fn build_results( + &self, + pager: Pager, + page: i64, + query: &str, + result_ui: impl Fn(&T) -> PreEscaped, + ) -> PreEscaped { + let results = pager.page(page as u64); + let reslen = results.len(); + + html! { + + @for res in results { + (result_ui(res)) + } + + @if reslen as u64 == pager.items_per_page { + div hx-get=(format!("{}?query={}&page={}", self.post_url, query, page+1)) + hx-trigger="revealed" + hx-swap="outerHTML" {}; + }; + } + } + + /// Builds the full response based on the context (HTMX or full HTML response). + /// + /// # Arguments + /// * `ctx` - The request context, used to determine if HTMX is enabled. + /// * `results` - The `Pager` instance containing the search results. + /// * `page` - The current page number. + /// * `query` - The search query string. + /// * `result_ui` - A function that transforms each result into HTML. + /// + /// # Returns + /// The HTML string containing either the HTMX response or full page content. + pub fn build_response( + &self, + ctx: &RequestContext, + results: Pager, + page: i64, + query: &str, + result_ui: impl Fn(&T) -> PreEscaped, + ) -> PreEscaped { + if ctx.is_htmx { + // Return HTMX Search elements + self.build_results(results, page, query, result_ui) + } else { + // Return full rendered site + let first_page = self.build_results(results, page, query, result_ui); + self.build(query, first_page) + } + } + + /// Builds the full search form and first search page results. + /// + /// # Arguments + /// * `query` - The search query string. + /// * `first_page` - The HTML string containing the first page of search results. + /// + /// # Returns + /// The HTML string containing the entire search form and results UI. + #[must_use] + pub fn build(&self, query: &str, first_page: PreEscaped) -> PreEscaped { + let no_html = PreEscaped(String::new()); + html! { + (self.heading.as_ref().unwrap_or_else(|| &no_html)) + input type="search" name="query" + value=(query) + placeholder=(self.placeholder.as_ref().unwrap_or(&"Search...".to_string())) hx-get=(self.post_url) + hx-trigger="input changed delay:500ms, keyup[key=='Enter'], load" + hx-target="#search_results" hx-push-url="true" + class=(self.search_class.as_deref().unwrap_or_default()) {}; + + div id="search_results" { + @if !query.is_empty() { + (first_page) + } + }; + } + } +} diff --git a/src/request/api.rs b/src/request/api.rs index b1670f9..c2a1967 100644 --- a/src/request/api.rs +++ b/src/request/api.rs @@ -83,3 +83,168 @@ pub fn api_error(msg: &str) -> ApiError { "error": msg })) } + +/// A `Pager` that manages paginated items, with the ability to handle incomplete data. +pub struct Pager { + inner: Vec, + pub items_per_page: u64, + complete_at: Option, +} + +impl Pager { + /// Creates a new `Pager` instance with the given items and items per page. + /// + /// # Arguments + /// * `items` - A vector of items to paginate. + /// * `per_page` - Number of items per page. + /// + /// # Returns + /// A new `Pager` instance. + #[must_use] + pub const fn new(items: Vec, per_page: u64) -> Self { + Self { + inner: items, + items_per_page: per_page, + complete_at: None, + } + } + + /// Creates a new `Pager` instance for an incomplete dataset, starting from a specific page. + /// + /// # Arguments + /// * `items` - A vector of items to paginate. + /// * `per_page` - Number of items per page. + /// * `at_page` - The page where data starts, meaning there are no pages before it. + /// + /// # Returns + /// A new `Pager` instance. + #[must_use] + pub const fn new_incomplete(items: Vec, per_page: u64, at_page: u64) -> Self { + Self { + inner: items, + items_per_page: per_page, + complete_at: Some(at_page), + } + } + + /// Calculates the offset for the given page number. + /// + /// This method considers whether the pager has an incomplete dataset and adjusts the offset accordingly. + /// + /// # Arguments + /// * `page` - The page number for which the offset is calculated. + /// + /// # Returns + /// The calculated offset. + #[must_use] + pub const fn offset(&self, page: u64) -> u64 { + if let Some(incomplete) = self.complete_at { + let page = page - incomplete; + return self.items_per_page * page; + } + + self.items_per_page * (page - 1) + } + + /// Retrieves the items for a specific page. + /// + /// # Arguments + /// * `page` - The page number to retrieve. + /// + /// # Panics + /// Panics if trying to access a page before the `complete_at` page in an incomplete pager. + /// + /// # Returns + /// A vector of items on the requested page. + #[must_use] + pub fn page(&self, page: u64) -> Vec<&T> { + if let Some(incomplete) = self.complete_at { + assert!( + page >= incomplete, + "Tried to access illegal page on incomplete Pager" + ); + } + + self.inner + .iter() + .skip(self.offset(page).try_into().unwrap()) + .take(self.items_per_page.try_into().unwrap()) + .collect() + } +} + +/// A `GeneratedPager` is a paginated generator that fetches items dynamically from a generator function. +pub struct GeneratedPager +where + G: Fn(I, u64, u64) -> futures::future::BoxFuture<'static, Vec>, +{ + generator: G, + pub items_per_page: u64, + _marker: std::marker::PhantomData<(T, I)>, +} + +impl GeneratedPager +where + G: Fn(I, u64, u64) -> futures::future::BoxFuture<'static, Vec>, +{ + /// Creates a new `GeneratedPager` instance with the provided generator and pagination settings. + /// + /// # Arguments + /// * `generator` - A function that generates a page of items based on the input and pagination settings. + /// * `items_per_page` - Number of items per page. + /// + /// # Generator + /// The generator function should take the following arguments: + /// * `input` - Generic input to the generator + /// * `offset` - Offset value + /// * `limit` - Limit value (items per page) + /// + /// # Returns + /// A new `GeneratedPager` instance. + pub const fn new(generator: G, items_per_page: u64) -> Self { + Self { + generator, + items_per_page, + _marker: std::marker::PhantomData, + } + } + + /// Calculates the offset for the given page number. + /// + /// # Arguments + /// * `page` - The page number for which the offset is calculated. + /// + /// # Returns + /// The calculated offset. + pub const fn offset(&self, page: u64) -> u64 { + self.items_per_page * (page - 1) + } + + /// Asynchronously retrieves the items for a specific page from the generator. + /// + /// # Arguments + /// * `page` - The page number to retrieve. + /// * `input` - The input that is passed to the generator. + /// + /// # Returns + /// A vector of items on the requested page. + pub async fn page(&self, page: u64, input: I) -> Vec { + let offset = self.offset(page); + (self.generator)(input, offset, self.items_per_page).await + } + + /// Converts the `GeneratedPager` into a regular `Pager` for a given page of items. + /// + /// This method allows you to use a `GeneratedPager` like a regular `Pager`, with a pre-generated dataset. + /// + /// # Arguments + /// * `page` - The page number to retrieve. + /// * `input` - The input to pass to the generator function. + /// + /// # Returns + /// A `Pager` instance containing the requested page of items. + pub async fn pager(&self, page: u64, input: I) -> Pager { + let content = self.page(page, input).await; + Pager::new_incomplete(content, self.items_per_page, page) + } +} diff --git a/src/request/assets.rs b/src/request/assets.rs index ead84fc..f0f1a62 100644 --- a/src/request/assets.rs +++ b/src/request/assets.rs @@ -15,7 +15,7 @@ pub struct DataResponse { impl DataResponse { #[must_use] - pub fn new(data: Vec, content_type: String, cache_duration: Option) -> Self { + pub const fn new(data: Vec, content_type: String, cache_duration: Option) -> Self { Self { data, content_type, diff --git a/src/request/context.rs b/src/request/context.rs index 13f8249..357c053 100644 --- a/src/request/context.rs +++ b/src/request/context.rs @@ -4,6 +4,7 @@ use rocket::{ }; /// Represents contextual information about an HTTP request. +#[derive(Default)] pub struct RequestContext { /// A flag indicating if the request is an HTMX request. /// @@ -21,9 +22,3 @@ impl<'r> FromRequest<'r> for RequestContext { }) } } - -impl Default for RequestContext { - fn default() -> Self { - Self { is_htmx: false } - } -} diff --git a/src/request/mod.rs b/src/request/mod.rs index 6365cde..76015ea 100644 --- a/src/request/mod.rs +++ b/src/request/mod.rs @@ -67,7 +67,7 @@ pub fn respond_json(json: &serde_json::Value) -> StringResponse { /// # Returns /// A `StringResponse` with status `200 OK`, content type `text/html`, and the HTML content as the body. #[must_use] -pub fn respond_html(html: String) -> StringResponse { +pub const fn respond_html(html: String) -> StringResponse { (Status::Ok, (ContentType::HTML, html)) } @@ -79,7 +79,7 @@ pub fn respond_html(html: String) -> StringResponse { /// # Returns /// A `StringResponse` with status `200 OK`, content type `text/javascript`, and the JS content as the body. #[must_use] -pub fn respond_script(script: String) -> StringResponse { +pub const fn respond_script(script: String) -> StringResponse { (Status::Ok, (ContentType::JavaScript, script)) }