mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -04:00 
			
		
		
		
	| @@ -11,7 +11,7 @@ | ||||
|           nudge-bottom="3" | ||||
|           :close-on-content-click="false" | ||||
|         > | ||||
|           <template #activator="{ props }"> | ||||
|           <template #activator="{ props: activatorProps }"> | ||||
|             <v-badge | ||||
|               :content="filterBadgeCount" | ||||
|               :model-value="filterBadgeCount > 0" | ||||
| @@ -21,7 +21,7 @@ | ||||
|                 class="rounded-circle" | ||||
|                 size="small" | ||||
|                 color="info" | ||||
|                 v-bind="props" | ||||
|                 v-bind="activatorProps" | ||||
|                 icon | ||||
|               > | ||||
|                 <v-icon> {{ $globals.icons.filter }} </v-icon> | ||||
| @@ -105,7 +105,7 @@ | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| <script setup lang="ts"> | ||||
| import { useThrottleFn, whenever } from "@vueuse/core"; | ||||
| import RecipeTimelineItem from "./RecipeTimelineItem.vue"; | ||||
| import { useTimelinePreferences } from "~/composables/use-users/preferences"; | ||||
| @@ -115,252 +115,208 @@ import { alert } from "~/composables/use-toast"; | ||||
| import { useUserApi } from "~/composables/api"; | ||||
| import type { Recipe, RecipeTimelineEventOut, RecipeTimelineEventUpdate, TimelineEventType } from "~/lib/api/types/recipe"; | ||||
|  | ||||
| export default defineNuxtComponent({ | ||||
|   components: { RecipeTimelineItem }, | ||||
| interface Props { | ||||
|   modelValue?: boolean; | ||||
|   queryFilter: string; | ||||
|   maxHeight?: number | string; | ||||
|   showRecipeCards?: boolean; | ||||
| } | ||||
|  | ||||
|   props: { | ||||
|     modelValue: { | ||||
|       type: Boolean, | ||||
|       default: false, | ||||
|     }, | ||||
|     queryFilter: { | ||||
|       type: String, | ||||
|       required: true, | ||||
|     }, | ||||
|     maxHeight: { | ||||
|       type: [Number, String], | ||||
|       default: undefined, | ||||
|     }, | ||||
|     showRecipeCards: { | ||||
|       type: Boolean, | ||||
|       default: false, | ||||
|     }, | ||||
| const props = withDefaults(defineProps<Props>(), { | ||||
|   modelValue: false, | ||||
|   maxHeight: undefined, | ||||
|   showRecipeCards: false, | ||||
| }); | ||||
|  | ||||
| const api = useUserApi(); | ||||
| const i18n = useI18n(); | ||||
| const preferences = useTimelinePreferences(); | ||||
| const { eventTypeOptions } = useTimelineEventTypes(); | ||||
| 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>(); | ||||
| const filterBadgeCount = computed(() => eventTypeOptions.value.length - preferences.value.types.length); | ||||
| const eventTypeFilterState = computed(() => { | ||||
|   return eventTypeOptions.value.map((option) => { | ||||
|     return { | ||||
|       ...option, | ||||
|       checked: preferences.value.types.includes(option.value), | ||||
|     }; | ||||
|   }); | ||||
| }); | ||||
| const screenBuffer = 4; | ||||
|  | ||||
| whenever( | ||||
|   () => props.modelValue, | ||||
|   () => { | ||||
|     initializeTimelineEvents(); | ||||
|   }, | ||||
| ); | ||||
|  | ||||
|   setup(props) { | ||||
|     const api = useUserApi(); | ||||
|     const i18n = useI18n(); | ||||
|     const preferences = useTimelinePreferences(); | ||||
|     const { eventTypeOptions } = useTimelineEventTypes(); | ||||
|     const loading = ref(true); | ||||
|     const ready = ref(false); | ||||
| // Preferences | ||||
| function reverseSort() { | ||||
|   if (loading.value) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|     const page = ref(1); | ||||
|     const perPage = 32; | ||||
|     const hasMore = ref(true); | ||||
|   preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc"; | ||||
|   initializeTimelineEvents(); | ||||
| } | ||||
|  | ||||
|     const timelineEvents = ref([] as RecipeTimelineEventOut[]); | ||||
|     const recipes = new Map<string, Recipe>(); | ||||
|     const filterBadgeCount = computed(() => eventTypeOptions.value.length - preferences.value.types.length); | ||||
|     const eventTypeFilterState = computed(() => { | ||||
|       return eventTypeOptions.value.map((option) => { | ||||
|         return { | ||||
|           ...option, | ||||
|           checked: preferences.value.types.includes(option.value), | ||||
|         }; | ||||
|       }); | ||||
|     }); | ||||
| function toggleEventTypeOption(option: TimelineEventType) { | ||||
|   if (loading.value) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|     interface ScrollEvent extends Event { | ||||
|       target: HTMLInputElement; | ||||
|   const index = preferences.value.types.indexOf(option); | ||||
|   if (index === -1) { | ||||
|     preferences.value.types.push(option); | ||||
|   } | ||||
|   else { | ||||
|     preferences.value.types.splice(index, 1); | ||||
|   } | ||||
|  | ||||
|   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 getRecipes(recipeIds: string[]): Promise<Recipe[]> { | ||||
|   const qf = "id IN [" + recipeIds.map(id => `"${id}"`).join(", ") + "]"; | ||||
|   const { data } = await api.recipes.getAll(1, -1, { queryFilter: qf }); | ||||
|   return data?.items || []; | ||||
| } | ||||
|  | ||||
| async function updateRecipes(events: RecipeTimelineEventOut[]) { | ||||
|   const recipeIds: string[] = []; | ||||
|   events.forEach((event) => { | ||||
|     if (recipeIds.includes(event.recipeId) || recipes.has(event.recipeId)) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const screenBuffer = 4; | ||||
|     const onScroll = (event: ScrollEvent) => { | ||||
|       if (!event.target) { | ||||
|         return; | ||||
|     recipeIds.push(event.recipeId); | ||||
|   }); | ||||
|  | ||||
|   const results = await getRecipes(recipeIds); | ||||
|   results.forEach((result) => { | ||||
|     if (!result?.id) { | ||||
|       return; | ||||
|     } | ||||
|     recipes.set(result.id, result); | ||||
|   }); | ||||
| } | ||||
|  | ||||
| async function scrollTimelineEvents() { | ||||
|   const orderBy = "timestamp"; | ||||
|   const orderDirection = preferences.value.orderDirection === "asc" ? "asc" : "desc"; | ||||
|  | ||||
|   const eventTypeValue = `["${preferences.value.types.join("\", \"")}"]`; | ||||
|   const queryFilter = `(${props.queryFilter}) AND eventType IN ${eventTypeValue}`; | ||||
|  | ||||
|   const response = await api.recipes.getAllTimelineEvents(page.value, perPage, { orderBy, orderDirection, 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(() => { | ||||
|   useAsyncData(useAsyncKey(), async () => { | ||||
|     if (!hasMore.value || loading.value) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     loading.value = true; | ||||
|     await scrollTimelineEvents(); | ||||
|     loading.value = false; | ||||
|   }); | ||||
| }, 500); | ||||
|  | ||||
| // preload events | ||||
| initializeTimelineEvents(); | ||||
|  | ||||
| onMounted( | ||||
|   () => { | ||||
|     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 { scrollTop, offsetHeight, scrollHeight } = event.target; | ||||
|  | ||||
|       // trigger when the user is getting close to the bottom | ||||
|       const bottomOfElement = scrollTop + offsetHeight >= scrollHeight - (offsetHeight * screenBuffer); | ||||
|       if (bottomOfElement) { | ||||
|       const bottomOfWindow = document.documentElement.scrollTop + window.innerHeight >= document.documentElement.offsetHeight - (window.innerHeight * screenBuffer); | ||||
|       if (bottomOfWindow) { | ||||
|         infiniteScroll(); | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     whenever( | ||||
|       () => props.modelValue, | ||||
|       () => { | ||||
|         initializeTimelineEvents(); | ||||
|       }, | ||||
|     ); | ||||
|  | ||||
|     // Preferences | ||||
|     function reverseSort() { | ||||
|       if (loading.value) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc"; | ||||
|       initializeTimelineEvents(); | ||||
|     } | ||||
|  | ||||
|     function toggleEventTypeOption(option: TimelineEventType) { | ||||
|       if (loading.value) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       const index = preferences.value.types.indexOf(option); | ||||
|       if (index === -1) { | ||||
|         preferences.value.types.push(option); | ||||
|       } | ||||
|       else { | ||||
|         preferences.value.types.splice(index, 1); | ||||
|       } | ||||
|  | ||||
|       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 getRecipes(recipeIds: string[]): Promise<Recipe[]> { | ||||
|       const qf = "id IN [" + recipeIds.map(id => `"${id}"`).join(", ") + "]"; | ||||
|       const { data } = await api.recipes.getAll(1, -1, { queryFilter: qf }); | ||||
|       return data?.items || []; | ||||
|     }; | ||||
|  | ||||
|     async function updateRecipes(events: RecipeTimelineEventOut[]) { | ||||
|       const recipeIds: string[] = []; | ||||
|       events.forEach((event) => { | ||||
|         if (recipeIds.includes(event.recipeId) || recipes.has(event.recipeId)) { | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         recipeIds.push(event.recipeId); | ||||
|       }); | ||||
|  | ||||
|       const results = await getRecipes(recipeIds); | ||||
|       results.forEach((result) => { | ||||
|         if (!result?.id) { | ||||
|           return; | ||||
|         } | ||||
|         recipes.set(result.id, result); | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     async function scrollTimelineEvents() { | ||||
|       const orderBy = "timestamp"; | ||||
|       const orderDirection = preferences.value.orderDirection === "asc" ? "asc" : "desc"; | ||||
|  | ||||
|       const eventTypeValue = `["${preferences.value.types.join("\", \"")}"]`; | ||||
|       const queryFilter = `(${props.queryFilter}) AND eventType IN ${eventTypeValue}`; | ||||
|  | ||||
|       const response = await api.recipes.getAllTimelineEvents(page.value, perPage, { orderBy, orderDirection, 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(() => { | ||||
|       useAsyncData(useAsyncKey(), async () => { | ||||
|         if (!hasMore.value || loading.value) { | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         loading.value = true; | ||||
|         await scrollTimelineEvents(); | ||||
|         loading.value = false; | ||||
|       }); | ||||
|     }, 500); | ||||
|  | ||||
|     // preload events | ||||
|     initializeTimelineEvents(); | ||||
|  | ||||
|     onMounted( | ||||
|       () => { | ||||
|         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(); | ||||
|           } | ||||
|         }; | ||||
|       }, | ||||
|     ); | ||||
|  | ||||
|     return { | ||||
|       deleteTimelineEvent, | ||||
|       filterBadgeCount, | ||||
|       loading, | ||||
|       onScroll, | ||||
|       preferences, | ||||
|       eventTypeFilterState, | ||||
|       recipes, | ||||
|       reverseSort, | ||||
|       toggleEventTypeOption, | ||||
|       timelineEvents, | ||||
|       updateTimelineEvent, | ||||
|     }; | ||||
|   }, | ||||
| }); | ||||
| ); | ||||
| </script> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user