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:
parent
0e397b34fd
commit
fe17922bb8
28 changed files with 871 additions and 506 deletions
|
@ -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();
|
||||
|
|
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -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'"
|
||||
/>
|
||||
|
|
266
frontend/components/Domain/Recipe/RecipeTimeline.vue
Normal file
266
frontend/components/Domain/Recipe/RecipeTimeline.vue
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -113,10 +113,6 @@ export default defineComponent({
|
|||
type: String,
|
||||
default: "primary",
|
||||
},
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
event: {
|
||||
type: Object as () => RecipeTimelineEventOut,
|
||||
required: true,
|
||||
|
|
162
frontend/components/Domain/Recipe/RecipeTimelineItem.vue
Normal file
162
frontend/components/Domain/Recipe/RecipeTimelineItem.vue
Normal 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>
|
Loading…
Add table
Add a link
Reference in a new issue