add ssr rust

This commit is contained in:
2023-05-08 00:05:45 +02:00
parent a923372997
commit 38d8e51975
13 changed files with 2757 additions and 1 deletions

View File

@@ -0,0 +1,25 @@
use super::Tree;
use axohtml::html;
pub struct Home {
doc: Tree,
}
impl Home {
pub fn build() -> Self {
let doc = html!(
<div id="home" class=["p-8", "max-w-xl"]>
<p><a href="/inventory">"Inventory"</a></p>
<p><a href="/trips">"Trips"</a></p>
</div>
);
Self { doc }
}
}
impl Into<Tree> for Home {
fn into(self) -> Tree {
self.doc
}
}

View File

@@ -0,0 +1,227 @@
use super::Tree;
use axohtml::{
html, text,
types::{Class, SpacedSet},
};
use crate::models::*;
use crate::State;
pub struct Inventory {
doc: Tree,
}
impl Inventory {
pub async fn build(state: State, categories: Vec<Category>) -> Result<Self, Error> {
let doc = html!(
<div id="pkglist-item-manager">
<div class=["p-8", "grid", "grid-cols-4", "gap-3"]>
<div class=["col-span-2"]>
{<InventoryCategoryList as Into<Tree>>::into(InventoryCategoryList::build(&categories).await?)}
</div>
{if state.has_active_category { html!(
<div class=["col-span-2"]>
{<InventoryItemList as Into<Tree>>::into(InventoryItemList::build(categories.iter().find(|category| category.active).unwrap().items()).await?)}
</div>
)} else {
html!(<div></div>)
}}
</div>
</div>
);
Ok(Self { doc })
}
}
impl Into<Tree> for Inventory {
fn into(self) -> Tree {
self.doc
}
}
pub struct InventoryCategoryList {
doc: Tree,
}
impl InventoryCategoryList {
pub async fn build(categories: &Vec<Category>) -> Result<Self, Error> {
let biggest_category_weight: u32 = categories
.iter()
.map(|category| category.total_weight())
.max()
.unwrap_or(1);
let cls_td_active: SpacedSet<Class> =
["border", "p-0", "m-0", "font-bold"].try_into().unwrap();
let cls_td_inactive: SpacedSet<Class> = ["border", "p-0", "m-0"].try_into().unwrap();
let cls_tr_active: SpacedSet<Class> = [
"h-10",
"hover:bg-purple-100",
"m-3",
"h-full",
"outline",
"outline-2",
"outline-indigo-600",
]
.try_into()
.unwrap();
let cls_tr_inactive: SpacedSet<Class> = ["h-10", "hover:bg-purple-100", "m-3", "h-full"]
.try_into()
.unwrap();
let doc = html!(
<div>
<h1 class=["text-2xl", "mb-5"]>"Categories"</h1>
<table class=[
"table",
"table-auto",
"border-collapse",
"border-spacing-0",
"border",
"w-full",
]>
<colgroup>
<col style="width:50%"/>
<col style="width:50%"/>
</colgroup>
<thead class=["bg-gray-200"]>
<tr class=["h-10"]>
<th class=["border", "p-2"]>"Name"</th>
<th class=["border", "p-2"]>"Weight"</th>
</tr>
</thead>
<tbody>
{categories.iter().map(|category| html!(
<tr
class={if category.active {
cls_tr_active.clone()
} else {
cls_tr_inactive.clone()
}}
>
<td
class={if category.active {
cls_td_active.clone()
} else {
cls_td_inactive.clone()
}}
>
<a
id="select-category"
href={
format!(
"/inventory/category/{id}",
id=category.id
)
}
class=["inline-block", "p-2", "m-0", "w-full"]
>
{text!(category.name.clone())}
</a>
</td>
<td class=["border", "p-0", "m-0"] style="position:relative;">
<a
id="select-category"
href={
format!(
"/inventory/category/{id}",
id=category.id
)
}
class=["inline-block", "p-2", "m-0", "w-full"]
>
<p>
{text!(category.total_weight().to_string())}
</p>
</a>
<div
class=["bg-blue-600", "h-1.5"]
style = {
format!(
"width: {width}%;position:absolute;left:0;bottom:0;right:0;",
width=(
category.total_weight() as f32
/ biggest_category_weight as f32
* 100.0
)
)
}
>
</div>
</td>
</tr>
))}
<tr class=["h-10", "hover:bg-purple-200", "bg-gray-300", "font-bold"]>
<td class=["border", "p-0", "m-0"]>
<p class=["p-2", "m-2"]>"Sum"</p>
</td>
<td class=["border", "p-0", "m-0"]>
<p class=["p-2", "m-2"]>
{text!(categories.iter().map(|category| category.total_weight()).sum::<u32>().to_string())}
</p>
</td>
</tr>
</tbody>
</table>
</div>
);
Ok(Self { doc })
}
}
impl Into<Tree> for InventoryCategoryList {
fn into(self) -> Tree {
self.doc
}
}
pub struct InventoryItemList {
doc: Tree,
}
impl InventoryItemList {
pub async fn build(items: &Vec<Item>) -> Result<Self, Error> {
let doc = html!(
<div>
<h1 class=["text-2xl", "mb-5"]>"Categories"</h1>
<table class=[
"table",
"table-auto",
"border-collapse",
"border-spacing-0",
"border",
"w-full",
]>
<thead class=["bg-gray-200"]>
<tr class=["h-10"]>
<th class=["border", "p-2"]>"Name"</th>
<th class=["border", "p-2"]>"Weight"</th>
</tr>
</thead>
<tbody>
{items.iter().map(|item| html!(
<tr>
<td>
{text!(item.name.clone())}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
Ok(Self { doc })
}
}
impl Into<Tree> for InventoryItemList {
fn into(self) -> Tree {
self.doc
}
}

View File

@@ -0,0 +1,86 @@
use axohtml::{
dom::DOMTree,
elements::FlowContent,
html,
types::{Class, SpacedSet},
unsafe_text,
};
type Tree = Box<dyn FlowContent<String>>;
pub mod home;
pub mod inventory;
pub mod triplist;
pub use home::*;
pub use inventory::*;
pub use triplist::*;
pub struct Root {
doc: DOMTree<String>,
}
pub enum TopLevelPage {
Inventory,
Trips,
None,
}
impl Root {
pub fn build(body: Tree, active_page: TopLevelPage) -> Self {
let active_classes: SpacedSet<Class> =
["text-lg", "font-bold", "underline"].try_into().unwrap();
let inactive_classes: SpacedSet<Class> = ["text-lg"].try_into().unwrap();
let doc = html!(
<html>
<head>
<title>"Packager"</title>
<script src="https://unpkg.com/htmx.org@1.7.0"/>
<script src="https://cdn.tailwindcss.com"/>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.js" defer="true"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@6.9.96/css/materialdesignicons.min.css"/>
<script>{unsafe_text!(include_str!(concat!(env!("CARGO_MANIFEST_DIR"),"/js/app.js")))}</script>
</head>
<body>
<header class=[
"bg-gray-200",
"p-5",
"flex",
"flex-row",
"flex-nowrap",
"justify-between",
"items-center",
]>
<span class=["text-xl", "font-semibold"]>
<a href="/">"Packager"</a>
</span>
<nav class=["grow", "flex", "flex-row", "justify-center", "gap-x-6"]>
<a href="/inventory/" class={
match active_page {
TopLevelPage::Inventory => active_classes.clone(),
_ => inactive_classes.clone(),
}
}>"Inventory"</a>
<a href="/trips/" class={
match active_page {
TopLevelPage::Trips => active_classes,
_ => inactive_classes,
}
}>"Trips"</a>
</nav>
</header>
{body}
</body>
</html>
);
Self { doc }
}
pub fn to_string(&self) -> String {
let mut doc = self.doc.to_string();
doc.insert_str(0, "<!DOCTYPE html>\n");
doc
}
}

View File

@@ -0,0 +1,43 @@
use super::Tree;
use crate::models::*;
use axohtml::{html, text};
pub struct TripList {
doc: Tree,
}
impl TripList {
pub fn build(package_lists: Vec<Trip>) -> Self {
let doc = html!(
<table>
<thead>
<tr>
<th>"ID"</th>
<th>"Name"</th>
</tr>
</thead>
<tbody>
{
package_lists.into_iter().map(|list| {
html!(
<tr>
<td>{text!(list.id.to_string())}</td>
<td>{text!(list.name)}</td>
</tr>
)
})
}
</tbody>
</table>
);
Self { doc }
}
}
impl Into<Tree> for TripList {
fn into(self) -> Tree {
self.doc
}
}

147
rust/src/main.rs Normal file
View File

@@ -0,0 +1,147 @@
use axum::{extract::Path, http::StatusCode, response::Html, routing::get, Router};
use sqlx::sqlite::SqlitePoolOptions;
use futures::TryStreamExt;
use uuid::Uuid;
use std::net::SocketAddr;
mod components;
mod models;
use crate::components::*;
use crate::models::*;
pub struct State {
pub has_active_category: bool,
}
impl State {
pub fn new() -> Self {
State {
has_active_category: false,
}
}
}
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
// build our application with a route
let app = Router::new()
.route("/", get(root))
.route("/trips/", get(trips))
.route("/inventory/", get(inventory_inactive))
.route("/inventory/category/:id", get(inventory_active));
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
tracing::debug!("listening on {}", addr);
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap();
Ok(())
}
async fn root() -> (StatusCode, Html<String>) {
(
StatusCode::OK,
Html::from(Root::build(Home::build().into(), TopLevelPage::None).to_string()),
)
}
async fn inventory_active(
Path(id): Path<String>,
) -> Result<(StatusCode, Html<String>), (StatusCode, Html<String>)> {
inventory(Some(id)).await
}
async fn inventory_inactive() -> Result<(StatusCode, Html<String>), (StatusCode, Html<String>)> {
inventory(None).await
}
async fn inventory(
active_id: Option<String>,
) -> Result<(StatusCode, Html<String>), (StatusCode, Html<String>)> {
let mut state: State = State::new();
state.has_active_category = active_id.is_some();
let active_id = active_id
.map(|id| Uuid::try_parse(&id))
.transpose()
.map_err(|e| (StatusCode::BAD_REQUEST, Html::from(e.to_string())))?;
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect("sqlite:///home/hannes-private/sync/items/items.sqlite")
.await
.unwrap();
let mut categories = sqlx::query("SELECT id,name,description FROM inventoryitemcategories")
.fetch(&pool)
.map_ok(|row| row.try_into())
.try_collect::<Vec<Result<Category, models::Error>>>()
.await
// we have two error handling lines here. these are distinct errors
// this one is the SQL error that may arise during the query
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Html::from(e.to_string())))?
.into_iter()
.collect::<Result<Vec<Category>, models::Error>>()
// and this one is the model mapping error that may arise e.g. during
// reading of the rows
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Html::from(e.to_string())))?;
for category in &mut categories {
category
.populate_items()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Html::from(e.to_string())))?;
if let Some(active_id) = active_id {
if category.id == active_id {
category.active = true;
}
}
}
Ok((
StatusCode::OK,
Html::from(
Root::build(
Inventory::build(state, categories)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Html::from(e.to_string())))?
.into(),
TopLevelPage::Inventory,
)
.to_string(),
),
))
}
async fn trips() -> Result<(StatusCode, Html<String>), (StatusCode, Html<String>)> {
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect("sqlite:///home/hannes-private/sync/items/items.sqlite")
.await
.unwrap();
let trips = sqlx::query("SELECT * FROM trips")
.fetch(&pool)
.map_ok(|row| row.try_into())
.try_collect::<Vec<Result<Trip, models::Error>>>()
.await
// we have two error handling lines here. these are distinct errors
// this one is the SQL error that may arise during the query
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Html::from(e.to_string())))?
.into_iter()
.collect::<Result<Vec<Trip>, models::Error>>()
// and this one is the model mapping error that may arise e.g. during
// reading of the rows
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Html::from(e.to_string())))?;
Ok((
StatusCode::OK,
Html::from(Root::build(TripList::build(trips).into(), TopLevelPage::Trips).to_string()),
))
}

