use std::sync::Arc; use maud::{PreEscaped, Render, html}; use crate::{ ogp::Metadata, request::{RequestContext, StringResponse}, ui::{ UIWidget, color::{Gray, UIColor}, htmx::{Event, HTMXAttributes, Selector, SwapStrategy}, prelude::{Div, Link, Optional}, primitives::link::LinkWidget, }, }; /// Represents the HTML structure of a page shell, including the head, body class, and body content. /// /// This structure is used to construct the overall HTML structure of a page, making it easier to generate consistent HTML pages dynamically. #[derive(Clone)] pub struct Shell { /// The HTML content for the `` section of the page. head: Arc>, /// An optional class attribute for the main container element. main_class: Arc, /// The HTML content for the static body portion. body_content: Arc>, ui: bool, metadata: Option, bottom_nav: Option>>, sidebar: Option>, navbar: Option>, } impl Shell { /// Constructs a new `Shell` instance with the given head content, body content, and body class. /// /// # Arguments /// * `head` - The HTML content for the page's head. /// * `body_content` - The HTML content for the body of the page. /// * `body_class` - An optional class to apply to the `` element. /// /// # Returns /// A `Shell` instance encapsulating the provided HTML content and attributes. #[must_use] pub fn new( head: T, body_content: B, body_class: C, ) -> Self { Self { head: Arc::new(head.render()), main_class: Arc::new(body_class.extended_class().join(" ")), body_content: Arc::new(body_content.render()), ui: false, metadata: None, bottom_nav: None, sidebar: None, navbar: None, } } /// This function can be used to extend the Shell dynamically at runtime. /// /// It clones the shell, allowing you to modify it without affecting global state if you use a shared shell. /// /// # Example /// ```ignore /// shell.extend().with_navbar(...) /// ``` pub fn extend(&self) -> Self { self.clone() } pub fn metadata(mut self, metadata: Metadata) -> Self { self.metadata = Some(metadata); self } pub fn with_navbar(mut self, navbar: NavBarWidget) -> Self { self.navbar = Some(Arc::new(navbar)); self } pub fn with_bottom_navigation(mut self, bottom_nav: PreEscaped) -> Self { self.bottom_nav = Some(Arc::new(bottom_nav)); self } pub fn with_sidebar(mut self, inner: PreEscaped) -> Self { self.sidebar = Some(Arc::new(Sidebar(inner))); self } pub fn use_ui(mut self) -> Self { self.ui = true; self } /// Renders the full HTML page using the shell structure, with additional content and a title. /// /// # Arguments /// * `content` - The additional HTML content to render inside the main content div. /// * `title` - The title of the page, rendered inside the `` element. /// /// # Returns /// A `PreEscaped<String>` containing the full HTML page content. #[must_use] pub fn render(&self, content: PreEscaped<String>, title: &str) -> PreEscaped<String> { let mut main_class = self.main_class.as_ref().clone(); if self.bottom_nav.is_some() { main_class.push_str(" pb-20"); } if self.sidebar.is_some() { main_class.push_str(" ml-[264px]"); } html! { (maud::DOCTYPE) html { head { title { (title) }; @if self.ui { script src="https://cdn.tailwindcss.com" {}; script src="/assets/htmx.min.js" {}; script src="/assets/flowbite.min.js" {}; link href="/assets/flowbite.min.css" rel="stylesheet" {}; link href="/assets/material.css" rel="stylesheet" {}; meta name="viewport" content="width=device-width, initial-scale=1.0"; }; (self.head) (Optional(self.metadata.as_ref(), |meta| meta.render())) }; body class=(self.main_class) { (PreEscaped(self.navbar.as_ref().map(|x| { if self.sidebar.is_some() { x.as_ref().clone().sticky().render().0.clone() } else { x.render().0.clone() } }).unwrap_or_default())); div id="notification_area" class=(format!("fixed top-{} start-0 z-50 w-full m-4", if self.navbar.is_some() { "[72px]" } else { "5" })) { } div id="toast_tl" class=(format!("fixed left-5 top-{} start-0 z-50 flex flex-col gap-4 m-4", if self.navbar.is_some() { "[72px]" } else { "5" })) {} div id="toast_tr" class=(format!("fixed right-0 top-{} z-50 flex flex-col gap-4 m-4", if self.navbar.is_some() { "[72px]" } else { "5" })) {} div id="toast_bl" class=(format!("fixed bottom-{} left-5 start-0 z-50 flex flex-col gap-4 m-4", if self.bottom_nav.is_some() { "[72px]" } else { "5" })) {} div id="toast_br" class=(format!("fixed bottom-{} right-0 z-50 flex flex-col gap-4 m-4", if self.bottom_nav.is_some() { "[72px]" } else { "5" })) {} (PreEscaped(self.sidebar.as_ref().map(|x| x.render(self.navbar.is_some()).0).unwrap_or_default())) (self.body_content); div id="main_content" class=(main_class) { (content) }; (PreEscaped(self.bottom_nav.as_ref().map(|x| x.0.as_str()).unwrap_or_default())) } } } } /// Renders a full page or an HTMX-compatible fragment based on the request context. /// /// If the request is not an HTMX request, this function uses the provided shell to generate /// a full HTML page. If it is an HTMX request, only the provided content is rendered. /// /// # Arguments /// * `content` - The HTML content to render. /// * `title` - The title of the page for full-page rendering. /// * `ctx` - The `RequestContext` containing request metadata. /// /// # Returns /// A `StringResponse` pub async fn render_page( &self, content: PreEscaped<String>, title: &str, ctx: RequestContext, ) -> StringResponse { if ctx.is_htmx { ( rocket::http::Status::Ok, (rocket::http::ContentType::HTML, content.into_string()), ) } else { ( rocket::http::Status::Ok, ( rocket::http::ContentType::HTML, self.render(content, title).into_string(), ), ) } } } #[allow(non_snake_case)] pub fn BottomNavigation<T: UIWidget + 'static>(inner: &[T]) -> PreEscaped<String> { let elements_len = inner.len(); html! { div class="fixed bottom-0 left-0 z-50 w-full h-16 bg-white border-t border-gray-200 dark:bg-gray-700 dark:border-gray-600" { div class=(format!("grid h-full max-w-lg grid-cols-{elements_len} mx-auto font-medium")) { @for item in inner { (item) } }; }; } } #[allow(non_snake_case)] pub fn Classic<T: UIWidget + 'static>(class: &str, inner: T) -> ClassicWidget<T> { ClassicWidget { inner: inner, class: class.to_string(), noble: false, } } pub struct ClassicWidget<T: UIWidget> { inner: T, noble: bool, class: String, } impl<T: UIWidget> ClassicWidget<T> { pub fn noble(mut self) -> Self { self.noble = true; self } pub fn inner<F>(mut self, f: F) -> Self where F: Fn(T) -> T, { let mut inner = self.inner; inner = f(inner); self.inner = inner; self } } impl<T: UIWidget> Render for ClassicWidget<T> { fn render(&self) -> maud::Markup { self.render_with_class(&self.class) } } impl<T: UIWidget> UIWidget for ClassicWidget<T> { fn can_inherit(&self) -> bool { !self.noble } fn base_class(&self) -> Vec<String> { self.class.split(" ").map(|x| x.to_string()).collect() } fn extended_class(&self) -> Vec<String> { let mut ret = self.base_class(); ret.extend(self.inner.extended_class()); ret } fn render_with_class(&self, class: &str) -> maud::Markup { let class = if self.noble { self.class.clone() } else { format!("{} {class}", self.class) }; self.inner.render_with_class(&class) } } #[allow(non_snake_case)] pub fn BottomNavigationTile<T: UIWidget + 'static>( reference: &str, icon: Option<T>, text: &str, ) -> ClassicWidget<LinkWidget> { Classic( "inline-flex flex-col items-center justify-center px-5 hover:bg-gray-50 dark:hover:bg-gray-800 group", Link(reference, html! { (icon.map(|x| x.render()).unwrap_or_default()); span class="text-sm text-gray-500 dark:text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-500" { (text) }; }), ) } #[allow(non_snake_case)] pub fn NavBar(title: &str) -> NavBarWidget { NavBarWidget { icon: None, name: title.to_string(), menu: None, user: None, no_dropdown: false, centered: false, sticky: false, } } #[derive(Clone)] pub struct NavBarWidget { icon: Option<PreEscaped<String>>, name: String, menu: Option<PreEscaped<String>>, user: Option<PreEscaped<String>>, no_dropdown: bool, centered: bool, sticky: bool, } impl NavBarWidget { pub fn icon<T: UIWidget + 'static>(mut self, icon: T) -> Self { self.icon = Some(icon.render()); self } pub fn sticky(mut self) -> Self { self.sticky = true; self } pub fn menu<T: UIWidget + 'static>(mut self, menu: T) -> Self { self.menu = Some(menu.render()); self } pub fn extra<T: UIWidget + 'static>(mut self, extra: T) -> Self { self.user = Some(extra.render()); self } pub fn no_dropdown(mut self) -> Self { self.no_dropdown = true; self } pub fn center(mut self) -> Self { self.centered = true; self } } impl Render for NavBarWidget { fn render(&self) -> maud::Markup { self.render_with_class("") } } impl UIWidget for NavBarWidget { fn can_inherit(&self) -> bool { false } fn base_class(&self) -> Vec<String> { Vec::new() } fn extended_class(&self) -> Vec<String> { Vec::new() } fn render_with_class(&self, _: &str) -> maud::Markup { let div_class = if self.centered { "max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4" } else { "flex flex-wrap items-center justify-between mx-auto p-4" }; let nav_class = if self.sticky { "sticky top-0 bg-white border-gray-200 dark:bg-gray-900 h-[72px]" } else { "bg-white border-gray-200 dark:bg-gray-900 h-[72px]" }; html! { nav class=(nav_class) { div class=(div_class) { a href="/" class="flex items-center space-x-3 rtl:space-x-reverse" { div class="h-8" { (PreEscaped(self.icon.as_ref().map(|x| x.render().0).unwrap_or_default())) }; span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white" { (self.name) }; }; @if self.no_dropdown { div class="md:hidden flex w-auto order-1" id="navbar-user" { ul class="flex font-medium p-0 space-x-8 rtl:space-x-reverse flex-row mt-0 border-0 bg-white dark:bg-gray-800 dark:bg-gray-900 dark:border-gray-700" { (PreEscaped(self.menu.as_ref().map(|x| x.render().0).unwrap_or_default())) }; }; } div class=(format!("flex items-center {}order-2 space-x-3 md:space-x-0 rtl:space-x-reverse", if self.no_dropdown { "" } else { "md:" })) { (PreEscaped(self.user.as_ref().map(|x| x.render().0).unwrap_or_default())) @if !self.no_dropdown { button data-collapse-toggle="navbar-user" type="button" class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600" aria-controls="navbar-user" aria-expanded="false" { span class="sr-only" { "Open main menu" }; svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14" { path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" {}; }; }; }; }; div class="items-center justify-between hidden w-full md:flex md:w-auto md:order-1" id="navbar-user" { ul class="flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700" { (PreEscaped(self.menu.as_ref().map(|x| x.render().0).unwrap_or_default())) }; }; }; }; } } } pub struct SidebarWidget { inner: PreEscaped<String>, } impl SidebarWidget { pub fn render(&self, has_navbar: bool) -> PreEscaped<String> { let class = match has_navbar { true => { "fixed left-0 w-64 h-screen transition-transform -translate-x-full sm:translate-x-0 z-40" } false => { "fixed top-0 left-0 w-64 h-screen transition-transform -translate-x-full sm:translate-x-0 z-40" } }; html! { aside id="default-sidebar" class=(class) aria-label="Sidebar" { div class="h-full px-3 py-4 overflow-y-auto bg-gray-50 dark:bg-gray-800" { ul class="space-y-2 font-medium" { (self.inner) } }; }; } } } #[allow(non_snake_case)] fn Sidebar<T: UIWidget + 'static>(inner: T) -> SidebarWidget { SidebarWidget { inner: inner.render(), } } pub enum Position { TopLeft, TopRight, BottomLeft, BottomRight, } pub enum Alignment { Horizontal, Vertical, } #[macro_export] macro_rules! page { ($shell:ident, $ctx:ident, $title:literal, $content:expr) => {{ use $crate::ui::UIWidget; let content = $content.render_with_class(""); $shell.render_page(content, $title, $ctx).await }}; ($shell:ident, $ctx:ident, $title:ident, $content:expr) => {{ use $crate::ui::UIWidget; let content = $content.render_with_class(""); $shell.render_page(content, &$title, $ctx).await }}; } pub struct ToastWidget { color: Box<dyn UIColor>, icon: Option<Box<dyn UIWidget>>, body: Box<dyn UIWidget>, } impl ToastWidget { pub fn color<C: UIColor + 'static>(mut self, c: C) -> Self { self.color = Box::new(c); self } pub fn icon<T: UIWidget + 'static>(mut self, icon: T) -> Self { self.icon = Some(Box::new(icon)); self } } impl Render for ToastWidget { fn render(&self) -> maud::Markup { self.render_with_class("") } } impl UIWidget for ToastWidget { fn can_inherit(&self) -> bool { false } fn base_class(&self) -> Vec<String> { Vec::new() } fn extended_class(&self) -> Vec<String> { Vec::new() } fn render_with_class(&self, _: &str) -> maud::Markup { let color = self.color.color_class(); let dim_color = color; let id = format!("toast_{}", uuid::Uuid::new_v4().to_string()); html! { div id=(id) class=(format!("flex items-center w-full max-w-xs p-4 text-gray-500 rounded-lg shadow dark:text-gray-400 bg-{color}")) role="alert" { @if let Some(icon) = &self.icon { div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-blue-500 bg-blue-100 rounded-lg dark:bg-blue-800 dark:text-blue-200" { (icon) }; }; div class="ms-3 text-sm font-normal" { (self.body) }; button type="button" class=(format!("ms-auto -mx-1.5 -my-1.5 text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 inline-flex items-center justify-center h-8 w-8 dark:text-gray-500 dark:hover:text-white bg-{color} hover:bg-{dim_color}")) data-dismiss-target=(format!("#{id}")) aria-label="Close" { span class="sr-only" { "Close" }; svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14" { path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" {}; }; } }; } } } #[allow(non_snake_case)] pub fn Toast<B: UIWidget + 'static>(body: B) -> ToastWidget { ToastWidget { color: Box::new(Gray::_800), icon: None, body: Box::new(body), } } #[allow(non_snake_case)] pub fn FetchToast(reference: &str, position: Position) -> PreEscaped<String> { Div() .hx_get(reference) .hx_target(Selector::Query(match position { Position::TopLeft => format!("#toast_tl"), Position::TopRight => format!("#toast_tr"), Position::BottomLeft => format!("#toast_bl"), Position::BottomRight => format!("#toast_br"), })) .hx_swap(SwapStrategy::beforeend) .hx_trigger(Event::on_load()) .render() }