// Copyright 2022 Jeremy Wall // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use sycamore::{futures::spawn_local_scoped, prelude::*}; use tracing::{debug, error}; use crate::app_state::{self, Message, StateHandler}; use recipes::{self, RecipeEntry}; fn check_recipe_parses( text: &str, error_text: &Signal, aria_hint: &Signal<&'static str>, ) -> bool { if let Err(e) = recipes::parse::as_recipe(text) { error!(?e, "Error parsing recipe"); error_text.set(e); aria_hint.set("true"); false } else { error_text.set(String::from("No parse errors...")); aria_hint.set("false"); true } } #[derive(Props)] pub struct RecipeComponentProps<'ctx> { recipe_id: String, sh: StateHandler<'ctx>, } #[component] pub fn Editor<'ctx, G: Html>(cx: Scope<'ctx>, props: RecipeComponentProps<'ctx>) -> View { let RecipeComponentProps { recipe_id, sh } = props; let store = crate::api::HttpStore::get_from_context(cx); let recipe: &Signal = create_signal(cx, RecipeEntry::new(&recipe_id, String::new())); let text = create_signal(cx, String::new()); let error_text = create_signal(cx, String::from("Parse results...")); let aria_hint = create_signal(cx, "false"); spawn_local_scoped(cx, { let store = store.clone(); async move { let entry = store .get_recipe_text(recipe_id.as_str()) .await .expect("Failure getting recipe"); if let Some(entry) = entry { text.set(entry.recipe_text().to_owned()); recipe.set(entry); } else { error_text.set("Unable to find recipe".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); 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); 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(Message::SetRecipe( id.get_untracked().as_ref().to_owned(), recipe, )); } }; } }); }); debug!("creating editor view"); view! {cx, div(class="grid") { textarea(bind:value=text, aria-invalid=aria_hint.get(), rows=20, on:change=move |_| { dirty.set(true); }) div(class="parse") { (error_text.get()) } } span(role="button", on:click=move |_| { let unparsed = text.get(); check_recipe_parses(unparsed.as_str(), error_text, aria_hint); }) { "Check" } " " span(role="button", on:click=move |_| { let unparsed = text.get(); if check_recipe_parses(unparsed.as_str(), error_text, aria_hint) { debug!("triggering a save"); save_signal.trigger_subscribers(); } else { } }) { "Save" } } } #[component] fn Steps(cx: Scope, steps: Vec) -> View { let step_fragments = View::new_fragment(steps.iter().map(|step| { let mut step = step.clone(); let ingredient_fragments = View::new_fragment(step.ingredients.drain(0..).map(|i| { view! {cx, li { (i.amt) " " (i.name) " " (i.form.as_ref().map(|f| format!("({})", f)).unwrap_or(String::new())) } } }).collect()); view! {cx, div { h3 { "Instructions" } ul(class="ingredients") { (ingredient_fragments) } div(class="instructions") { (step.instructions) } } } }).collect()); view! {cx, h2 { "Steps: " } div(class="recipe_steps") { (step_fragments) } } } #[component] pub fn Viewer<'ctx, G: Html>(cx: Scope<'ctx>, props: RecipeComponentProps<'ctx>) -> View { let RecipeComponentProps { recipe_id, sh } = props; let state = app_state::State::get_from_context(cx); let view = create_signal(cx, View::empty()); let recipe_signal = sh.get_selector(cx, |state| { if let Some(recipe) = state.get().recipes.get(&recipe_id) { let title = recipe.title.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."); view.set(view! {cx, div(class="recipe") { h1(class="recipe_title") { (title) } div(class="recipe_description") { (desc) } Steps(steps) } }); } view! {cx, (view.get().as_ref()) } }