Compare commits
36 Commits
2ffe20254c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 53e62fcb68 | |||
| 04c2182705 | |||
| f023f36558 | |||
| 62a2c4f5bc | |||
| 87bb1459b8 | |||
| 8dd20cb0cb | |||
| 55f1ac22b0 | |||
| c3ae332b0b | |||
| dede2e9072 | |||
| 4c03aea947 | |||
| ee82498161 | |||
| d822ecdf84 | |||
| e8a4c19fdb | |||
| 1a57f3d143 | |||
| 1a82e856d9 | |||
| e49ecbac4d | |||
| bca3e9f939 | |||
| 3fe3d874ca | |||
| 725824003b | |||
| ce80af94ee | |||
| cba2e95290 | |||
| 468b9449b2 | |||
| 6efec6bd22 | |||
| 3f4440ec2f | |||
| e7969c8050 | |||
| c73c99bf48 | |||
| 40b211d06c | |||
| fd51c7c0bb | |||
| c5c433edb5 | |||
| 54dc751dca | |||
| 655dc8e90f | |||
| 6ec711f7ed | |||
| 6acbe95024 | |||
| 8f61185434 | |||
| f752fa17bf | |||
| 01dacabc03 |
41
.gitea/workflows/build.yaml
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
name: Build Tavernworks
|
||||||
|
run-name: Building on Silverymoon by ${{ gitea.actor }}.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
Explore-Gitea-Actions:
|
||||||
|
runs-on: FreeBSD-14.1
|
||||||
|
env:
|
||||||
|
SOURCE_DIR: tavernworks
|
||||||
|
TARGET_DIR: /home/gitea/bin
|
||||||
|
steps:
|
||||||
|
- name: Setup Workspace
|
||||||
|
run: |
|
||||||
|
rm -rf ${{ github.workspace }}/*
|
||||||
|
echo "Source Dir: $SOURCE_DIR"
|
||||||
|
- name: Clone Repo
|
||||||
|
uses: https://gitea.com/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: CyberMages/tavernworks
|
||||||
|
token: ${{ secrets.CM_GIT_TOKEN }}
|
||||||
|
path: ${{ env.SOURCE_DIR }}
|
||||||
|
- name: Build Repo
|
||||||
|
run: |
|
||||||
|
pwd
|
||||||
|
ls -la
|
||||||
|
cd "$SOURCE_DIR"
|
||||||
|
echo "Current Shell: $SHELL"
|
||||||
|
echo "PATH=$PATH"
|
||||||
|
. ~/.profile
|
||||||
|
echo "PATH=$PATH"
|
||||||
|
echo "Building release."
|
||||||
|
cargo build --release
|
||||||
|
echo "Installing loreweaver."
|
||||||
|
cp ./target/release/loreweaver "$TARGET_DIR"
|
||||||
|
- name: Clean Workspace
|
||||||
|
run: |
|
||||||
|
rm -rf ${{ github.workspace }}/*
|
||||||
|
ls -la
|
||||||
3233
Cargo.lock
generated
16
Cargo.toml
@ -2,7 +2,19 @@
|
|||||||
members = [
|
members = [
|
||||||
"tavern",
|
"tavern",
|
||||||
"bard",
|
"bard",
|
||||||
"loremaster"
|
"loreweaver",
|
||||||
]
|
"blog_test"]
|
||||||
|
|
||||||
resolver = "2" # Enables modern dependency resolution
|
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"
|
||||||
|
|||||||
49
README.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# Tavernworks
|
||||||
|
|
||||||
|
Tavernworks is a modular blog system written in Rust. It is designed to parse,
|
||||||
|
compile, and serve structured blog content using a cohesive set of themed
|
||||||
|
libraries. The system is centered around the `tavern` content engine and is
|
||||||
|
supported by a publisher CLI and a rendering frontend.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
TavernWorks consists of the following components:
|
||||||
|
|
||||||
|
### tavern
|
||||||
|
|
||||||
|
The core library that defines the data model, parses content, and provides the
|
||||||
|
API to read and write blog data. It is responsible for turning a repository of
|
||||||
|
Markdown files and metadata into an in-memory structure or database.
|
||||||
|
|
||||||
|
### loreweaver
|
||||||
|
|
||||||
|
A command-line tool that uses `tavern` to compile a blog repository into a
|
||||||
|
SQLite database. It reads a `Tavern.toml` metadata file and outputs a portable
|
||||||
|
database that can be served or inspected.
|
||||||
|
|
||||||
|
### bard
|
||||||
|
|
||||||
|
A Dioxus-based frontend library that renders blog content from the database.
|
||||||
|
It provides components to create a full blog UI using data produced by
|
||||||
|
`loreweaver`.
|
||||||
|
|
||||||
|
### blog_test
|
||||||
|
|
||||||
|
A test project that verifies the full system from content to rendering. It is
|
||||||
|
used to validate that blog repositories are parsed, compiled, and rendered
|
||||||
|
correctly across all components.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Copyright & License
|
||||||
|
|
||||||
|
Copyright 2025 CyberMages LLC
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this library except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS
|
||||||
BIN
assets/images/tavernworks_card.png
Normal file
|
After Width: | Height: | Size: 246 KiB |
BIN
assets/images/tavernworks_logo.png
Normal file
|
After Width: | Height: | Size: 442 KiB |
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "bard"
|
name = "bard"
|
||||||
version = "0.0.23"
|
version = "0.3.17"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Dioxus components that will display a Tavern blogging system Blog."
|
description = "Dioxus components that will display a Tavern blogging system Blog."
|
||||||
repository = "/CyberMages/tavern"
|
repository = "/CyberMages/tavern"
|
||||||
@ -9,11 +9,17 @@ authors = ["CyberMages LLC <Software@CyberMagesLLC.com>",
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "blog"
|
||||||
|
path = "examples/blog.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
dioxus = { version = "*", features = ["router", "fullstack"], optional = true }
|
dioxus = { version = "=0.7.0-rc.1", features = ["router", "fullstack"] }
|
||||||
tavern = { version = "0.2.9", path = "../tavern", registry = "cybermages", optional = true}
|
tavern = { version = "0.3.0", path = "../tavern", registry="cybermages", optional = true}
|
||||||
tokio = { version = "1.0", features = ["full"], optional = true }
|
tokio = { version = "1.0", features = ["rt", "macros"], optional = true }
|
||||||
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["tavern", "dioxus/web"]
|
default = ["tavern"]
|
||||||
|
web = ["tavern", "dioxus/web"]
|
||||||
server = ["tavern/database", "dioxus/server", "tokio"]
|
server = ["tavern/database", "dioxus/server", "tokio"]
|
||||||
|
|||||||
@ -1,7 +1,72 @@
|
|||||||
# Tavern
|
# Bard
|
||||||
|
|
||||||
A blogging system that will allow you to write your blog in Markdown and then
|
Bard is the frontend rendering library for the Tavernworks blog system.
|
||||||
display it in HTML using Dioxus.
|
It provides Dioxus components for rendering blog content that has been compiled
|
||||||
|
into a SQLite database by Loreweaver and parsed by the Tavern engine.
|
||||||
|
|
||||||
|
Bard supports both server-side and client-side rendering and is designed to be
|
||||||
|
integrated into Dioxus applications.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Dioxus components for routing and rendering blog pages
|
||||||
|
- Works with content stored in a SQLite database
|
||||||
|
- Compatible with `dioxus-web` and `dioxus-desktop`
|
||||||
|
- Optional server integration via Axum for local hosting
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
To use Bard in a Dioxus application, you must:
|
||||||
|
|
||||||
|
1. Initialize the database using `bard::init_database`.
|
||||||
|
2. Add the `Router::<bard::Page>` component to your app.
|
||||||
|
3. Optionally, define routes that include Bard under your own layout.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[component]
|
||||||
|
fn App() -> Element {
|
||||||
|
rsx! {
|
||||||
|
Router::<Page> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Routable, Clone, PartialEq)]
|
||||||
|
pub enum Page {
|
||||||
|
#[layout(Navbar)]
|
||||||
|
#[route("/")]
|
||||||
|
Home {},
|
||||||
|
|
||||||
|
#[child("/blog")]
|
||||||
|
Bard
|
||||||
|
{
|
||||||
|
child: bard::Page
|
||||||
|
},
|
||||||
|
|
||||||
|
#[route("/:..route")]
|
||||||
|
PageNotFound { route: Vec<String> }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server-side Initialization
|
||||||
|
|
||||||
|
To serve the blog using Axum and load the database:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
tokio::runtime::Runtime::new()
|
||||||
|
.unwrap()
|
||||||
|
.block_on(async move {
|
||||||
|
bard::init_database("path/to/tavern.db").await;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
Enable the following Cargo features as needed:
|
||||||
|
|
||||||
|
- `server`: Enables Axum support for database-backed serving
|
||||||
|
- `web`: Enables Dioxus web support
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,16 @@
|
|||||||
|
.visually_hidden
|
||||||
|
{
|
||||||
|
position: absolute !important;
|
||||||
|
width: 1px !important;
|
||||||
|
height: 1px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: -1px !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
clip: rect(0, 0, 0, 0) !important;
|
||||||
|
border: 0 !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
.blog_style
|
.blog_style
|
||||||
{
|
{
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -17,3 +30,257 @@
|
|||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.blog_list
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
order: 5;
|
||||||
|
|
||||||
|
.blog_title
|
||||||
|
{
|
||||||
|
margin-top: 0px;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog_title_image
|
||||||
|
{
|
||||||
|
width: 23rem;
|
||||||
|
align-self: center;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog_item
|
||||||
|
{
|
||||||
|
margin-bottom: 30px;
|
||||||
|
|
||||||
|
h2
|
||||||
|
{
|
||||||
|
margin: 0px;
|
||||||
|
color: var(--accent-color);
|
||||||
|
|
||||||
|
a:link, a:visited
|
||||||
|
{
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover
|
||||||
|
{
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p
|
||||||
|
{
|
||||||
|
margin: 5px 0px 0px 0px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.blog_post
|
||||||
|
{
|
||||||
|
order: 5;
|
||||||
|
|
||||||
|
h1
|
||||||
|
{
|
||||||
|
margin: 0px;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
h4
|
||||||
|
{
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
li
|
||||||
|
{
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p
|
||||||
|
{
|
||||||
|
margin-top: 0px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog_post_tale
|
||||||
|
{
|
||||||
|
pre
|
||||||
|
{
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embedded_video
|
||||||
|
{
|
||||||
|
margin-top: 50px;
|
||||||
|
display: flex;
|
||||||
|
align-content: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
iframe
|
||||||
|
{
|
||||||
|
box-shadow: 0 0 80px var(--accent-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* Tags will have a class of "tag_{tag_name}" that allows specific
|
||||||
|
* customization.
|
||||||
|
*
|
||||||
|
* .tag_easy
|
||||||
|
* {
|
||||||
|
* background-color: #248721;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* */
|
||||||
|
.blog_tag
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
.tag_list
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog_nav
|
||||||
|
{
|
||||||
|
order: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 185px;
|
||||||
|
margin-right: 70px;
|
||||||
|
position:sticky;
|
||||||
|
top: 150px;
|
||||||
|
height: 50vh;
|
||||||
|
|
||||||
|
fieldset
|
||||||
|
{
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
min-inline-size: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend
|
||||||
|
{
|
||||||
|
min-width: 130px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag_list
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle_button {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 5px;
|
||||||
|
margin-right: 5px;
|
||||||
|
padding: 0px 6px;
|
||||||
|
border: 2px solid var(--text-color);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: 0.1s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle_button:hover {
|
||||||
|
color: var(--accent-color);
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle_button input[type="checkbox"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checked state using :has() — modern browsers only */
|
||||||
|
.toggle_button:has(input:checked) {
|
||||||
|
color: var(--accent-color);
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@container site (max-width: 1230px)
|
||||||
|
{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag_list
|
||||||
|
{
|
||||||
|
list-style: none;
|
||||||
|
padding: 0px;
|
||||||
|
margin: 0px;
|
||||||
|
|
||||||
|
.tag_item
|
||||||
|
{
|
||||||
|
margin-top: 5px;
|
||||||
|
margin-right: 5px;
|
||||||
|
|
||||||
|
a
|
||||||
|
{
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0px 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 2px solid var(--text-color);
|
||||||
|
border-radius: 5px;
|
||||||
|
|
||||||
|
color: var(--text-color);
|
||||||
|
transition: 0.1s ease-in;
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
{
|
||||||
|
color: var(--accent-color);
|
||||||
|
border: 2px solid var(--accent-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.video_link
|
||||||
|
{
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@media screen and (max-width: 1000px)
|
||||||
|
{
|
||||||
|
.blog_post .blog_post_tale
|
||||||
|
{
|
||||||
|
.video_link
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embedded_video
|
||||||
|
{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,230 +0,0 @@
|
|||||||
.blog_style
|
|
||||||
{
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: var(--bg-color);
|
|
||||||
padding: 50px 0px;
|
|
||||||
justify-content: center;
|
|
||||||
container-name: site;
|
|
||||||
container-type: inline-size;
|
|
||||||
|
|
||||||
.page_content
|
|
||||||
{
|
|
||||||
display: flex;
|
|
||||||
width: 67%;
|
|
||||||
container-name: page;
|
|
||||||
container-type: inline-size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.blog_nav
|
|
||||||
{
|
|
||||||
width: 15%;
|
|
||||||
padding: 0px 20px;
|
|
||||||
|
|
||||||
h3
|
|
||||||
{
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul
|
|
||||||
{
|
|
||||||
list-style: none;
|
|
||||||
|
|
||||||
li
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@container site (max-width: 1230px)
|
|
||||||
{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social
|
|
||||||
{
|
|
||||||
/* Hide .social until the font is loaded */
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 15px;
|
|
||||||
margin: 25px 0;
|
|
||||||
font-family: cm_social;
|
|
||||||
|
|
||||||
a
|
|
||||||
{
|
|
||||||
color: var(--text-color);
|
|
||||||
font-size: 46px;
|
|
||||||
text-decoration: none;
|
|
||||||
background: transparent;
|
|
||||||
width: 65px;
|
|
||||||
height: 65px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: center;
|
|
||||||
transition: 0.4s ease;
|
|
||||||
border: 4px solid var(--text-color);
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
{
|
|
||||||
color: var(--accent-color);
|
|
||||||
border: 4px solid var(--accent-color);
|
|
||||||
transform: translateY(-7px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.blog_item_area
|
|
||||||
{
|
|
||||||
.blog_list
|
|
||||||
{
|
|
||||||
list-style: none;
|
|
||||||
|
|
||||||
.blog_item
|
|
||||||
{
|
|
||||||
margin-bottom: 50px;
|
|
||||||
|
|
||||||
h1
|
|
||||||
{
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
h4
|
|
||||||
{
|
|
||||||
margin: 10px 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
p
|
|
||||||
{
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.blog_article
|
|
||||||
{
|
|
||||||
h1
|
|
||||||
{
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
h4
|
|
||||||
{
|
|
||||||
margin-top: 10px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
p
|
|
||||||
{
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.embeded_video
|
|
||||||
{
|
|
||||||
margin-top: 50px;
|
|
||||||
display: flex;
|
|
||||||
align-content: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
iframe
|
|
||||||
{
|
|
||||||
box-shadow: 0 0 80px var(--accent-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.tag_style
|
|
||||||
{
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
.tag_list
|
|
||||||
{
|
|
||||||
list-style: none;
|
|
||||||
|
|
||||||
.tag_item
|
|
||||||
{
|
|
||||||
display: inline-block;
|
|
||||||
margin: 5px 5px 0px 0px;
|
|
||||||
|
|
||||||
a
|
|
||||||
{
|
|
||||||
display: block;
|
|
||||||
padding: 0px 4px;
|
|
||||||
text-decoration: none;
|
|
||||||
border: 2px solid var(--text-color);
|
|
||||||
border-radius: 5px;
|
|
||||||
|
|
||||||
color: var(--text-color);
|
|
||||||
transition: 0.1s ease-in;
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
{
|
|
||||||
color: var(--accent-color);
|
|
||||||
border: 2px solid var(--accent-color);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.easy
|
|
||||||
{
|
|
||||||
background-color: #248721;
|
|
||||||
}
|
|
||||||
|
|
||||||
.medium
|
|
||||||
{
|
|
||||||
background-color: #d6a318;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hard
|
|
||||||
{
|
|
||||||
background-color: #d92121;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.social
|
|
||||||
{
|
|
||||||
background: transparent;
|
|
||||||
font-family: cm_social;
|
|
||||||
margin: 15px 0px;
|
|
||||||
|
|
||||||
a
|
|
||||||
{
|
|
||||||
color: var(--text-color);
|
|
||||||
font-size: 30px;
|
|
||||||
text-decoration: none;
|
|
||||||
background: transparent;
|
|
||||||
width: 45px;
|
|
||||||
height: 45px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: center;
|
|
||||||
transition: 0.4s ease;
|
|
||||||
border: 4px solid var(--text-color);
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
{
|
|
||||||
color: var(--accent-color);
|
|
||||||
border: 4px solid var(--accent-color);
|
|
||||||
transform: translateY(-7px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
.blog_list_style
|
|
||||||
{
|
|
||||||
.blog_list
|
|
||||||
{
|
|
||||||
list-style: none;
|
|
||||||
|
|
||||||
.blog_item
|
|
||||||
{
|
|
||||||
margin-bottom: 50px;
|
|
||||||
|
|
||||||
h1
|
|
||||||
{
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
h4
|
|
||||||
{
|
|
||||||
margin: 10px 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
p
|
|
||||||
{
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,117 +0,0 @@
|
|||||||
.blog_style
|
|
||||||
{
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: var(--bg-color);
|
|
||||||
padding: 50px 0px;
|
|
||||||
justify-content: center;
|
|
||||||
container-name: site;
|
|
||||||
container-type: inline-size;
|
|
||||||
|
|
||||||
.page_content
|
|
||||||
{
|
|
||||||
display: flex;
|
|
||||||
width: 67%;
|
|
||||||
container-name: page;
|
|
||||||
container-type: inline-size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.blog_nav_style
|
|
||||||
{
|
|
||||||
width: 15%;
|
|
||||||
padding: 0px 20px;
|
|
||||||
order: 5;
|
|
||||||
|
|
||||||
h3
|
|
||||||
{
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul
|
|
||||||
{
|
|
||||||
list-style: none;
|
|
||||||
|
|
||||||
li
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@container site (max-width: 1230px)
|
|
||||||
{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social
|
|
||||||
{
|
|
||||||
/* Hide .social until the font is loaded */
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 15px;
|
|
||||||
margin: 25px 0;
|
|
||||||
font-family: cm_social;
|
|
||||||
|
|
||||||
a
|
|
||||||
{
|
|
||||||
color: var(--text-color);
|
|
||||||
font-size: 22px;
|
|
||||||
text-decoration: none;
|
|
||||||
background: transparent;
|
|
||||||
width: 35px;
|
|
||||||
height: 35px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: center;
|
|
||||||
transition: 0.4s ease;
|
|
||||||
border: 4px solid var(--text-color);
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
{
|
|
||||||
color: var(--accent-color);
|
|
||||||
border: 4px solid var(--accent-color);
|
|
||||||
transform: translateY(-7px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag_style
|
|
||||||
{
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
.tag_list
|
|
||||||
{
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
list-style: none;
|
|
||||||
|
|
||||||
.tag_item
|
|
||||||
{
|
|
||||||
display: inline-block;
|
|
||||||
margin: 5px 5px 0px 0px;
|
|
||||||
|
|
||||||
a
|
|
||||||
{
|
|
||||||
display: inline-flex;
|
|
||||||
padding: 0px 4px;
|
|
||||||
text-decoration: none;
|
|
||||||
border: 2px solid var(--text-color);
|
|
||||||
border-radius: 5px;
|
|
||||||
|
|
||||||
color: var(--text-color);
|
|
||||||
transition: 0.1s ease-in;
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
{
|
|
||||||
color: var(--accent-color);
|
|
||||||
border: 2px solid var(--accent-color);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,119 +0,0 @@
|
|||||||
.blog_post_style
|
|
||||||
{
|
|
||||||
order: 1;
|
|
||||||
|
|
||||||
h1
|
|
||||||
{
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
h4
|
|
||||||
{
|
|
||||||
margin-top: 10px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
p
|
|
||||||
{
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.embeded_video
|
|
||||||
{
|
|
||||||
margin-top: 50px;
|
|
||||||
display: flex;
|
|
||||||
align-content: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
iframe
|
|
||||||
{
|
|
||||||
box-shadow: 0 0 80px var(--accent-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.tag_style
|
|
||||||
{
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
.tag_list
|
|
||||||
{
|
|
||||||
list-style: none;
|
|
||||||
|
|
||||||
.tag_item
|
|
||||||
{
|
|
||||||
display: inline-block;
|
|
||||||
margin: 5px 5px 0px 0px;
|
|
||||||
|
|
||||||
a
|
|
||||||
{
|
|
||||||
display: block;
|
|
||||||
padding: 0px 4px;
|
|
||||||
text-decoration: none;
|
|
||||||
border: 2px solid var(--text-color);
|
|
||||||
border-radius: 5px;
|
|
||||||
|
|
||||||
color: var(--text-color);
|
|
||||||
transition: 0.1s ease-in;
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
{
|
|
||||||
color: var(--accent-color);
|
|
||||||
border: 2px solid var(--accent-color);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.easy
|
|
||||||
{
|
|
||||||
background-color: #248721;
|
|
||||||
}
|
|
||||||
|
|
||||||
.medium
|
|
||||||
{
|
|
||||||
background-color: #d6a318;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hard
|
|
||||||
{
|
|
||||||
background-color: #d92121;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.social
|
|
||||||
{
|
|
||||||
background: transparent;
|
|
||||||
font-family: cm_social;
|
|
||||||
margin: 15px 0px;
|
|
||||||
|
|
||||||
a
|
|
||||||
{
|
|
||||||
color: var(--text-color);
|
|
||||||
font-size: 30px;
|
|
||||||
text-decoration: none;
|
|
||||||
background: transparent;
|
|
||||||
width: 45px;
|
|
||||||
height: 45px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: center;
|
|
||||||
transition: 0.4s ease;
|
|
||||||
border: 4px solid var(--text-color);
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
{
|
|
||||||
color: var(--accent-color);
|
|
||||||
border: 4px solid var(--accent-color);
|
|
||||||
transform: translateY(-7px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
.blog_tag_style
|
|
||||||
{
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
.tag_list
|
|
||||||
{
|
|
||||||
list-style: none;
|
|
||||||
|
|
||||||
.tag_item
|
|
||||||
{
|
|
||||||
display: inline-block;
|
|
||||||
margin: 5px 5px 0px 0px;
|
|
||||||
|
|
||||||
a
|
|
||||||
{
|
|
||||||
display: block;
|
|
||||||
padding: 0px 4px;
|
|
||||||
text-decoration: none;
|
|
||||||
border: 2px solid var(--text-color);
|
|
||||||
border-radius: 5px;
|
|
||||||
|
|
||||||
color: var(--text-color);
|
|
||||||
transition: 0.1s ease-in;
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
{
|
|
||||||
color: var(--accent-color);
|
|
||||||
border: 2px solid var(--accent-color);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.none
|
|
||||||
{
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.easy
|
|
||||||
{
|
|
||||||
background-color: #248721;
|
|
||||||
}
|
|
||||||
|
|
||||||
.medium
|
|
||||||
{
|
|
||||||
background-color: #d6a318;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hard
|
|
||||||
{
|
|
||||||
background-color: #d92121;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BIN
bard/assets/images/bard_logo.png
Normal file
|
After Width: | Height: | Size: 220 KiB |
@ -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..." }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
343
bard/src/components/list.rs
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::togglable::Togglable;
|
||||||
|
use crate::page::Page;
|
||||||
|
use crate::server::*;
|
||||||
|
use crate::settings::{BardSettings};
|
||||||
|
use super::tags::*;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn BlogItem(title: String, slug: String, author: String, summary: String,
|
||||||
|
tags: Vec<String>)
|
||||||
|
-> Element
|
||||||
|
{
|
||||||
|
let author_future = use_server_future(move ||
|
||||||
|
{
|
||||||
|
let handle: String = author.clone();
|
||||||
|
|
||||||
|
async move { get_author(handle).await }
|
||||||
|
})?;
|
||||||
|
|
||||||
|
rsx!
|
||||||
|
{
|
||||||
|
article
|
||||||
|
{
|
||||||
|
class: "blog_item",
|
||||||
|
|
||||||
|
h2
|
||||||
|
{
|
||||||
|
Link
|
||||||
|
{
|
||||||
|
to: Page::Post { slug: slug.clone() },
|
||||||
|
|
||||||
|
"{title}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TagList
|
||||||
|
{
|
||||||
|
for tag in tags
|
||||||
|
{
|
||||||
|
TagItem
|
||||||
|
{
|
||||||
|
tag: tag.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(Ok(Some(adventurer))) = (author_future.value())()
|
||||||
|
{
|
||||||
|
p
|
||||||
|
{
|
||||||
|
b
|
||||||
|
{
|
||||||
|
"Author: "
|
||||||
|
a
|
||||||
|
{
|
||||||
|
href: "{adventurer.legend.profile}", "{adventurer.name}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
p { "Loading author..." }
|
||||||
|
}
|
||||||
|
|
||||||
|
p { "{summary}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn BlogList(tags: Signal<HashSet<String>>, children: Element) -> Element
|
||||||
|
{
|
||||||
|
// Retrieve the provided settings from context.
|
||||||
|
let settings = use_context::<BardSettings>();
|
||||||
|
|
||||||
|
let list = use_resource(move ||
|
||||||
|
{
|
||||||
|
let tags = tags();
|
||||||
|
let categories: Vec<String> = tags.iter().cloned().collect();
|
||||||
|
|
||||||
|
async move { get_blog_list(categories).await }
|
||||||
|
});
|
||||||
|
|
||||||
|
rsx!
|
||||||
|
{
|
||||||
|
section
|
||||||
|
{
|
||||||
|
class: "blog_list",
|
||||||
|
|
||||||
|
match settings.blog_image
|
||||||
|
{
|
||||||
|
Some(image) =>
|
||||||
|
{
|
||||||
|
match settings.blog_name
|
||||||
|
{
|
||||||
|
Some(title) =>
|
||||||
|
{
|
||||||
|
rsx!
|
||||||
|
{
|
||||||
|
h1 { class: "blog_title visually_hidden", "{title}" }
|
||||||
|
img { class: "blog_title_image", alt: "{title} blog logo", src: image }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None =>
|
||||||
|
{
|
||||||
|
rsx!
|
||||||
|
{
|
||||||
|
h1 { class: "blog_title visually_hidden", "Blog" }
|
||||||
|
img { class: "blog_title_image", alt: "Blog logo", src: image }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None =>
|
||||||
|
{
|
||||||
|
match settings.blog_name
|
||||||
|
{
|
||||||
|
Some(title) =>
|
||||||
|
{
|
||||||
|
rsx!
|
||||||
|
{
|
||||||
|
h1 { class: "blog_title", "{title}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None =>
|
||||||
|
{
|
||||||
|
rsx!
|
||||||
|
{
|
||||||
|
h1 { class: "blog_title visually_hidden", "Blog" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(Ok(lores)) = &*list.read()
|
||||||
|
{
|
||||||
|
for lore in lores
|
||||||
|
{
|
||||||
|
BlogItem
|
||||||
|
{
|
||||||
|
key: "{lore.slug}",
|
||||||
|
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 { "Please choose a category to see related blog posts." }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
p { "Loading..." }
|
||||||
|
}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ToggleTag(tag: String, toggled_tags: Signal<HashSet<String>>) -> Element
|
||||||
|
{
|
||||||
|
let is_checked = toggled_tags.read().is_toggled(&tag);
|
||||||
|
rsx!
|
||||||
|
{
|
||||||
|
label
|
||||||
|
{
|
||||||
|
class: "toggle_button",
|
||||||
|
|
||||||
|
input
|
||||||
|
{
|
||||||
|
r#type: "checkbox",
|
||||||
|
checked: is_checked,
|
||||||
|
onchange: move |_|
|
||||||
|
{
|
||||||
|
toggled_tags.write().toggle(&tag.clone());
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
"{tag}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Using use_resource instead of use_server_future for URL-dependent tag selection
|
||||||
|
//
|
||||||
|
// While use_server_future should theoretically be reactive when reading signals in the
|
||||||
|
// closure (per Dioxus docs), in practice it doesn't reliably re-run when the url_tag
|
||||||
|
// signal changes, especially during direct URL navigation (typing URLs in browser).
|
||||||
|
//
|
||||||
|
// use_resource provides more reliable reactivity for this use case because:
|
||||||
|
// 1. It explicitly depends on url_tag and consistently re-runs when it changes
|
||||||
|
// 2. It handles both async data fetching AND selection logic in a single atomic operation
|
||||||
|
// 3. It works consistently across all navigation methods (links, direct URLs, etc.)
|
||||||
|
// 4. It avoids timing coordination issues between separate hooks
|
||||||
|
//
|
||||||
|
// This approach combines fetching available tags from the server with determining
|
||||||
|
// which tags should be selected based on the current URL, returning both pieces
|
||||||
|
// of data together for clean state management.
|
||||||
|
#[component]
|
||||||
|
pub fn TagSelector(url_tag: ReadSignal<String>,
|
||||||
|
toggled_tags: Signal<HashSet<String>>) -> Element
|
||||||
|
{
|
||||||
|
// Use use_resource to handle both fetching tags AND initializing selection
|
||||||
|
let tags_and_selection = use_resource(move ||
|
||||||
|
{
|
||||||
|
let current_url = url_tag();
|
||||||
|
|
||||||
|
async move
|
||||||
|
{
|
||||||
|
match get_tags().await
|
||||||
|
{
|
||||||
|
Ok(available_tags) =>
|
||||||
|
{
|
||||||
|
// Determine what should be selected based on URL
|
||||||
|
let should_show_all = current_url.is_empty() || current_url == "all";
|
||||||
|
|
||||||
|
let selected_tags: HashSet<String> = if should_show_all
|
||||||
|
{
|
||||||
|
available_tags.iter().cloned().collect()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
let url_categories = convert_categories(¤t_url);
|
||||||
|
|
||||||
|
let filtered: HashSet<String> = url_categories.into_iter()
|
||||||
|
.filter(|tag| available_tags.contains(tag))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
filtered
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((available_tags, selected_tags))
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(e) =>
|
||||||
|
{
|
||||||
|
eprintln!("RESOURCE - Error: {}", e);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Separate effect to update toggled_tags when resource completes
|
||||||
|
// This separates reading the resource from writing to toggled_tags
|
||||||
|
use_effect(move ||
|
||||||
|
{
|
||||||
|
if let Some(Ok((_, selected_tags))) = &*tags_and_selection.read()
|
||||||
|
{
|
||||||
|
toggled_tags.set(selected_tags.clone());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
rsx!
|
||||||
|
{
|
||||||
|
section
|
||||||
|
{
|
||||||
|
class: "blog_nav",
|
||||||
|
|
||||||
|
h2 { class: "visually_hidden", "Filters and Navigation" }
|
||||||
|
|
||||||
|
fieldset
|
||||||
|
{
|
||||||
|
class: "tag_style",
|
||||||
|
|
||||||
|
legend { "Category Filter" }
|
||||||
|
|
||||||
|
ul
|
||||||
|
{
|
||||||
|
class: "tag_list",
|
||||||
|
|
||||||
|
match &*tags_and_selection.read()
|
||||||
|
{
|
||||||
|
Some(Ok((available_tags, _))) =>
|
||||||
|
{
|
||||||
|
rsx!
|
||||||
|
{
|
||||||
|
for tag in available_tags
|
||||||
|
{
|
||||||
|
li
|
||||||
|
{
|
||||||
|
key: "selector-{tag}",
|
||||||
|
class: "tag_item",
|
||||||
|
|
||||||
|
ToggleTag
|
||||||
|
{
|
||||||
|
tag: tag.clone(),
|
||||||
|
toggled_tags: toggled_tags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Err(_)) =>
|
||||||
|
{
|
||||||
|
rsx!
|
||||||
|
{
|
||||||
|
li { "Error loading tags" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None =>
|
||||||
|
{
|
||||||
|
rsx!
|
||||||
|
{
|
||||||
|
li { "Loading tags..." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_categories(categories: &str) -> HashSet<String>
|
||||||
|
{
|
||||||
|
categories
|
||||||
|
.split('+')
|
||||||
|
.filter(|s| !s.is_empty() && *s != "all")
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
8
bard/src/components/mod.rs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
mod list;
|
||||||
|
mod post;
|
||||||
|
mod tags;
|
||||||
|
|
||||||
|
|
||||||
|
pub use self::list::*;
|
||||||
|
pub use self::post::*;
|
||||||
|
pub use self::tags::*;
|
||||||
235
bard/src/components/post.rs
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
use tavern::{Adventurer, Tale};
|
||||||
|
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::page::Page;
|
||||||
|
use crate::settings::BardSettings;
|
||||||
|
use crate::server::*;
|
||||||
|
use super::tags::*;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn BlogAuthor() -> Element
|
||||||
|
{
|
||||||
|
rsx!
|
||||||
|
{
|
||||||
|
section
|
||||||
|
{
|
||||||
|
class: "blog_author_style",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn PostHeaderAuthor(author: Option<Adventurer>, publish_date: String) -> Element
|
||||||
|
{
|
||||||
|
match author
|
||||||
|
{
|
||||||
|
Some(adventurer) =>
|
||||||
|
{
|
||||||
|
rsx!
|
||||||
|
{
|
||||||
|
document::Meta
|
||||||
|
{
|
||||||
|
name: "author",
|
||||||
|
content: "{adventurer.name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
i
|
||||||
|
{
|
||||||
|
"Published: {publish_date}"
|
||||||
|
}
|
||||||
|
|
||||||
|
p
|
||||||
|
{
|
||||||
|
b
|
||||||
|
{
|
||||||
|
"Author: ",
|
||||||
|
a { href: "{adventurer.legend.profile}", "{adventurer.name}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None=>
|
||||||
|
{
|
||||||
|
rsx!
|
||||||
|
{
|
||||||
|
document::Meta
|
||||||
|
{
|
||||||
|
name: "author",
|
||||||
|
content: "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
i
|
||||||
|
{
|
||||||
|
"Published: {publish_date}"
|
||||||
|
}
|
||||||
|
|
||||||
|
p
|
||||||
|
{
|
||||||
|
b
|
||||||
|
{
|
||||||
|
"Author: Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn PostHeader(tale: Tale, adventurer: Option<Adventurer>) -> Element
|
||||||
|
{
|
||||||
|
// Get the pages URL.
|
||||||
|
let url: Page = use_route();
|
||||||
|
|
||||||
|
// Get the blog's image to use as a placeholder if there isn't one for the
|
||||||
|
// blog post.
|
||||||
|
// Retrieve the provided settings from context.
|
||||||
|
let settings = use_context::<BardSettings>();
|
||||||
|
|
||||||
|
rsx!
|
||||||
|
{
|
||||||
|
// Open Graph (used by LinkedIn, Bluesky, Discord, etc.)
|
||||||
|
document::Meta { property: "og:locale", content: "en_US" }
|
||||||
|
document::Meta { name: "title", property: "og:title", content: "{tale.lore.title}" }
|
||||||
|
document::Meta { name: "description", property: "og:description", content: "{tale.lore.summary}" }
|
||||||
|
document::Meta { property: "og:type", content: "article" }
|
||||||
|
document::Meta { property: "og:url", content: "{url}" }
|
||||||
|
if let Some(image) = settings.default_post_image
|
||||||
|
{
|
||||||
|
document::Meta { name: "image", property: "og:image", content: "{image}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { {tale.lore.title} }
|
||||||
|
|
||||||
|
TagList
|
||||||
|
{
|
||||||
|
for tag in tale.lore.tags
|
||||||
|
{
|
||||||
|
TagItem
|
||||||
|
{
|
||||||
|
tag: tag.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PostHeaderAuthor
|
||||||
|
{
|
||||||
|
author: adventurer,
|
||||||
|
publish_date: tale.lore.publish_date.format("%m/%d/%Y").to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Story(text: String) -> Element
|
||||||
|
{
|
||||||
|
rsx!
|
||||||
|
{
|
||||||
|
div
|
||||||
|
{
|
||||||
|
class: "blog_post_tale",
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
let post = get_blog_post(url_slug).await?;
|
||||||
|
let author = get_author(post.lore.author.clone()).await?;
|
||||||
|
Ok::<_, ServerFnError>((post, author))
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
rsx!
|
||||||
|
{
|
||||||
|
article
|
||||||
|
{
|
||||||
|
class: "blog_post",
|
||||||
|
|
||||||
|
if let Some(Ok((tale, adventurer))) = (post_future.value())()
|
||||||
|
{
|
||||||
|
PostHeader
|
||||||
|
{
|
||||||
|
tale: tale.clone(),
|
||||||
|
adventurer: adventurer.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",
|
||||||
|
|
||||||
|
fieldset
|
||||||
|
{
|
||||||
|
class: "tag_list",
|
||||||
|
|
||||||
|
legend { "Category Filter" }
|
||||||
|
|
||||||
|
if let Some(Ok(categories)) = &*tags.read()
|
||||||
|
{
|
||||||
|
TagList
|
||||||
|
{
|
||||||
|
for tag in categories
|
||||||
|
{
|
||||||
|
TagItem
|
||||||
|
{
|
||||||
|
tag: tag.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if let Some(Err(e)) = &*tags.read()
|
||||||
|
{
|
||||||
|
p { "Unable to load tags." }
|
||||||
|
p { "{e}" }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
p { "Loading..." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
bard/src/components/tags.rs
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
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} tag_{tag}",
|
||||||
|
|
||||||
|
to: Page::Blog { tag: tag.clone() },
|
||||||
|
|
||||||
|
"{tag}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TagList(children: Element) -> Element
|
||||||
|
{
|
||||||
|
rsx!
|
||||||
|
{
|
||||||
|
ul
|
||||||
|
{
|
||||||
|
class: "blog_tag tag_list",
|
||||||
|
{children}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,8 @@ mod components;
|
|||||||
mod page;
|
mod page;
|
||||||
mod pages;
|
mod pages;
|
||||||
mod server;
|
mod server;
|
||||||
|
mod settings;
|
||||||
|
mod togglable;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -13,5 +15,6 @@ pub use crate::components::*;
|
|||||||
pub use crate::info::{get_name, get_version};
|
pub use crate::info::{get_name, get_version};
|
||||||
pub use crate::page::Page;
|
pub use crate::page::Page;
|
||||||
pub use crate::pages::*;
|
pub use crate::pages::*;
|
||||||
|
pub use crate::settings::*;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub use crate::server::*;
|
pub use crate::server::*;
|
||||||
|
|||||||
@ -1,13 +1,65 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
use crate::pages::{Blog, Post};
|
use crate::pages::{Blog, Post, Root};
|
||||||
|
use crate::settings::{BardSettings, StylesheetBehavior};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const BLOG_CSS: Asset = asset!("/assets/css/blog.css");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn BlogLayout() -> Element
|
||||||
|
{
|
||||||
|
// Retrieve the provided settings from context.
|
||||||
|
let settings = use_context::<BardSettings>();
|
||||||
|
|
||||||
|
rsx!
|
||||||
|
{
|
||||||
|
match settings.stylesheet
|
||||||
|
{
|
||||||
|
StylesheetBehavior::Override(asset) =>
|
||||||
|
{
|
||||||
|
rsx!
|
||||||
|
{
|
||||||
|
document::Stylesheet { href: asset }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StylesheetBehavior::Extend(asset) =>
|
||||||
|
{
|
||||||
|
rsx!
|
||||||
|
{
|
||||||
|
document::Stylesheet { href: BLOG_CSS }
|
||||||
|
document::Stylesheet { href: asset }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StylesheetBehavior::None =>
|
||||||
|
{
|
||||||
|
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)]
|
#[derive(Debug, Clone, Routable, PartialEq)]
|
||||||
#[rustfmt::skip]
|
#[rustfmt::skip]
|
||||||
pub enum Page
|
pub enum Page
|
||||||
{
|
{
|
||||||
|
#[layout(BlogLayout)]
|
||||||
|
#[route("/")]
|
||||||
|
Root { },
|
||||||
|
|
||||||
#[route("/:tag")]
|
#[route("/:tag")]
|
||||||
Blog { tag: String },
|
Blog { tag: String },
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
mod blog;
|
|
||||||
mod post;
|
|
||||||
|
|
||||||
|
|
||||||
pub use crate::pages::blog::Blog;
|
|
||||||
pub use crate::pages::post::Post;
|
|
||||||
@ -3,43 +3,18 @@ use std::collections::HashSet;
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
use crate::components::{BlogList, TagSelector};
|
use crate::components::{BlogList, TagSelector};
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// Blog page
|
/// Blog page
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Blog(tag: String) -> Element
|
pub fn Blog(tag: ReadSignal<String>) -> Element
|
||||||
{
|
{
|
||||||
let mut categories: Signal<HashSet<String>> =
|
let categories: Signal<HashSet<String>> =
|
||||||
use_signal(|| convert_categories(&tag));
|
use_signal(|| HashSet::new());
|
||||||
|
|
||||||
if *categories.read() != convert_categories(&tag)
|
rsx!
|
||||||
{
|
{
|
||||||
categories.set(convert_categories(&tag));
|
|
||||||
}
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
document::Stylesheet { href: BLOG_CSS }
|
|
||||||
|
|
||||||
main
|
main
|
||||||
{
|
{
|
||||||
class: "blog_style",
|
class: "blog_style",
|
||||||
@ -54,6 +29,7 @@ pub fn Blog(tag: String) -> Element
|
|||||||
|
|
||||||
TagSelector
|
TagSelector
|
||||||
{
|
{
|
||||||
|
url_tag: tag,
|
||||||
toggled_tags: categories
|
toggled_tags: categories
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
bard/src/pages/mod.rs
Normal 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;
|
||||||
@ -1,29 +1,18 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
use crate::components::{BlogPost, TagNav};
|
use crate::components::{BlogPost, TagNav};
|
||||||
use crate::page::Page;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const BLOG_CSS: Asset = asset!("/assets/css/blog.css");
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// Blog page
|
/// Blog page
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Post(slug: String) -> Element
|
pub fn Post(slug: ReadSignal<String>) -> Element
|
||||||
{
|
{
|
||||||
// Create a copy of the current slug to detect changes.
|
// Create a copy of the current slug to detect changes.
|
||||||
let mut url_slug = use_signal(|| slug.clone());
|
let url_slug = use_signal(|| slug());
|
||||||
|
|
||||||
if *url_slug.read() != slug
|
rsx!
|
||||||
{
|
{
|
||||||
url_slug.set(slug.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
document::Stylesheet { href: BLOG_CSS }
|
|
||||||
|
|
||||||
main
|
main
|
||||||
{
|
{
|
||||||
class: "blog_style",
|
class: "blog_style",
|
||||||
|
|||||||
18
bard/src/pages/root.rs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::pages::Blog;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/// Blog page
|
||||||
|
#[component]
|
||||||
|
pub fn Root() -> Element
|
||||||
|
{
|
||||||
|
rsx!
|
||||||
|
{
|
||||||
|
Blog
|
||||||
|
{
|
||||||
|
tag: String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -96,13 +96,11 @@ pub async fn get_blog_post(slug: String) -> Result<Tale, ServerFnError>
|
|||||||
|
|
||||||
|
|
||||||
#[server]
|
#[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 db = get_database().await?;
|
||||||
|
|
||||||
let author = db.get_adventurer(&handle)
|
let author = db.get_adventurer(&handle)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e))?;
|
.map_err(|e| ServerFnError::new(e))?;
|
||||||
|
Ok(author)
|
||||||
author.ok_or(ServerFnError::new(format!("Author {} not found.", handle)))
|
|
||||||
}
|
}
|
||||||
|
|||||||
46
bard/src/settings.rs
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Copy, Clone)]
|
||||||
|
pub enum StylesheetBehavior
|
||||||
|
{
|
||||||
|
/// Overrides the default stylesheet.
|
||||||
|
Override(Asset),
|
||||||
|
|
||||||
|
/// Extends the default stylesheet. The library will load both.
|
||||||
|
Extend(Asset),
|
||||||
|
|
||||||
|
/// Uses the library's default stylesheet.
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct BardSettings
|
||||||
|
{
|
||||||
|
/// The name to use for the blog.
|
||||||
|
/// If None, then the name will be Blog, but made invisible so that
|
||||||
|
/// it is still available for screen readers.
|
||||||
|
pub blog_name: Option<String>,
|
||||||
|
|
||||||
|
/// The image to use for the blog title instead of just the name.
|
||||||
|
pub blog_image: Option<Asset>,
|
||||||
|
|
||||||
|
/// The image to use for the default post image in open graph. This is what
|
||||||
|
/// is shown when sharing the article.
|
||||||
|
pub default_post_image: Option<Asset>,
|
||||||
|
|
||||||
|
/// A user defined stylesheet and how the library should include it.
|
||||||
|
pub stylesheet: StylesheetBehavior
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
impl Default for StylesheetBehavior
|
||||||
|
{
|
||||||
|
fn default() -> Self
|
||||||
|
{
|
||||||
|
StylesheetBehavior::None
|
||||||
|
}
|
||||||
|
}
|
||||||
37
bard/src/togglable.rs
Normal 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
@ -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
|
||||||
29
blog_test/Cargo.toml
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
[package]
|
||||||
|
name = "blog_test"
|
||||||
|
version = "0.3.2"
|
||||||
|
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.8.4", optional = true }
|
||||||
|
axum-server = { version = "0.7.2", optional = true }
|
||||||
|
dioxus = { version = "=0.7.0-rc.1", features = ["router", "fullstack"] }
|
||||||
|
bard = { version = "*", path="../bard", optional = true }
|
||||||
|
tokio = { version = "1.0", optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["bard"]
|
||||||
|
web = ["dioxus/web", "bard/web"]
|
||||||
|
server = ["dioxus/server", "axum", "axum-server", "tokio/rt-multi-thread", "tokio/macros", "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
@ -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
@ -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
|
||||||
|
```
|
||||||
|
|
||||||
45
blog_test/assets/blog.css
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
:root
|
||||||
|
{
|
||||||
|
--text-color: #000000;
|
||||||
|
--bg-color: #FFFFFF;
|
||||||
|
|
||||||
|
--primary-color: #ffffff;
|
||||||
|
--secondary-color: #000000;
|
||||||
|
--accent-color: #7dfdfe; /* Tron grid or try 00a8ff 00eaff 26b4ca 59b4c7 5584AC*/
|
||||||
|
--mobile-color: #363636;
|
||||||
|
|
||||||
|
--desktop-size: 1230px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle_button {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 5px;
|
||||||
|
margin-right: 5px;
|
||||||
|
padding: 0px 6px;
|
||||||
|
border: 2px solid var(--text-color);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: 0.1s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle_button:hover {
|
||||||
|
color: var(--accent-color);
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle_button input[type="checkbox"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checked state using :has() — modern browsers only */
|
||||||
|
.toggle_button:has(input:checked) {
|
||||||
|
color: var(--accent-color);
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
BIN
blog_test/assets/favicon.ico
Normal file
|
After Width: | Height: | Size: 130 KiB |
20
blog_test/assets/header.svg
Normal file
|
After Width: | Height: | Size: 23 KiB |
107
blog_test/assets/main.css
Normal 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;
|
||||||
|
}
|
||||||
BIN
blog_test/assets/runes_and_ramblings_logo.png
Normal file
|
After Width: | Height: | Size: 1019 KiB |
BIN
blog_test/assets/runes_and_ramblings_text.png
Normal file
|
After Width: | Height: | Size: 335 KiB |
32
blog_test/blog.css
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
.toggle_button {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 5px;
|
||||||
|
margin-right: 5px;
|
||||||
|
padding: 0px 6px;
|
||||||
|
border: 2px solid var(--text-color);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: 0.1s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle_button:hover {
|
||||||
|
color: var(--accent-color);
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle_button input[type="checkbox"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checked state using :has() — modern browsers only */
|
||||||
|
.toggle_button:has(input:checked) {
|
||||||
|
color: var(--accent-color);
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
8
blog_test/clippy.toml
Normal 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." },
|
||||||
|
]
|
||||||
111
blog_test/src/main.rs
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use bard::*;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const FAVICON: Asset = asset!("/assets/favicon.ico");
|
||||||
|
const BLOG: Asset = asset!("/assets/blog.css");
|
||||||
|
const BLOG_IMAGE: Asset = asset!("/assets/runes_and_ramblings_text.png",
|
||||||
|
AssetOptions::builder().with_hash_suffix(false));
|
||||||
|
const POST_IMAGE: Asset = asset!("/assets/runes_and_ramblings_logo.png",
|
||||||
|
AssetOptions::builder().with_hash_suffix(false));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
fn main()
|
||||||
|
{
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
{
|
||||||
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
|
rt.block_on(async
|
||||||
|
{
|
||||||
|
let db_path = "/home/myrddin/cybermages/website/tavern.db";
|
||||||
|
|
||||||
|
let _ = bard::init_database(db_path).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchBuilder::new().launch(App);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn App() -> Element
|
||||||
|
{
|
||||||
|
let custom_settings = BardSettings
|
||||||
|
{
|
||||||
|
blog_name: Some(String::from("Blog Test")),
|
||||||
|
blog_image: Some(BLOG_IMAGE),
|
||||||
|
default_post_image: Some(POST_IMAGE),
|
||||||
|
stylesheet: StylesheetBehavior::Extend(BLOG),
|
||||||
|
};
|
||||||
|
provide_context(custom_settings);
|
||||||
|
|
||||||
|
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 {},
|
||||||
|
|
||||||
|
#[child("/blog")]
|
||||||
|
Bard
|
||||||
|
{
|
||||||
|
child: bard::Page
|
||||||
|
},
|
||||||
|
|
||||||
|
#[route("/:..route")]
|
||||||
|
PageNotFound { route: Vec<String> }
|
||||||
|
}
|
||||||
@ -1,18 +0,0 @@
|
|||||||
# Loremaster
|
|
||||||
|
|
||||||
Converts a blog repository into an SQLite database using the Tavern blog system.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Copyright & License
|
|
||||||
|
|
||||||
Copyright 2025 CyberMages LLC
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this library except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
//! Converts a blog repository into an SQLite database using the Tavern blog system.
|
|
||||||
|
|
||||||
mod info;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// Print the version.
|
|
||||||
fn print_version()
|
|
||||||
{
|
|
||||||
println!("{} v{}", info::get_name(), info::get_version());
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fn main()
|
|
||||||
{
|
|
||||||
print_version();
|
|
||||||
}
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "loremaster"
|
name = "loreweaver"
|
||||||
version = "0.1.0"
|
version = "0.3.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Converts a blog repository into an SQLite database using the Tavern blog system."
|
description = "Converts a blog repository into an SQLite database using the Tavern blog system."
|
||||||
repository = "/CyberMages/tavern"
|
repository = "/CyberMages/tavern"
|
||||||
@ -10,3 +10,6 @@ readme = "README.md"
|
|||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
clap = { version = "4.5.46", features = ["derive"] }
|
||||||
|
tavern = { version = "0.3.0", path = "../tavern", registry = "cybermages", features = ["publisher"] }
|
||||||
|
tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros"] }
|
||||||
42
loreweaver/README.md
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# Loreweaver
|
||||||
|
|
||||||
|
Loreweaver is a command-line tool that compiles a blog repository into a SQLite
|
||||||
|
database using the Tavernworks blog system. It acts as a publisher that reads
|
||||||
|
a metadata configuration file and processes the blog content via the `tavern`
|
||||||
|
engine.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Reads a `Tavern.toml` metadata file to configure the compilation
|
||||||
|
- Converts Markdown blog repositories into portable SQLite databases
|
||||||
|
- Simple CLI interface with configurable input and output paths
|
||||||
|
- Built on top of the `tavern` library for content parsing and data handling
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Run Loreweaver with the default configuration file and output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
loreweaver --config Tavern.toml --output tavern.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Specify custom paths for config or output files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
loreweaver --config path/to/config.toml --output path/to/output.db
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Copyright & License
|
||||||
|
|
||||||
|
Copyright 2025 CyberMages LLC
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this library except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS
|
||||||
BIN
loreweaver/assets/images/loreweaver_logo.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
@ -3,12 +3,15 @@
|
|||||||
|
|
||||||
|
|
||||||
/// The environment variable defined by Cargo for the name.
|
/// The environment variable defined by Cargo for the name.
|
||||||
|
#[allow(dead_code)]
|
||||||
const NAME: Option<&str> = option_env!("CARGO_PKG_NAME");
|
const NAME: Option<&str> = option_env!("CARGO_PKG_NAME");
|
||||||
|
|
||||||
/// The environment variable defined by Cargo for the version.
|
/// The environment variable defined by Cargo for the version.
|
||||||
|
#[allow(dead_code)]
|
||||||
const VERSION: Option<&str> = option_env!("CARGO_PKG_VERSION");
|
const VERSION: Option<&str> = option_env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
/// The string to display if a value is not defined during compile time.
|
/// The string to display if a value is not defined during compile time.
|
||||||
|
#[allow(dead_code)]
|
||||||
const NOT_DEFINED: &'static str = "UNDEFINED";
|
const NOT_DEFINED: &'static str = "UNDEFINED";
|
||||||
|
|
||||||
|
|
||||||
@ -17,6 +20,7 @@ const NOT_DEFINED: &'static str = "UNDEFINED";
|
|||||||
/// set at compile time and comes from the Cargo.toml file.
|
/// set at compile time and comes from the Cargo.toml file.
|
||||||
///
|
///
|
||||||
/// If a value is not found, then it will return the not defined value.
|
/// If a value is not found, then it will return the not defined value.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn get_name() -> &'static str
|
pub fn get_name() -> &'static str
|
||||||
{
|
{
|
||||||
NAME.unwrap_or(NOT_DEFINED)
|
NAME.unwrap_or(NOT_DEFINED)
|
||||||
@ -27,6 +31,7 @@ pub fn get_name() -> &'static str
|
|||||||
/// This is set at compile time and comes from the Cargo.toml file.
|
/// This is set at compile time and comes from the Cargo.toml file.
|
||||||
///
|
///
|
||||||
/// If a value is not found, then it will return the not defined value.
|
/// If a value is not found, then it will return the not defined value.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn get_version() -> &'static str
|
pub fn get_version() -> &'static str
|
||||||
{
|
{
|
||||||
VERSION.unwrap_or(NOT_DEFINED)
|
VERSION.unwrap_or(NOT_DEFINED)
|
||||||
37
loreweaver/src/main.rs
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
//! Converts a blog repository into an SQLite database using the Tavern blog system.
|
||||||
|
|
||||||
|
mod info;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
use tavern::{Database, Tavern};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(version, about)]
|
||||||
|
struct Options
|
||||||
|
{
|
||||||
|
#[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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>>
|
||||||
|
{
|
||||||
|
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(())
|
||||||
|
}
|
||||||
2
rust-toolchain.toml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "nightly"
|
||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tavern"
|
name = "tavern"
|
||||||
version = "0.2.9"
|
version = "0.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "A blogging system that will allow you to write your blog in Markdown and then display it in HTML using Dioxus."
|
description = "A blogging system that will allow you to write your blog in Markdown and then display it in HTML using Dioxus."
|
||||||
repository = "/CyberMages/tavern"
|
repository = "/CyberMages/tavern"
|
||||||
|
|||||||
@ -1,7 +1,28 @@
|
|||||||
# Tavern
|
# Tavern
|
||||||
|
|
||||||
A blogging system that will allow you to write your blog in Markdown and then
|
Tavern is the core content engine of the Tavernworks blog system.
|
||||||
display it in HTML using Dioxus.
|
It provides the data model, parsing logic, and database interaction necessary
|
||||||
|
to convert a Markdown-based blog repository into a structured, queryable form.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Parses blog content from Markdown files and metadata.
|
||||||
|
- Converts content into an in-memory model or stores it in SQLite.
|
||||||
|
- Provides an async API to query and manipulate blog data.
|
||||||
|
- Designed to be extensible and integrated into CLI tools or frontends.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
As a library, `tavern` can be embedded in Rust applications or CLI tools
|
||||||
|
to handle blog content processing and storage.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use tavern::{Tavern, Database};
|
||||||
|
|
||||||
|
let tavern = Tavern::from_config_file("Tavern.toml");
|
||||||
|
let database = Database::open("blog.db").await?;
|
||||||
|
database.insert_tavern(&tavern).await?;
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
BIN
tavern/assets/images/tavern_logo.png
Normal file
|
After Width: | Height: | Size: 249 KiB |
@ -304,7 +304,8 @@ impl Database
|
|||||||
// Start a read-only transaction.
|
// Start a read-only transaction.
|
||||||
let mut tx = self.pool.begin().await?;
|
let mut tx = self.pool.begin().await?;
|
||||||
|
|
||||||
// Dynamically build the query.
|
// 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(
|
let mut query = String::from(
|
||||||
"SELECT
|
"SELECT
|
||||||
t.title,
|
t.title,
|
||||||
@ -312,51 +313,62 @@ impl Database
|
|||||||
t.summary,
|
t.summary,
|
||||||
t.author,
|
t.author,
|
||||||
t.publish_date,
|
t.publish_date,
|
||||||
GROUP_CONCAT(tg.name, ',') AS tags
|
GROUP_CONCAT(tg_all.name, ',') AS tags
|
||||||
FROM tales AS t
|
FROM tales AS t
|
||||||
LEFT JOIN tale_tags AS tt ON t.slug = tt.tale_slug
|
LEFT JOIN tale_tags AS tt_all ON t.slug = tt_all.tale_slug
|
||||||
LEFT JOIN tags AS tg ON tt.tag_id = tg.id"
|
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()
|
// Add placeholders for category names in EXISTS IN clause
|
||||||
{
|
if !categories.is_empty() {
|
||||||
query.push_str(" WHERE tg.name IN (");
|
let placeholders: Vec<_> = (0..categories.len()).map(|_| "?").collect();
|
||||||
let placeholders: Vec<_> =
|
|
||||||
(0..categories.len()).map(|_| "?").collect();
|
|
||||||
query.push_str(&placeholders.join(", "));
|
query.push_str(&placeholders.join(", "));
|
||||||
query.push(')');
|
} 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
|
|
||||||
{
|
// Bind the length of categories for the (? = 0) check
|
||||||
|
q = q.bind(categories.len() as i64);
|
||||||
|
|
||||||
|
// Bind the category names if any
|
||||||
|
for cat in categories {
|
||||||
q = q.bind(cat);
|
q = q.bind(cat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Execute the query
|
||||||
let rows = q.fetch_all(&mut *tx).await?;
|
let rows = q.fetch_all(&mut *tx).await?;
|
||||||
let current_time = chrono::Utc::now().naive_utc();
|
let current_time = chrono::Utc::now().naive_utc();
|
||||||
|
|
||||||
for row in rows
|
for row in rows {
|
||||||
{
|
|
||||||
let date_str: String = row.try_get("publish_date")?;
|
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")
|
let publish_date = chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%d %H:%M:%S")
|
||||||
.map_err(|e| sqlx::Error::Decode(e.into()))?;
|
.map_err(|e| sqlx::Error::Decode(e.into()))?;
|
||||||
|
|
||||||
// Only give tales that are ready to be published.
|
// Only include tales that are ready to be published.
|
||||||
if current_time >= publish_date
|
if current_time >= publish_date {
|
||||||
{
|
|
||||||
let tags_str: Option<String> = row.try_get("tags")?;
|
let tags_str: Option<String> = row.try_get("tags")?;
|
||||||
let tags = tags_str.map(|s| s.split(',').map(String::from).collect())
|
let tags = tags_str
|
||||||
|
.map(|s| s.split(',').map(String::from).collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
tales.push(Lore { title: row.try_get("title")?,
|
tales.push(Lore {
|
||||||
|
title: row.try_get("title")?,
|
||||||
slug: row.try_get("slug")?,
|
slug: row.try_get("slug")?,
|
||||||
summary: row.try_get("summary")?,
|
summary: row.try_get("summary")?,
|
||||||
author: row.try_get("author")?,
|
author: row.try_get("author")?,
|
||||||
publish_date,
|
publish_date,
|
||||||
tags });
|
tags,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||