diff --git a/bard/Cargo.toml b/bard/Cargo.toml index 92e8e37..17530da 100644 --- a/bard/Cargo.toml +++ b/bard/Cargo.toml @@ -9,11 +9,17 @@ authors = ["CyberMages LLC ", readme = "README.md" license = "Apache-2.0" +[[example]] +name = "blog" +path = "examples/blog.rs" + [dependencies] -dioxus = { version = "*", features = ["router", "fullstack"], optional = true } -tavern = { version = "0.2.9", path = "../tavern", registry = "cybermages", optional = true} -tokio = { version = "1.0", features = ["full"], optional = true } +dioxus = { version = "*", features = ["router", "fullstack"] } +tavern = { version = "0.2.9", path = "../tavern", optional = true} +tokio = { version = "1.0", features = ["rt", "macros"], optional = true } + [features] -default = ["tavern", "dioxus/web"] +default = ["web"] +web = ["tavern", "dioxus/web"] server = ["tavern/database", "dioxus/server", "tokio"] diff --git a/bard/src/components.rs b/bard/src/components.rs deleted file mode 100644 index e09409b..0000000 --- a/bard/src/components.rs +++ /dev/null @@ -1,496 +0,0 @@ -use std::borrow::Borrow; -use std::collections::HashSet; -use std::hash::Hash; - -use dioxus::prelude::*; -use tavern::{Adventurer, Legend, Lore, Tale}; - -use crate::page::Page; -use crate::server::*; - - - -const AUTHOR_CSS: Asset = asset!("/assets/css/blog_author.css"); -const LIST_CSS: Asset = asset!("/assets/css/blog_list.css"); -const NAV_CSS: Asset = asset!("/assets/css/blog_nav.css"); -const POST_CSS: Asset = asset!("/assets/css/blog_post.css"); -const TAG_CSS: Asset = asset!("/assets/css/blog_tag.css"); - - - -/// Trait to toggle the presence of a value (like a tag) in a set. -pub trait Togglable -{ - type Item: ?Sized; - - fn toggle(&mut self, item: &Self::Item); - fn is_toggled(&self, item: &Self::Item) -> bool; -} - -impl Togglable for HashSet -{ - type Item = str; - - - fn toggle(&mut self, item: &str) - { - if self.contains(item) - { - self.remove(item); - } - else - { - self.insert(item.to_owned()); - } - } - - fn is_toggled(&self, item: &str) -> bool - { - self.contains(item) - } -} - - - -fn convert_tag(text: S) -> (String, String) - where S: AsRef -{ - if let Some((first, second)) = text.as_ref().split_once('-') - { - (first.to_owned(), second.to_owned()) - } - else - { - (text.as_ref().to_owned(), "none".to_owned()) - } -} - -fn strip_tag(text: &str) -> &str -{ - if let Some((first, _)) = text.split_once('-') - { - first - } - else - { - text - } -} - - - -#[component] -pub fn TagItem(tag: String, style: String) -> Element -{ - rsx! { - li - { - class: "tag_item", - Link - { - class: "{style}", - to: Page::Blog { tag: tag.clone() }, - "{tag}" - } - // a { class: "{style}", "{tag}" } - } - } -} - -#[component] -pub fn TagList(children: Element) -> Element -{ - rsx! { - document::Link { rel: "stylesheet", href: TAG_CSS } - - h5 - { - class: "blog_tag_style", - ul - { - class: "tag_list", - {children} - } - } - } -} - - -#[component] -pub fn BlogAuthor() -> Element -{ - rsx! { - document::Link { rel: "stylesheet", href: AUTHOR_CSS } - - section - { - class: "blog_author_style", - - } - } -} - - -#[component] -pub fn BlogItem(title: String, slug: String, author: String, summary: String, - tags: Vec) - -> Element -{ - let tags: Vec<(String, String)> = - tags.iter().map(|t| convert_tag(t)).collect(); - - rsx! { - li - { - class: "blog_item", - Link - { - to: Page::Post { slug: slug }, - h1 { "{title}" } - } - TagList - { - for (tag, style) in tags - { - TagItem - { - tag: tag.clone(), - style: style.clone() - } - } - } - h4 { "Author: {author}" } - p { "{summary}" } - } - } -} - - -#[component] -pub fn BlogList(tags: Signal>, children: Element) -> Element -{ - let list = use_server_future(move || { - let categories = tags().iter().cloned().collect(); - - async move { get_blog_list(categories).await } - })?; - - // The tale depends on the post future resolving. - let mut summaries: Signal>> = use_signal(|| None); - use_context_provider(|| summaries); - - use_effect(move || { - if let Some(Ok(data)) = &*list.read() - { - summaries.set(Some(data.clone())); - } - }); - - rsx! { - document::Link { rel: "stylesheet", href: LIST_CSS } - - section - { - class: "blog_list_style", - ul - { - class: "blog_list", - - match &*list.read() - { - Some(Ok(lores)) => - { - rsx! - { - for lore in lores - { - BlogItem - { - title: lore.title.clone(), - slug: lore.slug.clone(), - author: lore.author.clone(), - summary: lore.summary.clone(), - tags: lore.tags.clone() - } - } - } - } - - Some(Err(e)) => - { - rsx! - { - p { "Unable to show post header." } - p { "{e}" } - } - } - - None => - { - rsx! - { - p { "Loading..." } - } - } - } - - {children} - } - } - } -} - -#[component] -pub fn TagSelector(toggled_tags: Signal>) -> Element -{ - let mut categories = use_signal(|| Vec::::new()); - - let tags = use_server_future(move || async move { get_tags().await })?; - - use_effect(move || - { - if let Some(Ok(ref tags_vec)) = *tags.read() - { - categories.set(tags_vec.clone()); - } - }); - - rsx! - { - document::Link { rel: "stylesheet", href: NAV_CSS } - - section - { - class: "blog_nav_style", - div - { - class: "tag_style", - h2 { "Categories" } - ul - { - class: "tag_list", - - if categories.read().is_empty() - { - p { "Loading..." } - } - else - { - for tag in categories.read().iter().cloned() - { - li - { - class: "tag_item", - span - { - class: if toggled_tags.read().is_toggled(&tag) - { "tag selected" } - - else - { "tag" }, - - onclick: move |_| - { toggled_tags.write().toggle(&tag); }, - - "{strip_tag(&tag)}" - } - } - } - } - } - } - } - } -} - -#[component] -pub fn PostHeaderAuthor(adventurer: Signal>) -> Element -{ - rsx! { - if let Some(author) = &*adventurer.read() - { - h4 - { - "Author: ", - a { href: "{author.legend.profile}", "{author.name} @{author.handle}" } - } - } - else - { - h4 { "Author: Unknown" } - } - } -} - -#[component] -pub fn PostHeader(tale: Signal>, - adventurer: Signal>) -> Element -{ - let converted_tags = use_memo(move || { - tale().as_ref() - .map(|t| { - t.lore - .tags - .iter() - .map(|tag| convert_tag(tag)) - .collect::>() - }) - .unwrap_or_default() - }); - - rsx! - { - if let Some(tale) = tale() - { - h1 { {tale.lore.title} } - - TagList - { - for (tag, style) in converted_tags().iter() - { - TagItem - { - tag: tag.clone(), - style: style.clone() - } - } - } - } - else - { - p { "Loading post header..." } - } - - if let Some(_) = adventurer() - { - PostHeaderAuthor { adventurer: adventurer } - } - else - { - p { "Loading author..." } - } - } -} - -#[component] -pub fn BlogPost(slug: Signal, children: Element) -> Element -{ - // The tale depends on the post future resolving. - let mut tale: Signal> = use_signal(|| None); - let mut adventurer = use_signal(|| None); - - // Run a once off server fetch of the unchanging blog data. - let post = use_server_future(move || - { - // Make this reactive so that as the page changes it should rerun this. - let url_slug = slug(); - - async move { get_blog_post(url_slug).await } - })?; - - let author = use_server_future(move || { - let handle = match &*tale.read() - { - Some(data) => data.lore.author.to_owned(), - - None => "unknown_author_handle".to_string() - }; - - async move { get_author(handle).await } - })?; - - use_effect(move || { - if let Some(Ok(data)) = &*post.read() - { - tale.set(Some(data.clone())); - } - }); - - use_effect(move || { - if let Some(Ok(data)) = &*author.read() - { - adventurer.set(Some(data.clone())); - } - }); - - - rsx! { - document::Link { rel: "stylesheet", href: POST_CSS } - - article - { - class: "blog_post_style", - - if let Some(Ok(post)) = &*post.read() - { - PostHeader - { - tale: tale, - adventurer: adventurer - } - - //Story {} - - div { dangerous_inner_html: "{post.story}" } - - //Author {} - } - else if let Some(Err(e)) = &*post.read() - { - p { "Unable to load desired post: {slug.read()}" } - p { "{e}" } - } - else - { - p { "Loading..." } - } - - {children} - } - } -} - -#[component] -pub fn TagNav() -> Element -{ - let tags = use_server_future(move || async move { get_tags().await })?; - - rsx! - { - document::Link { rel: "stylesheet", href: NAV_CSS } - - section - { - class: "blog_nav_style", - div - { - class: "tag_style", - h2 { "Categories" } - ul - { - class: "tag_list", - if let Some(Ok(categories)) = &*tags.read() - { - for tag in categories - { - li - { - class: "tag_item", - a { href: "/blog/{strip_tag(tag)}", "{strip_tag(tag)}" } - } - } - } - else if let Some(Err(e)) = &*tags.read() - { - p { "Unable to show desired post." } - p { "{e}" } - } - else - { - p { "Loading..." } - } - } - } - } - } -} diff --git a/bard/src/components/list.rs b/bard/src/components/list.rs new file mode 100644 index 0000000..4817b2d --- /dev/null +++ b/bard/src/components/list.rs @@ -0,0 +1,194 @@ +use std::borrow::Borrow; +use std::collections::HashSet; +use std::hash::Hash; + +use dioxus::prelude::*; +use tavern::{Adventurer, Legend, Lore, Tale}; + +use crate::togglable::Togglable; +use crate::page::Page; +use crate::server::*; +use super::tags::*; + + + +#[component] +pub fn BlogAuthor() -> Element +{ + rsx! + { + section + { + class: "blog_author_style", + } + } +} + + +#[component] +pub fn BlogItem(title: String, slug: String, author: String, summary: String, + tags: Vec) + -> Element +{ + println!("Blog Item: {title} -- [{tags:?}]"); + rsx! + { + li + { + key: "{slug}", + class: "blog_item", + + Link + { + to: Page::Post { slug: slug.clone() }, + h1 { "{title}" } + } + + TagList + { + for tag in tags + { + TagItem + { + tag: tag.clone(), + } + } + } + + h4 { "Author: {author}" } + + p { "{summary}" } + } + } +} + + +#[component] +pub fn BlogList(tags: Signal>, children: Element) -> Element +{ + let list = use_server_future(move || { + let categories = tags().iter().cloned().collect(); + + async move { get_blog_list(categories).await } + })?; + + rsx! + { + section + { + class: "blog_list_style", + ul + { + class: "blog_list", + + if let Some(Ok(lores)) = &*list.read() + { + for lore in lores + { + BlogItem + { + title: lore.title.clone(), + slug: lore.slug.clone(), + author: lore.author.clone(), + summary: lore.summary.clone(), + tags: lore.tags.clone() + } + } + } + else if let Some(Err(e)) = &*list.read() + { + p { "Unable to show post header." } + p { "{e}" } + } + else + { + p { "Loading..." } + } + + {children} + } + } + } +} + +#[component] +pub fn ToggleTag(tag: String, toggled_tags: Signal>) -> Element +{ + rsx! + { + label + { + class: "toggle_button", + + input + { + r#type: "checkbox", + checked: toggled_tags.read().is_toggled(&tag), + onchange: move |_| + { + toggled_tags.write().toggle(&tag.clone()); + }, + } + + span { "{tag}" } + } + } +} + +#[component] +pub fn TagSelector(show_all: Signal, toggled_tags: Signal>) -> Element +{ + let toggle_all: bool = show_all(); + + println!("Tag Selector toggled tags: {:?}", toggled_tags()); + let tags_future = use_server_future(move || async move { get_tags().await })?; + + use_effect(move || + { + if let Some(Ok(tags)) = &*tags_future.read() + { + if toggle_all + { + for tag in tags + { + toggled_tags.write().insert(tag.clone()); + } + } + } + }); + + rsx! + { + section + { + class: "blog_nav_style", + div + { + class: "tag_style", + h2 { "Categories" } + ul + { + class: "tag_list", + + if let Some(Ok(tags)) = tags_future() + { + for tag in tags + { + li + { + key: "selector-{tag}", + class: "tag_item", + + ToggleTag + { + tag: &tag, + toggled_tags: toggled_tags + } + } + } + } + } + } + } + } +} diff --git a/bard/src/components/mod.rs b/bard/src/components/mod.rs new file mode 100644 index 0000000..72f408e --- /dev/null +++ b/bard/src/components/mod.rs @@ -0,0 +1,8 @@ +mod list; +mod post; +mod tags; + + +pub use self::list::*; +pub use self::post::*; +pub use self::tags::*; diff --git a/bard/src/components/post.rs b/bard/src/components/post.rs new file mode 100644 index 0000000..dce16ae --- /dev/null +++ b/bard/src/components/post.rs @@ -0,0 +1,180 @@ +use std::borrow::Borrow; +use std::collections::HashSet; +use std::hash::Hash; + +use dioxus::prelude::*; +use tavern::{Adventurer, Legend, Lore, Tale}; + +use crate::togglable::Togglable; +use crate::page::Page; +use crate::server::*; +use super::tags::*; + + + +#[component] +pub fn BlogAuthor() -> Element +{ + rsx! + { + section + { + class: "blog_author_style", + } + } +} + + +#[component] +pub fn PostHeaderAuthor(adventurer: Adventurer) -> Element +{ + rsx! + { + h4 + { + "Author: ", + a { href: "{adventurer.legend.profile}", "{adventurer.name} @{adventurer.handle}" } + } + } +} + +#[component] +pub fn PostHeader(tale: Tale) -> Element +{ + let author_future = use_server_future(move || + { + let handle: String = tale.lore.author.clone(); + + async move { get_author(handle).await } + })?; + + rsx! + { + h1 { {tale.lore.title} } + + TagList + { + for tag in tale.lore.tags + { + TagItem + { + tag: tag.clone() + } + } + } + + if let Some(Ok(Some(adventurer))) = (author_future.value())() + { + PostHeaderAuthor { adventurer: adventurer } + } + else + { + p { "Loading author..." } + } + } +} + +#[component] +pub fn Story(text: String) -> Element +{ + rsx! + { + div { dangerous_inner_html: "{text}" } + } +} + +#[component] +pub fn BlogPost(slug: Signal, children: Element) -> Element +{ + // Run a once off server fetch of the unchanging blog data. + let post_future = use_server_future(move || + { + // Make this reactive so that as the page changes it should rerun this. + let url_slug = slug(); + + async move { get_blog_post(url_slug).await } + })?; + + rsx! + { + article + { + class: "blog_post_style", + + if let Some(Ok(tale)) = (post_future.value())() + { + PostHeader + { + tale: tale.clone() + } + + Story { text: tale.story } + + + //Author {} + } + else if let Some(Err(e)) = (post_future.value())() + { + p { "Unable to load desired post: {slug.read()}" } + p { "{e}" } + } + else + { + p { "Loading..." } + } + + {children} + } + } +} + +#[component] +pub fn TagNav() -> Element +{ + let tags = use_server_future(move || async move { get_tags().await })?; + + rsx! + { + section + { + class: "blog_nav_style", + div + { + class: "tag_style", + h2 { "Categories" } + ul + { + class: "tag_list", + if let Some(Ok(categories)) = &*tags.read() + { + for tag in categories + { + li + { + key: "{tag}", + class: "tag_item", + Link + { + to: Page::Blog { tag: tag.clone() }, + + "{tag}" + } + + //a { href: "/blog/{tag}", "{tag}" } + } + } + } + else if let Some(Err(e)) = &*tags.read() + { + p { "Unable to show desired post." } + p { "{e}" } + } + else + { + p { "Loading..." } + } + } + } + } + } +} diff --git a/bard/src/components/tags.rs b/bard/src/components/tags.rs new file mode 100644 index 0000000..8d75385 --- /dev/null +++ b/bard/src/components/tags.rs @@ -0,0 +1,51 @@ +use dioxus::prelude::*; + +use crate::page::Page; + + + +#[component] +pub fn TagItem(tag: String, style: Option) -> Element +{ + let tag_style: String = match style + { + None => { String::new() } + Some(s) => { s } + }; + + rsx! + { + li + { + key: "{tag}", + class: "tag_item", + + Link + { + class: "{tag_style}", + + to: Page::Blog { tag: tag.clone() }, + + "{tag}" + } + } + } +} + +#[component] +pub fn TagList(children: Element) -> Element +{ + rsx! + { + h5 + { + class: "blog_tag_style", + + ul + { + class: "tag_list", + {children} + } + } + } +} diff --git a/bard/src/lib.rs b/bard/src/lib.rs index 0e5c501..6edbc26 100644 --- a/bard/src/lib.rs +++ b/bard/src/lib.rs @@ -6,6 +6,7 @@ mod components; mod page; mod pages; mod server; +mod togglable; diff --git a/bard/src/page.rs b/bard/src/page.rs index 4f1950f..7fa0471 100644 --- a/bard/src/page.rs +++ b/bard/src/page.rs @@ -1,13 +1,35 @@ use dioxus::prelude::*; -use crate::pages::{Blog, Post}; +use crate::pages::{Blog, Post, Root}; +const BLOG_CSS: Asset = asset!("/assets/css/blog.css"); + + + +#[component] +fn BlogLayout() -> Element +{ + rsx! + { + document::Stylesheet { href: BLOG_CSS } + + Outlet:: {} + } +} + + + +//#[derive(Debug, Clone, Routable, PartialEq, Eq, Hash, Debug, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, Routable, PartialEq)] #[rustfmt::skip] pub enum Page { + #[layout(BlogLayout)] + #[route("/")] + Root { }, + #[route("/:tag")] Blog { tag: String }, diff --git a/bard/src/pages.rs b/bard/src/pages.rs deleted file mode 100644 index a09e767..0000000 --- a/bard/src/pages.rs +++ /dev/null @@ -1,6 +0,0 @@ -mod blog; -mod post; - - -pub use crate::pages::blog::Blog; -pub use crate::pages::post::Post; diff --git a/bard/src/pages/blog.rs b/bard/src/pages/blog.rs index 7a9e978..5edad78 100644 --- a/bard/src/pages/blog.rs +++ b/bard/src/pages/blog.rs @@ -7,39 +7,37 @@ use crate::page::Page; -const BLOG_CSS: Asset = asset!("/assets/css/blog.css"); - - - fn convert_categories(categories: &str) -> HashSet { - if categories.is_empty() || categories == "all" - { - HashSet::new() - } - else - { - categories.split('+').map(|s| s.to_string()).collect() - } + categories + .split('+') + .filter(|s| !s.is_empty() && *s != "all") + .map(str::to_string) + .collect() } /// Blog page #[component] -pub fn Blog(tag: String) -> Element +pub fn Blog(tag: ReadOnlySignal) -> Element { + let mut show_all: Signal = + use_signal(|| tag().is_empty() || tag() == "all"); + let mut categories: Signal> = - use_signal(|| convert_categories(&tag)); + use_signal(|| convert_categories(&tag())); - if *categories.read() != convert_categories(&tag) + use_effect(move || { - categories.set(convert_categories(&tag)); - } + let new_tags = convert_categories(&tag()); + categories.set(new_tags); + }); - rsx! { - document::Stylesheet { href: BLOG_CSS } + println!("Blog Categories: {:?}", categories()); + rsx! + { main { class: "blog_style", @@ -49,12 +47,13 @@ pub fn Blog(tag: String) -> Element BlogList { - tags: categories + tags: categories.clone() } TagSelector { - toggled_tags: categories + show_all: show_all.clone(), + toggled_tags: categories.clone() } } } diff --git a/bard/src/pages/mod.rs b/bard/src/pages/mod.rs new file mode 100644 index 0000000..69dd3b0 --- /dev/null +++ b/bard/src/pages/mod.rs @@ -0,0 +1,8 @@ +mod blog; +mod post; +mod root; + + +pub use self::blog::Blog; +pub use self::post::Post; +pub use self::root::Root; diff --git a/bard/src/pages/post.rs b/bard/src/pages/post.rs index 698c9a0..b3b09e6 100644 --- a/bard/src/pages/post.rs +++ b/bard/src/pages/post.rs @@ -5,25 +5,15 @@ use crate::page::Page; -const BLOG_CSS: Asset = asset!("/assets/css/blog.css"); - - - /// Blog page #[component] -pub fn Post(slug: String) -> Element +pub fn Post(slug: ReadOnlySignal) -> Element { // Create a copy of the current slug to detect changes. - let mut url_slug = use_signal(|| slug.clone()); + let mut url_slug = use_signal(|| slug()); - if *url_slug.read() != slug + rsx! { - url_slug.set(slug.clone()); - } - - rsx! { - document::Stylesheet { href: BLOG_CSS } - main { class: "blog_style", diff --git a/bard/src/pages/root.rs b/bard/src/pages/root.rs new file mode 100644 index 0000000..3820678 --- /dev/null +++ b/bard/src/pages/root.rs @@ -0,0 +1,21 @@ +use std::collections::HashSet; + +use dioxus::prelude::*; + +use crate::page::Page; +use crate::pages::Blog; + + + +/// Blog page +#[component] +pub fn Root() -> Element +{ + rsx! + { + Blog + { + tag: String::new() + } + } +} diff --git a/bard/src/server.rs b/bard/src/server.rs index 8ea7407..7c153ed 100644 --- a/bard/src/server.rs +++ b/bard/src/server.rs @@ -96,13 +96,11 @@ pub async fn get_blog_post(slug: String) -> Result #[server] -pub async fn get_author(handle: String) -> Result +pub async fn get_author(handle: String) -> Result, ServerFnError> { let db = get_database().await?; - let author = db.get_adventurer(&handle) .await .map_err(|e| ServerFnError::new(e))?; - - author.ok_or(ServerFnError::new(format!("Author {} not found.", handle))) + Ok(author) } diff --git a/bard/src/togglable.rs b/bard/src/togglable.rs new file mode 100644 index 0000000..6a2afb5 --- /dev/null +++ b/bard/src/togglable.rs @@ -0,0 +1,37 @@ +use std::collections::HashSet; + + + +/// Trait to toggle the presence of a value (like a tag) in a set. +pub trait Togglable +{ + type Item: ?Sized; + + fn toggle(&mut self, item: &Self::Item); + fn is_toggled(&self, item: &Self::Item) -> bool; +} + + + +impl Togglable for HashSet +{ + type Item = str; + + + fn toggle(&mut self, item: &str) + { + if self.contains(item) + { + self.remove(item); + } + else + { + self.insert(item.to_owned()); + } + } + + fn is_toggled(&self, item: &str) -> bool + { + self.contains(item) + } +}