diff --git a/rust/src/components/mod.rs b/rust/src/components/mod.rs index cfc01e6..47735be 100644 --- a/rust/src/components/mod.rs +++ b/rust/src/components/mod.rs @@ -23,13 +23,13 @@ impl Root { html { head { title { "Packager" } - script src="https://unpkg.com/htmx.org@1.7.0" {} + script src="https://unpkg.com/htmx.org@1.9.2" {} script src="https://cdn.tailwindcss.com" {} script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.js" defer {} link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@6.9.96/css/materialdesignicons.min.css"; script { (include_str!(concat!(env!("CARGO_MANIFEST_DIR"),"/js/app.js"))) } } - body { + body hx-boost="true" { header ."bg-gray-200" ."p-5" @@ -53,11 +53,7 @@ impl Root { }} { "Trips" } } } - div - hx-boost="true" - { - (body) - } + (body) } } ) diff --git a/rust/src/components/trip.rs b/rust/src/components/trip.rs index 34bf6ac..8bf8586 100644 --- a/rust/src/components/trip.rs +++ b/rust/src/components/trip.rs @@ -191,20 +191,83 @@ impl NewTrip { pub struct Trip; impl Trip { - pub fn build(state: &ClientState, trip: &models::Trip) -> Markup { - html!( - div ."p-8" { + pub fn build(state: &ClientState, trip: &models::Trip) -> Result { + Ok(html!( + div ."p-8" ."flex" ."flex-col" ."gap-8" { div ."flex" ."flex-row" ."items-center" ."gap-x-3" { - h1 ."text-2xl" ."font-semibold"{ (trip.name) } - } - div ."my-6" { - (TripInfo::build(state, &trip)) - } - div ."my-6" { - (TripComment::build(&trip)) + @if state.trip_edit_attribute.as_ref().map_or(false, |a| *a == TripAttribute::Name) { + form + id="edit-trip" + action=(format!("edit/{}/submit", to_variant_name(&TripAttribute::Name).unwrap())) + target="_self" + method="post" + { + div + ."flex" + ."flex-row" + ."items-center" + ."gap-x-3" + ."items-stretch" + { + input + ."bg-blue-200" + ."w-full" + ."text-2xl" + ."font-semibold" + type=(>::into(InputType::Text)) + name="new-value" + form="edit-trip" + value=(trip.name) + ; + 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" + ."bg-green-200" + ."hover:bg-green-300" + ."w-8" + { + span + ."mdi" + ."mdi-content-save" + ."text-xl" + ; + } + } + } + } @else { + h1 ."text-2xl" ."font-semibold"{ (trip.name) } + span { + a href=(format!("?edit={}", to_variant_name(&TripAttribute::Name).unwrap())) + { + span + ."mdi" + ."mdi-pencil" + ."text-xl" + ."opacity-50" + ; + } + } + } } + (TripInfo::build(state, &trip)) + (TripComment::build(&trip)) + (TripItems::build(state, &trip)?) } - ) + )) } } @@ -225,8 +288,8 @@ impl TripInfoRow { name="edit-trip" id="edit-trip" action=(format!("edit/{key}/submit", key=(to_variant_name(&attribute_key).unwrap()) )) - // hx-post=(format!("edit/{name}/submit")) - target="." + htmx-push-url="true" + target="_self" method="post" ; } @@ -410,6 +473,10 @@ impl TripInfo { } } } + tr .h-full { + td ."border" ."p-2" { "Carried weight" } + td ."border" ."p-2" { "TODO" } + } } } ) @@ -421,42 +488,290 @@ pub struct TripComment; impl TripComment { pub fn build(trip: &models::Trip) -> Markup { html!( - h1 ."text-xl" ."mb-5" { "Comments" } + div { + h1 ."text-xl" ."mb-5" { "Comments" } - form - id="edit-comment" - action="comment/submit" - target="_self" - method="post" - ; + form + id="edit-comment" + action="comment/submit" + target="_self" + method="post" + ; - // https://stackoverflow.com/a/48460773 - textarea - #"comment" - ."border" ."w-full" ."h-48" - name="new-comment" - form="edit-comment" - autocomplete="off" - oninput=r#"this.style.height = "";this.style.height = this.scrollHeight + 2 + "px""# - { (trip.comment.as_ref().unwrap_or(&"".to_string())) } - script defer { (PreEscaped(r#"e = document.getElementById("comment"); e.style.height = e.scrollHeight + 2 + "px";"#)) } + // https://stackoverflow.com/a/48460773 + textarea + #"comment" + ."border" ."w-full" ."h-48" + name="new-comment" + form="edit-comment" + autocomplete="off" + oninput=r#"this.style.height = "";this.style.height = this.scrollHeight + 2 + "px""# + { (trip.comment.as_ref().unwrap_or(&"".to_string())) } + script defer { (PreEscaped(r#"e = document.getElementById("comment"); e.style.height = e.scrollHeight + 2 + "px";"#)) } - button - type="submit" - form="edit-comment" - ."mt-2" - ."border" - ."bg-green-200" - ."hover:bg-green-400" - ."cursor-pointer" - ."flex" - ."flex-column" - ."p-2" - ."gap-2" - ."items-center" - { - span ."mdi" ."mdi-content-save" ."text-xl"; - span { "Save" } + button + type="submit" + form="edit-comment" + ."mt-2" + ."border" + ."bg-green-200" + ."hover:bg-green-400" + ."cursor-pointer" + ."flex" + ."flex-column" + ."p-2" + ."gap-2" + ."items-center" + { + span ."mdi" ."mdi-content-save" ."text-xl"; + span { "Save" } + } + } + ) + } +} + +pub struct TripItems; + +impl TripItems { + pub fn build(state: &ClientState, trip: &models::Trip) -> Result { + Ok(html!( + div ."grid" ."grid-cols-4" ."gap-3" { + div ."col-span-2" { + (TripCategoryList::build(state, &trip)) + } + div ."col-span-2" { + h1 ."text-2xl" ."mb-5" ."text-center" { "Items" } + @if let Some(active_category_id) = state.active_category_id { + (TripItemList::build( + &state, + &trip, + &trip + .categories() + .iter() + .find(|category| + category.category.id == active_category_id + ) + .ok_or( + Error::NotFoundError { + description: format!("no category with id {}", active_category_id) + } + )? + .items + .as_ref() + .unwrap() + ) + ) + } + } + } + )) + } +} + +pub struct TripCategoryList; + +impl TripCategoryList { + pub fn build(state: &ClientState, trip: &models::Trip) -> Markup { + let categories = trip.categories(); + + let biggest_category_weight: u32 = categories + .iter() + .map(TripCategory::total_picked_weight) + .max() + .unwrap_or(1); + + html!( + h1 ."text-2xl" ."mb-5" ."text-center" { "Categories" } + table + ."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-2/5" { "Name" } + th ."border" ."p-2" { "Weight" } + } + } + tbody { + @for category in trip.categories() { + @let active = state.active_category_id.map_or(false, |id| category.category.id == id); + tr + ."h-10" + ."hover:bg-purple-100" + ."m-3" + ."h-full" + ."outline"[active] + ."outline-2"[active] + ."outline-indigo-300"[active] + ."pointer-events-none"[active] + { + + td + class=@if state.active_category_id.map_or(false, |id| category.category.id == id) { + "border p-0 m-0 font-bold" + } @else { + "border p-0 m-0" + } { + a + id="select-category" + href=( + format!( + "?category={id}", + id=category.category.id + ) + ) + ."inline-block" ."p-2" ."m-0" ."w-full" + { + (category.category.name.clone()) + } + } + td ."border" ."p-2" ."m-0" style="position:relative;" { + p { + (category.total_picked_weight().to_string()) + } + div ."bg-blue-600" ."h-1.5" + style=( + format!( + "width: {width}%;position:absolute;left:0;bottom:0;right:0;", + width=( + f64::from(category.total_picked_weight()) + / f64::from(biggest_category_weight) + * 100.0 + ) + ) + ) {} + } + } + } + tr ."h-10" ."hover:bg-purple-200" ."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(TripCategory::total_picked_weight).sum::().to_string()) + } + } + } + } + } + ) + } +} + +pub struct TripItemList; + +impl TripItemList { + pub fn build(state: &ClientState, trip: &models::Trip, items: &Vec) -> Markup { + let biggest_item_weight: u32 = items.iter().map(|item| item.item.weight).max().unwrap_or(1); + + html!( + @if items.is_empty() { + p ."text-lg" ."text-center" ."py-5" ."text-gray-400" { "[Empty]" } + } @else { + @if let Some(edit_item) = state.edit_item { + form + name="edit-item" + id="edit-item" + action=(format!("/inventory/item/{edit_item}/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" { "Take?" } + th ."border" ."p-2" { "Packed?" } + th ."border" ."p-2" ."w-1/2" { "Name" } + th ."border" ."p-2" ."w-1/4" { "Weight" } + } + } + tbody { + @for item in items { + tr ."h-10" ."even:bg-gray-100" ."hover:bg-purple-100" { + td { + a + href={ + "/trip/" (trip.id) + "/items/" (item.item.id) + "/" (if item.picked { "unpick" } else { "pick" }) } + ."inline-block" + ."p-2" + ."m-0" + ."w-full" + ."justify-center" + ."content-center" + ."flex" + { + input + type="checkbox" + checked[item.picked] + autocomplete="off" + ; + } + } + td { + a + href={ + "/trip/" (trip.id) + "/items/" (item.item.id) + "/" (if item.packed { "unpack" } else { "pack" }) } + ."inline-block" + ."p-2" + ."m-0" + ."w-full" + ."justify-center" + ."content-center" + ."flex" + { + input + type="checkbox" + checked[item.packed] + autocomplete="off" + ; + } + } + td ."border" ."p-0" { + a + ."p-2" ."w-full" ."inline-block" + href=( + format!("/inventory/item/{id}/", id=item.item.id) + ) { + + (item.item.name.clone()) + } + } + td ."border" ."p-2" style="position:relative;" { + p { (item.item.weight.to_string()) } + div ."bg-blue-600" ."h-1.5" style=(format!(" + width: {width}%; + position:absolute; + left:0; + bottom:0; + right:0;", width=(f64::from(item.item.weight) / f64::from(biggest_item_weight) * 100.0))) {} + } + } + } + } + } } ) } diff --git a/rust/src/main.rs b/rust/src/main.rs index a50c5c2..8ef3a4e 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -97,6 +97,10 @@ async fn main() -> Result<(), sqlx::Error> { "/trip/:id/edit/:attribute/submit", post(trip_edit_attribute), ) + .route("/trip/:id/items/:id/pick", get(trip_item_set_pick)) + .route("/trip/:id/items/:id/unpick", get(trip_item_set_unpick)) + .route("/trip/:id/items/:id/pack", get(trip_item_set_pack)) + .route("/trip/:id/items/:id/unpack", get(trip_item_set_unpack)) .route("/inventory/", get(inventory_inactive)) .route("/inventory/item/", post(inventory_item_create)) .route("/inventory/category/:id/", get(inventory_active)) @@ -511,6 +515,7 @@ async fn trips( #[derive(Debug, Deserialize)] struct TripQuery { edit: Option, + category: Option, } async fn trip( @@ -519,6 +524,7 @@ async fn trip( Query(trip_query): Query, ) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { state.client_state.trip_edit_attribute = trip_query.edit; + state.client_state.active_category_id = trip_query.category; let mut trip: models::Trip = query("SELECT id,name,date_start,date_end,state,location,temp_min,temp_max,comment FROM trips WHERE id = ?") @@ -544,10 +550,27 @@ async fn trip( ) })?; + trip.load_categories(&state.database_pool) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + ErrorPage::build(&e.to_string()), + ) + })?; + Ok(( StatusCode::OK, Root::build( - components::Trip::build(&state.client_state, &trip), + components::Trip::build(&state.client_state, &trip).map_err(|e| match e { + Error::NotFoundError { description } => { + (StatusCode::NOT_FOUND, ErrorPage::build(&description)) + } + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + ErrorPage::build(&e.to_string()), + ), + })?, &TopLevelPage::Trips, ), )) @@ -703,6 +726,143 @@ async fn trip_edit_attribute( ErrorPage::build(&format!("trip with id {id} not found", id = trip_id)), )) } else { - Ok(Redirect::to(&format!("/trips/"))) + Ok(Redirect::to(&format!("/trip/{trip_id}/"))) } } + +async fn trip_item_set_state( + state: &AppState, + trip_id: Uuid, + item_id: Uuid, + key: TripItemStateKey, + value: bool, +) -> Result<(), (StatusCode, Markup)> { + let result = query(&format!( + "UPDATE tripitems + SET {key} = ? + WHERE trip_id = ? + AND item_id = ?", + key = to_variant_name(&key).unwrap() + )) + .bind(value) + .bind(trip_id.to_string()) + .bind(item_id.to_string()) + .execute(&state.database_pool) + .await + .map_err(|e| (StatusCode::BAD_REQUEST, ErrorPage::build(&e.to_string())))?; + + if result.rows_affected() == 0 { + Err(( + StatusCode::NOT_FOUND, + ErrorPage::build(&format!( + "trip with id {trip_id} or item with id {item_id} not found" + )), + )) + } else { + Ok(()) + } +} + +async fn trip_item_set_pick( + Path((trip_id, item_id)): Path<(Uuid, Uuid)>, + headers: HeaderMap, + State(state): State, +) -> Result { + Ok(trip_item_set_state(&state, trip_id, item_id, TripItemStateKey::Pick, true).await?).map( + |_| -> Result { + Ok(Redirect::to( + headers + .get("referer") + .ok_or(( + StatusCode::BAD_REQUEST, + ErrorPage::build("no referer header found"), + ))? + .to_str() + .map_err(|e| { + ( + StatusCode::BAD_REQUEST, + ErrorPage::build(&format!("referer could not be converted: {}", e)), + ) + })?, + )) + }, + )? +} + +async fn trip_item_set_unpick( + Path((trip_id, item_id)): Path<(Uuid, Uuid)>, + headers: HeaderMap, + State(state): State, +) -> Result { + Ok(trip_item_set_state(&state, trip_id, item_id, TripItemStateKey::Pick, false).await?).map( + |_| -> Result { + Ok(Redirect::to( + headers + .get("referer") + .ok_or(( + StatusCode::BAD_REQUEST, + ErrorPage::build("no referer header found"), + ))? + .to_str() + .map_err(|e| { + ( + StatusCode::BAD_REQUEST, + ErrorPage::build(&format!("referer could not be converted: {}", e)), + ) + })?, + )) + }, + )? +} + +async fn trip_item_set_pack( + Path((trip_id, item_id)): Path<(Uuid, Uuid)>, + headers: HeaderMap, + State(state): State, +) -> Result { + Ok(trip_item_set_state(&state, trip_id, item_id, TripItemStateKey::Pack, true).await?).map( + |_| -> Result { + Ok(Redirect::to( + headers + .get("referer") + .ok_or(( + StatusCode::BAD_REQUEST, + ErrorPage::build("no referer header found"), + ))? + .to_str() + .map_err(|e| { + ( + StatusCode::BAD_REQUEST, + ErrorPage::build(&format!("referer could not be converted: {}", e)), + ) + })?, + )) + }, + )? +} + +async fn trip_item_set_unpack( + Path((trip_id, item_id)): Path<(Uuid, Uuid)>, + headers: HeaderMap, + State(state): State, +) -> Result { + Ok(trip_item_set_state(&state, trip_id, item_id, TripItemStateKey::Pack, false).await?).map( + |_| -> Result { + Ok(Redirect::to( + headers + .get("referer") + .ok_or(( + StatusCode::BAD_REQUEST, + ErrorPage::build("no referer header found"), + ))? + .to_str() + .map_err(|e| { + ( + StatusCode::BAD_REQUEST, + ErrorPage::build(&format!("referer could not be converted: {}", e)), + ) + })?, + )) + }, + )? +} diff --git a/rust/src/models.rs b/rust/src/models.rs index 3bfc2f1..ac2541f 100644 --- a/rust/src/models.rs +++ b/rust/src/models.rs @@ -88,6 +88,50 @@ impl fmt::Display for TripState { } } +#[derive(Serialize, Debug)] +pub enum TripItemStateKey { + Pick, + Pack, +} + +impl fmt::Display for TripItemStateKey { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "{}", + match self { + Self::Pick => "pick", + Self::Pack => "pack", + }, + ) + } +} + +#[derive(Debug)] +pub struct TripCategory { + pub category: Category, + pub items: Option>, +} + +impl TripCategory { + pub fn total_picked_weight(&self) -> u32 { + self.items + .as_ref() + .unwrap() + .iter() + .filter(|item| item.picked) + .map(|item| item.item.weight) + .sum() + } +} + +#[derive(Debug)] +pub struct TripItem { + pub item: Item, + pub picked: bool, + pub packed: bool, +} + pub struct Trip { pub id: Uuid, pub name: String, @@ -99,10 +143,13 @@ pub struct Trip { pub temp_max: i32, pub comment: Option, types: Option>, + categories: Option>, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub enum TripAttribute { + #[serde(rename = "name")] + Name, #[serde(rename = "date_start")] DateStart, #[serde(rename = "date_end")] @@ -173,6 +220,7 @@ impl TryFrom for Trip { temp_max, comment, types: None, + categories: None, }) } } @@ -183,6 +231,12 @@ impl<'a> Trip { .as_ref() .expect("you need to call load_triptypes()") } + + pub fn categories(&'a self) -> &Vec { + self.categories + .as_ref() + .expect("you need to call load_triptypes()") + } } impl<'a> Trip { @@ -220,6 +274,82 @@ impl<'a> Trip { self.types = Some(types); Ok(()) } + + pub async fn load_categories( + &'a mut self, + pool: &sqlx::Pool, + ) -> Result<(), Error> { + let mut categories: Vec = vec![]; + // we can ignore the return type as we collect into `categories` + // in the `map_ok()` closure + sqlx::query( + " + SELECT + category.id as category_id, + category.name as category_name, + category.description as category_description, + item.id as item_id, + item.name as item_name, + item.description as item_description, + item.weight as item_weight, + trip.pick as item_is_picked, + trip.pack as item_is_packed + FROM tripitems as trip + INNER JOIN inventoryitems as item + ON item.id = trip.item_id + INNER JOIN inventoryitemcategories as category + ON category.id = item.category_id + WHERE trip.trip_id = ?; + ", + ) + .bind(self.id.to_string()) + .fetch(pool) + .map_ok(|row| -> Result<(), Error> { + let mut category = TripCategory { + category: Category { + id: Uuid::try_parse(row.try_get("category_id")?)?, + name: row.try_get("category_name")?, + description: row.try_get("category_description")?, + items: None, + }, + items: None, + }; + + let item = TripItem { + item: Item { + id: Uuid::try_parse(row.try_get("item_id")?)?, + name: row.try_get("item_name")?, + description: row.try_get("item_description")?, + weight: row.try_get("item_weight")?, + category_id: category.category.id, + }, + picked: row.try_get("item_is_picked")?, + packed: row.try_get("item_is_packed")?, + }; + + if let Some(&mut ref mut c) = categories + .iter_mut() + .find(|c| c.category.id == category.category.id) + { + // we always populate c.items when we add a new category, so + // it's safe to unwrap here + c.items.as_mut().unwrap().push(item); + } else { + category.items = Some(vec![item]); + categories.push(category); + } + + Ok(()) + }) + .try_collect::>>() + .await? + .into_iter() + .collect::>()?; + + self.categories = Some(categories); + + Ok(()) + } } pub struct TripType {