169
rust/src/models.rs Normal file
View File

@@ -0,0 +1,169 @@
use sqlx::{sqlite::SqliteRow, Row};
use std::convert;
use std::error;
use std::fmt;
use uuid::Uuid;
use sqlx::sqlite::SqlitePoolOptions;
use futures::TryStreamExt;
pub enum Error {
SqlError { description: String },
UuidError { description: String },
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::SqlError { description } => {
write!(f, "SQL error: {}", description)
}
Self::UuidError { description } => {
write!(f, "UUID error: {}", description)
}
}
}
}
impl fmt::Debug for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
// defer to Display
write!(f, "SQL error: {}", self)
}
}
impl convert::From<uuid::Error> for Error {
fn from(value: uuid::Error) -> Self {
Error::UuidError {
description: value.to_string(),
}
}
}
impl convert::From<sqlx::Error> for Error {
fn from(value: sqlx::Error) -> Self {
Error::SqlError {
description: value.to_string(),
}
}
}
impl error::Error for Error {}
pub struct Trip {
pub id: Uuid,
pub name: String,
pub start_date: time::Date,
pub end_date: time::Date,
}
impl TryFrom<SqliteRow> for Trip {
type Error = Error;
fn try_from(row: SqliteRow) -> Result<Self, Self::Error> {
let name: &str = row.try_get("name")?;
let id: &str = row.try_get("id")?;
let start_date: time::Date = row.try_get("start_date")?;
let end_date: time::Date = row.try_get("start_date")?;
let id: Uuid = Uuid::try_parse(id)?;
Ok(Trip {
name: name.to_string(),
id,
start_date,
end_date,
})
}
}
pub struct Category {
pub id: Uuid,
pub name: String,
pub description: String,
items: Option<Vec<Item>>,
pub active: bool,
}
impl TryFrom<SqliteRow> for Category {
type Error = Error;
fn try_from(row: SqliteRow) -> Result<Self, Self::Error> {
let name: &str = row.try_get("name")?;
let description: &str = row.try_get("description")?;
let id: Uuid = Uuid::try_parse(row.try_get("id")?)?;
Ok(Category {
id,
name: name.to_string(),
description: description.to_string(),
items: None,
active: false,
})
}
}
impl<'a> Category {
pub fn items(&'a self) -> &'a Vec<Item> {
self.items
.as_ref()
.expect("you need to call populate_items()")
}
pub fn total_weight(&self) -> u32 {
self.items().iter().map(|item| item.weight).sum()
}
pub async fn populate_items(&'a mut self) -> Result<(), Error> {
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect("sqlite:///home/hannes-private/sync/items/items.sqlite")
.await
.unwrap();
let items = sqlx::query(&format!(
"SELECT
id,name,weight,description,category_id
FROM inventoryitems
WHERE category_id = '{id}'",
id = self.id
))
.fetch(&pool)
.map_ok(|row| row.try_into())
.try_collect::<Vec<Result<Item, Error>>>()
.await?
.into_iter()
.collect::<Result<Vec<Item>, Error>>()?;
self.items = Some(items);
Ok(())
}
}
pub struct Item {
pub id: Uuid,
pub name: String,
pub description: String,
pub weight: u32,
pub category_id: Uuid,
}
impl TryFrom<SqliteRow> for Item {
type Error = Error;
fn try_from(row: SqliteRow) -> Result<Self, Self::Error> {
let name: &str = row.try_get("name")?;
let description: &str = row.try_get("description")?;
let weight: u32 = row.try_get("weight")?;
let id: Uuid = Uuid::try_parse(row.try_get("id")?)?;
let category_id: Uuid = Uuid::try_parse(row.try_get("category_id")?)?;
Ok(Item {
id,
name: name.to_string(),
weight,
description: description.to_string(),
category_id,
})
}
}