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` containing the full HTML page content.
#[must_use]
pub fn render(&self, content: PreEscaped, title: &str) -> PreEscaped {
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,
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(inner: &[T]) -> PreEscaped {
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(class: &str, inner: T) -> ClassicWidget {
ClassicWidget {
inner: inner,
class: class.to_string(),
noble: false,
}
}
pub struct ClassicWidget {
inner: T,
noble: bool,
class: String,
}
impl ClassicWidget {
pub fn noble(mut self) -> Self {
self.noble = true;
self
}
pub fn inner(mut self, f: F) -> Self
where
F: Fn(T) -> T,
{
let mut inner = self.inner;
inner = f(inner);
self.inner = inner;
self
}
}
impl Render for ClassicWidget {
fn render(&self) -> maud::Markup {
self.render_with_class(&self.class)
}
}
impl UIWidget for ClassicWidget {
fn can_inherit(&self) -> bool {
!self.noble
}
fn base_class(&self) -> Vec {
self.class.split(" ").map(|x| x.to_string()).collect()
}
fn extended_class(&self) -> Vec {
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(
reference: &str,
icon: Option,
text: &str,
) -> ClassicWidget {
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>,
name: String,
menu: Option>,
user: Option>,
no_dropdown: bool,
centered: bool,
sticky: bool,
}
impl NavBarWidget {
pub fn icon(mut self, icon: T) -> Self {
self.icon = Some(icon.render());
self
}
pub fn sticky(mut self) -> Self {
self.sticky = true;
self
}
pub fn menu(mut self, menu: T) -> Self {
self.menu = Some(menu.render());
self
}
pub fn extra(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 {
Vec::new()
}
fn extended_class(&self) -> Vec {
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,
}
impl SidebarWidget {
pub fn render(&self, has_navbar: bool) -> PreEscaped {
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(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,
icon: Option>,
body: Box,
}
impl ToastWidget {
pub fn color(mut self, c: C) -> Self {
self.color = Box::new(c);
self
}
pub fn icon(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 {
Vec::new()
}
fn extended_class(&self) -> Vec {
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(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 {
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()
}