readd most of the frontend
This commit is contained in:
parent
b8abd92adb
commit
192a952ae8
15 changed files with 660 additions and 62 deletions
37
Cargo.lock
generated
37
Cargo.lock
generated
|
@ -427,6 +427,7 @@ dependencies = [
|
||||||
"dioxus-html",
|
"dioxus-html",
|
||||||
"dioxus-liveview",
|
"dioxus-liveview",
|
||||||
"dioxus-logger",
|
"dioxus-logger",
|
||||||
|
"dioxus-router",
|
||||||
"dioxus-signals",
|
"dioxus-signals",
|
||||||
"dioxus-ssr",
|
"dioxus-ssr",
|
||||||
"dioxus-web",
|
"dioxus-web",
|
||||||
|
@ -738,6 +739,34 @@ dependencies = [
|
||||||
"tracing-wasm",
|
"tracing-wasm",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dioxus-router"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0299dea6f8759eb766ecf79247d50139f4b640056c3e60e8af254ea6921fb60d"
|
||||||
|
dependencies = [
|
||||||
|
"dioxus-cli-config",
|
||||||
|
"dioxus-history",
|
||||||
|
"dioxus-lib",
|
||||||
|
"dioxus-router-macro",
|
||||||
|
"rustversion",
|
||||||
|
"tracing",
|
||||||
|
"url",
|
||||||
|
"urlencoding",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dioxus-router-macro"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "149652f16bb41cdcb4477829947bdefd7847660986e7eabaa26fd7bf43acc985"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"slab",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dioxus-rsx"
|
name = "dioxus-rsx"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
|
@ -1506,10 +1535,12 @@ dependencies = [
|
||||||
name = "mabel-hex"
|
name = "mabel-hex"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
"dioxus",
|
"dioxus",
|
||||||
"dioxus-cli-config",
|
"dioxus-cli-config",
|
||||||
"dioxus-logger",
|
"dioxus-logger",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
"serde",
|
||||||
"thiserror 2.0.11",
|
"thiserror 2.0.11",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
@ -2641,6 +2672,12 @@ dependencies = [
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf-8"
|
name = "utf-8"
|
||||||
version = "0.7.6"
|
version = "0.7.6"
|
||||||
|
|
|
@ -5,11 +5,13 @@ authors = ["Ash Keel <ash@nebula.cafe>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
dioxus = { version = "0.6", features = ["fullstack"] }
|
async-trait = "0.1"
|
||||||
|
dioxus = { version = "0.6", features = ["fullstack", "router"] }
|
||||||
dioxus-cli-config = "0.6"
|
dioxus-cli-config = "0.6"
|
||||||
dioxus-logger = "0.6"
|
dioxus-logger = "0.6"
|
||||||
dotenvy = { version = "0.15", optional = true }
|
dotenvy = { version = "0.15", optional = true }
|
||||||
thiserror = "2.0.11"
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
thiserror = "2"
|
||||||
tokio = { version = "1", features = ["full"], optional = true }
|
tokio = { version = "1", features = ["full"], optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
@import url(https://rsms.me/inter/inter.css);
|
|
||||||
|
|
||||||
:root {
|
|
||||||
background-color: #F8F7FF;
|
|
||||||
color: rgb(23, 3, 21);
|
|
||||||
|
|
||||||
font-family: Inter, sans-serif;
|
|
||||||
/* fix for Chrome */
|
|
||||||
font-feature-settings: 'liga' 1, 'calt' 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@supports (font-variation-settings: normal) {
|
|
||||||
:root {
|
|
||||||
font-family: InterVariable, sans-serif;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
62
assets/style/main.css
Normal file
62
assets/style/main.css
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
@import url(https://rsms.me/inter/inter.css);
|
||||||
|
|
||||||
|
:root {
|
||||||
|
background-color: #F8F7FF;
|
||||||
|
color: rgb(23, 3, 21);
|
||||||
|
|
||||||
|
font-family: Inter, sans-serif;
|
||||||
|
/* fix for Chrome */
|
||||||
|
font-feature-settings: 'liga' 1, 'calt' 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (font-variation-settings: normal) {
|
||||||
|
:root {
|
||||||
|
font-family: InterVariable, sans-serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
header.site-header {
|
||||||
|
background-color: #5720a5;
|
||||||
|
color: #F8F7FF;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
|
||||||
|
a[href],
|
||||||
|
a[href]:visited {
|
||||||
|
color: #fab19a;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
pointer-events: none;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.page-list {
|
||||||
|
font-size: 16pt;
|
||||||
|
|
||||||
|
&>ul {
|
||||||
|
display: flex;
|
||||||
|
list-style-type: none;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&>li a {
|
||||||
|
font-weight: 300;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
7
src/domain/entities/cursor.rs
Normal file
7
src/domain/entities/cursor.rs
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Paginated<DataType, CursorType> {
|
||||||
|
pub data: Vec<DataType>,
|
||||||
|
pub next: Option<CursorType>,
|
||||||
|
}
|
|
@ -1 +1,2 @@
|
||||||
|
pub mod cursor;
|
||||||
pub mod site;
|
pub mod site;
|
||||||
|
|
|
@ -1,39 +1,55 @@
|
||||||
#[derive(Debug, Clone)]
|
use serde::{Deserialize, Serialize};
|
||||||
pub struct SiteMetadata {
|
|
||||||
pub domain: String,
|
|
||||||
pub title: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PageInfo {
|
pub struct PageInfo {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub order: i32,
|
pub order: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct SiteInfo {
|
pub struct SiteInfo {
|
||||||
pub info: SiteMetadata,
|
pub domain: String,
|
||||||
|
pub title: String,
|
||||||
pub pages: Vec<PageInfo>,
|
pub pages: Vec<PageInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Page {
|
pub struct Page {
|
||||||
pub info: PageInfo,
|
pub info: PageInfo,
|
||||||
pub content: PageContent,
|
pub content: PageContent,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum PageContent {
|
pub enum PageContent {
|
||||||
Single { content: Post },
|
Single {
|
||||||
|
content: Post,
|
||||||
|
},
|
||||||
|
Collection {
|
||||||
|
kind: CollectionKind,
|
||||||
|
collection_id: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum CollectionKind {
|
||||||
|
Blog,
|
||||||
|
Gallery,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Post {
|
pub struct Post {
|
||||||
pub blocks: Vec<Block>,
|
pub blocks: Vec<Block>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(PartialEq, Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum Block {
|
pub enum Block {
|
||||||
Text { text: String },
|
Text { text: String },
|
||||||
|
Gallery { images: Vec<Image> },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Image {
|
||||||
|
pub src: String,
|
||||||
|
pub caption: String,
|
||||||
}
|
}
|
||||||
|
|
29
src/inbound/renderer/meta.rs
Normal file
29
src/inbound/renderer/meta.rs
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::domain::entities::site::{Page, SiteInfo};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SiteContext {
|
||||||
|
pub info: SiteInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_site(info: &SiteInfo) {
|
||||||
|
let _state = use_context_provider(|| SiteContext { info: info.clone() });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn site() -> SiteContext {
|
||||||
|
use_context::<SiteContext>()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PageContext {
|
||||||
|
pub data: Page,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_page(data: Page) {
|
||||||
|
let _state = use_context_provider(|| PageContext { data });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn page() -> PageContext {
|
||||||
|
use_context::<PageContext>()
|
||||||
|
}
|
|
@ -2,10 +2,185 @@
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use page::{Page, PostElement};
|
||||||
|
use server::get_site_info;
|
||||||
|
|
||||||
|
use crate::domain::entities::site::PageContent;
|
||||||
|
|
||||||
|
mod meta;
|
||||||
|
mod page;
|
||||||
mod server;
|
mod server;
|
||||||
|
|
||||||
|
#[derive(Clone, Routable, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[rustfmt::skip]
|
||||||
|
enum Route {
|
||||||
|
#[layout(SiteLayout)]
|
||||||
|
#[route("/")]
|
||||||
|
Home {},
|
||||||
|
#[nest("/p")]
|
||||||
|
#[route("/:page")]
|
||||||
|
Single { page: String },
|
||||||
|
#[route("/:page/:id")]
|
||||||
|
Post { page: String, id: String },
|
||||||
|
#[end_nest]
|
||||||
|
#[route("/:..route")]
|
||||||
|
PageNotFound { route: Vec<String> },
|
||||||
|
}
|
||||||
|
|
||||||
pub fn App() -> Element {
|
pub fn App() -> Element {
|
||||||
|
// Retrieve site info
|
||||||
|
let site_info = use_server_future(get_site_info)?.suspend()?;
|
||||||
|
|
||||||
|
// Inject site info in context
|
||||||
|
match &*site_info.read() {
|
||||||
|
Ok(info) => meta::set_site(info),
|
||||||
|
Err(err) => {
|
||||||
|
return rsx! {
|
||||||
|
h1 { "FATAL ERROR: {err}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
h1 { "Hello, world!" }
|
Router::<Route> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn PageNotFound(route: Vec<String>) -> Element {
|
||||||
|
rsx! {
|
||||||
|
h1 { "404 - {route:?}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn PageLink(page: String, title: String, current: Route) -> Element {
|
||||||
|
let to = if page == "/" {
|
||||||
|
Route::Home {}
|
||||||
|
} else {
|
||||||
|
Route::Single { page }
|
||||||
|
};
|
||||||
|
|
||||||
|
let class = if current == to { "active" } else { "" };
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
Link { to, class, "{title}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SiteLayout() -> Element {
|
||||||
|
let site = meta::site();
|
||||||
|
let route = use_route::<Route>();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
header { class: "site-header",
|
||||||
|
nav { class: "page-list",
|
||||||
|
ul {
|
||||||
|
for page in site.info.pages {
|
||||||
|
li {
|
||||||
|
PageLink {
|
||||||
|
page: page.name,
|
||||||
|
title: page.title,
|
||||||
|
current: route.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h1 { "{site.info.title}" }
|
||||||
|
}
|
||||||
|
SuspenseBoundary { fallback: |_context: SuspenseContext| rsx! { "..." },
|
||||||
|
main { Outlet::<Route> {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Home() -> Element {
|
||||||
|
let site = meta::site();
|
||||||
|
let page_ref =
|
||||||
|
use_server_future(move || server::get_page(site.info.domain.clone(), "/".to_string()))?
|
||||||
|
.suspend()?;
|
||||||
|
match &*page_ref.read() {
|
||||||
|
Ok(page) => meta::set_page(page.clone()),
|
||||||
|
Err(err) => {
|
||||||
|
return rsx! {
|
||||||
|
h1 { "FATAL ERROR: {err}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
Page {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Single(page: String) -> Element {
|
||||||
|
let site = meta::site();
|
||||||
|
let page_ref =
|
||||||
|
use_server_future(move || server::get_page(site.info.domain.clone(), page.clone()))?
|
||||||
|
.suspend()?;
|
||||||
|
match &*page_ref.read() {
|
||||||
|
Ok(page) => meta::set_page(page.clone()),
|
||||||
|
Err(err) => {
|
||||||
|
return rsx! {
|
||||||
|
h1 { "FATAL ERROR: {err}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
Page {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Post(page: String, id: String) -> Element {
|
||||||
|
let site = meta::site();
|
||||||
|
let domain = site.info.domain.clone();
|
||||||
|
let page_ref =
|
||||||
|
use_server_future(move || server::get_page(domain.clone(), page.clone()))?.suspend()?;
|
||||||
|
match &*page_ref.read() {
|
||||||
|
Ok(page) => {
|
||||||
|
meta::set_page(page.clone());
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
return rsx! {
|
||||||
|
h1 { "FATAL ERROR: {err}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let page = meta::page();
|
||||||
|
|
||||||
|
let collection_id = match &page.data.content {
|
||||||
|
PageContent::Collection {
|
||||||
|
kind: _,
|
||||||
|
collection_id,
|
||||||
|
} => collection_id.clone(),
|
||||||
|
_ => {
|
||||||
|
return rsx! {
|
||||||
|
h1 { "NOT A COLLECTION" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let post_ref = use_server_future(move || {
|
||||||
|
server::get_post(site.info.domain.clone(), collection_id.clone(), id.clone())
|
||||||
|
})?
|
||||||
|
.suspend()?;
|
||||||
|
let post = match &*post_ref.read() {
|
||||||
|
Ok(post) => post.clone(),
|
||||||
|
Err(err) => {
|
||||||
|
return rsx! {
|
||||||
|
h1 { "FATAL ERROR: {err}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
PostElement { post }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
74
src/inbound/renderer/page.rs
Normal file
74
src/inbound/renderer/page.rs
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::domain::entities::site::{Block, PageContent, Post};
|
||||||
|
|
||||||
|
use super::meta;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Page() -> Element {
|
||||||
|
let page = meta::page();
|
||||||
|
let info = page.data.info;
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
h2 { "Page {info.title}" }
|
||||||
|
match page.data.content {
|
||||||
|
PageContent::Single { content } => rsx! {
|
||||||
|
PostElement { post: content }
|
||||||
|
},
|
||||||
|
PageContent::Collection { kind: _, collection_id } => {
|
||||||
|
rsx! {
|
||||||
|
SuspenseBoundary { fallback: |_context: SuspenseContext| rsx! { "..." },
|
||||||
|
//Collection { collection_id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
#[component]
|
||||||
|
pub fn Collection(collection_id: String) -> Element {
|
||||||
|
let site = meta::site();
|
||||||
|
let posts =
|
||||||
|
use_server_future(move || get_posts(site.info.domain.clone(), collection_id.clone()))?
|
||||||
|
.suspend()?;
|
||||||
|
|
||||||
|
let result = match &*posts.read() {
|
||||||
|
Ok(posts) => rsx! {
|
||||||
|
for post in posts.data.clone() {
|
||||||
|
PostElement { post }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
rsx! {
|
||||||
|
h1 { "FATAL ERROR: {err}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
result
|
||||||
|
}*/
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn PostElement(post: Post) -> Element {
|
||||||
|
rsx! {
|
||||||
|
for block in post.blocks {
|
||||||
|
BlockElement { block }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn BlockElement(block: Block) -> Element {
|
||||||
|
match block {
|
||||||
|
Block::Text { text } => rsx! {
|
||||||
|
p { "{text}" }
|
||||||
|
},
|
||||||
|
Block::Gallery { images } => rsx! {
|
||||||
|
for image in images {
|
||||||
|
img { src: "{image.src}", alt: "{image.caption}" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -1 +1,57 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::domain::entities::{
|
||||||
|
cursor::Paginated,
|
||||||
|
site::{Page, Post, SiteInfo},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn get_site_info() -> Result<SiteInfo, ServerFnError> {
|
||||||
|
use crate::outbound::services::site::SiteService;
|
||||||
|
let FromContext::<SiteService>(service) = extract().await?;
|
||||||
|
|
||||||
|
let headers = server_context().request_parts().headers.clone();
|
||||||
|
let domain = match headers.get("host").and_then(|h| h.to_str().ok()) {
|
||||||
|
Some(host) => host
|
||||||
|
.split_once(':')
|
||||||
|
.map(|(domain, _)| domain)
|
||||||
|
.unwrap_or(host),
|
||||||
|
None => "",
|
||||||
|
}
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let site = service.get_site(domain.as_str()).await?;
|
||||||
|
Ok(site)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn get_page(domain: String, page: String) -> Result<Page, ServerFnError> {
|
||||||
|
use crate::outbound::services::site::SiteService;
|
||||||
|
let FromContext::<SiteService>(service) = extract().await?;
|
||||||
|
|
||||||
|
Ok(service.get_page(&domain, &page).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
#[server]
|
||||||
|
pub async fn get_posts(
|
||||||
|
domain: String,
|
||||||
|
collection_id: String,
|
||||||
|
) -> Result<Paginated<Post, String>, ServerFnError> {
|
||||||
|
use crate::outbound::services::site::SiteService;
|
||||||
|
let FromContext::<SiteService>(service) = extract().await?;
|
||||||
|
|
||||||
|
Ok(service.get_posts(&domain, &collection_id).await?)
|
||||||
|
}*/
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn get_post(
|
||||||
|
domain: String,
|
||||||
|
collection_id: String,
|
||||||
|
id: String,
|
||||||
|
) -> Result<Post, ServerFnError> {
|
||||||
|
use crate::outbound::services::site::SiteService;
|
||||||
|
let FromContext::<SiteService>(service) = extract().await?;
|
||||||
|
|
||||||
|
Ok(service.get_post(&domain, &collection_id, &id).await?)
|
||||||
|
}
|
||||||
|
|
23
src/main.rs
23
src/main.rs
|
@ -21,7 +21,28 @@ fn main() {
|
||||||
builder = builder
|
builder = builder
|
||||||
.with_context_provider(
|
.with_context_provider(
|
||||||
|| {
|
|| {
|
||||||
Box::new(SiteService::new(InMemoryStore::new()))
|
let mut store = InMemoryStore::new();
|
||||||
|
store.add_site(outbound::repository::site::SiteMetadata {
|
||||||
|
domain: "localhost".to_string(),
|
||||||
|
title: "Test site".to_string(),
|
||||||
|
});
|
||||||
|
store.add_page("localhost", domain::entities::site::Page{
|
||||||
|
info: domain::entities::site::PageInfo {
|
||||||
|
title: "Home".to_string(),
|
||||||
|
name: "/".to_string(),
|
||||||
|
order: 0,
|
||||||
|
},
|
||||||
|
content: domain::entities::site::PageContent::Single {
|
||||||
|
content: domain::entities::site::Post {
|
||||||
|
blocks: vec![{
|
||||||
|
domain::entities::site::Block::Text {
|
||||||
|
text: "Hello, world!".to_string(),
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Box::new(SiteService::new(store))
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,19 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
domain::entities::site::{Page, PageInfo, SiteMetadata},
|
domain::entities::site::{Page, PageInfo, Post},
|
||||||
outbound::repository::{
|
outbound::repository::{
|
||||||
self,
|
self,
|
||||||
site::{Result, SiteRepository},
|
site::{Result, SiteMetadata, SiteRepository},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct InMemoryStore {
|
pub struct InMemoryStore {
|
||||||
sites: HashMap<String, SiteMetadata>,
|
sites: HashMap<String, SiteMetadata>,
|
||||||
pages: HashMap<(String, String), Page>,
|
pages: HashMap<(String, String), Page>,
|
||||||
|
posts: HashMap<(String, String), HashMap<String, Post>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InMemoryStore {
|
impl InMemoryStore {
|
||||||
|
@ -18,6 +21,7 @@ impl InMemoryStore {
|
||||||
InMemoryStore {
|
InMemoryStore {
|
||||||
sites: HashMap::new(),
|
sites: HashMap::new(),
|
||||||
pages: HashMap::new(),
|
pages: HashMap::new(),
|
||||||
|
posts: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -30,6 +34,13 @@ impl InMemoryStore {
|
||||||
.insert((site.to_string(), page.info.name.clone()), page);
|
.insert((site.to_string(), page.info.name.clone()), page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn add_post(&mut self, site: &str, collection_id: &str, post_id: &str, post: Post) {
|
||||||
|
self.posts
|
||||||
|
.entry((site.to_string(), collection_id.to_string()))
|
||||||
|
.or_insert_with(HashMap::new)
|
||||||
|
.insert(post_id.to_string(), post);
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub fn with_test_data() -> InMemoryStore {
|
pub fn with_test_data() -> InMemoryStore {
|
||||||
use crate::domain::entities::site::{Block, PageContent, Post};
|
use crate::domain::entities::site::{Block, PageContent, Post};
|
||||||
|
@ -75,10 +86,22 @@ impl InMemoryStore {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
store.add_post(
|
||||||
|
"example.com",
|
||||||
|
"home_posts",
|
||||||
|
"post_id",
|
||||||
|
Post {
|
||||||
|
blocks: vec![Block::Text {
|
||||||
|
text: "Hello, world!".to_string(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
store
|
store
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
impl SiteRepository for InMemoryStore {
|
impl SiteRepository for InMemoryStore {
|
||||||
async fn get_site_by_domain(&self, domain: &str) -> Result<SiteMetadata> {
|
async fn get_site_by_domain(&self, domain: &str) -> Result<SiteMetadata> {
|
||||||
self.sites
|
self.sites
|
||||||
|
@ -106,13 +129,24 @@ impl SiteRepository for InMemoryStore {
|
||||||
.ok_or(repository::site::Error::NotFound)
|
.ok_or(repository::site::Error::NotFound)
|
||||||
.cloned()
|
.cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_post(&self, domain: &str, collection_id: &str, post_id: &str) -> Result<Post> {
|
||||||
|
self.posts
|
||||||
|
.get(&(domain.to_string(), collection_id.to_string()))
|
||||||
|
.and_then(|posts| posts.get(post_id))
|
||||||
|
.cloned()
|
||||||
|
.ok_or(repository::site::Error::NotFound)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::{
|
use crate::{
|
||||||
domain::entities::site::{Block, Page, PageContent, PageInfo, Post, SiteMetadata},
|
domain::entities::site::{Block, Page, PageContent, PageInfo, Post},
|
||||||
outbound::repository::{self, site::SiteRepository},
|
outbound::repository::{
|
||||||
|
self,
|
||||||
|
site::{SiteMetadata, SiteRepository},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::InMemoryStore;
|
use super::InMemoryStore;
|
||||||
|
@ -185,10 +219,6 @@ mod tests {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn get_page_works() {
|
async fn get_page_works() {
|
||||||
let mut store = InMemoryStore::new();
|
let mut store = InMemoryStore::new();
|
||||||
store.add_site(SiteMetadata {
|
|
||||||
domain: "example.com".to_string(),
|
|
||||||
title: "Test site".to_string(),
|
|
||||||
});
|
|
||||||
store.add_page(
|
store.add_page(
|
||||||
"example.com",
|
"example.com",
|
||||||
Page {
|
Page {
|
||||||
|
@ -207,10 +237,15 @@ mod tests {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let page = store.get_page("example.com", "/").await.unwrap();
|
let page = store.get_page("example.com", "/").await.unwrap();
|
||||||
let PageContent::Single { content } = page.content;
|
let PageContent::Single { content } = page.content else {
|
||||||
|
panic!("page content must be a single text block")
|
||||||
|
};
|
||||||
let Post { blocks } = content;
|
let Post { blocks } = content;
|
||||||
assert_eq!(blocks.len(), 1);
|
assert_eq!(blocks.len(), 1);
|
||||||
let Block::Text { text } = &blocks[0];
|
|
||||||
|
let Block::Text { text } = &blocks[0] else {
|
||||||
|
panic!("page content must be a single text block")
|
||||||
|
};
|
||||||
assert_eq!(text, "Hello, world!");
|
assert_eq!(text, "Hello, world!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -224,4 +259,42 @@ mod tests {
|
||||||
repository::site::Error::NotFound
|
repository::site::Error::NotFound
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_post_works() {
|
||||||
|
let mut store = InMemoryStore::new();
|
||||||
|
store.add_post(
|
||||||
|
"example.com",
|
||||||
|
"home_posts",
|
||||||
|
"test_id",
|
||||||
|
Post {
|
||||||
|
blocks: vec![Block::Text {
|
||||||
|
text: "Hello, world!".to_string(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let post = store
|
||||||
|
.get_post("example.com", "home_posts", "test_id")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let Post { blocks } = post;
|
||||||
|
assert_eq!(blocks.len(), 1);
|
||||||
|
|
||||||
|
let Block::Text { text } = &blocks[0] else {
|
||||||
|
panic!("page content must be a single text block")
|
||||||
|
};
|
||||||
|
assert_eq!(text, "Hello, world!");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_post_for_nonexistent_fails() {
|
||||||
|
let store = InMemoryStore::new();
|
||||||
|
let result = store.get_post("example.com", "home_posts", "test_id").await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(
|
||||||
|
result.err().unwrap(),
|
||||||
|
repository::site::Error::NotFound
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,20 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::domain::entities::site::{Page, PageInfo, SiteMetadata};
|
use crate::domain::entities::site::{Page, PageInfo, Post};
|
||||||
|
|
||||||
pub trait SiteRepository {
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SiteMetadata {
|
||||||
|
pub domain: String,
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait SiteRepository: Send + Sync + 'static {
|
||||||
async fn get_site_by_domain(&self, domain: &str) -> Result<SiteMetadata>;
|
async fn get_site_by_domain(&self, domain: &str) -> Result<SiteMetadata>;
|
||||||
async fn get_pages_for_site(&self, domain: &str) -> Result<Vec<PageInfo>>;
|
async fn get_pages_for_site(&self, domain: &str) -> Result<Vec<PageInfo>>;
|
||||||
async fn get_page(&self, domain: &str, name: &str) -> Result<Page>;
|
async fn get_page(&self, domain: &str, name: &str) -> Result<Page>;
|
||||||
|
async fn get_post(&self, domain: &str, collection_id: &str, id: &str) -> Result<Post>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
|
@ -1,17 +1,22 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
domain::entities::site::{Page, SiteInfo},
|
domain::entities::site::{Page, Post, SiteInfo},
|
||||||
outbound::repository::{self, site::SiteRepository},
|
outbound::repository::{self, site::SiteRepository},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct SiteService<SiteRepo: SiteRepository> {
|
#[derive(Clone)]
|
||||||
site_repository: SiteRepo,
|
pub struct SiteService {
|
||||||
|
site_repository: Arc<dyn SiteRepository>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<SiteRepo: SiteRepository> SiteService<SiteRepo> {
|
impl SiteService {
|
||||||
pub fn new(site_repository: SiteRepo) -> Self {
|
pub fn new(site_repository: impl SiteRepository) -> Self {
|
||||||
Self { site_repository }
|
Self {
|
||||||
|
site_repository: Arc::new(site_repository),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_site(&self, domain: &str) -> Result<SiteInfo> {
|
pub async fn get_site(&self, domain: &str) -> Result<SiteInfo> {
|
||||||
|
@ -21,7 +26,11 @@ impl<SiteRepo: SiteRepository> SiteService<SiteRepo> {
|
||||||
.get_pages_for_site(&info.domain)
|
.get_pages_for_site(&info.domain)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(SiteInfo { info, pages })
|
Ok(SiteInfo {
|
||||||
|
title: info.title,
|
||||||
|
domain: info.domain,
|
||||||
|
pages,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_page(&self, domain: &str, name: &str) -> Result<Page> {
|
pub async fn get_page(&self, domain: &str, name: &str) -> Result<Page> {
|
||||||
|
@ -30,6 +39,16 @@ impl<SiteRepo: SiteRepository> SiteService<SiteRepo> {
|
||||||
|
|
||||||
Ok(page)
|
Ok(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_post(&self, domain: &str, collection_id: &str, id: &str) -> Result<Post> {
|
||||||
|
let info = self.site_repository.get_site_by_domain(domain).await?;
|
||||||
|
let post = self
|
||||||
|
.site_repository
|
||||||
|
.get_post(&info.domain, collection_id, id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(post)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
@ -64,11 +83,19 @@ mod tests {
|
||||||
async fn gets_site_info() {
|
async fn gets_site_info() {
|
||||||
let service = SiteService::new(InMemoryStore::with_test_data());
|
let service = SiteService::new(InMemoryStore::with_test_data());
|
||||||
let info = service.get_site("example.com").await.unwrap();
|
let info = service.get_site("example.com").await.unwrap();
|
||||||
assert_eq!(info.info.domain, "example.com");
|
assert_eq!(info.domain, "example.com");
|
||||||
assert_eq!(info.info.title, "Test site");
|
assert_eq!(info.title, "Test site");
|
||||||
assert_eq!(info.pages.len(), 2);
|
assert_eq!(info.pages.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gets_nonexistent_site() {
|
||||||
|
let service = SiteService::new(InMemoryStore::with_test_data());
|
||||||
|
let result = service.get_site("nonexistent.com").await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(result.err().unwrap(), Error::NotFound));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gets_page_data() {
|
async fn gets_page_data() {
|
||||||
let service = SiteService::new(InMemoryStore::with_test_data());
|
let service = SiteService::new(InMemoryStore::with_test_data());
|
||||||
|
@ -80,9 +107,15 @@ mod tests {
|
||||||
// page content must be a single text block
|
// page content must be a single text block
|
||||||
let PageContent::Single {
|
let PageContent::Single {
|
||||||
content: page_content,
|
content: page_content,
|
||||||
} = page.content;
|
} = page.content
|
||||||
|
else {
|
||||||
|
panic!("page content must be a single text block");
|
||||||
|
};
|
||||||
assert_eq!(page_content.blocks.len(), 1);
|
assert_eq!(page_content.blocks.len(), 1);
|
||||||
let Block::Text { text } = &page_content.blocks[0];
|
|
||||||
|
let Block::Text { text } = &page_content.blocks[0] else {
|
||||||
|
panic!("page content must be a single text block");
|
||||||
|
};
|
||||||
assert_eq!(text, "Hello, world!");
|
assert_eq!(text, "Hello, world!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -93,4 +126,29 @@ mod tests {
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert!(matches!(result.err().unwrap(), Error::NotFound));
|
assert!(matches!(result.err().unwrap(), Error::NotFound));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gets_a_single_post() {
|
||||||
|
let service = SiteService::new(InMemoryStore::with_test_data());
|
||||||
|
let post = service
|
||||||
|
.get_post("example.com", "home_posts", "post_id")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(post.blocks.len(), 1);
|
||||||
|
|
||||||
|
let Block::Text { text } = &post.blocks[0] else {
|
||||||
|
panic!("page content must be a single text block");
|
||||||
|
};
|
||||||
|
assert_eq!(text, "Hello, world!");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gets_nonexistent_post() {
|
||||||
|
let service = SiteService::new(InMemoryStore::with_test_data());
|
||||||
|
let result = service
|
||||||
|
.get_post("example.com", "home_posts", "nonexistent")
|
||||||
|
.await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(result.err().unwrap(), Error::NotFound));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue