Compare commits

...

5 Commits

Author SHA1 Message Date
6ec711f7ed Added the blog_test harness to the workspace. 2025-09-24 19:54:50 -04:00
6acbe95024 This is a test project for Bard.
This was done because setting up a test even as an example was hard with
the dependencies and other things. It may be doable, but the time
required was not worth it.
2025-09-24 19:52:25 -04:00
8f61185434 Adding and moving a lot of components.
This is the main thrust of the library. I know it is a lot of changes,
but I was running out of time and had to hammer them all in.
2025-09-24 19:50:38 -04:00
f752fa17bf Created the loremaster.
This is an application that uses tavern to create/publish a database
from a blog repository.
2025-09-24 19:40:57 -04:00
01dacabc03 Adjusted the tale summary retrieval.
The SQL was not properly searching the tales. It had been searching and
finding all the tags that matched the search criteria, but it was
dropping all the other tag information. Now it properly keeps all the
tag information. This fixed an issue where the tags of a post were
disapearing and reapearing as the TagSelector was toggled.
2025-09-24 19:36:29 -04:00
29 changed files with 3172 additions and 668 deletions

2237
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,19 @@
members = [
"tavern",
"bard",
"loremaster"
]
"loremaster",
"blog_test"]
resolver = "2" # Enables modern dependency resolution
[profile]
[profile.wasm-dev]
inherits = "dev"
opt-level = 1
[profile.server-dev]
inherits = "dev"
[profile.android-dev]
inherits = "dev"

View File

@ -9,11 +9,17 @@ authors = ["CyberMages LLC <Software@CyberMagesLLC.com>",
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"]

View File

@ -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<String>
{
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<S>(text: S) -> (String, String)
where S: AsRef<str>
{
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<String>)
-> 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<HashSet<String>>, 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<Option<Vec<Lore>>> = 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<HashSet<String>>) -> Element
{
let mut categories = use_signal(|| Vec::<String>::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<Option<Adventurer>>) -> 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<Option<Tale>>,
adventurer: Signal<Option<Adventurer>>) -> Element
{
let converted_tags = use_memo(move || {
tale().as_ref()
.map(|t| {
t.lore
.tags
.iter()
.map(|tag| convert_tag(tag))
.collect::<Vec<_>>()
})
.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<String>, children: Element) -> Element
{
// The tale depends on the post future resolving.
let mut tale: Signal<Option<Tale>> = 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..." }
}
}
}
}
}
}

194
bard/src/components/list.rs Normal file
View File

@ -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<String>)
-> 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<HashSet<String>>, 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<HashSet<String>>) -> 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<bool>, toggled_tags: Signal<HashSet<String>>) -> 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
}
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,8 @@
mod list;
mod post;
mod tags;
pub use self::list::*;
pub use self::post::*;
pub use self::tags::*;

180
bard/src/components/post.rs Normal file
View File

@ -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<String>, 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..." }
}
}
}
}
}
}

View File

@ -0,0 +1,51 @@
use dioxus::prelude::*;
use crate::page::Page;
#[component]
pub fn TagItem(tag: String, style: Option<String>) -> 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}
}
}
}
}

View File

@ -6,6 +6,7 @@ mod components;
mod page;
mod pages;
mod server;
mod togglable;

View File

@ -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::<Page> {}
}
}
//#[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 },

View File

@ -1,6 +0,0 @@
mod blog;
mod post;
pub use crate::pages::blog::Blog;
pub use crate::pages::post::Post;

View File

@ -7,39 +7,37 @@ use crate::page::Page;
const BLOG_CSS: Asset = asset!("/assets/css/blog.css");
fn convert_categories(categories: &str) -> HashSet<String>
{
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<String>) -> Element
{
let mut show_all: Signal<bool> =
use_signal(|| tag().is_empty() || tag() == "all");
let mut categories: Signal<HashSet<String>> =
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()
}
}
}

8
bard/src/pages/mod.rs Normal file
View File

@ -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;

View File

@ -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<String>) -> 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",

21
bard/src/pages/root.rs Normal file
View File

@ -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()
}
}
}

View File

