mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 10:13:32 -04:00 
			
		
		
		
	Basic nutrition editor (#288)
* Basic nutrition editor * fix no image on scrape * nutrition display * add recipe images * update by url * new upload options Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
		| @@ -21,3 +21,4 @@ | ||||
| - Unify Logger across the backend | ||||
| - mealie.log and last_recipe.json are now downloadable from the frontend from the /admin/about | ||||
| - New download schema where you request a token and then use that token to hit a single endpoint to download a file. This is a notable change if you are using the API to download backups.  | ||||
| - Recipe images can no be added directly from a URL - [See #177 for details](https://github.com/hay-kot/mealie/issues/117) | ||||
| @@ -61,6 +61,11 @@ export const recipeAPI = { | ||||
|     return response; | ||||
|   }, | ||||
|  | ||||
|   async updateImagebyURL(slug, url) { | ||||
|     const response = apiReq.post(recipeURLs.updateImage(slug), { url: url }); | ||||
|     return response; | ||||
|   }, | ||||
|  | ||||
|   async update(data) { | ||||
|     let response = await apiReq.put(recipeURLs.update(data.slug), data); | ||||
|     store.dispatch("requestRecentRecipes"); | ||||
|   | ||||
| @@ -0,0 +1,76 @@ | ||||
| <template> | ||||
|   <div class="text-center"> | ||||
|     <v-menu offset-y top nudge-top="6" :close-on-content-click="false"> | ||||
|       <template v-slot:activator="{ on, attrs }"> | ||||
|         <v-btn color="accent" dark v-bind="attrs" v-on="on"> | ||||
|           Image | ||||
|         </v-btn> | ||||
|       </template> | ||||
|       <v-card width="400"> | ||||
|         <v-card-title class="headline flex mb-0"> | ||||
|           <div> | ||||
|             Recipe Image | ||||
|           </div> | ||||
|           <UploadBtn | ||||
|             class="ml-auto" | ||||
|             url="none" | ||||
|             file-name="image" | ||||
|             :text-btn="false" | ||||
|             @uploaded="uploadImage" | ||||
|           /> | ||||
|         </v-card-title> | ||||
|         <v-card-text class="mt-n5"> | ||||
|           <div> | ||||
|             <v-text-field label="URL" class="pt-5" clearable v-model="url"> | ||||
|               <template v-slot:append-outer> | ||||
|                 <v-btn | ||||
|                   class="ml-2" | ||||
|                   color="primary" | ||||
|                   @click="getImageFromURL" | ||||
|                   :loading="loading" | ||||
|                 > | ||||
|                   Get | ||||
|                 </v-btn> | ||||
|               </template> | ||||
|             </v-text-field> | ||||
|           </div> | ||||
|         </v-card-text> | ||||
|       </v-card> | ||||
|     </v-menu> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| const REFRESH_EVENT = "refresh"; | ||||
| const UPLOAD_EVENT = "upload"; | ||||
| import UploadBtn from "@/components/UI/UploadBtn"; | ||||
| import { api } from "@/api"; | ||||
| // import axios from "axios"; | ||||
| export default { | ||||
|   components: { | ||||
|     UploadBtn, | ||||
|   }, | ||||
|   props: { | ||||
|     slug: String, | ||||
|   }, | ||||
|   data: () => ({ | ||||
|     items: [{ title: "Upload Image" }, { title: "From URL" }], | ||||
|     url: "", | ||||
|     loading: false, | ||||
|   }), | ||||
|   methods: { | ||||
|     uploadImage(fileObject) { | ||||
|       this.$emit(UPLOAD_EVENT, fileObject); | ||||
|     }, | ||||
|     async getImageFromURL() { | ||||
|       this.loading = true; | ||||
|       const response = await api.recipes.updateImagebyURL(this.slug, this.url); | ||||
|       if (response) this.$emit(REFRESH_EVENT); | ||||
|       this.loading = false; | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| </style> | ||||
| @@ -0,0 +1,81 @@ | ||||
| <template> | ||||
|   <div v-if="valueNotNull || edit"> | ||||
|     <h2 class="my-4">Nutrition</h2> | ||||
|     <div v-if="edit"> | ||||
|       <div v-for="(item, key, index) in value" :key="index"> | ||||
|         <v-text-field | ||||
|           dense | ||||
|           :value="value[key]" | ||||
|           :label="labels[key].label" | ||||
|           :suffix="labels[key].suffix" | ||||
|           type="number" | ||||
|           autocomplete="off" | ||||
|           @input="updateValue(key, $event)" | ||||
|         ></v-text-field> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div v-if="showViewer"> | ||||
|       <v-list dense> | ||||
|         <v-list-item-group color="primary"> | ||||
|           <v-list-item v-for="(item, key, index) in labels" :key="index"> | ||||
|             <v-list-item-content> | ||||
|               <v-list-item-title class="pl-4 text-subtitle-1 flex row "> | ||||
|                 <div>{{ item.label }}</div> | ||||
|                 <div class="ml-auto mr-1">{{ value[key] }}</div> | ||||
|                 <div>{{ item.suffix }}</div> | ||||
|               </v-list-item-title> | ||||
|             </v-list-item-content> | ||||
|           </v-list-item> | ||||
|         </v-list-item-group> | ||||
|       </v-list> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     value: {}, | ||||
|     edit: { | ||||
|       type: Boolean, | ||||
|       default: true, | ||||
|     }, | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       labels: { | ||||
|         calories: { | ||||
|           label: "Calories", | ||||
|           suffix: "calories", | ||||
|         }, | ||||
|         fatContent: { label: "Fat Content", suffix: "grams" }, | ||||
|         fiberContent: { label: "Fiber Content", suffix: "grams" }, | ||||
|         proteinContent: { label: "Protein Content", suffix: "grams" }, | ||||
|         sodiumContent: { label: "Sodium Content", suffix: "milligrams" }, | ||||
|         sugarContent: { label: "Sugar Content", suffix: "grams" }, | ||||
|       }, | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     showViewer() { | ||||
|       return !this.edit && this.valueNotNull; | ||||
|     }, | ||||
|     valueNotNull() { | ||||
|       for (const property in this.value) { | ||||
|         const valueProperty = this.value[property]; | ||||
|         if (valueProperty && valueProperty !== "") return true; | ||||
|       } | ||||
|       return false; | ||||
|     }, | ||||
|   }, | ||||
|  | ||||
|   methods: { | ||||
|     updateValue(key, value) { | ||||
|       this.$emit("input", { ...this.value, [key]: value }); | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| </style> | ||||
| @@ -2,16 +2,12 @@ | ||||
|   <v-form ref="form"> | ||||
|     <v-card-text> | ||||
|       <v-row dense> | ||||
|         <v-col cols="3"></v-col> | ||||
|         <v-col> | ||||
|           <v-file-input | ||||
|             v-model="fileObject" | ||||
|             :label="$t('general.image-file')" | ||||
|             truncate-length="30" | ||||
|             @change="uploadImage" | ||||
|           ></v-file-input> | ||||
|         </v-col> | ||||
|         <v-col cols="3"></v-col> | ||||
|         <ImageUploadBtn | ||||
|           class="mt-2" | ||||
|           @upload="uploadImage" | ||||
|           :slug="value.slug" | ||||
|           @refresh="$emit('upload')" | ||||
|         /> | ||||
|       </v-row> | ||||
|       <v-row dense> | ||||
|         <v-col> | ||||
| @@ -92,7 +88,7 @@ | ||||
|                     auto-grow | ||||
|                     solo | ||||
|                     dense | ||||
|                     rows="2" | ||||
|                     rows="1" | ||||
|                   > | ||||
|                     <v-icon | ||||
|                       class="mr-n1" | ||||
| @@ -165,6 +161,7 @@ | ||||
|           <v-btn class="mt-1" color="secondary" fab dark small @click="addNote"> | ||||
|             <v-icon>mdi-plus</v-icon> | ||||
|           </v-btn> | ||||
|           <NutritionEditor v-model="value.nutrition" :edit="true" /> | ||||
|           <ExtrasEditor :extras="value.extras" @save="saveExtras" /> | ||||
|         </v-col> | ||||
|  | ||||
| @@ -222,17 +219,20 @@ | ||||
|  | ||||
| <script> | ||||
| import draggable from "vuedraggable"; | ||||
| import { api } from "@/api"; | ||||
| import utils from "@/utils"; | ||||
| import BulkAdd from "./BulkAdd"; | ||||
| import ExtrasEditor from "./ExtrasEditor"; | ||||
| import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector"; | ||||
| import NutritionEditor from "./NutritionEditor"; | ||||
| import ImageUploadBtn from "./ImageUploadBtn.vue"; | ||||
| export default { | ||||
|   components: { | ||||
|     BulkAdd, | ||||
|     ExtrasEditor, | ||||
|     draggable, | ||||
|     CategoryTagSelector, | ||||
|     NutritionEditor, | ||||
|     ImageUploadBtn, | ||||
|   }, | ||||
|   props: { | ||||
|     value: Object, | ||||
| @@ -251,12 +251,8 @@ export default { | ||||
|     }; | ||||
|   }, | ||||
|   methods: { | ||||
|     uploadImage() { | ||||
|       this.$emit("upload", this.fileObject); | ||||
|     }, | ||||
|     async updateImage() { | ||||
|       const slug = this.value.slug; | ||||
|       api.recipes.updateImage(slug, this.fileObject); | ||||
|     uploadImage(fileObject) { | ||||
|       this.$emit("upload", fileObject); | ||||
|     }, | ||||
|     toggleDisabled(stepIndex) { | ||||
|       if (this.disabledSteps.includes(stepIndex)) { | ||||
|   | ||||
| @@ -40,6 +40,7 @@ | ||||
|               :isCategory="false" | ||||
|             /> | ||||
|             <Notes :notes="notes" /> | ||||
|             <NutritionEditor :value="nutrition" :edit="false" /> | ||||
|           </div> | ||||
|         </v-col> | ||||
|         <v-divider | ||||
| @@ -56,6 +57,7 @@ | ||||
|         <RecipeChips :title="$t('recipe.categories')" :items="categories" /> | ||||
|         <RecipeChips :title="$t('recipe.tags')" :items="tags" /> | ||||
|         <Notes :notes="notes" /> | ||||
|         <NutritionEditor :value="nutrition" :edit="false" /> | ||||
|       </div> | ||||
|       <v-row class="mt-2 mb-1"> | ||||
|         <v-col></v-col> | ||||
| @@ -80,6 +82,7 @@ | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import NutritionEditor from "@/components/Recipe/RecipeEditor/NutritionEditor"; | ||||
| import VueMarkdown from "@adapttive/vue-markdown"; | ||||
| import utils from "@/utils"; | ||||
| import RecipeChips from "./RecipeChips"; | ||||
| @@ -93,6 +96,7 @@ export default { | ||||
|     Steps, | ||||
|     Notes, | ||||
|     Ingredients, | ||||
|     NutritionEditor, | ||||
|   }, | ||||
|   props: { | ||||
|     name: String, | ||||
| @@ -105,6 +109,7 @@ export default { | ||||
|     rating: Number, | ||||
|     yields: String, | ||||
|     orgURL: String, | ||||
|     nutrition: Object, | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|   | ||||
| @@ -1,7 +1,12 @@ | ||||
| <template> | ||||
|   <v-form ref="file"> | ||||
|     <input ref="uploader" class="d-none" type="file" @change="onFileChanged" /> | ||||
|     <v-btn :loading="isSelecting" @click="onButtonClick" color="accent" text> | ||||
|     <v-btn | ||||
|       :loading="isSelecting" | ||||
|       @click="onButtonClick" | ||||
|       color="accent" | ||||
|       :text="textBtn" | ||||
|     > | ||||
|       <v-icon left> {{ icon }}</v-icon> | ||||
|       {{ text ? text : defaultText }} | ||||
|     </v-btn> | ||||
| @@ -13,10 +18,17 @@ const UPLOAD_EVENT = "uploaded"; | ||||
| import { api } from "@/api"; | ||||
| export default { | ||||
|   props: { | ||||
|     post: { | ||||
|       type: Boolean, | ||||
|       default: true, | ||||
|     }, | ||||
|     url: String, | ||||
|     text: { default: "Upload" }, | ||||
|     icon: { default: "mdi-cloud-upload" }, | ||||
|     fileName: { default: "archive" }, | ||||
|     textBtn: { | ||||
|       default: true, | ||||
|     }, | ||||
|   }, | ||||
|   data: () => ({ | ||||
|     file: null, | ||||
| @@ -33,6 +45,12 @@ export default { | ||||
|     async upload() { | ||||
|       if (this.file != null) { | ||||
|         this.isSelecting = true; | ||||
|  | ||||
|         if (this.post) { | ||||
|           this.$emit(UPLOAD_EVENT, this.file); | ||||
|           this.isSelecting = false; | ||||
|           return; | ||||
|         } | ||||
|         let formData = new FormData(); | ||||
|         formData.append(this.fileName, this.file); | ||||
|  | ||||
|   | ||||
| @@ -50,6 +50,7 @@ | ||||
|         :rating="recipeDetails.rating" | ||||
|         :yields="recipeDetails.recipeYield" | ||||
|         :orgURL="recipeDetails.orgURL" | ||||
|         :nutrition="recipeDetails.nutrition" | ||||
|       /> | ||||
|       <VJsoneditor | ||||
|         @error="logError()" | ||||
| @@ -151,6 +152,7 @@ export default { | ||||
|   methods: { | ||||
|     getImageFile(fileObject) { | ||||
|       this.fileObject = fileObject; | ||||
|       this.saveImage(); | ||||
|     }, | ||||
|     async getRecipeDetails() { | ||||
|       this.recipeDetails = await api.recipes.requestDetails(this.currentRecipe); | ||||
| @@ -172,19 +174,21 @@ export default { | ||||
|         return this.$refs.recipeEditor.validateRecipe(); | ||||
|       } | ||||
|     }, | ||||
|     async saveImage() { | ||||
|       if (this.fileObject) { | ||||
|         await api.recipes.updateImage(this.recipeDetails.slug, this.fileObject); | ||||
|       } | ||||
|       this.imageKey += 1; | ||||
|     }, | ||||
|     async saveRecipe() { | ||||
|       if (this.validateRecipe()) { | ||||
|         let slug = await api.recipes.update(this.recipeDetails); | ||||
|  | ||||
|         if (this.fileObject) { | ||||
|           await api.recipes.updateImage( | ||||
|             this.recipeDetails.slug, | ||||
|             this.fileObject | ||||
|           ); | ||||
|           this.saveImage(); | ||||
|         } | ||||
|  | ||||
|         this.form = false; | ||||
|         this.imageKey += 1; | ||||
|         if (slug != this.recipeDetails.slug) { | ||||
|           this.$router.push(`/recipe/${slug}`); | ||||
|         } | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| import shutil | ||||
| from enum import Enum | ||||
|  | ||||
| import requests | ||||
| from fastapi import APIRouter, Depends, File, Form, HTTPException | ||||
| from fastapi.responses import FileResponse | ||||
| from mealie.db.database import db | ||||
| @@ -7,7 +9,7 @@ from mealie.db.db_setup import generate_session | ||||
| from mealie.routes.deps import get_current_user | ||||
| from mealie.schema.recipe import Recipe, RecipeURLIn | ||||
| from mealie.schema.snackbar import SnackResponse | ||||
| from mealie.services.image.image import IMG_OPTIONS, delete_image, read_image, rename_image, write_image | ||||
| from mealie.services.image.image import IMG_OPTIONS, delete_image, read_image, rename_image, scrape_image, write_image | ||||
| from mealie.services.scraper.scraper import create_from_url | ||||
| from sqlalchemy.orm.session import Session | ||||
|  | ||||
| @@ -120,3 +122,16 @@ def update_recipe_image( | ||||
|     db.recipes.update_image(session, recipe_slug, extension) | ||||
|  | ||||
|     return response | ||||
|  | ||||
|  | ||||
| @router.post("/{recipe_slug}/image") | ||||
| def scrape_image_url( | ||||
|     recipe_slug: str, | ||||
|     url: RecipeURLIn, | ||||
|     current_user=Depends(get_current_user), | ||||
| ): | ||||
|     """ Removes an existing image and replaces it with the incoming file. """ | ||||
|  | ||||
|     scrape_image(url.url, recipe_slug) | ||||
|  | ||||
|     return SnackResponse.success("Recipe Image Updated") | ||||
|   | ||||
| @@ -62,12 +62,16 @@ def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path: | ||||
|     extension = extension.replace(".", "") | ||||
|     image_path = image_dir.joinpath(f"original.{extension}") | ||||
|  | ||||
|     if isinstance(file_data, bytes): | ||||
|     if isinstance(file_data, Path): | ||||
|         shutil.copy2(file_data, image_path) | ||||
|     elif isinstance(file_data, bytes): | ||||
|         with open(image_path, "ab") as f: | ||||
|             f.write(file_data) | ||||
|     else: | ||||
|         shutil.copy2(file_data, image_path) | ||||
|         with open(image_path, "ab") as f: | ||||
|             shutil.copyfileobj(file_data, f) | ||||
|  | ||||
|     print(image_path) | ||||
|     minify.minify_image(image_path) | ||||
|  | ||||
|     return image_path | ||||
| @@ -105,7 +109,7 @@ def scrape_image(image_url: str, slug: str) -> Path: | ||||
|  | ||||
|         write_image(slug, r.raw, filename.suffix) | ||||
|  | ||||
|         filename.unlink() | ||||
|         filename.unlink(missing_ok=True) | ||||
|  | ||||
|         return slug | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user