mirror of
https://github.com/zaphar/kitchen.git
synced 2025-07-21 19:29:49 -04:00
Merge the state management experiment into main
This commit is contained in:
commit
f7cf7bd468
3
.gitignore
vendored
3
.gitignore
vendored
@ -6,4 +6,5 @@ webdist/
|
|||||||
nix/*/result
|
nix/*/result
|
||||||
result
|
result
|
||||||
.vscode/
|
.vscode/
|
||||||
.session_store/
|
.session_store/
|
||||||
|
.gitignore/
|
681
Cargo.lock
generated
681
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -103,7 +103,7 @@ pub type CategoryResponse = Response<String>;
|
|||||||
|
|
||||||
pub type EmptyResponse = Response<()>;
|
pub type EmptyResponse = Response<()>;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||||
pub struct UserData {
|
pub struct UserData {
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,7 @@ let
|
|||||||
# incorrect. We override those here.
|
# incorrect. We override those here.
|
||||||
"sycamore-0.8.2" = "sha256-D968+8C5EelGGmot9/LkAlULZOf/Cr+1WYXRCMwb1nQ=";
|
"sycamore-0.8.2" = "sha256-D968+8C5EelGGmot9/LkAlULZOf/Cr+1WYXRCMwb1nQ=";
|
||||||
"sqlx-0.6.2" = "sha256-X/LFvtzRfiOIEZJiVzmFvvULPpjhqvI99pSwH7a//GM=";
|
"sqlx-0.6.2" = "sha256-X/LFvtzRfiOIEZJiVzmFvvULPpjhqvI99pSwH7a//GM=";
|
||||||
|
"sycamore-state-0.0.1" = "sha256-RatNr1b6r7eP3fOVatHA44D9xhDAljqSIWtFpMeBA9Y=";
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
in
|
in
|
||||||
|
@ -51,6 +51,14 @@ pub fn as_categories(i: &str) -> std::result::Result<BTreeMap<String, String>, S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn as_measure(i: &str) -> std::result::Result<Measure, String> {
|
||||||
|
match measure(StrIter::new(i)) {
|
||||||
|
Result::Abort(e) | Result::Fail(e) => Err(format_err(e)),
|
||||||
|
Result::Incomplete(_) => Err(format!("Incomplete categories list can not parse")),
|
||||||
|
Result::Complete(_, m) => Ok(m),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
make_fn!(
|
make_fn!(
|
||||||
pub categories<StrIter, BTreeMap<String, String>>,
|
pub categories<StrIter, BTreeMap<String, String>>,
|
||||||
do_each!(
|
do_each!(
|
||||||
|
@ -15,6 +15,7 @@ crate-type = ["cdylib", "rlib"]
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
recipes = { path = "../recipes" }
|
recipes = { path = "../recipes" }
|
||||||
client-api = { path = "../api", package="api", features = ["browser"] }
|
client-api = { path = "../api", package="api", features = ["browser"] }
|
||||||
|
sycamore-state = { git="https://github.com/zaphar/sycamore-state", rev="v0.1.0" }
|
||||||
# This makes debugging panics more tractable.
|
# This makes debugging panics more tractable.
|
||||||
console_error_panic_hook = "0.1.7"
|
console_error_panic_hook = "0.1.7"
|
||||||
serde_json = "1.0.79"
|
serde_json = "1.0.79"
|
||||||
|
402
web/src/api.rs
402
web/src/api.rs
@ -17,14 +17,16 @@ use base64;
|
|||||||
use reqwasm;
|
use reqwasm;
|
||||||
use serde_json::{from_str, to_string};
|
use serde_json::{from_str, to_string};
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
use tracing::{debug, error, info, instrument, warn};
|
use tracing::{debug, error, instrument, warn};
|
||||||
|
|
||||||
use client_api::*;
|
use client_api::*;
|
||||||
use recipes::{parse, IngredientKey, Recipe, RecipeEntry};
|
use recipes::{parse, IngredientKey, Recipe, RecipeEntry};
|
||||||
use wasm_bindgen::JsValue;
|
use wasm_bindgen::JsValue;
|
||||||
|
use web_sys::Storage;
|
||||||
|
|
||||||
use crate::{app_state, js_lib};
|
use crate::{app_state::AppState, js_lib};
|
||||||
|
|
||||||
|
// FIXME(jwall): We should be able to delete this now.
|
||||||
#[instrument]
|
#[instrument]
|
||||||
fn filter_recipes(
|
fn filter_recipes(
|
||||||
recipe_entries: &Option<Vec<RecipeEntry>>,
|
recipe_entries: &Option<Vec<RecipeEntry>>,
|
||||||
@ -53,79 +55,6 @@ fn filter_recipes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(state))]
|
|
||||||
pub async fn init_page_state(store: &HttpStore, state: &app_state::State) -> Result<(), String> {
|
|
||||||
info!("Synchronizing Recipes");
|
|
||||||
// TODO(jwall): Make our caching logic using storage more robust.
|
|
||||||
let recipes = store.get_recipes().await.map_err(|e| format!("{:?}", e))?;
|
|
||||||
if let Ok((staples, recipes)) = filter_recipes(&recipes) {
|
|
||||||
state.staples.set(staples);
|
|
||||||
if let Some(recipes) = recipes {
|
|
||||||
state.recipes.set(recipes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(Some(plan)) = store.get_plan().await {
|
|
||||||
// set the counts.
|
|
||||||
for (id, count) in plan {
|
|
||||||
state.set_recipe_count_by_index(&id, count as usize);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Initialize things to zero
|
|
||||||
if let Some(rs) = recipes {
|
|
||||||
for r in rs {
|
|
||||||
if !state.recipe_counts.get().contains_key(r.recipe_id()) {
|
|
||||||
state.set_recipe_count_by_index(&r.recipe_id().to_owned(), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
info!("Checking for user_data in local storage");
|
|
||||||
let storage = js_lib::get_storage();
|
|
||||||
let user_data = storage
|
|
||||||
.get("user_data")
|
|
||||||
.expect("Couldn't read from storage");
|
|
||||||
if let Some(data) = user_data {
|
|
||||||
if let Ok(user_data) = from_str(&data) {
|
|
||||||
state.auth.set(user_data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
info!("Synchronizing categories");
|
|
||||||
match store.get_categories().await {
|
|
||||||
Ok(Some(categories_content)) => {
|
|
||||||
debug!(categories=?categories_content);
|
|
||||||
let category_map = recipes::parse::as_categories(&categories_content)?;
|
|
||||||
state.category_map.set(category_map);
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
warn!("There is no category file");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("{:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
info!("Synchronizing inventory data");
|
|
||||||
match store.get_inventory_data().await {
|
|
||||||
Ok((filtered_ingredients, modified_amts, mut extra_items)) => {
|
|
||||||
state.reset_modified_amts(modified_amts);
|
|
||||||
state.filtered_ingredients.set(filtered_ingredients);
|
|
||||||
state.extras.set(
|
|
||||||
extra_items
|
|
||||||
.drain(0..)
|
|
||||||
.enumerate()
|
|
||||||
.map(|(idx, (amt, name))| {
|
|
||||||
(idx, (create_rc_signal(amt.clone()), create_rc_signal(name)))
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("{:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Error(String);
|
pub struct Error(String);
|
||||||
|
|
||||||
@ -179,14 +108,224 @@ fn token68(user: String, pass: String) -> String {
|
|||||||
base64::encode(format!("{}:{}", user, pass))
|
base64::encode(format!("{}:{}", user, pass))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct LocalStore {
|
||||||
|
store: Storage,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
store: js_lib::get_storage(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets user data from local storage.
|
||||||
|
pub fn get_user_data(&self) -> Option<UserData> {
|
||||||
|
self.store
|
||||||
|
.get("user_data")
|
||||||
|
.map_or(None, |val| val.map(|val| from_str(&val).unwrap_or(None)))
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set's user data to local storage.
|
||||||
|
pub fn set_user_data(&self, data: Option<&UserData>) {
|
||||||
|
if let Some(data) = data {
|
||||||
|
self.store
|
||||||
|
.set(
|
||||||
|
"user_data",
|
||||||
|
&to_string(data).expect("Failed to desrialize user_data"),
|
||||||
|
)
|
||||||
|
.expect("Failed to set user_data");
|
||||||
|
} else {
|
||||||
|
self.store
|
||||||
|
.delete("user_data")
|
||||||
|
.expect("Failed to delete user_data");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets categories from local storage.
|
||||||
|
pub fn get_categories(&self) -> Option<String> {
|
||||||
|
self.store
|
||||||
|
.get("categories")
|
||||||
|
.expect("Failed go get categories")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the categories to the given string.
|
||||||
|
pub fn set_categories(&self, categories: Option<&String>) {
|
||||||
|
if let Some(c) = categories {
|
||||||
|
self.store
|
||||||
|
.set("categories", c)
|
||||||
|
.expect("Failed to set categories");
|
||||||
|
} else {
|
||||||
|
self.store
|
||||||
|
.delete("categories")
|
||||||
|
.expect("Failed to delete categories")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_storage_keys(&self) -> Vec<String> {
|
||||||
|
let mut keys = Vec::new();
|
||||||
|
for idx in 0..self.store.length().unwrap() {
|
||||||
|
if let Some(k) = self.store.key(idx).expect("Failed to get storage key") {
|
||||||
|
keys.push(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_recipe_keys(&self) -> impl Iterator<Item = String> {
|
||||||
|
self.get_storage_keys()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|k| k.starts_with("recipe:"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets all the recipes from local storage.
|
||||||
|
pub fn get_recipes(&self) -> Option<Vec<RecipeEntry>> {
|
||||||
|
let mut recipe_list = Vec::new();
|
||||||
|
for recipe_key in self.get_recipe_keys() {
|
||||||
|
if let Some(entry) = self
|
||||||
|
.store
|
||||||
|
.get(&recipe_key)
|
||||||
|
.expect(&format!("Failed to get recipe: {}", recipe_key))
|
||||||
|
{
|
||||||
|
match from_str(&entry) {
|
||||||
|
Ok(entry) => {
|
||||||
|
recipe_list.push(entry);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(recipe_key, err = ?e, "Failed to parse recipe entry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if recipe_list.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(recipe_list)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_recipe_entry(&self, id: &str) -> Option<RecipeEntry> {
|
||||||
|
let key = recipe_key(id);
|
||||||
|
self.store
|
||||||
|
.get(&key)
|
||||||
|
.expect(&format!("Failed to get recipe {}", key))
|
||||||
|
.map(|entry| from_str(&entry).expect(&format!("Failed to get recipe {}", key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the set of recipes to the entries passed in. Deletes any recipes not
|
||||||
|
/// in the list.
|
||||||
|
pub fn set_all_recipes(&self, entries: &Vec<RecipeEntry>) {
|
||||||
|
for recipe_key in self.get_recipe_keys() {
|
||||||
|
self.store
|
||||||
|
.delete(&recipe_key)
|
||||||
|
.expect(&format!("Failed to get recipe {}", recipe_key));
|
||||||
|
}
|
||||||
|
for entry in entries {
|
||||||
|
self.set_recipe_entry(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set recipe entry in local storage.
|
||||||
|
pub fn set_recipe_entry(&self, entry: &RecipeEntry) {
|
||||||
|
self.store
|
||||||
|
.set(
|
||||||
|
&recipe_key(entry.recipe_id()),
|
||||||
|
&to_string(&entry).expect(&format!("Failed to get recipe {}", entry.recipe_id())),
|
||||||
|
)
|
||||||
|
.expect(&format!("Failed to store recipe {}", entry.recipe_id()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete recipe entry from local storage.
|
||||||
|
pub fn delete_recipe_entry(&self, recipe_id: &str) {
|
||||||
|
self.store
|
||||||
|
.delete(&recipe_key(recipe_id))
|
||||||
|
.expect(&format!("Failed to delete recipe {}", recipe_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save working plan to local storage.
|
||||||
|
pub fn save_plan(&self, plan: &Vec<(String, i32)>) {
|
||||||
|
self.store
|
||||||
|
.set("plan", &to_string(&plan).expect("Failed to serialize plan"))
|
||||||
|
.expect("Failed to store plan'");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_plan(&self) -> Option<Vec<(String, i32)>> {
|
||||||
|
if let Some(plan) = self.store.get("plan").expect("Failed to store plan") {
|
||||||
|
Some(from_str(&plan).expect("Failed to deserialize plan"))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_inventory_data(
|
||||||
|
&self,
|
||||||
|
) -> Option<(
|
||||||
|
BTreeSet<IngredientKey>,
|
||||||
|
BTreeMap<IngredientKey, String>,
|
||||||
|
Vec<(String, String)>,
|
||||||
|
)> {
|
||||||
|
if let Some(inventory) = self
|
||||||
|
.store
|
||||||
|
.get("inventory")
|
||||||
|
.expect("Failed to retrieve inventory data")
|
||||||
|
{
|
||||||
|
let (filtered, modified, extras): (
|
||||||
|
BTreeSet<IngredientKey>,
|
||||||
|
Vec<(IngredientKey, String)>,
|
||||||
|
Vec<(String, String)>,
|
||||||
|
) = from_str(&inventory).expect("Failed to deserialize inventory");
|
||||||
|
return Some((filtered, BTreeMap::from_iter(modified), extras));
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_inventory_data(&self) {
|
||||||
|
self.store
|
||||||
|
.delete("inventory")
|
||||||
|
.expect("Failed to delete inventory data");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_inventory_data(
|
||||||
|
&self,
|
||||||
|
inventory: (
|
||||||
|
&BTreeSet<IngredientKey>,
|
||||||
|
&BTreeMap<IngredientKey, String>,
|
||||||
|
&Vec<(String, String)>,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
let filtered = inventory.0;
|
||||||
|
let modified_amts = inventory
|
||||||
|
.1
|
||||||
|
.iter()
|
||||||
|
.map(|(k, amt)| (k.clone(), amt.clone()))
|
||||||
|
.collect::<Vec<(IngredientKey, String)>>();
|
||||||
|
let extras = inventory.2;
|
||||||
|
let inventory_data = (filtered, &modified_amts, extras);
|
||||||
|
self.store
|
||||||
|
.set(
|
||||||
|
"inventory",
|
||||||
|
&to_string(&inventory_data).expect(&format!(
|
||||||
|
"Failed to serialize inventory {:?}",
|
||||||
|
inventory_data
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.expect("Failed to set inventory");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct HttpStore {
|
pub struct HttpStore {
|
||||||
root: String,
|
root: String,
|
||||||
|
local_store: LocalStore,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HttpStore {
|
impl HttpStore {
|
||||||
fn new(root: String) -> Self {
|
pub fn new(root: String) -> Self {
|
||||||
Self { root }
|
Self {
|
||||||
|
root,
|
||||||
|
local_store: LocalStore::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn v1_path(&self) -> String {
|
pub fn v1_path(&self) -> String {
|
||||||
@ -215,7 +354,6 @@ impl HttpStore {
|
|||||||
debug!("attempting login request against api.");
|
debug!("attempting login request against api.");
|
||||||
let mut path = self.v1_path();
|
let mut path = self.v1_path();
|
||||||
path.push_str("/auth");
|
path.push_str("/auth");
|
||||||
let storage = js_lib::get_storage();
|
|
||||||
let result = reqwasm::http::Request::get(&path)
|
let result = reqwasm::http::Request::get(&path)
|
||||||
.header(
|
.header(
|
||||||
"Authorization",
|
"Authorization",
|
||||||
@ -230,12 +368,6 @@ impl HttpStore {
|
|||||||
.await
|
.await
|
||||||
.expect("Unparseable authentication response")
|
.expect("Unparseable authentication response")
|
||||||
.as_success();
|
.as_success();
|
||||||
storage
|
|
||||||
.set(
|
|
||||||
"user_data",
|
|
||||||
&to_string(&user_data).expect("Unable to serialize user_data"),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
return user_data;
|
return user_data;
|
||||||
}
|
}
|
||||||
error!(status = resp.status(), "Login was unsuccessful")
|
error!(status = resp.status(), "Login was unsuccessful")
|
||||||
@ -249,12 +381,11 @@ impl HttpStore {
|
|||||||
pub async fn get_categories(&self) -> Result<Option<String>, Error> {
|
pub async fn get_categories(&self) -> Result<Option<String>, Error> {
|
||||||
let mut path = self.v1_path();
|
let mut path = self.v1_path();
|
||||||
path.push_str("/categories");
|
path.push_str("/categories");
|
||||||
let storage = js_lib::get_storage();
|
|
||||||
let resp = match reqwasm::http::Request::get(&path).send().await {
|
let resp = match reqwasm::http::Request::get(&path).send().await {
|
||||||
Ok(resp) => resp,
|
Ok(resp) => resp,
|
||||||
Err(reqwasm::Error::JsError(err)) => {
|
Err(reqwasm::Error::JsError(err)) => {
|
||||||
error!(path, ?err, "Error hitting api");
|
error!(path, ?err, "Error hitting api");
|
||||||
return Ok(storage.get("categories")?);
|
return Ok(self.local_store.get_categories());
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
return Err(err)?;
|
return Err(err)?;
|
||||||
@ -262,14 +393,12 @@ impl HttpStore {
|
|||||||
};
|
};
|
||||||
if resp.status() == 404 {
|
if resp.status() == 404 {
|
||||||
debug!("Categories returned 404");
|
debug!("Categories returned 404");
|
||||||
storage.remove_item("categories")?;
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
} else if resp.status() != 200 {
|
} else if resp.status() != 200 {
|
||||||
Err(format!("Status: {}", resp.status()).into())
|
Err(format!("Status: {}", resp.status()).into())
|
||||||
} else {
|
} else {
|
||||||
debug!("We got a valid response back!");
|
debug!("We got a valid response back!");
|
||||||
let resp = resp.json::<CategoryResponse>().await?.as_success().unwrap();
|
let resp = resp.json::<CategoryResponse>().await?.as_success().unwrap();
|
||||||
storage.set("categories", &resp)?;
|
|
||||||
Ok(Some(resp))
|
Ok(Some(resp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -278,26 +407,16 @@ impl HttpStore {
|
|||||||
pub async fn get_recipes(&self) -> Result<Option<Vec<RecipeEntry>>, Error> {
|
pub async fn get_recipes(&self) -> Result<Option<Vec<RecipeEntry>>, Error> {
|
||||||
let mut path = self.v1_path();
|
let mut path = self.v1_path();
|
||||||
path.push_str("/recipes");
|
path.push_str("/recipes");
|
||||||
let storage = js_lib::get_storage();
|
|
||||||
let resp = match reqwasm::http::Request::get(&path).send().await {
|
let resp = match reqwasm::http::Request::get(&path).send().await {
|
||||||
Ok(resp) => resp,
|
Ok(resp) => resp,
|
||||||
Err(reqwasm::Error::JsError(err)) => {
|
Err(reqwasm::Error::JsError(err)) => {
|
||||||
error!(path, ?err, "Error hitting api");
|
error!(path, ?err, "Error hitting api");
|
||||||
let mut entries = Vec::new();
|
return Ok(self.local_store.get_recipes());
|
||||||
for key in js_lib::get_storage_keys() {
|
|
||||||
if key.starts_with("recipe:") {
|
|
||||||
let entry = from_str(&storage.get_item(&key)?.unwrap())
|
|
||||||
.map_err(|e| format!("{}", e))?;
|
|
||||||
entries.push(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Ok(Some(entries));
|
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
return Err(err)?;
|
return Err(err)?;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let storage = js_lib::get_storage();
|
|
||||||
if resp.status() != 200 {
|
if resp.status() != 200 {
|
||||||
Err(format!("Status: {}", resp.status()).into())
|
Err(format!("Status: {}", resp.status()).into())
|
||||||
} else {
|
} else {
|
||||||
@ -307,14 +426,6 @@ impl HttpStore {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{}", e))?
|
.map_err(|e| format!("{}", e))?
|
||||||
.as_success();
|
.as_success();
|
||||||
if let Some(ref entries) = entries {
|
|
||||||
for r in entries.iter() {
|
|
||||||
storage.set(
|
|
||||||
&recipe_key(r.recipe_id()),
|
|
||||||
&to_string(&r).expect("Unable to serialize recipe entries"),
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -326,15 +437,11 @@ impl HttpStore {
|
|||||||
let mut path = self.v1_path();
|
let mut path = self.v1_path();
|
||||||
path.push_str("/recipe/");
|
path.push_str("/recipe/");
|
||||||
path.push_str(id.as_ref());
|
path.push_str(id.as_ref());
|
||||||
let storage = js_lib::get_storage();
|
|
||||||
let resp = match reqwasm::http::Request::get(&path).send().await {
|
let resp = match reqwasm::http::Request::get(&path).send().await {
|
||||||
Ok(resp) => resp,
|
Ok(resp) => resp,
|
||||||
Err(reqwasm::Error::JsError(err)) => {
|
Err(reqwasm::Error::JsError(err)) => {
|
||||||
error!(path, ?err, "Error hitting api");
|
error!(path, ?err, "Error hitting api");
|
||||||
return match storage.get(&recipe_key(&id))? {
|
return Ok(self.local_store.get_recipe_entry(id.as_ref()));
|
||||||
Some(s) => Ok(Some(from_str(&s).map_err(|e| format!("{}", e))?)),
|
|
||||||
None => Ok(None),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
return Err(err)?;
|
return Err(err)?;
|
||||||
@ -354,8 +461,7 @@ impl HttpStore {
|
|||||||
.as_success()
|
.as_success()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
if let Some(ref entry) = entry {
|
if let Some(ref entry) = entry {
|
||||||
let serialized: String = to_string(entry).map_err(|e| format!("{}", e))?;
|
self.local_store.set_recipe_entry(entry);
|
||||||
storage.set(&recipe_key(entry.recipe_id()), &serialized)?
|
|
||||||
}
|
}
|
||||||
Ok(entry)
|
Ok(entry)
|
||||||
}
|
}
|
||||||
@ -365,15 +471,10 @@ impl HttpStore {
|
|||||||
pub async fn save_recipes(&self, recipes: Vec<RecipeEntry>) -> Result<(), Error> {
|
pub async fn save_recipes(&self, recipes: Vec<RecipeEntry>) -> Result<(), Error> {
|
||||||
let mut path = self.v1_path();
|
let mut path = self.v1_path();
|
||||||
path.push_str("/recipes");
|
path.push_str("/recipes");
|
||||||
let storage = js_lib::get_storage();
|
|
||||||
for r in recipes.iter() {
|
for r in recipes.iter() {
|
||||||
if r.recipe_id().is_empty() {
|
if r.recipe_id().is_empty() {
|
||||||
return Err("Recipe Ids can not be empty".into());
|
return Err("Recipe Ids can not be empty".into());
|
||||||
}
|
}
|
||||||
storage.set(
|
|
||||||
&recipe_key(r.recipe_id()),
|
|
||||||
&to_string(&r).expect("Unable to serialize recipe entries"),
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
let serialized = to_string(&recipes).expect("Unable to serialize recipe entries");
|
let serialized = to_string(&recipes).expect("Unable to serialize recipe entries");
|
||||||
let resp = reqwasm::http::Request::post(&path)
|
let resp = reqwasm::http::Request::post(&path)
|
||||||
@ -393,8 +494,6 @@ impl HttpStore {
|
|||||||
pub async fn save_categories(&self, categories: String) -> Result<(), Error> {
|
pub async fn save_categories(&self, categories: String) -> Result<(), Error> {
|
||||||
let mut path = self.v1_path();
|
let mut path = self.v1_path();
|
||||||
path.push_str("/categories");
|
path.push_str("/categories");
|
||||||
let storage = js_lib::get_storage();
|
|
||||||
storage.set("categories", &categories)?;
|
|
||||||
let resp = reqwasm::http::Request::post(&path)
|
let resp = reqwasm::http::Request::post(&path)
|
||||||
.body(to_string(&categories).expect("Unable to encode categories as json"))
|
.body(to_string(&categories).expect("Unable to encode categories as json"))
|
||||||
.header("content-type", "application/json")
|
.header("content-type", "application/json")
|
||||||
@ -408,25 +507,23 @@ impl HttpStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument]
|
#[instrument(skip_all)]
|
||||||
pub async fn save_state(&self, state: std::rc::Rc<app_state::State>) -> Result<(), Error> {
|
pub async fn save_app_state(&self, state: AppState) -> Result<(), Error> {
|
||||||
let mut plan = Vec::new();
|
let mut plan = Vec::new();
|
||||||
for (key, count) in state.recipe_counts.get_untracked().iter() {
|
for (key, count) in state.recipe_counts.iter() {
|
||||||
plan.push((key.clone(), *count.get_untracked() as i32));
|
plan.push((key.clone(), *count as i32));
|
||||||
}
|
}
|
||||||
debug!("Saving plan data");
|
debug!("Saving plan data");
|
||||||
self.save_plan(plan).await?;
|
self.save_plan(plan).await?;
|
||||||
debug!("Saving inventory data");
|
debug!("Saving inventory data");
|
||||||
self.save_inventory_data(
|
self.save_inventory_data(
|
||||||
state.filtered_ingredients.get_untracked().as_ref().clone(),
|
state.filtered_ingredients,
|
||||||
state.get_current_modified_amts(),
|
state.modified_amts,
|
||||||
state
|
state
|
||||||
.extras
|
.extras
|
||||||
.get()
|
|
||||||
.as_ref()
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| (t.1 .0.get().as_ref().clone(), t.1 .1.get().as_ref().clone()))
|
.cloned()
|
||||||
.collect(),
|
.collect::<Vec<(String, String)>>(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@ -434,9 +531,6 @@ impl HttpStore {
|
|||||||
pub async fn save_plan(&self, plan: Vec<(String, i32)>) -> Result<(), Error> {
|
pub async fn save_plan(&self, plan: Vec<(String, i32)>) -> Result<(), Error> {
|
||||||
let mut path = self.v1_path();
|
let mut path = self.v1_path();
|
||||||
path.push_str("/plan");
|
path.push_str("/plan");
|
||||||
let storage = js_lib::get_storage();
|
|
||||||
let serialized_plan = to_string(&plan).expect("Unable to encode plan as json");
|
|
||||||
storage.set("plan", &serialized_plan)?;
|
|
||||||
let resp = reqwasm::http::Request::post(&path)
|
let resp = reqwasm::http::Request::post(&path)
|
||||||
.body(to_string(&plan).expect("Unable to encode plan as json"))
|
.body(to_string(&plan).expect("Unable to encode plan as json"))
|
||||||
.header("content-type", "application/json")
|
.header("content-type", "application/json")
|
||||||
@ -454,7 +548,6 @@ impl HttpStore {
|
|||||||
let mut path = self.v1_path();
|
let mut path = self.v1_path();
|
||||||
path.push_str("/plan");
|
path.push_str("/plan");
|
||||||
let resp = reqwasm::http::Request::get(&path).send().await?;
|
let resp = reqwasm::http::Request::get(&path).send().await?;
|
||||||
let storage = js_lib::get_storage();
|
|
||||||
if resp.status() != 200 {
|
if resp.status() != 200 {
|
||||||
Err(format!("Status: {}", resp.status()).into())
|
Err(format!("Status: {}", resp.status()).into())
|
||||||
} else {
|
} else {
|
||||||
@ -464,10 +557,6 @@ impl HttpStore {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{}", e))?
|
.map_err(|e| format!("{}", e))?
|
||||||
.as_success();
|
.as_success();
|
||||||
if let Some(ref entry) = plan {
|
|
||||||
let serialized: String = to_string(entry).map_err(|e| format!("{}", e))?;
|
|
||||||
storage.set("plan", &serialized)?
|
|
||||||
}
|
|
||||||
Ok(plan)
|
Ok(plan)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -484,30 +573,12 @@ impl HttpStore {
|
|||||||
> {
|
> {
|
||||||
let mut path = self.v2_path();
|
let mut path = self.v2_path();
|
||||||
path.push_str("/inventory");
|
path.push_str("/inventory");
|
||||||
let storage = js_lib::get_storage();
|
|
||||||
let resp = reqwasm::http::Request::get(&path).send().await?;
|
let resp = reqwasm::http::Request::get(&path).send().await?;
|
||||||
if resp.status() != 200 {
|
if resp.status() != 200 {
|
||||||
let err = Err(format!("Status: {}", resp.status()).into());
|
let err = Err(format!("Status: {}", resp.status()).into());
|
||||||
Ok(match storage.get("inventory") {
|
Ok(match self.local_store.get_inventory_data() {
|
||||||
Ok(Some(val)) => match from_str(&val) {
|
Some(val) => val,
|
||||||
// TODO(jwall): Once we remove the v1 endpoint this is no longer needed.
|
None => return err,
|
||||||
Ok((filtered_ingredients, modified_amts)) => {
|
|
||||||
(filtered_ingredients, modified_amts, Vec::new())
|
|
||||||
}
|
|
||||||
Err(_) => match from_str(&val) {
|
|
||||||
Ok((filtered_ingredients, modified_amts, extra_items)) => {
|
|
||||||
(filtered_ingredients, modified_amts, extra_items)
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
// Whatever is in storage is corrupted or invalid so we should delete it.
|
|
||||||
storage
|
|
||||||
.delete("inventory")
|
|
||||||
.expect("Unable to delete corrupt data in inventory cache");
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Ok(None) | Err(_) => return err,
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
debug!("We got a valid response back");
|
debug!("We got a valid response back");
|
||||||
@ -521,11 +592,6 @@ impl HttpStore {
|
|||||||
.map_err(|e| format!("{}", e))?
|
.map_err(|e| format!("{}", e))?
|
||||||
.as_success()
|
.as_success()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let _ = storage.set(
|
|
||||||
"inventory",
|
|
||||||
&to_string(&(&filtered_ingredients, &modified_amts))
|
|
||||||
.expect("Failed to serialize inventory data"),
|
|
||||||
);
|
|
||||||
Ok((
|
Ok((
|
||||||
filtered_ingredients.into_iter().collect(),
|
filtered_ingredients.into_iter().collect(),
|
||||||
modified_amts.into_iter().collect(),
|
modified_amts.into_iter().collect(),
|
||||||
@ -545,13 +611,9 @@ impl HttpStore {
|
|||||||
path.push_str("/inventory");
|
path.push_str("/inventory");
|
||||||
let filtered_ingredients: Vec<IngredientKey> = filtered_ingredients.into_iter().collect();
|
let filtered_ingredients: Vec<IngredientKey> = filtered_ingredients.into_iter().collect();
|
||||||
let modified_amts: Vec<(IngredientKey, String)> = modified_amts.into_iter().collect();
|
let modified_amts: Vec<(IngredientKey, String)> = modified_amts.into_iter().collect();
|
||||||
|
debug!("Storing inventory data in cache");
|
||||||
let serialized_inventory = to_string(&(filtered_ingredients, modified_amts, extra_items))
|
let serialized_inventory = to_string(&(filtered_ingredients, modified_amts, extra_items))
|
||||||
.expect("Unable to encode plan as json");
|
.expect("Unable to encode plan as json");
|
||||||
let storage = js_lib::get_storage();
|
|
||||||
debug!("Storing inventory data in cache");
|
|
||||||
storage
|
|
||||||
.set("inventory", &serialized_inventory)
|
|
||||||
.expect("Failed to cache inventory data");
|
|
||||||
debug!("Storing inventory data via API");
|
debug!("Storing inventory data via API");
|
||||||
let resp = reqwasm::http::Request::post(&path)
|
let resp = reqwasm::http::Request::post(&path)
|
||||||
.body(&serialized_inventory)
|
.body(&serialized_inventory)
|
||||||
|
@ -11,139 +11,373 @@
|
|||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::{
|
||||||
|
collections::{BTreeMap, BTreeSet},
|
||||||
use sycamore::prelude::*;
|
fmt::Debug,
|
||||||
use tracing::{debug, instrument, warn};
|
};
|
||||||
|
|
||||||
use client_api::UserData;
|
use client_api::UserData;
|
||||||
use recipes::{Ingredient, IngredientAccumulator, IngredientKey, Recipe};
|
use recipes::{parse, IngredientKey, Recipe, RecipeEntry};
|
||||||
|
use sycamore::futures::spawn_local_scoped;
|
||||||
|
use sycamore::prelude::*;
|
||||||
|
use sycamore_state::{Handler, MessageMapper};
|
||||||
|
use tracing::{debug, error, info, instrument, warn};
|
||||||
|
use wasm_bindgen::throw_str;
|
||||||
|
|
||||||
#[derive(Debug)]
|
use crate::api::{HttpStore, LocalStore};
|
||||||
pub struct State {
|
|
||||||
pub recipe_counts: RcSignal<BTreeMap<String, RcSignal<usize>>>,
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub extras: RcSignal<Vec<(usize, (RcSignal<String>, RcSignal<String>))>>,
|
pub struct AppState {
|
||||||
pub staples: RcSignal<Option<Recipe>>,
|
pub recipe_counts: BTreeMap<String, usize>,
|
||||||
pub recipes: RcSignal<BTreeMap<String, Recipe>>,
|
pub extras: Vec<(String, String)>,
|
||||||
pub category_map: RcSignal<BTreeMap<String, String>>,
|
pub staples: Option<Recipe>,
|
||||||
pub filtered_ingredients: RcSignal<BTreeSet<IngredientKey>>,
|
pub recipes: BTreeMap<String, Recipe>,
|
||||||
pub modified_amts: RcSignal<BTreeMap<IngredientKey, RcSignal<String>>>,
|
pub category_map: String,
|
||||||
pub auth: RcSignal<Option<UserData>>,
|
pub filtered_ingredients: BTreeSet<IngredientKey>,
|
||||||
|
pub modified_amts: BTreeMap<IngredientKey, String>,
|
||||||
|
pub auth: Option<UserData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl State {
|
impl AppState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
recipe_counts: create_rc_signal(BTreeMap::new()),
|
recipe_counts: BTreeMap::new(),
|
||||||
extras: create_rc_signal(Vec::new()),
|
extras: Vec::new(),
|
||||||
staples: create_rc_signal(None),
|
staples: None,
|
||||||
recipes: create_rc_signal(BTreeMap::new()),
|
recipes: BTreeMap::new(),
|
||||||
category_map: create_rc_signal(BTreeMap::new()),
|
category_map: String::new(),
|
||||||
filtered_ingredients: create_rc_signal(BTreeSet::new()),
|
filtered_ingredients: BTreeSet::new(),
|
||||||
modified_amts: create_rc_signal(BTreeMap::new()),
|
modified_amts: BTreeMap::new(),
|
||||||
auth: create_rc_signal(None),
|
auth: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
pub fn provide_context(cx: Scope) {
|
|
||||||
provide_context(cx, std::rc::Rc::new(Self::new()));
|
pub enum Message {
|
||||||
}
|
ResetRecipeCounts,
|
||||||
|
UpdateRecipeCount(String, usize),
|
||||||
pub fn get_from_context(cx: Scope) -> std::rc::Rc<Self> {
|
AddExtra(String, String),
|
||||||
use_context::<std::rc::Rc<Self>>(cx).clone()
|
RemoveExtra(usize),
|
||||||
}
|
UpdateExtra(usize, String, String),
|
||||||
|
SaveRecipe(RecipeEntry),
|
||||||
pub fn get_menu_list(&self) -> Vec<(String, RcSignal<usize>)> {
|
SetRecipe(String, Recipe),
|
||||||
self.recipe_counts
|
SetCategoryMap(String),
|
||||||
.get()
|
ResetInventory,
|
||||||
.iter()
|
AddFilteredIngredient(IngredientKey),
|
||||||
.map(|(k, v)| (k.clone(), v.clone()))
|
UpdateAmt(IngredientKey, String),
|
||||||
.filter(|(_, v)| *(v.get_untracked()) != 0)
|
SetUserData(UserData),
|
||||||
.collect()
|
SaveState(Option<Box<dyn FnOnce()>>),
|
||||||
}
|
LoadState(Option<Box<dyn FnOnce()>>),
|
||||||
|
}
|
||||||
#[instrument(skip(self))]
|
|
||||||
pub fn get_shopping_list(
|
impl Debug for Message {
|
||||||
&self,
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
show_staples: bool,
|
match self {
|
||||||
) -> BTreeMap<String, Vec<(Ingredient, BTreeSet<String>)>> {
|
Self::ResetRecipeCounts => write!(f, "ResetRecipeCounts"),
|
||||||
let mut acc = IngredientAccumulator::new();
|
Self::UpdateRecipeCount(arg0, arg1) => f
|
||||||
let recipe_counts = self.get_menu_list();
|
.debug_tuple("UpdateRecipeCount")
|
||||||
for (idx, count) in recipe_counts.iter() {
|
.field(arg0)
|
||||||
for _ in 0..*count.get_untracked() {
|
.field(arg1)
|
||||||
acc.accumulate_from(
|
.finish(),
|
||||||
self.recipes
|
Self::AddExtra(arg0, arg1) => {
|
||||||
.get()
|
f.debug_tuple("AddExtra").field(arg0).field(arg1).finish()
|
||||||
.get(idx)
|
}
|
||||||
.expect(&format!("No such recipe id exists: {}", idx)),
|
Self::RemoveExtra(arg0) => f.debug_tuple("RemoveExtra").field(arg0).finish(),
|
||||||
);
|
Self::UpdateExtra(arg0, arg1, arg2) => f
|
||||||
}
|
.debug_tuple("UpdateExtra")
|
||||||
}
|
.field(arg0)
|
||||||
if show_staples {
|
.field(arg1)
|
||||||
if let Some(staples) = self.staples.get().as_ref() {
|
.field(arg2)
|
||||||
acc.accumulate_from(staples);
|
.finish(),
|
||||||
}
|
Self::SaveRecipe(arg0) => f.debug_tuple("SaveRecipe").field(arg0).finish(),
|
||||||
}
|
Self::SetRecipe(arg0, arg1) => {
|
||||||
let mut ingredients = acc.ingredients();
|
f.debug_tuple("SetRecipe").field(arg0).field(arg1).finish()
|
||||||
let mut groups = BTreeMap::new();
|
}
|
||||||
let cat_map = self.category_map.get().clone();
|
Self::SetCategoryMap(arg0) => f.debug_tuple("SetCategoryMap").field(arg0).finish(),
|
||||||
for (_, (i, recipes)) in ingredients.iter_mut() {
|
Self::ResetInventory => write!(f, "ResetInventory"),
|
||||||
let category = if let Some(cat) = cat_map.get(&i.name) {
|
Self::AddFilteredIngredient(arg0) => {
|
||||||
cat.clone()
|
f.debug_tuple("AddFilteredIngredient").field(arg0).finish()
|
||||||
} else {
|
}
|
||||||
"other".to_owned()
|
Self::UpdateAmt(arg0, arg1) => {
|
||||||
};
|
f.debug_tuple("UpdateAmt").field(arg0).field(arg1).finish()
|
||||||
i.category = category.clone();
|
}
|
||||||
groups
|
Self::SetUserData(arg0) => f.debug_tuple("SetUserData").field(arg0).finish(),
|
||||||
.entry(category)
|
Self::SaveState(_) => write!(f, "SaveState"),
|
||||||
.or_insert(vec![])
|
Self::LoadState(_) => write!(f, "LoadState"),
|
||||||
.push((i.clone(), recipes.clone()));
|
}
|
||||||
}
|
}
|
||||||
debug!(?self.category_map);
|
}
|
||||||
// FIXME(jwall): Sort by categories and names.
|
|
||||||
groups
|
pub struct StateMachine {
|
||||||
}
|
store: HttpStore,
|
||||||
|
local_store: LocalStore,
|
||||||
/// Retrieves the count for a recipe without triggering subscribers to the entire
|
}
|
||||||
/// recipe count set.
|
|
||||||
pub fn get_recipe_count_by_index(&self, key: &String) -> Option<RcSignal<usize>> {
|
#[instrument]
|
||||||
self.recipe_counts.get_untracked().get(key).cloned()
|
fn filter_recipes(
|
||||||
}
|
recipe_entries: &Option<Vec<RecipeEntry>>,
|
||||||
|
) -> Result<(Option<Recipe>, Option<BTreeMap<String, Recipe>>), String> {
|
||||||
pub fn reset_recipe_counts(&self) {
|
match recipe_entries {
|
||||||
for (_, count) in self.recipe_counts.get_untracked().iter() {
|
Some(parsed) => {
|
||||||
count.set(0);
|
let mut staples = None;
|
||||||
}
|
let mut parsed_map = BTreeMap::new();
|
||||||
}
|
for r in parsed {
|
||||||
|
let recipe = match parse::as_recipe(&r.recipe_text()) {
|
||||||
/// Set the recipe_count by index. Does not trigger subscribers to the entire set of recipe_counts.
|
Ok(r) => r,
|
||||||
/// This does trigger subscribers of the specific recipe you are updating though.
|
Err(e) => {
|
||||||
pub fn set_recipe_count_by_index(&self, key: &String, count: usize) -> RcSignal<usize> {
|
error!("Error parsing recipe {}", e);
|
||||||
let mut counts = self.recipe_counts.get_untracked().as_ref().clone();
|
continue;
|
||||||
counts
|
}
|
||||||
.entry(key.clone())
|
};
|
||||||
.and_modify(|e| e.set(count))
|
if recipe.title == "Staples" {
|
||||||
.or_insert_with(|| create_rc_signal(count));
|
staples = Some(recipe);
|
||||||
self.recipe_counts.set(counts);
|
} else {
|
||||||
self.recipe_counts.get_untracked().get(key).unwrap().clone()
|
parsed_map.insert(r.recipe_id().to_owned(), recipe);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
pub fn get_current_modified_amts(&self) -> BTreeMap<IngredientKey, String> {
|
Ok((staples, Some(parsed_map)))
|
||||||
let mut modified_amts = BTreeMap::new();
|
}
|
||||||
for (key, amt) in self.modified_amts.get_untracked().iter() {
|
None => Ok((None, None)),
|
||||||
modified_amts.insert(key.clone(), amt.get_untracked().as_ref().clone());
|
}
|
||||||
}
|
}
|
||||||
modified_amts
|
|
||||||
}
|
impl StateMachine {
|
||||||
|
pub fn new(store: HttpStore, local_store: LocalStore) -> Self {
|
||||||
pub fn reset_modified_amts(&self, modified_amts: BTreeMap<IngredientKey, String>) {
|
Self { store, local_store }
|
||||||
let mut modified_amts_copy = self.modified_amts.get().as_ref().clone();
|
}
|
||||||
for (key, amt) in modified_amts {
|
|
||||||
modified_amts_copy
|
async fn load_state(
|
||||||
.entry(key)
|
store: &HttpStore,
|
||||||
.and_modify(|amt_signal| amt_signal.set(amt.clone()))
|
local_store: &LocalStore,
|
||||||
.or_insert_with(|| create_rc_signal(amt));
|
original: &Signal<AppState>,
|
||||||
}
|
) -> Result<(), crate::api::Error> {
|
||||||
self.modified_amts.set(modified_amts_copy);
|
let mut state = original.get().as_ref().clone();
|
||||||
}
|
info!("Synchronizing Recipes");
|
||||||
|
let recipe_entries = &store.get_recipes().await?;
|
||||||
|
let (staples, recipes) = filter_recipes(&recipe_entries)?;
|
||||||
|
if let Some(recipes) = recipes {
|
||||||
|
state.staples = staples;
|
||||||
|
state.recipes = recipes;
|
||||||
|
};
|
||||||
|
if let Some(recipe_entries) = recipe_entries {
|
||||||
|
local_store.set_all_recipes(recipe_entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
let plan = store.get_plan().await?;
|
||||||
|
if let Some(plan) = plan {
|
||||||
|
// set the counts.
|
||||||
|
let mut plan_map = BTreeMap::new();
|
||||||
|
for (id, count) in plan {
|
||||||
|
plan_map.insert(id, count as usize);
|
||||||
|
}
|
||||||
|
state.recipe_counts = plan_map;
|
||||||
|
} else {
|
||||||
|
// Initialize things to zero
|
||||||
|
if let Some(rs) = recipe_entries {
|
||||||
|
for r in rs {
|
||||||
|
state.recipe_counts.insert(r.recipe_id().to_owned(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let plan = state
|
||||||
|
.recipe_counts
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), *v as i32))
|
||||||
|
.collect::<Vec<(String, i32)>>();
|
||||||
|
local_store.save_plan(&plan);
|
||||||
|
info!("Checking for user_data in local storage");
|
||||||
|
let user_data = local_store.get_user_data();
|
||||||
|
state.auth = user_data;
|
||||||
|
info!("Synchronizing categories");
|
||||||
|
match store.get_categories().await {
|
||||||
|
Ok(Some(categories_content)) => {
|
||||||
|
debug!(categories=?categories_content);
|
||||||
|
local_store.set_categories(Some(&categories_content));
|
||||||
|
state.category_map = categories_content;
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
warn!("There is no category file");
|
||||||
|
local_store.set_categories(None);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("{:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!("Synchronizing inventory data");
|
||||||
|
match store.get_inventory_data().await {
|
||||||
|
Ok((filtered_ingredients, modified_amts, extra_items)) => {
|
||||||
|
local_store.set_inventory_data((
|
||||||
|
&filtered_ingredients,
|
||||||
|
&modified_amts,
|
||||||
|
&extra_items,
|
||||||
|
));
|
||||||
|
state.modified_amts = modified_amts;
|
||||||
|
state.filtered_ingredients = filtered_ingredients;
|
||||||
|
state.extras = extra_items;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("{:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
original.set(state);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MessageMapper<Message, AppState> for StateMachine {
|
||||||
|
#[instrument(skip_all, fields(?msg))]
|
||||||
|
fn map<'ctx>(&self, cx: Scope<'ctx>, msg: Message, original: &'ctx Signal<AppState>) {
|
||||||
|
let mut original_copy = original.get().as_ref().clone();
|
||||||
|
debug!("handling state message");
|
||||||
|
match msg {
|
||||||
|
Message::ResetRecipeCounts => {
|
||||||
|
let mut map = BTreeMap::new();
|
||||||
|
for (id, _) in original_copy.recipes.iter() {
|
||||||
|
map.insert(id.clone(), 0);
|
||||||
|
}
|
||||||
|
let plan: Vec<(String, i32)> =
|
||||||
|
map.iter().map(|(s, i)| (s.clone(), *i as i32)).collect();
|
||||||
|
self.local_store.save_plan(&plan);
|
||||||
|
original_copy.recipe_counts = map;
|
||||||
|
}
|
||||||
|
Message::UpdateRecipeCount(id, count) => {
|
||||||
|
original_copy.recipe_counts.insert(id, count);
|
||||||
|
let plan: Vec<(String, i32)> = original_copy
|
||||||
|
.recipe_counts
|
||||||
|
.iter()
|
||||||
|
.map(|(s, i)| (s.clone(), *i as i32))
|
||||||
|
.collect();
|
||||||
|
self.local_store.save_plan(&plan);
|
||||||
|
}
|
||||||
|
Message::AddExtra(amt, name) => {
|
||||||
|
original_copy.extras.push((amt, name));
|
||||||
|
self.local_store.set_inventory_data((
|
||||||
|
&original_copy.filtered_ingredients,
|
||||||
|
&original_copy.modified_amts,
|
||||||
|
&original_copy.extras,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Message::RemoveExtra(idx) => {
|
||||||
|
original_copy.extras.remove(idx);
|
||||||
|
self.local_store.set_inventory_data((
|
||||||
|
&original_copy.filtered_ingredients,
|
||||||
|
&original_copy.modified_amts,
|
||||||
|
&original_copy.extras,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Message::UpdateExtra(idx, amt, name) => {
|
||||||
|
match original_copy.extras.get_mut(idx) {
|
||||||
|
Some(extra) => {
|
||||||
|
extra.0 = amt;
|
||||||
|
extra.1 = name;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
throw_str("Attempted to remove extra that didn't exist");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.local_store.set_inventory_data((
|
||||||
|
&original_copy.filtered_ingredients,
|
||||||
|
&original_copy.modified_amts,
|
||||||
|
&original_copy.extras,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Message::SetRecipe(id, recipe) => {
|
||||||
|
original_copy.recipes.insert(id, recipe);
|
||||||
|
}
|
||||||
|
Message::SaveRecipe(entry) => {
|
||||||
|
let recipe =
|
||||||
|
parse::as_recipe(entry.recipe_text()).expect("Failed to parse RecipeEntry");
|
||||||
|
original_copy
|
||||||
|
.recipes
|
||||||
|
.insert(entry.recipe_id().to_owned(), recipe);
|
||||||
|
original_copy
|
||||||
|
.recipe_counts
|
||||||
|
.insert(entry.recipe_id().to_owned(), 0);
|
||||||
|
let store = self.store.clone();
|
||||||
|
self.local_store.set_recipe_entry(&entry);
|
||||||
|
spawn_local_scoped(cx, async move {
|
||||||
|
if let Err(e) = store.save_recipes(vec![entry]).await {
|
||||||
|
error!(err=?e, "Unable to save Recipe");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Message::SetCategoryMap(category_text) => {
|
||||||
|
original_copy.category_map = category_text.clone();
|
||||||
|
self.local_store.set_categories(Some(&category_text));
|
||||||
|
let store = self.store.clone();
|
||||||
|
spawn_local_scoped(cx, async move {
|
||||||
|
if let Err(e) = store.save_categories(category_text).await {
|
||||||
|
error!(?e, "Failed to save categories");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Message::ResetInventory => {
|
||||||
|
original_copy.filtered_ingredients = BTreeSet::new();
|
||||||
|
original_copy.modified_amts = BTreeMap::new();
|
||||||
|
original_copy.extras = Vec::new();
|
||||||
|
self.local_store.set_inventory_data((
|
||||||
|
&original_copy.filtered_ingredients,
|
||||||
|
&original_copy.modified_amts,
|
||||||
|
&original_copy.extras,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Message::AddFilteredIngredient(key) => {
|
||||||
|
original_copy.filtered_ingredients.insert(key);
|
||||||
|
self.local_store.set_inventory_data((
|
||||||
|
&original_copy.filtered_ingredients,
|
||||||
|
&original_copy.modified_amts,
|
||||||
|
&original_copy.extras,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Message::UpdateAmt(key, amt) => {
|
||||||
|
original_copy.modified_amts.insert(key, amt);
|
||||||
|
self.local_store.set_inventory_data((
|
||||||
|
&original_copy.filtered_ingredients,
|
||||||
|
&original_copy.modified_amts,
|
||||||
|
&original_copy.extras,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Message::SetUserData(user_data) => {
|
||||||
|
self.local_store.set_user_data(Some(&user_data));
|
||||||
|
original_copy.auth = Some(user_data);
|
||||||
|
}
|
||||||
|
Message::SaveState(f) => {
|
||||||
|
let original_copy = original_copy.clone();
|
||||||
|
let store = self.store.clone();
|
||||||
|
spawn_local_scoped(cx, async move {
|
||||||
|
if let Err(e) = store.save_app_state(original_copy).await {
|
||||||
|
error!(err=?e, "Error saving app state")
|
||||||
|
};
|
||||||
|
f.map(|f| f());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Message::LoadState(f) => {
|
||||||
|
let store = self.store.clone();
|
||||||
|
let local_store = self.local_store.clone();
|
||||||
|
spawn_local_scoped(cx, async move {
|
||||||
|
Self::load_state(&store, &local_store, original)
|
||||||
|
.await
|
||||||
|
.expect("Failed to load_state.");
|
||||||
|
local_store.set_inventory_data((
|
||||||
|
&original.get().filtered_ingredients,
|
||||||
|
&original.get().modified_amts,
|
||||||
|
&original.get().extras,
|
||||||
|
));
|
||||||
|
f.map(|f| f());
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
original.set(original_copy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type StateHandler<'ctx> = &'ctx Handler<'ctx, StateMachine, AppState, Message>;
|
||||||
|
|
||||||
|
pub fn get_state_handler<'ctx>(
|
||||||
|
cx: Scope<'ctx>,
|
||||||
|
initial: AppState,
|
||||||
|
store: HttpStore,
|
||||||
|
) -> StateHandler<'ctx> {
|
||||||
|
Handler::new(cx, initial, StateMachine::new(store, LocalStore::new()))
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,7 @@
|
|||||||
use sycamore::{futures::spawn_local_scoped, prelude::*};
|
use sycamore::{futures::spawn_local_scoped, prelude::*};
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
use crate::app_state::{Message, StateHandler};
|
||||||
use recipes::RecipeEntry;
|
use recipes::RecipeEntry;
|
||||||
|
|
||||||
const STARTER_RECIPE: &'static str = "title: TITLE_PLACEHOLDER
|
const STARTER_RECIPE: &'static str = "title: TITLE_PLACEHOLDER
|
||||||
@ -28,7 +29,7 @@ Instructions here
|
|||||||
";
|
";
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn AddRecipe<G: Html>(cx: Scope) -> View<G> {
|
pub fn AddRecipe<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
|
||||||
let recipe_title = create_signal(cx, String::new());
|
let recipe_title = create_signal(cx, String::new());
|
||||||
let create_recipe_signal = create_signal(cx, ());
|
let create_recipe_signal = create_signal(cx, ());
|
||||||
let dirty = create_signal(cx, false);
|
let dirty = create_signal(cx, false);
|
||||||
@ -47,39 +48,6 @@ pub fn AddRecipe<G: Html>(cx: Scope) -> View<G> {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
create_effect(cx, move || {
|
|
||||||
create_recipe_signal.track();
|
|
||||||
if !*dirty.get_untracked() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
spawn_local_scoped(cx, {
|
|
||||||
let store = crate::api::HttpStore::get_from_context(cx);
|
|
||||||
async move {
|
|
||||||
let entry = entry.get_untracked();
|
|
||||||
// TODO(jwall): Better error reporting here.
|
|
||||||
match store.get_recipe_text(entry.recipe_id()).await {
|
|
||||||
Ok(Some(_)) => {
|
|
||||||
// TODO(jwall): We should tell the user that this id already exists
|
|
||||||
info!(recipe_id = entry.recipe_id(), "Recipe already exists");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
// noop
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
// TODO(jwall): We should tell the user that this is failing
|
|
||||||
error!(?err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
store
|
|
||||||
.save_recipes(vec![entry.as_ref().clone()])
|
|
||||||
.await
|
|
||||||
.expect("Unable to save New Recipe");
|
|
||||||
crate::js_lib::navigate_to_path(&format!("/ui/recipe/{}", entry.recipe_id()))
|
|
||||||
.expect("Unable to navigate to recipe");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
view! {cx,
|
view! {cx,
|
||||||
label(for="recipe_title") { "Recipe Title" }
|
label(for="recipe_title") { "Recipe Title" }
|
||||||
input(bind:value=recipe_title, type="text", name="recipe_title", id="recipe_title", on:change=move |_| {
|
input(bind:value=recipe_title, type="text", name="recipe_title", id="recipe_title", on:change=move |_| {
|
||||||
@ -87,6 +55,33 @@ pub fn AddRecipe<G: Html>(cx: Scope) -> View<G> {
|
|||||||
})
|
})
|
||||||
button(on:click=move |_| {
|
button(on:click=move |_| {
|
||||||
create_recipe_signal.trigger_subscribers();
|
create_recipe_signal.trigger_subscribers();
|
||||||
|
if !*dirty.get_untracked() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spawn_local_scoped(cx, {
|
||||||
|
let store = crate::api::HttpStore::get_from_context(cx);
|
||||||
|
async move {
|
||||||
|
let entry = entry.get_untracked();
|
||||||
|
// TODO(jwall): Better error reporting here.
|
||||||
|
match store.get_recipe_text(entry.recipe_id()).await {
|
||||||
|
Ok(Some(_)) => {
|
||||||
|
// TODO(jwall): We should tell the user that this id already exists
|
||||||
|
info!(recipe_id = entry.recipe_id(), "Recipe already exists");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
// TODO(jwall): We should tell the user that this is failing
|
||||||
|
error!(?err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sh.dispatch(cx, Message::SaveRecipe((*entry).clone()));
|
||||||
|
crate::js_lib::navigate_to_path(&format!("/ui/recipe/edit/{}", entry.recipe_id()))
|
||||||
|
.expect("Unable to navigate to recipe");
|
||||||
|
}
|
||||||
|
});
|
||||||
}) { "Create" }
|
}) { "Create" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,14 +11,16 @@
|
|||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
use crate::{
|
||||||
|
app_state::{Message, StateHandler},
|
||||||
|
js_lib::get_element_by_id,
|
||||||
|
};
|
||||||
use sycamore::{futures::spawn_local_scoped, prelude::*};
|
use sycamore::{futures::spawn_local_scoped, prelude::*};
|
||||||
use tracing::{debug, error, instrument};
|
use tracing::{debug, error, instrument};
|
||||||
use web_sys::HtmlDialogElement;
|
use web_sys::HtmlDialogElement;
|
||||||
|
|
||||||
use recipes::parse;
|
use recipes::parse;
|
||||||
|
|
||||||
use crate::js_lib::get_element_by_id;
|
|
||||||
|
|
||||||
fn get_error_dialog() -> HtmlDialogElement {
|
fn get_error_dialog() -> HtmlDialogElement {
|
||||||
get_element_by_id::<HtmlDialogElement>("error-dialog")
|
get_element_by_id::<HtmlDialogElement>("error-dialog")
|
||||||
.expect("error-dialog isn't an html dialog element!")
|
.expect("error-dialog isn't an html dialog element!")
|
||||||
@ -38,10 +40,9 @@ fn check_category_text_parses(unparsed: &str, error_text: &Signal<String>) -> bo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument]
|
#[instrument(skip_all)]
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Categories<G: Html>(cx: Scope) -> View<G> {
|
pub fn Categories<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
|
||||||
let save_signal = create_signal(cx, ());
|
|
||||||
let error_text = create_signal(cx, String::new());
|
let error_text = create_signal(cx, String::new());
|
||||||
let category_text: &Signal<String> = create_signal(cx, String::new());
|
let category_text: &Signal<String> = create_signal(cx, String::new());
|
||||||
let dirty = create_signal(cx, false);
|
let dirty = create_signal(cx, false);
|
||||||
@ -59,28 +60,6 @@ pub fn Categories<G: Html>(cx: Scope) -> View<G> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
create_effect(cx, move || {
|
|
||||||
save_signal.track();
|
|
||||||
if !*dirty.get() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
spawn_local_scoped(cx, {
|
|
||||||
let store = crate::api::HttpStore::get_from_context(cx);
|
|
||||||
async move {
|
|
||||||
// TODO(jwall): Save the categories.
|
|
||||||
if let Err(e) = store
|
|
||||||
.save_categories(category_text.get_untracked().as_ref().clone())
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!(?e, "Failed to save categories");
|
|
||||||
error_text.set(format!("{:?}", e));
|
|
||||||
} else {
|
|
||||||
dirty.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let dialog_view = view! {cx,
|
let dialog_view = view! {cx,
|
||||||
dialog(id="error-dialog") {
|
dialog(id="error-dialog") {
|
||||||
article{
|
article{
|
||||||
@ -107,10 +86,15 @@ pub fn Categories<G: Html>(cx: Scope) -> View<G> {
|
|||||||
check_category_text_parses(category_text.get().as_str(), error_text);
|
check_category_text_parses(category_text.get().as_str(), error_text);
|
||||||
}) { "Check" } " "
|
}) { "Check" } " "
|
||||||
span(role="button", on:click=move |_| {
|
span(role="button", on:click=move |_| {
|
||||||
// TODO(jwall): check and then save the categories.
|
if !*dirty.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if check_category_text_parses(category_text.get().as_str(), error_text) {
|
if check_category_text_parses(category_text.get().as_str(), error_text) {
|
||||||
debug!("triggering category save");
|
debug!("triggering category save");
|
||||||
save_signal.trigger_subscribers();
|
sh.dispatch(
|
||||||
|
cx,
|
||||||
|
Message::SetCategoryMap(category_text.get_untracked().as_ref().clone()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}) { "Save" }
|
}) { "Save" }
|
||||||
}
|
}
|
||||||
|
@ -14,17 +14,13 @@
|
|||||||
|
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
use crate::app_state;
|
use crate::app_state::StateHandler;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Header<G: Html>(cx: Scope) -> View<G> {
|
pub fn Header<'ctx, G: Html>(cx: Scope<'ctx>, h: StateHandler<'ctx>) -> View<G> {
|
||||||
let state = app_state::State::get_from_context(cx);
|
let login = h.get_selector(cx, |sig| match &sig.get().auth {
|
||||||
let login = create_memo(cx, move || {
|
Some(id) => id.user_id.clone(),
|
||||||
let user_id = state.auth.get();
|
None => "Login".to_owned(),
|
||||||
match user_id.as_ref() {
|
|
||||||
Some(user_data) => format!("{}", user_data.user_id),
|
|
||||||
None => "Login".to_owned(),
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
view! {cx,
|
view! {cx,
|
||||||
nav(class="no-print") {
|
nav(class="no-print") {
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
use sycamore::{futures::spawn_local_scoped, prelude::*};
|
use sycamore::{futures::spawn_local_scoped, prelude::*};
|
||||||
use tracing::{debug, error};
|
use tracing::{debug, error};
|
||||||
|
|
||||||
use crate::app_state;
|
use crate::app_state::{Message, StateHandler};
|
||||||
use recipes::{self, RecipeEntry};
|
use recipes::{self, RecipeEntry};
|
||||||
|
|
||||||
fn check_recipe_parses(
|
fn check_recipe_parses(
|
||||||
@ -34,8 +34,15 @@ fn check_recipe_parses(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Props)]
|
||||||
|
pub struct RecipeComponentProps<'ctx> {
|
||||||
|
recipe_id: String,
|
||||||
|
sh: StateHandler<'ctx>,
|
||||||
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Editor<G: Html>(cx: Scope, recipe_id: String) -> View<G> {
|
pub fn Editor<'ctx, G: Html>(cx: Scope<'ctx>, props: RecipeComponentProps<'ctx>) -> View<G> {
|
||||||
|
let RecipeComponentProps { recipe_id, sh } = props;
|
||||||
let store = crate::api::HttpStore::get_from_context(cx);
|
let store = crate::api::HttpStore::get_from_context(cx);
|
||||||
let recipe: &Signal<RecipeEntry> =
|
let recipe: &Signal<RecipeEntry> =
|
||||||
create_signal(cx, RecipeEntry::new(&recipe_id, String::new()));
|
create_signal(cx, RecipeEntry::new(&recipe_id, String::new()));
|
||||||
@ -60,45 +67,8 @@ pub fn Editor<G: Html>(cx: Scope, recipe_id: String) -> View<G> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let id = create_memo(cx, || recipe.get().recipe_id().to_owned());
|
let id = create_memo(cx, || recipe.get().recipe_id().to_owned());
|
||||||
let save_signal = create_signal(cx, ());
|
|
||||||
let dirty = create_signal(cx, false);
|
let dirty = create_signal(cx, false);
|
||||||
|
|
||||||
debug!("Creating effect");
|
|
||||||
create_effect(cx, move || {
|
|
||||||
save_signal.track();
|
|
||||||
if !*dirty.get_untracked() {
|
|
||||||
debug!("Recipe text is unchanged");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
debug!("Recipe text is changed");
|
|
||||||
spawn_local_scoped(cx, {
|
|
||||||
let store = crate::api::HttpStore::get_from_context(cx);
|
|
||||||
let state = app_state::State::get_from_context(cx);
|
|
||||||
async move {
|
|
||||||
debug!("Attempting to save recipe");
|
|
||||||
if let Err(e) = store
|
|
||||||
.save_recipes(vec![RecipeEntry(
|
|
||||||
id.get_untracked().as_ref().clone(),
|
|
||||||
text.get_untracked().as_ref().clone(),
|
|
||||||
)])
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!(?e, "Failed to save recipe");
|
|
||||||
error_text.set(format!("{:?}", e));
|
|
||||||
} else {
|
|
||||||
// We also need to set recipe in our state
|
|
||||||
dirty.set(false);
|
|
||||||
if let Ok(recipe) = recipes::parse::as_recipe(text.get_untracked().as_ref()) {
|
|
||||||
state
|
|
||||||
.recipes
|
|
||||||
.modify()
|
|
||||||
.insert(id.get_untracked().as_ref().to_owned(), recipe);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
debug!("creating editor view");
|
debug!("creating editor view");
|
||||||
view! {cx,
|
view! {cx,
|
||||||
div(class="grid") {
|
div(class="grid") {
|
||||||
@ -115,7 +85,36 @@ pub fn Editor<G: Html>(cx: Scope, recipe_id: String) -> View<G> {
|
|||||||
let unparsed = text.get();
|
let unparsed = text.get();
|
||||||
if check_recipe_parses(unparsed.as_str(), error_text, aria_hint) {
|
if check_recipe_parses(unparsed.as_str(), error_text, aria_hint) {
|
||||||
debug!("triggering a save");
|
debug!("triggering a save");
|
||||||
save_signal.trigger_subscribers();
|
if !*dirty.get_untracked() {
|
||||||
|
debug!("Recipe text is unchanged");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debug!("Recipe text is changed");
|
||||||
|
spawn_local_scoped(cx, {
|
||||||
|
let store = crate::api::HttpStore::get_from_context(cx);
|
||||||
|
async move {
|
||||||
|
debug!("Attempting to save recipe");
|
||||||
|
if let Err(e) = store
|
||||||
|
.save_recipes(vec![RecipeEntry(
|
||||||
|
id.get_untracked().as_ref().clone(),
|
||||||
|
text.get_untracked().as_ref().clone(),
|
||||||
|
)])
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
error!(?e, "Failed to save recipe");
|
||||||
|
error_text.set(format!("{:?}", e));
|
||||||
|
} else {
|
||||||
|
// We also need to set recipe in our state
|
||||||
|
dirty.set(false);
|
||||||
|
if let Ok(recipe) = recipes::parse::as_recipe(text.get_untracked().as_ref()) {
|
||||||
|
sh.dispatch(
|
||||||
|
cx,
|
||||||
|
Message::SetRecipe(id.get_untracked().as_ref().to_owned(), recipe),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
}
|
}
|
||||||
}) { "Save" }
|
}) { "Save" }
|
||||||
@ -154,13 +153,20 @@ fn Steps<G: Html>(cx: Scope, steps: Vec<recipes::Step>) -> View<G> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Viewer<G: Html>(cx: Scope, recipe_id: String) -> View<G> {
|
pub fn Viewer<'ctx, G: Html>(cx: Scope<'ctx>, props: RecipeComponentProps<'ctx>) -> View<G> {
|
||||||
let state = app_state::State::get_from_context(cx);
|
let RecipeComponentProps { recipe_id, sh } = props;
|
||||||
let view = create_signal(cx, View::empty());
|
let view = create_signal(cx, View::empty());
|
||||||
if let Some(recipe) = state.recipes.get_untracked().get(&recipe_id) {
|
let recipe_signal = sh.get_selector(cx, move |state| {
|
||||||
let title = recipe.title.clone();
|
if let Some(recipe) = state.get().recipes.get(&recipe_id) {
|
||||||
let desc = recipe.desc.clone().unwrap_or_else(|| String::new());
|
let title = recipe.title.clone();
|
||||||
let steps = recipe.steps.clone();
|
let desc = recipe.desc.clone().unwrap_or_else(|| String::new());
|
||||||
|
let steps = recipe.steps.clone();
|
||||||
|
Some((title, desc, steps))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Some((title, desc, steps)) = recipe_signal.get().as_ref().clone() {
|
||||||
debug!("Viewing recipe.");
|
debug!("Viewing recipe.");
|
||||||
view.set(view! {cx,
|
view.set(view! {cx,
|
||||||
div(class="recipe") {
|
div(class="recipe") {
|
||||||
|
@ -11,25 +11,32 @@
|
|||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
use crate::{app_state, components::recipe::Viewer};
|
use crate::{app_state::StateHandler, components::recipe::Viewer};
|
||||||
|
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
use tracing::{debug, instrument};
|
use tracing::{debug, instrument};
|
||||||
|
|
||||||
#[instrument]
|
#[instrument(skip_all)]
|
||||||
#[component]
|
#[component]
|
||||||
pub fn RecipeList<G: Html>(cx: Scope) -> View<G> {
|
pub fn RecipeList<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
|
||||||
let state = app_state::State::get_from_context(cx);
|
let menu_list = sh.get_selector(cx, |state| {
|
||||||
let menu_list = create_memo(cx, move || state.get_menu_list());
|
state
|
||||||
|
.get()
|
||||||
|
.recipe_counts
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
|
.filter(|(_, v)| *(v) != 0)
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
view! {cx,
|
view! {cx,
|
||||||
h1 { "Recipe List" }
|
h1 { "Recipe List" }
|
||||||
div() {
|
div() {
|
||||||
Indexed(
|
Indexed(
|
||||||
iterable=menu_list,
|
iterable=menu_list,
|
||||||
view= |cx, (id, _count)| {
|
view= move |cx, (id, _count)| {
|
||||||
debug!(id=%id, "Rendering recipe");
|
debug!(id=%id, "Rendering recipe");
|
||||||
view ! {cx,
|
view ! {cx,
|
||||||
Viewer(id)
|
Viewer(recipe_id=id, sh=sh)
|
||||||
hr()
|
hr()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,22 +12,20 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
use recipes::Recipe;
|
use recipes::Recipe;
|
||||||
use sycamore::{futures::spawn_local_scoped, prelude::*};
|
use sycamore::prelude::*;
|
||||||
use tracing::{error, instrument};
|
use tracing::instrument;
|
||||||
|
|
||||||
|
use crate::app_state::{Message, StateHandler};
|
||||||
use crate::components::recipe_selection::*;
|
use crate::components::recipe_selection::*;
|
||||||
use crate::{api::*, app_state};
|
|
||||||
|
|
||||||
#[allow(non_snake_case)]
|
#[allow(non_snake_case)]
|
||||||
#[instrument]
|
#[instrument(skip_all)]
|
||||||
pub fn RecipePlan<G: Html>(cx: Scope) -> View<G> {
|
pub fn RecipePlan<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
|
||||||
let rows = create_memo(cx, move || {
|
let rows = sh.get_selector(cx, move |state| {
|
||||||
let state = app_state::State::get_from_context(cx);
|
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
for row in state
|
for row in state
|
||||||
.recipes
|
|
||||||
.get()
|
.get()
|
||||||
.as_ref()
|
.recipes
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(k, v)| create_signal(cx, (k.clone(), v.clone())))
|
.map(|(k, v)| create_signal(cx, (k.clone(), v.clone())))
|
||||||
.collect::<Vec<&Signal<(String, Recipe)>>>()
|
.collect::<Vec<&Signal<(String, Recipe)>>>()
|
||||||
@ -37,30 +35,6 @@ pub fn RecipePlan<G: Html>(cx: Scope) -> View<G> {
|
|||||||
}
|
}
|
||||||
rows
|
rows
|
||||||
});
|
});
|
||||||
let refresh_click = create_signal(cx, false);
|
|
||||||
let save_click = create_signal(cx, false);
|
|
||||||
create_effect(cx, move || {
|
|
||||||
refresh_click.track();
|
|
||||||
let store = HttpStore::get_from_context(cx);
|
|
||||||
let state = app_state::State::get_from_context(cx);
|
|
||||||
spawn_local_scoped(cx, {
|
|
||||||
async move {
|
|
||||||
if let Err(err) = init_page_state(store.as_ref(), state.as_ref()).await {
|
|
||||||
error!(?err);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
create_effect(cx, move || {
|
|
||||||
save_click.track();
|
|
||||||
let store = HttpStore::get_from_context(cx);
|
|
||||||
let state = app_state::State::get_from_context(cx);
|
|
||||||
spawn_local_scoped(cx, {
|
|
||||||
async move {
|
|
||||||
store.save_state(state).await.expect("Failed to save plan");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
view! {cx,
|
view! {cx,
|
||||||
table(class="recipe_selector no-print") {
|
table(class="recipe_selector no-print") {
|
||||||
(View::new_fragment(
|
(View::new_fragment(
|
||||||
@ -68,10 +42,10 @@ pub fn RecipePlan<G: Html>(cx: Scope) -> View<G> {
|
|||||||
view ! {cx,
|
view ! {cx,
|
||||||
tr { Keyed(
|
tr { Keyed(
|
||||||
iterable=r,
|
iterable=r,
|
||||||
view=|cx, sig| {
|
view=move |cx, sig| {
|
||||||
let title = create_memo(cx, move || sig.get().1.title.clone());
|
let title = create_memo(cx, move || sig.get().1.title.clone());
|
||||||
view! {cx,
|
view! {cx,
|
||||||
td { RecipeSelection(i=sig.get().0.to_owned(), title=title) }
|
td { RecipeSelection(i=sig.get().0.to_owned(), title=title, sh=sh) }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
key=|sig| sig.get().0.to_owned(),
|
key=|sig| sig.get().0.to_owned(),
|
||||||
@ -81,18 +55,14 @@ pub fn RecipePlan<G: Html>(cx: Scope) -> View<G> {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
input(type="button", value="Reset", on:click=move |_| {
|
input(type="button", value="Reset", on:click=move |_| {
|
||||||
// Poor man's click event signaling.
|
sh.dispatch(cx, Message::LoadState(None));
|
||||||
let toggle = !*refresh_click.get();
|
|
||||||
refresh_click.set(toggle);
|
|
||||||
})
|
})
|
||||||
input(type="button", value="Clear All", on:click=move |_| {
|
input(type="button", value="Clear All", on:click=move |_| {
|
||||||
let state = app_state::State::get_from_context(cx);
|
sh.dispatch(cx, Message::ResetRecipeCounts);
|
||||||
state.reset_recipe_counts();
|
|
||||||
})
|
})
|
||||||
input(type="button", value="Save Plan", on:click=move |_| {
|
input(type="button", value="Save Plan", on:click=move |_| {
|
||||||
// Poor man's click event signaling.
|
// Poor man's click event signaling.
|
||||||
let toggle = !*save_click.get();
|
sh.dispatch(cx, Message::SaveState(None));
|
||||||
save_click.set(toggle);
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,43 +16,37 @@ use std::rc::Rc;
|
|||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
use tracing::{debug, instrument};
|
use tracing::{debug, instrument};
|
||||||
|
|
||||||
use crate::app_state;
|
use crate::app_state::{Message, StateHandler};
|
||||||
|
|
||||||
#[derive(Props)]
|
#[derive(Props)]
|
||||||
pub struct RecipeCheckBoxProps<'ctx> {
|
pub struct RecipeCheckBoxProps<'ctx> {
|
||||||
pub i: String,
|
pub i: String,
|
||||||
pub title: &'ctx ReadSignal<String>,
|
pub title: &'ctx ReadSignal<String>,
|
||||||
|
pub sh: StateHandler<'ctx>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(props, cx), fields(
|
#[instrument(skip(props, cx), fields(
|
||||||
idx=%props.i,
|
id=%props.i,
|
||||||
title=%props.title.get()
|
title=%props.title.get()
|
||||||
))]
|
))]
|
||||||
#[component]
|
#[component]
|
||||||
pub fn RecipeSelection<G: Html>(cx: Scope, props: RecipeCheckBoxProps) -> View<G> {
|
pub fn RecipeSelection<'ctx, G: Html>(
|
||||||
let state = app_state::State::get_from_context(cx);
|
cx: Scope<'ctx>,
|
||||||
// This is total hack but it works around the borrow issues with
|
props: RecipeCheckBoxProps<'ctx>,
|
||||||
// the `view!` macro.
|
) -> View<G> {
|
||||||
let id = Rc::new(props.i);
|
let RecipeCheckBoxProps { i, title, sh } = props;
|
||||||
|
let id = Rc::new(i);
|
||||||
|
let id_clone = id.clone();
|
||||||
let count = create_signal(
|
let count = create_signal(
|
||||||
cx,
|
cx,
|
||||||
format!(
|
sh.get_value(
|
||||||
"{}",
|
|state| match state.get_untracked().recipe_counts.get(id_clone.as_ref()) {
|
||||||
state
|
Some(count) => format!("{}", count),
|
||||||
.get_recipe_count_by_index(id.as_ref())
|
None => "0".to_owned(),
|
||||||
.unwrap_or_else(|| state.set_recipe_count_by_index(id.as_ref(), 0))
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
create_effect(cx, {
|
let title = title.get().clone();
|
||||||
let id = id.clone();
|
|
||||||
let state = app_state::State::get_from_context(cx);
|
|
||||||
move || {
|
|
||||||
if let Some(usize_count) = state.get_recipe_count_by_index(id.as_ref()) {
|
|
||||||
count.set(format!("{}", *usize_count.get()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let title = props.title.get().clone();
|
|
||||||
let for_id = id.clone();
|
let for_id = id.clone();
|
||||||
let href = format!("/ui/recipe/view/{}", id);
|
let href = format!("/ui/recipe/view/{}", id);
|
||||||
let name = format!("recipe_id:{}", id);
|
let name = format!("recipe_id:{}", id);
|
||||||
@ -60,9 +54,8 @@ pub fn RecipeSelection<G: Html>(cx: Scope, props: RecipeCheckBoxProps) -> View<G
|
|||||||
div() {
|
div() {
|
||||||
label(for=for_id) { a(href=href) { (*title) } }
|
label(for=for_id) { a(href=href) { (*title) } }
|
||||||
input(type="number", class="item-count-sel", min="0", bind:value=count, name=name, on:change=move |_| {
|
input(type="number", class="item-count-sel", min="0", bind:value=count, name=name, on:change=move |_| {
|
||||||
let state = app_state::State::get_from_context(cx);
|
|
||||||
debug!(idx=%id, count=%(*count.get()), "setting recipe count");
|
debug!(idx=%id, count=%(*count.get()), "setting recipe count");
|
||||||
state.set_recipe_count_by_index(id.as_ref(), count.get().parse().expect("recipe count isn't a valid usize number"));
|
sh.dispatch(cx, Message::UpdateRecipeCount(id.as_ref().clone(), count.get().parse().expect("Count is not a valid usize")));
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,36 +11,88 @@
|
|||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use recipes::{Ingredient, IngredientKey};
|
use recipes::{IngredientAccumulator, IngredientKey};
|
||||||
use sycamore::{futures::spawn_local_scoped, prelude::*};
|
use sycamore::prelude::*;
|
||||||
use tracing::{debug, info, instrument};
|
use tracing::{debug, info, instrument};
|
||||||
|
|
||||||
|
use crate::app_state::{Message, StateHandler};
|
||||||
|
|
||||||
|
#[instrument(skip_all)]
|
||||||
fn make_ingredients_rows<'ctx, G: Html>(
|
fn make_ingredients_rows<'ctx, G: Html>(
|
||||||
cx: Scope<'ctx>,
|
cx: Scope<'ctx>,
|
||||||
ingredients: &'ctx ReadSignal<Vec<(IngredientKey, (Ingredient, BTreeSet<String>))>>,
|
sh: StateHandler<'ctx>,
|
||||||
modified_amts: RcSignal<BTreeMap<IngredientKey, RcSignal<String>>>,
|
show_staples: &'ctx ReadSignal<bool>,
|
||||||
filtered_keys: RcSignal<BTreeSet<IngredientKey>>,
|
|
||||||
) -> View<G> {
|
) -> View<G> {
|
||||||
|
debug!("Making ingredients rows");
|
||||||
|
let ingredients = sh.get_selector(cx, move |state| {
|
||||||
|
let state = state.get();
|
||||||
|
debug!("building ingredient list from state");
|
||||||
|
let mut acc = IngredientAccumulator::new();
|
||||||
|
for (id, count) in state.recipe_counts.iter() {
|
||||||
|
for _ in 0..(*count) {
|
||||||
|
acc.accumulate_from(
|
||||||
|
state
|
||||||
|
.recipes
|
||||||
|
.get(id)
|
||||||
|
.expect(&format!("No such recipe id exists: {}", id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *show_staples.get() {
|
||||||
|
if let Some(staples) = &state.staples {
|
||||||
|
acc.accumulate_from(staples);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
acc.ingredients()
|
||||||
|
.into_iter()
|
||||||
|
// First we filter out any filtered ingredients
|
||||||
|
.filter(|(i, _)| !state.filtered_ingredients.contains(i))
|
||||||
|
// Then we take into account our modified amts
|
||||||
|
.map(|(k, (i, rs))| {
|
||||||
|
if state.modified_amts.contains_key(&k) {
|
||||||
|
(
|
||||||
|
k.clone(),
|
||||||
|
(
|
||||||
|
i.name,
|
||||||
|
i.form,
|
||||||
|
i.category,
|
||||||
|
state.modified_amts.get(&k).unwrap().clone(),
|
||||||
|
rs,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
k.clone(),
|
||||||
|
(
|
||||||
|
i.name,
|
||||||
|
i.form,
|
||||||
|
i.category,
|
||||||
|
format!("{}", i.amt.normalize()),
|
||||||
|
rs,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<(
|
||||||
|
IngredientKey,
|
||||||
|
(String, Option<String>, String, String, BTreeSet<String>),
|
||||||
|
)>>()
|
||||||
|
});
|
||||||
view!(
|
view!(
|
||||||
cx,
|
cx,
|
||||||
Indexed(
|
Indexed(
|
||||||
iterable = ingredients,
|
iterable = ingredients,
|
||||||
view = move |cx, (k, (i, rs))| {
|
view = move |cx, (k, (name, form, category, amt, rs))| {
|
||||||
let mut modified_amt_set = modified_amts.get().as_ref().clone();
|
let category = if category == "" {
|
||||||
let amt = modified_amt_set
|
|
||||||
.entry(k.clone())
|
|
||||||
.or_insert(create_rc_signal(format!("{}", i.amt.normalize())))
|
|
||||||
.clone();
|
|
||||||
modified_amts.set(modified_amt_set);
|
|
||||||
let name = i.name;
|
|
||||||
let category = if i.category == "" {
|
|
||||||
"other".to_owned()
|
"other".to_owned()
|
||||||
} else {
|
} else {
|
||||||
i.category
|
category
|
||||||
};
|
};
|
||||||
let form = i.form.map(|form| format!("({})", form)).unwrap_or_default();
|
let amt_signal = create_signal(cx, amt);
|
||||||
|
let k_clone = k.clone();
|
||||||
|
let form = form.map(|form| format!("({})", form)).unwrap_or_default();
|
||||||
let recipes = rs
|
let recipes = rs
|
||||||
.iter()
|
.iter()
|
||||||
.fold(String::new(), |acc, s| format!("{}{},", acc, s))
|
.fold(String::new(), |acc, s| format!("{}{},", acc, s))
|
||||||
@ -49,15 +101,14 @@ fn make_ingredients_rows<'ctx, G: Html>(
|
|||||||
view! {cx,
|
view! {cx,
|
||||||
tr {
|
tr {
|
||||||
td {
|
td {
|
||||||
input(bind:value=amt, type="text")
|
input(bind:value=amt_signal, type="text", on:change=move |_| {
|
||||||
|
sh.dispatch(cx, Message::UpdateAmt(k_clone.clone(), amt_signal.get_untracked().as_ref().clone()));
|
||||||
|
})
|
||||||
}
|
}
|
||||||
td {
|
td {
|
||||||
input(type="button", class="no-print destructive", value="X", on:click={
|
input(type="button", class="no-print destructive", value="X", on:click={
|
||||||
let filtered_keys = filtered_keys.clone();
|
|
||||||
move |_| {
|
move |_| {
|
||||||
let mut keyset = filtered_keys.get().as_ref().clone();
|
sh.dispatch(cx, Message::AddFilteredIngredient(k.clone()));
|
||||||
keyset.insert(k.clone());
|
|
||||||
filtered_keys.set(keyset);
|
|
||||||
}})
|
}})
|
||||||
}
|
}
|
||||||
td { (name) " " (form) "" br {} "" (category) "" }
|
td { (name) " " (form) "" br {} "" (category) "" }
|
||||||
@ -69,55 +120,53 @@ fn make_ingredients_rows<'ctx, G: Html>(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_extras_rows<'ctx, G: Html>(
|
#[instrument(skip_all)]
|
||||||
cx: Scope<'ctx>,
|
fn make_extras_rows<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
|
||||||
extras: RcSignal<Vec<(usize, (RcSignal<String>, RcSignal<String>))>>,
|
debug!("Making extras rows");
|
||||||
) -> View<G> {
|
let extras_read_signal = sh.get_selector(cx, |state| {
|
||||||
let extras_read_signal = create_memo(cx, {
|
state.get().extras.iter().cloned().enumerate().collect()
|
||||||
let extras = extras.clone();
|
|
||||||
move || extras.get().as_ref().clone()
|
|
||||||
});
|
});
|
||||||
view! {cx,
|
view! {cx,
|
||||||
Indexed(
|
Indexed(
|
||||||
iterable=extras_read_signal,
|
iterable=extras_read_signal,
|
||||||
view= move |cx, (idx, (amt, name))| {
|
view= move |cx, (idx, (amt, name))| {
|
||||||
view! {cx,
|
let amt_signal = create_signal(cx, amt.clone());
|
||||||
tr {
|
let name_signal = create_signal(cx, name.clone());
|
||||||
td {
|
view! {cx,
|
||||||
input(bind:value=amt, type="text")
|
tr {
|
||||||
}
|
td {
|
||||||
td {
|
input(bind:value=amt_signal, type="text", on:change=move |_| {
|
||||||
input(type="button", class="no-print destructive", value="X", on:click={
|
sh.dispatch(cx, Message::UpdateExtra(idx,
|
||||||
let extras = extras.clone();
|
amt_signal.get_untracked().as_ref().clone(),
|
||||||
move |_| {
|
name_signal.get_untracked().as_ref().clone()));
|
||||||
extras.set(extras.get().iter()
|
})
|
||||||
.filter(|(i, _)| *i != idx)
|
|
||||||
.map(|(_, v)| v.clone())
|
|
||||||
.enumerate()
|
|
||||||
.collect())
|
|
||||||
}})
|
|
||||||
}
|
|
||||||
td {
|
|
||||||
input(bind:value=name, type="text")
|
|
||||||
}
|
|
||||||
td { "Misc" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
td {
|
||||||
|
input(type="button", class="no-print destructive", value="X", on:click=move |_| {
|
||||||
|
sh.dispatch(cx, Message::RemoveExtra(idx));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
input(bind:value=name_signal, type="text", on:change=move |_| {
|
||||||
|
sh.dispatch(cx, Message::UpdateExtra(idx,
|
||||||
|
amt_signal.get_untracked().as_ref().clone(),
|
||||||
|
name_signal.get_untracked().as_ref().clone()));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
td { "Misc" }
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_shopping_table<'ctx, G: Html>(
|
fn make_shopping_table<'ctx, G: Html>(
|
||||||
cx: Scope<'ctx>,
|
cx: Scope<'ctx>,
|
||||||
ingredients: &'ctx ReadSignal<Vec<(IngredientKey, (Ingredient, BTreeSet<String>))>>,
|
sh: StateHandler<'ctx>,
|
||||||
modified_amts: RcSignal<BTreeMap<IngredientKey, RcSignal<String>>>,
|
show_staples: &'ctx ReadSignal<bool>,
|
||||||
extras: RcSignal<Vec<(usize, (RcSignal<String>, RcSignal<String>))>>,
|
|
||||||
filtered_keys: RcSignal<BTreeSet<IngredientKey>>,
|
|
||||||
) -> View<G> {
|
) -> View<G> {
|
||||||
let extra_rows_view = make_extras_rows(cx, extras);
|
debug!("Making shopping table");
|
||||||
let ingredient_rows =
|
|
||||||
make_ingredients_rows(cx, ingredients, modified_amts, filtered_keys.clone());
|
|
||||||
view! {cx,
|
view! {cx,
|
||||||
table(class="pad-top shopping-list page-breaker container-fluid", role="grid") {
|
table(class="pad-top shopping-list page-breaker container-fluid", role="grid") {
|
||||||
tr {
|
tr {
|
||||||
@ -127,103 +176,33 @@ fn make_shopping_table<'ctx, G: Html>(
|
|||||||
th { " Recipes " }
|
th { " Recipes " }
|
||||||
}
|
}
|
||||||
tbody {
|
tbody {
|
||||||
(ingredient_rows)
|
(make_ingredients_rows(cx, sh, show_staples))
|
||||||
(extra_rows_view)
|
(make_extras_rows(cx, sh))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument]
|
#[instrument(skip_all)]
|
||||||
#[component]
|
#[component]
|
||||||
pub fn ShoppingList<G: Html>(cx: Scope) -> View<G> {
|
pub fn ShoppingList<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
|
||||||
let state = crate::app_state::State::get_from_context(cx);
|
|
||||||
// FIXME(jwall): We need to init this state for the page at some point.
|
|
||||||
let filtered_keys: RcSignal<BTreeSet<IngredientKey>> = state.filtered_ingredients.clone();
|
|
||||||
let ingredients_map = create_rc_signal(BTreeMap::new());
|
|
||||||
let show_staples = create_signal(cx, true);
|
let show_staples = create_signal(cx, true);
|
||||||
let save_click = create_signal(cx, ());
|
|
||||||
create_effect(cx, {
|
|
||||||
let state = crate::app_state::State::get_from_context(cx);
|
|
||||||
let ingredients_map = ingredients_map.clone();
|
|
||||||
move || {
|
|
||||||
ingredients_map.set(state.get_shopping_list(*show_staples.get()));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
debug!(ingredients_map=?ingredients_map.get_untracked());
|
|
||||||
let ingredients = create_memo(cx, {
|
|
||||||
let filtered_keys = filtered_keys.clone();
|
|
||||||
let ingredients_map = ingredients_map.clone();
|
|
||||||
move || {
|
|
||||||
let mut ingredients = Vec::new();
|
|
||||||
// This has the effect of sorting the ingredients by category
|
|
||||||
for (_, ingredients_list) in ingredients_map.get().iter() {
|
|
||||||
for (i, recipes) in ingredients_list.iter() {
|
|
||||||
if !filtered_keys.get().contains(&i.key()) {
|
|
||||||
ingredients.push((i.key(), (i.clone(), recipes.clone())));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ingredients
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let table_view = create_signal(cx, View::empty());
|
|
||||||
create_effect(cx, {
|
|
||||||
let filtered_keys = filtered_keys.clone();
|
|
||||||
let state = crate::app_state::State::get_from_context(cx);
|
|
||||||
move || {
|
|
||||||
if (ingredients.get().len() > 0) || (state.extras.get().len() > 0) {
|
|
||||||
table_view.set(make_shopping_table(
|
|
||||||
cx,
|
|
||||||
ingredients,
|
|
||||||
state.modified_amts.clone(),
|
|
||||||
state.extras.clone(),
|
|
||||||
filtered_keys.clone(),
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
table_view.set(View::empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
create_effect(cx, move || {
|
|
||||||
save_click.track();
|
|
||||||
info!("Registering save request for inventory");
|
|
||||||
spawn_local_scoped(cx, {
|
|
||||||
let state = crate::app_state::State::get_from_context(cx);
|
|
||||||
let store = crate::api::HttpStore::get_from_context(cx);
|
|
||||||
async move {
|
|
||||||
debug!(?state, "Attempting save for inventory");
|
|
||||||
store
|
|
||||||
.save_state(state)
|
|
||||||
.await
|
|
||||||
.expect("Unable to save inventory data");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
let state = crate::app_state::State::get_from_context(cx);
|
|
||||||
view! {cx,
|
view! {cx,
|
||||||
h1 { "Shopping List " }
|
h1 { "Shopping List " }
|
||||||
label(for="show_staples_cb") { "Show staples" }
|
label(for="show_staples_cb") { "Show staples" }
|
||||||
input(id="show_staples_cb", type="checkbox", bind:checked=show_staples)
|
input(id="show_staples_cb", type="checkbox", bind:checked=show_staples)
|
||||||
(table_view.get().as_ref().clone())
|
(make_shopping_table(cx, sh, show_staples))
|
||||||
input(type="button", value="Add Item", class="no-print", on:click=move |_| {
|
input(type="button", value="Add Item", class="no-print", on:click=move |_| {
|
||||||
let mut cloned_extras: Vec<(RcSignal<String>, RcSignal<String>)> = (*state.extras.get()).iter().map(|(_, tpl)| tpl.clone()).collect();
|
info!("Registering add item request for inventory");
|
||||||
cloned_extras.push((create_rc_signal("".to_owned()), create_rc_signal("".to_owned())));
|
sh.dispatch(cx, Message::AddExtra(String::new(), String::new()));
|
||||||
state.extras.set(cloned_extras.drain(0..).enumerate().collect());
|
|
||||||
})
|
})
|
||||||
input(type="button", value="Reset", class="no-print", on:click={
|
input(type="button", value="Reset", class="no-print", on:click=move |_| {
|
||||||
let state = crate::app_state::State::get_from_context(cx);
|
info!("Registering reset request for inventory");
|
||||||
move |_| {
|
sh.dispatch(cx, Message::ResetInventory);
|
||||||
// TODO(jwall): We should actually pop up a modal here or use a different set of items.
|
|
||||||
ingredients_map.set(state.get_shopping_list(*show_staples.get()));
|
|
||||||
// clear the filter_signal
|
|
||||||
filtered_keys.set(BTreeSet::new());
|
|
||||||
state.modified_amts.set(BTreeMap::new());
|
|
||||||
state.extras.set(Vec::new());
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
input(type="button", value="Save", class="no-print", on:click=|_| {
|
input(type="button", value="Save", class="no-print", on:click=move |_| {
|
||||||
save_click.trigger_subscribers();
|
info!("Registering save request for inventory");
|
||||||
|
sh.dispatch(cx, Message::SaveState(None));
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -43,7 +43,6 @@ pub fn TabbedView<'a, G: Html>(cx: Scope<'a>, state: TabState<'a, G>) -> View<G>
|
|||||||
view! {cx,
|
view! {cx,
|
||||||
li(class=class) { a(href=href) { (show) } }
|
li(class=class) { a(href=href) { (show) } }
|
||||||
}
|
}
|
||||||
// TODO
|
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
);
|
);
|
||||||
|
@ -43,12 +43,3 @@ pub fn get_storage() -> Storage {
|
|||||||
.expect("Failed to get storage")
|
.expect("Failed to get storage")
|
||||||
.expect("No storage available")
|
.expect("No storage available")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_storage_keys() -> Vec<String> {
|
|
||||||
let storage = get_storage();
|
|
||||||
let mut keys = Vec::new();
|
|
||||||
for idx in 0..storage.length().unwrap() {
|
|
||||||
keys.push(get_storage().key(idx).unwrap().unwrap())
|
|
||||||
}
|
|
||||||
keys
|
|
||||||
}
|
|
||||||
|
@ -11,28 +11,16 @@
|
|||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
use sycamore::{futures::spawn_local_scoped, prelude::*};
|
use sycamore::futures::spawn_local_scoped;
|
||||||
|
use sycamore::prelude::*;
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
use crate::app_state;
|
use crate::app_state::{Message, StateHandler};
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn LoginForm<G: Html>(cx: Scope) -> View<G> {
|
pub fn LoginForm<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
|
||||||
let username = create_signal(cx, "".to_owned());
|
let username = create_signal(cx, "".to_owned());
|
||||||
let password = create_signal(cx, "".to_owned());
|
let password = create_signal(cx, "".to_owned());
|
||||||
let clicked = create_signal(cx, ("".to_owned(), "".to_owned()));
|
|
||||||
create_effect(cx, move || {
|
|
||||||
let (username, password) = (*clicked.get()).clone();
|
|
||||||
if username != "" && password != "" {
|
|
||||||
spawn_local_scoped(cx, async move {
|
|
||||||
let state = app_state::State::get_from_context(cx);
|
|
||||||
let store = crate::api::HttpStore::get_from_context(cx);
|
|
||||||
debug!("authenticating against ui");
|
|
||||||
// TODO(jwall): Navigate to plan if the below is successful.
|
|
||||||
state.auth.set(store.authenticate(username, password).await);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
view! {cx,
|
view! {cx,
|
||||||
form() {
|
form() {
|
||||||
label(for="username") { "Username" }
|
label(for="username") { "Username" }
|
||||||
@ -41,17 +29,26 @@ pub fn LoginForm<G: Html>(cx: Scope) -> View<G> {
|
|||||||
input(type="password", bind:value=password)
|
input(type="password", bind:value=password)
|
||||||
input(type="button", value="Login", on:click=move |_| {
|
input(type="button", value="Login", on:click=move |_| {
|
||||||
info!("Attempting login request");
|
info!("Attempting login request");
|
||||||
clicked.set(((*username.get_untracked()).clone(), (*password.get_untracked()).clone()));
|
let (username, password) = ((*username.get_untracked()).clone(), (*password.get_untracked()).clone());
|
||||||
|
if username != "" && password != "" {
|
||||||
|
spawn_local_scoped(cx, async move {
|
||||||
|
let store = crate::api::HttpStore::get_from_context(cx);
|
||||||
|
debug!("authenticating against ui");
|
||||||
|
if let Some(user_data) = store.authenticate(username, password).await {
|
||||||
|
sh.dispatch(cx, Message::SetUserData(user_data));
|
||||||
|
sh.dispatch(cx, Message::LoadState(Some(Box::new(|| sycamore_router::navigate("/ui/planning/plan")))));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
debug!("triggering login click subscribers");
|
debug!("triggering login click subscribers");
|
||||||
clicked.trigger_subscribers();
|
|
||||||
}) { }
|
}) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn LoginPage<G: Html>(cx: Scope) -> View<G> {
|
pub fn LoginPage<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
|
||||||
view! {cx,
|
view! {cx,
|
||||||
LoginForm()
|
LoginForm(sh)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,15 +12,15 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
use super::ManagePage;
|
use super::ManagePage;
|
||||||
use crate::components::add_recipe::AddRecipe;
|
use crate::{app_state::StateHandler, components::add_recipe::AddRecipe};
|
||||||
|
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn AddRecipePage<G: Html>(cx: Scope) -> View<G> {
|
pub fn AddRecipePage<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
|
||||||
view! {cx,
|
view! {cx,
|
||||||
ManagePage(
|
ManagePage(
|
||||||
selected=Some("New Recipe".to_owned()),
|
selected=Some("New Recipe".to_owned()),
|
||||||
) { AddRecipe() }
|
) { AddRecipe(sh) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,15 +12,15 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
use super::ManagePage;
|
use super::ManagePage;
|
||||||
use crate::components::categories::*;
|
use crate::{app_state::StateHandler, components::categories::*};
|
||||||
|
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
#[component()]
|
#[component()]
|
||||||
pub fn CategoryPage<G: Html>(cx: Scope) -> View<G> {
|
pub fn CategoryPage<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
|
||||||
view! {cx,
|
view! {cx,
|
||||||
ManagePage(
|
ManagePage(
|
||||||
selected=Some("Categories".to_owned()),
|
selected=Some("Categories".to_owned()),
|
||||||
) { Categories() }
|
) { Categories(sh) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,17 +12,17 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
use super::ManagePage;
|
use super::ManagePage;
|
||||||
use crate::components::recipe::Editor;
|
use crate::{app_state::StateHandler, components::recipe::Editor};
|
||||||
|
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
|
|
||||||
#[instrument]
|
#[instrument(skip_all)]
|
||||||
#[component()]
|
#[component()]
|
||||||
pub fn StaplesPage<G: Html>(cx: Scope) -> View<G> {
|
pub fn StaplesPage<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
|
||||||
view! {cx,
|
view! {cx,
|
||||||
ManagePage(
|
ManagePage(
|
||||||
selected=Some("Staples".to_owned()),
|
selected=Some("Staples".to_owned()),
|
||||||
) { Editor("staples.txt".to_owned()) }
|
) { Editor(recipe_id="staples.txt".to_owned(), sh=sh) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,13 +14,13 @@
|
|||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
use super::PlanningPage;
|
use super::PlanningPage;
|
||||||
use crate::components::recipe_list::*;
|
use crate::{app_state::StateHandler, components::recipe_list::*};
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn CookPage<G: Html>(cx: Scope) -> View<G> {
|
pub fn CookPage<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
|
||||||
view! {cx,
|
view! {cx,
|
||||||
PlanningPage(
|
PlanningPage(
|
||||||
selected=Some("Cook".to_owned()),
|
selected=Some("Cook".to_owned()),
|
||||||
) { RecipeList() }
|
) { RecipeList(sh) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,13 +14,13 @@
|
|||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
use super::PlanningPage;
|
use super::PlanningPage;
|
||||||
use crate::components::shopping_list::*;
|
use crate::{app_state::StateHandler, components::shopping_list::*};
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn InventoryPage<G: Html>(cx: Scope) -> View<G> {
|
pub fn InventoryPage<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
|
||||||
view! {cx,
|
view! {cx,
|
||||||
PlanningPage(
|
PlanningPage(
|
||||||
selected=Some("Inventory".to_owned()),
|
selected=Some("Inventory".to_owned()),
|
||||||
) { ShoppingList() }
|
) { ShoppingList(sh) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,15 +12,15 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
use super::PlanningPage;
|
use super::PlanningPage;
|
||||||
use crate::components::recipe_plan::*;
|
use crate::{app_state::StateHandler, components::recipe_plan::*};
|
||||||
|
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn PlanPage<G: Html>(cx: Scope) -> View<G> {
|
pub fn PlanPage<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
|
||||||
view! {cx,
|
view! {cx,
|
||||||
PlanningPage(
|
PlanningPage(
|
||||||
selected=Some("Plan".to_owned()),
|
selected=Some("Plan".to_owned()),
|
||||||
) { RecipePlan() }
|
) { RecipePlan(sh) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -17,13 +17,14 @@ use crate::components::recipe::Editor;
|
|||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
|
|
||||||
#[instrument]
|
#[instrument(skip_all, fields(recipe=props.recipe))]
|
||||||
#[component()]
|
#[component()]
|
||||||
pub fn RecipeEditPage<G: Html>(cx: Scope, props: RecipePageProps) -> View<G> {
|
pub fn RecipeEditPage<'ctx, G: Html>(cx: Scope<'ctx>, props: RecipePageProps<'ctx>) -> View<G> {
|
||||||
|
let RecipePageProps { recipe, sh } = props;
|
||||||
view! {cx,
|
view! {cx,
|
||||||
RecipePage(
|
RecipePage(
|
||||||
selected=Some("Edit".to_owned()),
|
selected=Some("Edit".to_owned()),
|
||||||
recipe=props.recipe.clone(),
|
recipe=recipe.clone(),
|
||||||
) { Editor(props.recipe) }
|
) { Editor(recipe_id=recipe, sh=sh) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,16 +13,17 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
use crate::components::tabs::*;
|
use crate::{app_state::StateHandler, components::tabs::*};
|
||||||
|
|
||||||
mod edit;
|
mod edit;
|
||||||
mod view;
|
mod view;
|
||||||
pub use edit::*;
|
pub use edit::*;
|
||||||
pub use view::*;
|
pub use view::*;
|
||||||
|
|
||||||
#[derive(Debug, Props)]
|
#[derive(Props)]
|
||||||
pub struct RecipePageProps {
|
pub struct RecipePageProps<'ctx> {
|
||||||
pub recipe: String,
|
pub recipe: String,
|
||||||
|
pub sh: StateHandler<'ctx>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Props)]
|
#[derive(Props)]
|
||||||
|
@ -18,13 +18,14 @@ use tracing::instrument;
|
|||||||
|
|
||||||
use super::{RecipePage, RecipePageProps};
|
use super::{RecipePage, RecipePageProps};
|
||||||
|
|
||||||
#[instrument]
|
#[instrument(skip_all, fields(recipe=props.recipe))]
|
||||||
#[component()]
|
#[component()]
|
||||||
pub fn RecipeViewPage<G: Html>(cx: Scope, props: RecipePageProps) -> View<G> {
|
pub fn RecipeViewPage<'ctx, G: Html>(cx: Scope<'ctx>, props: RecipePageProps<'ctx>) -> View<G> {
|
||||||
|
let RecipePageProps { recipe, sh } = props;
|
||||||
view! {cx,
|
view! {cx,
|
||||||
RecipePage(
|
RecipePage(
|
||||||
selected=Some("View".to_owned()),
|
selected=Some("View".to_owned()),
|
||||||
recipe=props.recipe.clone(),
|
recipe=recipe.clone(),
|
||||||
) { Viewer(props.recipe) }
|
) { Viewer(recipe_id=recipe, sh=sh) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,63 +12,15 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app_state::StateHandler,
|
||||||
|
components::{Footer, Header},
|
||||||
|
pages::*,
|
||||||
|
};
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
use sycamore_router::{HistoryIntegration, Route, Router};
|
use sycamore_router::{HistoryIntegration, Route, Router};
|
||||||
use tracing::{debug, instrument};
|
use tracing::{debug, instrument};
|
||||||
|
|
||||||
use crate::pages::*;
|
|
||||||
|
|
||||||
#[instrument]
|
|
||||||
fn route_switch<'a, G: Html>(cx: Scope<'a>, route: &'a ReadSignal<Routes>) -> View<G> {
|
|
||||||
// NOTE(jwall): This needs to not be a dynamic node. The rules around
|
|
||||||
// this are somewhat unclear and underdocumented for Sycamore. But basically
|
|
||||||
// avoid conditionals in the `view!` macro calls here.
|
|
||||||
|
|
||||||
let switcher = |cx: Scope, route: &Routes| {
|
|
||||||
debug!(?route, "Dispatching for route");
|
|
||||||
match route {
|
|
||||||
Routes::Planning(Plan) => view! {cx,
|
|
||||||
PlanPage()
|
|
||||||
},
|
|
||||||
Routes::Planning(Inventory) => view! {cx,
|
|
||||||
InventoryPage()
|
|
||||||
},
|
|
||||||
Routes::Planning(Cook) => view! {cx,
|
|
||||||
CookPage()
|
|
||||||
},
|
|
||||||
Routes::Login => view! {cx,
|
|
||||||
LoginPage()
|
|
||||||
},
|
|
||||||
Routes::Recipe(RecipeRoutes::View(id)) => view! {cx,
|
|
||||||
RecipeViewPage(recipe=id.clone())
|
|
||||||
},
|
|
||||||
Routes::Recipe(RecipeRoutes::Edit(id)) => view! {cx,
|
|
||||||
RecipeEditPage(recipe=id.clone())
|
|
||||||
},
|
|
||||||
Routes::Manage(ManageRoutes::Categories) => view! {cx,
|
|
||||||
CategoryPage()
|
|
||||||
},
|
|
||||||
Routes::Manage(ManageRoutes::NewRecipe) => view! {cx,
|
|
||||||
AddRecipePage()
|
|
||||||
},
|
|
||||||
Routes::Manage(ManageRoutes::Staples) => view! {cx,
|
|
||||||
StaplesPage()
|
|
||||||
},
|
|
||||||
Routes::NotFound
|
|
||||||
| Routes::Manage(ManageRoutes::NotFound)
|
|
||||||
| Routes::Planning(PlanningRoutes::NotFound)
|
|
||||||
| Routes::Recipe(RecipeRoutes::NotFound) => view! {cx,
|
|
||||||
// TODO(Create a real one)
|
|
||||||
PlanPage()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
};
|
|
||||||
use PlanningRoutes::*;
|
|
||||||
view! {cx,
|
|
||||||
(switcher(cx, route.get().as_ref()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Route, Debug)]
|
#[derive(Route, Debug)]
|
||||||
pub enum Routes {
|
pub enum Routes {
|
||||||
#[to("/ui/planning/<_..>")]
|
#[to("/ui/planning/<_..>")]
|
||||||
@ -117,12 +69,69 @@ pub enum PlanningRoutes {
|
|||||||
NotFound,
|
NotFound,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Props)]
|
||||||
|
pub struct HandlerProps<'ctx> {
|
||||||
|
sh: StateHandler<'ctx>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip_all, fields(?route))]
|
||||||
|
fn route_switch<'ctx, G: Html>(route: &Routes, cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View<G> {
|
||||||
|
debug!("Handling route change");
|
||||||
|
use ManageRoutes::*;
|
||||||
|
use PlanningRoutes::*;
|
||||||
|
match route {
|
||||||
|
Routes::Planning(Plan) => view! {cx,
|
||||||
|
PlanPage(sh)
|
||||||
|
},
|
||||||
|
Routes::Planning(Inventory) => view! {cx,
|
||||||
|
InventoryPage(sh)
|
||||||
|
},
|
||||||
|
Routes::Planning(Cook) => view! {cx,
|
||||||
|
CookPage(sh)
|
||||||
|
},
|
||||||
|
Routes::Login => view! {cx,
|
||||||
|
LoginPage(sh)
|
||||||
|
},
|
||||||
|
Routes::Recipe(RecipeRoutes::View(id)) => view! {cx,
|
||||||
|
RecipeViewPage(recipe=id.clone(), sh=sh)
|
||||||
|
},
|
||||||
|
Routes::Recipe(RecipeRoutes::Edit(id)) => view! {cx,
|
||||||
|
RecipeEditPage(recipe=id.clone(), sh=sh)
|
||||||
|
},
|
||||||
|
Routes::Manage(Categories) => view! {cx,
|
||||||
|
CategoryPage(sh)
|
||||||
|
},
|
||||||
|
Routes::Manage(NewRecipe) => view! {cx,
|
||||||
|
AddRecipePage(sh)
|
||||||
|
},
|
||||||
|
Routes::Manage(Staples) => view! {cx,
|
||||||
|
StaplesPage(sh)
|
||||||
|
},
|
||||||
|
Routes::NotFound
|
||||||
|
| Routes::Manage(ManageRoutes::NotFound)
|
||||||
|
| Routes::Planning(PlanningRoutes::NotFound)
|
||||||
|
| Routes::Recipe(RecipeRoutes::NotFound) => view! {cx,
|
||||||
|
// TODO(Create a real one)
|
||||||
|
PlanPage(sh)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Handler<G: Html>(cx: Scope) -> View<G> {
|
pub fn Handler<'ctx, G: Html>(cx: Scope<'ctx>, props: HandlerProps<'ctx>) -> View<G> {
|
||||||
|
let HandlerProps { sh } = props;
|
||||||
view! {cx,
|
view! {cx,
|
||||||
Router(
|
Router(
|
||||||
integration=HistoryIntegration::new(),
|
integration=HistoryIntegration::new(),
|
||||||
view=route_switch,
|
view=move |cx: Scope, route: &ReadSignal<Routes>| {
|
||||||
|
view!{cx,
|
||||||
|
div(class="app") {
|
||||||
|
Header(sh)
|
||||||
|
(route_switch(route.get().as_ref(), cx, sh))
|
||||||
|
Footer { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,35 +12,25 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
use sycamore::{futures::spawn_local_scoped, prelude::*};
|
use sycamore::{futures::spawn_local_scoped, prelude::*};
|
||||||
use tracing::{error, info, instrument};
|
use tracing::{info, instrument};
|
||||||
|
|
||||||
use crate::components::{Footer, Header};
|
use crate::app_state::Message;
|
||||||
use crate::{api, routing::Handler as RouteHandler};
|
use crate::{api, routing::Handler as RouteHandler};
|
||||||
|
|
||||||
#[instrument]
|
#[instrument]
|
||||||
#[component]
|
#[component]
|
||||||
pub fn UI<G: Html>(cx: Scope) -> View<G> {
|
pub fn UI<G: Html>(cx: Scope) -> View<G> {
|
||||||
crate::app_state::State::provide_context(cx);
|
|
||||||
api::HttpStore::provide_context(cx, "/api".to_owned());
|
api::HttpStore::provide_context(cx, "/api".to_owned());
|
||||||
|
let store = api::HttpStore::get_from_context(cx).as_ref().clone();
|
||||||
info!("Starting UI");
|
info!("Starting UI");
|
||||||
|
let app_state = crate::app_state::AppState::new();
|
||||||
|
let sh = crate::app_state::get_state_handler(cx, app_state, store);
|
||||||
let view = create_signal(cx, View::empty());
|
let view = create_signal(cx, View::empty());
|
||||||
// FIXME(jwall): We need a way to trigger refreshes when required. Turn this
|
|
||||||
// into a create_effect with a refresh signal stored as a context.
|
|
||||||
spawn_local_scoped(cx, {
|
spawn_local_scoped(cx, {
|
||||||
let store = api::HttpStore::get_from_context(cx);
|
|
||||||
let state = crate::app_state::State::get_from_context(cx);
|
|
||||||
async move {
|
async move {
|
||||||
if let Err(err) = api::init_page_state(store.as_ref(), state.as_ref()).await {
|
sh.dispatch(cx, Message::LoadState(None));
|
||||||
error!(?err);
|
|
||||||
};
|
|
||||||
// TODO(jwall): This needs to be moved into the RouteHandler
|
|
||||||
view.set(view! { cx,
|
view.set(view! { cx,
|
||||||
div(class="app") {
|
RouteHandler(sh=sh)
|
||||||
Header { }
|
|
||||||
RouteHandler()
|
|
||||||
Footer { }
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user