@ -96,13 +96,11 @@ pub async fn get_blog_post(slug: String) -> Result<Tale, ServerFnError>
#[server]
pub async fn get_author(handle: String) -> Result<Adventurer, ServerFnError>
pub async fn get_author(handle: String) -> Result<Option<Adventurer>, 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)
}

37
bard/src/togglable.rs Normal file
View File

@ -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<String>
{
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)
}
}

7
blog_test/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target
.DS_Store
# These are backup files generated by rustfmt
**/*.rs.bk

30
blog_test/Cargo.toml Normal file
View File

@ -0,0 +1,30 @@
[package]
name = "blog_test"
version = "0.1.0"
authors = ["Myrddin Dundragon <myrddin@cybermages.tech>"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
axum = { version = "0.7.0", optional = true }
axum-server = { version = "0.7.1", optional = true }
dioxus = { version = "*", features = ["router", "fullstack"] }
dioxus-cli-config = { version = "*", optional = true }
bard = { version = "*", path="../bard", optional = true }
tokio = { version = "1.0", optional = true }
[features]
default = ["web"]
web = ["dioxus/web", "bard"]
server = ["dioxus/server", "axum", "axum-server", "tokio/rt-multi-thread", "tokio/macros", "dioxus-cli-config", "bard/server"]
[profile.wasm-dev]
inherits = "dev"
opt-level = 1
[profile.server-dev]
inherits = "dev"
[profile.android-dev]
inherits = "dev"

21
blog_test/Dioxus.toml Normal file
View File

@ -0,0 +1,21 @@
[application]
[web.app]
# HTML title tag content
title = "blog_test"
# include `assets` in web platform
[web.resource]
# Additional CSS style files
style = []
# Additional JavaScript files
script = []
[web.resource.dev]
# Javascript code file
# serve: [dev-server] only
script = []

25
blog_test/README.md Normal file
View File

@ -0,0 +1,25 @@
# Development
Your new bare-bones project includes minimal organization with a single `main.rs` file and a few assets.
```
project/
├─ assets/ # Any assets that are used by the app should be placed here
├─ src/
│ ├─ main.rs # main.rs is the entry point to your application and currently contains all components for the app
├─ Cargo.toml # The Cargo.toml file defines the dependencies and feature flags for your project
```
### Serving Your App
Run the following command in the root of your project to start developing with the default platform:
```bash
dx serve --platform web
```
To run for a different platform, use the `--platform platform` flag. E.g.
```bash
dx serve --platform desktop
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

107
blog_test/assets/main.css Normal file
View File

@ -0,0 +1,107 @@
/* App-wide styling */
body {
background-color: #0f1116;
color: #ffffff;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 20px;
}
#hero {
margin: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
#links {
width: 400px;
text-align: left;
font-size: x-large;
color: white;
display: flex;
flex-direction: column;
}
#links a {
color: white;
text-decoration: none;
margin-top: 20px;
margin: 10px 0px;
border: white 1px solid;
border-radius: 5px;
padding: 10px;
}
#links a:hover {
background-color: #1f1f1f;
cursor: pointer;
}
#header {
max-width: 1200px;
}
/* Navbar */
#navbar {
display: flex;
flex-direction: row;
}
#navbar a {
color: #ffffff;
margin-right: 20px;
text-decoration: none;
transition: color 0.2s ease;
}
#navbar a:hover {
cursor: pointer;
color: #91a4d2;
}
/* Blog page */
#blog {
margin-top: 50px;
}
#blog a {
color: #ffffff;
margin-top: 50px;
}
/* Echo */
#echo {
width: 360px;
margin-left: auto;
margin-right: auto;
margin-top: 50px;
background-color: #1e222d;
padding: 20px;
border-radius: 10px;
}
#echo>h4 {
margin: 0px 0px 15px 0px;
}
#echo>input {
border: none;
border-bottom: 1px white solid;
background-color: transparent;
color: #ffffff;
transition: border-bottom-color 0.2s ease;
outline: none;
display: block;
padding: 0px 0px 5px 0px;
width: 100%;
}
#echo>input:focus {
border-bottom-color: #6d85c6;
}
#echo>p {
margin: 20px 0px 0px auto;
}

8
blog_test/clippy.toml Normal file
View File

