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

feat: Recipe Timeline Images (#2444)

* refactored recipe image paths/service

* added routes for updating/fetching timeline images

* make generate

* added event image upload and rendering

* switched update to patch to preserve timestamp

* added tests

* tweaked order of requests

* always reload events when opening the timeline

* re-arranged elements to make them look nicer

* delete files when timeline event is deleted
This commit is contained in:
Michael Genson 2023-08-06 12:49:30 -05:00 committed by GitHub
parent 06962cf865
commit dfe4942451
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 355 additions and 92 deletions

View file

@ -18,30 +18,49 @@
persistent-hint
rows="4"
></v-textarea>
<v-menu
v-model="datePickerMenu"
:close-on-content-click="false"
transition="scale-transition"
offset-y
max-width="290px"
min-width="auto"
>
<template #activator="{ on, attrs }">
<v-text-field
v-model="newTimelineEvent.timestamp"
:prepend-icon="$globals.icons.calendar"
v-bind="attrs"
readonly
v-on="on"
></v-text-field>
</template>
<v-date-picker
v-model="newTimelineEvent.timestamp"
no-title
:local="$i18n.locale"
@input="datePickerMenu = false"
/>
</v-menu>
<v-container>
<v-row>
<v-col cols="auto">
<v-menu
v-model="datePickerMenu"
:close-on-content-click="false"
transition="scale-transition"
offset-y
max-width="290px"
min-width="auto"
>
<template #activator="{ on, attrs }">
<v-text-field
v-model="newTimelineEventTimestamp"
:prepend-icon="$globals.icons.calendar"
v-bind="attrs"
readonly
v-on="on"
></v-text-field>
</template>
<v-date-picker
v-model="newTimelineEventTimestamp"
no-title
:local="$i18n.locale"
@input="datePickerMenu = false"
/>
</v-menu>
</v-col>
<v-spacer />
<v-col cols="auto" align-self="center">
<AppButtonUpload
class="ml-auto"
url="none"
file-name="image"
accept="image/*"
:text="$i18n.tc('recipe.upload-image')"
:text-btn="false"
:post="false"
@uploaded="uploadImage"
/>
</v-col>
</v-row>
</v-container>
</v-form>
</v-card-text>
</BaseDialog>
@ -101,34 +120,41 @@ export default defineComponent({
timestamp: undefined,
recipeId: props.recipe?.id || "",
});
const newTimelineEventImage = ref<File>();
const newTimelineEventTimestamp = ref<string>();
whenever(
() => madeThisDialog.value,
() => {
// Set timestamp to now
newTimelineEvent.value.timestamp = (
newTimelineEventTimestamp.value = (
new Date(Date.now() - (new Date()).getTimezoneOffset() * 60000)
).toISOString().substring(0, 10);
}
);
function uploadImage(fileObject: File) {
newTimelineEventImage.value = fileObject;
}
const state = reactive({datePickerMenu: false});
async function createTimelineEvent() {
if (!(newTimelineEvent.value.timestamp && props.recipe?.id && props.recipe?.slug)) {
if (!(newTimelineEventTimestamp.value && props.recipe?.id && props.recipe?.slug)) {
return;
}
newTimelineEvent.value.recipeId = props.recipe.id
const actions: Promise<any>[] = [];
// the user only selects the date, so we set the time to end of day local time
// we choose the end of day so it always comes after "new recipe" events
newTimelineEvent.value.timestamp = new Date(newTimelineEvent.value.timestamp + "T23:59:59").toISOString();
actions.push(userApi.recipes.createTimelineEvent(newTimelineEvent.value));
newTimelineEvent.value.timestamp = new Date(newTimelineEventTimestamp.value + "T23:59:59").toISOString();
const eventResponse = await userApi.recipes.createTimelineEvent(newTimelineEvent.value);
const newEvent = eventResponse.data;
// we also update the recipe's last made value
if (!props.value || newTimelineEvent.value.timestamp > props.value) {
actions.push(userApi.recipes.updateLastMade(props.recipe.slug, newTimelineEvent.value.timestamp));
await userApi.recipes.updateLastMade(props.recipe.slug, newTimelineEvent.value.timestamp);
// update recipe in parent so the user can see it
// we remove the trailing "Z" since this is how the API returns it
@ -138,12 +164,23 @@ export default defineComponent({
);
}
await Promise.allSettled(actions);
// update the image, if provided
if (newTimelineEventImage.value && newEvent) {
const imageResponse = await userApi.recipes.updateTimelineEventImage(newEvent.id, newTimelineEventImage.value);
if (imageResponse.data) {
// @ts-ignore the image response data will always match a value of TimelineEventImage
newEvent.image = imageResponse.data.image;
}
}
// reset form
newTimelineEvent.value.eventMessage = "";
newTimelineEvent.value.timestamp = undefined;
newTimelineEventImage.value = undefined;
madeThisDialog.value = false;
domMadeThisForm.value?.reset();
context.emit("eventCreated", newEvent);
}
return {
@ -151,7 +188,10 @@ export default defineComponent({
domMadeThisForm,
madeThisDialog,
newTimelineEvent,
newTimelineEventImage,
newTimelineEventTimestamp,
createTimelineEvent,
uploadImage,
};
},
});

View file

@ -107,9 +107,7 @@ export default defineComponent({
whenever(
() => props.value,
() => {
if (!ready.value) {
initializeTimelineEvents();
}
initializeTimelineEvents();
}
);

View file

@ -45,7 +45,7 @@
content-class="d-print-none"
>
<template #activator="{ on, attrs }">
<v-btn :fab="fab" :small="fab" :elevation="elevation" :color="color" :icon="!fab" v-bind="attrs" v-on="on" @click.prevent>
<v-btn :fab="fab" :x-small="fab" :elevation="elevation" :color="color" :icon="!fab" v-bind="attrs" v-on="on" @click.prevent>
<v-icon>{{ icon }}</v-icon>
</v-btn>
</template>

View file

@ -6,30 +6,30 @@
:icon="icon"
>
<template v-if="!useMobileFormat" #opposite>
<v-chip v-if="event.timestamp" label large>
<v-chip v-if="event.timestamp" label large>
<v-icon class="mr-1"> {{ $globals.icons.calendar }} </v-icon>
{{ new Date(event.timestamp+"Z").toLocaleDateString($i18n.locale) }}
</v-chip>
</v-chip>
</template>
<v-card>
<v-sheet>
<v-sheet>
<v-card-title>
<v-row>
<v-row>
<v-col align-self="center" :cols="useMobileFormat ? 'auto' : '2'" :class="attrs.avatar.class">
<UserAvatar :user-id="event.userId" :size="attrs.avatar.size" />
<UserAvatar :user-id="event.userId" :size="attrs.avatar.size" />
</v-col>
<v-col v-if="useMobileFormat" align-self="center" class="pr-0">
<v-chip label>
<v-chip label>
<v-icon> {{ $globals.icons.calendar }} </v-icon>
{{ new Date(event.timestamp+"Z").toLocaleDateString($i18n.locale) }}
</v-chip>
</v-chip>
</v-col>
<v-col v-else cols="9" style="margin: auto; text-align: center;">
{{ event.subject }}
{{ event.subject }}
</v-col>
<v-spacer />
<v-col :cols="useMobileFormat ? 'auto' : '1'" class="px-0">
<RecipeTimelineContextMenu
<v-col :cols="useMobileFormat ? 'auto' : '1'" class="px-0 pt-0">
<RecipeTimelineContextMenu
v-if="$auth.user && $auth.user.id == event.userId && event.eventType != 'system'"
:menu-top="false"
:event="event"
@ -44,12 +44,12 @@
}"
@update="$emit('update')"
@delete="$emit('delete')"
/>
/>
</v-col>
</v-row>
</v-row>
</v-card-title>
<v-sheet v-if="showRecipeCards && recipe">
<v-row class="pt-3 pb-7 mx-3" style="max-width: 100%;">
<v-row class="pt-3 pb-7 mx-3" style="max-width: 100%;">
<v-col align-self="center" class="pa-0">
<RecipeCardMobile
:vertical="useMobileFormat"
@ -61,20 +61,30 @@
:recipe-id="recipe.id"
/>
</v-col>
</v-row>
</v-row>
</v-sheet>
<v-divider v-if="showRecipeCards && recipe && (useMobileFormat || event.eventMessage)" />
<v-divider v-if="showRecipeCards && recipe && (useMobileFormat || event.eventMessage || (eventImageUrl && !hideImage))" />
<v-card-text>
<v-row>
<v-row>
<v-col>
<strong v-if="useMobileFormat">{{ event.subject }}</strong>
<div v-if="event.eventMessage" :class="useMobileFormat ? 'text-caption' : ''">
<strong v-if="useMobileFormat">{{ event.subject }}</strong>
<v-img
v-if="eventImageUrl"
:src="eventImageUrl"
min-height="50"
:height="hideImage ? undefined : 'auto'"
:max-height="attrs.image.maxHeight"
contain
:class=attrs.image.class
@error="hideImage = true"
/>
<div v-if="event.eventMessage" :class="useMobileFormat ? 'text-caption' : ''">
{{ event.eventMessage }}
</div>
</div>
</v-col>
</v-row>
</v-row>
</v-card-text>
</v-sheet>
</v-sheet>
</v-card>
</v-timeline-item>
</template>
@ -83,6 +93,7 @@
import { computed, defineComponent, ref, useContext } from "@nuxtjs/composition-api";
import RecipeCardMobile from "./RecipeCardMobile.vue";
import RecipeTimelineContextMenu from "./RecipeTimelineContextMenu.vue";
import { useStaticRoutes } from "~/composables/api";
import { Recipe, RecipeTimelineEventOut } from "~/lib/api/types/recipe"
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
@ -106,6 +117,7 @@ export default defineComponent({
setup(props) {
const { $globals, $vuetify } = useContext();
const { recipeTimelineEventImage } = useStaticRoutes();
const timelineEvents = ref([] as RecipeTimelineEventOut[]);
const useMobileFormat = computed(() => {
@ -121,6 +133,10 @@ export default defineComponent({
size: "30px",
class: "pr-0",
},
image: {
maxHeight: "250",
class: "my-3"
},
}
}
else {
@ -131,6 +147,10 @@ export default defineComponent({
size: "42px",
class: "",
},
image: {
maxHeight: "300",
class: "mb-5"
},
}
}
})
@ -151,9 +171,20 @@ export default defineComponent({
};
})
const hideImage = ref(false);
const eventImageUrl = computed<string>( () => {
if (props.event.image !== "has image") {
return "";
}
return recipeTimelineEventImage(props.event.recipeId, props.event.id);
})
return {
attrs,
icon,
eventImageUrl,
hideImage,
timelineEvents,
useMobileFormat,
};