"iana-time-zone",
"js-sys",
"num-traits",
+ "pure-rust-locales",
"serde",
"wasm-bindgen",
"windows-targets 0.52.6",
"cc",
]
+[[package]]
+name = "pure-rust-locales"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1190fd18ae6ce9e137184f207593877e70f39b015040156b1e05081cdfe3733a"
+
[[package]]
name = "quote"
version = "1.0.38"
#toast.show {
visibility: visible;
- animation: fadein 0.5s, fadeout 0.5s 3.6s;
+ animation: fadein 0.5s, fadeout 0.5s 9.6s;
animation-iteration-count: 1;
}
+-- Datetimes are stored as 'ISO 8601' text format.
+-- For example: '2025-01-07T10:41:05.697884837+00:00'.
+
-- Version 1 is the initial structure.
CREATE TABLE [Version] (
[id] INTEGER PRIMARY KEY,
[id] INTEGER PRIMARY KEY,
[user_id] INTEGER NOT NULL,
[recipe_id] INTEGER NOT NULL,
- [date] TEXT NOT NULL,
+ [date] TEXT NOT NULL, -- In form of 'YYYY-MM-DD'.
[servings] INTEGER, -- If NULL use [recipe].[servings].
+ UNIQUE([user_id], [recipe_id], [date]),
+
FOREIGN KEY([user_id]) REFERENCES [User]([id]) ON DELETE CASCADE,
FOREIGN KEY([recipe_id]) REFERENCES [Recipe]([id]) ON DELETE CASCADE
);
Ok(())
}
- pub async fn add_schedule_recipe(
+ pub async fn add_scheduled_recipe(
&self,
user_id: i64,
recipe_id: i64,
.map_err(DBError::from)
}
- pub async fn remove_scheduled_recipe(
+ pub async fn rm_scheduled_recipe(
&self,
user_id: i64,
recipe_id: i64,
let tomorrow = today + Days::new(1);
connection
- .add_schedule_recipe(user_id, recipe_id_1, today, 4)
+ .add_scheduled_recipe(user_id, recipe_id_1, today, 4)
.await?;
connection
- .add_schedule_recipe(user_id, recipe_id_2, yesterday, 4)
+ .add_scheduled_recipe(user_id, recipe_id_2, yesterday, 4)
.await?;
connection
- .add_schedule_recipe(user_id, recipe_id_1, tomorrow, 4)
+ .add_scheduled_recipe(user_id, recipe_id_1, tomorrow, 4)
.await?;
assert_eq!(
);
connection
- .remove_scheduled_recipe(user_id, recipe_id_1, today)
+ .rm_scheduled_recipe(user_id, recipe_id_1, today)
.await?;
connection
- .remove_scheduled_recipe(user_id, recipe_id_2, yesterday)
+ .rm_scheduled_recipe(user_id, recipe_id_2, yesterday)
.await?;
connection
- .remove_scheduled_recipe(user_id, recipe_id_1, tomorrow)
+ .rm_scheduled_recipe(user_id, recipe_id_1, tomorrow)
.await?;
assert_eq!(
"/calendar/get_scheduled_recipes",
get(services::ron::get_scheduled_recipes),
)
+ .route(
+ "/calendar/schedule_recipe",
+ post(services::ron::schedule_recipe),
+ )
+ .route(
+ "/calendar/remove_scheduled_recipe",
+ delete(services::ron::rm_scheduled_recipe),
+ )
.fallback(services::ron::not_found);
let fragments_routes = Router::new().route(
}
}
+#[debug_handler]
+pub async fn schedule_recipe(
+ State(connection): State<db::Connection>,
+ Extension(user): Extension<Option<model::User>>,
+ ExtractRon(ron): ExtractRon<common::ron_api::ScheduleRecipe>,
+) -> Result<impl IntoResponse> {
+ check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
+ if let Some(user) = user {
+ connection
+ .add_scheduled_recipe(user.id, ron.recipe_id, ron.date, ron.servings)
+ .await?;
+ }
+ Ok(StatusCode::OK)
+}
+
+#[debug_handler]
+pub async fn rm_scheduled_recipe(
+ State(connection): State<db::Connection>,
+ Extension(user): Extension<Option<model::User>>,
+ ExtractRon(ron): ExtractRon<common::ron_api::ScheduledRecipe>,
+) -> Result<impl IntoResponse> {
+ check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
+ if let Some(user) = user {
+ connection
+ .rm_scheduled_recipe(user.id, ron.recipe_id, ron.date)
+ .await?;
+ }
+ Ok(StatusCode::OK)
+}
+
/// 404 ///
#[debug_handler]
CalendarOctober,
CalendarNovember,
CalendarDecember,
+ CalendarAddToPlanner,
+ CalendarAddToPlannerSuccess,
+ CalendarDateFormat, // See https://docs.rs/chrono/latest/chrono/format/strftime/index.html.
}
pub const DEFAULT_LANGUAGE_CODE: &str = "en";
pub fn current_lang_code(&self) -> &str {
&self.lang.code
}
+
+ pub fn current_lang_and_territory_code(&self) -> String {
+ format!("{}-{}", self.lang.code, self.lang.territory)
+ }
}
// #[macro_export]
#[derive(Debug, Deserialize)]
struct StoredLanguage {
code: String,
+ territory: String,
name: String,
translation: Vec<(Sentence, String)>,
}
#[derive(Debug)]
struct Language {
code: String,
+ territory: String,
name: String,
translation: Vec<String>,
}
pub fn from_stored_language(stored_language: StoredLanguage) -> Self {
Self {
code: stored_language.code,
+ territory: stored_language.territory,
name: stored_language.name,
translation: {
let mut translation = vec![String::new(); Sentence::COUNT];
<!DOCTYPE html>
-<html lang="en">
+<html lang="{{ tr.current_lang_and_territory_code() }}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
{% if crate::data::model::can_user_edit_recipe(user, recipe) %}
<a class="edit-recipe" href="/recipe/edit/{{ recipe.id }}" >Edit</a>
{% endif %}
- <span class="add-to-planner">Add to planner</span>
+ <span class="add-to-planner">{{ tr.t(Sentence::CalendarAddToPlanner) }}</span>
{% endif %}
<div class="tags">
value="{{ user.default_servings }}"/>
</div>
{% endif %}
+
+ <span class="calendar-add-to-planner-success">{{ tr.t(Sentence::CalendarAddToPlannerSuccess) }}</span>
+ <span class="calendar-date-format">{{ tr.t(Sentence::CalendarDateFormat) }}</span>
</div>
</div>
[
(
code: "en",
+ territory: "US",
name: "English",
translation: [
(MainTitle, "Cooking Recipes"),
(CalendarOctober, "October"),
(CalendarNovember, "November"),
(CalendarDecember, "December"),
+ (CalendarAddToPlanner, "Add to planner"),
+ (CalendarAddToPlannerSuccess, "Recipe {} has been scheduled for {}"),
+ (CalendarDateFormat, "%A, %-d %B, %C%y"),
]
),
(
code: "fr",
+ territory: "FR",
name: "Français",
translation: [
(MainTitle, "Recettes de Cuisine"),
(CalendarOctober, "Octobre"),
(CalendarNovember, "Novembre"),
(CalendarDecember, "Décembre"),
+ (CalendarAddToPlanner, "Ajouter au planificateur"),
+ (CalendarAddToPlannerSuccess, "La recette {} a été agendée pour le {}"),
+ (CalendarDateFormat, "%A %-d %B %C%y"),
]
)
]
\ No newline at end of file
pub id: i64,
}
-/// RECIPE ///
+/*** RECIPE ***/
#[derive(Serialize, Deserialize, Clone)]
pub struct SetRecipeTitle {
pub quantity_unit: String,
}
-/// PROFILE ///
+/*** PROFILE ***/
#[derive(Serialize, Deserialize, Clone)]
pub struct UpdateProfile {
pub password: Option<String>,
}
+/*** Calendar ***/
+
+#[derive(Serialize, Deserialize, Clone, Debug)]
+pub struct ScheduledRecipes {
+ // (Scheduled date, recipe title, recipe id).
+ pub recipes: Vec<(NaiveDate, String, i64)>,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug)]
+pub struct ScheduleRecipe {
+ pub recipe_id: i64,
+ pub date: NaiveDate,
+ pub servings: u32,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug)]
+pub struct ScheduledRecipe {
+ pub recipe_id: i64,
+ pub date: NaiveDate,
+}
+
+/*** Misc ***/
+
pub fn to_string<T>(ron: T) -> String
where
T: Serialize,
// TODO: handle'unwrap'.
to_string_pretty(&ron, PrettyConfig::new()).unwrap()
}
-
-/// Calendar ///
-
-#[derive(Serialize, Deserialize, Clone, Debug)]
-pub struct ScheduledRecipes {
- // (Scheduled date, recipe title, recipe id).
- pub recipes: Vec<(NaiveDate, String, i64)>,
-}
[dependencies]
common = { path = "../common" }
-chrono = { version = "0.4", features = ["serde"] }
+chrono = { version = "0.4", features = ["serde", "unstable-locales"] }
ron = "0.8"
serde = { version = "1.0", features = ["derive"] }
}
#[derive(Clone)]
-struct CalendarState {
+pub struct CalendarState {
internal_state: Rc<RefCell<CalendarStateInternal>>,
}
}
}
-pub fn setup(calendar: Element) {
+pub fn setup(calendar: Element) -> CalendarState {
let prev: Element = calendar.selector(".prev");
let next: Element = calendar.selector(".next");
}
})
.forget();
+
+ state
}
const NB_CALENDAR_ROW: u64 = 5;
};
pub async fn show(element_selector: &str) -> bool {
- show_and_initialize(element_selector, async |_| {}).await
+ show_and_initialize(element_selector, async |_| Some(()))
+ .await
+ .is_some()
}
-pub async fn show_and_initialize<T>(element_selector: &str, initializer: T) -> bool
+pub async fn show_and_initialize<T, U>(element_selector: &str, initializer: T) -> Option<U>
where
- T: AsyncFn(Element),
+ T: AsyncFn(Element) -> U,
+{
+ show_and_initialize_with_ok(element_selector, initializer, |_, result| result).await
+}
+
+pub async fn show_and_initialize_with_ok<T, V, W, U>(
+ element_selector: &str,
+ initializer: T,
+ ok: V,
+) -> Option<W>
+where
+ T: AsyncFn(Element) -> U,
+ V: Fn(Element, U) -> W,
{
let dialog: HtmlDialogElement = by_id("modal-dialog");
let element: Element = selector_and_clone(element_selector);
content_element.set_inner_html("");
content_element.append_child(&element).unwrap();
- initializer(element).await;
+ let init_result = initializer(element.clone()).await;
dialog.show_modal().unwrap();
pin_mut!(click_ok, click_cancel);
let result = select! {
- () = click_ok => true,
- () = click_cancel => false,
+ () = click_ok => Some(ok(element, init_result)),
+ () = click_cancel => None,
};
dialog.close();
utils::{by_id, selector, selector_and_clone, SelectorExt},
};
-use futures::{
- future::{FutureExt, Ready},
- pin_mut, select, Future,
-};
-
pub fn setup_page(recipe_id: i64) -> Result<(), JsValue> {
// Title.
{
},
)
.await
+ .is_some()
{
let body = ron_api::Id { id: recipe_id };
let _ = request::delete::<(), _>("recipe/remove", body).await;
},
)
.await
+ .is_some()
{
let body = ron_api::Id { id: group_id };
let _ = request::delete::<(), _>("recipe/remove_group", body).await;
},
)
.await
+ .is_some()
{
let body = ron_api::Id { id: step_id };
let _ = request::delete::<(), _>("recipe/remove_step", body).await;
},
)
.await
+ .is_some()
{
let body = ron_api::Id { id: ingredient_id };
let _ = request::delete::<(), _>("recipe/remove_ingredient", body).await;
.await
{
Err(error) => {
- toast::show(Level::Info, &format!("Internal server error: {}", error));
+ toast::show_message(Level::Info, &format!("Internal server error: {}", error));
}
Ok(response) => {
let list = document().get_element_by_id("recipes-list").unwrap();
-use std::future::Future;
+use std::{cell::RefCell, future::Future, rc::Rc, str::FromStr};
-use common::ron_api;
+use chrono::Locale;
+use common::{ron_api, utils::substitute};
use gloo::{
- console::console,
+ console::log,
events::EventListener,
net::http::Request,
utils::{document, window},
let add_to_planner: Element = selector("#recipe-view .add-to-planner");
EventListener::new(&add_to_planner, "click", move |_event| {
spawn_local(async move {
- modal_dialog::show_and_initialize(
+ if let Some((date, servings)) = modal_dialog::show_and_initialize_with_ok(
"#hidden-templates .date-and-servings",
- async |element| {
- calendar::setup(element.selector(".calendar"));
+ async |element| calendar::setup(element.selector(".calendar")),
+ |element, calendar_state| {
+ let servings_element: HtmlInputElement = element.selector("#input-servings");
+ (
+ calendar_state.get_selected_date().date_naive(),
+ servings_element.value_as_number() as u32,
+ )
},
)
- .await;
+ .await
+ {
+ if request::post::<(), _>(
+ "calendar/schedule_recipe",
+ ron_api::ScheduleRecipe {
+ recipe_id,
+ date,
+ servings,
+ },
+ )
+ .await
+ .is_ok()
+ {
+ toast::show_element_and_initialize(
+ Level::Success,
+ "#hidden-templates .calendar-add-to-planner-success",
+ |element| {
+ let title =
+ selector::<Element>("#recipe-view .recipe-title").inner_html();
+ let date_format =
+ selector::<Element>("#hidden-templates .calendar-date-format")
+ .inner_html();
+ let locale = {
+ let lang_and_territory = selector::<Element>("html")
+ .get_attribute("lang")
+ .unwrap()
+ .replace("-", "_");
+ Locale::from_str(&lang_and_territory).unwrap_or_default()
+ };
+
+ element.set_inner_html(&substitute(
+ &element.inner_html(),
+ "{}",
+ &[
+ &title,
+ &date.format_localized(&date_format, locale).to_string(),
+ ],
+ ));
+ },
+ );
+ }
+ }
});
})
.forget();
use common::ron_api;
-use gloo::net::http::{Request, RequestBuilder};
+use gloo::{
+ console::log,
+ net::http::{Request, RequestBuilder},
+};
use serde::{de::DeserializeOwned, Serialize};
use thiserror::Error;
{
match request.send().await {
Err(error) => {
- toast::show(Level::Info, &format!("Internal server error: {}", error));
+ toast::show_message(Level::Info, &format!("Internal server error: {}", error));
Err(Error::Gloo(error))
}
Ok(response) => {
if !response.ok() {
- toast::show(
+ toast::show_message(
Level::Info,
&format!("HTTP error: {}", response.status_text()),
);
Err(Error::Http(response.status_text()))
} else {
- // Ok(())
- Ok(ron::de::from_bytes::<T>(&response.binary().await?)?)
+ let mut r = response.binary().await?;
+ // An empty response is considered to be an unit value.
+ if r.is_empty() {
+ r = b"()".to_vec();
+ }
+ Ok(ron::de::from_bytes::<T>(&r)?)
}
}
}
use gloo::{timers::callback::Timeout, utils::document};
+use web_sys::Element;
+
+use crate::utils::{by_id, selector_and_clone, SelectorExt};
pub enum Level {
Success,
Warning,
}
-pub fn show(level: Level, message: &str) {
+const TIME_DISPLAYED: u32 = 10_000; // [ms].
+
+pub fn show_message(level: Level, message: &str) {
let toast_element = document().get_element_by_id("toast").unwrap();
toast_element.set_inner_html(message);
toast_element.set_class_name("show");
- Timeout::new(4_000, move || {
+ Timeout::new(TIME_DISPLAYED, move || {
+ toast_element.set_class_name("");
+ })
+ .forget();
+}
+
+pub fn show_element(level: Level, selector: &str) {
+ show_element_and_initialize(level, selector, |_| {})
+}
+
+pub fn show_element_and_initialize<T>(level: Level, selector: &str, initializer: T)
+where
+ T: Fn(Element),
+{
+ let toast_element = document().get_element_by_id("toast").unwrap();
+
+ let element: Element = selector_and_clone(selector);
+ toast_element.set_inner_html("");
+ toast_element.append_child(&element).unwrap();
+ initializer(element.clone());
+ toast_element.set_class_name("show");
+
+ Timeout::new(TIME_DISPLAYED, move || {
toast_element.set_class_name("");
})
.forget();