@ -0,0 +1,8 @@
await-holding-invalid-types = [
"generational_box::GenerationalRef",
{ path = "generational_box::GenerationalRef", reason = "Reads should not be held over an await point. This will cause any writes to fail while the await is pending since the read borrow is still active." },
"generational_box::GenerationalRefMut",
{ path = "generational_box::GenerationalRefMut", reason = "Write should not be held over an await point. This will cause any reads or writes to fail while the await is pending since the write borrow is still active." },
"dioxus_signals::Write",
{ path = "dioxus_signals::Write", reason = "Write should not be held over an await point. This will cause any reads or writes to fail while the await is pending since the write borrow is still active." },
]

116
blog_test/src/main.rs Normal file
View File

@ -0,0 +1,116 @@
use std::net::SocketAddr;
use dioxus::prelude::*;
#[cfg(feature = "server")]
use axum::Router;
#[cfg(feature = "server")]
use axum::ServiceExt;
#[cfg(feature = "server")]
use axum::extract::{Extension, Host};
#[cfg(feature = "server")]
use axum::http::uri::{Parts, Uri};
#[cfg(feature = "server")]
use axum::http::StatusCode;
#[cfg(feature = "server")]
use axum::response::{IntoResponse, Redirect};
#[cfg(feature = "server")]
use axum::routing::get;
use bard::*;
const FAVICON: Asset = asset!("/assets/favicon.ico");
fn main()
{
#[cfg(feature = "server")]
{
tokio::runtime::Runtime::new()
.unwrap()
.block_on(async move { bard::init_database("/home/myrddin/cybermages/website/tavern.db").await });
}
#[cfg(feature = "web")]
dioxus::launch(App);
}
#[component]
fn App() -> Element
{
rsx!
{
document::Link { rel: "icon", href: FAVICON }
Router::<Page> {}
}
}
/// Home page
#[component]
fn Home() -> Element
{
rsx!
{
h1 { "Blog Test" }
}
}
/// This is the content for the About page.
#[component]
pub fn PageNotFound(route: Vec<String>) -> Element
{
rsx!
{
h1 { "Page not found" }
p { "We are terribly sorry, but the page you requested doesn't exist." }
pre { color: "red", "log:\nattemped to navigate to: {route:?}" }
}
}
/// Shared navbar component.
#[component]
fn Navbar() -> Element
{
rsx! {
div {
id: "navbar",
Link {
to: Page::Home {},
"Home"
}
Link {
to: Page::Bard{ child: bard::Page::Blog { tag: String::from("all") }},
"Blog"
}
//a { href: "/blog/all", "Blog" }
}
Outlet::<Page> {}
}
}
#[derive(Debug, Clone, Routable, PartialEq)]
#[rustfmt::skip]
pub enum Page
{
#[layout(Navbar)]
#[route("/")]
Home {},
// #[nest("/blog")]
#[child("/blog")]
Bard
{
child: bard::Page
},
// #[end_nest]
#[route("/:..route")]
PageNotFound { route: Vec<String> }
}

View File

@ -10,3 +10,6 @@ readme = "README.md"
license = "Apache-2.0"
[dependencies]
clap = { version = "4.5.46", features = ["derive"] }
tavern = { version = "0.2.4", path = "../tavern", registry = "cybermages", features = ["publisher"] }
tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros"] }

View File

@ -4,15 +4,34 @@ mod info;
/// Print the version.
fn print_version()
use clap::Parser;
use tavern::{Database, Tavern};
#[derive(Parser)]
#[command(version, about)]
struct Options
{
println!("{} v{}", info::get_name(), info::get_version());
#[arg(short = 'c', long = "config", default_value = "Tavern.toml")]
config_file: std::path::PathBuf,
#[arg(short = 'o', long = "output", default_value = "tavern.db")]
output: std::path::PathBuf
}
fn main()
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>>
{
print_version();
let options = Options::parse();
let tavern: Tavern = Tavern::from_config_file(&options.config_file);
let database = Database::open(&options.output).await?;
database.insert_tavern(&tavern).await?;
Ok(())
}

View File

