mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-08-05 13:35:23 +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:
parent
06962cf865
commit
dfe4942451
16 changed files with 355 additions and 92 deletions
|
@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
@ -107,9 +107,7 @@ export default defineComponent({
|
|||
whenever(
|
||||
() => props.value,
|
||||
() => {
|
||||
if (!ready.value) {
|
||||
initializeTimelineEvents();
|
||||
}
|
||||
initializeTimelineEvents();
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -30,6 +30,18 @@ export const useStaticRoutes = () => {
|
|||
)}`;
|
||||
}
|
||||
|
||||
function recipeTimelineEventImage(recipeId: string, timelineEventId: string) {
|
||||
return `${fullBase}/media/recipes/${recipeId}/images/timeline/${timelineEventId}/original.webp`;
|
||||
}
|
||||
|
||||
function recipeTimelineEventSmallImage(recipeId: string, timelineEventId: string) {
|
||||
return `${fullBase}/media/recipes/${recipeId}/images/timeline/${timelineEventId}/min-original.webp`;
|
||||
}
|
||||
|
||||
function recipeTimelineEventTinyImage(recipeId: string, timelineEventId: string) {
|
||||
return `${fullBase}/media/recipes/${recipeId}/images/timeline/${timelineEventId}/tiny-original.webp`;
|
||||
}
|
||||
|
||||
function recipeAssetPath(recipeId: string, assetName: string) {
|
||||
return `${fullBase}/media/recipes/${recipeId}/assets/${assetName}`;
|
||||
}
|
||||
|
@ -38,6 +50,9 @@ export const useStaticRoutes = () => {
|
|||
recipeImage,
|
||||
recipeSmallImage,
|
||||
recipeTinyImage,
|
||||
recipeTimelineEventImage,
|
||||
recipeTimelineEventSmallImage,
|
||||
recipeTimelineEventTinyImage,
|
||||
recipeAssetPath,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
/* This file was automatically generated from pydantic models by running pydantic2ts.
|
||||
/* Do not modify it by hand - just update the pydantic models and then re-run the script
|
||||
*/
|
||||
/* This file was automatically generated from pydantic models by running pydantic2ts.
|
||||
/* Do not modify it by hand - just update the pydantic models and then re-run the script
|
||||
*/
|
||||
|
||||
export type ExportTypes = "json";
|
||||
export type RegisteredParser = "nlp" | "brute";
|
||||
export type TimelineEventType = "system" | "info" | "comment";
|
||||
export type TimelineEventImage = "has image" | "does not have image";
|
||||
|
||||
export interface AssignCategories {
|
||||
recipes: string[];
|
||||
|
@ -351,31 +352,31 @@ export interface RecipeTagResponse {
|
|||
recipes?: RecipeSummary[];
|
||||
}
|
||||
export interface RecipeTimelineEventCreate {
|
||||
recipeId: string;
|
||||
userId: string;
|
||||
subject: string;
|
||||
eventType: TimelineEventType;
|
||||
eventMessage?: string;
|
||||
image?: string;
|
||||
image?: TimelineEventImage;
|
||||
timestamp?: string;
|
||||
recipeId: string;
|
||||
}
|
||||
export interface RecipeTimelineEventIn {
|
||||
recipeId: string;
|
||||
userId?: string;
|
||||
subject: string;
|
||||
eventType: TimelineEventType;
|
||||
eventMessage?: string;
|
||||
image?: string;
|
||||
image?: TimelineEventImage;
|
||||
timestamp?: string;
|
||||
recipeId: string;
|
||||
}
|
||||
export interface RecipeTimelineEventOut {
|
||||
recipeId: string;
|
||||
userId: string;
|
||||
subject: string;
|
||||
eventType: TimelineEventType;
|
||||
eventMessage?: string;
|
||||
image?: string;
|
||||
image?: TimelineEventImage;
|
||||
timestamp?: string;
|
||||
recipeId: string;
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updateAt: string;
|
||||
|
@ -383,7 +384,7 @@ export interface RecipeTimelineEventOut {
|
|||
export interface RecipeTimelineEventUpdate {
|
||||
subject: string;
|
||||
eventMessage?: string;
|
||||
image?: string;
|
||||
image?: TimelineEventImage;
|
||||
}
|
||||
export interface RecipeToolCreate {
|
||||
name: string;
|
||||
|
@ -400,7 +401,7 @@ export interface RecipeToolResponse {
|
|||
onHand?: boolean;
|
||||
id: string;
|
||||
slug: string;
|
||||
recipes?: Recipe[];
|
||||
recipes?: RecipeSummary[];
|
||||
}
|
||||
export interface RecipeToolSave {
|
||||
name: string;
|
||||
|
|
|
@ -52,6 +52,7 @@ const routes = {
|
|||
|
||||
recipesSlugLastMade: (slug: string) => `${prefix}/recipes/${slug}/last-made`,
|
||||
recipesTimelineEventId: (id: string) => `${prefix}/recipes/timeline/events/${id}`,
|
||||
recipesTimelineEventIdImage: (id: string) => `${prefix}/recipes/timeline/events/${id}/image`,
|
||||
};
|
||||
|
||||
export type RecipeSearchQuery = {
|
||||
|
@ -194,4 +195,12 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
|
|||
}
|
||||
);
|
||||
}
|
||||
|
||||
async updateTimelineEventImage(eventId: string, fileObject: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("image", fileObject);
|
||||
formData.append("extension", fileObject.name.split(".").pop() ?? "");
|
||||
|
||||
return await this.requests.put<UpdateImageResponse, FormData>(routes.recipesTimelineEventIdImage(eventId), formData);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue