remove old stacks

This commit is contained in:
2023-09-11 20:12:49 +02:00
parent 205eae2264
commit 4c850f6c0b
174 changed files with 30 additions and 13842 deletions

21
src/view/error.rs Normal file
View File

@@ -0,0 +1,21 @@
use maud::{html, Markup, DOCTYPE};
pub struct ErrorPage;
impl ErrorPage {
#[tracing::instrument]
pub fn build(message: &str) -> Markup {
html!(
(DOCTYPE)
html {
head {
title { "Packager" }
}
body {
h1 { "Error" }
p { (message) }
}
}
)
}
}

328
src/view/home.rs Normal file
View File

@@ -0,0 +1,328 @@
use maud::{html, Markup};
pub struct Home;
impl Home {
#[tracing::instrument]
pub fn build() -> Markup {
html!(
div
id="home"
."p-8"
."flex"
."flex-col"
."gap-8"
."flex-nowrap"
{
h1
."text-2xl"
."m-auto"
."my-4"
{
"Welcome!"
}
section
."border-2"
."border-gray-200"
."flex"
."flex-row"
{
a
href="/inventory/"
."p-8"
."w-1/5"
."flex"
."hover:bg-gray-200"
{
span
."m-auto"
."text-xl"
{ "Inventory" }
}
div
."p-8"
."w-4/5"
."flex"
."flex-col"
."gap-3"
{
p {
"The inventory contains all the items that you own."
}
p {
"It is effectively a list of items, sectioned into
arbitrary categories"
}
p {
"Each item has some important data attached to it,
like its weight"
}
}
}
section
."border-2"
."border-gray-200"
."flex"
."flex-row"
{
a
href="/trips/"
."p-8"
."w-1/5"
."flex"
."hover:bg-gray-200"
{
span
."m-auto"
."text-xl"
{ "Trips" }
}
div
."p-8"
."w-4/5"
."flex"
."flex-col"
."gap-6"
{
div
."flex"
."flex-col"
."gap-3"
{
p {
"Trips is where it gets interesting, as you can put
your inventory to good use"
}
p {
r#"With trips, you record any trips you plan to do. A
"trip" can be anything you want it to be. Anything
from a multi-week hike, a high altitude mountaineering
tour or just a visit to the library. Whenever it makes
sense to do some planning, creating a trip makes sense."#
}
p {
"Each trip has some metadata attached to it, like start-
and end dates or the expected temperature."
}
}
div
."flex"
."flex-col"
."gap-3"
{
div
."flex"
."flex-row"
."gap-2"
."items-center"
."justify-start"
{
span
."mdi"
."mdi-pound"
."text-lg"
."text-gray-300"
{}
h3 ."text-lg" {
"States"
}
}
p {
"One of the most important parts of each trip is
its " em{"state"} ", which determines certain
actions on the trip and can have the following values:"
}
table
."table"
."table-auto"
."border-collapse"
{
tr
."border-b-2"
."last:border-b-0"
{
td ."py-2" ."pr-4" ."border-r-2" {
"Init"
}
td ."py-2" ."w-full" ."pl-4" {
"The new trip was just created"
}
}
tr
."border-b-2"
."last:border-b-0"
{
td ."py-2" ."pr-4" ."border-r-2" {
"Planning"
}
td ."py-2" ."w-full" ."pl-4" {
"Now, you actually start planning the trip.
Setting the location, going through your
items to decide what to take with you."
}
}
tr
."border-b-2"
."last:border-b-0"
{
td ."py-2" ."pr-4" ."border-r-2" {
"Planned"
}
td ."py-2" ."w-full" ."pl-4" {
"You are done with the planning. It's time
to pack up your stuff and get going."
}
}
tr
."border-b-2"
."last:border-b-0"
{
td ."py-2" ."pr-4" ."border-r-2" {
"Active"
}
td ."py-2" ."w-full" ."pl-4" {
"The trip is finally underway!"
}
}
tr
."border-b-2"
."last:border-b-0"
{
td ."py-2" ."pr-4" ."border-r-2" {
"Review"
}
td ."py-2" ."w-full" ."pl-4" {
div
."flex"
."flex-col"
."gap-2"
{
p {
"You returned from your trip. It may make
sense to take a look back and see what
went well and what went not so well."
}
p {
"Anything you missed? Any items that you
took with you that turned out to be useless?
Record it and you will remember on your next
trip"
}
}
}
}
tr
."border-b-2"
."last:border-b-0"
{
td ."py-2" ."pr-4" ."border-r-2" {
"Done"
}
td ."py-2" ."w-full" ."pl-4" {
"Your review is done and the trip can be laid to rest"
}
}
}
}
div
."flex"
."flex-col"
."gap-3"
{
div
."flex"
."flex-row"
."gap-2"
."items-center"
."justify-start"
{
span
."mdi"
."mdi-pound"
."text-lg"
."text-gray-300"
{}
h3 ."text-lg" {
"Items"
}
}
p {
"Of course, you can use items defined in your
inventory in your trips"
}
p {
"Generally, all items are available to you in
the same way as the inventory. For each item,
there are two specific states for the trip: An
item can be " em{"picked"} ", which means that
you plan to take it on the trip, and it can
be " em{"packed"} ", which means that you actually
packed it into your bag (and therefore, you cannot
forget it any more)"
}
}
div
."flex"
."flex-col"
."gap-3"
{
div
."flex"
."flex-row"
."gap-2"
."items-center"
."justify-start"
{
span
."mdi"
."mdi-pound"
."text-lg"
."text-gray-300"
{}
h3 ."text-lg" {
"Types & Presets"
}
}
p {
"Often, you will take a certain set of items to
certain trips. Whenever you plan to sleep outdoors,
it makes sense to take your sleeping bag and mat
with you"
}
p {
"To reflect this, you can attach " em {"types"} " "
"to your trips. Types define arbitrary characteristics
about a trip and reference a certain set of items."
}
p {
"Here are some examples of types that might make sense:"
}
ul
."list-disc"
."list-inside"
{
li {
r#""Biking": Make sure to pack your helmet and
some repair tools"#
}
li {
r#""Climbing": You certainly don't want to forget
your climbing shoes"#
}
li {
r#""Rainy": Pack a rain jacket and some waterproof
shoes"#
}
}
p {
"Types are super flexible, it's up to you how to use
them"
}
}
}
}
}
)
}
}

636
src/view/inventory.rs Normal file
View File

@@ -0,0 +1,636 @@
use maud::{html, Markup};
use crate::models;
use crate::ClientState;
use uuid::Uuid;
pub struct Inventory;
impl Inventory {
#[tracing::instrument(
target = "packager::html::build",
name = "build_inventory",
fields(component = "Inventory")
skip(categories)
)]
pub fn build(
active_category: Option<&models::inventory::Category>,
categories: &Vec<models::inventory::Category>,
edit_item_id: Option<Uuid>,
) -> Markup {
html!(
div id="pkglist-item-manager" {
div ."p-8" ."grid" ."grid-cols-4" ."gap-5" {
div ."col-span-2" ."flex" ."flex-col" ."gap-8" {
h1 ."text-2xl" ."text-center" { "Categories" }
(InventoryCategoryList::build(active_category, categories))
(InventoryNewCategoryForm::build())
}
div ."col-span-2" ."flex" ."flex-col" ."gap-8" {
h1 ."text-2xl" ."text-center" { "Items" }
@if let Some(active_category) = active_category {
(InventoryItemList::build(edit_item_id, active_category.items()))
}
(InventoryNewItemForm::build(active_category, categories))
}
}
}
)
}
}
pub struct InventoryCategoryList;
impl InventoryCategoryList {
#[tracing::instrument(
target = "packager::html::build",
name = "build_inventory_category_list",
fields(component = "InventoryCategoryList"),
skip(categories)
)]
pub fn build(
active_category: Option<&models::inventory::Category>,
categories: &Vec<models::inventory::Category>,
) -> Markup {
let biggest_category_weight: i64 = categories
.iter()
.map(models::inventory::Category::total_weight)
.max()
.unwrap_or(1);
html!(
table
#category-list
."table"
."table-auto"
."border-collapse"
."border-spacing-0"
."border"
."w-full"
{
colgroup {
col style="width:50%" {}
col style="width:50%" {}
}
thead ."bg-gray-200" {
tr ."h-10" {
th ."border" ."p-2" ."w-3/5" { "Name" }
th ."border" ."p-2" { "Weight" }
}
}
tbody {
@for category in categories {
@let active = active_category.map_or(false, |c| category.id == c.id);
tr
."h-10"
."hover:bg-gray-100"
."m-3"
."h-full"
."outline"[active]
."outline-2"[active]
."outline-indigo-300"[active]
."pointer-events-none"[active]
{
td
class=@if active_category.map_or(false, |c| category.id == c.id) {
"border p-0 m-0 font-bold"
} @else {
"border p-0 m-0"
} {
a
id="select-category"
href={
"/inventory/category/"
(category.id) "/"
}
hx-post={
"/inventory/categories/"
(category.id)
"/select"
}
hx-swap="outerHTML"
hx-target="#pkglist-item-manager"
."inline-block" ."p-2" ."m-0" ."w-full"
{
(category.name.clone())
}
}
td ."border" ."p-2" ."m-0" style="position:relative;" {
p {
(category.total_weight().to_string())
}
div ."bg-blue-600" ."h-1.5"
style=(
format!(
"width: {width}%;position:absolute;left:0;bottom:0;right:0;",
width=(
(category.total_weight() as f64)
/ (biggest_category_weight as f64)
* 100.0
)
)
) {}
}
}
}
tr ."h-10" ."bg-gray-300" ."font-bold" {
td ."border" ."p-0" ."m-0" {
p ."p-2" ."m-2" { "Sum" }
}
td ."border" ."p-0" ."m-0" {
p ."p-2" ."m-2" {
(categories.iter().map(models::inventory::Category::total_weight).sum::<i64>().to_string())
}
}
}
}
}
)
}
}
pub struct InventoryItemList;
impl InventoryItemList {
#[tracing::instrument(
target = "packager::html::build",
name = "build_inventory_item_list",
fields(component = "InventoryItemList"),
skip(items)
)]
pub fn build(edit_item_id: Option<Uuid>, items: &Vec<models::inventory::Item>) -> Markup {
let biggest_item_weight: i64 = items.iter().map(|item| item.weight).max().unwrap_or(1);
html!(
div #items {
@if items.is_empty() {
p ."text-lg" ."text-center" ."py-5" ."text-gray-400" { "[Empty]" }
} @else {
@if let Some(edit_item_id) = edit_item_id {
form
name="edit-item"
id="edit-item"
action={"/inventory/item/" (edit_item_id) "/edit"}
target="_self"
method="post"
{}
}
table
."table"
."table-auto"
."table-fixed"
."border-collapse"
."border-spacing-0"
."border"
."w-full"
{
thead ."bg-gray-200" {
tr ."h-10" {
th ."border" ."p-2" ."w-3/5" { "Name" }
th ."border" ."p-2" { "Weight" }
th ."border" ."p-2" ."w-10" {}
th ."border" ."p-2" ."w-10" {}
}
}
tbody {
@for item in items {
@if edit_item_id.map_or(false, |id| id == item.id) {
tr ."h-10" {
td ."border" ."bg-blue-300" ."px-2" ."py-0" {
div ."h-full" ."w-full" ."flex" {
input ."m-auto" ."px-1" ."block" ."w-full" ."bg-blue-100" ."hover:bg-white"
type="text"
id="edit-item-name"
name="edit-item-name"
form="edit-item"
value=(item.name)
{}
}
}
td ."border" ."bg-blue-300" ."px-2" ."py-0" {
div ."h-full" ."w-full" ."flex" {
input ."m-auto" ."px-1"."block" ."w-full" ."bg-blue-100" ."hover:bg-white"
type="number"
id="edit-item-weight"
name="edit-item-weight"
form="edit-item"
value=(item.weight)
{}
}
}
td
."border-none"
."bg-green-100"
."hover:bg-green-200"
."p-0"
."h-full"
{
button
."aspect-square"
."flex"
."w-full"
."h-full"
type="submit"
form="edit-item"
{
span
."m-auto"
."mdi"
."mdi-content-save"
."text-xl"
{}
}
}
td
."border-none"
."bg-red-100"
."hover:bg-red-200"
."p-0"
."h-full"
{
a
href=(format!("/inventory/item/{id}/cancel", id = item.id))
."aspect-square"
."flex"
."w-full"
."h-full"
."p-0"
{
span
."m-auto"
."mdi"
."mdi-cancel"
."text-xl"
{}
}
}
}
} @else {
tr ."h-10" {
td ."border" ."p-0" {
a
."p-2" ."w-full" ."inline-block"
href=(
format!("/inventory/item/{id}/", id=item.id)
)
{
(item.name.clone())
}
}
td ."border" ."p-2" style="position:relative;" {
p { (item.weight.to_string()) }
div ."bg-blue-600" ."h-1.5" style=(format!("
width: {width}%;
position:absolute;
left:0;
bottom:0;
right:0;", width=((item.weight as f64) / (biggest_item_weight as f64) * 100.0))) {}
}
td
."border-none"
."p-0"
."bg-blue-200"
."hover:bg-blue-400"
."w-8"
."h-full"
{
a
href=(format!("?edit_item={id}", id = item.id))
."aspect-square"
."flex"
."w-full"
{
span ."m-auto" ."mdi" ."mdi-pencil" ."text-xl" {}
}
}
td
."border-none"
."p-0"
."bg-red-200"
."hover:bg-red-400"
."w-8"
."h-full"
{
a
href=(format!("/inventory/item/{id}/delete", id = item.id))
."aspect-square"
."flex"
."w-full"
{
span ."m-auto" ."mdi" ."mdi-delete" ."text-xl" {}
}
}
}
}
}
}
}
}
}
)
}
}
pub struct InventoryNewItemFormName;
impl InventoryNewItemFormName {
#[tracing::instrument(
target = "packager::html::build",
name = "build_inventory_new_item_form_name",
fields(component = "InventoryNewItemFormName")
)]
pub fn build(value: Option<&str>, error: bool) -> Markup {
html!(
div
."grid"
."grid-cols-[2fr,3fr]"
."justify-items-center"
."items-center"
hx-post="/inventory/item/name/validate"
hx-trigger="input delay:1s, loaded from:document"
hx-target="this"
hx-params="new-item-name"
hx-swap="outerHTML"
#abc
{
label for="name" .font-bold { "Name" }
input
type="text"
id="new-item-name"
name="new-item-name"
x-on:input="(e) => {save_active = inventory_new_item_check_input()}"
."block"
."w-full"
."p-2"
."bg-gray-50"
."border-2"
."border-red-500"[error]
."border-gray-300"[!error]
."focus:outline-none"
."focus:bg-white"
."focus:border-gray-500"[!error]
value=[value]
{}
@if error {
div
."col-start-2"
."text-sm"
."text-red-500"
{ "name already exists" }
}
}
)
}
}
pub struct InventoryNewItemFormWeight;
impl InventoryNewItemFormWeight {
#[tracing::instrument(
target = "packager::html::build",
name = "build_inventory_new_item_form_weight",
fields(component = "InventoryNewItemFormWeight")
)]
pub fn build() -> Markup {
html!(
div
."grid"
."grid-cols-[2fr,3fr]"
."justify-items-center"
."items-center"
{
label for="weight" .font-bold { "Weight" }
input
type="number"
id="new-item-weight"
name="new-item-weight"
min="0"
x-on:input="(e) => {
save_active = inventory_new_item_check_input();
weight_error = !check_weight();
}"
x-bind:class="weight_error && 'border-red-500' || 'border-gray-300 focus:border-gray-500'"
."block"
."w-full"
."p-2"
."bg-gray-50"
."border-2"
."focus:outline-none"
."focus:bg-white"
{}
span
// x-on produces some errors, this works as well
x-bind:class="!weight_error && 'hidden'"
."col-start-2"
."text-sm"
."text-red-500"
{ "invalid input" }
}
)
}
}
pub struct InventoryNewItemFormCategory;
impl InventoryNewItemFormCategory {
#[tracing::instrument(
target = "packager::html::build",
name = "build_inventory_new_item_form_category",
fields(component = "InventoryNewItemFormCategory"),
skip(categories)
)]
pub fn build(
active_category: Option<&models::inventory::Category>,
categories: &Vec<models::inventory::Category>,
) -> Markup {
html!(
div
."grid"
."grid-cols-[2fr,3fr]"
."justify-items-center"
."items-center"
{
label for="item-category" .font-bold ."w-1/2" .text-center { "Category" }
select
id="new-item-category-id"
name="new-item-category-id"
."block"
."w-full"
."p-2"
."bg-gray-50"
."border-2"
."border-gray-300"
."focus:outline-none"
."focus:bg-white"
."focus:border-gray-500"
autocomplete="off" // https://stackoverflow.com/a/10096033
{
@for category in categories {
option value=(category.id) selected[active_category.map_or(false, |c| c.id == category.id)] {
(category.name)
}
}
}
}
)
}
}
pub struct InventoryNewItemForm;
impl InventoryNewItemForm {
#[tracing::instrument(
target = "packager::html::build",
name = "build_inventory_new_item_form",
fields(component = "InventoryNewItemForm"),
skip(categories)
)]
pub fn build(
active_category: Option<&models::inventory::Category>,
categories: &Vec<models::inventory::Category>,
) -> Markup {
html!(
form
x-data="{
save_active: inventory_new_item_check_input(),
weight_error: !check_weight(),
}"
name="new-item"
hx-post="/inventory/item/"
hx-swap="outerHTML"
hx-target="#pkglist-item-manager"
id="new-item"
action="/inventory/item/"
target="_self"
method="post"
."p-5" ."border-2" ."border-gray-200" {
div ."mb-5" ."flex" ."flex-row" ."items-center" {
span ."mdi" ."mdi-playlist-plus" ."text-2xl" ."mr-4" {}
p ."inline" ."text-xl" { "Add new item" }
}
div ."w-11/12" ."mx-auto" ."flex" ."flex-col" ."gap-8" {
(InventoryNewItemFormName::build(None, false))
(InventoryNewItemFormWeight::build())
(InventoryNewItemFormCategory::build(active_category, categories))
input type="submit" value="Add"
x-bind:disabled="!save_active"
."enabled:cursor-pointer"
."disabled:opacity-50"
."py-2"
."border-2"
."border-gray-300"
."mx-auto"
."w-full" {
}
}
}
)
}
}
pub struct InventoryNewCategoryForm;
impl InventoryNewCategoryForm {
#[tracing::instrument(
target = "packager::html::build",
name = "build_inventory_new_category_form",
fields(component = "InventoryNewCategoryForm")
)]
pub fn build() -> Markup {
html!(
form
x-data="{ save_active: document.getElementById('new-category-name').value.length != 0 }"
name="new-category"
id="new-category"
action="/inventory/category/"
target="_self"
method="post"
."p-5" ."border-2" ."border-gray-200" {
div ."mb-5" ."flex" ."flex-row" ."items-center" {
span ."mdi" ."mdi-playlist-plus" ."text-2xl" ."mr-4" {}
p ."inline" ."text-xl" { "Add new category" }
}
div ."w-11/12" ."mx-auto" {
div ."pb-8" {
div ."flex" ."flex-row" ."justify-center" ."items-start"{
label for="name" .font-bold ."w-1/2" ."p-2" ."text-center" { "Name" }
span ."w-1/2" {
input type="text" id="new-category-name" name="new-category-name"
x-on:input="(e) => {save_active = e.target.value.length != 0 }"
."block"
."w-full"
."p-2"
."bg-gray-50"
."border-2"
."border-gray-300"
."focus:outline-none"
."focus:bg-white"
."focus:border-gray-500"
{
}
}
}
}
input type="submit" value="Add"
x-bind:disabled="!save_active"
."enabled:cursor-pointer"
."disabled:opacity-50"
."py-2"
."border-2"
."border-gray-300"
."mx-auto"
."w-full" {
}
}
}
)
}
}
pub struct InventoryItem;
impl InventoryItem {
#[tracing::instrument(
target = "packager::html::build",
name = "build_inventory_item",
fields(component = "InventoryItem")
)]
pub fn build(_state: &ClientState, item: &models::inventory::InventoryItem) -> Markup {
html!(
div ."p-8" {
table
."table"
."table-auto"
."border-collapse"
."border-spacing-0"
."border"
."w-full"
{
tbody {
tr ."h-10" ."even:bg-gray-100" ."hover:bg-gray-100" ."h-full" {
td ."border" ."p-2" { "Name" }
td ."border" ."p-2" { (item.name) }
}
tr ."h-10" ."even:bg-gray-100" ."hover:bg-gray-100" ."h-full" {
td ."border" ."p-2" { "Description" }
td ."border" ."p-2" { (item.description.clone().unwrap_or(String::new())) }
}
tr ."h-10" ."even:bg-gray-100" ."hover:bg-gray-100" ."h-full" {
td ."border" ."p-2" { "Weight" }
td ."border" ."p-2" { (item.weight.to_string()) }
}
tr ."h-10" ."even:bg-gray-100" ."hover:bg-gray-100" ."h-full" {
td ."border" ."p-2" { "Category" }
td ."border" ."p-2" { (item.category.name) }
}
}
}
@match item.product {
Some(ref product) => p { "this item is part of product" (product.name) },
None => p { "this item is not part of a product" },
}
}
)
}
}

128
src/view/mod.rs Normal file
View File

@@ -0,0 +1,128 @@
use std::fmt;
use base64::Engine as _;
use sha2::{Digest, Sha256};
use crate::Context;
use maud::Markup;
pub mod error;
pub mod home;
pub mod inventory;
pub mod root;
pub mod trip;
pub use error::ErrorPage;
pub use root::Root;
#[derive(Debug)]
pub enum HtmxAction {
Get(String),
}
impl fmt::Display for HtmxAction {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::Get(path) => write!(f, "{path}"),
}
}
}
#[derive(Debug)]
pub enum FallbackAction {
Get(String),
}
impl fmt::Display for FallbackAction {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::Get(path) => write!(f, "{path}"),
}
}
}
#[derive(Debug, Clone)]
pub struct ComponentId(String);
impl ComponentId {
#[tracing::instrument]
// fn new() -> Self {
// NOTE: this could also use a static AtomicUsize incrementing integer, which might be faster
// Self(random::<u32>())
// }
#[tracing::instrument]
fn html_id(&self) -> String {
let id = {
let mut hasher = Sha256::new();
hasher.update(self.0.as_bytes());
hasher.finalize()
};
// 9 bytes is enough to be unique
// If this is divisible by 3, it means that we can base64-encode it without
// any "=" padding
//
// cannot panic, as the output for sha256 will always be bit
let id = &id[..9];
// URL_SAFE because we cannot have slashes in the output
base64::engine::general_purpose::URL_SAFE.encode(id)
}
}
impl fmt::Display for ComponentId {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.html_id())
}
}
#[derive(Debug)]
pub enum HtmxTarget {
Myself,
Component(ComponentId),
}
#[derive(Debug)]
pub struct HtmxComponent {
id: ComponentId,
action: HtmxAction,
fallback_action: FallbackAction,
target: HtmxTarget,
}
impl HtmxComponent {
fn target(&self) -> &ComponentId {
match self.target {
HtmxTarget::Myself => &self.id,
HtmxTarget::Component(ref id) => id,
}
}
}
#[derive(Debug)]
pub enum Parent {
Root,
Component(ComponentId),
}
impl From<Parent> for ComponentId {
fn from(value: Parent) -> Self {
match value {
Parent::Root => ComponentId("/".into()),
Parent::Component(c) => c,
}
}
}
impl From<ComponentId> for Parent {
fn from(value: ComponentId) -> Self {
Self::Component(value)
}
}
pub trait Component {
type Args;
fn init(parent: Parent, args: Self::Args) -> Self;
fn build(self, context: &Context) -> Markup;
}

217
src/view/root.rs Normal file
View File

@@ -0,0 +1,217 @@
use crate::{Context, TopLevelPage};
use maud::{html, Markup, PreEscaped, DOCTYPE};
use super::{
Component, ComponentId, FallbackAction, HtmxAction, HtmxComponent, HtmxTarget, Parent,
};
pub struct Header;
impl Header {
#[tracing::instrument]
pub fn build() -> Markup {
html!(
head {
title { "Packager" }
script src="https://unpkg.com/htmx.org@1.9.4" {}
script src="https://unpkg.com/alpinejs@3.12.3" defer {}
script src="https://cdn.tailwindcss.com/3.3.3" {}
link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.2.96/css/materialdesignicons.min.css" {}
link rel="shortcut icon" type="image/svg+xml" href="/favicon.svg" {}
script { (PreEscaped(include_str!(concat!(env!("CARGO_MANIFEST_DIR"),"/js/app.js")))) }
meta name="htmx-config" content=r#"{"useTemplateFragments":true}"# {}
}
)
}
}
pub struct HeaderLink<'a> {
htmx: HtmxComponent,
args: HeaderLinkArgs<'a>,
}
pub struct HeaderLinkArgs<'a> {
pub item: TopLevelPage,
pub active_page: Option<&'a TopLevelPage>,
}
impl<'a> Component for HeaderLink<'a> {
type Args = HeaderLinkArgs<'a>;
#[tracing::instrument(skip(args))]
fn init(parent: Parent, args: Self::Args) -> Self {
Self {
htmx: HtmxComponent {
id: ComponentId(format!("/header/component/{}", args.item.id())),
action: HtmxAction::Get(args.item.path().to_string()),
fallback_action: FallbackAction::Get(args.item.path().to_string()),
target: HtmxTarget::Component(parent.into()),
},
args,
}
}
#[tracing::instrument(skip(self))]
fn build(self, context: &Context) -> Markup {
let active = self
.args
.active_page
.map_or(false, |page| *page == self.args.item);
html!(
a
href=(self.args.item.path())
hx-get=(self.args.item.path())
hx-target={ "#" (self.htmx.target().html_id()) }
hx-swap="outerHtml"
hx-push-url="true"
#{"header-link-" (self.args.item.id())}
."px-5"
."flex"
."h-full"
."text-lg"
."hover:bg-gray-300"
// invisible top border to fix alignment
."border-t-gray-200"[active]
."hover:border-t-gray-300"[active]
."border-b-gray-500"[active]
."border-y-4"[active]
."font-bold"[active]
{ span ."m-auto" ."font-semibold" { (self.args.item.name()) }}
)
}
}
pub struct Body<'a> {
htmx: HtmxComponent,
args: BodyArgs<'a>,
}
pub struct BodyArgs<'a> {
pub body: &'a Markup,
pub active_page: Option<&'a TopLevelPage>,
}
impl<'a> Component for Body<'a> {
type Args = BodyArgs<'a>;
#[tracing::instrument(skip(args))]
fn init(parent: Parent, args: Self::Args) -> Self {
Self {
htmx: HtmxComponent {
id: ComponentId("/body/".into()),
action: HtmxAction::Get("/".into()),
fallback_action: FallbackAction::Get("/".into()),
target: HtmxTarget::Myself,
},
args,
}
}
#[tracing::instrument(skip(self))]
fn build(self, context: &Context) -> Markup {
html!(
body #(self.htmx.id.html_id())
{
header
#header
."h-16"
."bg-gray-200"
."flex"
."flex-row"
."flex-nowrap"
."justify-between"
."items-stretch"
{
a
#home
href=(self.htmx.fallback_action)
hx-get=(self.htmx.action)
hx-target={ "#" (self.htmx.target()) }
hx-swap="outerHTML"
."flex"
."flex-row"
."items-center"
."gap-3"
."px-5"
."hover:bg-gray-300"
{
img ."h-12" src="/assets/luggage.svg" {}
span
."text-xl"
."font-semibold"
{ "Packager" }
}
nav
."grow"
."flex"
."flex-row"
."justify-center"
."gap-x-10"
."items-stretch"
{
(
// todo make clone() unnecessary
// make ComponentId take &str instead of owned string
HeaderLink::init(
self.htmx.id.clone().into(),
HeaderLinkArgs {
item: TopLevelPage::Inventory,
active_page: self.args.active_page
}
).build(context)
)
(
HeaderLink::init(
self.htmx.id.clone().into(),
HeaderLinkArgs {
item: TopLevelPage::Trips,
active_page: self.args.active_page
}
).build(context)
)
}
a
."flex"
."flex-row"
."items-center"
."gap-3"
."px-5"
."bg-gray-200"
."hover:bg-gray-300"
href=(format!("/user/{}", context.user.id))
{
span
."m-auto"
."mdi"
."mdi-account"
."text-3xl"
{}
p { (context.user.fullname)}
}
}
(self.args.body)
}
)
}
}
pub struct Root;
impl Root {
#[tracing::instrument]
pub fn build(context: &Context, body: &Markup, active_page: Option<&TopLevelPage>) -> Markup {
html!(
(DOCTYPE)
html {
(Header::build())
(Body::init(Parent::Root, BodyArgs {
body,
active_page
}).build(context))
}
)
}
}

1286
src/view/trip/mod.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,359 @@
use maud::{html, Markup};
use uuid::Uuid;
use crate::models;
pub struct TripPackageListRowReady;
impl TripPackageListRowReady {
#[tracing::instrument]
pub fn build(trip_id: Uuid, item: &models::trips::TripItem) -> Markup {
html!(
li
."flex"
."flex-row"
."justify-between"
."items-stretch"
."bg-green-50"[item.packed]
."bg-red-50"[!item.packed]
."hover:bg-white"[!item.packed]
."h-full"
{
span
."p-2"
{
(item.item.name)
}
@if item.packed {
a
href={
"/trips/" (trip_id)
"/items/" (item.item.id)
"/unpack"
}
hx-post={
"/trips/" (trip_id)
"/packagelist/item/"
(item.item.id) "/unpack"
}
hx-target="closest li"
hx-swap="outerHTML"
."flex"
."flex-row"
."aspect-square"
{
span
."mdi"
."m-auto"
."text-xl"
."mdi-check"
{}
}
} @else {
a
href={
"/trips/" (trip_id)
"/items/" (item.item.id)
"/pack"
}
hx-post={
"/trips/" (trip_id)
"/packagelist/item/"
(item.item.id) "/pack"
}
hx-target="closest li"
hx-swap="outerHTML"
."flex"
."flex-row"
."aspect-square"
{
span
."mdi"
."m-auto"
."text-xl"
."mdi-checkbox-blank-outline"
{}
}
}
}
)
}
}
pub struct TripPackageListRowUnready;
impl TripPackageListRowUnready {
#[tracing::instrument]
pub fn build(trip_id: Uuid, item: &models::trips::TripItem) -> Markup {
html!(
li
."flex"
."flex-row"
."justify-between"
."items-stretch"
."bg-green-50"[item.ready]
."bg-red-50"[!item.ready]
."hover:bg-white"[!item.ready]
."h-full"
{
span
."p-2"
{
(item.item.name)
}
@if item.ready {
a
href={
"/trips/" (trip_id)
"/items/" (item.item.id)
"/unready"
}
hx-post={
"/trips/" (trip_id)
"/packagelist/item/"
(item.item.id) "/unready"
}
hx-target="closest li"
hx-swap="outerHTML"
."flex"
."flex-row"
."aspect-square"
{
span
."mdi"
."m-auto"
."text-xl"
."mdi-check"
{}
}
} @else {
a
href={
"/trips/" (trip_id)
"/items/" (item.item.id)
"/ready"
}
hx-post={
"/trips/" (trip_id)
"/packagelist/item/"
(item.item.id) "/ready"
}
hx-target="closest li"
hx-swap="outerHTML"
."flex"
."flex-row"
."aspect-square"
{
span
."mdi"
."m-auto"
."text-xl"
."mdi-checkbox-blank-outline"
{}
}
}
}
)
}
}
pub struct TripPackageListCategoryBlockReady;
impl TripPackageListCategoryBlockReady {
#[tracing::instrument]
pub fn build(trip: &models::trips::Trip, category: &models::trips::TripCategory) -> Markup {
let empty = !category
.items
.as_ref()
.unwrap()
.iter()
.any(|item| item.picked);
html!(
div
."inline-block"
."w-full"
."mb-5"
."border"
."border-2"
."border-gray-300"
."opacity-30"[empty]
{
div
."bg-gray-100"
."border-b-2"
."border-gray-300"
."p-3"
{
h3 { (category.category.name) }
}
@if empty {
div
."flex"
."p-1"
{
span
."text-sm"
."m-auto"
{
"no items picked"
}
}
} @else {
ul
."flex"
."flex-col"
{
@for item in category.items.as_ref().unwrap().iter().filter(|item| item.picked) {
(TripPackageListRowReady::build(trip.id, item))
}
}
}
}
)
}
}
pub struct TripPackageListCategoryBlockUnready;
impl TripPackageListCategoryBlockUnready {
#[tracing::instrument]
pub fn build(trip: &models::trips::Trip, category: &models::trips::TripCategory) -> Markup {
let empty = !category
.items
.as_ref()
.unwrap()
.iter()
.any(|item| item.picked);
html!(
div
."inline-block"
."w-full"
."mb-5"
."border"
."border-2"
."border-gray-300"
."opacity-30"[empty]
{
div
."bg-gray-100"
."border-b-2"
."border-gray-300"
."p-3"
{
h3 { (category.category.name) }
}
@if empty {
div
."flex"
."p-1"
{
span
."text-sm"
."m-auto"
{
"no items picked"
}
}
} @else {
ul
."flex"
."flex-col"
{
@for item in category.items.as_ref().unwrap().iter().filter(|item| item.picked && !item.ready) {
(TripPackageListRowUnready::build(trip.id, item))
}
}
}
}
)
}
}
pub struct TripPackageList;
impl TripPackageList {
#[tracing::instrument]
pub fn build(trip: &models::trips::Trip) -> Markup {
// let all_packed = trip.categories().iter().all(|category| {
// category
// .items
// .as_ref()
// .unwrap()
// .iter()
// .all(|item| !item.picked || item.packed)
// });
let has_unready_items: bool = trip.categories.as_ref().unwrap().iter().any(|category| {
category
.items
.as_ref()
.unwrap()
.iter()
.any(|item| item.picked && !item.ready)
});
html!(
div
."p-8"
."flex"
."flex-col"
."gap-8"
{
div
."flex"
."flex-row"
."justify-between"
{
h1 ."text-xl" {
"Package list for "
a
href={"/trips/" (trip.id) "/"}
."font-bold"
{
(trip.name)
}
}
a
href={"/trips/" (trip.id) "/packagelist/"}
// disabled[!all_packed]
// ."opacity-50"[!all_packed]
."p-2"
."border-2"
."border-gray-500"
."bg-blue-200"
."hover:bg-blue-200"
{
"Finish packing"
}
}
@if has_unready_items {
p { "There are items that are not yet ready, get them!"}
div
."columns-3"
."gap-5"
{
@for category in trip.categories() {
@let empty = !category
.items
.as_ref()
.unwrap()
.iter()
.any(|item| item.picked);
@if !empty {
(TripPackageListCategoryBlockUnready::build(trip, category))
}
}
}
}
p { "Pack the following things:" }
div
."columns-3"
."gap-5"
{
@for category in trip.categories() {
(TripPackageListCategoryBlockReady::build(trip, category))
}
}
}
)
}
}

