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

Feature: Global Timeline (#2265)

* extended query filter to accept nested tables

* decoupled timeline api from recipe slug

* modified frontend to use simplified events api

* fixed nested loop index ghosting

* updated existing tests

* gave mypy a snack

* added tests for nested queries

* fixed "last made" render error

* decoupled recipe timeline from dialog

* removed unused props

* tweaked recipe get_all to accept ids

* created group global timeline
added new timeline page to sidebar
reformatted the recipe timeline
added vertical option to recipe card mobile

* extracted timeline item into its own component

* fixed apploader centering

* added paginated scrolling to recipe timeline

* added sort direction config
fixed infinite scroll on dialog
fixed hasMore var not resetting during instantiation

* added sort direction to user preferences

* updated API docs with new query filter feature

* better error tracing

* fix for recipe not found response

* simplified recipe crud route for slug/id
added test for fetching by slug/id

* made query filter UUID validation clearer

* moved timeline menu option below shopping lists

---------

Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
This commit is contained in:
Michael Genson 2023-04-25 12:46:00 -05:00 committed by GitHub
parent 0e397b34fd
commit fe17922bb8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 871 additions and 506 deletions

View file

@ -7,8 +7,18 @@
:to="$listeners.selected ? undefined : `/recipe/${slug}`"
@click="$emit('selected')"
>
<v-img v-if="vertical">
<RecipeCardImage
:icon-size="100"
:height="75"
:slug="slug"
:recipe-id="recipeId"
small
:image-version="image"
/>
</v-img>
<v-list-item three-line>
<slot name="avatar">
<slot v-if="!vertical" name="avatar">
<v-list-item-avatar tile size="125" class="v-mobile-img rounded-sm my-0 ml-n4">
<RecipeCardImage
:icon-size="100"
@ -17,7 +27,7 @@
:recipe-id="recipeId"
small
:image-version="image"
></RecipeCardImage>
/>
</v-list-item-avatar>
</slot>
<v-list-item-content>
@ -25,7 +35,7 @@
<v-list-item-subtitle>
<SafeMarkdown :source="description" />
</v-list-item-subtitle>
<div class="d-flex justify-center align-center">
<div class="d-flex flex-wrap justify-end align-center">
<slot name="actions">
<RecipeFavoriteBadge v-if="loggedIn" :slug="slug" show-always />
<v-rating
@ -107,6 +117,10 @@ export default defineComponent({
type: String,
required: true,
},
vertical: {
type: Boolean,
default: false,
}
},
setup() {
const { $auth } = useContext();

View file

@ -1,245 +0,0 @@
<template>
<BaseDialog
v-model="dialog"
:title="attrs.title"
:icon="$globals.icons.timelineText"
width="70%"
>
<v-card
v-if="timelineEvents && timelineEvents.length"
height="fit-content"
max-height="70vh"
width="100%"
style="overflow-y: auto;"
>
<v-timeline :dense="attrs.timeline.dense">
<v-timeline-item
v-for="(event, index) in timelineEvents"
:key="event.id"
:class="attrs.timeline.item.class"
fill-dot
:small="attrs.timeline.item.small"
:icon="chooseEventIcon(event)"
>
<template v-if="!useMobileFormat" #opposite>
<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>
</template>
<v-card>
<v-sheet>
<v-card-title>
<v-row>
<v-col align-self="center" :cols="useMobileFormat ? 'auto' : '2'">
<UserAvatar :user-id="event.userId" />
</v-col>
<v-col v-if="useMobileFormat" align-self="center" class="ml-3">
<v-chip label>
<v-icon> {{ $globals.icons.calendar }} </v-icon>
{{ new Date(event.timestamp+"Z").toLocaleDateString($i18n.locale) }}
</v-chip>
</v-col>
<v-col v-else cols="9">
{{ event.subject }}
</v-col>
<v-spacer />
<v-col :cols="useMobileFormat ? 'auto' : '1'" :class="useMobileFormat ? '' : 'pa-0'">
<RecipeTimelineContextMenu
v-if="$auth.user && $auth.user.id == event.userId && event.eventType != 'system'"
:menu-top="false"
:slug="slug"
:event="event"
:menu-icon="$globals.icons.dotsVertical"
fab
color="transparent"
:elevation="0"
:card-menu="false"
:use-items="{
edit: true,
delete: true,
}"
@update="updateTimelineEvent(index)"
@delete="deleteTimelineEvent(index)"
/>
</v-col>
</v-row>
</v-card-title>
<v-card-text>
<v-row>
<v-col>
<strong v-if="useMobileFormat">{{ event.subject }}</strong>
<div v-if="event.eventMessage" :class="useMobileFormat ? 'text-caption' : ''">
{{ event.eventMessage }}
</div>
</v-col>
</v-row>
</v-card-text>
</v-sheet>
</v-card>
</v-timeline-item>
</v-timeline>
</v-card>
<v-card v-else>
<v-card-title class="justify-center pa-9">
{{ $t("recipe.timeline-is-empty") }}
</v-card-title>
</v-card>
</BaseDialog>
</template>
<script lang="ts">
import { computed, defineComponent, ref, useContext } from "@nuxtjs/composition-api";
import { whenever } from "@vueuse/core";
import RecipeTimelineContextMenu from "./RecipeTimelineContextMenu.vue";
import { alert } from "~/composables/use-toast";
import { useUserApi } from "~/composables/api";
import { RecipeTimelineEventOut, RecipeTimelineEventUpdate } from "~/lib/api/types/recipe"
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
export default defineComponent({
components: { RecipeTimelineContextMenu, UserAvatar },
props: {
value: {
type: Boolean,
default: false,
},
slug: {
type: String,
default: "",
},
recipeName: {
type: String,
default: "",
},
},
setup(props, context) {
const api = useUserApi();
const { $globals, $vuetify, i18n } = useContext();
const timelineEvents = ref([{}] as RecipeTimelineEventOut[])
const useMobileFormat = computed(() => {
return $vuetify.breakpoint.smAndDown;
});
const attrs = computed(() => {
if (useMobileFormat.value) {
return {
title: i18n.tc("recipe.timeline"),
timeline: {
dense: true,
item: {
class: "pr-3",
small: true
}
}
}
}
else {
return {
title: `${i18n.tc("recipe.timeline")} ${props.recipeName}`,
timeline: {
dense: false,
item: {
class: "px-3",
small: false
}
}
}
}
})
// V-Model Support
const dialog = computed({
get: () => {
return props.value;
},
set: (val) => {
context.emit("input", val);
},
});
whenever(
() => props.value,
() => {
refreshTimelineEvents();
}
);
function chooseEventIcon(event: RecipeTimelineEventOut) {
switch (event.eventType) {
case "comment":
return $globals.icons.commentTextMultiple;
case "info":
return $globals.icons.informationVariant;
case "system":
return $globals.icons.cog;
default:
return $globals.icons.informationVariant;
};
};
async function updateTimelineEvent(index: number) {
const event = timelineEvents.value[index]
const payload: RecipeTimelineEventUpdate = {
subject: event.subject,
eventMessage: event.eventMessage,
image: event.image,
};
const { response } = await api.recipes.updateTimelineEvent(props.slug, event.id, payload);
if (response?.status !== 200) {
alert.error(i18n.t("events.something-went-wrong") as string);
return;
}
alert.success(i18n.t("events.event-updated") as string);
};
async function deleteTimelineEvent(index: number) {
const { response } = await api.recipes.deleteTimelineEvent(props.slug, timelineEvents.value[index].id);
if (response?.status !== 200) {
alert.error(i18n.t("events.something-went-wrong") as string);
return;
}
timelineEvents.value.splice(index, 1);
alert.success(i18n.t("events.event-deleted") as string);
};
async function refreshTimelineEvents() {
// TODO: implement infinite scroll and paginate instead of loading all events at once
const page = 1;
const perPage = -1;
const orderBy = "timestamp";
const orderDirection = "asc";
const response = await api.recipes.getAllTimelineEvents(props.slug, page, perPage, { orderBy, orderDirection });
if (!response?.data) {
return;
}
timelineEvents.value = response.data.items;
};
// preload events
refreshTimelineEvents();
return {
attrs,
chooseEventIcon,
deleteTimelineEvent,
dialog,
refreshTimelineEvents,
timelineEvents,
updateTimelineEvent,
useMobileFormat,
};
},
});
</script>

View file

@ -75,7 +75,7 @@ import { defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/comp
import { whenever } from "@vueuse/core";
import { VForm } from "~/types/vuetify";
import { useUserApi } from "~/composables/api";
import { RecipeTimelineEventIn } from "~/lib/api/types/recipe";
import { Recipe, RecipeTimelineEventIn } from "~/lib/api/types/recipe";
export default defineComponent({
props: {
@ -83,9 +83,9 @@ export default defineComponent({
type: String,
default: null,
},
recipeSlug: {
type: String,
required: true,
recipe: {
type: Object as () => Recipe,
default: null,
},
},
setup(props, context) {
@ -99,6 +99,7 @@ export default defineComponent({
eventType: "comment",
eventMessage: "",
timestamp: undefined,
recipeId: props.recipe?.id || "",
});
whenever(
@ -113,20 +114,21 @@ export default defineComponent({
const state = reactive({datePickerMenu: false});
async function createTimelineEvent() {
if (!newTimelineEvent.value.timestamp) {
if (!(newTimelineEvent.value.timestamp && 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(props.recipeSlug, newTimelineEvent.value));
actions.push(userApi.recipes.createTimelineEvent(newTimelineEvent.value));
// we also update the recipe's last made value
if (!props.value || newTimelineEvent.value.timestamp > props.value) {
actions.push(userApi.recipes.updateLastMade(props.recipeSlug, newTimelineEvent.value.timestamp));
actions.push(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

View file

@ -13,7 +13,7 @@
<div v-if="user.id" class="d-flex justify-center mt-5">
<RecipeLastMade
v-model="recipe.lastMade"
:recipe-slug="recipe.slug"
:recipe="recipe"
class="d-flex justify-center flex-wrap"
:class="true ? undefined : 'force-bottom'"
/>

View file

@ -0,0 +1,266 @@
<template>
<div :style="maxHeight ? `max-height: ${maxHeight}; overflow-y: auto;` : ''" @scroll="onScroll($event)">
<v-row class="my-0 mx-7">
<v-spacer />
<v-col class="text-right">
<v-btn fab small color="info" @click="reverseSort">
<v-icon> {{ preferences.orderDirection === "asc" ? $globals.icons.sortCalendarAscending : $globals.icons.sortCalendarDescending }} </v-icon>
</v-btn>
</v-col>
</v-row>
<v-divider v-if="timelineEvents.length" />
<v-card
v-if="timelineEvents.length"
id="timeline-container"
height="fit-content"
width="100%"
class="px-1"
>
<v-timeline :dense="$vuetify.breakpoint.smAndDown" class="timeline">
<RecipeTimelineItem
v-for="(event, index) in timelineEvents"
:key="event.id"
:event="event"
:recipe="recipes.get(event.recipeId)"
:show-recipe-cards="showRecipeCards"
@update="updateTimelineEvent(index)"
@delete="deleteTimelineEvent(index)"
/>
</v-timeline>
</v-card>
<v-card v-else-if="!loading">
<v-card-title class="justify-center pa-9">
{{ $t("recipe.timeline-is-empty") }}
</v-card-title>
</v-card>
<div v-if="loading" class="pb-3">
<AppLoader :loading="loading" :waiting-text="$tc('general.loading-events')" />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, useAsync, useContext } from "@nuxtjs/composition-api";
import { useThrottleFn, whenever } from "@vueuse/core";
import RecipeTimelineItem from "./RecipeTimelineItem.vue"
import { useTimelinePreferences } from "~/composables/use-users/preferences";
import { useAsyncKey } from "~/composables/use-utils";
import { alert } from "~/composables/use-toast";
import { useUserApi } from "~/composables/api";
import { Recipe, RecipeTimelineEventOut, RecipeTimelineEventUpdate } from "~/lib/api/types/recipe"
export default defineComponent({
components: { RecipeTimelineItem },
props: {
value: {
type: Boolean,
default: false,
},
queryFilter: {
type: String,
required: true,
},
maxHeight: {
type: [Number, String],
default: undefined,
},
showRecipeCards: {
type: Boolean,
default: false,
}
},
setup(props) {
const api = useUserApi();
const { i18n } = useContext();
const preferences = useTimelinePreferences();
const loading = ref(true);
const ready = ref(false);
const page = ref(1);
const perPage = 32;
const hasMore = ref(true);
const timelineEvents = ref([] as RecipeTimelineEventOut[]);
const recipes = new Map<string, Recipe>();
interface ScrollEvent extends Event {
target: HTMLInputElement;
}
const screenBuffer = 4;
const onScroll = (event: ScrollEvent) => {
if (!event.target) {
return;
}
const { scrollTop, offsetHeight, scrollHeight } = event.target;
// trigger when the user is getting close to the bottom
const bottomOfElement = scrollTop + offsetHeight >= scrollHeight - (offsetHeight*screenBuffer);
if (bottomOfElement) {
infiniteScroll();
}
};
document.onscroll = () => {
// if the inner element is scrollable, let its scroll event handle the infiniteScroll
const timelineContainerElement = document.getElementById("timeline-container");
if (timelineContainerElement) {
const { clientHeight, scrollHeight } = timelineContainerElement
// if scrollHeight == clientHeight, the element is not scrollable, so we need to look at the global position
// if scrollHeight > clientHeight, it is scrollable and we don't need to do anything here
if (scrollHeight > clientHeight) {
return;
}
}
const bottomOfWindow = document.documentElement.scrollTop + window.innerHeight >= document.documentElement.offsetHeight - (window.innerHeight*screenBuffer);
if (bottomOfWindow) {
infiniteScroll();
}
};
whenever(
() => props.value,
() => {
if (!ready.value) {
initializeTimelineEvents();
}
}
);
// Sorting
function reverseSort() {
if (loading.value) {
return;
}
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
initializeTimelineEvents();
}
// Timeline Actions
async function updateTimelineEvent(index: number) {
const event = timelineEvents.value[index]
const payload: RecipeTimelineEventUpdate = {
subject: event.subject,
eventMessage: event.eventMessage,
image: event.image,
};
const { response } = await api.recipes.updateTimelineEvent(event.id, payload);
if (response?.status !== 200) {
alert.error(i18n.t("events.something-went-wrong") as string);
return;
}
alert.success(i18n.t("events.event-updated") as string);
};
async function deleteTimelineEvent(index: number) {
const { response } = await api.recipes.deleteTimelineEvent(timelineEvents.value[index].id);
if (response?.status !== 200) {
alert.error(i18n.t("events.something-went-wrong") as string);
return;
}
timelineEvents.value.splice(index, 1);
alert.success(i18n.t("events.event-deleted") as string);
};
async function getRecipe(recipeId: string): Promise<Recipe | null> {
const { data } = await api.recipes.getOne(recipeId);
return data
};
async function updateRecipes(events: RecipeTimelineEventOut[]) {
const recipePromises: Promise<Recipe | null>[] = [];
const seenRecipeIds: string[] = [];
events.forEach(event => {
if (seenRecipeIds.includes(event.recipeId) || recipes.has(event.recipeId)) {
return;
}
seenRecipeIds.push(event.recipeId);
recipePromises.push(getRecipe(event.recipeId));
})
const results = await Promise.all(recipePromises);
results.forEach(result => {
if (result && result.id) {
recipes.set(result.id, result);
}
})
}
async function scrollTimelineEvents() {
const orderBy = "timestamp";
const orderDirection = preferences.value.orderDirection === "asc" ? "asc" : "desc";
const response = await api.recipes.getAllTimelineEvents(page.value, perPage, { orderBy, orderDirection, queryFilter: props.queryFilter });
page.value += 1;
if (!response?.data) {
return;
}
const events = response.data.items;
if (events.length < perPage) {
hasMore.value = false;
if (!events.length) {
return;
}
}
// fetch recipes
if (props.showRecipeCards) {
await updateRecipes(events);
}
// this is set last so Vue knows to re-render
timelineEvents.value.push(...events);
};
async function initializeTimelineEvents() {
loading.value = true;
ready.value = false;
page.value = 1;
hasMore.value = true;
timelineEvents.value = [];
await scrollTimelineEvents();
ready.value = true;
loading.value = false;
}
const infiniteScroll = useThrottleFn(() => {
useAsync(async () => {
if (!hasMore.value || loading.value) {
return;
}
loading.value = true;
await scrollTimelineEvents();
loading.value = false;
}, useAsyncKey());
}, 500);
// preload events
initializeTimelineEvents();
return {
deleteTimelineEvent,
loading,
onScroll,
preferences,
recipes,
reverseSort,
timelineEvents,
updateTimelineEvent,
};
},
});
</script>

View file

@ -14,17 +14,20 @@
{{ $globals.icons.timelineText }}
</v-icon>
</v-btn>
<RecipeDialogTimeline v-model="showTimeline" :slug="slug" :recipe-name="recipeName" />
<BaseDialog v-model="showTimeline" :title="timelineAttrs.title" :icon="$globals.icons.timelineText" width="70%">
<RecipeTimeline v-model="showTimeline" :query-filter="timelineAttrs.queryFilter" max-height="70vh" />
</BaseDialog>
</template>
<span>{{ $t('recipe.open-timeline') }}</span>
</v-tooltip>
</template>
<script lang="ts">
import { defineComponent, ref } from "@nuxtjs/composition-api";
import RecipeDialogTimeline from "./RecipeDialogTimeline.vue";
import { computed, defineComponent, ref, useContext } from "@nuxtjs/composition-api";
import RecipeTimeline from "./RecipeTimeline.vue";
export default defineComponent({
components: { RecipeDialogTimeline },
components: { RecipeTimeline },
props: {
buttonStyle: {
@ -41,13 +44,26 @@ export default defineComponent({
},
},
setup() {
setup(props) {
const { $vuetify, i18n } = useContext();
const showTimeline = ref(false);
function toggleTimeline() {
showTimeline.value = !showTimeline.value;
}
return { showTimeline, toggleTimeline };
const timelineAttrs = computed(() => {
let title = i18n.tc("recipe.timeline")
if ($vuetify.breakpoint.smAndDown) {
title += ` ${props.recipeName}`
}
return {
title,
queryFilter: `recipe.slug="${props.slug}"`,
}
})
return { showTimeline, timelineAttrs, toggleTimeline };
},
});
</script>

View file

@ -113,10 +113,6 @@ export default defineComponent({
type: String,
default: "primary",
},
slug: {
type: String,
required: true,
},
event: {
type: Object as () => RecipeTimelineEventOut,
required: true,

View file

@ -0,0 +1,162 @@
<template>
<v-timeline-item
:class="attrs.class"
fill-dot
:small="attrs.small"
:icon="icon"
>
<template v-if="!useMobileFormat" #opposite>
<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>
</template>
<v-card>
<v-sheet>
<v-card-title>
<v-row>
<v-col align-self="center" :cols="useMobileFormat ? 'auto' : '2'" :class="attrs.avatar.class">
<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-icon> {{ $globals.icons.calendar }} </v-icon>
{{ new Date(event.timestamp+"Z").toLocaleDateString($i18n.locale) }}
</v-chip>
</v-col>
<v-col v-else cols="9" style="margin: auto; text-align: center;">
{{ event.subject }}
</v-col>
<v-spacer />
<v-col :cols="useMobileFormat ? 'auto' : '1'" class="px-0">
<RecipeTimelineContextMenu
v-if="$auth.user && $auth.user.id == event.userId && event.eventType != 'system'"
:menu-top="false"
:event="event"
:menu-icon="$globals.icons.dotsVertical"
fab
color="transparent"
:elevation="0"
:card-menu="false"
:use-items="{
edit: true,
delete: true,
}"
@update="$emit('update')"
@delete="$emit('delete')"
/>
</v-col>
</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-col align-self="center" class="pa-0">
<RecipeCardMobile
:vertical="useMobileFormat"
:name="recipe.name"
:slug="recipe.slug"
:description="recipe.description"
:rating="recipe.rating"
:image="recipe.image"
:recipe-id="recipe.id"
/>
</v-col>
</v-row>
</v-sheet>
<v-divider v-if="showRecipeCards && recipe && (useMobileFormat || event.eventMessage)" />
<v-card-text>
<v-row>
<v-col>
<strong v-if="useMobileFormat">{{ event.subject }}</strong>
<div v-if="event.eventMessage" :class="useMobileFormat ? 'text-caption' : ''">
{{ event.eventMessage }}
</div>
</v-col>
</v-row>
</v-card-text>
</v-sheet>
</v-card>
</v-timeline-item>
</template>
<script lang="ts">
import { computed, defineComponent, ref, useContext } from "@nuxtjs/composition-api";
import RecipeCardMobile from "./RecipeCardMobile.vue";
import RecipeTimelineContextMenu from "./RecipeTimelineContextMenu.vue";
import { Recipe, RecipeTimelineEventOut } from "~/lib/api/types/recipe"
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
export default defineComponent({
components: { RecipeCardMobile, RecipeTimelineContextMenu, UserAvatar },
props: {
event: {
type: Object as () => RecipeTimelineEventOut,
required: true,
},
recipe: {
type: Object as () => Recipe,
default: undefined,
},
showRecipeCards: {
type: Boolean,
default: false,
}
},
setup(props) {
const { $globals, $vuetify } = useContext();
const timelineEvents = ref([] as RecipeTimelineEventOut[]);
const useMobileFormat = computed(() => {
return $vuetify.breakpoint.smAndDown;
});
const attrs = computed(() => {
if (useMobileFormat.value) {
return {
class: "px-0",
small: false,
avatar: {
size: "30px",
class: "pr-0",
},
}
}
else {
return {
class: "px-3",
small: false,
avatar: {
size: "42px",
class: "",
},
}
}
})
const icon = computed( () => {
switch (props.event.eventType) {
case "comment":
return $globals.icons.commentTextMultiple;
case "info":
return $globals.icons.informationVariant;
case "system":
return $globals.icons.cog;
default:
return $globals.icons.informationVariant;
};
})
return {
attrs,
icon,
timelineEvents,
useMobileFormat,
};
},
});
</script>