From bfbdb3d95f766fbb5ca705861d4f943f21b766aa Mon Sep 17 00:00:00 2001 From: Myrddin Dundragon Date: Fri, 12 Sep 2025 13:52:35 -0400 Subject: [PATCH] Adding more reactive pieces to our components. --- bard/Cargo.toml | 2 +- bard/assets/css/blog_nav.css | 1 + bard/assets/css/blog_post.css | 4 +- bard/src/components.rs | 400 ++++++++++++++++++++++++---------- bard/src/pages/blog.rs | 24 +- bard/src/pages/post.rs | 11 +- 6 files changed, 320 insertions(+), 122 deletions(-) diff --git a/bard/Cargo.toml b/bard/Cargo.toml index e5cb214..2bbb3d0 100644 --- a/bard/Cargo.toml +++ b/bard/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bard" -version = "0.0.18" +version = "0.0.19" edition = "2024" description = "Dioxus components that will display a Tavern blogging system Blog." repository = "/CyberMages/tavern" diff --git a/bard/assets/css/blog_nav.css b/bard/assets/css/blog_nav.css index c22149a..5b9bd02 100644 --- a/bard/assets/css/blog_nav.css +++ b/bard/assets/css/blog_nav.css @@ -24,6 +24,7 @@ { width: 15%; padding: 0px 20px; + order: 5; h3 { diff --git a/bard/assets/css/blog_post.css b/bard/assets/css/blog_post.css index 6df9db2..3f28a14 100644 --- a/bard/assets/css/blog_post.css +++ b/bard/assets/css/blog_post.css @@ -1,5 +1,7 @@ .blog_post_style { + order: 1; + h1 { color: var(--accent-color); @@ -28,7 +30,7 @@ box-shadow: 0 0 80px var(--accent-color); } } - +} diff --git a/bard/src/components.rs b/bard/src/components.rs index f19acb5..6bcbb12 100644 --- a/bard/src/components.rs +++ b/bard/src/components.rs @@ -1,3 +1,7 @@ +use std::borrow::Borrow; +use std::collections::HashSet; +use std::hash::Hash; + use dioxus::prelude::*; use tavern::{Adventurer, Legend, Lore, Tale}; @@ -14,6 +18,40 @@ 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 { @@ -45,18 +83,18 @@ fn strip_tag(text: &str) -> &str 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}" } + li + { + class: "tag_item", + Link + { + class: "{style}", + to: Page::Blog { tag: tag.clone() }, + "{tag}" + } + // a { class: "{style}", "{tag}" } + } } - } } #[component] @@ -129,13 +167,25 @@ pub fn BlogItem(title: String, slug: String, author: String, summary: String, #[component] -pub fn BlogList(tags: Vec, children: Element) -> Element +pub fn BlogList(tags: Signal>, children: Element) -> Element { - let summaries = use_server_future(move || { - let categories = tags.clone(); + 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 } @@ -146,7 +196,7 @@ pub fn BlogList(tags: Vec, children: Element) -> Element { class: "blog_list", - match &*summaries.read() + match &*list.read() { Some(Ok(lores)) => { @@ -191,11 +241,22 @@ pub fn BlogList(tags: Vec, children: Element) -> Element } #[component] -pub fn BlogNav() -> Element +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 })?; - rsx! { + 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 @@ -204,129 +265,178 @@ pub fn BlogNav() -> Element div { class: "tag_style", - h3 { "Categories" } + h2 { "Categories" } ul { class: "tag_list", - match &*tags.read() - { - Some(Ok(categories)) => - { - rsx! - { - for tag in categories - { - li - { - class: "tag_item", - a { href: "/blog/{strip_tag(tag)}", "{strip_tag(tag)}" } - } - } - } - } - Some(Err(e)) => - { - rsx! - { - p { "Unable to show desired post." } - p { "{e}" } - } - } - - None => - { - rsx! + if categories.read().is_empty() { p { "Loading..." } } - } - } - } - } - } - } -} - - -#[component] -pub fn PostHeaderAuthor(name: String, handle: String, profile_link: String) - -> Element -{ - rsx! { - h4 { "Author: ", a { href: "{profile_link}", "{name} @{handle}" } } - } -} - -#[component] -pub fn PostHeader(title: String, author: String, tags: Vec) -> Element -{ - let author = use_server_future(move || { - let target_author = author.clone(); - async move { get_author(target_author).await } - })?; - - - let converted_tags: Vec<(String, String)> = - tags.iter().map(|t| convert_tag(t)).collect(); - - rsx! { - h1 { "{title}" } - TagList - { - for (tag, style) in converted_tags - { - TagItem - { - tag: tag.clone(), - style: style.clone() - } - } - } - - match &*author.read() - { - Some(Ok(author)) => - { - rsx! - { - PostHeaderAuthor + else { - name: author.name.clone(), - handle: author.handle.clone(), - profile_link: author.legend.profile.clone() + 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)}" + } + } + } } } } + } + } +} - Some(Err(e)) => +#[component] +pub fn PostHeaderAuthor() -> Element +{ + let author: Signal> = + use_context::>>(); + + rsx! { + match author() + { + Some(author) => { rsx! { - p { "Unable to show post header." } - p { "{e}" } + h4 + { + "Author: ", + a { href: "{author.legend.profile}", "{author.name} @{author.handle}" } + } } } None => { - rsx! - { - p { "Loading..." } - } + rsx! { h4 { "Author: Unknown" } } } } } } #[component] -pub fn BlogPost(slug: String, children: Element) -> Element +pub fn PostHeader() -> Element { + let tale: Signal> = use_context::>>(); + let adventurer = use_context::>>(); + + 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! { + match tale() + { + Some(tale) => + { + rsx! + { + h1 { {tale.lore.title} } + TagList + { + for (tag, style) in converted_tags().iter() + { + TagItem + { + tag: tag.clone(), + style: style.clone() + } + } + } + } + } + + None => + { + rsx! { p { "Loading post header..." } } + } + } + + match adventurer() + { + Some(author) => + { + rsx! { PostHeaderAuthor {} } + } + + None => + { + rsx! { p { "Loading author..." } } + } + } + } +} + +#[component] +pub fn BlogPost(slug: Signal, children: Element) -> Element +{ + // Run a once off server fetch of the unchanging blog data. let post = use_server_future(move || { - let url_slug = slug.clone(); + // 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 } })?; + // The tale depends on the post future resolving. + let mut tale: Signal> = use_signal(|| None); + use_context_provider(|| tale); + + let author = use_server_future(move || { + let handle = match tale() + { + Some(data) => data.lore.author, + + None => "unknown_author_handle".to_string() + }; + + async move { get_author(handle).await } + })?; + + let mut adventurer = use_signal(|| None); + use_context_provider(|| adventurer); + + 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 } @@ -340,14 +450,13 @@ pub fn BlogPost(slug: String, children: Element) -> Element { rsx! { - PostHeader - { - title: post.lore.title.clone(), - author: post.lore.author.clone(), - tags: post.lore.tags.clone() - } + //PostHeader {} + + //Story {} div { dangerous_inner_html: "{post.story}" } + + //Author {} } } @@ -355,7 +464,7 @@ pub fn BlogPost(slug: String, children: Element) -> Element { rsx! { - p { "Unable to show desired post." } + p { "Unable to load desired post: {slug.read()}" } p { "{e}" } } } @@ -373,3 +482,62 @@ pub fn BlogPost(slug: String, children: Element) -> Element } } } + +#[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", + match &*tags.read() + { + Some(Ok(categories)) => + { + rsx! + { + for tag in categories + { + li + { + class: "tag_item", + a { href: "/blog/{strip_tag(tag)}", "{strip_tag(tag)}" } + } + } + } + } + + Some(Err(e)) => + { + rsx! + { + p { "Unable to show desired post." } + p { "{e}" } + } + } + + None => + { + rsx! + { + p { "Loading..." } + } + } + } + } + } + } + } +} diff --git a/bard/src/pages/blog.rs b/bard/src/pages/blog.rs index fdc383e..f642ef2 100644 --- a/bard/src/pages/blog.rs +++ b/bard/src/pages/blog.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use dioxus::prelude::*; use crate::components::BlogList; @@ -9,15 +11,30 @@ 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() + } +} + + + /// Blog page #[component] pub fn Blog(tag: String) -> Element { - let mut categories: Vec = vec![]; + let mut categories: Signal> = + use_signal(|| convert_categories(&tag)); - if !tag.is_empty() && tag != "all" + if *categories.read() != convert_categories(&tag) { - categories.push(tag); + categories.set(convert_categories(&tag)); } rsx! { @@ -29,6 +46,7 @@ pub fn Blog(tag: String) -> Element div { class: "page_content", + BlogList { tags: categories diff --git a/bard/src/pages/post.rs b/bard/src/pages/post.rs index fc6b7f8..b6531fc 100644 --- a/bard/src/pages/post.rs +++ b/bard/src/pages/post.rs @@ -13,6 +13,14 @@ const BLOG_CSS: Asset = asset!("/assets/css/blog.css"); #[component] pub fn Post(slug: String) -> Element { + // Create a copy of the current slug to detect changes. + let mut url_slug = use_signal(|| slug.clone()); + + if *url_slug.read() != slug + { + url_slug.set(slug.clone()); + } + rsx! { document::Stylesheet { href: BLOG_CSS } @@ -22,9 +30,10 @@ pub fn Post(slug: String) -> Element div { class: "page_content", + BlogPost { - slug: slug + slug: url_slug } } }