diff --git a/frontend/components/Domain/Recipe/RecipeLastMade.vue b/frontend/components/Domain/Recipe/RecipeLastMade.vue index 4b054e43d..56b5d9541 100644 --- a/frontend/components/Domain/Recipe/RecipeLastMade.vue +++ b/frontend/components/Domain/Recipe/RecipeLastMade.vue @@ -5,7 +5,7 @@ v-model="madeThisDialog" :icon="$globals.icons.chefHat" :title="$tc('recipe.made-this')" - :submit-text="$tc('general.save')" + :submit-text="$tc('recipe.add-to-timeline')" @submit="createTimelineEvent" > @@ -49,6 +49,7 @@ + + {{ $globals.icons.close }} + {{ $i18n.tc('recipe.remove-image') }} + + + + + + @@ -120,7 +139,9 @@ export default defineComponent({ timestamp: undefined, recipeId: props.recipe?.id || "", }); - const newTimelineEventImage = ref(); + const newTimelineEventImage = ref(); + const newTimelineEventImageName = ref(""); + const newTimelineEventImagePreviewUrl = ref(); const newTimelineEventTimestamp = ref(); whenever( @@ -133,8 +154,21 @@ export default defineComponent({ } ); + function clearImage() { + newTimelineEventImage.value = undefined; + newTimelineEventImageName.value = ""; + newTimelineEventImagePreviewUrl.value = undefined; + } + function uploadImage(fileObject: File) { newTimelineEventImage.value = fileObject; + newTimelineEventImageName.value = fileObject.name; + newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject); + } + + function updateUploadedImage(fileObject: Blob) { + newTimelineEventImage.value = fileObject; + newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject); } const state = reactive({datePickerMenu: false}); @@ -166,7 +200,11 @@ export default defineComponent({ // update the image, if provided if (newTimelineEventImage.value && newEvent) { - const imageResponse = await userApi.recipes.updateTimelineEventImage(newEvent.id, newTimelineEventImage.value); + const imageResponse = await userApi.recipes.updateTimelineEventImage( + newEvent.id, + newTimelineEventImage.value, + newTimelineEventImageName.value, + ); if (imageResponse.data) { // @ts-ignore the image response data will always match a value of TimelineEventImage newEvent.image = imageResponse.data.image; @@ -176,7 +214,7 @@ export default defineComponent({ // reset form newTimelineEvent.value.eventMessage = ""; newTimelineEvent.value.timestamp = undefined; - newTimelineEventImage.value = undefined; + clearImage(); madeThisDialog.value = false; domMadeThisForm.value?.reset(); @@ -189,9 +227,12 @@ export default defineComponent({ madeThisDialog, newTimelineEvent, newTimelineEventImage, + newTimelineEventImagePreviewUrl, newTimelineEventTimestamp, createTimelineEvent, + clearImage, uploadImage, + updateUploadedImage, }; }, }); diff --git a/frontend/components/global/AppButtonUpload.vue b/frontend/components/global/AppButtonUpload.vue index 77a9e7ebf..5ca87d22c 100644 --- a/frontend/components/global/AppButtonUpload.vue +++ b/frontend/components/global/AppButtonUpload.vue @@ -2,7 +2,7 @@ - + {{ effIcon }} {{ text ? text : defaultText }} @@ -50,6 +50,14 @@ export default defineComponent({ type: String, default: "", }, + color: { + type: String, + default: "info", + }, + disabled: { + type: Boolean, + default: false, + } }, setup(props, context) { const file = ref(null); diff --git a/frontend/components/global/ImageCropper.vue b/frontend/components/global/ImageCropper.vue new file mode 100644 index 000000000..b36e471b8 --- /dev/null +++ b/frontend/components/global/ImageCropper.vue @@ -0,0 +1,152 @@ + + + diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json index 1b2f90fc8..e41cc45c0 100644 --- a/frontend/lang/messages/en-US.json +++ b/frontend/lang/messages/en-US.json @@ -456,6 +456,7 @@ "date-format-hint-yyyy-mm-dd": "YYYY-MM-DD format", "add-to-list": "Add to List", "add-to-plan": "Add to Plan", + "add-to-timeline": "Add to Timeline", "recipe-added-to-list": "Recipe added to list", "recipe-added-to-mealplan": "Recipe added to mealplan", "failed-to-add-recipe-to-mealplan": "Failed to add recipe to mealplan", @@ -529,7 +530,8 @@ "tree-view": "Tree View", "recipe-yield": "Recipe Yield", "unit": "Unit", - "upload-image": "Upload image" + "upload-image": "Upload image", + "remove-image": "Remove image" }, "search": { "advanced-search": "Advanced Search", diff --git a/frontend/lib/api/user/recipes/recipe.ts b/frontend/lib/api/user/recipes/recipe.ts index eef097671..fadfb41ff 100644 --- a/frontend/lib/api/user/recipes/recipe.ts +++ b/frontend/lib/api/user/recipes/recipe.ts @@ -196,10 +196,10 @@ export class RecipeAPI extends BaseCRUDAPI { ); } - async updateTimelineEventImage(eventId: string, fileObject: File) { + async updateTimelineEventImage(eventId: string, fileObject: Blob | File, fileName: string) { const formData = new FormData(); formData.append("image", fileObject); - formData.append("extension", fileObject.name.split(".").pop() ?? ""); + formData.append("extension", fileName.split(".").pop() ?? ""); return await this.requests.put(routes.recipesTimelineEventIdImage(eventId), formData); } diff --git a/frontend/lib/icons/icons.ts b/frontend/lib/icons/icons.ts index ffaec84d2..1b0d5e20c 100644 --- a/frontend/lib/icons/icons.ts +++ b/frontend/lib/icons/icons.ts @@ -135,6 +135,10 @@ import { mdiDockTop, mdiDockBottom, mdiCheckboxOutline, + mdiFlipHorizontal, + mdiFlipVertical, + mdiRotateLeft, + mdiRotateRight, } from "@mdi/js"; export const icons = { @@ -200,6 +204,8 @@ export const icons = { fileImage: mdiFileImage, filePDF: mdiFilePdfBox, filter: mdiFilter, + flipHorizontal: mdiFlipHorizontal, + flipVertical: mdiFlipVertical, folderOutline: mdiFolderOutline, food: mdiFood, formatColorFill: mdiFormatColorFill, @@ -226,6 +232,8 @@ export const icons = { printerSettings: mdiPrinterPosCog, refreshCircle: mdiRefreshCircle, robot: mdiRobot, + rotateLeft: mdiRotateLeft, + rotateRight: mdiRotateRight, search: mdiMagnify, shareVariant: mdiShareVariant, shuffleVariant: mdiShuffleVariant, diff --git a/frontend/package.json b/frontend/package.json index 3746039f0..fdb73b48e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,7 @@ "isomorphic-dompurify": "^1.0.0", "nuxt": "^2.16.0", "v-jsoneditor": "^1.4.5", + "vue-advanced-cropper": "^1.11.6", "vuedraggable": "^2.24.3", "vuetify": "^2.6.13" }, diff --git a/frontend/types/components.d.ts b/frontend/types/components.d.ts index 85725e4dd..8806e6da8 100644 --- a/frontend/types/components.d.ts +++ b/frontend/types/components.d.ts @@ -21,6 +21,7 @@ import DevDumpJson from "@/components/global/DevDumpJson.vue"; import DocLink from "@/components/global/DocLink.vue"; import DropZone from "@/components/global/DropZone.vue"; import HelpIcon from "@/components/global/HelpIcon.vue"; +import ImageCropper from "@/components/global/ImageCropper.vue"; import InputColor from "@/components/global/InputColor.vue"; import InputLabelType from "@/components/global/InputLabelType.vue"; import InputQuantity from "@/components/global/InputQuantity.vue"; @@ -61,6 +62,7 @@ declare module "vue" { DocLink: typeof DocLink; DropZone: typeof DropZone; HelpIcon: typeof HelpIcon; + ImageCropper: typeof ImageCropper; InputColor: typeof InputColor; InputLabelType: typeof InputLabelType; InputQuantity: typeof InputQuantity; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index f3447ac77..883d9d358 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -3735,6 +3735,11 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" +classnames@^2.2.6: + version "2.3.2" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" + integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw== + clean-css@^4.2.1, clean-css@^4.2.3: version "4.2.4" resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.4.tgz#733bf46eba4e607c6891ea57c24a989356831178" @@ -4277,6 +4282,11 @@ de-indent@^1.0.2: resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d" integrity sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg== +debounce@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5" + integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug== + debug@2.6.9, debug@^2.2.0, debug@^2.3.3: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -4584,6 +4594,11 @@ eastasianwidth@^0.2.0: resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== +easy-bem@^1.0.2: + version "1.1.1" + resolved "https://registry.yarnpkg.com/easy-bem/-/easy-bem-1.1.1.tgz#1bfcc10425498090bcfddc0f9c000aba91399e03" + integrity sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A== + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -11032,6 +11047,15 @@ vm-browserify@^1.0.1: resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ== +vue-advanced-cropper@^1.11.6: + version "1.11.6" + resolved "https://registry.yarnpkg.com/vue-advanced-cropper/-/vue-advanced-cropper-1.11.6.tgz#38f824e515747d749168e20de6d5eeea1a8d508b" + integrity sha512-S/3VXfnvq/8C3Js6OaxfPN709l7mrWRqI4GRklGM08glyXF147Nl74EkfyVNv7zhuNLM4stPvaQB7XUvRH9/iA== + dependencies: + classnames "^2.2.6" + debounce "^1.2.0" + easy-bem "^1.0.2" + vue-client-only@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/vue-client-only/-/vue-client-only-2.1.0.tgz#1a67a47b8ecacfa86d75830173fffee3bf8a4ee3"