@ -299,70 +299,82 @@ impl Database
pub async fn get_tales_summary(&self, categories: &[String])
-> Result<Vec<Lore>>
{
let mut tales = Vec::new();
let mut tales = Vec::new();
// Start a read-only transaction.
let mut tx = self.pool.begin().await?;
// Start a read-only transaction.
let mut tx = self.pool.begin().await?;
// Dynamically build the query.
let mut query = String::from(
"SELECT
t.title,
t.slug,
t.summary,
t.author,
t.publish_date,
GROUP_CONCAT(tg.name, ',') AS tags
FROM tales AS t
LEFT JOIN tale_tags AS tt ON t.slug = tt.tale_slug
LEFT JOIN tags AS tg ON tt.tag_id = tg.id"
);
// Build the base query string with placeholders for categories in the EXISTS clause.
// We add a parameter for categories length to check if filtering is needed.
let mut query = String::from(
"SELECT
t.title,
t.slug,
t.summary,
t.author,
t.publish_date,
GROUP_CONCAT(tg_all.name, ',') AS tags
FROM tales AS t
LEFT JOIN tale_tags AS tt_all ON t.slug = tt_all.tale_slug
LEFT JOIN tags AS tg_all ON tt_all.tag_id = tg_all.id
WHERE (? = 0 OR EXISTS (
SELECT 1 FROM tale_tags tt_filter
JOIN tags tg_filter ON tt_filter.tag_id = tg_filter.id
WHERE tt_filter.tale_slug = t.slug
AND tg_filter.name IN ("
);
if !categories.is_empty()
{
query.push_str(" WHERE tg.name IN (");
let placeholders: Vec<_> =
(0..categories.len()).map(|_| "?").collect();
query.push_str(&placeholders.join(", "));
query.push(')');
}
// Add placeholders for category names in EXISTS IN clause
if !categories.is_empty() {
let placeholders: Vec<_> = (0..categories.len()).map(|_| "?").collect();
query.push_str(&placeholders.join(", "));
} else {
// No categories, so dummy placeholder to satisfy SQL syntax
query.push_str("NULL");
}
query.push_str("))) GROUP BY t.slug ORDER BY t.publish_date DESC");
query.push_str(" GROUP BY t.slug ORDER BY t.publish_date DESC");
// Prepare query with sqlx
let mut q = sqlx::query(&query);
let mut q = sqlx::query(&query);
for cat in categories
{
q = q.bind(cat);
}
// Bind the length of categories for the (? = 0) check
q = q.bind(categories.len() as i64);
let rows = q.fetch_all(&mut *tx).await?;
let current_time = chrono::Utc::now().naive_utc();
// Bind the category names if any
for cat in categories {
q = q.bind(cat);
}
for row in rows
{
let date_str: String = row.try_get("publish_date")?;
let publish_date = chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%d %H:%M:%S")
.map_err(|e| sqlx::Error::Decode(e.into()))?;
// Execute the query
let rows = q.fetch_all(&mut *tx).await?;
let current_time = chrono::Utc::now().naive_utc();
// Only give tales that are ready to be published.
if current_time >= publish_date
{
let tags_str: Option<String> = row.try_get("tags")?;
let tags = tags_str.map(|s| s.split(',').map(String::from).collect())
.unwrap_or_default();
for row in rows {
let date_str: String = row.try_get("publish_date")?;
let publish_date = chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%d %H:%M:%S")
.map_err(|e| sqlx::Error::Decode(e.into()))?;
tales.push(Lore { title: row.try_get("title")?,
slug: row.try_get("slug")?,
summary: row.try_get("summary")?,
author: row.try_get("author")?,
publish_date,
tags });
}
}
// Only include tales that are ready to be published.
if current_time >= publish_date {
let tags_str: Option<String> = row.try_get("tags")?;
let tags = tags_str
.map(|s| s.split(',').map(String::from).collect())
.unwrap_or_default();
tx.commit().await?; // Explicit commit, even for read transactions.
tales.push(Lore {
title: row.try_get("title")?,
slug: row.try_get("slug")?,
summary: row.try_get("summary")?,
author: row.try_get("author")?,
publish_date,
tags,
});
}
}
Ok(tales)
tx.commit().await?; // Explicit commit, even for read transactions.
Ok(tales)
}
#[cfg(any(not(feature = "publisher"), feature = "tester"))]