163
src/view/trip/types.rs Normal file
View File

@@ -0,0 +1,163 @@
use crate::models;
use crate::ClientState;
use maud::{html, Markup};
pub struct TypeList;
impl TypeList {
#[tracing::instrument]
pub fn build(state: &ClientState, trip_types: Vec<models::trips::TripsType>) -> Markup {
html!(
div ."p-8" ."flex" ."flex-col" ."gap-8" {
h1 ."text-2xl" {"Trip Types"}
ul
."flex"
."flex-col"
."items-stretch"
."border-t"
."border-l"
."h-full"
{
@for trip_type in trip_types {
li
."border-b"
."border-r"
."flex"
."flex-row"
."justify-between"
."items-stretch"
{
@if state.trip_type_edit.map_or(false, |id| id == trip_type.id) {
form
."hidden"
id="edit-trip-type"
action={ (trip_type.id) "/edit/name/submit" }
target="_self"
method="post"
{}
div
."bg-blue-200"
."p-2"
."grow"
{
input
."bg-blue-100"
."hover:bg-white"
."w-full"
type="text"
name="new-value"
form="edit-trip-type"
value=(trip_type.name)
{}
}
div
."flex"
."flex-row"
{
a
href="."
."bg-red-200"
."hover:bg-red-300"
."w-8"
."flex"
{
span
."mdi"
."mdi-cancel"
."text-xl"
."m-auto"
{}
}
button
type="submit"
form="edit-trip-type"
."bg-green-200"
."hover:bg-green-300"
."w-8"
{
span
."mdi"
."mdi-content-save"
."text-xl"
{}
}
}
} @else {
span
."p-2"
{
(trip_type.name)
}
div
."bg-blue-100"
."hover:bg-blue-200"
."p-0"
."w-8"
{
a
href={ "?edit=" (trip_type.id) }
.flex
."w-full"
."h-full"
{
span
."m-auto"
."mdi"
."mdi-pencil"
."text-xl"
{}
}
}
}
}
}
}
form
name="new-trip-type"
action="/trips/types/"
target="_self"
method="post"
."mt-8" ."p-5" ."border-2" ."border-gray-200"
{
div ."mb-5" ."flex" ."flex-row" {
span ."mdi" ."mdi-playlist-plus" ."text-2xl" ."mr-4" {}
p ."inline" ."text-xl" { "Add new trip type" }
}
div ."w-11/12" ."m-auto" {
div ."mx-auto" ."pb-8" {
div ."flex" ."flex-row" ."justify-center" {
label for="new-trip-type-name" ."font-bold" ."w-1/2" ."p-2" ."text-center" { "Name" }
span ."w-1/2" {
input
type="text"
id="new-trip-type-name"
name="new-trip-type-name"
."block"
."w-full"
."p-2"
."bg-gray-50"
."border-2"
."focus:outline-none"
."focus:bg-white"
{}
}
}
}
input
type="submit"
value="Add"
."py-2"
."border-2"
."border-gray-300"
."mx-auto"
."w-full"
{}
}
}
}
)
}
}