h1 {
text-align: center;
}
+ }
- #hidden-templates {
- display: none;
- }
+ #hidden-templates {
+ display: none;
}
#recipe-edit {
FOREIGN KEY([recipe_id]) REFERENCES [Recipe]([id]) ON DELETE CASCADE
);
+CREATE INDEX [RecipeScheduled_user_id_index] ON [RecipeScheduled]([user_id]);
CREATE INDEX [RecipeScheduled_date_index] ON [RecipeScheduled]([date]);
CREATE TABLE [ShoppingEntry] (
FOREIGN KEY([recipe_scheduled_id]) REFERENCES [RecipeScheduled]([id]) ON DELETE SET NULL
);
+CREATE INDEX [ShoppingEntry_user_id_index] ON [ShoppingEntry]([user_id]);
+
-- When an ingredient is deleted, its values are copied to any shopping entry
-- that referenced it.
CREATE TRIGGER [Ingredient_trigger_delete]
pub mod recipe;
pub mod settings;
+pub mod shopping_list;
pub mod user;
const CURRENT_DB_VERSION: u32 = 1;
--- /dev/null
+use sqlx;
+
+use super::{Connection, DBError, Result};
+use crate::data::model;
+
+impl Connection {
+ pub async fn get_shopping_list(&self, user_id: i64) -> Result<Vec<model::ShoppingListItem>> {
+ sqlx::query_as(
+ r#"
+SELECT [ShoppingEntry].[id],
+ CASE [ShoppingEntry].[name]
+ WHEN '' THEN [Ingredient].[name]
+ ELSE [ShoppingEntry].[name]
+ END AS [name],
+ CASE WHEN [ShoppingEntry].[quantity_value] IS NOT NULL THEN [ShoppingEntry].[quantity_value]
+ ELSE [Ingredient].[quantity_value]
+ END AS [quantity_value],
+ CASE [ShoppingEntry].[quantity_unit] WHEN '' THEN [Ingredient].[quantity_unit]
+ ELSE [ShoppingEntry].[quantity_unit]
+ END AS [quantity_unit],
+ [Recipe].[id] AS [recipe_id],
+ [Recipe].[title] AS [recipe_title],
+ [RecipeScheduled].[date],
+ [is_checked]
+FROM [ShoppingEntry]
+ LEFT JOIN [Ingredient] ON [Ingredient].[id] = [ShoppingEntry].[ingredient_id]
+ LEFT JOIN [RecipeScheduled] ON [RecipeScheduled].[id] = [ShoppingEntry].[recipe_scheduled_id]
+ LEFT JOIN [Recipe] ON [Recipe].[id] = [RecipeScheduled].[recipe_id]
+WHERE [ShoppingEntry].[user_id] = $1
+ORDER BY [is_checked], [recipe_id], [name]
+ "#,
+ )
+ .bind(user_id)
+ .fetch_all(&self.pool)
+ .await
+ .map_err(DBError::from)
+ }
+}
pub quantity_value: Option<f64>,
pub quantity_unit: String,
}
+
+#[derive(Debug, FromRow)]
+pub struct ShoppingListItem {
+ pub id: i64,
+ pub name: String,
+ pub quantity_value: Option<f64>,
+ pub quantity_unit: String,
+ pub recipe_id: Option<i64>,
+ pub recipe_title: Option<String>,
+ pub date: Option<NaiveDate>,
+ pub is_checked: bool,
+}
"/calendar/remove_scheduled_recipe",
delete(services::ron::rm_scheduled_recipe),
)
+ .route(
+ "/shopping_list/get_list",
+ get(services::ron::get_shopping_list),
+ )
.fallback(services::ron::not_found);
let fragments_routes = Router::new().route(
Ok(StatusCode::OK)
}
-/// Calendar ///
+/*** Calendar ***/
#[debug_handler]
pub async fn get_scheduled_recipes(
Ok(StatusCode::OK)
}
-/// 404 ///
+/*** Shopping list ***/
+
+impl From<model::ShoppingListItem> for common::ron_api::ShoppingListItem {
+ fn from(item: model::ShoppingListItem) -> Self {
+ Self {
+ id: item.id,
+ name: item.name,
+ quantity_value: item.quantity_value,
+ quantity_unit: item.quantity_unit,
+ recipe_id: item.recipe_id,
+ recipe_title: item.recipe_title,
+ date: item.date,
+ is_checked: item.is_checked,
+ }
+ }
+}
+
+#[debug_handler]
+pub async fn get_shopping_list(
+ State(connection): State<db::Connection>,
+ Extension(user): Extension<Option<model::User>>,
+) -> Result<impl IntoResponse> {
+ if let Some(user) = user {
+ Ok(ron_response_ok(
+ connection
+ .get_shopping_list(user.id)
+ .await?
+ .into_iter()
+ .map(common::ron_api::ShoppingListItem::from)
+ .collect::<Vec<_>>(),
+ ))
+ } else {
+ Err(ErrorResponse::from(ron_error(
+ StatusCode::UNAUTHORIZED,
+ NOT_AUTHORIZED_MESSAGE,
+ )))
+ }
+}
+
+/*** 404 ***/
#[debug_handler]
pub async fn not_found(Extension(_user): Extension<Option<model::User>>) -> impl IntoResponse {
<div class="content" id="home">
{% include "calendar.html" %}
+
+ <div id="shopping-list">
+ </div>
+</div>
+
+<div id="hidden-templates">
+ <div class="shopping-item">
+ <input class="item-is-checked" type="checkbox"/>
+ <div class="item-quantity"></div>
+ <div class="item-name"></div>
+ <div class="item-scheduled-recipe"><a></a></div>
+ <div class="item-delete"></div>
+ </div>
+ <span class="calendar-date-format">{{ tr.t(Sentence::CalendarDateFormat) }}</span>
</div>
{% endblock %}
\ No newline at end of file
{%+ if recipe.is_published %}
checked
{% endif %}
- >
+ />
<label for="input-is-published">{{ tr.t(Sentence::RecipeIsPublished) }}</label>
<input id="input-delete" type="button" value="{{ tr.t(Sentence::RecipeDelete) }}" />
</div>
<input id="input-add-group" type="button" value="{{ tr.t(Sentence::RecipeAddAGroup) }}" />
+</div>
- <div id="hidden-templates">
- <div class="group">
- <span class="drag-handle"></span>
-
- <label for="input-group-name">{{ tr.t(Sentence::RecipeGroupName) }}</label>
- <input class="input-group-name" type="text" />
+<div id="hidden-templates">
+ <div class="group">
+ <span class="drag-handle"></span>
- <label for="input-group-comment">{{ tr.t(Sentence::RecipeGroupComment) }}</label>
- <input class="input-group-comment" type="text" />
+ <label for="input-group-name">{{ tr.t(Sentence::RecipeGroupName) }}</label>
+ <input class="input-group-name" type="text" />
- <input class="input-group-delete" type="button" value="{{ tr.t(Sentence::RecipeRemoveGroup) }}" />
+ <label for="input-group-comment">{{ tr.t(Sentence::RecipeGroupComment) }}</label>
+ <input class="input-group-comment" type="text" />
- <div class="steps">
- </div>
+ <input class="input-group-delete" type="button" value="{{ tr.t(Sentence::RecipeRemoveGroup) }}" />
- <input class="input-add-step" type="button" value="{{ tr.t(Sentence::RecipeAddAStep) }}" />
+ <div class="steps">
</div>
- <div class="step">
- <span class="drag-handle"></span>
-
- <label for="text-area-step-action">{{ tr.t(Sentence::RecipeStepAction) }}</label>
- <textarea class="text-area-step-action"></textarea>
+ <input class="input-add-step" type="button" value="{{ tr.t(Sentence::RecipeAddAStep) }}" />
+ </div>
- <input class="input-step-delete" type="button" value="{{ tr.t(Sentence::RecipeRemoveStep) }}" />
+ <div class="step">
+ <span class="drag-handle"></span>
- <div class="ingredients"></div>
+ <label for="text-area-step-action">{{ tr.t(Sentence::RecipeStepAction) }}</label>
+ <textarea class="text-area-step-action"></textarea>
- <input class="input-add-ingredient" type="button" value="{{ tr.t(Sentence::RecipeAddAnIngredient) }}"/>
- </div>
+ <input class="input-step-delete" type="button" value="{{ tr.t(Sentence::RecipeRemoveStep) }}" />
- <div class="ingredient">
- <span class="drag-handle"></span>
+ <div class="ingredients"></div>
- <label for="input-ingredient-name">{{ tr.t(Sentence::RecipeIngredientName) }}</label>
- <input class="input-ingredient-name" type="text" />
+ <input class="input-add-ingredient" type="button" value="{{ tr.t(Sentence::RecipeAddAnIngredient) }}"/>
+ </div>
- <label for="input-ingredient-quantity">{{ tr.t(Sentence::RecipeIngredientQuantity) }}</label>
- <input class="input-ingredient-quantity" type="number" step="0.1" min="0" max="10000" />
+ <div class="ingredient">
+ <span class="drag-handle"></span>
- <label for="input-ingredient-unit">{{ tr.t(Sentence::RecipeIngredientUnit) }}</label>
- <input class="input-ingredient-unit" type="text" />
+ <label for="input-ingredient-name">{{ tr.t(Sentence::RecipeIngredientName) }}</label>
+ <input class="input-ingredient-name" type="text" />
- <label for="input-ingredient-comment">{{ tr.t(Sentence::RecipeIngredientComment) }}</label>
- <input class="input-ingredient-comment" type="text" />
+ <label for="input-ingredient-quantity">{{ tr.t(Sentence::RecipeIngredientQuantity) }}</label>
+ <input class="input-ingredient-quantity" type="number" step="0.1" min="0" max="10000" />
- <input class="input-ingredient-delete" type="button" value="{{ tr.t(Sentence::RecipeRemoveIngredient) }}" />
- </div>
+ <label for="input-ingredient-unit">{{ tr.t(Sentence::RecipeIngredientUnit) }}</label>
+ <input class="input-ingredient-unit" type="text" />
- <div class="dropzone"></div>
+ <label for="input-ingredient-comment">{{ tr.t(Sentence::RecipeIngredientComment) }}</label>
+ <input class="input-ingredient-comment" type="text" />
- <span class="recipe-delete-confirmation">{{ tr.t(Sentence::RecipeDeleteConfirmation) }}</span>
- <span class="recipe-group-delete-confirmation">{{ tr.t(Sentence::RecipeGroupDeleteConfirmation) }}</span>
- <span class="recipe-step-delete-confirmation">{{ tr.t(Sentence::RecipeStepDeleteConfirmation) }}</span>
- <span class="recipe-ingredient-delete-confirmation">{{ tr.t(Sentence::RecipeIngredientDeleteConfirmation) }}</span>
+ <input class="input-ingredient-delete" type="button" value="{{ tr.t(Sentence::RecipeRemoveIngredient) }}" />
</div>
+
+ <div class="dropzone"></div>
+
+ <span class="recipe-delete-confirmation">{{ tr.t(Sentence::RecipeDeleteConfirmation) }}</span>
+ <span class="recipe-group-delete-confirmation">{{ tr.t(Sentence::RecipeGroupDeleteConfirmation) }}</span>
+ <span class="recipe-step-delete-confirmation">{{ tr.t(Sentence::RecipeStepDeleteConfirmation) }}</span>
+ <span class="recipe-ingredient-delete-confirmation">{{ tr.t(Sentence::RecipeIngredientDeleteConfirmation) }}</span>
</div>
{% endblock %}
\ No newline at end of file
</div>
</div>
{% endfor %}
+</div>
- <div id="hidden-templates">
- {# To create a modal dialog to choose a date and and servings #}
- <div class="date-and-servings" >
- {% include "calendar.html" %}
-
- <label for="input-servings">{{ tr.t(Sentence::RecipeServings) }}</label>
- <input
- id="input-servings"
- type="number"
- step="1" min="1" max="100"
- value="
- {% if let Some(user) = user %}
- {{ user.default_servings }}
- {% else %}
- 4
- {% endif %}
- "/>
-
- <input
- id="input-add-ingredients-to-shopping-list"
- type="checkbox"
- checked
- >
- <label for="input-add-ingredients-to-shopping-list">
- {{ tr.t(Sentence::CalendarAddIngredientsToShoppingList) }}
- </label>
- </div>
-
- <span class="calendar-add-to-planner-success">{{ tr.t(Sentence::CalendarAddToPlannerSuccess) }}</span>
- <span class="calendar-add-to-planner-already-exists">{{ tr.t(Sentence::CalendarAddToPlannerAlreadyExists) }}</span>
- <span class="calendar-date-format">{{ tr.t(Sentence::CalendarDateFormat) }}</span>
+<div id="hidden-templates">
+ {# To create a modal dialog to choose a date and and servings #}
+ <div class="date-and-servings" >
+ {% include "calendar.html" %}
+
+ <label for="input-servings">{{ tr.t(Sentence::RecipeServings) }}</label>
+ <input
+ id="input-servings"
+ type="number"
+ step="1" min="1" max="100"
+ value="
+ {% if let Some(user) = user %}
+ {{ user.default_servings }}
+ {% else %}
+ 4
+ {% endif %}
+ "/>
+
+ <input
+ id="input-add-ingredients-to-shopping-list"
+ type="checkbox"
+ checked
+ >
+ <label for="input-add-ingredients-to-shopping-list">
+ {{ tr.t(Sentence::CalendarAddIngredientsToShoppingList) }}
+ </label>
</div>
+
+ <span class="calendar-add-to-planner-success">{{ tr.t(Sentence::CalendarAddToPlannerSuccess) }}</span>
+ <span class="calendar-add-to-planner-already-exists">{{ tr.t(Sentence::CalendarAddToPlannerAlreadyExists) }}</span>
+ <span class="calendar-date-format">{{ tr.t(Sentence::CalendarDateFormat) }}</span>
</div>
{% endblock %}
\ No newline at end of file
pub date: NaiveDate,
}
+/*** Shopping list ***/
+
+#[derive(Serialize, Deserialize, Clone, Debug)]
+pub struct ShoppingListItem {
+ pub id: i64,
+ pub name: String,
+ pub quantity_value: Option<f64>,
+ pub quantity_unit: String,
+ pub recipe_id: Option<i64>,
+ pub recipe_title: Option<String>,
+ pub date: Option<NaiveDate>,
+ pub is_checked: bool,
+}
+
/*** Misc ***/
pub fn to_string<T>(ron: T) -> String
let scheduled_recipes_element: Element =
selector(&format!("#day-grid-{}{} .scheduled-recipes", i, j));
- let recipe_element = recipe_template
- .clone_node_with_deep(true)
- .unwrap()
- .dyn_into::<Element>()
- .unwrap();
+ let recipe_element = recipe_template.deep_clone();
recipe_element.set_id(&id);
scheduled_recipes_element
calendar, modal_dialog,
recipe_scheduler::RecipeScheduler,
request,
+ shopping_list::ShoppingList,
toast::{self, Level},
- utils::{get_locale, selector, SelectorExt},
+ utils::{by_id, get_locale, selector, SelectorExt},
};
pub fn setup_page(is_user_logged: bool) -> Result<(), JsValue> {
},
recipe_scheduler,
);
+
+ let shopping_list = ShoppingList::new(!is_user_logged);
+
+ spawn_local(async move {
+ let item_template: Element = selector("#hidden-templates .shopping-item");
+ let container: Element = by_id("shopping-list");
+ let date_format =
+ selector::<Element>("#hidden-templates .calendar-date-format").inner_html();
+ for item in shopping_list.get_items().await.unwrap() {
+ let item_element = item_template.deep_clone();
+ item_element
+ .selector::<Element>(".item-name")
+ .set_inner_html(&item.name);
+
+ if let Some(quantity_value) = item.quantity_value {
+ item_element
+ .selector::<Element>(".item-quantity")
+ .set_inner_html(&format!("{} {}", quantity_value, item.quantity_unit));
+ }
+
+ // Display associated sheduled recipe information if it exists.
+ if let (Some(recipe_id), Some(recipe_title), Some(date)) =
+ (item.recipe_id, item.recipe_title, item.date)
+ {
+ let recipe_element = item_element.selector::<Element>(".item-scheduled-recipe a");
+ recipe_element.set_inner_html(&format!(
+ "{} @ {}",
+ recipe_title,
+ date.format_localized(&date_format, get_locale()),
+ ));
+ recipe_element
+ .set_attribute("href", &format!("/recipe/view/{}", recipe_id))
+ .unwrap();
+ }
+
+ container.append_child(&item_element).unwrap();
+ }
+ });
+
Ok(())
}
mod recipe_scheduler;
mod recipe_view;
mod request;
+mod shopping_list;
mod toast;
mod utils;
--- /dev/null
+use chrono::{Datelike, Days, Months, NaiveDate};
+use common::ron_api;
+use gloo::storage::{LocalStorage, Storage};
+use ron::ser::{to_string_pretty, PrettyConfig};
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+use crate::{calendar, request};
+
+#[derive(Error, Debug)]
+pub enum Error {
+ #[error("Request error: {0}")]
+ Request(#[from] request::Error),
+}
+
+type Result<T> = std::result::Result<T, Error>;
+
+#[derive(Clone, Copy)]
+pub struct ShoppingList {
+ is_local: bool,
+}
+
+impl ShoppingList {
+ pub fn new(is_local: bool) -> Self {
+ Self { is_local }
+ }
+
+ pub async fn get_items(&self) -> Result<Vec<ron_api::ShoppingListItem>> {
+ if self.is_local {
+ Ok(vec![]) // TODO
+ } else {
+ Ok(request::get("shopping_list/get_list", ()).await?)
+ }
+ }
+}
fn selector_all<T>(&self, selectors: &str) -> Vec<T>
where
T: JsCast;
+
+ fn deep_clone(&self) -> Self;
}
impl SelectorExt for Element {
.map(|e| e.unwrap().dyn_into::<T>().unwrap())
.collect()
}
+
+ fn deep_clone(&self) -> Self {
+ self.clone_node_with_deep(true)
+ .unwrap()
+ .dyn_into::<Element>()
+ .unwrap()
+ }
}
pub fn selector<T>(selectors: &str) -> T