1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-08-02 20:15:24 +02:00

feat: Timeline Image Uploader Improvements (#2494)

* improved UI responsiveness and added image preview

* added global image cropper component

* added image cropper to last made dialog

* style tweaks

* added more specific text for creating event

* mopped up some slop

* renamed height and width vars

---------

Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
This commit is contained in:
Michael Genson 2023-08-21 10:00:37 -05:00 committed by GitHub
parent e24e28ae03
commit 2151451634
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 246 additions and 8 deletions

View file

@ -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"
>
<v-card-text>
@ -49,6 +49,7 @@
<v-spacer />
<v-col cols="auto" align-self="center">
<AppButtonUpload
v-if="!newTimelineEventImage"
class="ml-auto"
url="none"
file-name="image"
@ -58,6 +59,24 @@
:post="false"
@uploaded="uploadImage"
/>
<v-btn
v-if="!!newTimelineEventImage"
color="error"
@click="clearImage"
>
<v-icon left>{{ $globals.icons.close }}</v-icon>
{{ $i18n.tc('recipe.remove-image') }}
</v-btn>
</v-col>
</v-row>
<v-row v-if="newTimelineEventImage && newTimelineEventImagePreviewUrl">
<v-col cols="12" align-self="center">
<ImageCropper
:img="newTimelineEventImagePreviewUrl"
cropper-height="20vh"
cropper-width="100%"
@save="updateUploadedImage"
/>
</v-col>
</v-row>
</v-container>
@ -120,7 +139,9 @@ export default defineComponent({
timestamp: undefined,
recipeId: props.recipe?.id || "",
});
const newTimelineEventImage = ref<File>();
const newTimelineEventImage = ref<Blob | File>();
const newTimelineEventImageName = ref<string>("");
const newTimelineEventImagePreviewUrl = ref<string>();
const newTimelineEventTimestamp = ref<string>();
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,
};
},
});

View file

@ -2,7 +2,7 @@
<v-form ref="file">
<input ref="uploader" class="d-none" type="file" :accept="accept" @change="onFileChanged" />
<slot v-bind="{ isSelecting, onButtonClick }">
<v-btn :loading="isSelecting" :small="small" color="info" :text="textBtn" @click="onButtonClick">
<v-btn :loading="isSelecting" :small="small" :color="color" :text="textBtn" :disabled="disabled" @click="onButtonClick">
<v-icon left> {{ effIcon }}</v-icon>
{{ text ? text : defaultText }}
</v-btn>
@ -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<File | null>(null);

View file

@ -0,0 +1,152 @@
<template>
<v-container class="pa-0">
<v-row no-gutters>
<v-col cols="2" align-self="center">
<v-container class="pa-0 mx-0">
<v-row v-for="(row, keyRow) in controls" :key="keyRow">
<v-col
v-for="(control, keyControl) in row" :key="keyControl"
:cols="12 / row.length"
class="py-2 mx-0"
style="display: flex; align-items: center; justify-content: center;"
>
<v-btn icon :color="control.color" @click="control.callback()">
<v-icon> {{ control.icon }} </v-icon>
</v-btn>
</v-col>
</v-row>
</v-container>
</v-col>
<v-spacer />
<v-col cols="8" align-self="center">
<Cropper
ref="cropper"
class="cropper"
:src="img"
:default-size="defaultSize"
:style="`height: ${cropperHeight}; width: ${cropperWidth};`"
/>
</v-col>
</v-row>
</v-container>
</template>
<script lang="ts">
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
import { Cropper } from "vue-advanced-cropper";
import "vue-advanced-cropper/dist/style.css";
export default defineComponent({
components: { Cropper },
props: {
img: {
type: String,
required: true,
},
cropperHeight: {
type: String,
default: undefined,
},
cropperWidth: {
type: String,
default: undefined,
}
},
setup(props, context) {
const cropper = ref<Cropper>();
const { $globals, $vuetify } = useContext();
interface Control {
color: string;
icon: string;
callback: CallableFunction;
}
const controls = ref<Control[][]>([
[
{
color: "info",
icon: $globals.icons.flipHorizontal,
callback: () => flip(true, false),
},
{
color: "info",
icon: $globals.icons.flipVertical,
callback: () => flip(false, true),
},
],
[
{
color: "info",
icon: $globals.icons.rotateLeft,
callback: () => rotate(-90),
},
{
color: "info",
icon: $globals.icons.rotateRight,
callback: () => rotate(90),
},
],
[
{
color: "success",
icon: $globals.icons.save,
callback: () => save(),
},
],
]);
function flip(hortizontal: boolean, vertical?: boolean) {
if (!cropper.value) {
return;
}
cropper.value.flip(hortizontal, vertical);
}
function rotate(angle: number) {
if (!cropper.value) {
return;
}
cropper.value.rotate(angle);
}
function save() {
if (!cropper.value) {
return;
}
const { canvas } = cropper.value.getResult();
if (!canvas) {
return;
}
canvas.toBlob((blob) => {
if (blob) {
context.emit("save", blob);
}
})
}
return {
cropper,
controls,
flip,
rotate,
save,
};
},
methods: {
// @ts-expect-error https://advanced-cropper.github.io/vue-advanced-cropper/guides/advanced-recipes.html
defaultSize({ imageSize, visibleArea }) {
return {
width: (visibleArea || imageSize).width,
height: (visibleArea || imageSize).height,
};
},
},
});
</script>